Compare commits

..

1 Commits

Author SHA1 Message Date
Claude
d34627b3f1 [AI] Improve release-note-generator to auto-create draft PRs
Rework the CLI tool so contributors no longer need to know the PR number
upfront. The new flow:

1. Asks for username, category, and summary first
2. Checks for an existing PR on the current branch
3. If no PR exists and `gh` CLI is available, offers to create a draft PR
   automatically (pushes the branch and runs `gh pr create --draft`)
4. Falls back to manual PR number entry

This eliminates the clunky workflow of having to create a draft PR
separately just to get the PR number for the release notes filename.

https://claude.ai/code/session_014ncE7UnSdms47J3Xwg6DYG
2026-03-07 21:27:58 +00:00
1691 changed files with 17257 additions and 39705 deletions

View File

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

View File

@@ -16,19 +16,14 @@ if (!token || !repo || !issueNumber || !summaryDataJson || !category) {
const [owner, repoName] = repo.split('/');
const octokit = new Octokit({ auth: token });
const VALID_CATEGORIES = [
'Features',
'Bugfixes',
'Enhancements',
'Maintenance',
];
const GITHUB_USERNAME_RE =
/^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$/;
async function createReleaseNotesFile() {
try {
const summaryData = JSON.parse(summaryDataJson);
console.log('Debug - Category value:', category);
console.log('Debug - Category type:', typeof category);
console.log('Debug - Category JSON stringified:', JSON.stringify(category));
if (!summaryData) {
console.log('No summary data available, cannot create file');
return;
@@ -39,62 +34,26 @@ async function createReleaseNotesFile() {
return;
}
// Normalize category - strip surrounding quotes and validate against allow-list
// Create file content - ensure category is not quoted
const cleanCategory =
typeof category === 'string'
? category.replace(/^["']|["']$/g, '')
: category;
if (!VALID_CATEGORIES.includes(cleanCategory)) {
console.log(
`Invalid category "${cleanCategory}". Must be one of: ${VALID_CATEGORIES.join(', ')}`,
);
return;
}
// Validate author is a plausible GitHub username
const author = String(summaryData.author || '');
if (!GITHUB_USERNAME_RE.test(author)) {
console.log(
`Invalid author "${author}", aborting release notes creation`,
);
return;
}
// Normalize summary: collapse whitespace to a single line so it cannot
// introduce extra YAML frontmatter or break the markdown structure.
const cleanSummary = String(summaryData.summary || '')
.replace(/\s+/g, ' ')
.trim();
if (!cleanSummary) {
console.log('Empty summary, aborting release notes creation');
return;
}
// Validate PR number - must be a positive integer. The value comes from
// the GitHub API, but we harden it because it's used to build a file path
// and a commit message.
const validatedPrNumber = Number(summaryData.prNumber);
if (!Number.isInteger(validatedPrNumber) || validatedPrNumber <= 0) {
console.log(
`Invalid PR number "${summaryData.prNumber}", aborting release notes creation`,
);
return;
}
console.log('Debug - Clean category:', cleanCategory);
const fileContent = `---
category: ${cleanCategory}
authors: [${author}]
authors: [${summaryData.author}]
---
${cleanSummary}
${summaryData.summary}
`;
const fileName = `upcoming-release-notes/${validatedPrNumber}.md`;
const fileName = `upcoming-release-notes/${summaryData.prNumber}.md`;
console.log(
`Creating release notes file: ${fileName} (category: ${cleanCategory}, author: ${author})`,
);
console.log(`Creating release notes file: ${fileName}`);
console.log('File content:');
console.log(fileContent);
// Get PR info
const { data: pr } = await octokit.rest.pulls.get({
@@ -116,7 +75,7 @@ ${cleanSummary}
owner: headOwner,
repo: headRepo,
path: fileName,
message: `Add release notes for PR #${validatedPrNumber}`,
message: `Add release notes for PR #${summaryData.prNumber}`,
content: Buffer.from(fileContent).toString('base64'),
branch: prBranch,
committer: {

View File

@@ -25,6 +25,8 @@ try {
process.exit(0);
}
console.log('CodeRabbit comment body:', commentBody);
const data = JSON.stringify({
model: 'gpt-4o-mini',
messages: [

View File

@@ -39,22 +39,6 @@ async function getPRDetails() {
console.log('- Base Branch:', pr.base.ref);
console.log('- Head Branch:', pr.head.ref);
// Fetch all changed files to detect docs-only PRs
const files = await octokit.paginate(octokit.rest.pulls.listFiles, {
owner,
repo: repoName,
pull_number: issueNumber,
per_page: 100,
});
const changedFiles = files.map(f => f.filename);
const isDocsOnly =
changedFiles.length > 0 &&
changedFiles.every(file => file.startsWith('packages/docs/'));
console.log('- Changed Files:', changedFiles.length);
console.log('- Is Docs Only:', isDocsOnly);
const result = {
number: pr.number,
author: pr.user.login,
@@ -63,31 +47,11 @@ async function getPRDetails() {
headBranch: pr.head.ref,
};
let eligible = true;
if (pr.base.ref !== 'master') {
console.log(
'PR does not target master branch, skipping release notes generation',
);
eligible = false;
} else if (pr.head.ref.startsWith('release/')) {
console.log(
'PR head branch is a release branch, skipping release notes generation',
);
eligible = false;
} else if (isDocsOnly) {
console.log(
'PR only changes documentation, skipping release notes generation',
);
eligible = false;
}
setOutput('result', JSON.stringify(result));
setOutput('eligible', JSON.stringify(eligible));
} catch (error) {
console.log('Error getting PR details:', error.message);
console.log('Stack:', error.stack);
setOutput('result', 'null');
setOutput('eligible', 'false');
process.exit(1);
}
}
@@ -96,6 +60,5 @@ getPRDetails().catch(error => {
console.log('Unhandled error:', error.message);
console.log('Stack:', error.stack);
setOutput('result', 'null');
setOutput('eligible', 'false');
process.exit(1);
});

View File

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

View File

@@ -68,6 +68,7 @@ ignore$
^\Qsrc/\E$
^\Qstatic/\E$
^\Q.github/\E$
(?:^|/)package(?:-lock|)\.json$
(?:^|/)yarn\.lock$
(?:^|/)(?i)docusaurus.config.js
(?:^|/)(?i)README.md

View File

@@ -2,9 +2,7 @@ Abanca
ABNAMRO
ABNANL
Activo
actualrc
AESUDEF
ajv
ALZEY
Anglais
ANZ
@@ -112,6 +110,7 @@ KBCBE
Keycloak
Khurozov
KORT
KRW
Kreditbank
lage
LHV
@@ -126,7 +125,6 @@ Moldovan
murmurhash
NETWORKDAYS
nginx
nodenext
OIDC
Okabe
overbudgeted
@@ -134,8 +132,6 @@ overbudgeting
oxc
Paribas
passwordless
PAYPAL
picomatch
pluggyai
Poste
PPABPLPK
@@ -176,11 +172,8 @@ tada
taskbar
templating
THB
TIMEFRAME
touchscreen
triaging
tsgo
TWD
UAH
ubuntu
undici

View File

@@ -1,17 +0,0 @@
name: Check release notes
description: Validate that a PR includes a properly formatted release note file
runs:
using: composite
steps:
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
- name: Install dependencies
shell: bash
run: yarn workspaces focus @actual-app/ci-actions
- name: Check release notes
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
shell: bash
run: node packages/ci-actions/bin/release-notes-check.mjs

View File

@@ -1,17 +0,0 @@
name: Generate release notes
description: Generate release documentation from release note files
runs:
using: composite
steps:
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
- name: Install dependencies
shell: bash
run: yarn workspaces focus @actual-app/ci-actions
- name: Generate release notes
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
run: node packages/ci-actions/bin/release-notes-generate.mjs

View File

@@ -15,7 +15,7 @@ runs:
using: composite
steps:
- name: Install node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 22
- name: Install yarn
@@ -27,7 +27,7 @@ runs:
run: echo "version=$(node -v)" >> "$GITHUB_OUTPUT"
shell: bash
- name: Cache
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
id: cache
with:
path: ${{ format('{0}/**/node_modules', inputs.working-directory) }}
@@ -36,7 +36,7 @@ runs:
run: mkdir -p ${{ format('{0}/.lage', inputs.working-directory) }}
shell: bash
- name: Cache Lage
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ format('{0}/.lage', inputs.working-directory) }}
key: lage-${{ runner.os }}-${{ github.sha }}
@@ -48,7 +48,7 @@ runs:
shell: bash
if: steps.cache.outputs.cache-hit != 'true'
- name: Download translations
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
repository: actualbudget/translations
path: ${{ inputs.working-directory }}/packages/desktop-client/locale

View File

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

View File

@@ -35,11 +35,7 @@ const CONFIG = {
'release-notes/**/*',
'upcoming-release-notes/**/*',
],
DOCS_FILES_PATTERNS: [
'packages/docs/**/*',
'!packages/docs/package.json',
'.github/actions/docs-spelling/*',
],
DOCS_FILES_PATTERN: 'packages/docs/**/*',
};
/**
@@ -61,29 +57,78 @@ function parseReleaseNotesCategory(content) {
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 {string|null} releaseNoteBlobSha - The blob SHA of the release notes file, or null if not found.
* @returns {Promise<Object>} Object with category and points.
* @param {number} prNumber - PR number.
* @param {Date} monthEnd - The end date of the month to use as base revision.
* @returns {Promise<Object>} Object with category and points, or null if error.
*/
async function getPRCategoryAndPoints(
octokit,
owner,
repo,
releaseNoteBlobSha,
prNumber,
monthEnd,
) {
try {
if (releaseNoteBlobSha) {
const { data: blob } = await octokit.git.getBlob({
owner,
repo,
file_sha: releaseNoteBlobSha,
});
const releaseNotesPath = `upcoming-release-notes/${prNumber}.md`;
const content = Buffer.from(blob.content, 'base64').toString('utf-8');
try {
// Get the last commit of the month to use as base revision
const commitSha = await getLastCommitBeforeDate(
octokit,
owner,
repo,
monthEnd,
);
// Try to read the release notes file from the last commit of the month
const { data: fileContent } = await octokit.repos.getContent({
owner,
repo,
path: releaseNotesPath,
ref: commitSha || undefined, // Use commit SHA if available, otherwise default branch
});
if (fileContent.content) {
// Decode base64 content
const content = Buffer.from(fileContent.content, 'base64').toString(
'utf-8',
);
const category = parseReleaseNotesCategory(content);
const tier = CONFIG.PR_CONTRIBUTION_POINTS.find(e =>
e.categories.includes(category),
@@ -231,25 +276,13 @@ async function countContributorPoints() {
),
);
const isDocsFile = file => {
const positivePatterns = CONFIG.DOCS_FILES_PATTERNS.filter(
p => !p.startsWith('!'),
);
const negativePatterns = CONFIG.DOCS_FILES_PATTERNS.filter(p =>
p.startsWith('!'),
);
return (
positivePatterns.some(p =>
minimatch(file.filename, p, { dot: true }),
) &&
negativePatterns.every(p =>
minimatch(file.filename, p, { dot: true }),
)
);
};
const docsFiles = filteredFiles.filter(isDocsFile);
const codeFiles = filteredFiles.filter(file => !isDocsFile(file));
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,
@@ -296,15 +329,12 @@ async function countContributorPoints() {
// Award points to PR author if they are a core maintainer
const prAuthor = pr.user?.login;
if (prAuthor && orgMemberLogins.has(prAuthor)) {
const releaseNoteFile = modifiedFiles.find(
file =>
file.filename === `upcoming-release-notes/${pr.number}.md`,
);
const categoryAndPoints = await getPRCategoryAndPoints(
octokit,
owner,
repo,
releaseNoteFile?.sha ?? null,
pr.number,
until,
);
if (categoryAndPoints) {

View File

@@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -42,7 +42,11 @@ jobs:
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
- name: Check if release notes file already exists
if: steps.pr-details.outputs.eligible == 'true'
if: >-
steps.check-first-comment.outputs.result == 'true' &&
steps.pr-details.outputs.result != 'null' &&
fromJSON(steps.pr-details.outputs.result).baseBranch == 'master' &&
!startsWith(fromJSON(steps.pr-details.outputs.result).headBranch, 'release/')
id: check-release-notes-exists
run: node .github/actions/ai-generated-release-notes/check-release-notes-exists.js
env:
@@ -52,7 +56,7 @@ jobs:
PR_DETAILS: ${{ steps.pr-details.outputs.result }}
- name: Generate summary with OpenAI
if: steps.check-release-notes-exists.outputs.result == 'false'
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false'
id: generate-summary
run: node .github/actions/ai-generated-release-notes/generate-summary.js
env:
@@ -61,7 +65,7 @@ jobs:
PR_DETAILS: ${{ steps.pr-details.outputs.result }}
- name: Determine category with OpenAI
if: steps.generate-summary.outputs.result != 'null' && steps.generate-summary.outputs.result != ''
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false' && steps.generate-summary.outputs.result != 'null'
id: determine-category
run: node .github/actions/ai-generated-release-notes/determine-category.js
env:
@@ -71,7 +75,7 @@ jobs:
SUMMARY_DATA: ${{ steps.generate-summary.outputs.result }}
- name: Create and commit release notes file via GitHub API
if: steps.determine-category.outputs.result != 'null' && steps.determine-category.outputs.result != ''
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 }}
@@ -81,7 +85,7 @@ jobs:
CATEGORY: ${{ steps.determine-category.outputs.result }}
- name: Comment on PR
if: steps.determine-category.outputs.result != 'null' && steps.determine-category.outputs.result != ''
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/comment-on-pr.js
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

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

View File

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

View File

@@ -12,20 +12,10 @@ concurrency:
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
jobs:
constraints:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Check dependency version consistency
run: yarn constraints
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -35,7 +25,7 @@ jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -45,7 +35,7 @@ jobs:
validate-cli:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -57,31 +47,21 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Test
run: yarn test
check-gh-actions:
runs-on: ubuntu-latest
permissions:
security-events: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
migrations:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
uses: ./.github/actions/setup
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
download-translations: 'false'
node-version: 22
- name: Check migrations
run: yarn workspace @actual-app/ci-actions tsx bin/check-migrations.ts
run: node ./.github/actions/check-migrations.js

View File

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

View File

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

View File

@@ -1,100 +0,0 @@
name: Cut release branch
on:
schedule:
# 17:00 UTC on the 25th of each month
- cron: '0 17 25 * *'
workflow_dispatch:
inputs:
ref:
description: 'Commit or branch to release'
required: true
default: 'master'
version:
description: 'Version number for the release (optional)'
required: false
default: ''
release-date:
description: 'Expected release date, YYYY-MM-DD (optional)'
required: false
default: ''
permissions:
contents: write
pull-requests: write
jobs:
cut-release-branch:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.inputs.ref || 'master' }}
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Bump package versions
id: bump_package_versions
shell: bash
env:
INPUT_VERSION: ${{ github.event.inputs.version }}
run: |
declare -A packages=(
[web]="desktop-client"
[electron]="desktop-electron"
[sync]="sync-server"
[api]="api"
[cli]="cli"
[core]="loot-core"
)
declare -A new_versions
for key in "${!packages[@]}"; do
pkg="${packages[$key]}"
if [[ -n "$INPUT_VERSION" ]]; then
version=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts \
--package-json "./packages/$pkg/package.json" \
--version "$INPUT_VERSION" \
--update)
else
version=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts \
--package-json "./packages/$pkg/package.json" \
--type auto \
--update)
fi
new_versions[$key]="$version"
done
echo "version=${new_versions[web]}" >> "$GITHUB_OUTPUT"
- name: Compute release date
id: release_date
shell: bash
env:
INPUT_DATE: ${{ github.event.inputs['release-date'] }}
run: |
if [[ -n "$INPUT_DATE" ]]; then
echo "date=$INPUT_DATE" >> "$GITHUB_OUTPUT"
else
# default to the 1st of next month
echo "date=$(date -d '+1 month' '+%Y-%m-01')" >> "$GITHUB_OUTPUT"
fi
- name: Create release branch and PR
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
token: ${{ secrets.ACTIONS_UPDATE_TOKEN }}
commit-message: '🔖 (${{ steps.bump_package_versions.outputs.version }})'
title: '🔖 (${{ steps.bump_package_versions.outputs.version }})'
body: |
Generated by [cut-release-branch.yml](../tree/master/.github/workflows/cut-release-branch.yml)
<!-- release-date:${{ steps.release_date.outputs.date }} -->
branch: 'release/${{ steps.bump_package_versions.outputs.version }}'
base: master

View File

@@ -36,17 +36,17 @@ jobs:
matrix:
os: [ubuntu, alpine]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Docker meta
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
with:
# Push to both Docker Hub and Github Container Registry
images: ${{ env.IMAGES }}
@@ -54,14 +54,14 @@ jobs:
tags: ${{ env.TAGS }}
- name: Login to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
if: github.event_name != 'pull_request' && !github.event.repository.fork
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
if: github.event_name != 'pull_request'
with:
registry: ghcr.io
@@ -76,7 +76,7 @@ jobs:
run: yarn build:server
- name: Build image for testing
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
push: false
@@ -93,7 +93,7 @@ jobs:
# This will use the cache from the earlier build step and not rebuild the image
# https://docs.docker.com/build/ci/github-actions/test-before-push/
- name: Build and push images
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
push: ${{ github.event_name != 'pull_request' }}

View File

@@ -28,17 +28,17 @@ jobs:
name: Build Docker image
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Docker meta
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
with:
# Push to both Docker Hub and Github Container Registry
images: ${{ env.IMAGES }}
@@ -48,7 +48,7 @@ jobs:
- name: Docker meta for Alpine image
id: alpine-meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
with:
images: ${{ env.IMAGES }}
# Automatically update :latest
@@ -58,13 +58,13 @@ jobs:
tags: ${{ env.TAGS }}
- name: Login to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -78,7 +78,7 @@ jobs:
run: yarn build:server
- name: Build and push ubuntu image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
push: true
@@ -87,7 +87,7 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
- name: Build and push alpine image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
push: true

View File

@@ -79,12 +79,12 @@ jobs:
steps:
- name: check-spelling
id: spelling
uses: check-spelling/check-spelling@cfb6f7e75bbfc89c71eaa30366d0c166f1bd9c8c # main
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@92453f59cac8985482dfc301a8ece4c6d7de8a0c # prerelease
spell_check_this: check-spelling/spell-check-this@prerelease
post_comment: 0
use_magic_file: 1
experimental_apply_changes_via_bot: 1
@@ -114,10 +114,10 @@ jobs:
if: (success() || failure()) && needs.spelling.outputs.followup && github.event_name == 'push'
steps:
- name: comment
uses: check-spelling/check-spelling@cfb6f7e75bbfc89c71eaa30366d0c166f1bd9c8c # main
uses: check-spelling/check-spelling@main
with:
checkout: true
spell_check_this: check-spelling/spell-check-this@92453f59cac8985482dfc301a8ece4c6d7de8a0c # prerelease
spell_check_this: check-spelling/spell-check-this@prerelease
task: ${{ needs.spelling.outputs.followup }}
config: .github/actions/docs-spelling
@@ -131,10 +131,10 @@ jobs:
if: (success() || failure()) && needs.spelling.outputs.followup && contains(github.event_name, 'pull_request')
steps:
- name: comment
uses: check-spelling/check-spelling@cfb6f7e75bbfc89c71eaa30366d0c166f1bd9c8c # main
uses: check-spelling/check-spelling@main
with:
checkout: true
spell_check_this: check-spelling/spell-check-this@92453f59cac8985482dfc301a8ece4c6d7de8a0c # prerelease
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
@@ -156,7 +156,7 @@ jobs:
cancel-in-progress: false
steps:
- name: apply spelling updates
uses: check-spelling/check-spelling@cfb6f7e75bbfc89c71eaa30366d0c166f1bd9c8c # main
uses: check-spelling/check-spelling@main
with:
experimental_apply_changes_via_bot: 1
checkout: true

View File

@@ -30,9 +30,9 @@ jobs:
matrix:
shard: [1, 2, 3, 4, 5]
container:
image: mcr.microsoft.com/playwright:v1.59.1-jammy
image: mcr.microsoft.com/playwright:v1.58.2-jammy
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -41,7 +41,7 @@ jobs:
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Run E2E Tests
run: yarn e2e --shard=${{ matrix.shard }}/5
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
if: always()
with:
name: desktop-client-test-results-shard-${{ matrix.shard }}
@@ -53,25 +53,19 @@ jobs:
name: Functional Desktop App
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.59.1-jammy
image: mcr.microsoft.com/playwright:v1.58.2-jammy
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Trust the repository directory
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
# Build tools are needed to rebuild native modules like better-sqlite3 used by the Desktop app, which is required to run E2E tests on the Desktop app.
- name: Install build tools
run: apt-get update && apt-get install -y build-essential python3
- name: Run Desktop app E2E Tests
run: |
yarn rebuild-electron
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
if: always()
with:
name: desktop-app-test-results
@@ -87,16 +81,16 @@ jobs:
matrix:
shard: [1, 2, 3, 4, 5]
container:
image: mcr.microsoft.com/playwright:v1.59.1-jammy
image: mcr.microsoft.com/playwright:v1.58.2-jammy
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Run VRT Tests
run: yarn vrt --shard=${{ matrix.shard }}/5
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
if: always()
with:
name: vrt-blob-report-${{ matrix.shard }}
@@ -110,13 +104,13 @@ jobs:
runs-on: ubuntu-latest
if: ${{ !cancelled() }}
container:
image: mcr.microsoft.com/playwright:v1.59.1-jammy
image: mcr.microsoft.com/playwright:v1.58.2-jammy
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
- name: Download all blob reports
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
path: packages/desktop-client/all-blob-reports
pattern: vrt-blob-report-*
@@ -124,7 +118,7 @@ jobs:
- name: Merge reports
id: merge-reports
run: yarn workspace @actual-app/web run playwright merge-reports --reporter html ./all-blob-reports
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
id: playwright-report-vrt
with:
name: html-report--attempt-${{ github.run_attempt }}
@@ -140,7 +134,7 @@ jobs:
echo "${{ steps.playwright-report-vrt.outputs.artifact-url }}" > vrt-metadata/artifact-url.txt
- name: Upload VRT metadata
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: vrt-comment-metadata
path: vrt-metadata/

View File

@@ -18,7 +18,7 @@ jobs:
if: github.event.workflow_run.event == 'pull_request'
steps:
- name: Download VRT metadata
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
@@ -53,7 +53,7 @@ jobs:
- name: Comment on PR with VRT report link
if: steps.metadata.outputs.should_comment == 'true'
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
with:
number: ${{ steps.metadata.outputs.pr_number }}
header: vrt-comment

View File

@@ -29,7 +29,7 @@ jobs:
- macos-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- if: ${{ startsWith(matrix.os, 'windows') }}
run: pip.exe install setuptools
- if: ${{ ! startsWith(matrix.os, 'windows') }}
@@ -74,7 +74,7 @@ jobs:
if: ${{ ! startsWith(matrix.os, 'macos') }}
run: ./bin/package-electron
- name: Upload Build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: actual-electron-${{ matrix.os }}
path: |
@@ -85,13 +85,13 @@ jobs:
packages/desktop-electron/dist/*.flatpak
- name: Upload Windows Store Build
if: ${{ startsWith(matrix.os, 'windows') }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: actual-electron-${{ matrix.os }}-appx
path: |
packages/desktop-electron/dist/*.appx
- name: Add to new release
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
with:
draft: true
body: |
@@ -126,7 +126,7 @@ jobs:
Install-Module -Name StoreBroker -AcceptLicense -Force -Scope CurrentUser -Verbose
- name: Download Microsoft Store artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: actual-electron-windows-latest-appx

View File

@@ -33,7 +33,7 @@ jobs:
- macos-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- if: ${{ startsWith(matrix.os, 'windows') }}
run: pip.exe install setuptools
- if: ${{ ! startsWith(matrix.os, 'windows') }}
@@ -42,8 +42,6 @@ jobs:
python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install setuptools
- name: Set up environment
uses: ./.github/actions/setup
- if: ${{ startsWith(matrix.os, 'ubuntu') }}
name: Setup Flatpak dependencies
run: |
@@ -58,63 +56,65 @@ jobs:
METAINFO_FILE="packages/desktop-electron/extra-resources/linux/com.actualbudget.actual.metainfo.xml"
TODAY=$(date +%Y-%m-%d)
VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-electron/package.json --type nightly)
VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-electron/package.json --type nightly)
sed -i "s/%RELEASE_VERSION%/$VERSION/g; s/%RELEASE_DATE%/$TODAY/g" "$METAINFO_FILE"
flatpak run --command=flatpak-builder-lint org.flatpak.Builder appstream "$METAINFO_FILE"
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Electron
run: ./bin/package-electron
- name: Upload Linux x64 AppImage
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-mac-arm64.dmg
if-no-files-found: ignore
@@ -122,7 +122,7 @@ jobs:
- name: Upload Windows Store Build
if: ${{ startsWith(matrix.os, 'windows') }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: actual-electron-${{ matrix.os }}-appx
path: |

View File

@@ -25,7 +25,7 @@ jobs:
if: github.event.pull_request.head.repo.full_name != github.repository
steps:
- name: Post welcome comment
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
number: ${{ github.event.pull_request.number }}

View File

@@ -0,0 +1,60 @@
name: Generate release PR
on:
workflow_dispatch:
inputs:
ref:
description: 'Commit or branch to release'
required: true
default: 'master'
version:
description: 'Version number for the release (optional)'
required: false
default: ''
jobs:
generate-release-pr:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.inputs.ref }}
- name: Bump package versions
id: bump_package_versions
shell: bash
run: |
declare -A packages=(
[web]="desktop-client"
[electron]="desktop-electron"
[sync]="sync-server"
[api]="api"
)
for key in "${!packages[@]}"; do
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)
else
version=$(node ./packages/ci-actions/bin/get-next-package-version.js \
--package-json "./packages/$pkg/package.json" \
--type auto \
--update)
fi
eval "NEW_${key^^}_VERSION=\"$version\""
done
echo "version=$NEW_WEB_VERSION" >> "$GITHUB_OUTPUT"
- name: Create PR
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ secrets.ACTIONS_UPDATE_TOKEN }}
commit-message: '🔖 (${{ steps.bump_package_versions.outputs.version }})'
title: '🔖 (${{ steps.bump_package_versions.outputs.version }})'
body: 'Generated by [generate-release-pr.yml](../tree/master/.github/workflows/generate-release-pr.yml)'
branch: 'release/v${{ steps.bump_package_versions.outputs.version }}'

View File

@@ -12,7 +12,7 @@ jobs:
if: github.repository == 'actualbudget/actual'
steps:
- name: Check out main repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
path: actual
- name: Set up environment
@@ -27,23 +27,12 @@ jobs:
- name: Configure i18n client
run: |
pip install wlc
- name: Configure Weblate API credentials
env:
WEBLATE_API_KEY: ${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}
run: |
# Write the API key to wlc's config file instead of passing it on
# the command line, so the secret doesn't appear in process listings.
mkdir -p "$HOME/.config"
umask 077
cat > "$HOME/.config/weblate" <<EOF
[keys]
https://hosted.weblate.org/api/ = ${WEBLATE_API_KEY}
EOF
- name: Lock translations
run: |
wlc \
--url https://hosted.weblate.org/api/ \
--key "${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}" \
lock \
actualbudget/actual
@@ -51,10 +40,11 @@ jobs:
run: |
wlc \
--url https://hosted.weblate.org/api/ \
--key "${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}" \
push \
actualbudget/actual
- name: Check out updated translations
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ssh-key: ${{ secrets.STRING_IMPORT_DEPLOY_KEY }}
repository: actualbudget/translations
@@ -83,6 +73,7 @@ jobs:
run: |
wlc \
--url https://hosted.weblate.org/api/ \
--key "${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}" \
pull \
actualbudget/actual
@@ -91,5 +82,6 @@ jobs:
run: |
wlc \
--url https://hosted.weblate.org/api/ \
--key "${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}" \
unlock \
actualbudget/actual

View File

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

View File

@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Repository Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
@@ -34,11 +34,10 @@ jobs:
- name: Deploy to Netlify
id: netlify_deploy
env:
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_API_TOKEN }}
run: |
netlify deploy \
--dir packages/desktop-client/build \
--site ${{ secrets.NETLIFY_SITE_ID }} \
--auth ${{ secrets.NETLIFY_API_TOKEN }} \
--filter @actual-app/web \
--prod

View File

@@ -92,7 +92,7 @@ jobs:
echo "APPIMAGE_ARM64_SHA256=$APPIMAGE_ARM64_SHA256" >> "$GITHUB_ENV"
- name: Checkout Flathub repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v6
with:
repository: flathub/com.actualbudget.actual
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
@@ -113,7 +113,7 @@ jobs:
cat com.actualbudget.actual.yml
- name: Create PR in Flathub repo
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
commit-message: 'Update Actual flatpak to version ${{ steps.resolve_version.outputs.version }}'

View File

@@ -28,7 +28,7 @@ jobs:
runs-on: ${{ matrix.os }}
if: github.event.repository.fork == false
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- if: ${{ startsWith(matrix.os, 'windows') }}
run: pip.exe install setuptools
@@ -39,9 +39,6 @@ jobs:
source .venv/bin/activate
python3 -m pip install setuptools
- name: Set up environment
uses: ./.github/actions/setup
- if: ${{ startsWith(matrix.os, 'ubuntu') }}
name: Setup Flatpak dependencies
run: |
@@ -56,14 +53,16 @@ jobs:
METAINFO_FILE="packages/desktop-electron/extra-resources/linux/com.actualbudget.actual.metainfo.xml"
TODAY=$(date +%Y-%m-%d)
VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-electron/package.json --type nightly)
VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-electron/package.json --type nightly)
sed -i "s/%RELEASE_VERSION%/$VERSION/g; s/%RELEASE_DATE%/$TODAY/g" "$METAINFO_FILE"
flatpak run --command=flatpak-builder-lint org.flatpak.Builder appstream "$METAINFO_FILE"
- name: Set up environment
uses: ./.github/actions/setup
- name: Update package versions
run: |
# Get new nightly version
NEW_DESKTOP_APP_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-electron/package.json --type nightly)
NEW_DESKTOP_APP_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-electron/package.json --type nightly)
# Set package version
npm version $NEW_DESKTOP_APP_VERSION --no-git-tag-version --workspace=desktop-electron --no-workspaces-update
@@ -83,49 +82,49 @@ jobs:
run: ./bin/package-electron
- name: Upload Linux x64 AppImage
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-mac-arm64.dmg
if-no-files-found: ignore
@@ -133,7 +132,7 @@ jobs:
- name: Upload Windows Store Build
if: ${{ startsWith(matrix.os, 'windows') }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: actual-electron-${{ matrix.os }}-appx
path: |

View File

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

View File

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

View File

@@ -3,10 +3,6 @@ name: Release notes
on:
pull_request:
permissions:
contents: write
pull-requests: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
@@ -15,31 +11,11 @@ jobs:
release-notes:
runs-on: ubuntu-latest
steps:
- name: Check if triggered by bot
id: bot-check
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { data: commit } = await github.rest.git.getCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.payload.pull_request.head.sha,
});
const skip = commit.author.name === 'github-actions[bot]'
&& commit.message.startsWith('Generate release notes');
console.log(`Head commit by "${commit.author.name}": ${commit.message.split('\n')[0]}`);
console.log(`Skip: ${skip}`);
core.setOutput('skip', String(skip));
- name: Checkout
if: steps.bot-check.outputs.skip != 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
token: ${{ secrets.ACTIONS_UPDATE_TOKEN || github.token }}
- name: Get changed files
if: steps.bot-check.outputs.skip != 'true'
id: changed-files
run: |
git fetch origin ${{ github.base_ref }}
@@ -52,17 +28,9 @@ jobs:
else
echo "only_docs=false" >> $GITHUB_OUTPUT
fi
- name: Check release notes
if: >-
steps.bot-check.outputs.skip != 'true'
&& startsWith(github.head_ref, 'release/') == false
&& steps.changed-files.outputs.only_docs != 'true'
uses: ./.github/actions/release-notes/check
if: startsWith(github.head_ref, 'release/') == false && steps.changed-files.outputs.only_docs != 'true'
uses: actualbudget/actions/release-notes/check@main
- name: Generate release notes
if: >-
steps.bot-check.outputs.skip != 'true'
&& startsWith(github.head_ref, 'release/') == true
&& github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name
uses: ./.github/actions/release-notes/generate
if: startsWith(github.head_ref, 'release/') == true
uses: actualbudget/actions/release-notes/generate@main

View File

@@ -35,7 +35,7 @@ jobs:
contents: read
steps:
- name: Checkout base branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.base_ref }}
- name: Set up environment
@@ -57,20 +57,6 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: api
ref: ${{github.base_ref}}
- name: Wait for ${{github.base_ref}} CLI build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: master-cli-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: cli
ref: ${{github.base_ref}}
- name: Wait for ${{github.base_ref}} CRDT build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: master-crdt-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: crdt
ref: ${{github.base_ref}}
- name: Wait for PR build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
@@ -86,29 +72,15 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: api
ref: ${{github.event.pull_request.head.sha}}
- name: Wait for CLI PR build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: wait-for-cli-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: cli
ref: ${{github.event.pull_request.head.sha}}
- name: Wait for CRDT PR build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: wait-for-crdt-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: crdt
ref: ${{github.event.pull_request.head.sha}}
- name: Report build failure
if: steps.wait-for-web-build.outputs.conclusion == 'failure' || steps.wait-for-api-build.outputs.conclusion == 'failure' || steps.wait-for-cli-build.outputs.conclusion == 'failure' || steps.wait-for-crdt-build.outputs.conclusion == 'failure'
if: steps.wait-for-web-build.outputs.conclusion == 'failure' || steps.wait-for-api-build.outputs.conclusion == 'failure'
run: |
echo "Build failed on PR branch or ${{github.base_ref}}"
exit 1
- name: Download web build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
id: pr-web-build
with:
branch: ${{github.base_ref}}
@@ -117,7 +89,7 @@ jobs:
name: build-stats
path: base
- name: Download API build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
id: pr-api-build
with:
branch: ${{github.base_ref}}
@@ -126,7 +98,7 @@ jobs:
name: api-build-stats
path: base
- name: Download build stats from PR
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
with:
pr: ${{github.event.pull_request.number}}
workflow: build.yml
@@ -135,7 +107,7 @@ jobs:
path: head
allow_forks: true
- name: Download API stats from PR
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
with:
pr: ${{github.event.pull_request.number}}
workflow: build.yml
@@ -143,40 +115,6 @@ jobs:
name: api-build-stats
path: head
allow_forks: true
- name: Download CLI build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
branch: ${{github.base_ref}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: cli-build-stats
path: base
- name: Download CLI stats from PR
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
pr: ${{github.event.pull_request.number}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: cli-build-stats
path: head
allow_forks: true
- name: Download CRDT build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
branch: ${{github.base_ref}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: crdt-build-stats
path: base
- name: Download CRDT stats from PR
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
pr: ${{github.event.pull_request.number}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: crdt-build-stats
path: head
allow_forks: true
- name: Strip content hashes from stats files
run: |
if [ -f ./head/web-stats.json ]; then
@@ -198,13 +136,9 @@ jobs:
--base desktop-client=./base/web-stats.json \
--base loot-core=./base/loot-core-stats.json \
--base api=./base/api-stats.json \
--base cli=./base/cli-stats.json \
--base crdt=./base/crdt-stats.json \
--head desktop-client=./head/web-stats.json \
--head loot-core=./head/loot-core-stats.json \
--head api=./head/api-stats.json \
--head cli=./head/cli-stats.json \
--head crdt=./head/crdt-stats.json \
--identifier combined \
--format pr-body > bundle-stats-comment.md
- name: Post combined bundle stats comment

View File

@@ -8,7 +8,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
with:
stale-pr-message: 'This PR is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.'
close-pr-message: 'This PR was closed because it has been stalled for 5 days with no activity.'
@@ -18,7 +18,7 @@ jobs:
stale-wip:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
with:
stale-pr-message: ':wave: Hi! It looks like this PR has not had any changes for a week now. Would you like someone to review this PR? If so - please remove the "[WIP]" prefix from the PR title. That will let the community know that this PR is open for a review.'
days-before-stale: 7
@@ -29,7 +29,7 @@ jobs:
stale-needs-info:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
with:
stale-issue-label: 'needs info'
days-before-stale: -1

View File

@@ -19,7 +19,7 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Download patch artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
@@ -27,7 +27,7 @@ jobs:
path: /tmp/artifacts
- name: Download metadata artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
@@ -54,7 +54,7 @@ jobs:
- name: Checkout fork branch
if: steps.metadata.outputs.pr_number != ''
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
repository: ${{ steps.metadata.outputs.head_repo }}
ref: ${{ steps.metadata.outputs.head_ref }}
@@ -133,15 +133,12 @@ jobs:
- name: Comment on PR - Failure
if: failure() && steps.metadata.outputs.pr_number != ''
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
APPLY_ERROR: ${{ steps.apply.outputs.error }}
PR_NUMBER: ${{ steps.metadata.outputs.pr_number }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const error = process.env.APPLY_ERROR || 'Unknown error occurred';
const error = `${{ steps.apply.outputs.error }}` || 'Unknown error occurred';
await github.rest.issues.createComment({
issue_number: parseInt(process.env.PR_NUMBER, 10),
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.`

View File

@@ -26,7 +26,7 @@ jobs:
pull-requests: write
steps:
- name: Add 👀 reaction to comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
await github.rest.reactions.createForIssueComment({
@@ -44,11 +44,11 @@ jobs:
github.event.issue.pull_request &&
startsWith(github.event.comment.body, '/update-vrt')
container:
image: mcr.microsoft.com/playwright:v1.59.1-jammy
image: mcr.microsoft.com/playwright:v1.58.2-jammy
steps:
- name: Get PR details
id: pr
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const { data: pr } = await github.rest.pulls.get({
@@ -60,7 +60,7 @@ jobs:
core.setOutput('head_ref', pr.head.ref);
core.setOutput('head_repo', pr.head.repo.full_name);
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ steps.pr.outputs.head_sha }}
@@ -69,14 +69,9 @@ jobs:
with:
download-translations: 'false'
# Build tools are needed to rebuild native modules like better-sqlite3 used by the Desktop app, which is required to run VRT tests on the Desktop app and generate updated snapshots.
- name: Install build tools
run: apt-get update && apt-get install -y build-essential python3
- name: Run VRT Tests on Desktop app
continue-on-error: true
run: |
yarn rebuild-electron
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop --update-snapshots
- name: Run VRT Tests
@@ -118,7 +113,7 @@ jobs:
- name: Upload patch artifact
if: steps.create-patch.outputs.has_changes == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: vrt-patch-${{ github.event.issue.number }}
path: vrt-update.patch
@@ -134,7 +129,7 @@ jobs:
- name: Upload PR metadata
if: steps.create-patch.outputs.has_changes == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: vrt-metadata-${{ github.event.issue.number }}
path: pr-metadata/

12
.gitignore vendored
View File

@@ -58,10 +58,6 @@ bundle.mobile.js.map
# IntelliJ IDEA
.idea
# Claude Code
.claude/worktrees/*
.claude/settings.local.json
# Misc
.#*
@@ -85,11 +81,3 @@ build/
*storybook.log
storybook-static
# cli config when testing locally
.actualrc.json
.actualrc
.actualrc.yaml
.actualrc.yml
actual.config.js
.playwright-cli/

View File

@@ -1,21 +0,0 @@
#!/bin/sh
# Run yarn install when switching branches (if yarn.lock changed)
# or when creating a new worktree (node_modules won't exist yet)
# $3 is 1 for branch checkout, 0 for file checkout
if [ "$3" != "1" ]; then
exit 0
fi
# Worktree creation: node_modules doesn't exist yet, always install
if [ ! -d "node_modules" ]; then
echo "New worktree detected — running yarn install..."
yarn install || exit 1
exit 0
fi
# Check if yarn.lock changed between the old and new HEAD
if git diff --name-only "$1" "$2" | grep -q "^yarn.lock$"; then
echo "yarn.lock changed — running yarn install..."
yarn install
fi

View File

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

0
.husky/pre-commit Executable file → Normal file
View File

View File

@@ -9,14 +9,24 @@
"react",
"builtin",
"external",
["parent", "subpath"],
"loot-core",
"parent",
"sibling",
"index"
"index",
"desktop-client"
],
"customGroups": [
{
"groupName": "react",
"elementNamePattern": ["react", "react-dom/*", "react-*"]
},
{
"groupName": "loot-core",
"elementNamePattern": ["loot-core/**"]
},
{
"groupName": "desktop-client",
"elementNamePattern": ["@desktop-client/**"]
}
],
"newlinesBetween": true

View File

@@ -36,8 +36,6 @@
"actual/prefer-const": "error",
"actual/no-anchor-tag": "error",
"actual/no-react-default-import": "error",
"actual/prefer-subpath-imports": "error",
"actual/no-extraneous-dependencies": "error",
// JSX A11y rules
"jsx-a11y/no-autofocus": [
@@ -103,18 +101,8 @@
"typescript/no-var-requires": "error",
// we want to allow unions such as "{ name: DbAccount['name'] | DbPayee['name'] }"
"typescript/no-duplicate-type-constituents": "off",
// we want to allow unions such as "string | 'network' | 'file-key-mismatch'"
"typescript/no-redundant-type-constituents": "off",
"typescript/await-thenable": "error",
"typescript/no-floating-promises": "error",
"typescript/require-array-sort-compare": "error",
"typescript/unbound-method": "error",
"typescript/no-for-in-array": "error",
"typescript/restrict-template-expressions": "error",
"typescript/no-misused-spread": "warn", // TODO: enable this
"typescript/no-base-to-string": "warn", // TODO: enable this
"typescript/no-unsafe-unary-minus": "warn", // TODO: enable this
"typescript/no-unsafe-type-assertion": "warn", // TODO: enable this
"typescript/no-floating-promises": "warn", // TODO: covert to error
// Import rules
"import/consistent-type-specifier-style": "error",
@@ -122,6 +110,9 @@
"import/no-amd": "error",
"import/no-default-export": "error",
"import/no-webpack-loader-syntax": "error",
"import/no-useless-path-segments": "error",
"import/no-unresolved": "error",
"import/no-unused-modules": "error",
"import/no-duplicates": [
"error",
{
@@ -159,6 +150,7 @@
"react/no-danger-with-children": "error",
"react/no-direct-mutation-state": "error",
"react/no-is-mounted": "error",
"react/no-unstable-nested-components": "error",
"react/require-render-return": "error",
"react/rules-of-hooks": "error",
"react/self-closing-comp": "error",
@@ -232,7 +224,7 @@
"eslint/require-yield": "error",
"eslint/getter-return": "error",
"eslint/unicode-bom": ["error", "never"],
"eslint/use-isnan": "error",
"eslint/no-use-isnan": "error",
"eslint/valid-typeof": "error",
"eslint/no-useless-rename": [
"error",
@@ -333,9 +325,14 @@
],
"patterns": [
{
"group": ["**/*.api", "**/*.electron"],
"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"],
@@ -354,9 +351,7 @@
],
"eslint/no-useless-constructor": "error",
"eslint/no-undef": "error",
"eslint/no-unused-expressions": "error",
"eslint/no-return-assign": "error",
"eslint/no-unused-vars": "error"
"eslint/no-unused-expressions": "error"
},
"overrides": [
{
@@ -416,16 +411,6 @@
"rules": {
"eslint/no-empty-function": "off"
}
},
// crdt enforces the repo's "TODO: enable this" typescript rules as errors
{
"files": ["packages/crdt/**/*"],
"rules": {
"typescript/no-misused-spread": "error",
"typescript/no-base-to-string": "error",
"typescript/no-unsafe-unary-minus": "error",
"typescript/no-unsafe-type-assertion": "error"
}
}
]
}

942
.yarn/releases/yarn-4.10.3.cjs vendored Executable file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,4 +6,4 @@ enableTransparentWorkspaces: false
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.13.0.cjs
yarnPath: .yarn/releases/yarn-4.10.3.cjs

View File

@@ -44,9 +44,25 @@ yarn start:desktop
### ⚠️ CRITICAL REQUIREMENT: AI-Generated Commit Messages and PR Titles
**ALL commit messages and PR titles MUST be prefixed with `[AI]`.** No exceptions.
**THIS IS A MANDATORY REQUIREMENT THAT MUST BE FOLLOWED WITHOUT EXCEPTION:**
See [PR and Commit Rules](.github/agents/pr-and-commit-rules.md) for the full specification, including git safety rules, pre-commit checklist, and PR workflow.
- **ALL commit messages MUST be prefixed with `[AI]`**
- **ALL pull request titles MUST be prefixed with `[AI]`**
**Examples:**
-`[AI] Fix type error in account validation`
-`[AI] Add support for new transaction categories`
-`Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
-`Add support for new transaction categories` (MISSING PREFIX - NOT ALLOWED)
**This requirement applies to:**
- Every single commit message created by AI agents
- Every single pull request title created by AI agents
- No exceptions are permitted
**This is a hard requirement that agents MUST follow. Failure to include the `[AI]` prefix is a violation of these instructions.**
### Task Orchestration with Lage
@@ -84,7 +100,7 @@ The core application logic that runs on any platform.
```bash
# Run all loot-core tests
yarn workspace @actual-app/core run test
yarn workspace loot-core run test
# Or run tests across all packages using lage
yarn test
@@ -219,7 +235,7 @@ yarn test
yarn test:debug
# Run tests for a specific package
yarn workspace @actual-app/core run test
yarn workspace loot-core run test
```
**E2E Tests (Playwright)**
@@ -331,7 +347,7 @@ Always maintain newlines between import groups.
### Platform-Specific Code
- Don't directly reference platform-specific imports (`.api`, `.electron`)
- 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
@@ -345,7 +361,13 @@ Always maintain newlines between import groups.
**Git Commands:**
See [PR and Commit Rules](.github/agents/pr-and-commit-rules.md) for complete git safety rules, commit message requirements, and PR workflow.
- **MANDATORY: ALL commit messages MUST be prefixed with `[AI]`** - This is a hard requirement with no exceptions
- **MANDATORY: ALL pull request titles MUST be prefixed with `[AI]`** - This is a hard requirement with no exceptions
- 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
@@ -501,7 +523,7 @@ Icons in `packages/component-library/src/icons/` are auto-generated. Don't manua
1. Check `tsconfig.json` for path mappings
2. Check package.json `exports` field (especially for loot-core)
3. Verify platform-specific imports (`.electron`, `.api`)
3. Verify platform-specific imports (`.web`, `.electron`, `.api`)
4. Use absolute imports in `desktop-client` (enforced by ESLint)
### Build Failures
@@ -544,7 +566,7 @@ Icons in `packages/component-library/src/icons/` are auto-generated. Don't manua
Before committing changes, ensure:
- [ ] Commit and PR rules followed (see [PR and Commit Rules](.github/agents/pr-and-commit-rules.md))
- [ ] **MANDATORY: Commit message is prefixed with `[AI]`** - This is a hard requirement with no exceptions
- [ ] `yarn typecheck` passes
- [ ] `yarn lint:fix` has been run
- [ ] Relevant tests pass
@@ -557,7 +579,17 @@ Before committing changes, ensure:
## Pull Request Guidelines
See [PR and Commit Rules](.github/agents/pr-and-commit-rules.md) for complete PR creation rules, including title prefix requirements, labeling, and PR template handling.
When creating pull requests:
- **MANDATORY PREFIX REQUIREMENT**: **ALL pull request titles MUST be prefixed with `[AI]`** - This is a hard requirement that MUST be followed without exception
- ✅ Correct: `[AI] Fix type error in account validation`
- ❌ Incorrect: `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
- **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.
### PR Template: Do Not Fill In
- **NEVER fill in the PR template** (`.github/PULL_REQUEST_TEMPLATE.md`). Leave all blank spaces and placeholder comments as-is. We expect **humans** to fill in the Description, Related issue(s), Testing, and Checklist sections.
- **Exception**: If a human **explicitly asks** you to fill out the PR template, then fill it out **in Chinese**, using Chinese characters (简体中文) for all content you add.
## Code Review Guidelines
@@ -625,7 +657,7 @@ Standard commands documented in `package.json` scripts and the Quick Start secti
- `yarn lint` / `yarn lint:fix` (uses oxlint + oxfmt)
- `yarn test` (lage across all workspaces)
- `yarn typecheck` (tsgo + lage typecheck)
- `yarn typecheck` (tsc + lage typecheck)
### Testing and previewing the app

View File

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

View File

@@ -1,218 +0,0 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
derivePublishImports,
validatePackage,
} from '../validate-publish-imports.js';
describe('derivePublishImports', () => {
it('prepends ./build/ to .js paths', () => {
const imports = {
'#account-db': './src/account-db.js',
};
expect(derivePublishImports(imports)).toEqual({
'#account-db': './build/src/account-db.js',
});
});
it('converts .ts extension to .js and prepends ./build/', () => {
const imports = {
'#migrations': './src/migrations.ts',
};
expect(derivePublishImports(imports)).toEqual({
'#migrations': './build/src/migrations.js',
});
});
it('converts .tsx extension to .js and prepends ./build/', () => {
const imports = {
'#component': './src/component.tsx',
};
expect(derivePublishImports(imports)).toEqual({
'#component': './build/src/component.js',
});
});
it('preserves wildcard patterns', () => {
const imports = {
'#accounts/*': './src/accounts/*.js',
'#services/*': './src/app-gocardless/services/*.ts',
};
expect(derivePublishImports(imports)).toEqual({
'#accounts/*': './build/src/accounts/*.js',
'#services/*': './build/src/app-gocardless/services/*.js',
});
});
it('handles multiple entries with mixed extensions', () => {
const imports = {
'#account-db': './src/account-db.js',
'#migrations': './src/migrations.ts',
'#app-gocardless/errors': './src/app-gocardless/errors.ts',
'#util/*': './src/util/*.ts',
'#scripts/*': './src/scripts/*.js',
};
expect(derivePublishImports(imports)).toEqual({
'#account-db': './build/src/account-db.js',
'#migrations': './build/src/migrations.js',
'#app-gocardless/errors': './build/src/app-gocardless/errors.js',
'#util/*': './build/src/util/*.js',
'#scripts/*': './build/src/scripts/*.js',
});
});
it('returns empty object for empty imports', () => {
expect(derivePublishImports({})).toEqual({});
});
it('throws error for non-string imports values', () => {
const imports = {
'#foo': './src/foo.js',
'#conditional': {
browser: './src/browser.js',
node: './src/node.js',
},
};
expect(() => derivePublishImports(imports)).toThrow(
'Unsupported imports target for "#conditional". Expected a string path.',
);
});
it('handles paths with /index.js suffix', () => {
const imports = {
'#util/title': './src/util/title/index.js',
};
expect(derivePublishImports(imports)).toEqual({
'#util/title': './build/src/util/title/index.js',
});
});
});
describe('validatePackage', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'validate-imports-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function writePackageJson(content: Record<string, unknown>) {
const filePath = path.join(tmpDir, 'package.json');
fs.writeFileSync(filePath, JSON.stringify(content, null, 2) + '\n');
return filePath;
}
it('skips packages with no publishConfig', () => {
const filePath = writePackageJson({
name: 'test-pkg',
imports: { '#foo': './src/foo.js' },
});
const { result, warnings } = validatePackage(filePath);
expect(result).toBeNull();
expect(warnings).toEqual([]);
});
it('skips packages with publishConfig but no publishConfig.imports', () => {
const filePath = writePackageJson({
name: 'test-pkg',
imports: { '#foo': './src/foo.js' },
publishConfig: { access: 'public' },
});
const { result, warnings } = validatePackage(filePath);
expect(result).toBeNull();
expect(warnings).toEqual([]);
});
it('warns when publishConfig.imports exists but imports does not', () => {
const filePath = writePackageJson({
name: 'test-pkg',
publishConfig: {
imports: { '#foo': './build/src/foo.js' },
},
});
const { result, warnings } = validatePackage(filePath);
expect(result).toBeNull();
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain('orphaned');
});
it('returns no errors when publishConfig.imports matches', () => {
const filePath = writePackageJson({
name: 'test-pkg',
imports: {
'#foo': './src/foo.js',
'#bar': './src/bar.ts',
},
publishConfig: {
imports: {
'#foo': './build/src/foo.js',
'#bar': './build/src/bar.js',
},
},
});
const { result } = validatePackage(filePath);
expect(result).not.toBeNull();
expect(result!.missingKeys).toEqual([]);
expect(result!.extraKeys).toEqual([]);
expect(result!.wrongValues).toEqual([]);
});
it('detects missing keys in publishConfig.imports', () => {
const filePath = writePackageJson({
name: 'test-pkg',
imports: {
'#foo': './src/foo.js',
'#bar': './src/bar.ts',
},
publishConfig: {
imports: {
'#foo': './build/src/foo.js',
},
},
});
const { result } = validatePackage(filePath);
expect(result!.missingKeys).toEqual(['#bar']);
});
it('detects extra keys in publishConfig.imports', () => {
const filePath = writePackageJson({
name: 'test-pkg',
imports: {
'#foo': './src/foo.js',
},
publishConfig: {
imports: {
'#foo': './build/src/foo.js',
'#orphan': './build/src/orphan.js',
},
},
});
const { result } = validatePackage(filePath);
expect(result!.extraKeys).toEqual(['#orphan']);
});
it('detects wrong values in publishConfig.imports', () => {
const filePath = writePackageJson({
name: 'test-pkg',
imports: {
'#foo': './src/foo.ts',
},
publishConfig: {
imports: {
'#foo': './src/foo.ts',
},
},
});
const { result } = validatePackage(filePath);
expect(result!.wrongValues).toEqual([
{ key: '#foo', expected: './build/src/foo.js', actual: './src/foo.ts' },
]);
});
});

View File

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

View File

@@ -43,7 +43,6 @@ if [ $SKIP_TRANSLATIONS == false ]; then
pushd packages/desktop-client/locale > /dev/null
git checkout .
git pull
popd > /dev/null
packages/desktop-client/bin/remove-untranslated-languages
@@ -51,18 +50,17 @@ fi
export NODE_OPTIONS="--max-old-space-size=4096"
yarn workspace @actual-app/crdt build
yarn workspace plugins-service build
yarn workspace @actual-app/core build:node
yarn workspace loot-core build:node
yarn workspace @actual-app/web build --mode=desktop # electron specific build
# required for running the sync-server server
yarn workspace @actual-app/core build:browser
yarn workspace loot-core build:browser
yarn workspace @actual-app/web build:browser
yarn workspace @actual-app/sync-server build
# Emit @actual-app/core declarations so desktop-electron (which includes typings/window.ts) can build
yarn workspace @actual-app/core exec tsgo -p tsconfig.json
# Emit loot-core declarations so desktop-electron (which includes typings/window.ts) can build
yarn workspace loot-core exec tsc -p tsconfig.json
yarn workspace desktop-electron update-client

View File

@@ -1,35 +1,32 @@
import { exec } from 'node:child_process';
import { existsSync, writeFile } from 'node:fs';
import { existsSync, writeFileSync } from 'node:fs';
import { exit } from 'node:process';
import prompts from 'prompts';
async function run() {
const username = await execAsync(
"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`.',
);
const activePr = await getActivePr(username);
if (activePr) {
const hasGhCli = await checkGhCli();
const username = hasGhCli ? await execAsync("gh api user --jq '.login'") : '';
if (!username) {
console.log(
`Found potentially matching PR ${activePr.number}: ${activePr.title}`,
'Tip: Install the GitHub CLI (https://github.com/cli/cli) and run `gh auth login` to enable auto-detection of your username and auto-creation of draft PRs.',
);
}
const initialPrNumber = activePr?.number ?? (await getNextPrNumber());
const result = await prompts([
const activePr = await getActivePr(username);
if (activePr) {
console.log(`Found existing PR #${activePr.number}: ${activePr.title}`);
}
// Ask for category and summary first (before PR number)
const initial = await prompts([
{
name: 'githubUsername',
message: 'Comma-separated GitHub username(s)',
type: 'text',
initial: username,
},
{
name: 'pullRequestNumber',
message: 'PR Number',
type: 'number',
initial: initialPrNumber,
},
{
name: 'releaseNoteType',
message: 'Release Note Type',
@@ -50,28 +47,65 @@ async function run() {
]);
if (
!result.githubUsername ||
!result.oneLineSummary ||
!result.releaseNoteType ||
!result.pullRequestNumber
!initial.githubUsername ||
!initial.oneLineSummary ||
initial.releaseNoteType === undefined
) {
console.log('All questions must be answered. Exiting');
exit(1);
}
// Determine PR number: use existing PR, offer to create draft, or ask manually
let prNumber: number;
if (activePr) {
prNumber = activePr.number;
} else if (hasGhCli) {
const { action } = await prompts({
name: 'action',
message: 'No existing PR found. How would you like to get the PR number?',
type: 'select',
choices: [
{
title: '🚀 Create a draft PR automatically',
value: 'create-draft',
description:
'Creates a draft PR using the GitHub CLI and uses its number',
},
{
title: '✏️ Enter PR number manually',
value: 'manual',
description: 'Enter a PR number you already know',
},
],
});
if (!action) {
console.log('Exiting');
exit(1);
}
if (action === 'create-draft') {
prNumber = await createDraftPr(initial.oneLineSummary);
} else {
prNumber = await askForPrNumber();
}
} else {
prNumber = await askForPrNumber();
}
const fileContents = getFileContents(
result.releaseNoteType,
result.githubUsername,
result.oneLineSummary,
initial.releaseNoteType,
initial.githubUsername,
initial.oneLineSummary,
);
const prNumber = result.pullRequestNumber;
const filepath = `./upcoming-release-notes/${prNumber}.md`;
if (existsSync(filepath)) {
const { confirm } = await prompts({
name: 'confirm',
type: 'confirm',
message: `This will overwrite the existing release note ${filepath} Are you sure?`,
message: `This will overwrite the existing release note ${filepath}. Are you sure?`,
});
if (!confirm) {
console.log('Exiting');
@@ -79,14 +113,79 @@ async function run() {
}
}
writeFile(filepath, fileContents, err => {
if (err) {
console.error('Failed to write release note file:', err);
writeFileSync(filepath, fileContents);
console.log(`Release note generated successfully: ${filepath}`);
}
async function checkGhCli(): Promise<boolean> {
const result = await execAsync('gh --version');
return result !== '';
}
async function createDraftPr(title: string): Promise<number> {
// Ensure current branch is pushed to remote
const branchName = await execAsync('git rev-parse --abbrev-ref HEAD');
if (!branchName || branchName === 'master' || branchName === 'main') {
console.error(
'Cannot create a draft PR from the main/master branch. Please switch to a feature branch first.',
);
exit(1);
}
console.log(`Pushing branch "${branchName}" to remote...`);
const pushResult = await execAsync(
`git push -u origin ${branchName} 2>&1`,
'Failed to push branch to remote. Please push manually first.',
);
if (pushResult === '') {
// execAsync returns '' on error
exit(1);
}
console.log('Creating draft PR...');
const prUrl = await execAsync(
`gh pr create --draft --title "${title.replace(/"/g, '\\"')}" --body "" 2>&1`,
'Failed to create draft PR. Please create one manually via GitHub.',
);
if (!prUrl) {
exit(1);
}
// Extract PR number from URL (e.g., https://github.com/owner/repo/pull/1234)
const prNumberMatch = prUrl.match(/\/pull\/(\d+)/);
if (!prNumberMatch) {
// gh pr create might return just a number or other format
const numberMatch = prUrl.match(/(\d+)/);
if (!numberMatch) {
console.error('Could not parse PR number from output:', prUrl);
exit(1);
} else {
console.log(`Release note generated successfully: ${filepath}`);
}
const prNumber = parseInt(numberMatch[1], 10);
console.log(`Draft PR #${prNumber} created: ${prUrl.trim()}`);
return prNumber;
}
const prNumber = parseInt(prNumberMatch[1], 10);
console.log(`Draft PR #${prNumber} created: ${prUrl.trim()}`);
return prNumber;
}
async function askForPrNumber(): Promise<number> {
const nextPrNumber = await getNextPrNumber();
const { pullRequestNumber } = await prompts({
name: 'pullRequestNumber',
message: 'PR Number',
type: 'number',
initial: nextPrNumber,
});
if (!pullRequestNumber) {
console.log('PR number is required. Exiting');
exit(1);
}
return pullRequestNumber;
}
// makes an attempt to find an existing open PR from <username>:<branch>
@@ -169,7 +268,9 @@ async function execAsync(cmd: string, errorLog?: string): Promise<string> {
return new Promise<string>(res => {
exec(cmd, (error, stdout) => {
if (error) {
console.log(errorLog);
if (errorLog) {
console.log(errorLog);
}
res('');
} else {
res(stdout.trim());

View File

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

View File

@@ -1,216 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
/**
* Derives publishConfig.imports from imports by:
* 1. Prepending ./build/ to each value path
* 2. Replacing .ts/.tsx extensions with .js
*/
export function derivePublishImports(
imports: Record<string, string | object>,
): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(imports)) {
if (typeof value !== 'string') {
throw new Error(
`Unsupported imports target for "${key}". Expected a string path.`,
);
}
const withBuildPrefix = value.replace(/^\.\//, './build/');
const withJsExtension = withBuildPrefix.replace(/\.tsx?$/, '.js');
result[key] = withJsExtension;
}
return result;
}
export type ValidationResult = {
packagePath: string;
packageName: string;
missingKeys: string[];
extraKeys: string[];
wrongValues: Array<{ key: string; expected: string; actual: string }>;
};
/**
* Validates publishConfig.imports against imports for a single package.json.
* Returns null if the package should be skipped (no publishConfig.imports).
* Returns a ValidationResult if the package has both fields.
*/
export function validatePackage(packageJsonPath: string): {
result: ValidationResult | null;
warnings: string[];
} {
const warnings: string[] = [];
const content = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
const packageName: string = content.name ?? packageJsonPath;
const imports: Record<string, string | object> | undefined = content.imports;
const publishImports: Record<string, string> | undefined =
content.publishConfig?.imports;
// No publishConfig.imports → skip
if (!publishImports) {
return { result: null, warnings };
}
// Has publishConfig.imports but no imports → warn
if (!imports) {
warnings.push(
`${packageName}: orphaned publishConfig.imports (no imports field)`,
);
return { result: null, warnings };
}
const expected = derivePublishImports(imports);
const expectedKeys = new Set(Object.keys(expected));
const actualKeys = new Set(Object.keys(publishImports));
const missingKeys = [...expectedKeys].filter(k => !actualKeys.has(k));
const extraKeys = [...actualKeys].filter(k => !expectedKeys.has(k));
const wrongValues: ValidationResult['wrongValues'] = [];
for (const key of expectedKeys) {
if (actualKeys.has(key) && publishImports[key] !== expected[key]) {
wrongValues.push({
key,
expected: expected[key],
actual: publishImports[key],
});
}
}
return {
result: {
packagePath: packageJsonPath,
packageName,
missingKeys,
extraKeys,
wrongValues,
},
warnings,
};
}
export function fixPackage(packageJsonPath: string): boolean {
const raw = fs.readFileSync(packageJsonPath, 'utf-8');
const content = JSON.parse(raw);
if (!content.imports || !content.publishConfig?.imports) {
return false;
}
const expected = derivePublishImports(content.imports);
// Check if already correct
if (
JSON.stringify(content.publishConfig.imports) === JSON.stringify(expected)
) {
return false;
}
content.publishConfig.imports = expected;
fs.writeFileSync(packageJsonPath, JSON.stringify(content, null, 2) + '\n');
return true;
}
function findPackageJsonFiles(): string[] {
const packagesDir = path.resolve(__dirname, '..', 'packages');
const entries = fs.readdirSync(packagesDir, { withFileTypes: true });
const results: string[] = [];
for (const entry of entries) {
if (entry.isDirectory()) {
const pkgPath = path.join(packagesDir, entry.name, 'package.json');
if (fs.existsSync(pkgPath)) {
results.push(pkgPath);
}
}
}
return results;
}
function resolvePackageJsonPaths(filePaths: string[]): string[] {
const packagesRoot = path.resolve(__dirname, '..', 'packages');
const seen = new Set<string>();
for (const filePath of filePaths) {
const resolvedPath = path.resolve(filePath);
let dir = path.dirname(resolvedPath);
while (dir.startsWith(packagesRoot + path.sep)) {
const candidate = path.join(dir, 'package.json');
if (
fs.existsSync(candidate) &&
candidate.startsWith(packagesRoot + path.sep)
) {
seen.add(candidate);
break;
}
dir = path.dirname(dir);
}
}
return [...seen];
}
function main() {
const args = process.argv.slice(2);
const fixMode = args.includes('--fix');
const filePaths = args.filter(arg => !arg.startsWith('--'));
const packageJsonFiles =
filePaths.length > 0
? resolvePackageJsonPaths(filePaths)
: findPackageJsonFiles();
let hasErrors = false;
const allWarnings: string[] = [];
for (const pkgPath of packageJsonFiles) {
if (fixMode) {
const fixed = fixPackage(pkgPath);
if (fixed) {
const name = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).name;
console.log(`Fixed publishConfig.imports in ${name}`);
}
} else {
const { result, warnings } = validatePackage(pkgPath);
allWarnings.push(...warnings);
if (result) {
const hasIssues =
result.missingKeys.length > 0 ||
result.extraKeys.length > 0 ||
result.wrongValues.length > 0;
if (hasIssues) {
hasErrors = true;
console.error(`\n${result.packageName}:`);
for (const key of result.missingKeys) {
console.error(` Missing key: ${key}`);
}
for (const key of result.extraKeys) {
console.error(` Extra key: ${key}`);
}
for (const { key, expected, actual } of result.wrongValues) {
console.error(` Wrong value for ${key}:`);
console.error(` expected: ${expected}`);
console.error(` actual: ${actual}`);
}
}
}
}
}
for (const warning of allWarnings) {
console.warn(`Warning: ${warning}`);
}
if (hasErrors) {
console.error(
'\npublishConfig.imports is out of sync. Run with --fix to auto-fix.',
);
process.exit(1);
}
}
if (require.main === module) {
main();
}

View File

@@ -1,9 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
include: ['__tests__/**/*.test.ts'],
environment: 'node',
},
});

View File

@@ -1,11 +1,8 @@
const BUILD_OUTPUT_GLOBS = ['lib-dist/**', 'dist/**', 'build/**', '@types/**'];
/** @type {import('lage').ConfigOptions} */
module.exports = {
pipeline: {
typecheck: {
type: 'npmScript',
dependsOn: ['^typecheck'],
},
test: {
type: 'npmScript',
@@ -19,17 +16,16 @@ module.exports = {
},
build: {
type: 'npmScript',
dependsOn: ['^build'],
cache: true,
options: {
outputGlob: BUILD_OUTPUT_GLOBS,
outputGlob: ['lib-dist/**', 'dist/**', 'build/**'],
},
},
},
cacheOptions: {
cacheStorageConfig: {
provider: 'local',
outputGlob: BUILD_OUTPUT_GLOBS,
outputGlob: ['lib-dist/**', 'dist/**', 'build/**'],
},
},
npmClient: 'yarn',

View File

@@ -25,23 +25,21 @@
"start:desktop": "yarn desktop-dependencies && npm-run-all --parallel 'start:desktop-*'",
"start:docs": "yarn workspace docs start",
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:browser-backend build:plugins-service",
"start:desktop-node": "yarn workspace @actual-app/core watch:node",
"start:desktop-node": "yarn workspace loot-core watch:node",
"start:desktop-client": "yarn workspace @actual-app/web watch",
"start:desktop-server-client": "yarn workspace @actual-app/web build:browser",
"start:desktop-electron": "yarn workspace desktop-electron watch",
"start:browser": "yarn workspace plugins-service build-dev && npm-run-all --parallel 'start:browser-*'",
"start:service-plugins": "yarn workspace plugins-service watch",
"start:browser-backend": "yarn workspace @actual-app/core watch:browser",
"start:browser-backend": "yarn workspace loot-core watch:browser",
"start:browser-frontend": "yarn workspace @actual-app/web start:browser",
"start:storybook": "yarn workspace @actual-app/components start:storybook",
"build": "lage build",
"build:browser-backend": "yarn workspace @actual-app/core build:browser",
"build:browser-backend": "yarn workspace loot-core build:browser",
"build:server": "yarn build:browser && yarn workspace @actual-app/sync-server build",
"build:browser": "./bin/package-browser",
"build:desktop": "./bin/package-electron",
"build:plugins-service": "yarn workspace plugins-service build",
"build:api": "yarn build --scope=@actual-app/api",
"build:cli": "yarn build --scope=@actual-app/cli",
"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",
@@ -54,58 +52,50 @@
"playwright": "yarn workspace @actual-app/web run playwright",
"vrt": "yarn workspace @actual-app/web run vrt",
"vrt:docker": "./bin/run-vrt",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core && ./node_modules/.bin/electron-rebuild -m ./packages/desktop-electron -o better-sqlite3,bcrypt",
"rebuild-node": "yarn workspace @actual-app/core rebuild",
"lint": "oxfmt --check . && oxlint --type-aware --quiet",
"lint:fix": "oxfmt . && oxlint --fix --type-aware --quiet",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core",
"rebuild-node": "yarn workspace loot-core rebuild",
"lint": "yarn workspace @actual-app/api clean && oxfmt --check . && oxlint --type-aware",
"lint:fix": "yarn workspace @actual-app/api clean && oxfmt . && oxlint --fix --type-aware",
"install:server": "yarn workspaces focus @actual-app/sync-server --production",
"constraints": "yarn constraints",
"typecheck": "tsgo -p tsconfig.root.json --noEmit && lage typecheck",
"typecheck": "yarn workspace @actual-app/api clean && tsc -b && tsc -p tsconfig.root.json --noEmit && lage typecheck",
"jq": "./node_modules/node-jq/bin/jq",
"prepare": "husky"
},
"devDependencies": {
"@octokit/rest": "^22.0.1",
"@types/node": "^22.19.17",
"@types/node": "^22.19.10",
"@types/prompts": "^2.4.9",
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
"@yarnpkg/types": "^4.0.1",
"eslint": "^10.2.0",
"eslint-plugin-perfectionist": "^5.8.0",
"baseline-browser-mapping": "^2.9.19",
"cross-env": "^10.1.0",
"eslint": "^9.39.2",
"eslint-plugin-perfectionist": "^4.15.1",
"eslint-plugin-typescript-paths": "^0.0.33",
"html-to-image": "^1.11.13",
"husky": "^9.1.7",
"lage": "^2.15.5",
"lint-staged": "^16.4.0",
"minimatch": "^10.2.5",
"lage": "^2.14.17",
"lint-staged": "^16.2.7",
"minimatch": "^10.1.2",
"node-jq": "^6.3.1",
"npm-run-all": "^4.1.5",
"oxfmt": "^0.44.0",
"oxlint": "^1.59.0",
"oxlint-tsgolint": "^0.20.0",
"oxfmt": "^0.32.0",
"oxlint": "^1.47.0",
"oxlint-tsgolint": "^0.13.0",
"p-limit": "^7.3.0",
"prompts": "^2.4.2",
"source-map-support": "^0.5.21",
"ts-node": "^10.9.2",
"typescript": "^6.0.2",
"vitest": "^4.1.2"
"typescript": "^5.9.3"
},
"resolutions": {
"adm-zip": "patch:adm-zip@npm%3A0.5.16#~/.yarn/patches/adm-zip-npm-0.5.16-4556fea098.patch",
"minimatch@10.2.1": "10.2.5",
"minimatch@3.1.2": "3.1.5",
"minimatch@>=10.0.0 <11.0.0": "10.2.5",
"minimatch@>=3.0.0 <4.0.0": "3.1.5",
"minimatch@>=5.0.0 <6.0.0": "5.1.9",
"minimatch@>=9.0.0 <10.0.0": "9.0.9",
"rollup": "4.40.1",
"socks": ">=2.8.3"
},
"lint-staged": {
"packages/*/package.json": [
"ts-node ./bin/validate-publish-imports.ts --fix"
],
"*.{js,mjs,jsx,ts,tsx,md,json,yml,yaml}": [
"oxfmt --no-error-on-unmatched-pattern"
],
"*.{js,mjs,jsx,ts,tsx}": [
"oxlint --fix --type-aware --quiet"
"oxlint --fix --type-aware"
]
},
"browserslist": [
@@ -116,5 +106,5 @@
"node": ">=22",
"yarn": "^4.9.1"
},
"packageManager": "yarn@4.13.0"
"packageManager": "yarn@4.10.3"
}

View File

@@ -3,7 +3,3 @@ npm install @actual-app/api
```
View docs here: https://actualbudget.org/docs/api/
## TypeScript
`@actual-app/api` publishes TypeScript declarations. Consumers using TypeScript must set `moduleResolution` to `"bundler"`, `"nodenext"`, or `"node16"` in their `tsconfig.json`. Legacy `"node"` / `"node10"` / `"classic"` resolution is not supported in strict mode — the published declarations rely on package.json `exports` conditions that older resolvers don't honor.

View File

@@ -1,7 +1,4 @@
class Query {
/** @type {import('@actual-app/core/shared/query').QueryState} */
state;
constructor(state) {
this.state = {
filterExpressions: state.filterExpressions || [],

View File

@@ -1,102 +0,0 @@
/// <reference lib="webworker" />
// Worker entry for @actual-app/api's browser build.
//
// This owns the real loot-core instance (sql.js + absurd-sql + IndexedDB)
// and speaks loot-core's existing backend protocol over postMessage:
// main → worker: {id, name, args, undoTag?, catchErrors?}
// worker → main: {type:'reply', id, result, mutated, undoTag}
// {type:'error', id, error}
// {type:'connect'} (handshake heartbeat)
//
// Bootstrapping:
// - We register an `api-browser/init` handler that runs loot-core's public
// init(config), so the main-thread facade can kick off the DB + auth via
// a normal RPC call. The reply carries no return value (loot-core's
// `init(config)` resolves to `lib`, which isn't structured-cloneable).
// - connection.init(self, handlers) starts the message loop and the
// `{type:'connect'}` handshake loot-core's client connection expects.
import * as connection from '@actual-app/core/platform/server/connection';
import { handlers, init } from '@actual-app/core/server/main';
import type { InitConfig } from '@actual-app/core/server/main';
// Dev-server friendliness: consumer bundlers (Vite first, others too) run
// import-analysis on every `.js` URL they serve. loot-core's JS migrations
// use `#`-subpath imports that only resolve inside loot-core — analysis
// fails when those files live under node_modules/@actual-app/api/dist/.
// Our build writes those files with an extra `.data` suffix, so bundlers
// leave them alone. Translate the URLs here so loot-core's fetch layer
// still sees `.js` names both in the manifest and on-disk.
//
// The wrap has to install before connection.init() runs, and populateDefault-
// Filesystem is kicked off lazily from the first `load-budget` / init call.
{
const origFetch = globalThis.fetch;
const MIGRATION_JS = /\/data\/migrations\/[^/?]+\.js(\?.*)?$/;
globalThis.fetch = (async (
input: RequestInfo | URL,
initArg?: RequestInit,
): Promise<Response> => {
const url =
typeof input === 'string' ? input : (input as URL | Request).toString();
if (MIGRATION_JS.test(url)) {
// Re-target .js → .js.data before hitting the network.
const patched = url.replace(/(\.js)(\?|$)/, '.js.data$2');
return origFetch(patched, initArg);
}
if (
url.endsWith('/data-file-index.txt') ||
url.endsWith('data-file-index.txt')
) {
const res = await origFetch(input as RequestInfo | URL, initArg);
if (!res.ok) return res;
const text = await res.text();
const rewritten = text.replace(/\.js\.data(\r?\n|$)/g, '.js$1');
return new Response(rewritten, {
status: res.status,
statusText: res.statusText,
headers: res.headers,
});
}
return origFetch(input as RequestInfo | URL, initArg);
}) as typeof fetch;
}
// `api-browser/init` is a worker-local handler; it isn't part of the shared
// Handlers type. Assign via the index-signature cast rather than extending
// the type globally.
(handlers as Record<string, (args?: unknown) => Promise<unknown>>)[
'api-browser/init'
] = async function (args?: unknown) {
const payload = (args ?? {}) as InitConfig & { __assetsBaseUrl?: string };
// Main thread hands us a URL pointing at the api's own dist/ dir. Setting
// PUBLIC_URL here is what makes loot-core's populateDefaultFilesystem
// fetch `data-file-index.txt` / `data/<name>` / `sql-wasm.wasm` from our
// package instead of the consumer's page origin — no manual copy step.
const { __assetsBaseUrl, ...config } = payload;
if (__assetsBaseUrl) {
process.env.PUBLIC_URL = __assetsBaseUrl;
}
await init(config);
// Nothing to return — the resolved `lib` has functions and isn't
// structured-cloneable anyway.
};
self.addEventListener('error', e => {
// eslint-disable-next-line no-console
console.error(
'[api worker] uncaught',
(e as ErrorEvent).error ?? (e as ErrorEvent).message,
);
});
self.addEventListener('unhandledrejection', e => {
// eslint-disable-next-line no-console
console.error(
'[api worker] unhandled rejection',
(e as PromiseRejectionEvent).reason,
);
});
connection.init(self as unknown as Window, handlers);

View File

@@ -1,39 +0,0 @@
// Browser main-thread stub for `@actual-app/core/server/main`.
//
// The real loot-core runs inside the worker (see browser-worker.ts). The
// main-thread bundle reuses packages/api/methods.ts verbatim, but that file
// reads `lib.send(...)` from loot-core. Resolving that import to this stub
// routes every call over postMessage instead of touching loot-core on the
// main thread.
export type BrowserSendFn = (name: string, args?: unknown) => Promise<unknown>;
let workerSend: BrowserSendFn = () => {
return Promise.reject(
new Error('@actual-app/api: call init() before any other method'),
);
};
// Shape-cast rather than `typeof import(...)` so this stub stays
// module-graph-independent from the real loot-core.
export const lib = {
send(name: string, args?: unknown) {
return workerSend(name, args);
},
} as unknown as {
send: <T = unknown>(name: string, args?: unknown) => Promise<T>;
};
export function _setBrowserSend(fn: BrowserSendFn) {
workerSend = fn;
}
// Inline InitConfig (matches loot-core's shape) so this stub does not force
// TS to pull in the real @actual-app/core/server/main module graph at all.
export type InitConfig = {
dataDir?: string;
serverURL?: string;
password?: string;
sessionToken?: string;
verbose?: boolean;
};

View File

@@ -1,132 +0,0 @@
// Main-thread RPC bridge to the api worker.
//
// Reuses `createBackendWorker` from loot-core so absurd-sql's main-thread
// plumbing (IDB helper worker, __absurd:* filtering) stays in one place.
// Speaks loot-core's existing backend protocol:
// out: {id, name, args, catchErrors?}
// in : {type:'reply', id, result, error?}
// {type:'error', id, error}
// {type:'connect'} (handshake heartbeat)
// {type:'push', name, args}
//
// We handle the handshake by replying {name:'client-connected-to-backend'}
// on the first 'connect'. Messages sent before handshake completes are
// queued.
import { createBackendWorker } from '@actual-app/core/platform/client/backend-worker';
import type { BackendWorker } from '@actual-app/core/platform/client/backend-worker';
type Pending = {
resolve: (v: unknown) => void;
reject: (e: unknown) => void;
};
type Reply =
| {
type: 'reply';
id: string;
result?: unknown;
error?: { type?: string; message?: string; [k: string]: unknown };
}
| {
type: 'error';
id: string;
error: { type?: string; message?: string; [k: string]: unknown };
};
let backend: BackendWorker | null = null;
let connected = false;
let queue: Array<{ id: string; name: string; args?: unknown }> = [];
const pending = new Map<string, Pending>();
function nextId(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
}
function toError(info: { type?: string; message?: string } | undefined) {
const msg = info?.message || info?.type || 'api worker error';
const err = new Error(msg);
if (info?.type) err.name = info.type;
return err;
}
export function setWorker(worker: Worker): BackendWorker {
if (backend) {
backend.terminate();
}
connected = false;
queue = [];
pending.clear();
backend = createBackendWorker(worker);
backend.onMessage((data: unknown) => {
if (!data || typeof data !== 'object') return;
const msg = data as { type?: string; name?: string };
if (msg.type === 'connect') {
if (!connected) {
connected = true;
backend!.postMessage({ name: 'client-connected-to-backend' });
// Drain anything queued while waiting for the handshake.
const drained = queue;
queue = [];
for (const m of drained) backend!.postMessage(m);
}
return;
}
if (msg.type === 'reply' || msg.type === 'error') {
const reply = msg as Reply;
const p = pending.get(reply.id);
if (!p) return;
pending.delete(reply.id);
if (reply.type === 'error') {
p.reject(toError(reply.error));
} else if ('error' in reply && reply.error) {
// api/* handlers funnel errors through the reply envelope.
p.reject(toError(reply.error));
} else {
p.resolve(reply.result);
}
return;
}
// push/capture-exception/etc. — ignore for now; the api consumer
// doesn't subscribe to loot-core's server events.
});
return backend;
}
export function rpc(name: string, args?: unknown): Promise<unknown> {
if (!backend) {
return Promise.reject(
new Error('@actual-app/api: init() must be called before any api method'),
);
}
return new Promise((resolve, reject) => {
const id = nextId();
pending.set(id, { resolve, reject });
const msg = { id, name, args };
if (connected) {
backend!.postMessage(msg);
} else {
queue.push(msg);
}
});
}
export function terminate() {
if (backend) {
backend.terminate();
backend = null;
}
connected = false;
queue = [];
pending.clear();
}

View File

@@ -1,66 +0,0 @@
// Main-thread browser entry for @actual-app/api.
//
// Public surface matches the Node entry. The worker is spawned internally
// so consumers write:
//
// import * as api from '@actual-app/api';
// await api.init({ dataDir: '/documents', serverURL, password });
// await api.getAccounts();
//
// worker.js must be a sibling of browser.js at runtime. Our build ships
// them together in dist/; the consumer's bundler resolves the worker URL
// via `new URL(..., import.meta.url)`.
import { _setBrowserSend } from './browser/lib-stub';
import type { InitConfig } from './browser/lib-stub';
import { rpc, setWorker, terminate } from './browser/rpc';
export * from './methods';
export * as utils from './utils';
// Wire methods.ts's `lib.send` through the worker.
_setBrowserSend((name, args) => rpc(name, args));
function createWorker(): Worker {
// Vite's `vite:worker-import-meta-url` plugin rewrites this pattern at
// the CONSUMER's build time (emit worker.js as an asset, substitute the
// hashed URL). Feeding it a non-literal first argument keeps the api's
// OWN lib build from trying to pre-bundle it, which would fail because
// ./worker.js is not a source-tree sibling of this file.
const rel = './worker.js';
return new Worker(new URL(rel, import.meta.url), { type: 'module' });
}
export async function init(config: InitConfig = {}) {
setWorker(createWorker());
// Point loot-core's browser fs at our dist/ directory. We want the
// directory portion of this bundle's own URL so loot-core's fetches land
// on files we ship (data-file-index.txt, migrations/, default-db.sqlite,
// sql-wasm.wasm). Vite's asset plugin tries to pre-bundle
// `new URL('.', import.meta.url)` at consumer build time and picks up
// the `development` export condition (inlining index.ts as a data URL!).
// Derive the base URL via string manipulation instead so static analyzers
// leave it alone.
const assetsBaseUrl = import.meta.url.replace(/[^/]+$/, '');
await rpc('api-browser/init', { ...config, __assetsBaseUrl: assetsBaseUrl });
// Return a {send} handle compatible with the Node entry so existing
// consumer code that does `const internal = await api.init(...); internal.send(...)`
// keeps working on the browser build too.
return {
send: (name: string, args?: unknown) => rpc(name, args),
};
}
export async function shutdown() {
try {
await rpc('sync');
} catch {
// most likely no budget loaded
}
try {
await rpc('close-budget');
} catch {
// ignore
}
terminate();
}

View File

@@ -1,30 +1,53 @@
import { init as initLootCore } from '@actual-app/core/server/main';
import type { InitConfig, lib } from '@actual-app/core/server/main';
import type {
RequestInfo as FetchInfo,
RequestInit as FetchInit,
} from 'node-fetch';
// 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
import * as bundle from './app/bundle.api.js';
import * as injected from './injected';
import { validateNodeVersion } from './validateNodeVersion';
let actualApp: null | typeof bundle.lib;
export const internal = bundle.lib;
export * from './methods';
export * as utils from './utils';
/** @deprecated Please use return value of `init` instead */
export let internal: typeof lib | null = null;
export async function init(config: InitConfig = {}) {
if (actualApp) {
return;
}
validateNodeVersion();
internal = await initLootCore(config);
return internal;
if (!globalThis.fetch) {
globalThis.fetch = (url: URL | RequestInfo, init?: RequestInit) => {
return import('node-fetch').then(({ default: fetch }) =>
fetch(url as unknown as FetchInfo, init as unknown as FetchInit),
) as unknown as Promise<Response>;
};
}
await bundle.init(config);
actualApp = bundle.lib;
injected.override(bundle.lib.send);
return bundle.lib;
}
export async function shutdown() {
if (internal) {
if (actualApp) {
try {
await internal.send('sync');
await actualApp.send('sync');
} catch {
// most likely that no budget is loaded, so the sync failed
}
await internal.send('close-budget');
internal = null;
await actualApp.send('close-budget');
actualApp = null;
}
}

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

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

View File

@@ -1,19 +1,21 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import type { RuleEntity } from '@actual-app/core/types/models';
import type { RuleEntity } from 'loot-core/types/models';
import * as api from '../index';
import * as api from './index';
const budgetName = 'test-budget';
global.IS_TESTING = true;
beforeEach(async () => {
const budgetPath = path.join(__dirname, '/../mocks/budgets/', budgetName);
const budgetPath = path.join(__dirname, '/mocks/budgets/', budgetName);
await fs.rm(budgetPath, { force: true, recursive: true });
await createTestBudget('default-budget-template', budgetName);
await api.init({
dataDir: path.join(__dirname, '/../mocks/budgets/'),
dataDir: path.join(__dirname, '/mocks/budgets/'),
});
});
@@ -25,10 +27,10 @@ afterEach(async () => {
async function createTestBudget(templateName: string, name: string) {
const templatePath = path.join(
__dirname,
'/../../loot-core/src/mocks/files',
'/../loot-core/src/mocks/files',
templateName,
);
const budgetPath = path.join(__dirname, '/../mocks/budgets/', name);
const budgetPath = path.join(__dirname, '/mocks/budgets/', name);
await fs.mkdir(budgetPath);
await fs.copyFile(
@@ -875,73 +877,6 @@ describe('API CRUD operations', () => {
);
expect(transactions[0].notes).toBeNull();
});
test('Transactions: reimportDeleted=false prevents reimporting deleted transactions', async () => {
const accountId = await api.createAccount({ name: 'test-account' }, 0);
// Import a transaction
const result1 = await api.importTransactions(accountId, [
{
date: '2023-11-03',
imported_id: 'reimport-test-1',
amount: 100,
account: accountId,
},
]);
expect(result1.added).toHaveLength(1);
// Delete the transaction
await api.deleteTransaction(result1.added[0]);
// Reimport the same transaction with reimportDeleted=false
const result2 = await api.importTransactions(
accountId,
[
{
date: '2023-11-03',
imported_id: 'reimport-test-1',
amount: 100,
account: accountId,
},
],
{ reimportDeleted: false },
);
// Should match the deleted transaction and not create a new one
expect(result2.added).toHaveLength(0);
expect(result2.updated).toHaveLength(0);
});
test('Transactions: reimportDeleted=true reimports deleted transactions', async () => {
const accountId = await api.createAccount({ name: 'test-account' }, 0);
// Import a transaction
const result1 = await api.importTransactions(accountId, [
{
date: '2023-11-03',
imported_id: 'reimport-test-2',
amount: 200,
account: accountId,
},
]);
expect(result1.added).toHaveLength(1);
// Delete the transaction
await api.deleteTransaction(result1.added[0]);
// Reimport the same transaction relying on reimportDeleted=true default
const result2 = await api.importTransactions(accountId, [
{
date: '2023-11-03',
imported_id: 'reimport-test-2',
amount: 200,
account: accountId,
},
]);
// Should create a new transaction since deleted ones are ignored
expect(result2.added).toHaveLength(1);
});
});
//apis: createSchedule, getSchedules, updateSchedule, deleteSchedule

View File

@@ -6,16 +6,17 @@ import type {
APIPayeeEntity,
APIScheduleEntity,
APITagEntity,
} from '@actual-app/core/server/api-models';
import { lib } from '@actual-app/core/server/main';
import type { Query } from '@actual-app/core/shared/query';
import type { ImportTransactionsOpts } from '@actual-app/core/types/api-handlers';
import type { Handlers } from '@actual-app/core/types/handlers';
} from 'loot-core/server/api-models';
import type { Query } from 'loot-core/shared/query';
import type { ImportTransactionsOpts } from 'loot-core/types/api-handlers';
import type { Handlers } from 'loot-core/types/handlers';
import type {
ImportTransactionEntity,
RuleEntity,
TransactionEntity,
} from '@actual-app/core/types/models';
} from 'loot-core/types/models';
import * as injected from './injected';
export { q } from './app/query';
@@ -23,7 +24,7 @@ function send<K extends keyof Handlers, T extends Handlers[K]>(
name: K,
args?: Parameters<T>[0],
): Promise<Awaited<ReturnType<T>>> {
return lib.send(name, args);
return injected.send(name, args);
}
export async function runImport(

View File

@@ -1,6 +1,6 @@
{
"name": "@actual-app/api",
"version": "26.4.0",
"version": "26.3.0",
"description": "An API for Actual",
"license": "MIT",
"files": [
@@ -9,51 +9,29 @@
],
"main": "dist/index.js",
"types": "@types/index.d.ts",
"exports": {
".": {
"types": "./@types/index.d.ts",
"development": "./index.ts",
"browser": "./dist/browser.js",
"default": "./dist/index.js"
}
},
"publishConfig": {
"exports": {
".": {
"types": "./@types/index.d.ts",
"browser": "./dist/browser.js",
"default": "./dist/index.js"
}
}
},
"scripts": {
"build": "npm-run-all -s build:node build:browser-worker build:browser",
"build:node": "vite build --config vite.config.mts && tsgo --emitDeclarationOnly",
"build:browser": "vite build --config vite.browser.config.mts",
"build:browser-worker": "vite build --config vite.browser-worker.config.mts",
"test": "npm-run-all -cp 'test:*'",
"test:node": "vitest --run --config vite.config.mts",
"test:browser": "vitest --run --config vitest.browser.config.mts",
"typecheck": "tsgo -b && tsc-strict"
"build:app": "yarn workspace loot-core build:api",
"build:crdt": "yarn workspace @actual-app/crdt build",
"build:node": "tsc && tsc-alias",
"build:migrations": "cp migrations/*.sql dist/migrations",
"build:default-db": "cp default-db.sqlite dist/",
"build": "yarn run clean && yarn run build:app && yarn run build:node && yarn run build:migrations && yarn run build:default-db",
"test": "yarn run clean && yarn run build:app && yarn run build:crdt && vitest --run",
"clean": "rm -rf dist @types",
"typecheck": "yarn build && tsc --noEmit && tsc-strict"
},
"dependencies": {
"@actual-app/core": "workspace:*",
"@actual-app/crdt": "workspace:*",
"absurd-sql": "0.0.54",
"better-sqlite3": "^12.8.0",
"compare-versions": "^6.1.1"
"@actual-app/crdt": "workspace:^",
"better-sqlite3": "^12.6.2",
"compare-versions": "^6.1.1",
"node-fetch": "^3.3.2",
"uuid": "^13.0.0"
},
"devDependencies": {
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
"fake-indexeddb": "^6.2.5",
"jsdom": "^27.4.0",
"npm-run-all": "^4.1.5",
"rollup-plugin-visualizer": "^7.0.1",
"tsc-alias": "^1.8.16",
"typescript": "^5.9.3",
"typescript-strict-plugin": "^2.4.4",
"vite": "^8.0.5",
"vite-plugin-node-polyfills": "^0.26.0",
"vite-plugin-peggy-loader": "^2.0.1",
"vitest": "^4.1.2"
"vitest": "^4.0.18"
},
"engines": {
"node": ">=20"

View File

@@ -1,183 +0,0 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import * as api from '../index.browser';
// Swap the real Worker constructor for a mock that the tests control. Vitest
// picks this up via vite.config resolve.alias; here we just stand in globally
// because jsdom does not ship Worker at all.
class MockWorker {
public posted: Array<unknown> = [];
public responder: (
req: { id: string; name: string; args?: unknown },
reply: (res: unknown) => void,
) => void = () => undefined;
private listeners: Array<(e: MessageEvent) => void> = [];
onmessage: ((e: MessageEvent) => void) | null = null;
onerror: ((e: ErrorEvent) => void) | null = null;
private connected = false;
addEventListener(type: string, handler: (e: MessageEvent) => void) {
if (type === 'message') this.listeners.push(handler);
}
removeEventListener() {
// no-op for tests
}
postMessage(msg: unknown) {
this.posted.push(msg);
if (
msg &&
typeof msg === 'object' &&
(msg as { name?: string }).name === 'client-connected-to-backend'
) {
// Handshake complete; we won't keep sending 'connect' heartbeats.
return;
}
const req = msg as { id: string; name: string; args?: unknown };
queueMicrotask(() => {
this.responder(req, (data: unknown) => {
const ev = { data } as MessageEvent;
this.onmessage?.(ev);
for (const l of this.listeners) l(ev);
});
});
}
/** Simulate loot-core's connect handshake from the worker side. */
fireConnect() {
if (this.connected) return;
this.connected = true;
const ev = { data: { type: 'connect' } } as MessageEvent;
this.onmessage?.(ev);
for (const l of this.listeners) l(ev);
}
terminate() {
this.listeners = [];
}
}
// Every Worker the api spawns inside init() comes through here.
let lastMockWorker: MockWorker | null = null;
const mockWorkerResponder = vi.fn<
(
req: { id: string; name: string; args?: unknown },
reply: (res: unknown) => void,
) => void
>(() => undefined);
// Global Worker stub — the api's internal `new Worker(...)` will call this.
// @ts-expect-error jsdom has no Worker; we override the global for the test.
globalThis.Worker = class {
constructor(_url: URL | string, _opts?: WorkerOptions) {
const w = new MockWorker();
w.responder = (req, reply) => mockWorkerResponder(req, reply);
lastMockWorker = w;
// Fire the connect handshake on the next tick so init() resolves.
queueMicrotask(() => w.fireConnect());
return w as unknown as Worker;
}
};
// absurd-sql's main-thread bridge expects real Worker event semantics. The
// mock above exposes addEventListener; initSQLBackend just attaches a
// message listener, so it's safe with jsdom.
afterEach(async () => {
// Keep whatever responder the test installed so shutdown's sync/close-budget
// calls resolve rather than hang.
await api.shutdown().catch(() => undefined);
mockWorkerResponder.mockReset();
lastMockWorker = null;
});
describe('@actual-app/api browser facade', () => {
test('spawns a worker on init and forwards config via api-browser/init', async () => {
mockWorkerResponder.mockImplementation((req, reply) => {
reply({ type: 'reply', id: req.id, result: undefined });
});
await api.init({
dataDir: '/documents',
serverURL: 'https://example.test',
password: 'pw',
});
expect(lastMockWorker).toBeTruthy();
// First post after the handshake ack is the api-browser/init request.
const initCall = lastMockWorker!.posted.find(
m =>
m &&
typeof m === 'object' &&
(m as { name?: string }).name === 'api-browser/init',
) as { name: string; args: unknown } | undefined;
expect(initCall).toBeTruthy();
expect(initCall!.args).toMatchObject({
dataDir: '/documents',
serverURL: 'https://example.test',
password: 'pw',
});
// The api also hands over its own asset base URL so loot-core's fs
// can fetch migrations / default-db / WASM from the api's dist/
// instead of the consumer's page origin.
expect(
(initCall!.args as { __assetsBaseUrl?: string }).__assetsBaseUrl,
).toBeTypeOf('string');
});
test('rpc methods forward as {id, name, args} and read {type:reply, result}', async () => {
mockWorkerResponder.mockImplementation((req, reply) => {
if (req.name === 'api-browser/init') {
reply({ type: 'reply', id: req.id, result: undefined });
return;
}
if (req.name === 'api/accounts-get') {
reply({
type: 'reply',
id: req.id,
result: [{ id: 'a1', name: 'Checking' }],
});
return;
}
reply({
type: 'error',
id: req.id,
error: { type: 'APIError', message: 'unexpected' },
});
});
await api.init({ dataDir: '/documents' });
const accounts = await api.getAccounts();
expect(accounts).toEqual([{ id: 'a1', name: 'Checking' }]);
const sendCalls = lastMockWorker!.posted.filter(
m =>
m &&
typeof m === 'object' &&
(m as { name?: string }).name === 'api/accounts-get',
);
expect(sendCalls).toHaveLength(1);
expect((sendCalls[0] as { args?: unknown }).args).toBeUndefined();
});
test('worker errors reject at the call site', async () => {
mockWorkerResponder.mockImplementation((req, reply) => {
if (req.name === 'api-browser/init') {
reply({ type: 'reply', id: req.id, result: undefined });
return;
}
reply({
type: 'reply',
id: req.id,
error: { type: 'APIError', message: 'budget not loaded' },
});
});
await api.init({ dataDir: '/documents' });
await expect(api.getAccounts()).rejects.toThrow(/budget not loaded/);
});
});

View File

@@ -1,43 +0,0 @@
import { afterEach, describe, expect, test } from 'vitest';
import * as api from '../index';
declare const __API_DATA_DIR__: string;
afterEach(async () => {
await api.shutdown();
});
describe('api CRUD roundtrip (Node)', () => {
test('creates a budget, writes, reads it back', async () => {
const internal = await api.init({ dataDir: __API_DATA_DIR__ });
await internal.send('create-budget', {
budgetName: 'Integration Test',
testMode: true,
testBudgetId: 'integration-test',
});
await api.loadBudget('integration-test');
const accountId = await api.createAccount(
{ name: 'Checking', offbudget: false },
0,
);
await api.addTransactions(accountId, [
{ date: '2026-04-01', amount: 1000, payee_name: 'Coffee' },
{ date: '2026-04-02', amount: -500, payee_name: 'Book' },
]);
const accounts = await api.getAccounts();
expect(accounts.map(a => a.name)).toContain('Checking');
const txns = await api.getTransactions(
accountId,
'2026-04-01',
'2026-04-30',
);
expect(txns).toHaveLength(2);
expect(txns.map(t => t.amount).sort((a, b) => a - b)).toEqual([-500, 1000]);
});
});

View File

@@ -1,31 +0,0 @@
import * as fsPromises from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { vi } from 'vitest';
// In tests we run from source; loot-core's API fs uses __dirname (for the built dist/).
// Mock the fs so path constants point at loot-core package root where migrations live.
vi.mock(
'../../loot-core/src/platform/server/fs/index.api',
async importOriginal => {
const actual = (await importOriginal()) as Record<string, unknown>;
const lootCoreRoot = path.join(__dirname, '..', '..', 'loot-core');
return {
...actual,
migrationsPath: path.join(lootCoreRoot, 'migrations'),
bundledDatabasePath: path.join(lootCoreRoot, 'default-db.sqlite'),
demoBudgetPath: path.join(lootCoreRoot, 'demo-budget'),
};
},
);
global.IS_TESTING = true;
// Shared integration test lives in a filesystem-backed tmp dir.
const dataDir = path.join(
os.tmpdir(),
`api-it-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
await fsPromises.mkdir(dataDir, { recursive: true });
globalThis.__API_DATA_DIR__ = dataDir;

View File

@@ -5,32 +5,22 @@
// Using ES2021 because that's the newest version where
// the latest Node 16.x release supports all of the features
"target": "ES2021",
"module": "es2022",
"moduleResolution": "bundler",
"customConditions": ["api"],
// composite + declaration: true require `noEmit: false`, so use
// emitDeclarationOnly to keep typecheck + project refs working without
// clobbering the Vite build artifacts in dist/. build:node also passes
// --emitDeclarationOnly on the CLI (redundant but explicit).
"module": "CommonJS",
"moduleResolution": "node10",
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": true,
"outDir": "dist",
"rootDir": ".",
"declarationDir": "@types",
"tsBuildInfoFile": "dist/.tsbuildinfo",
"paths": {
// TEMPORARY
"loot-core/*": ["../loot-core/src/*"]
},
"plugins": [{ "name": "typescript-strict-plugin", "paths": ["."] }]
},
"references": [{ "path": "../crdt" }, { "path": "../loot-core" }],
"include": ["."],
"exclude": [
"**/node_modules/*",
"dist",
"@types",
"**/*.test.ts",
"test/setup.*.ts",
"*.config.ts",
"*.config.mts"
]
"exclude": ["**/node_modules/*", "dist", "@types", "*.test.ts"]
}

View File

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

View File

@@ -1,4 +1,6 @@
import { lib } from '@actual-app/core/server/main';
// 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 = lib.amountToInteger;
export const integerToAmount = lib.integerToAmount;
export const amountToInteger = bundle.lib.amountToInteger;
export const integerToAmount = bundle.lib.integerToAmount;

View File

@@ -1,62 +0,0 @@
import path from 'path';
import { defineConfig } from 'vite';
import { nodePolyfills } from 'vite-plugin-node-polyfills';
import peggyLoader from 'vite-plugin-peggy-loader';
const distDir = path.resolve(__dirname, 'dist');
// Worker bundle: contains the full loot-core + sql.js + absurd-sql stack.
// Runs inside a Web Worker where absurd-sql's Atomics.wait has the right
// thread context. Consumer spawns the worker with this file as the entry.
export default defineConfig({
define: {
// NODE_ENV is read at build time by dead-code elimination paths and
// must stay a literal. The others (PUBLIC_URL, DATA_DIR, SERVER_URL,
// DOCUMENT_DIR) are set at runtime via the `api-browser/init` handler
// which receives them from the main thread — so they stay as
// `process.env.<name>` references and the nodePolyfills-provided
// process shim serves as the backing store.
'process.env.NODE_ENV': JSON.stringify('production'),
},
build: {
target: 'esnext',
outDir: distDir,
emptyOutDir: false,
sourcemap: true,
lib: {
entry: path.resolve(__dirname, 'browser-worker.ts'),
formats: ['es'],
fileName: () => 'worker.js',
},
rollupOptions: {
output: {
codeSplitting: false,
},
},
},
plugins: [
peggyLoader(),
nodePolyfills({
include: [
'process',
'buffer',
'stream',
'path',
'crypto',
'timers',
'util',
'zlib',
'fs',
'assert',
],
globals: {
process: true,
Buffer: true,
global: true,
},
}),
],
// Intentionally no resolve.conditions: ['api'] — loot-core falls back to
// its default (browser) platform files.
});

View File

@@ -1,39 +0,0 @@
import path from 'path';
import { defineConfig } from 'vite';
const distDir = path.resolve(__dirname, 'dist');
// Main-thread facade only. Tiny bundle: no loot-core, no sql.js, no absurd-sql.
// The worker is built separately by vite.browser-worker.config.mts. The
// consumer constructs the Worker (handling URL resolution through their own
// bundler) and hands it to init().
export default defineConfig({
build: {
target: 'esnext',
outDir: distDir,
emptyOutDir: false,
sourcemap: true,
lib: {
entry: path.resolve(__dirname, 'index.browser.ts'),
formats: ['es'],
fileName: () => 'browser.js',
},
rollupOptions: {
output: {
codeSplitting: false,
},
},
},
resolve: {
alias: {
// methods.ts reads `lib.send` from loot-core's server/main. Route it
// through the main-thread stub so loot-core is never pulled into
// the main bundle.
'@actual-app/core/server/main': path.resolve(
__dirname,
'browser/lib-stub.ts',
),
},
},
});

View File

@@ -1,145 +0,0 @@
import fs from 'fs';
import path from 'path';
import { visualizer } from 'rollup-plugin-visualizer';
import { defineConfig } from 'vite';
import peggyLoader from 'vite-plugin-peggy-loader';
const lootCoreRoot = path.resolve(__dirname, '../loot-core');
const distDir = path.resolve(__dirname, 'dist');
const typesDir = path.resolve(__dirname, '@types');
function cleanOutputDirs() {
return {
name: 'clean-output-dirs',
buildStart() {
if (fs.existsSync(distDir)) fs.rmSync(distDir, { recursive: true });
if (fs.existsSync(typesDir)) fs.rmSync(typesDir, { recursive: true });
},
};
}
function copyMigrationsAndDefaultDb() {
return {
name: 'copy-migrations-and-default-db',
closeBundle() {
const migrationsSrc = path.join(lootCoreRoot, 'migrations');
const defaultDbPath = path.join(lootCoreRoot, 'default-db.sqlite');
if (!fs.existsSync(migrationsSrc)) {
throw new Error(`migrations directory not found at ${migrationsSrc}`);
}
const migrationsStat = fs.statSync(migrationsSrc);
if (!migrationsStat.isDirectory()) {
throw new Error(`migrations path is not a directory: ${migrationsSrc}`);
}
const migrationsDest = path.join(distDir, 'migrations');
fs.mkdirSync(migrationsDest, { recursive: true });
for (const name of fs.readdirSync(migrationsSrc)) {
if (name.endsWith('.sql') || name.endsWith('.js')) {
fs.copyFileSync(
path.join(migrationsSrc, name),
path.join(migrationsDest, name),
);
}
}
if (!fs.existsSync(defaultDbPath)) {
throw new Error(`default-db.sqlite not found at ${defaultDbPath}`);
}
fs.copyFileSync(defaultDbPath, path.join(distDir, 'default-db.sqlite'));
// Browser consumers need sql.js' WASM to be served at the same origin
// as the bundle. Ship it alongside dist/ so downstream apps just point
// a static handler at dist and don't have to reach into node_modules.
const sqlJsWasm = require.resolve('@jlongster/sql.js/dist/sql-wasm.wasm');
fs.copyFileSync(sqlJsWasm, path.join(distDir, 'sql-wasm.wasm'));
// loot-core's browser fs bootstraps by fetching:
// `${PUBLIC_URL}data-file-index.txt` - flat manifest
// `${PUBLIC_URL}data/<name>` - each file listed in the manifest
// We point PUBLIC_URL at the api's dist dir at runtime (see
// index.browser.ts), so these two shapes need to exist here.
//
// JS migrations get a `.data` suffix on the *wire* path. Consumer
// bundlers (Vite's dev server first, others to varying degrees)
// auto-transform `.js` URLs through their import-analysis pipelines,
// which fails on loot-core's `#`-subpath imports. The api's worker
// (browser-worker.ts) wraps `fetch` to translate back to `.js` so
// loot-core's migration runner finds the file under its original
// name in the virtual FS. `.sql` migrations stay as-is.
const dataDir = path.join(distDir, 'data');
const dataMigrationsDir = path.join(dataDir, 'migrations');
fs.mkdirSync(dataMigrationsDir, { recursive: true });
linkOrCopy(
path.join(distDir, 'default-db.sqlite'),
path.join(dataDir, 'default-db.sqlite'),
);
const wireMigrationNames: string[] = [];
for (const name of fs.readdirSync(migrationsDest)) {
const wireName = name.endsWith('.js') ? `${name}.data` : name;
linkOrCopy(
path.join(migrationsDest, name),
path.join(dataMigrationsDir, wireName),
);
wireMigrationNames.push(`migrations/${wireName}`);
}
wireMigrationNames.sort();
// data-file-index.txt: one path per line, relative to `data/`.
const manifest =
['default-db.sqlite', ...wireMigrationNames].join('\n') + '\n';
fs.writeFileSync(path.join(distDir, 'data-file-index.txt'), manifest);
},
};
}
function linkOrCopy(src: string, dest: string) {
try {
fs.linkSync(src, dest);
} catch {
fs.copyFileSync(src, dest);
}
}
export default defineConfig({
ssr: {
noExternal: true,
external: ['better-sqlite3'],
resolve: { conditions: ['api'] },
},
build: {
ssr: true,
target: 'node20',
outDir: distDir,
emptyOutDir: true,
sourcemap: true,
lib: {
entry: path.resolve(__dirname, 'index.ts'),
formats: ['cjs'],
fileName: () => 'index.js',
},
},
plugins: [
cleanOutputDirs(),
peggyLoader(),
copyMigrationsAndDefaultDb(),
visualizer({ template: 'raw-data', filename: 'app/stats.json' }),
],
resolve: {
conditions: ['api'],
},
test: {
globals: true,
environment: 'node',
setupFiles: ['./test/setup.node.ts'],
exclude: ['**/node_modules/**', '**/browser-facade.test.ts'],
onConsoleLog(log: string, type: 'stdout' | 'stderr'): boolean | void {
// print only console.error
return type === 'stderr';
},
maxWorkers: 2,
},
});

View File

@@ -1,35 +0,0 @@
import path from 'path';
import { defineConfig } from 'vite';
import peggyLoader from 'vite-plugin-peggy-loader';
// Deliberately independent from vite.browser.config.mts: the build config
// applies node polyfills that would swap out Node fs in the test setup
// file. The test setup uses real Node fs to stream the on-disk fixtures
// (default-db.sqlite, migrations, sql.js WASM) through a fetch polyfill.
export default defineConfig({
plugins: [peggyLoader()],
// The facade test imports `../index.browser` directly and uses a mock
// Worker. loot-core never loads on the main thread, so no platform
// condition juggling is needed here. The sibling vite.browser.config.mts
// aliases loot-core to the stub for the bundled facade; for the test we
// mirror that so `methods.ts` resolves correctly.
resolve: {
alias: {
'@actual-app/core/server/main': path.resolve(
__dirname,
'browser/lib-stub.ts',
),
},
},
test: {
globals: true,
environment: 'jsdom',
include: ['test/browser-facade.test.ts'],
onConsoleLog(log: string, type: 'stdout' | 'stderr'): boolean | void {
return type === 'stderr';
},
maxWorkers: 2,
},
});

View File

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

View File

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

View File

@@ -2,13 +2,13 @@
// This script is used in GitHub Actions to get the next version based on the current package.json version.
// It supports three types of versioning: nightly, hotfix, and monthly.
import fs from 'node:fs';
import { parseArgs } from 'node:util';
import {
getNextVersion,
isValidVersionType,
} from '../src/versions/get-next-package-version';
import { getNextVersion } from '../src/versions/get-next-package-version.js';
const args = process.argv;
const options = {
'package-json': {
@@ -28,53 +28,40 @@ const options = {
short: 'u',
default: false,
},
} as const;
function fail(message: string): never {
console.error(message);
process.exit(1);
}
};
const { values } = parseArgs({
args,
options,
allowPositionals: true,
});
const packageJsonPath = values['package-json'];
if (!packageJsonPath) {
fail(
if (!values['package-json']) {
console.error(
'Please specify the path to package.json using --package-json or -p option.',
);
process.exit(1);
}
try {
const packageJsonPath = values['package-json'];
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
if (!('version' in packageJson) || typeof packageJson.version !== 'string') {
fail('The specified package.json does not contain a valid version field.');
}
const currentVersion = packageJson.version;
const explicitVersion = values.version;
let newVersion;
if (explicitVersion) {
newVersion = explicitVersion;
} else {
const type = values.type;
if (!type || !isValidVersionType(type)) {
fail('Please specify the release type using --type or -t.');
}
try {
newVersion = getNextVersion({
currentVersion,
type,
type: values.type,
currentDate: new Date(),
});
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
} catch (e) {
console.error(e.message);
process.exit(1);
}
}
@@ -89,5 +76,6 @@ try {
);
}
} catch (error) {
fail(`Error: ${error instanceof Error ? error.message : String(error)}`);
console.error('Error:', error.message);
process.exit(1);
}

View File

@@ -1,68 +0,0 @@
import * as fs from 'node:fs';
import matter from 'gray-matter';
import {
categoryAutocorrections,
categoryOrder,
} from '../src/release-notes/util.mjs';
console.log('Looking in ' + fs.realpathSync('upcoming-release-notes'));
const expectedPath = `upcoming-release-notes/${process.env.PR_NUMBER}.md`;
function reportError(message) {
console.log(`::error::${message}`);
process.stdout.write('::notice::');
fs.createReadStream('upcoming-release-notes/README.md').pipe(process.stdout);
fs.createReadStream('upcoming-release-notes/README.md')
.pipe(fs.createWriteStream(process.env.GITHUB_STEP_SUMMARY))
.on('close', () => {
process.exit(1);
});
}
(() => {
if (!fs.existsSync(expectedPath)) {
reportError(`Release note file ${expectedPath} not found`);
return;
}
const { data, content } = matter(fs.readFileSync(expectedPath, 'utf-8'));
if (!data.category) {
reportError(`Release note is missing a category.`);
return;
}
if (categoryAutocorrections[data.category]) {
data.category = categoryAutocorrections[data.category];
}
if (!categoryOrder.includes(data.category)) {
reportError(
`Release note category "${data.category}" is not one of ${categoryOrder
.map(JSON.stringify)
.join(', ')}`,
);
return;
}
if (!data.authors) {
reportError(`Release note is missing authors.`);
return;
}
if (!Array.isArray(data.authors)) {
reportError(`Release note authors should be a list.`);
return;
}
if (content.trim().split('\n').length !== 1) {
reportError(
`Release note file ${expectedPath} body should contain exactly one line`,
);
return;
}
console.log('Everything looks good! \u{1f389}');
})();

View File

@@ -1,229 +0,0 @@
import * as childProcess from 'node:child_process';
import * as fs from 'node:fs/promises';
import { join } from 'node:path';
import { inspect, promisify } from 'node:util';
import matter from 'gray-matter';
import listify from 'listify';
import {
categoryAutocorrections,
categoryOrder,
} from '../src/release-notes/util.mjs';
const exec = promisify(childProcess.exec);
const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/');
const apiResult = await fetch('https://api.github.com/graphql', {
method: 'POST',
headers: {
Authorization: `bearer ${process.env.GITHUB_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: /* GraphQL */ `
query GetPRMetadata(
$name: String!
$owner: String!
$headRefName: String!
) {
repository(name: $name, owner: $owner) {
pullRequests(headRefName: $headRefName, first: 1) {
edges {
node {
number
headRefName
body
}
}
}
}
}
`,
variables: {
name: repo,
owner,
headRefName: process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME,
},
}),
}).then(res => res.json());
await collapsedLog('API Response', apiResult);
const prData = apiResult.data.repository.pullRequests.edges[0].node;
const version = prData.headRefName.split('/')[1].replace(/^v/, '');
const slug = version.replace(/\./g, '-');
const author = process.env.GITHUB_ACTOR || 'TODO';
const commitMessage = `Generate release notes for v${version}`;
const releaseDateMatch = (prData.body || '').match(
/<!-- release-date:(\d{4}-\d{2}-\d{2}) -->/,
);
const releaseDate = releaseDateMatch ? releaseDateMatch[1] : 'TODO';
const botName = 'github-actions[bot]';
const botEmail = '41898282+github-actions[bot]@users.noreply.github.com';
await exec(`git config user.name '${botName}'`);
await exec(`git config user.email '${botEmail}'`);
await group('Prepare branch', async () => {
if (process.env.GITHUB_HEAD_REF) {
await exec(`git fetch origin ${process.env.GITHUB_HEAD_REF}`, {
stdio: 'inherit',
});
await exec(`git checkout ${process.env.GITHUB_HEAD_REF}`, {
stdio: 'inherit',
});
}
// the previous generation commit deletes source files from
// upcoming-release-notes, rebase it out so we can regenerate from all of them
const { stdout: commitHash } = await exec(
`git log --grep='${commitMessage}' --format=%H -1`,
);
const hash = commitHash.trim();
if (hash) {
console.log(`Dropping previous release notes commit ${hash}`);
await exec(`git rebase --onto ${hash}~1 ${hash}`, {
stdio: 'inherit',
});
}
});
const { notesByCategory, files } = await parseReleaseNotes(
'upcoming-release-notes',
);
const categorizedNotes = formatNotes(notesByCategory);
await collapsedLog('Release Notes', categorizedNotes);
if (files.length === 0) {
console.log('No release notes found, nothing to generate');
process.exit(0);
}
const highlights = '- TODO: Add release highlights';
await group('Generate blog post', async () => {
const blogPath = join(
'packages/docs/blog',
`${releaseDate}-release-${slug}.md`,
);
const blogContent = `---
title: Release ${version}
description: New release of Actual.
date: ${releaseDate}T10:00
slug: release-${version}
tags: [announcement, release]
hide_table_of_contents: false
authors: ${author}
---
${highlights}
<!--truncate-->
**Docker Tag: ${version}**
${categorizedNotes}
`;
await fs.writeFile(blogPath, blogContent);
console.log(`Wrote ${blogPath}`);
});
await group('Update releases.md', async () => {
const releasesPath = 'packages/docs/docs/releases.md';
const existing = await fs.readFile(releasesPath, 'utf-8');
const newSection = `## ${version}
Release date: ${releaseDate}
${highlights}
**Docker Tag: ${version}**
${categorizedNotes}`;
const updated = existing.replace(
'# Release Notes\n',
`# Release Notes\n\n${newSection}\n`,
);
await fs.writeFile(releasesPath, updated);
console.log(`Updated ${releasesPath}`);
});
await group('Remove used release notes', async () => {
await Promise.all(
files.map(f => fs.unlink(join('upcoming-release-notes', f))),
);
});
await group('Commit and push', async () => {
await exec(
'git add upcoming-release-notes packages/docs/blog packages/docs/docs/releases.md',
{ stdio: 'inherit' },
);
await exec(`git commit -m '${commitMessage}'`);
await exec('git push --force-with-lease origin', { stdio: 'inherit' });
});
async function parseReleaseNotes(dir) {
const files = (await fs.readdir(dir)).filter(f => f.match(/^\d+\.md$/));
const notes = files.map(async name => {
const content = await fs.readFile(join(dir, name), 'utf-8');
const { data, content: body } = matter(content);
const number = name.replace('.md', '');
const authors = listify(
data.authors.map(a => `@${a}`),
{ finalWord: '&' },
);
return {
category: categoryAutocorrections[data.category] ?? data.category,
value: `- [#${number}](https://github.com/actualbudget/${repo}/pull/${number}) ${body.trim()} — thanks ${authors}`,
};
});
const notesByCategory = (await Promise.all(notes)).reduce(
(acc, note) => {
if (!acc[note.category]) {
console.log(`WARNING: Unrecognized category "${note.category}"`);
acc[note.category] = [];
}
acc[note.category].push(note.value);
return acc;
},
Object.fromEntries(categoryOrder.map(c => [c, []])),
);
return { notesByCategory, files };
}
function formatNotes(notes) {
return Object.entries(notes)
.filter(([_, values]) => values.length > 0)
.map(([category, values]) => `#### ${category}\n\n${values.join('\n')}`)
.join('\n\n');
}
async function collapsedLog(name, value) {
await group(name, () => {
if (typeof value === 'string') {
console.log(value);
} else {
console.log(inspect(value, { depth: null }));
}
});
}
async function group(name, cb) {
console.log(`::group::${name}`);
await cb();
console.log('::endgroup::');
}

View File

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

View File

@@ -3,21 +3,9 @@
"private": true,
"type": "module",
"scripts": {
"tsx": "bin/tsx",
"test": "vitest --run",
"typecheck": "tsgo -b"
"test": "vitest --run"
},
"devDependencies": {
"@octokit/rest": "^22.0.1",
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
"extensionless": "^2.0.6",
"gray-matter": "^4.0.3",
"listify": "^1.0.3",
"vitest": "^4.1.2"
},
"extensionless": {
"lookFor": [
"ts"
]
"vitest": "^4.0.18"
}
}

View File

@@ -1,12 +0,0 @@
export const categoryAutocorrections = {
Feature: 'Features',
Enhancement: 'Enhancements',
Bugfix: 'Bugfixes',
};
export const categoryOrder = [
'Features',
'Enhancements',
'Bugfixes',
'Maintenance',
];

View File

@@ -1,69 +1,35 @@
export const versionTypeArray = [
'auto',
'hotfix',
'monthly',
'nightly',
] as const;
export type VersionType = (typeof versionTypeArray)[number];
type ParsedVersion = {
versionYear: number;
versionMonth: number;
versionHotfix: number;
};
type GetNextVersionOptions = {
currentVersion: string;
type: VersionType;
currentDate?: Date;
};
function parseVersion(version: string): ParsedVersion {
function parseVersion(version) {
const [y, m, p] = version.split('.');
return {
versionYear: Number.parseInt(y, 10),
versionMonth: Number.parseInt(m, 10),
versionHotfix: Number.parseInt(p, 10),
versionYear: parseInt(y, 10),
versionMonth: parseInt(m, 10),
versionHotfix: parseInt(p, 10),
};
}
function computeNextMonth(versionYear: number, versionMonth: number) {
const versionDate = new Date(2000 + versionYear, versionMonth - 1, 1);
function computeNextMonth(versionYear, versionMonth) {
// Create date and add 1 month
const versionDate = new Date(2000 + versionYear, versionMonth - 1, 1); // month is 0-indexed
const nextVersionMonthDate = new Date(
versionDate.getFullYear(),
versionDate.getMonth() + 1,
1,
);
// Format back to YY.M format
const fullYear = nextVersionMonthDate.getFullYear();
const nextVersionYear = fullYear.toString().slice(fullYear < 2100 ? -2 : -3);
const nextVersionMonth = nextVersionMonthDate.getMonth() + 1;
const nextVersionMonth = nextVersionMonthDate.getMonth() + 1; // Convert back to 1-indexed
return { nextVersionYear, nextVersionMonth };
}
export function isValidVersionType(value: string): value is VersionType {
return versionTypeArray.includes(value as VersionType);
}
function resolveType(
type: VersionType,
currentDate: Date,
versionYear: number,
versionMonth: number,
) {
if (type !== 'auto') {
return type;
}
// Determine logical type from 'auto' based on the current date and version
function resolveType(type, currentDate, versionYear, versionMonth) {
if (type !== 'auto') return type;
const inPatchMonth =
currentDate.getFullYear() === 2000 + versionYear &&
currentDate.getMonth() + 1 === versionMonth;
if (inPatchMonth && currentDate.getDate() < 25) {
return 'hotfix';
}
if (inPatchMonth && currentDate.getDate() <= 25) return 'hotfix';
return 'monthly';
}
@@ -71,7 +37,7 @@ export function getNextVersion({
currentVersion,
type,
currentDate = new Date(),
}: GetNextVersionOptions) {
}) {
const { versionYear, versionMonth, versionHotfix } =
parseVersion(currentVersion);
const { nextVersionYear, nextVersionMonth } = computeNextMonth(
@@ -85,10 +51,11 @@ export function getNextVersion({
versionMonth,
);
// Format date stamp once for nightly
const currentDateString = currentDate
.toISOString()
.split('T')[0]
.replace(/-/g, '');
.replaceAll('-', '');
switch (resolvedType) {
case 'nightly':
@@ -99,7 +66,7 @@ export function getNextVersion({
return `${nextVersionYear}.${nextVersionMonth}.0`;
default:
throw new Error(
`Invalid type ${String(resolvedType satisfies never)} specified. Use "auto", "nightly", "hotfix", or "monthly".`,
'Invalid type specified. Use "auto", "nightly", "hotfix", or "monthly".',
);
}
}

View File

@@ -77,7 +77,7 @@ describe('getNextVersion (lib)', () => {
expect(() =>
getNextVersion({
currentVersion: '25.8.4',
type: 'unknown' as never,
type: 'unknown',
currentDate: new Date('2025-08-10'),
}),
).toThrow(/Invalid type/);

View File

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

View File

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

View File

@@ -1,177 +0,0 @@
# @actual-app/cli
> **WARNING:** This CLI is experimental.
Command-line interface for [Actual Budget](https://actualbudget.org). Query and modify your budget data from the terminal — accounts, transactions, categories, payees, rules, schedules, and more.
> **Note:** This CLI connects to a running [Actual sync server](https://actualbudget.org/docs/install/). It does not operate on local budget files directly.
## Installation
```bash
npm install -g @actual-app/cli
```
Requires Node.js >= 22.
## Quick Start
```bash
# Set connection details
export ACTUAL_SERVER_URL=http://localhost:5006
export ACTUAL_PASSWORD=your-password
export ACTUAL_SYNC_ID=your-sync-id # Found in Settings → Advanced → Sync ID
# List your accounts
actual accounts list
# Check a balance
actual accounts balance <account-id>
# View this month's budget
actual budgets month 2026-03
```
## Configuration
Configuration is resolved in this order (highest priority first):
1. **CLI flags** (`--server-url`, `--password`, etc.)
2. **Environment variables**
3. **Config file** (via [cosmiconfig](https://github.com/cosmiconfig/cosmiconfig))
4. **Defaults** (`dataDir` defaults to `~/.actual-cli/data`)
### Environment Variables
| Variable | Description |
| ---------------------- | --------------------------------------------- |
| `ACTUAL_SERVER_URL` | URL of the Actual sync server (required) |
| `ACTUAL_PASSWORD` | Server password (required unless using token) |
| `ACTUAL_SESSION_TOKEN` | Session token (alternative to password) |
| `ACTUAL_SYNC_ID` | Budget Sync ID (required for most commands) |
| `ACTUAL_DATA_DIR` | Local directory for cached budget data |
### Config File
Create an `.actualrc.json` (or `.actualrc`, `.actualrc.yaml`, `actual.config.js`):
```json
{
"serverUrl": "http://localhost:5006",
"password": "your-password",
"syncId": "1cfdbb80-6274-49bf-b0c2-737235a4c81f"
}
```
**Security:** Do not store plaintext passwords in config files (e.g. `.actualrc.json`, `.actualrc`, `.actualrc.yaml`, `actual.config.js`). Add these files to `.gitignore` if they contain secrets. Prefer the `ACTUAL_SESSION_TOKEN` environment variable instead of the `password` field. See [Environment Variables](#environment-variables) for using a session token.
### Global Flags
| Flag | Description |
| ------------------------- | ----------------------------------------------- |
| `--server-url <url>` | Server URL |
| `--password <pw>` | Server password |
| `--session-token <token>` | Session token |
| `--sync-id <id>` | Budget Sync ID |
| `--data-dir <path>` | Data directory |
| `--format <format>` | Output format: `json` (default), `table`, `csv` |
| `--verbose` | Show informational messages |
## Commands
| Command | Description |
| ----------------- | ------------------------------ |
| `accounts` | Manage accounts |
| `budgets` | Manage budgets and allocations |
| `categories` | Manage categories |
| `category-groups` | Manage category groups |
| `transactions` | Manage transactions |
| `payees` | Manage payees |
| `tags` | Manage tags |
| `rules` | Manage transaction rules |
| `schedules` | Manage scheduled transactions |
| `query` | Run an ActualQL query |
| `server` | Server utilities and lookups |
Run `actual <command> --help` for subcommands and options.
### Examples
```bash
# List all accounts (as a table; excludes closed by default)
actual accounts list [--include-closed] --format table
# Find an entity ID by name
actual server get-id --type accounts --name "Checking"
# Add a transaction (amount in integer cents: -2500 = -$25.00)
actual transactions add --account <id> \
--data '[{"date":"2026-03-14","amount":-2500,"payee_name":"Coffee Shop"}]'
# Export transactions to CSV
actual transactions list --account <id> \
--start 2026-01-01 --end 2026-12-31 --format csv > transactions.csv
# Set budget amount ($500 = 50000 cents)
actual budgets set-amount --month 2026-03 --category <id> --amount 50000
# Run an ActualQL query
actual query run --table transactions \
--select "date,amount,payee" --filter '{"amount":{"$lt":0}}' --limit 10
```
### Amount Convention
All monetary amounts are **integer cents** when passed as input (flags, JSON):
| CLI Value | Dollar Amount |
| --------- | ------------- |
| `5000` | $50.00 |
| `-12350` | -$123.50 |
**Output formatting:** Table (`--format table`) and CSV (`--format csv`) output automatically converts cent values to decimal (e.g. `1665.00` instead of `166500`). JSON output always returns raw cents for programmatic use.
### Tips & Common Pitfalls
- **Split transactions:** When summing or counting transactions, filter `"is_parent": false` to avoid double-counting. A split parent holds the total amount, and its children hold the individual parts — including both would count the total twice.
- **Avoid rapid sequential requests:** Each CLI invocation opens a new server connection. Running queries in a tight loop (e.g. one per month) may trigger rate limiting or authentication failures. Instead, fetch all data in a single query with a date range filter and process locally:
```bash
# Good: single query for the full year
actual query run --table transactions \
--filter '{"$and":[{"date":{"$gte":"2025-01-01"}},{"date":{"$lte":"2025-12-31"}}]}' \
--limit 5000
# Bad: one query per month in a loop (may fail with auth errors)
for month in 01 02 03 ...; do actual query run ...; done
```
- **Uncategorized transactions:** `category.name` is `null` for transactions without a category. Account for this when filtering or grouping by category.
- **No date sub-fields in AQL:** `date.month`, `date.year`, etc. are not supported as query fields. To group by month, fetch raw transactions with a date range filter and aggregate locally in a script.
## Running Locally (Development)
If you're working on the CLI within the monorepo:
```bash
# 1. Build the CLI
yarn build:cli
# 2. Start a local sync server (in a separate terminal)
yarn start:server-dev
# 3. Open http://localhost:5006 in your browser, create a budget,
# then find the Sync ID in Settings → Advanced → Sync ID
# 4. Run the CLI directly from the build output
ACTUAL_SERVER_URL=http://localhost:5006 \
ACTUAL_PASSWORD=your-password \
ACTUAL_SYNC_ID=your-sync-id \
node packages/cli/dist/cli.js accounts list
# Or use a shorthand alias for convenience
alias actual-dev="node $(pwd)/packages/cli/dist/cli.js"
actual-dev budgets list
```

View File

@@ -1,43 +0,0 @@
{
"name": "@actual-app/cli",
"version": "26.4.0",
"description": "CLI for Actual Budget",
"license": "MIT",
"bin": {
"actual": "./dist/cli.js",
"actual-cli": "./dist/cli.js"
},
"files": [
"dist"
],
"type": "module",
"imports": {
"#commands/*": "./src/commands/*.ts",
"#config": "./src/config.ts",
"#connection": "./src/connection.ts",
"#input": "./src/input.ts",
"#output": "./src/output.ts",
"#utils": "./src/utils.ts"
},
"scripts": {
"build": "vite build",
"test": "vitest --run",
"typecheck": "tsgo -b"
},
"dependencies": {
"@actual-app/api": "workspace:*",
"cli-table3": "^0.6.5",
"commander": "^14.0.3",
"cosmiconfig": "^9.0.1"
},
"devDependencies": {
"@types/node": "^22.19.17",
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
"rollup-plugin-visualizer": "^7.0.1",
"vite": "^8.0.5",
"vitest": "^4.1.2"
},
"engines": {
"node": ">=22"
}
}

View File

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

View File

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

View File

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

View File

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

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