From e34a58524d25eea5b80eb3e17da8afae765eeb1b Mon Sep 17 00:00:00 2001 From: Erwann Mest Date: Fri, 31 Jul 2026 14:14:24 +0100 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20feat(sync):=20add=20sync=20daem?= =?UTF-8?q?on=20inspection=20and=20orphaned-pair=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `sync status` to inspect local sync pairs and report health issues - Add `sync prune` to remove orphaned sync pairs and their local indices - Implement database snapshot reading that bypasses WAL locks - Add comprehensive tests and documentation for sync operations - Detect orphaned, remote-missing, and stuck sync issues with visual indicators --- README.md | 13 + docs/meta.json | 2 +- docs/sync.mdx | 109 +++ package-lock.json | 1626 +++++++++++++++++++++++++++++++++++++----- package.json | 8 +- src/cli.ts | 296 +++++++- src/lib/sync.test.ts | 255 +++++++ src/lib/sync.ts | 380 ++++++++++ 8 files changed, 2494 insertions(+), 195 deletions(-) create mode 100644 docs/sync.mdx create mode 100644 src/lib/sync.test.ts create mode 100644 src/lib/sync.ts diff --git a/README.md b/README.md index 9923354..d53a4ca 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ - **Rewind restore** — browse rewind events for any path and recover a file to an arbitrary destination. - **Public links** — create, list, and delete public download links for files and folders, with optional expiry and download caps. - **Interactive browser** — a full-screen terminal file browser for exploring your pCloud drive without memorising IDs. +- **Local sync inspection** — read the pCloud Drive daemon's own database to find broken sync pairs, and prune orphaned ones the desktop app reports only as a bogus permissions error. ## Install @@ -61,6 +62,18 @@ File ID Name Size Deleted $ pcloud restore-trash 555001 ✓ File 555001 restored successfully. +$ pcloud sync +pCloud Drive running +Database ~/.pcloud/data.db · 3.13 MB + + Local Remote Files Queue +✓ ~/pCloud/Lib /Lib 883 – +🔥 ~/pCloud/Appdata — orphaned 2066 1 + +🔥 #3 ~/pCloud/Appdata + no remote folder — pCloud shows this pair as "/" + → pcloud sync prune 3 + $ pcloud browse ``` diff --git a/docs/meta.json b/docs/meta.json index 035e836..562737d 100644 --- a/docs/meta.json +++ b/docs/meta.json @@ -1,4 +1,4 @@ { "title": "pcloud-cli", - "pages": ["index", "authentication", "trash", "rewind"] + "pages": ["index", "authentication", "trash", "rewind", "sync"] } diff --git a/docs/sync.mdx b/docs/sync.mdx new file mode 100644 index 0000000..a09469f --- /dev/null +++ b/docs/sync.mdx @@ -0,0 +1,109 @@ +--- +title: 🔄 Local sync inspection +description: Inspect the pCloud Drive sync daemon, diagnose broken sync pairs, and prune orphaned ones. +--- + +Every other command in `pcloud` talks to the pCloud **API** — your files as the +server sees them. This one reads the **local** database that the pCloud Drive +desktop app keeps at `~/.pcloud/data.db`, which is where sync pairs live. + +That distinction matters because a sync pair can break in ways the API cannot +see. The symptom you actually get is a dialog reading _"pCloud doesn't have +permissions to upload this item"_ against a folder shown as `/` — which is +neither a permissions problem nor a folder called `/`. + +## 📋 Show sync pairs + +```bash +pcloud sync +``` + +```console +pCloud Drive running +Database ~/.pcloud/data.db · 3.13 MB + + Local Remote Files Queue +✓ ~/pCloud/Lib /Lib 883 – +✓ ~/pCloud/Share /Share 172 – +🔥 ~/pCloud/Appdata — orphaned 2066 1 +✓ ~/pCloud/Home /Home 40 – + +🔥 #3 ~/pCloud/Appdata + no remote folder — pCloud shows this pair as "/" + a second sync pair claims the same local folder + 1 queued operation(s) with no destination + → pcloud sync prune 3 +``` + +Add an id for a single pair, or `--json` for machine-readable output: + +```bash +pcloud sync 3 +pcloud sync --json +``` + +## 🔍 What it checks + +| Check | Meaning | +| ---------------- | --------------------------------------------------------- | +| `orphaned` | The pair's remote folder is `NULL` — nothing to sync into | +| `remote-missing` | The remote folder id is no longer in the local index | +| `duplicate` | Two pairs claim the same local folder | +| `local-missing` | The local folder no longer exists on disk | +| `stuck` | Queued operations with no destination folder to act on | + +`orphaned` is the interesting one. `syncfolder.folderid` is declared +`ON DELETE SET NULL`, so when a remote folder is deleted, SQLite blanks the +reference instead of removing the sync pair. The pair survives as a zombie +pointing at nothing, and every upload it attempts fails. + +## 🧹 Prune an orphaned pair + +```bash +pcloud sync prune 3 # dry run — shows what would be deleted +pcloud sync prune 3 --apply # perform it +``` + +The dry run is the default. `--apply` backs up `data.db` first, and **refuses to +run while pCloud Drive is open** — the daemon holds its own copy of the sync set +in memory and would either overwrite the edit or corrupt the write-ahead log. +Quit pCloud Drive, apply, then start it again. + +Pruning removes only the local index rows for that pair. It touches no files, on +disk or in the cloud. + +## 🔬 Debug view + +```bash +pcloud sync --debug +``` + +Shows daemon state, database size, write-ahead-log status, and row counts per +table. + +> **On privacy:** the same database holds `setting`, `cryptofilekey` and +> `cryptofolderkey` — your auth token and crypto key material. The debug view +> works from an **allowlist** of tables it may read, never an exclusion list, so +> a table pCloud adds in a future release is withheld by default rather than +> leaked. Withheld tables are named, never read. + +## 🩺 In `doctor` + +`pcloud doctor` runs these checks as a second section after its credential +report, and prints a one-line verdict per fault class: + +```console +Local daemon + 7 sync pair(s) · pCloud Drive running +✓ no local sync faults +``` + +The local half needs no credential, so it still runs when you are logged out. + +## 🔒 Read-only, and lock-safe + +pCloud Drive holds the database under an exclusive lock while it runs, so +`pcloud sync` copies the database and its write-ahead log to a temporary +directory and reads the copy. Your live database is only ever copied, never +opened by SQLite — `prune --apply` is the sole exception, and it requires the +daemon to be stopped. diff --git a/package-lock.json b/package-lock.json index a917505..28427e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,11 @@ "@types/node": "25.6.0", "@types/react": "19.2.14", "tsx": "4.21.0", - "typescript": "6.0.3" + "typescript": "6.0.3", + "vitest": "4.1.10" + }, + "engines": { + "node": ">=24" } }, "node_modules/@alcalzone/ansi-tokenize": { @@ -39,6 +43,18 @@ "node": ">=18" } }, + "node_modules/@emnapi/core": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", + "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "2.0.1", + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", @@ -49,6 +65,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@emnapi/wasi-threads": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", + "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -1004,6 +1031,13 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@kud/glyphs": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@kud/glyphs/-/glyphs-0.1.1.tgz", @@ -1062,205 +1096,703 @@ "react": ">=19" } }, - "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "undici-types": "~7.19.0" - } - }, - "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@xmldom/xmldom": { - "version": "0.8.13", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", - "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" + "@tybys/wasm-util": "^0.10.3" }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "node_modules/@oxc-project/types": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=12" - }, "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", + "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/app-path": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/app-path/-/app-path-4.0.0.tgz", - "integrity": "sha512-mgBO9PZJ3MpbKbwFTljTi36ZKBvG5X/fkVR1F85ANsVcVllEb+C0LGNdJfGUm84GpC4xxgN6HFkmkMU8VEO4mA==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz", + "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "execa": "^5.0.0" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/auto-bind": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", - "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz", + "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz", + "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==", + "cpu": [ + "x64" ], - "license": "MIT" - }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, "license": "MIT", - "dependencies": { - "run-applescript": "^7.0.0" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz", + "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/cli-boxes": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz", - "integrity": "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz", + "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.20 <19 || >=20.10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/cli-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", - "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz", + "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], "license": "MIT", - "dependencies": { - "restore-cursor": "^4.0.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/cli-spinners": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", - "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz", + "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/cli-truncate": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.0.0.tgz", - "integrity": "sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz", + "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", - "dependencies": { - "slice-ansi": "^9.0.0", - "string-width": "^8.2.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=22" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/code-excerpt": { - "version": "4.0.0", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz", + "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz", + "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz", + "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz", + "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "2.0.0-alpha.3", + "@emnapi/runtime": "2.0.0-alpha.3", + "@napi-rs/wasm-runtime": "^1.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", + "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz", + "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz", + "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-path": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/app-path/-/app-path-4.0.0.tgz", + "integrity": "sha512-mgBO9PZJ3MpbKbwFTljTi36ZKBvG5X/fkVR1F85ANsVcVllEb+C0LGNdJfGUm84GpC4xxgN6HFkmkMU8VEO4mA==", + "license": "MIT", + "dependencies": { + "execa": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-boxes": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz", + "integrity": "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==", + "license": "MIT", + "engines": { + "node": ">=18.20 <19 || >=20.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", + "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.0.0.tgz", + "integrity": "sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA==", + "license": "MIT", + "dependencies": { + "slice-ansi": "^9.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/code-excerpt": { + "version": "4.0.0", "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", "license": "MIT", @@ -1280,6 +1812,13 @@ "node": ">=20" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-to-spaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", @@ -1392,6 +1931,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-toolkit": { "version": "1.45.1", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", @@ -1453,6 +1999,16 @@ "node": ">=8" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -1476,6 +2032,34 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -1751,60 +2335,343 @@ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/iterm2-version": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/iterm2-version/-/iterm2-version-6.0.0.tgz", + "integrity": "sha512-oH14QLmk+49KBsxlVBRe9piHHIY0vn41Mmop/LTzkCMVyGcikhMLJCpbR4LWB+WRdDF1v66u39CuRj+LLfZ9QQ==", + "license": "MIT", + "dependencies": { + "app-path": "^4.0.0", + "plist": "^3.1.0", + "terminal-query": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "license": "MIT", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/is-wsl": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=16" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/iterm2-version": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/iterm2-version/-/iterm2-version-6.0.0.tgz", - "integrity": "sha512-oH14QLmk+49KBsxlVBRe9piHHIY0vn41Mmop/LTzkCMVyGcikhMLJCpbR4LWB+WRdDF1v66u39CuRj+LLfZ9QQ==", + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, "license": "MIT", "dependencies": { - "app-path": "^4.0.0", - "plist": "^3.1.0", - "terminal-query": "^0.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/merge-stream": { @@ -1822,6 +2689,25 @@ "node": ">=6" } }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -1872,6 +2758,20 @@ "node": ">=8" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/onetime": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", @@ -1925,6 +2825,33 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", @@ -1939,6 +2866,35 @@ "node": ">=10.4.0" } }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/powershell-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", @@ -2001,6 +2957,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/rolldown": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", + "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.142.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.1", + "@rolldown/binding-darwin-arm64": "1.2.1", + "@rolldown/binding-darwin-x64": "1.2.1", + "@rolldown/binding-freebsd-x64": "1.2.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", + "@rolldown/binding-linux-arm64-gnu": "1.2.1", + "@rolldown/binding-linux-arm64-musl": "1.2.1", + "@rolldown/binding-linux-ppc64-gnu": "1.2.1", + "@rolldown/binding-linux-s390x-gnu": "1.2.1", + "@rolldown/binding-linux-x64-gnu": "1.2.1", + "@rolldown/binding-linux-x64-musl": "1.2.1", + "@rolldown/binding-openharmony-arm64": "1.2.1", + "@rolldown/binding-wasm32-wasi": "1.2.1", + "@rolldown/binding-win32-arm64-msvc": "1.2.1", + "@rolldown/binding-win32-x64-msvc": "1.2.1" + } + }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -2096,6 +3086,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -2124,6 +3121,16 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -2136,6 +3143,20 @@ "node": ">=10" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", @@ -2224,6 +3245,50 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2287,6 +3352,174 @@ "dev": true, "license": "MIT" }, + "node_modules/vite": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -2311,6 +3544,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/widest-line": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-6.0.0.tgz", diff --git a/package.json b/package.json index cf99c32..8882ae0 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,12 @@ "build": "tsc", "start": "node dist/cli.js", "dev": "tsx src/cli.ts", - "test": "echo \"Error: no test specified\" && exit 1", + "test": "vitest run", "typecheck": "tsc --noEmit" }, + "engines": { + "node": ">=24" + }, "keywords": [], "author": "", "license": "MIT", @@ -26,7 +29,8 @@ "@types/node": "25.6.0", "@types/react": "19.2.14", "tsx": "4.21.0", - "typescript": "6.0.3" + "typescript": "6.0.3", + "vitest": "4.1.10" }, "dependencies": { "@kud/pcloud": "0.3.0", diff --git a/src/cli.ts b/src/cli.ts index 15be027..5119c50 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node import { readFileSync, writeFileSync, existsSync } from "fs" +import { homedir } from "os" import { basename } from "path" import readline from "readline" import { Writable } from "stream" @@ -21,6 +22,20 @@ import { renderAccount, renderChanges, renderFileList } from "./render.js" import { planRewind, applyRewind } from "./rewind.js" import { pathResolver } from "./lib/paths.js" import { checkAll } from "./lib/health.js" +import { + PCLOUD_DB, + applyPrune, + daemonRunning, + databaseLocked, + planPrune, + readPairs, + snapshot, + strandedTasks, + tableCounts, + unlistedTables, + verdicts, + type SyncPair, +} from "./lib/sync.js" dotenv.config({ quiet: true }) @@ -801,7 +816,10 @@ program .command("restore-trash") .description("Restore a file or folder from trash by ID") .argument("", "File ID, or folder ID with --folder") - .option("--folder", "Force folder handling (detected automatically for trash-root items)") + .option( + "--folder", + "Force folder handling (detected automatically for trash-root items)", + ) .option( "--to ", "Restore into this folder instead of the original location", @@ -1053,6 +1071,268 @@ const HEALTH_GLYPH: Record = { missing: "🔥", } +const shortenHome = (path: string): string => + path.startsWith(homedir()) ? `~${path.slice(homedir().length)}` : path + +const CRITICAL = new Set(["orphaned", "remote-missing", "stuck"]) + +const pairGlyph = (pair: SyncPair): string => + pair.issues.length === 0 + ? "✓" + : pair.issues.some((issue) => CRITICAL.has(issue.kind)) + ? "🔥" + : "🌶️" + +// A tick occupies one terminal cell where the emoji glyphs occupy two, so it is +// padded to match. Without this every healthy row's columns sit one cell to the +// left of the unhealthy ones, which is worst exactly when a table is being +// scanned for the odd row out. +const glyphCell = (glyph: string): string => + glyph === "✓" ? `${glyph} ` : glyph + +const REMOTE_NONE = "— orphaned" + +const renderPairs = (pairs: SyncPair[]): void => { + const localCol = + Math.max(5, ...pairs.map((p) => shortenHome(p.localpath).length)) + 2 + const remoteCol = + Math.max(6, ...pairs.map((p) => (p.remotepath ?? REMOTE_NONE).length)) + 2 + + console.log( + ` ${padEnd("Local", localCol)}${padEnd("Remote", remoteCol)}${padEnd("Files", 8)}Queue`, + ) + + pairs.forEach((pair) => + console.log( + `${glyphCell(pairGlyph(pair))} ${padEnd(shortenHome(pair.localpath), localCol)}${padEnd(pair.remotepath ?? REMOTE_NONE, remoteCol)}${padEnd(String(pair.files || "–"), 8)}${pair.queued || "–"}`, + ), + ) + + const unhealthy = pairs.filter((pair) => pair.issues.length > 0) + if (unhealthy.length === 0) { + console.log("\nAll sync pairs healthy.\n") + return + } + + console.log() + unhealthy.forEach((pair) => { + console.log( + `${pairGlyph(pair)} #${pair.id} ${shortenHome(pair.localpath)}`, + ) + pair.issues.forEach((issue) => console.log(` ${issue.detail}`)) + if (pair.issues.some((issue) => issue.kind === "orphaned")) + console.log(` → pcloud sync prune ${pair.id}`) + console.log() + }) +} + +const renderPairDetail = ( + db: ReturnType["db"], + pair: SyncPair, +): void => { + console.log(`\nSync pair #${pair.id}\n`) + console.log(` Local ${shortenHome(pair.localpath)}`) + console.log(` Remote ${pair.remotepath ?? REMOTE_NONE}`) + console.log(` Folder id ${pair.folderid ?? "— none"}`) + console.log(` Indexed ${pair.folders} folders, ${pair.files} files`) + console.log(` Queue ${pair.queued} task(s)`) + + const stranded = strandedTasks(db, pair.id) + if (stranded.length) { + console.log("\n Queued with no destination:") + stranded.forEach((task) => + console.log(` ${task.name ?? "?"} (type ${task.type})`), + ) + } + + if (pair.issues.length === 0) { + console.log("\n Healthy.\n") + return + } + + console.log() + pair.issues.forEach((issue) => + console.log(` ${issue.kind.padEnd(16)}${issue.detail}`), + ) + console.log() +} + +const renderDebug = (snap: ReturnType): void => { + console.log( + "\npCloud Drive " + (daemonRunning() ? "running" : "not running"), + ) + console.log(`Database ${shortenHome(snap.source)}`) + console.log( + ` ${formatBytes(snap.bytes)} · WAL ${snap.hadWal ? "present (snapshot replayed it)" : "checkpointed"}`, + ) + + const counts = tableCounts(snap.db) + const width = Math.max(...counts.map((row) => row.table.length)) + 2 + console.log( + "\nTable counts (allowlisted — credential and crypto tables are never read):\n", + ) + counts.forEach((row) => + console.log(` ${padEnd(row.table, width)}${row.rows}`), + ) + + const unlisted = unlistedTables(snap.db) + if (unlisted.length) + console.log(`\nNot shown (${unlisted.length}): ${unlisted.join(", ")}`) + console.log() +} + +const reportLocalDaemon = (dbPath: string): void => { + console.log("\nLocal daemon") + + if (!existsSync(dbPath)) { + console.log(" no database found — pCloud Drive is not installed here\n") + return + } + + const snap = snapshot(dbPath) + try { + const pairs = readPairs(snap.db) + const problems = verdicts(pairs) + + console.log( + ` ${pairs.length} sync pair(s) · pCloud Drive ${daemonRunning() ? "running" : "not running"}`, + ) + if (problems.length === 0) { + console.log("✓ no local sync faults\n") + return + } + + problems.forEach((verdict) => + console.log( + `${CRITICAL.has(verdict.kind) ? "🔥" : "🌶️"} ${verdict.count} ${verdict.detail}`, + ), + ) + console.log("\n Detail: pcloud sync\n") + } finally { + snap.close() + } +} + +const sync = program + .command("sync") + .description("Inspect the local pCloud Drive sync daemon") + +sync + .command("status", { isDefault: true }) + .description("Show local sync pairs and their health") + .argument("[id]", "Show detail for a single sync pair") + .option("--json", "Output raw JSON") + .option("--debug", "Show daemon state, table counts and schema anomalies") + .option("--db ", "Read a different database file", PCLOUD_DB) + .action((id: string | undefined, options) => { + try { + const snap = snapshot(options.db) + try { + if (options.debug && !id) { + renderDebug(snap) + return + } + + const pairs = readPairs(snap.db) + + if (options.json) { + console.log( + JSON.stringify( + id + ? (pairs.find((pair) => pair.id === parseInt(id, 10)) ?? null) + : pairs, + null, + 2, + ), + ) + return + } + + if (id) { + const pair = pairs.find((p) => p.id === parseInt(id, 10)) + if (!pair) { + console.error(`Error: no sync pair with id ${id}`) + process.exit(1) + } + renderPairDetail(snap.db, pair) + return + } + + console.log( + `\npCloud Drive ${daemonRunning() ? "running" : "not running"}`, + ) + console.log( + `Database ${shortenHome(snap.source)} · ${formatBytes(snap.bytes)}\n`, + ) + renderPairs(pairs) + } finally { + snap.close() + } + } catch (error) { + handleError(error) + } + }) + +sync + .command("prune") + .description("Remove an orphaned sync pair and its local index") + .argument("", "Sync pair id (from `pcloud sync`)") + .option("--apply", "Perform the deletion (default is a dry run)") + .option("--db ", "Operate on a different database file", PCLOUD_DB) + .action((id: string, options) => { + try { + const syncid = parseInt(id, 10) + const snap = snapshot(options.db) + const plan = (() => { + try { + return planPrune(snap.db, syncid) + } finally { + snap.close() + } + })() + + console.log(`\nSync pair #${syncid}`) + console.log(` Local ${shortenHome(plan.pair.localpath)}`) + console.log(` Remote ${plan.pair.remotepath ?? REMOTE_NONE}\n`) + + if (plan.pair.issues.length === 0) { + console.log( + "This pair is healthy — pruning it would unlink a working sync.", + ) + console.log("Remove it from pCloud Drive's preferences instead.\n") + process.exit(1) + } + + console.log("Rows to delete:") + Object.entries(plan.counts).forEach(([table, n]) => + console.log(` ${padEnd(table, 16)}${n}`), + ) + console.log(` ${padEnd("total", 16)}${plan.total}\n`) + + if (!options.apply) { + console.log("This was a dry run. Re-run with --apply to perform it.\n") + return + } + + // The daemon keeps its own picture of the sync set in memory and writes it + // back, so deleting underneath a running pCloud Drive either loses the edit + // or corrupts the WAL. Both checks are kept: the process may have exited + // while still holding the file, and the file may be held by something else. + if (daemonRunning() || databaseLocked(options.db)) { + console.error("Error: pCloud Drive is running and holds this database.") + console.error("Quit pCloud Drive, then run this again.\n") + process.exit(1) + } + + const { backup, removed } = applyPrune(options.db, syncid) + console.log(`✓ Removed ${removed} rows for sync pair #${syncid}`) + console.log(` Backup: ${shortenHome(backup)}`) + console.log(" Start pCloud Drive again to confirm the error is gone.\n") + } catch (error) { + handleError(error) + } + }) + program .command("download") .description("Download a file to the local filesystem") @@ -1124,9 +1404,10 @@ program program .command("doctor") .description( - "Check which commands your current credential can actually reach", + "Check which commands your credential can reach, and the local sync daemon", ) - .action(async () => { + .option("--db ", "Read a different local database", PCLOUD_DB) + .action(async (options) => { try { const stored = tokenStore.load() const envAuth = process.env.PCLOUD_AUTH @@ -1139,8 +1420,12 @@ program ? { access_token: accessToken } : null + // The local half of doctor needs no credential, and a broken sync pair is + // exactly the kind of fault someone hits before getting round to logging + // in — so it still runs, and only the remote half is given up on. if (!auth) { console.error("Not authenticated. Run `pcloud login` first.") + reportLocalDaemon(options.db) process.exit(1) } @@ -1179,7 +1464,10 @@ program `${missing.length} command(s) call endpoints pCloud does not expose.\nFor Rewind, use \`pcloud rewind\` instead.\n`, ) } - if (!blocked.length && !missing.length) console.log("\nAll reachable.\n") + if (!blocked.length && !missing.length) + console.log("\nAll endpoints reachable.") + + reportLocalDaemon(options.db) } catch (error) { handleError(error) } diff --git a/src/lib/sync.test.ts b/src/lib/sync.test.ts new file mode 100644 index 0000000..330963e --- /dev/null +++ b/src/lib/sync.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from "vitest" +import { DatabaseSync } from "node:sqlite" +import { existsSync, mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { + DEBUG_TABLES, + OPEN, + applyPrune, + backupPath, + planPrune, + readPairs, + tableCounts, + unlistedTables, + verdicts, +} from "./sync.js" + +// The database pCloud ships is a moving target — snapshotting a live one gives a +// torn WAL whose recovered contents differ between opens. These fixtures build +// only the columns the checks read, so a failure names a real regression rather +// than a shifted snapshot. +const SCHEMA = ` + CREATE TABLE folder (id INTEGER PRIMARY KEY, parentfolderid INTEGER, name TEXT); + CREATE TABLE syncfolder (id INTEGER PRIMARY KEY, folderid INTEGER REFERENCES folder(id) ON DELETE SET NULL, localpath TEXT); + CREATE TABLE syncedfolder (syncid INTEGER, folderid INTEGER, localfolderid INTEGER); + CREATE TABLE localfolder (id INTEGER PRIMARY KEY, syncid INTEGER); + CREATE TABLE localfile (id INTEGER PRIMARY KEY, syncid INTEGER); + CREATE TABLE task (id INTEGER PRIMARY KEY, type INTEGER, syncid INTEGER, itemid INTEGER, localitemid INTEGER, name TEXT); + CREATE TABLE setting (id TEXT PRIMARY KEY, value TEXT); + CREATE TABLE cryptofilekey (fileid INTEGER PRIMARY KEY, key TEXT); + CREATE TABLE cryptofolderkey (folderid INTEGER PRIMARY KEY, key TEXT); +` + +type Fixture = { db: DatabaseSync; path: string; dispose: () => void } + +const fixture = (): Fixture => { + const dir = mkdtempSync(join(tmpdir(), "pcloud-cli-test-")) + // Only pairs 1 and 2 share a local path, so a prune of 2 is expected to clear + // the duplicate flag outright rather than leave other pairs still colliding. + const solo = mkdtempSync(join(tmpdir(), "pcloud-cli-solo-")) + const spare = mkdtempSync(join(tmpdir(), "pcloud-cli-spare-")) + const path = join(dir, "data.db") + const db = new DatabaseSync(path, OPEN) + db.exec(SCHEMA) + + db.exec(` + INSERT INTO folder (id, parentfolderid, name) VALUES + (100, 0, 'Appdata'), + (200, 0, 'Docs'), + (201, 200, 'Invoices'); + + INSERT INTO syncfolder (id, folderid, localpath) VALUES + (1, 100, '${dir}'), + (2, NULL, '${dir}'), + (3, 200, '${solo}'), + (4, 999, '${spare}'), + (5, 201, '/nonexistent/path/for/test'); + + INSERT INTO localfolder (id, syncid) VALUES (1, 2), (2, 2), (3, 1); + INSERT INTO localfile (id, syncid) VALUES (1, 2), (2, 2), (3, 2), (4, 1); + INSERT INTO syncedfolder (syncid, folderid, localfolderid) VALUES (2, NULL, 1), (2, NULL, 2); + INSERT INTO task (id, type, syncid, itemid, localitemid, name) VALUES + (10, 1, 2, 0, 1868, 'usage'), + (11, 3, 2, 0, 7182, 'usage.tsv'), + (12, 1, 1, 4155, 900, 'fine'); + + INSERT INTO setting (id, value) VALUES ('auth', 'SECRET-TOKEN-DO-NOT-PRINT'); + INSERT INTO cryptofilekey (fileid, key) VALUES (1, 'SECRET-KEY-MATERIAL'); + `) + + return { + db, + path, + dispose: () => { + db.close() + ;[dir, solo, spare].forEach((target) => + rmSync(target, { recursive: true, force: true }), + ) + }, + } +} + +const kindsFor = (db: DatabaseSync, id: number): string[] => + (readPairs(db).find((pair) => pair.id === id)?.issues ?? []).map( + (issue) => issue.kind, + ) + +describe("readPairs", () => { + it("flags a sync pair whose remote folder was set to NULL", () => { + const f = fixture() + expect(kindsFor(f.db, 2)).toContain("orphaned") + f.dispose() + }) + + it("flags every pair sharing a local path, not just the broken one", () => { + const f = fixture() + expect(kindsFor(f.db, 1)).toContain("duplicate") + expect(kindsFor(f.db, 2)).toContain("duplicate") + f.dispose() + }) + + it("flags a folderid that is no longer in the folder index", () => { + const f = fixture() + expect(kindsFor(f.db, 4)).toContain("remote-missing") + f.dispose() + }) + + it("flags a local path that no longer exists on disk", () => { + const f = fixture() + expect(kindsFor(f.db, 5)).toContain("local-missing") + f.dispose() + }) + + it("flags queued tasks that have no destination folder", () => { + const f = fixture() + expect(kindsFor(f.db, 2)).toContain("stuck") + expect(kindsFor(f.db, 1)).not.toContain("stuck") + f.dispose() + }) + + it("resolves a nested remote path by walking to the root", () => { + const f = fixture() + const pair = readPairs(f.db).find((candidate) => candidate.id === 5) + expect(pair?.remotepath).toBe("/Docs/Invoices") + f.dispose() + }) + + it("reports no remote path for an orphan rather than inventing one", () => { + const f = fixture() + const pair = readPairs(f.db).find((candidate) => candidate.id === 2) + expect(pair?.remotepath).toBeNull() + f.dispose() + }) + + it("counts indexed folders, files and queued tasks per pair", () => { + const f = fixture() + const pair = readPairs(f.db).find((candidate) => candidate.id === 2) + expect(pair).toMatchObject({ folders: 2, files: 3, queued: 2 }) + f.dispose() + }) +}) + +describe("verdicts", () => { + it("counts affected pairs per issue kind and drops the clean ones", () => { + const f = fixture() + const summary = Object.fromEntries( + verdicts(readPairs(f.db)).map((verdict) => [verdict.kind, verdict.count]), + ) + expect(summary).toMatchObject({ + orphaned: 1, + duplicate: 2, + "remote-missing": 1, + "local-missing": 1, + stuck: 1, + }) + f.dispose() + }) +}) + +describe("planPrune", () => { + it("counts every row the prune would remove", () => { + const f = fixture() + const plan = planPrune(f.db, 2) + expect(plan.counts).toEqual({ + task: 2, + syncedfolder: 2, + localfile: 3, + localfolder: 2, + syncfolder: 1, + }) + expect(plan.total).toBe(10) + f.dispose() + }) + + it("refuses an id that is not a sync pair", () => { + const f = fixture() + expect(() => planPrune(f.db, 99)).toThrow(/No sync pair/) + f.dispose() + }) +}) + +describe("applyPrune", () => { + it("removes the pair and leaves the others untouched", () => { + const f = fixture() + f.db.close() + + const { removed, backup } = applyPrune(f.path, 2, new Date(0)) + expect(removed).toBe(10) + expect(existsSync(backup)).toBe(true) + + const after = new DatabaseSync(f.path, OPEN) + expect(readPairs(after).map((pair) => pair.id)).toEqual([1, 3, 4, 5]) + expect( + after.prepare("SELECT COUNT(*) AS n FROM localfile").get(), + ).toMatchObject({ n: 1 }) + expect(after.prepare("SELECT COUNT(*) AS n FROM task").get()).toMatchObject( + { + n: 1, + }, + ) + after.close() + + rmSync(f.path, { force: true }) + rmSync(backup, { force: true }) + }) + + it("clears the duplicate flag on the pair that survives", () => { + const f = fixture() + f.db.close() + + const { backup } = applyPrune(f.path, 2, new Date(0)) + const after = new DatabaseSync(f.path, OPEN) + expect(kindsFor(after, 1)).not.toContain("duplicate") + after.close() + + rmSync(f.path, { force: true }) + rmSync(backup, { force: true }) + }) +}) + +describe("backupPath", () => { + it("builds a colon-free name so the path is safe on every filesystem", () => { + expect(backupPath("/tmp/data.db", new Date("2026-07-31T14:05:09Z"))).toBe( + "/tmp/data.db.backup-2026-07-31T14-05-09", + ) + }) +}) + +describe("debug allowlist", () => { + it("never lists a table holding credentials or crypto keys", () => { + const forbidden = ["setting", "cryptofilekey", "cryptofolderkey"] + forbidden.forEach((table) => + expect(DEBUG_TABLES as readonly string[]).not.toContain(table), + ) + }) + + it("reports counts only for allowlisted tables present in the database", () => { + const f = fixture() + const shown = tableCounts(f.db).map((row) => row.table) + expect(shown).toContain("syncfolder") + expect(shown).not.toContain("setting") + expect(shown).not.toContain("cryptofilekey") + f.dispose() + }) + + it("names withheld tables without reading them", () => { + const f = fixture() + expect(unlistedTables(f.db)).toEqual([ + "cryptofilekey", + "cryptofolderkey", + "setting", + ]) + f.dispose() + }) +}) diff --git a/src/lib/sync.ts b/src/lib/sync.ts new file mode 100644 index 0000000..b59071f --- /dev/null +++ b/src/lib/sync.ts @@ -0,0 +1,380 @@ +import { DatabaseSync } from "node:sqlite" +import { execFileSync } from "node:child_process" +import { + copyFileSync, + existsSync, + mkdtempSync, + rmSync, + statSync, +} from "node:fs" +import { homedir, tmpdir } from "node:os" +import { join } from "node:path" + +export const PCLOUD_DB = join(homedir(), ".pcloud", "data.db") + +const WAL_SUFFIXES = ["-wal", "-shm"] as const + +// node:sqlite turns foreign keys on by default where pCloud's own writer leaves +// them off, and this database is full of references its writer tolerates — a +// dangling syncfolder.folderid is the very fault these checks exist to find. +// Enforcing constraints we did not author would reject the broken rows we came +// to read, so the connection matches the writer rather than the language default. +export const OPEN = { enableForeignKeyConstraints: false } as const + +// pCloud Drive holds the database under an exclusive WAL lock for as long as it +// runs, so opening it in place fails outright ("database is locked") rather than +// degrading to a stale read. Copying the WAL set and opening the copy is the only +// way to read a consistent snapshot without stopping the daemon. The -wal and +// -shm files are absent after a clean checkpoint, which is a valid state and not +// something to report as a broken database. +export type Snapshot = { + db: DatabaseSync + source: string + bytes: number + hadWal: boolean + close: () => void +} + +export const snapshot = (dbPath: string = PCLOUD_DB): Snapshot => { + if (!existsSync(dbPath)) { + throw new Error( + `No pCloud database at ${dbPath}. Is pCloud Drive installed?`, + ) + } + + const dir = mkdtempSync(join(tmpdir(), "pcloud-cli-")) + const copy = join(dir, "data.db") + copyFileSync(dbPath, copy) + + const hadWal = WAL_SUFFIXES.reduce((seen, suffix) => { + if (!existsSync(dbPath + suffix)) return seen + copyFileSync(dbPath + suffix, copy + suffix) + return true + }, false) + + // Opened writable on purpose: SQLite replays the copied -wal into the copy on + // first access, which is what makes the snapshot reflect committed state. A + // read-only handle would refuse that recovery and read the pre-WAL pages. + const db = new DatabaseSync(copy, OPEN) + + return { + db, + source: dbPath, + bytes: statSync(dbPath).size, + hadWal, + close: () => { + db.close() + rmSync(dir, { recursive: true, force: true }) + }, + } +} + +const DAEMON_BINARY = "pCloud Drive.app/Contents/MacOS/pCloud Drive" + +// pgrep exits 1 with no match, which execFileSync surfaces as a throw. Matching +// the binary path rather than the bare name keeps the Finder extension — a +// separate, transient process that does not hold the database — from reading as +// a running daemon. +export const daemonRunning = (): boolean => { + try { + execFileSync("pgrep", ["-f", DAEMON_BINARY], { stdio: "ignore" }) + return true + } catch { + return false + } +} + +export const databaseLocked = (dbPath: string = PCLOUD_DB): boolean => { + try { + execFileSync("lsof", ["--", dbPath], { stdio: "ignore" }) + return true + } catch { + return false + } +} + +export type IssueKind = + "orphaned" | "duplicate" | "local-missing" | "remote-missing" | "stuck" + +export type Issue = { kind: IssueKind; detail: string } + +export type SyncPair = { + id: number + folderid: number | null + localpath: string + remotepath: string | null + folders: number + files: number + queued: number + issues: Issue[] +} + +type Row = Record + +const rows = (db: DatabaseSync, sql: string, ...params: unknown[]): Row[] => + db.prepare(sql).all(...(params as never[])) as Row[] + +const num = (value: unknown): number => Number(value ?? 0) + +const countBySync = (db: DatabaseSync, table: string): Map => + new Map( + rows(db, `SELECT syncid, COUNT(*) AS n FROM ${table} GROUP BY syncid`) + .filter((row) => row.syncid !== null) + .map((row) => [num(row.syncid), num(row.n)]), + ) + +const MAX_DEPTH = 64 + +// Walking parentfolderid to the root is the only way to render a remote path: +// syncfolder stores an id, and the id alone tells the user nothing about which +// cloud folder a pair is bound to. A missing ancestor means the remote side was +// deleted out from under the sync, so the walk reports that rather than guessing. +const remotePath = (db: DatabaseSync, folderid: number): string | null => { + const parts: string[] = [] + let id = folderid + + for (let depth = 0; id !== 0 && depth < MAX_DEPTH; depth += 1) { + const row = db + .prepare("SELECT name, parentfolderid FROM folder WHERE id = ?") + .get(id) as Row | undefined + if (!row) return null + parts.unshift(String(row.name ?? "?")) + id = num(row.parentfolderid) + } + + return "/" + parts.join("/") +} + +export const readPairs = (db: DatabaseSync): SyncPair[] => { + const folders = countBySync(db, "localfolder") + const files = countBySync(db, "localfile") + const queued = countBySync(db, "task") + + const raw = rows( + db, + "SELECT id, folderid, localpath FROM syncfolder ORDER BY id", + ) + + const localpathCounts = raw.reduce((counts, row) => { + const path = String(row.localpath ?? "") + return counts.set(path, (counts.get(path) ?? 0) + 1) + }, new Map()) + + return raw.map((row) => { + const id = num(row.id) + const folderid = row.folderid === null ? null : num(row.folderid) + const localpath = String(row.localpath ?? "") + const resolved = folderid === null ? null : remotePath(db, folderid) + + // itemid 0 is a queued operation with no destination folder to act on — the + // shape every task takes once its sync pair has lost its remote side. + const stranded = num( + ( + db + .prepare( + "SELECT COUNT(*) AS n FROM task WHERE syncid = ? AND itemid = 0", + ) + .get(id) as Row + ).n, + ) + + const issues: Issue[] = [] + + if (folderid === null) { + issues.push({ + kind: "orphaned", + detail: "no remote folder — pCloud shows this pair as “/”", + }) + } else if (resolved === null) { + issues.push({ + kind: "remote-missing", + detail: `remote folder ${folderid} is no longer in the index`, + }) + } + + if ((localpathCounts.get(localpath) ?? 0) > 1) { + issues.push({ + kind: "duplicate", + detail: "a second sync pair claims the same local folder", + }) + } + + if (localpath && !existsSync(localpath)) { + issues.push({ kind: "local-missing", detail: "local folder is gone" }) + } + + if (stranded > 0) { + issues.push({ + kind: "stuck", + detail: `${stranded} queued operation(s) with no destination`, + }) + } + + return { + id, + folderid, + localpath, + remotepath: resolved, + folders: folders.get(id) ?? 0, + files: files.get(id) ?? 0, + queued: queued.get(id) ?? 0, + issues, + } + }) +} + +export const strandedTasks = (db: DatabaseSync, syncid: number): Row[] => + rows( + db, + "SELECT id, type, name, localitemid, inprogress FROM task WHERE syncid = ? AND itemid = 0", + syncid, + ) + +// Deleted child-first so no statement ever leaves a row pointing at a parent that +// has already gone. syncfolder is last because every other table keys off its id. +export const PRUNE_TABLES = [ + "task", + "syncedfolder", + "localfile", + "localfolder", +] as const + +export type PrunePlan = { + pair: SyncPair + counts: Record + total: number +} + +export const planPrune = (db: DatabaseSync, syncid: number): PrunePlan => { + const pair = readPairs(db).find((candidate) => candidate.id === syncid) + if (!pair) throw new Error(`No sync pair with id ${syncid}`) + + const counts = PRUNE_TABLES.reduce>((acc, table) => { + acc[table] = num( + ( + db + .prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE syncid = ?`) + .get(syncid) as Row + ).n, + ) + return acc + }, {}) + counts.syncfolder = 1 + + return { + pair, + counts, + total: Object.values(counts).reduce((sum, n) => sum + n, 0), + } +} + +export const backupPath = (dbPath: string, stamp: Date): string => + `${dbPath}.backup-${stamp.toISOString().slice(0, 19).replace(/:/g, "-")}` + +export const applyPrune = ( + dbPath: string, + syncid: number, + stamp: Date = new Date(), +): { backup: string; removed: number } => { + const backup = backupPath(dbPath, stamp) + copyFileSync(dbPath, backup) + + const db = new DatabaseSync(dbPath, OPEN) + try { + const plan = planPrune(db, syncid) + db.exec("BEGIN") + try { + PRUNE_TABLES.forEach((table) => + db.prepare(`DELETE FROM ${table} WHERE syncid = ?`).run(syncid), + ) + db.prepare("DELETE FROM syncfolder WHERE id = ?").run(syncid) + db.exec("COMMIT") + } catch (error) { + db.exec("ROLLBACK") + throw error + } + return { backup, removed: plan.total } + } finally { + db.close() + } +} + +// An allowlist rather than an exclusion list: the same database holds `setting`, +// `cryptofilekey` and `cryptofolderkey`, which carry the account's auth token and +// crypto key material. A deny-list would leak the first sensitive table pCloud +// adds in a future release; naming what may be shown cannot. +export const DEBUG_TABLES = [ + "syncfolder", + "syncedfolder", + "syncfolderdelayed", + "localfolder", + "localfile", + "localfileupload", + "task", + "fstask", + "fstaskupload", + "fstaskdepend", + "fstaskfileid", + "upload_tasks", + "uptask_fileupload", + "folder", + "file", + "filerevision", + "sharedfolder", + "bsharedfolder", + "sharerequest", + "links", + "devices", + "pagecache", + "pagecachetask", + "resolver", + "hashchecksum", + "contacts", + "myteams", +] as const + +export type TableCount = { table: string; rows: number | null } + +export const tableCounts = (db: DatabaseSync): TableCount[] => { + const present = new Set( + rows(db, "SELECT name FROM sqlite_master WHERE type = 'table'").map((row) => + String(row.name), + ), + ) + + return DEBUG_TABLES.filter((table) => present.has(table)).map((table) => ({ + table, + rows: num( + (db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as Row).n, + ), + })) +} + +export const unlistedTables = (db: DatabaseSync): string[] => { + const known = new Set(DEBUG_TABLES) + return rows(db, "SELECT name FROM sqlite_master WHERE type = 'table'") + .map((row) => String(row.name)) + .filter((name) => !known.has(name) && !name.startsWith("sqlite_")) + .sort() +} + +export type SyncVerdict = { kind: IssueKind; count: number; detail: string } + +const VERDICT_LABEL: Record = { + orphaned: "orphaned sync pair(s) — local folder with no remote target", + duplicate: "duplicate local path(s) across sync pairs", + "local-missing": "sync pair(s) whose local folder no longer exists", + "remote-missing": "sync pair(s) whose remote folder left the index", + stuck: "sync pair(s) with queued operations that cannot complete", +} + +export const verdicts = (pairs: SyncPair[]): SyncVerdict[] => + (Object.keys(VERDICT_LABEL) as IssueKind[]) + .map((kind) => ({ + kind, + count: pairs.filter((pair) => + pair.issues.some((issue) => issue.kind === kind), + ).length, + detail: VERDICT_LABEL[kind], + })) + .filter((verdict) => verdict.count > 0) From fcbf041c421f14eb92007c296d45e962fe05550b Mon Sep 17 00:00:00 2001 From: Erwann Mest Date: Fri, 31 Jul 2026 14:18:46 +0100 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A4=96=20ci(actions):=20run=20test=20?= =?UTF-8?q?suite=20in=20CI=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `npm test` step to `.github/workflows/ci.yml`, between typecheck and build - Ensures test failures block CI before a build is attempted, closing a gap where only typecheck and build were gated --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cb0396..4a6d714 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,4 +16,5 @@ jobs: cache: npm - run: npm ci - run: npm run typecheck + - run: npm test - run: npm run build