Compare commits

..

15 Commits

Author SHA1 Message Date
Michael Clark
922f9fd911 Merge branch 'master' into bugfix/plugins/fix-plugins-sw 2025-12-08 18:54:06 +00:00
lelemm
64165d4c4b lint 2025-12-07 04:08:22 -03:00
lelemm
710aa8ad08 update yarn.lock 2025-12-07 04:02:55 -03:00
lelemm
dad0ee062b linter 2025-12-07 04:00:45 -03:00
lelemm
b1a2501e83 lets see 2025-12-07 04:00:45 -03:00
lelemm
ef0ce7fc9d testing 2025-12-07 04:00:45 -03:00
lelemm
1eb5c50732 lock file updated 2025-12-07 04:00:45 -03:00
lelemm
62097f287a another test 2025-12-07 04:00:45 -03:00
lelemm
31140fc9b8 changes 2025-12-07 04:00:45 -03:00
lelemm
bc4e2238bb another test 2025-12-07 04:00:45 -03:00
lelemm
f8aae08784 testing offline mode 2025-12-07 04:00:45 -03:00
github-actions[bot]
d5042132b7 Add release notes for PR #6233 2025-12-07 04:00:45 -03:00
lelemm
59b7f374d2 update yarn.lock and lint fix 2025-12-07 04:00:45 -03:00
autofix-ci[bot]
912bd10367 [autofix.ci] apply automated fixes 2025-12-07 04:00:35 -03:00
lelemm
18b5429574 Attempt fix for offline mode 2025-12-07 04:00:35 -03:00
2626 changed files with 23546 additions and 57729 deletions

View File

@@ -1,24 +0,0 @@
issue_enrichment:
auto_enrich:
enabled: false
reviews:
request_changes_workflow: true
review_status: false
high_level_summary: false
finishing_touches:
docstrings:
enabled: false
pre_merge_checks:
docstrings:
mode: off
enabled: false
labeling_instructions:
- label: 'API'
instructions: 'This issue or PR updates the API in `packages/api`.'
- label: 'documentation'
instructions: 'This issue updates the documentation in `packages/docs`.'
- label: 'contains DB migrations'
instructions: 'This issue or PR contains DB migrations in `packages/loot-core/migrations`.'
- label: 'size small'
instructions: 'This issue or PR is a small change (less than 50 lines of code) that is expected to have a small impact on the codebase.'
auto_apply_labels: true

View File

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

View File

@@ -8,66 +8,35 @@ body:
attributes:
value: |
Thanks for taking the time to fill out this bug report! Please ensure you provide as much information as possible to better assist in confirming and identifying a fix for the bug.
⚠️ **CRITICAL:** Bug reports without clear, step-by-step reproduction instructions will be closed. We cannot investigate or fix bugs without being able to reproduce them. Please take the time to provide detailed reproduction steps.
- type: markdown
attributes:
value: |
**IMPORTANT:** we use GitHub Issues only for BUG REPORTS and FEATURE REQUESTS. If you are looking for help/support - please reach out to the [community on Discord](https://discord.gg/pRYNYr4W5A). All non-bug and non-feature-request issues will be closed.
**Bank-sync problems (SimpleFin / GoCardless)?** Reach out via the [community Discord](https://discord.gg/pRYNYr4W5A) first and open an issue only if the community deems the issue to be a legitimate bug in Actual.
- type: checkboxes
id: existing-issue
attributes:
label: 'Verified issue does not already exist?'
description: 'Please search to see if an issue already exists for the issue you encountered.'
options:
- label: 'I have searched and found no existing issue'
required: true
- type: textarea
id: what-happened
attributes:
label: What happened?
description: |
Describe the bug clearly and concisely. Include:
- What you were trying to do
- What you expected to happen
- What actually happened instead
- Any error messages (copy/paste the exact text)
If you're reporting an issue with imports, please include a (redacted) version of the file, and a screenshot of the import screen. You may need to zip it before uploading.
placeholder: |
I was trying to [action] when [context].
Expected: [expected behavior]
Actual: [actual behavior]
Error message: [if any]
description: Also tell us, what did you expect to happen? If youre reporting an issue with imports, please attach a (redacted) version of the file youre having trouble importing. You may need to zip it before uploading.
placeholder: Tell us what you see!
value: 'A bug happened!'
validations:
required: true
- type: markdown
attributes:
value: |
## Reproduction Steps
**REQUIRED:** Without clear reproduction steps, we cannot investigate or fix the bug. Please provide detailed, step-by-step instructions that anyone can follow to reproduce the issue.
- type: textarea
id: reproduction
attributes:
label: How can we reproduce the issue?
description: |
**This field is mandatory and must be filled out completely.**
Provide numbered, step-by-step instructions that allow us to reproduce the bug. Include:
- Specific actions you took (e.g., "Click on the Budget tab", "Enter $100 in the amount field")
- What you expected to happen
- What actually happened instead
Example format:
1. Navigate to [specific page/section]
2. Click on [specific button/link]
3. Enter [specific data] in [specific field]
4. Click [action]
5. Observe [expected vs actual behavior]
If the issue involves importing data, please attach a (redacted) sample file. You may need to zip it before uploading.
placeholder: |
1. Go to [specific location]
2. Click [specific element]
3. Enter [specific data]
4. Click [action]
5. Expected: [what should happen]
Actual: [what actually happens]
description: Please give step-by-step instructions on how to reproduce the issue. In most cases this might also require uploading a sample budget/import file.
value: 'How can we reproduce the issue?'
validations:
required: true
- type: markdown

View File

@@ -7,19 +7,19 @@ body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this feature request! Please ensure you provide as much information as possible so we can better understand what you're proposing so we can come up with the best solution for everyone.
Thanks for taking the time to fill out this feature request! Please ensure you provide as much information as possible so we can better understand what youre proposing so we can come up with the best solution for everyone.
- type: checkboxes
id: existing-issue
attributes:
label: 'Verified feature request does not already exist?'
description: "Please search to see if an issue or PR already exists for the feature you're requesting."
description: 'Please search to see if an issue or PR already exists for the feature youre requesting.'
options:
- label: 'I have searched and found no existing issue'
required: true
- type: checkboxes
attributes:
label: '💻'
description: (Optional) Please check this box if you're willing to open a PR to implement this feature. We'll help you get started and answer any questions you have along the way :)
description: (Optional) Please check this box if youre willing to open a PR to implement this feature. Well help you get started and answer any questions you have along the way :)
options:
- label: Would you like to implement this feature?
- type: textarea
@@ -33,7 +33,7 @@ body:
id: solution
attributes:
label: Describe your ideal solution to this problem
description: Feel free to give multiple different ideas for how the problem could be solved — we'd love to have a discussion to find the best way to solve your problem and related problems others may face! (Or leave this blank if you don't have a solution in mind yet.)
description: Feel free to give multiple different ideas for how the problem could be solved — wed love to have a discussion to find the best way to solve your problem and related problems others may face! (Or leave this blank if you dont have a solution in mind yet.)
validations:
required: false
- type: textarea

View File

@@ -1,21 +1 @@
<!-- Thank you for submitting a pull request! Make sure to follow the instructions to write release notes for your PR — it should only take a minute or two: https://github.com/actualbudget/docs#writing-good-release-notes. Try running yarn generate:release-notes *before* pushing your PR for an interactive experience. -->
## Description
<!-- What does this PR do? Why is it needed? Please give context on the "why?": why do we need this change? What problem is it solving for you?-->
## Related issue(s)
<!-- e.g. Fixes #123, Relates to #456 -->
## Testing
<!-- What did you test? How can we reproduce the issue you are fixing or how can we test the feature you built? -->
## Checklist
- [ ] Release notes added (see link above)
- [ ] No obvious regressions in affected areas
- [ ] Self-review has been performed - I understand what each change in the code does and why it is needed
<!--- actual-bot-sections --->

View File

@@ -1,13 +1,12 @@
#!/usr/bin/env node
import fs from 'fs';
import { Octokit } from '@octokit/rest';
import fs from 'fs';
const token = process.env.GITHUB_TOKEN;
const repo = process.env.GITHUB_REPOSITORY;
const issueNumber = process.env.GITHUB_EVENT_ISSUE_NUMBER;
const commentId = String(process.env.GITHUB_EVENT_COMMENT_ID);
const commentId = process.env.GITHUB_EVENT_COMMENT_ID;
if (!token || !repo || !issueNumber || !commentId) {
console.log('Missing required environment variables');
@@ -52,7 +51,7 @@ async function checkFirstComment() {
const isFirstSummaryComment =
coderabbitSummaryComments.length === 1 &&
String(coderabbitSummaryComments[0].id) === commentId;
coderabbitSummaryComments[0].id == commentId;
console.log(
`CodeRabbit summary comments found: ${coderabbitSummaryComments.length}`,

View File

@@ -1,8 +1,7 @@
#!/usr/bin/env node
import fs from 'fs';
import { Octokit } from '@octokit/rest';
import fs from 'fs';
const token = process.env.GITHUB_TOKEN;
const repo = process.env.GITHUB_REPOSITORY;
@@ -74,4 +73,4 @@ async function checkReleaseNotesExists() {
}
}
void checkReleaseNotesExists();
checkReleaseNotesExists();

View File

@@ -74,4 +74,4 @@ async function commentOnPR() {
}
}
void commentOnPR();
commentOnPR();

View File

@@ -46,8 +46,7 @@ category: ${cleanCategory}
authors: [${summaryData.author}]
---
${summaryData.summary}
`;
${summaryData.summary}`;
const fileName = `upcoming-release-notes/${summaryData.prNumber}.md`;
@@ -76,7 +75,7 @@ ${summaryData.summary}
repo: headRepo,
path: fileName,
message: `Add release notes for PR #${summaryData.prNumber}`,
content: Buffer.from(fileContent).toString('base64'),
content: Buffer.from(`${fileContent}\n\n`).toString('base64'),
branch: prBranch,
committer: {
name: 'github-actions[bot]',
@@ -94,4 +93,4 @@ ${summaryData.summary}
}
}
void createReleaseNotesFile();
createReleaseNotesFile();

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env node
const fs = require('fs');
const https = require('https');
const fs = require('fs');
const commentBody = process.env.GITHUB_EVENT_COMMENT_BODY;
const prDetailsJson = process.env.PR_DETAILS;
@@ -33,11 +33,11 @@ try {
{
role: 'system',
content:
'You are categorizing pull requests for release notes. You must respond with exactly one of these categories: "Features", "Enhancements", "Bugfixes", or "Maintenance". No other text or explanation.',
'You are categorizing pull requests for release notes. You must respond with exactly one of these categories: "Features", "Enhancements", "Bugfix", or "Maintenance". No other text or explanation.',
},
{
role: 'user',
content: `PR Title: ${prDetails.title}\n\nGenerated Summary: ${summaryData.summary}\n\nCodeRabbit Analysis:\n${commentBody}\n\nCategories:\n- Features: New functionality or capabilities\n- Bugfixes: Fixes for broken or incorrect behavior\n- Enhancements: Improvements to existing functionality\n- Maintenance: Code cleanup, refactoring, dependencies, etc.\n\nWhat category does this PR belong to?`,
content: `PR Title: ${prDetails.title}\n\nGenerated Summary: ${summaryData.summary}\n\nCodeRabbit Analysis:\n${commentBody}\n\nCategories:\n- Features: New functionality or capabilities\n- Bugfix: Fixes for broken or incorrect behavior\n- Enhancements: Improvements to existing functionality\n- Maintenance: Code cleanup, refactoring, dependencies, etc.\n\nWhat category does this PR belong to?`,
},
],
max_tokens: 10,
@@ -86,7 +86,7 @@ try {
// Validate the category response
const validCategories = [
'Features',
'Bugfixes',
'Bugfix',
'Enhancements',
'Maintenance',
];

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env node
const fs = require('fs');
const https = require('https');
const fs = require('fs');
const commentBody = process.env.GITHUB_EVENT_COMMENT_BODY;
const prDetailsJson = process.env.PR_DETAILS;
@@ -71,7 +71,7 @@ try {
console.log('Generated summary:', summary);
const result = {
summary,
summary: summary,
prNumber: prDetails.number,
author: prDetails.author,
};

View File

@@ -1,8 +1,7 @@
#!/usr/bin/env node
import fs from 'fs';
import { Octokit } from '@octokit/rest';
import fs from 'fs';
const token = process.env.GITHUB_TOKEN;
const repo = process.env.GITHUB_REPOSITORY;
@@ -36,15 +35,11 @@ async function getPRDetails() {
console.log('- PR Number:', pr.number);
console.log('- PR Author:', pr.user.login);
console.log('- PR Title:', pr.title);
console.log('- Base Branch:', pr.base.ref);
console.log('- Head Branch:', pr.head.ref);
const result = {
number: pr.number,
author: pr.user.login,
title: pr.title,
baseBranch: pr.base.ref,
headBranch: pr.head.ref,
};
setOutput('result', JSON.stringify(result));

View File

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

View File

@@ -101,8 +101,6 @@ offbudget
ofx
OFX
oneof
oxfmt
oxlint
payeerule
pikaday
pikapods

View File

@@ -72,6 +72,5 @@ ignore$
(?:^|/)yarn\.lock$
(?:^|/)(?i)docusaurus.config.js
(?:^|/)(?i)README.md
(?:^|/)(?i).nojekyll
^\static/
\.tsx$

View File

@@ -2,15 +2,12 @@ Abanca
ABNAMRO
ABNANL
Activo
actualrc
AESUDEF
ALZEY
Anglais
ANZ
aql
AUR
Authentik
AVERAGEA
BANKA
BANKINTER
BAWAATWW
@@ -32,7 +29,6 @@ CAGLPTPL
Caixa
CAMT
cashflow
Catppuccin
Cetelem
cimode
Citi
@@ -41,29 +37,20 @@ Cloudflare
CMCIFRPAXXX
COBADEFF
CODEOWNERS
COEP
commerzbank
Copiar
COUNTA
COUNTBLANK
countif
CREGBEBB
crt
CZK
Danske
datadir
DATEDIF
Depositos
deselection
DIREKT
Dockerfiles
Dominguez
DUSSDEDDXXX
DUSSELDORF
EDATE
ENTERCARD
Entra
EOMONTH
EUA
Eurocard
fidd
@@ -84,53 +71,34 @@ Grafana
HABAL
Hampel
HELADEF
HLOOKUP
HUF
IFERROR
IFNA
INDUSTRIEL
INGBPLPW
Ingo
INR
Intesa
INVSTMTMSGSRS
IRR
ISERROR
ISEVEN
ISLOGICAL
ISNA
ISODD
ISOWEEKNUM
ISTEXT
ISYBANK
ITBBITMM
jfdoming
JMD
jws
KBCBE
Keycloak
Khurozov
KORT
Kreditbank
KRW
lage
LHV
LHVBEE
LKR
MAXA
mbank
mdc
metainfo
modals
Moldovan
murmurhash
NETWORKDAYS
nginx
OIDC
Okabe
overbudgeted
overbudgeting
oxc
Paribas
passwordless
pluggyai
@@ -140,11 +108,11 @@ prefs
Primoco
Priotecs
proactively
pwa
Qatari
QNTOFRP
QONTO
Raiffeisen
REGEXREPLACE
revolut
RIED
RSchedule
@@ -158,11 +126,7 @@ sseldorf
SSK
Stadtsparkasse
statestore
STDEVP
SUBASKBX
sumif
SUMPRODUCT
SUMSQ
SVGR
swc
SWEDBANK
@@ -177,16 +141,12 @@ touchscreen
triaging
UAH
ubuntu
undici
userinfo
Userscripts
UZS
VLOOKUP
vrt
VUB
Wallos
websecure
WEEKNUM
Widiba
WOR
youngcw

View File

@@ -79,6 +79,3 @@
# allowlist specific non-English words with non-ASCII characters
\b(Länsförsäkringar|München|Złoty)\b
# allowlist specific proper nouns
\b(CodeRabbit)\b

40
.github/actions/netlify-wait-for-build vendored Executable file
View File

@@ -0,0 +1,40 @@
#!/bin/bash
current_commit=$(git rev-parse HEAD)
echo "Running on commit $COMMIT_SHA"
function get_status() {
echo "::group::API Response"
curl --header "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/actualbudget/actual/commits/$COMMIT_SHA/statuses" > /tmp/status.json
cat /tmp/status.json
echo "::endgroup::"
netlify=$(yarn jq '[.[] | select(.context == "netlify/actualbudget/deploy-preview")][0]' /tmp/status.json)
state=$(yarn jq -r '.state' <<< "$netlify")
echo "::group::Netlify Status"
echo "$netlify"
echo "::endgroup::"
}
get_status
while [ "$netlify" == "null" ]; do
echo "Waiting for Netlify to start building..."
sleep 10
get_status
done
while [ "$state" == "pending" ]; do
echo "Waiting for Netlify to finish building..."
sleep 10
get_status
done
if [ "$state" == "success" ]; then
echo -e "\033[0;32mNetlify build succeeded!\033[0m"
yarn jq -r '"url=" + .target_url' <<< "$netlify" > $GITHUB_OUTPUT
exit 0
else
echo -e "\033[0;31mNetlify build failed. Cancelling end-to-end tests.\033[0m"
exit 1
fi

View File

@@ -15,7 +15,7 @@ runs:
using: composite
steps:
- name: Install node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
repository: actualbudget/translations
path: ${{ inputs.working-directory }}/packages/desktop-client/locale

View File

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

View File

@@ -8,13 +8,6 @@ const CONFIG = {
POINTS_PER_ISSUE_TRIAGE_ACTION: 1,
POINTS_PER_ISSUE_CLOSING_ACTION: 1,
POINTS_PER_RELEASE_PR: 4, // Awarded to whoever merges the release PR
PR_CONTRIBUTION_POINTS: [
{ categories: ['Features'], points: 2 },
{ categories: ['Enhancements'], points: 2 },
{ categories: ['Bugfixes', 'Bugfix'], points: 3 },
{ categories: ['Maintenance'], points: 2 },
{ categories: ['Unknown'], points: 2 },
],
// Point tiers for code changes (non-docs)
CODE_PR_REVIEW_POINT_TIERS: [
{ minChanges: 500, points: 8 },
@@ -38,122 +31,6 @@ const CONFIG = {
DOCS_FILES_PATTERN: 'packages/docs/**/*',
};
/**
* Parse category from release notes file content.
* @param {string} content - The content of the release notes file.
* @returns {string|null} The category or null if not found.
*/
function parseReleaseNotesCategory(content) {
if (!content) return null;
// Extract YAML front matter
const frontMatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
if (!frontMatterMatch) return null;
// Extract category from front matter
const categoryMatch = frontMatterMatch[1].match(/^category:\s*(.+)$/m);
if (!categoryMatch) return null;
return categoryMatch[1].trim();
}
/**
* Get the last commit SHA on or before a given date.
* @param {Octokit} octokit - The Octokit instance.
* @param {string} owner - Repository owner.
* @param {string} repo - Repository name.
* @param {Date} beforeDate - The date to find the last commit before.
* @returns {Promise<string|null>} The commit SHA or null if not found.
*/
async function getLastCommitBeforeDate(octokit, owner, repo, beforeDate) {
try {
// Get the default branch from the repository
const { data: repoData } = await octokit.repos.get({ owner, repo });
const defaultBranch = repoData.default_branch;
const { data: commits } = await octokit.repos.listCommits({
owner,
repo,
sha: defaultBranch,
until: beforeDate.toISOString(),
per_page: 1,
});
if (commits.length > 0) {
return commits[0].sha;
}
} catch {
// If error occurs, return null to fall back to default branch
}
return null;
}
/**
* Get the category and points for a PR by reading its release notes file.
* @param {Octokit} octokit - The Octokit instance.
* @param {string} owner - Repository owner.
* @param {string} repo - Repository name.
* @param {number} prNumber - PR number.
* @param {Date} monthEnd - The end date of the month to use as base revision.
* @returns {Promise<Object>} Object with category and points, or null if error.
*/
async function getPRCategoryAndPoints(
octokit,
owner,
repo,
prNumber,
monthEnd,
) {
const releaseNotesPath = `upcoming-release-notes/${prNumber}.md`;
try {
// Get the last commit of the month to use as base revision
const commitSha = await getLastCommitBeforeDate(
octokit,
owner,
repo,
monthEnd,
);
// Try to read the release notes file from the last commit of the month
const { data: fileContent } = await octokit.repos.getContent({
owner,
repo,
path: releaseNotesPath,
ref: commitSha || undefined, // Use commit SHA if available, otherwise default branch
});
if (fileContent.content) {
// Decode base64 content
const content = Buffer.from(fileContent.content, 'base64').toString(
'utf-8',
);
const category = parseReleaseNotesCategory(content);
const tier = CONFIG.PR_CONTRIBUTION_POINTS.find(e =>
e.categories.includes(category),
);
if (tier) {
return {
category,
points: tier.points,
};
}
}
} catch {
// Do nothing
}
const unknownTier = CONFIG.PR_CONTRIBUTION_POINTS.find(e =>
e.categories.includes('Unknown'),
);
return {
category: 'Unknown',
points: unknownTier.points,
};
}
/**
* Get the start and end dates for the last month.
* @returns {Object} An object containing the start and end dates.
@@ -212,7 +89,6 @@ async function countContributorPoints() {
{
codeReviews: [], // Will store objects with PR number and points for main repo changes
docsReviews: [], // Will store objects with PR number and points for docs changes
prContributions: [], // Will store objects with PR number, category, and points for PR author contributions
labelRemovals: [],
issueClosings: [],
points: 0,
@@ -326,28 +202,6 @@ async function countContributorPoints() {
mergerStats.points += CONFIG.POINTS_PER_RELEASE_PR;
}
} else {
// Award points to PR author if they are a core maintainer
const prAuthor = pr.user?.login;
if (prAuthor && orgMemberLogins.has(prAuthor)) {
const categoryAndPoints = await getPRCategoryAndPoints(
octokit,
owner,
repo,
pr.number,
until,
);
if (categoryAndPoints) {
const authorStats = stats.get(prAuthor);
authorStats.prContributions.push({
pr: pr.number.toString(),
category: categoryAndPoints.category,
points: categoryAndPoints.points,
});
authorStats.points += categoryAndPoints.points;
}
}
const uniqueReviewers = new Set();
reviews.data.forEach(review => {
if (
@@ -424,7 +278,7 @@ async function countContributorPoints() {
if (
event.event === 'closed' &&
['not_planned', 'duplicate'].includes(event.state_reason)
event.state_reason === 'not_planned'
) {
const closer = event.actor.login;
const userStats = stats.get(closer);
@@ -439,7 +293,7 @@ async function countContributorPoints() {
// Print all statistics
printStats(
'Code Review Statistics',
stats => stats.codeReviews.reduce((sum, r) => sum + r.points, 0),
stats => stats.codeReviews.length,
(user, count) =>
`${user}: ${count} (PRs: ${stats
.get(user)
@@ -454,7 +308,7 @@ async function countContributorPoints() {
printStats(
'Docs Review Statistics',
stats => stats.docsReviews.reduce((sum, r) => sum + r.points, 0),
stats => stats.docsReviews.length,
(user, count) =>
`${user}: ${count} (PRs: ${stats
.get(user)
@@ -462,27 +316,16 @@ async function countContributorPoints() {
.join(', ')})`,
);
printStats(
'PR Contribution Statistics',
stats => stats.prContributions.reduce((sum, r) => sum + r.points, 0),
(user, count) =>
`${user}: ${count} (PRs: ${stats
.get(user)
.prContributions.map(r => `#${r.pr} (${r.points}pts - ${r.category})`)
.join(', ')})`,
);
printStats(
'"Needs Triage" Label Removal Statistics',
stats => stats.labelRemovals.length * CONFIG.POINTS_PER_ISSUE_TRIAGE_ACTION,
stats => stats.labelRemovals.length,
(user, count) =>
`${user}: ${count} (Issues: ${stats.get(user).labelRemovals.join(', ')})`,
);
printStats(
'Issue Closing Statistics',
stats =>
stats.issueClosings.length * CONFIG.POINTS_PER_ISSUE_CLOSING_ACTION,
stats => stats.issueClosings.length,
(user, count) =>
`${user}: ${count} (Issues: ${stats.get(user).issueClosings.join(', ')})`,
);

View File

@@ -17,11 +17,9 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Check if this is CodeRabbit's first comment
id: check-first-comment
@@ -42,11 +40,7 @@ jobs:
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
- name: Check if release notes file already exists
if: >-
steps.check-first-comment.outputs.result == 'true' &&
steps.pr-details.outputs.result != 'null' &&
fromJSON(steps.pr-details.outputs.result).baseBranch == 'master' &&
!startsWith(fromJSON(steps.pr-details.outputs.result).headBranch, 'release/')
if: steps.check-first-comment.outputs.result == 'true' && steps.pr-details.outputs.result != 'null'
id: check-release-notes-exists
run: node .github/actions/ai-generated-release-notes/check-release-notes-exists.js
env:
@@ -78,7 +72,7 @@ jobs:
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false' && steps.generate-summary.outputs.result != 'null' && steps.determine-category.outputs.result != 'null' && steps.determine-category.outputs.result != ''
run: node .github/actions/ai-generated-release-notes/create-release-notes-file.js
env:
GITHUB_TOKEN: ${{ secrets.ACTIONS_UPDATE_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
SUMMARY_DATA: ${{ steps.generate-summary.outputs.result }}

View File

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

View File

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

View File

@@ -12,20 +12,10 @@ 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -35,7 +25,7 @@ jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -45,7 +35,7 @@ jobs:
validate-cli:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -57,7 +47,7 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -69,10 +59,9 @@ jobs:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
uses: ./.github/actions/setup
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
download-translations: 'false'
node-version: 22
- name: Check migrations
run: yarn workspace @actual-app/ci-actions tsx bin/check-migrations.ts
run: node ./.github/actions/check-migrations.js

View File

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

View File

@@ -16,11 +16,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Count points
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -36,17 +36,17 @@ jobs:
matrix:
os: [ubuntu, alpine]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Docker meta
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
if: github.event_name != 'pull_request' && !github.event.repository.fork
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
if: github.event_name != 'pull_request'
with:
registry: ghcr.io
@@ -76,7 +76,7 @@ jobs:
run: yarn build:server
- name: Build image for testing
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
push: false
@@ -87,13 +87,13 @@ jobs:
- name: Test that the docker image boots
run: |
docker run --detach --network=host actualbudget/actual-server-testing
sleep 10
curl --fail -sS -LI -w '%{http_code}\n' --retry 20 --retry-delay 1 --retry-connrefused localhost:5006
sleep 5
curl --fail -sS -LI -w '%{http_code}\n' --retry 10 --retry-delay 1 --retry-connrefused localhost:5006
# This will use the cache from the earlier build step and not rebuild the image
# https://docs.docker.com/build/ci/github-actions/test-before-push/
- name: Build and push images
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
push: ${{ github.event_name != 'pull_request' }}

View File

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

View File

@@ -2,17 +2,6 @@ name: E2E Tests
on:
pull_request:
paths:
- 'packages/**'
- 'package.json'
- 'yarn.lock'
- '.github/workflows/e2e-test.yml'
- '!packages/sync-server/**' # Sync server changes don't affect E2E tests
- '!packages/api/**' # API changes don't affect E2E tests
- '!packages/ci-actions/**' # CI actions changes don't affect E2E tests
- '!packages/docs/**' # Docs changes don't affect E2E tests
- '!packages/eslint-plugin-actual/**' # Eslint plugin changes don't affect E2E tests
merge_group:
env:
GITHUB_PR_NUMBER: ${{github.event.pull_request.number}}
@@ -22,29 +11,42 @@ concurrency:
cancel-in-progress: true
jobs:
functional:
name: Functional (shard ${{ matrix.shard }}/5)
netlify:
name: Wait for Netlify build to finish
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5]
container:
image: mcr.microsoft.com/playwright:v1.58.2-jammy
outputs:
netlify_url: ${{ steps.netlify.outputs.url }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
- name: Wait for Netlify build to finish
id: netlify
env:
COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./.github/actions/netlify-wait-for-build
functional:
name: Functional
needs: netlify
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.56.0-jammy
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Trust the repository directory
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Run E2E Tests
run: yarn e2e --shard=${{ matrix.shard }}/5
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
- name: Run E2E Tests on Netlify URL
run: yarn e2e
env:
E2E_START_URL: ${{ needs.netlify.outputs.netlify_url }}
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
if: always()
with:
name: desktop-client-test-results-shard-${{ matrix.shard }}
name: desktop-client-test-results
path: packages/desktop-client/test-results/
retention-days: 30
overwrite: true
@@ -53,19 +55,17 @@ jobs:
name: Functional Desktop App
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.2-jammy
image: mcr.microsoft.com/playwright:v1.56.0-jammy
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Trust the repository directory
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Run Desktop app E2E Tests
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
if: always()
with:
name: desktop-app-test-results
@@ -74,68 +74,23 @@ jobs:
overwrite: true
vrt:
name: Visual regression (shard ${{ matrix.shard }}/5)
name: Visual regression
needs: netlify
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5]
container:
image: mcr.microsoft.com/playwright:v1.58.2-jammy
image: mcr.microsoft.com/playwright:v1.56.0-jammy
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
- name: Run VRT Tests on Netlify URL
run: yarn vrt
env:
E2E_START_URL: ${{ needs.netlify.outputs.netlify_url }}
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
if: always()
with:
name: vrt-blob-report-${{ matrix.shard }}
path: packages/desktop-client/blob-report/
retention-days: 1
overwrite: true
merge-vrt:
name: Merge VRT Reports
needs: vrt
runs-on: ubuntu-latest
if: ${{ !cancelled() }}
container:
image: mcr.microsoft.com/playwright:v1.58.2-jammy
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
uses: ./.github/actions/setup
- name: Download all blob reports
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: packages/desktop-client/all-blob-reports
pattern: vrt-blob-report-*
merge-multiple: true
- name: Merge reports
id: merge-reports
run: yarn workspace @actual-app/web run playwright merge-reports --reporter html ./all-blob-reports
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
id: playwright-report-vrt
with:
name: html-report--attempt-${{ github.run_attempt }}
path: packages/desktop-client/playwright-report
name: desktop-client-test-results
path: packages/desktop-client/test-results/
retention-days: 30
overwrite: true
- name: Save VRT metadata for comment workflow
if: github.event_name == 'pull_request'
run: |
mkdir -p vrt-metadata
echo "${{ github.event.pull_request.number }}" > vrt-metadata/pr-number.txt
echo "${{ needs.vrt.result }}" > vrt-metadata/vrt-result.txt
echo "${{ steps.playwright-report-vrt.outputs.artifact-url }}" > vrt-metadata/artifact-url.txt
- name: Upload VRT metadata
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: vrt-comment-metadata
path: vrt-metadata/
retention-days: 1

View File

@@ -1,66 +0,0 @@
name: VRT Comment
# This workflow posts VRT failure comments on PRs, including fork PRs.
# It runs with elevated permissions via workflow_run trigger.
on:
workflow_run:
workflows: ['E2E Tests']
types:
- completed
permissions:
actions: read
pull-requests: write
jobs:
comment:
name: Post VRT Comment
runs-on: ubuntu-latest
if: github.event.workflow_run.event == 'pull_request'
steps:
- name: Download VRT metadata
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
name: vrt-comment-metadata
path: /tmp/vrt-metadata
continue-on-error: true
- name: Extract metadata
id: metadata
run: |
if [ ! -f "/tmp/vrt-metadata/pr-number.txt" ]; then
echo "No metadata found, skipping..."
echo "should_comment=false" >> "$GITHUB_OUTPUT"
exit 0
fi
PR_NUMBER=$(cat "/tmp/vrt-metadata/pr-number.txt")
VRT_RESULT=$(cat "/tmp/vrt-metadata/vrt-result.txt")
ARTIFACT_URL=$(cat "/tmp/vrt-metadata/artifact-url.txt")
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
echo "vrt_result=$VRT_RESULT" >> "$GITHUB_OUTPUT"
echo "artifact_url=$ARTIFACT_URL" >> "$GITHUB_OUTPUT"
if [ "$VRT_RESULT" = "failure" ]; then
echo "should_comment=true" >> "$GITHUB_OUTPUT"
echo "VRT tests failed for PR #$PR_NUMBER"
else
echo "should_comment=false" >> "$GITHUB_OUTPUT"
echo "VRT tests passed or skipped for PR #$PR_NUMBER"
fi
- name: Comment on PR with VRT report link
if: steps.metadata.outputs.should_comment == 'true'
uses: marocchino/sticky-pull-request-comment@70d2764d1a7d5d9560b100cbea0077fc8f633987 # v3.0.2
with:
number: ${{ steps.metadata.outputs.pr_number }}
header: vrt-comment
hide_and_recreate: true
hide_classify: OUTDATED
message: |
<!-- vrt-comment -->
VRT tests ❌ failed. [View the test report](${{ steps.metadata.outputs.artifact_url }}).
To update the VRT screenshots, comment `/update-vrt` on this PR. The VRT update operation takes about 50 minutes.

View File

@@ -29,7 +29,7 @@ jobs:
- macos-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- if: ${{ startsWith(matrix.os, 'windows') }}
run: pip.exe install setuptools
- if: ${{ ! startsWith(matrix.os, 'windows') }}
@@ -52,13 +52,14 @@ jobs:
sudo flatpak install org.freedesktop.Sdk//24.08 -y
sudo flatpak install org.freedesktop.Platform//24.08 -y
sudo flatpak install org.electronjs.Electron2.BaseApp//24.08 -y
sudo flatpak install org.flatpak.Builder -y
METAINFO_FILE="packages/desktop-electron/extra-resources/linux/com.actualbudget.actual.metainfo.xml"
TODAY=$(date +%Y-%m-%d)
VERSION=${{ steps.process_version.outputs.version }}
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"
sudo apt-get install appstream
appstreamcli --version
appstreamcli validate "$METAINFO_FILE"
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Electron for Mac
@@ -74,7 +75,7 @@ jobs:
if: ${{ ! startsWith(matrix.os, 'macos') }}
run: ./bin/package-electron
- name: Upload Build
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: actual-electron-${{ matrix.os }}
path: |
@@ -85,13 +86,13 @@ jobs:
packages/desktop-electron/dist/*.flatpak
- name: Upload Windows Store Build
if: ${{ startsWith(matrix.os, 'windows') }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
with:
draft: true
body: |
@@ -100,20 +101,27 @@ jobs:
## Desktop releases
Please note: Microsoft store updates can sometimes lag behind the main release by a couple of days while they verify the new version.
<p>
<a href="https://apps.microsoft.com/detail/9p2hmlhsdbrm?cid=Github+Releases&mode=direct"><img src="https://get.microsoft.com/images/en-us%20dark.svg" width="200" /></a>
<img src="data:image/gif;base64,R0lGODlhAQABAAAAACw=" width="12" height="1" alt="" />
<a href="https://flathub.org/apps/com.actualbudget.actual"><img width="165" style="margin-left:12px;" alt="Get it on Flathub" src="https://flathub.org/api/badge?locale=en" /></a>
</p>
<a href="https://apps.microsoft.com/detail/9p2hmlhsdbrm?cid=Github+Releases&mode=direct">
<img src="https://get.microsoft.com/images/en-us%20dark.svg" width="200"/>
</a>
files: |
packages/desktop-electron/dist/*.dmg
packages/desktop-electron/dist/*.exe
!packages/desktop-electron/dist/Actual-windows.exe
packages/desktop-electron/dist/*.AppImage
packages/desktop-electron/dist/*.flatpak
- name: Retrieve AppImage SHA256 for Flathub
id: appimage_sha256
run: |
APPIMAGE_X64_SHA256=$(sha256sum packages/desktop-electron/dist/Actual-linux-x86_64.AppImage | awk '{ print $1 }')
APPIMAGE_ARM64_SHA256=$(sha256sum packages/desktop-electron/dist/Actual-linux-arm64.AppImage | awk '{ print $1 }')
echo "appimage_x64_sha256=$APPIMAGE_X64_SHA256" >> "$GITHUB_OUTPUT"
echo "appimage_arm64_sha256=$APPIMAGE_ARM64_SHA256" >> "$GITHUB_OUTPUT"
outputs:
version: ${{ steps.process_version.outputs.version }}
appimage_x64_sha256: ${{ steps.appimage_sha256.outputs.appimage_x64_sha256 }}
appimage_arm64_sha256: ${{ steps.appimage_sha256.outputs.appimage_arm64_sha256 }}
publish-microsoft-store:
needs: build
@@ -126,7 +134,7 @@ jobs:
Install-Module -Name StoreBroker -AcceptLicense -Force -Scope CurrentUser -Verbose
- name: Download Microsoft Store artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: actual-electron-windows-latest-appx
@@ -156,3 +164,38 @@ jobs:
-NoStatus `
-AutoCommit `
-Force
publish-flathub:
needs: build
runs-on: ubuntu-22.04
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
steps:
- name: Checkout Flathub repo
uses: actions/checkout@v6
with:
repository: flathub/com.actualbudget.actual
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
- name: Update manifest with new SHA256
run: |
# Replace x86_64 entry
sed -i "/x86_64.AppImage/{n;s|sha256:.*|sha256: ${{ needs.build.outputs.appimage_x64_sha256 }}|}" com.actualbudget.actual.yml
sed -i "/x86_64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${{ needs.build.outputs.version }}/Actual-linux-x86_64.AppImage|" com.actualbudget.actual.yml
# Replace arm64 entry
sed -i "/arm64.AppImage/{n;s|sha256:.*|sha256: ${{ needs.build.outputs.appimage_arm64_sha256 }}|}" com.actualbudget.actual.yml
sed -i "/arm64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${{ needs.build.outputs.version }}/Actual-linux-arm64.AppImage|" com.actualbudget.actual.yml
cat com.actualbudget.actual.yml
- name: Create PR in Flathub repo
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
commit-message: "Update Actual flatpak to version ${{ needs.build.outputs.version }}"
branch: "release/${{ needs.build.outputs.version }}"
title: "Update Actual flatpak to version ${{ needs.build.outputs.version }}"
body: |
This PR updates the Actual desktop flatpak to version ${{ needs.build.outputs.version }}.
:link: [View release notes](https://actualbudget.org/blog/release-${{ needs.build.outputs.version }})

View File

@@ -9,15 +9,6 @@ env:
on:
pull_request:
paths:
- 'packages/**'
- 'package.json'
- 'yarn.lock'
- '.github/workflows/electron-pr.yml'
- '!packages/api/**' # API changes don't affect Electron
- '!packages/ci-actions/**' # CI actions changes don't affect Electron
- '!packages/docs/**' # Docs changes don't affect Electron
- '!packages/eslint-plugin-actual/**' # Eslint plugin changes don't affect Electron
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
@@ -33,7 +24,7 @@ jobs:
- macos-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- if: ${{ startsWith(matrix.os, 'windows') }}
run: pip.exe install setuptools
- if: ${{ ! startsWith(matrix.os, 'windows') }}
@@ -42,8 +33,6 @@ 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: |
@@ -54,67 +43,70 @@ jobs:
sudo flatpak install org.freedesktop.Sdk//24.08 -y
sudo flatpak install org.freedesktop.Platform//24.08 -y
sudo flatpak install org.electronjs.Electron2.BaseApp//24.08 -y
sudo flatpak install org.flatpak.Builder -y
METAINFO_FILE="packages/desktop-electron/extra-resources/linux/com.actualbudget.actual.metainfo.xml"
TODAY=$(date +%Y-%m-%d)
VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-electron/package.json --type nightly)
VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --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"
sudo apt-get install appstream
appstreamcli --version
appstreamcli validate "$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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-mac-arm64.dmg
if-no-files-found: ignore
@@ -122,7 +114,7 @@ jobs:
- name: Upload Windows Store Build
if: ${{ startsWith(matrix.os, 'windows') }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: actual-electron-${{ matrix.os }}-appx
path: |

View File

@@ -1,48 +0,0 @@
name: Fork PR Welcome
##########################################################################################
# WARNING! This workflow uses the 'pull_request_target' event. That means that it will #
# always run in the context of the main actualbudget/actual repo, even if the PR is from #
# a fork. This is necessary to get access to a GitHub token that can post a comment on #
# the PR. Be VERY CAREFUL about adding things to this workflow, since forks can inject #
# arbitrary code into their branch, and can pollute the artifacts we download. Arbitrary #
# code execution in this workflow could lead to a compromise of the main repo. #
##########################################################################################
# See: https://securitylab.github.com/research/github-actions-preventing-pwn-requests #
##########################################################################################
on:
pull_request_target:
types: [opened, reopened]
permissions:
pull-requests: write
jobs:
welcome:
name: Post Welcome Message
runs-on: ubuntu-latest
if: github.event.pull_request.head.repo.full_name != github.repository
steps:
- name: Post welcome comment
uses: marocchino/sticky-pull-request-comment@70d2764d1a7d5d9560b100cbea0077fc8f633987 # v3.0.2
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
number: ${{ github.event.pull_request.number }}
header: fork-pr-welcome
hide_and_recreate: true
hide_classify: OUTDATED
message: |
<!-- fork-pr-welcome -->
👋 Hello contributor!
We would love to review your PR! Before we can do that, please make sure:
- ✅ All CI checks pass
- ✅ The PR is moved from draft to open (if applicable)
- ✅ The "[WIP]" prefix is removed from the PR title
- ✅ All CodeRabbit code review comments are resolved (if you disagree with anything - reply to the bot with your reasoning so we can read through it). The bot will eventually approve the PR.
We do this to reduce the TOIL the core contributor team has to go through for each PR and to allow for speedy reviews and merges.
For more information, please see our [Contributing Guide](https://actualbudget.org/docs/contributing/).

View File

@@ -17,13 +17,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.inputs.ref }}
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Bump package versions
id: bump_package_versions
shell: bash
@@ -39,12 +35,9 @@ jobs:
pkg="${packages[$key]}"
if [[ -n "${{ github.event.inputs.version }}" ]]; then
version=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts \
--package-json "./packages/$pkg/package.json" \
--version "${{ github.event.inputs.version }}" \
--update)
version="${{ github.event.inputs.version }}"
else
version=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts \
version=$(node ./packages/ci-actions/bin/get-next-package-version.js \
--package-json "./packages/$pkg/package.json" \
--type auto \
--update)
@@ -55,9 +48,8 @@ jobs:
echo "version=$NEW_WEB_VERSION" >> "$GITHUB_OUTPUT"
- name: Create PR
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ secrets.ACTIONS_UPDATE_TOKEN }}
commit-message: '🔖 (${{ steps.bump_package_versions.outputs.version }})'
title: '🔖 (${{ steps.bump_package_versions.outputs.version }})'
body: 'Generated by [generate-release-pr.yml](../tree/master/.github/workflows/generate-release-pr.yml)'

View File

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

View File

@@ -29,7 +29,7 @@ jobs:
The enhancement backlog can be found here: https://github.com/actualbudget/actual/issues?q=label%3A%22needs+votes%22+sort%3Areactions-%2B1-desc+
Don't forget to upvote the top comment with 👍!
Dont forget to upvote the top comment with 👍!
<!-- feature-auto-close-comment -->
- name: Close Issue

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ name: Publish nightly desktop app
# Publish nightly version of desktop app - Runs every day at midnight
on:
schedule:
- cron: '0 0 * * *'
- cron: "0 0 * * *"
workflow_dispatch:
defaults:
@@ -26,9 +26,8 @@ jobs:
- windows-latest
- macos-latest
runs-on: ${{ matrix.os }}
if: github.event.repository.fork == false
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- if: ${{ startsWith(matrix.os, 'windows') }}
run: pip.exe install setuptools
@@ -39,9 +38,6 @@ 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: |
@@ -52,18 +48,21 @@ jobs:
sudo flatpak install org.freedesktop.Sdk//24.08 -y
sudo flatpak install org.freedesktop.Platform//24.08 -y
sudo flatpak install org.electronjs.Electron2.BaseApp//24.08 -y
sudo flatpak install org.flatpak.Builder -y
METAINFO_FILE="packages/desktop-electron/extra-resources/linux/com.actualbudget.actual.metainfo.xml"
TODAY=$(date +%Y-%m-%d)
VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-electron/package.json --type nightly)
VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --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"
sudo apt-get install appstream
appstreamcli --version
appstreamcli validate "$METAINFO_FILE"
- name: Set up environment
uses: ./.github/actions/setup
- name: Update package versions
run: |
# Get new nightly version
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)
NEW_DESKTOP_APP_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --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
@@ -83,49 +82,49 @@ jobs:
run: ./bin/package-electron
- name: Upload Linux x64 AppImage
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-mac-arm64.dmg
if-no-files-found: ignore
@@ -133,7 +132,7 @@ jobs:
- name: Upload Windows Store Build
if: ${{ startsWith(matrix.os, 'windows') }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: actual-electron-${{ matrix.os }}-appx
path: |

View File

@@ -1,6 +1,6 @@
name: Publish nightly npm packages
# Nightly npm packages are built daily at midnight UTC
# Nightly npm packages are built daily
on:
schedule:
- cron: '0 0 * * *'
@@ -12,7 +12,7 @@ jobs:
name: Build and pack npm packages
if: github.event.repository.fork == false
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
@@ -20,27 +20,19 @@ jobs:
- name: Update package versions
run: |
# Get new nightly versions
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)
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)
# Set package versions
npm version $NEW_CORE_VERSION --no-git-tag-version --workspace=@actual-app/core --no-workspaces-update
npm version $NEW_WEB_VERSION --no-git-tag-version --workspace=@actual-app/web --no-workspaces-update
npm version $NEW_SYNC_VERSION --no-git-tag-version --workspace=@actual-app/sync-server --no-workspaces-update
npm version $NEW_API_VERSION --no-git-tag-version --workspace=@actual-app/api --no-workspaces-update
npm version $NEW_CLI_VERSION --no-git-tag-version --workspace=@actual-app/cli --no-workspaces-update
- name: Yarn install
run: |
yarn install
- name: Pack the core package
run: |
yarn workspace @actual-app/core pack --filename @actual-app/core.tgz
- name: Build Server & Web
run: yarn build:server
@@ -56,23 +48,14 @@ 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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: npm-packages
path: |
packages/loot-core/@actual-app/core.tgz
packages/desktop-client/@actual-app/web.tgz
packages/sync-server/@actual-app/sync-server.tgz
packages/api/@actual-app/api.tgz
packages/cli/@actual-app/cli.tgz
publish:
runs-on: ubuntu-latest
@@ -83,22 +66,16 @@ jobs:
packages: write
steps:
- name: Download the artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: npm-packages
- name: Setup node and npm registry
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'
- name: Publish Core
run: |
npm publish loot-core/@actual-app/core.tgz --access public --tag nightly
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish Web
run: |
npm publish desktop-client/@actual-app/web.tgz --access public --tag nightly
@@ -116,9 +93,3 @@ jobs:
npm publish api/@actual-app/api.tgz --access public --tag nightly
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish CLI
run: |
npm publish cli/@actual-app/cli.tgz --access public --tag nightly
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -11,15 +11,11 @@ jobs:
runs-on: ubuntu-latest
name: Build and pack npm packages
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
- name: Pack the core package
run: |
yarn workspace @actual-app/core pack --filename @actual-app/core.tgz
- name: Build Web
run: yarn build:server
@@ -35,23 +31,14 @@ 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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: npm-packages
path: |
packages/loot-core/@actual-app/core.tgz
packages/desktop-client/@actual-app/web.tgz
packages/sync-server/@actual-app/sync-server.tgz
packages/api/@actual-app/api.tgz
packages/cli/@actual-app/cli.tgz
publish:
runs-on: ubuntu-latest
@@ -62,22 +49,16 @@ jobs:
packages: write
steps:
- name: Download the artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: npm-packages
- name: Setup node and npm registry
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'
- name: Publish Core
run: |
npm publish loot-core/@actual-app/core.tgz --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish Web
run: |
npm publish desktop-client/@actual-app/web.tgz --access public
@@ -95,9 +76,3 @@ jobs:
npm publish api/@actual-app/api.tgz --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish CLI
run: |
npm publish cli/@actual-app/cli.tgz --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

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

View File

@@ -15,13 +15,7 @@ on:
pull_request_target:
paths:
- 'packages/**'
- 'package.json'
- 'yarn.lock'
- '.github/workflows/size-compare.yml'
- '!packages/sync-server/**' # Sync server changes don't affect the size of the web/api
- '!packages/ci-actions/**' # CI actions changes don't affect the size of the web/api
- '!packages/docs/**' # Docs changes don't affect the size of the web/api
- '!packages/eslint-plugin-actual/**' # Eslint plugin changes don't affect the size of the web/api
- '!packages/sync-server/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -35,7 +29,7 @@ jobs:
contents: read
steps:
- name: Checkout base branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.base_ref }}
- name: Set up environment
@@ -57,13 +51,6 @@ 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
@@ -79,22 +66,15 @@ 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' || steps.wait-for-cli-build.outputs.conclusion == 'failure'
if: steps.wait-for-web-build.outputs.conclusion == 'failure' || steps.wait-for-api-build.outputs.conclusion == 'failure'
run: |
echo "Build failed on PR branch or ${{github.base_ref}}"
exit 1
- name: Download web build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
id: pr-web-build
with:
branch: ${{github.base_ref}}
@@ -103,7 +83,7 @@ jobs:
name: build-stats
path: base
- name: Download API build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
id: pr-api-build
with:
branch: ${{github.base_ref}}
@@ -112,7 +92,7 @@ jobs:
name: api-build-stats
path: base
- name: Download build stats from PR
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
with:
pr: ${{github.event.pull_request.number}}
workflow: build.yml
@@ -121,7 +101,7 @@ jobs:
path: head
allow_forks: true
- name: Download API stats from PR
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
with:
pr: ${{github.event.pull_request.number}}
workflow: build.yml
@@ -129,23 +109,6 @@ 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
@@ -167,13 +130,10 @@ 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
--identifier combined > bundle-stats-comment.md
- name: Post combined bundle stats comment
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -182,5 +142,4 @@ jobs:
run: |
node packages/ci-actions/bin/update-bundle-stats-comment.mjs \
--comment-file bundle-stats-comment.md \
--identifier combined \
--target pr-body
--identifier '<!--- bundlestats-action-comment key:combined --->'

View File

@@ -8,7 +8,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.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@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.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@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
with:
stale-issue-label: 'needs info'
days-before-stale: -1

View File

@@ -19,7 +19,7 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Download patch artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
@@ -54,12 +54,11 @@ jobs:
- name: Checkout fork branch
if: steps.metadata.outputs.pr_number != ''
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
repository: ${{ steps.metadata.outputs.head_repo }}
ref: ${{ steps.metadata.outputs.head_ref }}
token: ${{ secrets.ACTIONS_UPDATE_TOKEN }}
persist-credentials: false
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Validate and apply patch
@@ -122,15 +121,22 @@ jobs:
env:
HEAD_REF: ${{ steps.metadata.outputs.head_ref }}
HEAD_REPO: ${{ steps.metadata.outputs.head_repo }}
GITHUB_TOKEN: ${{ secrets.ACTIONS_UPDATE_TOKEN }}
run: |
# Use PAT in URL to ensure push triggers CI workflows
# Note: GitHub Actions automatically masks secrets in logs
git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${HEAD_REPO}.git"
git push origin "HEAD:refs/heads/$HEAD_REF"
echo "Successfully pushed VRT updates to $HEAD_REPO@$HEAD_REF"
- name: Comment on PR - Success
if: steps.apply.outputs.applied == 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
await github.rest.issues.createComment({
issue_number: ${{ steps.metadata.outputs.pr_number }},
owner: context.repo.owner,
repo: context.repo.repo,
body: '✅ VRT screenshots have been automatically updated.'
});
- name: Comment on PR - Failure
if: failure() && steps.metadata.outputs.pr_number != ''
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0

View File

@@ -1,82 +1,51 @@
name: VRT Update - Generate
# SECURITY: This workflow runs in untrusted fork context with no write permissions.
# It only generates VRT patch artifacts that are later applied by vrt-update-apply.yml
# Triggered by commenting "/update-vrt" on a pull request.
on:
issue_comment:
types: [created]
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'packages/**'
- '.github/workflows/vrt-update-generate.yml'
permissions:
contents: read
pull-requests: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number }}
cancel-in-progress: false
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
add-reaction:
name: Add 👀 Reaction
runs-on: ubuntu-latest
# Only run on PR comments containing /update-vrt
if: >
github.event.issue.pull_request &&
startsWith(github.event.comment.body, '/update-vrt')
permissions:
pull-requests: write
steps:
- name: Add 👀 reaction to comment
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: 'eyes'
});
generate-vrt-updates:
name: Generate VRT Updates
runs-on: ubuntu-latest
# Only run on PR comments containing /update-vrt
if: >
github.event.issue.pull_request &&
startsWith(github.event.comment.body, '/update-vrt')
container:
image: mcr.microsoft.com/playwright:v1.58.2-jammy
image: mcr.microsoft.com/playwright:v1.56.0-jammy
steps:
- name: Get PR details
id: pr
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
script: |
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number
});
core.setOutput('head_sha', pr.head.sha);
core.setOutput('head_ref', pr.head.ref);
core.setOutput('head_repo', pr.head.repo.full_name);
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ steps.pr.outputs.head_sha }}
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Run VRT Tests on Desktop app
continue-on-error: true
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop --update-snapshots
- name: Run VRT Tests
- name: Wait for Netlify build to finish
id: netlify
env:
COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./.github/actions/netlify-wait-for-build
- name: Run VRT Tests on Netlify URL
continue-on-error: true
run: yarn vrt --update-snapshots
env:
E2E_START_URL: ${{ steps.netlify.outputs.url }}
- name: Create patch with PNG changes only
id: create-patch
@@ -113,9 +82,9 @@ jobs:
- name: Upload patch artifact
if: steps.create-patch.outputs.has_changes == 'true'
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: vrt-patch-${{ github.event.issue.number }}
name: vrt-patch-${{ github.event.pull_request.number }}
path: vrt-update.patch
retention-days: 5
@@ -123,14 +92,14 @@ jobs:
if: steps.create-patch.outputs.has_changes == 'true'
run: |
mkdir -p pr-metadata
echo "${{ github.event.issue.number }}" > pr-metadata/pr-number.txt
echo "${{ steps.pr.outputs.head_ref }}" > pr-metadata/head-ref.txt
echo "${{ steps.pr.outputs.head_repo }}" > pr-metadata/head-repo.txt
echo "${{ github.event.pull_request.number }}" > pr-metadata/pr-number.txt
echo "${{ github.event.pull_request.head.ref }}" > pr-metadata/head-ref.txt
echo "${{ github.event.pull_request.head.repo.full_name }}" > pr-metadata/head-repo.txt
- name: Upload PR metadata
if: steps.create-patch.outputs.has_changes == 'true'
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: vrt-metadata-${{ github.event.issue.number }}
name: vrt-metadata-${{ github.event.pull_request.number }}
path: pr-metadata/
retention-days: 5

25
.gitignore vendored
View File

@@ -15,17 +15,9 @@ export-2020-01-10.csv
# JavaScript
node_modules
packages/api/app/bundle.api.js
packages/api/app/stats.json
packages/api/dist
packages/api/@types
packages/crdt/dist
packages/desktop-client/build-stats
packages/desktop-client/dev-dist
packages/desktop-client/public/kcab
packages/desktop-client/locale
packages/desktop-client/playwright-report
packages/desktop-client/test-results
packages/desktop-electron/client-build
packages/desktop-electron/build
packages/desktop-electron/.electron-symbols
@@ -33,10 +25,6 @@ packages/desktop-electron/dist
packages/desktop-electron/loot-core
packages/desktop-client/service-worker
packages/plugins-service/dist
packages/component-library/dist
packages/loot-core/lib-dist
**/.tsbuildinfo
packages/sync-server/coverage
bundle.desktop.js
bundle.desktop.js.map
bundle.mobile.js
@@ -52,8 +40,7 @@ bundle.mobile.js.map
!.yarn/versions
# VSCode
.vscode/*
!.vscode/settings.default.json
.vscode
# IntelliJ IDEA
.idea
@@ -78,13 +65,3 @@ build/
# Lage cache
.lage/
*storybook.log
storybook-static
# cli config when testing locally
.actualrc.json
.actualrc
.actualrc.yaml
.actualrc.yml
actual.config.js

View File

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

View File

@@ -1,7 +0,0 @@
#!/bin/sh
# Run yarn install after pulling/merging (if yarn.lock changed)
if git diff --name-only ORIG_HEAD HEAD | grep -q "^yarn.lock$"; then
echo "yarn.lock changed — running yarn install..."
yarn install
fi

View File

@@ -3,32 +3,6 @@
"singleQuote": true,
"trailingComma": "all",
"arrowParens": "avoid",
"printWidth": 80,
"experimentalSortImports": {
"groups": [
"react",
"builtin",
"external",
"loot-core",
["parent", "subpath"],
"sibling",
"index",
"desktop-client"
],
"customGroups": [
{
"groupName": "react",
"elementNamePattern": ["react", "react-dom/*", "react-*"]
},
{
"groupName": "loot-core",
"elementNamePattern": ["loot-core/**", "@actual-app/core/**"]
},
{
"groupName": "desktop-client",
"elementNamePattern": ["@desktop-client/**"]
}
],
"newlinesBetween": true
}
"printWidth": 80
}

View File

@@ -1,426 +1,69 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "import", "jsx-a11y"],
"jsPlugins": [
"./packages/eslint-plugin-actual/lib/index.js",
"eslint-plugin-typescript-paths",
"eslint-plugin-perfectionist"
],
"env": {
"browser": true,
"node": true,
"vitest": true
},
"globals": {
"vi": "readonly",
"backend": "readonly",
"importScripts": "readonly",
"FS": "readonly"
},
"rules": {
// Import sorting
"perfectionist/sort-named-imports": [
"error",
{
"groups": ["value-import", "type-import"]
}
],
// Actual rules
"actual/typography": "error",
"actual/no-untranslated-strings": "error",
"actual/prefer-trans-over-t": "error",
"actual/prefer-if-statement": "error",
"actual/prefer-logger-over-console": "error",
"actual/object-shorthand-properties": "error",
"actual/prefer-const": "error",
"actual/no-anchor-tag": "error",
"actual/no-react-default-import": "error",
// JSX A11y rules
"jsx-a11y/no-autofocus": [
"error",
{
"ignoreNonDOM": true
}
],
"jsx-a11y/alt-text": "error",
"jsx-a11y/anchor-has-content": "error",
"jsx-a11y/anchor-is-valid": [
"error",
{
"aspects": ["noHref", "invalidHref"]
}
],
"jsx-a11y/aria-activedescendant-has-tabindex": "error",
"jsx-a11y/aria-props": "error",
"jsx-a11y/aria-proptypes": "error",
"jsx-a11y/aria-role": [
"error",
{
"ignoreNonDOM": true
}
],
"jsx-a11y/aria-unsupported-elements": "error",
"jsx-a11y/heading-has-content": "error",
"jsx-a11y/iframe-has-title": "error",
"jsx-a11y/img-redundant-alt": "error",
"jsx-a11y/no-access-key": "error",
"jsx-a11y/no-distracting-elements": "error",
"jsx-a11y/no-redundant-roles": "error",
"jsx-a11y/role-has-required-aria-props": "error",
"jsx-a11y/role-supports-aria-props": "error",
"jsx-a11y/scope": "error",
// Typescript rules
"typescript/ban-ts-comment": ["error"],
"typescript/consistent-type-definitions": ["error", "type"],
"typescript/consistent-type-imports": [
"error",
{
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}
],
"typescript/no-implied-eval": "error",
"typescript/no-explicit-any": "error",
"typescript/no-restricted-types": [
"error",
{
"types": {
// forbid FC as superfluous
"FunctionComponent": {
"message": "Type the props argument and let TS infer or use ComponentType for a component prop"
},
"FC": {
"message": "Type the props argument and let TS infer or use ComponentType for a component prop"
}
}
}
],
"typescript/no-var-requires": "error",
// we want to allow unions such as "{ name: DbAccount['name'] | DbPayee['name'] }"
"typescript/no-duplicate-type-constituents": "off",
// we want to allow unions such as "string | 'network' | 'file-key-mismatch'"
"typescript/no-redundant-type-constituents": "off",
"typescript/await-thenable": "error",
"typescript/no-floating-promises": "error",
"typescript/require-array-sort-compare": "error",
"typescript/unbound-method": "error",
"typescript/no-for-in-array": "error",
"typescript/restrict-template-expressions": "error",
"typescript/no-misused-spread": "warn", // TODO: enable this
"typescript/no-base-to-string": "warn", // TODO: enable this
"typescript/no-unsafe-unary-minus": "warn", // TODO: enable this
"typescript/no-unsafe-type-assertion": "warn", // TODO: enable this
// Import rules
"import/consistent-type-specifier-style": "error",
"import/first": "error",
"import/no-amd": "error",
"import/no-default-export": "error",
"import/no-webpack-loader-syntax": "error",
"import/no-useless-path-segments": "error",
"import/no-unresolved": "error",
"import/no-unused-modules": "error",
"import/no-duplicates": [
"error",
{
"prefer-inline": false
}
],
// React rules
"react/exhaustive-deps": [
"error",
{
"additionalHooks": "(^useQuery$|^useEffectAfterMount$)"
}
],
"react/jsx-curly-brace-presence": "error",
"react/jsx-filename-extension": [
"error",
{
"extensions": [".jsx", ".tsx"],
"allow": "as-needed"
}
],
"react/jsx-no-comment-textnodes": "error",
"react/jsx-no-duplicate-props": "error",
"react/jsx-no-target-blank": "error",
"react/jsx-no-undef": "error",
"react/jsx-no-useless-fragment": "error",
"react/jsx-pascal-case": [
"error",
{
"allowAllCaps": true,
"ignore": []
}
],
"react/no-danger-with-children": "error",
"react/no-direct-mutation-state": "error",
"react/no-is-mounted": "error",
"react/no-unstable-nested-components": "error",
"react/require-render-return": "error",
"react/rules-of-hooks": "error",
"react/self-closing-comp": "error",
"react/style-prop-object": "error",
"react/jsx-boolean-value": "error",
// ESLint rules
"eslint/array-callback-return": "error",
"eslint/curly": ["error", "multi-line", "consistent"],
"eslint/default-case": [
"error",
{
"commentPattern": "^no default$"
}
],
"eslint/eqeqeq": ["error", "smart"],
"eslint/no-array-constructor": "error",
"eslint/no-caller": "error",
"eslint/no-cond-assign": ["error", "except-parens"],
"eslint/no-const-assign": "error",
"eslint/no-control-regex": "error",
"eslint/no-delete-var": "error",
"eslint/no-dupe-class-members": "error",
"eslint/no-dupe-keys": "error",
"eslint/no-duplicate-case": "error",
"eslint/no-empty-character-class": "error",
"eslint/no-empty-function": "error",
"eslint/no-empty-pattern": "error",
"eslint/no-eval": "error",
"eslint/no-ex-assign": "error",
"eslint/no-extend-native": "error",
"eslint/no-extra-bind": "error",
"eslint/no-extra-label": "error",
"eslint/no-fallthrough": "error",
"eslint/no-func-assign": "error",
"eslint/no-invalid-regexp": "error",
"eslint/no-iterator": "error",
"eslint/no-label-var": "error",
"eslint/no-var": "error",
"eslint/no-labels": [
"error",
{
"allowLoop": true,
"allowSwitch": false
}
],
"eslint/no-new-func": "error",
"eslint/no-script-url": "error",
"eslint/no-self-assign": "error",
"eslint/no-self-compare": "error",
"eslint/no-sequences": "error",
"eslint/no-shadow-restricted-names": "error",
"eslint/no-sparse-arrays": "error",
"eslint/no-template-curly-in-string": "error",
"eslint/no-this-before-super": "error",
"eslint/no-throw-literal": "error",
"eslint/no-unreachable": "error",
"eslint/no-obj-calls": "error",
"eslint/no-new-wrappers": "error",
"eslint/no-unsafe-negation": "error",
"eslint/no-multi-str": "error",
"eslint/no-global-assign": "error",
"eslint/no-lone-blocks": "error",
"eslint/no-unused-labels": "error",
"eslint/no-object-constructor": "error",
"eslint/no-new-native-nonconstructor": "error",
"eslint/no-redeclare": "error",
"eslint/no-useless-computed-key": "error",
"eslint/no-useless-concat": "error",
"eslint/no-useless-escape": "error",
"eslint/require-yield": "error",
"eslint/getter-return": "error",
"eslint/unicode-bom": ["error", "never"],
"eslint/no-use-isnan": "error",
"eslint/valid-typeof": "error",
"eslint/no-useless-rename": [
"error",
{
"ignoreDestructuring": false,
"ignoreImport": false,
"ignoreExport": false
}
],
"eslint/no-with": "error",
"eslint/no-regex-spaces": "error",
"eslint/no-restricted-globals": [
"error",
// https://github.com/facebook/create-react-app/tree/main/packages/confusing-browser-globals
"addEventListener",
"blur",
"close",
"closed",
"confirm",
"defaultStatus",
"defaultstatus",
"event",
"external",
"find",
"focus",
"frameElement",
"frames",
"history",
"innerHeight",
"innerWidth",
"length",
"location",
"locationbar",
"menubar",
"moveBy",
"moveTo",
"name",
"onblur",
"onerror",
"onfocus",
"onload",
"onresize",
"onunload",
"open",
"opener",
"opera",
"outerHeight",
"outerWidth",
"pageXOffset",
"pageYOffset",
"parent",
"print",
"removeEventListener",
"resizeBy",
"resizeTo",
"screen",
"screenLeft",
"screenTop",
"screenX",
"screenY",
"scroll",
"scrollbars",
"scrollBy",
"scrollTo",
"scrollX",
"scrollY",
"status",
"statusbar",
"stop",
"toolbar",
"top"
],
"eslint/no-restricted-imports": [
"error",
{
"paths": [
{
"name": "react-router",
"importNames": ["useNavigate"],
"message": "Please import Actual's useNavigate() hook from `src/hooks` instead."
},
{
"name": "react-redux",
"importNames": ["useDispatch"],
"message": "Please import Actual's useDispatch() hook from `src/redux` instead."
},
{
"name": "react-redux",
"importNames": ["useSelector"],
"message": "Please import Actual's useSelector() hook from `src/redux` instead."
},
{
"name": "react-redux",
"importNames": ["useStore"],
"message": "Please import Actual's useStore() hook from `src/redux` instead."
}
],
"patterns": [
{
"group": ["**/*.api", "**/*.web", "**/*.electron"],
"message": "Don't directly reference imports from other platforms"
},
{
"group": ["uuid"],
"importNames": ["*"],
"message": "Use `import { v4 as uuidv4 } from 'uuid'` instead"
},
{
"group": ["**/style", "**/colors"],
"importNames": ["colors"],
"message": "Please use themes instead of colors"
},
{
"group": ["**/style/themes/*"],
"message": "Please do not import theme files directly"
},
{
"group": ["@actual-app/web/**/*"],
"message": "Please do not import `@actual-app/web` in `loot-core`"
}
]
}
],
"eslint/no-useless-constructor": "error",
"eslint/no-undef": "error",
"eslint/no-unused-expressions": "error"
// TODO fix all these and re-enable
"jsx-a11y/alt-text": "off",
"jsx-a11y/aria-role": "off",
"jsx-a11y/click-events-have-key-events": "off",
"jsx-a11y/label-has-associated-control": "off",
"jsx-a11y/prefer-tag-over-role": "off",
"jsx-a11y/role-has-required-aria-props": "off",
"jsx-a11y/tabindex-no-positive": "off",
"no-autofocus": "off",
},
"overrides": [
{
"files": ["packages/desktop-electron/**/*"],
"rules": {
"react/rules-of-hooks": "off"
}
},
{
"files": ["**/*.test.{js,ts,jsx,tsx}", "packages/docs/**/*"],
"rules": {
"actual/no-untranslated-strings": "off",
"actual/prefer-logger-over-console": "off"
}
},
{
"files": [
"packages/api/migrations/*",
"packages/loot-core/migrations/*",
"packages/sync-server/src/app-gocardless/banks/*.js",
"*.config.{ts,mts,mjs}"
],
"rules": {
"import/no-default-export": "off"
}
},
{
"files": ["packages/docs/**/*"],
"rules": {
"actual/no-anchor-tag": "off"
}
},
{
"files": ["packages/desktop-client/**/*.{js,ts,jsx,tsx}"],
"rules": {
"typescript-paths/absolute-parent-import": [
"error",
{ "preferPathOverBaseUrl": true }
],
"typescript-paths/absolute-import": ["error", { "enableAlias": false }]
}
},
{
"files": ["packages/desktop-client/src/style/themes/*"],
"rules": {
"eslint/no-restricted-imports": "off"
}
},
// TODO: enable these
{
// TODO: fix the issues in these files
"files": [
"packages/component-library/src/Menu.tsx",
"packages/desktop-client/src/components/accounts/Account.jsx",
"packages/desktop-client/src/components/accounts/MobileAccount.jsx",
"packages/desktop-client/src/components/accounts/MobileAccounts.jsx",
"packages/desktop-client/src/components/budget/BudgetCategories.jsx",
"packages/desktop-client/src/components/budget/BudgetSummaries.tsx",
"packages/desktop-client/src/components/budget/DynamicBudgetTable.tsx",
"packages/desktop-client/src/components/budget/envelope/HoldMenu.tsx",
"packages/desktop-client/src/components/budget/envelope/TransferMenu.tsx",
"packages/desktop-client/src/components/budget/index.tsx",
"packages/desktop-client/src/components/budget/MobileBudget.tsx",
"packages/desktop-client/src/components/FinancesApp.tsx",
"packages/desktop-client/src/components/GlobalKeys.ts",
"packages/desktop-client/src/components/LoggedInUser.tsx",
"packages/desktop-client/src/components/manager/ManagementApp.jsx",
"packages/desktop-client/src/components/manager/subscribe/common.tsx",
"packages/desktop-client/src/components/ManageRules.tsx",
"packages/desktop-client/src/components/mobile/budget/ExpenseGroupList.tsx",
"packages/desktop-client/src/components/reports/reports/Calendar.tsx",
"packages/desktop-client/src/components/table.tsx"
"packages/desktop-client/src/components/mobile/MobileAmountInput.jsx",
"packages/desktop-client/src/components/mobile/MobileNavTabs.tsx",
"packages/desktop-client/src/components/Modals.tsx",
"packages/desktop-client/src/components/modals/EditRule.jsx",
"packages/desktop-client/src/components/modals/ImportTransactions.jsx",
"packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.tsx",
"packages/desktop-client/src/components/modals/MergeUnusedPayees.jsx",
"packages/desktop-client/src/components/Notifications.tsx",
"packages/desktop-client/src/components/payees/ManagePayees.jsx",
"packages/desktop-client/src/components/payees/ManagePayeesWithData.jsx",
"packages/desktop-client/src/components/payees/PayeeTable.tsx",
"packages/desktop-client/src/components/reports/graphs/tableGraph/ReportTable.tsx",
"packages/desktop-client/src/components/reports/graphs/tableGraph/ReportTableTotals.tsx",
"packages/desktop-client/src/components/reports/reports/CashFlowCard.jsx",
"packages/desktop-client/src/components/reports/reports/CustomReport.jsx",
"packages/desktop-client/src/components/reports/reports/CustomReport.tsx",
"packages/desktop-client/src/components/reports/reports/NetWorthCard.jsx",
"packages/desktop-client/src/components/reports/SaveReportName.tsx",
"packages/desktop-client/src/components/reports/useReport.ts",
"packages/desktop-client/src/components/schedules/ScheduleDetails.jsx",
"packages/desktop-client/src/components/schedules/ScheduleEditModal.tsx",
"packages/desktop-client/src/components/schedules/SchedulesTable.tsx",
"packages/desktop-client/src/components/select/DateSelect.tsx",
"packages/desktop-client/src/components/sidebar/Tools.tsx",
"packages/desktop-client/src/components/sort.tsx",
"packages/desktop-client/src/hooks/useEffectAfterMount.ts",
"packages/desktop-client/src/hooks/useQuery.ts",
],
"rules": {
"eslint/no-empty-function": "off"
}
}
"react-hooks/exhaustive-deps": "off",
},
},
]
}

View File

@@ -1,7 +0,0 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"editor.defaultFormatter": "oxc.oxc-vscode",
"vitest.nodeEnv": {
"NODE_OPTIONS": "--experimental-vm-modules --import ./packages/sync-server/register-loader.mjs --trace-warnings"
}
}

View File

@@ -42,12 +42,6 @@ yarn start:desktop
- Use `yarn workspace <workspace-name> run <command>` for workspace-specific tasks
- Tests run once and exit by default (using `vitest --run`)
### ⚠️ CRITICAL REQUIREMENT: AI-Generated Commit Messages and PR Titles
**ALL commit messages and PR titles MUST be prefixed with `[AI]`.** No exceptions.
See [PR and Commit Rules](.github/agents/pr-and-commit-rules.md) for the full specification, including git safety rules, pre-commit checklist, and PR workflow.
### Task Orchestration with Lage
The project uses **[lage](https://microsoft.github.io/lage/)** (a task runner for JavaScript monorepos) to efficiently run tests and other tasks across multiple workspaces:
@@ -84,7 +78,7 @@ The core application logic that runs on any platform.
```bash
# Run all loot-core tests
yarn workspace @actual-app/core run test
yarn workspace loot-core run test
# Or run tests across all packages using lage
yarn test
@@ -175,7 +169,7 @@ Custom ESLint rules specific to Actual.
- `no-untranslated-strings`: Enforces i18n usage
- `prefer-trans-over-t`: Prefers Trans component over t() function
- `prefer-logger-over-console`: Enforces using logger instead of console in `packages/loot-core/`
- `prefer-logger-over-console`: Enforces using logger instead of console
- `typography`: Typography rules
- `prefer-if-statement`: Prefers explicit if statements
@@ -219,7 +213,7 @@ yarn test
yarn test:debug
# Run tests for a specific package
yarn workspace @actual-app/core run test
yarn workspace loot-core run test
```
**E2E Tests (Playwright)**
@@ -265,10 +259,6 @@ Always run `yarn typecheck` before committing.
- Generate i18n files: `yarn generate:i18n`
- Custom ESLint rules enforce translation usage
### 5. Financial Number Typography
- Wrap standalone financial numbers with `FinancialText` or apply `styles.tnum` directly if wrapping is not possible
## Code Style & Conventions
### TypeScript Guidelines
@@ -298,7 +288,6 @@ Always run `yarn typecheck` before committing.
**React Patterns:**
- The project uses **React Compiler** (`babel-plugin-react-compiler`) in the desktop-client. The compiler auto-memoizes component bodies, so you can omit manual `useCallback`, `useMemo`, and `React.memo` when adding or refactoring code; prefer inline callbacks and values unless a stable identity is required by a non-compiled dependency.
- Don't use `React.FunctionComponent` or `React.FC` - type props directly
- Don't use `React.*` patterns - use named imports instead
- Use `<Link>` instead of `<a>` tags
@@ -339,13 +328,18 @@ Always maintain newlines between import groups.
**Never:**
- Use `console.*` (use logger instead - enforced by ESLint)
- Import from `uuid` without destructuring: use `import { v4 as uuidv4 } from 'uuid'`
- Import colors directly - use theme instead
- Import `@actual-app/web/*` in `loot-core`
**Git Commands:**
See [PR and Commit Rules](.github/agents/pr-and-commit-rules.md) for complete git safety rules, commit message requirements, and PR workflow.
- Never update git config
- Never run destructive git operations (force push, hard reset) unless explicitly requested
- Never skip hooks (--no-verify, --no-gpg-sign)
- Never force push to main/master
- Never commit unless explicitly asked
## File Structure Patterns
@@ -508,7 +502,7 @@ Icons in `packages/component-library/src/icons/` are auto-generated. Don't manua
1. Clean build artifacts: `rm -rf packages/*/dist packages/*/lib-dist packages/*/build`
2. Reinstall dependencies: `yarn install`
3. Check Node.js version (requires >=22)
3. Check Node.js version (requires >=20)
4. Check Yarn version (requires ^4.9.1)
## Testing Patterns
@@ -544,10 +538,10 @@ Icons in `packages/component-library/src/icons/` are auto-generated. Don't manua
Before committing changes, ensure:
- [ ] Commit and PR rules followed (see [PR and Commit Rules](.github/agents/pr-and-commit-rules.md))
- [ ] `yarn typecheck` passes
- [ ] `yarn lint:fix` has been run
- [ ] Relevant tests pass
- [ ] No new console.\* usage (use logger)
- [ ] User-facing strings are translated
- [ ] Prefer `type` over `interface`
- [ ] Named exports used (not default exports)
@@ -557,11 +551,9 @@ Before committing changes, ensure:
## Pull Request Guidelines
See [PR and Commit Rules](.github/agents/pr-and-commit-rules.md) for complete PR creation rules, including title prefix requirements, labeling, and PR template handling.
When creating pull requests:
## Code Review Guidelines
When performing code reviews (especially for LLM agents): **see [CODE_REVIEW_GUIDELINES.md](./CODE_REVIEW_GUIDELINES.md)** for specific guidelines.
- **AI-Generated PRs**: If you create a PR using AI assistance, add the **"AI generated"** label to the pull request. This helps maintainers understand the nature of the contribution.
## Performance Considerations
@@ -588,7 +580,7 @@ yarn install:server
## Environment Requirements
- **Node.js**: >=22
- **Node.js**: >=20
- **Yarn**: ^4.9.1 (managed by packageManager field)
- **Browser Targets**: Electron >= 35.0, modern browsers (see browserslist)
@@ -601,40 +593,3 @@ The codebase is actively being migrated:
- **React.\* → Named Imports**: Legacy React.\* patterns being removed
When working with older code, follow the newer patterns described in this guide.
## Cursor Cloud specific instructions
### Services overview
| Service | Command | Port | Required |
| ------------------- | ----------------------- | ---- | ----------------------------- |
| Web Frontend (Vite) | `yarn start` | 3001 | Yes |
| Sync Server | `yarn start:server-dev` | 5006 | Optional (sync features only) |
All storage is **SQLite** (file-based via `better-sqlite3`). No external databases or services are needed.
### Running the app
- `yarn start` builds the plugins-service worker, loot-core browser backend, and starts the Vite dev server on port **3001**.
- `yarn start:server-dev` starts both the sync server (port 5006) and the web frontend together.
- The Vite HMR dev server serves many unbundled modules. In constrained environments, the browser may hit `ERR_INSUFFICIENT_RESOURCES`. If that happens, use `yarn build:browser` followed by serving the built output from `packages/desktop-client/build/` with proper COOP/COEP headers (`Cross-Origin-Opener-Policy: same-origin`, `Cross-Origin-Embedder-Policy: require-corp`).
### Lint, test, typecheck
Standard commands documented in `package.json` scripts and the Quick Start section above:
- `yarn lint` / `yarn lint:fix` (uses oxlint + oxfmt)
- `yarn test` (lage across all workspaces)
- `yarn typecheck` (tsgo + lage typecheck)
### Testing and previewing the app
When running the app for manual testing or demos, use **"View demo"** on the initial setup screen (after selecting "Don't use a server"). This creates a test budget pre-populated with realistic sample data (accounts, transactions, categories, and budgeted amounts), which is far more useful than starting with an empty budget.
### Gotchas
- The `engines` field requires **Node.js >=22** and **Yarn ^4.9.1**. The `.nvmrc` specifies `v22/*`.
- Pre-commit hook runs `lint-staged` (oxfmt + oxlint) via Husky. Run `yarn prepare` once after install to set up hooks.
- Lage caches test results in `.lage/`. If tests behave unexpectedly, clear with `rm -rf .lage`.
- Native modules (`better-sqlite3`, `bcrypt`) require build tools (`gcc`, `make`, `python3`). These are pre-installed in the Cloud VM.
- All yarn commands must be run from the repository root, never from child workspaces.

View File

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

View File

@@ -1,94 +0,0 @@
# CODE_REVIEW_GUIDELINES.md - Guidelines for LLM Agents Performing Code Reviews
This document provides specific guidelines for LLM agents performing code reviews on the Actual Budget codebase. These guidelines help maintain code quality, consistency, and follow the project's design principles.
## Settings Proliferation
**Do NOT add new settings for every little UI tweak.**
Actual Budget follows a design philosophy that prioritizes simplicity and avoids settings bloat. Before introducing code that adds new settings:
- Consider if the UI tweak can be achieved through existing theme/design tokens
- Evaluate whether the setting provides meaningful value to users
- Check if the change aligns with Actual's design guidelines
- Prefer hardcoded values or theme-based solutions over adding user-facing settings
## TypeScript Strict Mode Suppressions
**Do NOT approve code that adds new `@ts-strict-ignore` comments.**
The project uses strict TypeScript checking via `typescript-strict-plugin`. Adding `@ts-strict-ignore` comments undermines type safety. Instead, review should encourage:
- Fixing the underlying type issue
- Using proper type definitions
- Refactoring code to satisfy strict type checking
- Only in exceptional cases, document why strict checking cannot be applied and seek alternative solutions
## Linter Suppressions
**Do NOT approve code that adds new `eslint-disable` or `oxlint-disable` comments.**
Linter rules are in place for good reasons. Instead of suppressing them:
- Fix the underlying issue
- If the rule is incorrectly flagging valid code, consider if the code can be refactored
- Only approve suppressions if there's a documented, exceptional reason
## Type Assertions
**Prefer `x satisfies SomeType` over `x as SomeType` for type coercions.**
The `satisfies` operator provides better type safety by:
- Ensuring the value actually satisfies the type (narrowing)
- Preserving the actual type information for better inference
- Catching type mismatches at compile time
**Exception:** If you truly need to assert a type that TypeScript cannot verify (e.g., runtime type guards), use `as` but require a comment explaining why it's safe.
## Avoiding `any` and `unknown`
**Flag code that uses `any` or `unknown` unless absolutely necessary.**
The use of `any` or `unknown` should be rare and well-justified. Before approving:
- Require explicit justification for why the type cannot be determined
- Suggest using proper type definitions or generics
- Consider if the type can be narrowed or properly inferred
- Look for existing type definitions in `packages/loot-core/src/types/`
Only approve `any` or `unknown` if there's a documented, exceptional reason (e.g., interop with untyped external libraries, gradual migration).
## Internationalization (i18n)
**All user-facing strings must be translated.**
The project has custom ESLint rules (`actual/no-untranslated-strings`) that enforce i18n usage, but reviewers should actively flag untranslated strings:
- Use `Trans` component instead of `t()` function when possible
- All text visible to users must use i18n functions
- Flag hardcoded strings that should be translated
## Test Mocking
**Minimize mocked dependencies; prefer real implementations.**
When reviewing tests, encourage the use of real implementations over mocks:
- Prefer real dependencies, utilities, and data structures
- Only mock when the real implementation is impractical (e.g., external APIs, file system in unit tests)
- Ensure mocks accurately represent real behavior
Over-mocking makes tests brittle and less reliable. Real implementations provide better confidence that code works correctly.
## Financial Number Typography
Standalone financial numbers should have tabular number styles applied.
- Standalone financial numbers should be wrapped with `FinancialText` or `styles.tnum` should be applied directly if wrapping is not possible
## Related Documentation
- See [AGENTS.md](./AGENTS.md) for general development guidelines
- See [CONTRIBUTING.md](./CONTRIBUTING.md) for contribution guidelines
- Community documentation: [https://actualbudget.org/docs/contributing/](https://actualbudget.org/docs/contributing/)

View File

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

View File

@@ -8,7 +8,6 @@ CI=${CI:-false}
cd "$ROOT/.."
POSITIONAL=()
SKIP_EXE_BUILD=false
SKIP_TRANSLATIONS=false
while [[ $# -gt 0 ]]; do
key="$1"
@@ -21,10 +20,6 @@ while [[ $# -gt 0 ]]; do
SKIP_EXE_BUILD=true
shift
;;
--skip-translations)
SKIP_TRANSLATIONS=true
shift
;;
*)
POSITIONAL+=("$1")
shift
@@ -34,34 +29,27 @@ done
set -- "${POSITIONAL[@]}"
if [ $SKIP_TRANSLATIONS == false ]; then
# Get translations
echo "Updating translations..."
if ! [ -d packages/desktop-client/locale ]; then
git clone https://github.com/actualbudget/translations packages/desktop-client/locale
fi
pushd packages/desktop-client/locale > /dev/null
git pull
popd > /dev/null
packages/desktop-client/bin/remove-untranslated-languages
# Get translations
echo "Updating translations..."
if ! [ -d packages/desktop-client/locale ]; then
git clone https://github.com/actualbudget/translations packages/desktop-client/locale
fi
pushd packages/desktop-client/locale > /dev/null
git pull
popd > /dev/null
packages/desktop-client/bin/remove-untranslated-languages
export NODE_OPTIONS="--max-old-space-size=4096"
yarn workspace plugins-service build
yarn workspace @actual-app/core build:node
yarn workspace loot-core build:node
yarn workspace @actual-app/web build --mode=desktop # electron specific build
# required for running the sync-server server
yarn workspace @actual-app/core build:browser
yarn workspace loot-core build:browser
yarn workspace @actual-app/web build:browser
yarn workspace @actual-app/sync-server build
# Emit @actual-app/core declarations so desktop-electron (which includes typings/window.ts) can build
yarn workspace @actual-app/core exec tsgo -p tsconfig.json
yarn workspace desktop-electron update-client
(

View File

@@ -6,6 +6,7 @@ import prompts from 'prompts';
async function run() {
const username = await execAsync(
// eslint-disable-next-line actual/typography
"gh api user --jq '.login'",
'To avoid having to enter your username, consider installing the official GitHub CLI (https://github.com/cli/cli) and logging in with `gh auth login`.',
);
@@ -37,7 +38,7 @@ async function run() {
choices: [
{ title: '✨ Features', value: 'Features' },
{ title: '👍 Enhancements', value: 'Enhancements' },
{ title: '🐛 Bugfixes', value: 'Bugfixes' },
{ title: '🐛 Bugfix', value: 'Bugfix' },
{ title: '⚙️ Maintenance', value: 'Maintenance' },
],
},
@@ -178,4 +179,4 @@ async function execAsync(cmd: string, errorLog?: string): Promise<string> {
});
}
void run();
run();

View File

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

769
eslint.config.mjs Normal file
View File

@@ -0,0 +1,769 @@
import tsParser from '@typescript-eslint/parser';
import { defineConfig } from 'eslint/config';
import pluginImport from 'eslint-plugin-import';
import pluginJSXA11y from 'eslint-plugin-jsx-a11y';
import oxlint from 'eslint-plugin-oxlint';
import pluginReact from 'eslint-plugin-react';
import pluginReactHooks from 'eslint-plugin-react-hooks';
import pluginTypescriptPaths from 'eslint-plugin-typescript-paths';
import globals from 'globals';
import pluginTypescript from 'typescript-eslint';
// eslint-disable-next-line import/extensions
import pluginActual from './packages/eslint-plugin-actual/lib/index.js';
const confusingBrowserGlobals = [
// https://github.com/facebook/create-react-app/tree/main/packages/confusing-browser-globals
'addEventListener',
'blur',
'close',
'closed',
'confirm',
'defaultStatus',
'defaultstatus',
'event',
'external',
'find',
'focus',
'frameElement',
'frames',
'history',
'innerHeight',
'innerWidth',
'length',
'location',
'locationbar',
'menubar',
'moveBy',
'moveTo',
'name',
'onblur',
'onerror',
'onfocus',
'onload',
'onresize',
'onunload',
'open',
'opener',
'opera',
'outerHeight',
'outerWidth',
'pageXOffset',
'pageYOffset',
'parent',
'print',
'removeEventListener',
'resizeBy',
'resizeTo',
'screen',
'screenLeft',
'screenTop',
'screenX',
'screenY',
'scroll',
'scrollbars',
'scrollBy',
'scrollTo',
'scrollX',
'scrollY',
'status',
'statusbar',
'stop',
'toolbar',
'top',
];
export default defineConfig(
{
ignores: [
//temporary
'packages/docs',
'packages/api/app/bundle.api.js',
'packages/api/app/stats.json',
'packages/api/@types',
'packages/api/migrations',
'packages/component-library/src/icons/**/*',
'packages/desktop-client/bundle.browser.js',
'packages/desktop-client/dev-dist/',
'packages/desktop-client/service-worker/*',
'packages/desktop-client/build-electron/',
'packages/desktop-client/build-stats/',
'packages/desktop-client/public/kcab/',
'packages/desktop-client/public/data/',
'packages/desktop-client/test-results/',
'packages/desktop-client/playwright-report/',
'packages/desktop-electron/client-build/',
'packages/loot-core/**/lib-dist/*',
'packages/loot-core/**/proto/*',
'packages/sync-server/user-files/',
'packages/sync-server/server-files/',
'.yarn/*',
'.github/*',
'**/build/',
'**/dist/',
'**/node_modules/',
],
},
{
// Temporary until the sync-server is migrated to TypeScript
files: [
'packages/sync-server/**/*.spec.{js,jsx}',
'packages/sync-server/**/*.test.{js,jsx}',
],
languageOptions: {
globals: {
vi: true,
describe: true,
expect: true,
it: true,
beforeAll: true,
beforeEach: true,
afterAll: true,
afterEach: true,
test: true,
},
},
},
{
linterOptions: {
reportUnusedDisableDirectives: true,
},
languageOptions: {
globals: {
...globals.browser,
...globals.commonjs,
...globals.node,
globalThis: false,
vi: true,
},
},
settings: {
react: {
version: 'detect',
},
'import/resolver': {
typescript: {
alwaysTryTypes: true,
},
},
},
},
pluginReact.configs.flat.recommended,
pluginReact.configs.flat['jsx-runtime'],
pluginTypescript.configs.recommended,
pluginImport.flatConfigs.recommended,
{
plugins: {
actual: pluginActual,
},
rules: {
'actual/no-untranslated-strings': 'error',
'actual/prefer-trans-over-t': 'error',
},
},
{
files: ['**/*.{js,ts,jsx,tsx,mjs,mts}'],
plugins: {
'jsx-a11y': pluginJSXA11y,
'react-hooks': pluginReactHooks,
},
rules: {
// http://eslint.org/docs/rules/
'array-callback-return': 'warn',
'default-case': [
'warn',
{
commentPattern: '^no default$',
},
],
curly: ['warn', 'multi-line', 'consistent'],
'dot-location': ['warn', 'property'],
eqeqeq: ['warn', 'smart'],
'new-parens': 'warn',
'no-array-constructor': 'warn',
'no-caller': 'warn',
'no-cond-assign': ['warn', 'except-parens'],
'no-const-assign': 'warn',
'no-control-regex': 'warn',
'no-delete-var': 'warn',
'no-dupe-args': 'warn',
'no-dupe-class-members': 'warn',
'no-dupe-keys': 'warn',
'no-duplicate-case': 'warn',
'no-empty-character-class': 'warn',
'no-empty-pattern': 'warn',
'no-eval': 'warn',
'no-ex-assign': 'warn',
'no-extend-native': 'warn',
'no-extra-bind': 'warn',
'no-extra-label': 'warn',
'no-fallthrough': 'warn',
'no-func-assign': 'warn',
'no-implied-eval': 'warn',
'no-invalid-regexp': 'warn',
'no-iterator': 'warn',
'no-label-var': 'warn',
'no-labels': [
'warn',
{
allowLoop: true,
allowSwitch: false,
},
],
'no-lone-blocks': 'warn',
'no-mixed-operators': [
'warn',
{
groups: [
['&', '|', '^', '~', '<<', '>>', '>>>'],
['==', '!=', '===', '!==', '>', '>=', '<', '<='],
['&&', '||'],
['in', 'instanceof'],
],
allowSamePrecedence: false,
},
],
'no-multi-str': 'warn',
'no-global-assign': 'warn',
'no-unsafe-negation': 'warn',
'no-new-func': 'warn',
'no-new-object': 'warn',
'no-new-symbol': 'warn',
'no-new-wrappers': 'warn',
'no-obj-calls': 'warn',
'no-octal': 'warn',
'no-octal-escape': 'warn',
'no-redeclare': 'warn',
'no-regex-spaces': 'warn',
'no-script-url': 'warn',
'no-self-assign': 'warn',
'no-self-compare': 'warn',
'no-sequences': 'warn',
'no-shadow-restricted-names': 'warn',
'no-sparse-arrays': 'warn',
'no-template-curly-in-string': 'warn',
'no-this-before-super': 'warn',
'no-throw-literal': 'warn',
'no-undef': 'error',
'no-unreachable': 'warn',
'no-unused-expressions': [
'error',
{
allowShortCircuit: true,
allowTernary: true,
allowTaggedTemplates: true,
},
],
'no-unused-labels': 'warn',
'no-use-before-define': [
'warn',
{
functions: false,
classes: false,
variables: false,
},
],
'no-useless-computed-key': 'warn',
'no-useless-concat': 'warn',
'no-useless-constructor': 'warn',
'no-useless-escape': 'warn',
'no-useless-rename': [
'warn',
{
ignoreDestructuring: false,
ignoreImport: false,
ignoreExport: false,
},
],
'no-with': 'warn',
'no-whitespace-before-property': 'warn',
'require-yield': 'warn',
'rest-spread-spacing': ['warn', 'never'],
strict: ['warn', 'never'],
'unicode-bom': ['warn', 'never'],
'use-isnan': 'warn',
'valid-typeof': 'warn',
'no-restricted-properties': [
'error',
{
object: 'require',
property: 'ensure',
message:
'Please use import() instead. More info: https://facebook.github.io/create-react-app/docs/code-splitting',
},
{
object: 'System',
property: 'import',
message:
'Please use import() instead. More info: https://facebook.github.io/create-react-app/docs/code-splitting',
},
],
'getter-return': 'warn',
// https://github.com/benmosher/eslint-plugin-import/tree/master/docs/rules
'import/first': 'error',
'import/no-amd': 'error',
'import/no-anonymous-default-export': 'warn',
'import/no-webpack-loader-syntax': 'error',
'import/extensions': [
'warn',
'never',
{
json: 'always',
},
],
'import/no-useless-path-segments': 'warn',
'import/no-duplicates': [
'warn',
{
'prefer-inline': true,
},
],
'import/order': [
'warn',
{
alphabetize: {
caseInsensitive: true,
order: 'asc',
},
groups: ['builtin', 'external', 'parent', 'sibling', 'index'],
'newlines-between': 'always',
pathGroups: [
{
// Enforce that React (and react-related packages) is the first import
group: 'builtin',
pattern: 'react?(-*)',
position: 'before',
},
{
// Separate imports from Actual from "real" external imports
group: 'external',
pattern: 'loot-{core,design}/**/*',
position: 'after',
},
],
pathGroupsExcludedImportTypes: ['react'],
},
],
// https://github.com/yannickcr/eslint-plugin-react/tree/master/docs/rules
'react/forbid-foreign-prop-types': [
'warn',
{
allowInPropTypes: true,
},
],
'react/jsx-no-comment-textnodes': 'warn',
'react/jsx-no-duplicate-props': 'warn',
'react/jsx-no-target-blank': 'warn',
'react/jsx-no-undef': 'error',
'react/jsx-pascal-case': [
'warn',
{
allowAllCaps: true,
ignore: [],
},
],
'react/no-danger-with-children': 'warn',
// Disabled because of undesirable warnings
// See https://github.com/facebook/create-react-app/issues/5204 for
// blockers until its re-enabled
// 'react/no-deprecated': 'warn',
'react/no-direct-mutation-state': 'warn',
'react/no-is-mounted': 'warn',
'react/no-typos': 'error',
'react/require-render-return': 'error',
'react/style-prop-object': 'warn',
'react/jsx-no-useless-fragment': 'warn',
'react/self-closing-comp': 'warn',
'react/jsx-filename-extension': [
'warn',
{
extensions: ['.jsx', '.tsx'],
allow: 'as-needed',
},
],
'react/no-unstable-nested-components': [
'warn',
{
allowAsProps: true,
customValidators: ['formatter'],
},
],
// Don't need this as we're using TypeScript
'react/prop-types': 'off',
// https://github.com/evcohen/eslint-plugin-jsx-a11y/tree/master/docs/rules
'jsx-a11y/alt-text': 'warn',
'jsx-a11y/anchor-has-content': 'warn',
'jsx-a11y/anchor-is-valid': [
'warn',
{
aspects: ['noHref', 'invalidHref'],
},
],
'jsx-a11y/aria-activedescendant-has-tabindex': 'warn',
'jsx-a11y/aria-props': 'warn',
'jsx-a11y/aria-proptypes': 'warn',
'jsx-a11y/aria-role': [
'warn',
{
ignoreNonDOM: true,
},
],
'jsx-a11y/aria-unsupported-elements': 'warn',
'jsx-a11y/heading-has-content': 'warn',
'jsx-a11y/iframe-has-title': 'warn',
'jsx-a11y/img-redundant-alt': 'warn',
'jsx-a11y/no-access-key': 'warn',
'jsx-a11y/no-distracting-elements': 'warn',
'jsx-a11y/no-redundant-roles': 'warn',
'jsx-a11y/role-has-required-aria-props': 'warn',
'jsx-a11y/role-supports-aria-props': 'warn',
'jsx-a11y/scope': 'warn',
// https://github.com/facebook/react/tree/main/packages/eslint-plugin-react-hooks
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': [
'warn',
{
additionalHooks: '(useQuery|useEffectAfterMount)',
},
],
'actual/typography': 'warn',
'actual/prefer-if-statement': 'warn',
'actual/prefer-logger-over-console': 'error',
// Note: base rule explicitly disabled in favor of the TS one
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
varsIgnorePattern: '^(_|React)',
argsIgnorePattern: '^(_|React)',
ignoreRestSiblings: true,
caughtErrors: 'none',
},
],
'no-restricted-globals': ['warn', ...confusingBrowserGlobals],
// https://github.com/eslint/eslint/issues/16954
// https://github.com/eslint/eslint/issues/16953
'no-loop-func': 'off',
// TODO: re-enable these rules
'react/react-in-jsx-scope': 'off',
'no-var': 'warn',
'react/jsx-curly-brace-presence': 'warn',
'object-shorthand': ['warn', 'properties'],
'no-restricted-syntax': [
'warn',
{
// forbid React.* as they are legacy https://twitter.com/dan_abramov/status/1308739731551858689
selector:
":matches(MemberExpression[object.name='React'], TSQualifiedName[left.name='React'])",
message:
'Using default React import is discouraged, please use named exports directly instead.',
},
{
// forbid <a> in favor of <Link>
selector: 'JSXOpeningElement[name.name="a"]',
message: 'Using <a> is discouraged, please use <Link> instead.',
},
],
'no-restricted-imports': [
'warn',
{
paths: [
{
name: 'react-router',
importNames: ['useNavigate'],
message:
"Please import Actual's useNavigate() hook from `src/hooks` instead.",
},
{
name: 'react-redux',
importNames: ['useDispatch'],
message:
"Please import Actual's useDispatch() hook from `src/redux` instead.",
},
{
name: 'react-redux',
importNames: ['useSelector'],
message:
"Please import Actual's useSelector() hook from `src/redux` instead.",
},
{
name: 'react-redux',
importNames: ['useStore'],
message:
"Please import Actual's useStore() hook from `src/redux` instead.",
},
],
patterns: [
{
group: ['*.api', '*.web', '*.electron'],
message: "Don't directly reference imports from other platforms",
},
{
group: ['uuid'],
importNames: ['*'],
message: "Use `import { v4 as uuidv4 } from 'uuid'` instead",
},
{
group: ['**/style', '**/colors'],
importNames: ['colors'],
message: 'Please use themes instead of colors',
},
{
group: ['@actual-app/web/*'],
message: 'Please do not import `@actual-app/web` in `loot-core`',
},
],
},
],
'@typescript-eslint/ban-ts-comment': [
'error',
{
'ts-ignore': 'allow-with-description',
},
],
// Rules disabled during TS migration
'@typescript-eslint/no-var-requires': 'off',
'prefer-const': 'warn',
'prefer-spread': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-require-imports': 'off',
'import/no-default-export': 'warn',
},
},
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: tsParser,
ecmaVersion: 2018,
sourceType: 'module',
parserOptions: {
projectService: true,
ecmaFeatures: {
jsx: true,
},
// typescript-eslint specific options
warnOnUnsupportedTypeScriptVersion: true,
},
},
// If adding a typescript-eslint version of an existing ESLint rule,
// make sure to disable the ESLint rule here.
rules: {
// TypeScript's `noFallthroughCasesInSwitch` option is more robust (#6906)
'default-case': 'off',
// 'tsc' already handles this (https://github.com/typescript-eslint/typescript-eslint/issues/291)
'no-dupe-class-members': 'off',
// 'tsc' already handles this (https://github.com/typescript-eslint/typescript-eslint/issues/477)
'no-undef': 'off',
// TypeScript already handles these (https://typescript-eslint.io/troubleshooting/typed-linting/performance/#eslint-plugin-import)
'import/named': 'off',
'import/namespace': 'off',
'import/default': 'off',
'import/no-named-as-default-member': 'off',
'import/no-unresolved': 'off',
// Add TypeScript specific rules (and turn off ESLint equivalents)
'@typescript-eslint/consistent-type-assertions': 'warn',
'no-array-constructor': 'off',
'@typescript-eslint/no-array-constructor': 'warn',
'no-redeclare': 'off',
'@typescript-eslint/no-redeclare': 'warn',
'no-use-before-define': 'off',
'@typescript-eslint/no-use-before-define': [
'warn',
{
functions: false,
classes: false,
variables: false,
typedefs: false,
},
],
'no-unused-expressions': 'off',
'@typescript-eslint/no-unused-expressions': [
'error',
{
allowShortCircuit: true,
allowTernary: true,
allowTaggedTemplates: true,
},
],
'no-useless-constructor': 'off',
'@typescript-eslint/no-useless-constructor': 'warn',
},
},
{
files: ['packages/desktop-client/**/*.{js,ts,jsx,tsx}'],
plugins: {
'typescript-paths': pluginTypescriptPaths,
},
rules: {
'typescript-paths/absolute-parent-import': [
'error',
{ preferPathOverBaseUrl: true },
],
'typescript-paths/absolute-import': ['error', { enableAlias: false }],
},
},
{
files: [
'packages/desktop-client/**/*.{ts,tsx}',
'packages/loot-core/src/client/**/*.{ts,tsx}',
],
rules: {
// enforce import type
'@typescript-eslint/consistent-type-imports': [
'warn',
{
prefer: 'type-imports',
fixStyle: 'inline-type-imports',
},
],
'@typescript-eslint/no-restricted-types': [
'warn',
{
types: {
// forbid FC as superfluous
FunctionComponent: {
message:
'Type the props argument and let TS infer or use ComponentType for a component prop',
},
FC: {
message:
'Type the props argument and let TS infer or use ComponentType for a component prop',
},
},
},
],
},
},
{
files: [
'packages/loot-core/src/types/**/*',
'packages/loot-core/src/client/state-types/**/*',
'**/icons/**/*',
'**/{mocks,__mocks__}/**/*',
// can't correctly resolve usages
'**/*.{testing,electron,browser,web,api}.ts',
],
rules: {
'import/no-unused-modules': 'off',
},
},
{
files: ['packages/api/migrations/*', 'packages/loot-core/migrations/*'],
rules: {
'import/no-default-export': 'off',
},
},
{
files: ['packages/api/index.ts'],
rules: {
'import/no-unresolved': 'off',
},
},
// Allow configuring vitest with default exports (recommended as per vitest docs)
{
files: [
'**/vitest.config.{ts,mts}',
'**/vitest.web.config.ts',
'**/vite.config.{ts,mts}',
'eslint.config.mjs',
],
rules: {
'import/no-anonymous-default-export': 'off',
'import/no-default-export': 'off',
},
},
{
files: [
'eslint.config.mjs',
'**/*.test.js',
'**/*.test.ts',
'**/*.test.jsx',
'**/*.test.tsx',
'**/*.spec.js',
],
rules: {
'actual/typography': 'off',
'actual/no-untranslated-strings': 'off',
'actual/prefer-logger-over-console': 'off',
},
},
{
files: [
'packages/desktop-client/**/*.{ts,tsx}',
'packages/loot-core/src/client/**/*.{ts,tsx}',
],
ignores: ['**/**/globals.d.ts'],
rules: {
// enforce type over interface
'@typescript-eslint/consistent-type-definitions': ['warn', 'type'],
},
},
{
files: ['packages/sync-server/**/*'],
// TODO: fix the issues in these files
rules: {
'actual/typography': 'off',
},
},
{
files: ['packages/sync-server/src/app-gocardless/banks/*.js'],
rules: {
'import/no-anonymous-default-export': 'off',
'import/no-default-export': 'off',
},
},
// Disable ESLint rules that are already covered by oxlint
// This must be at the end to override previous rule configurations
...oxlint.configs['flat/recommended'],
);

View File

@@ -1,10 +1,6 @@
/** @type {import('lage').ConfigOptions} */
module.exports = {
pipeline: {
typecheck: {
type: 'npmScript',
dependsOn: ['^typecheck'],
},
test: {
type: 'npmScript',
options: {
@@ -17,7 +13,6 @@ module.exports = {
},
build: {
type: 'npmScript',
dependsOn: ['^build'],
cache: true,
options: {
outputGlob: ['lib-dist/**', 'dist/**', 'build/**'],

View File

@@ -7,11 +7,11 @@
"bugs": {
"url": "https://github.com/actualbudget/actual/issues/"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git@github.com:actualbudget/actual.git"
},
"license": "MIT",
"workspaces": {
"packages": [
"packages/*"
@@ -25,92 +25,91 @@
"start:desktop": "yarn desktop-dependencies && npm-run-all --parallel 'start:desktop-*'",
"start:docs": "yarn workspace docs start",
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:browser-backend build:plugins-service",
"start:desktop-node": "yarn workspace @actual-app/core watch:node",
"start:desktop-node": "yarn workspace loot-core watch:node",
"start:desktop-client": "yarn workspace @actual-app/web watch",
"start:desktop-server-client": "yarn workspace @actual-app/web build:browser",
"start:desktop-electron": "yarn workspace desktop-electron watch",
"start:browser": "yarn workspace plugins-service build-dev && npm-run-all --parallel 'start:browser-*'",
"start:service-plugins": "yarn workspace plugins-service watch",
"start:browser-backend": "yarn workspace @actual-app/core watch:browser",
"start:browser-backend": "yarn workspace loot-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:browser-backend": "yarn workspace loot-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",
"generate:i18n": "yarn workspace @actual-app/web generate:i18n",
"generate:release-notes": "ts-node ./bin/release-note-generator.ts",
"test": "lage test --continue",
"test:debug": "lage test --no-cache --continue",
"e2e": "yarn workspace @actual-app/web run e2e",
"e2e:desktop": "yarn build:desktop --skip-exe-build --skip-translations && yarn workspace desktop-electron e2e",
"e2e:desktop": "yarn build:desktop --skip-exe-build && yarn workspace desktop-electron e2e",
"playwright": "yarn workspace @actual-app/web run playwright",
"vrt": "yarn workspace @actual-app/web run vrt",
"vrt:docker": "./bin/run-vrt",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core",
"rebuild-node": "yarn workspace @actual-app/core rebuild",
"lint": "oxfmt --check . && oxlint --type-aware --quiet",
"lint:fix": "oxfmt . && oxlint --fix --type-aware --quiet",
"rebuild-node": "yarn workspace loot-core rebuild",
"lint": "oxfmt --check . && oxlint --deny-warnings && eslint . --max-warnings 0",
"lint:fix": "oxfmt . && oxlint --deny-warnings --fix && eslint . --max-warnings 0 --fix",
"install:server": "yarn workspaces focus @actual-app/sync-server --production",
"constraints": "yarn constraints",
"typecheck": "tsgo -p tsconfig.root.json --noEmit && lage typecheck",
"typecheck": "yarn tsc --incremental && tsc-strict",
"jq": "./node_modules/node-jq/bin/jq",
"prepare": "husky"
},
"devDependencies": {
"@octokit/rest": "^22.0.1",
"@types/node": "^22.19.15",
"@types/node": "^22.19.1",
"@types/prompts": "^2.4.9",
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"@yarnpkg/types": "^4.0.1",
"baseline-browser-mapping": "^2.10.0",
"@typescript-eslint/parser": "^8.46.4",
"cross-env": "^10.1.0",
"eslint": "^9.39.3",
"eslint-plugin-perfectionist": "^5.6.0",
"eslint": "^9.39.1",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-oxlint": "^1.30.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-typescript-paths": "^0.0.33",
"globals": "^16.5.0",
"html-to-image": "^1.11.13",
"husky": "^9.1.7",
"lage": "^2.14.19",
"lint-staged": "^16.3.2",
"minimatch": "^10.2.4",
"lage": "^2.14.15",
"lint-staged": "^16.2.6",
"minimatch": "^10.1.1",
"node-jq": "^6.3.1",
"npm-run-all": "^4.1.5",
"oxfmt": "^0.32.0",
"oxlint": "^1.51.0",
"oxlint-tsgolint": "^0.13.0",
"p-limit": "^7.3.0",
"oxfmt": "^0.15.0",
"oxlint": "^1.30.0",
"p-limit": "^7.2.0",
"prompts": "^2.4.2",
"source-map-support": "^0.5.21",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.4",
"typescript-strict-plugin": "^2.4.4"
},
"resolutions": {
"adm-zip": "patch:adm-zip@npm%3A0.5.16#~/.yarn/patches/adm-zip-npm-0.5.16-4556fea098.patch",
"rollup": "4.40.1",
"socks": ">=2.8.3"
},
"engines": {
"node": ">=22",
"yarn": "^4.9.1"
},
"lint-staged": {
"*.{js,mjs,jsx,ts,tsx,md,json,yml,yaml}": [
"oxfmt --no-error-on-unmatched-pattern"
],
"*.{js,mjs,jsx,ts,tsx}": [
"oxlint --fix --type-aware --quiet"
"oxlint --deny-warnings --fix",
"eslint --max-warnings 0 --fix --no-warn-ignored"
]
},
"packageManager": "yarn@4.10.3",
"browserslist": [
"electron >= 35.0",
"defaults"
],
"engines": {
"node": ">=22",
"yarn": "^4.9.1"
},
"packageManager": "yarn@4.10.3"
]
}

View File

@@ -1,7 +1,4 @@
class Query {
/** @type {import('loot-core/shared/query').QueryState} */
state;
constructor(state) {
this.state = {
filterExpressions: state.filterExpressions || [],
@@ -100,14 +97,6 @@ class Query {
serialize() {
return this.state;
}
reset() {
return q(this.state.table);
}
serializeAsString() {
return JSON.stringify(this.serialize());
}
}
export function q(table) {

View File

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

7
packages/api/injected.js Normal file
View File

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

View File

@@ -1,29 +1,10 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import { vi } from 'vitest';
import type { RuleEntity } from '@actual-app/core/types/models';
import { type RuleEntity } from 'loot-core/types/models';
import * as api from './index';
// In tests we run from source; loot-core's API fs uses __dirname (for the built dist/).
// Mock the fs so path constants point at loot-core package root where migrations live.
vi.mock(
'../loot-core/src/platform/server/fs/index.api',
async importOriginal => {
const actual = (await importOriginal()) as Record<string, unknown>;
const pathMod = await import('path');
const lootCoreRoot = pathMod.join(__dirname, '..', 'loot-core');
return {
...actual,
migrationsPath: pathMod.join(lootCoreRoot, 'migrations'),
bundledDatabasePath: pathMod.join(lootCoreRoot, 'default-db.sqlite'),
demoBudgetPath: pathMod.join(lootCoreRoot, 'demo-budget'),
};
},
);
const budgetName = 'test-budget';
global.IS_TESTING = true;
@@ -375,143 +356,6 @@ describe('API CRUD operations', () => {
);
});
// apis: createTag, getTags, updateTag, deleteTag
test('Tags: successfully complete tag operations', async () => {
// Create tags
const tagId1 = await api.createTag({ tag: 'test-tag1', color: '#ff0000' });
const tagId2 = await api.createTag({
tag: 'test-tag2',
description: 'A test tag',
});
let tags = await api.getTags();
expect(tags).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: tagId1,
tag: 'test-tag1',
color: '#ff0000',
}),
expect.objectContaining({
id: tagId2,
tag: 'test-tag2',
description: 'A test tag',
}),
]),
);
// Update tag
await api.updateTag(tagId1, { tag: 'updated-tag', color: '#00ff00' });
tags = await api.getTags();
expect(tags).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: tagId1,
tag: 'updated-tag',
color: '#00ff00',
}),
]),
);
// Delete tag
await api.deleteTag(tagId2);
tags = await api.getTags();
expect(tags).not.toEqual(
expect.arrayContaining([expect.objectContaining({ id: tagId2 })]),
);
});
test('Tags: create tag with minimal fields', async () => {
const tagId = await api.createTag({ tag: 'minimal-tag' });
const tags = await api.getTags();
expect(tags).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: tagId,
tag: 'minimal-tag',
color: null,
description: null,
}),
]),
);
});
test('Tags: update single field only', async () => {
const tagId = await api.createTag({ tag: 'original', color: '#ff0000' });
// Update only color, tag and description should remain unchanged
await api.updateTag(tagId, { color: '#00ff00' });
const tags = await api.getTags();
expect(tags).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: tagId,
tag: 'original',
color: '#00ff00',
description: null,
}),
]),
);
});
test('Tags: handle null values correctly', async () => {
const tagId = await api.createTag({
tag: 'with-nulls',
color: null,
description: null,
});
const tags = await api.getTags();
expect(tags).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: tagId,
color: null,
description: null,
}),
]),
);
});
test('Tags: clear optional field', async () => {
const tagId = await api.createTag({
tag: 'clearable',
color: '#ff0000',
description: 'will be cleared',
});
// Clear color by setting to null
await api.updateTag(tagId, { color: null });
let tags = await api.getTags();
expect(tags).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: tagId,
tag: 'clearable',
color: null,
description: 'will be cleared',
}),
]),
);
// Clear description by setting to null
await api.updateTag(tagId, { description: null });
tags = await api.getTags();
expect(tags).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: tagId,
tag: 'clearable',
color: null,
description: null,
}),
]),
);
});
// apis: getRules, getPayeeRules, createRule, updateRule, deleteRule
test('Rules: successfully update rules', async () => {
await api.createPayee({ name: 'test-payee' });
@@ -896,73 +740,6 @@ 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

View File

@@ -5,17 +5,16 @@ import type {
APIFileEntity,
APIPayeeEntity,
APIScheduleEntity,
APITagEntity,
} from '@actual-app/core/server/api-models';
import { lib } from '@actual-app/core/server/main';
import type { Query } from '@actual-app/core/shared/query';
import type { ImportTransactionsOpts } from '@actual-app/core/types/api-handlers';
import type { Handlers } from '@actual-app/core/types/handlers';
} from 'loot-core/server/api-models';
import type { Query } from 'loot-core/shared/query';
import type { Handlers } from 'loot-core/types/handlers';
import type {
ImportTransactionEntity,
RuleEntity,
TransactionEntity,
} from '@actual-app/core/types/models';
} from 'loot-core/types/models';
import * as injected from './injected';
export { q } from './app/query';
@@ -23,7 +22,7 @@ function send<K extends keyof Handlers, T extends Handlers[K]>(
name: K,
args?: Parameters<T>[0],
): Promise<Awaited<ReturnType<T>>> {
return lib.send(name, args);
return injected.send(name, args);
}
export async function runImport(
@@ -126,6 +125,11 @@ export function addTransactions(
});
}
export interface ImportTransactionsOpts {
defaultCleared?: boolean;
dryRun?: boolean;
}
export function importTransactions(
accountId: APIAccountEntity['id'],
transactions: ImportTransactionEntity[],
@@ -270,25 +274,6 @@ export function deletePayee(id: APIPayeeEntity['id']) {
return send('api/payee-delete', { id });
}
export function getTags() {
return send('api/tags-get');
}
export function createTag(tag: Omit<APITagEntity, 'id'>) {
return send('api/tag-create', { tag });
}
export function updateTag(
id: APITagEntity['id'],
fields: Partial<Omit<APITagEntity, 'id'>>,
) {
return send('api/tag-update', { id, fields });
}
export function deleteTag(id: APITagEntity['id']) {
return send('api/tag-delete', { id });
}
export function mergePayees(
targetId: APIPayeeEntity['id'],
mergeIds: APIPayeeEntity['id'][],

View File

@@ -1,51 +1,37 @@
{
"name": "@actual-app/api",
"version": "26.3.0",
"description": "An API for Actual",
"version": "25.12.0",
"license": "MIT",
"files": [
"@types",
"dist"
],
"description": "An API for Actual",
"engines": {
"node": ">=20"
},
"main": "dist/index.js",
"types": "@types/index.d.ts",
"exports": {
".": {
"development": "./index.ts",
"default": "./dist/index.js"
}
},
"publishConfig": {
"exports": {
".": {
"types": "./@types/index.d.ts",
"default": "./dist/index.js"
}
}
},
"files": [
"dist",
"@types"
],
"scripts": {
"build": "vite build",
"test": "vitest --run",
"typecheck": "tsgo -b && tsc-strict"
"build:app": "yarn workspace loot-core build:api",
"build:crdt": "yarn workspace @actual-app/crdt build",
"build:node": "tsc --p tsconfig.dist.json && tsc-alias -p tsconfig.dist.json",
"build:migrations": "cp migrations/*.sql dist/migrations",
"build:default-db": "cp default-db.sqlite dist/",
"build": "yarn run clean && yarn run build:app && yarn run build:node && yarn run build:migrations && yarn run build:default-db",
"test": "yarn run build:app && yarn run build:crdt && vitest --run",
"clean": "rm -rf dist @types"
},
"dependencies": {
"@actual-app/core": "workspace:*",
"@actual-app/crdt": "workspace:*",
"better-sqlite3": "^12.6.2",
"@actual-app/crdt": "workspace:^",
"better-sqlite3": "^12.4.1",
"compare-versions": "^6.1.1",
"node-fetch": "^3.3.2",
"uuid": "^13.0.0"
},
"devDependencies": {
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"rollup-plugin-visualizer": "^6.0.11",
"typescript-strict-plugin": "^2.4.4",
"vite": "^8.0.0",
"vite-plugin-dts": "^4.5.4",
"vite-plugin-peggy-loader": "^2.0.1",
"vitest": "^4.1.0"
},
"engines": {
"node": ">=20"
"tsc-alias": "^1.8.16",
"typescript": "^5.9.3",
"vitest": "^4.0.9"
}
}

View File

@@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
// Using ES2021 because thats the newest version where
// the latest Node 16.x release supports all of the features
"target": "ES2021",
"module": "CommonJS",
"moduleResolution": "node10",
"noEmit": false,
"declaration": true,
"outDir": "dist",
"declarationDir": "@types",
"paths": {
"loot-core/*": ["./@types/loot-core/src/*"]
}
},
"include": ["."],
"exclude": ["**/node_modules/*", "dist", "@types", "*.test.ts"]
}

View File

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

View File

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

View File

@@ -1 +0,0 @@
declare module '*.pegjs';

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,7 +22,7 @@ function parseRawArgs(argv) {
if (!key?.startsWith('--')) {
throw new Error(
`Unexpected argument "${key ?? ''}". Use --key value pairs.`,
`Unexpected argument ${key ?? ''}. Use --key value pairs.`,
);
}
@@ -34,7 +34,7 @@ function parseRawArgs(argv) {
}
if (values.length === 0) {
throw new Error(`Missing value for argument "${key}".`);
throw new Error(`Missing value for argument ${key}.`);
}
const keyName = key.slice(2);
@@ -55,14 +55,14 @@ function getSingleValue(args, key) {
return undefined;
}
if (values.length !== 1) {
throw new Error(`Argument "--${key}" must have exactly one value.`);
throw new Error(`Argument --${key} must have exactly one value.`);
}
return values[0];
}
function parseMapping(values, key, description) {
if (!values || values.length === 0) {
throw new Error(`Missing required argument "--${key}" (${description}).`);
throw new Error(`Missing required argument --${key} (${description}).`);
}
if (values.length === 1) {
@@ -81,7 +81,7 @@ function parseMapping(values, key, description) {
Object.entries(parsed).map(([name, pathValue]) => {
if (typeof pathValue !== 'string') {
throw new Error(
`Value for "${name}" in "--${key}" must be a string path.`,
`Value for ${name} in --${key} must be a string path.`,
);
}
return [name, pathValue];
@@ -91,7 +91,7 @@ function parseMapping(values, key, description) {
const message =
error instanceof Error ? error.message : 'Unknown parsing error';
throw new Error(
`Failed to parse "--${key}" value as JSON object: ${message}`,
`Failed to parse --${key} value as JSON object: ${message}`,
);
}
}
@@ -104,7 +104,7 @@ function parseMapping(values, key, description) {
if (!rawName || rawPathParts.length === 0) {
throw new Error(
`Argument "--${key}" must be provided as name=path pairs or a JSON object.`,
`Argument --${key} must be provided as name=path pairs or a JSON object.`,
);
}
@@ -112,12 +112,12 @@ function parseMapping(values, key, description) {
const pathValue = rawPathParts.join('=').trim();
if (!name) {
throw new Error(`Argument "--${key}" contains an empty bundle name.`);
throw new Error(`Argument --${key} contains an empty bundle name.`);
}
if (!pathValue) {
throw new Error(
`Argument "--${key}" for bundle "${name}" must include a non-empty path.`,
`Argument --${key} for bundle ${name} must include a non-empty path.`,
);
}
@@ -125,7 +125,7 @@ function parseMapping(values, key, description) {
}
if (entries.size === 0) {
throw new Error(`Argument "--${key}" must define at least one bundle.`);
throw new Error(`Argument --${key} must define at least one bundle.`);
}
return entries;
@@ -152,7 +152,7 @@ function parseArgs(argv) {
if (!headPath) {
throw new Error(
`Bundle "${name}" is missing a corresponding "--head" entry.`,
`Bundle ${name} is missing a corresponding --head entry.`,
);
}
@@ -166,7 +166,7 @@ function parseArgs(argv) {
for (const name of headMap.keys()) {
if (!baseMap.has(name)) {
throw new Error(
`Bundle "${name}" is missing a corresponding "--base" entry.`,
`Bundle ${name} is missing a corresponding --base entry.`,
);
}
}
@@ -174,7 +174,6 @@ function parseArgs(argv) {
return {
sections,
identifier: getSingleValue(args, 'identifier') ?? 'bundle-stats',
format: getSingleValue(args, 'format') ?? 'pr-body',
};
}
@@ -195,8 +194,8 @@ async function loadStats(filePath) {
error instanceof Error
? error.message
: 'Unknown error while parsing stats file';
console.error(`[bundle-stats] Failed to parse "${filePath}": ${message}`);
throw new Error(`Failed to load stats file "${filePath}": ${message}`);
console.error(`[bundle-stats] Failed to parse ${filePath}: ${message}`);
throw new Error(`Failed to load stats file ${filePath}: ${message}`);
}
}
@@ -464,12 +463,6 @@ const TOTAL_HEADERS = makeHeader([
'Total bundle size',
'% Changed',
]);
const SUMMARY_HEADERS = makeHeader([
'Bundle',
'Files count',
'Total bundle size',
'% Changed',
]);
const TABLE_HEADERS = makeHeader(['Asset', 'File Size', '% Changed']);
const CHUNK_TABLE_HEADERS = makeHeader(['File', 'Δ', 'Size']);
@@ -603,24 +596,6 @@ function printTotalAssetTable(statsDiff) {
return `**Total**\n${TOTAL_HEADERS}\n${printAssetTableRow(statsDiff.total)}`;
}
function printSummaryTable(sections) {
if (sections.length === 0) {
return `${SUMMARY_HEADERS}\nNo bundle stats were generated.`;
}
const rows = sections.map(section => {
const total = section.statsDiff.total;
return [
section.name,
total.name,
toFileSizeDiffCell(total),
conditionalPercentage(total.diffPercentage),
].join(' | ');
});
return `${SUMMARY_HEADERS}\n${rows.join('\n')}`;
}
function renderSection(title, statsDiff, chunkModuleDiff) {
const { total, ...groups } = statsDiff;
const parts = [`#### ${title}`, '', printTotalAssetTable({ total })];
@@ -640,30 +615,8 @@ function renderSection(title, statsDiff, chunkModuleDiff) {
return parts.join('\n');
}
function renderSections(sections) {
return sections
.map(section =>
renderSection(section.name, section.statsDiff, section.chunkDiff),
)
.join('\n\n---\n\n');
}
function getIdentifierMarkers(key) {
const label = 'bundlestats-action-comment';
return {
start: `<!--- ${label} key:${key} start --->`,
end: `<!--- ${label} key:${key} end --->`,
};
}
async function main() {
const args = parseArgs(process.argv);
const allowedFormats = new Set(['comment', 'pr-body']);
if (!allowedFormats.has(args.format)) {
throw new Error(
`Invalid format "${args.format}". Use "comment" or "pr-body".`,
);
}
console.error(
`[bundle-stats] Found ${args.sections.length} sections to process`,
@@ -701,29 +654,22 @@ async function main() {
});
}
const markers = getIdentifierMarkers(args.identifier);
const sectionsContent = renderSections(sections);
const summaryTable = printSummaryTable(sections);
const identifier = `<!--- bundlestats-action-comment key:${args.identifier} --->`;
const detailedBody = ['### Bundle Stats', '', sectionsContent].join('\n');
const commentBody = [markers.start, detailedBody, '', markers.end, ''].join(
'\n',
);
const prBody = [
markers.start,
const comment = [
'### Bundle Stats',
'',
summaryTable,
sections
.map(section =>
renderSection(section.name, section.statsDiff, section.chunkDiff),
)
.join('\n\n---\n\n'),
'',
`<details>\n<summary>View detailed bundle stats</summary>\n\n${sectionsContent}\n</details>`,
'',
markers.end,
identifier,
'',
].join('\n');
process.stdout.write(args.format === 'comment' ? commentBody : prBody);
process.stdout.write(comment);
}
main().catch(error => {

View File

@@ -2,13 +2,14 @@
// 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,
isValidVersionType,
} from '../src/versions/get-next-package-version';
// eslint-disable-next-line import/extensions
import { getNextVersion } from '../src/versions/get-next-package-version.js';
const args = process.argv;
const options = {
'package-json': {
@@ -19,63 +20,41 @@ const options = {
type: 'string', // nightly, hotfix, monthly, auto
short: 't',
},
version: {
type: 'string',
short: 'v',
},
update: {
type: 'boolean',
short: 'u',
default: false,
},
} as const;
function fail(message: string): never {
console.error(message);
process.exit(1);
}
};
const { values } = parseArgs({
args,
options,
allowPositionals: true,
});
const packageJsonPath = values['package-json'];
if (!packageJsonPath) {
fail(
if (!values['package-json']) {
console.error(
'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,
currentDate: new Date(),
});
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
}
try {
newVersion = getNextVersion({
currentVersion,
type: values.type,
currentDate: new Date(),
});
} catch (e) {
console.error(e.message);
process.exit(1);
}
process.stdout.write(newVersion);
@@ -89,5 +68,6 @@ try {
);
}
} catch (error) {
fail(`Error: ${error instanceof Error ? error.message : String(error)}`);
console.error('Error:', error.message);
process.exit(1);
}

View File

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

View File

@@ -14,14 +14,10 @@ import process from 'node:process';
import { Octokit } from '@octokit/rest';
const BOT_BOUNDARY_MARKER = '<!--- actual-bot-sections --->';
const BOT_BOUNDARY_TEXT = `${BOT_BOUNDARY_MARKER}\n<hr />`;
function parseArgs(argv) {
const args = {
commentFile: null,
identifier: null,
target: 'comment',
};
for (let i = 2; i < argv.length; i += 2) {
@@ -30,12 +26,12 @@ function parseArgs(argv) {
if (!key?.startsWith('--')) {
throw new Error(
`Unexpected argument "${key ?? ''}". Use --key value pairs.`,
`Unexpected argument ${key ?? ''}. Use --key value pairs.`,
);
}
if (typeof value === 'undefined') {
throw new Error(`Missing value for argument "${key}".`);
throw new Error(`Missing value for argument ${key}.`);
}
switch (key) {
@@ -45,26 +41,17 @@ function parseArgs(argv) {
case '--identifier':
args.identifier = value;
break;
case '--target':
args.target = value;
break;
default:
throw new Error(`Unknown argument "${key}".`);
throw new Error(`Unknown argument ${key}.`);
}
}
if (!args.commentFile) {
throw new Error('Missing required argument "--comment-file".');
throw new Error('Missing required argument --comment-file.');
}
if (!args.identifier) {
throw new Error('Missing required argument "--identifier".');
}
if (!['comment', 'pr-body'].includes(args.target)) {
throw new Error(
`Invalid value "${args.target}" for "--target". Use "comment" or "pr-body".`,
);
throw new Error('Missing required argument --identifier.');
}
return args;
@@ -83,7 +70,7 @@ function getRepoInfo() {
const [owner, repo] = repository.split('/');
if (!owner || !repo) {
throw new Error(`Invalid GITHUB_REPOSITORY value "${repository}".`);
throw new Error(`Invalid GITHUB_REPOSITORY value ${repository}.`);
}
return { owner, repo };
@@ -123,123 +110,20 @@ function isGitHubActionsBot(comment) {
return comment.user?.login === 'github-actions[bot]';
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function getIdentifierMarkers(identifier) {
if (identifier.includes('<!---')) {
return {
start: identifier,
end: null,
};
}
const label = 'bundlestats-action-comment';
return {
start: `<!--- ${label} key:${identifier} start --->`,
end: `<!--- ${label} key:${identifier} end --->`,
};
}
function upsertBlock(existingBody, block, markers) {
const body = existingBody ?? '';
if (markers.end) {
const pattern = new RegExp(
`${escapeRegExp(markers.start)}[\\s\\S]*?${escapeRegExp(markers.end)}`,
'm',
);
if (pattern.test(body)) {
return body.replace(pattern, block.trim());
}
}
if (body.trim().length === 0) {
return block.trim();
}
const separator = body.endsWith('\n') ? '\n' : '\n\n';
const boundary = body.includes(BOT_BOUNDARY_MARKER)
? ''
: `${BOT_BOUNDARY_TEXT}\n\n`;
return `${body}${separator}${boundary}${block.trim()}`;
}
async function updatePullRequestBody(
octokit,
owner,
repo,
pullNumber,
block,
markers,
) {
const { data } = await octokit.rest.pulls.get({
owner,
repo,
pull_number: pullNumber,
});
const nextBody = upsertBlock(data.body ?? '', block, markers);
await octokit.rest.pulls.update({
owner,
repo,
pull_number: pullNumber,
body: nextBody,
});
}
async function deleteExistingComment(
octokit,
owner,
repo,
issueNumber,
markers,
) {
const comments = await listComments(octokit, owner, repo, issueNumber);
const existingComment = comments.find(
comment =>
isGitHubActionsBot(comment) && comment.body?.includes(markers.start),
);
if (existingComment) {
await octokit.rest.issues.deleteComment({
owner,
repo,
comment_id: existingComment.id,
});
}
}
async function main() {
const { commentFile, identifier, target } = parseArgs(process.argv);
const { commentFile, identifier } = parseArgs(process.argv);
const commentBody = await loadCommentBody(commentFile);
const token = assertGitHubToken();
const { owner, repo } = getRepoInfo();
const issueNumber = getPullRequestNumber();
const markers = getIdentifierMarkers(identifier);
const octokit = new Octokit({ auth: token });
if (target === 'pr-body') {
await updatePullRequestBody(
octokit,
owner,
repo,
issueNumber,
commentBody,
markers,
);
await deleteExistingComment(octokit, owner, repo, issueNumber, markers);
console.log('Updated pull request body with bundle stats.');
return;
}
const comments = await listComments(octokit, owner, repo, issueNumber);
const existingComment = comments.find(
comment =>
isGitHubActionsBot(comment) && comment.body?.includes(markers.start),
isGitHubActionsBot(comment) && comment.body?.includes(identifier),
);
if (existingComment) {
@@ -250,16 +134,15 @@ async function main() {
body: commentBody,
});
console.log('Updated existing bundle stats comment.');
return;
} else {
await octokit.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: commentBody,
});
console.log('Created new bundle stats comment.');
}
await octokit.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: commentBody,
});
console.log('Created new bundle stats comment.');
}
main().catch(error => {

View File

@@ -2,19 +2,10 @@
"name": "@actual-app/ci-actions",
"private": true,
"type": "module",
"scripts": {
"tsx": "bin/tsx",
"test": "vitest --run",
"typecheck": "tsgo -b"
},
"devDependencies": {
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"extensionless": "^2.0.6",
"vitest": "^4.1.0"
"vitest": "^4.0.9"
},
"extensionless": {
"lookFor": [
"ts"
]
"scripts": {
"test": "vitest --run"
}
}

View File

@@ -1,69 +1,35 @@
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 {
function parseVersion(version) {
const [y, m, p] = version.split('.');
return {
versionYear: Number.parseInt(y, 10),
versionMonth: Number.parseInt(m, 10),
versionHotfix: Number.parseInt(p, 10),
versionYear: parseInt(y, 10),
versionMonth: parseInt(m, 10),
versionHotfix: parseInt(p, 10),
};
}
function computeNextMonth(versionYear: number, versionMonth: number) {
const versionDate = new Date(2000 + versionYear, versionMonth - 1, 1);
function computeNextMonth(versionYear, versionMonth) {
// Create date and add 1 month
const versionDate = new Date(2000 + versionYear, versionMonth - 1, 1); // month is 0-indexed
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;
const nextVersionMonth = nextVersionMonthDate.getMonth() + 1; // Convert back to 1-indexed
return { nextVersionYear, nextVersionMonth };
}
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;
}
// Determine logical type from 'auto' based on the current date and version
function resolveType(type, currentDate, versionYear, versionMonth) {
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';
}
@@ -71,7 +37,7 @@ export function getNextVersion({
currentVersion,
type,
currentDate = new Date(),
}: GetNextVersionOptions) {
}) {
const { versionYear, versionMonth, versionHotfix } =
parseVersion(currentVersion);
const { nextVersionYear, nextVersionMonth } = computeNextMonth(
@@ -85,10 +51,11 @@ export function getNextVersion({
versionMonth,
);
// Format date stamp once for nightly
const currentDateString = currentDate
.toISOString()
.split('T')[0]
.replace(/-/g, '');
.replaceAll('-', '');
switch (resolvedType) {
case 'nightly':
@@ -99,7 +66,7 @@ export function getNextVersion({
return `${nextVersionYear}.${nextVersionMonth}.0`;
default:
throw new Error(
`Invalid type ${String(resolvedType satisfies never)} specified. Use "auto", "nightly", "hotfix", or "monthly".`,
'Invalid type specified. Use auto, nightly, hotfix, or monthly”.',
);
}
}

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, it, expect } from 'vitest';
import { getNextVersion } from './get-next-package-version';
@@ -77,7 +77,7 @@ describe('getNextVersion (lib)', () => {
expect(() =>
getNextVersion({
currentVersion: '25.8.4',
type: 'unknown' as never,
type: 'unknown',
currentDate: new Date('2025-08-10'),
}),
).toThrow(/Invalid type/);

View File

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

View File

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

View File

@@ -1,155 +0,0 @@
# @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
```

View File

@@ -1,35 +0,0 @@
{
"name": "@actual-app/cli",
"version": "26.3.0",
"description": "CLI for Actual Budget",
"license": "MIT",
"bin": {
"actual": "./dist/cli.js",
"actual-cli": "./dist/cli.js"
},
"files": [
"dist"
],
"type": "module",
"scripts": {
"build": "vite build",
"test": "vitest --run",
"typecheck": "tsgo -b"
},
"dependencies": {
"@actual-app/api": "workspace:*",
"cli-table3": "^0.6.5",
"commander": "^13.0.0",
"cosmiconfig": "^9.0.0"
},
"devDependencies": {
"@types/node": "^22.19.15",
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"rollup-plugin-visualizer": "^6.0.11",
"vite": "^8.0.0",
"vitest": "^4.1.0"
},
"engines": {
"node": ">=22"
}
}

View File

@@ -1,259 +0,0 @@
import * as api from '@actual-app/api';
import { Command } from 'commander';
import { printOutput } from '../output';
import { registerAccountsCommand } from './accounts';
vi.mock('@actual-app/api', () => ({
getAccounts: vi.fn().mockResolvedValue([]),
createAccount: vi.fn().mockResolvedValue('new-id'),
updateAccount: vi.fn().mockResolvedValue(undefined),
closeAccount: vi.fn().mockResolvedValue(undefined),
reopenAccount: vi.fn().mockResolvedValue(undefined),
deleteAccount: vi.fn().mockResolvedValue(undefined),
getAccountBalance: vi.fn().mockResolvedValue(10000),
}));
vi.mock('../connection', () => ({
withConnection: vi.fn((_opts, fn) => fn()),
}));
vi.mock('../output', () => ({
printOutput: vi.fn(),
}));
function createProgram(): Command {
const program = new Command();
program.option('--format <format>');
program.option('--server-url <url>');
program.option('--password <pw>');
program.option('--session-token <token>');
program.option('--sync-id <id>');
program.option('--data-dir <dir>');
program.option('--verbose');
program.exitOverride();
registerAccountsCommand(program);
return program;
}
async function run(args: string[]) {
const program = createProgram();
await program.parseAsync(['node', 'test', ...args]);
}
describe('accounts commands', () => {
let stderrSpy: ReturnType<typeof vi.spyOn>;
let stdoutSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
stderrSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
stdoutSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
});
afterEach(() => {
stderrSpy.mockRestore();
stdoutSpy.mockRestore();
});
describe('list', () => {
it('calls api.getAccounts and prints result', async () => {
const accounts = [{ id: '1', name: 'Checking' }];
vi.mocked(api.getAccounts).mockResolvedValue(accounts);
await run(['accounts', 'list']);
expect(api.getAccounts).toHaveBeenCalled();
expect(printOutput).toHaveBeenCalledWith(accounts, undefined);
});
it('passes format option to printOutput', async () => {
vi.mocked(api.getAccounts).mockResolvedValue([]);
await run(['--format', 'csv', 'accounts', 'list']);
expect(printOutput).toHaveBeenCalledWith([], 'csv');
});
});
describe('create', () => {
it('passes name and defaults to api.createAccount', async () => {
await run(['accounts', 'create', '--name', 'Savings']);
expect(api.createAccount).toHaveBeenCalledWith(
{ name: 'Savings', offbudget: false },
0,
);
expect(printOutput).toHaveBeenCalledWith({ id: 'new-id' }, undefined);
});
it('passes offbudget and balance options', async () => {
await run([
'accounts',
'create',
'--name',
'Investments',
'--offbudget',
'--balance',
'50000',
]);
expect(api.createAccount).toHaveBeenCalledWith(
{ name: 'Investments', offbudget: true },
50000,
);
});
});
describe('update', () => {
it('passes fields to api.updateAccount', async () => {
await run(['accounts', 'update', 'acct-1', '--name', 'NewName']);
expect(api.updateAccount).toHaveBeenCalledWith('acct-1', {
name: 'NewName',
});
expect(printOutput).toHaveBeenCalledWith(
{ success: true, id: 'acct-1' },
undefined,
);
});
it('passes offbudget true', async () => {
await run([
'accounts',
'update',
'acct-1',
'--name',
'X',
'--offbudget',
'true',
]);
expect(api.updateAccount).toHaveBeenCalledWith('acct-1', {
name: 'X',
offbudget: true,
});
});
it('passes offbudget false', async () => {
await run([
'accounts',
'update',
'acct-1',
'--name',
'X',
'--offbudget',
'false',
]);
expect(api.updateAccount).toHaveBeenCalledWith('acct-1', {
name: 'X',
offbudget: false,
});
});
it('rejects invalid offbudget value', async () => {
await expect(
run(['accounts', 'update', 'acct-1', '--offbudget', 'yes']),
).rejects.toThrow(
'Invalid --offbudget: "yes". Expected "true" or "false".',
);
});
it('rejects empty name', async () => {
await expect(
run(['accounts', 'update', 'acct-1', '--name', ' ']),
).rejects.toThrow('Invalid --name: must be a non-empty string.');
});
it('rejects update with no fields', async () => {
await expect(run(['accounts', 'update', 'acct-1'])).rejects.toThrow(
'No update fields provided. Use --name or --offbudget.',
);
});
});
describe('close', () => {
it('passes transfer options to api.closeAccount', async () => {
await run([
'accounts',
'close',
'acct-1',
'--transfer-account',
'acct-2',
]);
expect(api.closeAccount).toHaveBeenCalledWith(
'acct-1',
'acct-2',
undefined,
);
});
it('passes transfer category', async () => {
await run([
'accounts',
'close',
'acct-1',
'--transfer-category',
'cat-1',
]);
expect(api.closeAccount).toHaveBeenCalledWith(
'acct-1',
undefined,
'cat-1',
);
});
});
describe('reopen', () => {
it('calls api.reopenAccount', async () => {
await run(['accounts', 'reopen', 'acct-1']);
expect(api.reopenAccount).toHaveBeenCalledWith('acct-1');
expect(printOutput).toHaveBeenCalledWith(
{ success: true, id: 'acct-1' },
undefined,
);
});
});
describe('delete', () => {
it('calls api.deleteAccount', async () => {
await run(['accounts', 'delete', 'acct-1']);
expect(api.deleteAccount).toHaveBeenCalledWith('acct-1');
expect(printOutput).toHaveBeenCalledWith(
{ success: true, id: 'acct-1' },
undefined,
);
});
});
describe('balance', () => {
it('calls api.getAccountBalance without cutoff', async () => {
await run(['accounts', 'balance', 'acct-1']);
expect(api.getAccountBalance).toHaveBeenCalledWith('acct-1', undefined);
expect(printOutput).toHaveBeenCalledWith(
{ id: 'acct-1', balance: 10000 },
undefined,
);
});
it('calls api.getAccountBalance with cutoff date', async () => {
await run(['accounts', 'balance', 'acct-1', '--cutoff', '2025-01-15']);
expect(api.getAccountBalance).toHaveBeenCalledWith(
'acct-1',
new Date('2025-01-15'),
);
});
});
});

View File

@@ -1,135 +0,0 @@
import * as api from '@actual-app/api';
import type { Command } from 'commander';
import { withConnection } from '../connection';
import { printOutput } from '../output';
import { parseBoolFlag, parseIntFlag } from '../utils';
export function registerAccountsCommand(program: Command) {
const accounts = program.command('accounts').description('Manage accounts');
accounts
.command('list')
.description('List all accounts')
.action(async () => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getAccounts();
printOutput(result, opts.format);
});
});
accounts
.command('create')
.description('Create a new account')
.requiredOption('--name <name>', 'Account name')
.option('--offbudget', 'Create as off-budget account', false)
.option('--balance <amount>', 'Initial balance in cents', '0')
.action(async cmdOpts => {
const balance = parseIntFlag(cmdOpts.balance, '--balance');
const opts = program.opts();
await withConnection(opts, async () => {
const id = await api.createAccount(
{ name: cmdOpts.name, offbudget: cmdOpts.offbudget },
balance,
);
printOutput({ id }, opts.format);
});
});
accounts
.command('update <id>')
.description('Update an account')
.option('--name <name>', 'New account name')
.option('--offbudget <bool>', 'Set off-budget status')
.action(async (id: string, cmdOpts) => {
const opts = program.opts();
const fields: Record<string, unknown> = {};
if (cmdOpts.name !== undefined) {
const trimmed = cmdOpts.name.trim();
if (trimmed === '') {
throw new Error('Invalid --name: must be a non-empty string.');
}
fields.name = trimmed;
}
if (cmdOpts.offbudget !== undefined) {
fields.offbudget = parseBoolFlag(cmdOpts.offbudget, '--offbudget');
}
if (Object.keys(fields).length === 0) {
throw new Error(
'No update fields provided. Use --name or --offbudget.',
);
}
await withConnection(opts, async () => {
await api.updateAccount(id, fields);
printOutput({ success: true, id }, opts.format);
});
});
accounts
.command('close <id>')
.description('Close an account')
.option(
'--transfer-account <id>',
'Transfer remaining balance to this account',
)
.option(
'--transfer-category <id>',
'Transfer remaining balance to this category',
)
.action(async (id: string, cmdOpts) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.closeAccount(
id,
cmdOpts.transferAccount,
cmdOpts.transferCategory,
);
printOutput({ success: true, id }, opts.format);
});
});
accounts
.command('reopen <id>')
.description('Reopen a closed account')
.action(async (id: string) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.reopenAccount(id);
printOutput({ success: true, id }, opts.format);
});
});
accounts
.command('delete <id>')
.description('Delete an account')
.action(async (id: string) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.deleteAccount(id);
printOutput({ success: true, id }, opts.format);
});
});
accounts
.command('balance <id>')
.description('Get account balance')
.option('--cutoff <date>', 'Cutoff date (YYYY-MM-DD)')
.action(async (id: string, cmdOpts) => {
let cutoff: Date | undefined;
if (cmdOpts.cutoff) {
const cutoffDate = new Date(cmdOpts.cutoff);
if (Number.isNaN(cutoffDate.getTime())) {
throw new Error(
'Invalid cutoff date: expected a valid date (e.g. YYYY-MM-DD).',
);
}
cutoff = cutoffDate;
}
const opts = program.opts();
await withConnection(opts, async () => {
const balance = await api.getAccountBalance(id, cutoff);
printOutput({ id, balance }, opts.format);
});
});
}

View File

@@ -1,135 +0,0 @@
import * as api from '@actual-app/api';
import type { Command } from 'commander';
import { resolveConfig } from '../config';
import { withConnection } from '../connection';
import { printOutput } from '../output';
import { parseBoolFlag, parseIntFlag } from '../utils';
export function registerBudgetsCommand(program: Command) {
const budgets = program.command('budgets').description('Manage budgets');
budgets
.command('list')
.description('List all available budgets')
.action(async () => {
const opts = program.opts();
await withConnection(
opts,
async () => {
const result = await api.getBudgets();
printOutput(result, opts.format);
},
{ loadBudget: false },
);
});
budgets
.command('download <syncId>')
.description('Download a budget by sync ID')
.option('--encryption-password <password>', 'Encryption password')
.action(async (syncId: string, cmdOpts) => {
const opts = program.opts();
const config = await resolveConfig(opts);
const password = config.encryptionPassword ?? cmdOpts.encryptionPassword;
await withConnection(
opts,
async () => {
await api.downloadBudget(syncId, {
password,
});
printOutput({ success: true, syncId }, opts.format);
},
{ loadBudget: false },
);
});
budgets
.command('sync')
.description('Sync the current budget')
.action(async () => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.sync();
printOutput({ success: true }, opts.format);
});
});
budgets
.command('months')
.description('List available budget months')
.action(async () => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getBudgetMonths();
printOutput(result, opts.format);
});
});
budgets
.command('month <month>')
.description('Get budget data for a specific month (YYYY-MM)')
.action(async (month: string) => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getBudgetMonth(month);
printOutput(result, opts.format);
});
});
budgets
.command('set-amount')
.description('Set budget amount for a category in a month')
.requiredOption('--month <month>', 'Budget month (YYYY-MM)')
.requiredOption('--category <id>', 'Category ID')
.requiredOption('--amount <amount>', 'Amount in cents')
.action(async cmdOpts => {
const amount = parseIntFlag(cmdOpts.amount, '--amount');
const opts = program.opts();
await withConnection(opts, async () => {
await api.setBudgetAmount(cmdOpts.month, cmdOpts.category, amount);
printOutput({ success: true }, opts.format);
});
});
budgets
.command('set-carryover')
.description('Enable/disable carryover for a category')
.requiredOption('--month <month>', 'Budget month (YYYY-MM)')
.requiredOption('--category <id>', 'Category ID')
.requiredOption('--flag <bool>', 'Enable (true) or disable (false)')
.action(async cmdOpts => {
const flag = parseBoolFlag(cmdOpts.flag, '--flag');
const opts = program.opts();
await withConnection(opts, async () => {
await api.setBudgetCarryover(cmdOpts.month, cmdOpts.category, flag);
printOutput({ success: true }, opts.format);
});
});
budgets
.command('hold-next-month')
.description('Hold budget amount for next month')
.requiredOption('--month <month>', 'Budget month (YYYY-MM)')
.requiredOption('--amount <amount>', 'Amount in cents')
.action(async cmdOpts => {
const parsedAmount = parseIntFlag(cmdOpts.amount, '--amount');
const opts = program.opts();
await withConnection(opts, async () => {
await api.holdBudgetForNextMonth(cmdOpts.month, parsedAmount);
printOutput({ success: true }, opts.format);
});
});
budgets
.command('reset-hold')
.description('Reset budget hold for a month')
.requiredOption('--month <month>', 'Budget month (YYYY-MM)')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.resetBudgetHold(cmdOpts.month);
printOutput({ success: true }, opts.format);
});
});
}

View File

@@ -1,75 +0,0 @@
import * as api from '@actual-app/api';
import type { Command } from 'commander';
import { withConnection } from '../connection';
import { printOutput } from '../output';
import { parseBoolFlag } from '../utils';
export function registerCategoriesCommand(program: Command) {
const categories = program
.command('categories')
.description('Manage categories');
categories
.command('list')
.description('List all categories')
.action(async () => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getCategories();
printOutput(result, opts.format);
});
});
categories
.command('create')
.description('Create a new category')
.requiredOption('--name <name>', 'Category name')
.requiredOption('--group-id <id>', 'Category group ID')
.option('--is-income', 'Mark as income category', false)
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const id = await api.createCategory({
name: cmdOpts.name,
group_id: cmdOpts.groupId,
is_income: cmdOpts.isIncome,
hidden: false,
});
printOutput({ id }, opts.format);
});
});
categories
.command('update <id>')
.description('Update a category')
.option('--name <name>', 'New category name')
.option('--hidden <bool>', 'Set hidden status')
.action(async (id: string, cmdOpts) => {
const fields: Record<string, unknown> = {};
if (cmdOpts.name !== undefined) fields.name = cmdOpts.name;
if (cmdOpts.hidden !== undefined) {
fields.hidden = parseBoolFlag(cmdOpts.hidden, '--hidden');
}
if (Object.keys(fields).length === 0) {
throw new Error('No update fields provided. Use --name or --hidden.');
}
const opts = program.opts();
await withConnection(opts, async () => {
await api.updateCategory(id, fields);
printOutput({ success: true, id }, opts.format);
});
});
categories
.command('delete <id>')
.description('Delete a category')
.option('--transfer-to <id>', 'Transfer transactions to this category')
.action(async (id: string, cmdOpts) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.deleteCategory(id, cmdOpts.transferTo);
printOutput({ success: true, id }, opts.format);
});
});
}

View File

@@ -1,73 +0,0 @@
import * as api from '@actual-app/api';
import type { Command } from 'commander';
import { withConnection } from '../connection';
import { printOutput } from '../output';
import { parseBoolFlag } from '../utils';
export function registerCategoryGroupsCommand(program: Command) {
const groups = program
.command('category-groups')
.description('Manage category groups');
groups
.command('list')
.description('List all category groups')
.action(async () => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getCategoryGroups();
printOutput(result, opts.format);
});
});
groups
.command('create')
.description('Create a new category group')
.requiredOption('--name <name>', 'Group name')
.option('--is-income', 'Mark as income group', false)
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const id = await api.createCategoryGroup({
name: cmdOpts.name,
is_income: cmdOpts.isIncome,
hidden: false,
});
printOutput({ id }, opts.format);
});
});
groups
.command('update <id>')
.description('Update a category group')
.option('--name <name>', 'New group name')
.option('--hidden <bool>', 'Set hidden status')
.action(async (id: string, cmdOpts) => {
const fields: Record<string, unknown> = {};
if (cmdOpts.name !== undefined) fields.name = cmdOpts.name;
if (cmdOpts.hidden !== undefined) {
fields.hidden = parseBoolFlag(cmdOpts.hidden, '--hidden');
}
if (Object.keys(fields).length === 0) {
throw new Error('No update fields provided. Use --name or --hidden.');
}
const opts = program.opts();
await withConnection(opts, async () => {
await api.updateCategoryGroup(id, fields);
printOutput({ success: true, id }, opts.format);
});
});
groups
.command('delete <id>')
.description('Delete a category group')
.option('--transfer-to <id>', 'Transfer transactions to this category ID')
.action(async (id: string, cmdOpts) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.deleteCategoryGroup(id, cmdOpts.transferTo);
printOutput({ success: true, id }, opts.format);
});
});
}

View File

@@ -1,95 +0,0 @@
import * as api from '@actual-app/api';
import type { Command } from 'commander';
import { withConnection } from '../connection';
import { printOutput } from '../output';
export function registerPayeesCommand(program: Command) {
const payees = program.command('payees').description('Manage payees');
payees
.command('list')
.description('List all payees')
.action(async () => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getPayees();
printOutput(result, opts.format);
});
});
payees
.command('common')
.description('List frequently used payees')
.action(async () => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getCommonPayees();
printOutput(result, opts.format);
});
});
payees
.command('create')
.description('Create a new payee')
.requiredOption('--name <name>', 'Payee name')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const id = await api.createPayee({ name: cmdOpts.name });
printOutput({ id }, opts.format);
});
});
payees
.command('update <id>')
.description('Update a payee')
.option('--name <name>', 'New payee name')
.action(async (id: string, cmdOpts) => {
const fields: Record<string, unknown> = {};
if (cmdOpts.name) fields.name = cmdOpts.name;
if (Object.keys(fields).length === 0) {
throw new Error(
'No fields to update. Use --name to specify a new name.',
);
}
const opts = program.opts();
await withConnection(opts, async () => {
await api.updatePayee(id, fields);
printOutput({ success: true, id }, opts.format);
});
});
payees
.command('delete <id>')
.description('Delete a payee')
.action(async (id: string) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.deletePayee(id);
printOutput({ success: true, id }, opts.format);
});
});
payees
.command('merge')
.description('Merge payees into a target payee')
.requiredOption('--target <id>', 'Target payee ID')
.requiredOption('--ids <ids>', 'Comma-separated payee IDs to merge')
.action(async (cmdOpts: { target: string; ids: string }) => {
const mergeIds = cmdOpts.ids
.split(',')
.map(id => id.trim())
.filter(id => id.length > 0);
if (mergeIds.length === 0) {
throw new Error(
'No valid payee IDs provided in --ids. Provide comma-separated IDs.',
);
}
const opts = program.opts();
await withConnection(opts, async () => {
await api.mergePayees(cmdOpts.target, mergeIds);
printOutput({ success: true }, opts.format);
});
});
}

View File

@@ -1,346 +0,0 @@
import * as api from '@actual-app/api';
import { Command } from 'commander';
import { printOutput } from '../output';
import { parseOrderBy, registerQueryCommand } from './query';
vi.mock('@actual-app/api', () => {
const queryObj = {
select: vi.fn().mockReturnThis(),
filter: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
offset: vi.fn().mockReturnThis(),
groupBy: vi.fn().mockReturnThis(),
calculate: vi.fn().mockReturnThis(),
};
return {
q: vi.fn().mockReturnValue(queryObj),
aqlQuery: vi.fn().mockResolvedValue({ data: [] }),
};
});
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();
registerQueryCommand(program);
return program;
}
async function run(args: string[]) {
const program = createProgram();
await program.parseAsync(['node', 'test', ...args]);
}
function getQueryObj() {
return vi.mocked(api.q).mock.results[0]?.value;
}
describe('parseOrderBy', () => {
it('parses plain field names', () => {
expect(parseOrderBy('date')).toEqual(['date']);
});
it('parses field:desc', () => {
expect(parseOrderBy('date:desc')).toEqual([{ date: 'desc' }]);
});
it('parses field:asc', () => {
expect(parseOrderBy('amount:asc')).toEqual([{ amount: 'asc' }]);
});
it('parses multiple mixed fields', () => {
expect(parseOrderBy('date:desc,amount:asc,id')).toEqual([
{ date: 'desc' },
{ amount: 'asc' },
'id',
]);
});
it('throws on invalid direction', () => {
expect(() => parseOrderBy('date:backwards')).toThrow(
'Invalid order direction "backwards"',
);
});
it('throws on empty field', () => {
expect(() => parseOrderBy('date,,amount')).toThrow('empty field');
});
});
describe('query 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('run', () => {
it('builds a basic query from flags', async () => {
await run([
'query',
'run',
'--table',
'transactions',
'--select',
'date,amount',
'--limit',
'5',
]);
expect(api.q).toHaveBeenCalledWith('transactions');
const qObj = getQueryObj();
expect(qObj.select).toHaveBeenCalledWith(['date', 'amount']);
expect(qObj.limit).toHaveBeenCalledWith(5);
});
it('rejects unknown table name', async () => {
await expect(
run(['query', 'run', '--table', 'nonexistent']),
).rejects.toThrow('Unknown table "nonexistent"');
});
it('parses order-by with desc direction', async () => {
await run([
'query',
'run',
'--table',
'transactions',
'--order-by',
'date:desc,amount:asc',
]);
const qObj = getQueryObj();
expect(qObj.orderBy).toHaveBeenCalledWith([
{ date: 'desc' },
{ amount: 'asc' },
]);
});
it('passes --filter as JSON', async () => {
await run([
'query',
'run',
'--table',
'transactions',
'--filter',
'{"amount":{"$lt":0}}',
]);
const qObj = getQueryObj();
expect(qObj.filter).toHaveBeenCalledWith({ amount: { $lt: 0 } });
});
});
describe('--last flag', () => {
it('sets default table, select, orderBy, and limit', async () => {
await run(['query', 'run', '--last', '10']);
expect(api.q).toHaveBeenCalledWith('transactions');
const qObj = getQueryObj();
expect(qObj.select).toHaveBeenCalledWith([
'date',
'account.name',
'payee.name',
'category.name',
'amount',
'notes',
]);
expect(qObj.orderBy).toHaveBeenCalledWith([{ date: 'desc' }]);
expect(qObj.limit).toHaveBeenCalledWith(10);
});
it('allows explicit --select override', async () => {
await run(['query', 'run', '--last', '5', '--select', 'date,amount']);
const qObj = getQueryObj();
expect(qObj.select).toHaveBeenCalledWith(['date', 'amount']);
});
it('allows explicit --order-by override', async () => {
await run(['query', 'run', '--last', '5', '--order-by', 'amount:asc']);
const qObj = getQueryObj();
expect(qObj.orderBy).toHaveBeenCalledWith([{ amount: 'asc' }]);
});
it('allows --table transactions explicitly', async () => {
await run(['query', 'run', '--last', '5', '--table', 'transactions']);
expect(api.q).toHaveBeenCalledWith('transactions');
});
it('errors if --table is not transactions', async () => {
await expect(
run(['query', 'run', '--last', '5', '--table', 'accounts']),
).rejects.toThrow('--last implies --table transactions');
});
it('errors if --limit is also set', async () => {
await expect(
run(['query', 'run', '--last', '5', '--limit', '10']),
).rejects.toThrow('--last and --limit are mutually exclusive');
});
});
describe('--count flag', () => {
it('uses calculate with $count', async () => {
vi.mocked(api.aqlQuery).mockResolvedValueOnce({ data: 42 });
await run(['query', 'run', '--table', 'transactions', '--count']);
const qObj = getQueryObj();
expect(qObj.calculate).toHaveBeenCalledWith({ $count: '*' });
expect(printOutput).toHaveBeenCalledWith({ count: 42 }, undefined);
});
it('errors if --select is also set', async () => {
await expect(
run([
'query',
'run',
'--table',
'transactions',
'--count',
'--select',
'date',
]),
).rejects.toThrow('--count and --select are mutually exclusive');
});
});
describe('--where alias', () => {
it('works the same as --filter', async () => {
await run([
'query',
'run',
'--table',
'transactions',
'--where',
'{"amount":{"$gt":0}}',
]);
const qObj = getQueryObj();
expect(qObj.filter).toHaveBeenCalledWith({ amount: { $gt: 0 } });
});
it('errors if both --where and --filter are provided', async () => {
await expect(
run([
'query',
'run',
'--table',
'transactions',
'--where',
'{}',
'--filter',
'{}',
]),
).rejects.toThrow('--where and --filter are mutually exclusive');
});
});
describe('--offset flag', () => {
it('passes offset through to query', async () => {
await run([
'query',
'run',
'--table',
'transactions',
'--offset',
'20',
'--limit',
'10',
]);
const qObj = getQueryObj();
expect(qObj.offset).toHaveBeenCalledWith(20);
expect(qObj.limit).toHaveBeenCalledWith(10);
});
});
describe('--group-by flag', () => {
it('passes group-by through to query', async () => {
await run([
'query',
'run',
'--table',
'transactions',
'--group-by',
'category.name',
'--select',
'category.name,amount',
]);
const qObj = getQueryObj();
expect(qObj.groupBy).toHaveBeenCalledWith(['category.name']);
});
});
describe('tables subcommand', () => {
it('lists available tables', async () => {
await run(['query', 'tables']);
expect(printOutput).toHaveBeenCalledWith(
expect.arrayContaining([
{ name: 'transactions' },
{ name: 'accounts' },
{ name: 'categories' },
{ name: 'payees' },
]),
undefined,
);
});
});
describe('fields subcommand', () => {
it('lists fields for a known table', async () => {
await run(['query', 'fields', 'accounts']);
const output = vi.mocked(printOutput).mock.calls[0][0] as Array<{
name: string;
type: string;
}>;
expect(output).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'id', type: 'id' }),
expect.objectContaining({ name: 'name', type: 'string' }),
]),
);
});
it('errors on unknown table', async () => {
await expect(run(['query', 'fields', 'unknown'])).rejects.toThrow(
'Unknown table "unknown"',
);
});
});
});

View File

@@ -1,344 +0,0 @@
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);
}
/**
* Parse order-by strings like "date:desc,amount:asc,id" into
* AQL orderBy format: [{ date: 'desc' }, { amount: 'asc' }, 'id']
*/
export function parseOrderBy(
input: string,
): Array<string | Record<string, string>> {
return input.split(',').map(part => {
const trimmed = part.trim();
if (!trimmed) {
throw new Error('--order-by contains an empty field');
}
const colonIndex = trimmed.indexOf(':');
if (colonIndex === -1) {
return trimmed;
}
const field = trimmed.slice(0, colonIndex).trim();
if (!field) {
throw new Error(
`Invalid order field in "${trimmed}". Field name cannot be empty.`,
);
}
const direction = trimmed.slice(colonIndex + 1);
if (direction !== 'asc' && direction !== 'desc') {
throw new Error(
`Invalid order direction "${direction}" for field "${field}". Expected "asc" or "desc".`,
);
}
return { [field]: direction };
});
}
// TODO: Import schema from API once it exposes table/field metadata
const TABLE_SCHEMA: Record<
string,
Record<string, { type: string; ref?: string }>
> = {
transactions: {
id: { type: 'id' },
account: { type: 'id', ref: 'accounts' },
date: { type: 'date' },
amount: { type: 'integer' },
payee: { type: 'id', ref: 'payees' },
category: { type: 'id', ref: 'categories' },
notes: { type: 'string' },
imported_id: { type: 'string' },
transfer_id: { type: 'id' },
cleared: { type: 'boolean' },
reconciled: { type: 'boolean' },
starting_balance_flag: { type: 'boolean' },
imported_payee: { type: 'string' },
is_parent: { type: 'boolean' },
is_child: { type: 'boolean' },
parent_id: { type: 'id' },
sort_order: { type: 'float' },
schedule: { type: 'id', ref: 'schedules' },
'account.name': { type: 'string', ref: 'accounts' },
'payee.name': { type: 'string', ref: 'payees' },
'category.name': { type: 'string', ref: 'categories' },
'category.group.name': { type: 'string', ref: 'category_groups' },
},
accounts: {
id: { type: 'id' },
name: { type: 'string' },
offbudget: { type: 'boolean' },
closed: { type: 'boolean' },
sort_order: { type: 'float' },
},
categories: {
id: { type: 'id' },
name: { type: 'string' },
is_income: { type: 'boolean' },
group_id: { type: 'id', ref: 'category_groups' },
sort_order: { type: 'float' },
hidden: { type: 'boolean' },
'group.name': { type: 'string', ref: 'category_groups' },
},
payees: {
id: { type: 'id' },
name: { type: 'string' },
transfer_acct: { type: 'id', ref: 'accounts' },
},
rules: {
id: { type: 'id' },
stage: { type: 'string' },
conditions_op: { type: 'string' },
conditions: { type: 'json' },
actions: { type: 'json' },
},
schedules: {
id: { type: 'id' },
name: { type: 'string' },
rule: { type: 'id', ref: 'rules' },
next_date: { type: 'date' },
completed: { type: 'boolean' },
},
};
const AVAILABLE_TABLES = Object.keys(TABLE_SCHEMA).join(', ');
const LAST_DEFAULT_SELECT = [
'date',
'account.name',
'payee.name',
'category.name',
'amount',
'notes',
];
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);
if (typeof parsed.offset === 'number') {
queryObj = queryObj.offset(parsed.offset);
}
if (Array.isArray(parsed.groupBy)) {
queryObj = queryObj.groupBy(parsed.groupBy);
}
return queryObj;
}
function buildQueryFromFlags(cmdOpts: Record<string, string | undefined>) {
const last = cmdOpts.last ? parseIntFlag(cmdOpts.last, '--last') : undefined;
if (last !== undefined) {
if (cmdOpts.table && cmdOpts.table !== 'transactions') {
throw new Error(
'--last implies --table transactions. Cannot use with --table ' +
cmdOpts.table,
);
}
if (cmdOpts.limit) {
throw new Error('--last and --limit are mutually exclusive');
}
}
const table =
cmdOpts.table ?? (last !== undefined ? 'transactions' : undefined);
if (!table) {
throw new Error('--table is required (or use --file or --last)');
}
if (!(table in TABLE_SCHEMA)) {
throw new Error(
`Unknown table "${table}". Available tables: ${AVAILABLE_TABLES}`,
);
}
if (cmdOpts.where && cmdOpts.filter) {
throw new Error('--where and --filter are mutually exclusive');
}
if (cmdOpts.count && cmdOpts.select) {
throw new Error('--count and --select are mutually exclusive');
}
let queryObj = api.q(table);
if (cmdOpts.count) {
queryObj = queryObj.calculate({ $count: '*' });
} else if (cmdOpts.select) {
queryObj = queryObj.select(cmdOpts.select.split(','));
} else if (last !== undefined) {
queryObj = queryObj.select(LAST_DEFAULT_SELECT);
}
const filterStr = cmdOpts.filter ?? cmdOpts.where;
if (filterStr) {
queryObj = queryObj.filter(JSON.parse(filterStr));
}
const orderByStr =
cmdOpts.orderBy ??
(last !== undefined && !cmdOpts.count ? 'date:desc' : undefined);
if (orderByStr) {
queryObj = queryObj.orderBy(parseOrderBy(orderByStr));
}
const limitVal =
last ??
(cmdOpts.limit ? parseIntFlag(cmdOpts.limit, '--limit') : undefined);
if (limitVal !== undefined) {
queryObj = queryObj.limit(limitVal);
}
if (cmdOpts.offset) {
queryObj = queryObj.offset(parseIntFlag(cmdOpts.offset, '--offset'));
}
if (cmdOpts.groupBy) {
queryObj = queryObj.groupBy(cmdOpts.groupBy.split(','));
}
return queryObj;
}
const RUN_EXAMPLES = `
Examples:
# Show last 5 transactions (shortcut)
actual query run --last 5
# Transactions ordered by date descending
actual query run --table transactions --select "date,amount,payee.name" --order-by "date:desc" --limit 10
# Filter with JSON (negative amounts = expenses)
actual query run --table transactions --filter '{"amount":{"$lt":0}}' --limit 5
# Count transactions
actual query run --table transactions --count
# Group by category (use --file for aggregate expressions)
echo '{"table":"transactions","groupBy":["category.name"],"select":["category.name",{"amount":{"$sum":"$amount"}}]}' | actual query run --file -
# Pagination
actual query run --table transactions --order-by "date:desc" --limit 10 --offset 20
# Use --where (alias for --filter)
actual query run --table transactions --where '{"payee.name":"Grocery Store"}' --limit 5
# Read query from a JSON file
actual query run --file query.json
# Pipe query from stdin
echo '{"table":"transactions","limit":5}' | actual query run --file -
Available tables: ${AVAILABLE_TABLES}
Use "actual query tables" and "actual query fields <table>" for schema info.
Common filter operators: $eq, $ne, $lt, $lte, $gt, $gte, $like, $and, $or
See ActualQL docs for full reference: https://actualbudget.org/docs/api/actual-ql/`;
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 (use "actual query tables" to list available tables)',
)
.option('--select <fields>', 'Comma-separated fields to select')
.option('--filter <json>', 'Filter as JSON (e.g. \'{"amount":{"$lt":0}}\')')
.option(
'--where <json>',
'Alias for --filter (cannot be used together with --filter)',
)
.option(
'--order-by <fields>',
'Fields with optional direction: field1:desc,field2 (default: asc)',
)
.option('--limit <n>', 'Limit number of results')
.option('--offset <n>', 'Skip first N results (for pagination)')
.option(
'--last <n>',
'Show last N transactions (implies --table transactions, --order-by date:desc)',
)
.option('--count', 'Count matching rows instead of returning them')
.option(
'--group-by <fields>',
'Comma-separated fields to group by (use with aggregate selects)',
)
.option(
'--file <path>',
'Read full query object from JSON file (use - for stdin)',
)
.addHelpText('after', RUN_EXAMPLES)
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const parsed = cmdOpts.file ? readJsonInput(cmdOpts) : undefined;
if (parsed !== undefined && !isRecord(parsed)) {
throw new Error('Query file must contain a JSON object');
}
const queryObj = parsed
? buildQueryFromFile(parsed, cmdOpts.table)
: buildQueryFromFlags(cmdOpts);
const result = await api.aqlQuery(queryObj);
if (cmdOpts.count) {
printOutput({ count: result.data }, opts.format);
} else {
printOutput(result, opts.format);
}
});
});
query
.command('tables')
.description('List available tables for querying')
.action(() => {
const opts = program.opts();
const tables = Object.keys(TABLE_SCHEMA).map(name => ({ name }));
printOutput(tables, opts.format);
});
query
.command('fields <table>')
.description('List fields for a given table')
.action((table: string) => {
const opts = program.opts();
const schema = TABLE_SCHEMA[table];
if (!schema) {
throw new Error(
`Unknown table "${table}". Available tables: ${Object.keys(TABLE_SCHEMA).join(', ')}`,
);
}
const fields = Object.entries(schema).map(([name, info]) => ({
name,
type: info.type,
...(info.ref ? { ref: info.ref } : {}),
}));
printOutput(fields, opts.format);
});
}

View File

@@ -1,77 +0,0 @@
import * as api from '@actual-app/api';
import type { Command } from 'commander';
import { withConnection } from '../connection';
import { readJsonInput } from '../input';
import { printOutput } from '../output';
export function registerRulesCommand(program: Command) {
const rules = program
.command('rules')
.description('Manage transaction rules');
rules
.command('list')
.description('List all rules')
.action(async () => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getRules();
printOutput(result, opts.format);
});
});
rules
.command('payee-rules <payeeId>')
.description('List rules for a specific payee')
.action(async (payeeId: string) => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getPayeeRules(payeeId);
printOutput(result, opts.format);
});
});
rules
.command('create')
.description('Create a new rule')
.option('--data <json>', 'Rule definition as JSON')
.option('--file <path>', 'Read rule from JSON file (use - for stdin)')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const rule = readJsonInput(cmdOpts) as Parameters<
typeof api.createRule
>[0];
const id = await api.createRule(rule);
printOutput({ id }, opts.format);
});
});
rules
.command('update')
.description('Update a rule')
.option('--data <json>', 'Rule data as JSON (must include id)')
.option('--file <path>', 'Read rule from JSON file (use - for stdin)')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const rule = readJsonInput(cmdOpts) as Parameters<
typeof api.updateRule
>[0];
await api.updateRule(rule);
printOutput({ success: true }, opts.format);
});
});
rules
.command('delete <id>')
.description('Delete a rule')
.action(async (id: string) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.deleteRule(id);
printOutput({ success: true, id }, opts.format);
});
});
}

View File

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

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