Compare commits

..

1 Commits

Author SHA1 Message Date
Claude
6dce328b00 [AI] Improve CLI transactions and query commands for better usability
- Flatten nested objects in table/csv output instead of showing [object Object]
- Make --start/--end optional on transactions list (defaults to last 30 days)
- Resolve payee, category, and account names in transactions list output via AQL
- Add --exclude-transfers flag to transactions list and query run
- Add query describe command for full schema output in one call
- Add select shorthand aliases (payee → payee.name, category → category.name)
- Add field descriptions to query fields/describe output (e.g. amount is in cents)
- Add CliError class with suggestion messages for better error guidance

https://claude.ai/code/session_01Tzo7tE4YutV9hqgHNYonXB
2026-03-21 21:56:05 +00:00
1723 changed files with 17340 additions and 30919 deletions

View File

@@ -16,19 +16,14 @@ if (!token || !repo || !issueNumber || !summaryDataJson || !category) {
const [owner, repoName] = repo.split('/'); const [owner, repoName] = repo.split('/');
const octokit = new Octokit({ auth: token }); 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() { async function createReleaseNotesFile() {
try { try {
const summaryData = JSON.parse(summaryDataJson); 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) { if (!summaryData) {
console.log('No summary data available, cannot create file'); console.log('No summary data available, cannot create file');
return; return;
@@ -39,62 +34,26 @@ async function createReleaseNotesFile() {
return; return;
} }
// Normalize category - strip surrounding quotes and validate against allow-list // Create file content - ensure category is not quoted
const cleanCategory = const cleanCategory =
typeof category === 'string' typeof category === 'string'
? category.replace(/^["']|["']$/g, '') ? category.replace(/^["']|["']$/g, '')
: category; : category;
console.log('Debug - Clean category:', cleanCategory);
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;
}
const fileContent = `--- const fileContent = `---
category: ${cleanCategory} 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( console.log(`Creating release notes file: ${fileName}`);
`Creating release notes file: ${fileName} (category: ${cleanCategory}, author: ${author})`, console.log('File content:');
); console.log(fileContent);
// Get PR info // Get PR info
const { data: pr } = await octokit.rest.pulls.get({ const { data: pr } = await octokit.rest.pulls.get({
@@ -116,7 +75,7 @@ ${cleanSummary}
owner: headOwner, owner: headOwner,
repo: headRepo, repo: headRepo,
path: fileName, path: fileName,
message: `Add release notes for PR #${validatedPrNumber}`, message: `Add release notes for PR #${summaryData.prNumber}`,
content: Buffer.from(fileContent).toString('base64'), content: Buffer.from(fileContent).toString('base64'),
branch: prBranch, branch: prBranch,
committer: { committer: {

View File

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

View File

@@ -39,22 +39,6 @@ async function getPRDetails() {
console.log('- Base Branch:', pr.base.ref); console.log('- Base Branch:', pr.base.ref);
console.log('- Head Branch:', pr.head.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 = { const result = {
number: pr.number, number: pr.number,
author: pr.user.login, author: pr.user.login,
@@ -63,31 +47,11 @@ async function getPRDetails() {
headBranch: pr.head.ref, 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('result', JSON.stringify(result));
setOutput('eligible', JSON.stringify(eligible));
} catch (error) { } catch (error) {
console.log('Error getting PR details:', error.message); console.log('Error getting PR details:', error.message);
console.log('Stack:', error.stack); console.log('Stack:', error.stack);
setOutput('result', 'null'); setOutput('result', 'null');
setOutput('eligible', 'false');
process.exit(1); process.exit(1);
} }
} }
@@ -96,6 +60,5 @@ getPRDetails().catch(error => {
console.log('Unhandled error:', error.message); console.log('Unhandled error:', error.message);
console.log('Stack:', error.stack); console.log('Stack:', error.stack);
setOutput('result', 'null'); setOutput('result', 'null');
setOutput('eligible', 'false');
process.exit(1); process.exit(1);
}); });

View File

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

View File

@@ -4,7 +4,6 @@ ABNANL
Activo Activo
actualrc actualrc
AESUDEF AESUDEF
ajv
ALZEY ALZEY
Anglais Anglais
ANZ ANZ
@@ -113,6 +112,7 @@ Keycloak
Khurozov Khurozov
KORT KORT
Kreditbank Kreditbank
KRW
lage lage
LHV LHV
LHVBEE LHVBEE
@@ -126,7 +126,6 @@ Moldovan
murmurhash murmurhash
NETWORKDAYS NETWORKDAYS
nginx nginx
nodenext
OIDC OIDC
Okabe Okabe
overbudgeted overbudgeted
@@ -134,8 +133,6 @@ overbudgeting
oxc oxc
Paribas Paribas
passwordless passwordless
PAYPAL
picomatch
pluggyai pluggyai
Poste Poste
PPABPLPK PPABPLPK
@@ -176,11 +173,8 @@ tada
taskbar taskbar
templating templating
THB THB
TIMEFRAME
touchscreen touchscreen
triaging triaging
tsgo
TWD
UAH UAH
ubuntu ubuntu
undici 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 @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

@@ -52,9 +52,8 @@ runs:
with: with:
repository: actualbudget/translations repository: actualbudget/translations
path: ${{ inputs.working-directory }}/packages/desktop-client/locale path: ${{ inputs.working-directory }}/packages/desktop-client/locale
persist-credentials: false if: ${{ inputs.download-translations == 'true' }}
if: ${{ inputs.download-translations == 'true' && !env.ACT }}
- name: Remove untranslated languages - name: Remove untranslated languages
run: packages/desktop-client/bin/remove-untranslated-languages run: packages/desktop-client/bin/remove-untranslated-languages
shell: bash shell: bash
if: ${{ inputs.download-translations == 'true' && !env.ACT }} if: ${{ inputs.download-translations == 'true' }}

View File

@@ -35,11 +35,7 @@ const CONFIG = {
'release-notes/**/*', 'release-notes/**/*',
'upcoming-release-notes/**/*', 'upcoming-release-notes/**/*',
], ],
DOCS_FILES_PATTERNS: [ DOCS_FILES_PATTERN: 'packages/docs/**/*',
'packages/docs/**/*',
'!packages/docs/package.json',
'.github/actions/docs-spelling/*',
],
}; };
/** /**
@@ -61,29 +57,78 @@ function parseReleaseNotesCategory(content) {
return categoryMatch[1].trim(); 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. * Get the category and points for a PR by reading its release notes file.
* @param {Octokit} octokit - The Octokit instance. * @param {Octokit} octokit - The Octokit instance.
* @param {string} owner - Repository owner. * @param {string} owner - Repository owner.
* @param {string} repo - Repository name. * @param {string} repo - Repository name.
* @param {string|null} releaseNoteBlobSha - The blob SHA of the release notes file, or null if not found. * @param {number} prNumber - PR number.
* @returns {Promise<Object>} Object with category and points. * @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( async function getPRCategoryAndPoints(
octokit, octokit,
owner, owner,
repo, repo,
releaseNoteBlobSha, prNumber,
monthEnd,
) { ) {
try { const releaseNotesPath = `upcoming-release-notes/${prNumber}.md`;
if (releaseNoteBlobSha) {
const { data: blob } = await octokit.git.getBlob({
owner,
repo,
file_sha: releaseNoteBlobSha,
});
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 category = parseReleaseNotesCategory(content);
const tier = CONFIG.PR_CONTRIBUTION_POINTS.find(e => const tier = CONFIG.PR_CONTRIBUTION_POINTS.find(e =>
e.categories.includes(category), e.categories.includes(category),
@@ -231,25 +276,13 @@ async function countContributorPoints() {
), ),
); );
const isDocsFile = file => { const docsFiles = filteredFiles.filter(file =>
const positivePatterns = CONFIG.DOCS_FILES_PATTERNS.filter( minimatch(file.filename, CONFIG.DOCS_FILES_PATTERN, { dot: true }),
p => !p.startsWith('!'), );
); const codeFiles = filteredFiles.filter(
const negativePatterns = CONFIG.DOCS_FILES_PATTERNS.filter(p => file =>
p.startsWith('!'), !minimatch(file.filename, CONFIG.DOCS_FILES_PATTERN, { dot: true }),
); );
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 docsChanges = docsFiles.reduce( const docsChanges = docsFiles.reduce(
(sum, file) => sum + file.additions + file.deletions, (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 // Award points to PR author if they are a core maintainer
const prAuthor = pr.user?.login; const prAuthor = pr.user?.login;
if (prAuthor && orgMemberLogins.has(prAuthor)) { if (prAuthor && orgMemberLogins.has(prAuthor)) {
const releaseNoteFile = modifiedFiles.find(
file =>
file.filename === `upcoming-release-notes/${pr.number}.md`,
);
const categoryAndPoints = await getPRCategoryAndPoints( const categoryAndPoints = await getPRCategoryAndPoints(
octokit, octokit,
owner, owner,
repo, repo,
releaseNoteFile?.sha ?? null, pr.number,
until,
); );
if (categoryAndPoints) { if (categoryAndPoints) {

View File

@@ -18,8 +18,6 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment - name: Set up environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:
@@ -44,7 +42,11 @@ jobs:
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
- name: Check if release notes file already exists - 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 id: check-release-notes-exists
run: node .github/actions/ai-generated-release-notes/check-release-notes-exists.js run: node .github/actions/ai-generated-release-notes/check-release-notes-exists.js
env: env:
@@ -54,7 +56,7 @@ jobs:
PR_DETAILS: ${{ steps.pr-details.outputs.result }} PR_DETAILS: ${{ steps.pr-details.outputs.result }}
- name: Generate summary with OpenAI - 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 id: generate-summary
run: node .github/actions/ai-generated-release-notes/generate-summary.js run: node .github/actions/ai-generated-release-notes/generate-summary.js
env: env:
@@ -63,7 +65,7 @@ jobs:
PR_DETAILS: ${{ steps.pr-details.outputs.result }} PR_DETAILS: ${{ steps.pr-details.outputs.result }}
- name: Determine category with OpenAI - 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 id: determine-category
run: node .github/actions/ai-generated-release-notes/determine-category.js run: node .github/actions/ai-generated-release-notes/determine-category.js
env: env:
@@ -73,7 +75,7 @@ jobs:
SUMMARY_DATA: ${{ steps.generate-summary.outputs.result }} SUMMARY_DATA: ${{ steps.generate-summary.outputs.result }}
- name: Create and commit release notes file via GitHub API - 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 run: node .github/actions/ai-generated-release-notes/create-release-notes-file.js
env: env:
GITHUB_TOKEN: ${{ secrets.ACTIONS_UPDATE_TOKEN }} GITHUB_TOKEN: ${{ secrets.ACTIONS_UPDATE_TOKEN }}
@@ -83,7 +85,7 @@ jobs:
CATEGORY: ${{ steps.determine-category.outputs.result }} CATEGORY: ${{ steps.determine-category.outputs.result }}
- name: Comment on PR - 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 run: node .github/actions/ai-generated-release-notes/comment-on-pr.js
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -16,8 +16,6 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment - name: Set up environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:

View File

@@ -19,54 +19,35 @@ concurrency:
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
jobs: jobs:
setup:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
api: api:
needs: setup
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment - name: Set up environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:
download-translations: 'false' download-translations: 'false'
- name: Build API - name: Build API
run: yarn build:api run: cd packages/api && yarn build
- name: Create package tgz - name: Create package tgz
run: cd packages/api && yarn pack && mv package.tgz actual-api.tgz run: cd packages/api && yarn pack && mv package.tgz actual-api.tgz
- name: Prepare bundle stats artifact - name: Prepare bundle stats artifact
run: cp packages/api/app/stats.json api-stats.json run: cp packages/api/app/stats.json api-stats.json
- name: Upload Build - name: Upload Build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: actual-api name: actual-api
path: packages/api/actual-api.tgz path: packages/api/actual-api.tgz
- name: Upload API bundle stats - name: Upload API bundle stats
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: api-build-stats name: api-build-stats
path: api-stats.json path: api-stats.json
crdt: crdt:
needs: setup
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment - name: Set up environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:
@@ -75,48 +56,35 @@ jobs:
run: cd packages/crdt && yarn build run: cd packages/crdt && yarn build
- name: Create package tgz - name: Create package tgz
run: cd packages/crdt && yarn pack && mv package.tgz actual-crdt.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 - name: Upload Build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: actual-crdt name: actual-crdt
path: packages/crdt/actual-crdt.tgz 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: web:
needs: setup
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment - name: Set up environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
- name: Build Web - name: Build Web
run: yarn build:browser run: yarn build:browser
- name: Upload Build - name: Upload Build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: actual-web name: actual-web
path: packages/desktop-client/build path: packages/desktop-client/build
- name: Upload Build Stats - name: Upload Build Stats
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: build-stats name: build-stats
path: packages/desktop-client/build-stats path: packages/desktop-client/build-stats
cli: cli:
needs: setup
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
- name: Set up environment - name: Set up environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:
@@ -128,23 +96,20 @@ jobs:
- name: Prepare bundle stats artifact - name: Prepare bundle stats artifact
run: cp packages/cli/dist/stats.json cli-stats.json run: cp packages/cli/dist/stats.json cli-stats.json
- name: Upload Build - name: Upload Build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with: with:
name: actual-cli name: actual-cli
path: packages/cli/actual-cli.tgz path: packages/cli/actual-cli.tgz
- name: Upload CLI bundle stats - name: Upload CLI bundle stats
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with: with:
name: cli-build-stats name: cli-build-stats
path: cli-stats.json path: cli-stats.json
server: server:
needs: setup
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment - name: Set up environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:
@@ -152,7 +117,7 @@ jobs:
- name: Build Server - name: Build Server
run: yarn workspace @actual-app/sync-server build run: yarn workspace @actual-app/sync-server build
- name: Upload Build - name: Upload Build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: sync-server name: sync-server
path: packages/sync-server/build path: packages/sync-server/build

View File

@@ -12,40 +12,20 @@ concurrency:
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
jobs: jobs:
setup:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
constraints: constraints:
needs: setup
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment - name: Set up environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:
download-translations: 'false' download-translations: 'false'
- name: Check dependency version consistency - name: Check dependency version consistency
run: yarn constraints run: yarn constraints
- name: Check tsconfig project references are in sync
run: yarn check:tsconfig-references
lint: lint:
needs: setup
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment - name: Set up environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:
@@ -53,12 +33,9 @@ jobs:
- name: Lint - name: Lint
run: yarn lint run: yarn lint
typecheck: typecheck:
needs: setup
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment - name: Set up environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:
@@ -66,12 +43,9 @@ jobs:
- name: Typecheck - name: Typecheck
run: yarn typecheck run: yarn typecheck
validate-cli: validate-cli:
needs: setup
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment - name: Set up environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:
@@ -81,36 +55,21 @@ jobs:
- name: Check that the built CLI works - name: Check that the built CLI works
run: node packages/sync-server/build/bin/actual-server.js --version run: node packages/sync-server/build/bin/actual-server.js --version
test: test:
needs: setup
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment - name: Set up environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:
download-translations: 'false' download-translations: 'false'
- name: Test - name: Test
run: yarn 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: migrations:
needs: setup
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment - name: Set up environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:

View File

@@ -23,15 +23,13 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 uses: github/codeql-action/init@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
with: with:
languages: javascript languages: javascript
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 uses: github/codeql-action/analyze@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
with: with:
category: '/language:javascript' category: '/language:javascript'

View File

@@ -17,8 +17,6 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment - name: Set up environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:

View File

@@ -1,101 +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' }}
persist-credentials: false
- 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

@@ -37,8 +37,6 @@ jobs:
os: [ubuntu, alpine] os: [ubuntu, alpine]
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
@@ -56,14 +54,14 @@ jobs:
tags: ${{ env.TAGS }} tags: ${{ env.TAGS }}
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
if: github.event_name != 'pull_request' && !github.event.repository.fork if: github.event_name != 'pull_request' && !github.event.repository.fork
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
with: with:
registry: ghcr.io registry: ghcr.io
@@ -78,7 +76,7 @@ jobs:
run: yarn build:server run: yarn build:server
- name: Build image for testing - name: Build image for testing
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with: with:
context: . context: .
push: false push: false
@@ -95,7 +93,7 @@ jobs:
# This will use the cache from the earlier build step and not rebuild the image # 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/ # https://docs.docker.com/build/ci/github-actions/test-before-push/
- name: Build and push images - name: Build and push images
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with: with:
context: . context: .
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}

View File

@@ -29,8 +29,6 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
@@ -60,13 +58,13 @@ jobs:
tags: ${{ env.TAGS }} tags: ${{ env.TAGS }}
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -80,7 +78,7 @@ jobs:
run: yarn build:server run: yarn build:server
- name: Build and push ubuntu image - name: Build and push ubuntu image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with: with:
context: . context: .
push: true push: true
@@ -89,7 +87,7 @@ jobs:
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
- name: Build and push alpine image - name: Build and push alpine image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with: with:
context: . context: .
push: true push: true

View File

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

View File

@@ -17,80 +17,32 @@ on:
env: env:
GITHUB_PR_NUMBER: ${{github.event.pull_request.number}} GITHUB_PR_NUMBER: ${{github.event.pull_request.number}}
permissions:
contents: read
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
build-web:
name: Build web bundle
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.59.1-jammy
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Trust the repository directory
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Build browser bundle
# REACT_APP_NETLIFY=true flips isNonProductionEnvironment() on in the
# bundle so the "Create test file" button (used by every e2e beforeEach
# via ConfigurationPage.createTestFile()) is still rendered in a
# production build. Without it, e2e tests would time out waiting for
# a button that was tree-shaken out.
# --skip-translations keeps VRT screenshots deterministic by rendering
# source-code English instead of upstream Weblate en.json (which can
# drift between snapshot capture and test runs).
env:
REACT_APP_NETLIFY: 'true'
run: yarn build:browser --skip-translations
- name: Upload build artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: desktop-client-build
path: packages/desktop-client/build/
retention-days: 1
overwrite: true
functional: functional:
name: Functional (shard ${{ matrix.shard }}/3) name: Functional (shard ${{ matrix.shard }}/5)
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build-web
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
shard: [1, 2, 3] shard: [1, 2, 3, 4, 5]
container: container:
image: mcr.microsoft.com/playwright:v1.59.1-jammy image: mcr.microsoft.com/playwright:v1.58.2-jammy
env:
E2E_USE_BUILD: '1'
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment - name: Set up environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:
download-translations: 'false' download-translations: 'false'
- name: Trust the repository directory - name: Trust the repository directory
run: git config --global --add safe.directory "$GITHUB_WORKSPACE" run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Download web build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: desktop-client-build
path: packages/desktop-client/build/
- name: Run E2E Tests - name: Run E2E Tests
run: yarn e2e --shard=${{ matrix.shard }}/3 run: yarn e2e --shard=${{ matrix.shard }}/5
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: failure() if: always()
with: with:
name: desktop-client-test-results-shard-${{ matrix.shard }} name: desktop-client-test-results-shard-${{ matrix.shard }}
path: packages/desktop-client/test-results/ path: packages/desktop-client/test-results/
@@ -101,27 +53,19 @@ jobs:
name: Functional Desktop App name: Functional Desktop App
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: mcr.microsoft.com/playwright:v1.59.1-jammy image: mcr.microsoft.com/playwright:v1.58.2-jammy
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment - name: Set up environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:
download-translations: 'false' download-translations: 'false'
- name: Trust the repository directory - name: Trust the repository directory
run: git config --global --add safe.directory "$GITHUB_WORKSPACE" 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 - name: Run Desktop app E2E Tests
run: | run: |
yarn rebuild-electron
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: always() if: always()
with: with:
name: desktop-app-test-results name: desktop-app-test-results
@@ -130,35 +74,23 @@ jobs:
overwrite: true overwrite: true
vrt: vrt:
name: Visual regression (shard ${{ matrix.shard }}/3) name: Visual regression (shard ${{ matrix.shard }}/5)
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build-web
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
shard: [1, 2, 3] shard: [1, 2, 3, 4, 5]
container: container:
image: mcr.microsoft.com/playwright:v1.59.1-jammy image: mcr.microsoft.com/playwright:v1.58.2-jammy
env:
E2E_USE_BUILD: '1'
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment - name: Set up environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:
download-translations: 'false' download-translations: 'false'
- name: Trust the repository directory
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Download web build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: desktop-client-build
path: packages/desktop-client/build/
- name: Run VRT Tests - name: Run VRT Tests
run: yarn vrt --shard=${{ matrix.shard }}/3 run: yarn vrt --shard=${{ matrix.shard }}/5
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: always() if: always()
with: with:
name: vrt-blob-report-${{ matrix.shard }} name: vrt-blob-report-${{ matrix.shard }}
@@ -172,11 +104,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
container: container:
image: mcr.microsoft.com/playwright:v1.59.1-jammy image: mcr.microsoft.com/playwright:v1.58.2-jammy
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment - name: Set up environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
- name: Download all blob reports - name: Download all blob reports
@@ -188,7 +118,7 @@ jobs:
- name: Merge reports - name: Merge reports
id: merge-reports id: merge-reports
run: yarn workspace @actual-app/web run playwright merge-reports --reporter html ./all-blob-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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
id: playwright-report-vrt id: playwright-report-vrt
with: with:
name: html-report--attempt-${{ github.run_attempt }} name: html-report--attempt-${{ github.run_attempt }}
@@ -201,12 +131,10 @@ jobs:
mkdir -p vrt-metadata mkdir -p vrt-metadata
echo "${{ github.event.pull_request.number }}" > vrt-metadata/pr-number.txt echo "${{ github.event.pull_request.number }}" > vrt-metadata/pr-number.txt
echo "${{ needs.vrt.result }}" > vrt-metadata/vrt-result.txt echo "${{ needs.vrt.result }}" > vrt-metadata/vrt-result.txt
echo "${STEPS_PLAYWRIGHT_REPORT_VRT_OUTPUTS_ARTIFACT_URL}" > vrt-metadata/artifact-url.txt echo "${{ steps.playwright-report-vrt.outputs.artifact-url }}" > vrt-metadata/artifact-url.txt
env:
STEPS_PLAYWRIGHT_REPORT_VRT_OUTPUTS_ARTIFACT_URL: ${{ steps.playwright-report-vrt.outputs.artifact-url }}
- name: Upload VRT metadata - name: Upload VRT metadata
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: vrt-comment-metadata name: vrt-comment-metadata
path: vrt-metadata/ path: vrt-metadata/

View File

@@ -53,7 +53,7 @@ jobs:
- name: Comment on PR with VRT report link - name: Comment on PR with VRT report link
if: steps.metadata.outputs.should_comment == 'true' if: steps.metadata.outputs.should_comment == 'true'
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 uses: marocchino/sticky-pull-request-comment@70d2764d1a7d5d9560b100cbea0077fc8f633987 # v3.0.2
with: with:
number: ${{ steps.metadata.outputs.pr_number }} number: ${{ steps.metadata.outputs.pr_number }}
header: vrt-comment header: vrt-comment

View File

@@ -22,7 +22,6 @@ jobs:
permissions: permissions:
contents: write contents: write
strategy: strategy:
fail-fast: false
matrix: matrix:
os: os:
- ubuntu-22.04 - ubuntu-22.04
@@ -31,8 +30,6 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- if: ${{ startsWith(matrix.os, 'windows') }} - if: ${{ startsWith(matrix.os, 'windows') }}
run: pip.exe install setuptools run: pip.exe install setuptools
- if: ${{ ! startsWith(matrix.os, 'windows') }} - if: ${{ ! startsWith(matrix.os, 'windows') }}
@@ -59,11 +56,9 @@ jobs:
METAINFO_FILE="packages/desktop-electron/extra-resources/linux/com.actualbudget.actual.metainfo.xml" METAINFO_FILE="packages/desktop-electron/extra-resources/linux/com.actualbudget.actual.metainfo.xml"
TODAY=$(date +%Y-%m-%d) TODAY=$(date +%Y-%m-%d)
VERSION=${STEPS_PROCESS_VERSION_OUTPUTS_VERSION} VERSION=${{ steps.process_version.outputs.version }}
sed -i "s/%RELEASE_VERSION%/$VERSION/g; s/%RELEASE_DATE%/$TODAY/g" "$METAINFO_FILE" 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" flatpak run --command=flatpak-builder-lint org.flatpak.Builder appstream "$METAINFO_FILE"
env:
STEPS_PROCESS_VERSION_OUTPUTS_VERSION: ${{ steps.process_version.outputs.version }}
- name: Set up environment - name: Set up environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
- name: Build Electron for Mac - name: Build Electron for Mac
@@ -79,7 +74,7 @@ jobs:
if: ${{ ! startsWith(matrix.os, 'macos') }} if: ${{ ! startsWith(matrix.os, 'macos') }}
run: ./bin/package-electron run: ./bin/package-electron
- name: Upload Build - name: Upload Build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: actual-electron-${{ matrix.os }} name: actual-electron-${{ matrix.os }}
path: | path: |
@@ -90,7 +85,7 @@ jobs:
packages/desktop-electron/dist/*.flatpak packages/desktop-electron/dist/*.flatpak
- name: Upload Windows Store Build - name: Upload Windows Store Build
if: ${{ startsWith(matrix.os, 'windows') }} if: ${{ startsWith(matrix.os, 'windows') }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: actual-electron-${{ matrix.os }}-appx name: actual-electron-${{ matrix.os }}-appx
path: | path: |

View File

@@ -26,7 +26,6 @@ concurrency:
jobs: jobs:
build: build:
strategy: strategy:
fail-fast: false
matrix: matrix:
os: os:
- ubuntu-22.04 - ubuntu-22.04
@@ -35,8 +34,6 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- if: ${{ startsWith(matrix.os, 'windows') }} - if: ${{ startsWith(matrix.os, 'windows') }}
run: pip.exe install setuptools run: pip.exe install setuptools
- if: ${{ ! startsWith(matrix.os, 'windows') }} - if: ${{ ! startsWith(matrix.os, 'windows') }}
@@ -68,56 +65,56 @@ jobs:
run: ./bin/package-electron run: ./bin/package-electron
- name: Upload Linux x64 AppImage - name: Upload Linux x64 AppImage
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: Actual-linux-x86_64.AppImage name: Actual-linux-x86_64.AppImage
if-no-files-found: ignore if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-x86_64.AppImage path: packages/desktop-electron/dist/Actual-linux-x86_64.AppImage
- name: Upload Linux arm64 AppImage - name: Upload Linux arm64 AppImage
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: Actual-linux-arm64.AppImage name: Actual-linux-arm64.AppImage
if-no-files-found: ignore if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-arm64.AppImage path: packages/desktop-electron/dist/Actual-linux-arm64.AppImage
- name: Upload Linux x64 flatpak - name: Upload Linux x64 flatpak
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: Actual-linux-x86_64.flatpak name: Actual-linux-x86_64.flatpak
if-no-files-found: ignore if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-x86_64.flatpak path: packages/desktop-electron/dist/Actual-linux-x86_64.flatpak
- name: Upload Windows x32 exe - name: Upload Windows x32 exe
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: Actual-windows-ia32.exe name: Actual-windows-ia32.exe
if-no-files-found: ignore if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-ia32.exe path: packages/desktop-electron/dist/Actual-windows-ia32.exe
- name: Upload Windows x64 exe - name: Upload Windows x64 exe
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: Actual-windows-x64.exe name: Actual-windows-x64.exe
if-no-files-found: ignore if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-x64.exe path: packages/desktop-electron/dist/Actual-windows-x64.exe
- name: Upload Windows arm64 exe - name: Upload Windows arm64 exe
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: Actual-windows-arm64.exe name: Actual-windows-arm64.exe
if-no-files-found: ignore if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-arm64.exe path: packages/desktop-electron/dist/Actual-windows-arm64.exe
- name: Upload Mac x64 dmg - name: Upload Mac x64 dmg
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: Actual-mac-x64.dmg name: Actual-mac-x64.dmg
if-no-files-found: ignore if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-mac-x64.dmg path: packages/desktop-electron/dist/Actual-mac-x64.dmg
- name: Upload Mac arm64 dmg - name: Upload Mac arm64 dmg
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: Actual-mac-arm64.dmg name: Actual-mac-arm64.dmg
if-no-files-found: ignore if-no-files-found: ignore
@@ -125,7 +122,7 @@ jobs:
- name: Upload Windows Store Build - name: Upload Windows Store Build
if: ${{ startsWith(matrix.os, 'windows') }} if: ${{ startsWith(matrix.os, 'windows') }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: actual-electron-${{ matrix.os }}-appx name: actual-electron-${{ matrix.os }}-appx
path: | path: |

View File

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

View File

@@ -0,0 +1,64 @@
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.inputs.ref }}
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Bump package versions
id: bump_package_versions
shell: bash
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=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts \
--package-json "./packages/$pkg/package.json" \
--version "${{ github.event.inputs.version }}" \
--update)
else
version=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts \
--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@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
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

@@ -15,7 +15,6 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
path: actual path: actual
persist-credentials: false
- name: Set up environment - name: Set up environment
uses: ./actual/.github/actions/setup uses: ./actual/.github/actions/setup
with: with:
@@ -28,23 +27,12 @@ jobs:
- name: Configure i18n client - name: Configure i18n client
run: | run: |
pip install wlc 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 - name: Lock translations
run: | run: |
wlc \ wlc \
--url https://hosted.weblate.org/api/ \ --url https://hosted.weblate.org/api/ \
--key "${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}" \
lock \ lock \
actualbudget/actual actualbudget/actual
@@ -52,6 +40,7 @@ jobs:
run: | run: |
wlc \ wlc \
--url https://hosted.weblate.org/api/ \ --url https://hosted.weblate.org/api/ \
--key "${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}" \
push \ push \
actualbudget/actual actualbudget/actual
- name: Check out updated translations - name: Check out updated translations
@@ -60,8 +49,6 @@ jobs:
ssh-key: ${{ secrets.STRING_IMPORT_DEPLOY_KEY }} ssh-key: ${{ secrets.STRING_IMPORT_DEPLOY_KEY }}
repository: actualbudget/translations repository: actualbudget/translations
path: translations path: translations
# Need to be able to push back extracted strings
persist-credentials: true
- name: Generate i18n strings - name: Generate i18n strings
working-directory: actual working-directory: actual
run: | run: |
@@ -86,6 +73,7 @@ jobs:
run: | run: |
wlc \ wlc \
--url https://hosted.weblate.org/api/ \ --url https://hosted.weblate.org/api/ \
--key "${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}" \
pull \ pull \
actualbudget/actual actualbudget/actual
@@ -94,5 +82,6 @@ jobs:
run: | run: |
wlc \ wlc \
--url https://hosted.weblate.org/api/ \ --url https://hosted.weblate.org/api/ \
--key "${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}" \
unlock \ unlock \
actualbudget/actual actualbudget/actual

View File

@@ -25,8 +25,6 @@ jobs:
steps: steps:
# This is not a security concern because we have approved & merged the PR # This is not a security concern because we have approved & merged the PR
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
node-version: 22 node-version: 22

View File

@@ -22,8 +22,6 @@ jobs:
steps: steps:
- name: Repository Checkout - name: Repository Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment - name: Set up environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
@@ -36,11 +34,10 @@ jobs:
- name: Deploy to Netlify - name: Deploy to Netlify
id: netlify_deploy id: netlify_deploy
env:
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_API_TOKEN }}
run: | run: |
netlify deploy \ netlify deploy \
--dir packages/desktop-client/build \ --dir packages/desktop-client/build \
--site ${{ secrets.NETLIFY_SITE_ID }} \
--auth ${{ secrets.NETLIFY_API_TOKEN }} \
--filter @actual-app/web \ --filter @actual-app/web \
--prod --prod

View File

@@ -1,47 +0,0 @@
name: Nightly theme catalog scan
on:
schedule:
# 05:15 UTC daily — runs after the i18n extract job (04:00) and well
# before the nightly Electron/npm publishes (00:00 UTC the next day).
- cron: '15 5 * * *'
workflow_dispatch:
permissions:
contents: read
jobs:
validate-theme-catalog:
name: Validate custom theme catalog
runs-on: ubuntu-latest
if: github.repository == 'actualbudget/actual'
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Validate themes
run: yarn workspace @actual-app/web validate:theme-catalog
notify-failure:
name: Notify Discord on failure
needs: validate-theme-catalog
if: failure() && github.repository == 'actualbudget/actual'
runs-on: ubuntu-latest
environment: nightly-alerts
timeout-minutes: 5
steps:
- name: Notify Discord
uses: sarisia/actions-status-discord@eb045afee445dc055c18d3d90bd0f244fd062708 # v1.16.0
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_URL }}
status: Failure
title: Nightly theme catalog scan failed
description: The nightly scan failed. One or more themes may be broken, or the scan itself did not complete.
username: Actual Nightly
nofail: true

View File

@@ -54,9 +54,8 @@ jobs:
- name: Verify release assets exist - name: Verify release assets exist
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
STEPS_RESOLVE_VERSION_OUTPUTS_TAG: ${{ steps.resolve_version.outputs.tag }}
run: | run: |
TAG="${STEPS_RESOLVE_VERSION_OUTPUTS_TAG}" TAG="${{ steps.resolve_version.outputs.tag }}"
echo "Checking release assets for tag $TAG..." echo "Checking release assets for tag $TAG..."
ASSETS=$(gh api "repos/${{ github.repository }}/releases/tags/$TAG" --jq '.assets[].name') ASSETS=$(gh api "repos/${{ github.repository }}/releases/tags/$TAG" --jq '.assets[].name')
@@ -78,7 +77,7 @@ jobs:
- name: Calculate AppImage SHA256 (streamed) - name: Calculate AppImage SHA256 (streamed)
run: | run: |
VERSION="${STEPS_RESOLVE_VERSION_OUTPUTS_VERSION}" VERSION="${{ steps.resolve_version.outputs.version }}"
BASE_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}" BASE_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}"
echo "Streaming x86_64 AppImage to compute SHA256..." echo "Streaming x86_64 AppImage to compute SHA256..."
@@ -91,35 +90,30 @@ jobs:
echo "APPIMAGE_X64_SHA256=$APPIMAGE_X64_SHA256" >> "$GITHUB_ENV" echo "APPIMAGE_X64_SHA256=$APPIMAGE_X64_SHA256" >> "$GITHUB_ENV"
echo "APPIMAGE_ARM64_SHA256=$APPIMAGE_ARM64_SHA256" >> "$GITHUB_ENV" echo "APPIMAGE_ARM64_SHA256=$APPIMAGE_ARM64_SHA256" >> "$GITHUB_ENV"
env:
STEPS_RESOLVE_VERSION_OUTPUTS_VERSION: ${{ steps.resolve_version.outputs.version }}
- name: Checkout Flathub repo - name: Checkout Flathub repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
repository: flathub/com.actualbudget.actual repository: flathub/com.actualbudget.actual
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }} token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
persist-credentials: false
- name: Update manifest with new version - name: Update manifest with new version
run: | run: |
VERSION="${STEPS_RESOLVE_VERSION_OUTPUTS_VERSION}" VERSION="${{ steps.resolve_version.outputs.version }}"
# Replace x86_64 entry # Replace x86_64 entry
sed -i "/x86_64.AppImage/{n;s|sha256:.*|sha256: ${APPIMAGE_X64_SHA256}|}" com.actualbudget.actual.yml sed -i "/x86_64.AppImage/{n;s|sha256:.*|sha256: ${{ env.APPIMAGE_X64_SHA256 }}|}" com.actualbudget.actual.yml
sed -i "/x86_64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${VERSION}/Actual-linux-x86_64.AppImage|" com.actualbudget.actual.yml sed -i "/x86_64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${VERSION}/Actual-linux-x86_64.AppImage|" com.actualbudget.actual.yml
# Replace arm64 entry # Replace arm64 entry
sed -i "/arm64.AppImage/{n;s|sha256:.*|sha256: ${APPIMAGE_ARM64_SHA256}|}" com.actualbudget.actual.yml sed -i "/arm64.AppImage/{n;s|sha256:.*|sha256: ${{ env.APPIMAGE_ARM64_SHA256 }}|}" com.actualbudget.actual.yml
sed -i "/arm64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${VERSION}/Actual-linux-arm64.AppImage|" com.actualbudget.actual.yml sed -i "/arm64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${VERSION}/Actual-linux-arm64.AppImage|" com.actualbudget.actual.yml
echo "Updated manifest:" echo "Updated manifest:"
cat com.actualbudget.actual.yml cat com.actualbudget.actual.yml
env:
STEPS_RESOLVE_VERSION_OUTPUTS_VERSION: ${{ steps.resolve_version.outputs.version }}
- name: Create PR in Flathub repo - name: Create PR in Flathub repo
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with: with:
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }} token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
commit-message: 'Update Actual flatpak to version ${{ steps.resolve_version.outputs.version }}' commit-message: 'Update Actual flatpak to version ${{ steps.resolve_version.outputs.version }}'

View File

@@ -20,7 +20,6 @@ concurrency:
jobs: jobs:
build: build:
strategy: strategy:
fail-fast: false
matrix: matrix:
os: os:
- ubuntu-22.04 - ubuntu-22.04
@@ -30,8 +29,6 @@ jobs:
if: github.event.repository.fork == false if: github.event.repository.fork == false
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- if: ${{ startsWith(matrix.os, 'windows') }} - if: ${{ startsWith(matrix.os, 'windows') }}
run: pip.exe install setuptools run: pip.exe install setuptools
@@ -86,49 +83,49 @@ jobs:
run: ./bin/package-electron run: ./bin/package-electron
- name: Upload Linux x64 AppImage - name: Upload Linux x64 AppImage
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: Actual-linux-x86_64.AppImage name: Actual-linux-x86_64.AppImage
if-no-files-found: ignore if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-x86_64.AppImage path: packages/desktop-electron/dist/Actual-linux-x86_64.AppImage
- name: Upload Linux arm64 AppImage - name: Upload Linux arm64 AppImage
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: Actual-linux-arm64.AppImage name: Actual-linux-arm64.AppImage
if-no-files-found: ignore if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-arm64.AppImage path: packages/desktop-electron/dist/Actual-linux-arm64.AppImage
- name: Upload Windows x32 exe - name: Upload Windows x32 exe
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: Actual-windows-ia32.exe name: Actual-windows-ia32.exe
if-no-files-found: ignore if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-ia32.exe path: packages/desktop-electron/dist/Actual-windows-ia32.exe
- name: Upload Windows x64 exe - name: Upload Windows x64 exe
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: Actual-windows-x64.exe name: Actual-windows-x64.exe
if-no-files-found: ignore if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-x64.exe path: packages/desktop-electron/dist/Actual-windows-x64.exe
- name: Upload Windows arm64 exe - name: Upload Windows arm64 exe
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: Actual-windows-arm64.exe name: Actual-windows-arm64.exe
if-no-files-found: ignore if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-arm64.exe path: packages/desktop-electron/dist/Actual-windows-arm64.exe
- name: Upload Mac x64 dmg - name: Upload Mac x64 dmg
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: Actual-mac-x64.dmg name: Actual-mac-x64.dmg
if-no-files-found: ignore if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-mac-x64.dmg path: packages/desktop-electron/dist/Actual-mac-x64.dmg
- name: Upload Mac arm64 dmg - name: Upload Mac arm64 dmg
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: Actual-mac-arm64.dmg name: Actual-mac-arm64.dmg
if-no-files-found: ignore if-no-files-found: ignore
@@ -136,7 +133,7 @@ jobs:
- name: Upload Windows Store Build - name: Upload Windows Store Build
if: ${{ startsWith(matrix.os, 'windows') }} if: ${{ startsWith(matrix.os, 'windows') }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: actual-electron-${{ matrix.os }}-appx name: actual-electron-${{ matrix.os }}-appx
path: | path: |

View File

@@ -0,0 +1,124 @@
name: Publish nightly npm packages
# Nightly npm packages are built daily at midnight UTC
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
jobs:
build-and-pack:
runs-on: ubuntu-latest
name: Build and pack npm packages
if: github.event.repository.fork == false
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
uses: ./.github/actions/setup
- 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)
# 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
- name: Pack the web and server packages
run: |
yarn workspace @actual-app/web pack --filename @actual-app/web.tgz
yarn workspace @actual-app/sync-server pack --filename @actual-app/sync-server.tgz
- name: Build API
run: yarn build:api
- name: Pack the api package
run: |
yarn workspace @actual-app/api pack --filename @actual-app/api.tgz
- name: Build CLI
run: yarn workspace @actual-app/cli build
- name: Pack the cli package
run: |
yarn workspace @actual-app/cli pack --filename @actual-app/cli.tgz
- name: Upload package artifacts
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
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
name: Publish Nightly npm packages
needs: build-and-pack
permissions:
contents: read
packages: write
steps:
- name: Download the artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: npm-packages
- name: Setup node and npm registry
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.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
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish Sync-Server
run: |
npm publish sync-server/@actual-app/sync-server.tgz --access public --tag nightly
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish API
run: |
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

@@ -1,55 +1,26 @@
name: Publish npm packages name: Publish npm packages
# Npm packages are published for every new tag and nightly schedule # # Npm packages are published for every new tag
on: on:
push: push:
tags: tags:
- 'v*.*.*' - 'v*.*.*'
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
jobs: jobs:
build-and-pack: build-and-pack:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Build and pack npm packages name: Build and pack npm packages
if: github.event_name == 'push' || (github.event.repository.fork == false)
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment - name: Set up environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
- name: Update package versions
if: github.event_name != 'push'
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)
# 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
if: github.event_name != 'push'
run: |
# Required after nightly `npm version` updates workspace manifests.
yarn install
- name: Pack the core package - name: Pack the core package
run: | run: |
yarn workspace @actual-app/core pack --filename @actual-app/core.tgz yarn workspace @actual-app/core pack --filename @actual-app/core.tgz
- name: Build Server & Web - name: Build Web
run: yarn build:server run: yarn build:server
- name: Pack the web and server packages - name: Pack the web and server packages
@@ -72,8 +43,7 @@ jobs:
yarn workspace @actual-app/cli pack --filename @actual-app/cli.tgz yarn workspace @actual-app/cli pack --filename @actual-app/cli.tgz
- name: Upload package artifacts - name: Upload package artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: ${{ !env.ACT }}
with: with:
name: npm-packages name: npm-packages
path: | path: |
@@ -90,9 +60,6 @@ jobs:
permissions: permissions:
contents: read contents: read
packages: write packages: write
id-token: write # Required for OIDC
env:
NPM_DIST_TAG: ${{ github.event_name != 'push' && 'nightly' || '' }}
steps: steps:
- name: Download the artifacts - name: Download the artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -102,26 +69,35 @@ jobs:
- name: Setup node and npm registry - name: Setup node and npm registry
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
node-version: 24 node-version: 22
check-latest: true
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
- name: Publish Core - name: Publish Core
run: | run: |
npm publish loot-core/@actual-app/core.tgz --access public ${NPM_DIST_TAG:+--tag "$NPM_DIST_TAG"} npm publish loot-core/@actual-app/core.tgz --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish Web - name: Publish Web
run: | run: |
npm publish desktop-client/@actual-app/web.tgz --access public ${NPM_DIST_TAG:+--tag "$NPM_DIST_TAG"} npm publish desktop-client/@actual-app/web.tgz --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish Sync-Server - name: Publish Sync-Server
run: | run: |
npm publish sync-server/@actual-app/sync-server.tgz --access public ${NPM_DIST_TAG:+--tag "$NPM_DIST_TAG"} npm publish sync-server/@actual-app/sync-server.tgz --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish API - name: Publish API
run: | run: |
npm publish api/@actual-app/api.tgz --access public ${NPM_DIST_TAG:+--tag "$NPM_DIST_TAG"} npm publish api/@actual-app/api.tgz --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish CLI - name: Publish CLI
run: | run: |
npm publish cli/@actual-app/cli.tgz --access public ${NPM_DIST_TAG:+--tag "$NPM_DIST_TAG"} 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: on:
pull_request: pull_request:
permissions:
contents: write
pull-requests: read
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true cancel-in-progress: true
@@ -15,37 +11,15 @@ jobs:
release-notes: release-notes:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: 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 - name: Checkout
if: steps.bot-check.outputs.skip != 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.ACTIONS_UPDATE_TOKEN || github.token }}
# Need to be able to commit release notes after generation
persist-credentials: true
- name: Get changed files - name: Get changed files
if: steps.bot-check.outputs.skip != 'true'
id: changed-files id: changed-files
run: | run: |
git fetch origin ${GITHUB_BASE_REF} git fetch origin ${{ github.base_ref }}
CHANGED_FILES=$(git diff --name-only origin/${GITHUB_BASE_REF}...HEAD) CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)
NON_DOCS_FILES=$(echo "$CHANGED_FILES" | grep -v -e "^packages/docs/" -e "^\.github/actions/docs-spelling/" || true) NON_DOCS_FILES=$(echo "$CHANGED_FILES" | grep -v -e "^packages/docs/" -e "^\.github/actions/docs-spelling/" || true)
if [ -z "$NON_DOCS_FILES" ] && [ -n "$CHANGED_FILES" ]; then if [ -z "$NON_DOCS_FILES" ] && [ -n "$CHANGED_FILES" ]; then
@@ -54,17 +28,9 @@ jobs:
else else
echo "only_docs=false" >> $GITHUB_OUTPUT echo "only_docs=false" >> $GITHUB_OUTPUT
fi fi
- name: Check release notes - name: Check release notes
if: >- if: startsWith(github.head_ref, 'release/') == false && steps.changed-files.outputs.only_docs != 'true'
steps.bot-check.outputs.skip != 'true' uses: actualbudget/actions/release-notes/check@main
&& startsWith(github.head_ref, 'release/') == false
&& steps.changed-files.outputs.only_docs != 'true'
uses: ./.github/actions/release-notes/check
- name: Generate release notes - name: Generate release notes
if: >- if: startsWith(github.head_ref, 'release/') == true
steps.bot-check.outputs.skip != 'true' uses: actualbudget/actions/release-notes/generate@main
&& 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

View File

@@ -38,7 +38,6 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: ${{ github.base_ref }} ref: ${{ github.base_ref }}
persist-credentials: false
- name: Set up environment - name: Set up environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:
@@ -65,13 +64,6 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
checkName: cli checkName: cli
ref: ${{github.base_ref}} 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 - name: Wait for PR build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0 uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
@@ -94,22 +86,15 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
checkName: cli checkName: cli
ref: ${{github.event.pull_request.head.sha}} 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 - 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' || steps.wait-for-cli-build.outputs.conclusion == 'failure'
run: | run: |
echo "Build failed on PR branch or ${GITHUB_BASE_REF}" echo "Build failed on PR branch or ${{github.base_ref}}"
exit 1 exit 1
- name: Download web build artifact from ${{github.base_ref}} - name: Download web build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20 uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
id: pr-web-build id: pr-web-build
with: with:
branch: ${{github.base_ref}} branch: ${{github.base_ref}}
@@ -118,7 +103,7 @@ jobs:
name: build-stats name: build-stats
path: base path: base
- name: Download API build artifact from ${{github.base_ref}} - name: Download API build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20 uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
id: pr-api-build id: pr-api-build
with: with:
branch: ${{github.base_ref}} branch: ${{github.base_ref}}
@@ -127,7 +112,7 @@ jobs:
name: api-build-stats name: api-build-stats
path: base path: base
- name: Download build stats from PR - name: Download build stats from PR
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20 uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
with: with:
pr: ${{github.event.pull_request.number}} pr: ${{github.event.pull_request.number}}
workflow: build.yml workflow: build.yml
@@ -136,7 +121,7 @@ jobs:
path: head path: head
allow_forks: true allow_forks: true
- name: Download API stats from PR - name: Download API stats from PR
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20 uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
with: with:
pr: ${{github.event.pull_request.number}} pr: ${{github.event.pull_request.number}}
workflow: build.yml workflow: build.yml
@@ -145,7 +130,7 @@ jobs:
path: head path: head
allow_forks: true allow_forks: true
- name: Download CLI build artifact from ${{github.base_ref}} - name: Download CLI build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20 uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
with: with:
branch: ${{github.base_ref}} branch: ${{github.base_ref}}
workflow: build.yml workflow: build.yml
@@ -153,7 +138,7 @@ jobs:
name: cli-build-stats name: cli-build-stats
path: base path: base
- name: Download CLI stats from PR - name: Download CLI stats from PR
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20 uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
with: with:
pr: ${{github.event.pull_request.number}} pr: ${{github.event.pull_request.number}}
workflow: build.yml workflow: build.yml
@@ -161,23 +146,6 @@ jobs:
name: cli-build-stats name: cli-build-stats
path: head path: head
allow_forks: true 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 - name: Strip content hashes from stats files
run: | run: |
if [ -f ./head/web-stats.json ]; then if [ -f ./head/web-stats.json ]; then
@@ -200,12 +168,10 @@ jobs:
--base loot-core=./base/loot-core-stats.json \ --base loot-core=./base/loot-core-stats.json \
--base api=./base/api-stats.json \ --base api=./base/api-stats.json \
--base cli=./base/cli-stats.json \ --base cli=./base/cli-stats.json \
--base crdt=./base/crdt-stats.json \
--head desktop-client=./head/web-stats.json \ --head desktop-client=./head/web-stats.json \
--head loot-core=./head/loot-core-stats.json \ --head loot-core=./head/loot-core-stats.json \
--head api=./head/api-stats.json \ --head api=./head/api-stats.json \
--head cli=./head/cli-stats.json \ --head cli=./head/cli-stats.json \
--head crdt=./head/crdt-stats.json \
--identifier combined \ --identifier combined \
--format pr-body > bundle-stats-comment.md --format pr-body > bundle-stats-comment.md
- name: Post combined bundle stats comment - name: Post combined bundle stats comment

View File

@@ -3,12 +3,9 @@ on:
schedule: schedule:
- cron: '30 1 * * *' - cron: '30 1 * * *'
workflow_dispatch: # Allow manual triggering workflow_dispatch: # Allow manual triggering
permissions: {}
jobs: jobs:
stale: stale:
permissions:
pull-requests: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
@@ -19,8 +16,6 @@ jobs:
days-before-close: 5 days-before-close: 5
days-before-issue-stale: -1 days-before-issue-stale: -1
stale-wip: stale-wip:
permissions:
pull-requests: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
@@ -32,8 +27,6 @@ jobs:
days-before-issue-stale: -1 days-before-issue-stale: -1
stale-needs-info: stale-needs-info:
permissions:
issues: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0

View File

@@ -107,7 +107,7 @@ jobs:
fi fi
# Commit # Commit
git commit -m "Update VRT screenshots" -m "Auto-generated by VRT workflow" -m "PR: #${STEPS_METADATA_OUTPUTS_PR_NUMBER}" git commit -m "Update VRT screenshots" -m "Auto-generated by VRT workflow" -m "PR: #${{ steps.metadata.outputs.pr_number }}"
echo "applied=true" >> "$GITHUB_OUTPUT" echo "applied=true" >> "$GITHUB_OUTPUT"
else else
@@ -116,8 +116,6 @@ jobs:
echo "error=Patch conflicts with current branch state" >> "$GITHUB_OUTPUT" echo "error=Patch conflicts with current branch state" >> "$GITHUB_OUTPUT"
exit 1 exit 1
fi fi
env:
STEPS_METADATA_OUTPUTS_PR_NUMBER: ${{ steps.metadata.outputs.pr_number }}
- name: Push changes - name: Push changes
if: steps.apply.outputs.applied == 'true' if: steps.apply.outputs.applied == 'true'
@@ -135,15 +133,12 @@ jobs:
- name: Comment on PR - Failure - name: Comment on PR - Failure
if: failure() && steps.metadata.outputs.pr_number != '' if: failure() && steps.metadata.outputs.pr_number != ''
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
APPLY_ERROR: ${{ steps.apply.outputs.error }}
PR_NUMBER: ${{ steps.metadata.outputs.pr_number }}
with: with:
script: | script: |
const error = process.env.APPLY_ERROR || 'Unknown error occurred'; const error = `${{ steps.apply.outputs.error }}` || 'Unknown error occurred';
await github.rest.issues.createComment({ await github.rest.issues.createComment({
issue_number: parseInt(process.env.PR_NUMBER, 10), issue_number: ${{ steps.metadata.outputs.pr_number }},
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
body: `❌ Failed to apply VRT updates: ${error}\n\nPlease check the workflow logs for details.` 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 pull-requests: write
steps: steps:
- name: Add 👀 reaction to comment - name: Add 👀 reaction to comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with: with:
script: | script: |
await github.rest.reactions.createForIssueComment({ await github.rest.reactions.createForIssueComment({
@@ -44,11 +44,11 @@ jobs:
github.event.issue.pull_request && github.event.issue.pull_request &&
startsWith(github.event.comment.body, '/update-vrt') startsWith(github.event.comment.body, '/update-vrt')
container: container:
image: mcr.microsoft.com/playwright:v1.59.1-jammy image: mcr.microsoft.com/playwright:v1.58.2-jammy
steps: steps:
- name: Get PR details - name: Get PR details
id: pr id: pr
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with: with:
script: | script: |
const { data: pr } = await github.rest.pulls.get({ const { data: pr } = await github.rest.pulls.get({
@@ -63,21 +63,15 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: ${{ steps.pr.outputs.head_sha }} ref: ${{ steps.pr.outputs.head_sha }}
persist-credentials: false
- name: Set up environment - name: Set up environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:
download-translations: 'false' 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 - name: Run VRT Tests on Desktop app
continue-on-error: true continue-on-error: true
run: | run: |
yarn rebuild-electron
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop --update-snapshots xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop --update-snapshots
- name: Run VRT Tests - name: Run VRT Tests
@@ -119,7 +113,7 @@ jobs:
- name: Upload patch artifact - name: Upload patch artifact
if: steps.create-patch.outputs.has_changes == 'true' if: steps.create-patch.outputs.has_changes == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: vrt-patch-${{ github.event.issue.number }} name: vrt-patch-${{ github.event.issue.number }}
path: vrt-update.patch path: vrt-update.patch
@@ -130,15 +124,12 @@ jobs:
run: | run: |
mkdir -p pr-metadata mkdir -p pr-metadata
echo "${{ github.event.issue.number }}" > pr-metadata/pr-number.txt echo "${{ github.event.issue.number }}" > pr-metadata/pr-number.txt
echo "${STEPS_PR_OUTPUTS_HEAD_REF}" > pr-metadata/head-ref.txt echo "${{ steps.pr.outputs.head_ref }}" > pr-metadata/head-ref.txt
echo "${STEPS_PR_OUTPUTS_HEAD_REPO}" > pr-metadata/head-repo.txt echo "${{ steps.pr.outputs.head_repo }}" > pr-metadata/head-repo.txt
env:
STEPS_PR_OUTPUTS_HEAD_REF: ${{ steps.pr.outputs.head_ref }}
STEPS_PR_OUTPUTS_HEAD_REPO: ${{ steps.pr.outputs.head_repo }}
- name: Upload PR metadata - name: Upload PR metadata
if: steps.create-patch.outputs.has_changes == 'true' if: steps.create-patch.outputs.has_changes == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: vrt-metadata-${{ github.event.issue.number }} name: vrt-metadata-${{ github.event.issue.number }}
path: pr-metadata/ path: pr-metadata/

7
.gitignore vendored
View File

@@ -42,9 +42,6 @@ bundle.desktop.js.map
bundle.mobile.js bundle.mobile.js
bundle.mobile.js.map bundle.mobile.js.map
# Python virtualenv (Electron CI provisions one at the repo root for setuptools)
.venv/
# Yarn # Yarn
.pnp.* .pnp.*
.yarn/* .yarn/*
@@ -61,10 +58,6 @@ bundle.mobile.js.map
# IntelliJ IDEA # IntelliJ IDEA
.idea .idea
# Claude Code
.claude/worktrees/*
.claude/settings.local.json
# Misc # Misc
.#* .#*

View File

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

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

View File

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

View File

@@ -36,9 +36,6 @@
"actual/prefer-const": "error", "actual/prefer-const": "error",
"actual/no-anchor-tag": "error", "actual/no-anchor-tag": "error",
"actual/no-react-default-import": "error", "actual/no-react-default-import": "error",
"actual/prefer-subpath-imports": "error",
"actual/enforce-boundaries": "error",
"actual/no-extraneous-dependencies": "error",
// JSX A11y rules // JSX A11y rules
"jsx-a11y/no-autofocus": [ "jsx-a11y/no-autofocus": [
@@ -123,6 +120,9 @@
"import/no-amd": "error", "import/no-amd": "error",
"import/no-default-export": "error", "import/no-default-export": "error",
"import/no-webpack-loader-syntax": "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": [ "import/no-duplicates": [
"error", "error",
{ {
@@ -160,6 +160,7 @@
"react/no-danger-with-children": "error", "react/no-danger-with-children": "error",
"react/no-direct-mutation-state": "error", "react/no-direct-mutation-state": "error",
"react/no-is-mounted": "error", "react/no-is-mounted": "error",
"react/no-unstable-nested-components": "error",
"react/require-render-return": "error", "react/require-render-return": "error",
"react/rules-of-hooks": "error", "react/rules-of-hooks": "error",
"react/self-closing-comp": "error", "react/self-closing-comp": "error",
@@ -233,7 +234,7 @@
"eslint/require-yield": "error", "eslint/require-yield": "error",
"eslint/getter-return": "error", "eslint/getter-return": "error",
"eslint/unicode-bom": ["error", "never"], "eslint/unicode-bom": ["error", "never"],
"eslint/use-isnan": "error", "eslint/no-use-isnan": "error",
"eslint/valid-typeof": "error", "eslint/valid-typeof": "error",
"eslint/no-useless-rename": [ "eslint/no-useless-rename": [
"error", "error",
@@ -334,9 +335,14 @@
], ],
"patterns": [ "patterns": [
{ {
"group": ["**/*.api", "**/*.electron"], "group": ["**/*.api", "**/*.web", "**/*.electron"],
"message": "Don't directly reference imports from other platforms" "message": "Don't directly reference imports from other platforms"
}, },
{
"group": ["uuid"],
"importNames": ["*"],
"message": "Use `import { v4 as uuidv4 } from 'uuid'` instead"
},
{ {
"group": ["**/style", "**/colors"], "group": ["**/style", "**/colors"],
"importNames": ["colors"], "importNames": ["colors"],
@@ -355,9 +361,7 @@
], ],
"eslint/no-useless-constructor": "error", "eslint/no-useless-constructor": "error",
"eslint/no-undef": "error", "eslint/no-undef": "error",
"eslint/no-unused-expressions": "error", "eslint/no-unused-expressions": "error"
"eslint/no-return-assign": "error",
"eslint/no-unused-vars": "error"
}, },
"overrides": [ "overrides": [
{ {
@@ -373,12 +377,6 @@
"actual/prefer-logger-over-console": "off" "actual/prefer-logger-over-console": "off"
} }
}, },
{
"files": ["packages/eslint-plugin-actual/lib/rules/__tests__/**/*"],
"rules": {
"actual/enforce-boundaries": "off"
}
},
{ {
"files": [ "files": [
"packages/api/migrations/*", "packages/api/migrations/*",
@@ -423,16 +421,6 @@
"rules": { "rules": {
"eslint/no-empty-function": "off" "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 nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.13.0.cjs yarnPath: .yarn/releases/yarn-4.10.3.cjs

View File

@@ -281,6 +281,7 @@ Always run `yarn typecheck` before committing.
- Avoid `any` or `unknown` unless absolutely necessary - Avoid `any` or `unknown` unless absolutely necessary
- Look for existing type definitions in the codebase - Look for existing type definitions in the codebase
- Avoid type assertions (`as`, `!`) - prefer `satisfies` - Avoid type assertions (`as`, `!`) - prefer `satisfies`
- Use inline type imports: `import { type MyType } from '...'`
**Naming:** **Naming:**
@@ -330,7 +331,7 @@ Always maintain newlines between import groups.
### Platform-Specific Code ### 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 - Use conditional exports in `loot-core` for platform-specific code
- Platform resolution happens at build time via package.json exports - Platform resolution happens at build time via package.json exports
@@ -500,7 +501,7 @@ Icons in `packages/component-library/src/icons/` are auto-generated. Don't manua
1. Check `tsconfig.json` for path mappings 1. Check `tsconfig.json` for path mappings
2. Check package.json `exports` field (especially for loot-core) 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) 4. Use absolute imports in `desktop-client` (enforced by ESLint)
### Build Failures ### Build Failures

View File

@@ -1,3 +1 @@
Please review the contributing documentation on our website: https://actualbudget.org/docs/contributing/ Please review the contributing documentation on our website: https://actualbudget.org/docs/contributing/
If you plan to use AI tools when contributing, please also read our [AI Usage Policy](https://actualbudget.org/docs/contributing/ai-usage-policy).

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

@@ -4,30 +4,20 @@ ROOT=`dirname $0`
cd "$ROOT/.." cd "$ROOT/.."
SKIP_TRANSLATIONS=false echo "Updating translations..."
while [[ $# -gt 0 ]]; do if ! [ -d packages/desktop-client/locale ]; then
case "$1" in git clone https://github.com/actualbudget/translations packages/desktop-client/locale
--skip-translations)
SKIP_TRANSLATIONS=true
shift
;;
*)
echo "Unknown argument: $1" >&2
exit 1
;;
esac
done
if [ "$SKIP_TRANSLATIONS" = false ]; then
echo "Updating translations..."
if ! [ -d packages/desktop-client/locale ]; then
git clone https://github.com/actualbudget/translations packages/desktop-client/locale
fi
pushd packages/desktop-client/locale > /dev/null
git checkout .
git pull
popd > /dev/null
packages/desktop-client/bin/remove-untranslated-languages
fi fi
pushd packages/desktop-client/locale > /dev/null
git checkout .
git pull
popd > /dev/null
packages/desktop-client/bin/remove-untranslated-languages
lage build:browser --to=@actual-app/web export NODE_OPTIONS="--max-old-space-size=4096"
yarn workspace plugins-service build
yarn workspace @actual-app/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 pushd packages/desktop-client/locale > /dev/null
git checkout .
git pull git pull
popd > /dev/null popd > /dev/null
packages/desktop-client/bin/remove-untranslated-languages packages/desktop-client/bin/remove-untranslated-languages
@@ -51,13 +50,13 @@ fi
export NODE_OPTIONS="--max-old-space-size=4096" export NODE_OPTIONS="--max-old-space-size=4096"
yarn workspace @actual-app/crdt build
yarn workspace plugins-service build yarn workspace plugins-service build
yarn workspace @actual-app/core build:node yarn workspace @actual-app/core build:node
yarn workspace @actual-app/web build --mode=desktop # electron specific build yarn workspace @actual-app/web build --mode=desktop # electron specific build
# required for running the sync-server server # required for running the sync-server server
yarn build:browser yarn workspace @actual-app/core build:browser
yarn workspace @actual-app/web build:browser
yarn workspace @actual-app/sync-server build yarn workspace @actual-app/sync-server build
# Emit @actual-app/core declarations so desktop-electron (which includes typings/window.ts) can build # Emit @actual-app/core declarations so desktop-electron (which includes typings/window.ts) can build

View File

@@ -28,5 +28,5 @@ echo "Running VRT tests with the following parameters:"
echo "E2E_START_URL: $E2E_START_URL" echo "E2E_START_URL: $E2E_START_URL"
echo "VRT_ARGS: $VRT_ARGS" 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" -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,5 +1,3 @@
const BUILD_OUTPUT_GLOBS = ['lib-dist/**', 'dist/**', 'build/**', '@types/**'];
/** @type {import('lage').ConfigOptions} */ /** @type {import('lage').ConfigOptions} */
module.exports = { module.exports = {
pipeline: { pipeline: {
@@ -22,22 +20,14 @@ module.exports = {
dependsOn: ['^build'], dependsOn: ['^build'],
cache: true, cache: true,
options: { options: {
outputGlob: BUILD_OUTPUT_GLOBS, outputGlob: ['lib-dist/**', 'dist/**', 'build/**'],
}, },
}, },
// Not cached: the script stages files into public/ and build-stats/ that
// fall outside BUILD_OUTPUT_GLOBS, so a cache hit would skip the side
// effects.
'build:browser': {
type: 'npmScript',
dependsOn: ['^build'],
cache: false,
},
}, },
cacheOptions: { cacheOptions: {
cacheStorageConfig: { cacheStorageConfig: {
provider: 'local', provider: 'local',
outputGlob: BUILD_OUTPUT_GLOBS, outputGlob: ['lib-dist/**', 'dist/**', 'build/**'],
}, },
}, },
npmClient: 'yarn', npmClient: 'yarn',

View File

@@ -24,21 +24,23 @@
"start:server-dev": "NODE_ENV=development BROWSER_OPEN=localhost:5006 yarn npm-run-all --parallel 'start:server-monitor' 'start'", "start:server-dev": "NODE_ENV=development BROWSER_OPEN=localhost:5006 yarn npm-run-all --parallel 'start:server-monitor' 'start'",
"start:desktop": "yarn desktop-dependencies && npm-run-all --parallel 'start:desktop-*'", "start:desktop": "yarn desktop-dependencies && npm-run-all --parallel 'start:desktop-*'",
"start:docs": "yarn workspace docs start", "start:docs": "yarn workspace docs start",
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:plugins-service", "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 @actual-app/core watch:node",
"start:desktop-client": "yarn workspace @actual-app/web watch", "start:desktop-client": "yarn workspace @actual-app/web watch",
"start:desktop-server-client": "yarn workspace @actual-app/web build:browser", "start:desktop-server-client": "yarn workspace @actual-app/web build:browser",
"start:desktop-electron": "yarn workspace desktop-electron watch", "start:desktop-electron": "yarn workspace desktop-electron watch",
"start:browser": "npm-run-all --parallel 'start:browser-*' 'start:service-plugins'", "start:browser": "yarn workspace plugins-service build-dev && npm-run-all --parallel 'start:browser-*'",
"start:service-plugins": "yarn workspace plugins-service watch", "start:service-plugins": "yarn workspace plugins-service watch",
"start:browser-backend": "yarn workspace @actual-app/core watch:browser",
"start:browser-frontend": "yarn workspace @actual-app/web start:browser", "start:browser-frontend": "yarn workspace @actual-app/web start:browser",
"start:storybook": "yarn workspace @actual-app/components start:storybook", "start:storybook": "yarn workspace @actual-app/components start:storybook",
"build": "lage build", "build": "lage build",
"build:browser-backend": "yarn workspace @actual-app/core build:browser",
"build:server": "yarn build:browser && yarn workspace @actual-app/sync-server build", "build:server": "yarn build:browser && yarn workspace @actual-app/sync-server build",
"build:browser": "./bin/package-browser", "build:browser": "./bin/package-browser",
"build:desktop": "./bin/package-electron", "build:desktop": "./bin/package-electron",
"build:plugins-service": "yarn workspace plugins-service build", "build:plugins-service": "yarn workspace plugins-service build",
"build:api": "yarn build --scope=@actual-app/api", "build:api": "yarn workspace @actual-app/api build",
"build:cli": "yarn build --scope=@actual-app/cli", "build:cli": "yarn build --scope=@actual-app/cli",
"build:docs": "yarn workspace docs build", "build:docs": "yarn workspace docs build",
"build:storybook": "yarn workspace @actual-app/components build:storybook", "build:storybook": "yarn workspace @actual-app/components build:storybook",
@@ -52,57 +54,49 @@
"playwright": "yarn workspace @actual-app/web run playwright", "playwright": "yarn workspace @actual-app/web run playwright",
"vrt": "yarn workspace @actual-app/web run vrt", "vrt": "yarn workspace @actual-app/web run vrt",
"vrt:docker": "./bin/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-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core",
"rebuild-node": "yarn workspace @actual-app/core rebuild", "rebuild-node": "yarn workspace @actual-app/core rebuild",
"lint": "oxfmt --check . && oxlint --type-aware --quiet", "lint": "oxfmt --check . && oxlint --type-aware --quiet",
"lint:fix": "oxfmt . && oxlint --fix --type-aware --quiet", "lint:fix": "oxfmt . && oxlint --fix --type-aware --quiet",
"install:server": "yarn workspaces focus @actual-app/sync-server --production", "install:server": "yarn workspaces focus @actual-app/sync-server --production",
"constraints": "yarn constraints", "constraints": "yarn constraints",
"typecheck": "tsgo -p tsconfig.root.json --noEmit && lage typecheck", "typecheck": "tsgo -p tsconfig.root.json --noEmit && lage typecheck",
"check:tsconfig-references": "workspaces-to-typescript-project-references --check", "jq": "./node_modules/node-jq/bin/jq",
"sync:tsconfig-references": "workspaces-to-typescript-project-references",
"prepare": "husky" "prepare": "husky"
}, },
"devDependencies": { "devDependencies": {
"@monorepo-utils/workspaces-to-typescript-project-references": "^2.10.3",
"@octokit/rest": "^22.0.1", "@octokit/rest": "^22.0.1",
"@types/node": "^22.19.17", "@types/node": "^22.19.15",
"@types/prompts": "^2.4.9", "@types/prompts": "^2.4.9",
"@typescript/native-preview": "beta", "@typescript/native-preview": "^7.0.0-dev.20260309.1",
"@yarnpkg/types": "^4.0.1", "@yarnpkg/types": "^4.0.1",
"eslint": "^10.2.0", "baseline-browser-mapping": "^2.10.0",
"eslint-plugin-perfectionist": "^5.8.0", "cross-env": "^10.1.0",
"eslint": "^9.39.3",
"eslint-plugin-perfectionist": "^5.6.0",
"eslint-plugin-typescript-paths": "^0.0.33", "eslint-plugin-typescript-paths": "^0.0.33",
"html-to-image": "^1.11.13",
"husky": "^9.1.7", "husky": "^9.1.7",
"lage": "^2.15.5", "lage": "^2.14.19",
"lint-staged": "^16.4.0", "lint-staged": "^16.3.2",
"minimatch": "^10.2.5", "minimatch": "^10.2.4",
"node-jq": "^6.3.1",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"oxfmt": "^0.44.0", "oxfmt": "^0.32.0",
"oxlint": "^1.59.0", "oxlint": "^1.51.0",
"oxlint-tsgolint": "^0.20.0", "oxlint-tsgolint": "^0.13.0",
"p-limit": "^7.3.0", "p-limit": "^7.3.0",
"prompts": "^2.4.2", "prompts": "^2.4.2",
"source-map-support": "^0.5.21",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^6.0.2", "typescript": "^5.9.3"
"vitest": "^4.1.2"
}, },
"resolutions": { "resolutions": {
"adm-zip": "patch:adm-zip@npm%3A0.5.16#~/.yarn/patches/adm-zip-npm-0.5.16-4556fea098.patch", "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", "rollup": "4.40.1",
"socks": ">=2.8.3" "socks": ">=2.8.3"
}, },
"lint-staged": { "lint-staged": {
"packages/*/{package.json,tsconfig.json}": [
"ts-node ./bin/validate-publish-imports.ts --fix",
"yarn sync:tsconfig-references"
],
"*.{js,mjs,jsx,ts,tsx,md,json,yml,yaml}": [ "*.{js,mjs,jsx,ts,tsx,md,json,yml,yaml}": [
"oxfmt --no-error-on-unmatched-pattern" "oxfmt --no-error-on-unmatched-pattern"
], ],
@@ -118,5 +112,5 @@
"node": ">=22", "node": ">=22",
"yarn": "^4.9.1" "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/ 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,5 +1,5 @@
class Query { class Query {
/** @type {import('@actual-app/core/shared/query').QueryState} */ /** @type {import('loot-core/shared/query').QueryState} */
state; state;
constructor(state) { constructor(state) {

View File

@@ -1,3 +1,8 @@
import type {
RequestInfo as FetchInfo,
RequestInit as FetchInit,
} from 'node-fetch';
import { init as initLootCore } from '@actual-app/core/server/main'; import { init as initLootCore } from '@actual-app/core/server/main';
import type { InitConfig, lib } from '@actual-app/core/server/main'; import type { InitConfig, lib } from '@actual-app/core/server/main';
@@ -12,6 +17,14 @@ export let internal: typeof lib | null = null;
export async function init(config: InitConfig = {}) { export async function init(config: InitConfig = {}) {
validateNodeVersion(); validateNodeVersion();
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>;
};
}
internal = await initLootCore(config); internal = await initLootCore(config);
return internal; return internal;
} }

View File

@@ -1,9 +1,10 @@
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import type { RuleEntity } from '@actual-app/core/types/models';
import { vi } from 'vitest'; import { vi } from 'vitest';
import type { RuleEntity } from '@actual-app/core/types/models';
import * as api from './index'; import * as api from './index';
// In tests we run from source; loot-core's API fs uses __dirname (for the built dist/). // In tests we run from source; loot-core's API fs uses __dirname (for the built dist/).

View File

@@ -1 +0,0 @@
export type * from '@actual-app/core/server/api-models';

View File

@@ -1,13 +1,8 @@
{ {
"name": "@actual-app/api", "name": "@actual-app/api",
"version": "26.4.0", "version": "26.3.0",
"description": "An API for Actual", "description": "An API for Actual",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/actualbudget/actual.git",
"directory": "packages/api"
},
"files": [ "files": [
"@types", "@types",
"dist" "dist"
@@ -16,14 +11,8 @@
"types": "@types/index.d.ts", "types": "@types/index.d.ts",
"exports": { "exports": {
".": { ".": {
"types": "./@types/index.d.ts",
"development": "./index.ts", "development": "./index.ts",
"default": "./dist/index.js" "default": "./dist/index.js"
},
"./models": {
"types": "./@types/models.d.ts",
"development": "./models.ts",
"default": "./dist/models.js"
} }
}, },
"publishConfig": { "publishConfig": {
@@ -31,31 +20,30 @@
".": { ".": {
"types": "./@types/index.d.ts", "types": "./@types/index.d.ts",
"default": "./dist/index.js" "default": "./dist/index.js"
},
"./models": {
"types": "./@types/models.d.ts",
"default": "./dist/models.js"
} }
} }
}, },
"scripts": { "scripts": {
"build": "vite build && tsgo --emitDeclarationOnly", "build": "vite build",
"test": "vitest --run", "test": "vitest --run",
"typecheck": "tsgo -b && tsc-strict" "typecheck": "tsgo -b && tsc-strict"
}, },
"dependencies": { "dependencies": {
"@actual-app/core": "workspace:*", "@actual-app/core": "workspace:*",
"@actual-app/crdt": "workspace:*", "@actual-app/crdt": "workspace:*",
"better-sqlite3": "^12.8.0", "better-sqlite3": "^12.6.2",
"compare-versions": "^6.1.1" "compare-versions": "^6.1.1",
"node-fetch": "^3.3.2",
"uuid": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {
"@typescript/native-preview": "beta", "@typescript/native-preview": "^7.0.0-dev.20260309.1",
"rollup-plugin-visualizer": "^7.0.1", "rollup-plugin-visualizer": "^6.0.11",
"typescript-strict-plugin": "^2.4.4", "typescript-strict-plugin": "^2.4.4",
"vite": "^8.0.5", "vite": "^8.0.0",
"vite-plugin-dts": "^4.5.4",
"vite-plugin-peggy-loader": "^2.0.1", "vite-plugin-peggy-loader": "^2.0.1",
"vitest": "^4.1.2" "vitest": "^4.1.0"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"

View File

@@ -7,7 +7,6 @@
"target": "ES2021", "target": "ES2021",
"module": "es2022", "module": "es2022",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"customConditions": ["api"],
"noEmit": false, "noEmit": false,
"declaration": true, "declaration": true,
"declarationMap": true, "declarationMap": true,
@@ -15,28 +14,9 @@
"rootDir": ".", "rootDir": ".",
"declarationDir": "@types", "declarationDir": "@types",
"tsBuildInfoFile": "dist/.tsbuildinfo", "tsBuildInfoFile": "dist/.tsbuildinfo",
"plugins": [ "plugins": [{ "name": "typescript-strict-plugin", "paths": ["."] }]
{
"name": "typescript-strict-plugin",
"paths": ["."]
}
]
}, },
"references": [ "references": [{ "path": "../crdt" }, { "path": "../loot-core" }],
{
"path": "../loot-core"
},
{
"path": "../crdt"
}
],
"include": ["."], "include": ["."],
"exclude": [ "exclude": ["**/node_modules/*", "dist", "@types", "*.test.ts", "*.config.ts"]
"**/node_modules/*",
"dist",
"@types",
"*.test.ts",
"*.config.ts",
"*.config.mts"
]
} }

View File

@@ -3,6 +3,7 @@ import path from 'path';
import { visualizer } from 'rollup-plugin-visualizer'; import { visualizer } from 'rollup-plugin-visualizer';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';
import peggyLoader from 'vite-plugin-peggy-loader'; import peggyLoader from 'vite-plugin-peggy-loader';
const lootCoreRoot = path.resolve(__dirname, '../loot-core'); const lootCoreRoot = path.resolve(__dirname, '../loot-core');
@@ -54,11 +55,7 @@ function copyMigrationsAndDefaultDb() {
} }
export default defineConfig({ export default defineConfig({
ssr: { ssr: { noExternal: true, external: ['better-sqlite3'] },
noExternal: true,
external: ['better-sqlite3'],
resolve: { conditions: ['api'] },
},
build: { build: {
ssr: true, ssr: true,
target: 'node20', target: 'node20',
@@ -66,22 +63,24 @@ export default defineConfig({
emptyOutDir: true, emptyOutDir: true,
sourcemap: true, sourcemap: true,
lib: { lib: {
entry: { entry: path.resolve(__dirname, 'index.ts'),
index: path.resolve(__dirname, 'index.ts'),
models: path.resolve(__dirname, 'models.ts'),
},
formats: ['cjs'], formats: ['cjs'],
fileName: (_format, entryName) => `${entryName}.js`, fileName: () => 'index.js',
}, },
}, },
plugins: [ plugins: [
cleanOutputDirs(), cleanOutputDirs(),
peggyLoader(), peggyLoader(),
dts({
tsconfigPath: path.resolve(__dirname, 'tsconfig.json'),
outDir: path.resolve(__dirname, '@types'),
rollupTypes: true,
}),
copyMigrationsAndDefaultDb(), copyMigrationsAndDefaultDb(),
visualizer({ template: 'raw-data', filename: 'app/stats.json' }), visualizer({ template: 'raw-data', filename: 'app/stats.json' }),
], ],
resolve: { resolve: {
conditions: ['api'], extensions: ['.api.ts', '.js', '.ts', '.tsx', '.json'],
}, },
test: { test: {
globals: true, globals: true,

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,312 +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}'`);
const AUTOGEN_MARKER = '<!-- release-notes:auto-generated -->';
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',
});
}
// recover deleted release note files from previous generation commits
const baseRef = process.env.GITHUB_BASE_REF || 'master';
await exec(`git fetch origin ${baseRef}`, { stdio: 'inherit' });
const { stdout: mergeBase } = await exec(
`git merge-base HEAD origin/${baseRef}`,
);
const base = mergeBase.trim();
const { stdout: genLog } = await exec(
`git log --grep='${commitMessage}' --format=%H ${base}..HEAD`,
);
const genCommits = genLog.split('\n').filter(Boolean);
console.log(
`Reversing upcoming-release-notes deletions from ${genCommits.length} prior generation commit(s)`,
);
const tmpDir = process.env.RUNNER_TEMP || '/tmp';
for (const sha of genCommits) {
const patchPath = join(tmpDir, `revert-${sha}.patch`);
try {
await exec(
`git diff --diff-filter=D ${sha}~1..${sha} -- upcoming-release-notes > ${patchPath}`,
);
const { size } = await fs.stat(patchPath);
if (size > 0) {
await exec(`git apply -R --3way ${patchPath}`, { stdio: 'inherit' });
}
} finally {
await fs.unlink(patchPath).catch(() => undefined);
}
}
});
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';
const blogPath = join(
'packages/docs/blog',
`${releaseDate}-release-${slug}.md`,
);
const releasesPath = 'packages/docs/docs/releases.md';
await group('Generate blog post', async () => {
const template = `---
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}**
${AUTOGEN_MARKER}
${categorizedNotes}
`;
let blogContent;
try {
const existing = await fs.readFile(blogPath, 'utf-8');
const idx = existing.indexOf(AUTOGEN_MARKER);
if (idx === -1) {
console.log(
`WARNING: ${blogPath} missing ${AUTOGEN_MARKER}, rewriting from template`,
);
blogContent = template;
} else {
blogContent =
existing.slice(0, idx + AUTOGEN_MARKER.length) +
'\n' +
categorizedNotes +
'\n';
}
} catch (e) {
if (e.code !== 'ENOENT') throw e;
blogContent = template;
}
await fs.writeFile(blogPath, blogContent);
console.log(`Wrote ${blogPath}`);
});
await group('Update releases.md', async () => {
const existing = await fs.readFile(releasesPath, 'utf-8');
const sectionRe = new RegExp(
`(^|\\n)## ${escapeRegExp(version)}\\n[\\s\\S]*?(?=\\n## |$)`,
);
const match = existing.match(sectionRe);
let updated;
if (match) {
const section = match[0];
const idx = section.indexOf(AUTOGEN_MARKER);
if (idx === -1) {
console.log(
`WARNING: section for ${version} in ${releasesPath} missing ${AUTOGEN_MARKER}, leaving as-is`,
);
updated = existing;
} else {
const newSection =
section.slice(0, idx + AUTOGEN_MARKER.length) + '\n' + categorizedNotes;
updated = existing.replace(section, newSection);
}
} else {
const newSection = `## ${version}
Release date: ${releaseDate}
${highlights}
**Docker Tag: ${version}**
${AUTOGEN_MARKER}
${categorizedNotes}`;
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('Format generated files', async () => {
await exec(`yarn exec oxfmt ${blogPath} ${releasesPath}`, {
stdio: 'inherit',
});
});
await group('Commit and push', async () => {
await exec(
'git add upcoming-release-notes packages/docs/blog packages/docs/docs/releases.md',
{ stdio: 'inherit' },
);
try {
await exec('git diff --cached --quiet');
console.log('No changes to commit');
return;
} catch {
// there are staged changes
}
await exec(`git commit -m '${commitMessage}'`);
await exec('git push 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 escapeRegExp(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
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

@@ -8,12 +8,9 @@
"typecheck": "tsgo -b" "typecheck": "tsgo -b"
}, },
"devDependencies": { "devDependencies": {
"@octokit/rest": "^22.0.1", "@typescript/native-preview": "^7.0.0-dev.20260309.1",
"@typescript/native-preview": "beta",
"extensionless": "^2.0.6", "extensionless": "^2.0.6",
"gray-matter": "^4.0.3", "vitest": "^4.1.0"
"listify": "^1.0.3",
"vitest": "^4.1.2"
}, },
"extensionless": { "extensionless": {
"lookFor": [ "lookFor": [

View File

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

View File

@@ -60,7 +60,7 @@ function resolveType(
currentDate.getFullYear() === 2000 + versionYear && currentDate.getFullYear() === 2000 + versionYear &&
currentDate.getMonth() + 1 === versionMonth; currentDate.getMonth() + 1 === versionMonth;
if (inPatchMonth && currentDate.getDate() < 25) { if (inPatchMonth && currentDate.getDate() <= 25) {
return 'hotfix'; return 'hotfix';
} }

View File

@@ -43,16 +43,13 @@ Configuration is resolved in this order (highest priority first):
### Environment Variables ### Environment Variables
| Variable | Description | | Variable | Description |
| ---------------------- | ----------------------------------------------------- | | ---------------------- | --------------------------------------------- |
| `ACTUAL_SERVER_URL` | URL of the Actual sync server (required) | | `ACTUAL_SERVER_URL` | URL of the Actual sync server (required) |
| `ACTUAL_PASSWORD` | Server password (required unless using token) | | `ACTUAL_PASSWORD` | Server password (required unless using token) |
| `ACTUAL_SESSION_TOKEN` | Session token (alternative to password) | | `ACTUAL_SESSION_TOKEN` | Session token (alternative to password) |
| `ACTUAL_SYNC_ID` | Budget Sync ID (required for most commands) | | `ACTUAL_SYNC_ID` | Budget Sync ID (required for most commands) |
| `ACTUAL_DATA_DIR` | Local directory for cached budget data | | `ACTUAL_DATA_DIR` | Local directory for cached budget data |
| `ACTUAL_CACHE_TTL` | Cache TTL in seconds (default: 60) |
| `ACTUAL_LOCK_TIMEOUT` | Budget-dir lock wait timeout in seconds (default: 10) |
| `ACTUAL_NO_LOCK` | Set to `1` to disable budget-dir locking |
### Config File ### Config File
@@ -62,10 +59,7 @@ Create an `.actualrc.json` (or `.actualrc`, `.actualrc.yaml`, `actual.config.js`
{ {
"serverUrl": "http://localhost:5006", "serverUrl": "http://localhost:5006",
"password": "your-password", "password": "your-password",
"syncId": "1cfdbb80-6274-49bf-b0c2-737235a4c81f", "syncId": "1cfdbb80-6274-49bf-b0c2-737235a4c81f"
"cacheTtl": 60,
"lockTimeout": 10,
"noLock": false
} }
``` ```
@@ -80,11 +74,6 @@ Create an `.actualrc.json` (or `.actualrc`, `.actualrc.yaml`, `actual.config.js`
| `--session-token <token>` | Session token | | `--session-token <token>` | Session token |
| `--sync-id <id>` | Budget Sync ID | | `--sync-id <id>` | Budget Sync ID |
| `--data-dir <path>` | Data directory | | `--data-dir <path>` | Data directory |
| `--cache-ttl <seconds>` | Cache TTL; `0` disables caching (default: 60) |
| `--refresh` | Force a sync on this call, ignoring the cache |
| `--no-cache` | Alias for `--refresh` |
| `--lock-timeout <secs>` | Lock wait timeout (default: 10) |
| `--no-lock` | Disable budget-dir locking (use with care) |
| `--format <format>` | Output format: `json` (default), `table`, `csv` | | `--format <format>` | Output format: `json` (default), `table`, `csv` |
| `--verbose` | Show informational messages | | `--verbose` | Show informational messages |
@@ -103,15 +92,14 @@ Create an `.actualrc.json` (or `.actualrc`, `.actualrc.yaml`, `actual.config.js`
| `schedules` | Manage scheduled transactions | | `schedules` | Manage scheduled transactions |
| `query` | Run an ActualQL query | | `query` | Run an ActualQL query |
| `server` | Server utilities and lookups | | `server` | Server utilities and lookups |
| `sync` | Refresh or inspect local cache |
Run `actual <command> --help` for subcommands and options. Run `actual <command> --help` for subcommands and options.
### Examples ### Examples
```bash ```bash
# List all accounts (as a table; excludes closed by default) # List all accounts (as a table)
actual accounts list [--include-closed] --format table actual accounts list --format table
# Find an entity ID by name # Find an entity ID by name
actual server get-id --type accounts --name "Checking" actual server get-id --type accounts --name "Checking"
@@ -134,45 +122,13 @@ actual query run --table transactions \
### Amount Convention ### Amount Convention
All monetary amounts are **integer cents** when passed as input (flags, JSON): All monetary amounts are **integer cents**:
| CLI Value | Dollar Amount | | CLI Value | Dollar Amount |
| --------- | ------------- | | --------- | ------------- |
| `5000` | $50.00 | | `5000` | $50.00 |
| `-12350` | -$123.50 | | `-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.
- **Rapid sequential requests:** The CLI caches the budget locally (see [Caching](#caching)), so read-heavy scripts no longer need a single-query workaround by default. For very chatty scripts, run `actual sync` once and then use a long `--cache-ttl` for reads:
```bash
actual sync
actual --cache-ttl 3600 query run ...
actual --cache-ttl 3600 accounts list
```
- **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.
## Caching
The CLI keeps a local copy of your budget so repeated commands don't hit the sync server on every call. Within the TTL (default `60` seconds), read commands (`list`, `balance`, `query run`, …) reuse the cached budget without a network round-trip. Write commands (`add`, `update`, `set-amount`, …) always sync with the server before and after the write.
- `actual sync` — refresh the cache now.
- `actual sync --status` — show how stale the local cache is.
- `actual sync --clear` — delete the local cache; the next command re-downloads.
- `--refresh` (or `--no-cache`) — force a sync on a single call.
- `--cache-ttl <seconds>` — override the TTL for a single call (use `0` to disable caching).
### Concurrency
The CLI takes a shared lock for reads and an exclusive lock for writes on the per-budget cache directory. Many parallel reads are safe; writes serialize. If another CLI process is holding the lock, subsequent invocations wait up to `--lock-timeout` seconds (default `10`) before failing with an error. Pass `--no-lock` to opt out in trusted single-process setups.
## Running Locally (Development) ## Running Locally (Development)
If you're working on the CLI within the monorepo: If you're working on the CLI within the monorepo:

View File

@@ -1,13 +1,8 @@
{ {
"name": "@actual-app/cli", "name": "@actual-app/cli",
"version": "26.4.0", "version": "26.3.0",
"description": "CLI for Actual Budget", "description": "CLI for Actual Budget",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/actualbudget/actual.git",
"directory": "packages/cli"
},
"bin": { "bin": {
"actual": "./dist/cli.js", "actual": "./dist/cli.js",
"actual-cli": "./dist/cli.js" "actual-cli": "./dist/cli.js"
@@ -16,16 +11,6 @@
"dist" "dist"
], ],
"type": "module", "type": "module",
"imports": {
"#cache": "./src/cache.ts",
"#commands/*": "./src/commands/*.ts",
"#config": "./src/config.ts",
"#connection": "./src/connection.ts",
"#input": "./src/input.ts",
"#lock": "./src/lock.ts",
"#output": "./src/output.ts",
"#utils": "./src/utils.ts"
},
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
"test": "vitest --run", "test": "vitest --run",
@@ -34,17 +19,15 @@
"dependencies": { "dependencies": {
"@actual-app/api": "workspace:*", "@actual-app/api": "workspace:*",
"cli-table3": "^0.6.5", "cli-table3": "^0.6.5",
"commander": "^14.0.3", "commander": "^13.0.0",
"cosmiconfig": "^9.0.1", "cosmiconfig": "^9.0.0"
"proper-lockfile": "^4.1.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.19.17", "@types/node": "^22.19.15",
"@types/proper-lockfile": "^4", "@typescript/native-preview": "^7.0.0-dev.20260309.1",
"@typescript/native-preview": "beta", "rollup-plugin-visualizer": "^6.0.11",
"rollup-plugin-visualizer": "^7.0.1", "vite": "^8.0.0",
"vite": "^8.0.5", "vitest": "^4.1.0"
"vitest": "^4.1.2"
}, },
"engines": { "engines": {
"node": ">=22" "node": ">=22"

View File

@@ -1,206 +0,0 @@
import {
existsSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
CACHE_FILE_NAME,
decideSyncAction,
readCacheState,
writeCacheState,
} from './cache';
describe('readCacheState', () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'actual-cli-cache-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('returns null when the file does not exist', () => {
expect(readCacheState(dir)).toBeNull();
});
it('returns null when the file is corrupt', () => {
writeFileSync(join(dir, CACHE_FILE_NAME), 'not json');
expect(readCacheState(dir)).toBeNull();
});
it('returns null when the file has the wrong version', () => {
writeFileSync(
join(dir, CACHE_FILE_NAME),
JSON.stringify({
version: 999,
syncId: 'a',
budgetId: 'b',
serverUrl: 'c',
lastSyncedAt: 1,
lastDownloadedAt: 1,
}),
);
expect(readCacheState(dir)).toBeNull();
});
it('returns the parsed state when the file is valid', () => {
writeFileSync(
join(dir, CACHE_FILE_NAME),
JSON.stringify({
version: 1,
syncId: 'a',
budgetId: 'b',
serverUrl: 'c',
lastSyncedAt: 1234,
lastDownloadedAt: 5678,
}),
);
expect(readCacheState(dir)).toEqual({
version: 1,
syncId: 'a',
budgetId: 'b',
serverUrl: 'c',
lastSyncedAt: 1234,
lastDownloadedAt: 5678,
});
});
});
describe('writeCacheState', () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'actual-cli-cache-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('writes the state to the cache file', () => {
writeCacheState(dir, {
version: 1,
syncId: 'a',
budgetId: 'b',
serverUrl: 'c',
lastSyncedAt: 1,
lastDownloadedAt: 1,
});
const raw = readFileSync(join(dir, CACHE_FILE_NAME), 'utf-8');
expect(JSON.parse(raw).syncId).toBe('a');
});
it('is atomic: removes the tmp file after rename', () => {
writeCacheState(dir, {
version: 1,
syncId: 'a',
budgetId: 'b',
serverUrl: 'c',
lastSyncedAt: 1,
lastDownloadedAt: 1,
});
expect(existsSync(join(dir, `${CACHE_FILE_NAME}.tmp`))).toBe(false);
});
it('does not throw when the filesystem refuses the write', () => {
// Force ENOTDIR by pointing writeCacheState at a path whose parent is a
// regular file — no OS-specific pseudo-filesystem semantics needed.
const file = join(dir, 'not-a-dir');
writeFileSync(file, '');
expect(() =>
writeCacheState(join(file, 'nested'), {
version: 1,
syncId: 'a',
budgetId: 'b',
serverUrl: 'c',
lastSyncedAt: 1,
lastDownloadedAt: 1,
}),
).not.toThrow();
});
});
describe('decideSyncAction', () => {
const base = {
state: {
version: 1 as const,
syncId: 'sync-1',
budgetId: 'bud-1',
serverUrl: 'http://s',
lastSyncedAt: 1_000_000,
lastDownloadedAt: 1_000_000,
},
config: { syncId: 'sync-1', serverUrl: 'http://s' },
now: 1_000_000,
ttlMs: 60_000,
mutates: false,
refresh: false,
encrypted: false,
};
it('returns "download" when state is null', () => {
expect(decideSyncAction({ ...base, state: null }).action).toBe('download');
});
it('returns "download" when syncId changed', () => {
expect(
decideSyncAction({
...base,
config: { ...base.config, syncId: 'other' },
}).action,
).toBe('download');
});
it('returns "download" when serverUrl changed', () => {
expect(
decideSyncAction({
...base,
config: { ...base.config, serverUrl: 'http://other' },
}).action,
).toBe('download');
});
it('returns "skip" for a read within the TTL', () => {
expect(decideSyncAction({ ...base, now: 1_000_000 + 30_000 }).action).toBe(
'skip',
);
});
it('returns "sync" for a read past the TTL', () => {
expect(decideSyncAction({ ...base, now: 1_000_000 + 61_000 }).action).toBe(
'sync',
);
});
it('returns "sync" for a write even when fresh', () => {
expect(decideSyncAction({ ...base, mutates: true }).action).toBe('sync');
});
it('returns "sync" when refresh is true', () => {
expect(decideSyncAction({ ...base, refresh: true }).action).toBe('sync');
});
it('returns "sync" when ttlMs is 0', () => {
expect(decideSyncAction({ ...base, ttlMs: 0 }).action).toBe('sync');
});
it('returns "sync" for encrypted budgets within the TTL', () => {
expect(decideSyncAction({ ...base, encrypted: true }).action).toBe('sync');
});
it('treats clock skew (negative age) as stale', () => {
expect(decideSyncAction({ ...base, now: 999_999 }).action).toBe('sync');
});
it('carries cached state on non-download actions', () => {
const decision = decideSyncAction({ ...base, mutates: true });
expect(decision).toEqual({ action: 'sync', state: base.state });
});
});

View File

@@ -1,107 +0,0 @@
import { randomBytes } from 'node:crypto';
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { isRecord } from './utils';
export const CACHE_FILE_NAME = 'state.json';
export const CACHE_VERSION = 1;
export const META_ROOT_DIR = '.actual-cli';
export type CacheState = {
version: typeof CACHE_VERSION;
syncId: string;
budgetId: string;
serverUrl: string;
lastSyncedAt: number;
lastDownloadedAt: number;
};
export function getMetaDir(dataDir: string, syncId: string): string {
return join(dataDir, META_ROOT_DIR, syncId);
}
function cachePath(metaDir: string): string {
return join(metaDir, CACHE_FILE_NAME);
}
function isCacheState(value: unknown): value is CacheState {
if (!isRecord(value)) return false;
return (
value.version === CACHE_VERSION &&
typeof value.syncId === 'string' &&
typeof value.budgetId === 'string' &&
typeof value.serverUrl === 'string' &&
typeof value.lastSyncedAt === 'number' &&
typeof value.lastDownloadedAt === 'number'
);
}
export function readCacheState(metaDir: string): CacheState | null {
let raw: string;
try {
raw = readFileSync(cachePath(metaDir), 'utf-8');
} catch {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
return isCacheState(parsed) ? parsed : null;
}
export function writeCacheState(metaDir: string, state: CacheState): void {
try {
mkdirSync(metaDir, { recursive: true });
const target = cachePath(metaDir);
// Unique tmp name per writer: concurrent shared-lock commands (encrypted
// budgets, --refresh, stale TTL) can both publish, and a shared tmp path
// lets the second writer's truncate destroy the first writer's bytes
// before either renames into place.
const tmp = `${target}.${process.pid}-${randomBytes(4).toString('hex')}.tmp`;
writeFileSync(tmp, JSON.stringify(state));
renameSync(tmp, target);
} catch {
// Cache persistence is best-effort. A read-only or unreachable dir must
// not crash the CLI; the next invocation simply won't find a cache.
}
}
export type SyncDecision =
| { action: 'download' }
| { action: 'skip'; state: CacheState }
| { action: 'sync'; state: CacheState };
export type DecideSyncArgs = {
state: CacheState | null;
config: { syncId: string; serverUrl: string };
now: number;
ttlMs: number;
mutates: boolean;
refresh: boolean;
encrypted: boolean;
};
export function decideSyncAction({
state,
config,
now,
ttlMs,
mutates,
refresh,
encrypted,
}: DecideSyncArgs): SyncDecision {
if (state === null) return { action: 'download' };
if (state.syncId !== config.syncId) return { action: 'download' };
if (state.serverUrl !== config.serverUrl) return { action: 'download' };
if (mutates || refresh || ttlMs === 0 || encrypted) {
return { action: 'sync', state };
}
const age = now - state.lastSyncedAt;
if (age < 0) return { action: 'sync', state };
if (age < ttlMs) return { action: 'skip', state };
return { action: 'sync', state };
}

View File

@@ -1,7 +1,7 @@
import * as api from '@actual-app/api'; import * as api from '@actual-app/api';
import { Command } from 'commander'; import { Command } from 'commander';
import { printOutput } from '#output'; import { printOutput } from '../output';
import { registerAccountsCommand } from './accounts'; import { registerAccountsCommand } from './accounts';
@@ -15,11 +15,11 @@ vi.mock('@actual-app/api', () => ({
getAccountBalance: vi.fn().mockResolvedValue(10000), getAccountBalance: vi.fn().mockResolvedValue(10000),
})); }));
vi.mock('#connection', () => ({ vi.mock('../connection', () => ({
withConnection: vi.fn((_opts, fn) => fn()), withConnection: vi.fn((_opts, fn) => fn()),
})); }));
vi.mock('#output', () => ({ vi.mock('../output', () => ({
printOutput: vi.fn(), printOutput: vi.fn(),
})); }));
@@ -62,28 +62,14 @@ describe('accounts commands', () => {
}); });
describe('list', () => { describe('list', () => {
it('calls api.getAccounts and prints result with computed balance', async () => { it('calls api.getAccounts and prints result', async () => {
const accounts = [ const accounts = [{ id: '1', name: 'Checking' }];
{ id: '1', name: 'Checking', offbudget: false, closed: false },
];
vi.mocked(api.getAccounts).mockResolvedValue(accounts); vi.mocked(api.getAccounts).mockResolvedValue(accounts);
await run(['accounts', 'list']); await run(['accounts', 'list']);
expect(api.getAccounts).toHaveBeenCalled(); expect(api.getAccounts).toHaveBeenCalled();
expect(api.getAccountBalance).toHaveBeenCalledWith('1'); expect(printOutput).toHaveBeenCalledWith(accounts, undefined);
expect(printOutput).toHaveBeenCalledWith(
[
{
id: '1',
name: 'Checking',
offbudget: false,
closed: false,
balance: 10000,
},
],
undefined,
);
}); });
it('passes format option to printOutput', async () => { it('passes format option to printOutput', async () => {
@@ -93,59 +79,6 @@ describe('accounts commands', () => {
expect(printOutput).toHaveBeenCalledWith([], 'csv'); 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', () => { describe('create', () => {

View File

@@ -1,9 +1,9 @@
import * as api from '@actual-app/api'; import * as api from '@actual-app/api';
import type { Command } from 'commander'; import type { Command } from 'commander';
import { withConnection } from '#connection'; import { withConnection } from '../connection';
import { printOutput } from '#output'; import { printOutput } from '../output';
import { parseBoolFlag, parseIntFlag } from '#utils'; import { parseBoolFlag, parseIntFlag } from '../utils';
export function registerAccountsCommand(program: Command) { export function registerAccountsCommand(program: Command) {
const accounts = program.command('accounts').description('Manage accounts'); const accounts = program.command('accounts').description('Manage accounts');
@@ -11,33 +11,12 @@ export function registerAccountsCommand(program: Command) {
accounts accounts
.command('list') .command('list')
.description('List all accounts') .description('List all accounts')
.option('--include-closed', 'Include closed accounts', false) .action(async () => {
.action(async cmdOpts => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, const result = await api.getAccounts();
async () => { printOutput(result, opts.format);
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);
},
{ mutates: false },
);
}); });
accounts accounts
@@ -45,25 +24,17 @@ export function registerAccountsCommand(program: Command) {
.description('Create a new account') .description('Create a new account')
.requiredOption('--name <name>', 'Account name') .requiredOption('--name <name>', 'Account name')
.option('--offbudget', 'Create as off-budget account', false) .option('--offbudget', 'Create as off-budget account', false)
.option( .option('--balance <amount>', 'Initial balance in cents', '0')
'--balance <amount>',
'Initial balance in cents (e.g. 50000 = 500.00)',
'0',
)
.action(async cmdOpts => { .action(async cmdOpts => {
const balance = parseIntFlag(cmdOpts.balance, '--balance'); const balance = parseIntFlag(cmdOpts.balance, '--balance');
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, const id = await api.createAccount(
async () => { { name: cmdOpts.name, offbudget: cmdOpts.offbudget },
const id = await api.createAccount( balance,
{ name: cmdOpts.name, offbudget: cmdOpts.offbudget }, );
balance, printOutput({ id }, opts.format);
); });
printOutput({ id }, opts.format);
},
{ mutates: true },
);
}); });
accounts accounts
@@ -89,14 +60,10 @@ export function registerAccountsCommand(program: Command) {
'No update fields provided. Use --name or --offbudget.', 'No update fields provided. Use --name or --offbudget.',
); );
} }
await withConnection( await withConnection(opts, async () => {
opts, await api.updateAccount(id, fields);
async () => { printOutput({ success: true, id }, opts.format);
await api.updateAccount(id, fields); });
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
}); });
accounts accounts
@@ -112,18 +79,14 @@ export function registerAccountsCommand(program: Command) {
) )
.action(async (id: string, cmdOpts) => { .action(async (id: string, cmdOpts) => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, await api.closeAccount(
async () => { id,
await api.closeAccount( cmdOpts.transferAccount,
id, cmdOpts.transferCategory,
cmdOpts.transferAccount, );
cmdOpts.transferCategory, printOutput({ success: true, id }, opts.format);
); });
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
}); });
accounts accounts
@@ -131,14 +94,10 @@ export function registerAccountsCommand(program: Command) {
.description('Reopen a closed account') .description('Reopen a closed account')
.action(async (id: string) => { .action(async (id: string) => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, await api.reopenAccount(id);
async () => { printOutput({ success: true, id }, opts.format);
await api.reopenAccount(id); });
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
}); });
accounts accounts
@@ -146,14 +105,10 @@ export function registerAccountsCommand(program: Command) {
.description('Delete an account') .description('Delete an account')
.action(async (id: string) => { .action(async (id: string) => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, await api.deleteAccount(id);
async () => { printOutput({ success: true, id }, opts.format);
await api.deleteAccount(id); });
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
}); });
accounts accounts
@@ -172,13 +127,9 @@ export function registerAccountsCommand(program: Command) {
cutoff = cutoffDate; cutoff = cutoffDate;
} }
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, const balance = await api.getAccountBalance(id, cutoff);
async () => { printOutput({ id, balance }, opts.format);
const balance = await api.getAccountBalance(id, cutoff); });
printOutput({ id, balance }, opts.format);
},
{ mutates: false },
);
}); });
} }

View File

@@ -1,9 +1,10 @@
import * as api from '@actual-app/api'; import * as api from '@actual-app/api';
import type { Command } from 'commander'; import type { Command } from 'commander';
import { withConnection } from '#connection'; import { resolveConfig } from '../config';
import { printOutput } from '#output'; import { withConnection } from '../connection';
import { parseBoolFlag, parseIntFlag } from '#utils'; import { printOutput } from '../output';
import { parseBoolFlag, parseIntFlag } from '../utils';
export function registerBudgetsCommand(program: Command) { export function registerBudgetsCommand(program: Command) {
const budgets = program.command('budgets').description('Manage budgets'); const budgets = program.command('budgets').description('Manage budgets');
@@ -19,7 +20,7 @@ export function registerBudgetsCommand(program: Command) {
const result = await api.getBudgets(); const result = await api.getBudgets();
printOutput(result, opts.format); printOutput(result, opts.format);
}, },
{ mutates: false, skipBudget: true }, { loadBudget: false },
); );
}); });
@@ -29,33 +30,40 @@ export function registerBudgetsCommand(program: Command) {
.option('--encryption-password <password>', 'Encryption password') .option('--encryption-password <password>', 'Encryption password')
.action(async (syncId: string, cmdOpts) => { .action(async (syncId: string, cmdOpts) => {
const opts = program.opts(); const opts = program.opts();
const config = await resolveConfig(opts);
const password = config.encryptionPassword ?? cmdOpts.encryptionPassword;
await withConnection( await withConnection(
opts, opts,
async config => { async () => {
const password =
cmdOpts.encryptionPassword ?? config.encryptionPassword;
await api.downloadBudget(syncId, { await api.downloadBudget(syncId, {
password, password,
}); });
printOutput({ success: true, syncId }, opts.format); printOutput({ success: true, syncId }, opts.format);
}, },
{ mutates: false, skipBudget: true }, { 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 budgets
.command('months') .command('months')
.description('List available budget months') .description('List available budget months')
.action(async () => { .action(async () => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, const result = await api.getBudgetMonths();
async () => { printOutput(result, opts.format);
const result = await api.getBudgetMonths(); });
printOutput(result, opts.format);
},
{ mutates: false },
);
}); });
budgets budgets
@@ -63,14 +71,10 @@ export function registerBudgetsCommand(program: Command) {
.description('Get budget data for a specific month (YYYY-MM)') .description('Get budget data for a specific month (YYYY-MM)')
.action(async (month: string) => { .action(async (month: string) => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, const result = await api.getBudgetMonth(month);
async () => { printOutput(result, opts.format);
const result = await api.getBudgetMonth(month); });
printOutput(result, opts.format);
},
{ mutates: false },
);
}); });
budgets budgets
@@ -78,21 +82,14 @@ export function registerBudgetsCommand(program: Command) {
.description('Set budget amount for a category in a month') .description('Set budget amount for a category in a month')
.requiredOption('--month <month>', 'Budget month (YYYY-MM)') .requiredOption('--month <month>', 'Budget month (YYYY-MM)')
.requiredOption('--category <id>', 'Category ID') .requiredOption('--category <id>', 'Category ID')
.requiredOption( .requiredOption('--amount <amount>', 'Amount in cents')
'--amount <amount>',
'Amount in cents (e.g. 50000 = 500.00)',
)
.action(async cmdOpts => { .action(async cmdOpts => {
const amount = parseIntFlag(cmdOpts.amount, '--amount'); const amount = parseIntFlag(cmdOpts.amount, '--amount');
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, await api.setBudgetAmount(cmdOpts.month, cmdOpts.category, amount);
async () => { printOutput({ success: true }, opts.format);
await api.setBudgetAmount(cmdOpts.month, cmdOpts.category, amount); });
printOutput({ success: true }, opts.format);
},
{ mutates: true },
);
}); });
budgets budgets
@@ -104,35 +101,24 @@ export function registerBudgetsCommand(program: Command) {
.action(async cmdOpts => { .action(async cmdOpts => {
const flag = parseBoolFlag(cmdOpts.flag, '--flag'); const flag = parseBoolFlag(cmdOpts.flag, '--flag');
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, await api.setBudgetCarryover(cmdOpts.month, cmdOpts.category, flag);
async () => { printOutput({ success: true }, opts.format);
await api.setBudgetCarryover(cmdOpts.month, cmdOpts.category, flag); });
printOutput({ success: true }, opts.format);
},
{ mutates: true },
);
}); });
budgets budgets
.command('hold-next-month') .command('hold-next-month')
.description('Hold budget amount for next month') .description('Hold budget amount for next month')
.requiredOption('--month <month>', 'Budget month (YYYY-MM)') .requiredOption('--month <month>', 'Budget month (YYYY-MM)')
.requiredOption( .requiredOption('--amount <amount>', 'Amount in cents')
'--amount <amount>',
'Amount in cents (e.g. 50000 = 500.00)',
)
.action(async cmdOpts => { .action(async cmdOpts => {
const parsedAmount = parseIntFlag(cmdOpts.amount, '--amount'); const parsedAmount = parseIntFlag(cmdOpts.amount, '--amount');
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, await api.holdBudgetForNextMonth(cmdOpts.month, parsedAmount);
async () => { printOutput({ success: true }, opts.format);
await api.holdBudgetForNextMonth(cmdOpts.month, parsedAmount); });
printOutput({ success: true }, opts.format);
},
{ mutates: true },
);
}); });
budgets budgets
@@ -141,13 +127,9 @@ export function registerBudgetsCommand(program: Command) {
.requiredOption('--month <month>', 'Budget month (YYYY-MM)') .requiredOption('--month <month>', 'Budget month (YYYY-MM)')
.action(async cmdOpts => { .action(async cmdOpts => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, await api.resetBudgetHold(cmdOpts.month);
async () => { printOutput({ success: true }, opts.format);
await api.resetBudgetHold(cmdOpts.month); });
printOutput({ success: true }, opts.format);
},
{ mutates: true },
);
}); });
} }

View File

@@ -1,9 +1,9 @@
import * as api from '@actual-app/api'; import * as api from '@actual-app/api';
import type { Command } from 'commander'; import type { Command } from 'commander';
import { withConnection } from '#connection'; import { withConnection } from '../connection';
import { printOutput } from '#output'; import { printOutput } from '../output';
import { parseBoolFlag } from '#utils'; import { parseBoolFlag } from '../utils';
export function registerCategoriesCommand(program: Command) { export function registerCategoriesCommand(program: Command) {
const categories = program const categories = program
@@ -15,14 +15,10 @@ export function registerCategoriesCommand(program: Command) {
.description('List all categories') .description('List all categories')
.action(async () => { .action(async () => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, const result = await api.getCategories();
async () => { printOutput(result, opts.format);
const result = await api.getCategories(); });
printOutput(result, opts.format);
},
{ mutates: false },
);
}); });
categories categories
@@ -33,19 +29,15 @@ export function registerCategoriesCommand(program: Command) {
.option('--is-income', 'Mark as income category', false) .option('--is-income', 'Mark as income category', false)
.action(async cmdOpts => { .action(async cmdOpts => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, const id = await api.createCategory({
async () => { name: cmdOpts.name,
const id = await api.createCategory({ group_id: cmdOpts.groupId,
name: cmdOpts.name, is_income: cmdOpts.isIncome,
group_id: cmdOpts.groupId, hidden: false,
is_income: cmdOpts.isIncome, });
hidden: false, printOutput({ id }, opts.format);
}); });
printOutput({ id }, opts.format);
},
{ mutates: true },
);
}); });
categories categories
@@ -63,14 +55,10 @@ export function registerCategoriesCommand(program: Command) {
throw new Error('No update fields provided. Use --name or --hidden.'); throw new Error('No update fields provided. Use --name or --hidden.');
} }
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, await api.updateCategory(id, fields);
async () => { printOutput({ success: true, id }, opts.format);
await api.updateCategory(id, fields); });
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
}); });
categories categories
@@ -79,13 +67,9 @@ export function registerCategoriesCommand(program: Command) {
.option('--transfer-to <id>', 'Transfer transactions to this category') .option('--transfer-to <id>', 'Transfer transactions to this category')
.action(async (id: string, cmdOpts) => { .action(async (id: string, cmdOpts) => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, await api.deleteCategory(id, cmdOpts.transferTo);
async () => { printOutput({ success: true, id }, opts.format);
await api.deleteCategory(id, cmdOpts.transferTo); });
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
}); });
} }

View File

@@ -1,9 +1,9 @@
import * as api from '@actual-app/api'; import * as api from '@actual-app/api';
import type { Command } from 'commander'; import type { Command } from 'commander';
import { withConnection } from '#connection'; import { withConnection } from '../connection';
import { printOutput } from '#output'; import { printOutput } from '../output';
import { parseBoolFlag } from '#utils'; import { parseBoolFlag } from '../utils';
export function registerCategoryGroupsCommand(program: Command) { export function registerCategoryGroupsCommand(program: Command) {
const groups = program const groups = program
@@ -15,14 +15,10 @@ export function registerCategoryGroupsCommand(program: Command) {
.description('List all category groups') .description('List all category groups')
.action(async () => { .action(async () => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, const result = await api.getCategoryGroups();
async () => { printOutput(result, opts.format);
const result = await api.getCategoryGroups(); });
printOutput(result, opts.format);
},
{ mutates: false },
);
}); });
groups groups
@@ -32,18 +28,14 @@ export function registerCategoryGroupsCommand(program: Command) {
.option('--is-income', 'Mark as income group', false) .option('--is-income', 'Mark as income group', false)
.action(async cmdOpts => { .action(async cmdOpts => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, const id = await api.createCategoryGroup({
async () => { name: cmdOpts.name,
const id = await api.createCategoryGroup({ is_income: cmdOpts.isIncome,
name: cmdOpts.name, hidden: false,
is_income: cmdOpts.isIncome, });
hidden: false, printOutput({ id }, opts.format);
}); });
printOutput({ id }, opts.format);
},
{ mutates: true },
);
}); });
groups groups
@@ -61,14 +53,10 @@ export function registerCategoryGroupsCommand(program: Command) {
throw new Error('No update fields provided. Use --name or --hidden.'); throw new Error('No update fields provided. Use --name or --hidden.');
} }
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, await api.updateCategoryGroup(id, fields);
async () => { printOutput({ success: true, id }, opts.format);
await api.updateCategoryGroup(id, fields); });
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
}); });
groups groups
@@ -77,13 +65,9 @@ export function registerCategoryGroupsCommand(program: Command) {
.option('--transfer-to <id>', 'Transfer transactions to this category ID') .option('--transfer-to <id>', 'Transfer transactions to this category ID')
.action(async (id: string, cmdOpts) => { .action(async (id: string, cmdOpts) => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, await api.deleteCategoryGroup(id, cmdOpts.transferTo);
async () => { printOutput({ success: true, id }, opts.format);
await api.deleteCategoryGroup(id, cmdOpts.transferTo); });
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
}); });
} }

View File

@@ -1,8 +1,8 @@
import * as api from '@actual-app/api'; import * as api from '@actual-app/api';
import type { Command } from 'commander'; import type { Command } from 'commander';
import { withConnection } from '#connection'; import { withConnection } from '../connection';
import { printOutput } from '#output'; import { printOutput } from '../output';
export function registerPayeesCommand(program: Command) { export function registerPayeesCommand(program: Command) {
const payees = program.command('payees').description('Manage payees'); const payees = program.command('payees').description('Manage payees');
@@ -12,14 +12,10 @@ export function registerPayeesCommand(program: Command) {
.description('List all payees') .description('List all payees')
.action(async () => { .action(async () => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, const result = await api.getPayees();
async () => { printOutput(result, opts.format);
const result = await api.getPayees(); });
printOutput(result, opts.format);
},
{ mutates: false },
);
}); });
payees payees
@@ -27,14 +23,10 @@ export function registerPayeesCommand(program: Command) {
.description('List frequently used payees') .description('List frequently used payees')
.action(async () => { .action(async () => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, const result = await api.getCommonPayees();
async () => { printOutput(result, opts.format);
const result = await api.getCommonPayees(); });
printOutput(result, opts.format);
},
{ mutates: false },
);
}); });
payees payees
@@ -43,14 +35,10 @@ export function registerPayeesCommand(program: Command) {
.requiredOption('--name <name>', 'Payee name') .requiredOption('--name <name>', 'Payee name')
.action(async cmdOpts => { .action(async cmdOpts => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, const id = await api.createPayee({ name: cmdOpts.name });
async () => { printOutput({ id }, opts.format);
const id = await api.createPayee({ name: cmdOpts.name }); });
printOutput({ id }, opts.format);
},
{ mutates: true },
);
}); });
payees payees
@@ -66,14 +54,10 @@ export function registerPayeesCommand(program: Command) {
); );
} }
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, await api.updatePayee(id, fields);
async () => { printOutput({ success: true, id }, opts.format);
await api.updatePayee(id, fields); });
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
}); });
payees payees
@@ -81,14 +65,10 @@ export function registerPayeesCommand(program: Command) {
.description('Delete a payee') .description('Delete a payee')
.action(async (id: string) => { .action(async (id: string) => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, await api.deletePayee(id);
async () => { printOutput({ success: true, id }, opts.format);
await api.deletePayee(id); });
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
}); });
payees payees
@@ -107,13 +87,9 @@ export function registerPayeesCommand(program: Command) {
); );
} }
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, await api.mergePayees(cmdOpts.target, mergeIds);
async () => { printOutput({ success: true }, opts.format);
await api.mergePayees(cmdOpts.target, mergeIds); });
printOutput({ success: true }, opts.format);
},
{ mutates: true },
);
}); });
} }

View File

@@ -1,9 +1,13 @@
import * as api from '@actual-app/api'; import * as api from '@actual-app/api';
import { Command } from 'commander'; import { Command } from 'commander';
import { printOutput } from '#output'; import { printOutput } from '../output';
import { parseOrderBy, registerQueryCommand } from './query'; import {
expandSelectAliases,
parseOrderBy,
registerQueryCommand,
} from './query';
vi.mock('@actual-app/api', () => { vi.mock('@actual-app/api', () => {
const queryObj = { const queryObj = {
@@ -21,11 +25,11 @@ vi.mock('@actual-app/api', () => {
}; };
}); });
vi.mock('#connection', () => ({ vi.mock('../connection', () => ({
withConnection: vi.fn((_opts, fn) => fn()), withConnection: vi.fn((_opts, fn) => fn()),
})); }));
vi.mock('#output', () => ({ vi.mock('../output', () => ({
printOutput: vi.fn(), printOutput: vi.fn(),
})); }));
@@ -145,25 +149,6 @@ describe('query commands', () => {
]); ]);
}); });
it('outputs unwrapped data array (not the full result envelope)', async () => {
const mockData = [{ id: '1', amount: -500 }];
vi.mocked(api.aqlQuery).mockResolvedValueOnce({
data: mockData,
dependencies: [],
});
await run([
'query',
'run',
'--table',
'transactions',
'--select',
'id,amount',
]);
expect(printOutput).toHaveBeenCalledWith(mockData, undefined);
});
it('passes --filter as JSON', async () => { it('passes --filter as JSON', async () => {
await run([ await run([
'query', 'query',
@@ -361,5 +346,87 @@ describe('query commands', () => {
'Unknown table "unknown"', 'Unknown table "unknown"',
); );
}); });
it('includes descriptions in field output', async () => {
await run(['query', 'fields', 'transactions']);
const output = vi.mocked(printOutput).mock.calls[0][0] as Array<{
name: string;
type: string;
description?: string;
}>;
const amountField = output.find(f => f.name === 'amount');
expect(amountField?.description).toContain('cents');
});
});
describe('describe subcommand', () => {
it('outputs schema for all tables', async () => {
await run(['query', 'describe']);
const output = vi.mocked(printOutput).mock.calls[0][0] as Record<
string,
unknown[]
>;
expect(output).toHaveProperty('transactions');
expect(output).toHaveProperty('accounts');
expect(output).toHaveProperty('categories');
expect(output).toHaveProperty('payees');
expect(output).toHaveProperty('rules');
expect(output).toHaveProperty('schedules');
});
});
describe('--exclude-transfers flag', () => {
it('adds transfer_id null filter for transactions', async () => {
await run([
'query',
'run',
'--table',
'transactions',
'--exclude-transfers',
]);
const qObj = getQueryObj();
expect(qObj.filter).toHaveBeenCalledWith({ transfer_id: { $eq: null } });
});
it('errors when used with non-transactions table', async () => {
await expect(
run(['query', 'run', '--table', 'accounts', '--exclude-transfers']),
).rejects.toThrow(
'--exclude-transfers can only be used with --table transactions',
);
});
});
});
describe('expandSelectAliases', () => {
it('expands transaction aliases', () => {
expect(
expandSelectAliases('transactions', [
'date',
'payee',
'category',
'amount',
]),
).toEqual(['date', 'payee.name', 'category.name', 'amount']);
});
it('expands account alias', () => {
expect(expandSelectAliases('transactions', ['account'])).toEqual([
'account.name',
]);
});
it('passes through unknown fields unchanged', () => {
expect(expandSelectAliases('transactions', ['notes'])).toEqual(['notes']);
});
it('returns fields unchanged for tables without aliases', () => {
expect(expandSelectAliases('rules', ['id', 'stage'])).toEqual([
'id',
'stage',
]);
}); });
}); });

View File

@@ -1,10 +1,14 @@
import * as api from '@actual-app/api'; import * as api from '@actual-app/api';
import type { Command } from 'commander'; import type { Command } from 'commander';
import { withConnection } from '#connection'; import { withConnection } from '../connection';
import { readJsonInput } from '#input'; import { readJsonInput } from '../input';
import { printOutput } from '#output'; import { printOutput } from '../output';
import { isRecord, parseIntFlag } from '#utils'; import { CliError, parseIntFlag } from '../utils';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** /**
* Parse order-by strings like "date:desc,amount:asc,id" into * Parse order-by strings like "date:desc,amount:asc,id" into
@@ -39,71 +43,166 @@ export function parseOrderBy(
} }
// TODO: Import schema from API once it exposes table/field metadata // TODO: Import schema from API once it exposes table/field metadata
const TABLE_SCHEMA: Record< type FieldInfo = { type: string; ref?: string; description?: string };
string,
Record<string, { type: string; ref?: string }> const TABLE_SCHEMA: Record<string, Record<string, FieldInfo>> = {
> = {
transactions: { transactions: {
id: { type: 'id' }, id: { type: 'id', description: 'Unique transaction identifier' },
account: { type: 'id', ref: 'accounts' }, account: { type: 'id', ref: 'accounts', description: 'Account ID' },
date: { type: 'date' }, date: { type: 'date', description: 'Transaction date (YYYY-MM-DD)' },
amount: { type: 'integer' }, amount: {
payee: { type: 'id', ref: 'payees' }, type: 'integer',
category: { type: 'id', ref: 'categories' }, description:
notes: { type: 'string' }, 'Amount in cents (e.g. 1000 = $10.00). Negative = expense, positive = income',
imported_id: { type: 'string' }, },
transfer_id: { type: 'id' }, payee: { type: 'id', ref: 'payees', description: 'Payee ID' },
cleared: { type: 'boolean' }, category: { type: 'id', ref: 'categories', description: 'Category ID' },
reconciled: { type: 'boolean' }, notes: { type: 'string', description: 'Transaction notes/memo' },
starting_balance_flag: { type: 'boolean' }, imported_id: {
imported_payee: { type: 'string' }, type: 'string',
is_parent: { type: 'boolean' }, description: 'External ID from bank import',
is_child: { type: 'boolean' }, },
parent_id: { type: 'id' }, transfer_id: {
sort_order: { type: 'float' }, type: 'id',
schedule: { type: 'id', ref: 'schedules' }, description:
'account.name': { type: 'string', ref: 'accounts' }, 'Linked transaction ID for transfers. Non-null means this is a transfer between own accounts',
'payee.name': { type: 'string', ref: 'payees' }, },
'category.name': { type: 'string', ref: 'categories' }, cleared: { type: 'boolean', description: 'Whether transaction is cleared' },
'category.group.name': { type: 'string', ref: 'category_groups' }, reconciled: {
type: 'boolean',
description: 'Whether transaction is reconciled',
},
starting_balance_flag: {
type: 'boolean',
description: 'True for the starting balance transaction',
},
imported_payee: {
type: 'string',
description: 'Original payee name from bank import',
},
is_parent: {
type: 'boolean',
description: 'True if this is a split parent transaction',
},
is_child: {
type: 'boolean',
description: 'True if this is a split child transaction',
},
parent_id: {
type: 'id',
description: 'Parent transaction ID for split children',
},
sort_order: { type: 'float', description: 'Sort order within a day' },
schedule: {
type: 'id',
ref: 'schedules',
description: 'Linked schedule ID',
},
'account.name': {
type: 'string',
ref: 'accounts',
description: 'Resolved account name',
},
'payee.name': {
type: 'string',
ref: 'payees',
description: 'Resolved payee name',
},
'category.name': {
type: 'string',
ref: 'categories',
description: 'Resolved category name',
},
'category.group.name': {
type: 'string',
ref: 'category_groups',
description: 'Resolved category group name',
},
}, },
accounts: { accounts: {
id: { type: 'id' }, id: { type: 'id', description: 'Unique account identifier' },
name: { type: 'string' }, name: { type: 'string', description: 'Account name' },
offbudget: { type: 'boolean' }, offbudget: {
closed: { type: 'boolean' }, type: 'boolean',
sort_order: { type: 'float' }, description: 'True if account is off-budget (tracking)',
},
closed: { type: 'boolean', description: 'True if account is closed' },
sort_order: { type: 'float', description: 'Display sort order' },
}, },
categories: { categories: {
id: { type: 'id' }, id: { type: 'id', description: 'Unique category identifier' },
name: { type: 'string' }, name: { type: 'string', description: 'Category name' },
is_income: { type: 'boolean' }, is_income: { type: 'boolean', description: 'True for income categories' },
group_id: { type: 'id', ref: 'category_groups' }, group_id: {
sort_order: { type: 'float' }, type: 'id',
hidden: { type: 'boolean' }, ref: 'category_groups',
'group.name': { type: 'string', ref: 'category_groups' }, description: 'Category group ID',
},
sort_order: { type: 'float', description: 'Display sort order' },
hidden: { type: 'boolean', description: 'True if category is hidden' },
'group.name': {
type: 'string',
ref: 'category_groups',
description: 'Resolved category group name',
},
}, },
payees: { payees: {
id: { type: 'id' }, id: { type: 'id', description: 'Unique payee identifier' },
name: { type: 'string' }, name: { type: 'string', description: 'Payee name' },
transfer_acct: { type: 'id', ref: 'accounts' }, transfer_acct: {
type: 'id',
ref: 'accounts',
description:
'Linked account ID for transfer payees. Non-null means this payee represents a transfer to/from this account',
},
}, },
rules: { rules: {
id: { type: 'id' }, id: { type: 'id', description: 'Unique rule identifier' },
stage: { type: 'string' }, stage: { type: 'string', description: 'Rule stage (pre, post, null)' },
conditions_op: { type: 'string' }, conditions_op: {
conditions: { type: 'json' }, type: 'string',
actions: { type: 'json' }, description: 'How conditions combine: "and" or "or"',
},
conditions: { type: 'json', description: 'Rule conditions as JSON array' },
actions: { type: 'json', description: 'Rule actions as JSON array' },
}, },
schedules: { schedules: {
id: { type: 'id' }, id: { type: 'id', description: 'Unique schedule identifier' },
name: { type: 'string' }, name: { type: 'string', description: 'Schedule name' },
rule: { type: 'id', ref: 'rules' }, rule: {
next_date: { type: 'date' }, type: 'id',
completed: { type: 'boolean' }, ref: 'rules',
description: 'Associated rule ID',
},
next_date: {
type: 'date',
description: 'Next occurrence date (YYYY-MM-DD)',
},
completed: {
type: 'boolean',
description: 'True if schedule is completed',
},
}, },
}; };
const FIELD_ALIASES: Record<string, Record<string, string>> = {
transactions: {
payee: 'payee.name',
category: 'category.name',
account: 'account.name',
group: 'category.group.name',
},
categories: {
group: 'group.name',
},
};
export function expandSelectAliases(table: string, fields: string[]): string[] {
const aliases = FIELD_ALIASES[table];
if (!aliases) return fields;
return fields.map(f => aliases[f.trim()] ?? f);
}
const AVAILABLE_TABLES = Object.keys(TABLE_SCHEMA).join(', '); const AVAILABLE_TABLES = Object.keys(TABLE_SCHEMA).join(', ');
const LAST_DEFAULT_SELECT = [ const LAST_DEFAULT_SELECT = [
@@ -159,7 +258,10 @@ function buildQueryFromFlags(cmdOpts: Record<string, string | undefined>) {
const table = const table =
cmdOpts.table ?? (last !== undefined ? 'transactions' : undefined); cmdOpts.table ?? (last !== undefined ? 'transactions' : undefined);
if (!table) { if (!table) {
throw new Error('--table is required (or use --file or --last)'); throw new CliError(
'--table is required (or use --file or --last)',
'Run "actual query tables" to see available tables, or use --last <n> for recent transactions.',
);
} }
if (!(table in TABLE_SCHEMA)) { if (!(table in TABLE_SCHEMA)) {
@@ -176,19 +278,38 @@ function buildQueryFromFlags(cmdOpts: Record<string, string | undefined>) {
throw new Error('--count and --select are mutually exclusive'); throw new Error('--count and --select are mutually exclusive');
} }
if (cmdOpts.excludeTransfers && table !== 'transactions') {
throw new Error(
'--exclude-transfers can only be used with --table transactions',
);
}
let queryObj = api.q(table); let queryObj = api.q(table);
if (cmdOpts.count) { if (cmdOpts.count) {
queryObj = queryObj.calculate({ $count: '*' }); queryObj = queryObj.calculate({ $count: '*' });
} else if (cmdOpts.select) { } else if (cmdOpts.select) {
queryObj = queryObj.select(cmdOpts.select.split(',')); queryObj = queryObj.select(
expandSelectAliases(table, cmdOpts.select.split(',')),
);
} else if (last !== undefined) { } else if (last !== undefined) {
queryObj = queryObj.select(LAST_DEFAULT_SELECT); queryObj = queryObj.select(LAST_DEFAULT_SELECT);
} }
const filterStr = cmdOpts.filter ?? cmdOpts.where; const filterStr = cmdOpts.filter ?? cmdOpts.where;
if (filterStr) { if (filterStr) {
queryObj = queryObj.filter(JSON.parse(filterStr)); try {
queryObj = queryObj.filter(JSON.parse(filterStr));
} catch {
throw new CliError(
'Invalid JSON in --filter.',
`Ensure valid JSON. Example: --filter '{"amount":{"$lt":0}}'`,
);
}
}
if (cmdOpts.excludeTransfers) {
queryObj = queryObj.filter({ transfer_id: { $eq: null } });
} }
const orderByStr = const orderByStr =
@@ -245,21 +366,18 @@ Examples:
# Pipe query from stdin # Pipe query from stdin
echo '{"table":"transactions","limit":5}' | actual query run --file - echo '{"table":"transactions","limit":5}' | actual query run --file -
# Exclude transfers from results
actual query run --table transactions --exclude-transfers --last 10
# Use shorthand aliases (payee = payee.name, category = category.name)
actual query run --table transactions --select "date,payee,category,amount" --last 10
Available tables: ${AVAILABLE_TABLES} Available tables: ${AVAILABLE_TABLES}
Use "actual query tables" and "actual query fields <table>" for schema info. Use "actual query tables" and "actual query fields <table>" for schema info.
Use "actual query describe" for full schema with all tables, fields, and descriptions.
Common filter operators: $eq, $ne, $lt, $lte, $gt, $gte, $like, $and, $or Common filter operators: $eq, $ne, $lt, $lte, $gt, $gte, $like, $and, $or
See ActualQL docs for full reference: https://actualbudget.org/docs/api/actual-ql/ See ActualQL docs for full reference: https://actualbudget.org/docs/api/actual-ql/`;
Tips:
- Amounts are stored as integer cents (e.g. 166500 = 1665.00).
Table and CSV output auto-formats these as decimals; JSON keeps raw cents.
- Filter "is_parent": false to avoid double-counting split transactions.
- Fetch all data in a single query with a date range instead of running
one query per month — rapid sequential requests may cause auth failures.
- date.month, date.year etc. are not supported as fields in AQL.
To group by month, fetch raw transactions with a date range filter
and aggregate locally (e.g. in a script).`;
export function registerQueryCommand(program: Command) { export function registerQueryCommand(program: Command) {
const query = program const query = program
@@ -298,34 +416,31 @@ export function registerQueryCommand(program: Command) {
'--file <path>', '--file <path>',
'Read full query object from JSON file (use - for stdin)', 'Read full query object from JSON file (use - for stdin)',
) )
.option(
'--exclude-transfers',
'Exclude transfer transactions (only for --table transactions)',
false,
)
.addHelpText('after', RUN_EXAMPLES) .addHelpText('after', RUN_EXAMPLES)
.action(async cmdOpts => { .action(async cmdOpts => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, const parsed = cmdOpts.file ? readJsonInput(cmdOpts) : undefined;
async () => { if (parsed !== undefined && !isRecord(parsed)) {
const parsed = cmdOpts.file ? readJsonInput(cmdOpts) : undefined; throw new Error('Query file must contain a JSON object');
if (parsed !== undefined && !isRecord(parsed)) { }
throw new Error('Query file must contain a JSON object'); const queryObj = parsed
} ? buildQueryFromFile(parsed, cmdOpts.table)
const queryObj = parsed : buildQueryFromFlags(cmdOpts);
? buildQueryFromFile(parsed, cmdOpts.table)
: buildQueryFromFlags(cmdOpts);
const result = await api.aqlQuery(queryObj); const result = await api.aqlQuery(queryObj);
if (!isRecord(result) || !('data' in result)) { if (cmdOpts.count) {
throw new Error('Query result missing data'); printOutput({ count: result.data }, opts.format);
} } else {
printOutput(result, opts.format);
if (cmdOpts.count) { }
printOutput({ count: result.data }, opts.format); });
} else {
printOutput(result.data, opts.format);
}
},
{ mutates: false },
);
}); });
query query
@@ -352,7 +467,27 @@ export function registerQueryCommand(program: Command) {
name, name,
type: info.type, type: info.type,
...(info.ref ? { ref: info.ref } : {}), ...(info.ref ? { ref: info.ref } : {}),
...(info.description ? { description: info.description } : {}),
})); }));
printOutput(fields, opts.format); printOutput(fields, opts.format);
}); });
query
.command('describe')
.description(
'Output full schema for all tables (fields, types, relationships, descriptions)',
)
.action(() => {
const opts = program.opts();
const schema: Record<string, unknown[]> = {};
for (const [table, fields] of Object.entries(TABLE_SCHEMA)) {
schema[table] = Object.entries(fields).map(([name, info]) => ({
name,
type: info.type,
...(info.ref ? { ref: info.ref } : {}),
...(info.description ? { description: info.description } : {}),
}));
}
printOutput(schema, opts.format);
});
} }

View File

@@ -1,9 +1,9 @@
import * as api from '@actual-app/api'; import * as api from '@actual-app/api';
import type { Command } from 'commander'; import type { Command } from 'commander';
import { withConnection } from '#connection'; import { withConnection } from '../connection';
import { readJsonInput } from '#input'; import { readJsonInput } from '../input';
import { printOutput } from '#output'; import { printOutput } from '../output';
export function registerRulesCommand(program: Command) { export function registerRulesCommand(program: Command) {
const rules = program const rules = program
@@ -15,14 +15,10 @@ export function registerRulesCommand(program: Command) {
.description('List all rules') .description('List all rules')
.action(async () => { .action(async () => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, const result = await api.getRules();
async () => { printOutput(result, opts.format);
const result = await api.getRules(); });
printOutput(result, opts.format);
},
{ mutates: false },
);
}); });
rules rules
@@ -30,14 +26,10 @@ export function registerRulesCommand(program: Command) {
.description('List rules for a specific payee') .description('List rules for a specific payee')
.action(async (payeeId: string) => { .action(async (payeeId: string) => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, const result = await api.getPayeeRules(payeeId);
async () => { printOutput(result, opts.format);
const result = await api.getPayeeRules(payeeId); });
printOutput(result, opts.format);
},
{ mutates: false },
);
}); });
rules rules
@@ -47,17 +39,13 @@ export function registerRulesCommand(program: Command) {
.option('--file <path>', 'Read rule from JSON file (use - for stdin)') .option('--file <path>', 'Read rule from JSON file (use - for stdin)')
.action(async cmdOpts => { .action(async cmdOpts => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, const rule = readJsonInput(cmdOpts) as Parameters<
async () => { typeof api.createRule
const rule = readJsonInput(cmdOpts) as Parameters< >[0];
typeof api.createRule const id = await api.createRule(rule);
>[0]; printOutput({ id }, opts.format);
const id = await api.createRule(rule); });
printOutput({ id }, opts.format);
},
{ mutates: true },
);
}); });
rules rules
@@ -67,17 +55,13 @@ export function registerRulesCommand(program: Command) {
.option('--file <path>', 'Read rule from JSON file (use - for stdin)') .option('--file <path>', 'Read rule from JSON file (use - for stdin)')
.action(async cmdOpts => { .action(async cmdOpts => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, const rule = readJsonInput(cmdOpts) as Parameters<
async () => { typeof api.updateRule
const rule = readJsonInput(cmdOpts) as Parameters< >[0];
typeof api.updateRule await api.updateRule(rule);
>[0]; printOutput({ success: true }, opts.format);
await api.updateRule(rule); });
printOutput({ success: true }, opts.format);
},
{ mutates: true },
);
}); });
rules rules
@@ -85,13 +69,9 @@ export function registerRulesCommand(program: Command) {
.description('Delete a rule') .description('Delete a rule')
.action(async (id: string) => { .action(async (id: string) => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, await api.deleteRule(id);
async () => { printOutput({ success: true, id }, opts.format);
await api.deleteRule(id); });
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
}); });
} }

View File

@@ -1,9 +1,9 @@
import * as api from '@actual-app/api'; import * as api from '@actual-app/api';
import type { Command } from 'commander'; import type { Command } from 'commander';
import { withConnection } from '#connection'; import { withConnection } from '../connection';
import { readJsonInput } from '#input'; import { readJsonInput } from '../input';
import { printOutput } from '#output'; import { printOutput } from '../output';
export function registerSchedulesCommand(program: Command) { export function registerSchedulesCommand(program: Command) {
const schedules = program const schedules = program
@@ -15,14 +15,10 @@ export function registerSchedulesCommand(program: Command) {
.description('List all schedules') .description('List all schedules')
.action(async () => { .action(async () => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, const result = await api.getSchedules();
async () => { printOutput(result, opts.format);
const result = await api.getSchedules(); });
printOutput(result, opts.format);
},
{ mutates: false },
);
}); });
schedules schedules
@@ -32,17 +28,13 @@ export function registerSchedulesCommand(program: Command) {
.option('--file <path>', 'Read schedule from JSON file (use - for stdin)') .option('--file <path>', 'Read schedule from JSON file (use - for stdin)')
.action(async cmdOpts => { .action(async cmdOpts => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, const schedule = readJsonInput(cmdOpts) as Parameters<
async () => { typeof api.createSchedule
const schedule = readJsonInput(cmdOpts) as Parameters< >[0];
typeof api.createSchedule const id = await api.createSchedule(schedule);
>[0]; printOutput({ id }, opts.format);
const id = await api.createSchedule(schedule); });
printOutput({ id }, opts.format);
},
{ mutates: true },
);
}); });
schedules schedules
@@ -53,17 +45,13 @@ export function registerSchedulesCommand(program: Command) {
.option('--reset-next-date', 'Reset next occurrence date', false) .option('--reset-next-date', 'Reset next occurrence date', false)
.action(async (id: string, cmdOpts) => { .action(async (id: string, cmdOpts) => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, const fields = readJsonInput(cmdOpts) as Parameters<
async () => { typeof api.updateSchedule
const fields = readJsonInput(cmdOpts) as Parameters< >[1];
typeof api.updateSchedule await api.updateSchedule(id, fields, cmdOpts.resetNextDate);
>[1]; printOutput({ success: true, id }, opts.format);
await api.updateSchedule(id, fields, cmdOpts.resetNextDate); });
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
}); });
schedules schedules
@@ -71,13 +59,9 @@ export function registerSchedulesCommand(program: Command) {
.description('Delete a schedule') .description('Delete a schedule')
.action(async (id: string) => { .action(async (id: string) => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, await api.deleteSchedule(id);
async () => { printOutput({ success: true, id }, opts.format);
await api.deleteSchedule(id); });
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
}); });
} }

View File

@@ -2,8 +2,8 @@ import * as api from '@actual-app/api';
import { Option } from 'commander'; import { Option } from 'commander';
import type { Command } from 'commander'; import type { Command } from 'commander';
import { withConnection } from '#connection'; import { withConnection } from '../connection';
import { printOutput } from '#output'; import { printOutput } from '../output';
export function registerServerCommand(program: Command) { export function registerServerCommand(program: Command) {
const server = program.command('server').description('Server utilities'); const server = program.command('server').description('Server utilities');
@@ -19,7 +19,7 @@ export function registerServerCommand(program: Command) {
const version = await api.getServerVersion(); const version = await api.getServerVersion();
printOutput({ version }, opts.format); printOutput({ version }, opts.format);
}, },
{ mutates: false, skipBudget: true }, { loadBudget: false },
); );
}); });
@@ -34,17 +34,13 @@ export function registerServerCommand(program: Command) {
.requiredOption('--name <name>', 'Entity name') .requiredOption('--name <name>', 'Entity name')
.action(async cmdOpts => { .action(async cmdOpts => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, const id = await api.getIDByName(cmdOpts.type, cmdOpts.name);
async () => { printOutput(
const id = await api.getIDByName(cmdOpts.type, cmdOpts.name); { id, type: cmdOpts.type, name: cmdOpts.name },
printOutput( opts.format,
{ id, type: cmdOpts.type, name: cmdOpts.name }, );
opts.format, });
);
},
{ mutates: false },
);
}); });
server server
@@ -53,16 +49,12 @@ export function registerServerCommand(program: Command) {
.option('--account <id>', 'Specific account ID to sync') .option('--account <id>', 'Specific account ID to sync')
.action(async cmdOpts => { .action(async cmdOpts => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, const args = cmdOpts.account
async () => { ? { accountId: cmdOpts.account }
const args = cmdOpts.account : undefined;
? { accountId: cmdOpts.account } await api.runBankSync(args);
: undefined; printOutput({ success: true }, opts.format);
await api.runBankSync(args); });
printOutput({ success: true }, opts.format);
},
{ mutates: true },
);
}); });
} }

View File

@@ -1,124 +0,0 @@
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { CACHE_FILE_NAME, getMetaDir, writeCacheState } from '#cache';
import { resolveConfig } from '#config';
import { registerSyncCommand } from './sync';
vi.mock('@actual-app/api', () => ({
init: vi.fn().mockResolvedValue(undefined),
downloadBudget: vi.fn().mockResolvedValue(undefined),
loadBudget: vi.fn().mockResolvedValue(undefined),
sync: vi.fn().mockResolvedValue(undefined),
shutdown: vi.fn().mockResolvedValue(undefined),
getBudgets: vi
.fn()
.mockResolvedValue([{ id: 'bud-disk-1', groupId: 'sync-1' }]),
}));
vi.mock('#config', () => ({
resolveConfig: vi.fn(),
}));
let dataDir: string;
function metaDirFor(syncId: string) {
return getMetaDir(dataDir, syncId);
}
function program() {
const p = new Command();
p.exitOverride();
p.option('--sync-id <id>');
p.option('--data-dir <path>');
p.option('--format <fmt>');
p.option('--verbose');
registerSyncCommand(p);
return p;
}
describe('actual sync', () => {
let stdoutSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
dataDir = mkdtempSync(join(tmpdir(), 'actual-cli-sync-'));
vi.mocked(resolveConfig).mockResolvedValue({
serverUrl: 'http://test',
password: 'pw',
dataDir,
syncId: 'sync-1',
cacheTtl: 60,
lockTimeout: 10,
refresh: false,
noLock: true,
});
stdoutSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
});
afterEach(() => {
stdoutSpy.mockRestore();
rmSync(dataDir, { recursive: true, force: true });
});
it('runs a sync and prints the syncId', async () => {
writeCacheState(metaDirFor('sync-1'), {
version: 1,
syncId: 'sync-1',
budgetId: 'bud-disk-1',
serverUrl: 'http://test',
lastSyncedAt: 0,
lastDownloadedAt: 0,
});
await program().parseAsync(['node', 'actual', 'sync']);
const out = stdoutSpy.mock.calls
.map((c: unknown[]) => String(c[0]))
.join('');
expect(out).toMatch(/"syncId":\s*"sync-1"/);
});
it('--status prints cache info without syncing', async () => {
writeCacheState(metaDirFor('sync-1'), {
version: 1,
syncId: 'sync-1',
budgetId: 'bud-disk-1',
serverUrl: 'http://test',
lastSyncedAt: Date.now() - 5000,
lastDownloadedAt: Date.now() - 5000,
});
await program().parseAsync(['node', 'actual', 'sync', '--status']);
const out = stdoutSpy.mock.calls
.map((c: unknown[]) => String(c[0]))
.join('');
expect(out).toMatch(/"stale":\s*(true|false)/);
expect(out).toMatch(/"ageSeconds":\s*\d+/);
});
it('--status on no prior sync reports "never synced" and exits 0', async () => {
await program().parseAsync(['node', 'actual', 'sync', '--status']);
const out = stdoutSpy.mock.calls
.map((c: unknown[]) => String(c[0]))
.join('');
expect(out).toMatch(/"neverSynced":\s*true/);
});
it('--clear removes the cache file', async () => {
writeCacheState(metaDirFor('sync-1'), {
version: 1,
syncId: 'sync-1',
budgetId: 'bud-disk-1',
serverUrl: 'http://test',
lastSyncedAt: Date.now(),
lastDownloadedAt: Date.now(),
});
expect(existsSync(join(metaDirFor('sync-1'), CACHE_FILE_NAME))).toBe(true);
await program().parseAsync(['node', 'actual', 'sync', '--clear']);
expect(existsSync(join(metaDirFor('sync-1'), CACHE_FILE_NAME))).toBe(false);
});
});

View File

@@ -1,118 +0,0 @@
import { rmSync } from 'node:fs';
import { join } from 'node:path';
import type { Command } from 'commander';
import { CACHE_FILE_NAME, getMetaDir, readCacheState } from '#cache';
import type { CliConfig } from '#config';
import { resolveConfig } from '#config';
import { withConnection } from '#connection';
import { acquireExclusive } from '#lock';
import { printOutput } from '#output';
type SyncCmdOpts = {
status?: boolean;
clear?: boolean;
};
async function requireSyncIdAndMeta(
opts: Record<string, unknown>,
flag: string,
): Promise<{ config: CliConfig; meta: string }> {
const config = await resolveConfig(opts);
if (!config.syncId) {
throw new Error(
`Sync ID is required for sync ${flag}. Set --sync-id or ACTUAL_SYNC_ID.`,
);
}
return { config, meta: getMetaDir(config.dataDir, config.syncId) };
}
export function registerSyncCommand(program: Command) {
program
.command('sync')
.description(
'Sync the local cached budget with the server, print cache status, or clear the cache',
)
.option('--status', 'Print cache status without syncing', false)
.option(
'--clear',
'Delete the local cache; next command re-downloads',
false,
)
.action(async (cmdOpts: SyncCmdOpts) => {
const opts = program.opts();
if (cmdOpts.status) {
const { config, meta } = await requireSyncIdAndMeta(opts, '--status');
const state = readCacheState(meta);
if (state === null) {
printOutput(
{
neverSynced: true,
syncId: config.syncId,
ttlSeconds: config.cacheTtl,
},
opts.format,
);
return;
}
const rawAgeSeconds = Math.round(
(Date.now() - state.lastSyncedAt) / 1000,
);
const ageSeconds = Math.max(0, rawAgeSeconds);
printOutput(
{
neverSynced: false,
syncId: state.syncId,
budgetId: state.budgetId,
syncedAt: new Date(state.lastSyncedAt).toISOString(),
lastDownloadedAt: new Date(state.lastDownloadedAt).toISOString(),
ageSeconds,
ttlSeconds: config.cacheTtl,
stale: rawAgeSeconds < 0 || rawAgeSeconds > config.cacheTtl,
},
opts.format,
);
return;
}
if (cmdOpts.clear) {
const { config, meta } = await requireSyncIdAndMeta(opts, '--clear');
// Serialize with concurrent writers so we don't rm a half-written
// state.json that's about to be renamed into place.
const release = config.noLock
? null
: await acquireExclusive(meta, {
timeoutMs: config.lockTimeout * 1000,
});
try {
rmSync(join(meta, CACHE_FILE_NAME), { force: true });
} finally {
await release?.();
}
printOutput({ cleared: true, syncId: config.syncId }, opts.format);
return;
}
await withConnection(
opts,
async config => {
const state = config.syncId
? readCacheState(getMetaDir(config.dataDir, config.syncId))
: null;
printOutput(
{
syncedAt: new Date(
state?.lastSyncedAt ?? Date.now(),
).toISOString(),
syncId: config.syncId,
budgetId: state?.budgetId ?? config.syncId,
},
opts.format,
);
},
{ mutates: true },
);
});
}

View File

@@ -1,8 +1,8 @@
import * as api from '@actual-app/api'; import * as api from '@actual-app/api';
import type { Command } from 'commander'; import type { Command } from 'commander';
import { withConnection } from '#connection'; import { withConnection } from '../connection';
import { printOutput } from '#output'; import { printOutput } from '../output';
export function registerTagsCommand(program: Command) { export function registerTagsCommand(program: Command) {
const tags = program.command('tags').description('Manage tags'); const tags = program.command('tags').description('Manage tags');
@@ -12,14 +12,10 @@ export function registerTagsCommand(program: Command) {
.description('List all tags') .description('List all tags')
.action(async () => { .action(async () => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, const result = await api.getTags();
async () => { printOutput(result, opts.format);
const result = await api.getTags(); });
printOutput(result, opts.format);
},
{ mutates: false },
);
}); });
tags tags
@@ -30,18 +26,14 @@ export function registerTagsCommand(program: Command) {
.option('--description <description>', 'Tag description') .option('--description <description>', 'Tag description')
.action(async cmdOpts => { .action(async cmdOpts => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, const id = await api.createTag({
async () => { tag: cmdOpts.tag,
const id = await api.createTag({ color: cmdOpts.color,
tag: cmdOpts.tag, description: cmdOpts.description,
color: cmdOpts.color, });
description: cmdOpts.description, printOutput({ id }, opts.format);
}); });
printOutput({ id }, opts.format);
},
{ mutates: true },
);
}); });
tags tags
@@ -63,14 +55,10 @@ export function registerTagsCommand(program: Command) {
); );
} }
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, await api.updateTag(id, fields);
async () => { printOutput({ success: true, id }, opts.format);
await api.updateTag(id, fields); });
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
}); });
tags tags
@@ -78,13 +66,9 @@ export function registerTagsCommand(program: Command) {
.description('Delete a tag') .description('Delete a tag')
.action(async (id: string) => { .action(async (id: string) => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, await api.deleteTag(id);
async () => { printOutput({ success: true, id }, opts.format);
await api.deleteTag(id); });
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
}); });
} }

View File

@@ -0,0 +1,170 @@
import * as api from '@actual-app/api';
import { Command } from 'commander';
import { printOutput } from '../output';
import { registerTransactionsCommand } from './transactions';
vi.mock('@actual-app/api', () => {
const queryObj = {
select: vi.fn().mockReturnThis(),
filter: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
offset: vi.fn().mockReturnThis(),
groupBy: vi.fn().mockReturnThis(),
calculate: vi.fn().mockReturnThis(),
};
return {
q: vi.fn().mockReturnValue(queryObj),
aqlQuery: vi.fn().mockResolvedValue({ data: [] }),
addTransactions: vi.fn().mockResolvedValue([]),
importTransactions: vi.fn().mockResolvedValue({ added: [], updated: [] }),
updateTransaction: vi.fn().mockResolvedValue(undefined),
deleteTransaction: vi.fn().mockResolvedValue(undefined),
};
});
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();
registerTransactionsCommand(program);
return program;
}
async function run(args: string[]) {
const program = createProgram();
await program.parseAsync(['node', 'test', ...args]);
}
function getQueryObj() {
return vi.mocked(api.q).mock.results[0]?.value;
}
describe('transactions list', () => {
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();
});
it('uses AQL query with resolved field names', async () => {
await run([
'transactions',
'list',
'--account',
'acc-1',
'--start',
'2025-01-01',
'--end',
'2025-01-31',
]);
expect(api.q).toHaveBeenCalledWith('transactions');
const qObj = getQueryObj();
expect(qObj.select).toHaveBeenCalledWith([
'*',
'account.name',
'payee.name',
'category.name',
]);
expect(qObj.filter).toHaveBeenCalledWith({
account: 'acc-1',
date: { $gte: '2025-01-01', $lte: '2025-01-31' },
});
expect(qObj.orderBy).toHaveBeenCalledWith([{ date: 'desc' }]);
});
it('defaults --start to 30 days before --end', async () => {
await run([
'transactions',
'list',
'--account',
'acc-1',
'--end',
'2025-02-28',
]);
const qObj = getQueryObj();
expect(qObj.filter).toHaveBeenCalledWith({
account: 'acc-1',
date: { $gte: '2025-01-29', $lte: '2025-02-28' },
});
});
it('defaults both --start and --end when omitted', async () => {
await run(['transactions', 'list', '--account', 'acc-1']);
const qObj = getQueryObj();
const filterCall = qObj.filter.mock.calls[0][0];
expect(filterCall.account).toBe('acc-1');
expect(filterCall.date.$gte).toBeDefined();
expect(filterCall.date.$lte).toBeDefined();
});
it('excludes transfers when --exclude-transfers is set', async () => {
await run([
'transactions',
'list',
'--account',
'acc-1',
'--start',
'2025-01-01',
'--end',
'2025-01-31',
'--exclude-transfers',
]);
const qObj = getQueryObj();
expect(qObj.filter).toHaveBeenCalledWith({
account: 'acc-1',
date: { $gte: '2025-01-01', $lte: '2025-01-31' },
transfer_id: { $eq: null },
});
});
it('outputs result.data from AQL query', async () => {
const mockData = [{ id: 't1', amount: -500 }];
vi.mocked(api.aqlQuery).mockResolvedValueOnce({ data: mockData });
await run([
'transactions',
'list',
'--account',
'acc-1',
'--start',
'2025-01-01',
'--end',
'2025-01-31',
]);
expect(printOutput).toHaveBeenCalledWith(mockData, undefined);
});
});

View File

@@ -1,9 +1,10 @@
import * as api from '@actual-app/api'; import * as api from '@actual-app/api';
import type { Command } from 'commander'; import type { Command } from 'commander';
import { withConnection } from '#connection'; import { withConnection } from '../connection';
import { readJsonInput } from '#input'; import { readJsonInput } from '../input';
import { printOutput } from '#output'; import { printOutput } from '../output';
import { defaultDateRange } from '../utils';
export function registerTransactionsCommand(program: Command) { export function registerTransactionsCommand(program: Command) {
const transactions = program const transactions = program
@@ -14,22 +15,33 @@ export function registerTransactionsCommand(program: Command) {
.command('list') .command('list')
.description('List transactions for an account') .description('List transactions for an account')
.requiredOption('--account <id>', 'Account ID') .requiredOption('--account <id>', 'Account ID')
.requiredOption('--start <date>', 'Start date (YYYY-MM-DD)') .option(
.requiredOption('--end <date>', 'End date (YYYY-MM-DD)') '--start <date>',
'Start date (YYYY-MM-DD, defaults to 30 days ago)',
)
.option('--end <date>', 'End date (YYYY-MM-DD, defaults to today)')
.option('--exclude-transfers', 'Exclude transfer transactions', false)
.action(async cmdOpts => { .action(async cmdOpts => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, const { start, end } = defaultDateRange(cmdOpts.start, cmdOpts.end);
async () => {
const result = await api.getTransactions( const filter: Record<string, unknown> = {
cmdOpts.account, account: cmdOpts.account,
cmdOpts.start, date: { $gte: start, $lte: end },
cmdOpts.end, };
); if (cmdOpts.excludeTransfers) {
printOutput(result, opts.format); filter.transfer_id = { $eq: null };
}, }
{ mutates: false },
); const queryObj = api
.q('transactions')
.select(['*', 'account.name', 'payee.name', 'category.name'])
.filter(filter)
.orderBy([{ date: 'desc' }]);
const result = await api.aqlQuery(queryObj);
printOutput(result.data, opts.format);
});
}); });
transactions transactions
@@ -45,24 +57,20 @@ export function registerTransactionsCommand(program: Command) {
.option('--run-transfers', 'Process transfers', false) .option('--run-transfers', 'Process transfers', false)
.action(async cmdOpts => { .action(async cmdOpts => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, const transactions = readJsonInput(cmdOpts) as Parameters<
async () => { typeof api.addTransactions
const transactions = readJsonInput(cmdOpts) as Parameters< >[1];
typeof api.addTransactions const result = await api.addTransactions(
>[1]; cmdOpts.account,
const result = await api.addTransactions( transactions,
cmdOpts.account, {
transactions, learnCategories: cmdOpts.learnCategories,
{ runTransfers: cmdOpts.runTransfers,
learnCategories: cmdOpts.learnCategories, },
runTransfers: cmdOpts.runTransfers, );
}, printOutput(result, opts.format);
); });
printOutput(result, opts.format);
},
{ mutates: true },
);
}); });
transactions transactions
@@ -77,24 +85,20 @@ export function registerTransactionsCommand(program: Command) {
.option('--dry-run', 'Preview without importing', false) .option('--dry-run', 'Preview without importing', false)
.action(async cmdOpts => { .action(async cmdOpts => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, const transactions = readJsonInput(cmdOpts) as Parameters<
async () => { typeof api.importTransactions
const transactions = readJsonInput(cmdOpts) as Parameters< >[1];
typeof api.importTransactions const result = await api.importTransactions(
>[1]; cmdOpts.account,
const result = await api.importTransactions( transactions,
cmdOpts.account, {
transactions, defaultCleared: true,
{ dryRun: cmdOpts.dryRun,
defaultCleared: true, },
dryRun: cmdOpts.dryRun, );
}, printOutput(result, opts.format);
); });
printOutput(result, opts.format);
},
{ mutates: true },
);
}); });
transactions transactions
@@ -104,17 +108,13 @@ export function registerTransactionsCommand(program: Command) {
.option('--file <path>', 'Read fields from JSON file (use - for stdin)') .option('--file <path>', 'Read fields from JSON file (use - for stdin)')
.action(async (id: string, cmdOpts) => { .action(async (id: string, cmdOpts) => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, const fields = readJsonInput(cmdOpts) as Parameters<
async () => { typeof api.updateTransaction
const fields = readJsonInput(cmdOpts) as Parameters< >[1];
typeof api.updateTransaction await api.updateTransaction(id, fields);
>[1]; printOutput({ success: true, id }, opts.format);
await api.updateTransaction(id, fields); });
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
}); });
transactions transactions
@@ -122,13 +122,9 @@ export function registerTransactionsCommand(program: Command) {
.description('Delete a transaction') .description('Delete a transaction')
.action(async (id: string) => { .action(async (id: string) => {
const opts = program.opts(); const opts = program.opts();
await withConnection( await withConnection(opts, async () => {
opts, await api.deleteTransaction(id);
async () => { printOutput({ success: true, id }, opts.format);
await api.deleteTransaction(id); });
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
}); });
} }

View File

@@ -28,9 +28,6 @@ describe('resolveConfig', () => {
'ACTUAL_SYNC_ID', 'ACTUAL_SYNC_ID',
'ACTUAL_DATA_DIR', 'ACTUAL_DATA_DIR',
'ACTUAL_ENCRYPTION_PASSWORD', 'ACTUAL_ENCRYPTION_PASSWORD',
'ACTUAL_CACHE_TTL',
'ACTUAL_LOCK_TIMEOUT',
'ACTUAL_NO_LOCK',
]; ];
beforeEach(() => { beforeEach(() => {
@@ -162,125 +159,6 @@ describe('resolveConfig', () => {
}); });
}); });
describe('cache options', () => {
beforeEach(() => {
process.env.ACTUAL_SERVER_URL = 'http://test';
process.env.ACTUAL_PASSWORD = 'pw';
});
it('defaults cacheTtl to 60 seconds', async () => {
const config = await resolveConfig({});
expect(config.cacheTtl).toBe(60);
});
it('reads cacheTtl from env', async () => {
process.env.ACTUAL_CACHE_TTL = '300';
const config = await resolveConfig({});
expect(config.cacheTtl).toBe(300);
});
it('prefers cacheTtl from CLI flag', async () => {
process.env.ACTUAL_CACHE_TTL = '300';
const config = await resolveConfig({ cacheTtl: 10 });
expect(config.cacheTtl).toBe(10);
});
it('rejects negative cacheTtl', async () => {
await expect(resolveConfig({ cacheTtl: -1 })).rejects.toThrow(/cacheTtl/);
});
it('rejects non-integer cacheTtl from env', async () => {
process.env.ACTUAL_CACHE_TTL = 'banana';
await expect(resolveConfig({})).rejects.toThrow(/ACTUAL_CACHE_TTL/);
});
it('defaults lockTimeout to 10 seconds', async () => {
const config = await resolveConfig({});
expect(config.lockTimeout).toBe(10);
});
it('reads lockTimeout from env', async () => {
process.env.ACTUAL_LOCK_TIMEOUT = '30';
const config = await resolveConfig({});
expect(config.lockTimeout).toBe(30);
});
it('defaults refresh to false', async () => {
const config = await resolveConfig({});
expect(config.refresh).toBe(false);
});
it('sets refresh when provided on CLI opts', async () => {
const config = await resolveConfig({ refresh: true });
expect(config.refresh).toBe(true);
});
it('sets refresh when --no-cache is passed (cliOpts.cache === false)', async () => {
const config = await resolveConfig({ cache: false });
expect(config.refresh).toBe(true);
});
it('does not set refresh when cliOpts.cache is true (flag absent)', async () => {
const config = await resolveConfig({ cache: true });
expect(config.refresh).toBe(false);
});
it('defaults noLock to false', async () => {
const config = await resolveConfig({});
expect(config.noLock).toBe(false);
});
it('sets noLock when --no-lock is passed (cliOpts.lock === false)', async () => {
const config = await resolveConfig({ lock: false });
expect(config.noLock).toBe(true);
});
it('leaves noLock false when cliOpts.lock is true (flag absent)', async () => {
const config = await resolveConfig({ lock: true });
expect(config.noLock).toBe(false);
});
it('parses ACTUAL_NO_LOCK=1 as true', async () => {
process.env.ACTUAL_NO_LOCK = '1';
const config = await resolveConfig({});
expect(config.noLock).toBe(true);
});
it('parses ACTUAL_NO_LOCK=true as true', async () => {
process.env.ACTUAL_NO_LOCK = 'true';
const config = await resolveConfig({});
expect(config.noLock).toBe(true);
});
it('throws on an invalid ACTUAL_NO_LOCK value', async () => {
process.env.ACTUAL_NO_LOCK = 'yes';
await expect(resolveConfig({})).rejects.toThrow(/ACTUAL_NO_LOCK/);
});
it('reads cacheTtl/lockTimeout/noLock from config file', async () => {
mockConfigFile({
serverUrl: 'http://file',
password: 'pw',
cacheTtl: 120,
lockTimeout: 5,
noLock: true,
});
const config = await resolveConfig({});
expect(config.cacheTtl).toBe(120);
expect(config.lockTimeout).toBe(5);
expect(config.noLock).toBe(true);
});
it('rejects non-number cacheTtl in config file', async () => {
mockConfigFile({
serverUrl: 'http://file',
password: 'pw',
cacheTtl: 'soon',
});
await expect(resolveConfig({})).rejects.toThrow(/cacheTtl/);
});
});
describe('cosmiconfig handling', () => { describe('cosmiconfig handling', () => {
it('handles null result (no config file found)', async () => { it('handles null result (no config file found)', async () => {
mockConfigFile(null); mockConfigFile(null);

View File

@@ -3,8 +3,6 @@ import { join } from 'path';
import { cosmiconfig } from 'cosmiconfig'; import { cosmiconfig } from 'cosmiconfig';
import { isRecord, parseBoolEnv, parseNonNegativeIntFlag } from './utils';
export type CliConfig = { export type CliConfig = {
serverUrl: string; serverUrl: string;
password?: string; password?: string;
@@ -12,10 +10,6 @@ export type CliConfig = {
syncId?: string; syncId?: string;
dataDir: string; dataDir: string;
encryptionPassword?: string; encryptionPassword?: string;
cacheTtl: number;
lockTimeout: number;
refresh: boolean;
noLock: boolean;
}; };
export type CliGlobalOpts = { export type CliGlobalOpts = {
@@ -25,29 +19,10 @@ export type CliGlobalOpts = {
syncId?: string; syncId?: string;
dataDir?: string; dataDir?: string;
encryptionPassword?: string; encryptionPassword?: string;
cacheTtl?: number;
lockTimeout?: number;
refresh?: boolean;
// Commander stores --no-foo flags under the positive key. Default true,
// false when the flag is passed.
cache?: boolean;
lock?: boolean;
format?: 'json' | 'table' | 'csv'; format?: 'json' | 'table' | 'csv';
verbose?: boolean; verbose?: boolean;
}; };
const stringKeys = [
'serverUrl',
'password',
'sessionToken',
'syncId',
'dataDir',
'encryptionPassword',
] as const;
const numberKeys = ['cacheTtl', 'lockTimeout'] as const;
const booleanKeys = ['noLock'] as const;
type ConfigFileContent = { type ConfigFileContent = {
serverUrl?: string; serverUrl?: string;
password?: string; password?: string;
@@ -55,17 +30,21 @@ type ConfigFileContent = {
syncId?: string; syncId?: string;
dataDir?: string; dataDir?: string;
encryptionPassword?: string; encryptionPassword?: string;
cacheTtl?: number;
lockTimeout?: number;
noLock?: boolean;
}; };
const configFileKeys: readonly string[] = [ const configFileKeys: readonly string[] = [
...stringKeys, 'serverUrl',
...numberKeys, 'password',
...booleanKeys, 'sessionToken',
'syncId',
'dataDir',
'encryptionPassword',
]; ];
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function validateConfigFileContent(value: unknown): ConfigFileContent { function validateConfigFileContent(value: unknown): ConfigFileContent {
if (!isRecord(value)) { if (!isRecord(value)) {
throw new Error( throw new Error(
@@ -77,30 +56,9 @@ function validateConfigFileContent(value: unknown): ConfigFileContent {
if (!configFileKeys.includes(key)) { if (!configFileKeys.includes(key)) {
throw new Error(`Invalid config file: unknown key "${key}"`); throw new Error(`Invalid config file: unknown key "${key}"`);
} }
const v = value[key]; if (value[key] !== undefined && typeof value[key] !== 'string') {
if (v === undefined) continue;
if (
(stringKeys as readonly string[]).includes(key) &&
typeof v !== 'string'
) {
throw new Error( throw new Error(
`Invalid config file: key "${key}" must be a string, got ${typeof v}`, `Invalid config file: key "${key}" must be a string, got ${typeof value[key]}`,
);
}
if (
(numberKeys as readonly string[]).includes(key) &&
(typeof v !== 'number' || !Number.isInteger(v) || v < 0)
) {
throw new Error(
`Invalid config file: key "${key}" must be a non-negative integer`,
);
}
if (
(booleanKeys as readonly string[]).includes(key) &&
typeof v !== 'boolean'
) {
throw new Error(
`Invalid config file: key "${key}" must be a boolean, got ${typeof v}`,
); );
} }
} }
@@ -127,22 +85,6 @@ async function loadConfigFile(): Promise<ConfigFileContent> {
return {}; return {};
} }
function parseNonNegativeIntEnv(
raw: string | undefined,
source: string,
): number | undefined {
return raw === undefined ? undefined : parseNonNegativeIntFlag(raw, source);
}
function validateNonNegativeInt(value: number, name: string): number {
if (!Number.isInteger(value) || value < 0) {
throw new Error(
`Invalid ${name}: expected a non-negative integer, got ${value}`,
);
}
return value;
}
export async function resolveConfig( export async function resolveConfig(
cliOpts: CliGlobalOpts, cliOpts: CliGlobalOpts,
): Promise<CliConfig> { ): Promise<CliConfig> {
@@ -188,37 +130,6 @@ export async function resolveConfig(
); );
} }
const cacheTtl = validateNonNegativeInt(
cliOpts.cacheTtl ??
parseNonNegativeIntEnv(
process.env.ACTUAL_CACHE_TTL,
'ACTUAL_CACHE_TTL',
) ??
fileConfig.cacheTtl ??
60,
'cacheTtl',
);
const lockTimeout = validateNonNegativeInt(
cliOpts.lockTimeout ??
parseNonNegativeIntEnv(
process.env.ACTUAL_LOCK_TIMEOUT,
'ACTUAL_LOCK_TIMEOUT',
) ??
fileConfig.lockTimeout ??
10,
'lockTimeout',
);
const refresh = (cliOpts.refresh ?? false) || cliOpts.cache === false;
const flagNoLock = cliOpts.lock === false ? true : undefined;
const noLock =
flagNoLock ??
parseBoolEnv(process.env.ACTUAL_NO_LOCK, 'ACTUAL_NO_LOCK') ??
fileConfig.noLock ??
false;
return { return {
serverUrl, serverUrl,
password, password,
@@ -226,9 +137,5 @@ export async function resolveConfig(
syncId, syncId,
dataDir, dataDir,
encryptionPassword, encryptionPassword,
cacheTtl,
lockTimeout,
refresh,
noLock,
}; };
} }

View File

@@ -1,44 +1,24 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import * as api from '@actual-app/api'; import * as api from '@actual-app/api';
import { getMetaDir, writeCacheState } from './cache';
import { resolveConfig } from './config'; import { resolveConfig } from './config';
import { withConnection } from './connection'; import { withConnection } from './connection';
vi.mock('@actual-app/api', () => ({ vi.mock('@actual-app/api', () => ({
init: vi.fn().mockResolvedValue(undefined), init: vi.fn().mockResolvedValue(undefined),
downloadBudget: vi.fn().mockResolvedValue(undefined), downloadBudget: vi.fn().mockResolvedValue(undefined),
loadBudget: vi.fn().mockResolvedValue(undefined),
sync: vi.fn().mockResolvedValue(undefined),
shutdown: vi.fn().mockResolvedValue(undefined), shutdown: vi.fn().mockResolvedValue(undefined),
getBudgets: vi
.fn()
.mockResolvedValue([{ id: 'bud-disk-1', groupId: 'sync-1' }]),
})); }));
vi.mock('./config', () => ({ vi.mock('./config', () => ({
resolveConfig: vi.fn(), resolveConfig: vi.fn(),
})); }));
let dataDir: string;
function metaDirFor(syncId: string) {
return getMetaDir(dataDir, syncId);
}
function setConfig(overrides: Record<string, unknown> = {}) { function setConfig(overrides: Record<string, unknown> = {}) {
vi.mocked(resolveConfig).mockResolvedValue({ vi.mocked(resolveConfig).mockResolvedValue({
serverUrl: 'http://test', serverUrl: 'http://test',
password: 'pw', password: 'pw',
dataDir, dataDir: '/tmp/data',
syncId: 'sync-1', syncId: 'budget-1',
cacheTtl: 60,
lockTimeout: 10,
refresh: false,
noLock: true,
...overrides, ...overrides,
}); });
} }
@@ -51,182 +31,104 @@ describe('withConnection', () => {
stderrSpy = vi stderrSpy = vi
.spyOn(process.stderr, 'write') .spyOn(process.stderr, 'write')
.mockImplementation(() => true); .mockImplementation(() => true);
dataDir = mkdtempSync(join(tmpdir(), 'actual-cli-conn-'));
setConfig(); setConfig();
}); });
afterEach(() => { afterEach(() => {
stderrSpy.mockRestore(); stderrSpy.mockRestore();
rmSync(dataDir, { recursive: true, force: true });
}); });
it('calls api.init with password when no sessionToken', async () => { it('calls api.init with password when no sessionToken', async () => {
await withConnection({}, async () => 'ok', { mutates: false }); setConfig({ password: 'pw', sessionToken: undefined });
await withConnection({}, async () => 'ok');
expect(api.init).toHaveBeenCalledWith({ expect(api.init).toHaveBeenCalledWith({
serverURL: 'http://test', serverURL: 'http://test',
password: 'pw', password: 'pw',
dataDir, dataDir: '/tmp/data',
verbose: undefined, verbose: undefined,
}); });
}); });
it('calls api.init with sessionToken when present', async () => { it('calls api.init with sessionToken when present', async () => {
setConfig({ sessionToken: 'tok', password: undefined }); setConfig({ sessionToken: 'tok', password: undefined });
await withConnection({}, async () => 'ok', { mutates: false });
await withConnection({}, async () => 'ok');
expect(api.init).toHaveBeenCalledWith({ expect(api.init).toHaveBeenCalledWith({
serverURL: 'http://test', serverURL: 'http://test',
sessionToken: 'tok', sessionToken: 'tok',
dataDir, dataDir: '/tmp/data',
verbose: undefined, verbose: undefined,
}); });
}); });
it('first run: calls downloadBudget and writes cache state', async () => { it('calls api.downloadBudget when syncId is set', async () => {
await withConnection({}, async () => 'ok', { mutates: false }); setConfig({ syncId: 'budget-1' });
expect(api.downloadBudget).toHaveBeenCalledWith('sync-1', {
await withConnection({}, async () => 'ok');
expect(api.downloadBudget).toHaveBeenCalledWith('budget-1', {
password: undefined, password: undefined,
}); });
expect(api.sync).not.toHaveBeenCalled();
}); });
it('skips sync on a read inside the TTL', async () => { it('throws when loadBudget is true but syncId is not set', async () => {
writeCacheState(metaDirFor('sync-1'), {
version: 1,
syncId: 'sync-1',
budgetId: 'bud-disk-1',
serverUrl: 'http://test',
lastSyncedAt: Date.now(),
lastDownloadedAt: Date.now(),
});
await withConnection({}, async () => 'ok', { mutates: false });
expect(api.loadBudget).toHaveBeenCalledWith('bud-disk-1');
expect(api.sync).not.toHaveBeenCalled();
expect(api.downloadBudget).not.toHaveBeenCalled();
});
it('syncs on a read past the TTL', async () => {
writeCacheState(metaDirFor('sync-1'), {
version: 1,
syncId: 'sync-1',
budgetId: 'bud-disk-1',
serverUrl: 'http://test',
lastSyncedAt: Date.now() - 10 * 60_000,
lastDownloadedAt: Date.now() - 10 * 60_000,
});
await withConnection({}, async () => 'ok', { mutates: false });
expect(api.loadBudget).toHaveBeenCalled();
expect(api.sync).toHaveBeenCalledTimes(1);
});
it('write command syncs before and after the callback, even when fresh', async () => {
writeCacheState(metaDirFor('sync-1'), {
version: 1,
syncId: 'sync-1',
budgetId: 'bud-disk-1',
serverUrl: 'http://test',
lastSyncedAt: Date.now(),
lastDownloadedAt: Date.now(),
});
await withConnection({}, async () => 'ok', { mutates: true });
expect(api.loadBudget).toHaveBeenCalled();
expect(api.sync).toHaveBeenCalledTimes(2);
});
it('--refresh forces a sync on a read inside the TTL', async () => {
setConfig({ refresh: true });
writeCacheState(metaDirFor('sync-1'), {
version: 1,
syncId: 'sync-1',
budgetId: 'bud-disk-1',
serverUrl: 'http://test',
lastSyncedAt: Date.now(),
lastDownloadedAt: Date.now(),
});
await withConnection({}, async () => 'ok', { mutates: false });
expect(api.sync).toHaveBeenCalledTimes(1);
});
it('encrypted budget forces a sync on a read inside the TTL', async () => {
setConfig({ encryptionPassword: 'secret' });
writeCacheState(metaDirFor('sync-1'), {
version: 1,
syncId: 'sync-1',
budgetId: 'bud-disk-1',
serverUrl: 'http://test',
lastSyncedAt: Date.now(),
lastDownloadedAt: Date.now(),
});
await withConnection({}, async () => 'ok', { mutates: false });
expect(api.sync).toHaveBeenCalledTimes(1);
});
it('invalidates cache when syncId changes', async () => {
writeCacheState(metaDirFor('sync-1'), {
version: 1,
syncId: 'OTHER',
budgetId: 'bud-disk-1',
serverUrl: 'http://test',
lastSyncedAt: Date.now(),
lastDownloadedAt: Date.now(),
});
await withConnection({}, async () => 'ok', { mutates: false });
expect(api.downloadBudget).toHaveBeenCalled();
});
it('skips budget work when skipBudget is true', async () => {
await withConnection({}, async () => 'ok', {
mutates: false,
skipBudget: true,
});
expect(api.downloadBudget).not.toHaveBeenCalled();
expect(api.loadBudget).not.toHaveBeenCalled();
expect(api.sync).not.toHaveBeenCalled();
});
it('throws when syncId is missing and skipBudget is false', async () => {
setConfig({ syncId: undefined }); setConfig({ syncId: undefined });
await expect(
withConnection({}, async () => 'ok', { mutates: false }), await expect(withConnection({}, async () => 'ok')).rejects.toThrow(
).rejects.toThrow('Sync ID is required'); 'Sync ID is required',
);
}); });
it('returns the callback result', async () => { it('skips budget download when loadBudget is false and syncId is not set', async () => {
const result = await withConnection({}, async () => 42, { setConfig({ syncId: undefined });
mutates: false,
}); await withConnection({}, async () => 'ok', { loadBudget: false });
expect(api.downloadBudget).not.toHaveBeenCalled();
});
it('does not call api.downloadBudget when loadBudget is false', async () => {
setConfig({ syncId: 'budget-1' });
await withConnection({}, async () => 'ok', { loadBudget: false });
expect(api.downloadBudget).not.toHaveBeenCalled();
});
it('returns callback result', async () => {
const result = await withConnection({}, async () => 42);
expect(result).toBe(42); expect(result).toBe(42);
}); });
it('calls api.shutdown on success', async () => { it('calls api.shutdown in finally block on success', async () => {
await withConnection({}, async () => 'ok', { mutates: false }); await withConnection({}, async () => 'ok');
expect(api.shutdown).toHaveBeenCalled(); expect(api.shutdown).toHaveBeenCalled();
}); });
it('calls api.shutdown on error', async () => { it('calls api.shutdown in finally block on error', async () => {
await expect( await expect(
withConnection( withConnection({}, async () => {
{}, throw new Error('boom');
async () => { }),
throw new Error('boom');
},
{ mutates: false },
),
).rejects.toThrow('boom'); ).rejects.toThrow('boom');
expect(api.shutdown).toHaveBeenCalled(); expect(api.shutdown).toHaveBeenCalled();
}); });
it('propagates sync errors on a stale read', async () => { it('does not write to stderr by default', async () => {
writeCacheState(metaDirFor('sync-1'), { await withConnection({}, async () => 'ok');
version: 1,
syncId: 'sync-1', expect(stderrSpy).not.toHaveBeenCalled();
budgetId: 'bud-disk-1', });
serverUrl: 'http://test',
lastSyncedAt: Date.now() - 10 * 60_000, it('writes info to stderr when verbose', async () => {
lastDownloadedAt: Date.now() - 10 * 60_000, await withConnection({ verbose: true }, async () => 'ok');
});
vi.mocked(api.sync).mockRejectedValueOnce(new Error('network')); expect(stderrSpy).toHaveBeenCalledWith(
await expect( expect.stringContaining('Connecting to'),
withConnection({}, async () => 'ok', { mutates: false }), );
).rejects.toThrow('network');
}); });
}); });

View File

@@ -1,49 +1,30 @@
import { mkdirSync } from 'fs';
import * as api from '@actual-app/api'; import * as api from '@actual-app/api';
import type { CacheState } from './cache';
import {
CACHE_VERSION,
decideSyncAction,
getMetaDir,
readCacheState,
writeCacheState,
} from './cache';
import type { CliConfig, CliGlobalOpts } from './config';
import { resolveConfig } from './config'; import { resolveConfig } from './config';
import { acquireExclusive, acquireShared } from './lock'; import type { CliGlobalOpts } from './config';
import type { Release } from './lock';
type ConnectionOptions = {
mutates: boolean;
skipBudget?: boolean;
};
function info(message: string, verbose?: boolean) { function info(message: string, verbose?: boolean) {
if (verbose) process.stderr.write(message + '\n'); if (verbose) {
process.stderr.write(message + '\n');
}
} }
async function resolveBudgetIdForSyncId(syncId: string): Promise<string> { type ConnectionOptions = {
const budgets = await api.getBudgets(); loadBudget?: boolean;
const match = budgets.find( };
b =>
typeof b.id === 'string' &&
(b.groupId === syncId || b.cloudFileId === syncId),
);
if (!match?.id) {
throw new Error(
`Could not resolve on-disk budget id for syncId ${syncId} after download.`,
);
}
return match.id;
}
export async function withConnection<T>( export async function withConnection<T>(
globalOpts: CliGlobalOpts, globalOpts: CliGlobalOpts,
fn: (config: CliConfig) => Promise<T>, fn: () => Promise<T>,
{ mutates, skipBudget = false }: ConnectionOptions, options: ConnectionOptions = {},
): Promise<T> { ): Promise<T> {
const { loadBudget = true } = options;
const config = await resolveConfig(globalOpts); const config = await resolveConfig(globalOpts);
mkdirSync(config.dataDir, { recursive: true });
info(`Connecting to ${config.serverUrl}...`, globalOpts.verbose); info(`Connecting to ${config.serverUrl}...`, globalOpts.verbose);
if (config.sessionToken) { if (config.sessionToken) {
@@ -67,87 +48,17 @@ export async function withConnection<T>(
} }
try { try {
if (skipBudget) return await fn(config); if (loadBudget && config.syncId) {
if (!config.syncId) { info(`Downloading budget ${config.syncId}...`, globalOpts.verbose);
await api.downloadBudget(config.syncId, {
password: config.encryptionPassword,
});
} else if (loadBudget && !config.syncId) {
throw new Error( throw new Error(
'Sync ID is required for this command. Set --sync-id or ACTUAL_SYNC_ID.', 'Sync ID is required for this command. Set --sync-id or ACTUAL_SYNC_ID.',
); );
} }
return await fn();
const meta = getMetaDir(config.dataDir, config.syncId);
let release: Release | null = null;
if (!config.noLock) {
release = mutates
? await acquireExclusive(meta, {
timeoutMs: config.lockTimeout * 1000,
})
: await acquireShared(meta, {
timeoutMs: config.lockTimeout * 1000,
});
}
try {
const cachedState = readCacheState(meta);
const decision = decideSyncAction({
state: cachedState,
config: { syncId: config.syncId, serverUrl: config.serverUrl },
now: Date.now(),
ttlMs: config.cacheTtl * 1000,
mutates,
refresh: config.refresh,
encrypted: Boolean(config.encryptionPassword),
});
let state: CacheState;
if (decision.action === 'download') {
info(
cachedState === null
? `Downloading budget ${config.syncId} for the first time...`
: `Re-downloading budget ${config.syncId} (cache invalidated)...`,
globalOpts.verbose,
);
await api.downloadBudget(config.syncId, {
password: config.encryptionPassword,
});
const budgetId = await resolveBudgetIdForSyncId(config.syncId);
const now = Date.now();
state = {
version: CACHE_VERSION,
syncId: config.syncId,
budgetId,
serverUrl: config.serverUrl,
lastSyncedAt: now,
lastDownloadedAt: now,
};
writeCacheState(meta, state);
} else if (decision.action === 'skip') {
const age = Math.round(
(Date.now() - decision.state.lastSyncedAt) / 1000,
);
info(`Using cached budget (synced ${age}s ago)...`, globalOpts.verbose);
await api.loadBudget(decision.state.budgetId);
state = decision.state;
} else {
info(`Syncing budget ${config.syncId}...`, globalOpts.verbose);
await api.loadBudget(decision.state.budgetId);
await api.sync();
state = { ...decision.state, lastSyncedAt: Date.now() };
writeCacheState(meta, state);
}
const result = await fn(config);
if (mutates) {
info(`Pushing changes for ${config.syncId}...`, globalOpts.verbose);
await api.sync();
state = { ...state, lastSyncedAt: Date.now() };
writeCacheState(meta, state);
}
return result;
} finally {
if (release) await release();
}
} finally { } finally {
await api.shutdown(); await api.shutdown();
} }

View File

@@ -9,10 +9,9 @@ import { registerQueryCommand } from './commands/query';
import { registerRulesCommand } from './commands/rules'; import { registerRulesCommand } from './commands/rules';
import { registerSchedulesCommand } from './commands/schedules'; import { registerSchedulesCommand } from './commands/schedules';
import { registerServerCommand } from './commands/server'; import { registerServerCommand } from './commands/server';
import { registerSyncCommand } from './commands/sync';
import { registerTagsCommand } from './commands/tags'; import { registerTagsCommand } from './commands/tags';
import { registerTransactionsCommand } from './commands/transactions'; import { registerTransactionsCommand } from './commands/transactions';
import { parseNonNegativeIntFlag } from './utils'; import { CliError } from './utils';
declare const __CLI_VERSION__: string; declare const __CLI_VERSION__: string;
@@ -34,22 +33,6 @@ program
'--encryption-password <password>', '--encryption-password <password>',
'E2E encryption password (env: ACTUAL_ENCRYPTION_PASSWORD)', 'E2E encryption password (env: ACTUAL_ENCRYPTION_PASSWORD)',
) )
.option(
'--cache-ttl <seconds>',
'Cache TTL in seconds (env: ACTUAL_CACHE_TTL; default: 60)',
value => parseNonNegativeIntFlag(value, '--cache-ttl'),
)
.option('--refresh', 'Force a sync on this call, ignoring the cache', false)
.option('--no-cache', 'Alias for --refresh')
.option(
'--lock-timeout <seconds>',
'How long to wait for another CLI process to release the lock (env: ACTUAL_LOCK_TIMEOUT; default: 10)',
value => parseNonNegativeIntFlag(value, '--lock-timeout'),
)
.option(
'--no-lock',
'Disable the budget directory lock (use with care, env: ACTUAL_NO_LOCK)',
)
.addOption( .addOption(
new Option('--format <format>', 'Output format: json, table, csv') new Option('--format <format>', 'Output format: json, table, csv')
.choices(['json', 'table', 'csv'] as const) .choices(['json', 'table', 'csv'] as const)
@@ -68,7 +51,6 @@ registerRulesCommand(program);
registerSchedulesCommand(program); registerSchedulesCommand(program);
registerQueryCommand(program); registerQueryCommand(program);
registerServerCommand(program); registerServerCommand(program);
registerSyncCommand(program);
function normalizeThrownMessage(err: unknown): string { function normalizeThrownMessage(err: unknown): string {
if (err instanceof Error) return err.message; if (err instanceof Error) return err.message;
@@ -85,5 +67,8 @@ function normalizeThrownMessage(err: unknown): string {
program.parseAsync(process.argv).catch((err: unknown) => { program.parseAsync(process.argv).catch((err: unknown) => {
const message = normalizeThrownMessage(err); const message = normalizeThrownMessage(err);
process.stderr.write(`Error: ${message}\n`); process.stderr.write(`Error: ${message}\n`);
if (err instanceof CliError && err.suggestion) {
process.stderr.write(`Suggestion: ${err.suggestion}\n`);
}
process.exitCode = 1; process.exitCode = 1;
}); });

View File

@@ -1,159 +0,0 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { acquireExclusive, acquireShared } from './lock';
// In-memory stand-in for proper-lockfile. The real library spins up a
// setTimeout loop to refresh lockfile mtimes; on some CI filesystems that
// timer keeps Node's event loop alive even after tests complete, wedging the
// test run. The mock behaves identically from our wrapper's perspective
// (acquire, detect contention with ELOCKED, release) without touching the
// filesystem or scheduling timers.
const mockHeld = new Set<string>();
vi.mock('proper-lockfile', () => ({
default: {
lock: vi.fn(
async (
file: string,
opts?: { lockfilePath?: string },
): Promise<() => Promise<void>> => {
const key = opts?.lockfilePath ?? file;
if (mockHeld.has(key)) {
const err = new Error('Lock is already held') as Error & {
code?: string;
};
err.code = 'ELOCKED';
throw err;
}
mockHeld.add(key);
return async () => {
mockHeld.delete(key);
};
},
),
},
}));
describe('acquireExclusive', () => {
let dir: string;
beforeEach(() => {
mockHeld.clear();
dir = mkdtempSync(join(tmpdir(), 'actual-cli-lock-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('creates the directory if it does not exist', async () => {
const target = join(dir, 'nested', 'budget');
const release = await acquireExclusive(target, { timeoutMs: 1000 });
expect(existsSync(target)).toBe(true);
await release();
});
it('returns a release function that frees the lock', async () => {
const release1 = await acquireExclusive(dir, { timeoutMs: 1000 });
await release1();
const release2 = await acquireExclusive(dir, { timeoutMs: 1000 });
await release2();
});
it('rejects with a user-friendly error when another holder has the lock', async () => {
const release = await acquireExclusive(dir, { timeoutMs: 1000 });
await expect(acquireExclusive(dir, { timeoutMs: 100 })).rejects.toThrow(
/holding the budget/,
);
await release();
});
});
describe('acquireShared', () => {
let dir: string;
beforeEach(() => {
mockHeld.clear();
dir = mkdtempSync(join(tmpdir(), 'actual-cli-lock-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('allows multiple concurrent shared holders', async () => {
const r1 = await acquireShared(dir, { timeoutMs: 1000 });
const r2 = await acquireShared(dir, { timeoutMs: 1000 });
const readers = readdirSync(join(dir, 'readers'));
expect(readers).toHaveLength(2);
await r1();
await r2();
});
it('removes the reader marker on release', async () => {
const release = await acquireShared(dir, { timeoutMs: 1000 });
await release();
const readers = readdirSync(join(dir, 'readers'));
expect(readers).toHaveLength(0);
});
it('rejects when an exclusive lock is held', async () => {
const releaseExclusive = await acquireExclusive(dir, { timeoutMs: 1000 });
await expect(acquireShared(dir, { timeoutMs: 100 })).rejects.toThrow(
/holding the budget/,
);
await releaseExclusive();
});
it('sweeps stale reader markers whose PIDs no longer exist', async () => {
const readersDir = join(dir, 'readers');
mkdirSync(readersDir, { recursive: true });
writeFileSync(join(readersDir, '-1-abc'), '');
const release = await acquireExclusive(dir, { timeoutMs: 1000 });
expect(readdirSync(readersDir)).toHaveLength(0);
await release();
});
});
describe('writer-reader interaction', () => {
let dir: string;
beforeEach(() => {
mockHeld.clear();
dir = mkdtempSync(join(tmpdir(), 'actual-cli-lock-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('exclusive waits for active shared holders to release', async () => {
const readerRelease = await acquireShared(dir, { timeoutMs: 500 });
let writerAcquired = false;
const writerPromise = acquireExclusive(dir, { timeoutMs: 1000 }).then(
release => {
writerAcquired = true;
return release;
},
);
await new Promise(resolve => setTimeout(resolve, 150));
expect(writerAcquired).toBe(false);
await readerRelease();
const writerRelease = await writerPromise;
expect(writerAcquired).toBe(true);
await writerRelease();
});
});

View File

@@ -1,149 +0,0 @@
import { randomBytes } from 'node:crypto';
import { mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import lockfile from 'proper-lockfile';
export type Release = () => Promise<void>;
export type AcquireOptions = {
timeoutMs: number;
};
const LOCKFILE_NAME = 'lock';
const READERS_DIR_NAME = 'readers';
const READER_POLL_INTERVAL_MS = 100;
function lockfilePath(dir: string): string {
return join(dir, LOCKFILE_NAME);
}
function readersDir(dir: string): string {
return join(dir, READERS_DIR_NAME);
}
function ensureDir(dir: string) {
mkdirSync(dir, { recursive: true });
}
function retriesForTimeout(timeoutMs: number) {
return {
retries: Math.max(1, Math.floor(timeoutMs / 200)),
minTimeout: 100,
maxTimeout: 500,
factor: 1.5,
};
}
function errorCode(err: unknown): string | undefined {
if (err instanceof Error && 'code' in err) {
const { code } = err as { code?: unknown };
if (typeof code === 'string') return code;
}
return undefined;
}
function isLockedError(err: unknown): boolean {
return errorCode(err) === 'ELOCKED';
}
function lockedMessage(timeoutMs: number): string {
return `Another CLI process is holding the budget (waited ${Math.round(
timeoutMs / 1000,
)}s). Retry, or use a different --data-dir.`;
}
function pidIsAlive(pid: number): boolean {
if (pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (err) {
return errorCode(err) === 'EPERM';
}
}
function readReaderNames(readers: string): string[] {
try {
return readdirSync(readers);
} catch (err) {
if (errorCode(err) === 'ENOENT') return [];
throw err;
}
}
function sweepStaleReaders(dir: string) {
const readers = readersDir(dir);
for (const name of readReaderNames(readers)) {
const pid = Number(name.split('-')[0]);
if (!Number.isFinite(pid) || !pidIsAlive(pid)) {
rmSync(join(readers, name), { force: true });
}
}
}
async function waitForReadersEmpty(dir: string, timeoutMs: number) {
const readers = readersDir(dir);
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
sweepStaleReaders(dir);
if (readReaderNames(readers).length === 0) return;
await new Promise(resolve => setTimeout(resolve, READER_POLL_INTERVAL_MS));
}
throw new Error(lockedMessage(timeoutMs));
}
async function acquireGate(
dir: string,
timeoutMs: number,
): Promise<() => Promise<void>> {
ensureDir(dir);
try {
return await lockfile.lock(dir, {
lockfilePath: lockfilePath(dir),
retries: retriesForTimeout(timeoutMs),
stale: 30_000,
});
} catch (err) {
if (isLockedError(err)) throw new Error(lockedMessage(timeoutMs));
throw err;
}
}
export async function acquireExclusive(
dir: string,
{ timeoutMs }: AcquireOptions,
): Promise<Release> {
const start = Date.now();
const release = await acquireGate(dir, timeoutMs);
try {
const remaining = Math.max(0, timeoutMs - (Date.now() - start));
await waitForReadersEmpty(dir, remaining);
} catch (err) {
await release();
throw err;
}
return () => release();
}
export async function acquireShared(
dir: string,
{ timeoutMs }: AcquireOptions,
): Promise<Release> {
const gate = await acquireGate(dir, timeoutMs);
let markerPath: string;
try {
const readers = readersDir(dir);
ensureDir(readers);
const markerName = `${process.pid}-${randomBytes(6).toString('hex')}`;
markerPath = join(readers, markerName);
writeFileSync(markerPath, '');
} catch (err) {
await gate();
throw err;
}
await gate();
return async () => {
rmSync(markerPath, { force: true });
};
}

View File

@@ -61,31 +61,20 @@ describe('formatOutput', () => {
expect(result).toContain('b'); expect(result).toContain('b');
}); });
it('formats amount fields as decimal values', () => { it('flattens nested objects instead of showing [object Object]', () => {
const data = [{ name: 'Groceries', amount: -250000 }]; const data = [{ payee: { id: 'p1', name: 'Grocery' } }];
const result = formatOutput(data, 'table'); const result = formatOutput(data, 'table');
expect(result).toContain('-2500.00'); expect(result).toContain('Grocery');
expect(result).not.toContain('-250000'); expect(result).not.toContain('[object Object]');
}); });
it('formats balance fields as decimal values', () => { it('flattens nested objects in key-value table', () => {
const data = [{ id: 'acc1', balance: 166500 }]; const result = formatOutput(
const result = formatOutput(data, 'table'); { payee: { id: 'p1', name: 'Grocery' } },
expect(result).toContain('1665.00'); 'table',
}); );
expect(result).toContain('Grocery');
it('formats budgeted and spent fields as decimal values', () => { expect(result).not.toContain('[object Object]');
const data = [{ budgeted: 50000, spent: -32150 }];
const result = formatOutput(data, 'table');
expect(result).toContain('500.00');
expect(result).toContain('-321.50');
});
it('does not format non-amount numeric fields', () => {
const data = [{ id: 12345, sort_order: 100 }];
const result = formatOutput(data, 'table');
expect(result).toContain('12345');
expect(result).toContain('100');
}); });
}); });
@@ -140,19 +129,22 @@ describe('formatOutput', () => {
expect(lines[0]).toBe('a,b'); expect(lines[0]).toBe('a,b');
}); });
it('formats amount fields as decimal values', () => { it('flattens nested objects instead of showing [object Object]', () => {
const data = [{ name: 'Coffee', amount: -2500 }]; const data = [{ payee: { id: 'p1', name: 'Grocery' } }];
const result = formatOutput(data, 'csv'); const result = formatOutput(data, 'csv');
const lines = result.split('\n'); const lines = result.split('\n');
expect(lines[0]).toBe('name,amount'); expect(lines[0]).toBe('payee');
expect(lines[1]).toBe('Coffee,-25.00'); expect(lines[1]).toContain('Grocery');
expect(lines[1]).not.toContain('[object Object]');
}); });
it('does not format amount fields in json output', () => { it('flattens nested objects in single-object csv', () => {
const data = [{ amount: 166500 }]; const result = formatOutput(
const result = formatOutput(data, 'json'); { payee: { id: 'p1', name: 'Grocery' } },
expect(result).toContain('166500'); 'csv',
expect(result).not.toContain('1665.00'); );
expect(result).toContain('Grocery');
expect(result).not.toContain('[object Object]');
}); });
}); });
}); });

View File

@@ -2,27 +2,10 @@ import Table from 'cli-table3';
export type OutputFormat = 'json' | 'table' | 'csv'; export type OutputFormat = 'json' | 'table' | 'csv';
// Fields containing integer-cent values, auto-formatted as decimals in table/csv output. function flattenValue(value: unknown): string {
const AMOUNT_FIELDS = new Set([ if (value === null || value === undefined) return '';
'amount', if (typeof value === 'object') return JSON.stringify(value);
'balance', return String(value);
'balance_available',
'balance_current',
'balance_limit',
'budgeted',
'spent',
'carryover',
]);
function isAmountValue(key: string, value: unknown): value is number {
return AMOUNT_FIELDS.has(key) && typeof value === 'number';
}
function formatCellValue(key: string, value: unknown): string {
if (isAmountValue(key, value)) {
return (value / 100).toFixed(2);
}
return String(value ?? '');
} }
export function formatOutput( export function formatOutput(
@@ -46,7 +29,7 @@ function formatTable(data: unknown): string {
if (data && typeof data === 'object') { if (data && typeof data === 'object') {
const table = new Table(); const table = new Table();
for (const [key, value] of Object.entries(data)) { for (const [key, value] of Object.entries(data)) {
table.push({ [key]: formatCellValue(key, value) }); table.push({ [key]: flattenValue(value) });
} }
return table.toString(); return table.toString();
} }
@@ -62,7 +45,7 @@ function formatTable(data: unknown): string {
for (const row of data) { for (const row of data) {
const r = row as Record<string, unknown>; const r = row as Record<string, unknown>;
table.push(keys.map(k => formatCellValue(k, r[k]))); table.push(keys.map(k => flattenValue(r[k])));
} }
return table.toString(); return table.toString();
@@ -74,7 +57,7 @@ function formatCsv(data: unknown): string {
const entries = Object.entries(data); const entries = Object.entries(data);
const header = entries.map(([k]) => escapeCsv(k)).join(','); const header = entries.map(([k]) => escapeCsv(k)).join(',');
const values = entries const values = entries
.map(([k, v]) => escapeCsv(formatCellValue(k, v))) .map(([, v]) => escapeCsv(flattenValue(v)))
.join(','); .join(',');
return header + '\n' + values; return header + '\n' + values;
} }
@@ -89,7 +72,7 @@ function formatCsv(data: unknown): string {
const header = keys.map(k => escapeCsv(k)).join(','); const header = keys.map(k => escapeCsv(k)).join(',');
const rows = data.map(row => { const rows = data.map(row => {
const r = row as Record<string, unknown>; const r = row as Record<string, unknown>;
return keys.map(k => escapeCsv(formatCellValue(k, r[k]))).join(','); return keys.map(k => escapeCsv(flattenValue(r[k]))).join(',');
}); });
return [header, ...rows].join('\n'); return [header, ...rows].join('\n');

View File

@@ -1,4 +1,56 @@
import { parseBoolFlag, parseIntFlag } from './utils'; import {
CliError,
defaultDateRange,
parseBoolFlag,
parseIntFlag,
} from './utils';
describe('CliError', () => {
it('stores message and suggestion', () => {
const err = new CliError('something failed', 'try this instead');
expect(err.message).toBe('something failed');
expect(err.suggestion).toBe('try this instead');
expect(err).toBeInstanceOf(Error);
});
it('works without suggestion', () => {
const err = new CliError('something failed');
expect(err.message).toBe('something failed');
expect(err.suggestion).toBeUndefined();
});
});
describe('defaultDateRange', () => {
it('returns both dates when both provided', () => {
expect(defaultDateRange('2025-01-01', '2025-01-31')).toEqual({
start: '2025-01-01',
end: '2025-01-31',
});
});
it('defaults start to 30 days before end', () => {
expect(defaultDateRange(undefined, '2025-02-28')).toEqual({
start: '2025-01-29',
end: '2025-02-28',
});
});
it('defaults end to today when only start provided', () => {
const result = defaultDateRange('2025-01-01');
expect(result.start).toBe('2025-01-01');
expect(result.end).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it('defaults both to last 30 days when neither provided', () => {
const result = defaultDateRange();
expect(result.start).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(result.end).toMatch(/^\d{4}-\d{2}-\d{2}$/);
const startDate = new Date(result.start);
const endDate = new Date(result.end);
const diffDays = (endDate.getTime() - startDate.getTime()) / 86400000;
expect(diffDays).toBe(30);
});
});
describe('parseBoolFlag', () => { describe('parseBoolFlag', () => {
it('parses "true"', () => { it('parses "true"', () => {

View File

@@ -1,5 +1,20 @@
export function isRecord(value: unknown): value is Record<string, unknown> { export function defaultDateRange(
return typeof value === 'object' && value !== null && !Array.isArray(value); start?: string,
end?: string,
): { start: string; end: string } {
const endDate = end ?? new Date().toLocaleDateString('en-CA');
if (start) return { start, end: endDate };
const d = new Date(endDate + 'T00:00:00');
d.setDate(d.getDate() - 30);
return { start: d.toLocaleDateString('en-CA'), end: endDate };
}
export class CliError extends Error {
suggestion?: string;
constructor(message: string, suggestion?: string) {
super(message);
this.suggestion = suggestion;
}
} }
export function parseBoolFlag(value: string, flagName: string): boolean { export function parseBoolFlag(value: string, flagName: string): boolean {
@@ -18,29 +33,3 @@ export function parseIntFlag(value: string, flagName: string): number {
} }
return parsed; return parsed;
} }
export function parseNonNegativeIntFlag(
value: string,
flagName: string,
): number {
const parsed = parseIntFlag(value, flagName);
if (parsed < 0) {
throw new Error(
`Invalid ${flagName}: "${value}". Expected a non-negative integer.`,
);
}
return parsed;
}
export function parseBoolEnv(
raw: string | undefined,
source: string,
): boolean | undefined {
if (raw === undefined) return undefined;
const lower = raw.toLowerCase();
if (raw === '1' || lower === 'true') return true;
if (raw === '0' || lower === 'false') return false;
throw new Error(
`Invalid ${source}: "${raw}". Expected "true", "false", "1", or "0".`,
);
}

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