diff --git a/README.md b/README.md index b1f9151..d962175 100644 --- a/README.md +++ b/README.md @@ -98,10 +98,17 @@ NEXT_PUBLIC_GITHUB_CLIENT_ID=Ov23li... # real-time collaboration. Leave unset to use local-only persistence (the # default; the previous public demo server was removed for security). NEXT_PUBLIC_YJS_WS_URL=wss://your-server.example.com + +# Optional. Allow-lists ONE self-hosted Forgejo/Gitea instance in the +# Content-Security-Policy so the browser may call its API for vault sync. +# Codeberg (https://codeberg.org) is built in and needs no entry here. +NEXT_PUBLIC_FORGEJO_BASE_URL=https://your-forgejo.example.com ``` `.env.local` is gitignored. For your hosting platform, add the same keys to the project's environment-variable settings. +Using Codeberg (or a self-hosted Forgejo/Gitea) instead of GitHub as the vault host? See **[docs/codeberg.md](docs/codeberg.md)** — a Codeberg-only setup needs no GitHub OAuth app at all. + ### Setting up the GitHub OAuth app 1. https://github.com/settings/developers → **New OAuth App** diff --git a/collab-server/package-lock.json b/collab-server/package-lock.json index cd204be..64686d3 100644 --- a/collab-server/package-lock.json +++ b/collab-server/package-lock.json @@ -637,9 +637,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", "cpu": [ "arm64" ], @@ -650,19 +650,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.2" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", "cpu": [ "x64" ], @@ -673,19 +673,39 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", "cpu": [ "arm64" ], @@ -700,9 +720,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", "cpu": [ "x64" ], @@ -717,9 +737,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", "cpu": [ "arm" ], @@ -737,9 +757,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", "cpu": [ "arm64" ], @@ -757,9 +777,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", "cpu": [ "ppc64" ], @@ -777,9 +797,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", "cpu": [ "riscv64" ], @@ -797,9 +817,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", "cpu": [ "s390x" ], @@ -817,9 +837,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", "cpu": [ "x64" ], @@ -837,9 +857,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", "cpu": [ "arm64" ], @@ -857,9 +877,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", "cpu": [ "x64" ], @@ -877,9 +897,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", "cpu": [ "arm" ], @@ -893,19 +913,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.2" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", "cpu": [ "arm64" ], @@ -919,19 +939,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.2" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", "cpu": [ "ppc64" ], @@ -945,19 +965,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.2" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", "cpu": [ "riscv64" ], @@ -971,19 +991,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.2" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", "cpu": [ "s390x" ], @@ -997,19 +1017,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.2" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", "cpu": [ "x64" ], @@ -1023,19 +1043,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", "cpu": [ "arm64" ], @@ -1049,19 +1069,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", "cpu": [ "x64" ], @@ -1075,39 +1095,56 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", "cpu": [ "wasm32" ], "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@img/sharp-wasm32": "0.35.3" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", "cpu": [ "arm64" ], @@ -1118,16 +1155,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", "cpu": [ "ia32" ], @@ -1138,16 +1175,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", "cpu": [ "x64" ], @@ -1158,7 +1195,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -2707,48 +2744,53 @@ } }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "dev": true, - "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/siginfo": { diff --git a/collab-server/package.json b/collab-server/package.json index 50a78a4..b80b453 100644 --- a/collab-server/package.json +++ b/collab-server/package.json @@ -22,6 +22,7 @@ "wrangler": "^4.110.0" }, "overrides": { - "esbuild": "^0.28.1" + "esbuild": "^0.28.1", + "sharp": "^0.35.0" } } diff --git a/docs/codeberg.md b/docs/codeberg.md new file mode 100644 index 0000000..322df13 --- /dev/null +++ b/docs/codeberg.md @@ -0,0 +1,135 @@ +# Using noteser with Codeberg (or self-hosted Forgejo/Gitea) + +noteser can sync your vault to a Codeberg repository instead of GitHub. Codeberg +runs [Forgejo](https://forgejo.org/), so everything here applies equally to any +self-hosted Forgejo or Gitea instance — Codeberg is just the built-in preset. + +## What you need + +- A [Codeberg](https://codeberg.org) account. +- A running noteser instance (locally via `npm run dev`, or your own deployment). + For a **Codeberg-only** setup you do **not** need `NEXT_PUBLIC_GITHUB_CLIENT_ID` + — that env var only powers the GitHub OAuth device flow. + +## 1. Create an access token + +Codeberg → **Settings → Applications → +[Manage Access Tokens](https://codeberg.org/user/settings/applications)** → +_Generate New Token_ with these scopes: + +| Scope | Why | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `read:user` | The connect flow verifies the token and shows who is signed in (`GET /user`); the repo picker lists your repositories (`GET /user/repos`). | +| `repository` — **Read and Write** | Pull and push the vault contents. | + +Copy the token — Codeberg shows it only once. + +## 2. Connect the vault + +1. In noteser, open the sync panel (Source control icon in the sidebar) and + click **Connect** — the "Connect a vault" dialog opens with the host picker. +2. Pick **Codeberg**. +3. Paste your access token and press Enter (or _Connect with token_). +4. The repo picker opens with your Codeberg repositories: + - pick an existing repo to use as the vault, **or** + - _New repo_ creates one (auto-initialized with a README) and connects it. + +From here on, sync works exactly like the GitHub flow: **Commit & Sync** pulls +remote changes first (conflicts open as merge tabs), then pushes your local +notes as a single commit. Notes are plain `.md` files at the repo root, so the +repo stays readable in the Codeberg web UI and clones fine with plain git. + +## Migrating an existing Obsidian vault + +Have a local Obsidian vault already? Push it to Codeberg with plain git and let +noteser clone it — do **not** use the in-app local-folder import for this (it +only reads `.md` files; the git route also brings your attachments and keeps +the full folder structure). + +1. Turn the vault into a repo and push it: + + ```bash + cd /path/to/your/vault + + # Keep Obsidian's internals out of the shared repo. noteser honours this + # .gitignore on both pull and push. + cat > .gitignore <<'EOF' + .obsidian/ + .trash/ + .DS_Store + EOF + + git init + git add -A + git commit -m "Initial vault import" + # Create an empty PRIVATE repo on codeberg.org first (no README), then: + git remote add origin https://codeberg.org/YOU/my-vault.git + git push -u origin main + ``` + +2. Connect noteser to that repo (see [Connect the vault](#2-connect-the-vault) + above). With an empty noteser vault the first sync clones everything — + nested folders, notes, and attachments included. + +What to expect with Obsidian content: + +- **Frontmatter survives byte-identically** — notes round-trip without churn. +- **Attachments** (`![[image.png]]` embeds) are mapped to their stored paths + and render inline. +- **Foreign files** (`.canvas`, PDFs, anything non-markdown) show up in the + file tree but are never modified or deleted by noteser — the repo stays + fully usable from Obsidian. +- **Keep using Obsidian in parallel** if you like (e.g. via the Obsidian Git + plugin on the same repo). noteser picks up outside commits on the next sync; + concurrent edits to the same note open as three-way merge tabs. +- **Sharing with others:** add them as collaborators on the Codeberg repo; + each person connects with their **own** access token. + +## Self-hosted Forgejo / Gitea + +Two differences from the Codeberg preset: + +1. In the host picker choose **Forgejo / Gitea (self-hosted)** and enter your + server's base URL (e.g. `https://git.example.com`) alongside the token. + Create the token on _your_ instance under Settings → Applications, with the + same scopes as above. +2. **Allow-list your instance in the Content-Security-Policy.** The browser + talks to the Forgejo API directly, and noteser's strict CSP only permits + origins known at deploy time. Codeberg is built in; for your own instance + set: + + ```ini + NEXT_PUBLIC_FORGEJO_BASE_URL=https://git.example.com + ``` + + in `.env.local` (or your hosting platform's environment settings) and + restart/redeploy. Exactly one origin is allow-listed — never a wildcard — + so an XSS payload still cannot exfiltrate your token to arbitrary hosts. + +## Feature differences vs. GitHub + +The sync core (pull, three-way merge, push, first-clone fast path) is fully +supported on Forgejo hosts. A few extras use GitHub-exclusive APIs and are +hidden while a Forgejo vault is connected: + +- **Publish as gist** (GitHub Gist API) +- **View history** per note and **Revert vault to a commit** (GitHub commits API) + +Porting these to Forgejo's API is a possible future enhancement. + +## Troubleshooting + +| Symptom | Likely cause | +| -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| "That token is missing a scope …" when connecting | The token lacks `read:user`. Generate a new token with both scopes — Codeberg tokens can't be edited after creation. | +| "That token did not work …" on a self-hosted instance | Base URL wrong (must start with `http(s)://` and point at the instance root, not `/api/v1`), or the token lacks scopes. | +| Browser console shows _"violates the Content Security Policy"_ | Self-hosted instance not allow-listed — set `NEXT_PUBLIC_FORGEJO_BASE_URL` (see above) and redeploy. | +| Repo list is empty | The token's repository scope is read-less, or the account genuinely has no repos — create one via _New repo_. | + +## Security notes + +- The token is stored in the browser's `localStorage`, same trust model as the + GitHub token (and as the Obsidian Git plugin). Anyone with access to your + browser profile can read it — use a dedicated token, not your account password. +- Prefer a token scoped as narrowly as your Forgejo version allows; rotate it + if you ever paste it anywhere outside the connect dialog. diff --git a/package-lock.json b/package-lock.json index e94fa32..e037063 100644 --- a/package-lock.json +++ b/package-lock.json @@ -970,6 +970,7 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1217,9 +1218,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", "cpu": [ "arm64" ], @@ -1229,19 +1230,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.2" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", "cpu": [ "x64" ], @@ -1251,19 +1252,38 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", "cpu": [ "arm64" ], @@ -1277,9 +1297,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", "cpu": [ "x64" ], @@ -1293,9 +1313,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", "cpu": [ "arm" ], @@ -1312,9 +1332,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", "cpu": [ "arm64" ], @@ -1331,9 +1351,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", "cpu": [ "ppc64" ], @@ -1350,9 +1370,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", "cpu": [ "riscv64" ], @@ -1369,9 +1389,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", "cpu": [ "s390x" ], @@ -1388,9 +1408,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", "cpu": [ "x64" ], @@ -1407,9 +1427,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", "cpu": [ "arm64" ], @@ -1426,9 +1446,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", "cpu": [ "x64" ], @@ -1445,9 +1465,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", "cpu": [ "arm" ], @@ -1460,19 +1480,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.2" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", "cpu": [ "arm64" ], @@ -1485,19 +1505,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.2" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", "cpu": [ "ppc64" ], @@ -1510,19 +1530,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.2" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", "cpu": [ "riscv64" ], @@ -1535,19 +1555,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.2" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", "cpu": [ "s390x" ], @@ -1560,19 +1580,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.2" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", "cpu": [ "x64" ], @@ -1585,19 +1605,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", "cpu": [ "arm64" ], @@ -1610,19 +1630,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", "cpu": [ "x64" ], @@ -1635,38 +1655,64 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-wasm32/node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", "cpu": [ "wasm32" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@img/sharp-wasm32": "0.35.3" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", "cpu": [ "arm64" ], @@ -1676,16 +1722,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", "cpu": [ "ia32" ], @@ -1695,16 +1741,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", "cpu": [ "x64" ], @@ -1714,7 +1760,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -2934,9 +2980,9 @@ } }, "node_modules/@next/env": { - "version": "15.5.18", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.18.tgz", - "integrity": "sha512-hAV85Ckd9QR6RvH04MEKwsfLTksvFpO47j9xwtoIuvuPnlwecpSi+uZTtm8HirVbtlI2Fnz//xpcSTjFdyJk+g==", + "version": "15.5.21", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.21.tgz", + "integrity": "sha512-hjJI/GfrjWHgNguRIBzItjRRu0m3Nrz17GhxsjuHfjIvg9hyg3239REd2dpI+bpMTFuVrVprHzEQ19m++cDtbw==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -2950,9 +2996,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.5.18", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.18.tgz", - "integrity": "sha512-w0WvQf1n+txiwns/9pwIQteCJpZTbxzO2SE0FLcwuD4v0WEh1JPOjdyxWL21XwJsdpx8cFRjyzxzCS/siP7HcQ==", + "version": "15.5.21", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.21.tgz", + "integrity": "sha512-ZfjqPEdi6TRC/fWx7UDbwb1fbVgyh2uD5tVTRKIDZDlYM+UNuE/LafDG2fwuAoZilADpABh46OY/F5qf9JjqLQ==", "cpu": [ "arm64" ], @@ -2966,9 +3012,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.5.18", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.18.tgz", - "integrity": "sha512-znn71QmDuxm+BOaglihMZfvyySMnNljkVIY5Z2TCssBmm+WqL6c19VhtH5ktFkHa8EZ2bnTUpcNcmNSQsg67og==", + "version": "15.5.21", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.21.tgz", + "integrity": "sha512-TlCf1NpxgQLzTrexuev75xwmNCJMd1/qkJpTVP1GRRcih93hlIBn1P72hkh8T0gnRFr6BmWksQtbyG3jT6jnww==", "cpu": [ "x64" ], @@ -2982,9 +3028,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.5.18", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.18.tgz", - "integrity": "sha512-yPPe5MNL+igZUa+OsqQJisqSfh6oarIuA1Q0BDxljGJhRQyZeP+WRHh7rs/jZUGMh5aY0YdIjXZG0VohkKkUdw==", + "version": "15.5.21", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.21.tgz", + "integrity": "sha512-LXRsq1p+HvHSi7ygwNcSEEcK0zuo5jS75ZlqFHtOH+LF7qntXAJVJxah+1Pi/GyBm7EpkwU7m4EgbvIKrMqm9A==", "cpu": [ "arm64" ], @@ -3001,9 +3047,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.5.18", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.18.tgz", - "integrity": "sha512-glaCczEWIrHsokFZ3pP08U4BpKxwIdnT+txdOM32OBgpL9Yw4aqx8NejmgtZQZOdstQ5f0L3CasIZudzCuD+nw==", + "version": "15.5.21", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.21.tgz", + "integrity": "sha512-hyGixhFxpDKjqoev6l4KlcRBlt9AXWrGhDZwmwg49sMJM5tnKQPSi+SEj9+e5n+l/bthRGZUdh59GKIs6lQPRw==", "cpu": [ "arm64" ], @@ -3020,9 +3066,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.5.18", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.18.tgz", - "integrity": "sha512-oUfg2EgJmU3R0OCOWiokGFUTvZiPfXtriXiuF3YNxRoROCdgvTedHIzYoeKH34gsZxS/V7mHbfq2hpAHwhH1/A==", + "version": "15.5.21", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.21.tgz", + "integrity": "sha512-qfE+YfOba6S2+13e8qn1/UozDVNZ2clBlrs8UtDoax4s8ediu6sq93z66OEHUYlb69Tffh5JTNkgtsAKiSuugg==", "cpu": [ "x64" ], @@ -3039,9 +3085,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.5.18", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.18.tgz", - "integrity": "sha512-JLxSP3KTd9iu/bvUMQxH7RJo9xKSHf55/6RPE4a6FTSZygGn7uvZbCej0AHXydwkggQGSD9UddSjwv6Xz5ESfA==", + "version": "15.5.21", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.21.tgz", + "integrity": "sha512-BXLGG+EvIwp/Rrgl6HY8sqvD6BOUOIRz8/naDbeLNX7mlA5H2XRcL6MW/0IGnJISfj5BA9gNhFyJj5yOoiIDJQ==", "cpu": [ "x64" ], @@ -3058,9 +3104,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.5.18", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.18.tgz", - "integrity": "sha512-ir1v7enP52K2HNz3tQQvwF+x7VNxBk1ciiZ18WBPvxf4C59IqdfmHPJYK3vH7rSxpuCVw/8C712wTXNAtEp+NA==", + "version": "15.5.21", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.21.tgz", + "integrity": "sha512-tNGNOlT0Wn7E4IMsSnufjXN/l2L2/AGdLLpa2vzS89SYCBuihgLn3ngLsIrvndAnWo9nAkus+4gZHTI/Ijx9HA==", "cpu": [ "arm64" ], @@ -3074,9 +3120,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.5.18", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.18.tgz", - "integrity": "sha512-LIu5me6QTANCd25E7I5uIEfvgQ06RK7tvHAbYo3zCb3VpxQEPvMcSpd87NwUABDT6MbGPdEGR5VRiK4PPTJhQg==", + "version": "15.5.21", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.21.tgz", + "integrity": "sha512-DmIdWmC9p4rdNIiQqo8ap0+Cnj6kKtTZnuSCxoYydSc8sgpDgAg9wFhxplunak9imLV0pTvc5WVCOHwm5eHLtQ==", "cpu": [ "x64" ], @@ -3438,6 +3484,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", @@ -4112,9 +4224,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { @@ -5169,9 +5281,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -9999,9 +10111,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -11676,12 +11788,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "15.5.18", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.18.tgz", - "integrity": "sha512-eKL8zUJkX9Y5lE+RX/2YJoItVdGlIscyVyboeD9wSpp0PaGqjoA4tTpT2qPqz9ax+5IzGESyLSeZ/RCwbSZ2uQ==", + "version": "15.5.21", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.21.tgz", + "integrity": "sha512-/TsdBtkWLhkl+NVL3Uqws2UphNd6IPzOtzSk1fHaf+0P7GQKLZDUytyhns/Ykbzdy9+YRjwG7ONvrHaaTDdFqQ==", "license": "MIT", "dependencies": { - "@next/env": "15.5.18", + "@next/env": "15.5.21", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -11694,14 +11806,14 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.5.18", - "@next/swc-darwin-x64": "15.5.18", - "@next/swc-linux-arm64-gnu": "15.5.18", - "@next/swc-linux-arm64-musl": "15.5.18", - "@next/swc-linux-x64-gnu": "15.5.18", - "@next/swc-linux-x64-musl": "15.5.18", - "@next/swc-win32-arm64-msvc": "15.5.18", - "@next/swc-win32-x64-msvc": "15.5.18", + "@next/swc-darwin-arm64": "15.5.21", + "@next/swc-darwin-x64": "15.5.21", + "@next/swc-linux-arm64-gnu": "15.5.21", + "@next/swc-linux-arm64-musl": "15.5.21", + "@next/swc-linux-x64-gnu": "15.5.21", + "@next/swc-linux-x64-musl": "15.5.21", + "@next/swc-win32-arm64-msvc": "15.5.21", + "@next/swc-win32-x64-msvc": "15.5.21", "sharp": "^0.34.3" }, "peerDependencies": { @@ -13083,9 +13195,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "devOptional": true, "license": "ISC", "bin": { @@ -13170,48 +13282,53 @@ } }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/shebang-command": { diff --git a/package.json b/package.json index 9c10613..28268ca 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "e2e:debug": "playwright test --debug", "e2e:ui": "playwright test --ui", "e2e:report": "playwright show-report", - "e2e:sync": "node scripts/run-e2e-sync.js" + "e2e:sync": "node scripts/run-e2e-sync.js", + "e2e:sync:codeberg": "node scripts/run-e2e-sync-codeberg.js" }, "dependencies": { "@codemirror/lang-markdown": "^6.5.0", @@ -87,6 +88,7 @@ }, "prismjs": "^1.30.0", "js-yaml": "^4.2.0", - "@babel/core": "^7.29.6" + "@babel/core": "^7.29.6", + "sharp": "^0.35.0" } } diff --git a/scripts/run-e2e-sync-codeberg.js b/scripts/run-e2e-sync-codeberg.js new file mode 100644 index 0000000..f9386d4 --- /dev/null +++ b/scripts/run-e2e-sync-codeberg.js @@ -0,0 +1,74 @@ +// Runner for the live Codeberg/Forgejo-sync E2E harness +// (src/__tests__/e2eSyncLiveCodeberg.test.ts). +// +// Loads the test token from ~/.config/noteser/codeberg-test-token.env into the +// environment, then execs jest on ONLY that test file. The token value is +// never printed, echoed, or passed on the command line — it is read from the +// file and handed to the child process via its environment. +// +// Usage: npm run e2e:sync:codeberg + +const fs = require('fs') +const os = require('os') +const path = require('path') +const { spawnSync } = require('child_process') + +const TOKEN_FILE = path.join( + os.homedir(), + '.config', + 'noteser', + 'codeberg-test-token.env' +) +const TOKEN_KEY = 'CODEBERG_TEST_TOKEN' + +function loadTokenEnv() { + let raw + try { + raw = fs.readFileSync(TOKEN_FILE, 'utf8') + } catch { + console.error(`[e2e:sync:codeberg] Token file not found at ${TOKEN_FILE}.`) + console.error( + '[e2e:sync:codeberg] The live harness needs CODEBERG_TEST_TOKEN to run.' + ) + process.exit(1) + } + for (const line of raw.split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const eq = trimmed.indexOf('=') + if (eq === -1) continue + const key = trimmed.slice(0, eq).trim() + let val = trimmed.slice(eq + 1).trim() + // Strip surrounding quotes if present. + if ( + (val.startsWith('"') && val.endsWith('"')) || + (val.startsWith("'") && val.endsWith("'")) + ) { + val = val.slice(1, -1) + } + if (key) process.env[key] = val + } + if (!process.env[TOKEN_KEY]) { + console.error( + `[e2e:sync:codeberg] ${TOKEN_KEY} not present in ${TOKEN_FILE}.` + ) + process.exit(1) + } +} + +loadTokenEnv() + +// Run jest on just the Codeberg harness file, runInBand to respect Codeberg's +// burst rate-limit, verbose so each scenario's per-test log lines are visible. +const result = spawnSync( + process.execPath, + [ + path.join('node_modules', '.bin', 'jest'), + 'e2eSyncLiveCodeberg', + '--verbose', + '--runInBand' + ], + { stdio: 'inherit', env: process.env } +) + +process.exit(result.status ?? 1) diff --git a/src/__tests__/attachmentSyncTimeoutRetry.test.ts b/src/__tests__/attachmentSyncTimeoutRetry.test.ts index e2af02a..c6ced8f 100644 --- a/src/__tests__/attachmentSyncTimeoutRetry.test.ts +++ b/src/__tests__/attachmentSyncTimeoutRetry.test.ts @@ -101,6 +101,7 @@ jest.mock('../utils/github', () => { }) import { syncToGitHub, _resetUploadedShaCache } from '../utils/githubSync' +import { GitHubProvider } from '../utils/gitHost/githubProvider' import type { Note, SyncRepo } from '@/types' import type { GitTreeEntry } from '../utils/github' @@ -155,7 +156,7 @@ describe('syncToGitHub — attachment push survives a stalled IndexedDB read', ( mockAttachmentState.listTimesOut = true const real = note({ id: 'n1', title: 'Real note', content: 'hello\n' }) - const outcome = await syncToGitHub({ token: 'tok', repo: REPO, notes: [real], folders: [] }) + const outcome = await syncToGitHub({ provider: new GitHubProvider('tok'), repo: REPO, notes: [real], folders: [] }) const paths = postedTreeEntries().map(e => e.path) expect(paths).toEqual(['Real note.md']) @@ -174,7 +175,7 @@ describe('syncToGitHub — attachment push survives a stalled IndexedDB read', ( mockAttachmentState.listTimesOut = true const real = note({ id: 'n1', title: 'Real note', content: 'hello\n' }) - const outcome = await syncToGitHub({ token: 'tok', repo: REPO, notes: [real], folders: [] }) + const outcome = await syncToGitHub({ provider: new GitHubProvider('tok'), repo: REPO, notes: [real], folders: [] }) expect(mockCreateBlobBinary).not.toHaveBeenCalled() const paths = postedTreeEntries().map(e => e.path) @@ -191,7 +192,7 @@ describe('syncToGitHub — attachment push survives a stalled IndexedDB read', ( // Cycle 1: times out, skips the attachment (also give it a real note edit // so the push doesn't short-circuit before reaching a real commit). const real1 = note({ id: 'n1', title: 'Real note', content: 'v1\n' }) - const outcome1 = await syncToGitHub({ token: 'tok', repo: REPO, notes: [real1], folders: [] }) + const outcome1 = await syncToGitHub({ provider: new GitHubProvider('tok'), repo: REPO, notes: [real1], folders: [] }) expect(outcome1.result.attachmentSyncSkipped).toBe(true) expect(mockCreateBlobBinary).not.toHaveBeenCalled() @@ -204,7 +205,7 @@ describe('syncToGitHub — attachment push survives a stalled IndexedDB read', ( gitPath: 'Real note.md', gitLastPushedSha: outcome1.pathUpdates[0]?.gitLastPushedSha ?? null, gitRemoteBaseSha: outcome1.pathUpdates[0]?.gitRemoteBaseSha ?? null, }) - const outcome2 = await syncToGitHub({ token: 'tok', repo: REPO, notes: [real2], folders: [] }) + const outcome2 = await syncToGitHub({ provider: new GitHubProvider('tok'), repo: REPO, notes: [real2], folders: [] }) expect(mockCreateBlobBinary).toHaveBeenCalledTimes(1) const paths = postedTreeEntries().map(e => e.path) @@ -216,7 +217,7 @@ describe('syncToGitHub — attachment push survives a stalled IndexedDB read', ( mockAttachmentState.listTimesOut = true const real = note({ id: 'n1', title: 'Real note', content: 'hello\n' }) - await syncToGitHub({ token: 'tok', repo: REPO, notes: [real], folders: [] }) + await syncToGitHub({ provider: new GitHubProvider('tok'), repo: REPO, notes: [real], folders: [] }) // clearAttachmentTombstones is mocked as a no-op jest.fn-less async — // the meaningful assertion is that the tombstone path never appears as diff --git a/src/__tests__/collabIdFrontmatter.test.ts b/src/__tests__/collabIdFrontmatter.test.ts index 755f011..bc73c76 100644 --- a/src/__tests__/collabIdFrontmatter.test.ts +++ b/src/__tests__/collabIdFrontmatter.test.ts @@ -54,6 +54,7 @@ jest.mock('../utils/github', () => { }) import { pullFromGitHub, serializeNote, parseNote } from '../utils/githubSync' +import { GitHubProvider } from '../utils/gitHost/githubProvider' import { applyNonConflicts } from '../utils/syncApply' import { gitBlobSha } from '../utils/github' import { useNoteStore } from '../stores/noteStore' @@ -119,7 +120,7 @@ test('a note that differs ONLY by gaining a collabId classifies as remoteUpdated const originalSha = await gitBlobSha(rawOriginal) mockGetTreeMap.mockResolvedValue(new Map([['Note.md', originalSha]])) mockGetBlobContent.mockResolvedValue(rawOriginal) - const first = await pullFromGitHub({ token: 't', repo: REPO, notes: [], folders: [] }) + const first = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: [], folders: [] }) await applyNonConflicts(first.classifications) const noteId = useNoteStore.getState().notes[0].id expect(useNoteStore.getState().notes[0].collabId).toBeUndefined() @@ -133,7 +134,7 @@ test('a note that differs ONLY by gaining a collabId classifies as remoteUpdated ) const second = await pullFromGitHub({ - token: 't', repo: REPO, notes: useNoteStore.getState().notes, folders: [], + provider: new GitHubProvider('t'), repo: REPO, notes: useNoteStore.getState().notes, folders: [], }) expect(second.classifications).toHaveLength(1) expect(second.classifications[0]).toMatchObject({ @@ -150,7 +151,7 @@ test('a note that differs ONLY by gaining a collabId classifies as remoteUpdated mockGetTreeMap.mockResolvedValue(new Map([['Note.md', collabSha]])) mockGetBlobContent.mockResolvedValue(rawWithCollab) const third = await pullFromGitHub({ - token: 't', repo: REPO, notes: useNoteStore.getState().notes, folders: [], + provider: new GitHubProvider('t'), repo: REPO, notes: useNoteStore.getState().notes, folders: [], }) expect(third.classifications).toEqual([{ kind: 'unchanged', noteId }]) }) @@ -164,7 +165,7 @@ test('when local and remote hold DIFFERENT collabIds but identical bodies, the r const originalSha = await gitBlobSha(rawOriginal) mockGetTreeMap.mockResolvedValue(new Map([['Note.md', originalSha]])) mockGetBlobContent.mockResolvedValue(rawOriginal) - const first = await pullFromGitHub({ token: 't', repo: REPO, notes: [], folders: [] }) + const first = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: [], folders: [] }) await applyNonConflicts(first.classifications) const noteId = useNoteStore.getState().notes[0].id const localRoom = useNoteStore.getState().ensureCollabId(noteId) @@ -179,7 +180,7 @@ test('when local and remote hold DIFFERENT collabIds but identical bodies, the r ) const second = await pullFromGitHub({ - token: 't', repo: REPO, notes: useNoteStore.getState().notes, folders: [], + provider: new GitHubProvider('t'), repo: REPO, notes: useNoteStore.getState().notes, folders: [], }) // Bodies match, only the metadata differs → clean remoteUpdated, NOT conflict. expect(second.classifications).toHaveLength(1) diff --git a/src/__tests__/commitSwitchFreshClone.test.tsx b/src/__tests__/commitSwitchFreshClone.test.tsx index dbb9894..0abef21 100644 --- a/src/__tests__/commitSwitchFreshClone.test.tsx +++ b/src/__tests__/commitSwitchFreshClone.test.tsx @@ -74,6 +74,8 @@ function setGitHub(syncRepo: SyncRepo | null) { lastCommitSha: null, repoSyncStates: {}, isSyncing: false, + host: 'github', + baseUrl: null, }) } diff --git a/src/__tests__/contextMenuGistVisibility.test.tsx b/src/__tests__/contextMenuGistVisibility.test.tsx index 7e09ebe..edc34ec 100644 --- a/src/__tests__/contextMenuGistVisibility.test.tsx +++ b/src/__tests__/contextMenuGistVisibility.test.tsx @@ -30,7 +30,7 @@ import type { ContextMenuState } from '@/types' const NOTE_ID = 'ctx-note-1' -function seedNote(overrides: Partial<{ isDeleted: boolean }> = {}) { +function seedNote(overrides: Partial<{ isDeleted: boolean; gitPath: string }> = {}) { useNoteStore.setState({ notes: [ { @@ -44,6 +44,7 @@ function seedNote(overrides: Partial<{ isDeleted: boolean }> = {}) { deletedAt: overrides.isDeleted ? 1000 : null, isPinned: false, templateId: null, + gitPath: overrides.gitPath ?? null, }, ], selectedNoteId: null, @@ -65,7 +66,7 @@ function renderMenu(onClose = jest.fn()) { beforeEach(() => { useNoteStore.setState({ notes: [], selectedNoteId: null }) - useGitHubStore.setState({ token: null, user: null }) + useGitHubStore.setState({ token: null, user: null, host: 'github' }) useUIStore.setState({ modal: { type: null } }) useSettingsStore.setState({ aiProvider: 'off' }) }) @@ -108,4 +109,27 @@ describe('ContextMenu — "Publish as gist" visibility', () => { expect((modal.data as { noteId: string }).noteId).toBe(NOTE_ID) expect(onClose).toHaveBeenCalled() }) + + test('is NOT rendered when host is not GitHub', () => { + seedNote() + useGitHubStore.setState({ token: 'ghp_tok', user: null, host: 'forgejo' }) + renderMenu() + expect(screen.queryByText('Publish as gist')).not.toBeInTheDocument() + }) +}) + +describe('ContextMenu — "View history" visibility', () => { + test('IS rendered when the note has a gitPath and host is GitHub', () => { + seedNote({ gitPath: 'notes/test.md' }) + useGitHubStore.setState({ token: 'ghp_tok', user: null, host: 'github' }) + renderMenu() + expect(screen.getByText('View history')).toBeInTheDocument() + }) + + test('is NOT rendered when host is not GitHub', () => { + seedNote({ gitPath: 'notes/test.md' }) + useGitHubStore.setState({ token: 'ghp_tok', user: null, host: 'forgejo' }) + renderMenu() + expect(screen.queryByText('View history')).not.toBeInTheDocument() + }) }) diff --git a/src/__tests__/cspHeader.test.ts b/src/__tests__/cspHeader.test.ts index 65959a7..b50bbc0 100644 --- a/src/__tests__/cspHeader.test.ts +++ b/src/__tests__/cspHeader.test.ts @@ -16,7 +16,7 @@ * 'unsafe-eval' only in dev/test, dropped in production. * - style-src: KEEPS 'unsafe-inline' (Tailwind / styled-jsx / CodeMirror). */ -import { buildCsp, deriveCollabWsOrigin } from '@/utils/csp' +import { buildCsp, deriveCollabWsOrigin, deriveGitHostOrigin } from '@/utils/csp' function getDirective(csp: string, name: string): string { const directive = csp @@ -64,6 +64,34 @@ describe('deriveCollabWsOrigin', () => { }) }) +describe('deriveGitHostOrigin', () => { + it('returns null for unset/empty input', () => { + expect(deriveGitHostOrigin(undefined)).toBeNull() + expect(deriveGitHostOrigin('')).toBeNull() + }) + + it('returns the origin for a valid https:// URL (path stripped)', () => { + expect(deriveGitHostOrigin('https://git.example.com/some/path')).toBe( + 'https://git.example.com' + ) + }) + + it('keeps an explicit port', () => { + expect(deriveGitHostOrigin('http://192.168.1.10:3000')).toBe('http://192.168.1.10:3000') + }) + + it('rejects non-http(s) schemes', () => { + expect(deriveGitHostOrigin('wss://git.example.com')).toBeNull() + expect(deriveGitHostOrigin('javascript:alert(1)')).toBeNull() + expect(deriveGitHostOrigin('data:text/plain,foo')).toBeNull() + }) + + it('rejects malformed URLs', () => { + expect(deriveGitHostOrigin('not a url')).toBeNull() + expect(deriveGitHostOrigin('git.example.com')).toBeNull() + }) +}) + describe('connect-src CSP directive (no ws origin)', () => { const csp = buildCsp(NONCE, { isDev: false, wsOrigin: null }) @@ -81,6 +109,31 @@ describe('connect-src CSP directive (no ws origin)', () => { expect(directive).toContain('https://api.anthropic.com') expect(directive).toContain('https://api.openai.com') }) + + it('allows codeberg.org (the built-in Forgejo preset) by default', () => { + const directive = getDirective(csp, 'connect-src') + expect(directive).toContain('https://codeberg.org') + }) +}) + +describe('connect-src CSP directive (with a derived git-host origin)', () => { + it('adds exactly the derived origin and no wildcard', () => { + const origin = deriveGitHostOrigin('https://git.example.com/api/v1') + expect(origin).toBe('https://git.example.com') + const csp = buildCsp(NONCE, { isDev: false, wsOrigin: null, gitHostOrigin: origin }) + const directive = getDirective(csp, 'connect-src') + expect(directive).toContain('https://git.example.com') + expect(directive).not.toContain('*') + // No bare scheme wildcard slipped in alongside the scoped origin. + expect(directive.split(/\s+/)).not.toContain('https:') + expect(directive.split(/\s+/)).not.toContain('http:') + }) + + it('omits the origin entirely when not configured', () => { + const csp = buildCsp(NONCE, { isDev: false, wsOrigin: null }) + const directive = getDirective(csp, 'connect-src') + expect(directive).not.toContain('git.example.com') + }) }) describe('connect-src CSP directive (with a derived ws origin)', () => { diff --git a/src/__tests__/doNotSyncSync.test.ts b/src/__tests__/doNotSyncSync.test.ts index 1b77e21..1aa6d4f 100644 --- a/src/__tests__/doNotSyncSync.test.ts +++ b/src/__tests__/doNotSyncSync.test.ts @@ -100,6 +100,7 @@ jest.mock('../utils/github', () => { }) import { pullFromGitHub, syncToGitHub, _resetUploadedShaCache } from '../utils/githubSync' +import { GitHubProvider } from '../utils/gitHost/githubProvider' import type { Note, SyncRepo } from '@/types' import type { GitTreeEntry } from '../utils/github' @@ -156,7 +157,7 @@ describe('syncToGitHub — doNotSync notes never enter the push tree', () => { test('a flagged note is never serialized: no blob, no tree, no commit', async () => { const tour = note({ id: 't1', title: 'Feature tour', content: 'demo body\n', doNotSync: true }) - const outcome = await syncToGitHub({ token: 'tok', repo: REPO, notes: [tour], folders: [] }) + const outcome = await syncToGitHub({ provider: new GitHubProvider('tok'), repo: REPO, notes: [tour], folders: [] }) expect(mockCreateBlob).not.toHaveBeenCalled() expect(mockCreateTree).not.toHaveBeenCalled() @@ -171,7 +172,7 @@ describe('syncToGitHub — doNotSync notes never enter the push tree', () => { const tour = note({ id: 't1', title: 'Feature tour', content: 'demo body\n', doNotSync: true }) const real = note({ id: 'n1', title: 'Real note', content: 'hello\n' }) - const outcome = await syncToGitHub({ token: 'tok', repo: REPO, notes: [tour, real], folders: [] }) + const outcome = await syncToGitHub({ provider: new GitHubProvider('tok'), repo: REPO, notes: [tour, real], folders: [] }) const paths = postedTreeEntries().map(e => e.path) expect(paths).toEqual(['Real note.md']) @@ -194,7 +195,7 @@ describe('syncToGitHub — doNotSync notes never enter the push tree', () => { }) mockGetTreeMap.mockResolvedValue(new Map([['Feature tour.md', 'legacy-sha']])) - const outcome = await syncToGitHub({ token: 'tok', repo: REPO, notes: [tour], folders: [] }) + const outcome = await syncToGitHub({ provider: new GitHubProvider('tok'), repo: REPO, notes: [tour], folders: [] }) expect(mockCreateTree).not.toHaveBeenCalled() expect(mockCreateCommit).not.toHaveBeenCalled() @@ -221,7 +222,7 @@ describe('syncToGitHub — doNotSync notes never enter the push tree', () => { }) mockGetTreeMap.mockResolvedValue(new Map([['Feature tour.md', 'legacy-sha']])) - const outcome = await syncToGitHub({ token: 'tok', repo: REPO, notes: [live, dup], folders: [] }) + const outcome = await syncToGitHub({ provider: new GitHubProvider('tok'), repo: REPO, notes: [live, dup], folders: [] }) // No delete entry — no tree change at all. expect(mockCreateTree).not.toHaveBeenCalled() @@ -241,7 +242,7 @@ describe('syncToGitHub — doNotSync notes never enter the push tree', () => { mockAttachmentState.blobByPath.set('Files/feature-tour/00-welcome.png', new Blob([new Uint8Array([1])], { type: 'image/png' })) mockAttachmentState.blobByPath.set('Files/mine.png', new Blob([new Uint8Array([2])], { type: 'image/png' })) - const outcome = await syncToGitHub({ token: 'tok', repo: REPO, notes: [], folders: [] }) + const outcome = await syncToGitHub({ provider: new GitHubProvider('tok'), repo: REPO, notes: [], folders: [] }) // Exactly one binary upload — the user's own attachment. expect(mockCreateBlobBinary).toHaveBeenCalledTimes(1) @@ -263,7 +264,7 @@ describe('pullFromGitHub — doNotSync notes are invisible to the classifier', ( gitLastPushedSha: 'old-sha', gitRemoteBaseSha: 'old-sha', }) - const { classifications } = await pullFromGitHub({ token: 'tok', repo: REPO, notes: [tour], folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('tok'), repo: REPO, notes: [tour], folders: [] }) expect(classifications).toEqual([{ kind: 'unchanged', noteId: 't1' }]) expect(mockGetBlobContent).not.toHaveBeenCalled() @@ -279,7 +280,7 @@ describe('pullFromGitHub — doNotSync notes are invisible to the classifier', ( gitLastPushedSha: 'legacy-sha', gitRemoteBaseSha: 'legacy-sha', }) - const { classifications } = await pullFromGitHub({ token: 'tok', repo: REPO, notes: [tour], folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('tok'), repo: REPO, notes: [tour], folders: [] }) expect(classifications).toEqual([]) }) @@ -293,7 +294,7 @@ describe('pullFromGitHub — doNotSync notes are invisible to the classifier', ( mockGetBlobContent.mockResolvedValue('my own tour notes\n') const tour = note({ id: 't1', title: 'Feature tour', content: 'demo body\n', doNotSync: true }) - const { classifications } = await pullFromGitHub({ token: 'tok', repo: REPO, notes: [tour], folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('tok'), repo: REPO, notes: [tour], folders: [] }) expect(classifications).toHaveLength(1) expect(classifications[0]).toMatchObject({ diff --git a/src/__tests__/e2eSyncLive.test.ts b/src/__tests__/e2eSyncLive.test.ts index 3254e0c..baa286d 100644 --- a/src/__tests__/e2eSyncLive.test.ts +++ b/src/__tests__/e2eSyncLive.test.ts @@ -139,6 +139,7 @@ if (typeof g.TextDecoder === 'undefined') { } import { pullFromGitHub, syncToGitHub, serializeNote, parseNote } from '../utils/githubSync' +import { GitHubProvider } from '../utils/gitHost/githubProvider' import { getBranchRefSha, getCommitTreeSha, @@ -324,7 +325,7 @@ maybe('e2e GitHub sync (live)', () => { expect(baselineHeadSha).toMatch(/^[0-9a-f]{40}$/) log(`[scenario 1] reset ${HARNESS_BRANCH} to main @ ${baselineHeadSha.slice(0, 8)}`) - const pull = await pullFromGitHub({ token: TOKEN!, repo, notes: [], folders: [] }) + const pull = await pullFromGitHub({ provider: new GitHubProvider(TOKEN!), repo, notes: [], folders: [] }) // With empty local state every remote .md classifies remoteCreated; there // must be no spurious local-side entries (remoteDeleted/conflict). const kinds = pull.classifications.reduce>((acc, c) => { @@ -339,7 +340,7 @@ maybe('e2e GitHub sync (live)', () => { test('scenario 2: push 3 new notes → created === 3 + commitSha returned', async () => { const before = await getRefSha(HARNESS_BRANCH) - const outcome = await syncToGitHub({ token: TOKEN!, repo, notes, folders: [] }) + const outcome = await syncToGitHub({ provider: new GitHubProvider(TOKEN!), repo, notes, folders: [] }) expect(outcome.result.unchanged).toBe(false) expect(outcome.result.created).toBe(3) @@ -381,7 +382,7 @@ maybe('e2e GitHub sync (live)', () => { // Mirror the production dispatch: a true first clone passes EMPTY local // state and isFirstClone=true, which now emits SHELLS (no body fetch). pull = await pullFromGitHub({ - token: TOKEN!, + provider: new GitHubProvider(TOKEN!), repo, notes: [], folders: [], @@ -450,7 +451,7 @@ maybe('e2e GitHub sync (live)', () => { }) as typeof fetch let pull: Awaited> try { - pull = await pullFromGitHub({ token: TOKEN!, repo, notes: shells, folders: [] }) + pull = await pullFromGitHub({ provider: new GitHubProvider(TOKEN!), repo, notes: shells, folders: [] }) } finally { globalThis.fetch = realFetch } @@ -474,7 +475,7 @@ maybe('e2e GitHub sync (live)', () => { // (b) syncToGitHub with ONLY shells → NO push (no empty-body overwrite, // no delete of the real remote file). Head sha unchanged. const headBefore = await getRefSha(HARNESS_BRANCH) - const dry = await syncToGitHub({ token: TOKEN!, repo, notes: shells, folders: [] }) + const dry = await syncToGitHub({ provider: new GitHubProvider(TOKEN!), repo, notes: shells, folders: [] }) expect(dry.result.unchanged).toBe(true) expect(dry.result.created).toBe(0) expect(dry.result.updated).toBe(0) @@ -500,7 +501,7 @@ maybe('e2e GitHub sync (live)', () => { // (d) After fill, a re-pull still reads `unchanged` — normal behaviour // resumed, no phantom local edit, no re-upload churn. - const pull2 = await pullFromGitHub({ token: TOKEN!, repo, notes: shells, folders: [] }) + const pull2 = await pullFromGitHub({ provider: new GitHubProvider(TOKEN!), repo, notes: shells, folders: [] }) const mine2 = pull2.classifications.filter( c => 'noteId' in c && shellIds.has((c as { noteId: string }).noteId), ) @@ -511,7 +512,7 @@ maybe('e2e GitHub sync (live)', () => { }) test('scenario 3: re-pull with the 3 notes as local state → all unchanged (no misclassification)', async () => { - const pull = await pullFromGitHub({ token: TOKEN!, repo, notes, folders: [] }) + const pull = await pullFromGitHub({ provider: new GitHubProvider(TOKEN!), repo, notes, folders: [] }) // The 3 pushed notes must each classify `unchanged`. None may surface as // remoteCreated (the duplicate/twin bug) or remoteUpdated/conflict. @@ -538,7 +539,7 @@ maybe('e2e GitHub sync (live)', () => { test('scenario 4: empty-commit guard — re-push unchanged notes makes no new commit', async () => { const before = await getRefSha(HARNESS_BRANCH) - const outcome = await syncToGitHub({ token: TOKEN!, repo, notes, folders: [] }) + const outcome = await syncToGitHub({ provider: new GitHubProvider(TOKEN!), repo, notes, folders: [] }) expect(outcome.result.unchanged).toBe(true) expect(outcome.result.created).toBe(0) @@ -559,7 +560,7 @@ maybe('e2e GitHub sync (live)', () => { i === 0 ? { ...n, content: `${n.content}edited at ${Date.now()}\n`, updatedAt: Date.now() } : n, ) - const outcome = await syncToGitHub({ token: TOKEN!, repo, notes, folders: [] }) + const outcome = await syncToGitHub({ provider: new GitHubProvider(TOKEN!), repo, notes, folders: [] }) expect(outcome.result.unchanged).toBe(false) expect(outcome.result.updated).toBe(1) expect(outcome.result.created).toBe(0) @@ -590,7 +591,7 @@ maybe('e2e GitHub sync (live)', () => { // the way applyNonConflicts would. gitLastPushedSha = CANONICAL sha, // gitRemoteBaseSha = the RAW non-canonical remote sha. Critically the // canonical sha differs from the remote sha (the churn trigger). - const pull = await pullFromGitHub({ token: TOKEN!, repo, notes: [], folders: [] }) + const pull = await pullFromGitHub({ provider: new GitHubProvider(TOKEN!), repo, notes: [], folders: [] }) const created = pull.classifications.find( (c): c is Extract => c.kind === 'remoteCreated' && (c as { path: string }).path === nonCanonPath, @@ -612,7 +613,7 @@ maybe('e2e GitHub sync (live)', () => { // (3) syncToGitHub with the note UNCHANGED → NO push, NO commit, NO blob. // This is the churn fix: a non-canonical clone produces zero rewrites. const headBefore = await getRefSha(HARNESS_BRANCH) - const dry = await syncToGitHub({ token: TOKEN!, repo, notes: [cloned], folders: [] }) + const dry = await syncToGitHub({ provider: new GitHubProvider(TOKEN!), repo, notes: [cloned], folders: [] }) expect(dry.result.unchanged).toBe(true) expect(dry.result.created).toBe(0) expect(dry.result.updated).toBe(0) @@ -625,7 +626,7 @@ maybe('e2e GitHub sync (live)', () => { // (4) Now make a REAL edit → it MUST push (updated === 1, new commit). cloned = { ...cloned, content: `${body}\nedited at ${Date.now()}\n`, updatedAt: Date.now() } - const wet = await syncToGitHub({ token: TOKEN!, repo, notes: [cloned], folders: [] }) + const wet = await syncToGitHub({ provider: new GitHubProvider(TOKEN!), repo, notes: [cloned], folders: [] }) expect(wet.result.unchanged).toBe(false) expect(wet.result.updated).toBe(1) expect(wet.result.created).toBe(0) @@ -654,7 +655,7 @@ maybe('e2e GitHub sync (live)', () => { let renNotes: Note[] = [1, 2, 3].map(i => makeNote(`rename ${rStamp} note ${i}`, `Rename scenario body ${i} for ${rStamp}\n`), ) - const pushOut = await syncToGitHub({ token: TOKEN!, repo, notes: renNotes, folders: [] }) + const pushOut = await syncToGitHub({ provider: new GitHubProvider(TOKEN!), repo, notes: renNotes, folders: [] }) expect(pushOut.result.created).toBe(3) renNotes = applyPathUpdates(renNotes, pushOut.pathUpdates) expect(renNotes.every(n => n.gitPath && n.gitLastPushedSha)).toBe(true) @@ -687,7 +688,7 @@ maybe('e2e GitHub sync (live)', () => { // (3) Re-pull with these notes (stale space-form gitPath, dash-form title). // The fix must ADOPT each note to the dash-form remote file, NEVER // classify it remoteDeleted (the soft-delete that precedes the wipe). - const pull = await pullFromGitHub({ token: TOKEN!, repo, notes: renNotes, folders: [] }) + const pull = await pullFromGitHub({ provider: new GitHubProvider(TOKEN!), repo, notes: renNotes, folders: [] }) const ourIds = new Set(renNotes.map(n => n.id)) const ours = pull.classifications.filter( c => 'noteId' in c && ourIds.has((c as { noteId: string }).noteId), @@ -729,7 +730,7 @@ maybe('e2e GitHub sync (live)', () => { // files survive. Content + path both match the remote now, so this is a // clean no-op push (head unchanged). const headBefore = await getRefSha(HARNESS_BRANCH) - const sync = await syncToGitHub({ token: TOKEN!, repo, notes: renNotes, folders: [] }) + const sync = await syncToGitHub({ provider: new GitHubProvider(TOKEN!), repo, notes: renNotes, folders: [] }) expect(sync.result.deleted).toBe(0) const headAfter = await getRefSha(HARNESS_BRANCH) expect(headAfter).toBe(headBefore) // no commit at all → certainly no delete @@ -753,7 +754,7 @@ maybe('e2e GitHub sync (live)', () => { const sStamp = Date.now() // Plant a note remotely and clone it so we know its exact remote path + sha. let live = makeNote(`safetynet ${sStamp}`, `Safety-net body ${sStamp}\n`) - const push = await syncToGitHub({ token: TOKEN!, repo, notes: [live], folders: [] }) + const push = await syncToGitHub({ provider: new GitHubProvider(TOKEN!), repo, notes: [live], folders: [] }) live = applyPathUpdates([live], push.pathUpdates)[0] const livePath = live.gitPath! expect(livePath).toBeTruthy() @@ -773,7 +774,7 @@ maybe('e2e GitHub sync (live)', () => { } const headBefore = await getRefSha(HARNESS_BRANCH) - const out = await syncToGitHub({ token: TOKEN!, repo, notes: [live, ghost], folders: [] }) + const out = await syncToGitHub({ provider: new GitHubProvider(TOKEN!), repo, notes: [live, ghost], folders: [] }) expect(out.result.deleted).toBe(0) const headAfter = await getRefSha(HARNESS_BRANCH) expect(headAfter).toBe(headBefore) @@ -952,7 +953,7 @@ rvhMaybe('e2e GitHub sync — REALISTIC VAULT (live)', () => { const settings = useSettingsStore.getState() const vaultSettingsPath = vaultSettingsRepoPath(settings.settingsFolderPath) const pull = await pullFromGitHub({ - token: TOKEN!, + provider: new GitHubProvider(TOKEN!), repo: rvhRepo, notes: useNoteStore.getState().notes, folders: useFolderStore.getState().folders, @@ -1116,7 +1117,7 @@ rvhMaybe('e2e GitHub sync — REALISTIC VAULT (live)', () => { const headBefore = await getRefSha(RVH_BRANCH) let outcome: Awaited> try { - outcome = await syncToGitHub({ token: TOKEN!, repo: rvhRepo, notes, folders, vaultSettings }) + outcome = await syncToGitHub({ provider: new GitHubProvider(TOKEN!), repo: rvhRepo, notes, folders, vaultSettings }) } finally { globalThis.fetch = realFetch } @@ -1219,7 +1220,7 @@ rvhMaybe('e2e GitHub sync — REALISTIC VAULT (live)', () => { const folders = useFolderStore.getState().folders const vaultSettings = await buildVaultSettingsBundle() const headBefore = await getRefSha(RVH_BRANCH) - const outcome = await syncToGitHub({ token: TOKEN!, repo: rvhRepo, notes, folders, vaultSettings }) + const outcome = await syncToGitHub({ provider: new GitHubProvider(TOKEN!), repo: rvhRepo, notes, folders, vaultSettings }) const headAfter = await getRefSha(RVH_BRANCH) const settingsWouldRePush = seeded !== canonicalHash @@ -1364,7 +1365,7 @@ rvhMaybe('e2e GitHub sync — REALISTIC VAULT (live)', () => { const headBefore = await getRefSha(RVH_BRANCH) let outcome: Awaited> try { - outcome = await syncToGitHub({ token: TOKEN!, repo: rvhRepo, notes, folders, vaultSettings }) + outcome = await syncToGitHub({ provider: new GitHubProvider(TOKEN!), repo: rvhRepo, notes, folders, vaultSettings }) } finally { globalThis.fetch = realFetch } @@ -1483,7 +1484,7 @@ rvhMaybe('e2e GitHub sync — REALISTIC VAULT (live)', () => { const headBefore = await getRefSha(RVH_BRANCH) let outcome: Awaited> try { - outcome = await syncToGitHub({ token: TOKEN!, repo: rvhRepo, notes, folders, vaultSettings }) + outcome = await syncToGitHub({ provider: new GitHubProvider(TOKEN!), repo: rvhRepo, notes, folders, vaultSettings }) } finally { globalThis.fetch = realFetch } diff --git a/src/__tests__/e2eSyncLiveCodeberg.test.ts b/src/__tests__/e2eSyncLiveCodeberg.test.ts new file mode 100644 index 0000000..ee392f0 --- /dev/null +++ b/src/__tests__/e2eSyncLiveCodeberg.test.ts @@ -0,0 +1,540 @@ +/** + * @jest-environment node + * + * e2eSyncLiveCodeberg.test.ts + * + * END-TO-END harness that drives noteser's REAL sync pipeline + * (`pullFromGitHub` / `syncToGitHub`) against a LIVE Codeberg (Forgejo) repo + * through the ForgejoProvider. Where e2eSyncLive.test.ts proves the GitHub + * path, this proves the WHOLE flow on Forgejo — not just the provider in + * isolation — and answers the open optimistic-concurrency question for the + * ChangeFiles write path (#17 / #24). + * + * It only runs when `CODEBERG_TEST_TOKEN` is present — otherwise every test + * self-skips, so the normal `npm test` suite stays green. Run it with the + * token loaded: + * npm run e2e:sync:codeberg + * which sources ~/.config/noteser/codeberg-test-token.env and runs only this + * file. + * + * Target: rotecodefraktion/noteser-codeberg-test (override via + * CODEBERG_TEST_OWNER / CODEBERG_TEST_REPO). Base URL defaults to + * https://codeberg.org (ForgejoProvider's default). + * + * SAFETY: the harness NEVER touches `main`. It operates on a per-run harness + * branch (timestamped name) created from main via the Gitea branch API and + * deleted in afterAll (best-effort). The token value is read at runtime and + * never logged. + * + * What it asserts (each scenario logged with a [scenario] tag): + * 1. Baseline pull on the empty harness branch → no conflict / remoteDeleted. + * 2. Push 3 new notes (spaced titles) → created === 3, commitSha, head moved. + * 3. Re-pull with those 3 as local state → all unchanged, paths preserved. + * 4. Update one note → updated === 1, new commit. + * 5. No-churn: re-push unchanged → unchanged === true, head unchanged. + * 6. Delete one note (soft-delete) → removed remotely. + * 7. CONCURRENCY PROBE: capture head, make an out-of-band remote change via + * ChangeFiles directly on the harness branch, then syncToGitHub with a + * STALE parentSha baseline → DOCUMENT what ChangeFiles does (reject / + * merge / overwrite). This answers the open optimistic-concurrency item. + */ + +// idb-keyval backed by an in-memory Map (the Zustand persist layer + +// attachments.ts need somewhere to write under Node). The providers / +// github.ts stay REAL — the whole point is the network calls go to Codeberg. +jest.mock('idb-keyval', () => { + const store = new Map() + return { + get: jest.fn(async (key: IDBValidKey) => store.get(key)), + set: jest.fn(async (key: IDBValidKey, val: unknown) => { + store.set(key, val) + }), + del: jest.fn(async (key: IDBValidKey) => { + store.delete(key) + }), + keys: jest.fn(async () => Array.from(store.keys())), + clear: jest.fn(async () => { + store.clear() + }) + } +}) + +// Polyfills for the Node test env. fetch is provided natively on Node 22, so we +// only fill the WebCrypto / text codec / base64 surface the sync stack touches. +// (attachments.ts is not exercised here — text-only scenarios — but we polyfill +// atob/btoa/FileReader/URL defensively to match the GitHub harness's setup.) +import { webcrypto } from 'node:crypto' +import { TextEncoder, TextDecoder } from 'node:util' + +const g = globalThis as unknown as { + crypto?: Crypto + TextEncoder?: typeof TextEncoder + TextDecoder?: typeof TextDecoder + atob?: (s: string) => string + btoa?: (s: string) => string + FileReader?: unknown + URL: { + createObjectURL?: (b: unknown) => string + revokeObjectURL?: (u: string) => void + } +} +if (typeof g.crypto === 'undefined' || !g.crypto.subtle) { + g.crypto = webcrypto as unknown as Crypto +} +if (typeof g.TextEncoder === 'undefined') g.TextEncoder = TextEncoder +if (typeof g.TextDecoder === 'undefined') g.TextDecoder = TextDecoder +if (typeof g.atob === 'undefined') { + g.atob = (s: string) => Buffer.from(s, 'base64').toString('binary') +} +if (typeof g.btoa === 'undefined') { + g.btoa = (s: string) => Buffer.from(s, 'binary').toString('base64') +} +if (typeof g.URL.createObjectURL === 'undefined') { + g.URL.createObjectURL = () => + `blob:node/${Math.random().toString(36).slice(2)}` + g.URL.revokeObjectURL = () => undefined +} + +import { pullFromGitHub, syncToGitHub } from '../utils/githubSync' +import { ForgejoProvider } from '../utils/gitHost/forgejoProvider' +import type { Note, SyncRepo } from '@/types' + +const TOKEN = process.env.CODEBERG_TEST_TOKEN +const OWNER = process.env.CODEBERG_TEST_OWNER || 'rotecodefraktion' +const REPO_NAME = process.env.CODEBERG_TEST_REPO || 'noteser-codeberg-test' +const BASE_URL = 'https://codeberg.org' +const BASE_BRANCH = 'main' +// Unique per run so concurrent / repeated runs never collide. +const HARNESS_BRANCH = `claude-harness-${Date.now()}` + +const repo: SyncRepo = { + owner: OWNER, + name: REPO_NAME, + branch: HARNESS_BRANCH, + isPrivate: true +} + +// ── Gitea branch + ChangeFiles helpers (the Forgejo equivalents of the +// GitHub harness's git/refs lifecycle). All use `Authorization: token `. +const API = `${BASE_URL}/api/v1` +const giteaHeaders = (extra: Record = {}) => ({ + Authorization: `token ${TOKEN}`, + Accept: 'application/json', + ...extra +}) + +/** Create the harness branch from main via POST /branches. */ +async function createHarnessBranch(): Promise { + const res = await fetch(`${API}/repos/${OWNER}/${REPO_NAME}/branches`, { + method: 'POST', + headers: giteaHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + new_branch_name: HARNESS_BRANCH, + old_branch_name: BASE_BRANCH + }) + }) + if (!res.ok) throw new Error(`createHarnessBranch failed (${res.status})`) +} + +/** Delete the harness branch via DELETE /branches/{name}. Best-effort. */ +async function deleteHarnessBranch(): Promise { + const res = await fetch( + `${API}/repos/${OWNER}/${REPO_NAME}/branches/${HARNESS_BRANCH}`, + { + method: 'DELETE', + headers: giteaHeaders() + } + ) + // 204 = deleted, 404 = already gone. Anything else is a real failure. + if (res.status !== 204 && res.status !== 404) { + throw new Error(`deleteHarnessBranch failed (${res.status})`) + } +} + +/** Head sha of the harness branch via GET /branches/{name} → .commit.id. */ +async function getHarnessHeadSha(): Promise { + const res = await fetch( + `${API}/repos/${OWNER}/${REPO_NAME}/branches/${HARNESS_BRANCH}`, + { + headers: giteaHeaders() + } + ) + if (!res.ok) throw new Error(`getHarnessHeadSha failed (${res.status})`) + const data = (await res.json()) as { commit: { id: string } } + return data.commit.id +} + +/** Blob sha of `path` on the harness branch, or null when absent. */ +async function getRemoteBlobSha(path: string): Promise { + const res = await fetch( + `${API}/repos/${OWNER}/${REPO_NAME}/contents/${encodeURIComponent(path)}?ref=${HARNESS_BRANCH}`, + { headers: giteaHeaders() } + ) + if (res.status === 404) return null + if (!res.ok) + throw new Error(`getRemoteBlobSha(${path}) failed (${res.status})`) + const data = (await res.json()) as { sha: string } + return data.sha +} + +/** + * Write `content` to `path` on the harness branch out-of-band via the SAME + * ChangeFiles batch API the provider uses (POST /contents). This advances the + * branch head independently of any pending syncToGitHub baseline — the setup + * for the concurrency probe. Returns the new head sha. + */ +async function writeRemoteFileOutOfBand( + path: string, + content: string +): Promise { + const existing = await getRemoteBlobSha(path) + const op = existing ? 'update' : 'create' + const file: Record = { + operation: op, + path, + content: Buffer.from(content, 'utf8').toString('base64') + } + if (existing) file.sha = existing + const res = await fetch(`${API}/repos/${OWNER}/${REPO_NAME}/contents`, { + method: 'POST', + headers: giteaHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + branch: HARNESS_BRANCH, + message: `harness: out-of-band ${op} ${path}`, + files: [file] + }) + }) + if (!res.ok) + throw new Error(`writeRemoteFileOutOfBand(${path}) failed (${res.status})`) + const data = (await res.json()) as { commit: { sha: string } } + return data.commit.sha +} + +// ── Local note factory + path-update applier (mirrors the GitHub harness). ── +function makeNote(title: string, content: string): Note { + const now = Date.now() + return { + id: `${title}-id`, + title, + content, + folderId: null, + createdAt: now, + updatedAt: now, + isDeleted: false, + deletedAt: null, + isPinned: false, + templateId: null, + gitPath: null, + gitLastPushedSha: null, + gitRemoteBaseSha: null + } +} + +function applyPathUpdates( + notes: Note[], + updates: { + noteId: string + gitPath: string | null + gitLastPushedSha: string | null + gitRemoteBaseSha: string | null + }[] +): Note[] { + const byId = new Map(updates.map(u => [u.noteId, u])) + return notes.map(n => { + const u = byId.get(n.id) + if (!u) return n + return { + ...n, + gitPath: u.gitPath, + gitLastPushedSha: u.gitLastPushedSha, + gitRemoteBaseSha: u.gitRemoteBaseSha + } + }) +} + +const log = (msg: string) => console.log(` ${msg}`) +const newProvider = () => new ForgejoProvider(TOKEN!, BASE_URL) + +const maybe = TOKEN ? describe : describe.skip + +maybe('e2e Codeberg/Forgejo sync (live)', () => { + // Live API calls — give the suite a generous bound. runInBand + no loops to + // respect Codeberg's burst rate-limit. + jest.setTimeout(120_000) + + const stamp = Date.now() + // Spaced titles prove sanitizeFilename keeps spaces through the round-trip + // and a spaced path still classifies `unchanged` (no re-upload churn). + const titles = [1, 2, 3].map(i => `cb harness ${stamp} note ${i}`) + let notes: Note[] = titles.map((t, i) => + makeNote(t, `Note ${i + 1} body for ${t}\n`) + ) + let baselineHeadSha = '' + + beforeAll(async () => { + if (!TOKEN) return + await createHarnessBranch() + baselineHeadSha = await getHarnessHeadSha() + log( + `[setup] created ${HARNESS_BRANCH} from ${BASE_BRANCH} @ ${baselineHeadSha.slice(0, 8)}` + ) + }) + + afterAll(async () => { + if (!TOKEN) return + try { + await deleteHarnessBranch() + log(`[cleanup] deleted branch ${HARNESS_BRANCH}`) + } catch (err) { + log(`[cleanup] branch delete failed (ignored): ${(err as Error).message}`) + } + }) + + test('scenario 1: baseline pull on the empty harness branch → no conflict / remoteDeleted', async () => { + const pull = await pullFromGitHub({ + provider: newProvider(), + repo, + notes: [], + folders: [] + }) + const kinds = pull.classifications.reduce>( + (acc, c) => { + acc[c.kind] = (acc[c.kind] ?? 0) + 1 + return acc + }, + {} + ) + expect(pull.latestCommitSha).toMatch(/^[0-9a-f]{40}$/) + expect(kinds['remoteDeleted'] ?? 0).toBe(0) + expect(kinds['conflict'] ?? 0).toBe(0) + log( + `[scenario 1] baseline pull classifications: ${JSON.stringify(kinds)} (latestCommitSha ${pull.latestCommitSha.slice(0, 8)})` + ) + }) + + test('scenario 2: push 3 new notes → created === 3 + commitSha + head advanced', async () => { + const before = await getHarnessHeadSha() + const outcome = await syncToGitHub({ + provider: newProvider(), + repo, + notes, + folders: [] + }) + + expect(outcome.result.unchanged).toBe(false) + expect(outcome.result.created).toBe(3) + expect(outcome.result.updated).toBe(0) + expect(outcome.result.deleted).toBe(0) + expect(outcome.result.commitSha).toMatch(/^[0-9a-f]{40}$/) + expect(outcome.result.commitSha).not.toBe(before) + const after = await getHarnessHeadSha() + expect(after).toBe(outcome.result.commitSha) + + notes = applyPathUpdates(notes, outcome.pathUpdates) + expect(notes.every(n => n.gitPath && n.gitLastPushedSha)).toBe(true) + log( + `[scenario 2] pushed 3 notes: created=${outcome.result.created} commit=${outcome.result.commitSha.slice(0, 8)} (head ${before.slice(0, 8)} → ${after.slice(0, 8)})` + ) + }) + + test('scenario 3: re-pull with the 3 notes as local state → all unchanged, paths preserved', async () => { + const pull = await pullFromGitHub({ + provider: newProvider(), + repo, + notes, + folders: [] + }) + + const ourIds = new Set(notes.map(n => n.id)) + const ours = pull.classifications.filter( + c => 'noteId' in c && ourIds.has((c as { noteId: string }).noteId) + ) + expect(ours).toHaveLength(3) + for (const c of ours) expect(c.kind).toBe('unchanged') + + // No remoteCreated entry should match one of our note paths (the twin bug). + const ourPaths = new Set(notes.map(n => n.gitPath)) + const stray = pull.classifications.find( + c => + c.kind === 'remoteCreated' && ourPaths.has((c as { path: string }).path) + ) + expect(stray).toBeUndefined() + // Spaces in the title survived as spaces in the git path. + expect(notes.every(n => n.gitPath?.includes(' '))).toBe(true) + log( + `[scenario 3] re-pull: all 3 notes (spaced titles) classified unchanged, paths kept spaces, no duplicate remoteCreated` + ) + }) + + test('scenario 4: update one note → updated === 1 + a new commit exists', async () => { + const before = await getHarnessHeadSha() + notes = notes.map((n, i) => + i === 0 + ? { + ...n, + content: `${n.content}edited at ${Date.now()}\n`, + updatedAt: Date.now() + } + : n + ) + + const outcome = await syncToGitHub({ + provider: newProvider(), + repo, + notes, + folders: [] + }) + expect(outcome.result.unchanged).toBe(false) + expect(outcome.result.updated).toBe(1) + expect(outcome.result.created).toBe(0) + expect(outcome.result.deleted).toBe(0) + expect(outcome.result.commitSha).toMatch(/^[0-9a-f]{40}$/) + expect(outcome.result.commitSha).not.toBe(before) + const after = await getHarnessHeadSha() + expect(after).toBe(outcome.result.commitSha) + + notes = applyPathUpdates(notes, outcome.pathUpdates) + log( + `[scenario 4] updated 1 note: updated=${outcome.result.updated} new commit=${outcome.result.commitSha.slice(0, 8)} (head ${before.slice(0, 8)} → ${after.slice(0, 8)})` + ) + }) + + test('scenario 5: no-churn — re-push unchanged notes makes no new commit', async () => { + const before = await getHarnessHeadSha() + const outcome = await syncToGitHub({ + provider: newProvider(), + repo, + notes, + folders: [] + }) + + expect(outcome.result.unchanged).toBe(true) + expect(outcome.result.created).toBe(0) + expect(outcome.result.updated).toBe(0) + expect(outcome.result.deleted).toBe(0) + const after = await getHarnessHeadSha() + expect(after).toBe(before) + log( + `[scenario 5] re-push unchanged: unchanged=${outcome.result.unchanged}, head unchanged @ ${after.slice(0, 8)} (no commit)` + ) + }) + + test('scenario 6: delete one note (soft-delete) → removed remotely', async () => { + const target = notes[2] + const targetPath = target.gitPath! + expect(await getRemoteBlobSha(targetPath)).not.toBeNull() // present before delete + + notes = notes.map(n => + n.id === target.id ? { ...n, isDeleted: true, deletedAt: Date.now() } : n + ) + const outcome = await syncToGitHub({ + provider: newProvider(), + repo, + notes, + folders: [] + }) + expect(outcome.result.deleted).toBe(1) + expect(outcome.result.commitSha).toMatch(/^[0-9a-f]{40}$/) + + expect(await getRemoteBlobSha(targetPath)).toBeNull() // gone after delete + // Drop it locally so later scenarios don't re-reference it. + notes = notes.filter(n => n.id !== target.id) + log( + `[scenario 6] soft-delete: deleted=${outcome.result.deleted}, remote file ${targetPath} removed` + ) + }) + + // ── CONCURRENCY PROBE — the key open question (#17 / #24). ── + // Forgejo's ChangeFiles (POST /contents) takes a `branch` but NO expected + // parent/head sha — CommitRequest.parentSha is computed by the caller and + // (in the GitHub provider) used as the commit parent, but the Forgejo + // provider never sends it. So the question is: when a SECOND writer advances + // the branch out-of-band AFTER we captured our baseline, does ChangeFiles + // reject the now-stale write, silently merge it, or clobber the concurrent + // change? + // + // We drive `provider.commitChanges` DIRECTLY here (not syncToGitHub) for two + // reasons: (1) it is the exact ChangeFiles write path the seam exposes, and + // (2) syncToGitHub's read phase is GitHub-hardcoded (see syncPush.ts:155 → + // getBranchRefSha against api.github.com), so it never reaches the Forgejo + // write on Codeberg — see the report. Driving commitChanges directly gives a + // CLEAN answer to the concurrency question. + // + // Outcomes: + // - reject (4xx) → ChangeFiles enforces optimistic concurrency. SAFE. + // - silently merge → our edit AND the out-of-band file both survive + // (ChangeFiles edits only the named paths and + // carries the rest of the tree forward). SAFE. + // - overwrite/clobber → the out-of-band file is LOST. UNSAFE lost-update. + test('concurrency probe: stale-baseline ChangeFiles vs out-of-band edit', async () => { + const provider = newProvider() + + // (0) Seed a file we will edit, so the stale write is an UPDATE (the case + // that needs the current blob sha) and capture the head as our baseline. + const targetPath = `cb harness ${stamp} probe-target.md` + await writeRemoteFileOutOfBand( + targetPath, + `Initial probe-target body at ${Date.now()}\n` + ) + const baselineHead = await getHarnessHeadSha() + const targetSha = await getRemoteBlobSha(targetPath) + expect(targetSha).not.toBeNull() + + // (1) Out-of-band remote change: a SEPARATE new file via ChangeFiles, + // advancing the branch head PAST our captured baseline. + const oobPath = `cb harness ${stamp} out-of-band.md` + const oobBody = `Out-of-band content written directly via ChangeFiles at ${Date.now()}\n` + const oobHead = await writeRemoteFileOutOfBand(oobPath, oobBody) + expect(oobHead).not.toBe(baselineHead) + expect(await getRemoteBlobSha(oobPath)).not.toBeNull() + log( + `[probe] out-of-band write advanced head ${baselineHead.slice(0, 8)} → ${oobHead.slice(0, 8)} (${oobPath})` + ) + + // (2) Now WE commit an update to targetPath with the STALE baselineHead as + // parentSha — the classic concurrent-writer race. We hold targetSha + // (still valid: the OOB write touched a different path), so the only + // stale input is parentSha. + let pushError: Error | null = null + let result: Awaited> | null = null + try { + result = await provider.commitChanges(repo, { + branch: HARNESS_BRANCH, + parentSha: baselineHead, // STALE: head already moved to oobHead + message: `harness: stale-baseline update ${targetPath}`, + changes: [ + { + op: 'update', + path: targetPath, + content: `Edited under stale baseline at ${Date.now()}\n`, + sha: targetSha! + } + ] + }) + } catch (err) { + pushError = err as Error + } + + const oobSurvives = (await getRemoteBlobSha(oobPath)) !== null + const headAfter = await getHarnessHeadSha() + + if (pushError) { + log( + `[probe] RESULT = REJECT: ChangeFiles refused the stale-baseline write (${pushError.message}). Out-of-band file survives: ${oobSurvives}. Optimistic concurrency is ENFORCED — SAFE.` + ) + expect(oobSurvives).toBe(true) + } else if (oobSurvives) { + log( + `[probe] RESULT = MERGE/SAFE: ChangeFiles committed our update (head ${baselineHead.slice(0, 8)} → ${headAfter.slice(0, 8)}) despite the stale parentSha, AND the out-of-band file survives. ChangeFiles IGNORES parentSha and edits ONLY the named paths, carrying the rest of the tree forward — no optimistic-concurrency rejection, but ALSO no lost-update for the untouched OOB file.` + ) + expect(result).not.toBeNull() + expect(result!.committed).toBe(true) + expect(oobSurvives).toBe(true) + } else { + log( + `[probe] RESULT = CLOBBER/UNSAFE: ChangeFiles committed under the stale baseline but the out-of-band file was LOST (head ${baselineHead.slice(0, 8)} → ${headAfter.slice(0, 8)}). NO optimistic-concurrency guard AND it overwrote a concurrent remote change — DATA LOSS GAP (#17/#24).` + ) + expect(oobSurvives).toBe(true) // intentional fail to surface the gap + } + }) +}) diff --git a/src/__tests__/foreignVaultFilesPush.test.ts b/src/__tests__/foreignVaultFilesPush.test.ts index 3277a5b..9471f7e 100644 --- a/src/__tests__/foreignVaultFilesPush.test.ts +++ b/src/__tests__/foreignVaultFilesPush.test.ts @@ -15,6 +15,7 @@ */ import { syncToGitHub, _resetUploadedShaCache } from '../utils/githubSync' +import { GitHubProvider } from '../utils/gitHost/githubProvider' import type { Note, Folder, SyncRepo } from '@/types' jest.mock('../utils/attachments', () => ({ @@ -121,7 +122,7 @@ describe('syncToGitHub — foreign-kind notes never appear in the push plan', () global.fetch = fetchMock as unknown as typeof fetch const outcome = await syncToGitHub({ - token: 't', + provider: new GitHubProvider('t'), repo: REPO, notes: [foreignMirror], folders: [] as Folder[], @@ -161,7 +162,7 @@ describe('syncToGitHub — foreign-kind notes never appear in the push plan', () global.fetch = fetchMock as unknown as typeof fetch const outcome = await syncToGitHub({ - token: 't', + provider: new GitHubProvider('t'), repo: REPO, notes: [foreignMirror], folders: [] as Folder[], @@ -191,7 +192,7 @@ describe('syncToGitHub — foreign-kind notes never appear in the push plan', () global.fetch = fetchMock as unknown as typeof fetch const outcome = await syncToGitHub({ - token: 't', + provider: new GitHubProvider('t'), repo: REPO, notes: [md, foreignMirror], folders: [] as Folder[], diff --git a/src/__tests__/forgejoProvider.test.ts b/src/__tests__/forgejoProvider.test.ts new file mode 100644 index 0000000..2124729 --- /dev/null +++ b/src/__tests__/forgejoProvider.test.ts @@ -0,0 +1,490 @@ +/** + * @jest-environment node + * + * ForgejoProvider is the Forgejo/Gitea implementation of the GitHostProvider + * seam (docs/multi-host-sync-plan.md). Unlike GitHubProvider (a thin wrap of + * utils/github.ts), Forgejo talks to its own `{baseUrl}/api/v1` endpoints + * directly, so these tests mock global `fetch` and assert: + * + * - the Gitea-style auth header (`Authorization: token `) and the + * baseUrl default (codeberg) + override are applied to every request, + * - each read method hits the right URL and parses the right field — note + * getCommitTreeSha reads `.commit.tree.sha`, NOT a top-level `.tree.sha`, + * - getTreeMap keeps only `type === 'blob'` entries, + * - commitChanges maps a mixed create/update/delete batch onto the + * `ChangeFiles` payload (base64 content, sha on update/delete) in one POST, + * and short-circuits an empty change set with no network call, + * - fetchArchive targets the `/archive/{ref}.zip` endpoint. + */ + +import type { SyncRepo } from '@/types' +import { ForgejoProvider } from '../utils/gitHost/forgejoProvider' + +const TOKEN = 'pat-abc' +const REPO: SyncRepo = { + owner: 'octo', + name: 'vault', + branch: 'main', + isPrivate: true +} +const CODEBERG = 'https://codeberg.org' + +// Minimal mock Response. `ok` is derived from status; json()/arrayBuffer() +// hand back whatever the test queued. +function jsonResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + arrayBuffer: async () => new ArrayBuffer(0), + clone() { + return this + }, + headers: new Map() as unknown as Headers + } as unknown as Response +} + +function arrayBufferResponse(buf: ArrayBuffer, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => ({}), + arrayBuffer: async () => buf, + clone() { + return this + }, + headers: new Map() as unknown as Headers + } as unknown as Response +} + +let fetchMock: jest.Mock + +beforeEach(() => { + fetchMock = jest.fn() + global.fetch = fetchMock as unknown as typeof fetch +}) + +afterEach(() => { + jest.resetAllMocks() +}) + +// Pull the (url, init) of the Nth fetch call (0-indexed). +function call(n = 0): { url: string; init: RequestInit } { + const [url, init] = fetchMock.mock.calls[n] + return { url: url as string, init: (init ?? {}) as RequestInit } +} + +function authHeader(init: RequestInit): string | undefined { + const headers = (init.headers ?? {}) as Record + return headers['Authorization'] +} + +describe('ForgejoProvider — identity + auth', () => { + test('kind is forgejo and baseUrl defaults to codeberg', () => { + const p = new ForgejoProvider(TOKEN) + expect(p.kind).toBe('forgejo') + expect(p.baseUrl).toBe(CODEBERG) + }) + + test('baseUrl can be overridden for self-hosted instances', () => { + const p = new ForgejoProvider(TOKEN, 'https://git.example.com') + expect(p.baseUrl).toBe('https://git.example.com') + }) + + test('a trailing slash on the baseUrl is normalised away', () => { + const p = new ForgejoProvider(TOKEN, 'https://git.example.com/') + expect(p.baseUrl).toBe('https://git.example.com') + }) + + test('requests send the Gitea-style "token " auth header', async () => { + fetchMock.mockResolvedValue(jsonResponse({ object: { sha: 'x' } })) + const p = new ForgejoProvider(TOKEN) + await p.getBranchHeadSha(REPO) + expect(authHeader(call().init)).toBe(`token ${TOKEN}`) + }) +}) + +describe('ForgejoProvider — git-data read', () => { + test('getBranchHeadSha hits git/refs/heads and reads object.sha (object form)', async () => { + fetchMock.mockResolvedValue(jsonResponse({ object: { sha: 'head-sha' } })) + const p = new ForgejoProvider(TOKEN) + await expect(p.getBranchHeadSha(REPO)).resolves.toBe('head-sha') + expect(call().url).toBe( + `${CODEBERG}/api/v1/repos/octo/vault/git/refs/heads/main` + ) + }) + + test('getBranchHeadSha handles the array response form', async () => { + fetchMock.mockResolvedValue(jsonResponse([{ object: { sha: 'arr-sha' } }])) + const p = new ForgejoProvider(TOKEN) + await expect(p.getBranchHeadSha(REPO)).resolves.toBe('arr-sha') + }) + + test('getCommitTreeSha reads .commit.tree.sha (not top-level)', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ sha: 'commit-sha', commit: { tree: { sha: 'tree-sha' } } }) + ) + const p = new ForgejoProvider(TOKEN) + await expect(p.getCommitTreeSha(REPO, 'commit-sha')).resolves.toBe( + 'tree-sha' + ) + expect(call().url).toBe( + `${CODEBERG}/api/v1/repos/octo/vault/git/commits/commit-sha` + ) + }) + + test('getTreeMap requests recursively and keeps only blob entries', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + tree: [ + { path: 'a.md', type: 'blob', sha: 'sha-a' }, + { path: 'sub', type: 'tree', sha: 'sha-sub' }, + { path: 'sub/b.md', type: 'blob', sha: 'sha-b' } + ], + truncated: false, + total_count: 3 + }) + ) + const p = new ForgejoProvider(TOKEN) + const map = await p.getTreeMap(REPO, 'tree-sha') + expect(call().url).toBe( + `${CODEBERG}/api/v1/repos/octo/vault/git/trees/tree-sha?recursive=true&page=1` + ) + expect(map).toEqual( + new Map([ + ['a.md', 'sha-a'], + ['sub/b.md', 'sha-b'] + ]) + ) + }) + + // Forgejo has no ETag layer, so the cached variants are plain aliases — + // same endpoint, same result as getTreeMap / getBlobContent. + test('getTreeMapCached aliases the plain getTreeMap (no ETag layer)', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + tree: [{ path: 'a.md', type: 'blob', sha: 'sha-a' }], + truncated: false, + total_count: 1 + }) + ) + const p = new ForgejoProvider(TOKEN) + const map = await p.getTreeMapCached(REPO, 'tree-sha') + expect(call().url).toBe( + `${CODEBERG}/api/v1/repos/octo/vault/git/trees/tree-sha?recursive=true&page=1` + ) + expect(map).toEqual(new Map([['a.md', 'sha-a']])) + }) + + test('getBlobContentCached aliases the plain getBlobContent (no ETag layer)', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ content: btoa('# hi'), encoding: 'base64' }) + ) + const p = new ForgejoProvider(TOKEN) + await expect(p.getBlobContentCached(REPO, 'blob-sha')).resolves.toBe('# hi') + expect(call().url).toBe( + `${CODEBERG}/api/v1/repos/octo/vault/git/blobs/blob-sha` + ) + }) + + test('getTreeMap pages through a truncated tree', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse({ + tree: [{ path: 'a.md', type: 'blob', sha: 'sha-a' }], + truncated: true, + total_count: 2 + }) + ) + .mockResolvedValueOnce( + jsonResponse({ + tree: [{ path: 'b.md', type: 'blob', sha: 'sha-b' }], + truncated: false, + total_count: 2 + }) + ) + const p = new ForgejoProvider(TOKEN) + const map = await p.getTreeMap(REPO, 'tree-sha') + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(call(1).url).toBe( + `${CODEBERG}/api/v1/repos/octo/vault/git/trees/tree-sha?recursive=true&page=2` + ) + expect(map).toEqual( + new Map([ + ['a.md', 'sha-a'], + ['b.md', 'sha-b'] + ]) + ) + }) + + test('getBlobContent decodes base64 to a UTF-8 string', async () => { + const text = '# héllo' + const b64 = Buffer.from(text, 'utf-8').toString('base64') + fetchMock.mockResolvedValue( + jsonResponse({ content: b64, encoding: 'base64' }) + ) + const p = new ForgejoProvider(TOKEN) + await expect(p.getBlobContent(REPO, 'blob-sha')).resolves.toBe(text) + expect(call().url).toBe( + `${CODEBERG}/api/v1/repos/octo/vault/git/blobs/blob-sha` + ) + }) + + test('getBlobBytes returns raw bytes from the base64 content', async () => { + const bytes = new Uint8Array([0xff, 0x00, 0x10]) + const b64 = Buffer.from(bytes).toString('base64') + fetchMock.mockResolvedValue( + jsonResponse({ content: b64, encoding: 'base64' }) + ) + const p = new ForgejoProvider(TOKEN) + await expect(p.getBlobBytes(REPO, 'blob-sha')).resolves.toEqual(bytes) + }) +}) + +describe('ForgejoProvider — repo ops', () => { + function giteaRepo(over: Record = {}) { + return { + name: 'vault', + owner: { login: 'octo' }, + default_branch: 'main', + private: true, + ...over + } + } + + test('listRepos paginates and maps to HostRepo[]', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse( + Array.from({ length: 50 }, (_, i) => + giteaRepo({ name: `r${i}`, default_branch: 'main', private: false }) + ) + ) + ) + .mockResolvedValueOnce(jsonResponse([giteaRepo({ name: 'last' })])) + const p = new ForgejoProvider(TOKEN) + const repos = await p.listRepos() + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(call(0).url).toBe(`${CODEBERG}/api/v1/user/repos?limit=50&page=1`) + expect(repos).toHaveLength(51) + expect(repos[0]).toEqual({ + owner: 'octo', + name: 'r0', + defaultBranch: 'main', + isPrivate: false + }) + expect(repos[50]).toEqual({ + owner: 'octo', + name: 'last', + defaultBranch: 'main', + isPrivate: true + }) + }) + + test('getRepo maps a Gitea repo to HostRepo', async () => { + fetchMock.mockResolvedValue( + jsonResponse(giteaRepo({ name: 'vault', private: true })) + ) + const p = new ForgejoProvider(TOKEN) + await expect(p.getRepo('octo', 'vault')).resolves.toEqual({ + owner: 'octo', + name: 'vault', + defaultBranch: 'main', + isPrivate: true + }) + expect(call().url).toBe(`${CODEBERG}/api/v1/repos/octo/vault`) + }) + + test('listBranches maps {name}[] to string[]', async () => { + fetchMock.mockResolvedValue( + jsonResponse([{ name: 'main' }, { name: 'dev' }]) + ) + const p = new ForgejoProvider(TOKEN) + await expect(p.listBranches('octo', 'vault')).resolves.toEqual([ + 'main', + 'dev' + ]) + expect(call().url).toBe(`${CODEBERG}/api/v1/repos/octo/vault/branches`) + }) + + test('createRepo posts to /user/repos with auto_init and maps the result', async () => { + fetchMock.mockResolvedValue( + jsonResponse(giteaRepo({ name: 'fresh', private: false })) + ) + const p = new ForgejoProvider(TOKEN) + await expect(p.createRepo('fresh', false)).resolves.toEqual({ + owner: 'octo', + name: 'fresh', + defaultBranch: 'main', + isPrivate: false + }) + const { url, init } = call() + expect(url).toBe(`${CODEBERG}/api/v1/user/repos`) + expect(init.method).toBe('POST') + expect(JSON.parse(init.body as string)).toEqual({ + name: 'fresh', + private: false, + auto_init: true + }) + }) +}) + +describe('ForgejoProvider — commitChanges (ChangeFiles)', () => { + test('maps a mixed create/update/delete batch into one POST /contents', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + commit: { + sha: 'new-commit', + html_url: 'https://codeberg.org/octo/vault/commit/new-commit' + } + }) + ) + const onProgress = jest.fn() + const p = new ForgejoProvider(TOKEN) + const result = await p.commitChanges(REPO, { + branch: 'main', + parentSha: 'parent', + message: 'Sync from Noteser (3 changes)', + changes: [ + { op: 'create', path: 'new.md', content: '# new' }, + { op: 'update', path: 'old.md', content: '# old v2', sha: 'old-sha' }, + { op: 'delete', path: 'gone.md', sha: 'gone-sha' } + ], + onProgress + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const { url, init } = call() + expect(url).toBe(`${CODEBERG}/api/v1/repos/octo/vault/contents`) + expect(init.method).toBe('POST') + expect(authHeader(init)).toBe(`token ${TOKEN}`) + expect(JSON.parse(init.body as string)).toEqual({ + branch: 'main', + message: 'Sync from Noteser (3 changes)', + files: [ + { + operation: 'create', + path: 'new.md', + content: Buffer.from('# new', 'utf-8').toString('base64') + }, + { + operation: 'update', + path: 'old.md', + content: Buffer.from('# old v2', 'utf-8').toString('base64'), + sha: 'old-sha' + }, + { operation: 'delete', path: 'gone.md', sha: 'gone-sha' } + ] + }) + + expect(result).toEqual({ + commitSha: 'new-commit', + commitUrl: 'https://codeberg.org/octo/vault/commit/new-commit', + committed: true, + uploadedPaths: ['new.md', 'old.md'] + }) + expect(onProgress).toHaveBeenCalledWith({ phase: 'committing' }) + }) + + test('binary changes use contentBytes for the base64 content', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ commit: { sha: 'c', html_url: 'u' } }) + ) + const bytes = new Uint8Array([0xff, 0x00, 0x10]) + const p = new ForgejoProvider(TOKEN) + await p.commitChanges(REPO, { + branch: 'main', + parentSha: 'parent', + message: 'add image', + changes: [{ op: 'create', path: 'img.png', contentBytes: bytes }] + }) + const body = JSON.parse(call().init.body as string) + expect(body.files[0]).toEqual({ + operation: 'create', + path: 'img.png', + content: Buffer.from(bytes).toString('base64') + }) + }) + + test('an empty change set makes NO network call and reports committed:false', async () => { + const p = new ForgejoProvider(TOKEN) + const result = await p.commitChanges(REPO, { + branch: 'main', + parentSha: 'parent-sha', + message: 'noop', + changes: [] + }) + expect(fetchMock).not.toHaveBeenCalled() + expect(result).toEqual({ + commitSha: 'parent-sha', + commitUrl: null, + committed: false, + uploadedPaths: [] + }) + }) + + test('commitUrl falls back to null when the response omits html_url', async () => { + fetchMock.mockResolvedValue(jsonResponse({ commit: { sha: 'c' } })) + const p = new ForgejoProvider(TOKEN) + const result = await p.commitChanges(REPO, { + branch: 'main', + parentSha: 'parent', + message: 'm', + changes: [{ op: 'create', path: 'a.md', content: '# a' }] + }) + expect(result.commitUrl).toBeNull() + }) +}) + +describe('ForgejoProvider — fetchArchive', () => { + test('downloads the .zip archive for the ref as an ArrayBuffer', async () => { + const buf = new Uint8Array([1, 2, 3, 4]).buffer + fetchMock.mockResolvedValue(arrayBufferResponse(buf)) + const p = new ForgejoProvider(TOKEN) + await expect(p.fetchArchive(REPO, 'main')).resolves.toBe(buf) + expect(call().url).toBe( + `${CODEBERG}/api/v1/repos/octo/vault/archive/main.zip` + ) + }) +}) + +describe('ForgejoProvider — error handling', () => { + test('a non-ok response throws an error carrying the status', async () => { + fetchMock.mockResolvedValue(jsonResponse({ message: 'Not Found' }, 404)) + const p = new ForgejoProvider(TOKEN) + await expect(p.getRepo('octo', 'missing')).rejects.toThrow(/404/) + }) +}) + +describe('getAuthenticatedUser', () => { + it('maps the Gitea /user payload to HostUser', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + id: 42, + login: 'octo', + full_name: 'Octo Cat', + avatar_url: 'https://c.org/a.png' + }) + ) + const provider = new ForgejoProvider(TOKEN, CODEBERG) + const user = await provider.getAuthenticatedUser() + expect(fetchMock).toHaveBeenCalledWith( + `${CODEBERG}/api/v1/user`, + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: `token ${TOKEN}` }) + }) + ) + expect(user).toEqual({ + id: 42, + login: 'octo', + name: 'Octo Cat', + avatarUrl: 'https://c.org/a.png' + }) + }) + + it('throws ForgejoAPIError on a non-ok response', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'bad token' }, 401)) + const provider = new ForgejoProvider(TOKEN, CODEBERG) + await expect(provider.getAuthenticatedUser()).rejects.toThrow(/401/) + }) +}) diff --git a/src/__tests__/gitHostFactory.test.ts b/src/__tests__/gitHostFactory.test.ts new file mode 100644 index 0000000..2563c87 --- /dev/null +++ b/src/__tests__/gitHostFactory.test.ts @@ -0,0 +1,58 @@ +/** + * @jest-environment node + * + * makeGitHostProvider picks the provider from a connection's host kind. This + * is the seam that puts Forgejo in the sync flow — `useGitHubSync` calls it + * instead of `new GitHubProvider`. The behavior-preserving invariant: with + * host 'github' (the default for every existing user) it returns a + * GitHubProvider exactly as before. Forgejo gets a ForgejoProvider honoring + * the connection's baseUrl (null → the provider's codeberg.org default). + */ + +import { makeGitHostProvider } from '../utils/gitHost' +import { GitHubProvider } from '../utils/gitHost/githubProvider' +import { ForgejoProvider } from '../utils/gitHost/forgejoProvider' + +describe('makeGitHostProvider', () => { + it('returns a GitHubProvider for host "github"', () => { + const provider = makeGitHostProvider({ + host: 'github', + token: 'gh-tok', + baseUrl: null + }) + expect(provider).toBeInstanceOf(GitHubProvider) + expect(provider.kind).toBe('github') + expect(provider.baseUrl).toBe('https://api.github.com') + }) + + it('ignores baseUrl on GitHub (always the GitHub API base)', () => { + const provider = makeGitHostProvider({ + host: 'github', + token: 'gh-tok', + baseUrl: 'https://example.test' + }) + expect(provider).toBeInstanceOf(GitHubProvider) + expect(provider.baseUrl).toBe('https://api.github.com') + }) + + it('returns a ForgejoProvider with the supplied baseUrl for host "forgejo"', () => { + const provider = makeGitHostProvider({ + host: 'forgejo', + token: 'pat-tok', + baseUrl: 'https://forgejo.example.org' + }) + expect(provider).toBeInstanceOf(ForgejoProvider) + expect(provider.kind).toBe('forgejo') + expect(provider.baseUrl).toBe('https://forgejo.example.org') + }) + + it('falls back to codeberg.org when forgejo baseUrl is null', () => { + const provider = makeGitHostProvider({ + host: 'forgejo', + token: 'pat-tok', + baseUrl: null + }) + expect(provider).toBeInstanceOf(ForgejoProvider) + expect(provider.baseUrl).toBe('https://codeberg.org') + }) +}) diff --git a/src/__tests__/githubAuthModalHostPicker.test.tsx b/src/__tests__/githubAuthModalHostPicker.test.tsx new file mode 100644 index 0000000..ec32ea8 --- /dev/null +++ b/src/__tests__/githubAuthModalHostPicker.test.tsx @@ -0,0 +1,135 @@ +/** + * githubAuthModalHostPicker.test.tsx + * + * Tests for the host-selection step added to GitHubAuthModal (Task 2). + * Covers: + * - the modal opens to a host picker ("Choose your git host") + * - Codeberg PAT path: calls makeGitHostProvider, stores host + baseUrl + * - self-hosted Forgejo: rejects submit when base URL is missing + */ + +// idb-keyval is transitively required by the stores (noteStore → idbStorage). +// Mock it so jsdom doesn't fail on missing IndexedDB. +jest.mock('idb-keyval', () => ({ + get: jest.fn().mockResolvedValue(undefined), + set: jest.fn().mockResolvedValue(undefined), + del: jest.fn().mockResolvedValue(undefined), + keys: jest.fn().mockResolvedValue([]) +})) + +// Provider factory is the seam we assert against. +const mockGetAuthenticatedUser = jest.fn() +jest.mock('../utils/gitHost', () => ({ + makeGitHostProvider: jest.fn(() => ({ + getAuthenticatedUser: mockGetAuthenticatedUser + })), + hostUserToGitHubUser: (u: { + id: number + login: string + name: string | null + avatarUrl?: string + }) => ({ + id: u.id, + login: u.login, + name: u.name, + avatar_url: u.avatarUrl ?? '' + }) +})) + +// Prevent the GitHub device flow from making real HTTP calls in case +// a test accidentally triggers the github step. +jest.mock('../utils/github', () => ({ + startDeviceFlow: jest.fn( + () => + new Promise(() => { + /* never resolves */ + }) + ), + pollForToken: jest.fn( + () => + new Promise(() => { + /* never resolves */ + }) + ), + fetchGitHubUserAndScopes: jest.fn( + () => + new Promise(() => { + /* never resolves */ + }) + ), + DeviceFlowError: class DeviceFlowError extends Error { + code = 'unknown' + } +})) + +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { GitHubAuthModal } from '../components/modals/GitHubAuthModal' +import { useUIStore } from '../stores/uiStore' +import { useGitHubStore } from '../stores/githubStore' + +beforeEach(() => { + mockGetAuthenticatedUser.mockReset() + useGitHubStore.setState({ + token: null, + user: null, + host: 'github', + baseUrl: null, + syncRepo: null + }) + useUIStore.setState({ modal: { type: 'github-auth' } }) +}) + +it('shows the host picker first', () => { + render() + expect(screen.getByText(/choose your git host/i)).toBeInTheDocument() +}) + +it('connects a Codeberg vault via PAT and stores host+baseUrl', async () => { + mockGetAuthenticatedUser.mockResolvedValueOnce({ + id: 9, + login: 'cberg', + name: 'C', + avatarUrl: '' + }) + render() + fireEvent.click(screen.getByTestId('host-pick-codeberg')) + fireEvent.change(screen.getByTestId('forgejo-pat-input'), { + target: { value: 'pat-x' } + }) + fireEvent.click(screen.getByTestId('forgejo-pat-submit')) + await waitFor(() => expect(useGitHubStore.getState().host).toBe('forgejo')) + expect(useGitHubStore.getState().baseUrl).toBe('https://codeberg.org') + expect(useGitHubStore.getState().token).toBe('pat-x') +}) + +it('requires a base URL for self-hosted Forgejo', async () => { + render() + fireEvent.click(screen.getByTestId('host-pick-forgejo')) + fireEvent.change(screen.getByTestId('forgejo-pat-input'), { + target: { value: 'pat-x' } + }) + fireEvent.click(screen.getByTestId('forgejo-pat-submit')) + await waitFor(() => + expect(screen.getByText(/enter.*server url/i)).toBeInTheDocument() + ) + expect(mockGetAuthenticatedUser).not.toHaveBeenCalled() +}) + +it('rejects a base URL without an http(s) scheme', async () => { + // The base-URL field is type="text" (not type="url") so our own regex + // validation always runs — a type="url" input would let the browser + // silently block submit and our inline error would never show. + render() + fireEvent.click(screen.getByTestId('host-pick-forgejo')) + fireEvent.change(screen.getByTestId('forgejo-baseurl-input'), { + target: { value: 'notaurl' } + }) + fireEvent.change(screen.getByTestId('forgejo-pat-input'), { + target: { value: 'pat-x' } + }) + fireEvent.click(screen.getByTestId('forgejo-pat-submit')) + await waitFor(() => + expect(screen.getByText(/enter.*server url/i)).toBeInTheDocument() + ) + expect(mockGetAuthenticatedUser).not.toHaveBeenCalled() +}) diff --git a/src/__tests__/githubAuthModalPat.test.tsx b/src/__tests__/githubAuthModalPat.test.tsx index 885d767..5113f5c 100644 --- a/src/__tests__/githubAuthModalPat.test.tsx +++ b/src/__tests__/githubAuthModalPat.test.tsx @@ -34,7 +34,7 @@ jest.mock('../utils/github', () => { import React from 'react' import '@testing-library/jest-dom' -import { render, screen, waitFor } from '@testing-library/react' +import { render, screen, waitFor, fireEvent } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { GitHubAuthModal } from '../components/modals/GitHubAuthModal' @@ -54,8 +54,8 @@ beforeEach(() => { mockPollForToken.mockReset() useUIStore.setState({ modal: { type: null } }) useGitHubStore.setState({ token: null, user: null, tokenScopes: null, syncRepo: null }) - // Keep the device flow "pending" by default so the auto-started poll in the - // modal's open effect never resolves during PAT-path tests. + // Keep the device flow pending by default so the poll never resolves during + // PAT-path tests (they pick GitHub then toggle to the PAT sub-form). mockStartDeviceFlow.mockResolvedValue({ device_code: 'dc', user_code: 'WXYZ-1234', verification_uri: 'https://github.com/login/device', expires_in: 900, interval: 5, @@ -70,9 +70,9 @@ describe('GitHubAuthModal — PAT sign-in path', () => { const user = userEvent.setup() render() - // Let the auto-started device flow settle into its "waiting" view first, - // then switch to the PAT sub-form (mirrors real usage and avoids racing - // the toggle click against the device-code response). + // The modal now opens to the host picker. Pick GitHub to start the device + // flow, then wait for the "waiting" view before switching to the PAT form. + await user.click(screen.getByTestId('host-pick-github')) await screen.findByText('WXYZ-1234') await user.click(screen.getByTestId('github-pat-toggle')) await user.type(screen.getByTestId('github-pat-input'), PAT) @@ -92,9 +92,9 @@ describe('GitHubAuthModal — PAT sign-in path', () => { const user = userEvent.setup() render() - // Let the auto-started device flow settle into its "waiting" view first, - // then switch to the PAT sub-form (mirrors real usage and avoids racing - // the toggle click against the device-code response). + // The modal now opens to the host picker. Pick GitHub to start the device + // flow, then wait for the "waiting" view before switching to the PAT form. + await user.click(screen.getByTestId('host-pick-github')) await screen.findByText('WXYZ-1234') await user.click(screen.getByTestId('github-pat-toggle')) await user.type(screen.getByTestId('github-pat-input'), 'bad-token') @@ -121,6 +121,8 @@ describe('GitHubAuthModal — PAT sign-in path', () => { openAuthModal() render() + // The modal now opens to the host picker — pick GitHub to start the flow. + fireEvent.click(screen.getByTestId('host-pick-github')) await waitFor(() => expect(mockFetchUserAndScopes).toHaveBeenCalledWith('oauth_token_abc')) await waitFor(() => { const state = useGitHubStore.getState() diff --git a/src/__tests__/githubProvider.test.ts b/src/__tests__/githubProvider.test.ts new file mode 100644 index 0000000..225bd50 --- /dev/null +++ b/src/__tests__/githubProvider.test.ts @@ -0,0 +1,476 @@ +/** + * @jest-environment node + * + * GitHubProvider is the GitHub implementation of the GitHostProvider seam + * (docs/multi-host-sync-plan.md). It is a thin wrap of the existing + * functions in `utils/github.ts`: it must DELEGATE rather than reimplement + * any HTTP. These tests mock the github.ts module and assert: + * + * - each method forwards to the matching github.ts function with the + * token bound at construction and the SyncRepo mapped onto its + * positional (token, owner, repo, ...) args, + * - repo/branch shapes are mapped onto HostRepo / string[], + * - commitChanges performs the blob → tree → commit → ref sequence for a + * mixed create/update/delete batch (deletes = sha:null tree entries, + * binary uses createBlobBinary, base tree resolved from parentSha). + */ + +import type { SyncRepo, GitHubRepo } from '@/types' + +jest.mock('../utils/github', () => ({ + getBranchRefSha: jest.fn(), + getCommitTreeSha: jest.fn(), + getTreeMap: jest.fn(), + getBlobContent: jest.fn(), + getBlobBytes: jest.fn(), + fetchGitHubUser: jest.fn(), + listUserRepos: jest.fn(), + listRepoBranches: jest.fn(), + getRepo: jest.fn(), + createRepo: jest.fn(), + createBlob: jest.fn(), + createBlobBinary: jest.fn(), + createTree: jest.fn(), + createCommit: jest.fn(), + updateBranchRef: jest.fn(), + // commitChanges computes content-addressable blob SHAs locally to key its + // upload cache and to compare against the parent tree. Stubbed to a + // content-derived string so distinct content yields distinct cache keys. + gitBlobSha: jest.fn(async (content: string) => `sha:${content}`), + gitBlobShaBytes: jest.fn( + async (bytes: Uint8Array) => `sha-bytes:${bytes.length}` + ) +})) + +// The *Cached read variants delegate to the #69 ETag-conditional wrappers +// (used by the PULL path); the plain getTreeMap/getBlobContent above are for +// PUSH. Mock both so we can assert the split. +jest.mock('../utils/githubETagCache', () => ({ + getTreeMapConditional: jest.fn(), + getBlobContentConditional: jest.fn() +})) + +import * as github from '../utils/github' +import { fetchGitHubUser } from '../utils/github' +import * as etagCache from '../utils/githubETagCache' +import { + GitHubProvider, + _resetUploadedShaCache +} from '../utils/gitHost/githubProvider' + +const mock = github as jest.Mocked +const etagMock = etagCache as jest.Mocked + +const TOKEN = 'tok-123' +const REPO: SyncRepo = { + owner: 'octocat', + name: 'vault', + branch: 'main', + isPrivate: true +} + +function makeGitHubRepo(over: Partial = {}): GitHubRepo { + return { + id: 1, + name: 'vault', + full_name: 'octocat/vault', + owner: { login: 'octocat' }, + private: true, + default_branch: 'main', + updated_at: '2024-01-01T00:00:00Z', + ...over + } +} + +beforeEach(() => { + jest.clearAllMocks() + // The upload cache is module-level and persists across commitChanges calls + // (so a token-refresh retry skips already-uploaded blobs). Reset it between + // tests so cache state from one test can't leak into the next. + _resetUploadedShaCache() +}) + +describe('GitHubProvider — identity', () => { + test('kind is github and baseUrl defaults to the GitHub API base', () => { + const p = new GitHubProvider(TOKEN) + expect(p.kind).toBe('github') + expect(p.baseUrl).toBe('https://api.github.com') + }) + + test('baseUrl can be overridden via the constructor', () => { + const p = new GitHubProvider(TOKEN, 'https://github.example.com/api/v3') + expect(p.baseUrl).toBe('https://github.example.com/api/v3') + }) +}) + +describe('GitHubProvider — git-data read delegation', () => { + test('getBranchHeadSha → getBranchRefSha(token, owner, repo, branch)', async () => { + mock.getBranchRefSha.mockResolvedValue('head-sha') + const p = new GitHubProvider(TOKEN) + await expect(p.getBranchHeadSha(REPO)).resolves.toBe('head-sha') + expect(mock.getBranchRefSha).toHaveBeenCalledWith( + TOKEN, + 'octocat', + 'vault', + 'main' + ) + }) + + test('getCommitTreeSha → getCommitTreeSha(token, owner, repo, commitSha)', async () => { + mock.getCommitTreeSha.mockResolvedValue('tree-sha') + const p = new GitHubProvider(TOKEN) + await expect(p.getCommitTreeSha(REPO, 'commit-sha')).resolves.toBe( + 'tree-sha' + ) + expect(mock.getCommitTreeSha).toHaveBeenCalledWith( + TOKEN, + 'octocat', + 'vault', + 'commit-sha' + ) + }) + + test('getTreeMap → getTreeMap(token, owner, repo, treeSha)', async () => { + const treeMap = new Map([['a.md', 'sha-a']]) + mock.getTreeMap.mockResolvedValue(treeMap) + const p = new GitHubProvider(TOKEN) + await expect(p.getTreeMap(REPO, 'tree-sha')).resolves.toBe(treeMap) + expect(mock.getTreeMap).toHaveBeenCalledWith( + TOKEN, + 'octocat', + 'vault', + 'tree-sha' + ) + }) + + test('getBlobContent → getBlobContent(token, owner, repo, sha)', async () => { + mock.getBlobContent.mockResolvedValue('# hi') + const p = new GitHubProvider(TOKEN) + await expect(p.getBlobContent(REPO, 'blob-sha')).resolves.toBe('# hi') + expect(mock.getBlobContent).toHaveBeenCalledWith( + TOKEN, + 'octocat', + 'vault', + 'blob-sha' + ) + }) + + // The cached variants (PULL path) must hit the ETag-conditional wrappers, + // NOT the plain reads — that's the split that keeps PUSH byte-identical. + test('getTreeMapCached → getTreeMapConditional(token, repo, treeSha)', async () => { + const treeMap = new Map([['a.md', 'sha-a']]) + etagMock.getTreeMapConditional.mockResolvedValue(treeMap) + const p = new GitHubProvider(TOKEN) + await expect(p.getTreeMapCached(REPO, 'tree-sha')).resolves.toBe(treeMap) + expect(etagMock.getTreeMapConditional).toHaveBeenCalledWith( + TOKEN, + REPO, + 'tree-sha' + ) + expect(mock.getTreeMap).not.toHaveBeenCalled() + }) + + test('getBlobContentCached → getBlobContentConditional(token, repo, sha)', async () => { + etagMock.getBlobContentConditional.mockResolvedValue('# hi') + const p = new GitHubProvider(TOKEN) + await expect(p.getBlobContentCached(REPO, 'blob-sha')).resolves.toBe('# hi') + expect(etagMock.getBlobContentConditional).toHaveBeenCalledWith( + TOKEN, + REPO, + 'blob-sha' + ) + expect(mock.getBlobContent).not.toHaveBeenCalled() + }) + + test('getBlobBytes → getBlobBytes(token, owner, repo, sha)', async () => { + const bytes = new Uint8Array([1, 2, 3]) + mock.getBlobBytes.mockResolvedValue(bytes) + const p = new GitHubProvider(TOKEN) + await expect(p.getBlobBytes(REPO, 'blob-sha')).resolves.toBe(bytes) + expect(mock.getBlobBytes).toHaveBeenCalledWith( + TOKEN, + 'octocat', + 'vault', + 'blob-sha' + ) + }) +}) + +describe('GitHubProvider — repo op delegation + shape mapping', () => { + test('listRepos maps GitHubRepo[] → HostRepo[]', async () => { + mock.listUserRepos.mockResolvedValue([ + makeGitHubRepo({ + name: 'one', + owner: { login: 'octocat' }, + private: false, + default_branch: 'trunk' + }), + makeGitHubRepo({ + name: 'two', + owner: { login: 'someorg' }, + private: true, + default_branch: 'main' + }) + ]) + const p = new GitHubProvider(TOKEN) + const out = await p.listRepos() + expect(mock.listUserRepos).toHaveBeenCalledWith(TOKEN) + expect(out).toEqual([ + { + owner: 'octocat', + name: 'one', + defaultBranch: 'trunk', + isPrivate: false + }, + { owner: 'someorg', name: 'two', defaultBranch: 'main', isPrivate: true } + ]) + }) + + test('getRepo maps GitHubRepo → HostRepo', async () => { + mock.getRepo.mockResolvedValue( + makeGitHubRepo({ name: 'vault', default_branch: 'main', private: true }) + ) + const p = new GitHubProvider(TOKEN) + await expect(p.getRepo('octocat', 'vault')).resolves.toEqual({ + owner: 'octocat', + name: 'vault', + defaultBranch: 'main', + isPrivate: true + }) + expect(mock.getRepo).toHaveBeenCalledWith(TOKEN, 'octocat', 'vault') + }) + + test('listBranches maps {name}[] → string[]', async () => { + mock.listRepoBranches.mockResolvedValue([{ name: 'main' }, { name: 'dev' }]) + const p = new GitHubProvider(TOKEN) + await expect(p.listBranches('octocat', 'vault')).resolves.toEqual([ + 'main', + 'dev' + ]) + expect(mock.listRepoBranches).toHaveBeenCalledWith( + TOKEN, + 'octocat', + 'vault' + ) + }) + + test('createRepo maps GitHubRepo → HostRepo', async () => { + mock.createRepo.mockResolvedValue( + makeGitHubRepo({ name: 'fresh', default_branch: 'main', private: true }) + ) + const p = new GitHubProvider(TOKEN) + await expect(p.createRepo('fresh', true)).resolves.toEqual({ + owner: 'octocat', + name: 'fresh', + defaultBranch: 'main', + isPrivate: true + }) + expect(mock.createRepo).toHaveBeenCalledWith(TOKEN, 'fresh', true) + }) +}) + +describe('GitHubProvider — commitChanges blob→tree→commit→ref', () => { + test('performs the full sequence for a mixed create/update/delete batch', async () => { + mock.getCommitTreeSha.mockResolvedValue('base-tree') + mock.createBlob + .mockResolvedValueOnce('blob-create') + .mockResolvedValueOnce('blob-update') + mock.createTree.mockResolvedValue('new-tree') + mock.createCommit.mockResolvedValue({ + sha: 'new-commit', + html_url: 'https://github.com/octocat/vault/commit/new-commit' + }) + mock.updateBranchRef.mockResolvedValue(undefined) + + const p = new GitHubProvider(TOKEN) + const result = await p.commitChanges(REPO, { + branch: 'main', + parentSha: 'parent-commit', + message: 'Sync from Noteser (3 changes)', + changes: [ + { op: 'create', path: 'new.md', content: '# new' }, + { op: 'update', path: 'old.md', content: '# old v2' }, + { op: 'delete', path: 'gone.md', sha: 'gone-sha' } + ] + }) + + // Base tree resolved from the parent commit. + expect(mock.getCommitTreeSha).toHaveBeenCalledWith( + TOKEN, + 'octocat', + 'vault', + 'parent-commit' + ) + + // One blob per create/update; delete uploads nothing. + expect(mock.createBlob).toHaveBeenCalledTimes(2) + expect(mock.createBlob).toHaveBeenNthCalledWith( + 1, + TOKEN, + 'octocat', + 'vault', + '# new' + ) + expect(mock.createBlob).toHaveBeenNthCalledWith( + 2, + TOKEN, + 'octocat', + 'vault', + '# old v2' + ) + expect(mock.createBlobBinary).not.toHaveBeenCalled() + + // Tree built against the base tree; delete is a sha:null entry. + expect(mock.createTree).toHaveBeenCalledWith( + TOKEN, + 'octocat', + 'vault', + 'base-tree', + [ + { path: 'new.md', mode: '100644', type: 'blob', sha: 'blob-create' }, + { path: 'old.md', mode: '100644', type: 'blob', sha: 'blob-update' }, + { path: 'gone.md', mode: '100644', type: 'blob', sha: null } + ] + ) + + // Commit parented on parentSha, then fast-forward the branch. + expect(mock.createCommit).toHaveBeenCalledWith( + TOKEN, + 'octocat', + 'vault', + 'Sync from Noteser (3 changes)', + 'new-tree', + 'parent-commit' + ) + expect(mock.updateBranchRef).toHaveBeenCalledWith( + TOKEN, + 'octocat', + 'vault', + 'main', + 'new-commit' + ) + + expect(result).toEqual({ + commitSha: 'new-commit', + commitUrl: 'https://github.com/octocat/vault/commit/new-commit', + committed: true, + // Both create/update blobs were freshly uploaded; the delete carries no blob. + uploadedPaths: ['new.md', 'old.md'] + }) + }) + + test('no-op-tree skip: when the built tree equals the parent tree, no commit is created', async () => { + // createTree resolves to the SAME sha the parent commit's tree resolves + // to — the change set was a no-op (e.g. content round-tripped to identical + // bytes). The push must skip commit + ref and report committed:false. + mock.getCommitTreeSha.mockResolvedValue('same-tree') + mock.createBlob.mockResolvedValue('blob-x') + mock.createTree.mockResolvedValue('same-tree') + + const p = new GitHubProvider(TOKEN) + const result = await p.commitChanges(REPO, { + branch: 'main', + parentSha: 'parent-commit', + message: 'noop', + changes: [{ op: 'update', path: 'a.md', content: '# a' }] + }) + + expect(mock.createTree).toHaveBeenCalledTimes(1) + expect(mock.createCommit).not.toHaveBeenCalled() + expect(mock.updateBranchRef).not.toHaveBeenCalled() + expect(result).toEqual({ + commitSha: 'parent-commit', + commitUrl: null, + committed: false, + uploadedPaths: ['a.md'] + }) + }) + + test('upload cache: a second commit of the same content skips the blob POST', async () => { + mock.getCommitTreeSha.mockResolvedValue('base-tree') + mock.createBlob.mockResolvedValue('blob-1') + mock.createTree.mockResolvedValueOnce('tree-1') + mock.createCommit.mockResolvedValue({ sha: 'c1', html_url: 'u1' }) + mock.updateBranchRef.mockResolvedValue(undefined) + + const p = new GitHubProvider(TOKEN) + // First commit fails AFTER the blob upload but before clearing the cache, + // by throwing on createCommit — so the uploaded blob sha stays cached. + mock.createCommit.mockRejectedValueOnce(new Error('boom')) + await expect( + p.commitChanges(REPO, { + branch: 'main', + parentSha: 'parent', + message: 'first', + changes: [{ op: 'create', path: 'n.md', content: '# n' }] + }) + ).rejects.toThrow('boom') + expect(mock.createBlob).toHaveBeenCalledTimes(1) + + // Retry with the same content: the blob is cached → no second POST, and it + // is reported as skipped (not uploaded) so the caller suppresses the + // redundant path-metadata update. + mock.createCommit.mockResolvedValue({ sha: 'c2', html_url: 'u2' }) + const result = await p.commitChanges(REPO, { + branch: 'main', + parentSha: 'parent', + message: 'retry', + changes: [{ op: 'create', path: 'n.md', content: '# n' }] + }) + expect(mock.createBlob).toHaveBeenCalledTimes(1) // still only the first POST + expect(result.committed).toBe(true) + expect(result.uploadedPaths).toEqual([]) // served from cache, not transmitted + }) + + test('binary file changes go through createBlobBinary, not createBlob', async () => { + mock.getCommitTreeSha.mockResolvedValue('base-tree') + mock.createBlobBinary.mockResolvedValue('binary-blob') + mock.createTree.mockResolvedValue('new-tree') + mock.createCommit.mockResolvedValue({ sha: 'c', html_url: 'u' }) + mock.updateBranchRef.mockResolvedValue(undefined) + + const bytes = new Uint8Array([0xff, 0x00, 0x10]) + const p = new GitHubProvider(TOKEN) + await p.commitChanges(REPO, { + branch: 'main', + parentSha: 'parent', + message: 'add image', + changes: [{ op: 'create', path: 'img.png', contentBytes: bytes }] + }) + + expect(mock.createBlob).not.toHaveBeenCalled() + expect(mock.createBlobBinary).toHaveBeenCalledTimes(1) + const [tok, owner, repo, blobArg] = mock.createBlobBinary.mock.calls[0] + expect(tok).toBe(TOKEN) + expect(owner).toBe('octocat') + expect(repo).toBe('vault') + expect(blobArg).toBeInstanceOf(Blob) + expect(mock.createTree).toHaveBeenCalledWith( + TOKEN, + 'octocat', + 'vault', + 'base-tree', + [{ path: 'img.png', mode: '100644', type: 'blob', sha: 'binary-blob' }] + ) + }) +}) + +describe('getAuthenticatedUser', () => { + it('delegates to fetchGitHubUser and maps to HostUser', async () => { + ;(fetchGitHubUser as jest.Mock).mockResolvedValueOnce({ + id: 7, + login: 'mona', + name: 'Mona', + avatar_url: 'https://gh/a.png' + }) + const provider = new GitHubProvider('tok') + const user = await provider.getAuthenticatedUser() + expect(fetchGitHubUser).toHaveBeenCalledWith('tok') + expect(user).toEqual({ + id: 7, + login: 'mona', + name: 'Mona', + avatarUrl: 'https://gh/a.png' + }) + }) +}) diff --git a/src/__tests__/githubRepoModalHostBlind.test.tsx b/src/__tests__/githubRepoModalHostBlind.test.tsx new file mode 100644 index 0000000..fdf78c2 --- /dev/null +++ b/src/__tests__/githubRepoModalHostBlind.test.tsx @@ -0,0 +1,95 @@ +/** + * @jest-environment jsdom + * + * githubRepoModalHostBlind.test.tsx — asserts GitHubRepoModal fetches and + * creates repos through the active host's provider (makeGitHostProvider), + * not the GitHub-specific listUserRepos. + */ + +import { render, screen, waitFor, fireEvent } from '@testing-library/react' +import { GitHubRepoModal } from '../components/modals/GitHubRepoModal' +import { useUIStore, useGitHubStore } from '../stores' + +const listRepos = jest.fn() +const createRepo = jest.fn() +const makeGitHostProvider = jest.fn() +jest.mock('../utils/gitHost', () => ({ + makeGitHostProvider: (...args: unknown[]) => makeGitHostProvider(...args) +})) + +const switchVaultMock = jest.fn().mockResolvedValue(undefined) +jest.mock('../utils/switchVault', () => ({ + switchVault: (...args: unknown[]) => switchVaultMock(...args) +})) + +const runSyncMock = jest.fn().mockResolvedValue(undefined) +jest.mock('../hooks/useGitHubSync', () => ({ + useGitHubSync: () => ({ runSync: runSyncMock }) +})) + +jest.mock('idb-keyval', () => ({ + get: jest.fn().mockResolvedValue(undefined), + set: jest.fn().mockResolvedValue(undefined), + del: jest.fn().mockResolvedValue(undefined), + keys: jest.fn().mockResolvedValue([]) +})) + +beforeEach(() => { + listRepos.mockReset() + createRepo.mockReset() + switchVaultMock.mockClear() + runSyncMock.mockClear() + makeGitHostProvider.mockClear().mockReturnValue({ listRepos, createRepo }) + useGitHubStore.setState({ + token: 'pat-x', + host: 'forgejo', + baseUrl: 'https://codeberg.org', + syncRepo: null + }) + useUIStore.setState({ modal: { type: 'github-repo' } }) +}) + +it('lists repos through the active host provider', async () => { + listRepos.mockResolvedValueOnce([ + { owner: 'cberg', name: 'vault', defaultBranch: 'main', isPrivate: true } + ]) + render() + await waitFor(() => + expect(screen.getByText('cberg/vault')).toBeInTheDocument() + ) + expect(makeGitHostProvider).toHaveBeenCalledWith( + expect.objectContaining({ + host: 'forgejo', + token: 'pat-x', + baseUrl: 'https://codeberg.org' + }) + ) +}) + +it('calls provider.createRepo with the correct (name, isPrivate) args', async () => { + listRepos.mockResolvedValueOnce([]) + createRepo.mockResolvedValueOnce({ + owner: 'cberg', + name: 'my-vault', + defaultBranch: 'main', + isPrivate: true + }) + + render() + + // Wait for the list view to render (empty list is fine) + await waitFor(() => expect(screen.getByText('New repo')).toBeInTheDocument()) + + // Navigate to the create form + fireEvent.click(screen.getByText('New repo')) + + // Change the repo name from the default + const nameInput = screen.getByPlaceholderText('noteser-vault') + fireEvent.change(nameInput, { target: { value: 'my-vault' } }) + + // Private checkbox is checked by default — leave it as-is (isPrivate: true) + fireEvent.click(screen.getByText(/Create & Use/)) + + await waitFor(() => expect(createRepo).toHaveBeenCalledTimes(1)) + expect(createRepo).toHaveBeenCalledWith('my-vault', true) +}) diff --git a/src/__tests__/githubStore.test.ts b/src/__tests__/githubStore.test.ts new file mode 100644 index 0000000..8d02fc5 --- /dev/null +++ b/src/__tests__/githubStore.test.ts @@ -0,0 +1,95 @@ +/** + * @jest-environment jsdom + * + * githubStore carries the active sync connection. Phase 2 of the multi-host + * plan adds `host`/`baseUrl` so the sync pipeline can select a provider from + * the connection instead of hardcoding GitHub. + * + * The load-bearing invariant for existing users: the store has NO persist + * version, so Zustand merges a persisted blob over the initial state. A blob + * written by an older build (no `host` key) must therefore rehydrate to the + * default `host: 'github'`, `baseUrl: null` — i.e. behave exactly as before. + * This test pins that so a future persist-version bump can't silently break it. + */ + +import { STORAGE_KEYS } from '../utils/storageKeys' + +describe('githubStore host/baseUrl connection state', () => { + beforeEach(() => { + localStorage.clear() + jest.resetModules() + }) + + it('defaults host to "github" and baseUrl to null on a fresh store', async () => { + const { useGitHubStore } = await import('../stores/githubStore') + const state = useGitHubStore.getState() + expect(state.host).toBe('github') + expect(state.baseUrl).toBeNull() + }) + + it('rehydrates a legacy persisted blob (no host key) to host "github"', async () => { + // Simulate a blob written by an older build: a connected GitHub user with + // a sync repo but no `host`/`baseUrl` keys. + localStorage.setItem( + STORAGE_KEYS.github, + JSON.stringify({ + version: 0, + state: { + token: 'legacy-token', + user: { id: 1, login: 'octocat', name: 'Octo', avatar_url: '' }, + connectedAt: 123, + syncRepo: { + owner: 'octocat', + name: 'vault', + branch: 'main', + isPrivate: false + }, + lastSyncedAt: null, + lastCommitSha: null, + repoSyncStates: {}, + tokenScopes: ['repo'], + accessTokenExpiresAt: null, + refreshToken: null, + refreshTokenExpiresAt: null + } + }) + ) + + const { useGitHubStore } = await import('../stores/githubStore') + await useGitHubStore.persist.rehydrate() + + const state = useGitHubStore.getState() + // The persisted fields are restored… + expect(state.token).toBe('legacy-token') + expect(state.syncRepo).toEqual({ + owner: 'octocat', + name: 'vault', + branch: 'main', + isPrivate: false + }) + // …and the missing host/baseUrl fall back to the initial defaults. + expect(state.host).toBe('github') + expect(state.baseUrl).toBeNull() + }) + + it('setHost updates host + baseUrl and persists them', async () => { + const { useGitHubStore } = await import('../stores/githubStore') + useGitHubStore.getState().setHost('forgejo', 'https://codeberg.org') + + expect(useGitHubStore.getState().host).toBe('forgejo') + expect(useGitHubStore.getState().baseUrl).toBe('https://codeberg.org') + + const persisted = JSON.parse(localStorage.getItem(STORAGE_KEYS.github)!) + expect(persisted.state.host).toBe('forgejo') + expect(persisted.state.baseUrl).toBe('https://codeberg.org') + }) + + it('disconnect resets host/baseUrl to the GitHub defaults', async () => { + const { useGitHubStore } = await import('../stores/githubStore') + useGitHubStore.getState().setHost('forgejo', 'https://codeberg.org') + useGitHubStore.getState().disconnect() + + expect(useGitHubStore.getState().host).toBe('github') + expect(useGitHubStore.getState().baseUrl).toBeNull() + }) +}) diff --git a/src/__tests__/githubSyncClassify.test.ts b/src/__tests__/githubSyncClassify.test.ts index 0700bcc..1178c69 100644 --- a/src/__tests__/githubSyncClassify.test.ts +++ b/src/__tests__/githubSyncClassify.test.ts @@ -54,6 +54,7 @@ jest.mock('../utils/github', () => ({ })) import { pullFromGitHub } from '../utils/githubSync' +import { GitHubProvider } from '../utils/gitHost/githubProvider' import type { Note, Folder, SyncRepo } from '@/types' const REPO: SyncRepo = { owner: 'me', name: 'vault', branch: 'main', isPrivate: false } @@ -93,7 +94,7 @@ test('classifies a stable local note with matching remote SHA as unchanged', asy mockGitBlobSha.mockResolvedValue('sha-foo') const local: Note[] = [note({ id: '1', title: 'Foo', content: 'body', gitPath: 'Foo.md', gitLastPushedSha: 'sha-foo' })] - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: local, folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [] }) expect(classifications).toHaveLength(1) expect(classifications[0]).toEqual({ kind: 'unchanged', noteId: '1' }) @@ -107,7 +108,7 @@ test('classifies a remote-only file (no local match) as remoteCreated', async () mockGetTreeMap.mockResolvedValue(new Map([['Brand new.md', 'sha-new']])) mockGetBlobContent.mockResolvedValue('hello world') - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: [], folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: [], folders: [] }) expect(classifications).toHaveLength(1) expect(classifications[0]).toMatchObject({ @@ -128,7 +129,7 @@ test('remote changed + local untouched = remoteUpdated', async () => { const local: Note[] = [ note({ id: '1', title: 'Foo', content: 'old body', gitPath: 'Foo.md', gitLastPushedSha: 'sha-old' }), ] - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: local, folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [] }) expect(classifications).toHaveLength(1) expect(classifications[0]).toMatchObject({ @@ -164,7 +165,7 @@ test('non-overlapping local + remote edits auto-merge', async () => { const local: Note[] = [ note({ id: '1', title: 'Foo', content: localContent, gitPath: 'Foo.md', gitLastPushedSha: 'sha-ancestor' }), ] - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: local, folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [] }) expect(classifications).toHaveLength(1) expect(classifications[0].kind).toBe('autoMerged') @@ -186,7 +187,7 @@ test('overlapping local + remote edits on the same line = conflict', async () => const local: Note[] = [ note({ id: '1', title: 'Foo', content: localContent, gitPath: 'Foo.md', gitLastPushedSha: 'sha-ancestor' }), ] - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: local, folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [] }) expect(classifications).toHaveLength(1) expect(classifications[0]).toMatchObject({ @@ -207,7 +208,7 @@ test('no lastPushed sha → falls through to conflict instead of crashing', asyn const local: Note[] = [ note({ id: '1', title: 'Foo', content: 'local body', gitPath: 'Foo.md', gitLastPushedSha: null }), ] - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: local, folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [] }) expect(classifications).toHaveLength(1) expect(classifications[0].kind).toBe('conflict') @@ -224,7 +225,7 @@ test('local note with gitPath that disappeared remotely = remoteDeleted', async const local: Note[] = [ note({ id: '1', title: 'Foo', content: 'body', gitPath: 'Foo.md', gitLastPushedSha: 'sha-clean', updatedAt: 0 }), ] - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: local, folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [] }) expect(classifications).toHaveLength(1) expect(classifications[0]).toMatchObject({ kind: 'remoteDeleted', noteId: '1' }) @@ -244,7 +245,7 @@ test('remote deleted while local edited (sha drifted) = conflictDeleted', async gitLastPushedSha: 'sha-old', }), ] - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: local, folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [] }) expect(classifications).toHaveLength(1) expect(classifications[0].kind).toBe('conflictDeleted') @@ -274,7 +275,7 @@ test('mixed batch: unchanged + remoteCreated + remoteUpdated in one pull', async note({ id: '1', title: 'Stable', content: 'stable body', gitPath: 'Stable.md', gitLastPushedSha: 'sha-stable' }), note({ id: '2', title: 'Drifted', content: 'drifted body', gitPath: 'Drifted.md', gitLastPushedSha: 'sha-drifted-old' }), ] - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: local, folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [] }) const kinds = classifications.map(c => c.kind).sort() expect(kinds).toEqual(['remoteCreated', 'remoteUpdated', 'unchanged']) @@ -314,7 +315,7 @@ test('bulk-delete + sync emits sha:null tree entries for every deleted note', as note({ id: '2', title: 'Note B', content: 'b body', gitPath: 'Note B.md', gitLastPushedSha: 'sha-b', isDeleted: true }), ] - const result = await syncToGitHub({ token: 't', repo: REPO, notes: local, folders: [] }) + const result = await syncToGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [] }) // The push step emitted a tree with TWO sha:null deletions, no blob uploads. expect(mockCreateBlob).not.toHaveBeenCalled() @@ -347,7 +348,7 @@ test('soft-deleted local note with matching gitPath is NOT classified as remoteC isDeleted: true, }), ] - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: local, folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [] }) // Classified as unchanged (no fetch, no apply churn). The push step's // delete-handling pass is what propagates the deletion to the remote. @@ -367,7 +368,7 @@ test('non-.md entries route to separate kinds (attachments, folderCreated)', asy ])) mockGetBlobContent.mockResolvedValue('body') - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: [], folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: [], folders: [] }) const kinds = classifications.map(c => c.kind).sort() expect(kinds).toContain('remoteCreated') @@ -397,7 +398,7 @@ test('folder derivation skips parents of dying paths (deleted-folder re-derive b note({ id: '1', title: 'note', content: '', gitPath: '.foo/note.md', gitLastPushedSha: 'sha-foo', folderId: null }), ] - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: local, folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [] }) const folderCreates = classifications.filter(c => c.kind === 'folderCreated') expect(folderCreates).toHaveLength(0) @@ -411,7 +412,7 @@ test('folder derivation still fires for genuinely-remote folders', async () => { ])) mockGetBlobContent.mockResolvedValue('body') - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: [], folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: [], folders: [] }) const folderCreates = classifications.filter(c => c.kind === 'folderCreated') expect(folderCreates.map(c => (c as { path: string }).path)).toContain('.foo') @@ -427,7 +428,7 @@ test('folder derivation skips parents of soft-deleted notes', async () => { note({ id: '1', title: 'note', content: '', gitPath: '.foo/note.md', gitLastPushedSha: 'sha-foo', isDeleted: true }), ] - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: local, folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [] }) const folderCreates = classifications.filter(c => c.kind === 'folderCreated') expect(folderCreates).toHaveLength(0) @@ -447,7 +448,7 @@ test('excludedFolderPaths tombstones a hidden folder from being re-derived', asy ])) const { classifications } = await pullFromGitHub({ - token: 't', repo: REPO, notes: [], folders: [], + provider: new GitHubProvider('t'), repo: REPO, notes: [], folders: [], excludedFolderPaths: ['.obsidian'], }) @@ -464,7 +465,7 @@ test('excludedFolderPaths also blocks nested paths inside the tombstone', async ])) const { classifications } = await pullFromGitHub({ - token: 't', repo: REPO, notes: [], folders: [], + provider: new GitHubProvider('t'), repo: REPO, notes: [], folders: [], excludedFolderPaths: ['.obsidian'], }) @@ -481,7 +482,7 @@ test('excludedFolderPaths leaves OTHER folders alone', async () => { mockGetBlobContent.mockResolvedValue('body') const { classifications } = await pullFromGitHub({ - token: 't', repo: REPO, notes: [], folders: [], + provider: new GitHubProvider('t'), repo: REPO, notes: [], folders: [], excludedFolderPaths: ['.obsidian'], }) @@ -510,7 +511,7 @@ test('pull skips remote .md files matching a vault .gitignore', async () => { }) const { classifications } = await pullFromGitHub({ - token: 't', repo: REPO, notes: [], folders: [], + provider: new GitHubProvider('t'), repo: REPO, notes: [], folders: [], }) // The keeper survives; the ignored one is filtered out completely. @@ -547,7 +548,7 @@ test('vault settings conflict — local + remote both dirty since last sync', as })) const { classifications } = await pullFromGitHub({ - token: 't', repo: REPO, notes: [], folders: [], + provider: new GitHubProvider('t'), repo: REPO, notes: [], folders: [], vaultSettingsPath: '.noteser/settings.json', vaultSettingsLocalUpdatedAt: 1000, }) @@ -588,7 +589,7 @@ test('vault settings updates (not conflict) when local is clean', async () => { })) const { classifications } = await pullFromGitHub({ - token: 't', repo: REPO, notes: [], folders: [], + provider: new GitHubProvider('t'), repo: REPO, notes: [], folders: [], vaultSettingsPath: '.noteser/settings.json', vaultSettingsLocalUpdatedAt: 1000, }) @@ -616,7 +617,7 @@ test('pull combines the remote .gitignore with the local overlay (gi9n UI)', asy }) const { classifications } = await pullFromGitHub({ - token: 't', repo: REPO, notes: [], folders: [], + provider: new GitHubProvider('t'), repo: REPO, notes: [], folders: [], }) const paths = classifications @@ -636,7 +637,7 @@ test('pull applies the default OS-junk preset when no .gitignore exists', async ])) const { classifications } = await pullFromGitHub({ - token: 't', repo: REPO, notes: [], folders: [], + provider: new GitHubProvider('t'), repo: REPO, notes: [], folders: [], }) const attaches = classifications.filter(c => c.kind === 'attachmentCreated') @@ -664,7 +665,7 @@ test('PROBE: local DELETES a line that remote MODIFIED → must be conflict', as const local: Note[] = [ note({ id: '1', title: 'Foo', content: localContent, gitPath: 'Foo.md', gitLastPushedSha: 'sha-ancestor' }), ] - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: local, folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [] }) expect(classifications[0].kind).toBe('conflict') }) @@ -682,7 +683,7 @@ test('PROBE: local MODIFIES a line that remote DELETED → must be conflict', as const local: Note[] = [ note({ id: '1', title: 'Foo', content: localContent, gitPath: 'Foo.md', gitLastPushedSha: 'sha-ancestor' }), ] - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: local, folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [] }) expect(classifications[0].kind).toBe('conflict') }) @@ -700,7 +701,7 @@ test('PROBE: edits on CONSECUTIVE lines (no overlap) auto-merge — they should const local: Note[] = [ note({ id: '1', title: 'Foo', content: localContent, gitPath: 'Foo.md', gitLastPushedSha: 'sha-ancestor' }), ] - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: local, folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [] }) expect(classifications[0].kind).toBe('autoMerged') }) @@ -721,7 +722,7 @@ test('PROBE: local + remote add DIFFERENT lines at the same position → conflic const local: Note[] = [ note({ id: '1', title: 'Foo', content: localContent, gitPath: 'Foo.md', gitLastPushedSha: 'sha-ancestor' }), ] - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: local, folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [] }) expect(classifications[0].kind).toBe('conflict') }) @@ -742,7 +743,7 @@ test('PROBE: ancestor blob fetch FAILS → falls through to manual conflict (not const local: Note[] = [ note({ id: '1', title: 'Foo', content: localContent, gitPath: 'Foo.md', gitLastPushedSha: 'sha-ancestor' }), ] - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: local, folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [] }) expect(classifications[0].kind).toBe('conflict') }) @@ -767,7 +768,7 @@ test('REGRESSION GUARD: unpushed local note whose notePath matches a remote file const local: Note[] = [ note({ id: '1', title: 'Temp', content: 'local body', gitPath: null, gitLastPushedSha: null }), ] - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: local, folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [] }) expect(classifications).toHaveLength(1) // Must NOT be remoteCreated (that would create a duplicate note). @@ -792,7 +793,7 @@ test('reconcile adopt: byte-identical unpushed local note → unchanged + adoptP const local: Note[] = [ note({ id: '1', title: 'Temp', content: 'same body', gitPath: null, gitLastPushedSha: null }), ] - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: local, folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [] }) expect(classifications).toHaveLength(1) expect(classifications[0]).toMatchObject({ kind: 'unchanged', noteId: '1', adoptPath: 'Temp.md' }) @@ -807,7 +808,7 @@ test('genuinely new remote file with NO local counterpart is still remoteCreated const local: Note[] = [ note({ id: '9', title: 'Other', content: 'unrelated', gitPath: null }), ] - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: local, folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [] }) const created = classifications.filter(c => c.kind === 'remoteCreated') expect(created).toHaveLength(1) @@ -828,7 +829,7 @@ test('reconcile adopt: STALE gitPath (not in remote tree) but notePath matches r const local: Note[] = [ note({ id: '1', title: 'Temp', content: 'local body', gitPath: 'Old.md', gitLastPushedSha: 'sha-old', gitRemoteBaseSha: 'sha-old' }), ] - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: local, folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [] }) // No remoteCreated for Temp.md (no twin). expect(classifications.find(c => c.kind === 'remoteCreated')).toBeUndefined() @@ -856,7 +857,7 @@ test('reconcile adopt is conservative when AMBIGUOUS: two unlinked notes map to note({ id: '1', title: 'Temp', content: 'one', gitPath: null }), note({ id: '2', title: 'Temp', content: 'two', gitPath: null }), ] - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: local, folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [] }) // Ambiguous + no clean SHA match → remoteCreated (conservative). Neither // local note is adopted; they fall to the orphan pass as never-synced @@ -884,7 +885,7 @@ test('reconcile adopt resolves AMBIGUITY via clean blob-SHA match', async () => note({ id: '1', title: 'Temp', content: 'different', gitPath: null }), note({ id: '2', title: 'Temp', content: 'identical', gitPath: null }), ] - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: local, folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [] }) // No duplicate created. expect(classifications.find(c => c.kind === 'remoteCreated')).toBeUndefined() @@ -907,7 +908,7 @@ test('PROBE: identical local + remote content despite drifted ancestor → uncha const local: Note[] = [ note({ id: '1', title: 'Foo', content: 'same content', gitPath: 'Foo.md', gitLastPushedSha: 'sha-ancestor' }), ] - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: local, folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [] }) expect(classifications[0].kind).toBe('unchanged') }) @@ -920,7 +921,7 @@ test('PROBE: identical local + remote content despite drifted ancestor → uncha test('classifies a remote .canvas file with no local match as foreignFile', async () => { mockGetTreeMap.mockResolvedValue(new Map([['Untitled.canvas', 'sha-canvas']])) - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: [], folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: [], folders: [] }) // Exactly one classification, exactly the foreignFile entry — no body fetch. const foreign = classifications.filter(c => c.kind === 'foreignFile') @@ -935,7 +936,7 @@ test('classifies both a .canvas and a .base remote file as foreignFile', async ( ['Untitled.base', 'sha-base'], ])) - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: [], folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: [], folders: [] }) const foreign = classifications.filter(c => c.kind === 'foreignFile') expect(foreign).toHaveLength(2) @@ -961,7 +962,7 @@ test('does not re-emit foreignFile when a local foreign note already mirrors the // Tag it as foreign — note() helper doesn't expose `kind` directly. ;(local[0] as { kind?: string }).kind = 'foreign' - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: local, folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [] }) expect(classifications.find(c => c.kind === 'foreignFile')).toBeUndefined() }) @@ -984,7 +985,7 @@ test('local foreign mirror whose remote file is gone classifies as remoteDeleted ] ;(local[0] as { kind?: string }).kind = 'foreign' - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: local, folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [] }) expect(classifications).toHaveLength(1) expect(classifications[0]).toEqual({ kind: 'remoteDeleted', noteId: '1' }) @@ -1005,7 +1006,7 @@ test('non-md non-attachment file under an ignored path does NOT classify as fore ['ignored.canvas', 'sha-ignored'], ])) - const { classifications } = await pullFromGitHub({ token: 't', repo: REPO, notes: [], folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: [], folders: [] }) const foreign = classifications.filter(c => c.kind === 'foreignFile') expect(foreign).toHaveLength(1) diff --git a/src/__tests__/githubSyncGaps.test.ts b/src/__tests__/githubSyncGaps.test.ts index 1d96445..321052f 100644 --- a/src/__tests__/githubSyncGaps.test.ts +++ b/src/__tests__/githubSyncGaps.test.ts @@ -67,6 +67,7 @@ import { pullFromGitHub, syncToGitHub, } from '../utils/githubSync' +import { GitHubProvider } from '../utils/gitHost/githubProvider' import type { Note, Folder, SyncRepo } from '@/types' const REPO: SyncRepo = { owner: 'me', name: 'vault', branch: 'main', isPrivate: false } @@ -337,7 +338,7 @@ describe('pullFromGitHub — isFirstClone shell classification', () => { ])) const { classifications } = await pullFromGitHub({ - token: 't', repo: REPO, notes: [], folders: [], + provider: new GitHubProvider('t'), repo: REPO, notes: [], folders: [], isFirstClone: true, }) @@ -362,7 +363,7 @@ describe('pullFromGitHub — isFirstClone shell classification', () => { mockGetBlobContent.mockResolvedValue('real content') const { classifications } = await pullFromGitHub({ - token: 't', repo: REPO, notes: [], folders: [], + provider: new GitHubProvider('t'), repo: REPO, notes: [], folders: [], isFirstClone: false, }) @@ -393,7 +394,7 @@ describe('pullFromGitHub — contentLoaded=false safety guard', () => { }) const { classifications } = await pullFromGitHub({ - token: 't', repo: REPO, + provider: new GitHubProvider('t'), repo: REPO, notes: [shellNote], folders: [], }) @@ -419,7 +420,7 @@ describe('pullFromGitHub — remoteDeleted edge cases', () => { note({ id: '1', title: 'Gone', content: 'body', gitPath: 'Gone.md', gitLastPushedSha: null }), ] const { classifications } = await pullFromGitHub({ - token: 't', repo: REPO, notes: local, folders: [], + provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [], }) expect(classifications).toHaveLength(1) @@ -435,7 +436,7 @@ describe('pullFromGitHub — remoteDeleted edge cases', () => { note({ id: '1', title: 'Gone', content: 'body', gitPath: 'Gone.md', gitLastPushedSha: 'sha-last-push' }), ] const { classifications } = await pullFromGitHub({ - token: 't', repo: REPO, notes: local, folders: [], + provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [], }) expect(classifications).toHaveLength(1) @@ -457,7 +458,7 @@ describe('pullFromGitHub — special-character filenames round-trip as unchanged note({ id: '1', title: 'R&D Work', content: 'content', gitPath: path, gitLastPushedSha: 'sha-rd' }), ] const { classifications } = await pullFromGitHub({ - token: 't', repo: REPO, notes: local, folders: [], + provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [], }) expect(classifications).toHaveLength(1) @@ -474,7 +475,7 @@ describe('pullFromGitHub — special-character filenames round-trip as unchanged note({ id: '1', title: "Jake's project", content: 'notes', gitPath: path, gitLastPushedSha: 'sha-j' }), ] const { classifications } = await pullFromGitHub({ - token: 't', repo: REPO, notes: local, folders: [], + provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [], }) expect(classifications).toHaveLength(1) @@ -490,7 +491,7 @@ describe('pullFromGitHub — special-character filenames round-trip as unchanged note({ id: '1', title: 'My Daily Note', gitPath: path, gitLastPushedSha: 'sha-daily' }), ] const { classifications } = await pullFromGitHub({ - token: 't', repo: REPO, notes: local, folders: [], + provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [], }) expect(classifications[0]).toMatchObject({ kind: 'unchanged' }) @@ -506,7 +507,7 @@ describe('pullFromGitHub — special-character filenames round-trip as unchanged note({ id: '1', title: 'config', folderId: 'f1', gitPath: path, gitLastPushedSha: 'sha-obs' }), ] const { classifications } = await pullFromGitHub({ - token: 't', repo: REPO, notes: local, folders, + provider: new GitHubProvider('t'), repo: REPO, notes: local, folders, }) expect(classifications[0]).toMatchObject({ kind: 'unchanged' }) @@ -522,7 +523,7 @@ describe('pullFromGitHub — special-character filenames round-trip as unchanged note({ id: '1', title: 'deleted', folderId: 'f1', gitPath: path, gitLastPushedSha: 'sha-trash' }), ] const { classifications } = await pullFromGitHub({ - token: 't', repo: REPO, notes: local, folders, + provider: new GitHubProvider('t'), repo: REPO, notes: local, folders, }) expect(classifications[0]).toMatchObject({ kind: 'unchanged' }) @@ -549,7 +550,7 @@ describe('syncToGitHub — special character filename handling', () => { mockGitBlobSha.mockResolvedValue('sha-local') const local = [note({ id: '1', title: 'R&D Work', content: 'body' })] - await syncToGitHub({ token: 't', repo: REPO, notes: local, folders: [] }) + await syncToGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [] }) expect(mockCreateBlob).toHaveBeenCalledTimes(1) expect(mockCreateTree).toHaveBeenCalledTimes(1) @@ -573,7 +574,7 @@ describe('syncToGitHub — special character filename handling', () => { gitRemoteBaseSha: 'sha-existing', }), ] - const result = await syncToGitHub({ token: 't', repo: REPO, notes: local, folders: [] }) + const result = await syncToGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [] }) expect(result.result.unchanged).toBe(true) expect(mockCreateBlob).not.toHaveBeenCalled() @@ -603,7 +604,7 @@ describe('pullFromGitHub — remoteUpdated vs unchanged with dual-SHA tracking', }), ] const { classifications } = await pullFromGitHub({ - token: 't', repo: REPO, notes: local, folders: [], + provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [], }) expect(classifications).toHaveLength(1) @@ -629,7 +630,7 @@ describe('pullFromGitHub — remoteUpdated vs unchanged with dual-SHA tracking', }), ] const { classifications } = await pullFromGitHub({ - token: 't', repo: REPO, notes: local, folders: [], + provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [], }) expect(classifications).toHaveLength(1) diff --git a/src/__tests__/githubSyncRoundtrip.test.ts b/src/__tests__/githubSyncRoundtrip.test.ts index 4709c72..488ca8b 100644 --- a/src/__tests__/githubSyncRoundtrip.test.ts +++ b/src/__tests__/githubSyncRoundtrip.test.ts @@ -69,6 +69,7 @@ jest.mock('../utils/github', () => { }) import { pullFromGitHub } from '../utils/githubSync' +import { GitHubProvider } from '../utils/gitHost/githubProvider' import { applyNonConflicts } from '../utils/syncApply' import { gitBlobSha } from '../utils/github' import { useNoteStore } from '../stores/noteStore' @@ -104,7 +105,7 @@ test('REPRO (a): pulled frontmatter note round-trips to `unchanged` on the next mockGetTreeMap.mockResolvedValue(new Map([['Note.md', remoteSha]])) mockGetBlobContent.mockResolvedValue(rawRemote) - const first = await pullFromGitHub({ token: 't', repo: REPO, notes: [], folders: [] }) + const first = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: [], folders: [] }) const created = first.classifications.find(c => c.kind === 'remoteCreated') expect(created).toBeDefined() @@ -123,7 +124,7 @@ test('REPRO (a): pulled frontmatter note round-trips to `unchanged` on the next mockGetBlobContent.mockResolvedValue(rawRemote) const second = await pullFromGitHub({ - token: 't', repo: REPO, + provider: new GitHubProvider('t'), repo: REPO, notes: useNoteStore.getState().notes, folders: [], }) @@ -159,7 +160,7 @@ test('REPRO (b): untouched local + remote-only edit must be `remoteUpdated`, nev // 1) First pull + apply → seeds the local note (transformed, no frontmatter). mockGetTreeMap.mockResolvedValue(new Map([['Note.md', originalSha]])) mockGetBlobContent.mockResolvedValue(rawOriginal) - const first = await pullFromGitHub({ token: 't', repo: REPO, notes: [], folders: [] }) + const first = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: [], folders: [] }) await applyNonConflicts(first.classifications) const noteId = useNoteStore.getState().notes[0].id @@ -185,7 +186,7 @@ test('REPRO (b): untouched local + remote-only edit must be `remoteUpdated`, nev }) const second = await pullFromGitHub({ - token: 't', repo: REPO, + provider: new GitHubProvider('t'), repo: REPO, notes: useNoteStore.getState().notes, folders: [], }) @@ -237,7 +238,7 @@ test('REPRO (c): unlinked local note matching a remote path is adopted on apply mockGetBlobContent.mockResolvedValue(rawRemote) const pull = await pullFromGitHub({ - token: 't', repo: REPO, + provider: new GitHubProvider('t'), repo: REPO, notes: useNoteStore.getState().notes, folders: [], }) @@ -293,7 +294,7 @@ test('REPRO (c2): non-identical unlinked local note adopts via remoteUpdated and mockGetBlobContent.mockResolvedValue(rawRemoteNew) const pull = await pullFromGitHub({ - token: 't', repo: REPO, + provider: new GitHubProvider('t'), repo: REPO, notes: useNoteStore.getState().notes, folders: [], }) @@ -364,7 +365,7 @@ test('REPRO (rename): unlinked note adopts a renamed remote file by CONTENT HASH mockGetBlobContent.mockResolvedValue(body) const pull = await pullFromGitHub({ - token: 't', repo: REPO, + provider: new GitHubProvider('t'), repo: REPO, notes: useNoteStore.getState().notes, folders: [], }) diff --git a/src/__tests__/githubSyncSafetyNet.test.ts b/src/__tests__/githubSyncSafetyNet.test.ts index 8a6e987..014a351 100644 --- a/src/__tests__/githubSyncSafetyNet.test.ts +++ b/src/__tests__/githubSyncSafetyNet.test.ts @@ -72,6 +72,7 @@ jest.mock('../utils/github', () => { }) import { syncToGitHub, serializeNote, _resetUploadedShaCache } from '../utils/githubSync' +import { GitHubProvider } from '../utils/gitHost/githubProvider' import { gitBlobSha } from '../utils/github' import type { Note, SyncRepo } from '@/types' @@ -125,7 +126,7 @@ test('safety net: soft-deleted note does NOT delete a path an active note curren // Remote tree has Doc.md at the live note's content sha → no real change. mockGetTreeMap.mockResolvedValue(new Map([['Doc.md', sha]])) - const out = await syncToGitHub({ token: 't', repo: REPO, notes: [live, ghost], folders: [] }) + const out = await syncToGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: [live, ghost], folders: [] }) // No deletion emitted, and no sha:null entry for Doc.md. expect(out.result.deleted).toBe(0) @@ -158,7 +159,7 @@ test('safety net: soft-deleted note does NOT delete a path a live note represent ['my-note.md', sha], ])) - const out = await syncToGitHub({ token: 't', repo: REPO, notes: [live, ghost], folders: [] }) + const out = await syncToGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: [live, ghost], folders: [] }) // The dash-form path is protected by the live note's CONTENT hash → no delete. expect(lastTreeEntries.find(e => e.path === 'my-note.md' && e.sha === null)).toBeUndefined() @@ -187,7 +188,7 @@ test('control: a genuinely orphaned soft-deleted note IS still deleted', async ( ['Gone.md', goneSha], ])) - const out = await syncToGitHub({ token: 't', repo: REPO, notes: [live, ghost], folders: [] }) + const out = await syncToGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: [live, ghost], folders: [] }) // Gone.md is not represented by any live note → it IS deleted. expect(lastTreeEntries.find(e => e.path === 'Gone.md' && e.sha === null)).toBeDefined() diff --git a/src/__tests__/githubSyncZipball.test.ts b/src/__tests__/githubSyncZipball.test.ts index 7daca36..db88b4b 100644 --- a/src/__tests__/githubSyncZipball.test.ts +++ b/src/__tests__/githubSyncZipball.test.ts @@ -51,6 +51,7 @@ jest.mock('../utils/githubFetch', () => ({ import JSZip from 'jszip' import { pullFromZipball, takeZipballAttachmentBytes } from '../utils/githubSync' +import { GitHubProvider } from '../utils/gitHost/githubProvider' import type { SyncRepo } from '@/types' const REPO: SyncRepo = { owner: 'me', name: 'vault', branch: 'main', isPrivate: false } @@ -129,7 +130,7 @@ test('pullFromZipball loads jszip via dynamic import and classifies .md files as }) wireFetch(zipBuffer) - const { classifications, latestCommitSha } = await pullFromZipball({ token: 't', repo: REPO }) + const { classifications, latestCommitSha } = await pullFromZipball({ provider: new GitHubProvider('t'), repo: REPO }) expect(latestCommitSha).toBe(HEAD_SHA) @@ -158,7 +159,7 @@ test('pullFromZipball classifies files under the attachments folder as attachmen }) wireFetch(zipBuffer) - const { classifications } = await pullFromZipball({ token: 't', repo: REPO }) + const { classifications } = await pullFromZipball({ provider: new GitHubProvider('t'), repo: REPO }) const attach = classifications.find(c => c.kind === 'attachmentCreated') as { path: string; mime: string; remoteSha: string @@ -181,5 +182,5 @@ test('pullFromZipball does not throw on an empty repo (only ignored root files)' }) wireFetch(zipBuffer) - await expect(pullFromZipball({ token: 't', repo: REPO })).resolves.toBeDefined() + await expect(pullFromZipball({ provider: new GitHubProvider('t'), repo: REPO })).resolves.toBeDefined() }) diff --git a/src/__tests__/progressiveClone.test.ts b/src/__tests__/progressiveClone.test.ts index 8a4e049..7c52254 100644 --- a/src/__tests__/progressiveClone.test.ts +++ b/src/__tests__/progressiveClone.test.ts @@ -86,6 +86,7 @@ jest.mock('../utils/github', () => { }) import { pullFromGitHub, syncToGitHub, serializeNote } from '../utils/githubSync' +import { GitHubProvider } from '../utils/gitHost/githubProvider' import { gitBlobSha as realGitBlobSha } from '../utils/github' import { applyNonConflicts } from '../utils/syncApply' import { fillShellsInBackground, ensureNoteBodyLoaded, _resetFillInFlight } from '../utils/backgroundFill' @@ -197,7 +198,7 @@ describe('classifier guard: a shell classifies unchanged WITHOUT fetching its bl }) const { classifications } = await pullFromGitHub({ - token: 'tok', repo: REPO, notes: [shell], folders: [], + provider: new GitHubProvider('tok'), repo: REPO, notes: [shell], folders: [], }) const mine = classifications.filter(c => 'noteId' in c && (c as { noteId: string }).noteId === 's1') @@ -222,7 +223,7 @@ describe('classifier guard: a shell classifies unchanged WITHOUT fetching its bl content: '', contentLoaded: false, gitLastPushedSha: SHELL_SHA, gitRemoteBaseSha: SHELL_SHA, }) - const { classifications } = await pullFromGitHub({ token: 'tok', repo: REPO, notes: [shell], folders: [] }) + const { classifications } = await pullFromGitHub({ provider: new GitHubProvider('tok'), repo: REPO, notes: [shell], folders: [] }) const mine = classifications.filter(c => 'noteId' in c && (c as { noteId: string }).noteId === 's2') expect(mine).toHaveLength(1) expect(mine[0].kind).toBe('unchanged') @@ -392,7 +393,7 @@ describe('syncToGitHub never pushes an unfilled shell', () => { gitLastPushedSha: REMOTE_SHA, gitRemoteBaseSha: REMOTE_SHA, }) - const outcome = await syncToGitHub({ token: 'tok', repo: REPO, notes: [shell], folders: [] }) + const outcome = await syncToGitHub({ provider: new GitHubProvider('tok'), repo: REPO, notes: [shell], folders: [] }) // No blob upload, no tree, no commit, no ref update. expect(mockCreateBlob).not.toHaveBeenCalled() @@ -421,7 +422,7 @@ describe('syncToGitHub never pushes an unfilled shell', () => { content: 'Edited body now\n', contentLoaded: true, gitLastPushedSha: 'oldcanonical', gitRemoteBaseSha: REMOTE_SHA, }) - const outcome = await syncToGitHub({ token: 'tok', repo: REPO, notes: [filled], folders: [] }) + const outcome = await syncToGitHub({ provider: new GitHubProvider('tok'), repo: REPO, notes: [filled], folders: [] }) expect(mockCreateBlob).toHaveBeenCalled() expect(mockCreateCommit).toHaveBeenCalled() expect(outcome.result.unchanged).toBe(false) diff --git a/src/__tests__/publishGistModal.test.tsx b/src/__tests__/publishGistModal.test.tsx index 6b6f565..7cfc120 100644 --- a/src/__tests__/publishGistModal.test.tsx +++ b/src/__tests__/publishGistModal.test.tsx @@ -76,7 +76,7 @@ function seedToken(token: string | null = GITHUB_TOKEN) { // Seed with `repo gist` so the publish button renders by default. // Individual tests that need the "needs scope upgrade" flow can // override tokenScopes afterwards. - useGitHubStore.setState({ token, user: null, tokenScopes: token ? ['repo', 'gist'] : null }) + useGitHubStore.setState({ token, user: null, tokenScopes: token ? ['repo', 'gist'] : null, host: 'github' }) } beforeEach(() => { diff --git a/src/__tests__/pushOnlyRealEdits.test.ts b/src/__tests__/pushOnlyRealEdits.test.ts index dd76656..9703047 100644 --- a/src/__tests__/pushOnlyRealEdits.test.ts +++ b/src/__tests__/pushOnlyRealEdits.test.ts @@ -22,6 +22,7 @@ */ import { syncToGitHub, _resetUploadedShaCache, serializeNote } from '../utils/githubSync' +import { GitHubProvider } from '../utils/gitHost/githubProvider' import { gitBlobSha } from '../utils/github' import type { Note, Folder, SyncRepo } from '@/types' @@ -150,7 +151,7 @@ describe('syncToGitHub — push only on a real edit (churn fix)', () => { const { fetchMock, record } = makeFetchMock(new Map([['Note.md', remoteSha]])) global.fetch = fetchMock as unknown as typeof fetch - const outcome = await syncToGitHub({ token: 't', repo: REPO, notes: [note], folders: [] as Folder[] }) + const outcome = await syncToGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: [note], folders: [] as Folder[] }) // Nothing was pushed. expect(outcome.result.unchanged).toBe(true) @@ -180,7 +181,7 @@ describe('syncToGitHub — push only on a real edit (churn fix)', () => { const { fetchMock, record } = makeFetchMock(new Map([['Note.md', remoteSha]])) global.fetch = fetchMock as unknown as typeof fetch - const outcome = await syncToGitHub({ token: 't', repo: REPO, notes: [edited], folders: [] as Folder[] }) + const outcome = await syncToGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: [edited], folders: [] as Folder[] }) expect(outcome.result.unchanged).toBe(false) expect(outcome.result.updated).toBe(1) @@ -201,7 +202,7 @@ describe('syncToGitHub — push only on a real edit (churn fix)', () => { const { fetchMock, record } = makeFetchMock(new Map()) // empty remote tree global.fetch = fetchMock as unknown as typeof fetch - const outcome = await syncToGitHub({ token: 't', repo: REPO, notes: [fresh], folders: [] as Folder[] }) + const outcome = await syncToGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: [fresh], folders: [] as Folder[] }) expect(outcome.result.created).toBe(1) expect(record.blobsCreated).toHaveLength(1) @@ -228,7 +229,7 @@ describe('syncToGitHub — push only on a real edit (churn fix)', () => { const { fetchMock, record } = makeFetchMock(new Map([['Old.md', oldRemoteSha]])) global.fetch = fetchMock as unknown as typeof fetch - const outcome = await syncToGitHub({ token: 't', repo: REPO, notes: [moved], folders: [] as Folder[] }) + const outcome = await syncToGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: [moved], folders: [] as Folder[] }) // New path written (created — no remote blob there), old path deleted. expect(outcome.result.created).toBe(1) @@ -266,7 +267,7 @@ describe('syncToGitHub — push only on a real edit (churn fix)', () => { const { fetchMock, record } = makeFetchMock(remoteBlobs, blobReadBodies) global.fetch = fetchMock as unknown as typeof fetch - const outcome = await syncToGitHub({ token: 't', repo: REPO, notes: [legacy], folders: [] as Folder[] }) + const outcome = await syncToGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: [legacy], folders: [] as Folder[] }) // Nothing pushed — the byte-exact normalization check identified it as unedited. expect(outcome.result.unchanged).toBe(true) @@ -297,7 +298,7 @@ describe('syncToGitHub — push only on a real edit (churn fix)', () => { const { fetchMock, record } = makeFetchMock(remoteBlobs, blobReadBodies) global.fetch = fetchMock as unknown as typeof fetch - const outcome = await syncToGitHub({ token: 't', repo: REPO, notes: [edited], folders: [] as Folder[] }) + const outcome = await syncToGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: [edited], folders: [] as Folder[] }) // The normalized bodies DIFFER (real edit) → push goes through. expect(outcome.result.unchanged).toBe(false) diff --git a/src/__tests__/sourceControlHostGating.test.tsx b/src/__tests__/sourceControlHostGating.test.tsx new file mode 100644 index 0000000..fbbb6a7 --- /dev/null +++ b/src/__tests__/sourceControlHostGating.test.tsx @@ -0,0 +1,124 @@ +/** + * sourceControlHostGating.test.tsx + * + * Tests for the host gating of RecentCommits component in SourceControlPanel: + * - Hidden when host is not 'github'. + * - Visible when host is 'github' and token + repo are set. + */ + +jest.mock('idb-keyval', () => ({ + get: jest.fn().mockResolvedValue(undefined), + set: jest.fn().mockResolvedValue(undefined), + del: jest.fn().mockResolvedValue(undefined), + keys: jest.fn().mockResolvedValue([]) +})) + +jest.mock('../utils/githubHistory', () => ({ + listRecentCommits: jest.fn().mockResolvedValue([]), + formatRelativeAuthorDate: () => 'now' +})) + +import React from 'react' +import '@testing-library/jest-dom' +import { render, screen } from '@testing-library/react' + +import { SourceControlPanel } from '../components/sidebar/SourceControlPanel' +import { useGitHubStore } from '../stores/githubStore' +import { useNoteStore } from '../stores/noteStore' +import { useFolderStore } from '../stores/folderStore' + +beforeEach(() => { + useGitHubStore.setState({ + token: null, + user: null, + syncRepo: null, + lastCommitSha: null, + lastSyncedAt: null, + host: 'github', + baseUrl: 'https://github.com' + }) + useNoteStore.setState({ notes: [], selectedNoteId: null }) + useFolderStore.setState({ folders: [], activeFolderId: null }) +}) + +describe('SourceControlPanel — repo web link host routing', () => { + test('forgejo link uses the Codeberg base URL, not github.com', () => { + useGitHubStore.setState({ + token: 'pat-x', + host: 'forgejo', + baseUrl: 'https://codeberg.org', + syncRepo: { + owner: 'cberg', + name: 'vault', + branch: 'main', + isPrivate: true + }, + lastCommitSha: null + }) + render() + const link = screen.getByTestId('source-control-open-github') + expect(link.getAttribute('href')).toMatch( + /^https:\/\/codeberg\.org\/cberg\/vault/ + ) + expect(link.getAttribute('href')).not.toContain('github.com') + }) + + test('github link uses github.com (unchanged behavior)', () => { + useGitHubStore.setState({ + token: 'ghp_x', + host: 'github', + baseUrl: 'https://github.com', + syncRepo: { + owner: 'owner', + name: 'repo', + branch: 'main', + isPrivate: false + }, + lastCommitSha: null + }) + render() + const link = screen.getByTestId('source-control-open-github') + expect(link.getAttribute('href')).toMatch( + /^https:\/\/github\.com\/owner\/repo/ + ) + }) +}) + +describe('SourceControlPanel — RecentCommits host gating', () => { + test('hides recent commits on a non-GitHub host', () => { + useGitHubStore.setState({ + token: 'pat-x', + host: 'forgejo', + baseUrl: 'https://codeberg.org', + syncRepo: { + owner: 'c', + name: 'v', + branch: 'main', + isPrivate: true + }, + lastCommitSha: null + }) + render() + expect( + screen.queryByTestId('source-control-recent-commits') + ).not.toBeInTheDocument() + }) + + test('shows recent commits on GitHub host when token and repo are set', () => { + useGitHubStore.setState({ + token: 'ghp_x', + host: 'github', + syncRepo: { + owner: 'owner', + name: 'repo', + branch: 'main', + isPrivate: false + }, + lastCommitSha: null + }) + render() + expect( + screen.getByTestId('source-control-recent-commits') + ).toBeInTheDocument() + }) +}) diff --git a/src/__tests__/syncApply.test.ts b/src/__tests__/syncApply.test.ts index c404863..cdaba4d 100644 --- a/src/__tests__/syncApply.test.ts +++ b/src/__tests__/syncApply.test.ts @@ -72,6 +72,7 @@ import { bodyWithInlineTags, } from '../utils/syncApply' import { pullFromGitHub, serializeNote, type PullClassification } from '../utils/githubSync' +import { GitHubProvider } from '../utils/gitHost/githubProvider' import { gitBlobSha } from '../utils/github' import { useNoteStore } from '../stores/noteStore' import { useFolderStore } from '../stores/folderStore' @@ -391,7 +392,7 @@ test('ROUND-TRIP INVARIANT: a pulled frontmatter note re-classifies as `unchange mockGetTreeMap.mockResolvedValue(new Map([['Note.md', remoteSha]])) mockGetBlobContent.mockResolvedValue(rawRemote) - const first = await pullFromGitHub({ token: 't', repo: REPO, notes: [], folders: [] }) + const first = await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: [], folders: [] }) expect(first.classifications.find(c => c.kind === 'remoteCreated')).toBeDefined() await applyNonConflicts(first.classifications) @@ -412,7 +413,7 @@ test('ROUND-TRIP INVARIANT: a pulled frontmatter note re-classifies as `unchange mockGetBlobContent.mockResolvedValue(rawRemote) const second = await pullFromGitHub({ - token: 't', repo: REPO, + provider: new GitHubProvider('t'), repo: REPO, notes: useNoteStore.getState().notes, folders: [], }) diff --git a/src/__tests__/syncPullWritesSnapshot.test.ts b/src/__tests__/syncPullWritesSnapshot.test.ts index 5671aa8..7cfe0f3 100644 --- a/src/__tests__/syncPullWritesSnapshot.test.ts +++ b/src/__tests__/syncPullWritesSnapshot.test.ts @@ -47,6 +47,7 @@ jest.mock('../utils/github', () => ({ })) import { pullFromGitHub } from '../utils/githubSync' +import { GitHubProvider } from '../utils/gitHost/githubProvider' import { VAULT_CACHE_KEY_PREFIX } from '../utils/vaultSnapshotCache' import type { Note, SyncRepo } from '@/types' @@ -105,7 +106,7 @@ test('records a vault snapshot under noteser-vault-cache:/', async note({ id: '1', title: 'Foo', content: 'body', gitPath: 'Foo.md', gitLastPushedSha: 'sha-foo' }), ] - await pullFromGitHub({ token: 't', repo: REPO, notes: local, folders: [] }) + await pullFromGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes: local, folders: [] }) const key = `${VAULT_CACHE_KEY_PREFIX}${REPO.owner}/${REPO.name}` const snap = (await awaitWrite(key)) as { commitSha: string; treeMap: Array<[string, string]>; syncedAt: number } @@ -127,7 +128,7 @@ test('subsequent pulls overwrite the snapshot (SHA-driven invalidation)', async mockGetTreeMap.mockResolvedValue(new Map([['Foo.md', 'sha-foo']])) mockGitBlobSha.mockResolvedValue('sha-foo') await pullFromGitHub({ - token: 't', repo: REPO, + provider: new GitHubProvider('t'), repo: REPO, notes: [note({ id: '1', title: 'Foo', content: 'body', gitPath: 'Foo.md', gitLastPushedSha: 'sha-foo' })], folders: [], }) @@ -143,7 +144,7 @@ test('subsequent pulls overwrite the snapshot (SHA-driven invalidation)', async // pull writes the snapshot at the end. mockGitBlobSha.mockResolvedValue('sha-foo') await pullFromGitHub({ - token: 't', repo: REPO, + provider: new GitHubProvider('t'), repo: REPO, notes: [note({ id: '1', title: 'Foo', content: 'body', gitPath: 'Foo.md', gitLastPushedSha: 'sha-foo' })], folders: [], }) diff --git a/src/__tests__/syncPushProgress.test.ts b/src/__tests__/syncPushProgress.test.ts index 2d14acb..1923c77 100644 --- a/src/__tests__/syncPushProgress.test.ts +++ b/src/__tests__/syncPushProgress.test.ts @@ -18,6 +18,7 @@ */ import { syncToGitHub, _resetUploadedShaCache } from '../utils/githubSync' +import { GitHubProvider } from '../utils/gitHost/githubProvider' import type { PushProgress } from '../utils/githubSync' import type { Note, Folder, SyncRepo } from '@/types' @@ -114,7 +115,7 @@ describe('syncToGitHub — push progress events', () => { global.fetch = fetchMock as unknown as typeof fetch const phases: PushProgress[] = [] await syncToGitHub({ - token: 't', + provider: new GitHubProvider('t'), repo: REPO, notes: [makeNote('1', 'A', 'aaaa'), makeNote('2', 'B', 'bbbb')], folders: [] as Folder[], @@ -158,7 +159,7 @@ describe('syncToGitHub — push progress events', () => { // First attempt — expect a thrown error mid-loop. await expect(syncToGitHub({ - token: 't', + provider: new GitHubProvider('t'), repo: REPO, notes, folders: [] as Folder[], @@ -180,7 +181,7 @@ describe('syncToGitHub — push progress events', () => { })) as unknown as typeof fetch await syncToGitHub({ - token: 't', + provider: new GitHubProvider('t'), repo: REPO, notes, folders: [] as Folder[], @@ -195,13 +196,13 @@ describe('syncToGitHub — push progress events', () => { const fetchMock = makeFetchMock() global.fetch = fetchMock as unknown as typeof fetch const notes = [makeNote('1', 'A', 'one')] - await syncToGitHub({ token: 't', repo: REPO, notes, folders: [] as Folder[] }) + await syncToGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes, folders: [] as Folder[] }) // Force a deliberate cache miss by changing the remote tree's view. // The 2nd push should upload again because the cache is empty. const fetchMock2 = makeFetchMock() global.fetch = fetchMock2 as unknown as typeof fetch - await syncToGitHub({ token: 't', repo: REPO, notes, folders: [] as Folder[] }) + await syncToGitHub({ provider: new GitHubProvider('t'), repo: REPO, notes, folders: [] as Folder[] }) const blobPosts2 = fetchMock2.mock.calls.filter(c => String(c[0]).endsWith('/git/blobs') && (c[1] as RequestInit | undefined)?.method === 'POST').length expect(blobPosts2).toBe(1) }) diff --git a/src/__tests__/vaultEncryptionRoundtrip.test.ts b/src/__tests__/vaultEncryptionRoundtrip.test.ts index bbf9f23..5db71dd 100644 --- a/src/__tests__/vaultEncryptionRoundtrip.test.ts +++ b/src/__tests__/vaultEncryptionRoundtrip.test.ts @@ -9,6 +9,7 @@ */ import { syncToGitHub } from '../utils/githubSync' +import { GitHubProvider } from '../utils/gitHost/githubProvider' import { generateSalt, saltToString, isEncryptedContent, decryptNoteContent, deriveKey } from '../utils/vaultCrypto' import { unlockVault, lockVault, _resetVaultKeyForTests, VaultLockedError } from '../utils/vaultKey' import type { Note, Folder, SyncRepo } from '@/types' @@ -119,7 +120,7 @@ describe('vault encryption roundtrip', () => { global.fetch = fetchMock as unknown as typeof fetch await syncToGitHub({ - token: 't', + provider: new GitHubProvider('t'), repo: REPO, notes: [makeNote('1', 'A', 'Secret note body 🔐')], folders: [] as Folder[], @@ -140,7 +141,7 @@ describe('vault encryption roundtrip', () => { global.fetch = fetchMock as unknown as typeof fetch await syncToGitHub({ - token: 't', + provider: new GitHubProvider('t'), repo: REPO, notes: [makeNote('1', 'A', 'just plain text')], folders: [] as Folder[], @@ -160,7 +161,7 @@ describe('vault encryption roundtrip', () => { global.fetch = fetchMock as unknown as typeof fetch await expect(syncToGitHub({ - token: 't', + provider: new GitHubProvider('t'), repo: REPO, notes: [makeNote('1', 'A', 'cannot push')], folders: [] as Folder[], diff --git a/src/__tests__/zipballRetry.test.ts b/src/__tests__/zipballRetry.test.ts index 28a8080..6a6d9e7 100644 --- a/src/__tests__/zipballRetry.test.ts +++ b/src/__tests__/zipballRetry.test.ts @@ -65,6 +65,7 @@ jest.mock('jszip', () => ({ })) import { pullFromZipball } from '../utils/githubSync' +import { GitHubProvider } from '../utils/gitHost/githubProvider' import type { SyncRepo } from '@/types' const REPO: SyncRepo = { owner: 'me', name: 'vault', branch: 'main', isPrivate: false } @@ -114,7 +115,7 @@ test('retries a corrupted first attempt and SUCCEEDS without surfacing the error .mockResolvedValueOnce(fakeZipWithOneNote()) const onPhase = jest.fn() - const outcome = await runWithTimers(pullFromZipball({ token: 't', repo: REPO, onPhase })) + const outcome = await runWithTimers(pullFromZipball({ provider: new GitHubProvider('t'), repo: REPO, onPhase })) // It retried: download + parse each ran twice. expect(mockFetchZipball).toHaveBeenCalledTimes(2) @@ -133,7 +134,7 @@ test('also retries when fetchZipball itself throws (e.g. truncated-length guard) .mockResolvedValueOnce(new ArrayBuffer(8)) mockLoadAsync.mockResolvedValueOnce(fakeZipWithOneNote()) - const outcome = await runWithTimers(pullFromZipball({ token: 't', repo: REPO })) + const outcome = await runWithTimers(pullFromZipball({ provider: new GitHubProvider('t'), repo: REPO })) expect(mockFetchZipball).toHaveBeenCalledTimes(2) expect(outcome.classifications).toHaveLength(1) @@ -144,7 +145,7 @@ test('gives up and surfaces the error after the max attempts', async () => { mockLoadAsync.mockRejectedValue(err) await expect( - runWithTimers(pullFromZipball({ token: 't', repo: REPO })), + runWithTimers(pullFromZipball({ provider: new GitHubProvider('t'), repo: REPO })), ).rejects.toThrow("can't find end of central directory") // Three attempts total (MAX_ATTEMPTS), then the error is re-thrown. diff --git a/src/components/modals/GitHubAuthModal.tsx b/src/components/modals/GitHubAuthModal.tsx index faa80ea..2f751df 100644 --- a/src/components/modals/GitHubAuthModal.tsx +++ b/src/components/modals/GitHubAuthModal.tsx @@ -5,6 +5,7 @@ import { ArrowTopRightOnSquareIcon, CheckCircleIcon, ExclamationCircleIcon } fro import { Modal, Button, Input } from '@/components/ui' import { useUIStore, useGitHubStore } from '@/stores' import { startDeviceFlow, pollForToken, fetchGitHubUserAndScopes, DeviceFlowError, type DeviceFlowStart } from '@/utils/github' +import { makeGitHostProvider, hostUserToGitHubUser } from '@/utils/gitHost' type Status = | { kind: 'requesting' } @@ -19,31 +20,58 @@ export const GitHubAuthModal = () => { const closeModal = useUIStore(s => s.closeModal) const openModal = useUIStore(s => s.openModal) const setSession = useGitHubStore((s) => s.setSession) + const setHost = useGitHubStore((s) => s.setHost) const syncRepo = useGitHubStore((s) => s.syncRepo) const isOpen = modal.type === 'github-auth' + + // Step state: 'pick' is the initial host picker; 'github' is the device-flow + // + GitHub-PAT path; 'forgejo' is the Forgejo/Codeberg PAT form. + const [step, setStep] = useState<'pick' | 'github' | 'forgejo'>('pick') + + // GitHub device-flow / PAT state const [status, setStatus] = useState({ kind: 'requesting' }) const [copied, setCopied] = useState(false) const abortRef = useRef(null) - - // Alternative sign-in path: a user can paste a fine-grained PAT scoped to - // just their vault repo (Contents: read+write) instead of running the broad - // `repo`-scoped device flow. Default stays the one-click device flow; this - // is revealed only when `usePat` is toggled on. const [usePat, setUsePat] = useState(false) const [patValue, setPatValue] = useState('') const [patError, setPatError] = useState(null) const [patSubmitting, setPatSubmitting] = useState(false) - // Run the device flow whenever the modal opens; cancel on close. + // Forgejo / Codeberg state + const [forgejoPreset, setForgejoPreset] = useState<'codeberg' | 'custom'>('codeberg') + const [forgejoBaseUrl, setForgejoBaseUrl] = useState('') + const [forgejoPat, setForgejoPat] = useState('') + const [forgejoError, setForgejoError] = useState(null) + const [forgejoSubmitting, setForgejoSubmitting] = useState(false) + + // Reset to the host picker on modal open; do NOT auto-start the device flow. useEffect(() => { if (!isOpen) return - // Reset the PAT sub-form to its default (hidden) state each open so the - // device flow remains the default experience. + abortRef.current?.abort() + abortRef.current = null + setStep('pick') setUsePat(false) setPatValue('') setPatError(null) setPatSubmitting(false) + setForgejoPreset('codeberg') + setForgejoBaseUrl('') + setForgejoPat('') + setForgejoError(null) + setForgejoSubmitting(false) + setStatus({ kind: 'requesting' }) + setCopied(false) + return () => { + abortRef.current?.abort() + abortRef.current = null + } + }, [isOpen]) + + // Start (or restart) the GitHub OAuth device flow. Extracted so both the + // "pick GitHub" handler and the retry button can call it. + const startGitHubDeviceFlow = () => { + abortRef.current?.abort() const controller = new AbortController() abortRef.current = controller setStatus({ kind: 'requesting' }) @@ -89,13 +117,7 @@ export const GitHubAuthModal = () => { } } })() - return () => { - controller.abort() - abortRef.current = null - } - // closeModal/setSession are stable Zustand refs; safe to omit - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isOpen]) + } const handleClose = () => { abortRef.current?.abort() @@ -103,35 +125,29 @@ export const GitHubAuthModal = () => { } const handleRetry = () => { - abortRef.current?.abort() - // Toggle via setStatus to trigger the effect path; closeModal+reopen would also work. - setStatus({ kind: 'requesting' }) - const controller = new AbortController() - abortRef.current = controller - ;(async () => { - try { - const device = await startDeviceFlow() - if (controller.signal.aborted) return - setStatus({ kind: 'waiting', device }) - const tokenSet = await pollForToken({ - deviceCode: device.device_code, - interval: device.interval, - expiresIn: device.expires_in, - signal: controller.signal, - }) - if (controller.signal.aborted) return - const { user, scopes } = await fetchGitHubUserAndScopes(tokenSet.accessToken) - setSession(tokenSet.accessToken, user, scopes, tokenSet) - setStatus({ kind: 'success', login: user.login }) - setTimeout(() => { if (!controller.signal.aborted) closeModal() }, 1200) - } catch (err) { - if (controller.signal.aborted) return - const message = err instanceof Error ? err.message : 'Unknown error' - setStatus({ kind: 'error', message }) - } - })() + startGitHubDeviceFlow() } + // ── Host picker handlers ───────────────────────────────────────────────────── + + const handlePickGitHub = () => { + setHost('github', null) + setStep('github') + startGitHubDeviceFlow() + } + + const handlePickCodeberg = () => { + setForgejoPreset('codeberg') + setStep('forgejo') + } + + const handlePickForgejo = () => { + setForgejoPreset('custom') + setStep('forgejo') + } + + // ── GitHub PAT sub-form handlers ───────────────────────────────────────────── + // Reveal the PAT sub-form. Abort the in-flight device-flow polling so the // two paths can't both resolve and race on setSession. const handleShowPat = () => { @@ -179,6 +195,51 @@ export const GitHubAuthModal = () => { } } + // ── Forgejo / Codeberg PAT handler ─────────────────────────────────────────── + + const handleForgejoSubmit = async (e: React.FormEvent) => { + e.preventDefault() + const token = forgejoPat.trim() + if (!token || forgejoSubmitting) return + + // Validate the base URL for self-hosted Forgejo (Codeberg fixes the URL). + if (forgejoPreset === 'custom') { + const u = forgejoBaseUrl.trim() + if (!u || !/^https?:\/\//.test(u)) { + setForgejoError('Enter your Forgejo/Gitea server URL (https://…).') + return + } + } + + setForgejoSubmitting(true) + setForgejoError(null) + + try { + const baseUrl = forgejoPreset === 'codeberg' + ? 'https://codeberg.org' + : forgejoBaseUrl.trim().replace(/\/+$/, '') + const provider = makeGitHostProvider({ host: 'forgejo', token, baseUrl }) + const hostUser = await provider.getAuthenticatedUser() + setHost('forgejo', baseUrl) + setSession(token, hostUserToGitHubUser(hostUser)) + setStatus({ kind: 'success', login: hostUser.login }) + setTimeout(() => { syncRepo ? closeModal() : openModal({ type: 'github-repo' }) }, 1200) + } catch (err) { + // A missing read:user scope is the most common first-token mistake: the + // token can touch repos but /user is denied, so surface that specifically + // rather than a generic failure. Forgejo's 403 body literally names the + // required scope. + const msg = err instanceof Error ? err.message : '' + setForgejoError( + /scope|read:user|403/i.test(msg) + ? 'That token is missing a scope — it needs read:user plus repository read/write.' + : 'That token did not work — check the URL and that it has read:user and repository read/write.', + ) + } finally { + setForgejoSubmitting(false) + } + } + // Anchor's default click opens the new tab without tripping popup blockers. // We piggyback on the same click to copy synchronously (no await before the // browser sees the navigation intent). @@ -199,10 +260,29 @@ export const GitHubAuthModal = () => { // When the PAT sub-form is open it replaces the device-flow views (but not // the terminal success/error views, which the PAT path drives too). - const showDeviceViews = !usePat + const showDeviceViews = step === 'github' && !usePat return ( - + + {/* ── Host picker ────────────────────────────────────────────────── */} + {step === 'pick' && ( +
+

Choose your git host to connect your vault.

+
+ + + +
+
+ )} + + {/* ── GitHub device-flow views (gated behind step === 'github') ─── */} {showDeviceViews && status.kind === 'requesting' && (
@@ -245,7 +325,8 @@ export const GitHubAuthModal = () => {
)} - {usePat && status.kind !== 'success' && ( + {/* ── GitHub PAT sub-form ─────────────────────────────────────────── */} + {step === 'github' && usePat && status.kind !== 'success' && (

Create a fine-grained token in GitHub → Settings → Developer settings, scoped to your vault @@ -291,6 +372,63 @@ export const GitHubAuthModal = () => {

)} + {/* ── Forgejo / Codeberg PAT form ─────────────────────────────────── */} + {step === 'forgejo' && status.kind !== 'success' && ( +
+

+ {forgejoPreset === 'codeberg' + ? 'Paste a Codeberg personal access token with read:user and repository read/write scopes.' + : 'Provide your self-hosted Forgejo/Gitea URL and a personal access token with read:user and repository read/write scopes.'} +

+ + {forgejoPreset === 'custom' && ( + { setForgejoBaseUrl(e.target.value); setForgejoError(null) }} + data-testid="forgejo-baseurl-input" + autoFocus + /> + )} + + { setForgejoPat(e.target.value); setForgejoError(null) }} + error={forgejoError ?? undefined} + data-testid="forgejo-pat-input" + autoFocus={forgejoPreset === 'codeberg'} + /> + +
+ + +
+
+ )} + + {/* ── Shared success view (both GitHub and Forgejo paths end here) ─ */} {status.kind === 'success' && (
@@ -298,6 +436,7 @@ export const GitHubAuthModal = () => {
)} + {/* ── GitHub device-flow error view ───────────────────────────────── */} {showDeviceViews && status.kind === 'error' && (
diff --git a/src/components/modals/GitHubRepoModal.tsx b/src/components/modals/GitHubRepoModal.tsx index d7747e9..b70bcf4 100644 --- a/src/components/modals/GitHubRepoModal.tsx +++ b/src/components/modals/GitHubRepoModal.tsx @@ -13,12 +13,13 @@ import { } from '@heroicons/react/24/outline' import { Modal, Button } from '@/components/ui' import { useUIStore, useGitHubStore, useWorkspaceStore, useNoteStore, useFolderStore } from '@/stores' -import { listUserRepos, createRepo } from '@/utils/github' import { withTokenRefresh } from '@/utils/tokenRefresh' +import { makeGitHostProvider } from '@/utils/gitHost' +import type { HostRepo } from '@/utils/gitHost' import { switchVault } from '@/utils/switchVault' import { getUnpushedChangeCount, discardUnpushedChanges } from '@/utils/dirtyState' import { useGitHubSync } from '@/hooks/useGitHubSync' -import type { GitHubRepo, SyncRepo } from '@/types' +import type { SyncRepo } from '@/types' // True when the active stores hold no real content — used to decide whether // to fire an automatic sync right after switching vaults. @@ -38,6 +39,8 @@ export const GitHubRepoModal = () => { const modal = useUIStore(s => s.modal) const closeModal = useUIStore(s => s.closeModal) const token = useGitHubStore((s) => s.token) + const host = useGitHubStore((s) => s.host) + const baseUrl = useGitHubStore((s) => s.baseUrl) const syncRepo = useGitHubStore((s) => s.syncRepo) const setSyncRepo = useGitHubStore((s) => s.setSyncRepo) const disconnect = useGitHubStore((s) => s.disconnect) @@ -46,7 +49,7 @@ export const GitHubRepoModal = () => { const isOpen = modal.type === 'github-repo' const [view, setView] = useState({ kind: 'list' }) - const [repos, setRepos] = useState(null) + const [repos, setRepos] = useState(null) const [loading, setLoading] = useState(false) const [search, setSearch] = useState('') const [switching, setSwitching] = useState(false) @@ -59,13 +62,15 @@ export const GitHubRepoModal = () => { // Fetch repo list when the modal opens. Wrapped in withTokenRefresh so an // expired OAuth token auto-renews instead of 401-ing the list (matches how // the sync pull/push are wrapped); a ReconnectRequiredError message lands - // in the error view like any other failure. + // in the error view like any other failure. Routed through the active + // host's provider — host-blind, so this works identically for GitHub and + // Forgejo/Codeberg connections. useEffect(() => { if (!isOpen || !token) return setView({ kind: 'list' }) setSearch('') setLoading(true) - withTokenRefresh(tok => listUserRepos(tok)) + withTokenRefresh(tok => makeGitHostProvider({ host, token: tok, baseUrl }).listRepos()) .then((rs) => { setRepos(rs) setLoading(false) @@ -74,13 +79,13 @@ export const GitHubRepoModal = () => { setLoading(false) setView({ kind: 'error', message: err instanceof Error ? err.message : 'Failed to load repos' }) }) - }, [isOpen, token]) + }, [isOpen, token, host, baseUrl]) const filtered = useMemo(() => { if (!repos) return [] const q = search.trim().toLowerCase() if (!q) return repos - return repos.filter((r) => r.full_name.toLowerCase().includes(q)) + return repos.filter((r) => `${r.owner}/${r.name}`.toLowerCase().includes(q)) }, [repos, search]) // Carry-over makes sense when we're attaching a vault that didn't have a @@ -113,12 +118,12 @@ export const GitHubRepoModal = () => { } } - const handlePick = async (repo: GitHubRepo) => { + const handlePick = async (repo: HostRepo) => { const target: SyncRepo = { - owner: repo.owner.login, + owner: repo.owner, name: repo.name, - branch: repo.default_branch, - isPrivate: repo.private, + branch: repo.defaultBranch, + isPrivate: repo.isPrivate, } // Same repo — nothing to switch. @@ -146,12 +151,14 @@ export const GitHubRepoModal = () => { if (!token || !newName.trim()) return setCreating(true) try { - const created = await withTokenRefresh(tok => createRepo(tok, newName.trim(), newPrivate)) + const created = await withTokenRefresh(tok => + makeGitHostProvider({ host, token: tok, baseUrl }).createRepo(newName.trim(), newPrivate), + ) const target: SyncRepo = { - owner: created.owner.login, + owner: created.owner, name: created.name, - branch: created.default_branch, - isPrivate: created.private, + branch: created.defaultBranch, + isPrivate: created.isPrivate, } // New repo can't conflict with anything — but if the user already had // a repo connected we still avoid carrying the previous vault over. @@ -214,8 +221,10 @@ export const GitHubRepoModal = () => { closeModal() } + const modalTitle = host === 'github' ? 'GitHub vault' : 'Codeberg/Forgejo vault' + return ( - + {view.kind === 'list' && (
{syncRepo && ( @@ -261,9 +270,10 @@ export const GitHubRepoModal = () => { ) : (
    {filtered.map((repo) => { - const isCurrent = syncRepo?.owner === repo.owner.login && syncRepo?.name === repo.name + const key = `${repo.owner}/${repo.name}` + const isCurrent = syncRepo?.owner === repo.owner && syncRepo?.name === repo.name return ( -
  • +
  • ) @@ -293,7 +303,7 @@ export const GitHubRepoModal = () => { onClick={handleDisconnect} className="text-xs text-red-400 hover:text-red-300 transition-colors" > - Disconnect GitHub + Disconnect
diff --git a/src/components/sidebar/ContextMenu.tsx b/src/components/sidebar/ContextMenu.tsx index 6a8aac9..b9fae2a 100644 --- a/src/components/sidebar/ContextMenu.tsx +++ b/src/components/sidebar/ContextMenu.tsx @@ -120,6 +120,7 @@ export const ContextMenu = ({ contextMenu, onClose }: ContextMenuProps) => { // return` below — react-hooks/rules-of-hooks won't accept a hook // call after an early return. const hasGithubToken = useGitHubStore(s => Boolean(s.token)) + const isGitHubHost = useGitHubStore(s => s.host === 'github') const isNote = contextMenu.type === 'note' // The synthetic ".trash" sidebar folder uses a reserved id and is NOT a @@ -621,14 +622,14 @@ export const ContextMenu = ({ contextMenu, onClose }: ContextMenuProps) => { onClick={handleToggleCollab} /> )} - {canViewHistory && ( + {canViewHistory && isGitHubHost && ( )} - {hasGithubToken && !isTrashedNote && ( + {hasGithubToken && !isTrashedNote && isGitHubHost && ( ` -// suffix for these so the link lands on the repo root. +// Branches git hosts treat as the repo default — we omit the branch suffix +// for these so the link lands on the repo root. const DEFAULT_BRANCHES = new Set(['main', 'master']) -// Build the GitHub web URL for the configured vault repo. Appends -// `/tree/` only when the branch isn't the conventional default. -function repoWebUrl(repo: { owner: string; name: string; branch: string }): string { +// Build the web URL for the configured vault repo. Host-aware: +// - GitHub → https://github.com/{owner}/{name}[/tree/{branch}] +// - Forgejo → {baseUrl}/{owner}/{name}[/src/branch/{branch}] +// (Gitea/Codeberg uses /src/branch/ instead of GitHub's /tree/) +function repoWebUrl( + repo: { owner: string; name: string; branch: string }, + host: 'github' | 'forgejo', + baseUrl: string | null, +): string { + if (host === 'forgejo') { + const base = (baseUrl ?? 'https://codeberg.org').replace(/\/+$/, '') + const root = `${base}/${repo.owner}/${repo.name}` + return DEFAULT_BRANCHES.has(repo.branch) ? root : `${root}/src/branch/${repo.branch}` + } + // GitHub const base = `https://github.com/${repo.owner}/${repo.name}` return DEFAULT_BRANCHES.has(repo.branch) ? base : `${base}/tree/${repo.branch}` } @@ -84,6 +96,8 @@ export function SourceControlPanel() { const folders = useFolderStore(s => s.folders) const lastSyncedAt = useGitHubStore(s => s.lastSyncedAt) const syncRepo = useGitHubStore(s => s.syncRepo) + const host = useGitHubStore(s => s.host) + const baseUrl = useGitHubStore(s => s.baseUrl) const openNote = useWorkspaceStore(s => s.openNote) // Pass `folders` so created (never-pushed) notes carry a synthetic @@ -120,14 +134,14 @@ export function SourceControlPanel() { {syncRepo.owner}/{syncRepo.name} e.stopPropagation()} className="flex-none text-obsidianSecondaryText hover:text-obsidianText transition-colors" - data-noteser-tip="Open in GitHub" + data-noteser-tip={host === 'github' ? 'Open in GitHub' : 'Open in browser'} data-testid="source-control-open-github" - aria-label="Open in GitHub" + aria-label={host === 'github' ? 'Open in GitHub' : 'Open in browser'} > @@ -168,6 +182,7 @@ const RecentCommits = () => { const token = useGitHubStore(s => s.token) const repo = useGitHubStore(s => s.syncRepo) const lastCommitSha = useGitHubStore(s => s.lastCommitSha) + const isGitHubHost = useGitHubStore(s => s.host === 'github') const [open, setOpen] = useState(true) const [commits, setCommits] = useState(null) const [loading, setLoading] = useState(false) @@ -204,7 +219,7 @@ const RecentCommits = () => { return () => { cancelled = true } }, [token, repo, lastCommitSha]) - if (!token || !repo) return null + if (!token || !repo || !isGitHubHost) return null return (
diff --git a/src/hooks/useGitHubSync.ts b/src/hooks/useGitHubSync.ts index 6a1fd90..68851e8 100644 --- a/src/hooks/useGitHubSync.ts +++ b/src/hooks/useGitHubSync.ts @@ -11,6 +11,7 @@ import { isChunkLoadError, showChunkReloadToast, CHUNK_RELOAD_MESSAGE } from '@/ // prefetch). pullFromZipball still lives in githubSync.ts for callers/tests. import { syncToGitHub, pullFromGitHub } from '@/utils/githubSync' import type { PullClassification, SyncResult, GitPathUpdate } from '@/utils/githubSync' +import { makeGitHostProvider } from '@/utils/gitHost' import { getValidGitHubToken, withTokenRefresh, ReconnectRequiredError } from '@/utils/tokenRefresh' import { applyNonConflicts, applyAttachmentClassifications } from '@/utils/syncApply' import { fillShellsInBackground } from '@/utils/backgroundFill' @@ -186,8 +187,9 @@ async function runPull( // clone runs on the user's own authenticated GitHub API quota instead of // Noteser's Vercel bandwidth. pullFromZipball + fetchZipball + the // /api/github/zipball route are kept in the tree but no longer on this path. + const { host, baseUrl } = useGitHubStore.getState() const { classifications, latestCommitSha } = await pullFromGitHub({ - token, repo, + provider: makeGitHostProvider({ host, token, baseUrl }), repo, notes: localNotes, folders: localFolders, excludedFolderPaths, vaultSettingsPath, @@ -242,8 +244,9 @@ async function runPush( } } + const { host, baseUrl } = useGitHubStore.getState() const outcome = await syncToGitHub({ - token, repo, notes, folders, commitMessage, + provider: makeGitHostProvider({ host, token, baseUrl }), repo, notes, folders, commitMessage, vaultSettings: vaultSettingsInput, // gi9n: thread the editor's draft through. Null = no pending edit; // syncToGitHub will leave the remote `.gitignore` alone. diff --git a/src/middleware.ts b/src/middleware.ts index f9f74b4..02c4280 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,6 +1,6 @@ import { NextResponse, type NextRequest } from 'next/server' -import { buildCsp, deriveCollabWsOrigin } from '@/utils/csp' +import { buildCsp, deriveCollabWsOrigin, deriveGitHostOrigin } from '@/utils/csp' /** * Per-request nonce-based CSP (Finding 6 of the 2026-05-21 security audit). @@ -22,6 +22,7 @@ export function middleware(request: NextRequest) { const csp = buildCsp(nonce, { isDev: process.env.NODE_ENV !== 'production', wsOrigin: deriveCollabWsOrigin(process.env.NEXT_PUBLIC_YJS_WS_URL), + gitHostOrigin: deriveGitHostOrigin(process.env.NEXT_PUBLIC_FORGEJO_BASE_URL), // /share renders arbitrary shared content to arbitrary visitors — no // remote-image tracking pixels there. See BuildCspOptions.restrictImages. restrictImages: request.nextUrl.pathname.startsWith('/share'), diff --git a/src/stores/githubStore.ts b/src/stores/githubStore.ts index e5afe6f..d5e7466 100644 --- a/src/stores/githubStore.ts +++ b/src/stores/githubStore.ts @@ -1,6 +1,7 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' import type { GitHubUser, SyncRepo } from '@/types' +import type { HostKind } from '@/utils/gitHost/types' import { STORAGE_KEYS } from '@/utils/storageKeys' import { localStorageJSON } from '@/utils/persistStorage' import { trackEventOncePerSession } from '@/utils/analytics' @@ -46,6 +47,13 @@ interface GitHubState { token: string | null user: GitHubUser | null connectedAt: number | null + // Which git host this connection targets. `'github'` is the default for + // every existing user (a persisted blob with no `host` key merges over this + // initial value). The sync pipeline selects the provider from it. + host: HostKind + // Base URL for the active host. Null means "use the provider's own default" + // (GitHub ignores it entirely; Forgejo falls back to codeberg.org). + baseUrl: string | null syncRepo: SyncRepo | null lastSyncedAt: number | null lastCommitSha: string | null @@ -83,6 +91,7 @@ interface GitHubState { // the refresh token on every use, so the new one is persisted here too. applyRefreshedTokens: (tokens: GitHubTokenSet) => void setTokenScopes: (scopes: string[] | null) => void + setHost: (host: HostKind, baseUrl: string | null) => void setSyncRepo: (repo: SyncRepo | null) => void recordSync: (commitSha: string) => void setIsSyncing: (value: boolean) => void @@ -100,6 +109,8 @@ export const useGitHubStore = create()( token: null, user: null, connectedAt: null, + host: 'github', + baseUrl: null, syncRepo: null, lastSyncedAt: null, lastCommitSha: null, @@ -129,6 +140,7 @@ export const useGitHubStore = create()( refreshTokenExpiresAt: tokens.refreshTokenExpiresAt, }), setTokenScopes: (scopes) => set({ tokenScopes: scopes }), + setHost: (host, baseUrl) => set({ host, baseUrl }), setIsSyncing: (value) => set({ isSyncing: value }), setSyncRepo: (repo) => set(state => { const currentKey = repoKey(state.syncRepo) @@ -162,6 +174,7 @@ export const useGitHubStore = create()( }), disconnect: () => set({ token: null, user: null, connectedAt: null, + host: 'github', baseUrl: null, syncRepo: null, lastSyncedAt: null, lastCommitSha: null, repoSyncStates: {}, tokenScopes: null, accessTokenExpiresAt: null, refreshToken: null, refreshTokenExpiresAt: null, @@ -177,6 +190,8 @@ export const useGitHubStore = create()( token: state.token, user: state.user, connectedAt: state.connectedAt, + host: state.host, + baseUrl: state.baseUrl, syncRepo: state.syncRepo, lastSyncedAt: state.lastSyncedAt, lastCommitSha: state.lastCommitSha, diff --git a/src/utils/csp.ts b/src/utils/csp.ts index 35815ef..3807834 100644 --- a/src/utils/csp.ts +++ b/src/utils/csp.ts @@ -41,11 +41,33 @@ export function deriveCollabWsOrigin(raw: string | undefined): string | null { } } +/** + * Derive a single http(s) origin from a raw NEXT_PUBLIC_FORGEJO_BASE_URL + * value so the CSP only allows the self-hosted Forgejo/Gitea instance the + * operator opted into at deploy time. Same posture as deriveCollabWsOrigin: + * an allow-listed origin, never a scheme wildcard — a runtime-controllable + * connect-src would let an XSS payload exfiltrate localStorage (which holds + * the git token) to an arbitrary host. Codeberg itself is a static entry in + * connect-src and needs no env var. + */ +export function deriveGitHostOrigin(raw: string | undefined): string | null { + if (!raw) return null + try { + const url = new URL(raw) + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null + return `${url.protocol}//${url.host}` + } catch { + return null + } +} + export interface BuildCspOptions { /** Non-production (dev / test): adds 'unsafe-eval' to script-src. */ isDev: boolean /** Already-derived ws(s):// origin to add to connect-src, or null. */ wsOrigin: string | null + /** Already-derived self-hosted Forgejo origin for connect-src, or null. */ + gitHostOrigin?: string | null /** * Drop the `https:` wildcard from img-src. The editor's own notes * legitimately embed arbitrary HTTPS images (`![]()`), but the public @@ -63,7 +85,7 @@ export interface BuildCspOptions { * @param nonce base64 per-request nonce (already generated by middleware). */ export function buildCsp(nonce: string, options: BuildCspOptions): string { - const { isDev, wsOrigin, restrictImages = false } = options + const { isDev, wsOrigin, gitHostOrigin = null, restrictImages = false } = options const scriptSrc = [ "'self'", @@ -76,9 +98,15 @@ export function buildCsp(nonce: string, options: BuildCspOptions): string { "'self'", 'https://api.github.com', 'https://github.com', + // Codeberg is the built-in Forgejo host preset in the vault connect flow; + // its Gitea API is called browser-direct just like api.github.com. + 'https://codeberg.org', 'https://api.anthropic.com', 'https://api.openai.com', ...(wsOrigin ? [wsOrigin] : []), + // Self-hosted Forgejo/Gitea: only the single origin the operator + // allow-listed via NEXT_PUBLIC_FORGEJO_BASE_URL (see deriveGitHostOrigin). + ...(gitHostOrigin ? [gitHostOrigin] : []), ].join(' ') return [ diff --git a/src/utils/gitHost/forgejoProvider.ts b/src/utils/gitHost/forgejoProvider.ts new file mode 100644 index 0000000..d5d1e1f --- /dev/null +++ b/src/utils/gitHost/forgejoProvider.ts @@ -0,0 +1,322 @@ +// ForgejoProvider: the Forgejo/Gitea implementation of the GitHostProvider +// seam. Codeberg is just a base-URL preset (https://codeberg.org); any +// self-hosted Forgejo/Gitea instance works via a configurable baseUrl. +// +// Unlike GitHubProvider (a thin wrap of github.ts), this provider talks to +// the Gitea API directly at `{baseUrl}/api/v1`. The big divergence from +// GitHub is the write path: Forgejo's git-data endpoints are read-only, so +// commitChanges goes through `POST /repos/{owner}/{repo}/contents` (the +// ChangeFiles batch API) — one request writes N files as a single commit. +// See docs/multi-host-sync-plan.md. + +import type { SyncRepo } from '@/types' +import { base64ToBytes } from '../github' +import type { + GitHostProvider, + HostKind, + HostRepo, + HostUser, + CommitRequest, + CommitResult +} from './types' + +const CODEBERG_BASE = 'https://codeberg.org' + +// Gitea caps repo listings; 50 keeps each page small enough to stay snappy. +const REPOS_PER_PAGE = 50 + +// Typed error thrown at the network boundary for any non-ok Gitea response. +// Carries the HTTP status and the API's error message (when the body parses +// as JSON) so the UI can show a precise message instead of a bare code. +export class ForgejoAPIError extends Error { + constructor( + public readonly status: number, + public readonly operation: string, + public readonly serverMessage: string | null + ) { + const tail = serverMessage ? ` — ${serverMessage}` : '' + super(`${operation} failed (${status})${tail}`) + this.name = 'ForgejoAPIError' + } + + static async fromResponse( + res: Response, + operation: string + ): Promise { + let serverMessage: string | null = null + try { + const body = (await res.clone().json()) as { message?: string } + if (typeof body.message === 'string') serverMessage = body.message + } catch { + // Body wasn't JSON, leave message null. + } + return new ForgejoAPIError(res.status, operation, serverMessage) + } +} + +// Base64-encode a UTF-8 string for the ChangeFiles `content` field. TextEncoder +// + btoa (btoa alone only handles Latin-1). +function utf8ToBase64(content: string): string { + const bytes = new TextEncoder().encode(content) + let bin = '' + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]) + return btoa(bin) +} + +function bytesToBase64(bytes: Uint8Array): string { + let bin = '' + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]) + return btoa(bin) +} + +interface GiteaRepo { + name: string + owner: { login: string } + default_branch: string + private: boolean +} + +function toHostRepo(repo: GiteaRepo): HostRepo { + return { + owner: repo.owner.login, + name: repo.name, + defaultBranch: repo.default_branch, + isPrivate: repo.private + } +} + +export class ForgejoProvider implements GitHostProvider { + readonly kind: HostKind = 'forgejo' + readonly baseUrl: string + + constructor( + private readonly token: string, + baseUrl: string = CODEBERG_BASE + ) { + this.baseUrl = baseUrl.replace(/\/+$/, '') + } + + private get apiBase(): string { + return `${this.baseUrl}/api/v1` + } + + private headers(extra: Record = {}): Record { + return { + Authorization: `token ${this.token}`, + Accept: 'application/json', + ...extra + } + } + + private async get(path: string, operation: string): Promise { + const res = await fetch(`${this.apiBase}${path}`, { + headers: this.headers() + }) + if (!res.ok) throw await ForgejoAPIError.fromResponse(res, operation) + return res.json() + } + + // --- repo ops --- + async listRepos(): Promise { + const out: HostRepo[] = [] + for (let page = 1; ; page++) { + const batch = (await this.get( + `/user/repos?limit=${REPOS_PER_PAGE}&page=${page}`, + 'List repos' + )) as GiteaRepo[] + out.push(...batch.map(toHostRepo)) + if (batch.length < REPOS_PER_PAGE) break + } + return out + } + + async getRepo(owner: string, name: string): Promise { + const repo = (await this.get( + `/repos/${owner}/${name}`, + 'Fetch repo' + )) as GiteaRepo + return toHostRepo(repo) + } + + async listBranches(owner: string, name: string): Promise { + const branches = (await this.get( + `/repos/${owner}/${name}/branches`, + 'List branches' + )) as { name: string }[] + return branches.map(b => b.name) + } + + async createRepo(name: string, isPrivate: boolean): Promise { + const res = await fetch(`${this.apiBase}/user/repos`, { + method: 'POST', + headers: this.headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ name, private: isPrivate, auto_init: true }) + }) + if (!res.ok) throw await ForgejoAPIError.fromResponse(res, 'Create repo') + return toHostRepo((await res.json()) as GiteaRepo) + } + + async getAuthenticatedUser(): Promise { + const data = (await this.get('/user', 'Read user')) as { + id: number + login: string + full_name?: string + avatar_url?: string + } + return { + id: data.id, + login: data.login, + name: data.full_name ? data.full_name : null, + avatarUrl: data.avatar_url + } + } + + // --- git-data READ --- + async getBranchHeadSha(repo: SyncRepo): Promise { + // The refs endpoint returns an array when the path is a prefix match for + // multiple refs, or a single object for an exact match. + const data = (await this.get( + `/repos/${repo.owner}/${repo.name}/git/refs/heads/${repo.branch}`, + 'Read ref' + )) as { object: { sha: string } } | { object: { sha: string } }[] + return Array.isArray(data) ? data[0].object.sha : data.object.sha + } + + async getCommitTreeSha(repo: SyncRepo, commitSha: string): Promise { + // Forgejo nests the tree sha under `.commit.tree.sha` (GitHub puts it at + // the top-level `.tree.sha`). + const data = (await this.get( + `/repos/${repo.owner}/${repo.name}/git/commits/${commitSha}`, + 'Read commit' + )) as { commit: { tree: { sha: string } } } + return data.commit.tree.sha + } + + async getTreeMap( + repo: SyncRepo, + treeSha: string + ): Promise> { + // Forgejo paginates large recursive trees via ?page=; follow `truncated` + // until the whole tree is read. + const out = new Map() + for (let page = 1; ; page++) { + const data = (await this.get( + `/repos/${repo.owner}/${repo.name}/git/trees/${treeSha}?recursive=true&page=${page}`, + 'Read tree' + )) as { + tree: Array<{ path: string; type: string; sha: string }> + truncated?: boolean + } + for (const entry of data.tree) { + if (entry.type === 'blob') out.set(entry.path, entry.sha) + } + if (!data.truncated) break + } + return out + } + + async getBlobContent(repo: SyncRepo, sha: string): Promise { + const data = (await this.get( + `/repos/${repo.owner}/${repo.name}/git/blobs/${sha}`, + `Read blob ${sha}` + )) as { content: string; encoding: string } + return new TextDecoder('utf-8').decode(base64ToBytes(data.content)) + } + + // Forgejo/Gitea has no ETag-conditional read layer here, so the cached + // variants are plain reads — same result, no caching. (The seam's PULL path + // calls these; PUSH uses getTreeMap/getBlobContent.) + getTreeMapCached( + repo: SyncRepo, + treeSha: string + ): Promise> { + return this.getTreeMap(repo, treeSha) + } + + getBlobContentCached(repo: SyncRepo, sha: string): Promise { + return this.getBlobContent(repo, sha) + } + + async getBlobBytes(repo: SyncRepo, sha: string): Promise { + const data = (await this.get( + `/repos/${repo.owner}/${repo.name}/git/blobs/${sha}`, + `Read binary blob ${sha}` + )) as { content: string; encoding: string } + return base64ToBytes(data.content) + } + + // --- bulk archive (first-clone fast path) --- + async fetchArchive(repo: SyncRepo, ref: string): Promise { + const res = await fetch( + `${this.apiBase}/repos/${repo.owner}/${repo.name}/archive/${ref}.zip`, + { headers: this.headers() } + ) + if (!res.ok) + throw await ForgejoAPIError.fromResponse(res, 'Download archive') + return res.arrayBuffer() + } + + // --- git-data WRITE --- + // One `POST /contents` with a ChangeFiles batch: every create/update/delete + // is a single ChangeFileOperation and the server builds the blobs, tree, and + // commit and advances the branch in one shot. Create/update carry base64 + // content; update/delete carry the current blob sha. An empty change set is + // a no-op (syncPush already filters unchanged files), so it makes no network + // call and reports committed:false — preserving the no-churn invariant. + async commitChanges( + repo: SyncRepo, + req: CommitRequest + ): Promise { + if (req.changes.length === 0) { + return { + commitSha: req.parentSha, + commitUrl: null, + committed: false, + uploadedPaths: [] + } + } + + const files = req.changes.map(change => { + if (change.op === 'delete') { + return { operation: 'delete', path: change.path, sha: change.sha } + } + const content = change.contentBytes + ? bytesToBase64(change.contentBytes) + : utf8ToBase64(change.content ?? '') + return change.op === 'update' + ? { operation: 'update', path: change.path, content, sha: change.sha } + : { operation: 'create', path: change.path, content } + }) + + const uploadedPaths = req.changes + .filter(c => c.op !== 'delete') + .map(c => c.path) + + // Forgejo writes are a single request — no per-blob progress. + req.onProgress?.({ phase: 'committing' }) + + const res = await fetch( + `${this.apiBase}/repos/${repo.owner}/${repo.name}/contents`, + { + method: 'POST', + headers: this.headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + branch: req.branch, + message: req.message, + files + }) + } + ) + if (!res.ok) throw await ForgejoAPIError.fromResponse(res, 'Commit changes') + const data = (await res.json()) as { + commit: { sha: string; html_url?: string } + } + + return { + commitSha: data.commit.sha, + commitUrl: data.commit.html_url ?? null, + committed: true, + uploadedPaths + } + } +} diff --git a/src/utils/gitHost/githubProvider.ts b/src/utils/gitHost/githubProvider.ts new file mode 100644 index 0000000..a591751 --- /dev/null +++ b/src/utils/gitHost/githubProvider.ts @@ -0,0 +1,305 @@ +// GitHubProvider: the GitHub implementation of the GitHostProvider seam. +// +// This is a mechanical WRAP of the existing functions in `../github.ts` — +// it delegates every call rather than reimplementing any HTTP. The repo +// read/write functions in github.ts take `(token, owner, repo, ...)`; this +// class binds the token at construction and maps a `SyncRepo {owner,name, +// branch}` onto those positional args. +// +// Tree/blob reads delegate to the ETag-conditional wrappers in +// `../githubETagCache` (which themselves fall back to the bare github.ts +// helpers on a cold cache), so GitHub's #69 conditional-request caching is +// encapsulated here and available to any caller of the provider — the pull +// pipeline never has to know about it. `getBlobBytes` has no conditional +// variant and stays on the bare helper. +// +// `commitChanges` reproduces the existing push flow that `githubSync/ +// syncPush.ts` used to perform inline (createBlob → createTree → +// createCommit → updateBranchRef), mirroring its semantics: deletes are +// `sha:null` tree entries, the base tree is resolved from the parent commit, +// and the branch ref is fast-forwarded. It also keeps the GitHub-specific +// blob upload-cache and the no-op-tree skip that used to live in syncPush — +// both are pure GitHub-blob optimizations (Forgejo's ChangeFiles batch sends +// content directly, so they have no meaning there). See +// docs/multi-host-sync-plan.md. + +import type { SyncRepo, GitHubRepo } from '@/types' +import { + getBranchRefSha, + getCommitTreeSha, + getTreeMap, + getBlobContent, + getBlobBytes, + fetchZipball, + fetchGitHubUser, + listUserRepos, + listRepoBranches, + getRepo as githubGetRepo, + createRepo as githubCreateRepo, + createBlob, + createBlobBinary, + createTree, + createCommit, + updateBranchRef, + gitBlobSha, + gitBlobShaBytes, + type GitTreeEntry +} from '../github' +// The plain `getTreeMap`/`getBlobContent` above are the canonical reads used +// by the PUSH path — byte-identical to pre-seam behavior, no caching layer in +// the way (a stale tree during a push would risk a non-fast-forward commit). +// The `*Cached` methods below add the #69 ETag-conditional caching and are +// used only by the PULL path, where a re-sync of an unchanged repo should come +// back as cheap 304s. Forgejo has no ETag variant, so its provider aliases the +// cached methods to the plain reads. getBlobBytes has no conditional variant. +import { + getBlobContentConditional, + getTreeMapConditional +} from '../githubETagCache' +import type { + GitHostProvider, + HostKind, + HostRepo, + HostUser, + CommitRequest, + CommitResult +} from './types' + +const GITHUB_API_BASE = 'https://api.github.com' + +// In-memory cache of blob SHAs we've already uploaded to GitHub in this tab +// session. Git blob SHAs are content-addressable, so a hit here means GitHub +// already has that content — skip the redundant network round-trip. Survives +// across commitChanges calls within the tab (so a token-refresh retry skips +// blobs the first attempt already uploaded) but is cleared on a reload and +// after each successful commit. Indexed per-repo so two vaults don't share +// state. Lives at module scope (not on the instance) so a fresh provider +// built for a retry still sees the prior attempt's uploads. +const uploadedBlobShaCache = new Map>() + +function repoCacheKey(repo: SyncRepo): string { + return `${repo.owner}/${repo.name}#${repo.branch}` +} + +function getUploadedShas(repo: SyncRepo): Set { + const key = repoCacheKey(repo) + let set = uploadedBlobShaCache.get(key) + if (!set) { + set = new Set() + uploadedBlobShaCache.set(key, set) + } + return set +} + +/** Test hook. Drops the in-memory upload cache. */ +export function _resetUploadedShaCache(): void { + uploadedBlobShaCache.clear() +} + +function toHostRepo(repo: GitHubRepo): HostRepo { + return { + owner: repo.owner.login, + name: repo.name, + defaultBranch: repo.default_branch, + isPrivate: repo.private + } +} + +export class GitHubProvider implements GitHostProvider { + readonly kind: HostKind = 'github' + readonly baseUrl: string + + constructor( + private readonly token: string, + baseUrl: string = GITHUB_API_BASE + ) { + this.baseUrl = baseUrl + } + + // --- repo ops --- + async listRepos(): Promise { + const repos = await listUserRepos(this.token) + return repos.map(toHostRepo) + } + + async getRepo(owner: string, name: string): Promise { + const repo = await githubGetRepo(this.token, owner, name) + return toHostRepo(repo) + } + + async listBranches(owner: string, name: string): Promise { + const branches = await listRepoBranches(this.token, owner, name) + return branches.map(b => b.name) + } + + async createRepo(name: string, isPrivate: boolean): Promise { + const repo = await githubCreateRepo(this.token, name, isPrivate) + return toHostRepo(repo) + } + + async getAuthenticatedUser(): Promise { + const u = await fetchGitHubUser(this.token) + return { id: u.id, login: u.login, name: u.name, avatarUrl: u.avatar_url } + } + + // --- git-data READ --- + getBranchHeadSha(repo: SyncRepo): Promise { + return getBranchRefSha(this.token, repo.owner, repo.name, repo.branch) + } + + getCommitTreeSha(repo: SyncRepo, commitSha: string): Promise { + return getCommitTreeSha(this.token, repo.owner, repo.name, commitSha) + } + + getTreeMap(repo: SyncRepo, treeSha: string): Promise> { + return getTreeMap(this.token, repo.owner, repo.name, treeSha) + } + + getBlobContent(repo: SyncRepo, sha: string): Promise { + return getBlobContent(this.token, repo.owner, repo.name, sha) + } + + getTreeMapCached( + repo: SyncRepo, + treeSha: string + ): Promise> { + return getTreeMapConditional(this.token, repo, treeSha) + } + + getBlobContentCached(repo: SyncRepo, sha: string): Promise { + return getBlobContentConditional(this.token, repo, sha) + } + + getBlobBytes(repo: SyncRepo, sha: string): Promise { + return getBlobBytes(this.token, repo.owner, repo.name, sha) + } + + // --- bulk archive (first-clone fast path) --- + fetchArchive(repo: SyncRepo, ref: string): Promise { + return fetchZipball(this.token, repo.owner, repo.name, ref) + } + + // --- git-data WRITE --- + // Reproduces the existing GitHub push: build a blob per create/update file + // (binary via createBlobBinary when contentBytes is set), add a sha:null + // tree entry per delete, then createTree(baseTree) → createCommit(parent) → + // updateBranchRef. The base tree is the parent commit's tree. + // + // Two GitHub-blob optimizations carried over from the old inline syncPush + // write block: + // 1. Upload cache — a create/update whose content blob SHA is already in + // the per-repo cache (uploaded earlier this tab session, e.g. before a + // token-refresh retry) skips the blob POST and reuses the SHA as its + // tree entry. Reported as `skipped` in the progress stream. + // 2. No-op-tree skip — if the assembled tree is byte-identical to the + // parent's tree (a freshly-cloned note that round-trips to the same + // bytes), creating a commit would produce an empty "No files changed" + // commit. We skip commit+ref and return `committed:false`. + async commitChanges( + repo: SyncRepo, + req: CommitRequest + ): Promise { + const { owner, name } = repo + const { onProgress } = req + const uploadedShas = getUploadedShas(repo) + const baseTreeSha = await this.getCommitTreeSha(repo, req.parentSha) + + // Pre-pass: split create/update changes into "cached" (blob already on the + // host, no POST) vs "to upload", so we can emit a stable `total`. Deletes + // carry no blob. Content blob SHAs are computed locally and are identical + // to what GitHub assigns (git content-addressing). + interface BlobPlan { + change: (typeof req.changes)[number] + localSha: string + cached: boolean + } + const blobPlans: BlobPlan[] = [] + for (const change of req.changes) { + if (change.op === 'delete') continue + const localSha = change.contentBytes + ? await gitBlobShaBytes(change.contentBytes) + : await gitBlobSha(change.content ?? '') + blobPlans.push({ change, localSha, cached: uploadedShas.has(localSha) }) + } + + const total = blobPlans.filter(p => !p.cached).length + const skipped = blobPlans.filter(p => p.cached).length + let uploaded = 0 + const emit = () => + onProgress?.({ phase: 'uploading-blobs', uploaded, total, skipped }) + if (total > 0 || skipped > 0) emit() + + // Map every change to a tree entry, uploading blobs that aren't cached. + const blobShaByChange = new Map<(typeof req.changes)[number], string>() + const uploadedPaths: string[] = [] + for (const plan of blobPlans) { + if (plan.cached) { + blobShaByChange.set(plan.change, plan.localSha) + continue + } + const blobSha = plan.change.contentBytes + ? await createBlobBinary( + this.token, + owner, + name, + new Blob([plan.change.contentBytes.slice()]) + ) + : await createBlob(this.token, owner, name, plan.change.content ?? '') + uploadedShas.add(plan.localSha) + blobShaByChange.set(plan.change, blobSha) + uploadedPaths.push(plan.change.path) + uploaded++ + emit() + } + + const entries: GitTreeEntry[] = req.changes.map(change => + change.op === 'delete' + ? { path: change.path, mode: '100644', type: 'blob', sha: null } + : { + path: change.path, + mode: '100644', + type: 'blob', + sha: blobShaByChange.get(change)! + } + ) + + onProgress?.({ phase: 'building-tree' }) + const newTreeSha = await createTree( + this.token, + owner, + name, + baseTreeSha, + entries + ) + + // No-op-tree skip: the changes resolved to the parent's exact tree, so a + // commit would be empty. Leave the branch untouched and report no commit. + if (newTreeSha === baseTreeSha) { + uploadedShas.clear() + return { + commitSha: req.parentSha, + commitUrl: null, + committed: false, + uploadedPaths + } + } + + onProgress?.({ phase: 'committing' }) + const { sha: commitSha, html_url } = await createCommit( + this.token, + owner, + name, + req.message, + newTreeSha, + req.parentSha + ) + onProgress?.({ phase: 'updating-ref' }) + await updateBranchRef(this.token, owner, name, req.branch, commitSha) + + // Push succeeded — start the next push from a clean cache (the remote tree + // is consulted again then). + uploadedShas.clear() + + return { commitSha, commitUrl: html_url, committed: true, uploadedPaths } + } +} diff --git a/src/utils/gitHost/index.ts b/src/utils/gitHost/index.ts new file mode 100644 index 0000000..14760b4 --- /dev/null +++ b/src/utils/gitHost/index.ts @@ -0,0 +1,28 @@ +// Public entry point for the git-host abstraction. Re-exports the seam types +// and both provider implementations, plus the factory that picks a provider +// from a connection's host kind. The sync pipeline imports `makeGitHostProvider` +// here rather than constructing a concrete provider, so the active host is a +// piece of connection state — not a hardcoded `new GitHubProvider`. See +// docs/multi-host-sync-plan.md → "Data model changes". + +import type { GitHostProvider, HostKind } from './types' +import { GitHubProvider } from './githubProvider' +import { ForgejoProvider } from './forgejoProvider' + +export * from './types' +export { GitHubProvider } from './githubProvider' +export { ForgejoProvider } from './forgejoProvider' + +/** Build the provider for a connection. GitHub is the default — `baseUrl` is + * ignored there. For Forgejo a null/undefined baseUrl falls back to the + * provider's own default (codeberg.org). */ +export function makeGitHostProvider(opts: { + host: HostKind + token: string + baseUrl?: string | null +}): GitHostProvider { + if (opts.host === 'forgejo') { + return new ForgejoProvider(opts.token, opts.baseUrl ?? undefined) + } + return new GitHubProvider(opts.token) +} diff --git a/src/utils/gitHost/types.ts b/src/utils/gitHost/types.ts new file mode 100644 index 0000000..389f797 --- /dev/null +++ b/src/utils/gitHost/types.ts @@ -0,0 +1,138 @@ +// The host-abstraction seam for vault sync. See +// docs/multi-host-sync-plan.md → "The seam: GitHostProvider". +// +// The whole point: the GitHub push flow (createBlob → createTree → +// createCommit → updateBranchRef) cannot be ported to Forgejo/Gitea because +// those create endpoints don't exist there. So the seam does NOT expose +// git-data write primitives — it exposes a higher-level "commit a batch of +// file changes" operation each host implements its own way. + +import type { SyncRepo, GitHubUser } from '@/types' + +export type HostKind = 'github' | 'forgejo' + +export interface HostUser { + id: string | number + login: string + name: string | null + avatarUrl?: string +} + +export interface HostRepo { + owner: string + name: string + defaultBranch: string + isPrivate: boolean +} + +/** One file change in a commit. content is the raw UTF-8 string (provider + * handles base64). For deletes, content is omitted. sha is the *current* + * blob sha of the file being replaced/deleted (Forgejo requires it; + * GitHub ignores it). */ +export interface FileChange { + op: 'create' | 'update' | 'delete' + path: string + content?: string + contentBytes?: Uint8Array // for binary attachments + sha?: string +} + +// Host-agnostic progress for a single commitChanges call. A multi-request +// host (GitHub: N blobs + tree + commit + ref) reports each phase; a +// single-request host (Forgejo ChangeFiles) may only report `committing`. +// `uploading-blobs` carries running counts so the caller can render +// "uploaded 47 / 200 (3 skipped)". The caller translates these back into +// whatever external progress shape it exposes. +export type CommitProgress = + | { + phase: 'uploading-blobs' + uploaded: number + total: number + skipped: number + } + | { phase: 'building-tree' } + | { phase: 'committing' } + | { phase: 'updating-ref' } + +export interface CommitRequest { + branch: string + parentSha: string // expected current head (optimistic FF) + message: string + changes: FileChange[] + // Optional host-agnostic progress hook. Hosts emit the phases they + // actually perform; absent phases simply aren't reported. + onProgress?: (event: CommitProgress) => void +} + +export interface CommitResult { + commitSha: string + commitUrl: string | null + // False when the host determined the change set was a no-op (e.g. the + // built tree was byte-identical to the parent's tree, so no commit was + // created and the branch was left untouched). commitSha then equals the + // parent sha. True when a real commit advanced the branch. + committed: boolean + // Paths whose create/update content was actually transmitted to the host + // (as opposed to satisfied from a same-session content cache). On GitHub a + // path is absent here when its blob was already uploaded earlier this tab + // session (a token-refresh retry) and reused. Hosts with no content cache + // list every create/update path. The caller uses this to mirror the prior + // syncPush behavior of only recording a path-metadata update for paths it + // genuinely pushed in this attempt. + uploadedPaths: string[] +} + +export interface GitHostProvider { + readonly kind: HostKind + readonly baseUrl: string // e.g. https://api.github.com | https://codeberg.org + + // --- repo ops --- + listRepos(): Promise + getRepo(owner: string, name: string): Promise + listBranches(owner: string, name: string): Promise + createRepo(name: string, isPrivate: boolean): Promise + + // --- git-data READ (near-identical across hosts) --- + getBranchHeadSha(repo: SyncRepo): Promise + getCommitTreeSha(repo: SyncRepo, commitSha: string): Promise + getTreeMap(repo: SyncRepo, treeSha: string): Promise> // path -> blobSha + getBlobContent(repo: SyncRepo, sha: string): Promise + getBlobBytes(repo: SyncRepo, sha: string): Promise + + // Cached read variants for the PULL path: same result as getTreeMap / + // getBlobContent, but a host may layer caching on top (GitHub uses #69 + // ETag-conditional requests). The PUSH path must use the plain reads above + // — a stale tree would risk a non-fast-forward commit — so only pull uses + // these. Hosts without a caching layer (Forgejo) alias them to plain reads. + getTreeMapCached( + repo: SyncRepo, + treeSha: string + ): Promise> + getBlobContentCached(repo: SyncRepo, sha: string): Promise + + // --- bulk archive (first-clone fast path) --- + // Optional whole-repo archive download for the first-clone fast path + // (GitHub: zipball; Forgejo: GET /archive/{ref}.zip, or omit). Returns the + // raw archive bytes; the caller unzips. Hosts without an archive endpoint + // leave this undefined and the caller falls back to the per-blob pull. + fetchArchive?(repo: SyncRepo, ref: string): Promise + + // The authenticated user behind the token. Used by the connect flow to + // populate the session identity host-agnostically. + getAuthenticatedUser(): Promise + + // --- git-data WRITE (the one real divergence) --- + commitChanges(repo: SyncRepo, req: CommitRequest): Promise +} + +// Bridge the host-agnostic HostUser onto the store's existing GitHubUser shape +// (avatarUrl -> avatar_url, id coerced to number). Lets the store keep its +// current type while the connect flow stays host-agnostic. +export function hostUserToGitHubUser(u: HostUser): GitHubUser { + return { + id: typeof u.id === 'number' ? u.id : Number(u.id) || 0, + login: u.login, + name: u.name, + avatar_url: u.avatarUrl ?? '' + } +} diff --git a/src/utils/githubSync/syncPull.ts b/src/utils/githubSync/syncPull.ts index 3831211..d94e1f5 100644 --- a/src/utils/githubSync/syncPull.ts +++ b/src/utils/githubSync/syncPull.ts @@ -11,20 +11,10 @@ import type JSZip from 'jszip' import type { Note, SyncRepo } from '@/types' import { - getBranchRefSha, - getCommitTreeSha, gitBlobSha, gitBlobShaBytes, - fetchZipball, } from '../github' -// #69: pull-side blob/tree reads go through the ETag-conditional wrappers -// so a re-sync of an unchanged repo comes back as 304s and doesn't burn -// quota. Push-side reads in `syncPush.ts` still call the bare helpers from -// `../github` — the cache is read-side only. -import { - getBlobContentConditional, - getTreeMapConditional, -} from '../githubETagCache' +import type { GitHostProvider } from '../gitHost/types' import { threeWayMerge } from '../lineDiff' import { isAttachmentPath, @@ -54,7 +44,12 @@ function sameTagSet(a: string[], b: string[]): boolean { } export async function pullFromGitHub(input: { - token: string + // Host abstraction for every remote read. The pull pipeline calls + // provider.* and never branches on host kind; the GitHubProvider + // encapsulates GitHub's ETag-conditional caching (#69) behind + // getTreeMapCached / getBlobContentCached so re-syncs of an unchanged + // repo still come back as 304s. + provider: GitHostProvider repo: SyncRepo notes: Note[] folders: import('@/types').Folder[] @@ -83,15 +78,14 @@ export async function pullFromGitHub(input: { // own progress via the onPhase callback wired in useGitHubSync. onBlobProgress?: (loaded: number, total: number) => void }): Promise { - const { token, repo, notes } = input + const { provider, repo, notes } = input const excluded = input.excludedFolderPaths ?? [] const vaultSettingsPath = input.vaultSettingsPath ?? null const vaultSettingsLocalUpdatedAt = input.vaultSettingsLocalUpdatedAt ?? 0 const isFirstClone = input.isFirstClone ?? false - const { owner, name, branch } = repo - const headSha = await getBranchRefSha(token, owner, name, branch) - const treeSha = await getCommitTreeSha(token, owner, name, headSha) - const remoteTree = await getTreeMapConditional(token, repo, treeSha) + const headSha = await provider.getBranchHeadSha(repo) + const treeSha = await provider.getCommitTreeSha(repo, headSha) + const remoteTree = await provider.getTreeMapCached(repo, treeSha) // Build the gitignore matcher BEFORE walking the tree so step 1's // .md loop can short-circuit on ignored paths. The matcher is also @@ -111,7 +105,7 @@ export async function pullFromGitHub(input: { let remoteRaw = '' if (gitignoreSha) { try { - remoteRaw = await getBlobContentConditional(token, repo, gitignoreSha) + remoteRaw = await provider.getBlobContentCached(repo, gitignoreSha) } catch { remoteRaw = '' } @@ -219,7 +213,7 @@ export async function pullFromGitHub(input: { // didn't cover). The conditional read may itself be served from the // ETag cache and short-circuit before hitting the network. decrypt // runs here either way. - const raw = prefetchedBlobs.get(remoteSha) ?? await getBlobContentConditional(token, repo, remoteSha) + const raw = prefetchedBlobs.get(remoteSha) ?? await provider.getBlobContentCached(repo, remoteSha) remoteContent = await maybeDecryptFromPull(raw) } return remoteContent @@ -398,14 +392,14 @@ export async function pullFromGitHub(input: { // and remote edits don't overlap line-wise we can auto-merge and the // user never sees the conflict tab. The common ancestor is the REMOTE // blob we last synced against (`gitRemoteBaseSha`, fetchable via - // getBlobContentConditional) — NOT gitLastPushedSha, which is the SHA of + // provider.getBlobContentCached) — NOT gitLastPushedSha, which is the SHA of // the transformed local bytes and may not exist as a remote blob at all. // Anything that goes wrong (no ancestor sha, blob GC'd, network hiccup, // overlapping edits) falls back to the existing manual conflict flow. let autoMerged: string | null = null if (remoteBase) { try { - const ancestorRaw = await getBlobContentConditional(token, repo, remoteBase) + const ancestorRaw = await provider.getBlobContentCached(repo, remoteBase) const ancestor = await maybeDecryptFromPull(ancestorRaw) const merged = threeWayMerge(ancestor, localContent, content) if (merged.ok) autoMerged = merged.merged @@ -508,7 +502,7 @@ export async function pullFromGitHub(input: { const remoteSettingsSha = remoteTree.get(vaultSettingsPath) if (remoteSettingsSha) { try { - const raw = await getBlobContentConditional(token, repo, remoteSettingsSha) + const raw = await provider.getBlobContentCached(repo, remoteSettingsSha) const { parseVaultSettings, vaultSettingsHash, pickVaultSlice, serializeVaultSettings } = await import('../vaultSettings') const parsed = parseVaultSettings(raw) if (parsed && parsed.updatedAt > vaultSettingsLocalUpdatedAt) { @@ -689,19 +683,29 @@ export async function pullFromGitHub(input: { // same SHA-1 of `blob \0`), so a separate tree fetch isn't // necessary. export async function pullFromZipball(input: { - token: string + // Host abstraction. The archive download goes through provider.fetchArchive + // (GitHub: zipball). A provider without an archive endpoint can't take this + // fast path — callers gate on `provider.fetchArchive` before choosing it. + provider: GitHostProvider repo: SyncRepo // Phase hint so the caller can surface "Downloading vault (retrying)…" when // a corrupted/truncated archive triggers a re-download. Optional — the retry // loop works regardless. onPhase?: (msg: string) => void }): Promise { - const { token, repo, onPhase } = input - const { owner, name, branch } = repo + const { provider, repo, onPhase } = input + const { branch } = repo + + // This fast path only exists for hosts with a whole-repo archive endpoint. + // Callers gate on provider.fetchArchive; guard here at the boundary so a + // mis-wired caller fails loudly rather than throwing an opaque TypeError. + if (!provider.fetchArchive) { + throw new Error(`pullFromZipball: provider "${provider.kind}" has no archive endpoint`) + } // The ref is cheap and we need it for `latestCommitSha` regardless — fetch // it once up front, independent of the archive retry loop below. - const headSha = await getBranchRefSha(token, owner, name, branch) + const headSha = await provider.getBranchHeadSha(repo) // Lazy-load jszip — only callers of pullFromZipball pay the // ~140kB cost. The rest of the sync flow (push, regular pull via @@ -723,7 +727,7 @@ export async function pullFromZipball(input: { let attempt = 0 for (;;) { try { - const zipBuffer = await fetchZipball(token, owner, name, branch) + const zipBuffer = await provider.fetchArchive(repo, branch) zip = await JSZip.loadAsync(zipBuffer) break } catch (err) { diff --git a/src/utils/githubSync/syncPush.ts b/src/utils/githubSync/syncPush.ts index c3a3d3e..7cef902 100644 --- a/src/utils/githubSync/syncPush.ts +++ b/src/utils/githubSync/syncPush.ts @@ -8,19 +8,9 @@ // from `@/utils/githubSync`. import type { Note, SyncRepo } from '@/types' -import { - getBranchRefSha, - getCommitTreeSha, - getTreeMap, - createBlob, - createBlobBinary, - createTree, - createCommit, - updateBranchRef, - getBlobContent, - gitBlobSha, - type GitTreeEntry, -} from '../github' +import { gitBlobSha } from '../github' +import type { GitHostProvider, FileChange, CommitProgress } from '../gitHost/types' +import { _resetUploadedShaCache } from '../gitHost/githubProvider' import { listAttachmentPathsTracked, getAttachmentBlob, @@ -49,7 +39,10 @@ export type PushProgress = | { phase: 'done' } export interface SyncInput { - token: string + // Host abstraction for the final write. syncToGitHub builds a host-neutral + // FileChange[] and hands it to provider.commitChanges; the provider turns it + // into commits its host's way (GitHub: blob/tree/commit/ref). + provider: GitHostProvider repo: SyncRepo notes: Note[] folders: import('@/types').Folder[] @@ -79,32 +72,10 @@ export interface SyncInput { onProgress?: (event: PushProgress) => void } -// In-memory cache of blob SHAs we've already uploaded to GitHub in this -// tab session. Git blob SHAs are content-addressable, so a hit here -// means GitHub already has that content — skip the redundant network -// round-trip. Survives across syncToGitHub calls within the tab but is -// cleared when the user reloads. Indexed per-repo so two different -// vaults don't share state. -const uploadedBlobShaCache = new Map>() - -function repoCacheKey(repo: SyncRepo): string { - return `${repo.owner}/${repo.name}#${repo.branch}` -} - -function getUploadedShas(repo: SyncRepo): Set { - const key = repoCacheKey(repo) - let set = uploadedBlobShaCache.get(key) - if (!set) { - set = new Set() - uploadedBlobShaCache.set(key, set) - } - return set -} - -/** Test hook. Drops the in-memory upload cache. */ -export function _resetUploadedShaCache(): void { - uploadedBlobShaCache.clear() -} +// The blob upload-cache now lives in GitHubProvider (it is a GitHub-blob +// optimization). Re-exported here so the public test hook keeps its import +// path (`@/utils/githubSync`). +export { _resetUploadedShaCache } export type GitPathUpdate = { noteId: string @@ -137,9 +108,8 @@ export interface SyncOutcome { } export async function syncToGitHub(input: SyncInput): Promise { - const { token, repo, notes, folders, commitMessage, vaultSettings, vaultGitignoreDraft, onProgress } = input - const { owner, name, branch } = repo - const uploadedShas = getUploadedShas(repo) + const { provider, repo, notes, folders, commitMessage, vaultSettings, vaultGitignoreDraft, onProgress } = input + const { branch } = repo onProgress?.({ phase: 'computing' }) // 1. Compute desired files for every active note. @@ -184,10 +154,12 @@ export async function syncToGitHub(input: SyncInput): Promise { desired.set(path, { content, note }) } - // 2. Fetch the current branch state. - const parentCommitSha = await getBranchRefSha(token, owner, name, branch) - const baseTreeSha = await getCommitTreeSha(token, owner, name, parentCommitSha) - const remoteTree = await getTreeMap(token, owner, name, baseTreeSha) + // 2. Fetch the current branch state. PLAIN reads (not the pull path's + // ETag-conditional variants) — a stale tree here risks a non-fast-forward + // commit, so push always talks to the live remote state. + const parentCommitSha = await provider.getBranchHeadSha(repo) + const baseTreeSha = await provider.getCommitTreeSha(repo, parentCommitSha) + const remoteTree = await provider.getTreeMap(repo, baseTreeSha) // Layered gitignore matcher for push (gi9n): defaults + remote + // local overlay. Same composition as the pull side so push and pull @@ -198,7 +170,7 @@ export async function syncToGitHub(input: SyncInput): Promise { let pushRemoteRaw = '' if (remoteGitignoreSha) { try { - pushRemoteRaw = await getBlobContent(token, owner, name, remoteGitignoreSha) + pushRemoteRaw = await provider.getBlobContent(repo, remoteGitignoreSha) } catch { pushRemoteRaw = '' } @@ -216,23 +188,34 @@ export async function syncToGitHub(input: SyncInput): Promise { // Defaults already applied above. } - // 3. Build tree entries for changes only. - const entries: GitTreeEntry[] = [] + // 3. Build the host-neutral change set for CHANGED files only. Unchanged + // files are NOT listed — the host carries them forward from the parent + // tree, so a no-churn sync produces an empty `changes` and never touches + // the host (see the early return below). + const changes: FileChange[] = [] const pathUpdates: GitPathUpdate[] = [] let created = 0 let updated = 0 let deleted = 0 + // Path-metadata updates for genuinely-changed notes are recorded as + // candidates here and committed AFTER the push, gated on whether the host + // actually transmitted that path's content this attempt (CommitResult + // .uploadedPaths). This mirrors the old behavior where a note whose blob + // was served from the same-session upload cache got a tree entry but NO + // pathUpdate. + const uploadedNotePathUpdate = new Map() + // We snapshot per-note pushed content so the editor's gutter diff // (109) can compare against it. The IDB write is fire-and-forget // outside the loop — collect first, write after the loop ends to // avoid serial awaits per note. const lastPushedToSnapshot: Array<{ noteId: string; content: string }> = [] - // Pre-pass: classify every desired path into "skip" (remote already - // has this SHA, or our in-tab cache says we uploaded it) vs "needs - // upload". Keeping the pre-pass separate lets us emit a stable - // `total` to the progress callback. + // Pre-pass: classify every desired path into "skip" (remote already has + // this SHA, or the note wasn't genuinely edited) vs "push". Keeping this + // separate from the host write lets the genuine-edit / normalization-churn + // logic stay here while the provider owns the actual blob upload + dedupe. // // push-only-real-edits: the upload decision must reflect a GENUINE local // edit, not just a wire-form mismatch against the remote blob. The remote @@ -248,8 +231,8 @@ export async function syncToGitHub(input: SyncInput): Promise { // canonical SHA as of the last sync). When they match the body is byte-equal // to what we last synced → NO genuine edit. A null baseline means a // new/never-synced note → MUST push. We use `plainSha` (NOT the wire/encrypted - // sha) ONLY for this change decision; `localSha` (wire) is still what we - // hash, upload and dedupe against `uploadedShas`/`remoteSha`. + // sha) ONLY for this change decision; `localSha` (wire) is the git content sha + // we compare against `remoteSha` to decide whether the file actually changed. interface NoteBlobPlan { path: string; content: string; note: Note; localSha: string; remoteSha: string | undefined; locallyChanged: boolean } const noteBlobPlan: NoteBlobPlan[] = [] for (const [path, { content, note }] of desired) { @@ -287,7 +270,7 @@ export async function syncToGitHub(input: SyncInput): Promise { if (locallyChanged && baseline !== null && remoteSha !== undefined && baseline === remoteSha) { try { const remoteRawBody = await maybeDecryptFromPull( - await getBlobContent(token, owner, name, remoteSha), + await provider.getBlobContent(repo, remoteSha), ) if (isUnchangedModuloNormalization(content, remoteRawBody)) { locallyChanged = false @@ -300,47 +283,53 @@ export async function syncToGitHub(input: SyncInput): Promise { noteBlobPlan.push({ path, content: wireContent, note, localSha, remoteSha, locallyChanged }) } - // push-only-real-edits: SUPPRESS the upload for a note that is NOT locally - // changed AND already has a remote blob at this path. We emit NO tree entry - // for it, so the base tree's existing (user's original, possibly - // non-canonical) blob is preserved untouched — zero rewrite. We STILL push - // when the note is locally changed (a real edit) OR has no remote blob yet - // (`remoteSha === undefined`: a brand-new note, or a note moved to a new path - // — the move's old-path deletion is handled by the deletion loop in step 4). + // push-only-real-edits: SUPPRESS the push for a note that is NOT locally + // changed AND already has a remote blob at this path. We emit NO change for + // it, so the base tree's existing (user's original, possibly non-canonical) + // blob is preserved untouched — zero rewrite. We STILL push when the note is + // locally changed (a real edit) OR has no remote blob yet (`remoteSha === + // undefined`: a brand-new note, or a note moved to a new path — the move's + // old-path deletion is handled by the deletion loop in step 4). const noteBlobsSuppressed = noteBlobPlan.filter(p => !p.locallyChanged && p.remoteSha !== undefined && p.remoteSha !== p.localSha) const suppressedNoteIds = new Set(noteBlobsSuppressed.map(p => p.note.id)) - const noteBlobsToUpload = noteBlobPlan.filter(p => !suppressedNoteIds.has(p.note.id) && p.remoteSha !== p.localSha && !uploadedShas.has(p.localSha)) - const noteBlobsCached = noteBlobPlan.filter(p => !suppressedNoteIds.has(p.note.id) && p.remoteSha !== p.localSha && uploadedShas.has(p.localSha)) - let blobsUploaded = 0 - let blobsSkipped = noteBlobsCached.length - // Use a single running `total` that we refine after the attachment - // pre-pass below. For now: just notes. - let blobsTotal = noteBlobsToUpload.length - - const emitBlobProgress = () => { - onProgress?.({ phase: 'uploading-blobs', uploaded: blobsUploaded, total: blobsTotal, skipped: blobsSkipped }) - } - - // Apply the cached-skip entries first — no network, just emit tree entries - // and bookkeeping. - for (const plan of noteBlobsCached) { - entries.push({ path: plan.path, mode: '100644', type: 'blob', sha: plan.localSha }) + // Genuinely-changed notes (remoteSha differs from what we'd push, and not + // suppressed) become create/update FileChanges. The provider owns the blob + // upload + same-session content cache; here we just describe WHAT changed. + for (const plan of noteBlobPlan) { + if (suppressedNoteIds.has(plan.note.id)) continue + if (plan.remoteSha === plan.localSha) continue + changes.push({ + op: plan.remoteSha ? 'update' : 'create', + path: plan.path, + content: plan.content, + // Forgejo's ChangeFiles requires the current blob sha when updating an + // existing file; GitHub ignores it. Undefined for a create (no remote + // file yet), matching the delete path which also carries remoteSha. + sha: plan.remoteSha, + }) if (plan.remoteSha) updated++; else created++ + // Candidate pathUpdate — committed after the push only if the provider + // actually transmitted this path's content this attempt (uploadedPaths). + // After a push the pushed blob IS the remote file, so the local baseline + // and the remote merge base coincide — set both to localSha (== the git + // content sha the host stores). + if (plan.note.gitPath !== plan.path || plan.note.gitLastPushedSha !== plan.localSha || plan.note.gitRemoteBaseSha !== plan.localSha) { + uploadedNotePathUpdate.set(plan.path, { noteId: plan.note.id, gitPath: plan.path, gitLastPushedSha: plan.localSha, gitRemoteBaseSha: plan.localSha }) + } } - // Pure-skip entries (remote has this SHA): no entry, no upload, but the - // note's path metadata may still need an update. The lastPushedSnapshot - // gets the PLAINTEXT body (gutter compares against unencrypted text). + // Pure-skip notes (remote already has this SHA): no change, but the note's + // path metadata may still need an update. The lastPushedSnapshot gets the + // PLAINTEXT body (gutter compares against unencrypted text). // // push-only-real-edits: a SUPPRESSED note (unchanged but its canonical wire // SHA differs from the non-canonical remote blob) is left ENTIRELY untouched: - // no tree entry (handled by the filters above) AND no pathUpdate. Emitting a - // pathUpdate here would rewrite gitLastPushedSha to `finalSha` (the wire SHA), - // overwriting the canonical baseline syncApply pinned — which would make the - // NEXT pull misclassify the note (localChanged would flip on every sync). The - // note did not change, so we leave gitPath / gitLastPushedSha / gitRemoteBaseSha - // exactly as they are. We still take a gutter snapshot (body is unchanged, so - // the plaintext is correct). + // no change AND no pathUpdate. Emitting a pathUpdate here would rewrite + // gitLastPushedSha to the wire SHA, overwriting the canonical baseline + // syncApply pinned — which would make the NEXT pull misclassify the note + // (localChanged would flip on every sync). The note did not change, so we + // leave gitPath / gitLastPushedSha / gitRemoteBaseSha exactly as they are. We + // still take a gutter snapshot (body is unchanged, so the plaintext is correct). for (const plan of noteBlobPlan) { const suppressed = suppressedNoteIds.has(plan.note.id) const skipped = plan.remoteSha === plan.localSha @@ -360,25 +349,6 @@ export async function syncToGitHub(input: SyncInput): Promise { } } - // Upload the genuinely-changed blobs. - if (blobsTotal > 0) emitBlobProgress() - for (const plan of noteBlobsToUpload) { - const finalSha = await createBlob(token, owner, name, plan.content) - // Cache by LOCAL SHA — that's what the next iteration computes from - // the same content. (In production localSha === serverSha because - // both follow git's content-addressing, but the local key is what - // gates the next cache lookup.) - uploadedShas.add(plan.localSha) - entries.push({ path: plan.path, mode: '100644', type: 'blob', sha: finalSha }) - if (plan.remoteSha) updated++; else created++ - if (plan.note.gitPath !== plan.path || plan.note.gitLastPushedSha !== finalSha || plan.note.gitRemoteBaseSha !== finalSha) { - // Pushed blob == remote file → both SHAs coincide. - pathUpdates.push({ noteId: plan.note.id, gitPath: plan.path, gitLastPushedSha: finalSha, gitRemoteBaseSha: finalSha }) - } - blobsUploaded++ - emitBlobProgress() - } - // Fire-and-forget the per-note snapshot writes. The gutter will pick // them up on the next render — we don't await because a slow IDB // flush shouldn't block the push completing. @@ -389,10 +359,10 @@ export async function syncToGitHub(input: SyncInput): Promise { } })() - // 3b. Local attachments → binary blob entries. Push uploads any local - // attachment whose SHA differs from the remote. Files only present - // locally get created remotely; files present in both get updated when - // their content drifts. Same upload-cache + progress treatment as notes. + // 3b. Local attachments → binary FileChanges. Push uploads any local + // attachment whose SHA differs from the remote. Files only present locally + // get created remotely; files present in both get updated when their content + // drifts. The provider handles the binary blob upload + content cache. // // attachment-timeout-retry: a stalled IDB read (mobile Safari) must not // silently look like "zero local attachments" — that would push a commit @@ -400,10 +370,10 @@ export async function syncToGitHub(input: SyncInput): Promise { // nothing would ever retry it (the attachment was already durably saved // locally, just never uploaded). So the *Tracked reads report `timedOut` // and, the moment any one of them fires, we abort the ENTIRE attachment - // section for this cycle: no partial plan, no tree entries, no - // `uploadedShas` writes. Because nothing gets marked as pushed, the next - // sync's 3b runs exactly as if this one never attempted it — that's what - // makes the retry automatic without a dedicated retry-queue. + // section for this cycle: no partial plan, no FileChanges, no bookkeeping. + // Because nothing gets marked as pushed, the next sync's 3b runs exactly as + // if this one never attempted it — that's what makes the retry automatic + // without a dedicated retry-queue. let attachmentSyncSkipped = false const { value: localAttachmentPaths, timedOut: listTimedOut } = await listAttachmentPathsTracked() if (listTimedOut) attachmentSyncSkipped = true @@ -427,30 +397,16 @@ export async function syncToGitHub(input: SyncInput): Promise { attachmentPlan.push({ path, localSha, remoteSha }) } const effectiveAttachmentPlan = attachmentSyncSkipped ? [] : attachmentPlan - const attachmentsToUpload = effectiveAttachmentPlan.filter(p => p.remoteSha !== p.localSha && !uploadedShas.has(p.localSha)) - const attachmentsCached = effectiveAttachmentPlan.filter(p => p.remoteSha !== p.localSha && uploadedShas.has(p.localSha)) - blobsTotal += attachmentsToUpload.length - blobsSkipped += attachmentsCached.length - if (blobsTotal > 0 || blobsSkipped > 0) emitBlobProgress() if (attachmentSyncSkipped) { console.warn('[syncPush] attachment sync skipped this cycle (IndexedDB stalled) — will retry next sync.') } - - for (const plan of attachmentsCached) { - entries.push({ path: plan.path, mode: '100644', type: 'blob', sha: plan.localSha }) - if (plan.remoteSha) updated++; else created++ - } - for (const plan of attachmentsToUpload) { + for (const plan of effectiveAttachmentPlan) { + if (plan.remoteSha === plan.localSha) continue const blob = await getAttachmentBlob(plan.path) if (!blob) continue - const uploadedSha = await createBlobBinary(token, owner, name, blob) - // See the note loop above: cache the LOCAL sha for the next-pass - // lookup, which uses local-side hashing. - uploadedShas.add(plan.localSha) - entries.push({ path: plan.path, mode: '100644', type: 'blob', sha: uploadedSha }) + const contentBytes = new Uint8Array(await blob.arrayBuffer()) + changes.push({ op: plan.remoteSha ? 'update' : 'create', path: plan.path, contentBytes, sha: plan.remoteSha }) if (plan.remoteSha) updated++; else created++ - blobsUploaded++ - emitBlobProgress() } // 3c. Apply attachment tombstones — paths the user explicitly deleted @@ -466,8 +422,9 @@ export async function syncToGitHub(input: SyncInput): Promise { const tombstones = attachmentSyncSkipped ? [] : await getAttachmentTombstones() const consumedTombstones: string[] = [] for (const path of tombstones) { - if (remoteTree.has(path)) { - entries.push({ path, mode: '100644', type: 'blob', sha: null }) + const remoteSha = remoteTree.get(path) + if (remoteSha !== undefined) { + changes.push({ op: 'delete', path, sha: remoteSha }) deleted++ } consumedTombstones.push(path) @@ -479,8 +436,7 @@ export async function syncToGitHub(input: SyncInput): Promise { // the no-change case keeps idle syncs commit-free. let vaultGitignorePushed = false if (vaultGitignoreDraft != null && vaultGitignoreDraft !== pushRemoteRaw) { - const blobSha = await createBlob(token, owner, name, vaultGitignoreDraft) - entries.push({ path: GITIGNORE_PATH, mode: '100644', type: 'blob', sha: blobSha }) + changes.push({ op: remoteGitignoreSha ? 'update' : 'create', path: GITIGNORE_PATH, content: vaultGitignoreDraft, sha: remoteGitignoreSha }) if (remoteGitignoreSha) updated++; else created++ vaultGitignorePushed = true } @@ -496,8 +452,7 @@ export async function syncToGitHub(input: SyncInput): Promise { const remoteHasFile = remoteTree.has(path) const localChanged = contentHash !== lastPushedHash if (localChanged || !remoteHasFile) { - const blobSha = await createBlob(token, owner, name, content) - entries.push({ path, mode: '100644', type: 'blob', sha: blobSha }) + changes.push({ op: remoteHasFile ? 'update' : 'create', path, content, sha: remoteTree.get(path) }) if (remoteHasFile) updated++; else created++ vaultSettingsHashPushed = contentHash } else { @@ -513,10 +468,10 @@ export async function syncToGitHub(input: SyncInput): Promise { const seenGitPaths = new Set() // rename-not-delete HARD PUSH-SIDE SAFETY NET (the critical data-loss - // preventer). Before we emit ANY `sha:null` delete, build the set of remote - // paths that a LIVE (non-deleted) note still represents. A remote file at - // such a path MUST NOT be deleted, even if some upstream classification was - // wrong (e.g. a rename misread as a delete that soft-deleted the note). + // preventer). Before we emit ANY delete, build the set of remote paths + // that a LIVE (non-deleted) note still represents. A remote file at such a + // path MUST NOT be deleted, even if some upstream classification was wrong + // (e.g. a rename misread as a delete that soft-deleted the note). // // A path is protected when EITHER: // (a) it is in `desired` — an active note's CURRENT computed path maps to @@ -567,7 +522,7 @@ export async function syncToGitHub(input: SyncInput): Promise { // Safety net: never delete a path a live note still represents (by // content), even though this note's CURRENT path moved away from it. if (protectedRemotePaths.has(note.gitPath)) continue - entries.push({ path: note.gitPath, mode: '100644', type: 'blob', sha: null }) + changes.push({ op: 'delete', path: note.gitPath, sha: remoteTree.get(note.gitPath) }) deleted++ } } @@ -578,7 +533,7 @@ export async function syncToGitHub(input: SyncInput): Promise { if (note.kind === 'foreign') continue // do-not-sync (#179): a flagged note never touches the remote, deletes // included. Soft-deleting the seeded tour note locally must not emit a - // sha:null for a legacy user's remote copy — remote cleanup is manual. + // delete for a legacy user's remote copy — remote cleanup is manual. // Skipping also leaves its git fields intact in case it is restored. if (note.doNotSync) continue if (note.isDeleted && note.gitPath && remoteTree.has(note.gitPath)) { @@ -592,15 +547,17 @@ export async function syncToGitHub(input: SyncInput): Promise { !seenGitPaths.has(note.gitPath) && !protectedRemotePaths.has(note.gitPath) ) { - entries.push({ path: note.gitPath, mode: '100644', type: 'blob', sha: null }) + changes.push({ op: 'delete', path: note.gitPath, sha: remoteTree.get(note.gitPath) }) deleted++ } pathUpdates.push({ noteId: note.id, gitPath: null, gitLastPushedSha: null, gitRemoteBaseSha: null }) } } - if (entries.length === 0) { - // Even with no tree changes, clear stale tombstones (files already gone + if (changes.length === 0) { + // No changed files → nothing to commit. The host carries the whole tree + // forward unchanged, so we never touch it (the no-churn invariant: 0 + // network calls). Even so, clear stale tombstones (files already gone // remotely) so they don't re-attempt every sync. if (consumedTombstones.length > 0) await clearAttachmentTombstones(consumedTombstones) return { @@ -611,46 +568,67 @@ export async function syncToGitHub(input: SyncInput): Promise { } } - // 5. Create new tree → commit → fast-forward branch. Each step gets - // its own progress event so the UI (and any error) can pinpoint where - // a failure happened. - onProgress?.({ phase: 'creating-tree' }) - const newTreeSha = await createTree(token, owner, name, baseTreeSha, entries) - // Some "changed" entries can resolve to a blob byte-identical to what the - // base tree already holds (e.g. a freshly-cloned note that round-trips to - // the same bytes on the first sync). GitHub then returns a tree equal to the - // base, so committing it would create an EMPTY "Sync from Noteser (1 change)" - // commit — "No files changed" — cluttering the history on every initial sync - // (and, with discard-on-switch re-cloning, on every repo switch). Skip the - // commit entirely when the tree did not actually change. - if (newTreeSha === baseTreeSha) { - if (consumedTombstones.length > 0) await clearAttachmentTombstones(consumedTombstones) - uploadedShas.clear() - onProgress?.({ phase: 'done' }) + // 5. Hand the change set to the host. provider.commitChanges turns it into + // a commit its host's way (GitHub: blob/tree/commit/ref). We translate its + // host-agnostic progress back into the external PushProgress stream so the + // UI sequence is unchanged. + const total = created + updated + deleted + const autoMessage = `Sync from Noteser (${total} change${total === 1 ? '' : 's'})` + const message = commitMessage && commitMessage.length > 0 ? commitMessage : autoMessage + + const translateCommitProgress = (e: CommitProgress) => { + switch (e.phase) { + case 'uploading-blobs': + onProgress?.({ phase: 'uploading-blobs', uploaded: e.uploaded, total: e.total, skipped: e.skipped }) + break + case 'building-tree': + onProgress?.({ phase: 'creating-tree' }) + break + case 'committing': + onProgress?.({ phase: 'creating-commit' }) + break + case 'updating-ref': + onProgress?.({ phase: 'updating-ref' }) + break + } + } + + const commit = await provider.commitChanges(repo, { + branch, + parentSha: parentCommitSha, + message, + changes, + onProgress: translateCommitProgress, + }) + + // Record path-metadata updates only for note paths the host actually + // transmitted this attempt (see uploadedNotePathUpdate's note). A path + // served from the host's same-session content cache keeps its prior + // metadata, matching the old cached-blob behavior. + for (const path of commit.uploadedPaths) { + const upd = uploadedNotePathUpdate.get(path) + if (upd) pathUpdates.push(upd) + } + + // Push reached the host (whether or not it produced a commit) — drop + // tombstones whose deletes are now applied so they don't re-attempt. + if (consumedTombstones.length > 0) await clearAttachmentTombstones(consumedTombstones) + onProgress?.({ phase: 'done' }) + + // committed:false means the host found the change set was a no-op (e.g. the + // built tree was byte-identical to the parent's). Report it as unchanged, + // exactly like the old empty-tree skip did. + if (!commit.committed) { return { - result: { unchanged: true, created: 0, updated: 0, deleted: 0, commitSha: parentCommitSha, commitUrl: null, attachmentSyncSkipped }, + result: { unchanged: true, created: 0, updated: 0, deleted: 0, commitSha: commit.commitSha, commitUrl: commit.commitUrl, attachmentSyncSkipped }, pathUpdates, vaultSettingsHashPushed, vaultGitignorePushed, } } - const total = created + updated + deleted - const autoMessage = `Sync from Noteser (${total} change${total === 1 ? '' : 's'})` - const message = commitMessage && commitMessage.length > 0 ? commitMessage : autoMessage - onProgress?.({ phase: 'creating-commit' }) - const { sha: commitSha, html_url } = await createCommit(token, owner, name, message, newTreeSha, parentCommitSha) - onProgress?.({ phase: 'updating-ref' }) - await updateBranchRef(token, owner, name, branch, commitSha) - - // Push succeeded — drop tombstones whose deletes are now in the commit - // AND clear the upload cache for this repo. The next push will start - // from scratch (which is fine — remote tree will be consulted again). - if (consumedTombstones.length > 0) await clearAttachmentTombstones(consumedTombstones) - uploadedShas.clear() - onProgress?.({ phase: 'done' }) return { - result: { unchanged: false, created, updated, deleted, commitSha, commitUrl: html_url, attachmentSyncSkipped }, + result: { unchanged: false, created, updated, deleted, commitSha: commit.commitSha, commitUrl: commit.commitUrl, attachmentSyncSkipped }, pathUpdates, vaultSettingsHashPushed, vaultGitignorePushed,