mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-21 15:36:50 -05:00
Compare commits
27 Commits
js-proxy
...
budget-tab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49ed0ab628 | ||
|
|
d799ede866 | ||
|
|
02cbd7729b | ||
|
|
806d54720d | ||
|
|
5a8b3a0acc | ||
|
|
1cabe8ab6c | ||
|
|
f378d75727 | ||
|
|
1b02460456 | ||
|
|
1db3a43c56 | ||
|
|
eb5b5231dc | ||
|
|
475c4ef521 | ||
|
|
04301e9bc8 | ||
|
|
d6f313b06e | ||
|
|
165b6d3b17 | ||
|
|
7b7d9d2ddb | ||
|
|
3db6af520c | ||
|
|
a24d180002 | ||
|
|
4f6d5b5544 | ||
|
|
8bfb3bbf1d | ||
|
|
72124c772f | ||
|
|
3f827b8fea | ||
|
|
f8391c6e45 | ||
|
|
58fa711f4a | ||
|
|
29c52a830c | ||
|
|
e63e68e764 | ||
|
|
e8917275fa | ||
|
|
cad15ed4f9 |
@@ -3,16 +3,27 @@ issue_enrichment:
|
||||
enabled: false
|
||||
reviews:
|
||||
request_changes_workflow: true
|
||||
review_status: false
|
||||
review_status: true
|
||||
high_level_summary: false
|
||||
finishing_touches:
|
||||
docstrings:
|
||||
enabled: false
|
||||
pre_merge_checks:
|
||||
docstrings:
|
||||
mode: off
|
||||
enabled: false
|
||||
custom_checks:
|
||||
- mode: error
|
||||
name: 'settings'
|
||||
instructions: 'Every addition of a new setting toggle must be thoroughly evaluated against the core design principles of Actual. The settings screen is reserved for essential and foundational options only — do not introduce settings for minor UI adjustments such as sizes, paddings, colors, or margins. Prioritize preserving a simple and uncluttered user experience. Users proposing new settings must confirm in a reply to the Coderabbit comment that they have reviewed and ensured alignment with these principles. Excessive or granular UI options increase code complexity and risk confusing users, and are generally not permitted.'
|
||||
- mode: error
|
||||
name: 'linting'
|
||||
instructions: 'Do not allow any oxlint-disable lines.'
|
||||
- mode: error
|
||||
name: 'typecheck'
|
||||
instructions: 'Do not allow creating new components or utilities with the @ts-strict-ignore comment.'
|
||||
labeling_instructions:
|
||||
- label: 'suspect ai generated'
|
||||
instructions: 'This issue or PR is suspected to be generated by AI.'
|
||||
- label: 'API'
|
||||
instructions: 'This issue or PR updates the API in `packages/api`.'
|
||||
- label: 'documentation'
|
||||
|
||||
@@ -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)
|
||||
57
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
57
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -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 you're reporting an issue with imports, please attach a (redacted) version of the file you're 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
|
||||
|
||||
20
.github/PULL_REQUEST_TEMPLATE.md
vendored
20
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -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 --->
|
||||
|
||||
@@ -74,4 +74,4 @@ async function checkReleaseNotesExists() {
|
||||
}
|
||||
}
|
||||
|
||||
void checkReleaseNotesExists();
|
||||
checkReleaseNotesExists();
|
||||
|
||||
@@ -74,4 +74,4 @@ async function commentOnPR() {
|
||||
}
|
||||
}
|
||||
|
||||
void commentOnPR();
|
||||
commentOnPR();
|
||||
|
||||
@@ -94,4 +94,4 @@ ${summaryData.summary}
|
||||
}
|
||||
}
|
||||
|
||||
void createReleaseNotesFile();
|
||||
createReleaseNotesFile();
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -36,15 +36,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));
|
||||
|
||||
@@ -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 { spawnSync } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
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',
|
||||
12
.github/actions/docs-spelling/expect.txt
vendored
12
.github/actions/docs-spelling/expect.txt
vendored
@@ -2,11 +2,9 @@ Abanca
|
||||
ABNAMRO
|
||||
ABNANL
|
||||
Activo
|
||||
actualrc
|
||||
AESUDEF
|
||||
ALZEY
|
||||
Anglais
|
||||
ANZ
|
||||
aql
|
||||
AUR
|
||||
Authentik
|
||||
@@ -32,7 +30,6 @@ CAGLPTPL
|
||||
Caixa
|
||||
CAMT
|
||||
cashflow
|
||||
Catppuccin
|
||||
Cetelem
|
||||
cimode
|
||||
Citi
|
||||
@@ -43,13 +40,13 @@ COBADEFF
|
||||
CODEOWNERS
|
||||
COEP
|
||||
commerzbank
|
||||
COOP
|
||||
Copiar
|
||||
COUNTA
|
||||
COUNTBLANK
|
||||
countif
|
||||
CREGBEBB
|
||||
crt
|
||||
CZK
|
||||
Danske
|
||||
datadir
|
||||
DATEDIF
|
||||
@@ -71,6 +68,7 @@ Fineco
|
||||
Finicity
|
||||
Fintro
|
||||
Finverse
|
||||
flathub
|
||||
Flathub
|
||||
FORTUNEO
|
||||
FTNOFRP
|
||||
@@ -85,7 +83,6 @@ HABAL
|
||||
Hampel
|
||||
HELADEF
|
||||
HLOOKUP
|
||||
HUF
|
||||
IFERROR
|
||||
IFNA
|
||||
INDUSTRIEL
|
||||
@@ -112,7 +109,6 @@ Keycloak
|
||||
Khurozov
|
||||
KORT
|
||||
Kreditbank
|
||||
KRW
|
||||
lage
|
||||
LHV
|
||||
LHVBEE
|
||||
@@ -120,14 +116,12 @@ LKR
|
||||
MAXA
|
||||
mbank
|
||||
mdc
|
||||
metainfo
|
||||
modals
|
||||
Moldovan
|
||||
murmurhash
|
||||
NETWORKDAYS
|
||||
nginx
|
||||
OIDC
|
||||
Okabe
|
||||
overbudgeted
|
||||
overbudgeting
|
||||
oxc
|
||||
@@ -140,6 +134,7 @@ prefs
|
||||
Primoco
|
||||
Priotecs
|
||||
proactively
|
||||
pwa
|
||||
Qatari
|
||||
QNTOFRP
|
||||
QONTO
|
||||
@@ -177,7 +172,6 @@ touchscreen
|
||||
triaging
|
||||
UAH
|
||||
ubuntu
|
||||
undici
|
||||
userinfo
|
||||
Userscripts
|
||||
UZS
|
||||
|
||||
3
.github/actions/docs-spelling/patterns.txt
vendored
3
.github/actions/docs-spelling/patterns.txt
vendored
@@ -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
|
||||
|
||||
8
.github/actions/setup/action.yml
vendored
8
.github/actions/setup/action.yml
vendored
@@ -15,7 +15,7 @@ runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install node
|
||||
uses: actions/setup-node@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
|
||||
|
||||
70
.github/agents/pr-and-commit-rules.md
vendored
70
.github/agents/pr-and-commit-rules.md
vendored
@@ -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)
|
||||
167
.github/scripts/count-points.mjs
vendored
167
.github/scripts/count-points.mjs
vendored
@@ -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(', ')})`,
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ 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:
|
||||
@@ -42,11 +42,7 @@ jobs:
|
||||
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
|
||||
- name: Check if release notes file already exists
|
||||
if: >-
|
||||
steps.check-first-comment.outputs.result == 'true' &&
|
||||
steps.pr-details.outputs.result != 'null' &&
|
||||
fromJSON(steps.pr-details.outputs.result).baseBranch == 'master' &&
|
||||
!startsWith(fromJSON(steps.pr-details.outputs.result).headBranch, 'release/')
|
||||
if: steps.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:
|
||||
|
||||
4
.github/workflows/autofix.yml
vendored
4
.github/workflows/autofix.yml
vendored
@@ -15,11 +15,11 @@ jobs:
|
||||
autofix:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@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
|
||||
|
||||
45
.github/workflows/build.yml
vendored
45
.github/workflows/build.yml
vendored
@@ -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
|
||||
|
||||
27
.github/workflows/check.yml
vendored
27
.github/workflows/check.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -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'
|
||||
|
||||
2
.github/workflows/count-points.yml
vendored
2
.github/workflows/count-points.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
|
||||
20
.github/workflows/docker-edge.yml
vendored
20
.github/workflows/docker-edge.yml
vendored
@@ -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' }}
|
||||
|
||||
18
.github/workflows/docker-release.yml
vendored
18
.github/workflows/docker-release.yml
vendored
@@ -28,17 +28,17 @@ jobs:
|
||||
name: Build Docker image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@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
|
||||
|
||||
33
.github/workflows/docs-release.yml
vendored
Normal file
33
.github/workflows/docs-release.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: Release Docs to Github Pages
|
||||
|
||||
# Release docs on every push to master
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- 'packages/docs/**'
|
||||
- '.github/workflows/docs-spelling.yml'
|
||||
- '.github/actions/docs-spelling/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy Docs
|
||||
if: github.event.repository.fork == false
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
|
||||
- name: Docusaurus Deploy
|
||||
run: |
|
||||
GIT_USER=MikesGlitch \
|
||||
GIT_PASS=${{ secrets.DOCS_GITHUB_PAGES_DEPLOY }} \
|
||||
GIT_USER_NAME=github-actions[bot] \
|
||||
GIT_USER_EMAIL=github-actions[bot]@users.noreply.github.com \
|
||||
yarn deploy:docs
|
||||
28
.github/workflows/e2e-test.yml
vendored
28
.github/workflows/e2e-test.yml
vendored
@@ -30,9 +30,9 @@ jobs:
|
||||
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:
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
- name: Run E2E Tests
|
||||
run: yarn e2e --shard=${{ matrix.shard }}/5
|
||||
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: desktop-client-test-results-shard-${{ matrix.shard }}
|
||||
@@ -53,9 +53,9 @@ 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:
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
- name: Run Desktop app E2E Tests
|
||||
run: |
|
||||
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop
|
||||
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: desktop-app-test-results
|
||||
@@ -81,16 +81,16 @@ jobs:
|
||||
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
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: vrt-blob-report-${{ matrix.shard }}
|
||||
@@ -104,13 +104,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !cancelled() }}
|
||||
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
|
||||
- name: Download all blob reports
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
path: packages/desktop-client/all-blob-reports
|
||||
pattern: vrt-blob-report-*
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
- name: Merge reports
|
||||
id: merge-reports
|
||||
run: yarn workspace @actual-app/web run playwright merge-reports --reporter html ./all-blob-reports
|
||||
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
id: playwright-report-vrt
|
||||
with:
|
||||
name: html-report--attempt-${{ github.run_attempt }}
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
echo "${{ steps.playwright-report-vrt.outputs.artifact-url }}" > vrt-metadata/artifact-url.txt
|
||||
- name: Upload VRT metadata
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: vrt-comment-metadata
|
||||
path: vrt-metadata/
|
||||
|
||||
4
.github/workflows/e2e-vrt-comment.yml
vendored
4
.github/workflows/e2e-vrt-comment.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
if: github.event.workflow_run.event == 'pull_request'
|
||||
steps:
|
||||
- name: Download VRT metadata
|
||||
uses: actions/download-artifact@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 }}
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
|
||||
- name: Comment on PR with VRT report link
|
||||
if: steps.metadata.outputs.should_comment == 'true'
|
||||
uses: marocchino/sticky-pull-request-comment@70d2764d1a7d5d9560b100cbea0077fc8f633987 # v3.0.2
|
||||
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||
with:
|
||||
number: ${{ steps.metadata.outputs.pr_number }}
|
||||
header: vrt-comment
|
||||
|
||||
59
.github/workflows/electron-master.yml
vendored
59
.github/workflows/electron-master.yml
vendored
@@ -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') }}
|
||||
@@ -74,7 +74,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 +85,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: |
|
||||
@@ -126,7 +126,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 +156,52 @@ jobs:
|
||||
-NoStatus `
|
||||
-AutoCommit `
|
||||
-Force
|
||||
|
||||
publish-flathub:
|
||||
needs: build
|
||||
runs-on: ubuntu-22.04
|
||||
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
|
||||
steps:
|
||||
- name: Download Linux artifacts
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: actual-electron-ubuntu-22.04
|
||||
|
||||
- name: Calculate AppImage SHA256
|
||||
id: appimage_sha256
|
||||
run: |
|
||||
APPIMAGE_X64_SHA256=$(sha256sum Actual-linux-x86_64.AppImage | awk '{ print $1 }')
|
||||
APPIMAGE_ARM64_SHA256=$(sha256sum Actual-linux-arm64.AppImage | awk '{ print $1 }')
|
||||
echo "APPIMAGE_X64_SHA256=$APPIMAGE_X64_SHA256" >> "$GITHUB_ENV"
|
||||
echo "APPIMAGE_ARM64_SHA256=$APPIMAGE_ARM64_SHA256" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Checkout Flathub repo
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: flathub/com.actualbudget.actual
|
||||
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
|
||||
|
||||
- name: Update manifest with new SHA256
|
||||
run: |
|
||||
# Replace x86_64 entry
|
||||
sed -i "/x86_64.AppImage/{n;s|sha256:.*|sha256: ${{ env.APPIMAGE_X64_SHA256 }}|}" com.actualbudget.actual.yml
|
||||
sed -i "/x86_64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${{ needs.build.outputs.version }}/Actual-linux-x86_64.AppImage|" com.actualbudget.actual.yml
|
||||
|
||||
# Replace arm64 entry
|
||||
sed -i "/arm64.AppImage/{n;s|sha256:.*|sha256: ${{ env.APPIMAGE_ARM64_SHA256 }}|}" com.actualbudget.actual.yml
|
||||
sed -i "/arm64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${{ needs.build.outputs.version }}/Actual-linux-arm64.AppImage|" com.actualbudget.actual.yml
|
||||
|
||||
cat com.actualbudget.actual.yml
|
||||
|
||||
- name: Create PR in Flathub repo
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
|
||||
commit-message: 'Update Actual flatpak to version ${{ needs.build.outputs.version }}'
|
||||
branch: 'release/${{ needs.build.outputs.version }}'
|
||||
title: 'Update Actual flatpak to version ${{ needs.build.outputs.version }}'
|
||||
body: |
|
||||
This PR updates the Actual desktop flatpak to version ${{ needs.build.outputs.version }}.
|
||||
|
||||
:link: [View release notes](https://actualbudget.org/blog/release-${{ needs.build.outputs.version }})
|
||||
reviewers: 'jfdoming,MatissJanis,youngcw' # The core team that have accepted the collaborator access to the Flathub repo
|
||||
|
||||
26
.github/workflows/electron-pr.yml
vendored
26
.github/workflows/electron-pr.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@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 +42,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: |
|
||||
@@ -58,63 +56,65 @@ jobs:
|
||||
|
||||
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"
|
||||
- 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 +122,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: |
|
||||
|
||||
48
.github/workflows/fork-pr-welcome.yml
vendored
48
.github/workflows/fork-pr-welcome.yml
vendored
@@ -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/).
|
||||
16
.github/workflows/generate-release-pr.yml
vendored
16
.github/workflows/generate-release-pr.yml
vendored
@@ -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)'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
37
.github/workflows/merge-freeze-unfreeze.yml
vendored
37
.github/workflows/merge-freeze-unfreeze.yml
vendored
@@ -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."
|
||||
2
.github/workflows/netlify-release.yml
vendored
2
.github/workflows/netlify-release.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Repository Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
126
.github/workflows/publish-flathub.yml
vendored
126
.github/workflows/publish-flathub.yml
vendored
@@ -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'
|
||||
27
.github/workflows/publish-nightly-electron.yml
vendored
27
.github/workflows/publish-nightly-electron.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: github.event.repository.fork == false
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
run: pip.exe install setuptools
|
||||
|
||||
@@ -39,9 +39,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: |
|
||||
@@ -56,14 +53,16 @@ jobs:
|
||||
|
||||
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"
|
||||
- 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: |
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
33
.github/workflows/publish-npm-packages.yml
vendored
33
.github/workflows/publish-npm-packages.yml
vendored
@@ -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 }}
|
||||
|
||||
2
.github/workflows/release-notes.yml
vendored
2
.github/workflows/release-notes.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Get changed files
|
||||
|
||||
51
.github/workflows/size-compare.yml
vendored
51
.github/workflows/size-compare.yml
vendored
@@ -35,7 +35,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 +57,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 +72,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 +89,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 +98,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 +107,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 +115,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 +136,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 +148,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 --->'
|
||||
|
||||
6
.github/workflows/stale.yml
vendored
6
.github/workflows/stale.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@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
|
||||
|
||||
6
.github/workflows/vrt-update-apply.yml
vendored
6
.github/workflows/vrt-update-apply.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
steps:
|
||||
- name: Download patch artifact
|
||||
uses: actions/download-artifact@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,7 +54,7 @@ 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 }}
|
||||
|
||||
8
.github/workflows/vrt-update-generate.yml
vendored
8
.github/workflows/vrt-update-generate.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
github.event.issue.pull_request &&
|
||||
startsWith(github.event.comment.body, '/update-vrt')
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.56.0-jammy
|
||||
steps:
|
||||
- name: Get PR details
|
||||
id: pr
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
core.setOutput('head_ref', pr.head.ref);
|
||||
core.setOutput('head_repo', pr.head.repo.full_name);
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: ${{ steps.pr.outputs.head_sha }}
|
||||
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
|
||||
- name: Upload patch artifact
|
||||
if: steps.create-patch.outputs.has_changes == 'true'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: vrt-patch-${{ github.event.issue.number }}
|
||||
path: vrt-update.patch
|
||||
@@ -129,7 +129,7 @@ jobs:
|
||||
|
||||
- name: Upload PR metadata
|
||||
if: steps.create-patch.outputs.has_changes == 'true'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: vrt-metadata-${{ github.event.issue.number }}
|
||||
path: pr-metadata/
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -33,9 +33,7 @@ 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
|
||||
@@ -78,13 +76,3 @@ build/
|
||||
|
||||
# Lage cache
|
||||
.lage/
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
# cli config when testing locally
|
||||
.actualrc.json
|
||||
.actualrc
|
||||
.actualrc.yaml
|
||||
.actualrc.yml
|
||||
actual.config.js
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -4,31 +4,7 @@
|
||||
"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
|
||||
}
|
||||
"ignorePatterns": [
|
||||
"packages/docs/*" // TOOD: fixme; temporary
|
||||
]
|
||||
}
|
||||
|
||||
362
.oxlintrc.json
362
.oxlintrc.json
@@ -15,77 +15,113 @@
|
||||
"vi": "readonly",
|
||||
"backend": "readonly",
|
||||
"importScripts": "readonly",
|
||||
"FS": "readonly"
|
||||
"FS": "readonly" // TODO: remove this
|
||||
},
|
||||
"rules": {
|
||||
// TODO fix all these and re-enable
|
||||
"jsx-a11y/click-events-have-key-events": "off",
|
||||
"jsx-a11y/prefer-tag-over-role": "off",
|
||||
"jsx-a11y/tabindex-no-positive": "off",
|
||||
|
||||
// Import sorting
|
||||
"perfectionist/sort-named-imports": [
|
||||
"error",
|
||||
// TODO replace once oxfmt supports this: https://github.com/oxc-project/oxc/issues/17076
|
||||
"perfectionist/sort-imports": [
|
||||
"warn",
|
||||
{
|
||||
"groups": ["value-import", "type-import"]
|
||||
"groups": [
|
||||
"react",
|
||||
"builtin",
|
||||
"external",
|
||||
"loot-core",
|
||||
"parent",
|
||||
"sibling",
|
||||
"index",
|
||||
"desktop-client"
|
||||
],
|
||||
"customGroups": [
|
||||
{
|
||||
"groupName": "react",
|
||||
"elementNamePattern": "^react(-.*)?$"
|
||||
},
|
||||
{
|
||||
"groupName": "loot-core",
|
||||
"elementNamePattern": "^loot-core"
|
||||
},
|
||||
{
|
||||
"groupName": "desktop-client",
|
||||
"elementNamePattern": "^@desktop-client"
|
||||
}
|
||||
],
|
||||
"newlinesBetween": "always"
|
||||
}
|
||||
],
|
||||
|
||||
// Actual rules
|
||||
"actual/typography": "error",
|
||||
"actual/typography": "warn",
|
||||
"actual/no-untranslated-strings": "error",
|
||||
"actual/prefer-trans-over-t": "error",
|
||||
"actual/prefer-if-statement": "error",
|
||||
"actual/prefer-if-statement": "warn",
|
||||
"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",
|
||||
"actual/object-shorthand-properties": "warn",
|
||||
"actual/prefer-const": "warn",
|
||||
"actual/no-anchor-tag": "warn",
|
||||
"actual/no-react-default-import": "warn",
|
||||
|
||||
// JSX A11y rules
|
||||
"jsx-a11y/no-autofocus": [
|
||||
"error",
|
||||
"warn",
|
||||
{
|
||||
"ignoreNonDOM": true
|
||||
}
|
||||
],
|
||||
"jsx-a11y/alt-text": "error",
|
||||
"jsx-a11y/anchor-has-content": "error",
|
||||
"jsx-a11y/alt-text": "warn",
|
||||
"jsx-a11y/anchor-has-content": "warn",
|
||||
"jsx-a11y/anchor-is-valid": [
|
||||
"error",
|
||||
"warn",
|
||||
{
|
||||
"aspects": ["noHref", "invalidHref"]
|
||||
}
|
||||
],
|
||||
"jsx-a11y/aria-activedescendant-has-tabindex": "error",
|
||||
"jsx-a11y/aria-props": "error",
|
||||
"jsx-a11y/aria-proptypes": "error",
|
||||
"jsx-a11y/aria-activedescendant-has-tabindex": "warn",
|
||||
"jsx-a11y/aria-props": "warn",
|
||||
"jsx-a11y/aria-proptypes": "warn",
|
||||
"jsx-a11y/aria-role": [
|
||||
"error",
|
||||
"warn",
|
||||
{
|
||||
"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",
|
||||
"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",
|
||||
|
||||
// Typescript rules
|
||||
"typescript/ban-ts-comment": ["error"],
|
||||
"typescript/consistent-type-definitions": ["error", "type"],
|
||||
"typescript/ban-ts-comment": [
|
||||
"warn",
|
||||
{
|
||||
// TODO: remove this
|
||||
"ts-ignore": "allow-with-description"
|
||||
}
|
||||
],
|
||||
"typescript/consistent-type-definitions": ["warn", "type"],
|
||||
"typescript/consistent-type-imports": [
|
||||
"error",
|
||||
"warn",
|
||||
{
|
||||
"prefer": "type-imports",
|
||||
"fixStyle": "inline-type-imports"
|
||||
}
|
||||
],
|
||||
"typescript/no-implied-eval": "error",
|
||||
"typescript/no-explicit-any": "error",
|
||||
"typescript/no-implied-eval": "warn",
|
||||
"typescript/no-explicit-any": "warn",
|
||||
"typescript/no-restricted-types": [
|
||||
"error",
|
||||
"warn",
|
||||
{
|
||||
"types": {
|
||||
// forbid FC as superfluous
|
||||
@@ -98,156 +134,141 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"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
|
||||
"typescript/no-var-requires": "warn",
|
||||
|
||||
// Import rules
|
||||
"import/consistent-type-specifier-style": "error",
|
||||
"import/first": "error",
|
||||
"import/no-amd": "error",
|
||||
"import/no-default-export": "error",
|
||||
"import/no-default-export": "warn",
|
||||
"import/no-webpack-loader-syntax": "error",
|
||||
"import/no-useless-path-segments": "error",
|
||||
"import/no-unresolved": "error",
|
||||
"import/no-unused-modules": "error",
|
||||
"import/no-useless-path-segments": "warn",
|
||||
"import/no-unresolved": "warn",
|
||||
"import/no-unused-modules": "warn",
|
||||
"import/no-duplicates": [
|
||||
"error",
|
||||
"warn",
|
||||
{
|
||||
"prefer-inline": false
|
||||
"prefer-inline": true
|
||||
}
|
||||
],
|
||||
|
||||
// React rules
|
||||
"react/exhaustive-deps": [
|
||||
"error",
|
||||
"warn",
|
||||
{
|
||||
"additionalHooks": "(^useQuery$|^useEffectAfterMount$)"
|
||||
"additionalHooks": "(useQuery|useEffectAfterMount)"
|
||||
}
|
||||
],
|
||||
"react/jsx-curly-brace-presence": "error",
|
||||
"react/jsx-curly-brace-presence": "warn",
|
||||
"react/jsx-filename-extension": [
|
||||
"error",
|
||||
"warn",
|
||||
{
|
||||
"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-comment-textnodes": "warn",
|
||||
"react/jsx-no-duplicate-props": "warn",
|
||||
"react/jsx-no-target-blank": "warn",
|
||||
"react/jsx-no-undef": "error",
|
||||
"react/jsx-no-useless-fragment": "error",
|
||||
"react/jsx-no-useless-fragment": "warn",
|
||||
"react/jsx-pascal-case": [
|
||||
"error",
|
||||
"warn",
|
||||
{
|
||||
"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/no-danger-with-children": "warn",
|
||||
"react/no-direct-mutation-state": "warn",
|
||||
"react/no-is-mounted": "warn",
|
||||
"react/no-unstable-nested-components": "warn",
|
||||
"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",
|
||||
"react/self-closing-comp": "warn",
|
||||
"react/style-prop-object": "warn",
|
||||
"react/jsx-boolean-value": "warn",
|
||||
|
||||
// ESLint rules
|
||||
"eslint/array-callback-return": "error",
|
||||
"eslint/curly": ["error", "multi-line", "consistent"],
|
||||
"eslint/array-callback-return": "warn",
|
||||
// "eslint/curly": ["warn", "multi-line", "consistent"], // TODO: re-enable? this rule is really slow
|
||||
"eslint/default-case": [
|
||||
"error",
|
||||
"warn",
|
||||
{
|
||||
"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/eqeqeq": ["warn", "smart"],
|
||||
"eslint/no-array-constructor": "warn",
|
||||
"eslint/no-caller": "warn",
|
||||
"eslint/no-cond-assign": ["warn", "except-parens"],
|
||||
"eslint/no-const-assign": "warn",
|
||||
"eslint/no-control-regex": "warn",
|
||||
"eslint/no-delete-var": "warn",
|
||||
"eslint/no-dupe-class-members": "warn",
|
||||
"eslint/no-dupe-keys": "warn",
|
||||
"eslint/no-duplicate-case": "warn",
|
||||
"eslint/no-empty-character-class": "warn",
|
||||
"eslint/no-empty-function": "warn",
|
||||
"eslint/no-empty-pattern": "warn",
|
||||
"eslint/no-eval": "warn",
|
||||
"eslint/no-ex-assign": "warn",
|
||||
"eslint/no-extend-native": "warn",
|
||||
"eslint/no-extra-bind": "warn",
|
||||
"eslint/no-extra-label": "warn",
|
||||
"eslint/no-fallthrough": "warn",
|
||||
"eslint/no-func-assign": "warn",
|
||||
"eslint/no-invalid-regexp": "warn",
|
||||
"eslint/no-iterator": "warn",
|
||||
"eslint/no-label-var": "warn",
|
||||
"eslint/no-var": "warn",
|
||||
"eslint/no-labels": [
|
||||
"error",
|
||||
"warn",
|
||||
{
|
||||
"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-new-func": "warn",
|
||||
"eslint/no-script-url": "warn",
|
||||
"eslint/no-self-assign": "warn",
|
||||
"eslint/no-self-compare": "warn",
|
||||
"eslint/no-sequences": "warn",
|
||||
"eslint/no-shadow-restricted-names": "warn",
|
||||
"eslint/no-sparse-arrays": "warn",
|
||||
"eslint/no-template-curly-in-string": "warn",
|
||||
"eslint/no-this-before-super": "warn",
|
||||
"eslint/no-throw-literal": "warn",
|
||||
"eslint/no-unreachable": "warn",
|
||||
"eslint/no-obj-calls": "warn",
|
||||
"eslint/no-new-wrappers": "warn",
|
||||
"eslint/no-unsafe-negation": "warn",
|
||||
"eslint/no-multi-str": "warn",
|
||||
"eslint/no-global-assign": "warn",
|
||||
"eslint/no-lone-blocks": "warn",
|
||||
"eslint/no-unused-labels": "warn",
|
||||
"eslint/no-object-constructor": "warn",
|
||||
"eslint/no-new-native-nonconstructor": "warn",
|
||||
"eslint/no-redeclare": "warn",
|
||||
"eslint/no-useless-computed-key": "warn",
|
||||
"eslint/no-useless-concat": "warn",
|
||||
"eslint/no-useless-escape": "warn",
|
||||
"eslint/require-yield": "warn",
|
||||
"eslint/getter-return": "warn",
|
||||
"eslint/unicode-bom": ["warn", "never"],
|
||||
"eslint/no-use-isnan": "warn",
|
||||
"eslint/valid-typeof": "warn",
|
||||
"eslint/no-useless-rename": [
|
||||
"error",
|
||||
"warn",
|
||||
{
|
||||
"ignoreDestructuring": false,
|
||||
"ignoreImport": false,
|
||||
"ignoreExport": false
|
||||
}
|
||||
],
|
||||
"eslint/no-with": "error",
|
||||
"eslint/no-regex-spaces": "error",
|
||||
"eslint/no-with": "warn",
|
||||
"eslint/no-regex-spaces": "warn",
|
||||
"eslint/no-restricted-globals": [
|
||||
"error",
|
||||
"warn",
|
||||
|
||||
// https://github.com/facebook/create-react-app/tree/main/packages/confusing-browser-globals
|
||||
"addEventListener",
|
||||
@@ -309,7 +330,7 @@
|
||||
"top"
|
||||
],
|
||||
"eslint/no-restricted-imports": [
|
||||
"error",
|
||||
"warn",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
@@ -348,10 +369,6 @@
|
||||
"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`"
|
||||
@@ -359,15 +376,61 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"eslint/no-useless-constructor": "error",
|
||||
"eslint/no-undef": "error",
|
||||
"eslint/no-unused-expressions": "error"
|
||||
"eslint/no-useless-constructor": "warn",
|
||||
"eslint/no-undef": "warn",
|
||||
"eslint/no-unused-expressions": "warn"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["packages/desktop-electron/**/*"],
|
||||
// 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/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": {
|
||||
"react/rules-of-hooks": "off"
|
||||
"react/exhaustive-deps": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -404,18 +467,19 @@
|
||||
"typescript-paths/absolute-import": ["error", { "enableAlias": false }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["packages/desktop-client/src/style/themes/*"],
|
||||
"rules": {
|
||||
"eslint/no-restricted-imports": "off"
|
||||
}
|
||||
},
|
||||
// TODO: enable these
|
||||
{
|
||||
"files": [
|
||||
"packages/desktop-client/src/components/admin/UserAccess/UserAccess.tsx",
|
||||
"packages/desktop-client/src/components/admin/UserDirectory/UserDirectory.tsx",
|
||||
"packages/desktop-client/src/components/budget/BudgetCategories.tsx",
|
||||
"packages/desktop-client/src/components/budget/envelope/BalanceMovementMenu.tsx",
|
||||
"packages/desktop-client/src/components/ManageRules.tsx",
|
||||
"packages/desktop-client/src/components/mobile/budget/ExpenseGroupList.tsx",
|
||||
"packages/desktop-client/src/components/modals/EditFieldModal.tsx",
|
||||
"packages/desktop-client/src/components/reports/reports/Calendar.tsx",
|
||||
"packages/desktop-client/src/components/schedules/ScheduleLink.tsx",
|
||||
"packages/desktop-client/src/components/ServerContext.tsx",
|
||||
"packages/desktop-client/src/components/table.tsx"
|
||||
],
|
||||
"rules": {
|
||||
|
||||
73
AGENTS.md
73
AGENTS.md
@@ -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.
|
||||
|
||||
@@ -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/)
|
||||
@@ -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"
|
||||
|
||||
@@ -51,17 +51,14 @@ fi
|
||||
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
|
||||
|
||||
(
|
||||
|
||||
@@ -37,7 +37,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' },
|
||||
],
|
||||
},
|
||||
@@ -160,8 +160,7 @@ category: ${type}
|
||||
authors: [${username}]
|
||||
---
|
||||
|
||||
${summary}
|
||||
`;
|
||||
${summary}`;
|
||||
}
|
||||
|
||||
// simple exec that fails silently and returns an empty string on failure
|
||||
@@ -178,4 +177,4 @@ async function execAsync(cmd: string, errorLog?: string): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
void run();
|
||||
run();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/**'],
|
||||
|
||||
47
package.json
47
package.json
@@ -25,25 +25,21 @@
|
||||
"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",
|
||||
@@ -55,44 +51,39 @@
|
||||
"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",
|
||||
"lint:fix": "oxfmt . && oxlint --deny-warnings --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",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-plugin-perfectionist": "^5.6.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-perfectionist": "^4.15.1",
|
||||
"eslint-plugin-typescript-paths": "^0.0.33",
|
||||
"html-to-image": "^1.11.13",
|
||||
"husky": "^9.1.7",
|
||||
"lage": "^2.14.19",
|
||||
"lint-staged": "^16.3.2",
|
||||
"minimatch": "^10.2.4",
|
||||
"lage": "^2.14.15",
|
||||
"lint-staged": "^16.2.7",
|
||||
"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.22.0",
|
||||
"oxlint": "^1.37.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-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"
|
||||
},
|
||||
@@ -101,7 +92,7 @@
|
||||
"oxfmt --no-error-on-unmatched-pattern"
|
||||
],
|
||||
"*.{js,mjs,jsx,ts,tsx}": [
|
||||
"oxlint --fix --type-aware --quiet"
|
||||
"oxlint --deny-warnings --fix"
|
||||
]
|
||||
},
|
||||
"browserslist": [
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
class Query {
|
||||
/** @type {import('loot-core/shared/query').QueryState} */
|
||||
state;
|
||||
|
||||
constructor(state) {
|
||||
this.state = {
|
||||
filterExpressions: state.filterExpressions || [],
|
||||
|
||||
@@ -3,18 +3,25 @@ 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
|
||||
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 +32,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
7
packages/api/injected.js
Normal file
@@ -0,0 +1,7 @@
|
||||
// TODO: comment on why it works this way
|
||||
|
||||
export let send;
|
||||
|
||||
export function override(sendImplementation) {
|
||||
send = sendImplementation;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 type 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'][],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actual-app/api",
|
||||
"version": "26.3.0",
|
||||
"version": "26.1.0",
|
||||
"description": "An API for Actual",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
@@ -9,41 +9,27 @@
|
||||
],
|
||||
"main": "dist/index.js",
|
||||
"types": "@types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"development": "./index.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./@types/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "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 clean && 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"
|
||||
"tsc-alias": "^1.8.16",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
{
|
||||
"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",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node10",
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"declarationDir": "@types",
|
||||
"tsBuildInfoFile": "dist/.tsbuildinfo",
|
||||
"plugins": [{ "name": "typescript-strict-plugin", "paths": ["."] }]
|
||||
"paths": {
|
||||
"loot-core/*": ["./@types/loot-core/src/*"]
|
||||
}
|
||||
},
|
||||
"references": [{ "path": "../crdt" }, { "path": "../loot-core" }],
|
||||
"include": ["."],
|
||||
"exclude": ["**/node_modules/*", "dist", "@types", "*.test.ts", "*.config.ts"]
|
||||
"exclude": ["**/node_modules/*", "dist", "@types", "*.test.ts"]
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
declare module 'hyperformula/i18n/languages/enUS';
|
||||
declare module '*.pegjs';
|
||||
1
packages/api/typings/pegjs.d.ts
vendored
1
packages/api/typings/pegjs.d.ts
vendored
@@ -1 +0,0 @@
|
||||
declare module '*.pegjs';
|
||||
@@ -1,4 +1,5 @@
|
||||
import { lib } from '@actual-app/core/server/main';
|
||||
// @ts-ignore: bundle not available until we build it
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
10
packages/api/vitest.config.ts
Normal file
10
packages/api/vitest.config.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
1
packages/ci-actions/.gitignore
vendored
1
packages/ci-actions/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
dist/*
|
||||
@@ -174,7 +174,6 @@ function parseArgs(argv) {
|
||||
return {
|
||||
sections,
|
||||
identifier: getSingleValue(args, 'identifier') ?? 'bundle-stats',
|
||||
format: getSingleValue(args, 'format') ?? 'pr-body',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
63
packages/ci-actions/bin/get-next-package-version.ts → packages/ci-actions/bin/get-next-package-version.js
Normal file → Executable file
63
packages/ci-actions/bin/get-next-package-version.ts → packages/ci-actions/bin/get-next-package-version.js
Normal file → Executable file
@@ -2,13 +2,13 @@
|
||||
|
||||
// This script is used in GitHub Actions to get the next version based on the current package.json version.
|
||||
// It supports three types of versioning: nightly, hotfix, and monthly.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { parseArgs } from 'node:util';
|
||||
|
||||
import {
|
||||
getNextVersion,
|
||||
isValidVersionType,
|
||||
} from '../src/versions/get-next-package-version';
|
||||
import { getNextVersion } from '../src/versions/get-next-package-version.js';
|
||||
|
||||
const args = process.argv;
|
||||
|
||||
const options = {
|
||||
'package-json': {
|
||||
@@ -19,63 +19,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 +67,6 @@ try {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
console.error('Error:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -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" "$@"
|
||||
@@ -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) {
|
||||
@@ -45,9 +41,6 @@ function parseArgs(argv) {
|
||||
case '--identifier':
|
||||
args.identifier = value;
|
||||
break;
|
||||
case '--target':
|
||||
args.target = value;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown argument "${key}".`);
|
||||
}
|
||||
@@ -61,12 +54,6 @@ function parseArgs(argv) {
|
||||
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".`,
|
||||
);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -3,18 +3,9 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"tsx": "bin/tsx",
|
||||
"test": "vitest --run",
|
||||
"typecheck": "tsgo -b"
|
||||
"test": "vitest --run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"extensionless": "^2.0.6",
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
"extensionless": {
|
||||
"lookFor": [
|
||||
"ts"
|
||||
]
|
||||
"vitest": "^4.0.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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".',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
@@ -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"]
|
||||
}
|
||||
7
packages/cli/.gitignore
vendored
7
packages/cli/.gitignore
vendored
@@ -1,7 +0,0 @@
|
||||
dist
|
||||
coverage
|
||||
.actualrc.json
|
||||
.actualrc
|
||||
.actualrc.yaml
|
||||
.actualrc.yml
|
||||
actual.config.js
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import { Option } from 'commander';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { withConnection } from '../connection';
|
||||
import { printOutput } from '../output';
|
||||
|
||||
export function registerServerCommand(program: Command) {
|
||||
const server = program.command('server').description('Server utilities');
|
||||
|
||||
server
|
||||
.command('version')
|
||||
.description('Get server version')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const version = await api.getServerVersion();
|
||||
printOutput({ version }, opts.format);
|
||||
},
|
||||
{ loadBudget: false },
|
||||
);
|
||||
});
|
||||
|
||||
server
|
||||
.command('get-id')
|
||||
.description('Get entity ID by name')
|
||||
.addOption(
|
||||
new Option('--type <type>', 'Entity type')
|
||||
.choices(['accounts', 'categories', 'payees', 'schedules'])
|
||||
.makeOptionMandatory(),
|
||||
)
|
||||
.requiredOption('--name <name>', 'Entity name')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const id = await api.getIDByName(cmdOpts.type, cmdOpts.name);
|
||||
printOutput(
|
||||
{ id, type: cmdOpts.type, name: cmdOpts.name },
|
||||
opts.format,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
server
|
||||
.command('bank-sync')
|
||||
.description('Run bank synchronization')
|
||||
.option('--account <id>', 'Specific account ID to sync')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const args = cmdOpts.account
|
||||
? { accountId: cmdOpts.account }
|
||||
: undefined;
|
||||
await api.runBankSync(args);
|
||||
printOutput({ success: true }, opts.format);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { withConnection } from '../connection';
|
||||
import { printOutput } from '../output';
|
||||
|
||||
export function registerTagsCommand(program: Command) {
|
||||
const tags = program.command('tags').description('Manage tags');
|
||||
|
||||
tags
|
||||
.command('list')
|
||||
.description('List all tags')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getTags();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
tags
|
||||
.command('create')
|
||||
.description('Create a new tag')
|
||||
.requiredOption('--tag <tag>', 'Tag name')
|
||||
.option('--color <color>', 'Tag color')
|
||||
.option('--description <description>', 'Tag description')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const id = await api.createTag({
|
||||
tag: cmdOpts.tag,
|
||||
color: cmdOpts.color,
|
||||
description: cmdOpts.description,
|
||||
});
|
||||
printOutput({ id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
tags
|
||||
.command('update <id>')
|
||||
.description('Update a tag')
|
||||
.option('--tag <tag>', 'New tag name')
|
||||
.option('--color <color>', 'New tag color')
|
||||
.option('--description <description>', 'New tag description')
|
||||
.action(async (id: string, cmdOpts) => {
|
||||
const fields: Record<string, unknown> = {};
|
||||
if (cmdOpts.tag !== undefined) fields.tag = cmdOpts.tag;
|
||||
if (cmdOpts.color !== undefined) fields.color = cmdOpts.color;
|
||||
if (cmdOpts.description !== undefined) {
|
||||
fields.description = cmdOpts.description;
|
||||
}
|
||||
if (Object.keys(fields).length === 0) {
|
||||
throw new Error(
|
||||
'At least one of --tag, --color, or --description is required',
|
||||
);
|
||||
}
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.updateTag(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
tags
|
||||
.command('delete <id>')
|
||||
.description('Delete a tag')
|
||||
.action(async (id: string) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.deleteTag(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,114 +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 registerTransactionsCommand(program: Command) {
|
||||
const transactions = program
|
||||
.command('transactions')
|
||||
.description('Manage transactions');
|
||||
|
||||
transactions
|
||||
.command('list')
|
||||
.description('List transactions for an account')
|
||||
.requiredOption('--account <id>', 'Account ID')
|
||||
.requiredOption('--start <date>', 'Start date (YYYY-MM-DD)')
|
||||
.requiredOption('--end <date>', 'End date (YYYY-MM-DD)')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getTransactions(
|
||||
cmdOpts.account,
|
||||
cmdOpts.start,
|
||||
cmdOpts.end,
|
||||
);
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
transactions
|
||||
.command('add')
|
||||
.description('Add transactions to an account')
|
||||
.requiredOption('--account <id>', 'Account ID')
|
||||
.option('--data <json>', 'Transaction data as JSON array')
|
||||
.option(
|
||||
'--file <path>',
|
||||
'Read transaction data from JSON file (use - for stdin)',
|
||||
)
|
||||
.option('--learn-categories', 'Learn category assignments', false)
|
||||
.option('--run-transfers', 'Process transfers', false)
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const transactions = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.addTransactions
|
||||
>[1];
|
||||
const result = await api.addTransactions(
|
||||
cmdOpts.account,
|
||||
transactions,
|
||||
{
|
||||
learnCategories: cmdOpts.learnCategories,
|
||||
runTransfers: cmdOpts.runTransfers,
|
||||
},
|
||||
);
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
transactions
|
||||
.command('import')
|
||||
.description('Import transactions to an account')
|
||||
.requiredOption('--account <id>', 'Account ID')
|
||||
.option('--data <json>', 'Transaction data as JSON array')
|
||||
.option(
|
||||
'--file <path>',
|
||||
'Read transaction data from JSON file (use - for stdin)',
|
||||
)
|
||||
.option('--dry-run', 'Preview without importing', false)
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const transactions = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.importTransactions
|
||||
>[1];
|
||||
const result = await api.importTransactions(
|
||||
cmdOpts.account,
|
||||
transactions,
|
||||
{
|
||||
defaultCleared: true,
|
||||
dryRun: cmdOpts.dryRun,
|
||||
},
|
||||
);
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
transactions
|
||||
.command('update <id>')
|
||||
.description('Update a transaction')
|
||||
.option('--data <json>', 'Fields to update as JSON')
|
||||
.option('--file <path>', 'Read fields from JSON file (use - for stdin)')
|
||||
.action(async (id: string, cmdOpts) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const fields = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.updateTransaction
|
||||
>[1];
|
||||
await api.updateTransaction(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
transactions
|
||||
.command('delete <id>')
|
||||
.description('Delete a transaction')
|
||||
.action(async (id: string) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.deleteTransaction(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { resolveConfig } from './config';
|
||||
|
||||
const mockSearch = vi.fn().mockResolvedValue(null);
|
||||
|
||||
vi.mock('cosmiconfig', () => ({
|
||||
cosmiconfig: () => ({
|
||||
search: (...args: unknown[]) => mockSearch(...args),
|
||||
}),
|
||||
}));
|
||||
|
||||
function mockConfigFile(config: Record<string, unknown> | null) {
|
||||
if (config) {
|
||||
mockSearch.mockResolvedValue({ config, isEmpty: false });
|
||||
} else {
|
||||
mockSearch.mockResolvedValue(null);
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveConfig', () => {
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
const envKeys = [
|
||||
'ACTUAL_SERVER_URL',
|
||||
'ACTUAL_PASSWORD',
|
||||
'ACTUAL_SESSION_TOKEN',
|
||||
'ACTUAL_SYNC_ID',
|
||||
'ACTUAL_DATA_DIR',
|
||||
'ACTUAL_ENCRYPTION_PASSWORD',
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of envKeys) {
|
||||
savedEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
mockConfigFile(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of envKeys) {
|
||||
if (savedEnv[key] !== undefined) {
|
||||
process.env[key] = savedEnv[key];
|
||||
} else {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe('priority chain', () => {
|
||||
it('CLI opts take highest priority', async () => {
|
||||
process.env.ACTUAL_SERVER_URL = 'http://env';
|
||||
process.env.ACTUAL_PASSWORD = 'envpw';
|
||||
process.env.ACTUAL_ENCRYPTION_PASSWORD = 'env-enc';
|
||||
mockConfigFile({
|
||||
serverUrl: 'http://file',
|
||||
password: 'filepw',
|
||||
encryptionPassword: 'file-enc',
|
||||
});
|
||||
|
||||
const config = await resolveConfig({
|
||||
serverUrl: 'http://cli',
|
||||
password: 'clipw',
|
||||
encryptionPassword: 'cli-enc',
|
||||
});
|
||||
|
||||
expect(config.serverUrl).toBe('http://cli');
|
||||
expect(config.password).toBe('clipw');
|
||||
expect(config.encryptionPassword).toBe('cli-enc');
|
||||
});
|
||||
|
||||
it('env vars override file config', async () => {
|
||||
process.env.ACTUAL_SERVER_URL = 'http://env';
|
||||
process.env.ACTUAL_PASSWORD = 'envpw';
|
||||
process.env.ACTUAL_ENCRYPTION_PASSWORD = 'env-enc';
|
||||
mockConfigFile({
|
||||
serverUrl: 'http://file',
|
||||
password: 'filepw',
|
||||
encryptionPassword: 'file-enc',
|
||||
});
|
||||
|
||||
const config = await resolveConfig({});
|
||||
|
||||
expect(config.serverUrl).toBe('http://env');
|
||||
expect(config.password).toBe('envpw');
|
||||
expect(config.encryptionPassword).toBe('env-enc');
|
||||
});
|
||||
|
||||
it('file config is used when no CLI opts or env vars', async () => {
|
||||
mockConfigFile({
|
||||
serverUrl: 'http://file',
|
||||
password: 'filepw',
|
||||
syncId: 'budget-1',
|
||||
encryptionPassword: 'file-enc',
|
||||
});
|
||||
|
||||
const config = await resolveConfig({});
|
||||
|
||||
expect(config.serverUrl).toBe('http://file');
|
||||
expect(config.password).toBe('filepw');
|
||||
expect(config.syncId).toBe('budget-1');
|
||||
expect(config.encryptionPassword).toBe('file-enc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaults', () => {
|
||||
it('dataDir defaults to ~/.actual-cli/data', async () => {
|
||||
const config = await resolveConfig({
|
||||
serverUrl: 'http://test',
|
||||
password: 'pw',
|
||||
});
|
||||
|
||||
expect(config.dataDir).toBe(join(homedir(), '.actual-cli', 'data'));
|
||||
});
|
||||
|
||||
it('CLI opt overrides default dataDir', async () => {
|
||||
const config = await resolveConfig({
|
||||
serverUrl: 'http://test',
|
||||
password: 'pw',
|
||||
dataDir: '/custom/dir',
|
||||
});
|
||||
|
||||
expect(config.dataDir).toBe('/custom/dir');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
it('throws when serverUrl is missing', async () => {
|
||||
await expect(resolveConfig({ password: 'pw' })).rejects.toThrow(
|
||||
'Server URL is required',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when neither password nor sessionToken provided', async () => {
|
||||
await expect(resolveConfig({ serverUrl: 'http://test' })).rejects.toThrow(
|
||||
'Authentication required',
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts sessionToken without password', async () => {
|
||||
const config = await resolveConfig({
|
||||
serverUrl: 'http://test',
|
||||
sessionToken: 'tok',
|
||||
});
|
||||
|
||||
expect(config.sessionToken).toBe('tok');
|
||||
expect(config.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts password without sessionToken', async () => {
|
||||
const config = await resolveConfig({
|
||||
serverUrl: 'http://test',
|
||||
password: 'pw',
|
||||
});
|
||||
|
||||
expect(config.password).toBe('pw');
|
||||
expect(config.sessionToken).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cosmiconfig handling', () => {
|
||||
it('handles null result (no config file found)', async () => {
|
||||
mockConfigFile(null);
|
||||
|
||||
const config = await resolveConfig({
|
||||
serverUrl: 'http://test',
|
||||
password: 'pw',
|
||||
});
|
||||
|
||||
expect(config.serverUrl).toBe('http://test');
|
||||
});
|
||||
|
||||
it('handles isEmpty result', async () => {
|
||||
mockSearch.mockResolvedValue({ config: {}, isEmpty: true });
|
||||
|
||||
const config = await resolveConfig({
|
||||
serverUrl: 'http://test',
|
||||
password: 'pw',
|
||||
});
|
||||
|
||||
expect(config.serverUrl).toBe('http://test');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,141 +0,0 @@
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { cosmiconfig } from 'cosmiconfig';
|
||||
|
||||
export type CliConfig = {
|
||||
serverUrl: string;
|
||||
password?: string;
|
||||
sessionToken?: string;
|
||||
syncId?: string;
|
||||
dataDir: string;
|
||||
encryptionPassword?: string;
|
||||
};
|
||||
|
||||
export type CliGlobalOpts = {
|
||||
serverUrl?: string;
|
||||
password?: string;
|
||||
sessionToken?: string;
|
||||
syncId?: string;
|
||||
dataDir?: string;
|
||||
encryptionPassword?: string;
|
||||
format?: 'json' | 'table' | 'csv';
|
||||
verbose?: boolean;
|
||||
};
|
||||
|
||||
type ConfigFileContent = {
|
||||
serverUrl?: string;
|
||||
password?: string;
|
||||
sessionToken?: string;
|
||||
syncId?: string;
|
||||
dataDir?: string;
|
||||
encryptionPassword?: string;
|
||||
};
|
||||
|
||||
const configFileKeys: readonly string[] = [
|
||||
'serverUrl',
|
||||
'password',
|
||||
'sessionToken',
|
||||
'syncId',
|
||||
'dataDir',
|
||||
'encryptionPassword',
|
||||
];
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function validateConfigFileContent(value: unknown): ConfigFileContent {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error(
|
||||
'Invalid config file: expected an object with keys: ' +
|
||||
configFileKeys.join(', '),
|
||||
);
|
||||
}
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!configFileKeys.includes(key)) {
|
||||
throw new Error(`Invalid config file: unknown key "${key}"`);
|
||||
}
|
||||
if (value[key] !== undefined && typeof value[key] !== 'string') {
|
||||
throw new Error(
|
||||
`Invalid config file: key "${key}" must be a string, got ${typeof value[key]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return value as ConfigFileContent;
|
||||
}
|
||||
|
||||
async function loadConfigFile(): Promise<ConfigFileContent> {
|
||||
const explorer = cosmiconfig('actual', {
|
||||
searchPlaces: [
|
||||
'package.json',
|
||||
'.actualrc',
|
||||
'.actualrc.json',
|
||||
'.actualrc.yaml',
|
||||
'.actualrc.yml',
|
||||
'actual.config.json',
|
||||
'actual.config.yaml',
|
||||
'actual.config.yml',
|
||||
],
|
||||
});
|
||||
const result = await explorer.search();
|
||||
if (result && !result.isEmpty) {
|
||||
return validateConfigFileContent(result.config);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export async function resolveConfig(
|
||||
cliOpts: CliGlobalOpts,
|
||||
): Promise<CliConfig> {
|
||||
const fileConfig = await loadConfigFile();
|
||||
|
||||
const serverUrl =
|
||||
cliOpts.serverUrl ??
|
||||
process.env.ACTUAL_SERVER_URL ??
|
||||
fileConfig.serverUrl ??
|
||||
'';
|
||||
|
||||
const password =
|
||||
cliOpts.password ?? process.env.ACTUAL_PASSWORD ?? fileConfig.password;
|
||||
|
||||
const sessionToken =
|
||||
cliOpts.sessionToken ??
|
||||
process.env.ACTUAL_SESSION_TOKEN ??
|
||||
fileConfig.sessionToken;
|
||||
|
||||
const syncId =
|
||||
cliOpts.syncId ?? process.env.ACTUAL_SYNC_ID ?? fileConfig.syncId;
|
||||
|
||||
const dataDir =
|
||||
cliOpts.dataDir ??
|
||||
process.env.ACTUAL_DATA_DIR ??
|
||||
fileConfig.dataDir ??
|
||||
join(homedir(), '.actual-cli', 'data');
|
||||
|
||||
const encryptionPassword =
|
||||
cliOpts.encryptionPassword ??
|
||||
process.env.ACTUAL_ENCRYPTION_PASSWORD ??
|
||||
fileConfig.encryptionPassword;
|
||||
|
||||
if (!serverUrl) {
|
||||
throw new Error(
|
||||
'Server URL is required. Set --server-url, ACTUAL_SERVER_URL env var, or serverUrl in config file.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!password && !sessionToken) {
|
||||
throw new Error(
|
||||
'Authentication required. Set --password/--session-token, ACTUAL_PASSWORD/ACTUAL_SESSION_TOKEN env var, or password/sessionToken in config file.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
serverUrl,
|
||||
password,
|
||||
sessionToken,
|
||||
syncId,
|
||||
dataDir,
|
||||
encryptionPassword,
|
||||
};
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import * as api from '@actual-app/api';
|
||||
|
||||
import { resolveConfig } from './config';
|
||||
import { withConnection } from './connection';
|
||||
|
||||
vi.mock('@actual-app/api', () => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
downloadBudget: vi.fn().mockResolvedValue(undefined),
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('./config', () => ({
|
||||
resolveConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
function setConfig(overrides: Record<string, unknown> = {}) {
|
||||
vi.mocked(resolveConfig).mockResolvedValue({
|
||||
serverUrl: 'http://test',
|
||||
password: 'pw',
|
||||
dataDir: '/tmp/data',
|
||||
syncId: 'budget-1',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe('withConnection', () => {
|
||||
let stderrSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
stderrSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
setConfig();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stderrSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('calls api.init with password when no sessionToken', async () => {
|
||||
setConfig({ password: 'pw', sessionToken: undefined });
|
||||
|
||||
await withConnection({}, async () => 'ok');
|
||||
|
||||
expect(api.init).toHaveBeenCalledWith({
|
||||
serverURL: 'http://test',
|
||||
password: 'pw',
|
||||
dataDir: '/tmp/data',
|
||||
verbose: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls api.init with sessionToken when present', async () => {
|
||||
setConfig({ sessionToken: 'tok', password: undefined });
|
||||
|
||||
await withConnection({}, async () => 'ok');
|
||||
|
||||
expect(api.init).toHaveBeenCalledWith({
|
||||
serverURL: 'http://test',
|
||||
sessionToken: 'tok',
|
||||
dataDir: '/tmp/data',
|
||||
verbose: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls api.downloadBudget when syncId is set', async () => {
|
||||
setConfig({ syncId: 'budget-1' });
|
||||
|
||||
await withConnection({}, async () => 'ok');
|
||||
|
||||
expect(api.downloadBudget).toHaveBeenCalledWith('budget-1', {
|
||||
password: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when loadBudget is true but syncId is not set', async () => {
|
||||
setConfig({ syncId: undefined });
|
||||
|
||||
await expect(withConnection({}, async () => 'ok')).rejects.toThrow(
|
||||
'Sync ID is required',
|
||||
);
|
||||
});
|
||||
|
||||
it('skips budget download when loadBudget is false and syncId is not set', async () => {
|
||||
setConfig({ syncId: undefined });
|
||||
|
||||
await withConnection({}, async () => 'ok', { loadBudget: false });
|
||||
|
||||
expect(api.downloadBudget).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call api.downloadBudget when loadBudget is false', async () => {
|
||||
setConfig({ syncId: 'budget-1' });
|
||||
|
||||
await withConnection({}, async () => 'ok', { loadBudget: false });
|
||||
|
||||
expect(api.downloadBudget).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns callback result', async () => {
|
||||
const result = await withConnection({}, async () => 42);
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it('calls api.shutdown in finally block on success', async () => {
|
||||
await withConnection({}, async () => 'ok');
|
||||
expect(api.shutdown).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls api.shutdown in finally block on error', async () => {
|
||||
await expect(
|
||||
withConnection({}, async () => {
|
||||
throw new Error('boom');
|
||||
}),
|
||||
).rejects.toThrow('boom');
|
||||
|
||||
expect(api.shutdown).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not write to stderr by default', async () => {
|
||||
await withConnection({}, async () => 'ok');
|
||||
|
||||
expect(stderrSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes info to stderr when verbose', async () => {
|
||||
await withConnection({ verbose: true }, async () => 'ok');
|
||||
|
||||
expect(stderrSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Connecting to'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import { mkdirSync } from 'fs';
|
||||
|
||||
import * as api from '@actual-app/api';
|
||||
|
||||
import { resolveConfig } from './config';
|
||||
import type { CliGlobalOpts } from './config';
|
||||
|
||||
function info(message: string, verbose?: boolean) {
|
||||
if (verbose) {
|
||||
process.stderr.write(message + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
type ConnectionOptions = {
|
||||
loadBudget?: boolean;
|
||||
};
|
||||
|
||||
export async function withConnection<T>(
|
||||
globalOpts: CliGlobalOpts,
|
||||
fn: () => Promise<T>,
|
||||
options: ConnectionOptions = {},
|
||||
): Promise<T> {
|
||||
const { loadBudget = true } = options;
|
||||
const config = await resolveConfig(globalOpts);
|
||||
|
||||
mkdirSync(config.dataDir, { recursive: true });
|
||||
|
||||
info(`Connecting to ${config.serverUrl}...`, globalOpts.verbose);
|
||||
|
||||
if (config.sessionToken) {
|
||||
await api.init({
|
||||
serverURL: config.serverUrl,
|
||||
dataDir: config.dataDir,
|
||||
sessionToken: config.sessionToken,
|
||||
verbose: globalOpts.verbose,
|
||||
});
|
||||
} else if (config.password) {
|
||||
await api.init({
|
||||
serverURL: config.serverUrl,
|
||||
dataDir: config.dataDir,
|
||||
password: config.password,
|
||||
verbose: globalOpts.verbose,
|
||||
});
|
||||
} else {
|
||||
throw new Error(
|
||||
'Authentication required. Provide --password or --session-token, or set ACTUAL_PASSWORD / ACTUAL_SESSION_TOKEN.',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (loadBudget && config.syncId) {
|
||||
info(`Downloading budget ${config.syncId}...`, globalOpts.verbose);
|
||||
await api.downloadBudget(config.syncId, {
|
||||
password: config.encryptionPassword,
|
||||
});
|
||||
} else if (loadBudget && !config.syncId) {
|
||||
throw new Error(
|
||||
'Sync ID is required for this command. Set --sync-id or ACTUAL_SYNC_ID.',
|
||||
);
|
||||
}
|
||||
return await fn();
|
||||
} finally {
|
||||
await api.shutdown();
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Command, Option } from 'commander';
|
||||
|
||||
import { registerAccountsCommand } from './commands/accounts';
|
||||
import { registerBudgetsCommand } from './commands/budgets';
|
||||
import { registerCategoriesCommand } from './commands/categories';
|
||||
import { registerCategoryGroupsCommand } from './commands/category-groups';
|
||||
import { registerPayeesCommand } from './commands/payees';
|
||||
import { registerQueryCommand } from './commands/query';
|
||||
import { registerRulesCommand } from './commands/rules';
|
||||
import { registerSchedulesCommand } from './commands/schedules';
|
||||
import { registerServerCommand } from './commands/server';
|
||||
import { registerTagsCommand } from './commands/tags';
|
||||
import { registerTransactionsCommand } from './commands/transactions';
|
||||
|
||||
declare const __CLI_VERSION__: string;
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('actual')
|
||||
.description('CLI for Actual Budget')
|
||||
.version(__CLI_VERSION__)
|
||||
.option('--server-url <url>', 'Actual server URL (env: ACTUAL_SERVER_URL)')
|
||||
.option('--password <password>', 'Server password (env: ACTUAL_PASSWORD)')
|
||||
.option(
|
||||
'--session-token <token>',
|
||||
'Session token (env: ACTUAL_SESSION_TOKEN)',
|
||||
)
|
||||
.option('--sync-id <id>', 'Budget sync ID (env: ACTUAL_SYNC_ID)')
|
||||
.option('--data-dir <path>', 'Data directory (env: ACTUAL_DATA_DIR)')
|
||||
.option(
|
||||
'--encryption-password <password>',
|
||||
'E2E encryption password (env: ACTUAL_ENCRYPTION_PASSWORD)',
|
||||
)
|
||||
.addOption(
|
||||
new Option('--format <format>', 'Output format: json, table, csv')
|
||||
.choices(['json', 'table', 'csv'] as const)
|
||||
.default('json'),
|
||||
)
|
||||
.option('--verbose', 'Show informational messages', false);
|
||||
|
||||
registerAccountsCommand(program);
|
||||
registerBudgetsCommand(program);
|
||||
registerCategoriesCommand(program);
|
||||
registerCategoryGroupsCommand(program);
|
||||
registerTransactionsCommand(program);
|
||||
registerPayeesCommand(program);
|
||||
registerTagsCommand(program);
|
||||
registerRulesCommand(program);
|
||||
registerSchedulesCommand(program);
|
||||
registerQueryCommand(program);
|
||||
registerServerCommand(program);
|
||||
|
||||
function normalizeThrownMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === 'object' && err !== null) {
|
||||
try {
|
||||
return JSON.stringify(err);
|
||||
} catch {
|
||||
return '<non-serializable error>';
|
||||
}
|
||||
}
|
||||
return String(err);
|
||||
}
|
||||
|
||||
program.parseAsync(process.argv).catch((err: unknown) => {
|
||||
const message = normalizeThrownMessage(err);
|
||||
process.stderr.write(`Error: ${message}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
export function readJsonInput(cmdOpts: {
|
||||
data?: string;
|
||||
file?: string;
|
||||
}): unknown {
|
||||
if (cmdOpts.data && cmdOpts.file) {
|
||||
throw new Error('Cannot use both --data and --file');
|
||||
}
|
||||
if (cmdOpts.data) {
|
||||
return JSON.parse(cmdOpts.data);
|
||||
}
|
||||
if (cmdOpts.file) {
|
||||
const content =
|
||||
cmdOpts.file === '-'
|
||||
? readFileSync(0, 'utf-8')
|
||||
: readFileSync(cmdOpts.file, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
}
|
||||
throw new Error('Either --data or --file is required');
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user