Compare commits

..

20 Commits

Author SHA1 Message Date
Cursor Agent
04a2e8a309 [AI] Merge master into matiss/crdt-source-loading
Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>
2026-05-08 21:07:26 +00:00
dependabot[bot]
db38565524 Bump uuid from 13.0.2 to 14.0.0 (#7739)
* Bump uuid from 13.0.2 to 14.0.0

Bumps [uuid](https://github.com/uuidjs/uuid) from 13.0.2 to 14.0.0.
- [Release notes](https://github.com/uuidjs/uuid/releases)
- [Changelog](https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/uuidjs/uuid/compare/v13.0.2...v14.0.0)

---
updated-dependencies:
- dependency-name: uuid
  dependency-version: 14.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* [AI] Add engines field to sync-server package.json for Node.js >=22 requirement

Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>
2026-05-08 20:40:07 +00:00
Nikhil Verma
9e9cf45641 [AI] Update preview-builds doc with current URL pattern and additional previews (#7728)
* [AI] Update preview-builds doc with current URL pattern and additional previews

* [AI] Simplify preview labels and add release notes
2026-05-08 20:26:47 +00:00
Matiss Janis Aboltins
8ab8277429 [AI] Replace Support contact link with auto-closing tech-support issue template (#7670)
* [AI] Replace Support contact link with auto-closing tech-support issue template

Convert the external Discord "Support" contact link into a proper GitHub issue
form so users who skip the redirect still land somewhere useful. The new form
has a single "Describe your problem" field and a prominent notice that tech
support tickets are auto-closed and Discord is the place to get help. A new
workflow watches for the `tech-support` label, posts a friendly Discord pointer
and closes the issue, mirroring the existing feature-request auto-close flow.

* Add release notes for PR #7670

* [AI] Replace create-or-update-comment action with gh CLI

The peter-evans/create-or-update-comment action is unnecessary since GitHub's gh CLI (pre-installed on all GitHub-hosted runners) provides the same functionality natively via 'gh issue comment' and 'gh issue close' commands. This change addresses the zizmor security scanner warning about using an action when the functionality is already included by the runner.

Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>

* Update 7670.md

* [AI] Fix formatting in workflow file

Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>

* Update issues-close-tech-support.yml

Co-authored-by: Stephen Brown II <Stephen.Brown2@gmail.com>

* Update tech-support.yml

Co-authored-by: Stephen Brown II <Stephen.Brown2@gmail.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>
Co-authored-by: Stephen Brown II <Stephen.Brown2@gmail.com>
2026-05-08 20:24:17 +00:00
Matiss Janis Aboltins
d9fb66422b [AI] Document patch release process in releasing.md (#7746)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:23:27 +00:00
github-actions[bot]
988edc4a7d Revert "[AI] Revert rebuild-electron to master version"
This reverts commit 4b6baab79f.
2026-05-07 22:04:31 +01:00
github-actions[bot]
4b6baab79f [AI] Revert rebuild-electron to master version 2026-05-07 21:26:05 +01:00
github-actions[bot]
1e142e055d [AI] Merge remote-tracking branch 'origin/master' into matiss/crdt-source-loading 2026-05-07 21:24:31 +01:00
github-actions[bot]
8393a65d7a [AI] Fix applyAppUpdate hanging in dev mode
In dev mode browser-preload's updateSW was () => undefined, so
applyAppUpdate() — which calls updateSW() and then awaits a
deliberately never-resolving promise (waiting for the SW-driven page
reload) — hung the renderer instead of refreshing. In prod the page
is replaced by the new service worker, so the never-resolving await is
fine. The dev path now triggers a plain window.location.reload() so
the page reloads and the never-settling await is irrelevant, matching
prod's effective behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:52:50 +01:00
github-actions[bot]
6b351eafc7 [AI] Restore bank-factory glob to ./banks/*_*.{ts,js}
Re-apply the glob widening originally added in 145868f9d. It was
reverted in 531b1a191 because the desktop e2e was failing — that
failure is now traced to the rebuild-electron breakage (fixed in
6e8ac0784), not to this glob. Mirroring migrations.ts so future TS
bank handlers are picked up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:32:46 +01:00
github-actions[bot]
43fba254b5 [AI] Restore CSP unsafe-eval comment
Bring back the explanatory comment that was stripped diagnostically in
99682268c. Now that the desktop e2e regression is traced to
rebuild-electron and not to anything in this branch, we can keep the
documentation noting why 'unsafe-eval' is retained in both CSP branches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:29:24 +01:00
github-actions[bot]
6e8ac07846 [AI] Make rebuild-electron actually rebuild better-sqlite3
PR #7712 simplified rebuild-electron to just `electron-rebuild -f -o
better-sqlite3,bcrypt` from the repo root. Two problems with that:

  1. Without `-m`, electron-rebuild scans the root workspace's package.json
     for native deps. better-sqlite3 isn't a direct root dep — it lives
     under packages/sync-server/ — so the scan returns no candidates and
     the rebuild silently no-ops.
  2. Without --build-from-source, electron-rebuild defers to
     prebuild-install, which downloads a stale prebuilt binary keyed off
     better-sqlite3's package.json (ABI 127) instead of recompiling
     against Electron 39's bundled Node ABI 140. The download succeeds
     and "Rebuild Complete" prints, but the resulting `better_sqlite3.node`
     can't `dlopen` inside Electron's utility process — sync-server
     crashes immediately on db init, the renderer's startSyncServer IPC
     never resolves, and the e2e test hangs on "Configure your server".

Point -m at packages/desktop-electron (which transitively pulls in
better-sqlite3 and bcrypt via @actual-app/sync-server) and force a real
compile via --build-from-source. Verified locally: better-sqlite3
rebuilds to darwin-arm64-140 and the desktop e2e onboarding test passes
in 6s instead of hanging for 60s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:23:46 +01:00
github-actions[bot]
99682268cc [AI] Strip CSP comment to restore identical state to 9513c1e16
The desktop e2e has been failing despite my prior commits being a strict
revert (only difference was a 2-line comment, which can't change runtime).
Removing even the comment so the branch matches 9513c1e16's relevant
files exactly, to isolate whether the failure is from the master merge
or from CI-environment drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:55:53 +01:00
Matiss Janis Aboltins
749aee4f44 Merge branch 'master' into matiss/crdt-source-loading 2026-05-05 21:29:45 +01:00
github-actions[bot]
531b1a1914 [AI] Revert bank-factory glob change
Widening the glob to ./banks/*_*.{ts,js} broke the desktop e2e tests in
CI even though every current handler is .js and the brace expansion
matches no .ts files locally. Reverting to ./banks/*_*.js — the change
had no behavioural benefit since there are no TS handlers, so the
nitpick isn't worth chasing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:23:32 +01:00
github-actions[bot]
f08490052f [AI] Restore 'unsafe-eval' in production CSP for Electron
The Electron app needs `'unsafe-eval'` at runtime, so revert the dev-only
restriction and keep `'unsafe-eval'` in both branches. Comment updated to
record the actual reason instead of marking it as removable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:10:48 +01:00
github-actions[bot]
145868f9da [AI] Address review feedback
- sync-server CSP: drop 'unsafe-eval' from the production script-src;
  the bundle has no genuine eval/new Function usage (only a defensive
  branch in setimmediate's polyfill that's never hit). Keep it on the
  dev branch where Vite's HMR runtime relies on it. Add a comment so
  it's obvious which branch needs it and why.
- bank-factory: widen the loader glob to ./banks/*_*.{ts,js} so
  TypeScript handlers are discovered too, mirroring migrations.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:04:34 +01:00
github-actions[bot]
9513c1e160 [AI] Restructure sync-server to build with Vite
Replace the hand-rolled tsgo + add-import-extensions + copy-static-assets
+ runtime loader pipeline with a single Vite SSR build. Bundles every
entry (app, bin/actual-server, scripts/*) and inlines @actual-app/crdt
source so Node never has to resolve TS at runtime — the
MODULE_TYPELESS_PACKAGE_JSON warning that surfaced via crdt's source
exports is gone. Migrations and bank handlers move from readdir-based
dynamic imports to import.meta.glob; messages.sql becomes a ?raw import.

Drop loader.mjs, register-loader.mjs, start.mjs, and
bin/add-import-extensions.mjs. Electron's startSyncServer() forks
build/app.js directly. publishConfig.imports goes away (subpath imports
are resolved at build time and don't appear in the bundle).

In dev (start:server-dev) sync-server proxies to Vite, so loosen the CSP
to allow Vite's inline preamble script and HMR websocket — production
CSP is unchanged. desktop-client skips registerSW() in dev (and disables
vite-plugin-pwa's devOptions) so stale cached assets don't override
edits between page loads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:32:22 +01:00
github-actions[bot]
e661951753 Add release notes for PR #7702 2026-05-04 13:44:21 +01:00
github-actions[bot]
fc5e836a02 [AI] Load @actual-app/crdt from source in dev, only bundle for publish
@actual-app/crdt's local exports now point at src/index.ts so consumers
(sync-server, loot-core, desktop-client) never see a stale Vite bundle.
publishConfig keeps the dist/ mapping for npm consumers. crdt's
tsconfig switches to bundler module resolution to match the rest of
the workspace (no extensions in source imports).

Sync-server's existing extension-resolution loader is extended to also
handle directory-index imports (./crdt → ./crdt/index.ts), and the
standalone `start` / `start-monitor` scripts now invoke Node with
--import ./register-loader.mjs so the loader is in place before crdt's
source resolves.

Electron's utilityProcess.fork accepts execArgv but doesn't actually
preload --import modules, so a new packages/sync-server/start.mjs
bootstrap entry registers the loader imperatively and then dynamic-
imports build/app.js. desktop-electron's startSyncServer() points the
fork at start.mjs. sync-server's "files" array now ships start.mjs,
register-loader.mjs and loader.mjs so packaged Electron / npm
consumers actually receive them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 13:32:18 +01:00
36 changed files with 315 additions and 412 deletions

View File

@@ -3,9 +3,6 @@ contact_links:
- name: Bank-sync issues
url: https://discord.gg/pRYNYr4W5A
about: Is bank-sync not working? Returning too much or too few information? Reach out to the community on Discord.
- name: Support
url: https://discord.gg/pRYNYr4W5A
about: Need help with something? Having troubles setting up? Or perhaps issues using the API? Reach out to the community on Discord.
- name: Translations
url: https://hosted.weblate.org/projects/actualbudget/actual/
about: Found a string that needs a better translation? Add your suggestion or upvote an existing one in Weblate.

17
.github/ISSUE_TEMPLATE/tech-support.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
name: Tech Support
description: Need help with something? Having troubles setting up? Or perhaps issues using the API?
title: '[Support]: '
labels: ['tech-support']
body:
- type: markdown
attributes:
value: |
> ⚠️ **Tech support tickets opened here are automatically closed.** GitHub Issues are reserved for bug reports and feature requests. The fastest way to get help is to ask the community on [Discord](https://discord.gg/pRYNYr4W5A) — that's where most of the community lives and can help you in real time.
- type: textarea
id: problem
attributes:
label: Describe your problem
description: Please describe, in as much detail as you can, what you need help with.
placeholder: I'm trying to [...] but [...]
validations:
required: true

View File

@@ -0,0 +1,23 @@
name: Close tech support issues with automated message
on:
issues:
types: [labeled]
jobs:
tech-support:
if: ${{ github.event.label.name == 'tech-support' }}
runs-on: ubuntu-latest
steps:
- name: Create comment and close issue
run: |
gh issue comment "$ISSUE_URL" --body ":wave: Thanks for reaching out!
GitHub Issues are reserved for bug reports and feature requests, so tech support tickets are automatically closed. The fastest way to get help is to ask the community on [Discord](https://discord.gg/pRYNYr4W5A) — that's where most of the community lives and can help you in real time.
<!-- tech-support-auto-close-comment -->"
gh issue close "$ISSUE_URL"
env:
ISSUE_URL: https://github.com/actualbudget/actual/issues/${{ github.event.issue.number }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -15,7 +15,8 @@
"vi": "readonly",
"backend": "readonly",
"importScripts": "readonly",
"FS": "readonly"
"FS": "readonly",
"__APP_VERSION__": "readonly"
},
"rules": {
// Import sorting

View File

@@ -52,7 +52,7 @@
"playwright": "yarn workspace @actual-app/web run playwright",
"vrt": "yarn workspace @actual-app/web run vrt",
"vrt:docker": "./bin/run-vrt",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -f -m ./packages/desktop-electron -o better-sqlite3,bcrypt",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/desktop-electron -o better-sqlite3,bcrypt --build-from-source -f",
"rebuild-node": "yarn workspace @actual-app/core rebuild",
"lint": "oxfmt --check . && oxlint --type-aware --quiet",
"lint:fix": "oxfmt . && oxlint --fix --type-aware --quiet",

View File

@@ -50,7 +50,7 @@
"@actual-app/crdt": "workspace:*",
"better-sqlite3": "^12.8.0",
"compare-versions": "^6.1.1",
"uuid": "^13.0.0"
"uuid": "^14.0.0"
},
"devDependencies": {
"@typescript/native-preview": "beta",

View File

@@ -10,14 +10,10 @@
"!dist/**/*.spec.d.ts",
"!dist/**/*.spec.d.ts.map"
],
"main": "dist/index.js",
"types": "dist/index.d.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
}
".": "./src/index.ts"
},
"publishConfig": {
"exports": {
@@ -25,7 +21,9 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
},
"main": "dist/index.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build:node": "vite build",
@@ -37,7 +35,7 @@
"dependencies": {
"google-protobuf": "^3.21.4",
"murmurhash": "^2.0.1",
"uuid": "^13.0.0"
"uuid": "^14.0.0"
},
"devDependencies": {
"@types/google-protobuf": "3.15.12",

View File

@@ -4,8 +4,8 @@
"rootDir": "./src",
"composite": true,
"target": "ES2021",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"module": "ES2022",
"moduleResolution": "bundler",
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,

View File

@@ -199,7 +199,7 @@
"sass": "^1.99.0",
"typescript-strict-plugin": "^2.4.4",
"usehooks-ts": "^3.1.1",
"uuid": "^13.0.0",
"uuid": "^14.0.0",
"vite": "^8.0.5",
"vite-plugin-pwa": "^1.2.0",
"vitest": "^4.1.2"

View File

@@ -335,10 +335,17 @@ const isUpdateReadyForDownloadPromise = new Promise(resolve => {
resolve(true);
};
});
const updateSW = registerSW({
immediate: true,
onNeedRefresh: markUpdateReadyForDownload,
});
// Skip SW registration in dev so stale cached assets don't override edits
// between page loads. Plugin code that needs a SW can register one itself.
// In dev there is no SW to install, so applyAppUpdate() can't rely on the
// SW lifecycle to swap the page — fall back to a plain reload so callers
// don't hang on the never-resolving promise inside applyAppUpdate.
const updateSW = IS_DEV
? () => window.location.reload()
: registerSW({
immediate: true,
onNeedRefresh: markUpdateReadyForDownload,
});
global.Actual = {
IS_DEV,

View File

@@ -25,15 +25,14 @@ const importScriptsWithRetry = async (script, { maxRetries = 5 } = {}) => {
}
// Attempt to retry after a small delay
await new Promise((resolve, reject) => {
setTimeout(() => {
importScriptsWithRetry(script, {
await new Promise(resolve =>
setTimeout(async () => {
await importScriptsWithRetry(script, {
maxRetries: maxRetries - 1,
})
.then(resolve)
.catch(reject);
}, 5000);
});
});
resolve();
}, 5000),
);
}
};
@@ -77,11 +76,9 @@ self.addEventListener('message', async event => {
return;
}
// A single failed importScripts bricks the SharedWorker until
// it's evicted, so retry in production too.
await importScriptsWithRetry(
`${msg.publicUrl}/kcab/kcab.worker.${hash}.js`,
{ maxRetries: isDev ? 5 : 3 },
{ maxRetries: isDev ? 5 : 0 },
);
backend.initApp(isDev, self).catch(err => {

View File

@@ -30,7 +30,7 @@ describe('FatalError', () => {
expect(screen.getByText(/IndexedDB/)).toBeInTheDocument();
});
it('renders a backend-worker message for a BackendInitFailure', () => {
it('renders the generic simple message for an app-init-failure without a specific cause', () => {
const error = {
type: 'app-init-failure',
BackendInitFailure: true,
@@ -38,16 +38,6 @@ describe('FatalError', () => {
render(<FatalError error={error} />, { wrapper: TestProviders });
expect(
screen.getByText(/couldn't load a critical backend worker/i),
).toBeInTheDocument();
});
it('renders the generic simple message for an app-init-failure without a specific cause', () => {
const error = { type: 'app-init-failure' };
render(<FatalError error={error} />, { wrapper: TestProviders });
expect(
screen.getByText(/problem loading the app in this browser version/i),
).toBeInTheDocument();

View File

@@ -69,17 +69,10 @@ function RenderSimple({ error }: RenderSimpleProps) {
</Trans>
</Text>
);
} else if ('BackendInitFailure' in error && error.BackendInitFailure) {
msg = (
<Text>
<Trans>
Actual couldn't load a critical backend worker. Reload the page to try
again; if the problem persists, do a hard refresh to clear any stale
cached assets.
</Trans>
</Text>
);
} else {
// This indicates the backend failed to initialize. Show the
// user something at least so they aren't looking at a blank
// screen
msg = (
<Text>
<Trans>
@@ -99,6 +92,19 @@ function RenderSimple({ error }: RenderSimpleProps) {
}}
>
<Text>{msg}</Text>
<Text>
<Trans>
Please get{' '}
<Link
variant="external"
linkColor="muted"
to="https://actualbudget.org/contact"
>
in touch
</Link>{' '}
for support
</Trans>
</Text>
</SpaceBetween>
);
}

View File

@@ -172,47 +172,6 @@ describe('SharedWorker coordinator', () => {
expect.objectContaining({ type: 'app-init-failure', error: 'boom' }),
);
});
it('clears the cached init failure when the client acknowledges it', () => {
const leader = connectTab(coordinator);
sendInit(leader);
simulateWorkerConnect(leader);
sendMsg(leader, {
type: '__from-worker',
msg: { type: 'app-init-failure', error: 'boom' },
});
expect(coordinator.getState().lastAppInitFailure).toEqual(
expect.objectContaining({ type: 'app-init-failure' }),
);
sendMsg(leader, { name: '__app-init-failure-acknowledged' });
expect(coordinator.getState().lastAppInitFailure).toBeNull();
const port2 = connectTab(coordinator);
sendInit(port2);
expect(port2.postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'app-init-failure' }),
);
});
it('clears the cached init failure when a backend later connects successfully', () => {
const failedLeader = connectTab(coordinator);
sendInit(failedLeader);
sendMsg(failedLeader, {
type: '__from-worker',
msg: { type: 'app-init-failure', error: 'boom' },
});
expect(coordinator.getState().lastAppInitFailure).not.toBeNull();
sendMsg(failedLeader, { type: 'tab-closing' });
const newLeader = connectTab(coordinator);
sendInit(newLeader);
simulateWorkerConnect(newLeader);
expect(coordinator.getState().lastAppInitFailure).toBeNull();
});
});
// ── Load budget ─────────────────────────────────────────────────────

View File

@@ -100,7 +100,6 @@ export function createCoordinator({
}
function broadcastConnect(budgetId: string) {
lastAppInitFailure = null;
const connectMsg = { type: 'connect' };
broadcastToAllInGroup(budgetId, connectMsg);
for (const port of unassignedPorts) {
@@ -545,22 +544,10 @@ export function createCoordinator({
return;
}
// Drop the cache once the client has surfaced the failure, so a
// subsequent init can re-attempt. Falls through to the leader so
// the Worker still stops its retry interval.
if (msg.name === '__app-init-failure-acknowledged') {
lastAppInitFailure = null;
}
// ── Initialization ─────────────────────────────────────────
if (msg.type === 'init') {
cachedInitMsg = msg;
// The leader that produced the failure is gone, so the cache
// can't recover — drop it and let this tab attempt a fresh init.
if (lastAppInitFailure && budgetGroups.size === 0) {
lastAppInitFailure = null;
}
if (lastAppInitFailure) {
port.postMessage(lastAppInitFailure);
} else {

View File

@@ -376,7 +376,9 @@ export default defineConfig(async ({ mode, command }) => {
// swSrc: `service-worker/plugin-sw.js`,
// },
devOptions: {
enabled: true, // We need service worker in dev mode to work with plugins
// Disabled: caches stale assets across reloads in dev. Plugin
// code that explicitly needs a SW can register one itself.
enabled: false,
type: 'module',
},
workbox: {

View File

@@ -239,12 +239,11 @@ async function startSyncServer() {
),
};
const serverPath = path.join(
// require.resolve will recursively search up the workspace for the module
path.dirname(require.resolve('@actual-app/sync-server/package.json')),
'build',
'app.js',
// require.resolve will recursively search up the workspace for the module
const syncServerRoot = path.dirname(
require.resolve('@actual-app/sync-server/package.json'),
);
const serverPath = path.join(syncServerRoot, 'build/app.js');
const webRoot = path.join(
// require.resolve will recursively search up the workspace for the module

View File

@@ -1,12 +1,16 @@
# Preview Builds
It is possible using our deployment pipeline to run preview builds of Actual directly on Netlify.
Each pull request automatically deploys preview builds to Netlify, so you can try out changes without cloning the branch.
To do this, find the pull request (PR) that you would like to preview in GitHub, you can find the pull requests in scope of the preview builds [here](https://github.com/actualbudget/actual/pulls).
To find a PR, browse the [open pull requests](https://github.com/actualbudget/actual/pulls). Once you have the PR number, replace `{pr-number}` in the URLs below.
Once you have the number of the PR navigate to the following URL: https://deploy-preview-pr_number--actualbudget.netlify.app/ replacing pr_number with the number of the PR you would like to preview, for example https://deploy-preview-414--actualbudget.netlify.app/
Three previews are deployed per PR:
This will load directly on Netlify where you will be able to preview the changes in that pull request without the need to clone the specific branch.
- **Demo:** `https://deploy-preview-{pr-number}.demo.actualbudget.org/`
- **Storybook:** `https://deploy-preview-{pr-number}--actualbudget-storybook.netlify.app/`
- **Website:** `https://deploy-preview-{pr-number}.www.actualbudget.org/`
The exact URLs are also posted as a comment on each PR by the Netlify bot.
:::info
There is no sync server on preview builds so when asked "Where's the server" select "Don't use a server." Alternatively, you can use your own self-hosted server. You should exercise caution when using a server with preview builds because they are much more likely to have bugs that could damage your budget. Consider running a separate local server for preview builds.

View File

@@ -68,3 +68,46 @@ Finally, a draft GitHub release should be automatically created; confirm [on the
- [ ] Un-draft the GitHub release which will send announcement notifications to all apps and create a PR to the [Actual Flathub Repository](https://github.com/flathub/com.actualbudget.actual/pulls).
- [ ] Send an announcement on Discord and Twitter.
- [ ] Approve and merge the [Flathub Release PR](https://github.com/flathub/com.actualbudget.actual/pulls) to master. After merge, it can take anywhere from hours to a few days before the app will be available in the Flathub Store.
## Cutting a patch release
Patch releases (e.g. `v26.5.1`) ship a small, targeted set of fixes on top of an existing release. Unlike monthly releases, the release branch is built by cherry-picking specific commits from `master` onto the previous release tag, so unrelated in-progress work on `master` is not pulled in.
### Build the release branch
- [ ] Identify the commits on `master` that should be included in the patch release and note their commit hashes.
- [ ] Check out the previous release tag and create a new release branch from it:
```bash
git checkout v26.5.0
git checkout -b release/v26.5.1
```
- [ ] Cherry-pick each commit onto the new branch, in the same order they were merged to `master`:
```bash
git cherry-pick <commit-sha>
```
- [ ] Push the release branch. This is the branch that will be tagged later — **do not tag it yet**:
```bash
git push -u {remote} release/v26.5.1
```
### Open the release PR against master
The release branch is what gets tagged, but the version bump, release notes cleanup, and blog post still need to land on `master` so future releases pick them up.
- [ ] Check out `master` and create a new branch from it (e.g. `release-notes/v26.5.1`).
- [ ] In this branch:
- Bump the version in the relevant `package.json` files.
- Delete the `upcoming-release-notes/*.md` files that correspond to the cherry-picked commits.
- Add a new blog post under `packages/docs/blog/` (see [`2026-02-22-release-26-2-1.md`](https://github.com/actualbudget/actual/blob/master/packages/docs/blog/2026-02-22-release-26-2-1.md) for an example).
- [ ] Commit the changes and open a PR against `master`. Include a link to the previously pushed release branch (e.g. `release/v26.5.1`) in the PR description so reviewers can see exactly what is shipping.
### Tag the release
- [ ] Once the PR has been approved and merged, tag the **release branch** (not `master`) and push the tag:
```bash
git checkout release/v26.5.1
git tag v26.5.1
git push {remote} v26.5.1
```
From here the rest of the release pipeline (NPM, Docker, Desktop, GitHub draft release) runs automatically. Follow the [Verify the release](#verify-the-release) and [Finalize the release](#finalize-the-release) steps above to complete the rollout.

View File

@@ -191,7 +191,7 @@
"promise-retry": "^2.0.1",
"typescript-strict-plugin": "^2.4.4",
"ua-parser-js": "^2.0.9",
"uuid": "^13.0.0",
"uuid": "^14.0.0",
"xml2js": "^0.6.2"
},
"devDependencies": {

View File

@@ -1,7 +1,6 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
import { parseArgs } from 'node:util';
const args = process.argv;
@@ -54,11 +53,7 @@ if (values.help) {
}
if (values.version) {
const __dirname = dirname(fileURLToPath(import.meta.url));
const packageJsonPath = resolve(__dirname, '../../package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
console.log('v' + packageJson.version);
console.log('v' + __APP_VERSION__);
process.exit();
}

View File

@@ -1,146 +0,0 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from 'node:fs';
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, extname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const buildDir = resolve(__dirname, '../build');
const packageRoot = resolve(__dirname, '..');
const packageJson = JSON.parse(
readFileSync(join(packageRoot, 'package.json'), 'utf-8'),
);
// publishConfig.imports already has ./build/src/ paths with .js extensions
const importsMap = packageJson.publishConfig?.imports || {};
// Sort wildcard patterns longest-prefix-first so more specific patterns
// (e.g. #app-gocardless/services/tests/*) match before broader ones (#app-gocardless/*)
const wildcardEntries = Object.entries(importsMap)
.filter(([p]) => p.includes('*'))
.sort(([a], [b]) => b.length - a.length);
async function getAllJsFiles(dir) {
const files = [];
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await getAllJsFiles(fullPath)));
} else if (entry.isFile() && extname(entry.name) === '.js') {
files.push(fullPath);
}
}
return files;
}
function resolveImportPath(importPath, fromFile) {
const baseDir = dirname(fromFile);
const resolvedPath = resolve(baseDir, importPath);
// Check if it's a file with .js extension
if (existsSync(`${resolvedPath}.js`)) {
return `${importPath}.js`;
}
// Check if it's a directory with index.js
if (existsSync(resolvedPath) && existsSync(join(resolvedPath, 'index.js'))) {
return `${importPath}/index.js`;
}
// Verify the file exists before adding extension
if (!existsSync(`${resolvedPath}.js`)) {
console.warn(
`Warning: Could not resolve import '${importPath}' from ${relative(buildDir, fromFile)}`,
);
}
// Default: assume it's a file and add .js
return `${importPath}.js`;
}
function toRelativePath(target, fromFile) {
const absoluteTarget = resolve(packageRoot, target);
let rel = relative(dirname(fromFile), absoluteTarget);
if (!rel.startsWith('.')) rel = './' + rel;
return rel.split('\\').join('/');
}
function resolveSubpathImport(importPath, fromFile) {
if (importsMap[importPath]) {
return toRelativePath(importsMap[importPath], fromFile);
}
for (const [pattern, target] of wildcardEntries) {
const prefix = pattern.replaceAll('*', '');
if (importPath.startsWith(prefix)) {
const wildcard = importPath.slice(prefix.length);
return toRelativePath(target.replaceAll('*', wildcard), fromFile);
}
}
console.warn(
`Warning: Could not resolve subpath import '${importPath}' from ${relative(buildDir, fromFile)}`,
);
return null;
}
function addExtensionsToImports(content, filePath) {
const importRegex =
/(?:import\s+(?:(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)(?:\s*,\s*(?:\{[^}]*\}|\*\s+as\s+\w+|\w+))*\s+from\s+)?|import\s*\(|require\s*\()['"]((\.\.?\/[^'"]+)|(#[^'"]+))['"]/g;
return content.replace(importRegex, (match, importPath) => {
if (!importPath || typeof importPath !== 'string') {
return match;
}
if (importPath.startsWith('#')) {
const resolved = resolveSubpathImport(importPath, filePath);
if (resolved) {
return match.replace(importPath, resolved);
}
return match;
}
// Skip if already has an extension
if (/\.(js|mjs|ts|mts|json)$/.test(importPath)) {
return match;
}
// Skip if ends with / (directory import that already has trailing slash)
if (importPath.endsWith('/')) {
return match;
}
const newImportPath = resolveImportPath(importPath, filePath);
return match.replace(importPath, newImportPath);
});
}
async function processFile(filePath) {
const content = await readFile(filePath, 'utf-8');
const newContent = addExtensionsToImports(content, filePath);
if (content !== newContent) {
await writeFile(filePath, newContent, 'utf-8');
const relativePath = relative(buildDir, filePath);
console.log(`Updated imports in ${relativePath}`);
}
}
async function main() {
try {
const files = await getAllJsFiles(buildDir);
await Promise.all(files.map(processFile));
console.log(`Processed ${files.length} files`);
} catch (error) {
console.error('Error processing files:', error);
process.exit(1);
}
}
void main();

View File

@@ -1,26 +0,0 @@
import { existsSync } from 'node:fs';
import { dirname, extname, resolve as nodeResolve } from 'node:path';
import { pathToFileURL } from 'node:url';
const extensions = ['.ts', '.js', '.mts', '.mjs'];
export async function resolve(specifier, context, nextResolve) {
// Only handle relative imports without extensions
if (specifier.startsWith('.') && !extname(specifier)) {
const parentURL = context.parentURL;
if (parentURL) {
const parentPath = new URL(parentURL).pathname;
const parentDir = dirname(parentPath);
// Try extensions in order
for (const ext of extensions) {
const resolvedPath = nodeResolve(parentDir, `${specifier}${ext}`);
if (existsSync(resolvedPath)) {
return nextResolve(pathToFileURL(resolvedPath).href, context);
}
}
}
}
return nextResolve(specifier, context);
}

View File

@@ -41,47 +41,19 @@
"#util/title": "./src/util/title/index.js",
"#util/*": "./src/util/*.ts"
},
"publishConfig": {
"imports": {
"#db": "./build/src/db.js",
"#account-db": "./build/src/account-db.js",
"#load-config": "./build/src/load-config.js",
"#migrations": "./build/src/migrations.js",
"#accounts/*": "./build/src/accounts/*.js",
"#app-gocardless/banks/bank.interface": "./build/src/app-gocardless/banks/bank.interface.js",
"#app-gocardless/banks/*": "./build/src/app-gocardless/banks/*.js",
"#app-gocardless/errors": "./build/src/app-gocardless/errors.js",
"#app-gocardless/gocardless-node.types": "./build/src/app-gocardless/gocardless-node.types.js",
"#app-gocardless/gocardless.types": "./build/src/app-gocardless/gocardless.types.js",
"#app-gocardless/services/*": "./build/src/app-gocardless/services/*.js",
"#app-gocardless/services/tests/*": "./build/src/app-gocardless/services/tests/*.js",
"#app-gocardless/util/*": "./build/src/app-gocardless/util/*.js",
"#app-gocardless/*": "./build/src/app-gocardless/*.js",
"#app-pluggyai/*": "./build/src/app-pluggyai/*.js",
"#app-simplefin/*": "./build/src/app-simplefin/*.js",
"#app-sync/services/*": "./build/src/app-sync/services/*.js",
"#app-sync/*": "./build/src/app-sync/*.js",
"#scripts/*": "./build/src/scripts/*.js",
"#services/*": "./build/src/services/*.js",
"#util/title": "./build/src/util/title/index.js",
"#util/*": "./build/src/util/*.js"
}
},
"scripts": {
"start": "yarn build && node build/app",
"start-monitor": "nodemon --exec 'yarn build && node build/app' --ignore './build/**/*' --ext 'ts,js' build/app",
"build": "tsgo -b && yarn add-import-extensions && yarn copy-static-assets",
"start": "yarn build && node build/app.js",
"start-monitor": "nodemon --exec 'yarn build && node build/app.js' --ignore './build/**/*' --ext 'ts,js' build/app.js",
"build": "vite build",
"typecheck": "tsgo -b && tsc-strict",
"add-import-extensions": "node bin/add-import-extensions.mjs",
"copy-static-assets": "rm -rf build/src/sql && cp -r src/sql build/src/sql",
"test": "NODE_ENV=test NODE_OPTIONS='--experimental-vm-modules --import ./register-loader.mjs --trace-warnings' vitest --run",
"db:migrate": "yarn build && cross-env NODE_ENV=development node build/src/scripts/run-migrations.js up",
"db:downgrade": "yarn build && cross-env NODE_ENV=development node build/src/scripts/run-migrations.js down",
"db:test-migrate": "yarn build && cross-env NODE_ENV=test node build/src/scripts/run-migrations.js up",
"db:test-downgrade": "yarn build && cross-env NODE_ENV=test node build/src/scripts/run-migrations.js down",
"reset-password": "yarn build && node build/src/scripts/reset-password.js",
"disable-openid": "yarn build && node build/src/scripts/disable-openid.js",
"health-check": "yarn build && node build/src/scripts/health-check.js"
"test": "NODE_ENV=test NODE_OPTIONS='--experimental-vm-modules --trace-warnings' vitest --run",
"db:migrate": "yarn build && cross-env NODE_ENV=development node build/scripts/run-migrations.js up",
"db:downgrade": "yarn build && cross-env NODE_ENV=development node build/scripts/run-migrations.js down",
"db:test-migrate": "yarn build && cross-env NODE_ENV=test node build/scripts/run-migrations.js up",
"db:test-downgrade": "yarn build && cross-env NODE_ENV=test node build/scripts/run-migrations.js down",
"reset-password": "yarn build && node build/scripts/reset-password.js",
"disable-openid": "yarn build && node build/scripts/disable-openid.js",
"health-check": "yarn build && node build/scripts/health-check.js"
},
"dependencies": {
"@actual-app/crdt": "workspace:*",
@@ -99,7 +71,7 @@
"migrate": "^2.1.0",
"openid-client": "^5.7.1",
"pluggy-sdk": "^0.83.0",
"uuid": "^13.0.0",
"uuid": "^14.0.0",
"winston": "^3.19.0"
},
"devDependencies": {
@@ -116,6 +88,10 @@
"nodemon": "^3.1.14",
"supertest": "^7.2.2",
"typescript-strict-plugin": "^2.4.4",
"vite": "^8.0.5",
"vitest": "^4.1.2"
},
"engines": {
"node": ">=22"
}
}

View File

@@ -1,9 +0,0 @@
import { register } from 'node:module';
import { dirname, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const loaderPath = resolve(__dirname, 'loader.mjs');
register(pathToFileURL(loaderPath).href, pathToFileURL(__dirname));

View File

@@ -1,22 +1,14 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import IntegrationBank from './banks/integration-bank';
const dirname = path.resolve(fileURLToPath(import.meta.url), '..');
const banksDir = path.resolve(dirname, 'banks');
// Filename convention: <name>_<bic>.{ts,js} (skips bank.interface,
// integration-bank, and any other helper without an underscore).
const bankLoaders = import.meta.glob('./banks/*_*.{ts,js}');
async function loadBanks() {
const bankHandlers = fs
.readdirSync(banksDir)
.filter(filename => filename.includes('_') && filename.endsWith('.js'));
const imports = await Promise.all(
bankHandlers.map(file => {
const fileUrlToBank = pathToFileURL(path.resolve(banksDir, file)); // pathToFileURL for ESM compatibility
return import(fileUrlToBank.toString()).then(handler => handler.default);
}),
Object.values(bankLoaders).map(loader =>
loader().then(handler => handler.default),
),
);
return imports;

View File

@@ -124,17 +124,32 @@ app.get('/metrics', (_req, res) => {
});
});
// The web frontend
// The web frontend.
// Dev mode proxies to Vite, which injects inline preamble scripts and uses
// a websocket for HMR. Loosen script-src and connect-src accordingly.
// `'unsafe-eval'` is required at runtime for the Electron app, so it is
// kept in both branches.
const isDev = process.env.NODE_ENV === 'development';
const scriptSrc = isDev
? "'self' 'unsafe-inline' 'unsafe-eval' blob:"
: "'self' 'unsafe-eval' blob:";
const connectSrc = isDev ? "'self' ws: wss: http: https:" : 'http: https:';
const csp = [
"default-src 'self' blob:",
"img-src 'self' blob: data:",
`script-src ${scriptSrc}`,
"style-src 'self' 'unsafe-inline'",
"font-src 'self' data:",
`connect-src ${connectSrc}`,
].join('; ');
app.use((req, res, next) => {
res.set('Cross-Origin-Opener-Policy', 'same-origin');
res.set('Cross-Origin-Embedder-Policy', 'require-corp');
res.set(
'Content-Security-Policy',
"default-src 'self' blob:; img-src 'self' blob: data:; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src http: https:;",
);
res.set('Content-Security-Policy', csp);
next();
});
if (process.env.NODE_ENV === 'development') {
if (isDev) {
console.log(
'Running in development mode - Proxying frontend routes to React Dev Server',
);

View File

@@ -21,8 +21,6 @@ const defaultDataDir = process.env.ACTUAL_DATA_DIR
debug(`Project root: '${projectRoot}'`);
export const sqlDir = path.join(__dirname, 'sql');
const actualAppWebBuildPath = path.join(
path.dirname(require.resolve('@actual-app/web/package.json')),
'build',

View File

@@ -1,40 +1,34 @@
import { readdir } from 'node:fs/promises';
import path, { dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import path from 'node:path';
import { load } from 'migrate';
import { config } from './load-config';
type MigrationCallback = (err?: Error) => void;
type MigrationModule = {
up: (next?: MigrationCallback) => void;
down: (next?: MigrationCallback) => void;
};
// Vite resolves this glob at build time and inlines a static map of
// () => import('chunks/...js') calls. Each migration becomes its own chunk.
// Runtime fs reads against a migrations/ directory disappear.
const migrationsLoaders = import.meta.glob<MigrationModule>(
'../migrations/*.{ts,js}',
);
export async function run(direction: 'up' | 'down' = 'up'): Promise<void> {
console.log(
`Checking if there are any migrations to run for direction "${direction}"...`,
);
const __dirname = dirname(fileURLToPath(import.meta.url)); // this directory
const migrationsDir = path.join(__dirname, '../migrations');
try {
// Load all script files in the migrations directory
const files = await readdir(migrationsDir);
const migrationsModules: Record<
string,
{
up: (next?: MigrationCallback) => void;
down: (next?: MigrationCallback) => void;
}
> = {};
const sortedKeys = Object.keys(migrationsLoaders).sort();
const migrationsModules: Record<string, MigrationModule> = {};
for (const f of files
.filter(
f => (f.endsWith('.js') || f.endsWith('.ts')) && !f.endsWith('.d.ts'),
)
.sort((a, b) => (a > b ? 1 : a < b ? -1 : 0))) {
migrationsModules[f] = await import(
pathToFileURL(path.join(migrationsDir, f)).href
);
for (const key of sortedKeys) {
const fileName = key.split('/').pop()!;
migrationsModules[fileName] = await migrationsLoaders[key]();
}
return new Promise<void>((resolve, reject) => {

View File

@@ -1,10 +1,9 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { existsSync } from 'node:fs';
import { merkle, SyncProtoBuf, Timestamp } from '@actual-app/crdt';
import { openDatabase } from './db';
import { sqlDir } from './load-config';
import messagesSql from './sql/messages.sql?raw';
import { getPathForGroupFile } from './util/paths';
function getGroupDb(groupId) {
@@ -14,8 +13,7 @@ function getGroupDb(groupId) {
const db = openDatabase(path);
if (needsInit) {
const sql = readFileSync(join(sqlDir, 'messages.sql'), 'utf8');
db.exec(sql);
db.exec(messagesSql);
}
return db;

View File

@@ -0,0 +1,73 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { defineConfig } from 'vite';
import type { Plugin } from 'vite';
const pkg = JSON.parse(
readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8'),
);
const shebangPlugin = (entryFile: string): Plugin => ({
name: 'sync-server-shebang',
generateBundle(_options, bundle) {
const chunk = bundle[entryFile];
if (chunk?.type === 'chunk' && !chunk.code.startsWith('#!')) {
chunk.code = `#!/usr/bin/env node\n${chunk.code}`;
}
},
});
export default defineConfig({
ssr: {
target: 'node',
// Inline workspace deps that ship as TS source. Anything else
// (express, better-sqlite3, bcrypt, @actual-app/web, etc.) stays
// external so Node resolves it at runtime.
noExternal: ['@actual-app/crdt'],
},
build: {
ssr: true,
target: 'node22',
outDir: path.resolve(__dirname, 'build'),
emptyOutDir: true,
sourcemap: true,
minify: false,
rollupOptions: {
input: {
app: path.resolve(__dirname, 'app.ts'),
'bin/actual-server': path.resolve(__dirname, 'bin/actual-server.js'),
'scripts/run-migrations': path.resolve(
__dirname,
'src/scripts/run-migrations.js',
),
'scripts/reset-password': path.resolve(
__dirname,
'src/scripts/reset-password.js',
),
'scripts/disable-openid': path.resolve(
__dirname,
'src/scripts/disable-openid.js',
),
'scripts/enable-openid': path.resolve(
__dirname,
'src/scripts/enable-openid.js',
),
'scripts/health-check': path.resolve(
__dirname,
'src/scripts/health-check.js',
),
},
output: {
format: 'esm',
entryFileNames: '[name].js',
chunkFileNames: 'chunks/[name]-[hash].js',
},
},
},
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
assetsInclude: ['**/*.sql'],
plugins: [shebangPlugin('bin/actual-server.js')],
});

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
Replace support contact link with auto-closing tech support issue template.

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
Refactor module resolution to load `@actual-app/crdt` from source during development.

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [nikhilweee]
---
Update the preview-builds contributing doc to reflect the current `deploy-preview-{pr-number}.demo.actualbudget.org` URL pattern and mention the Storybook and website previews.

View File

@@ -1,6 +0,0 @@
---
category: Enhancements
authors: [MatissJanis]
---
Surface the backend init failures with a custom error screen and message.

View File

@@ -30,7 +30,7 @@ __metadata:
compare-versions: "npm:^6.1.1"
rollup-plugin-visualizer: "npm:^7.0.1"
typescript-strict-plugin: "npm:^2.4.4"
uuid: "npm:^13.0.0"
uuid: "npm:^14.0.0"
vite: "npm:^8.0.5"
vite-plugin-peggy-loader: "npm:^2.0.1"
vitest: "npm:^4.1.2"
@@ -147,7 +147,7 @@ __metadata:
typescript-strict-plugin: "npm:^2.4.4"
ua-parser-js: "npm:^2.0.9"
util: "npm:^0.12.5"
uuid: "npm:^13.0.0"
uuid: "npm:^14.0.0"
vite: "npm:^8.0.5"
vite-plugin-node-polyfills: "npm:^0.26.0"
vite-plugin-peggy-loader: "npm:^2.0.1"
@@ -168,7 +168,7 @@ __metadata:
protoc-gen-js: "npm:3.21.4-4"
rollup-plugin-visualizer: "npm:^7.0.1"
ts-protoc-gen: "npm:0.15.0"
uuid: "npm:^13.0.0"
uuid: "npm:^14.0.0"
vite: "npm:^8.0.5"
vitest: "npm:^4.1.2"
languageName: unknown
@@ -206,7 +206,8 @@ __metadata:
pluggy-sdk: "npm:^0.83.0"
supertest: "npm:^7.2.2"
typescript-strict-plugin: "npm:^2.4.4"
uuid: "npm:^13.0.0"
uuid: "npm:^14.0.0"
vite: "npm:^8.0.5"
vitest: "npm:^4.1.2"
winston: "npm:^3.19.0"
bin:
@@ -296,7 +297,7 @@ __metadata:
sass: "npm:^1.99.0"
typescript-strict-plugin: "npm:^2.4.4"
usehooks-ts: "npm:^3.1.1"
uuid: "npm:^13.0.0"
uuid: "npm:^14.0.0"
vite: "npm:^8.0.5"
vite-plugin-pwa: "npm:^1.2.0"
vitest: "npm:^4.1.2"
@@ -28352,12 +28353,12 @@ __metadata:
languageName: node
linkType: hard
"uuid@npm:^13.0.0":
version: 13.0.2
resolution: "uuid@npm:13.0.2"
"uuid@npm:^14.0.0":
version: 14.0.0
resolution: "uuid@npm:14.0.0"
bin:
uuid: dist-node/bin/uuid
checksum: 10/567dddca18a8520796dd3cd1e4513f4c7c522f25602c15381615395d60c7892f330366680fc21373f19fb83c991f3da8413f57dbd85bf976069cf0818aa6c61c
checksum: 10/8ee9b98f9650e25555515f7a28d3c3ae9364e72f7bb19b9e08b681bc135338beba5509b2830f6ae1cfaba4d45401da0d16d4d109b977097bc3d6ba0c5583341b
languageName: node
linkType: hard