mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-10 04:02:38 -05:00
Compare commits
9 Commits
copilot/su
...
sync-serve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cef14e1a79 | ||
|
|
1e5d5b9b78 | ||
|
|
33f6ae7f91 | ||
|
|
50fba76c47 | ||
|
|
744ae1625d | ||
|
|
9dda58b61d | ||
|
|
734bb86126 | ||
|
|
efb0d80aa4 | ||
|
|
605206d2f7 |
@@ -1,26 +0,0 @@
|
||||
issue_enrichment:
|
||||
auto_enrich:
|
||||
enabled: false
|
||||
reviews:
|
||||
request_changes_workflow: true
|
||||
review_status: false
|
||||
high_level_summary: false
|
||||
finishing_touches:
|
||||
docstrings:
|
||||
enabled: false
|
||||
pre_merge_checks:
|
||||
docstrings:
|
||||
mode: off
|
||||
enabled: false
|
||||
labeling_instructions:
|
||||
- label: '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'
|
||||
instructions: 'This issue updates the documentation in `packages/docs`.'
|
||||
- label: 'contains DB migrations'
|
||||
instructions: 'This issue or PR contains DB migrations in `packages/loot-core/migrations`.'
|
||||
- label: 'size small'
|
||||
instructions: 'This issue or PR is a small change (less than 50 lines of code) that is expected to have a small impact on the codebase.'
|
||||
auto_apply_labels: true
|
||||
12
.cursor/rules/commands.mdc
Normal file
12
.cursor/rules/commands.mdc
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
When running yarn commands - always run them in the root directory. Do not run them in child workspaces.
|
||||
|
||||
The following commands can be useful:
|
||||
|
||||
- `yarn typecheck` to run typechecker
|
||||
- `yarn lint` to run the code linter and formatter
|
||||
- `yarn lint:fix` to fix some of the code lint issues (running this is preferred over `yarn lint`)
|
||||
- `yarn test` to run all the tests
|
||||
37
.cursor/rules/typescript.mdc
Normal file
37
.cursor/rules/typescript.mdc
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
description:
|
||||
globs: *.ts,*.tsx
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
You are an expert in TypeScript and React.
|
||||
|
||||
Code Style and Structure
|
||||
|
||||
- Write concise, technical TypeScript code.
|
||||
- Use functional and declarative programming patterns; avoid classes.
|
||||
- Prefer iteration and modularization over code duplication.
|
||||
- Use descriptive variable names with auxiliary verbs (e.g., isLoaded, hasError).
|
||||
- Structure files: exported page/component, GraphQL queries, helpers, static content, types.
|
||||
- When creating a new component, place it in its own file rather than grouping multiple components in a single file.
|
||||
|
||||
Naming Conventions
|
||||
|
||||
- Favor named exports for components and utilities.
|
||||
|
||||
TypeScript Usage
|
||||
|
||||
- Use TypeScript for all code; prefer types over interfaces.
|
||||
- Avoid enums; use objects or maps instead.
|
||||
- Avoid using `any` or `unknown` unless absolutely necessary. Look for type definitions in the codebase instead.
|
||||
- Avoid type assertions with `as` or `!`; prefer using `satisfies`.
|
||||
|
||||
Syntax and Formatting
|
||||
|
||||
- Use the "function" keyword for pure functions.
|
||||
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements.
|
||||
- Use declarative JSX, keeping JSX minimal and readable.
|
||||
|
||||
Change validation
|
||||
|
||||
- Run `yarn typecheck` in the root directory to validate that the generated TypeScript code is correct
|
||||
14
.cursor/rules/unit-tests.mdc
Normal file
14
.cursor/rules/unit-tests.mdc
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
Vitest test runner is used for unit tests.
|
||||
|
||||
When running unit tests, always include the flag `--watch=false` to prevent watch mode.
|
||||
|
||||
To run unit tests for a specific package in the monorepo, use the following command:
|
||||
|
||||
`yarn workspace <workspaceNameFromPackageJson> run test <pathToTest>`
|
||||
|
||||
Recommendation: Minimize the number of dependencies you mock. The fewer dependencies you mock, the better.
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"setup-worktree": ["yarn"]
|
||||
}
|
||||
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -12,8 +12,6 @@
|
||||
*.sh text eol=lf
|
||||
*.tsx text eol=lf
|
||||
|
||||
**/bin/* text eol=lf
|
||||
|
||||
yarn.lock text eol=lf
|
||||
|
||||
# Denote all files that are truly binary and should not be modified.
|
||||
|
||||
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
|
||||
|
||||
69
.github/ISSUE_TEMPLATE/documentation.yml
vendored
69
.github/ISSUE_TEMPLATE/documentation.yml
vendored
@@ -1,69 +0,0 @@
|
||||
name: 'Documentation'
|
||||
description: Report documentation issues, request new documentation, or suggest improvements to existing docs.
|
||||
title: '[Docs] - <title>'
|
||||
labels: ['documentation']
|
||||
body:
|
||||
- type: dropdown
|
||||
id: issue-type
|
||||
attributes:
|
||||
label: 'Issue Type'
|
||||
description: What type of documentation issue is this?
|
||||
options:
|
||||
- New Documentation Request
|
||||
- Documentation Improvement
|
||||
- Documentation Bug/Error
|
||||
- Documentation Change Request
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 'Description'
|
||||
description: Please describe the documentation issue, request, or improvement
|
||||
placeholder: Provide a clear and detailed description...
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: doc-url
|
||||
attributes:
|
||||
label: 'Documentation URL'
|
||||
description: If this relates to existing documentation, please provide the URL
|
||||
placeholder: ex. https://actualbudget.org/docs/budgeting/categories or https://github.com/actualbudget/actual/blob/master/packages/docs/...
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: category
|
||||
attributes:
|
||||
label: 'Documentation Category'
|
||||
description: What category does this relate to?
|
||||
multiple: true
|
||||
options:
|
||||
- Accounts
|
||||
- Backup & Restore
|
||||
- Budgeting
|
||||
- Development
|
||||
- Installation & Configuration
|
||||
- Overview
|
||||
- Reports
|
||||
- Troubleshooting
|
||||
- Other
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: 'Expected/Desired Content'
|
||||
description: If applicable, describe what you expect to see or what should be documented
|
||||
placeholder: What should the documentation say or include?
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: screenshot
|
||||
attributes:
|
||||
label: 'Screenshots or Examples'
|
||||
description: If applicable, add screenshots or examples to help explain your request
|
||||
value: |
|
||||

|
||||
render: bash
|
||||
validations:
|
||||
required: false
|
||||
8
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
8
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
@@ -7,19 +7,19 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this feature request! Please ensure you provide as much information as possible so we can better understand what you're proposing so we can come up with the best solution for everyone.
|
||||
Thanks for taking the time to fill out this feature request! Please ensure you provide as much information as possible so we can better understand what you’re proposing so we can come up with the best solution for everyone.
|
||||
- type: checkboxes
|
||||
id: existing-issue
|
||||
attributes:
|
||||
label: 'Verified feature request does not already exist?'
|
||||
description: "Please search to see if an issue or PR already exists for the feature you're requesting."
|
||||
description: 'Please search to see if an issue or PR already exists for the feature you’re requesting.'
|
||||
options:
|
||||
- label: 'I have searched and found no existing issue'
|
||||
required: true
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: '💻'
|
||||
description: (Optional) Please check this box if you're willing to open a PR to implement this feature. We'll help you get started and answer any questions you have along the way :)
|
||||
description: (Optional) Please check this box if you’re willing to open a PR to implement this feature. We’ll help you get started and answer any questions you have along the way :)
|
||||
options:
|
||||
- label: Would you like to implement this feature?
|
||||
- type: textarea
|
||||
@@ -33,7 +33,7 @@ body:
|
||||
id: solution
|
||||
attributes:
|
||||
label: Describe your ideal solution to this problem
|
||||
description: Feel free to give multiple different ideas for how the problem could be solved — we'd love to have a discussion to find the best way to solve your problem and related problems others may face! (Or leave this blank if you don't have a solution in mind yet.)
|
||||
description: Feel free to give multiple different ideas for how the problem could be solved — we’d love to have a discussion to find the best way to solve your problem and related problems others may face! (Or leave this blank if you don’t have a solution in mind yet.)
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'fs';
|
||||
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import fs from 'fs';
|
||||
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const repo = process.env.GITHUB_REPOSITORY;
|
||||
const issueNumber = process.env.GITHUB_EVENT_ISSUE_NUMBER;
|
||||
const commentId = String(process.env.GITHUB_EVENT_COMMENT_ID);
|
||||
const commentId = process.env.GITHUB_EVENT_COMMENT_ID;
|
||||
|
||||
if (!token || !repo || !issueNumber || !commentId) {
|
||||
console.log('Missing required environment variables');
|
||||
@@ -52,7 +51,7 @@ async function checkFirstComment() {
|
||||
|
||||
const isFirstSummaryComment =
|
||||
coderabbitSummaryComments.length === 1 &&
|
||||
String(coderabbitSummaryComments[0].id) === commentId;
|
||||
coderabbitSummaryComments[0].id == commentId;
|
||||
|
||||
console.log(
|
||||
`CodeRabbit summary comments found: ${coderabbitSummaryComments.length}`,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'fs';
|
||||
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import fs from 'fs';
|
||||
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const repo = process.env.GITHUB_REPOSITORY;
|
||||
|
||||
@@ -46,8 +46,7 @@ category: ${cleanCategory}
|
||||
authors: [${summaryData.author}]
|
||||
---
|
||||
|
||||
${summaryData.summary}
|
||||
`;
|
||||
${summaryData.summary}`;
|
||||
|
||||
const fileName = `upcoming-release-notes/${summaryData.prNumber}.md`;
|
||||
|
||||
@@ -76,7 +75,7 @@ ${summaryData.summary}
|
||||
repo: headRepo,
|
||||
path: fileName,
|
||||
message: `Add release notes for PR #${summaryData.prNumber}`,
|
||||
content: Buffer.from(fileContent).toString('base64'),
|
||||
content: Buffer.from(`${fileContent}\n\n`).toString('base64'),
|
||||
branch: prBranch,
|
||||
committer: {
|
||||
name: 'github-actions[bot]',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
|
||||
const commentBody = process.env.GITHUB_EVENT_COMMENT_BODY;
|
||||
const prDetailsJson = process.env.PR_DETAILS;
|
||||
@@ -33,11 +33,11 @@ try {
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'You are categorizing pull requests for release notes. You must respond with exactly one of these categories: "Features", "Enhancements", "Bugfixes", or "Maintenance". No other text or explanation.',
|
||||
'You are categorizing pull requests for release notes. You must respond with exactly one of these categories: "Features", "Enhancements", "Bugfix", or "Maintenance". No other text or explanation.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `PR Title: ${prDetails.title}\n\nGenerated Summary: ${summaryData.summary}\n\nCodeRabbit Analysis:\n${commentBody}\n\nCategories:\n- Features: New functionality or capabilities\n- Bugfixes: Fixes for broken or incorrect behavior\n- Enhancements: Improvements to existing functionality\n- Maintenance: Code cleanup, refactoring, dependencies, etc.\n\nWhat category does this PR belong to?`,
|
||||
content: `PR Title: ${prDetails.title}\n\nGenerated Summary: ${summaryData.summary}\n\nCodeRabbit Analysis:\n${commentBody}\n\nCategories:\n- Features: New functionality or capabilities\n- Bugfix: Fixes for broken or incorrect behavior\n- Enhancements: Improvements to existing functionality\n- Maintenance: Code cleanup, refactoring, dependencies, etc.\n\nWhat category does this PR belong to?`,
|
||||
},
|
||||
],
|
||||
max_tokens: 10,
|
||||
@@ -86,7 +86,7 @@ try {
|
||||
// Validate the category response
|
||||
const validCategories = [
|
||||
'Features',
|
||||
'Bugfixes',
|
||||
'Bugfix',
|
||||
'Enhancements',
|
||||
'Maintenance',
|
||||
];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
|
||||
const commentBody = process.env.GITHUB_EVENT_COMMENT_BODY;
|
||||
const prDetailsJson = process.env.PR_DETAILS;
|
||||
@@ -71,7 +71,7 @@ try {
|
||||
console.log('Generated summary:', summary);
|
||||
|
||||
const result = {
|
||||
summary,
|
||||
summary: summary,
|
||||
prNumber: prDetails.number,
|
||||
author: prDetails.author,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'fs';
|
||||
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import fs from 'fs';
|
||||
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const repo = process.env.GITHUB_REPOSITORY;
|
||||
@@ -36,13 +35,11 @@ async function getPRDetails() {
|
||||
console.log('- PR Number:', pr.number);
|
||||
console.log('- PR Author:', pr.user.login);
|
||||
console.log('- PR Title:', pr.title);
|
||||
console.log('- Base Branch:', pr.base.ref);
|
||||
|
||||
const result = {
|
||||
number: pr.number,
|
||||
author: pr.user.login,
|
||||
title: pr.title,
|
||||
baseBranch: pr.base.ref,
|
||||
};
|
||||
|
||||
setOutput('result', JSON.stringify(result));
|
||||
|
||||
14
.github/actions/check-migrations.js
vendored
14
.github/actions/check-migrations.js
vendored
@@ -4,8 +4,9 @@
|
||||
// 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`.
|
||||
|
||||
const { spawnSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const migrationsDir = path.join(
|
||||
__dirname,
|
||||
@@ -35,15 +36,14 @@ function readMigrations(ref) {
|
||||
}
|
||||
|
||||
spawnSync('git', ['fetch', 'origin', 'master']);
|
||||
const masterMigrations = readMigrations('origin/master');
|
||||
const headMigrations = readMigrations('HEAD');
|
||||
let masterMigrations = readMigrations('origin/master');
|
||||
let headMigrations = readMigrations('HEAD');
|
||||
|
||||
const latestMasterMigration =
|
||||
masterMigrations[masterMigrations.length - 1].date;
|
||||
const newMigrations = headMigrations.filter(
|
||||
let latestMasterMigration = masterMigrations[masterMigrations.length - 1].date;
|
||||
let newMigrations = headMigrations.filter(
|
||||
migration => !masterMigrations.find(m => m.name === migration.name),
|
||||
);
|
||||
const badMigrations = newMigrations.filter(
|
||||
let badMigrations = newMigrations.filter(
|
||||
migration => migration.date <= latestMasterMigration,
|
||||
);
|
||||
|
||||
|
||||
17
.github/actions/docs-spelling/README.md
vendored
17
.github/actions/docs-spelling/README.md
vendored
@@ -1,17 +0,0 @@
|
||||
# check-spelling/check-spelling configuration
|
||||
|
||||
| File | Purpose | Format | Info |
|
||||
| -------------------------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| [dictionary.txt](dictionary.txt) | Replacement dictionary (creating this file will override the default dictionary) | one word per line | [dictionary](https://github.com/check-spelling/check-spelling/wiki/Configuration#dictionary) |
|
||||
| [allow.txt](allow.txt) | Add words to the dictionary | one word per line (only letters and `'`s allowed) | [allow](https://github.com/check-spelling/check-spelling/wiki/Configuration#allow) |
|
||||
| [reject.txt](reject.txt) | Remove words from the dictionary (after allow) | grep pattern matching whole dictionary words | [reject](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-reject) |
|
||||
| [excludes.txt](excludes.txt) | Files to ignore entirely | perl regular expression | [excludes](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-excludes) |
|
||||
| [only.txt](only.txt) | Only check matching files (applied after excludes) | perl regular expression | [only](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-only) |
|
||||
| [patterns.txt](patterns.txt) | Patterns to ignore from checked lines | perl regular expression (order matters, first match wins) | [patterns](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-patterns) |
|
||||
| [candidate.patterns](candidate.patterns) | Patterns that might be worth adding to [patterns.txt](patterns.txt) | perl regular expression with optional comment block introductions (all matches will be suggested) | [candidates](https://github.com/check-spelling/check-spelling/wiki/Feature:-Suggest-patterns) |
|
||||
| [line_forbidden.patterns](line_forbidden.patterns) | Patterns to flag in checked lines | perl regular expression (order matters, first match wins) | [patterns](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-patterns) |
|
||||
| [expect.txt](expect.txt) | Expected words that aren't in the dictionary | one word per line (sorted, alphabetically) | [expect](https://github.com/check-spelling/check-spelling/wiki/Configuration#expect) |
|
||||
| [advice.md](advice.md) | Supplement for GitHub comment when unrecognized words are found | GitHub Markdown | [advice](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-advice) |
|
||||
|
||||
Note: you can replace any of these files with a directory by the same name (minus the suffix)
|
||||
and then include multiple files inside that directory (with that suffix) to merge multiple files together.
|
||||
24
.github/actions/docs-spelling/advice.md
vendored
24
.github/actions/docs-spelling/advice.md
vendored
@@ -1,24 +0,0 @@
|
||||
<!-- See https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-advice --> <!-- markdownlint-disable MD033 MD041 -->
|
||||
<details>
|
||||
<summary>If the flagged items are :exploding_head: false positives</summary>
|
||||
|
||||
If items relate to a ...
|
||||
|
||||
- binary file (or some other file you wouldn't want to check at all).
|
||||
|
||||
Please add a file path to the `excludes.txt` file matching the containing file.
|
||||
|
||||
File paths are Perl 5 Regular Expressions - you can [test](https://www.regexplanet.com/advanced/perl/) yours before committing to verify it will match your files.
|
||||
|
||||
`^` refers to the file's path from the root of the repository, so `^README\.md$` would exclude [README.md](../tree/HEAD/README.md) (on whichever branch you're using).
|
||||
|
||||
- well-formed pattern.
|
||||
|
||||
If you can write a [pattern](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples:-patterns) that would match it,
|
||||
try adding it to the `patterns.txt` file.
|
||||
|
||||
Patterns are Perl 5 Regular Expressions - you can [test](https://www.regexplanet.com/advanced/perl/) yours before committing to verify it will match your lines.
|
||||
|
||||
Note that patterns can't match multiline strings.
|
||||
|
||||
</details>
|
||||
@@ -1,8 +0,0 @@
|
||||
trevdor
|
||||
Farlow
|
||||
Matiss
|
||||
Aboltins
|
||||
jlongster
|
||||
howell
|
||||
evequefou
|
||||
Fiddaman
|
||||
152
.github/actions/docs-spelling/allow/keywords.txt
vendored
152
.github/actions/docs-spelling/allow/keywords.txt
vendored
@@ -1,152 +0,0 @@
|
||||
ABANCA
|
||||
actualbudget
|
||||
addtransactions
|
||||
Akahu
|
||||
AMZN
|
||||
Andelskassen
|
||||
AQL
|
||||
Authelia
|
||||
autocompletes
|
||||
Belarusian
|
||||
Blix
|
||||
bnp
|
||||
BSCHESMM
|
||||
BTC
|
||||
CAGLESMM
|
||||
Caju
|
||||
caniuse
|
||||
Cardless
|
||||
CAROOT
|
||||
categorygroup
|
||||
Cembra
|
||||
Certbot
|
||||
CLI
|
||||
clickable
|
||||
clsx
|
||||
codemirror
|
||||
Coinbase
|
||||
commandlet
|
||||
Coverflex
|
||||
Crd
|
||||
crdt
|
||||
creditcards
|
||||
crowdsourced
|
||||
debian
|
||||
dedupes
|
||||
deleteaccount
|
||||
DKB
|
||||
DKK
|
||||
dmg
|
||||
easybank
|
||||
Edenred
|
||||
Coverfelx
|
||||
emojis
|
||||
emoji
|
||||
escodegen
|
||||
EUR
|
||||
expando
|
||||
Firefox
|
||||
flyctl
|
||||
Formik
|
||||
Fortuneo
|
||||
gebabebb
|
||||
GEBABEBB
|
||||
Greenshot
|
||||
GTQ
|
||||
HSA
|
||||
htpasswd
|
||||
IBANs
|
||||
IDR
|
||||
iex
|
||||
importtransactions
|
||||
ING
|
||||
invokable
|
||||
iwr
|
||||
jointaccounts
|
||||
jwl
|
||||
KBC
|
||||
kcab
|
||||
keyout
|
||||
KREDBEBB
|
||||
Kroger
|
||||
kubectl
|
||||
kubernetes
|
||||
ldaplogin
|
||||
letsencrypt
|
||||
libofx
|
||||
linting
|
||||
Linuxes
|
||||
linuxsvg
|
||||
lleskassen
|
||||
lte
|
||||
mac
|
||||
macsvg
|
||||
Mariushosting
|
||||
minimalistic
|
||||
monkeypatch
|
||||
Monobank
|
||||
Morrisons
|
||||
MYR
|
||||
NAIAGB
|
||||
NDEADKKK
|
||||
Netflix
|
||||
netlify
|
||||
Nordea
|
||||
NORDEA
|
||||
nordigen
|
||||
notlike
|
||||
NRNBGB
|
||||
nynab
|
||||
offbudget
|
||||
ofx
|
||||
OFX
|
||||
oneof
|
||||
oxfmt
|
||||
oxlint
|
||||
payeerule
|
||||
pikaday
|
||||
pikapods
|
||||
playsinline
|
||||
portalization
|
||||
Postgresql
|
||||
protobuf
|
||||
publix
|
||||
QFX
|
||||
QIF
|
||||
Quicken
|
||||
returnsandreimbursements
|
||||
responsitivity
|
||||
Rezip
|
||||
roadmap
|
||||
RUpdate
|
||||
sankey
|
||||
SANTANDER
|
||||
screenshots
|
||||
SEB
|
||||
subfolders
|
||||
subreaper
|
||||
subtransaction
|
||||
subtransactions
|
||||
Suisse
|
||||
Sztup
|
||||
tini
|
||||
traefik
|
||||
Trafico
|
||||
Trumf
|
||||
Upstash
|
||||
useb
|
||||
usernames
|
||||
valign
|
||||
Venmo
|
||||
Weblate
|
||||
winsvg
|
||||
WSL
|
||||
Xxxxx
|
||||
ynab
|
||||
Ynab
|
||||
YNAB
|
||||
ZKB
|
||||
Zsolt
|
||||
IDBy
|
||||
isapprox
|
||||
isbetween
|
||||
77
.github/actions/docs-spelling/excludes.txt
vendored
77
.github/actions/docs-spelling/excludes.txt
vendored
@@ -1,77 +0,0 @@
|
||||
# See https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples:-excludes
|
||||
(?:^|/)(?i)COPYRIGHT
|
||||
(?:^|/)(?i)LICEN[CS]E
|
||||
(?:^|/)3rdparty/
|
||||
(?:^|/)go\.sum$
|
||||
(?:^|/)package(?:-lock|)\.json$
|
||||
(?:^|/)pyproject.toml
|
||||
(?:^|/)requirements(?:-dev|-doc|-test|)\.txt$
|
||||
(?:^|/)vendor/
|
||||
ignore$
|
||||
\.a$
|
||||
\.ai$
|
||||
\.avi$
|
||||
\.bmp$
|
||||
\.bz2$
|
||||
\.class$
|
||||
\.coveragerc$
|
||||
\.crt$
|
||||
\.css$
|
||||
\.dll$
|
||||
\.docx?$
|
||||
\.drawio$
|
||||
\.DS_Store$
|
||||
\.eot$
|
||||
\.exe$
|
||||
\.gif$
|
||||
\.git-blame-ignore-revs$
|
||||
\.gitattributes$
|
||||
\.graffle$
|
||||
\.gz$
|
||||
\.icns$
|
||||
\.ico$
|
||||
\.jar$
|
||||
\.jks$
|
||||
\.jpe?g$
|
||||
\.key$
|
||||
\.lib$
|
||||
\.lock$
|
||||
\.map$
|
||||
\.min\..
|
||||
\.mod$
|
||||
\.mp[34]$
|
||||
\.o$
|
||||
\.ocf$
|
||||
\.otf$
|
||||
\.pdf$
|
||||
\.pem$
|
||||
\.png$
|
||||
\.psd$
|
||||
\.pyc$
|
||||
\.pylintrc$
|
||||
\.s$
|
||||
\.svgz?$
|
||||
\.tar$
|
||||
\.tiff?$
|
||||
\.ttf$
|
||||
\.wav$
|
||||
\.webm$
|
||||
\.webp$
|
||||
\.woff2?$
|
||||
\.xlsx?$
|
||||
\.zip$
|
||||
^\.github/actions/spelling/
|
||||
^\.github/ISSUE_TEMPLATE/
|
||||
^\Q.github/workflows/spelling.yml\E$
|
||||
^\.yarn/
|
||||
^\Qnode_modules/\E$
|
||||
^\Qsrc/\E$
|
||||
^\Qstatic/\E$
|
||||
^\Q.github/\E$
|
||||
(?:^|/)package(?:-lock|)\.json$
|
||||
(?:^|/)yarn\.lock$
|
||||
(?:^|/)(?i)docusaurus.config.js
|
||||
(?:^|/)(?i)README.md
|
||||
(?:^|/)(?i).nojekyll
|
||||
^\static/
|
||||
\.tsx$
|
||||
189
.github/actions/docs-spelling/expect.txt
vendored
189
.github/actions/docs-spelling/expect.txt
vendored
@@ -1,189 +0,0 @@
|
||||
Abanca
|
||||
ABNAMRO
|
||||
ABNANL
|
||||
Activo
|
||||
AESUDEF
|
||||
ALZEY
|
||||
Anglais
|
||||
ANZ
|
||||
aql
|
||||
AUR
|
||||
Authentik
|
||||
AVERAGEA
|
||||
BANKA
|
||||
BANKINTER
|
||||
BAWAATWW
|
||||
Belfius
|
||||
Biedenkopf
|
||||
BIGBPLPW
|
||||
Bizum
|
||||
BKBKESMM
|
||||
BOFIIE
|
||||
Bourso
|
||||
Boursobank
|
||||
Boursorama
|
||||
BPER
|
||||
BPMOIT
|
||||
brexplpw
|
||||
BYLADEM
|
||||
Caddyfile
|
||||
CAGLPTPL
|
||||
Caixa
|
||||
CAMT
|
||||
cashflow
|
||||
Cetelem
|
||||
cimode
|
||||
Citi
|
||||
Citibank
|
||||
Cloudflare
|
||||
CMCIFRPAXXX
|
||||
COBADEFF
|
||||
CODEOWNERS
|
||||
COEP
|
||||
commerzbank
|
||||
Copiar
|
||||
COUNTA
|
||||
COUNTBLANK
|
||||
countif
|
||||
CREGBEBB
|
||||
crt
|
||||
CZK
|
||||
Danske
|
||||
datadir
|
||||
DATEDIF
|
||||
Depositos
|
||||
deselection
|
||||
DIREKT
|
||||
Dockerfiles
|
||||
Dominguez
|
||||
DUSSDEDDXXX
|
||||
DUSSELDORF
|
||||
EDATE
|
||||
ENTERCARD
|
||||
Entra
|
||||
EOMONTH
|
||||
EUA
|
||||
Eurocard
|
||||
fidd
|
||||
Fineco
|
||||
Finicity
|
||||
Fintro
|
||||
Finverse
|
||||
Flathub
|
||||
FORTUNEO
|
||||
FTNOFRP
|
||||
Gemeinschaftsbank
|
||||
Geral
|
||||
gernes
|
||||
Globecard
|
||||
GLS
|
||||
gocardless
|
||||
Grafana
|
||||
HABAL
|
||||
Hampel
|
||||
HELADEF
|
||||
HLOOKUP
|
||||
HUF
|
||||
IFERROR
|
||||
IFNA
|
||||
INDUSTRIEL
|
||||
INGBPLPW
|
||||
Ingo
|
||||
INR
|
||||
Intesa
|
||||
INVSTMTMSGSRS
|
||||
IRR
|
||||
ISERROR
|
||||
ISEVEN
|
||||
ISLOGICAL
|
||||
ISNA
|
||||
ISODD
|
||||
ISOWEEKNUM
|
||||
ISTEXT
|
||||
ISYBANK
|
||||
ITBBITMM
|
||||
jfdoming
|
||||
JMD
|
||||
jws
|
||||
KBCBE
|
||||
Keycloak
|
||||
Khurozov
|
||||
KORT
|
||||
Kreditbank
|
||||
lage
|
||||
LHV
|
||||
LHVBEE
|
||||
LKR
|
||||
MAXA
|
||||
mbank
|
||||
mdc
|
||||
metainfo
|
||||
modals
|
||||
Moldovan
|
||||
murmurhash
|
||||
NETWORKDAYS
|
||||
nginx
|
||||
OIDC
|
||||
Okabe
|
||||
overbudgeted
|
||||
overbudgeting
|
||||
oxc
|
||||
Paribas
|
||||
passwordless
|
||||
pluggyai
|
||||
Poste
|
||||
PPABPLPK
|
||||
prefs
|
||||
Primoco
|
||||
Priotecs
|
||||
proactively
|
||||
Qatari
|
||||
QNTOFRP
|
||||
QONTO
|
||||
Raiffeisen
|
||||
REGEXREPLACE
|
||||
revolut
|
||||
RIED
|
||||
RSchedule
|
||||
RSD
|
||||
SEK
|
||||
simplefin
|
||||
SKHSFI
|
||||
Sparkasse
|
||||
SPK
|
||||
sseldorf
|
||||
SSK
|
||||
Stadtsparkasse
|
||||
statestore
|
||||
STDEVP
|
||||
SUBASKBX
|
||||
sumif
|
||||
SUMPRODUCT
|
||||
SUMSQ
|
||||
SVGR
|
||||
swc
|
||||
SWEDBANK
|
||||
SWEDNOKK
|
||||
Synology
|
||||
systemctl
|
||||
tada
|
||||
taskbar
|
||||
templating
|
||||
THB
|
||||
touchscreen
|
||||
triaging
|
||||
UAH
|
||||
ubuntu
|
||||
undici
|
||||
userinfo
|
||||
Userscripts
|
||||
UZS
|
||||
VLOOKUP
|
||||
vrt
|
||||
VUB
|
||||
Wallos
|
||||
websecure
|
||||
WEEKNUM
|
||||
Widiba
|
||||
WOR
|
||||
youngcw
|
||||
@@ -1,62 +0,0 @@
|
||||
# reject `m_data` as there's a certain OS which has evil defines that break things if it's used elsewhere
|
||||
# \bm_data\b
|
||||
|
||||
# If you have a framework that uses `it()` for testing and `fit()` for debugging a specific test,
|
||||
# you might not want to check in code where you were debugging w/ `fit()`, in which case, you might want
|
||||
# to use this:
|
||||
#\bfit\(
|
||||
|
||||
# s.b. GitHub
|
||||
#\bGithub\b
|
||||
|
||||
# s.b. GitLab
|
||||
\bGitlab\b
|
||||
|
||||
# s.b. JavaScript
|
||||
\bJavascript\b
|
||||
|
||||
# s.b. Microsoft
|
||||
\bMicroSoft\b
|
||||
|
||||
# s.b. another
|
||||
\ban[- ]other\b
|
||||
|
||||
# s.b. greater than
|
||||
\bgreater then\b
|
||||
|
||||
# s.b. into
|
||||
#\sin to\s
|
||||
|
||||
# s.b. opt-in
|
||||
\sopt in\s
|
||||
|
||||
# s.b. less than
|
||||
\bless then\b
|
||||
|
||||
# s.b. otherwise
|
||||
\bother[- ]wise\b
|
||||
|
||||
# s.b. nonexistent
|
||||
\bnon existing\b
|
||||
\b[Nn]o[nt][- ]existent\b
|
||||
|
||||
# s.b. preexisting
|
||||
[Pp]re[- ]existing
|
||||
|
||||
# s.b. preempt
|
||||
[Pp]re[- ]empt\b
|
||||
|
||||
# s.b. preemptively
|
||||
[Pp]re[- ]emptively
|
||||
|
||||
# s.b. reentrancy
|
||||
[Rr]e[- ]entrancy
|
||||
|
||||
# s.b. reentrant
|
||||
[Rr]e[- ]entrant
|
||||
|
||||
# s.b. workaround(s)
|
||||
\bwork[- ]arounds?\b
|
||||
|
||||
# Reject duplicate words
|
||||
\s([A-Z]{3,}|[A-Z][a-z]{2,}|[a-z]{3,})\s\g{-1}\s
|
||||
3
.github/actions/docs-spelling/only.txt
vendored
3
.github/actions/docs-spelling/only.txt
vendored
@@ -1,3 +0,0 @@
|
||||
# Only check files in the packages/docs directory
|
||||
^packages/docs/
|
||||
|
||||
81
.github/actions/docs-spelling/patterns.txt
vendored
81
.github/actions/docs-spelling/patterns.txt
vendored
@@ -1,81 +0,0 @@
|
||||
# See https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples:-patterns
|
||||
|
||||
# Questionably acceptable forms of `in to`
|
||||
# Personally, I prefer `log into`, but people object
|
||||
# https://www.tprteaching.com/log-into-log-in-to-login/
|
||||
\b[Ll]og in to\b
|
||||
|
||||
# acceptable duplicates
|
||||
# ls directory listings
|
||||
[-bcdlpsw](?:[-r][-w][-Ssx]){3}\s+\d+\s+\S+\s+\S+\s+\d+\s+
|
||||
# C types and repeated CSS values
|
||||
\s(center|div|inherit|long|LONG|none|normal|solid|thin|transparent|very)(?: \g{-1})+\s
|
||||
# go templates
|
||||
\s(\w+)\s+\g{-1}\s+\`(?:graphql|json|yaml):
|
||||
# javadoc / .net
|
||||
(?:[\\@](?:groupname|param)|(?:public|private)(?:\s+static|\s+readonly)*)\s+(\w+)\s+\g{-1}\s
|
||||
|
||||
# Commit message -- Signed-off-by and friends
|
||||
^\s*(?:(?:Based-on-patch|Co-authored|Helped|Mentored|Reported|Reviewed|Signed-off)-by|Thanks-to): (?:[^<]*<[^>]*>|[^<]*)\s*$
|
||||
|
||||
# Autogenerated revert commit message
|
||||
^This reverts commit [0-9a-f]{40}\.$
|
||||
|
||||
# ignore long runs of a single character:
|
||||
\b([A-Za-z])\g{-1}{3,}\b
|
||||
|
||||
# Automatically suggested patterns
|
||||
# hit-count: 1255 file-count: 51
|
||||
# https/http/file urls
|
||||
(?:\b(?:https?|ftp|file)://)[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[-A-Za-z0-9+&@#/%=~_|]
|
||||
|
||||
# hit-count: 1174 file-count: 33
|
||||
# GitHub SHAs (markdown)
|
||||
(?:\[`?[0-9a-f]+`?\]\(https:/|)/(?:www\.|)github\.com(?:/[^/\s"]+){2,}(?:/[^/\s")]+)(?:[0-9a-f]+(?:[-0-9a-zA-Z/#.]*|)\b|)
|
||||
|
||||
# hit-count: 6 file-count: 4
|
||||
# version suffix <word>v#
|
||||
(?:(?<=[A-Z]{2})V|(?<=[a-z]{2}|[A-Z]{2})v)\d+(?:\b|(?=[a-zA-Z_]))
|
||||
|
||||
# hit-count: 6 file-count: 2
|
||||
# URL escaped characters
|
||||
\%[0-9A-F][A-F]
|
||||
|
||||
# hit-count: 5 file-count: 4
|
||||
# hex runs
|
||||
\b[0-9a-fA-F]{16,}\b
|
||||
|
||||
# hit-count: 4 file-count: 2
|
||||
# uuid:
|
||||
\b[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}\b
|
||||
|
||||
# hit-count: 3 file-count: 2
|
||||
# discord
|
||||
/discord(?:app\.com|\.gg)/(?:invite/)?[a-zA-Z0-9]{7,}
|
||||
|
||||
# hit-count: 2 file-count: 2
|
||||
# Contributor
|
||||
\[[^\]]+\]\(https://github\.com/[^/\s"]+\)
|
||||
@[^$\W]*-?\w+
|
||||
|
||||
# hit-count: 1 file-count: 1
|
||||
# While you could try to match `http://` and `https://` by using `s?` in `https?://`, sometimes there
|
||||
# YouTube url
|
||||
\b(?:(?:www\.|)youtube\.com|youtu.be)/(?:channel/|embed/|user/|playlist\?list=|watch\?v=|v/|)[-a-zA-Z0-9?&=_%]*
|
||||
|
||||
# hit-count: 1 file-count: 1
|
||||
# Google Fonts
|
||||
\bfonts\.(?:googleapis|gstatic)\.com/[-/?=:;+&0-9a-zA-Z]*
|
||||
|
||||
# hit-count: 1 file-count: 1
|
||||
# hex digits including css/html color classes:
|
||||
(?:[\\0][xX]|\\u|[uU]\+|#x?|\%23)[0-9_a-fA-FgGrR]*?[a-fA-FgGrR]{2,}[0-9_a-fA-FgGrR]*(?:[uUlL]{0,3}|u\d+)\b
|
||||
|
||||
# docusaurus image paths, URLs
|
||||
[-a-zA-Z0-9@:%_\+.~#?&//=]{2,256}\.[a-z]{2,4}\b(\/[-a-zA-Z0-9@:%_\+.~#?&//=]*)?
|
||||
|
||||
# eliminate words like [`nvm`] or [`asdf`] or [heidiSQL] without backquotes
|
||||
\[.+?]
|
||||
|
||||
# allowlist specific non-English words with non-ASCII characters
|
||||
\b(Länsförsäkringar|München|Złoty)\b
|
||||
10
.github/actions/docs-spelling/reject.txt
vendored
10
.github/actions/docs-spelling/reject.txt
vendored
@@ -1,10 +0,0 @@
|
||||
^attache$
|
||||
benefitting
|
||||
occurences?
|
||||
^dependan.*
|
||||
^oer$
|
||||
Sorce
|
||||
^[Ss]pae.*
|
||||
^untill$
|
||||
^untilling$
|
||||
^wether.*
|
||||
40
.github/actions/netlify-wait-for-build
vendored
Executable file
40
.github/actions/netlify-wait-for-build
vendored
Executable file
@@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
|
||||
current_commit=$(git rev-parse HEAD)
|
||||
|
||||
echo "Running on commit $COMMIT_SHA"
|
||||
|
||||
function get_status() {
|
||||
echo "::group::API Response"
|
||||
curl --header "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/actualbudget/actual/commits/$COMMIT_SHA/statuses" > /tmp/status.json
|
||||
cat /tmp/status.json
|
||||
echo "::endgroup::"
|
||||
netlify=$(yarn jq '[.[] | select(.context == "netlify/actualbudget/deploy-preview")][0]' /tmp/status.json)
|
||||
state=$(yarn jq -r '.state' <<< "$netlify")
|
||||
echo "::group::Netlify Status"
|
||||
echo "$netlify"
|
||||
echo "::endgroup::"
|
||||
}
|
||||
|
||||
get_status
|
||||
|
||||
while [ "$netlify" == "null" ]; do
|
||||
echo "Waiting for Netlify to start building..."
|
||||
sleep 10
|
||||
get_status
|
||||
done
|
||||
|
||||
while [ "$state" == "pending" ]; do
|
||||
echo "Waiting for Netlify to finish building..."
|
||||
sleep 10
|
||||
get_status
|
||||
done
|
||||
|
||||
if [ "$state" == "success" ]; then
|
||||
echo -e "\033[0;32mNetlify build succeeded!\033[0m"
|
||||
yarn jq -r '"url=" + .target_url' <<< "$netlify" > $GITHUB_OUTPUT
|
||||
exit 0
|
||||
else
|
||||
echo -e "\033[0;31mNetlify build failed. Cancelling end-to-end tests.\033[0m"
|
||||
exit 1
|
||||
fi
|
||||
18
.github/actions/setup/action.yml
vendored
18
.github/actions/setup/action.yml
vendored
@@ -15,9 +15,9 @@ runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install node
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 20
|
||||
- name: Install yarn
|
||||
run: npm install -g yarn
|
||||
shell: bash
|
||||
@@ -27,28 +27,18 @@ runs:
|
||||
run: echo "version=$(node -v)" >> "$GITHUB_OUTPUT"
|
||||
shell: bash
|
||||
- name: Cache
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
uses: actions/cache@v4
|
||||
id: cache
|
||||
with:
|
||||
path: ${{ format('{0}/**/node_modules', inputs.working-directory) }}
|
||||
key: yarn-v1-${{ runner.os }}-${{ steps.get-node.outputs.version }}-${{ hashFiles(format('{0}/**/yarn.lock', inputs.working-directory)) }}
|
||||
- name: Ensure Lage cache directory exists
|
||||
run: mkdir -p ${{ format('{0}/.lage', inputs.working-directory) }}
|
||||
shell: bash
|
||||
- name: Cache Lage
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ${{ format('{0}/.lage', inputs.working-directory) }}
|
||||
key: lage-${{ runner.os }}-${{ github.sha }}
|
||||
restore-keys: |
|
||||
lage-${{ runner.os }}-
|
||||
- name: Install
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: yarn --immutable
|
||||
shell: bash
|
||||
if: steps.cache.outputs.cache-hit != 'true'
|
||||
- name: Download translations
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: actualbudget/translations
|
||||
path: ${{ inputs.working-directory }}/packages/desktop-client/locale
|
||||
|
||||
417
.github/scripts/count-points.mjs
vendored
417
.github/scripts/count-points.mjs
vendored
@@ -4,149 +4,43 @@ import pLimit from 'p-limit';
|
||||
|
||||
const limit = pLimit(50);
|
||||
|
||||
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: {
|
||||
Features: 2,
|
||||
Enhancements: 2,
|
||||
Bugfix: 3,
|
||||
Maintenance: 2,
|
||||
Unknown: 2,
|
||||
},
|
||||
// Point tiers for code changes (non-docs)
|
||||
CODE_PR_REVIEW_POINT_TIERS: [
|
||||
{ minChanges: 500, points: 8 },
|
||||
{ minChanges: 100, points: 6 },
|
||||
{ minChanges: 10, points: 2 },
|
||||
{ minChanges: 0, points: 1 },
|
||||
/** Repository-specific configuration for points calculation */
|
||||
const REPOSITORY_CONFIG = new Map([
|
||||
[
|
||||
'actual',
|
||||
{
|
||||
POINTS_PER_ISSUE_TRIAGE_ACTION: 1,
|
||||
POINTS_PER_ISSUE_CLOSING_ACTION: 1,
|
||||
POINTS_PER_RELEASE_PR: 0,
|
||||
PR_REVIEW_POINT_TIERS: [
|
||||
{ minChanges: 500, points: 8 },
|
||||
{ minChanges: 100, points: 6 },
|
||||
{ minChanges: 10, points: 2 },
|
||||
{ minChanges: 0, points: 1 },
|
||||
],
|
||||
EXCLUDED_FILES: [
|
||||
'yarn.lock',
|
||||
'.yarn/**/*',
|
||||
'packages/component-library/src/icons/**/*',
|
||||
'release-notes/**/*',
|
||||
],
|
||||
},
|
||||
],
|
||||
// Point tiers for docs changes (packages/docs/**)
|
||||
DOCS_PR_REVIEW_POINT_TIERS: [
|
||||
{ minChanges: 2000, points: 6 },
|
||||
{ minChanges: 200, points: 4 },
|
||||
{ minChanges: 0, points: 2 },
|
||||
[
|
||||
'docs',
|
||||
{
|
||||
POINTS_PER_ISSUE_TRIAGE_ACTION: 1,
|
||||
POINTS_PER_ISSUE_CLOSING_ACTION: 1,
|
||||
POINTS_PER_RELEASE_PR: 4,
|
||||
PR_REVIEW_POINT_TIERS: [
|
||||
{ minChanges: 2000, points: 6 },
|
||||
{ minChanges: 200, points: 4 },
|
||||
{ minChanges: 0, points: 2 },
|
||||
],
|
||||
EXCLUDED_FILES: ['yarn.lock', '.yarn/**/*'],
|
||||
},
|
||||
],
|
||||
EXCLUDED_FILES: [
|
||||
'yarn.lock',
|
||||
'.yarn/**/*',
|
||||
'packages/component-library/src/icons/**/*',
|
||||
'release-notes/**/*',
|
||||
'upcoming-release-notes/**/*',
|
||||
],
|
||||
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 {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);
|
||||
|
||||
if (category && CONFIG.PR_CONTRIBUTION_POINTS[category]) {
|
||||
return {
|
||||
category,
|
||||
points: CONFIG.PR_CONTRIBUTION_POINTS[category],
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
return {
|
||||
category: 'Unknown',
|
||||
points: CONFIG.PR_CONTRIBUTION_POINTS.Unknown,
|
||||
};
|
||||
}
|
||||
]);
|
||||
|
||||
/**
|
||||
* Get the start and end dates for the last month.
|
||||
@@ -182,14 +76,15 @@ function getLastMonthDates() {
|
||||
/**
|
||||
* Used for calculating the monthly points each core contributor has earned.
|
||||
* These are used for payouts depending.
|
||||
* @returns {Map} A map of contributor logins to their total points earned
|
||||
* @param {string} repo - The repository to analyze ('actual' or 'docs')
|
||||
* @returns {number} The total points earned for the repository
|
||||
*/
|
||||
async function countContributorPoints() {
|
||||
async function countContributorPoints(repo) {
|
||||
const octokit = new Octokit({
|
||||
auth: process.env.GITHUB_TOKEN,
|
||||
});
|
||||
const owner = 'actualbudget';
|
||||
const repo = 'actual';
|
||||
const config = REPOSITORY_CONFIG.get(repo);
|
||||
|
||||
const { since, until } = getLastMonthDates();
|
||||
|
||||
@@ -204,9 +99,7 @@ async function countContributorPoints() {
|
||||
Array.from(orgMemberLogins).map(login => [
|
||||
login,
|
||||
{
|
||||
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
|
||||
reviews: [], // Will store objects with PR number and points
|
||||
labelRemovals: [],
|
||||
issueClosings: [],
|
||||
points: 0,
|
||||
@@ -263,113 +156,48 @@ async function countContributorPoints() {
|
||||
),
|
||||
]);
|
||||
|
||||
const filteredFiles = modifiedFiles.filter(
|
||||
file =>
|
||||
!CONFIG.EXCLUDED_FILES.some(pattern =>
|
||||
minimatch(file.filename, pattern, { dot: true }),
|
||||
),
|
||||
);
|
||||
|
||||
const docsFiles = filteredFiles.filter(file =>
|
||||
minimatch(file.filename, CONFIG.DOCS_FILES_PATTERN, { dot: true }),
|
||||
);
|
||||
const codeFiles = filteredFiles.filter(
|
||||
file =>
|
||||
!minimatch(file.filename, CONFIG.DOCS_FILES_PATTERN, { dot: true }),
|
||||
);
|
||||
|
||||
const docsChanges = docsFiles.reduce(
|
||||
(sum, file) => sum + file.additions + file.deletions,
|
||||
0,
|
||||
);
|
||||
const codeChanges = codeFiles.reduce(
|
||||
(sum, file) => sum + file.additions + file.deletions,
|
||||
0,
|
||||
);
|
||||
|
||||
const docsPoints =
|
||||
docsChanges > 0
|
||||
? (CONFIG.DOCS_PR_REVIEW_POINT_TIERS.find(
|
||||
t => docsChanges >= t.minChanges,
|
||||
)?.points ?? 0)
|
||||
: 0;
|
||||
const codePoints =
|
||||
codeChanges > 0 || docsChanges === 0
|
||||
? (CONFIG.CODE_PR_REVIEW_POINT_TIERS.find(
|
||||
t => codeChanges >= t.minChanges,
|
||||
)?.points ?? 0)
|
||||
: 0;
|
||||
const totalChanges = modifiedFiles
|
||||
.filter(
|
||||
file =>
|
||||
!config.EXCLUDED_FILES.some(pattern =>
|
||||
minimatch(file.filename, pattern),
|
||||
),
|
||||
)
|
||||
.reduce((sum, file) => sum + file.additions + file.deletions, 0);
|
||||
|
||||
const isReleasePR = pr.title.match(/🔖.*\d+\.\d+\.\d+/);
|
||||
const prPoints =
|
||||
config.PR_REVIEW_POINT_TIERS.find(t => totalChanges >= t.minChanges)
|
||||
?.points ?? 0;
|
||||
|
||||
if (isReleasePR) {
|
||||
// release PRs are created by the github-actions bot so we attribute points to the merger
|
||||
const { data: prDetails } = await octokit.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
|
||||
if (prDetails.merged_by && stats.has(prDetails.merged_by.login)) {
|
||||
const mergerStats = stats.get(prDetails.merged_by.login);
|
||||
mergerStats.codeReviews.push({
|
||||
if (stats.has(pr.user.login)) {
|
||||
const creatorStats = stats.get(pr.user.login);
|
||||
creatorStats.reviews.push({
|
||||
pr: pr.number.toString(),
|
||||
points: CONFIG.POINTS_PER_RELEASE_PR,
|
||||
isReleaseMerger: true,
|
||||
points: config.POINTS_PER_RELEASE_PR,
|
||||
isReleaseCreator: true,
|
||||
});
|
||||
mergerStats.points += CONFIG.POINTS_PER_RELEASE_PR;
|
||||
creatorStats.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 (
|
||||
review.state === 'APPROVED' &&
|
||||
stats.has(review.user?.login) &&
|
||||
!uniqueReviewers.has(review.user?.login)
|
||||
) {
|
||||
const reviewer = review.user.login;
|
||||
reviews.data
|
||||
.filter(
|
||||
review =>
|
||||
stats.has(review.user?.login) &&
|
||||
review.state === 'APPROVED' &&
|
||||
!uniqueReviewers.has(review.user?.login),
|
||||
)
|
||||
.forEach(({ user: { login: reviewer } }) => {
|
||||
uniqueReviewers.add(reviewer);
|
||||
const userStats = stats.get(reviewer);
|
||||
|
||||
if (docsPoints > 0) {
|
||||
userStats.docsReviews.push({
|
||||
pr: pr.number.toString(),
|
||||
points: docsPoints,
|
||||
});
|
||||
userStats.points += docsPoints;
|
||||
}
|
||||
|
||||
if (codePoints > 0) {
|
||||
userStats.codeReviews.push({
|
||||
pr: pr.number.toString(),
|
||||
points: codePoints,
|
||||
});
|
||||
userStats.points += codePoints;
|
||||
}
|
||||
}
|
||||
});
|
||||
userStats.reviews.push({
|
||||
pr: pr.number.toString(),
|
||||
points: prPoints,
|
||||
});
|
||||
userStats.points += prPoints;
|
||||
});
|
||||
}
|
||||
}),
|
||||
),
|
||||
@@ -413,17 +241,17 @@ async function countContributorPoints() {
|
||||
const remover = event.actor.login;
|
||||
const userStats = stats.get(remover);
|
||||
userStats.labelRemovals.push(issue.number.toString());
|
||||
userStats.points += CONFIG.POINTS_PER_ISSUE_TRIAGE_ACTION;
|
||||
userStats.points += config.POINTS_PER_ISSUE_TRIAGE_ACTION;
|
||||
}
|
||||
|
||||
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);
|
||||
userStats.issueClosings.push(issue.number.toString());
|
||||
userStats.points += CONFIG.POINTS_PER_ISSUE_CLOSING_ACTION;
|
||||
userStats.points += config.POINTS_PER_ISSUE_CLOSING_ACTION;
|
||||
}
|
||||
});
|
||||
}),
|
||||
@@ -432,58 +260,35 @@ async function countContributorPoints() {
|
||||
|
||||
// Print all statistics
|
||||
printStats(
|
||||
'Code Review Statistics',
|
||||
stats => stats.codeReviews.reduce((sum, r) => sum + r.points, 0),
|
||||
`PR Review Statistics (${repo})`,
|
||||
stats => stats.reviews.length,
|
||||
(user, count) =>
|
||||
`${user}: ${count} (PRs: ${stats
|
||||
.get(user)
|
||||
.codeReviews.map(r => {
|
||||
if (r.isReleaseMerger) {
|
||||
return `#${r.pr} (${r.points}pts - Release Merger)`;
|
||||
.reviews.map(r => {
|
||||
if (r.isReleaseCreator) {
|
||||
return `#${r.pr} (${r.points}pts - Release Creator)`;
|
||||
}
|
||||
return `#${r.pr} (${r.points}pts)`;
|
||||
})
|
||||
.join(', ')})`,
|
||||
);
|
||||
|
||||
printStats(
|
||||
'Docs Review Statistics',
|
||||
stats => stats.docsReviews.reduce((sum, r) => sum + r.points, 0),
|
||||
(user, count) =>
|
||||
`${user}: ${count} (PRs: ${stats
|
||||
.get(user)
|
||||
.docsReviews.map(r => `#${r.pr} (${r.points}pts)`)
|
||||
.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,
|
||||
`"Needs Triage" Label Removal Statistics (${repo})`,
|
||||
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,
|
||||
`Issue Closing Statistics (${repo})`,
|
||||
stats => stats.issueClosings.length,
|
||||
(user, count) =>
|
||||
`${user}: ${count} (Issues: ${stats.get(user).issueClosings.join(', ')})`,
|
||||
);
|
||||
|
||||
// Print points summary
|
||||
printStats(
|
||||
'Points Summary',
|
||||
`Points Summary (${repo})`,
|
||||
stats => stats.points,
|
||||
(user, userPoints) => `${user}: ${userPoints}`,
|
||||
);
|
||||
@@ -493,7 +298,7 @@ async function countContributorPoints() {
|
||||
(sum, userStats) => sum + userStats.points,
|
||||
0,
|
||||
);
|
||||
console.log(`\nTotal points earned: ${totalPoints}`);
|
||||
console.log(`\nTotal points earned for ${repo}: ${totalPoints}`);
|
||||
|
||||
// Return the points
|
||||
return new Map(
|
||||
@@ -504,5 +309,55 @@ async function countContributorPoints() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the points for both repositories and print cumulative results
|
||||
*/
|
||||
async function calculateCumulativePoints() {
|
||||
// Get stats for each repository
|
||||
const repoPointsResults = await Promise.all(
|
||||
Array.from(REPOSITORY_CONFIG.keys()).map(countContributorPoints),
|
||||
);
|
||||
|
||||
// Calculate cumulative stats
|
||||
const cumulativeStats = new Map(repoPointsResults[0]);
|
||||
|
||||
// Combine stats from all repositories
|
||||
for (let i = 1; i < repoPointsResults.length; i++) {
|
||||
for (const [login, points] of repoPointsResults[i].entries()) {
|
||||
if (!cumulativeStats.has(login)) {
|
||||
cumulativeStats.set(login, 0);
|
||||
}
|
||||
|
||||
cumulativeStats.set(login, cumulativeStats.get(login) + points);
|
||||
}
|
||||
}
|
||||
|
||||
// Print cumulative statistics
|
||||
console.log('\n\nCUMULATIVE STATISTICS ACROSS ALL REPOSITORIES');
|
||||
console.log('='.repeat(50));
|
||||
|
||||
console.log('\nCumulative Points Summary:');
|
||||
console.log('='.repeat('Cumulative Points Summary'.length + 1));
|
||||
|
||||
const entries = Array.from(cumulativeStats.entries())
|
||||
.filter(([, count]) => count > 0)
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log('No cumulative points summary found.');
|
||||
} else {
|
||||
entries.forEach(([user, points]) => {
|
||||
console.log(`${user}: ${points}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate and print total cumulative points
|
||||
const totalCumulativePoints = Array.from(cumulativeStats.values()).reduce(
|
||||
(sum, points) => sum + points,
|
||||
0,
|
||||
);
|
||||
console.log('\nTotal cumulative points earned: ' + totalCumulativePoints);
|
||||
}
|
||||
|
||||
// Run the calculations
|
||||
countContributorPoints().catch(console.error);
|
||||
calculateCumulativePoints().catch(console.error);
|
||||
|
||||
21
.github/workflows/ai-generated-release-notes.yml
vendored
21
.github/workflows/ai-generated-release-notes.yml
vendored
@@ -17,11 +17,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
|
||||
- name: Check if this is CodeRabbit's first comment
|
||||
id: check-first-comment
|
||||
@@ -41,21 +39,8 @@ jobs:
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
|
||||
- name: Check if PR targets master branch
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.pr-details.outputs.result != 'null'
|
||||
id: check-base-branch
|
||||
run: |
|
||||
BASE_BRANCH=$(echo '${{ steps.pr-details.outputs.result }}' | jq -r '.baseBranch')
|
||||
echo "Base branch: $BASE_BRANCH"
|
||||
if [ "$BASE_BRANCH" = "master" ]; then
|
||||
echo "targets_master=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "targets_master=false" >> $GITHUB_OUTPUT
|
||||
echo "PR does not target master branch, skipping release notes generation"
|
||||
fi
|
||||
|
||||
- name: Check if release notes file already exists
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.pr-details.outputs.result != 'null' && steps.check-base-branch.outputs.targets_master == 'true'
|
||||
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:
|
||||
@@ -87,7 +72,7 @@ jobs:
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false' && steps.generate-summary.outputs.result != 'null' && steps.determine-category.outputs.result != 'null' && steps.determine-category.outputs.result != ''
|
||||
run: node .github/actions/ai-generated-release-notes/create-release-notes-file.js
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.ACTIONS_UPDATE_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
SUMMARY_DATA: ${{ steps.generate-summary.outputs.result }}
|
||||
|
||||
4
.github/workflows/autofix.yml
vendored
4
.github/workflows/autofix.yml
vendored
@@ -15,11 +15,9 @@ jobs:
|
||||
autofix:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Format code
|
||||
run: yarn lint:fix
|
||||
- uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27
|
||||
|
||||
32
.github/workflows/build.yml
vendored
32
.github/workflows/build.yml
vendored
@@ -12,7 +12,6 @@ on:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
merge_group:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -22,42 +21,31 @@ jobs:
|
||||
api:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Build API
|
||||
run: cd packages/api && yarn build
|
||||
- name: Create package tgz
|
||||
run: cd packages/api && yarn pack && mv package.tgz actual-api.tgz
|
||||
- name: Prepare bundle stats artifact
|
||||
run: cp packages/api/app/stats.json api-stats.json
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: actual-api
|
||||
path: packages/api/actual-api.tgz
|
||||
- name: Upload API bundle stats
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: api-build-stats
|
||||
path: api-stats.json
|
||||
|
||||
crdt:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Build CRDT
|
||||
run: cd packages/crdt && yarn build
|
||||
- name: Create package tgz
|
||||
run: cd packages/crdt && yarn pack && mv package.tgz actual-crdt.tgz
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: actual-crdt
|
||||
path: packages/crdt/actual-crdt.tgz
|
||||
@@ -65,18 +53,18 @@ jobs:
|
||||
web:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Web
|
||||
run: yarn build:browser
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: actual-web
|
||||
path: packages/desktop-client/build
|
||||
- name: Upload Build Stats
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-stats
|
||||
path: packages/desktop-client/build-stats
|
||||
@@ -84,15 +72,13 @@ jobs:
|
||||
server:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Build Server
|
||||
run: yarn workspace @actual-app/sync-server build
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: sync-server
|
||||
path: packages/sync-server/build
|
||||
|
||||
23
.github/workflows/check.yml
vendored
23
.github/workflows/check.yml
vendored
@@ -5,7 +5,6 @@ on:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
merge_group:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -15,31 +14,25 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Lint
|
||||
run: yarn lint
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Typecheck
|
||||
run: yarn typecheck
|
||||
validate-cli:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Build Web
|
||||
run: yarn build:server
|
||||
- name: Check that the built CLI works
|
||||
@@ -47,11 +40,9 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Test
|
||||
run: yarn test
|
||||
|
||||
@@ -59,9 +50,9 @@ jobs:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 20
|
||||
- name: Check migrations
|
||||
run: node ./.github/actions/check-migrations.js
|
||||
|
||||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
|
||||
4
.github/workflows/count-points.yml
vendored
4
.github/workflows/count-points.yml
vendored
@@ -16,11 +16,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Count points
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
31
.github/workflows/docker-edge.yml
vendored
31
.github/workflows/docker-edge.yml
vendored
@@ -1,16 +1,21 @@
|
||||
name: Build Edge Docker Image
|
||||
|
||||
# Edge Docker images are built for every push to master
|
||||
# Edge Docker images are built for every commit, and daily
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- 'packages/sync-server/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- 'packages/sync-server/**'
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: docker-edge-build
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -36,17 +41,17 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu, alpine]
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
# Push to both Docker Hub and Github Container Registry
|
||||
images: ${{ env.IMAGES }}
|
||||
@@ -54,14 +59,14 @@ jobs:
|
||||
tags: ${{ env.TAGS }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@v3
|
||||
if: github.event_name != 'pull_request' && !github.event.repository.fork
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@v3
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@@ -76,7 +81,7 @@ jobs:
|
||||
run: yarn build:server
|
||||
|
||||
- name: Build image for testing
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
@@ -93,7 +98,7 @@ jobs:
|
||||
# This will use the cache from the earlier build step and not rebuild the image
|
||||
# https://docs.docker.com/build/ci/github-actions/test-before-push/
|
||||
- name: Build and push images
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
18
.github/workflows/docker-release.yml
vendored
18
.github/workflows/docker-release.yml
vendored
@@ -28,17 +28,17 @@ jobs:
|
||||
name: Build Docker image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
# Push to both Docker Hub and Github Container Registry
|
||||
images: ${{ env.IMAGES }}
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
|
||||
- name: Docker meta for Alpine image
|
||||
id: alpine-meta
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGES }}
|
||||
# Automatically update :latest
|
||||
@@ -58,13 +58,13 @@ jobs:
|
||||
tags: ${{ env.TAGS }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
run: yarn build:server
|
||||
|
||||
- name: Build and push ubuntu image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
- name: Build and push alpine image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
164
.github/workflows/docs-spelling.yml
vendored
164
.github/workflows/docs-spelling.yml
vendored
@@ -1,164 +0,0 @@
|
||||
name: Check Spelling (Docs)
|
||||
|
||||
# Comment management is handled through a secondary job, for details see:
|
||||
# https://github.com/check-spelling/check-spelling/wiki/Feature%3A-Restricted-Permissions
|
||||
#
|
||||
# `jobs.comment-push` runs when a push is made to a repository and the `jobs.spelling` job needs to make a comment
|
||||
# (in odd cases, it might actually run just to collapse a comment, but that's fairly rare)
|
||||
# it needs `contents: write` in order to add a comment.
|
||||
#
|
||||
# `jobs.comment-pr` runs when a pull_request is made to a repository and the `jobs.spelling` job needs to make a comment
|
||||
# or collapse a comment (in the case where it had previously made a comment and now no longer needs to show a comment)
|
||||
# it needs `pull-requests: write` in order to manipulate those comments.
|
||||
|
||||
# Updating pull request branches is managed via comment handling.
|
||||
# For details, see: https://github.com/check-spelling/check-spelling/wiki/Feature:-Update-expect-list
|
||||
#
|
||||
# These elements work together to make it happen:
|
||||
#
|
||||
# `on.issue_comment`
|
||||
# This event listens to comments by users asking to update the metadata.
|
||||
#
|
||||
# `jobs.update`
|
||||
# This job runs in response to an issue_comment and will push a new commit
|
||||
# to update the spelling metadata.
|
||||
#
|
||||
# `with.experimental_apply_changes_via_bot`
|
||||
# Tells the action to support and generate messages that enable it
|
||||
# to make a commit to update the spelling metadata.
|
||||
#
|
||||
# `with.ssh_key`
|
||||
# In order to trigger workflows when the commit is made, you can provide a
|
||||
# secret (typically, a write-enabled github deploy key).
|
||||
#
|
||||
# For background, see: https://github.com/check-spelling/check-spelling/wiki/Feature:-Update-with-deploy-key
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
tags-ignore:
|
||||
- '**'
|
||||
paths:
|
||||
- 'packages/docs/**'
|
||||
- '.github/workflows/docs-spelling.yml'
|
||||
- '.github/actions/docs-spelling/**'
|
||||
pull_request_target:
|
||||
branches:
|
||||
- '**'
|
||||
tags-ignore:
|
||||
- '**'
|
||||
paths:
|
||||
- 'packages/docs/**'
|
||||
- '.github/workflows/docs-spelling.yml'
|
||||
- '.github/actions/docs-spelling/**'
|
||||
types:
|
||||
- 'opened'
|
||||
- 'reopened'
|
||||
- 'synchronize'
|
||||
issue_comment:
|
||||
types:
|
||||
- 'created'
|
||||
|
||||
jobs:
|
||||
spelling:
|
||||
name: Check Spelling
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
actions: read
|
||||
security-events: write
|
||||
outputs:
|
||||
followup: ${{ steps.spelling.outputs.followup }}
|
||||
runs-on: ubuntu-latest
|
||||
if: "contains(github.event_name, 'pull_request') || github.event_name == 'push'"
|
||||
concurrency:
|
||||
group: spelling-${{ github.event.pull_request.number || github.ref }}
|
||||
# note: If you use only_check_changed_files, you do not want cancel-in-progress
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: check-spelling
|
||||
id: spelling
|
||||
uses: check-spelling/check-spelling@main
|
||||
with:
|
||||
suppress_push_for_open_pull_request: 1
|
||||
checkout: true
|
||||
check_file_names: 1
|
||||
spell_check_this: check-spelling/spell-check-this@prerelease
|
||||
post_comment: 0
|
||||
use_magic_file: 1
|
||||
experimental_apply_changes_via_bot: 1
|
||||
use_sarif: 1
|
||||
extra_dictionary_limit: 12
|
||||
check_extra_dictionaries: ''
|
||||
extra_dictionaries: cspell:cpp/src/cpp.txt
|
||||
cspell:software-terms/src/software-terms.txt
|
||||
cspell:python/src/python/python-lib.txt
|
||||
cspell:node/node.txt
|
||||
cspell:filetypes/filetypes.txt
|
||||
cspell:aws/aws.txt
|
||||
cspell:typescript/dict/typescript.txt
|
||||
cspell:npm/dict/npm.txt
|
||||
cspell:fullstack/dict/fullstack.txt
|
||||
cspell:html/dict/html.txt
|
||||
cspell:css/dict/css.txt
|
||||
config: .github/actions/docs-spelling
|
||||
|
||||
comment-push:
|
||||
name: Report (Push)
|
||||
# If your workflow isn't running on push, you can remove this job
|
||||
runs-on: ubuntu-latest
|
||||
needs: spelling
|
||||
permissions:
|
||||
contents: write
|
||||
if: (success() || failure()) && needs.spelling.outputs.followup && github.event_name == 'push'
|
||||
steps:
|
||||
- name: comment
|
||||
uses: check-spelling/check-spelling@main
|
||||
with:
|
||||
checkout: true
|
||||
spell_check_this: check-spelling/spell-check-this@prerelease
|
||||
task: ${{ needs.spelling.outputs.followup }}
|
||||
config: .github/actions/docs-spelling
|
||||
|
||||
comment-pr:
|
||||
name: Report (PR)
|
||||
# If you workflow isn't running on pull_request*, you can remove this job
|
||||
runs-on: ubuntu-latest
|
||||
needs: spelling
|
||||
permissions:
|
||||
pull-requests: write
|
||||
if: (success() || failure()) && needs.spelling.outputs.followup && contains(github.event_name, 'pull_request')
|
||||
steps:
|
||||
- name: comment
|
||||
uses: check-spelling/check-spelling@main
|
||||
with:
|
||||
checkout: true
|
||||
spell_check_this: check-spelling/spell-check-this@prerelease
|
||||
task: ${{ needs.spelling.outputs.followup }}
|
||||
experimental_apply_changes_via_bot: 1
|
||||
config: .github/actions/docs-spelling
|
||||
|
||||
update:
|
||||
name: Update PR
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
actions: read
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '@check-spelling-bot apply')
|
||||
}}
|
||||
concurrency:
|
||||
group: spelling-update-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: apply spelling updates
|
||||
uses: check-spelling/check-spelling@main
|
||||
with:
|
||||
experimental_apply_changes_via_bot: 1
|
||||
checkout: true
|
||||
ssh_key: '${{ secrets.CHECK_SPELLING }}'
|
||||
config: .github/actions/docs-spelling
|
||||
133
.github/workflows/e2e-test.yml
vendored
133
.github/workflows/e2e-test.yml
vendored
@@ -2,17 +2,6 @@ name: E2E Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'packages/**'
|
||||
- 'package.json'
|
||||
- 'yarn.lock'
|
||||
- '.github/workflows/e2e-test.yml'
|
||||
- '!packages/sync-server/**' # Sync server changes don't affect E2E tests
|
||||
- '!packages/api/**' # API changes don't affect E2E tests
|
||||
- '!packages/ci-actions/**' # CI actions changes don't affect E2E tests
|
||||
- '!packages/docs/**' # Docs changes don't affect E2E tests
|
||||
- '!packages/eslint-plugin-actual/**' # Eslint plugin changes don't affect E2E tests
|
||||
merge_group:
|
||||
|
||||
env:
|
||||
GITHUB_PR_NUMBER: ${{github.event.pull_request.number}}
|
||||
@@ -22,29 +11,40 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
functional:
|
||||
name: Functional (shard ${{ matrix.shard }}/5)
|
||||
netlify:
|
||||
name: Wait for Netlify build to finish
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5]
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.57.0-jammy
|
||||
outputs:
|
||||
netlify_url: ${{ steps.netlify.outputs.url }}
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Trust the repository directory
|
||||
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
- name: Run E2E Tests
|
||||
run: yarn e2e --shard=${{ matrix.shard }}/5
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
- name: Wait for Netlify build to finish
|
||||
id: netlify
|
||||
env:
|
||||
COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: ./.github/actions/netlify-wait-for-build
|
||||
|
||||
functional:
|
||||
name: Functional
|
||||
needs: netlify
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.55.1-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Run E2E Tests on Netlify URL
|
||||
run: yarn e2e
|
||||
env:
|
||||
E2E_START_URL: ${{ needs.netlify.outputs.netlify_url }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: desktop-client-test-results-shard-${{ matrix.shard }}
|
||||
name: desktop-client-test-results
|
||||
path: packages/desktop-client/test-results/
|
||||
retention-days: 30
|
||||
overwrite: true
|
||||
@@ -53,19 +53,15 @@ jobs:
|
||||
name: Functional Desktop App
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.57.0-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.55.1-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Trust the repository directory
|
||||
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
- name: Run Desktop app E2E Tests
|
||||
run: |
|
||||
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: desktop-app-test-results
|
||||
@@ -74,68 +70,23 @@ jobs:
|
||||
overwrite: true
|
||||
|
||||
vrt:
|
||||
name: Visual regression (shard ${{ matrix.shard }}/5)
|
||||
name: Visual regression
|
||||
needs: netlify
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5]
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.57.0-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.55.1-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Run VRT Tests
|
||||
run: yarn vrt --shard=${{ matrix.shard }}/5
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
- name: Run VRT Tests on Netlify URL
|
||||
run: yarn vrt
|
||||
env:
|
||||
E2E_START_URL: ${{ needs.netlify.outputs.netlify_url }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: vrt-blob-report-${{ matrix.shard }}
|
||||
path: packages/desktop-client/blob-report/
|
||||
retention-days: 1
|
||||
overwrite: true
|
||||
|
||||
merge-vrt:
|
||||
name: Merge VRT Reports
|
||||
needs: vrt
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !cancelled() }}
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.57.0-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Download all blob reports
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
path: packages/desktop-client/all-blob-reports
|
||||
pattern: vrt-blob-report-*
|
||||
merge-multiple: true
|
||||
- name: Merge reports
|
||||
id: merge-reports
|
||||
run: yarn workspace @actual-app/web run playwright merge-reports --reporter html ./all-blob-reports
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
id: playwright-report-vrt
|
||||
with:
|
||||
name: html-report--attempt-${{ github.run_attempt }}
|
||||
path: packages/desktop-client/playwright-report
|
||||
name: desktop-client-test-results
|
||||
path: packages/desktop-client/test-results/
|
||||
retention-days: 30
|
||||
overwrite: true
|
||||
- name: Save VRT metadata for comment workflow
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
mkdir -p vrt-metadata
|
||||
echo "${{ github.event.pull_request.number }}" > vrt-metadata/pr-number.txt
|
||||
echo "${{ needs.vrt.result }}" > vrt-metadata/vrt-result.txt
|
||||
echo "${{ steps.playwright-report-vrt.outputs.artifact-url }}" > vrt-metadata/artifact-url.txt
|
||||
- name: Upload VRT metadata
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: vrt-comment-metadata
|
||||
path: vrt-metadata/
|
||||
retention-days: 1
|
||||
|
||||
66
.github/workflows/e2e-vrt-comment.yml
vendored
66
.github/workflows/e2e-vrt-comment.yml
vendored
@@ -1,66 +0,0 @@
|
||||
name: VRT Comment
|
||||
# This workflow posts VRT failure comments on PRs, including fork PRs.
|
||||
# It runs with elevated permissions via workflow_run trigger.
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['E2E Tests']
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
name: Post VRT Comment
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.workflow_run.event == 'pull_request'
|
||||
steps:
|
||||
- name: Download VRT metadata
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
name: vrt-comment-metadata
|
||||
path: /tmp/vrt-metadata
|
||||
continue-on-error: true
|
||||
|
||||
- name: Extract metadata
|
||||
id: metadata
|
||||
run: |
|
||||
if [ ! -f "/tmp/vrt-metadata/pr-number.txt" ]; then
|
||||
echo "No metadata found, skipping..."
|
||||
echo "should_comment=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PR_NUMBER=$(cat "/tmp/vrt-metadata/pr-number.txt")
|
||||
VRT_RESULT=$(cat "/tmp/vrt-metadata/vrt-result.txt")
|
||||
ARTIFACT_URL=$(cat "/tmp/vrt-metadata/artifact-url.txt")
|
||||
|
||||
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
|
||||
echo "vrt_result=$VRT_RESULT" >> "$GITHUB_OUTPUT"
|
||||
echo "artifact_url=$ARTIFACT_URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ "$VRT_RESULT" = "failure" ]; then
|
||||
echo "should_comment=true" >> "$GITHUB_OUTPUT"
|
||||
echo "VRT tests failed for PR #$PR_NUMBER"
|
||||
else
|
||||
echo "should_comment=false" >> "$GITHUB_OUTPUT"
|
||||
echo "VRT tests passed or skipped for PR #$PR_NUMBER"
|
||||
fi
|
||||
|
||||
- name: Comment on PR with VRT report link
|
||||
if: steps.metadata.outputs.should_comment == 'true'
|
||||
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||
with:
|
||||
number: ${{ steps.metadata.outputs.pr_number }}
|
||||
header: vrt-comment
|
||||
hide_and_recreate: true
|
||||
hide_classify: OUTDATED
|
||||
message: |
|
||||
<!-- vrt-comment -->
|
||||
VRT tests ❌ failed. [View the test report](${{ steps.metadata.outputs.artifact_url }}).
|
||||
|
||||
To update the VRT screenshots, comment `/update-vrt` on this PR. The VRT update operation takes about 50 minutes.
|
||||
94
.github/workflows/electron-master.yml
vendored
94
.github/workflows/electron-master.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@v4
|
||||
- if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
run: pip.exe install setuptools
|
||||
- if: ${{ ! startsWith(matrix.os, 'windows') }}
|
||||
@@ -38,27 +38,15 @@ jobs:
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
python3 -m pip install setuptools
|
||||
- name: Process release version
|
||||
id: process_version
|
||||
run: |
|
||||
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||
- if: ${{ startsWith(matrix.os, 'ubuntu') }}
|
||||
name: Setup Flatpak dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install flatpak -y
|
||||
sudo apt-get install flatpak-builder -y
|
||||
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
sudo flatpak install org.freedesktop.Sdk//24.08 -y
|
||||
sudo flatpak install org.freedesktop.Platform//24.08 -y
|
||||
sudo flatpak install org.electronjs.Electron2.BaseApp//24.08 -y
|
||||
sudo flatpak install org.flatpak.Builder -y
|
||||
|
||||
METAINFO_FILE="packages/desktop-electron/extra-resources/linux/com.actualbudget.actual.metainfo.xml"
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
VERSION=${{ steps.process_version.outputs.version }}
|
||||
sed -i "s/%RELEASE_VERSION%/$VERSION/g; s/%RELEASE_DATE%/$TODAY/g" "$METAINFO_FILE"
|
||||
flatpak run --command=flatpak-builder-lint org.flatpak.Builder appstream "$METAINFO_FILE"
|
||||
sudo flatpak install org.freedesktop.Sdk/x86_64/23.08 -y
|
||||
sudo flatpak install org.freedesktop.Platform/x86_64/23.08 -y
|
||||
sudo flatpak install org.electronjs.Electron2.BaseApp/x86_64/23.08 -y
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Electron for Mac
|
||||
@@ -69,12 +57,11 @@ jobs:
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
- name: Build Electron
|
||||
if: ${{ ! startsWith(matrix.os, 'macos') }}
|
||||
run: ./bin/package-electron
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}
|
||||
path: |
|
||||
@@ -85,13 +72,17 @@ jobs:
|
||||
packages/desktop-electron/dist/*.flatpak
|
||||
- name: Upload Windows Store Build
|
||||
if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}-appx
|
||||
path: |
|
||||
packages/desktop-electron/dist/*.appx
|
||||
- name: Process release version
|
||||
id: process_version
|
||||
run: |
|
||||
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||
- name: Add to new release
|
||||
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
draft: true
|
||||
body: |
|
||||
@@ -100,11 +91,9 @@ jobs:
|
||||
## Desktop releases
|
||||
Please note: Microsoft store updates can sometimes lag behind the main release by a couple of days while they verify the new version.
|
||||
|
||||
<p>
|
||||
<a href="https://apps.microsoft.com/detail/9p2hmlhsdbrm?cid=Github+Releases&mode=direct"><img src="https://get.microsoft.com/images/en-us%20dark.svg" width="200" /></a>
|
||||
<img src="data:image/gif;base64,R0lGODlhAQABAAAAACw=" width="12" height="1" alt="" />
|
||||
<a href="https://flathub.org/apps/com.actualbudget.actual"><img width="165" style="margin-left:12px;" alt="Get it on Flathub" src="https://flathub.org/api/badge?locale=en" /></a>
|
||||
</p>
|
||||
<a href="https://apps.microsoft.com/detail/9p2hmlhsdbrm?cid=Github+Releases&mode=direct">
|
||||
<img src="https://get.microsoft.com/images/en-us%20dark.svg" width="200"/>
|
||||
</a>
|
||||
files: |
|
||||
packages/desktop-electron/dist/*.dmg
|
||||
packages/desktop-electron/dist/*.exe
|
||||
@@ -112,9 +101,6 @@ jobs:
|
||||
packages/desktop-electron/dist/*.AppImage
|
||||
packages/desktop-electron/dist/*.flatpak
|
||||
|
||||
outputs:
|
||||
version: ${{ steps.process_version.outputs.version }}
|
||||
|
||||
publish-microsoft-store:
|
||||
needs: build
|
||||
runs-on: windows-latest
|
||||
@@ -126,7 +112,7 @@ jobs:
|
||||
Install-Module -Name StoreBroker -AcceptLicense -Force -Scope CurrentUser -Verbose
|
||||
|
||||
- name: Download Microsoft Store artifacts
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: actual-electron-windows-latest-appx
|
||||
|
||||
@@ -156,53 +142,3 @@ 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 }}'
|
||||
draft: true
|
||||
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
|
||||
|
||||
92
.github/workflows/electron-pr.yml
vendored
92
.github/workflows/electron-pr.yml
vendored
@@ -9,15 +9,6 @@ env:
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'packages/**'
|
||||
- 'package.json'
|
||||
- 'yarn.lock'
|
||||
- '.github/workflows/electron-pr.yml'
|
||||
- '!packages/api/**' # API changes don't affect Electron
|
||||
- '!packages/ci-actions/**' # CI actions changes don't affect Electron
|
||||
- '!packages/docs/**' # Docs changes don't affect Electron
|
||||
- '!packages/eslint-plugin-actual/**' # Eslint plugin changes don't affect Electron
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
@@ -33,7 +24,7 @@ jobs:
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@v4
|
||||
- if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
run: pip.exe install setuptools
|
||||
- if: ${{ ! startsWith(matrix.os, 'windows') }}
|
||||
@@ -43,86 +34,31 @@ jobs:
|
||||
source .venv/bin/activate
|
||||
python3 -m pip install setuptools
|
||||
- if: ${{ startsWith(matrix.os, 'ubuntu') }}
|
||||
name: Setup Flatpak dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install flatpak -y
|
||||
sudo apt-get install flatpak-builder -y
|
||||
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
sudo flatpak install org.freedesktop.Sdk//24.08 -y
|
||||
sudo flatpak install org.freedesktop.Platform//24.08 -y
|
||||
sudo flatpak install org.electronjs.Electron2.BaseApp//24.08 -y
|
||||
sudo flatpak install org.flatpak.Builder -y
|
||||
|
||||
METAINFO_FILE="packages/desktop-electron/extra-resources/linux/com.actualbudget.actual.metainfo.xml"
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-electron/package.json --type nightly)
|
||||
sed -i "s/%RELEASE_VERSION%/$VERSION/g; s/%RELEASE_DATE%/$TODAY/g" "$METAINFO_FILE"
|
||||
flatpak run --command=flatpak-builder-lint org.flatpak.Builder appstream "$METAINFO_FILE"
|
||||
sudo flatpak install org.freedesktop.Sdk/x86_64/23.08 -y
|
||||
sudo flatpak install org.freedesktop.Platform/x86_64/23.08 -y
|
||||
sudo flatpak install org.electronjs.Electron2.BaseApp/x86_64/23.08 -y
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Electron
|
||||
run: ./bin/package-electron
|
||||
|
||||
- name: Upload Linux x64 AppImage
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Actual-linux-x86_64.AppImage
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-linux-x86_64.AppImage
|
||||
|
||||
- name: Upload Linux arm64 AppImage
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: Actual-linux-arm64.AppImage
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-linux-arm64.AppImage
|
||||
|
||||
- name: Upload Linux x64 flatpak
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: Actual-linux-x86_64.flatpak
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-linux-x86_64.flatpak
|
||||
|
||||
- name: Upload Windows x32 exe
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: Actual-windows-ia32.exe
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-windows-ia32.exe
|
||||
|
||||
- name: Upload Windows x64 exe
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: Actual-windows-x64.exe
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-windows-x64.exe
|
||||
|
||||
- name: Upload Windows arm64 exe
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: Actual-windows-arm64.exe
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-windows-arm64.exe
|
||||
|
||||
- name: Upload Mac x64 dmg
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: Actual-mac-x64.dmg
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-mac-x64.dmg
|
||||
|
||||
- name: Upload Mac arm64 dmg
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: Actual-mac-arm64.dmg
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-mac-arm64.dmg
|
||||
|
||||
name: actual-electron-${{ matrix.os }}
|
||||
path: |
|
||||
packages/desktop-electron/dist/*.dmg
|
||||
packages/desktop-electron/dist/*.exe
|
||||
!packages/desktop-electron/dist/Actual-windows.exe
|
||||
packages/desktop-electron/dist/*.AppImage
|
||||
packages/desktop-electron/dist/*.flatpak
|
||||
- name: Upload Windows Store Build
|
||||
if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@v4
|
||||
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@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||
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/).
|
||||
10
.github/workflows/generate-release-pr.yml
vendored
10
.github/workflows/generate-release-pr.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
- name: Bump package versions
|
||||
@@ -35,10 +35,7 @@ jobs:
|
||||
pkg="${packages[$key]}"
|
||||
|
||||
if [[ -n "${{ github.event.inputs.version }}" ]]; then
|
||||
version=$(node ./packages/ci-actions/bin/get-next-package-version.js \
|
||||
--package-json "./packages/$pkg/package.json" \
|
||||
--version "${{ github.event.inputs.version }}" \
|
||||
--update)
|
||||
version="${{ github.event.inputs.version }}"
|
||||
else
|
||||
version=$(node ./packages/ci-actions/bin/get-next-package-version.js \
|
||||
--package-json "./packages/$pkg/package.json" \
|
||||
@@ -51,9 +48,8 @@ jobs:
|
||||
|
||||
echo "version=$NEW_WEB_VERSION" >> "$GITHUB_OUTPUT"
|
||||
- name: Create PR
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: actual
|
||||
- name: Set up environment
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
push \
|
||||
actualbudget/actual
|
||||
- name: Check out updated translations
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ssh-key: ${{ secrets.STRING_IMPORT_DEPLOY_KEY }}
|
||||
repository: actualbudget/translations
|
||||
|
||||
@@ -9,17 +9,16 @@ jobs:
|
||||
if: ${{ github.event.label.name == 'feature' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions-ecosystem/action-add-labels@bd52874380e3909a1ac983768df6976535ece7f8 # v1.1.0
|
||||
- uses: actions-ecosystem/action-add-labels@v1
|
||||
with:
|
||||
labels: needs votes
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Add reactions
|
||||
uses: aidan-mundy/react-to-issue@109392cac5159c2df6c47c8ab3b5d6b708852fe5 # v1.1.2
|
||||
uses: aidan-mundy/react-to-issue@v1.1.1
|
||||
with:
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
reactions: '+1'
|
||||
- name: Create comment
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
uses: peter-evans/create-or-update-comment@v3
|
||||
with:
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
body: |
|
||||
@@ -29,7 +28,7 @@ jobs:
|
||||
|
||||
The enhancement backlog can be found here: https://github.com/actualbudget/actual/issues?q=label%3A%22needs+votes%22+sort%3Areactions-%2B1-desc+
|
||||
|
||||
Don't forget to upvote the top comment with 👍!
|
||||
Don’t forget to upvote the top comment with 👍!
|
||||
|
||||
<!-- feature-auto-close-comment -->
|
||||
- name: Close Issue
|
||||
|
||||
@@ -24,10 +24,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# This is not a security concern because we have approved & merged the PR
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 20
|
||||
- name: Handle feature requests
|
||||
run: node .github/actions/handle-feature-requests.js
|
||||
env:
|
||||
|
||||
@@ -9,6 +9,6 @@ jobs:
|
||||
if: ${{ !contains(github.event.issue.labels.*.name, 'feature') && contains(github.event.issue.labels.*.name, 'help wanted') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions-ecosystem/action-remove-labels@2ce5d41b4b6aa8503e285553f75ed56e0a40bae0 # v1.3.0
|
||||
- uses: actions-ecosystem/action-remove-labels@v1
|
||||
with:
|
||||
labels: help wanted
|
||||
|
||||
2
.github/workflows/netlify-release.yml
vendored
2
.github/workflows/netlify-release.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Repository Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
25
.github/workflows/pr-ai-label-cleanup.yml
vendored
25
.github/workflows/pr-ai-label-cleanup.yml
vendored
@@ -1,25 +0,0 @@
|
||||
name: Remove 'suspect ai generated' label when 'AI generated' is present
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
remove-suspect-label:
|
||||
if: >-
|
||||
${{ contains(github.event.pull_request.labels.*.name, 'AI generated') &&
|
||||
contains(github.event.pull_request.labels.*.name, 'suspect ai generated') }}
|
||||
runs-on: ubuntu-slim
|
||||
steps:
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
name: 'suspect ai generated'
|
||||
});
|
||||
139
.github/workflows/publish-nightly-electron.yml
vendored
139
.github/workflows/publish-nightly-electron.yml
vendored
@@ -1,139 +0,0 @@
|
||||
name: Publish nightly desktop app
|
||||
|
||||
# Publish nightly version of desktop app - Runs every day at midnight
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
CI: true
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-22.04
|
||||
- windows-latest
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: github.event.repository.fork == false
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
run: pip.exe install setuptools
|
||||
|
||||
- if: ${{ ! startsWith(matrix.os, 'windows') }}
|
||||
run: |
|
||||
mkdir .venv
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
python3 -m pip install setuptools
|
||||
|
||||
- if: ${{ startsWith(matrix.os, 'ubuntu') }}
|
||||
name: Setup Flatpak dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install flatpak -y
|
||||
sudo apt-get install flatpak-builder -y
|
||||
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
sudo flatpak install org.freedesktop.Sdk//24.08 -y
|
||||
sudo flatpak install org.freedesktop.Platform//24.08 -y
|
||||
sudo flatpak install org.electronjs.Electron2.BaseApp//24.08 -y
|
||||
sudo flatpak install org.flatpak.Builder -y
|
||||
|
||||
METAINFO_FILE="packages/desktop-electron/extra-resources/linux/com.actualbudget.actual.metainfo.xml"
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
VERSION=$(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=$(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
|
||||
|
||||
- name: Build Electron for Mac
|
||||
if: ${{ startsWith(matrix.os, 'macos') }}
|
||||
run: ./bin/package-electron
|
||||
env:
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
- name: Build Electron
|
||||
if: ${{ ! startsWith(matrix.os, 'macos') }}
|
||||
run: ./bin/package-electron
|
||||
|
||||
- name: Upload Linux x64 AppImage
|
||||
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@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@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@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@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@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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: Actual-mac-arm64.dmg
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-mac-arm64.dmg
|
||||
|
||||
- name: Upload Windows Store Build
|
||||
if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}-appx
|
||||
path: |
|
||||
packages/desktop-electron/dist/*.appx
|
||||
@@ -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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
yarn workspace @actual-app/api pack --filename @actual-app/api.tgz
|
||||
|
||||
- name: Upload package artifacts
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: npm-packages
|
||||
path: |
|
||||
@@ -66,14 +66,14 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Download the artifacts
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: npm-packages
|
||||
|
||||
- name: Setup node and npm registry
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 20
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Publish Web
|
||||
|
||||
10
.github/workflows/publish-npm-packages.yml
vendored
10
.github/workflows/publish-npm-packages.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build and pack npm packages
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
yarn workspace @actual-app/api pack --filename @actual-app/api.tgz
|
||||
|
||||
- name: Upload package artifacts
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: npm-packages
|
||||
path: |
|
||||
@@ -49,14 +49,14 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Download the artifacts
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: npm-packages
|
||||
|
||||
- name: Setup node and npm registry
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 20
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Publish Web
|
||||
|
||||
19
.github/workflows/release-notes.yml
vendored
19
.github/workflows/release-notes.yml
vendored
@@ -12,24 +12,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
run: |
|
||||
git fetch origin ${{ github.base_ref }}
|
||||
CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)
|
||||
NON_DOCS_FILES=$(echo "$CHANGED_FILES" | grep -v -e "^packages/docs/" -e "^\.github/actions/docs-spelling/" || true)
|
||||
|
||||
if [ -z "$NON_DOCS_FILES" ] && [ -n "$CHANGED_FILES" ]; then
|
||||
echo "only_docs=true" >> $GITHUB_OUTPUT
|
||||
echo "only documentation files changed, skipping release notes check"
|
||||
else
|
||||
echo "only_docs=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
uses: actions/checkout@v4
|
||||
- name: Check release notes
|
||||
if: startsWith(github.head_ref, 'release/') == false && steps.changed-files.outputs.only_docs != 'true'
|
||||
if: startsWith(github.head_ref, 'release/') == false
|
||||
uses: actualbudget/actions/release-notes/check@main
|
||||
- name: Generate release notes
|
||||
if: startsWith(github.head_ref, 'release/') == true
|
||||
|
||||
138
.github/workflows/size-compare.yml
vendored
138
.github/workflows/size-compare.yml
vendored
@@ -15,13 +15,7 @@ on:
|
||||
pull_request_target:
|
||||
paths:
|
||||
- 'packages/**'
|
||||
- 'package.json'
|
||||
- 'yarn.lock'
|
||||
- '.github/workflows/size-compare.yml'
|
||||
- '!packages/sync-server/**' # Sync server changes don't affect the size of the web/api
|
||||
- '!packages/ci-actions/**' # CI actions changes don't affect the size of the web/api
|
||||
- '!packages/docs/**' # Docs changes don't affect the size of the web/api
|
||||
- '!packages/eslint-plugin-actual/**' # Eslint plugin changes don't affect the size of the web/api
|
||||
- '!packages/sync-server/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -32,122 +26,64 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout base branch
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: ${{ github.base_ref }}
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
|
||||
- name: Wait for ${{github.base_ref}} web build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
id: master-web-build
|
||||
- name: Wait for ${{github.base_ref}} build to succeed
|
||||
uses: fountainhead/action-wait-for-check@v1.2.0
|
||||
id: master-build
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: web
|
||||
ref: ${{github.base_ref}}
|
||||
- name: Wait for ${{github.base_ref}} API build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
id: master-api-build
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: api
|
||||
ref: ${{github.base_ref}}
|
||||
|
||||
- name: Wait for PR build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
id: wait-for-web-build
|
||||
uses: fountainhead/action-wait-for-check@v1.2.0
|
||||
id: wait-for-build
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: web
|
||||
ref: ${{github.event.pull_request.head.sha}}
|
||||
- name: Wait for API PR build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
id: wait-for-api-build
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: api
|
||||
ref: ${{github.event.pull_request.head.sha}}
|
||||
|
||||
- name: Report build failure
|
||||
if: steps.wait-for-web-build.outputs.conclusion == 'failure' || steps.wait-for-api-build.outputs.conclusion == 'failure'
|
||||
if: steps.wait-for-build.outputs.conclusion == 'failure'
|
||||
run: |
|
||||
echo "Build failed on PR branch or ${{github.base_ref}}"
|
||||
exit 1
|
||||
- name: Download build artifact from ${{github.base_ref}}
|
||||
uses: dawidd6/action-download-artifact@v6
|
||||
id: pr-build
|
||||
with:
|
||||
branch: ${{github.base_ref}}
|
||||
workflow: build.yml
|
||||
name: build-stats
|
||||
path: base
|
||||
|
||||
- name: Download web build artifact from ${{github.base_ref}}
|
||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
|
||||
id: pr-web-build
|
||||
with:
|
||||
branch: ${{github.base_ref}}
|
||||
workflow: build.yml
|
||||
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
|
||||
name: build-stats
|
||||
path: base
|
||||
- name: Download API build artifact from ${{github.base_ref}}
|
||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
|
||||
id: pr-api-build
|
||||
with:
|
||||
branch: ${{github.base_ref}}
|
||||
workflow: build.yml
|
||||
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
|
||||
name: api-build-stats
|
||||
path: base
|
||||
- name: Download build stats from PR
|
||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
|
||||
- name: Download build artifact from PR
|
||||
uses: dawidd6/action-download-artifact@v6
|
||||
with:
|
||||
pr: ${{github.event.pull_request.number}}
|
||||
workflow: build.yml
|
||||
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
|
||||
name: build-stats
|
||||
path: head
|
||||
allow_forks: true
|
||||
- name: Download API stats from PR
|
||||
uses: dawidd6/action-download-artifact@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: api-build-stats
|
||||
path: head
|
||||
allow_forks: true
|
||||
|
||||
- name: Strip content hashes from stats files
|
||||
run: |
|
||||
if [ -f ./head/web-stats.json ]; then
|
||||
sed -i -E 's/index\.[0-9a-zA-Z_-]{8,}\./index./g' ./head/web-stats.json
|
||||
sed -i -E 's/\.[0-9a-zA-Z_-]{8,}\.chunk\././g' ./head/web-stats.json
|
||||
fi
|
||||
if [ -f ./base/web-stats.json ]; then
|
||||
sed -i -E 's/index\.[0-9a-zA-Z_-]{8,}\./index./g' ./base/web-stats.json
|
||||
sed -i -E 's/\.[0-9a-zA-Z_-]{8,}\.chunk\././g' ./base/web-stats.json
|
||||
fi
|
||||
for file in ./head/*.json ./base/*.json; do
|
||||
if [ -f "$file" ]; then
|
||||
sed -i -E 's/\.[0-9a-f]{8,}\././g' "$file"
|
||||
fi
|
||||
done
|
||||
- name: Generate combined bundle stats comment
|
||||
run: |
|
||||
node packages/ci-actions/bin/bundle-stats-comment.mjs \
|
||||
--base desktop-client=./base/web-stats.json \
|
||||
--base loot-core=./base/loot-core-stats.json \
|
||||
--base api=./base/api-stats.json \
|
||||
--head desktop-client=./head/web-stats.json \
|
||||
--head loot-core=./head/loot-core-stats.json \
|
||||
--head api=./head/api-stats.json \
|
||||
--identifier combined \
|
||||
--format pr-body > bundle-stats-comment.md
|
||||
- name: Post combined bundle stats comment
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
node packages/ci-actions/bin/update-bundle-stats-comment.mjs \
|
||||
--comment-file bundle-stats-comment.md \
|
||||
--identifier combined \
|
||||
--target pr-body
|
||||
sed -i -E 's/index\.[0-9a-zA-Z_-]{8,}\./index./g' ./head/web-stats.json
|
||||
sed -i -E 's/\.[0-9a-zA-Z_-]{8,}\.chunk\././g' ./head/web-stats.json
|
||||
sed -i -E 's/\.[0-9a-f]{8,}\././g' ./head/*.json
|
||||
sed -i -E 's/index\.[0-9a-zA-Z_-]{8,}\./index./g' ./base/web-stats.json
|
||||
sed -i -E 's/\.[0-9a-zA-Z_-]{8,}\.chunk\././g' ./base/web-stats.json
|
||||
sed -i -E 's/\.[0-9a-f]{8,}\././g' ./base/*.json
|
||||
- uses: twk3/rollup-size-compare-action@v1.1.1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
current-stats-json-path: ./head/web-stats.json
|
||||
base-stats-json-path: ./base/web-stats.json
|
||||
title: desktop-client
|
||||
|
||||
- uses: twk3/rollup-size-compare-action@v1.1.1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
current-stats-json-path: ./head/loot-core-stats.json
|
||||
base-stats-json-path: ./base/loot-core-stats.json
|
||||
title: loot-core
|
||||
|
||||
6
.github/workflows/stale.yml
vendored
6
.github/workflows/stale.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
stale-pr-message: 'This PR is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.'
|
||||
close-pr-message: 'This PR was closed because it has been stalled for 5 days with no activity.'
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
stale-wip:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
stale-pr-message: ':wave: Hi! It looks like this PR has not had any changes for a week now. Would you like someone to review this PR? If so - please remove the "[WIP]" prefix from the PR title. That will let the community know that this PR is open for a review.'
|
||||
days-before-stale: 7
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
stale-needs-info:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
stale-issue-label: 'needs info'
|
||||
days-before-stale: -1
|
||||
|
||||
119
.github/workflows/update-vrt.yml
vendored
Normal file
119
.github/workflows/update-vrt.yml
vendored
Normal file
@@ -0,0 +1,119 @@
|
||||
name: /update-vrt
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number }}-${{ contains(github.event.comment.body, '/update-vrt') }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
update-vrt:
|
||||
name: Update VRT
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '/update-vrt')
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.55.1-jammy
|
||||
steps:
|
||||
- name: Get PR branch
|
||||
# Until https://github.com/xt0rted/pull-request-comment-branch/issues/322 is resolved we use the forked version
|
||||
uses: gotson/pull-request-comment-branch@head-repo-owner-dist
|
||||
id: comment-branch
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ steps.comment-branch.outputs.head_owner }}/${{ steps.comment-branch.outputs.head_repo }}
|
||||
ref: ${{ steps.comment-branch.outputs.head_ref }}
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Run VRT Tests on Desktop app
|
||||
continue-on-error: true
|
||||
run: |
|
||||
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop --update-snapshots
|
||||
- name: Wait for Netlify build to finish
|
||||
id: netlify
|
||||
env:
|
||||
COMMIT_SHA: ${{ steps.comment-branch.outputs.head_sha }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: ./.github/actions/netlify-wait-for-build
|
||||
- name: Run VRT Tests on Netlify URL
|
||||
run: yarn vrt --update-snapshots
|
||||
env:
|
||||
E2E_START_URL: ${{ steps.netlify.outputs.url }}
|
||||
- name: Create patch
|
||||
run: |
|
||||
git config --system --add safe.directory "*"
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git reset
|
||||
git add "**/*.png"
|
||||
if git diff --staged --quiet; then
|
||||
echo "No changes to commit"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "Update VRT"
|
||||
git format-patch -1 HEAD --stdout > Update-VRT.patch
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: patch
|
||||
path: Update-VRT.patch
|
||||
|
||||
push-patch:
|
||||
runs-on: ubuntu-latest
|
||||
needs: update-vrt
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Get PR branch
|
||||
# Until https://github.com/xt0rted/pull-request-comment-branch/issues/322 is resolved we use the forked version
|
||||
uses: gotson/pull-request-comment-branch@head-repo-owner-dist
|
||||
id: comment-branch
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ steps.comment-branch.outputs.head_owner }}/${{ steps.comment-branch.outputs.head_repo }}
|
||||
ref: ${{ steps.comment-branch.outputs.head_ref }}
|
||||
- uses: actions/download-artifact@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: patch
|
||||
- name: Apply patch and push
|
||||
env:
|
||||
BRANCH_NAME: ${{ steps.comment-branch.outputs.head_ref }}
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git apply Update-VRT.patch
|
||||
git add "**/*.png"
|
||||
if git diff --staged --quiet; then
|
||||
echo "No changes to commit"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "Update VRT"
|
||||
git push origin HEAD:${BRANCH_NAME}
|
||||
- name: Add finished reaction
|
||||
uses: dkershner6/reaction-action@v2
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commentId: ${{ github.event.comment.id }}
|
||||
reaction: 'rocket'
|
||||
|
||||
add-starting-reaction:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '/update-vrt')
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: React to comment
|
||||
uses: dkershner6/reaction-action@v2
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commentId: ${{ github.event.comment.id }}
|
||||
reaction: '+1'
|
||||
145
.github/workflows/vrt-update-apply.yml
vendored
145
.github/workflows/vrt-update-apply.yml
vendored
@@ -1,145 +0,0 @@
|
||||
name: VRT Update - Apply
|
||||
# SECURITY: This workflow runs in trusted base repo context.
|
||||
# It treats the patch artifact as untrusted data, validates it contains only PNGs,
|
||||
# and safely applies it to the contributor's fork branch.
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['VRT Update - Generate']
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
apply-vrt-updates:
|
||||
name: Apply VRT Updates
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
steps:
|
||||
- name: Download patch artifact
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
pattern: vrt-patch-*
|
||||
path: /tmp/artifacts
|
||||
|
||||
- name: Download metadata artifact
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
pattern: vrt-metadata-*
|
||||
path: /tmp/metadata
|
||||
|
||||
- name: Extract metadata
|
||||
id: metadata
|
||||
run: |
|
||||
if [ ! -f "/tmp/metadata/pr-number.txt" ]; then
|
||||
echo "No metadata found, skipping..."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PR_NUMBER=$(cat "/tmp/metadata/pr-number.txt")
|
||||
HEAD_REF=$(cat "/tmp/metadata/head-ref.txt")
|
||||
HEAD_REPO=$(cat "/tmp/metadata/head-repo.txt")
|
||||
|
||||
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
|
||||
echo "head_ref=$HEAD_REF" >> "$GITHUB_OUTPUT"
|
||||
echo "head_repo=$HEAD_REPO" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Found PR #$PR_NUMBER: $HEAD_REPO @ $HEAD_REF"
|
||||
|
||||
- name: Checkout fork branch
|
||||
if: steps.metadata.outputs.pr_number != ''
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
repository: ${{ steps.metadata.outputs.head_repo }}
|
||||
ref: ${{ steps.metadata.outputs.head_ref }}
|
||||
token: ${{ secrets.ACTIONS_UPDATE_TOKEN }}
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate and apply patch
|
||||
if: steps.metadata.outputs.pr_number != ''
|
||||
id: apply
|
||||
run: |
|
||||
PATCH_FILE="/tmp/artifacts/vrt-update.patch"
|
||||
|
||||
if [ ! -f "$PATCH_FILE" ]; then
|
||||
echo "No patch file found"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Found patch file: $PATCH_FILE"
|
||||
|
||||
# Validate patch only contains PNG files
|
||||
echo "Validating patch contains only PNG files..."
|
||||
if grep -E '^(\+\+\+|---) [ab]/' "$PATCH_FILE" | grep -v '\.png$'; then
|
||||
echo "ERROR: Patch contains non-PNG files! Rejecting for security."
|
||||
echo "applied=false" >> "$GITHUB_OUTPUT"
|
||||
echo "error=Patch validation failed: contains non-PNG files" >> "$GITHUB_OUTPUT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract file list for verification
|
||||
FILES_CHANGED=$(grep -E '^\+\+\+ b/' "$PATCH_FILE" | sed 's/^+++ b\///' | wc -l)
|
||||
echo "Patch modifies $FILES_CHANGED PNG file(s)"
|
||||
|
||||
# Configure git
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# Apply patch
|
||||
echo "Applying patch..."
|
||||
if git apply --check "$PATCH_FILE" 2>&1; then
|
||||
git apply "$PATCH_FILE"
|
||||
|
||||
# Stage only PNG files (extra safety)
|
||||
git add "**/*.png"
|
||||
|
||||
if git diff --staged --quiet; then
|
||||
echo "No changes after applying patch"
|
||||
echo "applied=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Commit
|
||||
git commit -m "Update VRT screenshots" -m "Auto-generated by VRT workflow" -m "PR: #${{ steps.metadata.outputs.pr_number }}"
|
||||
|
||||
echo "applied=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Patch could not be applied cleanly"
|
||||
echo "applied=false" >> "$GITHUB_OUTPUT"
|
||||
echo "error=Patch conflicts with current branch state" >> "$GITHUB_OUTPUT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Push changes
|
||||
if: steps.apply.outputs.applied == 'true'
|
||||
env:
|
||||
HEAD_REF: ${{ steps.metadata.outputs.head_ref }}
|
||||
HEAD_REPO: ${{ steps.metadata.outputs.head_repo }}
|
||||
GITHUB_TOKEN: ${{ secrets.ACTIONS_UPDATE_TOKEN }}
|
||||
run: |
|
||||
# Use PAT in URL to ensure push triggers CI workflows
|
||||
# Note: GitHub Actions automatically masks secrets in logs
|
||||
git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${HEAD_REPO}.git"
|
||||
|
||||
git push origin "HEAD:refs/heads/$HEAD_REF"
|
||||
echo "Successfully pushed VRT updates to $HEAD_REPO@$HEAD_REF"
|
||||
|
||||
- name: Comment on PR - Failure
|
||||
if: failure() && steps.metadata.outputs.pr_number != ''
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const error = `${{ steps.apply.outputs.error }}` || 'Unknown error occurred';
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: ${{ steps.metadata.outputs.pr_number }},
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `❌ Failed to apply VRT updates: ${error}\n\nPlease check the workflow logs for details.`
|
||||
});
|
||||
136
.github/workflows/vrt-update-generate.yml
vendored
136
.github/workflows/vrt-update-generate.yml
vendored
@@ -1,136 +0,0 @@
|
||||
name: VRT Update - Generate
|
||||
# SECURITY: This workflow runs in untrusted fork context with no write permissions.
|
||||
# It only generates VRT patch artifacts that are later applied by vrt-update-apply.yml
|
||||
# Triggered by commenting "/update-vrt" on a pull request.
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
add-reaction:
|
||||
name: Add 👀 Reaction
|
||||
runs-on: ubuntu-latest
|
||||
# Only run on PR comments containing /update-vrt
|
||||
if: >
|
||||
github.event.issue.pull_request &&
|
||||
startsWith(github.event.comment.body, '/update-vrt')
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Add 👀 reaction to comment
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: context.payload.comment.id,
|
||||
content: 'eyes'
|
||||
});
|
||||
|
||||
generate-vrt-updates:
|
||||
name: Generate VRT Updates
|
||||
runs-on: ubuntu-latest
|
||||
# Only run on PR comments containing /update-vrt
|
||||
if: >
|
||||
github.event.issue.pull_request &&
|
||||
startsWith(github.event.comment.body, '/update-vrt')
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.57.0-jammy
|
||||
steps:
|
||||
- name: Get PR details
|
||||
id: pr
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number
|
||||
});
|
||||
core.setOutput('head_sha', pr.head.sha);
|
||||
core.setOutput('head_ref', pr.head.ref);
|
||||
core.setOutput('head_repo', pr.head.repo.full_name);
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: ${{ steps.pr.outputs.head_sha }}
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
|
||||
- name: Run VRT Tests on Desktop app
|
||||
continue-on-error: true
|
||||
run: |
|
||||
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop --update-snapshots
|
||||
|
||||
- name: Run VRT Tests
|
||||
continue-on-error: true
|
||||
run: yarn vrt --update-snapshots
|
||||
|
||||
- name: Create patch with PNG changes only
|
||||
id: create-patch
|
||||
run: |
|
||||
# Trust the repository directory (required for container environments)
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# Stage only PNG files
|
||||
git add "**/*.png"
|
||||
|
||||
# Check if there are any changes
|
||||
if git diff --staged --quiet; then
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No VRT changes to commit"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Create commit and patch
|
||||
git commit -m "Update VRT screenshots"
|
||||
git format-patch -1 HEAD --stdout > vrt-update.patch
|
||||
|
||||
# Validate patch only contains PNG files
|
||||
if grep -E '^(\+\+\+|---) [ab]/' vrt-update.patch | grep -v '\.png$'; then
|
||||
echo "ERROR: Patch contains non-PNG files!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Patch created successfully with PNG changes only"
|
||||
|
||||
- name: Upload patch artifact
|
||||
if: steps.create-patch.outputs.has_changes == 'true'
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: vrt-patch-${{ github.event.issue.number }}
|
||||
path: vrt-update.patch
|
||||
retention-days: 5
|
||||
|
||||
- name: Save PR metadata
|
||||
if: steps.create-patch.outputs.has_changes == 'true'
|
||||
run: |
|
||||
mkdir -p pr-metadata
|
||||
echo "${{ github.event.issue.number }}" > pr-metadata/pr-number.txt
|
||||
echo "${{ steps.pr.outputs.head_ref }}" > pr-metadata/head-ref.txt
|
||||
echo "${{ steps.pr.outputs.head_repo }}" > pr-metadata/head-repo.txt
|
||||
|
||||
- name: Upload PR metadata
|
||||
if: steps.create-patch.outputs.has_changes == 'true'
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: vrt-metadata-${{ github.event.issue.number }}
|
||||
path: pr-metadata/
|
||||
retention-days: 5
|
||||
22
.gitignore
vendored
22
.gitignore
vendored
@@ -7,6 +7,9 @@ Actual-*
|
||||
**/xcuserdata/*
|
||||
export-2020-01-10.csv
|
||||
|
||||
# Secrets
|
||||
.secret-tokens
|
||||
|
||||
# MacOS
|
||||
.DS_Store
|
||||
|
||||
@@ -15,17 +18,9 @@ export-2020-01-10.csv
|
||||
|
||||
# JavaScript
|
||||
node_modules
|
||||
packages/api/app/bundle.api.js
|
||||
packages/api/app/stats.json
|
||||
packages/api/dist
|
||||
packages/api/@types
|
||||
packages/crdt/dist
|
||||
packages/desktop-client/build-stats
|
||||
packages/desktop-client/dev-dist
|
||||
packages/desktop-client/public/kcab
|
||||
packages/desktop-client/locale
|
||||
packages/desktop-client/playwright-report
|
||||
packages/desktop-client/test-results
|
||||
packages/desktop-electron/client-build
|
||||
packages/desktop-electron/build
|
||||
packages/desktop-electron/.electron-symbols
|
||||
@@ -33,8 +28,6 @@ packages/desktop-electron/dist
|
||||
packages/desktop-electron/loot-core
|
||||
packages/desktop-client/service-worker
|
||||
packages/plugins-service/dist
|
||||
packages/loot-core/lib-dist
|
||||
packages/sync-server/coverage
|
||||
bundle.desktop.js
|
||||
bundle.desktop.js.map
|
||||
bundle.mobile.js
|
||||
@@ -50,8 +43,7 @@ bundle.mobile.js.map
|
||||
!.yarn/versions
|
||||
|
||||
# VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.default.json
|
||||
.vscode
|
||||
|
||||
# IntelliJ IDEA
|
||||
.idea
|
||||
@@ -73,9 +65,3 @@ build/
|
||||
|
||||
# .d.ts files aren't type-checked with skipLibCheck set to true
|
||||
*.d.ts
|
||||
|
||||
# Lage cache
|
||||
.lage/
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"arrowParens": "avoid",
|
||||
"printWidth": 80,
|
||||
"experimentalSortImports": {
|
||||
"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": true
|
||||
}
|
||||
}
|
||||
412
.oxlintrc.json
412
.oxlintrc.json
@@ -1,412 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "import", "jsx-a11y"],
|
||||
"jsPlugins": [
|
||||
"./packages/eslint-plugin-actual/lib/index.js",
|
||||
"eslint-plugin-typescript-paths",
|
||||
"eslint-plugin-perfectionist"
|
||||
],
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true,
|
||||
"vitest": true
|
||||
},
|
||||
"globals": {
|
||||
"vi": "readonly",
|
||||
"backend": "readonly",
|
||||
"importScripts": "readonly",
|
||||
"FS": "readonly"
|
||||
},
|
||||
"rules": {
|
||||
// Import sorting
|
||||
"perfectionist/sort-named-imports": [
|
||||
"warn",
|
||||
{
|
||||
"groups": ["value-import", "type-import"]
|
||||
}
|
||||
],
|
||||
|
||||
// Actual rules
|
||||
"actual/typography": "warn",
|
||||
"actual/no-untranslated-strings": "error",
|
||||
"actual/prefer-trans-over-t": "error",
|
||||
"actual/prefer-if-statement": "warn",
|
||||
"actual/prefer-logger-over-console": "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": [
|
||||
"warn",
|
||||
{
|
||||
"ignoreNonDOM": true
|
||||
}
|
||||
],
|
||||
"jsx-a11y/alt-text": "warn",
|
||||
"jsx-a11y/anchor-has-content": "warn",
|
||||
"jsx-a11y/anchor-is-valid": [
|
||||
"warn",
|
||||
{
|
||||
"aspects": ["noHref", "invalidHref"]
|
||||
}
|
||||
],
|
||||
"jsx-a11y/aria-activedescendant-has-tabindex": "warn",
|
||||
"jsx-a11y/aria-props": "warn",
|
||||
"jsx-a11y/aria-proptypes": "warn",
|
||||
"jsx-a11y/aria-role": [
|
||||
"warn",
|
||||
{
|
||||
"ignoreNonDOM": true
|
||||
}
|
||||
],
|
||||
"jsx-a11y/aria-unsupported-elements": "warn",
|
||||
"jsx-a11y/heading-has-content": "warn",
|
||||
"jsx-a11y/iframe-has-title": "warn",
|
||||
"jsx-a11y/img-redundant-alt": "warn",
|
||||
"jsx-a11y/no-access-key": "warn",
|
||||
"jsx-a11y/no-distracting-elements": "warn",
|
||||
"jsx-a11y/no-redundant-roles": "warn",
|
||||
"jsx-a11y/role-has-required-aria-props": "warn",
|
||||
"jsx-a11y/role-supports-aria-props": "warn",
|
||||
"jsx-a11y/scope": "warn",
|
||||
|
||||
// Typescript rules
|
||||
"typescript/ban-ts-comment": ["warn"],
|
||||
"typescript/consistent-type-definitions": ["warn", "type"],
|
||||
"typescript/consistent-type-imports": [
|
||||
"warn",
|
||||
{
|
||||
"prefer": "type-imports",
|
||||
"fixStyle": "inline-type-imports"
|
||||
}
|
||||
],
|
||||
"typescript/no-implied-eval": "warn",
|
||||
"typescript/no-explicit-any": "warn",
|
||||
"typescript/no-restricted-types": [
|
||||
"warn",
|
||||
{
|
||||
"types": {
|
||||
// forbid FC as superfluous
|
||||
"FunctionComponent": {
|
||||
"message": "Type the props argument and let TS infer or use ComponentType for a component prop"
|
||||
},
|
||||
"FC": {
|
||||
"message": "Type the props argument and let TS infer or use ComponentType for a component prop"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"typescript/no-var-requires": "warn",
|
||||
|
||||
// Import rules
|
||||
"import/consistent-type-specifier-style": "warn",
|
||||
"import/first": "error",
|
||||
"import/no-amd": "error",
|
||||
"import/no-default-export": "warn",
|
||||
"import/no-webpack-loader-syntax": "error",
|
||||
"import/no-useless-path-segments": "warn",
|
||||
"import/no-unresolved": "warn",
|
||||
"import/no-unused-modules": "warn",
|
||||
"import/no-duplicates": [
|
||||
"warn",
|
||||
{
|
||||
"prefer-inline": false
|
||||
}
|
||||
],
|
||||
|
||||
// React rules
|
||||
"react/exhaustive-deps": [
|
||||
"warn",
|
||||
{
|
||||
"additionalHooks": "(^useQuery$|^useEffectAfterMount$)"
|
||||
}
|
||||
],
|
||||
"react/jsx-curly-brace-presence": "warn",
|
||||
"react/jsx-filename-extension": [
|
||||
"warn",
|
||||
{
|
||||
"extensions": [".jsx", ".tsx"],
|
||||
"allow": "as-needed"
|
||||
}
|
||||
],
|
||||
"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": "warn",
|
||||
"react/jsx-pascal-case": [
|
||||
"warn",
|
||||
{
|
||||
"allowAllCaps": true,
|
||||
"ignore": []
|
||||
}
|
||||
],
|
||||
"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": "warn",
|
||||
"react/style-prop-object": "warn",
|
||||
"react/jsx-boolean-value": "warn",
|
||||
|
||||
// ESLint rules
|
||||
"eslint/array-callback-return": "warn",
|
||||
"eslint/curly": ["warn", "multi-line", "consistent"],
|
||||
"eslint/default-case": [
|
||||
"warn",
|
||||
{
|
||||
"commentPattern": "^no default$"
|
||||
}
|
||||
],
|
||||
"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": [
|
||||
"warn",
|
||||
{
|
||||
"allowLoop": true,
|
||||
"allowSwitch": false
|
||||
}
|
||||
],
|
||||
"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": [
|
||||
"warn",
|
||||
{
|
||||
"ignoreDestructuring": false,
|
||||
"ignoreImport": false,
|
||||
"ignoreExport": false
|
||||
}
|
||||
],
|
||||
"eslint/no-with": "warn",
|
||||
"eslint/no-regex-spaces": "warn",
|
||||
"eslint/no-restricted-globals": [
|
||||
"warn",
|
||||
|
||||
// https://github.com/facebook/create-react-app/tree/main/packages/confusing-browser-globals
|
||||
"addEventListener",
|
||||
"blur",
|
||||
"close",
|
||||
"closed",
|
||||
"confirm",
|
||||
"defaultStatus",
|
||||
"defaultstatus",
|
||||
"event",
|
||||
"external",
|
||||
"find",
|
||||
"focus",
|
||||
"frameElement",
|
||||
"frames",
|
||||
"history",
|
||||
"innerHeight",
|
||||
"innerWidth",
|
||||
"length",
|
||||
"location",
|
||||
"locationbar",
|
||||
"menubar",
|
||||
"moveBy",
|
||||
"moveTo",
|
||||
"name",
|
||||
"onblur",
|
||||
"onerror",
|
||||
"onfocus",
|
||||
"onload",
|
||||
"onresize",
|
||||
"onunload",
|
||||
"open",
|
||||
"opener",
|
||||
"opera",
|
||||
"outerHeight",
|
||||
"outerWidth",
|
||||
"pageXOffset",
|
||||
"pageYOffset",
|
||||
"parent",
|
||||
"print",
|
||||
"removeEventListener",
|
||||
"resizeBy",
|
||||
"resizeTo",
|
||||
"screen",
|
||||
"screenLeft",
|
||||
"screenTop",
|
||||
"screenX",
|
||||
"screenY",
|
||||
"scroll",
|
||||
"scrollbars",
|
||||
"scrollBy",
|
||||
"scrollTo",
|
||||
"scrollX",
|
||||
"scrollY",
|
||||
"status",
|
||||
"statusbar",
|
||||
"stop",
|
||||
"toolbar",
|
||||
"top"
|
||||
],
|
||||
"eslint/no-restricted-imports": [
|
||||
"warn",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "react-router",
|
||||
"importNames": ["useNavigate"],
|
||||
"message": "Please import Actual's useNavigate() hook from `src/hooks` instead."
|
||||
},
|
||||
{
|
||||
"name": "react-redux",
|
||||
"importNames": ["useDispatch"],
|
||||
"message": "Please import Actual's useDispatch() hook from `src/redux` instead."
|
||||
},
|
||||
{
|
||||
"name": "react-redux",
|
||||
"importNames": ["useSelector"],
|
||||
"message": "Please import Actual's useSelector() hook from `src/redux` instead."
|
||||
},
|
||||
{
|
||||
"name": "react-redux",
|
||||
"importNames": ["useStore"],
|
||||
"message": "Please import Actual's useStore() hook from `src/redux` instead."
|
||||
}
|
||||
],
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["**/*.api", "**/*.web", "**/*.electron"],
|
||||
"message": "Don't directly reference imports from other platforms"
|
||||
},
|
||||
{
|
||||
"group": ["uuid"],
|
||||
"importNames": ["*"],
|
||||
"message": "Use `import { v4 as uuidv4 } from 'uuid'` instead"
|
||||
},
|
||||
{
|
||||
"group": ["**/style", "**/colors"],
|
||||
"importNames": ["colors"],
|
||||
"message": "Please use themes instead of colors"
|
||||
},
|
||||
{
|
||||
"group": ["**/style/themes/*"],
|
||||
"message": "Please do not import theme files directly"
|
||||
},
|
||||
{
|
||||
"group": ["@actual-app/web/**/*"],
|
||||
"message": "Please do not import `@actual-app/web` in `loot-core`"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"eslint/no-useless-constructor": "warn",
|
||||
"eslint/no-undef": "warn",
|
||||
"eslint/no-unused-expressions": "warn"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["packages/desktop-electron/**/*"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["**/*.test.{js,ts,jsx,tsx}", "packages/docs/**/*"],
|
||||
"rules": {
|
||||
"actual/no-untranslated-strings": "off",
|
||||
"actual/prefer-logger-over-console": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"packages/api/migrations/*",
|
||||
"packages/loot-core/migrations/*",
|
||||
"packages/sync-server/src/app-gocardless/banks/*.js",
|
||||
"*.config.{ts,mts,mjs}"
|
||||
],
|
||||
"rules": {
|
||||
"import/no-default-export": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["packages/docs/**/*"],
|
||||
"rules": {
|
||||
"actual/no-anchor-tag": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["packages/desktop-client/**/*.{js,ts,jsx,tsx}"],
|
||||
"rules": {
|
||||
"typescript-paths/absolute-parent-import": [
|
||||
"error",
|
||||
{ "preferPathOverBaseUrl": true }
|
||||
],
|
||||
"typescript-paths/absolute-import": ["error", { "enableAlias": false }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["packages/desktop-client/src/style/themes/*"],
|
||||
"rules": {
|
||||
"eslint/no-restricted-imports": "off"
|
||||
}
|
||||
},
|
||||
// TODO: enable these
|
||||
{
|
||||
"files": [
|
||||
"packages/desktop-client/src/components/ManageRules.tsx",
|
||||
"packages/desktop-client/src/components/mobile/budget/ExpenseGroupList.tsx",
|
||||
"packages/desktop-client/src/components/reports/reports/Calendar.tsx",
|
||||
"packages/desktop-client/src/components/table.tsx"
|
||||
],
|
||||
"rules": {
|
||||
"eslint/no-empty-function": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
30
.prettierignore
Normal file
30
.prettierignore
Normal file
@@ -0,0 +1,30 @@
|
||||
sync_pb.*
|
||||
packages/api/app/bundle.api.js
|
||||
packages/api/app/stats.json
|
||||
packages/api/dist
|
||||
packages/api/@types
|
||||
packages/api/migrations
|
||||
packages/crdt/dist
|
||||
packages/component-library/src/icons/**/*
|
||||
packages/desktop-client/bundle.browser.js
|
||||
packages/desktop-client/stats.json
|
||||
packages/desktop-client/.swc/
|
||||
packages/desktop-client/build/
|
||||
packages/desktop-client/locale/
|
||||
packages/desktop-client/build-electron/
|
||||
packages/desktop-client/build-stats/
|
||||
packages/desktop-client/public/kcab/
|
||||
packages/desktop-client/public/data/
|
||||
packages/desktop-client/**/node_modules/*
|
||||
packages/desktop-client/node_modules/
|
||||
packages/desktop-client/test-results/
|
||||
packages/desktop-client/playwright-report/
|
||||
packages/desktop-electron/client-build/
|
||||
packages/desktop-electron/build/
|
||||
packages/desktop-electron/dist/
|
||||
packages/loot-core/**/node_modules/*
|
||||
packages/loot-core/**/lib-dist/*
|
||||
packages/loot-core/**/proto/*
|
||||
packages/sync-server/coverage/
|
||||
.yarn/*
|
||||
upcoming-release-notes/*
|
||||
5
.prettierrc.json
Normal file
5
.prettierrc.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
2
.secret-tokens.example
Normal file
2
.secret-tokens.example
Normal file
@@ -0,0 +1,2 @@
|
||||
export APPLE_ID=example@email.com
|
||||
export APPLE_APP_SPECIFIC_PASSWORD=password
|
||||
7
.vscode/settings.default.json
vendored
7
.vscode/settings.default.json
vendored
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||
"vitest.nodeEnv": {
|
||||
"NODE_OPTIONS": "--experimental-vm-modules --import ./packages/sync-server/register-loader.mjs --trace-warnings"
|
||||
}
|
||||
}
|
||||
942
.yarn/releases/yarn-4.10.3.cjs
vendored
942
.yarn/releases/yarn-4.10.3.cjs
vendored
File diff suppressed because one or more lines are too long
948
.yarn/releases/yarn-4.9.1.cjs
vendored
Executable file
948
.yarn/releases/yarn-4.9.1.cjs
vendored
Executable file
File diff suppressed because one or more lines are too long
@@ -6,4 +6,4 @@ enableTransparentWorkspaces: false
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.10.3.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.9.1.cjs
|
||||
|
||||
601
AGENTS.md
601
AGENTS.md
@@ -1,601 +0,0 @@
|
||||
# AGENTS.md - Guide for AI Agents Working with Actual Budget
|
||||
|
||||
This guide provides comprehensive information for AI agents (like Cursor) working with the Actual Budget codebase.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Actual Budget** is a local-first personal finance tool written in TypeScript/JavaScript. It's 100% free and open-source with synchronization capabilities across devices.
|
||||
|
||||
- **Repository**: https://github.com/actualbudget/actual
|
||||
- **Community Docs**: Documentation is part of the monorepo at `packages/docs/`. Published at https://actualbudget.org/docs
|
||||
- **License**: MIT
|
||||
- **Primary Language**: TypeScript (with React)
|
||||
- **Build System**: Yarn 4 workspaces (monorepo)
|
||||
|
||||
## Quick Start Commands
|
||||
|
||||
### Essential Commands (Run from Root)
|
||||
|
||||
```bash
|
||||
# Type checking (ALWAYS run before committing)
|
||||
yarn typecheck
|
||||
|
||||
# Linting and formatting (with auto-fix)
|
||||
yarn lint:fix
|
||||
|
||||
# Run all tests
|
||||
yarn test
|
||||
|
||||
# Start development server (browser)
|
||||
yarn start
|
||||
|
||||
# Start with sync server
|
||||
yarn start:server-dev
|
||||
|
||||
# Start desktop app development
|
||||
yarn start:desktop
|
||||
```
|
||||
|
||||
### Important Rules
|
||||
|
||||
- **ALWAYS run yarn commands from the root directory** - never run them in child workspaces
|
||||
- Use `yarn workspace <workspace-name> run <command>` for workspace-specific tasks
|
||||
- Tests run once and exit by default (using `vitest --run`)
|
||||
|
||||
### 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:
|
||||
|
||||
- **Parallel execution**: Runs tests in parallel across workspaces for faster feedback
|
||||
- **Smart caching**: Caches test results to skip unchanged packages (cached in `.lage/` directory)
|
||||
- **Dependency awareness**: Understands workspace dependencies and execution order
|
||||
- **Continues on error**: Uses `--continue` flag to run all packages even if one fails
|
||||
|
||||
**Lage Commands:**
|
||||
|
||||
```bash
|
||||
# Run all tests across all packages
|
||||
yarn test # Equivalent to: lage test --continue
|
||||
|
||||
# Run tests without cache (for debugging/CI)
|
||||
yarn test:debug # Equivalent to: lage test --no-cache --continue
|
||||
```
|
||||
|
||||
Configuration is in `lage.config.js` at the project root.
|
||||
|
||||
## Architecture & Package Structure
|
||||
|
||||
### Core Packages
|
||||
|
||||
#### 1. **loot-core** (`packages/loot-core/`)
|
||||
|
||||
The core application logic that runs on any platform.
|
||||
|
||||
- Business logic, database operations, and calculations
|
||||
- Platform-agnostic code
|
||||
- Exports for both browser and node environments
|
||||
- Test commands:
|
||||
|
||||
```bash
|
||||
# Run all loot-core tests
|
||||
yarn workspace loot-core run test
|
||||
|
||||
# Or run tests across all packages using lage
|
||||
yarn test
|
||||
```
|
||||
|
||||
#### 2. **desktop-client** (`packages/desktop-client/` - aliased as `@actual-app/web`)
|
||||
|
||||
The React-based UI for web and desktop.
|
||||
|
||||
- React components using functional programming patterns
|
||||
- E2E tests using Playwright
|
||||
- Vite for bundling
|
||||
- Commands:
|
||||
|
||||
```bash
|
||||
# Development
|
||||
yarn workspace @actual-app/web start:browser
|
||||
|
||||
# Build
|
||||
yarn workspace @actual-app/web build
|
||||
|
||||
# E2E tests
|
||||
yarn workspace @actual-app/web e2e
|
||||
|
||||
# Visual regression tests
|
||||
yarn workspace @actual-app/web vrt
|
||||
```
|
||||
|
||||
#### 3. **desktop-electron** (`packages/desktop-electron/`)
|
||||
|
||||
Electron wrapper for the desktop application.
|
||||
|
||||
- Window management and native OS integration
|
||||
- E2E tests for Electron-specific features
|
||||
|
||||
#### 4. **api** (`packages/api/` - aliased as `@actual-app/api`)
|
||||
|
||||
Public API for programmatic access to Actual.
|
||||
|
||||
- Node.js API
|
||||
- Designed for integrations and automation
|
||||
- Commands:
|
||||
|
||||
```bash
|
||||
# Build
|
||||
yarn workspace @actual-app/api build
|
||||
|
||||
# Run tests
|
||||
yarn workspace @actual-app/api test
|
||||
|
||||
# Or use lage to run all tests
|
||||
yarn test
|
||||
```
|
||||
|
||||
#### 5. **sync-server** (`packages/sync-server/` - aliased as `@actual-app/sync-server`)
|
||||
|
||||
Synchronization server for multi-device support.
|
||||
|
||||
- Express-based server
|
||||
- Currently transitioning to TypeScript (mostly JavaScript)
|
||||
- Commands:
|
||||
```bash
|
||||
yarn workspace @actual-app/sync-server start
|
||||
```
|
||||
|
||||
#### 6. **component-library** (`packages/component-library/` - aliased as `@actual-app/components`)
|
||||
|
||||
Reusable React UI components.
|
||||
|
||||
- Shared components like Button, Input, Menu, etc.
|
||||
- Theme system and design tokens
|
||||
- Icons (375+ icons in SVG/TSX format)
|
||||
|
||||
#### 7. **crdt** (`packages/crdt/` - aliased as `@actual-app/crdt`)
|
||||
|
||||
CRDT (Conflict-free Replicated Data Type) implementation for data synchronization.
|
||||
|
||||
- Protocol buffers for serialization
|
||||
- Core sync logic
|
||||
|
||||
#### 8. **plugins-service** (`packages/plugins-service/`)
|
||||
|
||||
Service for handling plugins/extensions.
|
||||
|
||||
#### 9. **eslint-plugin-actual** (`packages/eslint-plugin-actual/`)
|
||||
|
||||
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/`
|
||||
- `typography`: Typography rules
|
||||
- `prefer-if-statement`: Prefers explicit if statements
|
||||
|
||||
#### 10. **docs** (`packages/docs/`)
|
||||
|
||||
Documentation website built with Docusaurus.
|
||||
|
||||
- Documentation is part of the monorepo
|
||||
- Built with Docusaurus 3
|
||||
- Commands:
|
||||
```bash
|
||||
yarn workspace docs start
|
||||
yarn workspace docs build
|
||||
yarn start:docs # From root
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### 1. Making Changes
|
||||
|
||||
When implementing changes:
|
||||
|
||||
1. Read relevant files to understand current implementation
|
||||
2. Make focused, incremental changes
|
||||
3. Run type checking: `yarn typecheck`
|
||||
4. Run linting: `yarn lint:fix`
|
||||
5. Run relevant tests
|
||||
6. Fix any linter errors that are introduced
|
||||
|
||||
### 2. Testing Strategy
|
||||
|
||||
**Unit Tests (Vitest)**
|
||||
|
||||
The project uses **lage** for running tests across all workspaces efficiently.
|
||||
|
||||
```bash
|
||||
# Run all tests across all packages (using lage)
|
||||
yarn test
|
||||
|
||||
# Run tests without cache (for debugging)
|
||||
yarn test:debug
|
||||
|
||||
# Run tests for a specific package
|
||||
yarn workspace loot-core run test
|
||||
```
|
||||
|
||||
**E2E Tests (Playwright)**
|
||||
|
||||
```bash
|
||||
# Run E2E tests for web
|
||||
yarn e2e
|
||||
|
||||
# Desktop Electron E2E (includes full build)
|
||||
yarn e2e:desktop
|
||||
|
||||
# Visual regression tests
|
||||
yarn vrt
|
||||
|
||||
# Visual regression in Docker (consistent environment)
|
||||
yarn vrt:docker
|
||||
|
||||
# Run E2E tests for a specific package
|
||||
yarn workspace @actual-app/web e2e
|
||||
```
|
||||
|
||||
**Testing Best Practices:**
|
||||
|
||||
- Minimize mocked dependencies - prefer real implementations
|
||||
- Use descriptive test names
|
||||
- Vitest globals are available: `describe`, `it`, `expect`, `beforeEach`, etc.
|
||||
- For sync-server tests, globals are explicitly defined in config
|
||||
|
||||
### 3. Type Checking
|
||||
|
||||
TypeScript configuration uses:
|
||||
|
||||
- Incremental compilation
|
||||
- Strict type checking with `typescript-strict-plugin`
|
||||
- Platform-specific exports in `loot-core` (node vs browser)
|
||||
|
||||
Always run `yarn typecheck` before committing.
|
||||
|
||||
### 4. Internationalization (i18n)
|
||||
|
||||
- Use `Trans` component instead of `t()` function when possible
|
||||
- All user-facing strings must be translated
|
||||
- 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
|
||||
|
||||
**Type Usage:**
|
||||
|
||||
- Use TypeScript for all code
|
||||
- Prefer `type` over `interface`
|
||||
- Avoid `enum` - use objects or maps
|
||||
- Avoid `any` or `unknown` unless absolutely necessary
|
||||
- Look for existing type definitions in the codebase
|
||||
- Avoid type assertions (`as`, `!`) - prefer `satisfies`
|
||||
- Use inline type imports: `import { type MyType } from '...'`
|
||||
|
||||
**Naming:**
|
||||
|
||||
- Use descriptive variable names with auxiliary verbs (e.g., `isLoaded`, `hasError`)
|
||||
- Named exports for components and utilities (avoid default exports except in specific cases)
|
||||
|
||||
**Code Structure:**
|
||||
|
||||
- Functional and declarative programming patterns - avoid classes
|
||||
- Use the `function` keyword for pure functions
|
||||
- Prefer iteration and modularization over code duplication
|
||||
- Structure files: exported component/page, helpers, static content, types
|
||||
- Create new components in their own files
|
||||
|
||||
**React Patterns:**
|
||||
|
||||
- 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
|
||||
- Use custom hooks from `src/hooks` (not react-router directly):
|
||||
- `useNavigate()` from `src/hooks` (not react-router)
|
||||
- `useDispatch()`, `useSelector()`, `useStore()` from `src/redux` (not react-redux)
|
||||
- Avoid unstable nested components
|
||||
- Use `satisfies` for type narrowing
|
||||
|
||||
**JSX Style:**
|
||||
|
||||
- Declarative JSX, minimal and readable
|
||||
- Avoid unnecessary curly braces in conditionals
|
||||
- Use concise syntax for simple statements
|
||||
- Prefer explicit expressions (`condition && <Component />`)
|
||||
|
||||
### Import Organization
|
||||
|
||||
Imports are automatically organized by ESLint with the following order:
|
||||
|
||||
1. React imports (first)
|
||||
2. Built-in Node.js modules
|
||||
3. External packages
|
||||
4. Actual packages (`loot-core`, `@actual-app/components` - legacy pattern `loot-design` may appear in old code)
|
||||
5. Parent imports
|
||||
6. Sibling imports
|
||||
7. Index imports
|
||||
|
||||
Always maintain newlines between import groups.
|
||||
|
||||
### Platform-Specific Code
|
||||
|
||||
- Don't directly reference platform-specific imports (`.api`, `.web`, `.electron`)
|
||||
- Use conditional exports in `loot-core` for platform-specific code
|
||||
- Platform resolution happens at build time via package.json exports
|
||||
|
||||
### Restricted Patterns
|
||||
|
||||
**Never:**
|
||||
|
||||
- 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:**
|
||||
|
||||
- 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
|
||||
|
||||
### Typical Component File
|
||||
|
||||
```typescript
|
||||
import { type ComponentType } from 'react';
|
||||
// ... other imports
|
||||
|
||||
type MyComponentProps = {
|
||||
// Props definition
|
||||
};
|
||||
|
||||
export function MyComponent({ prop1, prop2 }: MyComponentProps) {
|
||||
// Component logic
|
||||
return (
|
||||
// JSX
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Test File
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
// ... imports
|
||||
|
||||
describe('ComponentName', () => {
|
||||
it('should behave as expected', () => {
|
||||
// Test logic
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Important Directories & Files
|
||||
|
||||
### Configuration Files
|
||||
|
||||
- `/package.json` - Root workspace configuration, scripts
|
||||
- `/lage.config.js` - Lage task runner configuration
|
||||
- `/eslint.config.mjs` - ESLint configuration (flat config format)
|
||||
- `/tsconfig.json` - Root TypeScript configuration
|
||||
- `/.cursorignore`, `/.gitignore` - Ignored files
|
||||
- `/yarn.lock` - Dependency lockfile (Yarn 4)
|
||||
|
||||
### Documentation
|
||||
|
||||
- `/README.md` - Project overview
|
||||
- `/CONTRIBUTING.md` - Points to community docs
|
||||
- `/upcoming-release-notes/` - Release notes for next version
|
||||
- `/CODEOWNERS` - Code ownership definitions
|
||||
- `/packages/docs/` - Documentation website (Docusaurus)
|
||||
|
||||
### Build Artifacts (Don't Edit)
|
||||
|
||||
- `packages/*/lib-dist/` - Built output
|
||||
- `packages/*/dist/` - Built output
|
||||
- `packages/*/build/` - Built output
|
||||
- `packages/desktop-client/playwright-report/` - Test reports
|
||||
- `packages/desktop-client/test-results/` - Test results
|
||||
- `.lage/` - Lage task runner cache (improves test performance)
|
||||
|
||||
### Key Source Directories
|
||||
|
||||
- `packages/loot-core/src/client/` - Client-side core logic
|
||||
- `packages/loot-core/src/server/` - Server-side core logic
|
||||
- `packages/loot-core/src/shared/` - Shared utilities
|
||||
- `packages/loot-core/src/types/` - Type definitions
|
||||
- `packages/desktop-client/src/components/` - React components
|
||||
- `packages/desktop-client/src/hooks/` - Custom React hooks
|
||||
- `packages/desktop-client/e2e/` - End-to-end tests
|
||||
- `packages/component-library/src/` - Reusable components
|
||||
- `packages/component-library/src/icons/` - Icon components (auto-generated, don't edit)
|
||||
- `packages/docs/docs/` - Documentation source files (Markdown)
|
||||
- `packages/docs/docs/contributing/` - Developer documentation
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
### Running Specific Tests
|
||||
|
||||
```bash
|
||||
# Run all tests across all packages (recommended)
|
||||
yarn test
|
||||
|
||||
# E2E test for a specific file
|
||||
yarn workspace @actual-app/web run playwright test accounts.test.ts --browser=chromium
|
||||
```
|
||||
|
||||
### Building for Production
|
||||
|
||||
```bash
|
||||
# Browser build
|
||||
yarn build:browser
|
||||
|
||||
# Desktop build
|
||||
yarn build:desktop
|
||||
|
||||
# API build
|
||||
yarn build:api
|
||||
|
||||
# Sync server build
|
||||
yarn build:server
|
||||
```
|
||||
|
||||
### Type Checking Specific Packages
|
||||
|
||||
TypeScript uses project references. Run `yarn typecheck` from root to check all packages.
|
||||
|
||||
### Debugging Tests
|
||||
|
||||
```bash
|
||||
# Run tests in debug mode (without parallelization)
|
||||
yarn test:debug
|
||||
|
||||
# Run specific E2E test with headed browser
|
||||
yarn workspace @actual-app/web run playwright test --headed --debug accounts.test.ts
|
||||
```
|
||||
|
||||
### Working with Icons
|
||||
|
||||
Icons in `packages/component-library/src/icons/` are auto-generated. Don't manually edit them.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Type Errors
|
||||
|
||||
1. Run `yarn typecheck` to see all type errors
|
||||
2. Check if types are imported correctly
|
||||
3. Look for existing type definitions in `packages/loot-core/src/types/`
|
||||
4. Use `satisfies` instead of `as` for type narrowing
|
||||
|
||||
### Linter Errors
|
||||
|
||||
1. Run `yarn lint:fix` to auto-fix many issues
|
||||
2. Check ESLint output for specific rule violations
|
||||
3. Custom rules:
|
||||
- `actual/no-untranslated-strings` - Add i18n
|
||||
- `actual/prefer-trans-over-t` - Use Trans component
|
||||
- `actual/prefer-logger-over-console` - Use logger
|
||||
- Check `eslint.config.mjs` for complete rules
|
||||
|
||||
### Test Failures
|
||||
|
||||
1. Check if test is running in correct environment (node vs web)
|
||||
2. For Vitest: check `vitest.config.ts` or `vitest.web.config.ts`
|
||||
3. For Playwright: check `playwright.config.ts`
|
||||
4. Ensure mock minimization - prefer real implementations
|
||||
5. **Lage cache issues**: Clear cache with `rm -rf .lage` if tests behave unexpectedly
|
||||
6. **Tests continue on error**: With `--continue` flag, all packages run even if one fails
|
||||
|
||||
### Import Resolution Issues
|
||||
|
||||
1. Check `tsconfig.json` for path mappings
|
||||
2. Check package.json `exports` field (especially for loot-core)
|
||||
3. Verify platform-specific imports (`.web`, `.electron`, `.api`)
|
||||
4. Use absolute imports in `desktop-client` (enforced by ESLint)
|
||||
|
||||
### Build Failures
|
||||
|
||||
1. Clean build artifacts: `rm -rf packages/*/dist packages/*/lib-dist packages/*/build`
|
||||
2. Reinstall dependencies: `yarn install`
|
||||
3. Check Node.js version (requires >=20)
|
||||
4. Check Yarn version (requires ^4.9.1)
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- Located alongside source files or in `__tests__` directories
|
||||
- Use `.test.ts`, `.test.tsx`, `.spec.js` extensions
|
||||
- Vitest is the test runner
|
||||
- Minimize mocking - prefer real implementations
|
||||
|
||||
### E2E Tests
|
||||
|
||||
- Located in `packages/desktop-client/e2e/`
|
||||
- Use Playwright test runner
|
||||
- Visual regression snapshots in `*-snapshots/` directories
|
||||
- Page models in `e2e/page-models/` for reusable page interactions
|
||||
- Mobile tests have `.mobile.test.ts` suffix
|
||||
|
||||
### Visual Regression Tests (VRT)
|
||||
|
||||
- Snapshots stored per test file in `*-snapshots/` directories
|
||||
- Use Docker for consistent environment: `yarn vrt:docker`
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- **Community Documentation**: https://actualbudget.org/docs/contributing/
|
||||
- **Discord Community**: https://discord.gg/pRYNYr4W5A
|
||||
- **GitHub Issues**: https://github.com/actualbudget/actual/issues
|
||||
- **Feature Requests**: Label "needs votes" sorted by reactions
|
||||
|
||||
## Code Quality Checklist
|
||||
|
||||
Before committing changes, ensure:
|
||||
|
||||
- [ ] `yarn typecheck` passes
|
||||
- [ ] `yarn lint:fix` has been run
|
||||
- [ ] Relevant tests pass
|
||||
- [ ] User-facing strings are translated
|
||||
- [ ] Prefer `type` over `interface`
|
||||
- [ ] Named exports used (not default exports)
|
||||
- [ ] Imports are properly ordered
|
||||
- [ ] Platform-specific code uses proper exports
|
||||
- [ ] No unnecessary type assertions
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
When creating pull requests:
|
||||
|
||||
- **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.
|
||||
|
||||
## Code Review Guidelines
|
||||
|
||||
When performing code reviews (especially for LLM agents): **see [CODE_REVIEW_GUIDELINES.md](./CODE_REVIEW_GUIDELINES.md)** for specific guidelines.
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **Bundle Size**: Check with rollup-plugin-visualizer
|
||||
- **Type Checking**: Uses incremental compilation
|
||||
- **Testing**: Tests run in parallel by default
|
||||
- **Linting**: ESLint caches results for faster subsequent runs
|
||||
|
||||
## Workspace Commands Reference
|
||||
|
||||
```bash
|
||||
# List all workspaces
|
||||
yarn workspaces list
|
||||
|
||||
# Run command in specific workspace
|
||||
yarn workspace <workspace-name> run <command>
|
||||
|
||||
# Run command in all workspaces
|
||||
yarn workspaces foreach --all run <command>
|
||||
|
||||
# Install production dependencies only (for server deployment)
|
||||
yarn install:server
|
||||
```
|
||||
|
||||
## Environment Requirements
|
||||
|
||||
- **Node.js**: >=20
|
||||
- **Yarn**: ^4.9.1 (managed by packageManager field)
|
||||
- **Browser Targets**: Electron >= 35.0, modern browsers (see browserslist)
|
||||
|
||||
## Migration Notes
|
||||
|
||||
The codebase is actively being migrated:
|
||||
|
||||
- **JavaScript → TypeScript**: sync-server is in progress
|
||||
- **Classes → Functions**: Prefer functional patterns
|
||||
- **React.\* → Named Imports**: Legacy React.\* patterns being removed
|
||||
|
||||
When working with older code, follow the newer patterns described in this guide.
|
||||
@@ -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/)
|
||||
@@ -5,7 +5,7 @@
|
||||
# you are doing.
|
||||
###################################################
|
||||
|
||||
FROM node:22-bookworm as dev
|
||||
FROM node:20-bullseye as dev
|
||||
RUN apt-get update -y && apt-get upgrade -y && apt-get install -y openssl
|
||||
WORKDIR /app
|
||||
CMD ["sh", "./bin/docker-start"]
|
||||
|
||||
605
PLUGIN_ARCHITECTURE.md
Normal file
605
PLUGIN_ARCHITECTURE.md
Normal file
@@ -0,0 +1,605 @@
|
||||
# Actual Budget Plugin Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
Actual Budget's plugin system enables extending the sync-server with custom functionality through isolated, sandboxed processes. Plugins run as separate Node.js child processes that communicate with the sync-server via Inter-Process Communication (IPC).
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Plugin Structure
|
||||
|
||||
A plugin is a standalone Node.js application that:
|
||||
|
||||
- **Runs as a child process** forked from the sync-server
|
||||
- **Uses Express.js** to define HTTP-like routes
|
||||
- **Communicates via IPC** instead of network sockets
|
||||
- **Has isolated dependencies** and runtime environment
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **Plugin Manager** (`sync-server`) - Discovers, loads, and manages plugin lifecycle
|
||||
2. **Plugin Middleware** (`sync-server`) - Routes HTTP requests to appropriate plugins via IPC
|
||||
3. **Plugin Core Library** (`@actual-app/plugins-core-sync-server`) - Utilities for plugin authors
|
||||
4. **Plugin Process** - Your custom plugin code running as a child process
|
||||
|
||||
---
|
||||
|
||||
## Plugin Development
|
||||
|
||||
### 1. Project Setup
|
||||
|
||||
```bash
|
||||
# Create plugin directory
|
||||
mkdir my-plugin
|
||||
cd my-plugin
|
||||
|
||||
# Initialize npm project
|
||||
npm init -y
|
||||
|
||||
# Install dependencies
|
||||
npm install express @actual-app/plugins-core-sync-server
|
||||
npm install -D typescript @types/express @types/node
|
||||
```
|
||||
|
||||
### 2. Create Manifest
|
||||
|
||||
Every plugin needs a `manifest.ts` file that describes the plugin:
|
||||
|
||||
```typescript
|
||||
import { PluginManifest } from '@actual-app/plugins-core-sync-server';
|
||||
|
||||
export const manifest: PluginManifest = {
|
||||
name: 'my-plugin',
|
||||
version: '1.0.0',
|
||||
description: 'My awesome plugin',
|
||||
entry: 'dist/index.js',
|
||||
author: 'Your Name',
|
||||
license: 'MIT',
|
||||
routes: [
|
||||
{
|
||||
path: '/hello',
|
||||
methods: ['GET', 'POST'],
|
||||
auth: 'authenticated', // or 'anonymous'
|
||||
description: 'Hello endpoint',
|
||||
},
|
||||
],
|
||||
bankSync: {
|
||||
// Optional: for bank sync plugins
|
||||
enabled: true,
|
||||
displayName: 'My Bank Provider',
|
||||
description: 'Connect accounts via my provider',
|
||||
requiresAuth: true,
|
||||
endpoints: {
|
||||
status: '/status',
|
||||
accounts: '/accounts',
|
||||
transactions: '/transactions',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
```
|
||||
|
||||
### 3. Create Plugin Code
|
||||
|
||||
```typescript
|
||||
import express from 'express';
|
||||
import {
|
||||
attachPluginMiddleware,
|
||||
saveSecret,
|
||||
getSecret,
|
||||
} from '@actual-app/plugins-core-sync-server';
|
||||
|
||||
const app = express();
|
||||
|
||||
// Essential: Parse JSON request bodies
|
||||
app.use(express.json());
|
||||
|
||||
// Essential: Enable IPC communication with sync-server
|
||||
attachPluginMiddleware(app);
|
||||
|
||||
// Define your routes
|
||||
app.get('/hello', (req, res) => {
|
||||
res.json({ message: 'Hello from plugin!' });
|
||||
});
|
||||
|
||||
app.post('/save-config', async (req, res) => {
|
||||
const { apiKey } = req.body;
|
||||
|
||||
// Save secrets (encrypted & user-scoped)
|
||||
await saveSecret(req, 'apiKey', apiKey);
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.get('/config', async (req, res) => {
|
||||
// Retrieve secrets
|
||||
const result = await getSecret(req, 'apiKey');
|
||||
|
||||
res.json({ configured: !!result.value });
|
||||
});
|
||||
|
||||
// No need to call app.listen() - IPC handles communication
|
||||
console.log('My plugin loaded successfully');
|
||||
```
|
||||
|
||||
### 4. Build Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"build": "tsc && node build-manifest.js",
|
||||
"dev": "tsc --watch"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The build process should:
|
||||
|
||||
1. Compile TypeScript to JavaScript
|
||||
2. Convert `manifest.ts` to `manifest.json`
|
||||
|
||||
---
|
||||
|
||||
## Plugin Loading Process
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Sync-Server Starts] --> B[Initialize PluginManager]
|
||||
B --> C[Scan plugins-api Directory]
|
||||
C --> D{Find Plugins}
|
||||
D -->|For each plugin| E[Read manifest.json]
|
||||
E --> F{Valid Manifest?}
|
||||
F -->|No| G[Skip Plugin]
|
||||
F -->|Yes| H[Fork Child Process]
|
||||
H --> I[Pass Environment Variables]
|
||||
I --> J[Plugin Process Starts]
|
||||
J --> K[attachPluginMiddleware Called]
|
||||
K --> L[Plugin Sends 'ready' Message]
|
||||
L --> M{Ready within timeout?}
|
||||
M -->|No| N[Reject Plugin]
|
||||
M -->|Yes| O[Mark Plugin as Online]
|
||||
O --> P[Register Routes]
|
||||
P --> Q[Plugin Available]
|
||||
|
||||
style A fill:#e1f5ff
|
||||
style Q fill:#d4edda
|
||||
style G fill:#f8d7da
|
||||
style N fill:#f8d7da
|
||||
```
|
||||
|
||||
### Loading Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant SS as Sync-Server
|
||||
participant PM as PluginManager
|
||||
participant FS as File System
|
||||
participant PP as Plugin Process
|
||||
|
||||
SS->>PM: Initialize(pluginsDir)
|
||||
SS->>PM: loadPlugins()
|
||||
|
||||
PM->>FS: Read plugins-api directory
|
||||
FS-->>PM: List of plugin folders
|
||||
|
||||
loop For each plugin
|
||||
PM->>FS: Read manifest.json
|
||||
FS-->>PM: Manifest data
|
||||
|
||||
PM->>PM: Validate manifest
|
||||
|
||||
PM->>PP: fork(entryPoint)
|
||||
Note over PP: Plugin process starts
|
||||
|
||||
PP->>PP: Create Express app
|
||||
PP->>PP: Define routes
|
||||
PP->>PP: attachPluginMiddleware()
|
||||
|
||||
PP-->>PM: IPC: {type: 'ready'}
|
||||
|
||||
PM->>PM: Mark plugin as online
|
||||
PM->>PM: Register routes
|
||||
end
|
||||
|
||||
PM-->>SS: All plugins loaded
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Communication Architecture
|
||||
|
||||
### HTTP Request Flow
|
||||
|
||||
When a client makes a request to a plugin endpoint:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant SS as Sync-Server
|
||||
participant PM as PluginMiddleware
|
||||
participant MGR as PluginManager
|
||||
participant PP as Plugin Process
|
||||
|
||||
C->>SS: POST /plugins-api/my-plugin/hello
|
||||
SS->>PM: Route to plugin middleware
|
||||
|
||||
PM->>PM: Extract plugin slug & route
|
||||
PM->>PM: Check authentication
|
||||
PM->>PM: Verify route permissions
|
||||
|
||||
PM->>MGR: sendRequest(pluginSlug, requestData)
|
||||
|
||||
MGR->>PP: IPC: {type: 'request', method, path, body}
|
||||
|
||||
Note over PP: Plugin receives IPC message
|
||||
PP->>PP: Simulate HTTP request
|
||||
PP->>PP: Route to Express handler
|
||||
PP->>PP: Execute business logic
|
||||
|
||||
PP-->>MGR: IPC: {type: 'response', status, body}
|
||||
|
||||
MGR-->>PM: Response data
|
||||
PM-->>SS: Forward response
|
||||
SS-->>C: HTTP Response
|
||||
```
|
||||
|
||||
### IPC Message Types
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph "Sync-Server → Plugin"
|
||||
A[request<br/>HTTP request data]
|
||||
B[secret-response<br/>Secret value response]
|
||||
end
|
||||
|
||||
subgraph "Plugin → Sync-Server"
|
||||
C[ready<br/>Plugin initialized]
|
||||
D[response<br/>HTTP response data]
|
||||
E[secret-get<br/>Request secret]
|
||||
F[secret-set<br/>Save secret]
|
||||
G[error<br/>Error occurred]
|
||||
end
|
||||
|
||||
style A fill:#fff3cd
|
||||
style B fill:#fff3cd
|
||||
style C fill:#d4edda
|
||||
style D fill:#d4edda
|
||||
style E fill:#d1ecf1
|
||||
style F fill:#d1ecf1
|
||||
style G fill:#f8d7da
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Secrets Management
|
||||
|
||||
Plugins can store encrypted, user-scoped secrets (API keys, tokens, etc.):
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant PH as Plugin Handler
|
||||
participant PC as Plugin Core
|
||||
participant PP as Plugin Process (IPC)
|
||||
participant PM as PluginManager
|
||||
participant SS as Secrets Store
|
||||
|
||||
Note over PH: User saves API key
|
||||
|
||||
PH->>PC: saveSecret(req, 'apiKey', 'abc123')
|
||||
PC->>PC: Namespace: 'my-plugin_apiKey'
|
||||
PC->>PP: process.send({type: 'secret-set'})
|
||||
|
||||
PP-->>PM: IPC: secret-set message
|
||||
PM->>SS: Store secret (encrypted)
|
||||
SS-->>PM: Success
|
||||
|
||||
PM-->>PP: IPC: secret-response
|
||||
PP-->>PC: Promise resolves
|
||||
PC-->>PH: {success: true}
|
||||
|
||||
Note over PH: Later: retrieve secret
|
||||
|
||||
PH->>PC: getSecret(req, 'apiKey')
|
||||
PC->>PP: process.send({type: 'secret-get'})
|
||||
PP-->>PM: IPC: secret-get message
|
||||
PM->>SS: Retrieve secret
|
||||
SS-->>PM: Decrypted value
|
||||
PM-->>PP: IPC: secret-response
|
||||
PP-->>PC: Promise resolves
|
||||
PC-->>PH: {value: 'abc123'}
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- **User-scoped**: Each user has their own secrets
|
||||
- **Encrypted**: Stored securely in the database
|
||||
- **Namespaced**: Automatically prefixed with plugin slug
|
||||
- **Async**: Uses IPC promises for retrieval
|
||||
|
||||
---
|
||||
|
||||
## Plugin Architecture Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Client["Client (Browser/App)"]
|
||||
UI[User Interface]
|
||||
end
|
||||
|
||||
subgraph SyncServer["Sync-Server Process"]
|
||||
HTTP[HTTP Server]
|
||||
AUTH[Authentication]
|
||||
API[API Routes]
|
||||
PMW[Plugin Middleware]
|
||||
MGR[Plugin Manager]
|
||||
SEC[Secrets Store]
|
||||
end
|
||||
|
||||
subgraph Plugin1["Plugin Process 1"]
|
||||
P1APP[Express App]
|
||||
P1MW[Plugin Middleware]
|
||||
P1ROUTES[Route Handlers]
|
||||
P1LOGIC[Business Logic]
|
||||
end
|
||||
|
||||
subgraph Plugin2["Plugin Process 2"]
|
||||
P2APP[Express App]
|
||||
P2MW[Plugin Middleware]
|
||||
P2ROUTES[Route Handlers]
|
||||
P2LOGIC[Business Logic]
|
||||
end
|
||||
|
||||
UI -->|HTTP Request| HTTP
|
||||
HTTP --> AUTH
|
||||
AUTH --> API
|
||||
API --> PMW
|
||||
PMW -->|Route| MGR
|
||||
|
||||
MGR <-->|IPC<br/>Messages| P1MW
|
||||
MGR <-->|IPC<br/>Messages| P2MW
|
||||
|
||||
P1MW --> P1APP
|
||||
P1APP --> P1ROUTES
|
||||
P1ROUTES --> P1LOGIC
|
||||
|
||||
P2MW --> P2APP
|
||||
P2APP --> P2ROUTES
|
||||
P2ROUTES --> P2LOGIC
|
||||
|
||||
P1LOGIC <-.->|Secret<br/>Requests| MGR
|
||||
P2LOGIC <-.->|Secret<br/>Requests| MGR
|
||||
MGR <-.-> SEC
|
||||
|
||||
style Client fill:#e1f5ff
|
||||
style SyncServer fill:#fff3cd
|
||||
style Plugin1 fill:#d4edda
|
||||
style Plugin2 fill:#d4edda
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bank Sync Plugins
|
||||
|
||||
Bank sync plugins follow a specific contract to integrate with Actual's account linking:
|
||||
|
||||
### Required Endpoints
|
||||
|
||||
1. **`/status`** - Check if plugin is configured
|
||||
|
||||
```json
|
||||
Response: {
|
||||
"status": "ok",
|
||||
"data": { "configured": true }
|
||||
}
|
||||
```
|
||||
|
||||
2. **`/accounts`** - Fetch available accounts
|
||||
|
||||
```json
|
||||
Response: {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"accounts": [
|
||||
{
|
||||
"account_id": "ext-123",
|
||||
"name": "Checking",
|
||||
"institution": "My Bank",
|
||||
"balance": 1000,
|
||||
"mask": "1234",
|
||||
"official_name": "Primary Checking",
|
||||
"orgDomain": "mybank.com",
|
||||
"orgId": "bank-001"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **`/transactions`** - Fetch transactions
|
||||
|
||||
```json
|
||||
Request: {
|
||||
"accountId": "ext-123",
|
||||
"startDate": "2024-01-01"
|
||||
}
|
||||
|
||||
Response: {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"transactions": {
|
||||
"booked": [...],
|
||||
"pending": [...]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Error Handling
|
||||
|
||||
```typescript
|
||||
app.post('/endpoint', async (req, res) => {
|
||||
try {
|
||||
const result = await doSomething();
|
||||
res.json({ status: 'ok', data: result });
|
||||
} catch (error) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Input Validation
|
||||
|
||||
```typescript
|
||||
app.post('/config', async (req, res) => {
|
||||
const { apiKey } = req.body;
|
||||
|
||||
if (!apiKey || typeof apiKey !== 'string') {
|
||||
return res.json({
|
||||
status: 'error',
|
||||
error: 'apiKey is required',
|
||||
});
|
||||
}
|
||||
|
||||
// Process...
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Logging
|
||||
|
||||
```typescript
|
||||
// Plugin stdout/stderr is visible in sync-server logs
|
||||
console.log('[MY-PLUGIN] Processing request...');
|
||||
console.error('[MY-PLUGIN] Error occurred:', error);
|
||||
```
|
||||
|
||||
### 4. Graceful Shutdown
|
||||
|
||||
```typescript
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('[MY-PLUGIN] Shutting down...');
|
||||
// Cleanup resources
|
||||
process.exit(0);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
sync-server/
|
||||
└── user-files/
|
||||
└── plugins-api/
|
||||
└── my-plugin/
|
||||
├── manifest.json
|
||||
├── package.json
|
||||
├── node_modules/
|
||||
└── dist/
|
||||
└── index.js
|
||||
```
|
||||
|
||||
### Installation Steps
|
||||
|
||||
1. **Build the plugin** (as ZIP or folder)
|
||||
2. **Place in plugins-api directory**
|
||||
3. **Restart sync-server** (auto-loads on startup)
|
||||
|
||||
### ZIP Format (Recommended)
|
||||
|
||||
```
|
||||
my-plugin.zip
|
||||
├── manifest.json
|
||||
├── package.json
|
||||
├── node_modules/
|
||||
└── dist/
|
||||
└── index.js
|
||||
```
|
||||
|
||||
The plugin manager automatically extracts ZIPs to a temporary directory.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Plugin Not Loading
|
||||
|
||||
- Check `manifest.json` exists and is valid JSON
|
||||
- Verify `entry` field points to correct file
|
||||
- Check sync-server logs for error messages
|
||||
|
||||
### IPC Communication Failures
|
||||
|
||||
- Ensure `attachPluginMiddleware(app)` is called
|
||||
- Verify plugin sends `ready` message within 10s timeout
|
||||
- Check that `process.send` is available (forked process)
|
||||
|
||||
### Route Not Found
|
||||
|
||||
- Verify route is defined in `manifest.json`
|
||||
- Check authentication requirements match
|
||||
- Ensure route path matches exactly (case-sensitive)
|
||||
|
||||
### Secrets Not Persisting
|
||||
|
||||
- Confirm user is authenticated
|
||||
- Check `pluginSlug` is passed in request context
|
||||
- Verify secrets store is properly initialized
|
||||
|
||||
---
|
||||
|
||||
## Example: Complete Bank Sync Plugin
|
||||
|
||||
See the [Pluggy.ai plugin](packages/bank-sync-plugin-pluggy.ai/) for a full working example that demonstrates:
|
||||
|
||||
- Authentication and configuration
|
||||
- Account fetching with proper typing
|
||||
- Transaction synchronization
|
||||
- Secret management
|
||||
- Error handling
|
||||
- TypeScript usage
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
### `attachPluginMiddleware(app: Express)`
|
||||
|
||||
Enables IPC communication for the plugin. Must be called before defining routes.
|
||||
|
||||
### `saveSecret(req: Request, key: string, value: string)`
|
||||
|
||||
Saves an encrypted, user-scoped secret.
|
||||
|
||||
### `getSecret(req: Request, key: string)`
|
||||
|
||||
Retrieves a secret by key.
|
||||
|
||||
### `saveSecrets(req: Request, secrets: Record<string, string>)`
|
||||
|
||||
Saves multiple secrets at once.
|
||||
|
||||
### `getSecrets(req: Request, keys: string[])`
|
||||
|
||||
Retrieves multiple secrets at once.
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Process Isolation** - Each plugin runs in its own process
|
||||
2. **Route Authentication** - Manifest declares auth requirements
|
||||
3. **Secret Encryption** - All secrets encrypted at rest
|
||||
4. **User Scoping** - Secrets isolated per user
|
||||
5. **Namespace Isolation** - Secrets auto-prefixed with plugin slug
|
||||
6. **No Direct DB Access** - Plugins can't access database directly
|
||||
7. **Controlled IPC** - Only specific message types allowed
|
||||
@@ -8,7 +8,6 @@ CI=${CI:-false}
|
||||
cd "$ROOT/.."
|
||||
POSITIONAL=()
|
||||
SKIP_EXE_BUILD=false
|
||||
SKIP_TRANSLATIONS=false
|
||||
while [[ $# -gt 0 ]]; do
|
||||
key="$1"
|
||||
|
||||
@@ -21,10 +20,6 @@ while [[ $# -gt 0 ]]; do
|
||||
SKIP_EXE_BUILD=true
|
||||
shift
|
||||
;;
|
||||
--skip-translations)
|
||||
SKIP_TRANSLATIONS=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
POSITIONAL+=("$1")
|
||||
shift
|
||||
@@ -34,19 +29,15 @@ done
|
||||
|
||||
set -- "${POSITIONAL[@]}"
|
||||
|
||||
if [ $SKIP_TRANSLATIONS == false ]; then
|
||||
# Get translations
|
||||
echo "Updating translations..."
|
||||
if ! [ -d packages/desktop-client/locale ]; then
|
||||
git clone https://github.com/actualbudget/translations packages/desktop-client/locale
|
||||
fi
|
||||
|
||||
pushd packages/desktop-client/locale > /dev/null
|
||||
|
||||
git pull
|
||||
popd > /dev/null
|
||||
packages/desktop-client/bin/remove-untranslated-languages
|
||||
# Get translations
|
||||
echo "Updating translations..."
|
||||
if ! [ -d packages/desktop-client/locale ]; then
|
||||
git clone https://github.com/actualbudget/translations packages/desktop-client/locale
|
||||
fi
|
||||
pushd packages/desktop-client/locale > /dev/null
|
||||
git pull
|
||||
popd > /dev/null
|
||||
packages/desktop-client/bin/remove-untranslated-languages
|
||||
|
||||
export NODE_OPTIONS="--max-old-space-size=4096"
|
||||
|
||||
@@ -71,11 +62,14 @@ yarn workspace desktop-electron update-client
|
||||
echo "Skipping exe build"
|
||||
else
|
||||
if [ "$RELEASE" == "production" ]; then
|
||||
if [ -f ../../.secret-tokens ]; then
|
||||
source ../../.secret-tokens
|
||||
fi
|
||||
yarn build
|
||||
|
||||
echo "Created release"
|
||||
else
|
||||
yarn build
|
||||
SKIP_NOTARIZATION=true yarn build
|
||||
fi
|
||||
fi
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import prompts from 'prompts';
|
||||
|
||||
async function run() {
|
||||
const username = await execAsync(
|
||||
// eslint-disable-next-line actual/typography
|
||||
"gh api user --jq '.login'",
|
||||
'To avoid having to enter your username, consider installing the official GitHub CLI (https://github.com/cli/cli) and logging in with `gh auth login`.',
|
||||
);
|
||||
@@ -37,7 +38,7 @@ async function run() {
|
||||
choices: [
|
||||
{ title: '✨ Features', value: 'Features' },
|
||||
{ title: '👍 Enhancements', value: 'Enhancements' },
|
||||
{ title: '🐛 Bugfixes', value: 'Bugfixes' },
|
||||
{ title: '🐛 Bugfix', value: 'Bugfix' },
|
||||
{ title: '⚙️ Maintenance', value: 'Maintenance' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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.57.0-jammy /bin/bash \
|
||||
MSYS_NO_PATHCONV=1 docker run --rm --network host -v "$(pwd)":/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.55.1-jammy /bin/bash \
|
||||
-c "E2E_START_URL=$E2E_START_URL yarn vrt $VRT_ARGS"
|
||||
|
||||
820
eslint.config.mjs
Normal file
820
eslint.config.mjs
Normal file
@@ -0,0 +1,820 @@
|
||||
import globals from 'globals';
|
||||
|
||||
import pluginImport from 'eslint-plugin-import';
|
||||
import pluginJSXA11y from 'eslint-plugin-jsx-a11y';
|
||||
import pluginReact from 'eslint-plugin-react';
|
||||
import pluginReactHooks from 'eslint-plugin-react-hooks';
|
||||
import pluginTypescript from 'typescript-eslint';
|
||||
import pluginTypescriptPaths from 'eslint-plugin-typescript-paths';
|
||||
import pluginActual from './packages/eslint-plugin-actual/lib/index.js';
|
||||
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
|
||||
const confusingBrowserGlobals = [
|
||||
// https://github.com/facebook/create-react-app/tree/main/packages/confusing-browser-globals
|
||||
'addEventListener',
|
||||
'blur',
|
||||
'close',
|
||||
'closed',
|
||||
'confirm',
|
||||
'defaultStatus',
|
||||
'defaultstatus',
|
||||
'event',
|
||||
'external',
|
||||
'find',
|
||||
'focus',
|
||||
'frameElement',
|
||||
'frames',
|
||||
'history',
|
||||
'innerHeight',
|
||||
'innerWidth',
|
||||
'length',
|
||||
'location',
|
||||
'locationbar',
|
||||
'menubar',
|
||||
'moveBy',
|
||||
'moveTo',
|
||||
'name',
|
||||
'onblur',
|
||||
'onerror',
|
||||
'onfocus',
|
||||
'onload',
|
||||
'onresize',
|
||||
'onunload',
|
||||
'open',
|
||||
'opener',
|
||||
'opera',
|
||||
'outerHeight',
|
||||
'outerWidth',
|
||||
'pageXOffset',
|
||||
'pageYOffset',
|
||||
'parent',
|
||||
'print',
|
||||
'removeEventListener',
|
||||
'resizeBy',
|
||||
'resizeTo',
|
||||
'screen',
|
||||
'screenLeft',
|
||||
'screenTop',
|
||||
'screenX',
|
||||
'screenY',
|
||||
'scroll',
|
||||
'scrollbars',
|
||||
'scrollBy',
|
||||
'scrollTo',
|
||||
'scrollX',
|
||||
'scrollY',
|
||||
'status',
|
||||
'statusbar',
|
||||
'stop',
|
||||
'toolbar',
|
||||
'top',
|
||||
];
|
||||
|
||||
export default pluginTypescript.config(
|
||||
{
|
||||
ignores: [
|
||||
// Global ignore patterns
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/*.zip',
|
||||
// Specific ignore patterns
|
||||
'packages/api/app/bundle.api.js',
|
||||
'packages/api/app/stats.json',
|
||||
'packages/api/@types',
|
||||
'packages/api/migrations',
|
||||
'packages/component-library/src/icons/**/*',
|
||||
'packages/desktop-client/bundle.browser.js',
|
||||
'packages/desktop-client/build/',
|
||||
'packages/desktop-client/service-worker/*',
|
||||
'packages/desktop-client/build-electron/',
|
||||
'packages/desktop-client/build-stats/',
|
||||
'packages/desktop-client/public/kcab/',
|
||||
'packages/desktop-client/public/data/',
|
||||
'packages/desktop-client/test-results/',
|
||||
'packages/desktop-client/playwright-report/',
|
||||
'packages/desktop-electron/client-build/',
|
||||
'packages/desktop-electron/build/',
|
||||
'packages/loot-core/**/lib-dist/*',
|
||||
'packages/loot-core/**/proto/*',
|
||||
'packages/sync-server/build/',
|
||||
'.yarn/*',
|
||||
'.github/*',
|
||||
],
|
||||
},
|
||||
{
|
||||
// Temporary until the sync-server is migrated to TypeScript
|
||||
files: [
|
||||
'packages/sync-server/**/*.spec.{js,jsx}',
|
||||
'packages/sync-server/**/*.test.{js,jsx}',
|
||||
],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
vi: true,
|
||||
describe: true,
|
||||
expect: true,
|
||||
it: true,
|
||||
beforeAll: true,
|
||||
beforeEach: true,
|
||||
afterAll: true,
|
||||
afterEach: true,
|
||||
test: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: true,
|
||||
},
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.commonjs,
|
||||
...globals.node,
|
||||
globalThis: false,
|
||||
vi: true,
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
alwaysTryTypes: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginReact.configs.flat.recommended,
|
||||
pluginReact.configs.flat['jsx-runtime'],
|
||||
pluginTypescript.configs.recommended,
|
||||
pluginImport.flatConfigs.recommended,
|
||||
{
|
||||
plugins: {
|
||||
actual: pluginActual,
|
||||
},
|
||||
rules: {
|
||||
'actual/no-untranslated-strings': 'error',
|
||||
'actual/prefer-trans-over-t': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.{js,ts,jsx,tsx}'],
|
||||
plugins: {
|
||||
'jsx-a11y': pluginJSXA11y,
|
||||
'react-hooks': pluginReactHooks,
|
||||
},
|
||||
rules: {
|
||||
// http://eslint.org/docs/rules/
|
||||
'array-callback-return': 'warn',
|
||||
|
||||
'default-case': [
|
||||
'warn',
|
||||
{
|
||||
commentPattern: '^no default$',
|
||||
},
|
||||
],
|
||||
|
||||
curly: ['warn', 'multi-line', 'consistent'],
|
||||
'dot-location': ['warn', 'property'],
|
||||
eqeqeq: ['warn', 'smart'],
|
||||
'new-parens': 'warn',
|
||||
'no-array-constructor': 'warn',
|
||||
'no-caller': 'warn',
|
||||
'no-cond-assign': ['warn', 'except-parens'],
|
||||
'no-const-assign': 'warn',
|
||||
'no-control-regex': 'warn',
|
||||
'no-delete-var': 'warn',
|
||||
'no-dupe-args': 'warn',
|
||||
'no-dupe-class-members': 'warn',
|
||||
'no-dupe-keys': 'warn',
|
||||
'no-duplicate-case': 'warn',
|
||||
'no-empty-character-class': 'warn',
|
||||
'no-empty-pattern': 'warn',
|
||||
'no-eval': 'warn',
|
||||
'no-ex-assign': 'warn',
|
||||
'no-extend-native': 'warn',
|
||||
'no-extra-bind': 'warn',
|
||||
'no-extra-label': 'warn',
|
||||
'no-fallthrough': 'warn',
|
||||
'no-func-assign': 'warn',
|
||||
'no-implied-eval': 'warn',
|
||||
'no-invalid-regexp': 'warn',
|
||||
'no-iterator': 'warn',
|
||||
'no-label-var': 'warn',
|
||||
|
||||
'no-labels': [
|
||||
'warn',
|
||||
{
|
||||
allowLoop: true,
|
||||
allowSwitch: false,
|
||||
},
|
||||
],
|
||||
|
||||
'no-lone-blocks': 'warn',
|
||||
|
||||
'no-mixed-operators': [
|
||||
'warn',
|
||||
{
|
||||
groups: [
|
||||
['&', '|', '^', '~', '<<', '>>', '>>>'],
|
||||
['==', '!=', '===', '!==', '>', '>=', '<', '<='],
|
||||
['&&', '||'],
|
||||
['in', 'instanceof'],
|
||||
],
|
||||
|
||||
allowSamePrecedence: false,
|
||||
},
|
||||
],
|
||||
|
||||
'no-multi-str': 'warn',
|
||||
'no-global-assign': 'warn',
|
||||
'no-unsafe-negation': 'warn',
|
||||
'no-new-func': 'warn',
|
||||
'no-new-object': 'warn',
|
||||
'no-new-symbol': 'warn',
|
||||
'no-new-wrappers': 'warn',
|
||||
'no-obj-calls': 'warn',
|
||||
'no-octal': 'warn',
|
||||
'no-octal-escape': 'warn',
|
||||
'no-redeclare': 'warn',
|
||||
'no-regex-spaces': 'warn',
|
||||
'no-script-url': 'warn',
|
||||
'no-self-assign': 'warn',
|
||||
'no-self-compare': 'warn',
|
||||
'no-sequences': 'warn',
|
||||
'no-shadow-restricted-names': 'warn',
|
||||
'no-sparse-arrays': 'warn',
|
||||
'no-template-curly-in-string': 'warn',
|
||||
'no-this-before-super': 'warn',
|
||||
'no-throw-literal': 'warn',
|
||||
'no-undef': 'error',
|
||||
'no-unreachable': 'warn',
|
||||
|
||||
'no-unused-expressions': [
|
||||
'error',
|
||||
{
|
||||
allowShortCircuit: true,
|
||||
allowTernary: true,
|
||||
allowTaggedTemplates: true,
|
||||
},
|
||||
],
|
||||
|
||||
'no-unused-labels': 'warn',
|
||||
|
||||
'no-use-before-define': [
|
||||
'warn',
|
||||
{
|
||||
functions: false,
|
||||
classes: false,
|
||||
variables: false,
|
||||
},
|
||||
],
|
||||
|
||||
'no-useless-computed-key': 'warn',
|
||||
'no-useless-concat': 'warn',
|
||||
'no-useless-constructor': 'warn',
|
||||
'no-useless-escape': 'warn',
|
||||
|
||||
'no-useless-rename': [
|
||||
'warn',
|
||||
{
|
||||
ignoreDestructuring: false,
|
||||
ignoreImport: false,
|
||||
ignoreExport: false,
|
||||
},
|
||||
],
|
||||
|
||||
'no-with': 'warn',
|
||||
'no-whitespace-before-property': 'warn',
|
||||
|
||||
'require-yield': 'warn',
|
||||
'rest-spread-spacing': ['warn', 'never'],
|
||||
strict: ['warn', 'never'],
|
||||
'unicode-bom': ['warn', 'never'],
|
||||
'use-isnan': 'warn',
|
||||
'valid-typeof': 'warn',
|
||||
|
||||
'no-restricted-properties': [
|
||||
'error',
|
||||
{
|
||||
object: 'require',
|
||||
property: 'ensure',
|
||||
message:
|
||||
'Please use import() instead. More info: https://facebook.github.io/create-react-app/docs/code-splitting',
|
||||
},
|
||||
{
|
||||
object: 'System',
|
||||
property: 'import',
|
||||
message:
|
||||
'Please use import() instead. More info: https://facebook.github.io/create-react-app/docs/code-splitting',
|
||||
},
|
||||
],
|
||||
|
||||
'getter-return': 'warn',
|
||||
|
||||
// https://github.com/benmosher/eslint-plugin-import/tree/master/docs/rules
|
||||
'import/first': 'error',
|
||||
'import/no-amd': 'error',
|
||||
'import/no-anonymous-default-export': 'warn',
|
||||
'import/no-webpack-loader-syntax': 'error',
|
||||
'import/extensions': [
|
||||
'warn',
|
||||
'never',
|
||||
{
|
||||
json: 'always',
|
||||
},
|
||||
],
|
||||
'import/no-useless-path-segments': 'warn',
|
||||
'import/no-duplicates': [
|
||||
'warn',
|
||||
{
|
||||
'prefer-inline': true,
|
||||
},
|
||||
],
|
||||
'import/order': [
|
||||
'warn',
|
||||
{
|
||||
alphabetize: {
|
||||
caseInsensitive: true,
|
||||
order: 'asc',
|
||||
},
|
||||
|
||||
groups: ['builtin', 'external', 'parent', 'sibling', 'index'],
|
||||
'newlines-between': 'always',
|
||||
|
||||
pathGroups: [
|
||||
{
|
||||
// Enforce that React (and react-related packages) is the first import
|
||||
group: 'builtin',
|
||||
pattern: 'react?(-*)',
|
||||
position: 'before',
|
||||
},
|
||||
{
|
||||
// Separate imports from Actual from "real" external imports
|
||||
group: 'external',
|
||||
pattern: 'loot-{core,design}/**/*',
|
||||
position: 'after',
|
||||
},
|
||||
],
|
||||
|
||||
pathGroupsExcludedImportTypes: ['react'],
|
||||
},
|
||||
],
|
||||
|
||||
// https://github.com/yannickcr/eslint-plugin-react/tree/master/docs/rules
|
||||
'react/forbid-foreign-prop-types': [
|
||||
'warn',
|
||||
{
|
||||
allowInPropTypes: true,
|
||||
},
|
||||
],
|
||||
'react/jsx-no-comment-textnodes': 'warn',
|
||||
'react/jsx-no-duplicate-props': 'warn',
|
||||
'react/jsx-no-target-blank': 'warn',
|
||||
'react/jsx-no-undef': 'error',
|
||||
'react/jsx-pascal-case': [
|
||||
'warn',
|
||||
{
|
||||
allowAllCaps: true,
|
||||
ignore: [],
|
||||
},
|
||||
],
|
||||
'react/no-danger-with-children': 'warn',
|
||||
// Disabled because of undesirable warnings
|
||||
// See https://github.com/facebook/create-react-app/issues/5204 for
|
||||
// blockers until its re-enabled
|
||||
// 'react/no-deprecated': 'warn',
|
||||
'react/no-direct-mutation-state': 'warn',
|
||||
'react/no-is-mounted': 'warn',
|
||||
'react/no-typos': 'error',
|
||||
'react/require-render-return': 'error',
|
||||
'react/style-prop-object': 'warn',
|
||||
'react/jsx-no-useless-fragment': 'warn',
|
||||
'react/self-closing-comp': 'warn',
|
||||
'react/jsx-filename-extension': [
|
||||
'warn',
|
||||
{
|
||||
extensions: ['.jsx', '.tsx'],
|
||||
allow: 'as-needed',
|
||||
},
|
||||
],
|
||||
'react/no-unstable-nested-components': [
|
||||
'warn',
|
||||
{
|
||||
allowAsProps: true,
|
||||
customValidators: ['formatter'],
|
||||
},
|
||||
],
|
||||
// Don't need this as we're using TypeScript
|
||||
'react/prop-types': 'off',
|
||||
|
||||
// https://github.com/evcohen/eslint-plugin-jsx-a11y/tree/master/docs/rules
|
||||
'jsx-a11y/alt-text': 'warn',
|
||||
'jsx-a11y/anchor-has-content': 'warn',
|
||||
'jsx-a11y/anchor-is-valid': [
|
||||
'warn',
|
||||
{
|
||||
aspects: ['noHref', 'invalidHref'],
|
||||
},
|
||||
],
|
||||
'jsx-a11y/aria-activedescendant-has-tabindex': 'warn',
|
||||
'jsx-a11y/aria-props': 'warn',
|
||||
'jsx-a11y/aria-proptypes': 'warn',
|
||||
'jsx-a11y/aria-role': [
|
||||
'warn',
|
||||
{
|
||||
ignoreNonDOM: true,
|
||||
},
|
||||
],
|
||||
'jsx-a11y/aria-unsupported-elements': 'warn',
|
||||
'jsx-a11y/heading-has-content': 'warn',
|
||||
'jsx-a11y/iframe-has-title': 'warn',
|
||||
'jsx-a11y/img-redundant-alt': 'warn',
|
||||
'jsx-a11y/no-access-key': 'warn',
|
||||
'jsx-a11y/no-distracting-elements': 'warn',
|
||||
'jsx-a11y/no-redundant-roles': 'warn',
|
||||
'jsx-a11y/role-has-required-aria-props': 'warn',
|
||||
'jsx-a11y/role-supports-aria-props': 'warn',
|
||||
'jsx-a11y/scope': 'warn',
|
||||
|
||||
// https://github.com/facebook/react/tree/main/packages/eslint-plugin-react-hooks
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': [
|
||||
'warn',
|
||||
{
|
||||
additionalHooks: '(useQuery)',
|
||||
},
|
||||
],
|
||||
|
||||
'actual/typography': 'warn',
|
||||
'actual/prefer-if-statement': 'warn',
|
||||
'actual/prefer-logger-over-console': 'error',
|
||||
|
||||
// Note: base rule explicitly disabled in favor of the TS one
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
varsIgnorePattern: '^(_|React)',
|
||||
argsIgnorePattern: '^(_|React)',
|
||||
ignoreRestSiblings: true,
|
||||
caughtErrors: 'none',
|
||||
},
|
||||
],
|
||||
|
||||
'no-restricted-globals': ['warn', ...confusingBrowserGlobals],
|
||||
|
||||
// https://github.com/eslint/eslint/issues/16954
|
||||
// https://github.com/eslint/eslint/issues/16953
|
||||
'no-loop-func': 'off',
|
||||
|
||||
// TODO: re-enable these rules
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'no-var': 'warn',
|
||||
'react/jsx-curly-brace-presence': 'warn',
|
||||
'object-shorthand': ['warn', 'properties'],
|
||||
|
||||
'no-restricted-syntax': [
|
||||
'warn',
|
||||
{
|
||||
// forbid React.* as they are legacy https://twitter.com/dan_abramov/status/1308739731551858689
|
||||
selector:
|
||||
":matches(MemberExpression[object.name='React'], TSQualifiedName[left.name='React'])",
|
||||
message:
|
||||
'Using default React import is discouraged, please use named exports directly instead.',
|
||||
},
|
||||
{
|
||||
// forbid <a> in favor of <Link>
|
||||
selector: 'JSXOpeningElement[name.name="a"]',
|
||||
message: 'Using <a> is discouraged, please use <Link> instead.',
|
||||
},
|
||||
],
|
||||
|
||||
'no-restricted-imports': [
|
||||
'warn',
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: 'react-router',
|
||||
importNames: ['useNavigate'],
|
||||
message:
|
||||
"Please import Actual's useNavigate() hook from `src/hooks` instead.",
|
||||
},
|
||||
{
|
||||
name: 'react-redux',
|
||||
importNames: ['useDispatch'],
|
||||
message:
|
||||
"Please import Actual's useDispatch() hook from `src/redux` instead.",
|
||||
},
|
||||
{
|
||||
name: 'react-redux',
|
||||
importNames: ['useSelector'],
|
||||
message:
|
||||
"Please import Actual's useSelector() hook from `src/redux` instead.",
|
||||
},
|
||||
{
|
||||
name: 'react-redux',
|
||||
importNames: ['useStore'],
|
||||
message:
|
||||
"Please import Actual's useStore() hook from `src/redux` instead.",
|
||||
},
|
||||
],
|
||||
patterns: [
|
||||
{
|
||||
group: ['*.api', '*.web', '*.electron'],
|
||||
message: "Don't directly reference imports from other platforms",
|
||||
},
|
||||
{
|
||||
group: ['uuid'],
|
||||
importNames: ['*'],
|
||||
message: "Use `import { v4 as uuidv4 } from 'uuid'` instead",
|
||||
},
|
||||
{
|
||||
group: ['**/style', '**/colors'],
|
||||
importNames: ['colors'],
|
||||
message: 'Please use themes instead of colors',
|
||||
},
|
||||
{
|
||||
group: ['@actual-app/web/*'],
|
||||
message: 'Please do not import `@actual-app/web` in `loot-core`',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
'@typescript-eslint/ban-ts-comment': [
|
||||
'error',
|
||||
{
|
||||
'ts-ignore': 'allow-with-description',
|
||||
},
|
||||
],
|
||||
|
||||
// Rules disabled during TS migration
|
||||
'@typescript-eslint/no-var-requires': 'off',
|
||||
'prefer-const': 'warn',
|
||||
'prefer-spread': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'import/no-default-export': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
ecmaVersion: 2018,
|
||||
sourceType: 'module',
|
||||
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
|
||||
// typescript-eslint specific options
|
||||
warnOnUnsupportedTypeScriptVersion: true,
|
||||
},
|
||||
},
|
||||
|
||||
// If adding a typescript-eslint version of an existing ESLint rule,
|
||||
// make sure to disable the ESLint rule here.
|
||||
rules: {
|
||||
// TypeScript's `noFallthroughCasesInSwitch` option is more robust (#6906)
|
||||
'default-case': 'off',
|
||||
// 'tsc' already handles this (https://github.com/typescript-eslint/typescript-eslint/issues/291)
|
||||
'no-dupe-class-members': 'off',
|
||||
// 'tsc' already handles this (https://github.com/typescript-eslint/typescript-eslint/issues/477)
|
||||
'no-undef': 'off',
|
||||
|
||||
// TypeScript already handles these (https://typescript-eslint.io/troubleshooting/typed-linting/performance/#eslint-plugin-import)
|
||||
'import/named': 'off',
|
||||
'import/namespace': 'off',
|
||||
'import/default': 'off',
|
||||
'import/no-named-as-default-member': 'off',
|
||||
'import/no-unresolved': 'off',
|
||||
|
||||
// Add TypeScript specific rules (and turn off ESLint equivalents)
|
||||
'@typescript-eslint/consistent-type-assertions': 'warn',
|
||||
'no-array-constructor': 'off',
|
||||
'@typescript-eslint/no-array-constructor': 'warn',
|
||||
'no-redeclare': 'off',
|
||||
'@typescript-eslint/no-redeclare': 'warn',
|
||||
'no-use-before-define': 'off',
|
||||
|
||||
'@typescript-eslint/no-use-before-define': [
|
||||
'warn',
|
||||
{
|
||||
functions: false,
|
||||
classes: false,
|
||||
variables: false,
|
||||
typedefs: false,
|
||||
},
|
||||
],
|
||||
|
||||
'no-unused-expressions': 'off',
|
||||
|
||||
'@typescript-eslint/no-unused-expressions': [
|
||||
'error',
|
||||
{
|
||||
allowShortCircuit: true,
|
||||
allowTernary: true,
|
||||
allowTaggedTemplates: true,
|
||||
},
|
||||
],
|
||||
|
||||
'no-useless-constructor': 'off',
|
||||
'@typescript-eslint/no-useless-constructor': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['packages/desktop-client/**/*.{js,ts,jsx,tsx}'],
|
||||
plugins: {
|
||||
'typescript-paths': pluginTypescriptPaths,
|
||||
},
|
||||
rules: {
|
||||
'typescript-paths/absolute-parent-import': [
|
||||
'error',
|
||||
{ preferPathOverBaseUrl: true },
|
||||
],
|
||||
'typescript-paths/absolute-import': ['error', { enableAlias: false }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'packages/desktop-client/**/*.{ts,tsx}',
|
||||
'packages/loot-core/src/client/**/*.{ts,tsx}',
|
||||
],
|
||||
rules: {
|
||||
// enforce import type
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'warn',
|
||||
{
|
||||
prefer: 'type-imports',
|
||||
fixStyle: 'inline-type-imports',
|
||||
},
|
||||
],
|
||||
|
||||
'@typescript-eslint/no-restricted-types': [
|
||||
'warn',
|
||||
{
|
||||
types: {
|
||||
// forbid FC as superflous
|
||||
FunctionComponent: {
|
||||
message:
|
||||
'Type the props argument and let TS infer or use ComponentType for a component prop',
|
||||
},
|
||||
|
||||
FC: {
|
||||
message:
|
||||
'Type the props argument and let TS infer or use ComponentType for a component prop',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'packages/loot-core/src/types/**/*',
|
||||
'packages/loot-core/src/client/state-types/**/*',
|
||||
'**/icons/**/*',
|
||||
'**/{mocks,__mocks__}/**/*',
|
||||
// can't correctly resolve usages
|
||||
'**/*.{testing,electron,browser,web,api}.ts',
|
||||
],
|
||||
|
||||
rules: {
|
||||
'import/no-unused-modules': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['packages/api/migrations/*', 'packages/loot-core/migrations/*'],
|
||||
|
||||
rules: {
|
||||
'import/no-default-export': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['packages/api/index.ts'],
|
||||
rules: {
|
||||
'import/no-unresolved': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Allow configuring vitest with default exports (recommended as per vitest docs)
|
||||
{
|
||||
files: ['**/vitest.config.ts', '**/vitest.web.config.ts'],
|
||||
rules: {
|
||||
'import/no-anonymous-default-export': 'off',
|
||||
'import/no-default-export': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
{},
|
||||
{
|
||||
// TODO: fix the issues in these files
|
||||
files: [
|
||||
'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/index.tsx',
|
||||
'packages/desktop-client/src/components/budget/MobileBudget.tsx',
|
||||
'packages/desktop-client/src/components/budget/envelope/HoldMenu.tsx',
|
||||
'packages/desktop-client/src/components/budget/envelope/TransferMenu.tsx',
|
||||
'packages/component-library/src/Menu.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/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/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/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',
|
||||
],
|
||||
|
||||
rules: {
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.cjs'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/manifest.ts'],
|
||||
rules: {
|
||||
'import/no-default-export': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'eslint.config.mjs',
|
||||
'**/*.test.js',
|
||||
'**/*.test.ts',
|
||||
'**/*.test.jsx',
|
||||
'**/*.test.tsx',
|
||||
'**/*.spec.js',
|
||||
],
|
||||
|
||||
rules: {
|
||||
'actual/typography': 'off',
|
||||
'actual/no-untranslated-strings': 'off',
|
||||
'actual/prefer-logger-over-console': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'packages/desktop-client/**/*.{ts,tsx}',
|
||||
'packages/loot-core/src/client/**/*.{ts,tsx}',
|
||||
],
|
||||
ignores: ['**/**/globals.d.ts'],
|
||||
rules: {
|
||||
// enforce type over interface
|
||||
'@typescript-eslint/consistent-type-definitions': ['warn', 'type'],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['packages/sync-server/**/*'],
|
||||
// TODO: fix the issues in these files
|
||||
rules: {
|
||||
'import/extensions': 'off',
|
||||
'actual/typography': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['packages/sync-server/src/app-gocardless/banks/*.js'],
|
||||
rules: {
|
||||
'import/no-anonymous-default-export': 'off',
|
||||
'import/no-default-export': 'off',
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1,30 +0,0 @@
|
||||
/** @type {import('lage').ConfigOptions} */
|
||||
module.exports = {
|
||||
pipeline: {
|
||||
test: {
|
||||
type: 'npmScript',
|
||||
options: {
|
||||
outputGlob: [
|
||||
'coverage/**',
|
||||
'**/test-results/**',
|
||||
'**/playwright-report/**',
|
||||
],
|
||||
},
|
||||
},
|
||||
build: {
|
||||
type: 'npmScript',
|
||||
cache: true,
|
||||
options: {
|
||||
outputGlob: ['lib-dist/**', 'dist/**', 'build/**'],
|
||||
},
|
||||
},
|
||||
},
|
||||
cacheOptions: {
|
||||
cacheStorageConfig: {
|
||||
provider: 'local',
|
||||
outputGlob: ['lib-dist/**', 'dist/**', 'build/**'],
|
||||
},
|
||||
},
|
||||
npmClient: 'yarn',
|
||||
concurrency: 2,
|
||||
};
|
||||
70
package.json
70
package.json
@@ -7,11 +7,11 @@
|
||||
"bugs": {
|
||||
"url": "https://github.com/actualbudget/actual/issues/"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git@github.com:actualbudget/actual.git"
|
||||
},
|
||||
"license": "MIT",
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"packages/*"
|
||||
@@ -23,7 +23,6 @@
|
||||
"start:server-monitor": "yarn workspace @actual-app/sync-server start-monitor",
|
||||
"start:server-dev": "NODE_ENV=development BROWSER_OPEN=localhost:5006 yarn npm-run-all --parallel 'start:server-monitor' 'start'",
|
||||
"start:desktop": "yarn desktop-dependencies && npm-run-all --parallel 'start:desktop-*'",
|
||||
"start:docs": "yarn workspace docs start",
|
||||
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:browser-backend build:plugins-service",
|
||||
"start:desktop-node": "yarn workspace loot-core watch:node",
|
||||
"start:desktop-client": "yarn workspace @actual-app/web watch",
|
||||
@@ -33,78 +32,77 @@
|
||||
"start:service-plugins": "yarn workspace plugins-service watch",
|
||||
"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: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:docs": "yarn workspace docs build",
|
||||
"build:storybook": "yarn workspace @actual-app/components build:storybook",
|
||||
"deploy:docs": "yarn workspace docs deploy",
|
||||
"generate:i18n": "yarn workspace @actual-app/web generate:i18n",
|
||||
"generate:release-notes": "ts-node ./bin/release-note-generator.ts",
|
||||
"test": "lage test --continue",
|
||||
"test:debug": "lage test --no-cache --continue",
|
||||
"e2e": "yarn workspace @actual-app/web run e2e",
|
||||
"e2e:desktop": "yarn build:desktop --skip-exe-build --skip-translations && yarn workspace desktop-electron e2e",
|
||||
"test": "yarn workspaces foreach --all --parallel --verbose run test",
|
||||
"test:debug": "yarn workspaces foreach --all --verbose run test",
|
||||
"e2e": "yarn workspaces foreach --all --exclude desktop-electron --parallel --verbose run e2e",
|
||||
"e2e:desktop": "yarn build:desktop --skip-exe-build && yarn workspace desktop-electron e2e",
|
||||
"playwright": "yarn workspace @actual-app/web run playwright",
|
||||
"vrt": "yarn workspace @actual-app/web run vrt",
|
||||
"vrt": "yarn workspaces foreach --all --parallel --verbose run vrt",
|
||||
"vrt:docker": "./bin/run-vrt",
|
||||
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core",
|
||||
"rebuild-node": "yarn workspace loot-core rebuild",
|
||||
"lint": "oxfmt --check . && oxlint --deny-warnings",
|
||||
"lint:fix": "oxfmt . && oxlint --deny-warnings --fix",
|
||||
"lint": "prettier --check . && eslint . --max-warnings 0",
|
||||
"lint:fix": "prettier --check --write . && eslint . --max-warnings 0 --fix",
|
||||
"install:server": "yarn workspaces focus @actual-app/sync-server --production",
|
||||
"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.3",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@types/node": "^22.18.8",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"baseline-browser-mapping": "^2.9.14",
|
||||
"@typescript-eslint/parser": "^8.45.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-perfectionist": "^4.15.1",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^6.1.1",
|
||||
"eslint-plugin-typescript-paths": "^0.0.33",
|
||||
"globals": "^15.15.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"husky": "^9.1.7",
|
||||
"lage": "^2.14.15",
|
||||
"lint-staged": "^16.2.7",
|
||||
"minimatch": "^10.1.1",
|
||||
"node-jq": "^6.3.1",
|
||||
"lint-staged": "^16.2.3",
|
||||
"minimatch": "^10.0.3",
|
||||
"node-jq": "^6.0.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"oxfmt": "^0.26.0",
|
||||
"oxlint": "^1.41.0",
|
||||
"p-limit": "^7.2.0",
|
||||
"p-limit": "^6.2.0",
|
||||
"prettier": "^3.6.2",
|
||||
"prompts": "^2.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.45.0",
|
||||
"typescript-strict-plugin": "^2.4.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"rollup": "4.40.1",
|
||||
"socks": ">=2.8.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20",
|
||||
"yarn": "^4.9.1"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,mjs,jsx,ts,tsx,md,json,yml,yaml}": [
|
||||
"oxfmt --no-error-on-unmatched-pattern"
|
||||
],
|
||||
"*.{js,mjs,jsx,ts,tsx}": [
|
||||
"oxlint --deny-warnings --fix"
|
||||
"*.{js,mjs,jsx,ts,tsx,md,json,yml}": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"packageManager": "yarn@4.9.1",
|
||||
"browserslist": [
|
||||
"electron >= 35.0",
|
||||
"defaults"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22",
|
||||
"yarn": "^4.9.1"
|
||||
},
|
||||
"packageManager": "yarn@4.10.3"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -97,14 +97,6 @@ class Query {
|
||||
serialize() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
reset() {
|
||||
return q(this.state.table);
|
||||
}
|
||||
|
||||
serializeAsString() {
|
||||
return JSON.stringify(this.serialize());
|
||||
}
|
||||
}
|
||||
|
||||
export function q(table) {
|
||||
|
||||
@@ -6,8 +6,8 @@ import type {
|
||||
// loot-core types
|
||||
import type { InitConfig } from 'loot-core/server/main';
|
||||
|
||||
// oxlint-disable-next-line typescript/ban-ts-comment
|
||||
// @ts-ignore: bundle not available until we build it
|
||||
// eslint-disable-next-line import/extensions
|
||||
import * as bundle from './app/bundle.api.js';
|
||||
import * as injected from './injected';
|
||||
import { validateNodeVersion } from './validateNodeVersion';
|
||||
@@ -44,7 +44,7 @@ export async function shutdown() {
|
||||
if (actualApp) {
|
||||
try {
|
||||
await actualApp.send('sync');
|
||||
} catch {
|
||||
} catch (e) {
|
||||
// most likely that no budget is loaded, so the sync failed
|
||||
}
|
||||
await actualApp.send('close-budget');
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// @ts-strict-ignore
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
import type { RuleEntity } from 'loot-core/types/models';
|
||||
|
||||
import * as api from './index';
|
||||
|
||||
const budgetName = 'test-budget';
|
||||
@@ -283,7 +282,7 @@ describe('API CRUD operations', () => {
|
||||
expect(await api.getAccountBalance(accountId2)).toEqual(0);
|
||||
|
||||
await api.updateAccount(accountId1, { offbudget: false });
|
||||
await api.closeAccount(accountId1, accountId2);
|
||||
await api.closeAccount(accountId1, accountId2, null);
|
||||
await api.deleteAccount(accountId2);
|
||||
|
||||
// accounts successfully updated, and one of them deleted
|
||||
@@ -506,7 +505,7 @@ describe('API CRUD operations', () => {
|
||||
...rule,
|
||||
stage: 'post',
|
||||
conditionsOp: 'or',
|
||||
} satisfies RuleEntity;
|
||||
};
|
||||
expect(await api.updateRule(updatedRule)).toEqual(updatedRule);
|
||||
|
||||
expect(await api.getRules()).toEqual(
|
||||
@@ -720,7 +719,7 @@ describe('API CRUD operations', () => {
|
||||
|
||||
// Test without notes
|
||||
const transactionsWithoutNotes = [
|
||||
{ date: '2023-11-03', imported_id: '11', amount: 100 },
|
||||
{ date: '2023-11-03', imported_id: '11', amount: 100, notes: null },
|
||||
];
|
||||
|
||||
const addResultWithoutNotes = await api.addTransactions(
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
import type {
|
||||
APIAccountEntity,
|
||||
APICategoryEntity,
|
||||
APICategoryGroupEntity,
|
||||
APIFileEntity,
|
||||
APIPayeeEntity,
|
||||
APIScheduleEntity,
|
||||
} from 'loot-core/server/api-models';
|
||||
import type { Query } from 'loot-core/shared/query';
|
||||
// @ts-strict-ignore
|
||||
import type { Handlers } from 'loot-core/types/handlers';
|
||||
import type {
|
||||
ImportTransactionEntity,
|
||||
RuleEntity,
|
||||
TransactionEntity,
|
||||
} from 'loot-core/types/models';
|
||||
import type { ImportTransactionEntity } from 'loot-core/types/models/import-transaction';
|
||||
|
||||
import * as injected from './injected';
|
||||
|
||||
@@ -25,11 +13,8 @@ function send<K extends keyof Handlers, T extends Handlers[K]>(
|
||||
return injected.send(name, args);
|
||||
}
|
||||
|
||||
export async function runImport(
|
||||
budgetName: APIFileEntity['name'],
|
||||
func: () => Promise<void>,
|
||||
) {
|
||||
await send('api/start-import', { budgetName });
|
||||
export async function runImport(name, func) {
|
||||
await send('api/start-import', { budgetName: name });
|
||||
try {
|
||||
await func();
|
||||
} catch (e) {
|
||||
@@ -39,14 +24,11 @@ export async function runImport(
|
||||
await send('api/finish-import');
|
||||
}
|
||||
|
||||
export async function loadBudget(budgetId: string) {
|
||||
export async function loadBudget(budgetId) {
|
||||
return send('api/load-budget', { id: budgetId });
|
||||
}
|
||||
|
||||
export async function downloadBudget(
|
||||
syncId: string,
|
||||
{ password }: { password?: string } = {},
|
||||
) {
|
||||
export async function downloadBudget(syncId, { password }: { password? } = {}) {
|
||||
return send('api/download-budget', { syncId, password });
|
||||
}
|
||||
|
||||
@@ -58,13 +40,11 @@ export async function sync() {
|
||||
return send('api/sync');
|
||||
}
|
||||
|
||||
export async function runBankSync(args?: {
|
||||
accountId: APIAccountEntity['id'];
|
||||
}) {
|
||||
export async function runBankSync(args?: { accountId: string }) {
|
||||
return send('api/bank-sync', args);
|
||||
}
|
||||
|
||||
export async function batchBudgetUpdates(func: () => Promise<void>) {
|
||||
export async function batchBudgetUpdates(func) {
|
||||
await send('api/batch-budget-start');
|
||||
try {
|
||||
await func();
|
||||
@@ -77,11 +57,11 @@ export async function batchBudgetUpdates(func: () => Promise<void>) {
|
||||
* @deprecated Please use `aqlQuery` instead.
|
||||
* This function will be removed in a future release.
|
||||
*/
|
||||
export function runQuery(query: Query) {
|
||||
export function runQuery(query) {
|
||||
return send('api/query', { query: query.serialize() });
|
||||
}
|
||||
|
||||
export function aqlQuery(query: Query) {
|
||||
export function aqlQuery(query) {
|
||||
return send('api/query', { query: query.serialize() });
|
||||
}
|
||||
|
||||
@@ -89,33 +69,22 @@ export function getBudgetMonths() {
|
||||
return send('api/budget-months');
|
||||
}
|
||||
|
||||
export function getBudgetMonth(month: string) {
|
||||
export function getBudgetMonth(month) {
|
||||
return send('api/budget-month', { month });
|
||||
}
|
||||
|
||||
export function setBudgetAmount(
|
||||
month: string,
|
||||
categoryId: APICategoryEntity['id'],
|
||||
value: number,
|
||||
) {
|
||||
export function setBudgetAmount(month, categoryId, value) {
|
||||
return send('api/budget-set-amount', { month, categoryId, amount: value });
|
||||
}
|
||||
|
||||
export function setBudgetCarryover(
|
||||
month: string,
|
||||
categoryId: APICategoryEntity['id'],
|
||||
flag: boolean,
|
||||
) {
|
||||
export function setBudgetCarryover(month, categoryId, flag) {
|
||||
return send('api/budget-set-carryover', { month, categoryId, flag });
|
||||
}
|
||||
|
||||
export function addTransactions(
|
||||
accountId: APIAccountEntity['id'],
|
||||
transactions: Omit<ImportTransactionEntity, 'account'>[],
|
||||
{
|
||||
learnCategories = false,
|
||||
runTransfers = false,
|
||||
}: { learnCategories?: boolean; runTransfers?: boolean } = {},
|
||||
accountId,
|
||||
transactions,
|
||||
{ learnCategories = false, runTransfers = false } = {},
|
||||
) {
|
||||
return send('api/transactions-add', {
|
||||
accountId,
|
||||
@@ -125,13 +94,13 @@ export function addTransactions(
|
||||
});
|
||||
}
|
||||
|
||||
export type ImportTransactionsOpts = {
|
||||
export interface ImportTransactionsOpts {
|
||||
defaultCleared?: boolean;
|
||||
dryRun?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function importTransactions(
|
||||
accountId: APIAccountEntity['id'],
|
||||
accountId: string,
|
||||
transactions: ImportTransactionEntity[],
|
||||
opts: ImportTransactionsOpts = {
|
||||
defaultCleared: true,
|
||||
@@ -146,22 +115,15 @@ export function importTransactions(
|
||||
});
|
||||
}
|
||||
|
||||
export function getTransactions(
|
||||
accountId: APIAccountEntity['id'],
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
) {
|
||||
export function getTransactions(accountId, startDate, endDate) {
|
||||
return send('api/transactions-get', { accountId, startDate, endDate });
|
||||
}
|
||||
|
||||
export function updateTransaction(
|
||||
id: TransactionEntity['id'],
|
||||
fields: Partial<TransactionEntity>,
|
||||
) {
|
||||
export function updateTransaction(id, fields) {
|
||||
return send('api/transaction-update', { id, fields });
|
||||
}
|
||||
|
||||
export function deleteTransaction(id: TransactionEntity['id']) {
|
||||
export function deleteTransaction(id) {
|
||||
return send('api/transaction-delete', { id });
|
||||
}
|
||||
|
||||
@@ -169,25 +131,15 @@ export function getAccounts() {
|
||||
return send('api/accounts-get');
|
||||
}
|
||||
|
||||
export function createAccount(
|
||||
account: Omit<APIAccountEntity, 'id'>,
|
||||
initialBalance?: number,
|
||||
) {
|
||||
export function createAccount(account, initialBalance?) {
|
||||
return send('api/account-create', { account, initialBalance });
|
||||
}
|
||||
|
||||
export function updateAccount(
|
||||
id: APIAccountEntity['id'],
|
||||
fields: Partial<APIAccountEntity>,
|
||||
) {
|
||||
export function updateAccount(id, fields) {
|
||||
return send('api/account-update', { id, fields });
|
||||
}
|
||||
|
||||
export function closeAccount(
|
||||
id: APIAccountEntity['id'],
|
||||
transferAccountId?: APIAccountEntity['id'],
|
||||
transferCategoryId?: APICategoryEntity['id'],
|
||||
) {
|
||||
export function closeAccount(id, transferAccountId?, transferCategoryId?) {
|
||||
return send('api/account-close', {
|
||||
id,
|
||||
transferAccountId,
|
||||
@@ -195,15 +147,15 @@ export function closeAccount(
|
||||
});
|
||||
}
|
||||
|
||||
export function reopenAccount(id: APIAccountEntity['id']) {
|
||||
export function reopenAccount(id) {
|
||||
return send('api/account-reopen', { id });
|
||||
}
|
||||
|
||||
export function deleteAccount(id: APIAccountEntity['id']) {
|
||||
export function deleteAccount(id) {
|
||||
return send('api/account-delete', { id });
|
||||
}
|
||||
|
||||
export function getAccountBalance(id: APIAccountEntity['id'], cutoff?: Date) {
|
||||
export function getAccountBalance(id, cutoff?) {
|
||||
return send('api/account-balance', { id, cutoff });
|
||||
}
|
||||
|
||||
@@ -211,21 +163,15 @@ export function getCategoryGroups() {
|
||||
return send('api/category-groups-get');
|
||||
}
|
||||
|
||||
export function createCategoryGroup(group: Omit<APICategoryGroupEntity, 'id'>) {
|
||||
export function createCategoryGroup(group) {
|
||||
return send('api/category-group-create', { group });
|
||||
}
|
||||
|
||||
export function updateCategoryGroup(
|
||||
id: APICategoryGroupEntity['id'],
|
||||
fields: Partial<APICategoryGroupEntity>,
|
||||
) {
|
||||
export function updateCategoryGroup(id, fields) {
|
||||
return send('api/category-group-update', { id, fields });
|
||||
}
|
||||
|
||||
export function deleteCategoryGroup(
|
||||
id: APICategoryGroupEntity['id'],
|
||||
transferCategoryId?: APICategoryEntity['id'],
|
||||
) {
|
||||
export function deleteCategoryGroup(id, transferCategoryId?) {
|
||||
return send('api/category-group-delete', { id, transferCategoryId });
|
||||
}
|
||||
|
||||
@@ -233,21 +179,15 @@ export function getCategories() {
|
||||
return send('api/categories-get', { grouped: false });
|
||||
}
|
||||
|
||||
export function createCategory(category: Omit<APICategoryEntity, 'id'>) {
|
||||
export function createCategory(category) {
|
||||
return send('api/category-create', { category });
|
||||
}
|
||||
|
||||
export function updateCategory(
|
||||
id: APICategoryEntity['id'],
|
||||
fields: Partial<APICategoryEntity>,
|
||||
) {
|
||||
export function updateCategory(id, fields) {
|
||||
return send('api/category-update', { id, fields });
|
||||
}
|
||||
|
||||
export function deleteCategory(
|
||||
id: APICategoryEntity['id'],
|
||||
transferCategoryId?: APICategoryEntity['id'],
|
||||
) {
|
||||
export function deleteCategory(id, transferCategoryId?) {
|
||||
return send('api/category-delete', { id, transferCategoryId });
|
||||
}
|
||||
|
||||
@@ -259,25 +199,19 @@ export function getPayees() {
|
||||
return send('api/payees-get');
|
||||
}
|
||||
|
||||
export function createPayee(payee: Omit<APIPayeeEntity, 'id'>) {
|
||||
export function createPayee(payee) {
|
||||
return send('api/payee-create', { payee });
|
||||
}
|
||||
|
||||
export function updatePayee(
|
||||
id: APIPayeeEntity['id'],
|
||||
fields: Partial<APIPayeeEntity>,
|
||||
) {
|
||||
export function updatePayee(id, fields) {
|
||||
return send('api/payee-update', { id, fields });
|
||||
}
|
||||
|
||||
export function deletePayee(id: APIPayeeEntity['id']) {
|
||||
export function deletePayee(id) {
|
||||
return send('api/payee-delete', { id });
|
||||
}
|
||||
|
||||
export function mergePayees(
|
||||
targetId: APIPayeeEntity['id'],
|
||||
mergeIds: APIPayeeEntity['id'][],
|
||||
) {
|
||||
export function mergePayees(targetId, mergeIds) {
|
||||
return send('api/payees-merge', { targetId, mergeIds });
|
||||
}
|
||||
|
||||
@@ -285,39 +219,35 @@ export function getRules() {
|
||||
return send('api/rules-get');
|
||||
}
|
||||
|
||||
export function getPayeeRules(id: RuleEntity['id']) {
|
||||
export function getPayeeRules(id) {
|
||||
return send('api/payee-rules-get', { id });
|
||||
}
|
||||
|
||||
export function createRule(rule: Omit<RuleEntity, 'id'>) {
|
||||
export function createRule(rule) {
|
||||
return send('api/rule-create', { rule });
|
||||
}
|
||||
|
||||
export function updateRule(rule: RuleEntity) {
|
||||
export function updateRule(rule) {
|
||||
return send('api/rule-update', { rule });
|
||||
}
|
||||
|
||||
export function deleteRule(id: RuleEntity['id']) {
|
||||
export function deleteRule(id: string) {
|
||||
return send('api/rule-delete', id);
|
||||
}
|
||||
|
||||
export function holdBudgetForNextMonth(month: string, amount: number) {
|
||||
export function holdBudgetForNextMonth(month, amount) {
|
||||
return send('api/budget-hold-for-next-month', { month, amount });
|
||||
}
|
||||
|
||||
export function resetBudgetHold(month: string) {
|
||||
export function resetBudgetHold(month) {
|
||||
return send('api/budget-reset-hold', { month });
|
||||
}
|
||||
|
||||
export function createSchedule(schedule: Omit<APIScheduleEntity, 'id'>) {
|
||||
export function createSchedule(schedule) {
|
||||
return send('api/schedule-create', schedule);
|
||||
}
|
||||
|
||||
export function updateSchedule(
|
||||
id: APIScheduleEntity['id'],
|
||||
fields: Partial<APIScheduleEntity>,
|
||||
resetNextDate?: boolean,
|
||||
) {
|
||||
export function updateSchedule(id, fields, resetNextDate?: boolean) {
|
||||
return send('api/schedule-update', {
|
||||
id,
|
||||
fields,
|
||||
@@ -325,7 +255,7 @@ export function updateSchedule(
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteSchedule(scheduleId: APIScheduleEntity['id']) {
|
||||
export function deleteSchedule(scheduleId) {
|
||||
return send('api/schedule-delete', scheduleId);
|
||||
}
|
||||
|
||||
@@ -333,10 +263,7 @@ export function getSchedules() {
|
||||
return send('api/schedules-get');
|
||||
}
|
||||
|
||||
export function getIDByName(
|
||||
type: 'accounts' | 'schedules' | 'categories' | 'payees',
|
||||
name: string,
|
||||
) {
|
||||
export function getIDByName(type, name) {
|
||||
return send('api/get-id-by-name', { type, name });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
{
|
||||
"name": "@actual-app/api",
|
||||
"version": "26.2.0",
|
||||
"description": "An API for Actual",
|
||||
"version": "25.10.0",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"@types",
|
||||
"dist"
|
||||
],
|
||||
"description": "An API for Actual",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "@types/index.d.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"@types"
|
||||
],
|
||||
"scripts": {
|
||||
"build:app": "yarn workspace loot-core build:api",
|
||||
"build:crdt": "yarn workspace @actual-app/crdt build",
|
||||
@@ -16,12 +19,12 @@
|
||||
"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",
|
||||
"test": "yarn run build:app && yarn run build:crdt && vitest",
|
||||
"clean": "rm -rf dist @types"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actual-app/crdt": "workspace:^",
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"compare-versions": "^6.1.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"uuid": "^13.0.0"
|
||||
@@ -29,9 +32,6 @@
|
||||
"devDependencies": {
|
||||
"tsc-alias": "^1.8.16",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.16"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
// Using ES2021 because that's the newest version where
|
||||
// Using ES2021 because that’s the newest version where
|
||||
// the latest Node 16.x release supports all of the features
|
||||
"target": "ES2021",
|
||||
"module": "CommonJS",
|
||||
|
||||
@@ -1,6 +1 @@
|
||||
// oxlint-disable-next-line typescript/ban-ts-comment
|
||||
// @ts-ignore: bundle not available until we build it
|
||||
import * as bundle from './app/bundle.api.js';
|
||||
|
||||
export const amountToInteger = bundle.lib.amountToInteger;
|
||||
export const integerToAmount = bundle.lib.integerToAmount;
|
||||
export { amountToInteger, integerToAmount } from 'loot-core/shared/util';
|
||||
|
||||
@@ -5,6 +5,5 @@ export default {
|
||||
// print only console.error
|
||||
return type === 'stderr';
|
||||
},
|
||||
maxWorkers: 2,
|
||||
},
|
||||
};
|
||||
|
||||
11
packages/bank-sync-plugin-pluggy.ai/.gitignore
vendored
Normal file
11
packages/bank-sync-plugin-pluggy.ai/.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Generated build artifacts
|
||||
manifest.json
|
||||
*.zip
|
||||
|
||||
Binary file not shown.
40911
packages/bank-sync-plugin-pluggy.ai/dist/bundle.js
vendored
Normal file
40911
packages/bank-sync-plugin-pluggy.ai/dist/bundle.js
vendored
Normal file
File diff suppressed because one or more lines are too long
459
packages/bank-sync-plugin-pluggy.ai/dist/index.js
vendored
Normal file
459
packages/bank-sync-plugin-pluggy.ai/dist/index.js
vendored
Normal file
@@ -0,0 +1,459 @@
|
||||
import { attachPluginMiddleware, saveSecret, getSecret, BankSyncErrorCode, } from '@actual-app/plugins-core-sync-server';
|
||||
import express from 'express';
|
||||
import { PluggyClient } from 'pluggy-sdk';
|
||||
// Import manifest (used during build)
|
||||
import './manifest';
|
||||
// Create Express app
|
||||
const app = express();
|
||||
// Use JSON middleware for parsing request bodies
|
||||
app.use(express.json());
|
||||
// Attach the plugin middleware to enable IPC communication with sync-server
|
||||
attachPluginMiddleware(app);
|
||||
// Pluggy client singleton
|
||||
let pluggyClient = null;
|
||||
async function getPluggyClient(req) {
|
||||
// Try to get credentials from secrets first
|
||||
const clientIdResult = await getSecret(req, 'clientId');
|
||||
const clientSecretResult = await getSecret(req, 'clientSecret');
|
||||
const clientId = clientIdResult.value || req.body.clientId;
|
||||
const clientSecret = clientSecretResult.value || req.body.clientSecret;
|
||||
if (!clientId || !clientSecret) {
|
||||
throw new Error('Pluggy.ai credentials not configured');
|
||||
}
|
||||
if (!pluggyClient) {
|
||||
pluggyClient = new PluggyClient({
|
||||
clientId,
|
||||
clientSecret,
|
||||
});
|
||||
}
|
||||
return pluggyClient;
|
||||
}
|
||||
/**
|
||||
* GET /status
|
||||
* Check if Pluggy.ai is configured
|
||||
*/
|
||||
app.get('/status', async (req, res) => {
|
||||
try {
|
||||
const clientIdResult = await getSecret(req, 'clientId');
|
||||
const configured = clientIdResult.value != null;
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
configured,
|
||||
},
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
/**
|
||||
* POST /accounts
|
||||
* Fetch accounts from Pluggy.ai
|
||||
* Body: { itemIds: string, clientId?: string, clientSecret?: string }
|
||||
*
|
||||
* If clientId and clientSecret are provided, they will be saved as secrets
|
||||
*/
|
||||
app.post('/accounts', async (req, res) => {
|
||||
try {
|
||||
const { itemIds, clientId, clientSecret } = req.body;
|
||||
// If credentials are provided in request, save them
|
||||
if (clientId && clientSecret) {
|
||||
await saveSecret(req, 'clientId', clientId);
|
||||
await saveSecret(req, 'clientSecret', clientSecret);
|
||||
}
|
||||
// Get itemIds from request or from stored secrets
|
||||
let itemIdsArray;
|
||||
if (itemIds) {
|
||||
// Parse itemIds from request (can be comma-separated string or array)
|
||||
if (typeof itemIds === 'string') {
|
||||
itemIdsArray = itemIds.split(',').map((id) => id.trim());
|
||||
}
|
||||
else if (Array.isArray(itemIds)) {
|
||||
itemIdsArray = itemIds;
|
||||
}
|
||||
else {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'itemIds must be a string or array',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Save itemIds for future use
|
||||
await saveSecret(req, 'itemIds', itemIdsArray.join(','));
|
||||
}
|
||||
else {
|
||||
// Try to get itemIds from secrets
|
||||
const storedItemIds = await getSecret(req, 'itemIds');
|
||||
if (!storedItemIds.value) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'itemIds is required (comma-separated string or array). Please provide itemIds in request or configure them first.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
itemIdsArray = storedItemIds.value
|
||||
.split(',')
|
||||
.map((id) => id.trim());
|
||||
}
|
||||
if (!itemIdsArray.length) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'At least one item ID is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const client = await getPluggyClient(req);
|
||||
let accounts = [];
|
||||
// Fetch all accounts and their items with connector info
|
||||
for (const itemId of itemIdsArray) {
|
||||
const partial = await client.fetchAccounts(itemId);
|
||||
// For each account, also fetch the item to get connector details
|
||||
for (const account of partial.results) {
|
||||
try {
|
||||
const item = await client.fetchItem(itemId);
|
||||
// Attach item info to account for transformation
|
||||
account.itemData = item;
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`[PLUGGY ACCOUNTS] Error fetching item ${itemId}:`, error);
|
||||
}
|
||||
}
|
||||
accounts = accounts.concat(partial.results);
|
||||
}
|
||||
// Transform Pluggy accounts to GenericBankSyncAccount format
|
||||
const transformedAccounts = accounts.map((account) => {
|
||||
const institution = account.itemData?.connector?.name ||
|
||||
account.item?.connector?.name ||
|
||||
'Unknown Institution';
|
||||
const connectorId = account.itemData?.connector?.id ||
|
||||
account.item?.connector?.id ||
|
||||
account.itemId;
|
||||
return {
|
||||
account_id: account.id,
|
||||
name: account.name,
|
||||
institution,
|
||||
balance: account.balance || 0,
|
||||
mask: account.number?.substring(account.number.length - 4),
|
||||
official_name: account.name,
|
||||
orgDomain: account.itemData?.connector?.institutionUrl ||
|
||||
account.item?.connector?.institutionUrl ||
|
||||
null,
|
||||
orgId: connectorId?.toString() || null,
|
||||
};
|
||||
});
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
accounts: transformedAccounts,
|
||||
},
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[PLUGGY ACCOUNTS] Error:', error);
|
||||
// Extract Pluggy error message and code if available
|
||||
let pluggyMessage = 'Unknown error';
|
||||
let pluggyCode;
|
||||
if (error instanceof Error) {
|
||||
pluggyMessage = error.message;
|
||||
// Try to parse Pluggy SDK error format from error message
|
||||
// Pluggy errors often include the error details in the message
|
||||
try {
|
||||
// Check if error has a structured format
|
||||
const errorAny = error;
|
||||
if (errorAny.message && typeof errorAny.message === 'string') {
|
||||
pluggyMessage = errorAny.message;
|
||||
}
|
||||
if (errorAny.code !== undefined) {
|
||||
pluggyCode = errorAny.code;
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
const errorResponse = {
|
||||
error_type: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
error_code: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
status: 'error',
|
||||
reason: pluggyMessage, // Use the Pluggy error message directly
|
||||
};
|
||||
// Map HTTP status codes to error types
|
||||
const errorMessageLower = pluggyMessage.toLowerCase();
|
||||
if (pluggyCode === 401 || errorMessageLower.includes('401') || errorMessageLower.includes('unauthorized') || errorMessageLower.includes('invalid credentials')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
}
|
||||
else if (pluggyCode === 403 || errorMessageLower.includes('403') || errorMessageLower.includes('forbidden')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.UNAUTHORIZED;
|
||||
errorResponse.error_code = BankSyncErrorCode.UNAUTHORIZED;
|
||||
}
|
||||
else if (pluggyCode === 429 || errorMessageLower.includes('429') || errorMessageLower.includes('rate limit')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.RATE_LIMIT;
|
||||
errorResponse.error_code = BankSyncErrorCode.RATE_LIMIT;
|
||||
}
|
||||
else if (pluggyCode === 400 || errorMessageLower.includes('400') || errorMessageLower.includes('bad request')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_REQUEST;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_REQUEST;
|
||||
}
|
||||
else if (pluggyCode === 404 || errorMessageLower.includes('404') || errorMessageLower.includes('not found')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
errorResponse.error_code = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
}
|
||||
else if (errorMessageLower.includes('network') || errorMessageLower.includes('connect') || errorMessageLower.includes('econnrefused')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.NETWORK_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.NETWORK_ERROR;
|
||||
}
|
||||
else if ((pluggyCode && typeof pluggyCode === 'number' && pluggyCode >= 500) || errorMessageLower.includes('500') || errorMessageLower.includes('502') || errorMessageLower.includes('503')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.SERVER_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.SERVER_ERROR;
|
||||
}
|
||||
errorResponse.details = {
|
||||
originalError: pluggyMessage,
|
||||
pluggyCode: pluggyCode,
|
||||
};
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: errorResponse,
|
||||
});
|
||||
}
|
||||
});
|
||||
/**
|
||||
* POST /transactions
|
||||
* Fetch transactions from Pluggy.ai
|
||||
* Body: { accountId: string, startDate: string, clientId?: string, clientSecret?: string }
|
||||
*/
|
||||
app.post('/transactions', async (req, res) => {
|
||||
try {
|
||||
const { accountId, startDate } = req.body;
|
||||
if (!accountId) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'accountId is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const client = await getPluggyClient(req);
|
||||
const transactions = await getTransactions(client, accountId, startDate);
|
||||
const account = (await client.fetchAccount(accountId));
|
||||
let startingBalance = parseInt(Math.round(account.balance * 100).toString());
|
||||
if (account.type === 'CREDIT') {
|
||||
startingBalance = -startingBalance;
|
||||
}
|
||||
const date = getDate(new Date(account.updatedAt));
|
||||
const balances = [
|
||||
{
|
||||
balanceAmount: {
|
||||
amount: startingBalance,
|
||||
currency: account.currencyCode,
|
||||
},
|
||||
balanceType: 'expected',
|
||||
referenceDate: date,
|
||||
},
|
||||
];
|
||||
const all = [];
|
||||
const booked = [];
|
||||
const pending = [];
|
||||
for (const trans of transactions) {
|
||||
const transRecord = trans;
|
||||
const newTrans = {};
|
||||
newTrans.booked = !(transRecord.status === 'PENDING');
|
||||
const transactionDate = new Date(transRecord.date);
|
||||
if (transactionDate < new Date(startDate) && !transRecord.sandbox) {
|
||||
continue;
|
||||
}
|
||||
newTrans.date = getDate(transactionDate);
|
||||
newTrans.payeeName = getPayeeName(transRecord);
|
||||
newTrans.notes = transRecord.descriptionRaw || transRecord.description;
|
||||
if (account.type === 'CREDIT') {
|
||||
if (transRecord.amountInAccountCurrency) {
|
||||
transRecord.amountInAccountCurrency =
|
||||
transRecord.amountInAccountCurrency * -1;
|
||||
}
|
||||
transRecord.amount = transRecord.amount * -1;
|
||||
}
|
||||
let amountInCurrency = transRecord.amountInAccountCurrency ??
|
||||
transRecord.amount;
|
||||
amountInCurrency = Math.round(amountInCurrency * 100) / 100;
|
||||
newTrans.transactionAmount = {
|
||||
amount: amountInCurrency,
|
||||
currency: transRecord.currencyCode,
|
||||
};
|
||||
newTrans.transactionId = transRecord.id;
|
||||
newTrans.sortOrder = transactionDate.getTime();
|
||||
delete transRecord.amount;
|
||||
const finalTrans = { ...flattenObject(transRecord), ...newTrans };
|
||||
if (newTrans.booked) {
|
||||
booked.push(finalTrans);
|
||||
}
|
||||
else {
|
||||
pending.push(finalTrans);
|
||||
}
|
||||
all.push(finalTrans);
|
||||
}
|
||||
const sortFunction = (a, b) => {
|
||||
const aRec = a;
|
||||
const bRec = b;
|
||||
return bRec.sortOrder - aRec.sortOrder;
|
||||
};
|
||||
const bookedSorted = booked.sort(sortFunction);
|
||||
const pendingSorted = pending.sort(sortFunction);
|
||||
const allSorted = all.sort(sortFunction);
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
balances,
|
||||
startingBalance,
|
||||
transactions: {
|
||||
all: allSorted,
|
||||
booked: bookedSorted,
|
||||
pending: pendingSorted,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[PLUGGY TRANSACTIONS] Error:', error);
|
||||
// Extract Pluggy error message and code if available
|
||||
let pluggyMessage = 'Unknown error';
|
||||
let pluggyCode;
|
||||
if (error instanceof Error) {
|
||||
pluggyMessage = error.message;
|
||||
// Try to parse Pluggy SDK error format from error message
|
||||
try {
|
||||
const errorAny = error;
|
||||
if (errorAny.message && typeof errorAny.message === 'string') {
|
||||
pluggyMessage = errorAny.message;
|
||||
}
|
||||
if (errorAny.code !== undefined) {
|
||||
pluggyCode = errorAny.code;
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
const errorResponse = {
|
||||
error_type: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
error_code: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
status: 'error',
|
||||
reason: pluggyMessage, // Use the Pluggy error message directly
|
||||
};
|
||||
// Map HTTP status codes to error types
|
||||
const errorMessageLower = pluggyMessage.toLowerCase();
|
||||
if (pluggyCode === 401 || errorMessageLower.includes('401') || errorMessageLower.includes('unauthorized') || errorMessageLower.includes('invalid credentials')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
}
|
||||
else if (pluggyCode === 403 || errorMessageLower.includes('403') || errorMessageLower.includes('forbidden')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.UNAUTHORIZED;
|
||||
errorResponse.error_code = BankSyncErrorCode.UNAUTHORIZED;
|
||||
}
|
||||
else if (pluggyCode === 404 || errorMessageLower.includes('404') || errorMessageLower.includes('not found')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
errorResponse.error_code = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
}
|
||||
else if (pluggyCode === 429 || errorMessageLower.includes('429') || errorMessageLower.includes('rate limit')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.RATE_LIMIT;
|
||||
errorResponse.error_code = BankSyncErrorCode.RATE_LIMIT;
|
||||
}
|
||||
else if (pluggyCode === 400 || errorMessageLower.includes('400') || errorMessageLower.includes('bad request')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_REQUEST;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_REQUEST;
|
||||
}
|
||||
else if (errorMessageLower.includes('network') || errorMessageLower.includes('connect') || errorMessageLower.includes('econnrefused')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.NETWORK_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.NETWORK_ERROR;
|
||||
}
|
||||
else if ((pluggyCode && typeof pluggyCode === 'number' && pluggyCode >= 500) || errorMessageLower.includes('500') || errorMessageLower.includes('502') || errorMessageLower.includes('503')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.SERVER_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.SERVER_ERROR;
|
||||
}
|
||||
errorResponse.details = {
|
||||
originalError: pluggyMessage,
|
||||
pluggyCode: pluggyCode,
|
||||
};
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: errorResponse,
|
||||
});
|
||||
}
|
||||
});
|
||||
// Helper functions
|
||||
async function getTransactions(client, accountId, startDate) {
|
||||
let transactions = [];
|
||||
let result = await getTransactionsByAccountId(client, accountId, startDate, 500, 1);
|
||||
transactions = transactions.concat(result.results);
|
||||
const totalPages = result.totalPages;
|
||||
let currentPage = result.page;
|
||||
while (currentPage !== totalPages) {
|
||||
result = await getTransactionsByAccountId(client, accountId, startDate, 500, currentPage + 1);
|
||||
transactions = transactions.concat(result.results);
|
||||
currentPage = result.page;
|
||||
}
|
||||
return transactions;
|
||||
}
|
||||
async function getTransactionsByAccountId(client, accountId, startDate, pageSize, page) {
|
||||
const account = (await client.fetchAccount(accountId));
|
||||
// Sandbox account handling
|
||||
const sandboxAccount = account.owner === 'John Doe';
|
||||
const fromDate = sandboxAccount ? '2000-01-01' : startDate;
|
||||
const transactions = await client.fetchTransactions(accountId, {
|
||||
from: fromDate,
|
||||
pageSize,
|
||||
page,
|
||||
});
|
||||
if (sandboxAccount) {
|
||||
const mappedResults = transactions.results.map((t) => ({
|
||||
...t,
|
||||
sandbox: true,
|
||||
}));
|
||||
transactions.results =
|
||||
mappedResults;
|
||||
}
|
||||
return transactions;
|
||||
}
|
||||
function getDate(date) {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
function flattenObject(obj, prefix = '') {
|
||||
const result = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const newKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (value === null) {
|
||||
continue;
|
||||
}
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
Object.assign(result, flattenObject(value, newKey));
|
||||
}
|
||||
else {
|
||||
result[newKey] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function getPayeeName(trans) {
|
||||
const merchant = trans.merchant;
|
||||
if (merchant && (merchant.name || merchant.businessName)) {
|
||||
return merchant.name || merchant.businessName || '';
|
||||
}
|
||||
const paymentData = trans.paymentData;
|
||||
if (paymentData) {
|
||||
const { receiver, payer } = paymentData;
|
||||
if (trans.type === 'DEBIT' && receiver) {
|
||||
const receiverData = receiver;
|
||||
const docNum = receiverData.documentNumber;
|
||||
return receiverData.name || docNum?.value || '';
|
||||
}
|
||||
if (trans.type === 'CREDIT' && payer) {
|
||||
const payerData = payer;
|
||||
const docNum = payerData.documentNumber;
|
||||
return payerData.name || docNum?.value || '';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
console.log('Pluggy.ai Bank Sync Plugin loaded');
|
||||
40
packages/bank-sync-plugin-pluggy.ai/dist/manifest.js
vendored
Normal file
40
packages/bank-sync-plugin-pluggy.ai/dist/manifest.js
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
export const manifest = {
|
||||
name: 'pluggy-bank-sync',
|
||||
version: '0.0.1',
|
||||
description: 'Pluggy.ai bank synchronization plugin for Actual Budget',
|
||||
entry: 'index.js',
|
||||
author: 'Actual Budget Team',
|
||||
license: 'MIT',
|
||||
routes: [
|
||||
{
|
||||
path: '/status',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Check Pluggy.ai configuration status',
|
||||
},
|
||||
{
|
||||
path: '/accounts',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Fetch accounts from Pluggy.ai',
|
||||
},
|
||||
{
|
||||
path: '/transactions',
|
||||
methods: ['POST'],
|
||||
auth: 'authenticated',
|
||||
description: 'Fetch transactions from Pluggy.ai',
|
||||
},
|
||||
],
|
||||
bankSync: {
|
||||
enabled: true,
|
||||
displayName: 'Pluggy.ai',
|
||||
description: 'Connect your bank accounts via Pluggy.ai',
|
||||
requiresAuth: true,
|
||||
endpoints: {
|
||||
status: '/status',
|
||||
accounts: '/accounts',
|
||||
transactions: '/transactions',
|
||||
},
|
||||
},
|
||||
};
|
||||
export default manifest;
|
||||
45
packages/bank-sync-plugin-pluggy.ai/manifest.json
Normal file
45
packages/bank-sync-plugin-pluggy.ai/manifest.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "pluggy-bank-sync",
|
||||
"version": "0.0.1",
|
||||
"description": "Pluggy.ai bank synchronization plugin for Actual Budget",
|
||||
"entry": "index.js",
|
||||
"author": "Actual Budget Team",
|
||||
"license": "MIT",
|
||||
"routes": [
|
||||
{
|
||||
"path": "/status",
|
||||
"methods": [
|
||||
"POST"
|
||||
],
|
||||
"auth": "authenticated",
|
||||
"description": "Check Pluggy.ai configuration status"
|
||||
},
|
||||
{
|
||||
"path": "/accounts",
|
||||
"methods": [
|
||||
"POST"
|
||||
],
|
||||
"auth": "authenticated",
|
||||
"description": "Fetch accounts from Pluggy.ai"
|
||||
},
|
||||
{
|
||||
"path": "/transactions",
|
||||
"methods": [
|
||||
"POST"
|
||||
],
|
||||
"auth": "authenticated",
|
||||
"description": "Fetch transactions from Pluggy.ai"
|
||||
}
|
||||
],
|
||||
"bankSync": {
|
||||
"enabled": true,
|
||||
"displayName": "Pluggy.ai",
|
||||
"description": "Connect your bank accounts via Pluggy.ai",
|
||||
"requiresAuth": true,
|
||||
"endpoints": {
|
||||
"status": "/status",
|
||||
"accounts": "/accounts",
|
||||
"transactions": "/transactions"
|
||||
}
|
||||
}
|
||||
}
|
||||
40
packages/bank-sync-plugin-pluggy.ai/package.json
Normal file
40
packages/bank-sync-plugin-pluggy.ai/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@actual-app/bank-sync-plugin-pluggy.ai",
|
||||
"version": "0.0.1",
|
||||
"description": "Pluggy.ai bank sync plugin for Actual Budget",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "npm run build:compile && npm run build:bundle && npm run build:manifest && npm run build:zip",
|
||||
"build:compile": "tsc",
|
||||
"build:bundle": "node scripts/build-bundle.cjs",
|
||||
"build:manifest": "node scripts/build-manifest.cjs",
|
||||
"build:zip": "node scripts/build-zip.cjs",
|
||||
"deploy": "npm run build && npm run install:plugin",
|
||||
"install:plugin": "node scripts/install-plugin.cjs",
|
||||
"watch": "tsc --watch",
|
||||
"clean": "rm -rf dist *.zip",
|
||||
"dev": "tsc --watch"
|
||||
},
|
||||
"keywords": [
|
||||
"actual",
|
||||
"plugin",
|
||||
"bank-sync",
|
||||
"pluggy",
|
||||
"pluggyai"
|
||||
],
|
||||
"author": "Actual Budget",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.0.0",
|
||||
"archiver": "^7.0.0",
|
||||
"esbuild": "^0.24.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actual-app/plugins-core-sync-server": "workspace:*",
|
||||
"express": "^4.18.0",
|
||||
"pluggy-sdk": "^0.77.0"
|
||||
}
|
||||
}
|
||||
39
packages/bank-sync-plugin-pluggy.ai/scripts/build-bundle.cjs
Normal file
39
packages/bank-sync-plugin-pluggy.ai/scripts/build-bundle.cjs
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Build script to bundle the plugin with all dependencies
|
||||
* Uses esbuild to create a single self-contained JavaScript file
|
||||
*/
|
||||
|
||||
const esbuild = require('esbuild');
|
||||
const { join } = require('path');
|
||||
|
||||
async function bundle() {
|
||||
try {
|
||||
console.log('Bundling plugin with dependencies...');
|
||||
|
||||
const entryPoint = join(__dirname, '..', 'dist', 'index.js');
|
||||
const outFile = join(__dirname, '..', 'dist', 'bundle.js');
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [entryPoint],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node20',
|
||||
format: 'esm',
|
||||
outfile: outFile,
|
||||
external: [],
|
||||
minify: false,
|
||||
sourcemap: false,
|
||||
treeShaking: true,
|
||||
});
|
||||
|
||||
console.log('Bundle created successfully');
|
||||
console.log(`Output: dist/bundle.js`);
|
||||
} catch (error) {
|
||||
console.error('Failed to bundle:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
bundle();
|
||||
51
packages/bank-sync-plugin-pluggy.ai/scripts/build-manifest.cjs
Executable file
51
packages/bank-sync-plugin-pluggy.ai/scripts/build-manifest.cjs
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Build script to convert TypeScript manifest to JSON
|
||||
* This script imports the manifest.ts file and writes it as JSON to manifest.json
|
||||
*/
|
||||
|
||||
const { writeFileSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
|
||||
// Import the manifest from the built TypeScript file
|
||||
// Note: __dirname is already available in CommonJS and refers to the scripts/ directory
|
||||
async function importManifest() {
|
||||
// First try to import from the compiled JavaScript
|
||||
try {
|
||||
const manifestModule = await import('../dist/manifest.js');
|
||||
return manifestModule.manifest;
|
||||
} catch (error) {
|
||||
console.error('Could not import compiled manifest:', error.message);
|
||||
console.log(
|
||||
'Make sure TypeScript is compiled first. Run: npm run build:compile',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildManifest() {
|
||||
try {
|
||||
console.log('Building manifest.json...');
|
||||
|
||||
// Import the manifest from the compiled TypeScript
|
||||
const manifest = await importManifest();
|
||||
|
||||
// Convert to JSON with pretty formatting
|
||||
const jsonContent = JSON.stringify(manifest, null, 2);
|
||||
|
||||
// Write to manifest.json in the root directory
|
||||
const manifestPath = join(__dirname, '..', 'manifest.json');
|
||||
writeFileSync(manifestPath, jsonContent + '\n');
|
||||
|
||||
console.log('manifest.json created successfully');
|
||||
console.log(`Package: ${manifest.name}@${manifest.version}`);
|
||||
console.log(`Description: ${manifest.description}`);
|
||||
console.log(`Entry point: ${manifest.entry}`);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to build manifest:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
buildManifest();
|
||||
89
packages/bank-sync-plugin-pluggy.ai/scripts/build-zip.cjs
Executable file
89
packages/bank-sync-plugin-pluggy.ai/scripts/build-zip.cjs
Executable file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Build script to create a plugin distribution zip file
|
||||
* Creates: {packageName}.{version}.zip containing dist/index.js and manifest.json
|
||||
*/
|
||||
|
||||
const { createWriteStream, existsSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
const archiver = require('archiver');
|
||||
|
||||
// Import package.json to get name and version
|
||||
// Note: __dirname is already available in CommonJS and refers to the scripts/ directory
|
||||
function importPackageJson() {
|
||||
try {
|
||||
const packageJson = require('../package.json');
|
||||
return packageJson;
|
||||
} catch (error) {
|
||||
console.error('Could not import package.json:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function createZip() {
|
||||
try {
|
||||
console.log('Creating plugin distribution zip...');
|
||||
|
||||
// Get package info
|
||||
const packageJson = importPackageJson();
|
||||
const packageName = packageJson.name;
|
||||
const version = packageJson.version;
|
||||
|
||||
// Create zip filename
|
||||
const zipFilename = `${packageName.replace('@', '').replace('/', '-')}.${version}.zip`;
|
||||
const zipPath = join(__dirname, '..', zipFilename);
|
||||
|
||||
console.log(`Creating ${zipFilename}`);
|
||||
|
||||
// Check if required files exist
|
||||
const bundlePath = join(__dirname, '..', 'dist', 'bundle.js');
|
||||
const manifestPath = join(__dirname, '..', 'manifest.json');
|
||||
|
||||
if (!existsSync(bundlePath)) {
|
||||
console.error('dist/bundle.js not found. Run: npm run build:bundle');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!existsSync(manifestPath)) {
|
||||
console.error('manifest.json not found. Run: npm run build:manifest');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Create zip file
|
||||
const output = createWriteStream(zipPath);
|
||||
const archive = archiver('zip', {
|
||||
zlib: { level: 9 }, // Maximum compression
|
||||
});
|
||||
|
||||
// Handle archive events
|
||||
archive.on('error', err => {
|
||||
console.error('Archive error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
archive.on('end', () => {
|
||||
const stats = archive.pointer();
|
||||
console.log(`${zipFilename} created successfully`);
|
||||
console.log(`Size: ${(stats / 1024).toFixed(2)} KB`);
|
||||
console.log(
|
||||
`📁 Contents: index.js (bundled with dependencies), manifest.json`,
|
||||
);
|
||||
});
|
||||
|
||||
// Pipe archive to file
|
||||
archive.pipe(output);
|
||||
|
||||
// Add files to archive
|
||||
archive.file(bundlePath, { name: 'index.js' });
|
||||
archive.file(manifestPath, { name: 'manifest.json' });
|
||||
|
||||
// Finalize the archive
|
||||
await archive.finalize();
|
||||
} catch (error) {
|
||||
console.error('Failed to create zip:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
createZip();
|
||||
70
packages/bank-sync-plugin-pluggy.ai/scripts/install-plugin.cjs
Executable file
70
packages/bank-sync-plugin-pluggy.ai/scripts/install-plugin.cjs
Executable file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const packageJson = require('../package.json');
|
||||
const packageName = packageJson.name;
|
||||
const version = packageJson.version;
|
||||
const pluginName = packageName.replace('@', '').replace('/', '-');
|
||||
const zipFileName = `${pluginName}.${version}.zip`;
|
||||
|
||||
// Source: built zip in package root (not in dist/)
|
||||
const sourceZip = path.join(__dirname, '..', zipFileName);
|
||||
|
||||
// Target: sync-server plugins directory
|
||||
// Go up to monorepo root, then to sync-server
|
||||
const targetDir = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'sync-server',
|
||||
'server-files',
|
||||
'plugins',
|
||||
);
|
||||
const targetZip = path.join(targetDir, zipFileName);
|
||||
|
||||
console.log('📦 Installing plugin to sync-server...');
|
||||
console.log(` Source: ${sourceZip}`);
|
||||
console.log(` Target: ${targetZip}`);
|
||||
|
||||
// Check if source exists
|
||||
if (!fs.existsSync(sourceZip)) {
|
||||
console.error(`Error: ZIP file not found at ${sourceZip}`);
|
||||
console.error(' Run "npm run build" first to create the ZIP file.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Create target directory if it doesn't exist
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
console.log(`Creating plugins directory: ${targetDir}`);
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Remove old versions of this plugin
|
||||
try {
|
||||
const files = fs.readdirSync(targetDir);
|
||||
const oldVersions = files.filter(
|
||||
f => f.startsWith(pluginName) && f.endsWith('.zip') && f !== zipFileName,
|
||||
);
|
||||
|
||||
for (const oldFile of oldVersions) {
|
||||
const oldPath = path.join(targetDir, oldFile);
|
||||
console.log(` Removing old version: ${oldFile}`);
|
||||
fs.unlinkSync(oldPath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(` Warning: Could not clean old versions: ${err.message}`);
|
||||
}
|
||||
|
||||
// Copy the new ZIP
|
||||
try {
|
||||
fs.copyFileSync(sourceZip, targetZip);
|
||||
console.log(` Plugin installed successfully!`);
|
||||
console.log(` Location: ${targetZip}`);
|
||||
console.log('');
|
||||
console.log(' Restart your sync-server to load the plugin.');
|
||||
} catch (err) {
|
||||
console.error(` Error copying file: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
605
packages/bank-sync-plugin-pluggy.ai/src/index.ts
Normal file
605
packages/bank-sync-plugin-pluggy.ai/src/index.ts
Normal file
@@ -0,0 +1,605 @@
|
||||
import {
|
||||
attachPluginMiddleware,
|
||||
saveSecret,
|
||||
getSecret,
|
||||
BankSyncErrorCode,
|
||||
BankSyncError,
|
||||
} from '@actual-app/plugins-core-sync-server';
|
||||
import express, { Request, Response } from 'express';
|
||||
import { PluggyClient } from 'pluggy-sdk';
|
||||
|
||||
// Import manifest (used during build)
|
||||
import './manifest';
|
||||
|
||||
// Type definitions for Pluggy account structure
|
||||
type PluggyConnector = {
|
||||
id: number | string;
|
||||
name: string;
|
||||
institutionUrl?: string;
|
||||
};
|
||||
|
||||
type PluggyItem = {
|
||||
connector?: PluggyConnector;
|
||||
};
|
||||
|
||||
type PluggyAccount = {
|
||||
id: string;
|
||||
name: string;
|
||||
number?: string;
|
||||
balance?: number;
|
||||
type?: string;
|
||||
itemId?: string;
|
||||
item?: PluggyItem;
|
||||
itemData?: PluggyItem;
|
||||
updatedAt?: string;
|
||||
currencyCode?: string;
|
||||
owner?: string;
|
||||
};
|
||||
|
||||
// Create Express app
|
||||
const app = express();
|
||||
|
||||
// Use JSON middleware for parsing request bodies
|
||||
app.use(express.json());
|
||||
|
||||
// Attach the plugin middleware to enable IPC communication with sync-server
|
||||
attachPluginMiddleware(app);
|
||||
|
||||
// Pluggy client singleton
|
||||
let pluggyClient: PluggyClient | null = null;
|
||||
|
||||
async function getPluggyClient(req: Request): Promise<PluggyClient> {
|
||||
// Try to get credentials from secrets first
|
||||
const clientIdResult = await getSecret(req, 'clientId');
|
||||
const clientSecretResult = await getSecret(req, 'clientSecret');
|
||||
|
||||
const clientId = clientIdResult.value || req.body.clientId;
|
||||
const clientSecret = clientSecretResult.value || req.body.clientSecret;
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
throw new Error('Pluggy.ai credentials not configured');
|
||||
}
|
||||
|
||||
if (!pluggyClient) {
|
||||
pluggyClient = new PluggyClient({
|
||||
clientId,
|
||||
clientSecret,
|
||||
});
|
||||
}
|
||||
|
||||
return pluggyClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /status
|
||||
* Check if Pluggy.ai is configured
|
||||
*/
|
||||
app.get('/status', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const clientIdResult = await getSecret(req, 'clientId');
|
||||
const configured = clientIdResult.value != null;
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
configured,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /accounts
|
||||
* Fetch accounts from Pluggy.ai
|
||||
* Body: { itemIds: string, clientId?: string, clientSecret?: string }
|
||||
*
|
||||
* If clientId and clientSecret are provided, they will be saved as secrets
|
||||
*/
|
||||
app.post('/accounts', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { itemIds, clientId, clientSecret } = req.body;
|
||||
|
||||
// If credentials are provided in request, save them
|
||||
if (clientId && clientSecret) {
|
||||
await saveSecret(req, 'clientId', clientId);
|
||||
await saveSecret(req, 'clientSecret', clientSecret);
|
||||
}
|
||||
|
||||
// Get itemIds from request or from stored secrets
|
||||
let itemIdsArray: string[];
|
||||
|
||||
if (itemIds) {
|
||||
// Parse itemIds from request (can be comma-separated string or array)
|
||||
if (typeof itemIds === 'string') {
|
||||
itemIdsArray = itemIds.split(',').map((id: string) => id.trim());
|
||||
} else if (Array.isArray(itemIds)) {
|
||||
itemIdsArray = itemIds;
|
||||
} else {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'itemIds must be a string or array',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Save itemIds for future use
|
||||
await saveSecret(req, 'itemIds', itemIdsArray.join(','));
|
||||
} else {
|
||||
// Try to get itemIds from secrets
|
||||
const storedItemIds = await getSecret(req, 'itemIds');
|
||||
if (!storedItemIds.value) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error:
|
||||
'itemIds is required (comma-separated string or array). Please provide itemIds in request or configure them first.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
itemIdsArray = storedItemIds.value
|
||||
.split(',')
|
||||
.map((id: string) => id.trim());
|
||||
}
|
||||
|
||||
if (!itemIdsArray.length) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'At least one item ID is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getPluggyClient(req);
|
||||
let accounts: PluggyAccount[] = [];
|
||||
|
||||
// Fetch all accounts and their items with connector info
|
||||
for (const itemId of itemIdsArray) {
|
||||
const partial = await client.fetchAccounts(itemId);
|
||||
|
||||
// For each account, also fetch the item to get connector details
|
||||
for (const account of partial.results) {
|
||||
try {
|
||||
const item = await client.fetchItem(itemId);
|
||||
// Attach item info to account for transformation
|
||||
(account as PluggyAccount).itemData = item;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[PLUGGY ACCOUNTS] Error fetching item ${itemId}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
accounts = accounts.concat(partial.results as PluggyAccount[]);
|
||||
}
|
||||
|
||||
// Transform Pluggy accounts to GenericBankSyncAccount format
|
||||
const transformedAccounts = accounts.map((account: PluggyAccount) => {
|
||||
const institution =
|
||||
account.itemData?.connector?.name ||
|
||||
account.item?.connector?.name ||
|
||||
'Unknown Institution';
|
||||
|
||||
const connectorId =
|
||||
account.itemData?.connector?.id ||
|
||||
account.item?.connector?.id ||
|
||||
account.itemId;
|
||||
|
||||
return {
|
||||
account_id: account.id,
|
||||
name: account.name,
|
||||
institution,
|
||||
balance: account.balance || 0,
|
||||
mask: account.number?.substring(account.number.length - 4),
|
||||
official_name: account.name,
|
||||
orgDomain:
|
||||
account.itemData?.connector?.institutionUrl ||
|
||||
account.item?.connector?.institutionUrl ||
|
||||
null,
|
||||
orgId: connectorId?.toString() || null,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
accounts: transformedAccounts,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[PLUGGY ACCOUNTS] Error:', error);
|
||||
|
||||
// Extract Pluggy error message and code if available
|
||||
let pluggyMessage = 'Unknown error';
|
||||
let pluggyCode: string | number | undefined;
|
||||
|
||||
if (error instanceof Error) {
|
||||
pluggyMessage = error.message;
|
||||
|
||||
// Try to parse Pluggy SDK error format from error message
|
||||
// Pluggy errors often include the error details in the message
|
||||
try {
|
||||
// Check if error has a structured format
|
||||
const errorAny = error as unknown as Record<string, unknown>;
|
||||
if (errorAny.message && typeof errorAny.message === 'string') {
|
||||
pluggyMessage = errorAny.message;
|
||||
}
|
||||
if (errorAny.code !== undefined) {
|
||||
pluggyCode = errorAny.code as string | number;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
const errorResponse: BankSyncError = {
|
||||
error_type: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
error_code: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
status: 'error',
|
||||
reason: pluggyMessage, // Use the Pluggy error message directly
|
||||
};
|
||||
|
||||
// Map HTTP status codes to error types
|
||||
const errorMessageLower = pluggyMessage.toLowerCase();
|
||||
|
||||
if (pluggyCode === 401 || errorMessageLower.includes('401') || errorMessageLower.includes('unauthorized') || errorMessageLower.includes('invalid credentials')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
} else if (pluggyCode === 403 || errorMessageLower.includes('403') || errorMessageLower.includes('forbidden')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.UNAUTHORIZED;
|
||||
errorResponse.error_code = BankSyncErrorCode.UNAUTHORIZED;
|
||||
} else if (pluggyCode === 429 || errorMessageLower.includes('429') || errorMessageLower.includes('rate limit')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.RATE_LIMIT;
|
||||
errorResponse.error_code = BankSyncErrorCode.RATE_LIMIT;
|
||||
} else if (pluggyCode === 400 || errorMessageLower.includes('400') || errorMessageLower.includes('bad request')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_REQUEST;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_REQUEST;
|
||||
} else if (pluggyCode === 404 || errorMessageLower.includes('404') || errorMessageLower.includes('not found')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
errorResponse.error_code = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
} else if (errorMessageLower.includes('network') || errorMessageLower.includes('connect') || errorMessageLower.includes('econnrefused')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.NETWORK_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.NETWORK_ERROR;
|
||||
} else if ((pluggyCode && typeof pluggyCode === 'number' && pluggyCode >= 500) || errorMessageLower.includes('500') || errorMessageLower.includes('502') || errorMessageLower.includes('503')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.SERVER_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.SERVER_ERROR;
|
||||
}
|
||||
|
||||
errorResponse.details = {
|
||||
originalError: pluggyMessage,
|
||||
pluggyCode: pluggyCode,
|
||||
};
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: errorResponse,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /transactions
|
||||
* Fetch transactions from Pluggy.ai
|
||||
* Body: { accountId: string, startDate: string, clientId?: string, clientSecret?: string }
|
||||
*/
|
||||
app.post(
|
||||
'/transactions',
|
||||
async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { accountId, startDate } = req.body;
|
||||
|
||||
if (!accountId) {
|
||||
res.json({
|
||||
status: 'error',
|
||||
error: 'accountId is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getPluggyClient(req);
|
||||
const transactions = await getTransactions(client, accountId, startDate);
|
||||
const account = (await client.fetchAccount(accountId)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
let startingBalance = parseInt(
|
||||
Math.round((account.balance as number) * 100).toString(),
|
||||
);
|
||||
if (account.type === 'CREDIT') {
|
||||
startingBalance = -startingBalance;
|
||||
}
|
||||
const date = getDate(new Date(account.updatedAt as string));
|
||||
|
||||
const balances = [
|
||||
{
|
||||
balanceAmount: {
|
||||
amount: startingBalance,
|
||||
currency: account.currencyCode,
|
||||
},
|
||||
balanceType: 'expected',
|
||||
referenceDate: date,
|
||||
},
|
||||
];
|
||||
|
||||
const all: unknown[] = [];
|
||||
const booked: unknown[] = [];
|
||||
const pending: unknown[] = [];
|
||||
|
||||
for (const trans of transactions) {
|
||||
const transRecord = trans as Record<string, unknown>;
|
||||
const newTrans: Record<string, unknown> = {};
|
||||
|
||||
newTrans.booked = !(transRecord.status === 'PENDING');
|
||||
|
||||
const transactionDate = new Date(transRecord.date as string);
|
||||
|
||||
if (transactionDate < new Date(startDate) && !transRecord.sandbox) {
|
||||
continue;
|
||||
}
|
||||
|
||||
newTrans.date = getDate(transactionDate);
|
||||
newTrans.payeeName = getPayeeName(transRecord);
|
||||
newTrans.notes = transRecord.descriptionRaw || transRecord.description;
|
||||
|
||||
if (account.type === 'CREDIT') {
|
||||
if (transRecord.amountInAccountCurrency) {
|
||||
transRecord.amountInAccountCurrency =
|
||||
(transRecord.amountInAccountCurrency as number) * -1;
|
||||
}
|
||||
|
||||
transRecord.amount = (transRecord.amount as number) * -1;
|
||||
}
|
||||
|
||||
let amountInCurrency =
|
||||
(transRecord.amountInAccountCurrency as number) ??
|
||||
(transRecord.amount as number);
|
||||
amountInCurrency = Math.round(amountInCurrency * 100) / 100;
|
||||
|
||||
newTrans.transactionAmount = {
|
||||
amount: amountInCurrency,
|
||||
currency: transRecord.currencyCode,
|
||||
};
|
||||
|
||||
newTrans.transactionId = transRecord.id;
|
||||
newTrans.sortOrder = transactionDate.getTime();
|
||||
|
||||
delete transRecord.amount;
|
||||
|
||||
const finalTrans = { ...flattenObject(transRecord), ...newTrans };
|
||||
if (newTrans.booked) {
|
||||
booked.push(finalTrans);
|
||||
} else {
|
||||
pending.push(finalTrans);
|
||||
}
|
||||
all.push(finalTrans);
|
||||
}
|
||||
|
||||
const sortFunction = (a: unknown, b: unknown) => {
|
||||
const aRec = a as Record<string, unknown>;
|
||||
const bRec = b as Record<string, unknown>;
|
||||
return (bRec.sortOrder as number) - (aRec.sortOrder as number);
|
||||
};
|
||||
|
||||
const bookedSorted = booked.sort(sortFunction);
|
||||
const pendingSorted = pending.sort(sortFunction);
|
||||
const allSorted = all.sort(sortFunction);
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
balances,
|
||||
startingBalance,
|
||||
transactions: {
|
||||
all: allSorted,
|
||||
booked: bookedSorted,
|
||||
pending: pendingSorted,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[PLUGGY TRANSACTIONS] Error:', error);
|
||||
|
||||
// Extract Pluggy error message and code if available
|
||||
let pluggyMessage = 'Unknown error';
|
||||
let pluggyCode: string | number | undefined;
|
||||
|
||||
if (error instanceof Error) {
|
||||
pluggyMessage = error.message;
|
||||
|
||||
// Try to parse Pluggy SDK error format from error message
|
||||
try {
|
||||
const errorAny = error as unknown as Record<string, unknown>;
|
||||
if (errorAny.message && typeof errorAny.message === 'string') {
|
||||
pluggyMessage = errorAny.message;
|
||||
}
|
||||
if (errorAny.code !== undefined) {
|
||||
pluggyCode = errorAny.code as string | number;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
const errorResponse: BankSyncError = {
|
||||
error_type: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
error_code: BankSyncErrorCode.UNKNOWN_ERROR,
|
||||
status: 'error',
|
||||
reason: pluggyMessage, // Use the Pluggy error message directly
|
||||
};
|
||||
|
||||
// Map HTTP status codes to error types
|
||||
const errorMessageLower = pluggyMessage.toLowerCase();
|
||||
|
||||
if (pluggyCode === 401 || errorMessageLower.includes('401') || errorMessageLower.includes('unauthorized') || errorMessageLower.includes('invalid credentials')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_CREDENTIALS;
|
||||
} else if (pluggyCode === 403 || errorMessageLower.includes('403') || errorMessageLower.includes('forbidden')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.UNAUTHORIZED;
|
||||
errorResponse.error_code = BankSyncErrorCode.UNAUTHORIZED;
|
||||
} else if (pluggyCode === 404 || errorMessageLower.includes('404') || errorMessageLower.includes('not found')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
errorResponse.error_code = BankSyncErrorCode.ACCOUNT_NOT_FOUND;
|
||||
} else if (pluggyCode === 429 || errorMessageLower.includes('429') || errorMessageLower.includes('rate limit')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.RATE_LIMIT;
|
||||
errorResponse.error_code = BankSyncErrorCode.RATE_LIMIT;
|
||||
} else if (pluggyCode === 400 || errorMessageLower.includes('400') || errorMessageLower.includes('bad request')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.INVALID_REQUEST;
|
||||
errorResponse.error_code = BankSyncErrorCode.INVALID_REQUEST;
|
||||
} else if (errorMessageLower.includes('network') || errorMessageLower.includes('connect') || errorMessageLower.includes('econnrefused')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.NETWORK_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.NETWORK_ERROR;
|
||||
} else if ((pluggyCode && typeof pluggyCode === 'number' && pluggyCode >= 500) || errorMessageLower.includes('500') || errorMessageLower.includes('502') || errorMessageLower.includes('503')) {
|
||||
errorResponse.error_type = BankSyncErrorCode.SERVER_ERROR;
|
||||
errorResponse.error_code = BankSyncErrorCode.SERVER_ERROR;
|
||||
}
|
||||
|
||||
errorResponse.details = {
|
||||
originalError: pluggyMessage,
|
||||
pluggyCode: pluggyCode,
|
||||
};
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
data: errorResponse,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Helper functions
|
||||
async function getTransactions(
|
||||
client: PluggyClient,
|
||||
accountId: string,
|
||||
startDate: string,
|
||||
): Promise<unknown[]> {
|
||||
let transactions: unknown[] = [];
|
||||
let result = await getTransactionsByAccountId(
|
||||
client,
|
||||
accountId,
|
||||
startDate,
|
||||
500,
|
||||
1,
|
||||
);
|
||||
transactions = transactions.concat(result.results);
|
||||
const totalPages = result.totalPages;
|
||||
let currentPage = result.page;
|
||||
|
||||
while (currentPage !== totalPages) {
|
||||
result = await getTransactionsByAccountId(
|
||||
client,
|
||||
accountId,
|
||||
startDate,
|
||||
500,
|
||||
currentPage + 1,
|
||||
);
|
||||
transactions = transactions.concat(result.results);
|
||||
currentPage = result.page;
|
||||
}
|
||||
|
||||
return transactions;
|
||||
}
|
||||
|
||||
async function getTransactionsByAccountId(
|
||||
client: PluggyClient,
|
||||
accountId: string,
|
||||
startDate: string,
|
||||
pageSize: number,
|
||||
page: number,
|
||||
): Promise<{ results: unknown[]; totalPages: number; page: number }> {
|
||||
const account = (await client.fetchAccount(accountId)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
// Sandbox account handling
|
||||
const sandboxAccount = account.owner === 'John Doe';
|
||||
const fromDate = sandboxAccount ? '2000-01-01' : startDate;
|
||||
|
||||
const transactions = await client.fetchTransactions(accountId, {
|
||||
from: fromDate,
|
||||
pageSize,
|
||||
page,
|
||||
});
|
||||
|
||||
if (sandboxAccount) {
|
||||
const mappedResults = transactions.results.map(
|
||||
(t: Record<string, unknown>) => ({
|
||||
...t,
|
||||
sandbox: true,
|
||||
}),
|
||||
);
|
||||
transactions.results =
|
||||
mappedResults as unknown as typeof transactions.results;
|
||||
}
|
||||
|
||||
return transactions;
|
||||
}
|
||||
|
||||
function getDate(date: Date): string {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function flattenObject(
|
||||
obj: Record<string, unknown>,
|
||||
prefix = '',
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const newKey = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
Object.assign(
|
||||
result,
|
||||
flattenObject(value as Record<string, unknown>, newKey),
|
||||
);
|
||||
} else {
|
||||
result[newKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getPayeeName(trans: Record<string, unknown>): string {
|
||||
const merchant = trans.merchant as Record<string, string> | undefined;
|
||||
if (merchant && (merchant.name || merchant.businessName)) {
|
||||
return merchant.name || merchant.businessName || '';
|
||||
}
|
||||
|
||||
const paymentData = trans.paymentData as
|
||||
| Record<string, Record<string, unknown>>
|
||||
| undefined;
|
||||
if (paymentData) {
|
||||
const { receiver, payer } = paymentData;
|
||||
|
||||
if (trans.type === 'DEBIT' && receiver) {
|
||||
const receiverData = receiver as Record<string, unknown>;
|
||||
const docNum = receiverData.documentNumber as
|
||||
| Record<string, string>
|
||||
| undefined;
|
||||
return (receiverData.name as string) || docNum?.value || '';
|
||||
}
|
||||
|
||||
if (trans.type === 'CREDIT' && payer) {
|
||||
const payerData = payer as Record<string, unknown>;
|
||||
const docNum = payerData.documentNumber as
|
||||
| Record<string, string>
|
||||
| undefined;
|
||||
return (payerData.name as string) || docNum?.value || '';
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
console.log('Pluggy.ai Bank Sync Plugin loaded');
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user