feat: add safe Gitea sync and signed updates

This commit is contained in:
NuklearRabbit
2026-08-27 00:38:58 +02:00
parent cb9bdcd713
commit 01137f70bb
46 changed files with 1658 additions and 246 deletions
+5 -1
View File
@@ -75,7 +75,11 @@ try {
"ForgeFlow-Setup-$version-win-x64.exe",
"ForgeFlow-Setup-$version-win-x64.exe.sha256",
"ForgeFlow-Portable-$version-win-x64.exe",
"ForgeFlow-Portable-$version-win-x64.exe.sha256"
"ForgeFlow-Portable-$version-win-x64.exe.sha256",
"ForgeFlow-$version-provenance.json",
"ForgeFlow-$version-sbom.cdx.json",
"ForgeFlow-$version-release-manifest.json",
"ForgeFlow-$version-release-manifest.json.sig"
)
foreach ($assetName in $expectedAssets) {
if (-not (Test-Path -LiteralPath (Join-Path $clone "dist\$assetName"))) {
+5 -1
View File
@@ -73,7 +73,11 @@ try {
"ForgeFlow-Setup-$version-win-x64.exe",
"ForgeFlow-Setup-$version-win-x64.exe.sha256",
"ForgeFlow-Portable-$version-win-x64.exe",
"ForgeFlow-Portable-$version-win-x64.exe.sha256"
"ForgeFlow-Portable-$version-win-x64.exe.sha256",
"ForgeFlow-$version-provenance.json",
"ForgeFlow-$version-sbom.cdx.json",
"ForgeFlow-$version-release-manifest.json",
"ForgeFlow-$version-release-manifest.json.sig"
)
foreach ($assetName in $expectedAssets) {
$assetPath = Join-Path $clone "dist\$assetName"
+7 -4
View File
@@ -4,7 +4,7 @@
ForgeFlow is een Windows-desktopapp voor wie Git, Gitea en eigen Docker- of Unraid-servers gebruikt. Je ziet in één werkruimte wat lokaal gewijzigd is, wat op Gitea staat en welke exacte commit op de server draait. ForgeFlow begeleidt je daarna veilig door review, commit, push, deployment en verificatie.
> Huidige release: **0.10.12** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest)
> Huidige release: **0.10.13** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest)
![ForgeFlow release-overzicht](docs/screenshots/overview.png)
@@ -12,6 +12,7 @@ ForgeFlow is een Windows-desktopapp voor wie Git, Gitea en eigen Docker- of Unra
- **Eén duidelijke actielijst:** zie meteen welke repository aandacht nodig heeft en waarom.
- **Veilige Git-flow:** review wijzigingen, stage volledige bestanden of afzonderlijke hunks, commit, push en herstel conflicten zonder contextwissel.
- **Veilige Gitea-sync:** bekijk vooraf welke bestanden wijzigen of verdwijnen, bewaar lokale commits in een recovery branch en zet gewijzigde of untracked bestanden in een stash voordat de werkmap exact gelijk wordt gemaakt aan Gitea.
- **Deployment op een exacte commit:** ForgeFlow gebruikt volledige commit-SHA's en toont lokaal, Gitea en server naast elkaar.
- **Automatische serverinventaris:** ForgeFlow herkent draaiende en gestopte Docker-, Compose- en DockerMan-workloads, koppelt alleen op betrouwbaar bewijs en houdt tijdelijke of externe containers apart.
- **Veilige server-pull:** Unraid haalt de exacte commit uit Gitea met een unieke, repository-scoped read-only deploy key en een vastgepinde SSH-hostsleutel.
@@ -28,7 +29,7 @@ ForgeFlow is een Windows-desktopapp voor wie Git, Gitea en eigen Docker- of Unra
4. Voeg je Gitea-server, token en lokale projectmappen toe.
5. Voeg optioneel een Docker- of Unraid-server toe. Start daarna **Scan servers** om bestaande deployments te ontdekken en veilig aan repositories te koppelen.
Vanaf versie 0.10.2 kun je nieuwe packaged releases vanuit **Settings → Updates** ophalen. ForgeFlow accepteert uitsluitend de release die bij de exacte Gitea-commit hoort en controleert de download tegen de gepubliceerde SHA-256-checksum. Zie [UPDATING.md](docs/UPDATING.md) voor oudere of source-only installaties.
Vanuit **Settings → Updates** kun je nieuwe packaged releases ophalen. ForgeFlow accepteert uitsluitend de release die bij de exacte Gitea-commit hoort, controleert de SHA-256-checksum én verifieert vanaf 0.10.13 een onafhankelijk Ed25519-releasemanifest met de ingebouwde publieke sleutel. Zie [UPDATING.md](docs/UPDATING.md) voor oudere of source-only installaties.
### Eerst vrijblijvend bekijken
@@ -94,6 +95,7 @@ Een gelijke commit bewijst welke code draait; een geslaagde healthcheck bewijst
- repositories ontdekken, favorieten beheren en ontbrekende lokale clones koppelen;
- status, diff, staging, partial hunks, commit, push, fetch, pull, stash en conflict recovery;
- read-only achtergrondfetch en een expliciete preview om een lokale projectmap veilig exact met de upstream Gitea-branch te synchroniseren;
- branches maken, wisselen, vergelijken en opruimen;
- branch protection controleren en pull requests openen;
- Git Validator met assurance score, bewijs per controle en gerichte veilige fixes.
@@ -114,7 +116,7 @@ Een gelijke commit bewijst welke code draait; een geslaagde healthcheck bewijst
- credentials versleuteld via de beveiligde opslag van het besturingssysteem;
- origin-checks voorkomen dat een Gitea-token naar een andere host wordt gestuurd;
- updatepakketten worden alleen vanaf de geconfigureerde Gitea-origin gedownload en met checksums geverifieerd;
- updatepakketten worden alleen vanaf de geconfigureerde Gitea-origin gedownload en met checksums plus een vastgepinde Ed25519-publisherhandtekening geverifieerd;
- lokale redactie van tokens, wachtwoorden en gevoelige diagnostische data;
- versleutelde configuratieback-up, herstelvoorbeeld en lokale audittrail;
- packaged builds als Windows-installer en portable executable.
@@ -156,7 +158,8 @@ Handige opdrachten:
| `npm run check` | Voert bronverificatie en de volledige testset uit. |
| `npm run doctor` | Controleert de lokale ontwikkelomgeving. |
| `npm run acceptance` | Voert de release-acceptatiecontroles uit. |
| `npm run dist:win` | Bouwt Windows installer + portable package, schrijft checksums en ruimt oude dist-artifacts op. |
| `npm run signing:setup` | Maakt eenmalig de lokale Ed25519-releasesleutel en schrijft alleen de publieke sleutel naar het project. |
| `npm run dist:win` | Bouwt Windows installer + portable package, schrijft checksums en een ondertekend releasemanifest en ruimt oude dist-artifacts op. |
| `.\Publish-ForgeFlow-Release.ps1` | Publiceert broncode én de bijbehorende Windows-release-assets als één gecontroleerde release. |
| `.\Publish-Missing-Binary-Release.ps1` | Herstelt een reeds gepushte versie waarvoor de Gitea binary release ontbreekt. |
+46 -42
View File
@@ -1,4 +1,4 @@
ForgeFlow 0.10.12 source manifest
ForgeFlow 0.10.13 source manifest
SHA-256 BYTES PATH
(The manifest excludes itself, dependencies and generated release artifacts.)
61f37822ae5502219a38b2eaf23fdcb611875f0e675efb4abe6157c9f072c0cc 937 .gitea/workflows/quality.yml
@@ -19,11 +19,12 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263
4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183 3364 build/icon-64.png
25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png
164c059453a5737110b4e5e98b6211650c757f0aff710f8f7523ffe0ff1815d7 113 build/update-signing-public.pem
5f4aca19a35cbcaffa1a6993ce96b7d66052ec2b286022f2af74594e8a310568 15712 CHANGELOG.md
c612fcc44ff222db0c9a4cfd11a4076fafe080e4ada31e689a08739a4f14e74f 1650 docs/ACCEPTANCE.md
a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md
e05458ee2696e3c57e2475bb42ae1f914f6a36e01768d7a26a3199f1fffed490 1157 docs/COVERAGE_POLICY.md
b9e39748ff125031be0ee8a963ef0d457c342f7998113fc2dd042ec237ae32ad 3084 docs/CURRENT_STATE.md
9eb9eec82518c0bfc7686f5faaf690a93ed63c71d35f1dc5a2c5ec5199da652a 3124 docs/CURRENT_STATE.md
8ea655d1912ac2e17f8834e33a566a8b14461b396ec4268c396ca189a1749b94 2205 docs/DEPENDENCY_AUDIT.md
30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md
eb42f979666e05d51c587e4223282914926a2b9b1ade9f3fb75525019ce7f738 4616 docs/DIAGNOSTICS.md
@@ -37,6 +38,7 @@ f79908fb3dad98c38030c6e6be7c79a1999e0478ed9c2496923891954438daa1 4581
5d3240169765e3fb1d3cd391d09547101227e76dd4670ee46be8ca3a21553a03 894 docs/RELEASE_NOTES_0.10.10.md
36edb4f096a248fb8679bd13e5766befb478cf628c6ccfb21e3eda71bbec7633 992 docs/RELEASE_NOTES_0.10.11.md
a355d3f577c2ec85dde5dfd7b6995f4f1615e2f6bac597529f3ff102acd93c35 1292 docs/RELEASE_NOTES_0.10.12.md
609c55a1c0b06c307ebe16f2daaf1e48601edd57137586e4f2be1febd6a7060a 1931 docs/RELEASE_NOTES_0.10.13.md
8d713471a437a8a55b00d7e1dd95290680862107bc4e586cf27d727f6274e46c 577 docs/RELEASE_NOTES_0.10.2.md
0942fb2c4a4f972296423b5232687f7389e2c6417a9d48a3244beef9dec907b9 1164 docs/RELEASE_NOTES_0.10.3.md
8f4a0fe6dc250ae210cc2fc1c57c46091822ae6c2a58caa76e0091f255f9f30d 775 docs/RELEASE_NOTES_0.10.4.md
@@ -79,7 +81,7 @@ ed40e08bac8792f95970bc05e49bce3cc9e288a08d11565a1bd156d787360a3b 720
25169225d73d22b9d884ab3b5c1625f03fd44e53c7a7a4c4067775e80482c9f8 2182 docs/RELEASE_NOTES_0.9.3.md
720506842e0aeb30c9fc635f86a52a5545556f092e678cf37f08436243244c3d 933 docs/RELEASE_NOTES_0.9.4.md
dd90c81a375f97dfb7fa8f7808db03b19d7e7dafe3818a93537397f57eaae829 2109 docs/RELEASE_NOTES_0.9.5.md
60cb1f1ed55322b519236dde8388ecf9ee5fd67c169fd8093b5acf61387557d8 2068 docs/RELEASING.md
1bf75f25d704dab0c6bc56c639d259f34523f0fb46718dd5a8419a59911ad2c3 2242 docs/RELEASING.md
ac76cb50fabde6a00f28d7e9eccd3ef1129a40665eabdc90d78690a38d424652 4195 docs/ROADMAP.md
1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png
b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png
@@ -88,13 +90,13 @@ ed69b8beb948a2cf9a6deb6c82368e2bb44ffe8d8990a900dc878b0938d1084f 95937
3868ab978de2a7945761c53a9a718aecd54dc791605d07660bcd5cad62a33ea8 103569 docs/screenshots/git-validator.png
007681714895ac062c980db1dda806ac17d4f01019ce9c46491a108d17c2dbda 85338 docs/screenshots/overview.png
1f78414b00ec100af2ec9bf5c9a3e400b6c9bf6dca6fcc317fd951789acc4536 112852 docs/screenshots/repository-workspace.png
158cd3a13e9c4d081a63575fbafc77e0b23812f793a15096888b3667f41fa28c 5605 docs/SECURITY.md
735950c1e77bd4a1cf5ee986a7a307600741e5fdb90918adb0687bd29a89aaec 5877 docs/SECURITY.md
32a34ec13a284d3f9ceebbc107b25a844e3db096f8cafa4e43951fc2050c9a03 13552 docs/SETUP_GUIDE.md
2fd71e9bcaeb4cb10c3fa2496b7e52fedf70c5b7f871cd587e22dc060c399079 4421 docs/SSH_UNRAID_DEPLOYMENT.md
b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md
0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md
4983414a980075e6faae687b0d71c8e57bfe53fcb4cadb8b979b8abca636fe95 6654 docs/TEST_MATRIX.md
dbbd9fa96988e7543e98c85da864adaadd3057815f18d20a3b3ccb5c540a169d 4558 docs/UPDATING.md
03fb2fe52a863b9d3d536f3c8abe23e47b9851be7fd7ccbfb106554b9c595385 5013 docs/UPDATING.md
73f094a2f0db3de053e515feb2771cd5a4f3aa4178f2c5f37be01ca65ff1c938 2705 eslint.config.js
c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 examples/gitea-actions/deploy.yml
4c792cc9fd57ed36da291300c252a6ef75b08a249cf6f2561e23c4c22522138a 1477 examples/gitea-actions/rollback.yml
@@ -106,16 +108,16 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE
e2daa28bbc01c68c3702add6ea8259dff5920b22f6fdc3c9193ed78a153f2e9e 14708 main.cjs
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
198a8a51705fcea2ddf716558a49e06df5a8181c5476709c474d183b64890ca0 179808 package-lock.json
fb12ca4da1105da46b4aacacb67260357c29aa362385c9cfaaeadeffd1bf82b3 6142 package.json
46e77e759b75c39737fc9812d105b31c20b68f0c46c7994bebf01a487ec9f207 179808 package-lock.json
ba81105b16f3f3605a9e578a9e7a40d6fbc29f0001ad96f7f296441e563ceeb4 6367 package.json
1237df9ddcbb5ac7dc4316f18c34ff4a7030e3e0d56216ade6dd07369e5e2a04 1353 playwright.config.mjs
16d6c37007c6c14dbf541060535c594308758477b867fc2a190c2946d74022b9 12196 preload.cjs
e8f678b26a1b651ee0e06e499b0538d8a193d0565687e9f750b58f801e4fabd8 12473 preload.cjs
abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd
f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1
688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1
42e2f375c2fa9bea138428b85eb2bb93fe8bf6e4a5fc0cd728f2c6713a019df7 10315 README.md
bfddedaf5e21a4948a4ba3d0ca89aee968028990f0079748963d4088f22d4537 14962 reports/architecture-audit.json
68c2e5d245f93bb6c8e53bed105ed827601443827972b157a60b858aa440afaf 1343 reports/architecture-audit.md
6d0858d6654c3c3dc7083ebbd234c88324afcebaecd7b772719440a8afbc2e4e 10736 Publish-ForgeFlow-Release.ps1
33f3c4795705ab77c6e6603c88a32c123b3a286bc77e8e472b76970485699338 4386 Publish-Missing-Binary-Release.ps1
423447da25b001d869f9e9d7c860710f470945e72344332fc16b6c42a70d3ff6 10965 README.md
79d93000a8b9dec65448f866f48d83b62d6bf37af26480becd73654544d454e1 15831 reports/architecture-audit.json
dd54fd4a24120a466603acedab329bd3ac6f482ddb1e65c305d4cc755be5ec0f 1516 reports/architecture-audit.md
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
d0745072321aca2c80f44460974a7926715a9f429164aaf7660dced40b52c736 4790 scripts/apply-binary-update.ps1
404863bcbe7292355662e3a326455df864d7279badc29f90866a3b837420df54 10745 scripts/apply-source-update.ps1
@@ -124,73 +126,75 @@ d0745072321aca2c80f44460974a7926715a9f429164aaf7660dced40b52c736 4790
6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs
0244d42896b8c44f734d0bb6cdcb29b5981342be2f070ce89f8d9eaf3e4d49e6 1793 scripts/generate-source-manifest.mjs
842436680521311594e798848b050ae4e488d0595f0de57315f6ec081c049fb9 1266 scripts/prune-dist.mjs
b83d443f5724ac15393567f3a688aed8315fbe3e5966832c864a9466e0669464 8102 scripts/publish-binary-release.cjs
d0e6fd6ce67b553a3654acd4393e5b9c3be825c45d03d957fee36fb2a3c56a85 8308 scripts/publish-binary-release.cjs
558ff442988f1396c174c7161ff5bd3ef0b2f43cfc31459ec7c3967faa146bc3 1694 scripts/serve-demo.mjs
288c4b93f6006c0b32cdf90555bdc0d1d3b61d24a8763fcc30f1e6425ce1684d 1713 scripts/setup-update-signing-key.mjs
431d3d7eabf7e2ea2d5cbb96fb0ddc13f26d85afebcb9692f3e30242197cbd8d 2607 scripts/sign-release-manifest.mjs
c76507857292c5713e1c699cf02e24b80265da39af2cecd148034bdb874adbb6 5246 scripts/test-authenticode-chain.ps1
4393f7dc5f417e6d601a68238f4e26791799a3634acec228fe4d79deaee85eb5 3109 scripts/validate-installed-connections.cjs
e6127e1e62f39c70ddb1abf72f4d7e7b8e3f19ff1f219e1a3660353c2e0cdfac 2411 scripts/verify-release-signatures.mjs
02832d62cc6a4b9bc4e300a8c4528983272c903498eab77f97bda6467504608d 21420 scripts/verify.mjs
0b9f03ba3c67ff7cdb2916a902ad8ce25e81a7c90b210e4ae52d2ad029efabf3 2353 scripts/write-release-checksums.mjs
587efcb60a363614fb61f722fa84a81e4da09c5ea35e6897009f82c5d244d740 21919 scripts/verify.mjs
c2c9e4ba251d93a530a52b2d0079787680261c314083bb99d2356fc177719613 2434 scripts/write-release-checksums.mjs
619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1
dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs
4ca0fd6e1084db8db67e0b1c1c2d54ca6d29fe76da4f322dc417db00ef91c3dc 33088 src/main/config-store.cjs
5506ac1e5e49006ffd028a29c89bd0b95485ea3f2abc22db4f2b9fedd959852f 33194 src/main/config-store.cjs
2fb04b1494b39f5d7c0720fa5fd298cd46fa85dc1b696d77657592347fcf1819 2731 src/main/configuration-backup.cjs
86e9fc2eda66b4b563f6c4bbb87d3e8514340d484fb503b73137e63b6b05c3c9 14597 src/main/deploy-key-lifecycle-service.cjs
7cbfe51973d6607203cb197652ed7f296a3f6b6b644df876957117866a47d802 2159 src/main/deployment-identity.cjs
ce30ddac403d1adf21176e5df21b0cc3db435305d2628f51f1486eacf20df6f2 23708 src/main/deployment-service.cjs
165a37f8fbc82fb17e66c177f32aef70ac1ea293b00b82fc9fe301fe7e8b7080 15943 src/main/diagnostics-service.cjs
f22348297291199e858656248cec70f94f002144ea7ea0807bd844cc5016baaf 16277 src/main/diagnostics-service.cjs
a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs
665859c48be68e5f256b9fac9a4722390cf1154705c099778f36e0e0a6c07e27 35214 src/main/git-service.cjs
3d19a328eec427329fd9123b24fe79b5ab6670c0b33ac68397948e8df636a99d 43961 src/main/git-service.cjs
e28fc1ca2fd4c0116148f5005d793feddf04c36ef711d2d348560394d209a613 7253 src/main/git-validator-policy.cjs
3a101b63ad3761c26350c2ac0793279a0b27672b91a5b1d8dc75bc44d92f0b52 27128 src/main/git-validator-service.cjs
3cc53e24e023aa0d8bf36c35ce9672ca98e6c74066512c8b59ab42274e838c22 21307 src/main/gitea-service.cjs
2ad3b2e647377f687ad987fe248a142ad399ecac98e4b49965aa7efc6093e5fa 6914 src/main/inventory-classifier.cjs
dafdb09133d2b6ec2161a3f0b09354551e54fc606c8107976fca37405643be91 3404 src/main/inventory-review-service.cjs
0e01dfeb9ad230ee13ee913e17b577b1d34507b9b436ebf849fa05312e470d46 24398 src/main/ipc.cjs
5e10cf3759bbf9294909866ed16f206d394789f0564549fc0fe23cdd89118ca6 25110 src/main/ipc.cjs
26efebb4c147ed560966e7e60e64a013b3476327b3bbdb4e4439949142fa7846 2250 src/main/ipc/channel.cjs
b80357dd1f0aa18022d92db85b6cc8f29bc691ef11f9a0e90b4886ae5e19c763 12543 src/main/ipc/deployment-handlers.cjs
dc9b5971c9fefe8c374aa31916f5513601ce86003fd48b1d0e51330a909ae3a5 3442 src/main/ipc/operations-handlers.cjs
253d4f027de09e1e8fd46b40ef0da4eaf8350c0edfbf069b0a3429d712c7e8c4 15929 src/main/ipc/repository-handlers.cjs
8072252821b1245d121eac534a18eeb64f0d7d18429e272e21a6e90b010005b9 17272 src/main/ipc/repository-handlers.cjs
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs
720c4a0c554f46386d87c3ab6607d1fbcae66e50b69483c7dbba169d5128c851 680 src/main/process-error-policy.cjs
3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs
e64f7257d478955c675a133b3735b6afe138a69d2ad090898061e56f557c43e5 9926 src/main/production-acceptance-harness.cjs
c629efa832a806409954ab5a4986bf88606ce5fa5278bb1885a76222d1cb6faf 6441 src/main/repository-monitor.cjs
27bd6621c731545ec46d8914e9408c89928a8ce563b40eb4bcd8a516662a54d1 8716 src/main/repository-monitor.cjs
6393583911263575c6e2a19d9baab6e638cce90252c386b0a5144f2fb6f81f15 12154 src/main/repository-service.cjs
52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs
792c5cf1b884e71a601b80ee373ff81178c34a910da5a7d64d3040f266bb0cee 20398 src/main/ssh-service.cjs
793003566823e1d5c02283f583888ecc07e44477525620579b3d858f488b3c08 22347 src/main/ssh-service.cjs
19538a3c40ea3489bbaee9a23af36a5e99962af6bb3d04259f05ece6588cbeb2 25901 src/main/unraid-access-methods.cjs
5621e35323e4f81fb14a05670f81579ec1e66bea3a55fa6457ece0f807421424 9801 src/main/unraid-deploy-key-host.cjs
6d9910dace52625f88e066a8485af2663c3735ff15e9ce9031441ce742710a21 30793 src/main/unraid-deployment-methods.cjs
673b1692e7c2b5197545df98750b5d048bddf44206263e25be4f17d9bf900e2c 17208 src/main/unraid-deployment-service.cjs
a2fa253bb2664ad7e430d7a4cd4c4434b89e6c54c19c7b9e4f53fe08485339d3 42652 src/main/unraid-inventory-methods.cjs
bb4a99c3526fcf4db4fbae88a058e8598fd10a87990e7d502bfdc765328bdaa1 42766 src/main/unraid-inventory-methods.cjs
2c0cf07921ca7ee5a9085ced44498c2e6798e5cc1e8a5ecf704c3cecabe39a25 27607 src/main/unraid-preflight-methods.cjs
d45220176aed72d692f9ae5534f9d40bcc359a2d08e025e74a3b3b505b8b9ed4 16559 src/main/unraid-runtime-methods.cjs
4c5cf01922e1feb36a31b50af22e973d8aee3fecccd406e449690604111898ac 11608 src/main/unraid-state-methods.cjs
d968d64d1e65f4a0fbb7a19f16775469d8e2e35b5ae249813c0b635b27bef8fe 22311 src/main/update-service.cjs
0b25c3729c5fffe3cd412c2325616bb86a6c916ae248eeb39d837378bb78c144 26420 src/main/update-service.cjs
b5c304531bec358d059189a27cd9db8fa20cefb7f817e5eb0287001f7353f6a7 985 src/renderer/actions/command.js
d0bf607dd1de9d55f2947d0adf0997cd3ca5c269d10a5362cc1d8bc4d1a2a8ae 6706 src/renderer/actions/deployment-operation.js
33e65457e44708cecbe859b8c8a0c51b4ab33219c3b7a9f9dad884089f07d367 17904 src/renderer/actions/deployment-profile.js
0db283b1a458ae0b31538940b1ddc931ffdb53bd04ceb7fd8903813f9200d071 17978 src/renderer/actions/deployment-profile.js
48bed91dd2a85bb51ee7307f7acc3b79c881ce8cf63b22ba79d5d079b265eb4b 7785 src/renderer/actions/inventory.js
4227a05a20580a31127d2c929640defc3d36e8e3e89d6be830aab1940da81082 12267 src/renderer/actions/recovery.js
cdfaacdcd5ae04b0e5c79fefa21f5e09d5c810bcea504c5b6e1d6b744182ff84 15567 src/renderer/actions/setup-and-settings.js
13b8611b5389625deeec59ff2a6cfebcfc93bd7972902439715be371d1f9f573 14936 src/renderer/actions/recovery.js
2414a0d29a0380d343b9b0e58ba1909e7a7eeb46357fd45ddbb3ad411d119f78 16280 src/renderer/actions/setup-and-settings.js
a980f2e86d8286ea605a7259b9e9adcf3fda4f657b8d54a5d2d8765a7bbbec13 19065 src/renderer/actions/shell.js
984da4d93acc7461078b70c1c767f4843ef22fa5f3e1d947a1f3d5a5175ed20d 26601 src/renderer/app.js
65e305965d6d00d45b516f271c0f905854797af07c38982768199bb30d988c2b 26659 src/renderer/app.js
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png
094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png
02ae73c50856c827ed13e929396643699a29ac03114ceef1a8706e2e172578c5 51148 src/renderer/dialogs.js
fd47efd296409c19b80fd7719429e3eb33963ef78f30bc748d1770308aa472a3 55138 src/renderer/dialogs.js
dede1f21a06c73a2c2a462a869d27530d85f99baff202a2eb509c57436ad6aec 2732 src/renderer/diff-view.js
eef2f269ba4fbb76bf66ad328d481b461255d0acb753b30878dd4d4eaac57dc6 6924 src/renderer/events.js
c4a71213d412166093f7bd8254b847de4d8beb58c1aaa356a0cdc8d728080326 1524 src/renderer/index.html
06180d9656dd254edfb6949c397f8e313954fc560ddcb22b3a35fce3c3e35655 21350 src/renderer/mock-bridge.js
870024aff376826a92c9cf7452689cc1ecc5d9034f055bea56734f3f7fcea5e5 28703 src/renderer/mock-deployment-bridge.js
81b0155dd51c7bc8871fd29bd7fdb28857cf11a86ed6d6953238c04c2774800c 20033 src/renderer/mock-repository-bridge.js
92cacf58a3576044800ee5f9823d4fd2a1c768571b7f5d271950f5002a80d212 25031 src/renderer/mock-repository-bridge.js
94fa265c2fe9ca8d644f0ce9b620b6f85d9b25dca5802c4e9195b66dcbe80120 6522 src/renderer/operations.js
21b8f3228bddd6da06613857763282d1f88f4eeab3d063f3a63395d7b567f2e4 80004 src/renderer/styles.css
f06b2ef7cc4d7f6ce83f5227212271c25d7a904f45bb7ab84f06b082bd9298b5 100508 src/renderer/views.js
431ed7bbadecbcc99572aea9a5ae5550dbb5bacbd2d8028f8a05bf77f97b251e 81161 src/renderer/styles.css
8ef063feee96c0a3692da21fa797bf44325388b94e051d53f20a5198036c9252 102998 src/renderer/views.js
e9e72c072a5c5d04f59cd6763de0cfbf736c2a5ffa2f722143f3bad2bdbc630b 1411 src/shared/clone-target.cjs
5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs
029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs
@@ -198,7 +202,7 @@ e9e72c072a5c5d04f59cd6763de0cfbf736c2a5ffa2f722143f3bad2bdbc630b 1411
c7e120ea53c5ef3c01b8cce71afe913f34bb461bb73aa3ade24656e09f99f338 1152 src/shared/semver.cjs
8791d3813e6cf285ee6aa49f76e75fc1f3af76fd98c76bcb3c92ee18e9cb699f 2889 src/shared/shell-verification.cjs
2daa98fd421598bfe5fc9757c9b6f4d82c31d1bfece15829928473581d5d2639 1210 src/shared/tool-invocation.cjs
114f01be8bd54c91b90af82d8e1604e24cc0c5f8e64e63c40cf3f4042623a98e 5402 src/shared/validation.cjs
ee73fdf9c591c029243385cb2d2085c3005c7b08c5b9e1b89102201f0ab30759 5702 src/shared/validation.cjs
13b731c38863b1007b0312fd9d89562401b7cce875c952f52429bde74f77a8af 3096 src/shared/zip-writer.cjs
f8853dce6fdf360d5df2fbe2b6df3e5687630c807fee5ba8436679b34ec737ea 2436 START_HERE.md
058aeaa5d9bfe377c7e322f213c7871ecc4151b5d08ef790992f4ee28d857658 743 START-FORGEFLOW-OVERLAY.ps1
@@ -206,7 +210,7 @@ f5b0ea887fcdeadec78c1ad49b0ec7979723562f5c0b730703acb77a37281ee0 1009
a4e5947204ff6878e601e32477bc85b53cd0153baf95a161c8935b6e5466c257 1155 tests/audit-service.test.mjs
0841f6a2515508f8d28562d5c565c1519d357b8579515f890883dc2aec7d7737 16881 tests/browser/forgeflow.spec.mjs
1728c0a7abd92f4d7d9e68df32e4a6b00730555f23795e9b36416795d9d127af 5978 tests/clone-target.test.mjs
ac17f8bbe9e388b80abef7792c8b184a1fd482c93f13d23a478e433961020f75 17214 tests/config-store.test.mjs
aa2ae0e5a12bc47f8024e0d7408148e3af797e3f3f0ecb2525fb1cc54cf4e1f0 17378 tests/config-store.test.mjs
f1463326aee79842d265687ae628189ce54e92544600f2bd14073780287cfb14 2502 tests/configuration-backup.test.mjs
144c8e217a334cd69f502938d944e0f2dac61703d5df47e287b9ed542918c779 8129 tests/dependency-wiring.test.mjs
aad5948ea374d1e56e777005c73639654c96a90364dd398c949052cf5ae343a2 11130 tests/deploy-key-host.test.mjs
@@ -216,7 +220,7 @@ b7e009fed4171d6dd6b4c3154ba1d3f7198e98f5b79b298687841fc8169447cd 9354
bf4576901e32662d832687a2761852aa1b2cffe256de5044f18c6637c189463b 9780 tests/deployment-status.test.mjs
fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800 tests/diagnostics.test.mjs
dd121d96ca265a027cd415a52064500a4541b2f8a662f4f4b25f2f996d52b5da 762 tests/external-tools.test.mjs
e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 tests/git-integration.test.mjs
1da4abd9355183ee04410b3d89403cc36094eb5df622ecfe6736e8807135bd28 19532 tests/git-integration.test.mjs
5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db 1239 tests/git-status.test.mjs
61e0b8cad926acd22b5b17e4044f7edcbe96b6977cbbe2b6fbe123406626fc89 4283 tests/git-validator-policy.test.mjs
2b31459f14a5e36e30cf84c1054f634f4dba8676d29adeb9c2a8e18179f56fa0 6097 tests/git-validator.test.mjs
@@ -229,20 +233,20 @@ caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392
1b6c920e18a248f78acaed6187197c88ec8d911b62d5e2a9f8ad57b91ae80499 11827 tests/preflight.test.mjs
7f2751ea2621f76b5427f442e931344d13e97faa7b6ef3151949bbd6a03097cf 1205 tests/process-error-policy.test.mjs
0cb884cf62c1cb02cf59a81662be055bcb5339d176de85e2a3eeb8e8573e11b3 6435 tests/production-acceptance.test.mjs
9e4064295909405674c945378062b772a370fcc26c855d343c0840f9e18bbb1d 9927 tests/renderer-workflow.test.mjs
252e0345fcd3cd3899467f2a9ed363df0675d529e59d4d6e1204f4464fd8a5f7 11485 tests/renderer-workflow.test.mjs
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs
e3c6e0f5c9351fce2ccf96b8abb15de404a0f935633df0b5b1c3fefa207c723b 3993 tests/repository-monitor.test.mjs
76712a5d26f2598b00b83b925c9c84a90ab81c0eb1760895e9d6a2bd2f6eb425 5828 tests/repository-monitor.test.mjs
5476f3ba90bc096d4172900d9b54ada7c12da521f8627913d87794eade3cee23 13494 tests/repository-service.test.mjs
d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463 tests/security-validation.test.mjs
5fea04e668344508fb4e16da9bb6fe8733e2b83d1c227acb3421e51da26b2ffa 3636 tests/security-validation.test.mjs
bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs
12cb3b240bdd0922566323c0014838ca067ad10d9d4009943165ae2c4e93bc6f 11786 tests/server-inventory-branches.test.mjs
020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs
a39d30f47813dfb98c998811f3d76ebbb1544ecfd017a165d44f9afb80d7daf9 9090 tests/ssh-connection-pool.test.mjs
7ee9166327ed227d2b7c6929692dea5c5d7a41c3e566596fa92d9ec4f42e8677 4085 tests/ssh-connection.test.mjs
0d1bc4d623ce299337736c577ec61c8ffd6974ebe20335b72838d10eae35ecb1 7993 tests/ssh-service.test.mjs
c9354e4bf3720c28cff21c15ff8b9474ba4a23b7f55389de4326f4dffde54d78 9510 tests/ssh-service.test.mjs
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs
db0a347cdab1da11a04d41981695a65d7244367c46f0cdc868326e8d00d73cbf 56694 tests/unraid-deployment.test.mjs
bf95071f8d8bec6dcf2c889f1f9d83be1b56178dc0d2be84da555848b6319cbc 25650 tests/update-service.test.mjs
3e4a1a6d6a744df9badcfece2cf8d09f8c34efb3c437cb08a6f2e6c9d428c0d4 59353 tests/unraid-deployment.test.mjs
3bd3247ed821ba261ad7c02d649c26979e3591df456afd1bda04e351b2296fa1 29168 tests/update-service.test.mjs
9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs
7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs
8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea 767 UPDATE_FROM_0.3.2.md
+3
View File
@@ -0,0 +1,3 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEApGKe81NzC5mU3jfMNAQUnAOfQnCnMFry8cNpmjQsdtE=
-----END PUBLIC KEY-----
+1 -1
View File
@@ -51,4 +51,4 @@ ForgeFlow writes its configuration atomically. Explicit server reconciliation ad
- Read-only repository-scoped deploy keys for server pull.
- SSH host-key changes fail closed.
- Live commit, remote commit and runtime health remain separate evidence.
- Packaged updates must fail closed on missing or mismatched release assets and SHA-256 evidence; paid code signing is optional.
- Packaged updates fail closed on missing or mismatched release assets, SHA-256 evidence and the pinned Ed25519 publisher signature; paid Authenticode remains optional.
+18
View File
@@ -0,0 +1,18 @@
# ForgeFlow 0.10.13
## Veilige synchronisatie en aantoonbare release-integriteit
- **Gitea workspace sync** toont eerst de exacte additions, wijzigingen en deletions ten opzichte van de actuele upstream-SHA. Lokale commits worden beschermd in een recovery branch; staged, unstaged en untracked werk gaat naar een stash. Genegeerde runtimebestanden blijven onaangeroerd.
- Read-only achtergrondfetch houdt `ahead` en `behind` actueel zonder projectbestanden automatisch te wijzigen. Interval `0` schakelt netwerkfetch volledig uit.
- Stale deployment links blokkeren niet langer de automatische, bewijsgebaseerde koppeling van de werkelijk draaiende vervangende workload.
- SSH-hostidentiteit wordt vóór het verzenden van credentials getoond en bij bevestiging exact vastgepind. Gitea-tokens vereisen HTTPS, behalve bij expliciete loopbackontwikkeling.
- Packaged updates vereisen een **Ed25519-signed release manifest** dat versie, tag, broncommit, artifactnaam, bytegrootte en SHA-256 bindt aan de ingebouwde publisher key. Hiervoor is geen betaald certificaat of Azure-dienst nodig.
- Diagnostische bundels exporteren geen ruwe remote output meer. Untracked diffs kunnen geen junction of symlink buiten de repository volgen en zijn begrensd op bestandsgrootte.
- De Git-toolsgrid behoudt nu de volledige inhoudshoogte binnen zijn eigen scrollvlak; workspace sync en troubleshooting overlappen niet meer. De demo bridge ondersteunt dezelfde recoveryflow als de desktopapp.
- Repositorymonitoring, deploymentpolling, Docker-inspect en SSH-verbindingen gebruiken begrensde paralleliteit en hergebruik waar dat veilig is.
## Verificatie
- Volledige Node-testset, coveragepoort, architectuuraudit en dependency-audit.
- 72 browserflows over dark/light, compact/desktop/wide, 100150% schaal en reduced motion.
- Windows installer en portable build, SHA-256-sidecars, provenance, CycloneDX-SBOM en ondertekend releasemanifest.
+10 -8
View File
@@ -19,11 +19,13 @@ no certificate, Azure or other paid-service dependency:
npm run dist:win
```
This produces the installer and portable executable, SHA-256 sidecars, a
CycloneDX SBOM and provenance evidence. The in-app updater downloads only the
matching Gitea release asset, checks its Windows executable format and verifies
the published SHA-256 digest before staging it. The update helper verifies the
digest again immediately before replacing the installed executable.
Run `npm run signing:setup` once on the release workstation. It stores the
private Ed25519 key outside the repository and writes only its public key into
the packaged app. `npm run dist:win` then produces the installer and portable
executable, SHA-256 sidecars, CycloneDX SBOM, provenance and an Ed25519-signed
manifest bound to the exact source commit. The updater verifies the pinned
publisher key before trusting the artifact digest and verifies that digest again
immediately before replacing the installed executable.
Windows can display an `Unknown publisher` warning for an unsigned installer.
That warning concerns public publisher reputation; it does not prevent ForgeFlow
@@ -34,9 +36,9 @@ correct operation.
## Atomic publication
`npm run release:binary` keeps the Gitea release in draft state while uploading
the installer, portable executable, two checksums, provenance and SBOM. It only
publishes after all six assets are present. A failed upload leaves a draft rather
than exposing an incomplete updater target.
the installer, portable executable, two checksums, provenance, SBOM, signed
manifest and signature. It only publishes after all eight assets are present. A
failed upload leaves a draft rather than exposing an incomplete updater target.
The optional signing acceptance fixture can still validate the complete local
Authenticode chain without purchasing or retaining a certificate:
+5 -1
View File
@@ -124,9 +124,13 @@ included model uses:
- SSH passwords and private-key passphrases use Electron `safeStorage`;
- diagnostics receive those runtime secrets only for redaction and never export
encrypted credential fields;
- SSH deployment requires a pinned host-key fingerprint;
- SSH host identity is previewed without credentials and authenticated sessions
require the exact user-confirmed pinned fingerprint;
- remote folders and Compose paths are validated against traversal;
- tracked server-side changes block exact-SHA reset;
- updater tokens are sent only to the configured Gitea origin;
- non-loopback Gitea connections require HTTPS;
- packaged updates require a publisher-signed Ed25519 manifest that binds the
source commit, artifact identity, byte length and SHA-256 digest;
- update archives are checksummed and validated by the full local quality gate;
- source backup is restored when an update fails.
+9 -3
View File
@@ -30,14 +30,20 @@ Update logs and status files are stored beneath ForgeFlow's local user-data `upd
## Packaged Windows updates
ForgeFlow 0.9.1 and newer use authenticated Gitea release assets when running from the installer or portable executable. The updater selects the installer or portable artifact that matches the current installation mode, requires its `.sha256` sidecar, validates the Windows executable header and SHA-256 digest, then verifies the digest again immediately before applying it. An external PowerShell helper waits for ForgeFlow to exit, installs or replaces the executable and restarts it.
ForgeFlow uses authenticated Gitea release assets when running from the installer or portable executable. The updater selects the artifact that matches the current installation mode and requires its `.sha256` sidecar. From version 0.10.13 onward it also requires an Ed25519-signed release manifest. The embedded public key verifies that manifest before ForgeFlow trusts the artifact name, byte length, exact source commit or SHA-256 digest. The digest is checked again immediately before applying the update.
`Publish-ForgeFlow-Release.ps1` now treats source and binaries as one release transaction. By default it pushes the validated source, builds the exact published commit and uploads all four required assets:
`Publish-ForgeFlow-Release.ps1` treats source and binaries as one release transaction. It pushes the validated source, builds the exact published commit and uploads eight required assets:
- `ForgeFlow-Setup-<version>-win-x64.exe`
- `ForgeFlow-Setup-<version>-win-x64.exe.sha256`
- `ForgeFlow-Portable-<version>-win-x64.exe`
- `ForgeFlow-Portable-<version>-win-x64.exe.sha256`
- `ForgeFlow-<version>-provenance.json`
- `ForgeFlow-<version>-sbom.cdx.json`
- `ForgeFlow-<version>-release-manifest.json`
- `ForgeFlow-<version>-release-manifest.json.sig`
Run `npm run signing:setup` once on the release workstation. The private Ed25519 key stays outside the repository in ForgeFlow's user-data folder. This independent publisher signature is free; optional Authenticode can still be added later for Windows reputation.
Use `-SkipBinaryRelease` only when intentionally publishing source without enabling packaged auto-update.
@@ -70,6 +76,6 @@ Set-ExecutionPolicy -Scope Process Bypass
.\Publish-ForgeFlow-Release.ps1
```
The script installs dependencies, runs the complete quality gate, clones `git@gitea.itworx.tech:Jens/ForgeFlow.git` into a temporary folder, mirrors the validated source without `.git`, `node_modules`, `dist` or release archives, commits it and pushes `main`. It then compares local `HEAD` with `git ls-remote`, builds the exact published checkout and uploads the installer, portable executable and both checksums to the matching Gitea release. Publication fails when either the source commit or any required binary asset cannot be verified.
The script installs dependencies, runs the complete quality gate, clones `git@gitea.itworx.tech:Jens/ForgeFlow.git` into a temporary folder, mirrors the validated source without `.git`, `node_modules`, `dist` or release archives, commits it and pushes `main`. It then compares local `HEAD` with `git ls-remote`, builds the exact published checkout and uploads all binaries, checksums and signed release evidence to the matching Gitea release. Publication fails when either the source commit, publisher signature or any required asset cannot be verified.
Keep the currently installed older ForgeFlow source folder untouched until the built-in updater test is complete.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "forgeflow",
"version": "0.10.12",
"version": "0.10.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "forgeflow",
"version": "0.10.12",
"version": "0.10.13",
"dependencies": {
"ssh2": "1.17.0"
},
+6 -2
View File
@@ -1,6 +1,6 @@
{
"name": "forgeflow",
"version": "0.10.12",
"version": "0.10.13",
"private": true,
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
"main": "main.cjs",
@@ -14,7 +14,7 @@
"coverage": "c8 --check-coverage --lines 85 --functions 85 --branches 68 --statements 85 node --test tests/*.test.mjs && npm run coverage:modules",
"coverage:modules": "c8 report --check-coverage --per-file --include src/** --statements 60 --lines 60 --functions 50 --branches 36 --reporter=text-summary",
"verify": "node scripts/verify.mjs",
"dist:win": "electron-builder --win nsis portable && node scripts/write-release-checksums.mjs && node scripts/verify-release-signatures.mjs && node scripts/prune-dist.mjs",
"dist:win": "electron-builder --win nsis portable && node scripts/write-release-checksums.mjs && node scripts/sign-release-manifest.mjs && node scripts/verify-release-signatures.mjs && node scripts/prune-dist.mjs",
"dist:linux": "electron-builder --linux AppImage && node scripts/prune-dist.mjs",
"dist:mac": "electron-builder --mac dmg && node scripts/prune-dist.mjs",
"doctor": "node scripts/doctor.mjs",
@@ -24,6 +24,7 @@
"test:browser": "playwright test",
"test:browser:ci": "playwright test --reporter=line,html",
"test:signing": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/test-authenticode-chain.ps1",
"signing:setup": "node scripts/setup-update-signing-key.mjs",
"connections:check": "electron scripts/validate-installed-connections.cjs",
"deployments:audit": "electron scripts/audit-installed-deployments.cjs",
"release:binary": "electron scripts/publish-binary-release.cjs",
@@ -50,6 +51,7 @@
"package.json",
"build/icon.png",
"build/icon.ico",
"build/update-signing-public.pem",
"docs/SETUP_GUIDE.md",
"docs/DIAGNOSTICS.md",
"docs/STATUS_ENDPOINT.md",
@@ -67,6 +69,7 @@
"scripts/apply-source-update.ps1",
"scripts/apply-binary-update.ps1",
"scripts/prune-dist.mjs",
"scripts/sign-release-manifest.mjs",
"docs/RELEASE_NOTES_0.4.0.md",
"docs/LUMAOPS_SERVER_AUDIT.md",
"docs/SSH_UNRAID_DEPLOYMENT.md",
@@ -118,6 +121,7 @@
"docs/RELEASE_NOTES_0.10.10.md",
"docs/RELEASE_NOTES_0.10.11.md",
"docs/RELEASE_NOTES_0.10.12.md",
"docs/RELEASE_NOTES_0.10.13.md",
"docs/CURRENT_STATE.md",
"docs/MUTATION_MODEL.md",
"docs/RELEASING.md",
+3 -1
View File
@@ -46,7 +46,7 @@ contextBridge.exposeInMainWorld(
applyUpdate: () => invoke('updates:apply'),
saveServer: (server, password = '', passphrase = '') => invoke('server:save', { server, password, passphrase }),
deleteServer: (serverId) => invoke('server:delete', { serverId }),
testServer: (serverId) => invoke('server:test', { serverId }),
testServer: (serverId, expectedFingerprint = '') => invoke('server:test', { serverId, expectedFingerprint }),
inspectServerProject: (repository, profileId) => invoke('server:inspect-project', { repository, profileId }),
discoverExistingDeployment: (repository, serverId, remoteFolder) =>
invoke('server:discover-existing', {
@@ -97,6 +97,8 @@ contextBridge.exposeInMainWorld(
repairGitLocks: (localPath, force = false) => invoke('repository:repair-git-locks', { localPath, force }),
reconcileRepository: (localPath) => invoke('repository:reconcile', { localPath }),
repairRepositorySync: (localPath, strategy) => invoke('repository:repair-sync', { localPath, strategy }),
previewWorkspaceSync: (localPath) => invoke('repository:workspace-sync-preview', { localPath }),
applyWorkspaceSync: (localPath, expectedPlanId) => invoke('repository:workspace-sync-apply', { localPath, expectedPlanId }),
setOrigin: (localPath, remoteUrl) => invoke('repository:set-origin', { localPath, remoteUrl }),
normalizeOrigins: () => invoke('repositories:normalize-origins'),
cloneRepository: (fullName, mode = 'default') => invoke('repository:clone', { fullName, mode }),
+139 -95
View File
@@ -1,12 +1,54 @@
{
"generatedAt": "2026-08-23T13:06:16.630Z",
"generatedAt": "2026-08-26T22:18:32.525Z",
"thresholds": {
"preferredMaximumLines": 750,
"justificationRequiredLines": 1000
},
"over750": [],
"over750": [
{
"file": "src/main/git-service.cjs",
"lines": 881,
"branches": 134,
"functions": 147,
"ipcHandlers": 0,
"responsibilities": [
"git",
"renderer",
"security",
"updates"
],
"hotspotScore": 154
},
{
"file": "src/main/update-service.cjs",
"lines": 854,
"branches": 64,
"functions": 65,
"ipcHandlers": 0,
"responsibilities": [
"git",
"security",
"updates"
],
"hotspotScore": 74
}
],
"over1000": [],
"cyclomaticHotspots": [
{
"file": "src/main/git-service.cjs",
"lines": 881,
"branches": 134,
"functions": 147,
"ipcHandlers": 0,
"responsibilities": [
"git",
"renderer",
"security",
"updates"
],
"hotspotScore": 154
},
{
"file": "src/renderer/actions/shell.js",
"lines": 518,
@@ -24,7 +66,7 @@
},
{
"file": "src/renderer/app.js",
"lines": 732,
"lines": 734,
"branches": 80,
"functions": 124,
"ipcHandlers": 0,
@@ -53,21 +95,9 @@
],
"hotspotScore": 119
},
{
"file": "src/main/git-service.cjs",
"lines": 687,
"branches": 111,
"functions": 118,
"ipcHandlers": 0,
"responsibilities": [
"git",
"security"
],
"hotspotScore": 111
},
{
"file": "src/main/unraid-inventory-methods.cjs",
"lines": 708,
"lines": 710,
"branches": 76,
"functions": 93,
"ipcHandlers": 0,
@@ -82,6 +112,20 @@
}
],
"mixedResponsibilityModules": [
{
"file": "src/main/git-service.cjs",
"lines": 881,
"branches": 134,
"functions": 147,
"ipcHandlers": 0,
"responsibilities": [
"git",
"renderer",
"security",
"updates"
],
"hotspotScore": 154
},
{
"file": "src/renderer/actions/shell.js",
"lines": 518,
@@ -99,7 +143,7 @@
},
{
"file": "src/renderer/app.js",
"lines": 732,
"lines": 734,
"branches": 80,
"functions": 124,
"ipcHandlers": 0,
@@ -130,7 +174,7 @@
},
{
"file": "src/main/unraid-inventory-methods.cjs",
"lines": 708,
"lines": 710,
"branches": 76,
"functions": 93,
"ipcHandlers": 0,
@@ -143,6 +187,22 @@
],
"hotspotScore": 106
},
{
"file": "src/renderer/dialogs.js",
"lines": 433,
"branches": 60,
"functions": 83,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"security",
"updates"
],
"hotspotScore": 100
},
{
"file": "src/main/unraid-deployment-methods.cjs",
"lines": 583,
@@ -159,24 +219,24 @@
"hotspotScore": 99
},
{
"file": "src/renderer/dialogs.js",
"lines": 419,
"branches": 58,
"functions": 80,
"ipcHandlers": 0,
"file": "src/main/ipc.cjs",
"lines": 706,
"branches": 54,
"functions": 72,
"ipcHandlers": 27,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"ipc",
"security",
"updates"
],
"hotspotScore": 98
"hotspotScore": 94
},
{
"file": "src/renderer/views.js",
"lines": 715,
"lines": 736,
"branches": 53,
"functions": 152,
"ipcHandlers": 0,
@@ -191,20 +251,33 @@
"hotspotScore": 93
},
{
"file": "src/main/ipc.cjs",
"lines": 694,
"branches": 51,
"functions": 70,
"ipcHandlers": 27,
"file": "src/main/ssh-service.cjs",
"lines": 513,
"branches": 70,
"functions": 101,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"ipc",
"security",
"updates"
],
"hotspotScore": 91
"hotspotScore": 90
},
{
"file": "src/renderer/actions/setup-and-settings.js",
"lines": 441,
"branches": 60,
"functions": 60,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"renderer",
"security",
"updates"
],
"hotspotScore": 90
},
{
"file": "main.cjs",
@@ -223,21 +296,6 @@
],
"hotspotScore": 89
},
{
"file": "src/renderer/actions/setup-and-settings.js",
"lines": 427,
"branches": 58,
"functions": 58,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"renderer",
"security",
"updates"
],
"hotspotScore": 88
},
{
"file": "src/main/gitea-service.cjs",
"lines": 624,
@@ -252,24 +310,10 @@
],
"hotspotScore": 87
},
{
"file": "src/main/ssh-service.cjs",
"lines": 467,
"branches": 66,
"functions": 92,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 86
},
{
"file": "src/renderer/actions/deployment-profile.js",
"lines": 407,
"branches": 56,
"lines": 408,
"branches": 57,
"functions": 44,
"ipcHandlers": 0,
"responsibilities": [
@@ -279,11 +323,25 @@
"renderer",
"security"
],
"hotspotScore": 86
"hotspotScore": 87
},
{
"file": "src/renderer/actions/recovery.js",
"lines": 421,
"branches": 64,
"functions": 43,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"renderer",
"updates"
],
"hotspotScore": 84
},
{
"file": "src/main/config-store.cjs",
"lines": 665,
"lines": 668,
"branches": 52,
"functions": 89,
"ipcHandlers": 0,
@@ -328,20 +386,6 @@
],
"hotspotScore": 77
},
{
"file": "src/renderer/actions/recovery.js",
"lines": 356,
"branches": 56,
"functions": 38,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"renderer",
"updates"
],
"hotspotScore": 76
},
{
"file": "src/main/unraid-access-methods.cjs",
"lines": 462,
@@ -359,7 +403,7 @@
},
{
"file": "src/main/diagnostics-service.cjs",
"lines": 373,
"lines": 377,
"branches": 39,
"functions": 51,
"ipcHandlers": 0,
@@ -477,9 +521,9 @@
},
{
"file": "preload.cjs",
"lines": 162,
"lines": 164,
"branches": 2,
"functions": 123,
"functions": 125,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
@@ -612,9 +656,9 @@
},
{
"file": "src/renderer/mock-repository-bridge.js",
"lines": 636,
"branches": 13,
"functions": 89,
"lines": 701,
"branches": 14,
"functions": 91,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
@@ -622,7 +666,7 @@
"security",
"updates"
],
"hotspotScore": 33
"hotspotScore": 34
},
{
"file": "src/main/deployment-identity.cjs",
@@ -685,9 +729,9 @@
"ipcHotspots": [
{
"file": "src/main/ipc.cjs",
"lines": 694,
"branches": 51,
"functions": 70,
"lines": 706,
"branches": 54,
"functions": 72,
"ipcHandlers": 27,
"responsibilities": [
"inventory",
@@ -697,7 +741,7 @@
"security",
"updates"
],
"hotspotScore": 91
"hotspotScore": 94
},
{
"file": "src/main/ipc/deployment-handlers.cjs",
@@ -717,10 +761,10 @@
},
{
"file": "src/main/ipc/repository-handlers.cjs",
"lines": 413,
"lines": 451,
"branches": 16,
"functions": 79,
"ipcHandlers": 51,
"functions": 83,
"ipcHandlers": 53,
"responsibilities": [
"git",
"ipc"
+6 -5
View File
@@ -1,12 +1,13 @@
# ForgeFlow architecture audit
Generated 2026-08-23T13:06:16.630Z. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity.
Generated 2026-08-26T22:18:32.525Z. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity.
## Files above 750 lines
| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |
|---|---:|---:|---:|---:|---|
No findings.
| `src/main/git-service.cjs` | 881 | 134 | 147 | 0 | git, renderer, security, updates |
| `src/main/update-service.cjs` | 854 | 64 | 65 | 0 | git, security, updates |
## Files above 1,000 lines
@@ -18,11 +19,11 @@ No findings.
| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |
|---|---:|---:|---:|---:|---|
| `src/main/git-service.cjs` | 881 | 134 | 147 | 0 | git, renderer, security, updates |
| `src/renderer/actions/shell.js` | 518 | 101 | 86 | 0 | inventory, deployment, git, renderer, updates |
| `src/renderer/app.js` | 732 | 80 | 124 | 0 | inventory, deployment, git, renderer, security, updates |
| `src/renderer/app.js` | 734 | 80 | 124 | 0 | inventory, deployment, git, renderer, security, updates |
| `src/main/server-inventory.cjs` | 578 | 89 | 104 | 0 | inventory, deployment, git, security, updates |
| `src/main/git-service.cjs` | 687 | 111 | 118 | 0 | git, security |
| `src/main/unraid-inventory-methods.cjs` | 708 | 76 | 93 | 0 | inventory, deployment, git, security, updates |
| `src/main/unraid-inventory-methods.cjs` | 710 | 76 | 93 | 0 | inventory, deployment, git, security, updates |
## Interpretation
+6 -4
View File
@@ -4,6 +4,7 @@ const fs = require("node:fs/promises");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { app, safeStorage } = require("electron");
const { normalizeBaseUrl } = require("../src/shared/validation.cjs");
const root = path.resolve(__dirname, "..");
const configuredUserData =
@@ -59,10 +60,7 @@ app.whenReady().then(async () => {
const token = safeStorage.decryptString(
Buffer.from(config.gitea.encryptedToken, "base64"),
);
const baseUrl = String(config.gitea.baseUrl || "").replace(/\/+$/, "");
if (!/^https?:\/\//i.test(baseUrl)) {
throw new Error("The configured Gitea base URL is invalid.");
}
const baseUrl = normalizeBaseUrl(config.gitea.baseUrl);
const owner = safeRepositoryPart(
process.env.FORGEFLOW_RELEASE_OWNER || config.updates?.owner || "Jens",
"Release repository owner",
@@ -181,6 +179,8 @@ app.whenReady().then(async () => {
for (const [name, type] of [
[`ForgeFlow-${version}-provenance.json`, "application/json"],
[`ForgeFlow-${version}-sbom.cdx.json`, "application/vnd.cyclonedx+json"],
[`ForgeFlow-${version}-release-manifest.json`, "application/json"],
[`ForgeFlow-${version}-release-manifest.json.sig`, "application/octet-stream"],
]) {
const bytes = await fs.readFile(path.join(root, "dist", name));
const existing = (release.assets || []).find((asset) => asset.name === name);
@@ -194,6 +194,8 @@ app.whenReady().then(async () => {
...binaries.flatMap((binaryPath) => [path.basename(binaryPath), `${path.basename(binaryPath)}.sha256`]),
`ForgeFlow-${version}-provenance.json`,
`ForgeFlow-${version}-sbom.cdx.json`,
`ForgeFlow-${version}-release-manifest.json`,
`ForgeFlow-${version}-release-manifest.json.sig`,
];
const missingAssets = requiredAssets.filter((name) => !(release.assets || []).some((asset) => asset.name === name));
if (missingAssets.length) throw new Error(`Release remains draft because required assets are missing: ${missingAssets.join(", ")}`);
+34
View File
@@ -0,0 +1,34 @@
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const defaultPrivatePath = path.join(
process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"),
"forgeflow",
"release-signing-private.pem",
);
const privatePath = path.resolve(process.env.FORGEFLOW_UPDATE_SIGNING_PRIVATE_KEY || defaultPrivatePath);
const publicPath = path.join(root, "build", "update-signing-public.pem");
let privateKey;
try {
privateKey = createPrivateKey(await readFile(privatePath));
if (privateKey.asymmetricKeyType !== "ed25519") throw new Error("The existing key is not Ed25519.");
} catch (error) {
if (error.code !== "ENOENT") throw error;
privateKey = generateKeyPairSync("ed25519").privateKey;
await mkdir(path.dirname(privatePath), { recursive: true, mode: 0o700 });
await writeFile(privatePath, privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o600, flag: "wx" });
}
const publicKey = createPublicKey(privateKey);
const publicPem = publicKey.export({ type: "spki", format: "pem" });
await mkdir(path.dirname(publicPath), { recursive: true });
await writeFile(publicPath, publicPem, { mode: 0o644 });
const fingerprint = createHash("sha256").update(publicKey.export({ type: "spki", format: "der" })).digest("hex");
console.log(`ForgeFlow Ed25519 update key ready. Public key fingerprint: SHA256:${fingerprint}`);
console.log(`Private key: ${privatePath}`);
console.log(`Public key: ${publicPath}`);
+46
View File
@@ -0,0 +1,46 @@
import { createHash, createPrivateKey, createPublicKey, sign, verify } from "node:crypto";
import { readFile, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const pkg = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
const privatePath = path.resolve(
process.env.FORGEFLOW_UPDATE_SIGNING_PRIVATE_KEY ||
path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "forgeflow", "release-signing-private.pem"),
);
const publicPath = path.join(root, "build", "update-signing-public.pem");
const privateKey = createPrivateKey(await readFile(privatePath).catch((error) => {
if (error.code === "ENOENT") throw new Error(`ForgeFlow update signing key is missing. Run npm run signing:setup once. Expected: ${privatePath}`);
throw error;
}));
const publicKey = createPublicKey(await readFile(publicPath));
if (!publicKey.equals(createPublicKey(privateKey))) throw new Error("The release private key does not match the public key embedded in ForgeFlow.");
const provenance = JSON.parse(await readFile(path.join(root, "dist", `ForgeFlow-${pkg.version}-provenance.json`), "utf8"));
const artifacts = [];
for (const kind of ["Setup", "Portable"]) {
const name = `ForgeFlow-${kind}-${pkg.version}-win-x64.exe`;
const filePath = path.join(root, "dist", name);
const bytes = await readFile(filePath);
artifacts.push({ name, bytes: (await stat(filePath)).size, sha256: createHash("sha256").update(bytes).digest("hex") });
}
const keyId = createHash("sha256").update(publicKey.export({ type: "spki", format: "der" })).digest("hex");
const manifest = {
schemaVersion: 1,
product: "ForgeFlow",
version: pkg.version,
tag: `v${pkg.version}`,
commit: provenance.commit,
buildId: provenance.buildId,
signature: { algorithm: "Ed25519", keyId: `SHA256:${keyId}` },
artifacts,
};
const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8");
const signature = sign(null, manifestBytes, privateKey);
if (!verify(null, manifestBytes, publicKey, signature)) throw new Error("The generated release signature did not verify.");
const manifestName = `ForgeFlow-${pkg.version}-release-manifest.json`;
await writeFile(path.join(root, "dist", manifestName), manifestBytes, { mode: 0o644 });
await writeFile(path.join(root, "dist", `${manifestName}.sig`), `${signature.toString("base64")}\n`, { mode: 0o644 });
console.log(`${manifestName}: signed with SHA256:${keyId}`);
+10 -2
View File
@@ -47,6 +47,8 @@ const required = [
"scripts/validate-installed-connections.cjs",
"scripts/publish-binary-release.cjs",
"scripts/write-release-checksums.mjs",
"scripts/setup-update-signing-key.mjs",
"scripts/sign-release-manifest.mjs",
"scripts/prune-dist.mjs",
"scripts/generate-source-manifest.mjs",
"setup-windows.ps1",
@@ -92,6 +94,7 @@ const required = [
"docs/RELEASE_NOTES_0.10.10.md",
"docs/RELEASE_NOTES_0.10.11.md",
"docs/RELEASE_NOTES_0.10.12.md",
"docs/RELEASE_NOTES_0.10.13.md",
"docs/UPDATING.md",
"docs/DIAGNOSTICS.md",
"docs/DEPLOYMENT_SETUP.md",
@@ -123,6 +126,7 @@ const required = [
"examples/server/status-example.json",
"build/icon.png",
"build/icon.ico",
"build/update-signing-public.pem",
];
for (const file of required) await access(path.join(root, file));
@@ -130,9 +134,9 @@ for (const file of required) await access(path.join(root, file));
const packageJson = JSON.parse(
await readFile(path.join(root, "package.json"), "utf8"),
);
if (packageJson.version !== "0.10.12")
if (packageJson.version !== "0.10.13")
throw new Error(
`Expected package version 0.10.12, got ${packageJson.version}.`,
`Expected package version 0.10.13, got ${packageJson.version}.`,
);
const sourceManifest = await readFile(
path.join(root, "SOURCE_MANIFEST.txt"),
@@ -494,6 +498,10 @@ const release01012 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.12.
for (const phrase of ["coalesced", "exact Gitea commit parity", "batched Docker inspect", "bounded worker pools", "stopped container"]) {
if (!release01012.includes(phrase)) throw new Error(`0.10.12 release notes are missing: ${phrase}`);
}
const release01013 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.13.md"), "utf8");
for (const phrase of ["Gitea workspace sync", "recovery branch", "Stale deployment links", "Ed25519-signed release manifest", "Git-toolsgrid"]) {
if (!release01013.includes(phrase)) throw new Error(`0.10.13 release notes are missing: ${phrase}`);
}
const configSource = await readFile(path.join(root, "src/main/config-store.cjs"), "utf8");
for (const mode of ["server-git", "push-bundle", "monitor-only"]) {
if (!configSource.includes(mode)) throw new Error(`Deployment configuration is missing mode: ${mode}`);
+13 -1
View File
@@ -25,7 +25,19 @@ for (const kind of ["Setup", "Portable"]) {
}
const commit = String(process.env.FORGEFLOW_BUILD_COMMIT || (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: root })).stdout).trim();
const buildId = String(process.env.FORGEFLOW_BUILD_ID || `${manifest.version}-${commit.slice(0, 12)}`);
const provenance = { schemaVersion: 1, product: "ForgeFlow", version: manifest.version, commit, buildId, createdAt: new Date().toISOString(), signedRelease: process.env.FORGEFLOW_SIGNED_RELEASE === "1", expectedPublisher: process.env.FORGEFLOW_EXPECTED_PUBLISHER || null, artifacts };
const provenance = {
schemaVersion: 1,
product: "ForgeFlow",
version: manifest.version,
commit,
buildId,
createdAt: new Date().toISOString(),
publisherManifestSignature: "Ed25519",
authenticodeSigned: process.env.FORGEFLOW_SIGNED_RELEASE === "1",
expectedAuthenticodePublisher:
process.env.FORGEFLOW_EXPECTED_PUBLISHER || null,
artifacts,
};
await writeFile(path.join(root, "dist", `ForgeFlow-${manifest.version}-provenance.json`), `${JSON.stringify(provenance, null, 2)}\n`, "utf8");
const lock = JSON.parse(await readFile(path.join(root, "package-lock.json"), "utf8"));
const components = Object.entries(lock.packages || {}).filter(([name]) => name.startsWith("node_modules/")).map(([name, value]) => ({ type: "library", name: name.slice(13), version: value.version || "unknown", licenses: value.license ? [{ license: { id: value.license } }] : undefined })).sort((a, b) => a.name.localeCompare(b.name));
+4 -1
View File
@@ -615,7 +615,10 @@ class ConfigStore {
const next = { ...this.data.preferences, ...(preferences || {}) };
next.repositoryPollSeconds = Math.min(Math.max(Number(next.repositoryPollSeconds) || 4, 2), 60);
next.operationPollSeconds = Math.min(Math.max(Number(next.operationPollSeconds) || 5, 3), 120);
next.fetchIntervalMinutes = Math.min(Math.max(Number(next.fetchIntervalMinutes) || 10, 0), 240);
const fetchIntervalMinutes = Number(next.fetchIntervalMinutes);
next.fetchIntervalMinutes = Number.isFinite(fetchIntervalMinutes)
? Math.min(Math.max(fetchIntervalMinutes, 0), 240)
: 10;
next.autoRefresh = next.autoRefresh !== false;
next.preferredCloneProtocol = ['https', 'ssh'].includes(next.preferredCloneProtocol) ? next.preferredCloneProtocol : 'https';
next.diagnosticsEnabled = next.diagnosticsEnabled !== false;
+7 -3
View File
@@ -291,9 +291,13 @@ class DiagnosticsService {
dispatchedAt: operation.dispatchedAt,
stages: operation.stages,
jobs: operation.jobs,
logs: operation.logs,
failure: operation.failure,
pollError: operation.pollError,
remoteOutput: operation.logs || operation.failure || operation.pollError ? {
included: false,
reason: 'Remote build and command output is intentionally omitted because it may contain application secrets unknown to ForgeFlow.',
logCharacters: String(operation.logs || '').length,
failureRecorded: Boolean(operation.failure),
pollErrorRecorded: Boolean(operation.pollError)
} : null,
applicationState: operation.applicationState,
run: operation.run ? {
id: operation.run.id,
+195 -1
View File
@@ -2,11 +2,13 @@
const path = require('node:path');
const fs = require('node:fs/promises');
const crypto = require('node:crypto');
const { run } = require('./process-runner.cjs');
const { parsePorcelainV2 } = require('../shared/git-status.cjs');
const { normalizeRemoteUrl } = require('../shared/repository-match.cjs');
const COMMON_GIT_LOCK_FILES = ['HEAD.lock', 'index.lock'];
const MAX_UNTRACKED_DIFF_BYTES = 16 * 1024 * 1024;
const {
assertSafeRepositoryPath,
assertRepositoryRelativePath,
@@ -28,6 +30,34 @@ function parseUnifiedDiff(diffText) {
return { header, hunks };
}
function parseNameStatus(output) {
const entries = String(output || '').split('\0');
const changes = [];
for (let index = 0; index < entries.length;) {
const rawStatus = entries[index++];
if (!rawStatus) continue;
const code = rawStatus[0];
if (code === 'R' || code === 'C') {
const originalPath = entries[index++] || '';
const filePath = entries[index++] || '';
if (filePath) changes.push({ code, status: code === 'R' ? 'renamed' : 'copied', path: filePath, originalPath });
continue;
}
const filePath = entries[index++] || '';
if (!filePath) continue;
const labels = { A: 'added', D: 'deleted', M: 'modified', T: 'type-changed', U: 'conflict' };
changes.push({ code, status: labels[code] || 'changed', path: filePath, originalPath: null });
}
return changes;
}
function parseCompactLog(output) {
return String(output || '').split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
const [sha, shortSha, date, subject] = record.split('\x1f');
return { sha, shortSha, date, subject };
});
}
class GitService {
constructor() {
// `git remote get-url` is only re-run when the repository configuration file
@@ -316,6 +346,151 @@ class GitService {
return { strategy: requested, backupBranch: null, status, lockReport: await this.listGitLocks(root) };
}
async previewWorkspaceSync(repoPath) {
const root = await this.ensureRepository(repoPath);
const { status } = await this.fetch(root);
const branch = status.branch?.head;
const upstream = status.branch?.upstream;
if (!status.head || !branch || branch === '(detached)') {
const error = new Error('Workspace synchronization requires a named branch with at least one commit.');
error.code = 'WORKSPACE_SYNC_BRANCH_REQUIRED';
throw error;
}
if (!upstream) {
const error = new Error('The current branch has no Gitea upstream. Publish it or switch to a tracked branch first.');
error.code = 'WORKSPACE_SYNC_UPSTREAM_REQUIRED';
throw error;
}
const targetSha = (await run('git', ['rev-parse', '--verify', upstream], { cwd: root, timeout: 30_000 })).stdout.trim();
const changes = parseNameStatus((await run('git', [
'diff', '--name-status', '-z', '--find-renames', 'HEAD', upstream, '--'
], { cwd: root, timeout: 60_000, maxBuffer: 16 * 1024 * 1024 })).stdout);
const logFormat = '%H%x1f%h%x1f%aI%x1f%s%x1e';
const [incomingResult, localResult, interruptedOperation] = await Promise.all([
run('git', ['log', `--format=${logFormat}`, `HEAD..${upstream}`, '-20'], { cwd: root, timeout: 30_000 }),
run('git', ['log', `--format=${logFormat}`, `${upstream}..HEAD`, '-20'], { cwd: root, timeout: 30_000 }),
this.detectInterruptedOperation(root)
]);
const blockers = [];
if (interruptedOperation) blockers.push(`Finish or abort the active Git ${interruptedOperation} before synchronizing.`);
if (status.counts.conflicts) blockers.push(`Resolve ${status.counts.conflicts} conflicted file${status.counts.conflicts === 1 ? '' : 's'} before synchronizing.`);
const summary = {
resultingTrackedChanges: changes.length,
added: changes.filter((item) => item.code === 'A').length,
modified: changes.filter((item) => ['M', 'T'].includes(item.code)).length,
deleted: changes.filter((item) => item.code === 'D').length,
renamed: changes.filter((item) => item.code === 'R').length,
localFilesToStash: status.counts.changed,
untrackedFilesToStash: status.counts.untracked,
localCommitsToProtect: status.branch.ahead,
incomingCommits: status.branch.behind
};
const planId = crypto.createHash('sha256').update(JSON.stringify({
head: status.head,
targetSha,
branch,
upstream,
fingerprint: this.statusFingerprint(status)
})).digest('hex');
return {
id: planId,
repositoryRoot: root,
branch,
upstream,
currentSha: status.head,
targetSha,
needsSync: status.head !== targetSha || !status.clean,
cleanBeforeSync: status.clean,
blockers,
summary,
changes: changes.slice(0, 250),
changesTruncated: changes.length > 250,
localFiles: status.files.slice(0, 250),
localFilesTruncated: status.files.length > 250,
incomingCommits: parseCompactLog(incomingResult.stdout),
localCommits: parseCompactLog(localResult.stdout),
recovery: {
safetyBranch: status.branch.ahead > 0,
stash: status.counts.changed > 0,
untrackedCleanup: status.counts.untracked > 0,
ignoredFilesPreserved: true
}
};
}
async synchronizeWorkspace(repoPath, expectedPlanId) {
const expected = String(expectedPlanId || '').trim();
if (!/^[0-9a-f]{64}$/i.test(expected)) {
const error = new Error('Apply workspace synchronization only from a reviewed preview.');
error.code = 'WORKSPACE_SYNC_PLAN_REQUIRED';
throw error;
}
const plan = await this.previewWorkspaceSync(repoPath);
if (plan.id !== expected) {
const error = new Error('The local workspace or Gitea branch changed after the preview. Review a fresh synchronization plan.');
error.code = 'WORKSPACE_SYNC_PLAN_STALE';
error.recoverable = true;
throw error;
}
if (plan.blockers.length) {
const error = new Error(plan.blockers.join(' '));
error.code = 'WORKSPACE_SYNC_BLOCKED';
error.recoverable = true;
throw error;
}
if (!plan.needsSync) {
return { applied: false, unchanged: true, plan, status: await this.status(plan.repositoryRoot), backupBranch: null, stash: null, cleaned: [] };
}
const root = plan.repositoryRoot;
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
let backupBranch = null;
let stash = null;
if (plan.summary.localCommitsToProtect > 0) {
const safeBranch = plan.branch.replace(/[^A-Za-z0-9._-]/g, '-');
backupBranch = `forgeflow/recovery-${safeBranch}-${stamp}-${plan.currentSha.slice(0, 7)}`;
await run('git', ['check-ref-format', '--branch', backupBranch], { cwd: root, timeout: 30_000 });
await run('git', ['branch', backupBranch, 'HEAD'], { cwd: root, timeout: 30_000 });
}
if (plan.summary.localFilesToStash > 0) {
const label = `ForgeFlow workspace sync ${plan.branch} ${stamp}`;
await run('git', ['stash', 'push', '--include-untracked', '-m', label], { cwd: root, timeout: 120_000 });
stash = (await this.stashList(root))[0] || null;
}
const protectedStatus = await this.status(root);
if (!protectedStatus.clean || protectedStatus.head !== plan.currentSha) {
const error = new Error('The workspace changed while ForgeFlow was protecting local work. Nothing was reset; review a fresh synchronization plan.');
error.code = 'WORKSPACE_SYNC_CONCURRENT_CHANGE';
error.recoverable = true;
error.backupBranch = backupBranch;
error.stash = stash;
throw error;
}
await run('git', ['reset', '--hard', plan.targetSha], { cwd: root, timeout: 2 * 60_000 });
const status = await this.status(root);
if (status.head !== plan.targetSha || !status.clean) {
const error = new Error('Git did not verify an exact clean match with the reviewed Gitea commit. Local recovery references were preserved.');
error.code = 'WORKSPACE_SYNC_VERIFICATION_FAILED';
error.recoverable = true;
error.backupBranch = backupBranch;
error.stash = stash;
throw error;
}
return {
applied: true,
unchanged: false,
plan,
status,
backupBranch,
stash,
cleaned: plan.localFiles.filter((file) => file.untracked).map((file) => file.path),
ignoredFilesPreserved: true
};
}
async setRemoteUrl(repoPath, remoteUrl, remote = 'origin') {
const root = await this.ensureRepository(repoPath);
const safeRemote = assertCloneRemote(remoteUrl);
@@ -336,7 +511,26 @@ class GitService {
if (!result.stdout && safeFile && !staged) {
const candidate = path.resolve(root, safeFile);
if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) throw new Error('File path escapes repository root.');
const content = await fs.readFile(candidate, 'utf8').catch(() => '');
const [realRoot, realCandidate, candidateStat] = await Promise.all([
fs.realpath(root).catch(() => root),
fs.realpath(candidate).catch(() => candidate),
fs.stat(candidate).catch(() => null)
]);
const normalize = (value) => process.platform === 'win32' ? value.toLowerCase() : value;
const normalizedRoot = normalize(realRoot);
const normalizedCandidate = normalize(realCandidate);
if (normalizedCandidate !== normalizedRoot && !normalizedCandidate.startsWith(`${normalizedRoot}${path.sep}`)) {
const error = new Error('ForgeFlow refuses to read a diff target that resolves outside the repository.');
error.code = 'DIFF_TARGET_OUTSIDE_REPOSITORY';
throw error;
}
if (candidateStat?.size > MAX_UNTRACKED_DIFF_BYTES) {
const error = new Error('The untracked file is too large to render safely as a diff.');
error.code = 'DIFF_FILE_TOO_LARGE';
error.recoverable = true;
throw error;
}
const content = candidateStat?.isFile() ? await fs.readFile(candidate, 'utf8').catch(() => '') : '';
if (content) return `diff --git a/${safeFile} b/${safeFile}\nnew file mode 100644\n--- /dev/null\n+++ b/${safeFile}\n${content.split('\n').map((line) => `+${line}`).join('\n')}`;
}
return result.stdout;
+14 -2
View File
@@ -415,13 +415,25 @@ function registerIpc({
await diagnostics.info("server.deleted", { serverId });
return store.getPublicState();
});
register("server:test", async ({ serverId }) => {
register("server:test", async ({ serverId, expectedFingerprint = "" }) => {
const server = store.getServer(serverId);
if (!server) throw new Error("The configured server no longer exists.");
const expected = String(expectedFingerprint || "").trim();
if (!server.hostFingerprint && !expected) {
const probe = await ssh.probeHostFingerprint(serverId);
return { ...probe, connected: false, needsTrust: true, state: store.getPublicState() };
}
if (!server.hostFingerprint && !/^SHA256:[A-Za-z0-9+/]{40,44}$/.test(expected))
throw new Error("Confirm the exact SSH host fingerprint returned by ForgeFlow.");
const result = await ssh.test(serverId, {
trustOnFirstUse: !server.hostFingerprint,
expectedFingerprint: server.hostFingerprint ? null : expected,
});
if (!server.hostFingerprint) {
if (result.fingerprint !== expected) {
const error = new Error("The SSH host identity changed between preview and confirmation.");
error.code = "SSH_HOST_KEY_MISMATCH";
throw error;
}
await store.saveServer(
{ ...server, hostFingerprint: result.fingerprint },
{},
+38
View File
@@ -265,6 +265,44 @@ function registerRepositoryIpc({
git.repairSync(safePath, strategy),
);
});
register("repository:workspace-sync-preview", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
const plan = await withRepositoryMutation(safePath, () =>
git.previewWorkspaceSync(safePath),
);
await diagnostics.info("repository.workspace-sync.previewed", {
localPath: safePath,
branch: plan.branch,
upstream: plan.upstream,
currentSha: plan.currentSha,
targetSha: plan.targetSha,
planId: plan.id,
summary: plan.summary,
blockers: plan.blockers,
});
return plan;
});
register(
"repository:workspace-sync-apply",
async ({ localPath, expectedPlanId }) => {
const safePath = await assertKnownRepositoryPath(localPath);
const result = await withRepositoryMutation(safePath, () =>
git.synchronizeWorkspace(safePath, expectedPlanId),
);
await audit.append("repository.workspace-synchronized", {
localPath: safePath,
branch: result.plan.branch,
upstream: result.plan.upstream,
previousSha: result.plan.currentSha,
targetSha: result.plan.targetSha,
backupBranch: result.backupBranch,
stashSha: result.stash?.sha || null,
ignoredFilesPreserved: true,
applied: result.applied,
});
return result;
},
);
register("repository:set-origin", async ({ localPath, remoteUrl }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
+65 -7
View File
@@ -26,7 +26,9 @@ class RepositoryMonitor {
this.watchers = new Map();
this.changed = new Set();
this.lastCheckedAt = new Map();
this.lastFetchedAt = new Map();
this.watchTimer = null;
this.fetchRunning = false;
}
setPaths(paths) {
@@ -46,6 +48,13 @@ class RepositoryMonitor {
for (const existing of [...this.lastCheckedAt.keys()]) {
if (!watched.has(existing)) this.lastCheckedAt.delete(existing);
}
for (const existing of [...this.lastFetchedAt.keys()]) {
if (!watched.has(existing)) this.lastFetchedAt.delete(existing);
}
const now = Date.now();
for (const localPath of this.paths) {
if (!this.lastFetchedAt.has(localPath)) this.lastFetchedAt.set(localPath, now);
}
this.syncWatchers();
}
@@ -105,6 +114,60 @@ class RepositoryMonitor {
return sinceLastCheck >= SAFETY_CHECK_INTERVAL_MS;
}
fetchIntervalMs() {
const minutes = Number(this.store.data.preferences.fetchIntervalMinutes);
return Number.isFinite(minutes) && minutes > 0 ? Math.min(minutes, 240) * 60_000 : 0;
}
shouldFetch(localPath, now) {
const interval = this.fetchIntervalMs();
return interval > 0
&& !this.paused.has(localPath)
&& now - (this.lastFetchedAt.get(localPath) || now) >= interval;
}
async recordStatus(localPath, status, reason) {
const next = this.git.statusFingerprint(status);
const previous = this.fingerprints.get(localPath);
this.fingerprints.set(localPath, next);
if (previous && previous !== next) {
await this.diagnostics?.debug('repository-monitor.changed', { localPath, head: status.head, branch: status.branch?.head, counts: status.counts, reason });
this.onChange?.({ localPath, status, reason });
}
}
async fetchRemoteUpdates(now = Date.now()) {
if (this.fetchRunning) return;
const queue = this.paths.filter((localPath) => this.shouldFetch(localPath, now));
if (!queue.length) return;
this.fetchRunning = true;
try {
const workers = Array.from({ length: Math.min(2, queue.length) }, async () => {
while (queue.length) {
const localPath = queue.shift();
// Mark the attempt before awaiting the network. A failing remote should
// not be retried every local poll interval.
this.lastFetchedAt.set(localPath, Date.now());
try {
const result = await this.git.fetch(localPath);
await this.recordStatus(localPath, result.status, 'remote-state-changed');
await this.diagnostics?.debug('repository-monitor.fetch.completed', {
localPath,
branch: result.status?.branch?.head,
ahead: result.status?.branch?.ahead,
behind: result.status?.branch?.behind,
});
} catch (error) {
await this.diagnostics?.warning('repository-monitor.fetch.failed', { localPath, message: error.message });
}
}
});
await Promise.all(workers);
} finally {
this.fetchRunning = false;
}
}
pause(localPath) { if (localPath) this.paused.add(localPath); }
resume(localPath) { if (localPath) this.paused.delete(localPath); }
@@ -128,6 +191,7 @@ class RepositoryMonitor {
}
async tick() {
void this.fetchRemoteUpdates().catch((error) => this.diagnostics?.warning('repository-monitor.fetch-cycle.failed', error));
if (this.running || !this.paths.length) return;
this.running = true;
try {
@@ -140,13 +204,7 @@ class RepositoryMonitor {
this.lastCheckedAt.set(localPath, Date.now());
try {
const status = await this.git.status(localPath);
const next = this.git.statusFingerprint(status);
const previous = this.fingerprints.get(localPath);
this.fingerprints.set(localPath, next);
if (previous && previous !== next) {
await this.diagnostics?.debug('repository-monitor.changed', { localPath, head: status.head, branch: status.branch?.head, counts: status.counts });
this.onChange?.({ localPath, status, reason: 'working-tree-changed' });
}
await this.recordStatus(localPath, status, 'working-tree-changed');
} catch (error) {
const next = `error:${error.message}`;
const previous = this.fingerprints.get(localPath);
+53 -7
View File
@@ -53,9 +53,10 @@ function parseCapabilityOutput(output) {
}
class SshService {
constructor({ store, diagnostics, idleConnectionMs = 60_000 }) {
constructor({ store, diagnostics, idleConnectionMs = 60_000, clientFactory = loadSshClient }) {
this.store = store;
this.diagnostics = diagnostics;
this.clientFactory = clientFactory;
// Every command used to pay for a TCP handshake, a key exchange and an
// authentication round trip. Sessions are kept per server for a short while
// so a sequence of commands shares one connection.
@@ -104,7 +105,7 @@ class SshService {
return { valid: true, method: 'privateKey', encrypted: Boolean(passphrase), privateKeyPath };
}
async connectionOptions(server, { trustOnFirstUse = false } = {}) {
async connectionOptions(server, { trustOnFirstUse = false, expectedFingerprint = null } = {}) {
const credentials = this.store.getServerCredentials(server.id);
let observedFingerprint = null;
const options = {
@@ -116,7 +117,8 @@ class SshService {
keepaliveCountMax: 3,
hostVerifier: (key) => {
observedFingerprint = fingerprintKey(key);
return trustOnFirstUse || Boolean(server.hostFingerprint && observedFingerprint === server.hostFingerprint);
const trustedFingerprint = String(server.hostFingerprint || expectedFingerprint || '').trim();
return trustOnFirstUse || Boolean(trustedFingerprint && observedFingerprint === trustedFingerprint);
},
};
if (server.authType === 'password') options.password = credentials.password;
@@ -137,7 +139,7 @@ class SshService {
if (!server) throw new Error('The configured SSH server no longer exists.');
// A trust-on-first-use connection is established without checking the
// fingerprint, so it must never serve a later verified call.
if (options.trustOnFirstUse) return this.withDedicatedClient(server, action, options);
if (options.trustOnFirstUse || options.expectedFingerprint) return this.withDedicatedClient(server, action, options);
return this.withPooledClient(server, action, options);
}
@@ -243,7 +245,7 @@ class SshService {
async withDedicatedClient(server, action, options = {}, { keepOpen = false } = {}) {
const serverId = server.id;
const Client = loadSshClient();
const Client = this.clientFactory();
const connection = await this.connectionOptions(server, options);
const client = new Client();
const started = Date.now();
@@ -414,7 +416,51 @@ class SshService {
}));
}
async test(serverId, { trustOnFirstUse = true } = {}) {
async probeHostFingerprint(serverId) {
const server = this.store.getServer(serverId);
if (!server) throw new Error('The configured SSH server no longer exists.');
const Client = this.clientFactory();
const client = new Client();
let observedFingerprint = null;
return new Promise((resolve, reject) => {
let settled = false;
const finish = (callback, value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
try { client.end(); } catch { /* handshake already closed */ }
callback(value);
};
const completeProbe = (error = null) => {
if (observedFingerprint) {
finish(resolve, {
fingerprint: observedFingerprint,
server: { id: server.id, name: server.name, host: server.host, port: server.port || 22 },
});
return;
}
const wrapped = new Error(`Could not read the SSH host fingerprint: ${error?.message || 'the server closed the handshake'}`);
wrapped.code = error?.code || 'SSH_HOST_KEY_PROBE_FAILED';
finish(reject, wrapped);
};
const timer = setTimeout(() => completeProbe(new Error('The SSH host-key probe timed out.')), 25_000);
client.on('error', completeProbe);
client.on('close', () => completeProbe());
client.on('end', () => completeProbe());
client.connect({
host: server.host,
port: server.port || 22,
username: server.username,
readyTimeout: 20_000,
hostVerifier: (key) => {
observedFingerprint = fingerprintKey(key);
return false;
},
});
});
}
async test(serverId, { trustOnFirstUse = false, expectedFingerprint = null } = {}) {
return this.withClient(serverId, async (client, server, fingerprint) => {
const script = `
platform=$(uname -srm 2>/dev/null || true)
@@ -449,7 +495,7 @@ printf 'baseWritable=%s\\n' "$base_writable"
capabilities,
output: [capabilities.platform, capabilities.composeVersion].filter(Boolean).join('\n'),
};
}, { trustOnFirstUse });
}, { trustOnFirstUse, expectedFingerprint });
}
async exec(serverId, command, options = {}) {
+4 -2
View File
@@ -431,7 +431,7 @@ function createUnraidInventoryMethods({
const plan = this.reconciliationPlan(server, workloads, repositories, { autoLink: true });
if (plan.additions.length) await this.store.createRecoverySnapshot?.(`automatic-server-links-${serverId}`);
const linkedRepositories = new Set(workloads
.filter((workload) => workload.link?.repositoryFullName)
.filter((workload) => workload.classification?.type !== "stale-link" && workload.link?.repositoryFullName)
.map((workload) => String(workload.link.repositoryFullName).toLowerCase()));
for (const addition of plan.additions) {
const workload = workloads.find((item) => item.workloadId === addition.workloadId);
@@ -466,7 +466,9 @@ function createUnraidInventoryMethods({
reconciliationPlan(server, workloads, repositories, { autoLink = true } = {}) {
const profiles = this.allSshProfiles().filter((profile) => profile.serverId === server.id);
const activeWorkloadIds = new Set(workloads.filter((item) => item.classification?.type !== "stale-link").map((item) => item.workloadId));
const linkedRepositories = new Set(workloads.filter((item) => item.link?.repositoryFullName).map((item) => String(item.link.repositoryFullName).toLowerCase()));
const linkedRepositories = new Set(workloads
.filter((item) => item.classification?.type !== "stale-link" && item.link?.repositoryFullName)
.map((item) => String(item.link.repositoryFullName).toLowerCase()));
const additions = [];
const updates = [];
const conflicts = [];
+151 -19
View File
@@ -14,6 +14,85 @@ function safeRepositoryPart(value, label) {
return text;
}
function verifyReleaseManifest({
manifestBytes,
signatureBytes,
publicKey,
update,
assetName,
}) {
if (
!Buffer.isBuffer(manifestBytes) ||
manifestBytes.length < 100 ||
manifestBytes.length > 1_000_000
) {
throw new Error("The signed release manifest has an invalid size.");
}
const signatureText = Buffer.from(signatureBytes || "")
.toString("utf8")
.trim();
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(signatureText)) {
throw new Error("The release manifest signature is invalid.");
}
const signature = Buffer.from(signatureText, "base64");
if (signature.length !== 64) {
throw new Error("The release manifest signature is invalid.");
}
let verified = false;
try {
verified = crypto.verify(null, manifestBytes, publicKey, signature);
} catch {
verified = false;
}
if (!verified) {
const error = new Error(
"The release manifest was not signed by the trusted ForgeFlow publisher key.",
);
error.code = "RELEASE_SIGNATURE_INVALID";
throw error;
}
let manifest;
try {
manifest = JSON.parse(manifestBytes.toString("utf8"));
} catch {
throw new Error("The signed release manifest is not valid JSON.");
}
const expectedVersion = String(update.remoteVersion || "").trim();
const expectedCommit = String(update.remoteSha || "").toLowerCase();
if (
manifest.schemaVersion !== 1 ||
manifest.product !== "ForgeFlow" ||
manifest.version !== expectedVersion ||
manifest.tag !== `v${expectedVersion}` ||
manifest.signature?.algorithm !== "Ed25519" ||
(expectedCommit &&
String(manifest.commit || "").toLowerCase() !== expectedCommit)
) {
const error = new Error(
"The signed release manifest does not match the requested ForgeFlow update.",
);
error.code = "RELEASE_MANIFEST_MISMATCH";
throw error;
}
const artifact = Array.isArray(manifest.artifacts)
? manifest.artifacts.find((item) => item?.name === assetName)
: null;
if (
!artifact ||
!Number.isSafeInteger(artifact.bytes) ||
artifact.bytes < 1_000_000 ||
!/^[a-f0-9]{64}$/.test(String(artifact.sha256 || ""))
) {
const error = new Error(
`The signed release manifest has no valid entry for ${assetName}.`,
);
error.code = "RELEASE_MANIFEST_INCOMPLETE";
throw error;
}
return { manifest, artifact };
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -156,6 +235,7 @@ class UpdateService {
powershellPath = null,
handshakeTimeoutMs = 12000,
handshakePollMs = 100,
updatePublicKey = null,
}) {
this.store = store;
this.gitea = gitea;
@@ -168,6 +248,7 @@ class UpdateService {
this.powershellPath = powershellPath;
this.handshakeTimeoutMs = handshakeTimeoutMs;
this.handshakePollMs = handshakePollMs;
this.updatePublicKey = updatePublicKey;
this.staged = null;
}
@@ -291,7 +372,7 @@ class UpdateService {
));
if (!release || release.draft || release.prerelease) {
const error = new Error(
`ForgeFlow ${update.remoteVersion} has no published binary release yet. The source branch was updated, but the matching Windows installer/portable assets were not published. Run Publish-Missing-Binary-Release.ps1 from the release source or publish the four required assets in Gitea.`,
`ForgeFlow ${update.remoteVersion} has no published binary release yet. The source branch was updated, but the matching signed Windows release was not published. Run Publish-Missing-Binary-Release.ps1 from the release source.`,
);
error.code = "BINARY_RELEASE_NOT_FOUND";
throw error;
@@ -300,33 +381,70 @@ class UpdateService {
const portable = Boolean(this.appInfo.portableExecutablePath);
const assetName = `ForgeFlow-${portable ? "Portable" : "Setup"}-${update.remoteVersion}-win-x64.exe`;
const checksumName = `${assetName}.sha256`;
const manifestName = `ForgeFlow-${update.remoteVersion}-release-manifest.json`;
const signatureName = `${manifestName}.sig`;
const assets = Array.isArray(release.assets) ? release.assets : [];
const asset = assets.find((item) => item.name === assetName);
const checksumAsset = assets.find((item) => item.name === checksumName);
if (!asset?.id || !checksumAsset?.id) {
const manifestAsset = assets.find((item) => item.name === manifestName);
const signatureAsset = assets.find((item) => item.name === signatureName);
if (
!asset?.id ||
!checksumAsset?.id ||
!manifestAsset?.id ||
!signatureAsset?.id
) {
const error = new Error(
`Release v${update.remoteVersion} is missing ${assetName} or its SHA-256 file.`,
`Release v${update.remoteVersion} is incomplete: the executable, SHA-256 file, signed manifest and signature are all required.`,
);
error.code = "BINARY_RELEASE_INCOMPLETE";
throw error;
}
const [binary, checksumBytes] = await Promise.all([
this.gitea.downloadReleaseAsset(
update.owner,
update.repo,
release.id,
asset.id,
{ downloadUrl: asset.browser_download_url },
),
this.gitea.downloadReleaseAsset(
update.owner,
update.repo,
release.id,
checksumAsset.id,
{ downloadUrl: checksumAsset.browser_download_url },
),
]);
const [binary, checksumBytes, manifestBytes, signatureBytes] =
await Promise.all([
this.gitea.downloadReleaseAsset(
update.owner,
update.repo,
release.id,
asset.id,
{ downloadUrl: asset.browser_download_url },
),
this.gitea.downloadReleaseAsset(
update.owner,
update.repo,
release.id,
checksumAsset.id,
{ downloadUrl: checksumAsset.browser_download_url },
),
this.gitea.downloadReleaseAsset(
update.owner,
update.repo,
release.id,
manifestAsset.id,
{ downloadUrl: manifestAsset.browser_download_url },
),
this.gitea.downloadReleaseAsset(
update.owner,
update.repo,
release.id,
signatureAsset.id,
{ downloadUrl: signatureAsset.browser_download_url },
),
]);
const publicKey =
this.updatePublicKey ||
(await fs.readFile(
path.join(this.sourcePath, "build", "update-signing-public.pem"),
));
const { manifest, artifact } = verifyReleaseManifest({
manifestBytes,
signatureBytes,
publicKey,
update,
assetName,
});
if (binary.length < 1_000_000 || binary[0] !== 0x4d || binary[1] !== 0x5a) {
const preview = binary.subarray(0, 200).toString("utf8").trim();
const looksLikeMetadata =
@@ -348,6 +466,16 @@ class UpdateService {
?.toLowerCase();
if (!/^[a-f0-9]{64}$/.test(expectedSha256 || ""))
throw new Error("The release SHA-256 file is invalid.");
if (expectedSha256 !== artifact.sha256) {
throw new Error(
"The release checksum does not match the signed publisher manifest.",
);
}
if (binary.length !== artifact.bytes) {
throw new Error(
"The downloaded Windows update size does not match the signed publisher manifest.",
);
}
const sha256 = crypto.createHash("sha256").update(binary).digest("hex");
if (sha256 !== expectedSha256)
throw new Error(
@@ -368,6 +496,8 @@ class UpdateService {
? this.appInfo.portableExecutablePath
: this.appInfo.executablePath,
releaseTag: release.tag_name,
publisherKeyId: manifest.signature.keyId,
releaseManifest: manifestName,
downloadedAt: new Date().toISOString(),
downloaded: true,
};
@@ -383,6 +513,7 @@ class UpdateService {
bytes: binary.length,
sha256,
portable,
publisherKeyId: manifest.signature.keyId,
});
return metadata;
}
@@ -713,6 +844,7 @@ class UpdateService {
module.exports = {
UpdateService,
safeRepositoryPart,
verifyReleaseManifest,
resolveWindowsPowerShellPath,
windowsUpdaterSpawnOptions,
waitForUpdaterStarted,
@@ -23,6 +23,7 @@ async function handleDeploymentProfileActions(event, target, action, repository)
};
render();
} else if (action === "close-modal") {
if (ui.modal?.type === "workspace-sync") ui.workspaceSyncPlan = null;
ui.modal = null;
render();
} else if (action === "select-profile-icon") {
+65
View File
@@ -121,6 +121,71 @@ Force repair after you have closed all Git tools for this repository?`)
}
setLoading(false);
render();
} else if (action === "preview-workspace-sync") {
if (!repository?.localPath) return;
setLoading(true, "Fetching Gitea and building a safe synchronization plan…");
try {
ui.workspaceSyncPlan = await window.forgeflow.previewWorkspaceSync(
repository.localPath,
);
ui.modal = { type: "workspace-sync" };
showToast(
ui.workspaceSyncPlan.needsSync
? "Workspace sync preview ready"
: "Workspace already synchronized",
ui.workspaceSyncPlan.needsSync
? `${ui.workspaceSyncPlan.summary.resultingTrackedChanges} tracked change(s) and ${ui.workspaceSyncPlan.summary.localFilesToStash} local file(s) reviewed.`
: `Local ${ui.workspaceSyncPlan.branch} already matches ${ui.workspaceSyncPlan.upstream}.`,
ui.workspaceSyncPlan.blockers?.length ? "error" : "success",
);
} catch (error) {
showToast("Could not preview Gitea sync", error.message, "error");
}
setLoading(false);
render();
} else if (action === "confirm-workspace-sync") {
if (!repository?.localPath || !ui.workspaceSyncPlan) return;
const expectedPlanId = target.dataset.planId;
setLoading(true, "Protecting local work and synchronizing exact Gitea state…");
try {
const result = await window.forgeflow.applyWorkspaceSync(
repository.localPath,
expectedPlanId,
);
ui.modal = null;
ui.workspaceSyncPlan = null;
await refreshRepositories(false);
[ui.branches, ui.stashes] = await Promise.all([
window.forgeflow.branches(repository.localPath),
window.forgeflow.stashList(repository.localPath),
]);
const recovery = [
result.backupBranch ? `recovery branch ${result.backupBranch}` : null,
result.stash ? `stash ${result.stash.ref}` : null,
].filter(Boolean).join(" and ");
showToast(
"Workspace synchronized with Gitea",
recovery
? `Local work is preserved in ${recovery}. Ignored runtime files were retained.`
: `Tracked files now match ${result.plan.upstream}; ignored runtime files were retained.`,
"success",
);
} catch (error) {
if (error.code === "WORKSPACE_SYNC_PLAN_STALE") {
try {
ui.workspaceSyncPlan = await window.forgeflow.previewWorkspaceSync(
repository.localPath,
);
ui.modal = { type: "workspace-sync" };
} catch {
ui.modal = null;
ui.workspaceSyncPlan = null;
}
}
showToast("Workspace synchronization stopped", error.message, "error");
}
setLoading(false);
render();
} else if (action === "repair-repository-sync") {
if (!repository?.localPath) return;
const strategy = target.dataset.strategy;
+15 -1
View File
@@ -242,7 +242,18 @@ async function handleSetupAndSettingsActions(event, target, action, repository)
"Checking SSH identity, Docker, Compose and optional Git capabilities…",
);
try {
const result = await window.forgeflow.testServer(target.dataset.serverId);
let result = await window.forgeflow.testServer(target.dataset.serverId);
if (result.needsTrust) {
const approved = confirm(
`Verify this fingerprint on the SSH server before trusting it:\n\n${result.fingerprint}\n\nServer: ${result.server.host}:${result.server.port}\n\nTrust this exact host identity and continue with authentication?`,
);
if (!approved) {
showToast("SSH trust cancelled", "No credentials were sent and the host identity was not saved.", "info");
setLoading(false);
return true;
}
result = await window.forgeflow.testServer(target.dataset.serverId, result.fingerprint);
}
ui.boot.state = result.state;
const capabilities = result.capabilities || {};
const deploymentReady =
@@ -329,6 +340,9 @@ async function handleSetupAndSettingsActions(event, target, action, repository)
operationPollSeconds: Number(
document.querySelector("#pref-operation-poll").value,
),
fetchIntervalMinutes: Number(
document.querySelector("#pref-fetch-interval").value,
),
preferredCloneProtocol: document.querySelector("#pref-clone-protocol")
.value,
};
+2
View File
@@ -188,6 +188,7 @@ const ui = {
servers: [],
serverInspection: null,
gitRecovery: null,
workspaceSyncPlan: null,
gitValidation: null,
diffHunks: null,
conflictState: null,
@@ -607,6 +608,7 @@ function selectRepository(id, shouldRender = true) {
ui.branches = [];
ui.stashes = [];
ui.gitRecovery = null;
ui.workspaceSyncPlan = null;
ui.gitValidation = null;
ui.branchProtection = null;
const repository = selectedRepository();
+14
View File
@@ -44,6 +44,20 @@ function renderModal() {
];
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true" aria-labelledby="reconciliation-title"><header class="modal-header"><h2 id="reconciliation-title">Review server reconciliation</h2><button class="icon-button" data-action="close-modal" aria-label="Close reconciliation preview">${icon("close")}</button></header><div class="modal-body"><div class="notice success">${icon("shield")}This reviewed plan may update ForgeFlow configuration only. It never starts, stops or recreates containers, and stale profiles are never removed automatically.</div><div class="summary-grid" style="margin-top:12px"><div class="summary-card"><span>New links</span><strong>${Number(summary.additions || 0)}</strong></div><div class="summary-card"><span>Refreshes</span><strong>${Number(summary.updates || 0)}</strong></div><div class="summary-card"><span>Stale reviews</span><strong>${Number(summary.stale || 0)}</strong></div><div class="summary-card"><span>Conflicts</span><strong>${Number(summary.conflicts || 0)}</strong></div></div><div class="tool-list" style="margin-top:14px">${rows.length ? rows.map((item) => `<div class="tool-row"><div><strong>${escapeHtml(item.title)}</strong><span>${escapeHtml(item.detail)}</span></div>${item.tone ? `<span class="status-pill ${item.tone}">${escapeHtml(item.tone === "success" ? "Planned" : item.tone === "warning" ? "Review" : "Blocked")}</span>` : ""}</div>`).join("") : '<div class="empty-state compact"><p>No configuration changes are proposed.</p></div>'}</div><div class="notice" style="margin-top:12px">${icon("archive")}A private recovery snapshot is written before the plan is applied. Plan ID: <span class="mono">${escapeHtml(String(plan.id || "").slice(0, 12))}</span></div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="apply-server-reconciliation" data-server-id="${attr(plan.serverId || "")}" data-plan-id="${attr(plan.id || "")}" ${summary.conflicts ? "disabled title=\"Resolve ambiguous workloads manually before applying reconciliation\"" : ""}>Apply reviewed plan</button></footer></section></div>`;
}
if (ui.modal.type === "workspace-sync") {
const plan = ui.workspaceSyncPlan;
if (!plan) return "";
const summary = plan.summary || {};
const blocked = Boolean(plan.blockers?.length);
const changeRows = (plan.changes || []).map((change) => `<div class="tool-row"><div><strong>${escapeHtml(change.path)}</strong><span>${change.originalPath ? `${escapeHtml(change.originalPath)}` : ""}${escapeHtml(change.status)}</span></div><span class="status-pill ${change.code === "D" ? "danger" : change.code === "A" ? "success" : "warning"}">${escapeHtml(change.code)}</span></div>`).join("");
const recoveryRows = [
plan.recovery?.safetyBranch ? "Local commits → recovery branch" : "No local commits require a recovery branch",
plan.recovery?.stash ? "Modified and untracked files → named Git stash" : "No working-tree files require a stash",
plan.recovery?.untrackedCleanup ? "Untracked files are removed after they are stashed" : "No untracked cleanup required",
"Ignored runtime files remain in place",
];
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true" aria-labelledby="workspace-sync-title"><header class="modal-header"><h2 id="workspace-sync-title">Review Gitea workspace sync</h2><button class="icon-button" data-action="close-modal" aria-label="Close workspace sync preview">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero ${blocked ? "danger" : plan.needsSync ? "" : "success"}">${icon(blocked ? "error" : "shield")}<div><strong>${blocked ? "Synchronization is blocked" : plan.needsSync ? `${escapeHtml(plan.branch)} will match ${escapeHtml(plan.upstream)}` : "Workspace already matches Gitea"}</strong><span>${shortSha(plan.currentSha)}${shortSha(plan.targetSha)} · reviewed plan ${escapeHtml(plan.id.slice(0, 12))}</span></div></div>${blocked ? `<div class="notice danger" style="margin-top:12px">${icon("error")}<div><strong>Resolve before applying</strong><p>${escapeHtml(plan.blockers.join(" "))}</p></div></div>` : ""}<div class="summary-grid" style="margin-top:12px"><div class="summary-card"><span>Incoming commits</span><strong>${Number(summary.incomingCommits || 0)}</strong></div><div class="summary-card"><span>Tracked file changes</span><strong>${Number(summary.resultingTrackedChanges || 0)}</strong></div><div class="summary-card"><span>Files removed by sync</span><strong>${Number(summary.deleted || 0)}</strong></div><div class="summary-card"><span>Local files protected</span><strong>${Number(summary.localFilesToStash || 0)}</strong></div><div class="summary-card"><span>Local commits protected</span><strong>${Number(summary.localCommitsToProtect || 0)}</strong></div></div><section class="settings-group" style="margin-top:14px"><h3>Recovery contract</h3><ul>${recoveryRows.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul><div class="notice success">${icon("archive")}ForgeFlow never reapplies saved local work automatically. You can review the recovery branch or stash later, file by file.</div></section><section class="settings-group"><div class="section-heading"><div><h3>Resulting tracked changes</h3><span class="meta">${summary.added || 0} added · ${summary.modified || 0} modified · ${summary.deleted || 0} deleted · ${summary.renamed || 0} renamed</span></div></div><div class="tool-list">${changeRows || '<div class="empty-state compact"><p>No tracked file changes between local HEAD and Gitea.</p></div>'}</div>${plan.changesTruncated ? '<p class="meta">Only the first 250 paths are shown. Counts include the complete plan.</p>' : ""}</section></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="confirm-workspace-sync" data-plan-id="${attr(plan.id)}" ${blocked || !plan.needsSync ? "disabled" : ""}>Protect local work & synchronize</button></footer></section></div>`;
}
if (ui.modal.type === "workload-link") {
const serverResult = (ui.serverDiscovery || []).find(
(item) => item.serverId === ui.modal.serverId,
+145 -1
View File
@@ -5,7 +5,7 @@ function createMockRepositoryBridge(context) {
await wait(80);
snapshot();
return {
appVersion: "0.10.12-demo",
appVersion: "0.10.13-demo",
platform: "win32",
state: clone(state),
git: { available: true, version: "git version 2.47.3" },
@@ -427,6 +427,71 @@ function createMockRepositoryBridge(context) {
emitRepositories();
return { output: "Fast-forwarded.", status: clone(repo.localStatus) };
},
async previewWorkspaceSync(localPath) {
await wait(260);
const repo = findRepo(localPath);
const status = repo.localStatus;
const targetSha = status.branch.behind ? "f".repeat(40) : status.head;
return {
id: `demo-${String(status.head).slice(0, 7)}-${status.branch.ahead}-${status.branch.behind}`.padEnd(64, "0").slice(0, 64),
branch: status.branch.head,
upstream: status.branch.upstream || `origin/${status.branch.head}`,
currentSha: status.head,
targetSha,
needsSync: !status.clean || status.head !== targetSha || status.branch.ahead > 0,
blockers: [],
summary: {
resultingTrackedChanges: status.branch.behind ? 3 : 0,
added: status.branch.behind ? 1 : 0,
modified: status.branch.behind ? 1 : 0,
deleted: status.branch.behind ? 1 : 0,
renamed: 0,
localFilesToStash: status.counts.changed,
untrackedFilesToStash: status.counts.untracked,
localCommitsToProtect: status.branch.ahead,
incomingCommits: status.branch.behind,
},
changes: status.branch.behind
? [
{ code: "A", status: "added", path: "src/remote-feature.js" },
{ code: "M", status: "modified", path: "README.md" },
{ code: "D", status: "deleted", path: "docs/obsolete.md" },
]
: [],
localFiles: clone(status.files),
incomingCommits: [],
localCommits: [],
recovery: {
safetyBranch: status.branch.ahead > 0,
stash: status.counts.changed > 0,
untrackedCleanup: status.counts.untracked > 0,
ignoredFilesPreserved: true,
},
};
},
async applyWorkspaceSync(localPath, expectedPlanId) {
const plan = await this.previewWorkspaceSync(localPath);
if (plan.id !== expectedPlanId) throw new Error("The workspace sync preview is stale.");
const repo = findRepo(localPath);
const hadChanges = repo.localStatus.counts.changed > 0;
repo.localStatus.head = plan.targetSha;
repo.localStatus.shortHead = plan.targetSha.slice(0, 7);
repo.localStatus.files = [];
repo.localStatus.branch.ahead = 0;
repo.localStatus.branch.behind = 0;
recompute(repo);
emitRepositories();
return {
applied: plan.needsSync,
unchanged: !plan.needsSync,
plan,
status: clone(repo.localStatus),
backupBranch: plan.summary.localCommitsToProtect ? `forgeflow/recovery-${plan.branch}-demo` : null,
stash: hadChanges ? { ref: "stash@{0}", shortSha: "demo123", subject: "ForgeFlow workspace sync" } : null,
ignoredFilesPreserved: true,
cleaned: [],
};
},
async history() {
await wait(100);
return clone(commitHistory);
@@ -556,6 +621,85 @@ function createMockRepositoryBridge(context) {
stashes: clone(list),
};
},
async gitRecoveryStatus(localPath) {
const repo = findRepo(localPath);
const status = clone(repo.localStatus);
const upstream = status.branch?.upstream;
const recommendations = [
{
id: "fetch",
label: "Fetch and recalculate remote state",
action: "fetch",
safe: true,
},
];
if (
status.clean &&
status.branch.behind > 0 &&
status.branch.ahead === 0 &&
upstream
) {
recommendations.push({
id: "pull",
label: `Fast-forward from ${upstream}`,
action: "fast-forward",
safe: true,
});
}
if (
status.branch.ahead > 0 &&
status.branch.behind === 0 &&
upstream
) {
recommendations.push({
id: "push",
label: `Push ${status.branch.ahead} local commit(s)`,
action: "push",
safe: true,
});
}
return {
status,
lockReport: {
root: localPath,
gitDir: `${localPath}\\.git`,
locks: [],
processes: { available: true, active: [] },
},
recommendations,
};
},
async reconcileRepository(localPath) {
await wait(160);
return this.gitRecoveryStatus(localPath);
},
async repairGitLocks(localPath) {
return {
...(await this.gitRecoveryStatus(localPath)).lockReport,
removed: [],
skipped: [],
repaired: false,
};
},
async repairRepositorySync(localPath, strategy) {
const repo = findRepo(localPath);
if (strategy === "fast-forward") {
repo.localStatus.branch.behind = 0;
repo.localStatus.head = "f".repeat(40);
} else if (strategy === "push") {
repo.localStatus.branch.ahead = 0;
} else if (strategy !== "fetch") {
throw new Error("Unsupported demo synchronization strategy.");
}
recompute(repo);
emitRepositories();
return {
strategy,
backupBranch: null,
status: clone(repo.localStatus),
lockReport: (await this.gitRecoveryStatus(localPath)).lockReport,
};
},
async indexLockInfo() {
return { exists: false, ageMs: 0 };
},
+53
View File
@@ -648,6 +648,24 @@ select:focus-visible {
right: 14px;
color: var(--text-faint);
}
.modal .summary-grid {
grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
}
.modal .summary-card {
min-height: 108px;
}
.modal .summary-card > span {
display: block;
max-width: 12ch;
color: var(--text-muted);
line-height: 1.35;
}
.modal .summary-card > strong {
display: block;
margin-top: 15px;
font: 700 27px/1 var(--font-sans);
color: var(--text);
}
.summary-card.warning .summary-value,
.summary-card.warning .icon {
color: var(--warning);
@@ -2653,6 +2671,7 @@ kbd {
.git-tools-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: max-content;
gap: 15px;
align-items: start;
}
@@ -3318,6 +3337,33 @@ html[data-theme="light"] .setup-brand-logo-light {
.git-tools-grid .troubleshooting-panel {
grid-column: 1 / -1;
}
.git-tools-grid .workspace-sync-panel {
grid-column: 1 / -1;
overflow: hidden;
background:
radial-gradient(circle at 88% 10%, color-mix(in srgb, var(--accent) 15%, transparent), transparent 34%),
var(--surface-1);
}
.workspace-sync-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 24px;
}
.workspace-sync-layout h3 {
margin: 0 0 6px;
}
.workspace-sync-layout p {
margin: 0 0 13px;
color: var(--text-muted);
max-width: 820px;
}
.workspace-sync-actions {
display: grid;
justify-items: end;
gap: 10px;
min-width: 220px;
}
.troubleshooting-summary {
display: flex;
align-items: center;
@@ -3351,6 +3397,13 @@ html[data-theme="light"] .setup-brand-logo-light {
margin-left: auto;
}
@media (max-width: 760px) {
.workspace-sync-layout {
grid-template-columns: 1fr;
}
.workspace-sync-actions {
justify-items: stretch;
min-width: 0;
}
.repo-quick-actions {
margin-inline: 12px;
}
+26 -5
View File
@@ -183,7 +183,7 @@ function renderOverview() {
${readinessRow("Git executable", ui.boot.git.available, ui.boot.git.version || ui.boot.git.error)}
${readinessRow("Gitea connection", ui.boot.state.gitea.hasToken, ui.boot.state.gitea.baseUrl || "Not configured")}
${readinessRow("Workspace folders", ui.boot.state.workspaceRoots.length > 0, `${ui.boot.state.workspaceRoots.length} configured`)}
${readinessRow("Automatic awareness", ui.boot.state.preferences?.autoRefresh !== false, ui.boot.state.preferences?.autoRefresh === false ? "Manual refresh only" : `Every ${ui.boot.state.preferences?.repositoryPollSeconds || 4}s`)}
${readinessRow("Automatic awareness", ui.boot.state.preferences?.autoRefresh !== false, ui.boot.state.preferences?.autoRefresh === false ? "Manual refresh only" : `Local every ${ui.boot.state.preferences?.repositoryPollSeconds || 4}s · Gitea every ${ui.boot.state.preferences?.fetchIntervalMinutes || "manual"}${ui.boot.state.preferences?.fetchIntervalMinutes ? " min" : ""}`)}
</div></div>
</section>
</div>`;
@@ -361,7 +361,28 @@ function renderGitTools(repository) {
const locks = recovery?.lockReport?.locks || [];
const activeProcesses = recovery?.lockReport?.processes?.active || [];
const recommendations = recovery?.recommendations || [];
return `<div class="tab-page git-tools-grid"><section class="panel"><div class="panel-header"><h2>Branches</h2><button class="button ghost" data-action="load-git-tools">${icon("refresh")}Refresh</button></div><div class="panel-body"><div class="inline-form"><input id="new-branch-name" class="input" placeholder="feature/name"/><button class="button" data-action="create-branch">${icon("plus")}Create & switch</button></div><div class="tool-list">${ui.branches.length ? ui.branches.map((branch) => `<div class="tool-row"><div><strong>${escapeHtml(branch.name)}</strong><span>${escapeHtml(branch.shortSha)}${branch.upstream ? ` · ${escapeHtml(branch.upstream)}` : " · unpublished"}</span></div>${branch.current ? '<span class="status-pill success">Current</span>' : `<button class="button" data-action="checkout-branch" data-branch="${attr(branch.name)}">Switch</button>`}</div>`).join("") : '<div class="empty-state compact"><p>Load branch information.</p></div>'}</div></div></section><section class="panel"><div class="panel-header"><h2>Stashes</h2><button class="button" data-action="stash-changes" ${repository.localStatus?.clean ? "disabled" : ""}>${icon("archive")}Stash changes</button></div><div class="panel-body"><div class="tool-list">${ui.stashes.length ? ui.stashes.map((stash) => `<div class="tool-row"><div><strong>${escapeHtml(stash.ref)}</strong><span>${escapeHtml(stash.subject)} · ${formatDate(stash.date)}</span></div><button class="button" data-action="pop-stash" data-stash-ref="${attr(stash.ref)}">Apply & drop</button></div>`).join("") : '<div class="empty-state compact"><p>No stashes, or Git tools have not been loaded.</p></div>'}</div></div></section><section class="panel troubleshooting-panel"><div class="panel-header"><div><h2>Repository troubleshooting</h2><span class="meta">Safe, repository-specific recovery actions</span></div><button class="button primary" data-action="scan-git-recovery">${icon("pulse")}Scan</button></div><div class="panel-body">${recovery ? `<div class="troubleshooting-summary"><span class="status-pill ${locks.length ? "warning" : "success"}">${locks.length ? `${locks.length} lock${locks.length === 1 ? "" : "s"}` : "No Git locks"}</span><span>${activeProcesses.length ? `${activeProcesses.length} active Git process(es)` : "No matching active Git process detected"}</span></div>${locks.length ? `<div class="tool-list">${locks.map((lock) => `<div class="tool-row"><div><strong>${escapeHtml(lock.name)}</strong><span>${Math.round(lock.ageMs / 1000)}s old · ${escapeHtml(lock.modifiedAt)}</span></div></div>`).join("")}</div>` : ""}${recommendations.length ? `<div class="tool-list recovery-actions">${recommendations.map((item) => `<div class="tool-row"><div><strong>${escapeHtml(item.label)}</strong><span>${item.safe ? "Safe automated action" : item.action ? "Creates a safety branch before changing history" : "Review required"}</span></div>${item.action ? `<button class="button ${item.safe ? "" : "danger"}" data-action="repair-repository-sync" data-strategy="${attr(item.action)}">Run</button>` : ""}</div>`).join("")}</div>` : ""}` : '<div class="empty-state compact"><p>Scan before repairing. ForgeFlow checks every .lock file in the actual Git directory, not only index.lock.</p></div>'}<div class="card-actions"><button class="button" data-action="repair-git-locks">${icon("wrench")}Repair proven stale locks</button><button class="button" data-action="reconcile-repository">${icon("refresh")}Refresh Git state</button>${repository.sshUrl && repository.localStatus?.remoteUrl !== repository.sshUrl ? `<button class="button" data-action="repair-origin">${icon("link")}Repair origin</button>` : ""}</div><div class="notice warning">Lock repair refuses to run while a matching Git process is active. A force option is shown only when process detection itself is unavailable.</div></div></section></div>`;
const status = repository.localStatus || {};
const branchRows = ui.branches.length
? ui.branches.map((branch) => `<div class="tool-row"><div><strong>${escapeHtml(branch.name)}</strong><span>${escapeHtml(branch.shortSha)}${branch.upstream ? ` · ${escapeHtml(branch.upstream)}` : " · unpublished"}</span></div>${branch.current ? '<span class="status-pill success">Current</span>' : `<button class="button" data-action="checkout-branch" data-branch="${attr(branch.name)}">Switch</button>`}</div>`).join("")
: '<div class="empty-state compact"><p>Load branch information.</p></div>';
const stashRows = ui.stashes.length
? ui.stashes.map((stash) => `<div class="tool-row"><div><strong>${escapeHtml(stash.ref)}</strong><span>${escapeHtml(stash.subject)} · ${formatDate(stash.date)}</span></div><button class="button" data-action="pop-stash" data-stash-ref="${attr(stash.ref)}">Apply & drop</button></div>`).join("")
: '<div class="empty-state compact"><p>No stashes, or Git tools have not been loaded.</p></div>';
const recoveryBody = recovery
? `<div class="troubleshooting-summary"><span class="status-pill ${locks.length ? "warning" : "success"}">${locks.length ? `${locks.length} lock${locks.length === 1 ? "" : "s"}` : "No Git locks"}</span><span>${activeProcesses.length ? `${activeProcesses.length} active Git process(es)` : "No matching active Git process detected"}</span></div>${locks.length ? `<div class="tool-list">${locks.map((lock) => `<div class="tool-row"><div><strong>${escapeHtml(lock.name)}</strong><span>${Math.round(lock.ageMs / 1000)}s old · ${escapeHtml(lock.modifiedAt)}</span></div></div>`).join("")}</div>` : ""}${recommendations.length ? `<div class="tool-list recovery-actions">${recommendations.map((item) => `<div class="tool-row"><div><strong>${escapeHtml(item.label)}</strong><span>${item.safe ? "Safe automated action" : item.action ? "Creates a safety branch before changing history" : "Review required"}</span></div>${item.action ? `<button class="button ${item.safe ? "" : "danger"}" data-action="repair-repository-sync" data-strategy="${attr(item.action)}">Run</button>` : ""}</div>`).join("")}</div>` : ""}`
: '<div class="empty-state compact"><p>Scan before repairing. ForgeFlow checks every .lock file in the actual Git directory, not only index.lock.</p></div>';
const syncState = status.counts?.changed
? `${status.counts.changed} local file${status.counts.changed === 1 ? "" : "s"} need protection`
: status.branch?.ahead || status.branch?.behind
? `${status.branch.ahead || 0} ahead · ${status.branch.behind || 0} behind`
: "Preview against Gitea before changing files";
return `<div class="tab-page git-tools-grid">
<section class="panel"><div class="panel-header"><h2>Branches</h2><button class="button ghost" data-action="load-git-tools">${icon("refresh")}Refresh</button></div><div class="panel-body"><div class="inline-form"><input id="new-branch-name" class="input" placeholder="feature/name"/><button class="button" data-action="create-branch">${icon("plus")}Create & switch</button></div><div class="tool-list">${branchRows}</div></div></section>
<section class="panel"><div class="panel-header"><h2>Stashes</h2><button class="button" data-action="stash-changes" ${status.clean ? "disabled" : ""}>${icon("archive")}Stash changes</button></div><div class="panel-body"><div class="tool-list">${stashRows}</div></div></section>
<section class="panel workspace-sync-panel"><div class="panel-header"><div><h2>Gitea workspace sync</h2><span class="meta">Make tracked files match the current upstream branch exactly</span></div><span class="status-pill ${status.branch?.behind || status.branch?.ahead || status.counts?.changed ? "warning" : "success"}">${escapeHtml(syncState)}</span></div><div class="panel-body"><div class="workspace-sync-layout"><div><h3>Safe mirror, never silent overwrite</h3><p>ForgeFlow fetches Gitea, previews additions, changes and deletions, then protects local Codex work before resetting. Local commits go to a recovery branch; modified and untracked files go to a stash.</p><div class="notice">${icon("shield")}Ignored runtime data such as <span class="mono">.env</span>, dependency folders and local databases is preserved. Background awareness only fetches; it never applies this sync automatically.</div></div><div class="workspace-sync-actions"><span class="meta">${escapeHtml(status.branch?.head || "No branch")} ${escapeHtml(status.branch?.upstream || "No upstream")}</span><button class="button primary" data-action="preview-workspace-sync">${icon("refresh")}Preview Gitea sync</button></div></div></div></section>
<section class="panel troubleshooting-panel"><div class="panel-header"><div><h2>Repository troubleshooting</h2><span class="meta">Safe, repository-specific recovery actions</span></div><button class="button primary" data-action="scan-git-recovery">${icon("pulse")}Scan</button></div><div class="panel-body">${recoveryBody}<div class="card-actions"><button class="button" data-action="repair-git-locks">${icon("wrench")}Repair proven stale locks</button><button class="button" data-action="reconcile-repository">${icon("refresh")}Refresh Git state</button>${repository.sshUrl && status.remoteUrl !== repository.sshUrl ? `<button class="button" data-action="repair-origin">${icon("link")}Repair origin</button>` : ""}</div><div class="notice warning">Lock repair refuses to run while a matching Git process is active. A force option is shown only when process detection itself is unavailable.</div></div></section>
</div>`;
}
function renderRepositorySettings(repository) {
@@ -573,7 +594,7 @@ function renderServerInventory() {
const repository = ui.repositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link?.repositoryFullName || "").toLowerCase());
return !repository?.deploymentProfiles?.some((profile) => profile.id === workload.link?.profileId);
}).length;
return `<section class="panel server-inventory-panel"><div class="panel-header"><div><h3>${escapeHtml(server.serverName || server.server?.name || server.serverId)}</h3><span class="meta">${server.running || 0} running · ${resolvedLinks} visible repository links${unresolvedLinks ? ` · ${unresolvedLinks} unresolved` : ""} · ${visibleWorkloads.filter((workload) => !workload.link).length} to review${hiddenCount ? ` · ${hiddenCount} unrelated/system workloads hidden` : ""}</span></div><div class="stack horizontal compact"><span class="status-pill ${server.error ? "danger" : capabilities.docker && capabilities.compose ? "success" : "warning"}">${server.error ? "Scan failed" : escapeHtml(capabilityText)}</span>${server.error ? "" : `<button class="button" data-action="plan-server-reconciliation" data-server-id="${attr(server.serverId)}">${icon("shield")}Review reconciliation</button>`}</div></div><div class="panel-body">${errorBlock}${warnings}<div class="tool-list">${workloads}</div></div></section>`;
return `<section class="panel server-inventory-panel"><div class="panel-header"><div><h3>${escapeHtml(server.serverName || server.server?.name || server.serverId)}</h3><span class="meta">${server.running || 0} running · ${resolvedLinks} visible repository link${resolvedLinks === 1 ? "" : "s"}${unresolvedLinks ? ` · ${unresolvedLinks} unresolved` : ""} · ${visibleWorkloads.filter((workload) => !workload.link).length} to review${hiddenCount ? ` · ${hiddenCount} unrelated/system workloads hidden` : ""}</span></div><div class="stack horizontal compact"><span class="status-pill ${server.error ? "danger" : capabilities.docker && capabilities.compose ? "success" : "warning"}">${server.error ? "Scan failed" : escapeHtml(capabilityText)}</span>${server.error ? "" : `<button class="button" data-action="plan-server-reconciliation" data-server-id="${attr(server.serverId)}">${icon("shield")}Review reconciliation</button>`}</div></div><div class="panel-body">${errorBlock}${warnings}<div class="tool-list">${workloads}</div></div></section>`;
}).join("");
const empty = configuredServers.length
? `<div class="empty-state panel"><h3>Server inventory has not completed</h3><p>ForgeFlow will query Docker directly. A failed connection is shown explicitly instead of being reported as zero deployments.</p><button class="button primary" data-action="scan-server-inventory">Scan servers now</button></div>`
@@ -602,10 +623,10 @@ function renderSettings() {
return `<div class="settings-layout"><aside class="settings-nav"><button class="nav-button active">${icon("settings")}<span>General</span></button><button class="nav-button" data-action="check-updates">${icon("update")}<span>Updates</span></button><button class="nav-button" data-action="open-add-server">${icon("server")}<span>Servers</span></button><button class="nav-button" data-action="reset-app">${icon("trash")}<span>Reset setup</span></button></aside><div class="settings-content"><div class="page-header"><div><div class="eyebrow">Application</div><h1>Settings</h1><p>Connections, project discovery, secure SSH servers and application updates.</p></div></div>
<section class="settings-group"><h2>Gitea connection</h2><div class="form-grid"><div class="field full"><label for="settings-gitea-url">Instance URL</label><input id="settings-gitea-url" class="input" value="${attr(state.gitea.baseUrl)}" placeholder="https://gitea.example.com" /></div><div class="field full"><label for="settings-gitea-token">New access token</label><input id="settings-gitea-token" class="input" type="password" placeholder="Leave empty to keep the existing token" /></div></div><div class="connection-card" style="margin-top:10px"><div><strong>${state.gitea.hasToken ? `Connected as ${escapeHtml(state.gitea.user?.login || "user")}` : "Not connected"}</strong><div class="queue-sub">${escapeHtml(state.gitea.baseUrl || "No Gitea instance configured")}</div></div><button class="button primary" data-action="save-gitea-settings">Validate & save</button></div></section>
<section class="settings-group"><div class="section-heading"><div><h2>ForgeFlow updates</h2><span class="meta">Secure source update from ${escapeHtml(state.updates?.owner || "Jens")}/${escapeHtml(state.updates?.repo || "ForgeFlow")}</span></div><button class="button" data-action="check-updates" ${ui.updateChecking ? "disabled" : ""}>${icon("update")}${ui.updateChecking ? "Checking" : "Check now"}</button></div><div class="form-grid"><div class="field"><label>Repository owner</label><input id="update-owner" class="input" value="${attr(state.updates?.owner || "Jens")}"/></div><div class="field"><label>Repository name</label><input id="update-repo" class="input" value="${attr(state.updates?.repo || "ForgeFlow")}"/></div><div class="field"><label>Release branch</label><input id="update-branch" class="input" value="${attr(state.updates?.branch || "main")}"/></div><div class="field"><label>Automatic startup check</label><select id="update-auto-check" class="select"><option value="true" ${state.updates?.autoCheck !== false ? "selected" : ""}>Enabled</option><option value="false" ${state.updates?.autoCheck === false ? "selected" : ""}>Disabled</option></select></div></div><div class="update-card ${update?.available ? "available" : ""}"><div>${icon(update?.available ? "download" : "check")}<span><strong>${update ? (update.available ? `ForgeFlow ${escapeHtml(update.remoteVersion)} is available` : `ForgeFlow ${escapeHtml(update.currentVersion)} is up to date`) : `Current version ${escapeHtml(ui.boot.appVersion)}`}</strong><small>${update ? `Branch ${escapeHtml(update.branch)} · commit ${escapeHtml(update.shortSha)} · checked ${formatDate(update.checkedAt)}` : "No update check in this session."}</small></span></div><div class="stack horizontal compact">${update?.available && !update.downloaded ? `<button class="button primary" data-action="download-update">${icon("download")}Download update</button>` : ""}${update?.downloaded ? `<button class="button success" data-action="apply-update">${icon("update")}Apply & restart</button>` : ""}<button class="button" data-action="save-update-settings">Save update settings</button></div></div><div class="notice" style="margin-top:10px">${icon("shield")}The updater downloads an authenticated ZIP for the exact remote commit, verifies its SHA-256 checksum, runs the complete quality gate and restores the previous source version if validation fails.</div></section>
<section class="settings-group"><div class="section-heading"><div><h2>SSH / Unraid servers</h2><span class="meta">Credentials are entered locally and encrypted with the Windows credential protection used by Electron.</span></div><button class="button primary" data-action="open-add-server">${icon("plus")}Add server</button></div>${servers.length ? `<div class="server-list">${servers.map((server) => `<article class="server-card"><div class="server-card-main">${icon("server")}<div><strong>${escapeHtml(server.name)}</strong><span>${escapeHtml(server.username)}@${escapeHtml(server.host)}:${escapeHtml(server.port)} · ${escapeHtml(server.basePath)}</span><small>${server.hostFingerprint ? `Trusted ${escapeHtml(server.hostFingerprint)}` : "Host identity not trusted yet"}</small></div></div><div class="stack horizontal compact"><button class="button" data-action="test-server" data-server-id="${attr(server.id)}">Test & trust</button><button class="button" data-action="edit-server" data-server-id="${attr(server.id)}">Edit</button><button class="icon-button danger" data-action="delete-server" data-server-id="${attr(server.id)}" title="Delete server">${icon("trash")}</button></div></article>`).join("")}</div>` : '<div class="empty-state compact"><p>No SSH server configured. Add your Unraid server before creating an SSH deployment profile.</p></div>'}</section>
<section class="settings-group"><div class="section-heading"><div><h2>SSH / Unraid servers</h2><span class="meta">Credentials are encrypted locally; a new host fingerprint is shown before authentication.</span></div><button class="button primary" data-action="open-add-server">${icon("plus")}Add server</button></div>${servers.length ? `<div class="server-list">${servers.map((server) => `<article class="server-card"><div class="server-card-main">${icon("server")}<div><strong>${escapeHtml(server.name)}</strong><span>${escapeHtml(server.username)}@${escapeHtml(server.host)}:${escapeHtml(server.port)} · ${escapeHtml(server.basePath)}</span><small>${server.hostFingerprint ? `Trusted ${escapeHtml(server.hostFingerprint)}` : "Host identity not trusted yet"}</small></div></div><div class="stack horizontal compact"><button class="button" data-action="test-server" data-server-id="${attr(server.id)}">${server.hostFingerprint ? "Test connection" : "Preview & trust fingerprint"}</button><button class="button" data-action="edit-server" data-server-id="${attr(server.id)}">Edit</button><button class="icon-button danger" data-action="delete-server" data-server-id="${attr(server.id)}" title="Delete server">${icon("trash")}</button></div></article>`).join("")}</div>` : '<div class="empty-state compact"><p>No SSH server configured. Add your Unraid server before creating an SSH deployment profile.</p></div>'}</section>
<section class="settings-group"><div class="section-heading"><div><h2>Git remote maintenance</h2><span class="meta">Standardize linked repositories to the current Gitea SSH URLs.</span></div><button class="button" data-action="normalize-origins">${icon("link")}Normalize all origins</button></div><p>This replaces legacy aliases and renamed owners only after an explicit click. Local commits and files are not changed.</p></section>
<section class="settings-group"><h2>Project roots</h2><p>The first folder is the default clone destination. ForgeFlow automatically creates one subfolder per repository.</p><div class="stack">${state.workspaceRoots.map((root, index) => `<div class="root-row">${index === 0 ? '<span class="status-pill success">Default</span>' : ""}<input class="input" data-root-index="${index}" value="${attr(root)}" aria-label="Project root ${index + 1}"/><button class="icon-button" data-action="remove-root" data-index="${index}" title="Remove">${icon("trash")}</button></div>`).join("")}<button class="button" data-action="add-root">${icon("plus")}Add project root</button><button class="button primary" data-action="save-roots">Save folders & rescan</button></div></section>
<section class="settings-group"><h2>Background awareness</h2><div class="form-grid"><div class="field"><label>Automatic repository refresh</label><select id="pref-auto-refresh" class="select"><option value="true" ${prefs.autoRefresh !== false ? "selected" : ""}>Enabled</option><option value="false" ${prefs.autoRefresh === false ? "selected" : ""}>Disabled</option></select></div><div class="field"><label>Local poll interval</label><input id="pref-repo-poll" class="input" type="number" min="2" max="60" value="${attr(prefs.repositoryPollSeconds || 4)}"/></div><div class="field"><label>Actions poll interval</label><input id="pref-operation-poll" class="input" type="number" min="3" max="120" value="${attr(prefs.operationPollSeconds || 5)}"/></div><div class="field"><label>Preferred clone protocol</label><select id="pref-clone-protocol" class="select"><option value="https" ${prefs.preferredCloneProtocol !== "ssh" ? "selected" : ""}>HTTPS</option><option value="ssh" ${prefs.preferredCloneProtocol === "ssh" ? "selected" : ""}>SSH</option></select></div></div><button class="button primary" style="margin-top:12px" data-action="save-preferences">Save awareness settings</button></section>
<section class="settings-group"><h2>Background awareness</h2><div class="form-grid"><div class="field"><label>Automatic repository refresh</label><select id="pref-auto-refresh" class="select"><option value="true" ${prefs.autoRefresh !== false ? "selected" : ""}>Enabled</option><option value="false" ${prefs.autoRefresh === false ? "selected" : ""}>Disabled</option></select></div><div class="field"><label>Local poll interval</label><input id="pref-repo-poll" class="input" type="number" min="2" max="60" value="${attr(prefs.repositoryPollSeconds || 4)}"/></div><div class="field"><label>Gitea fetch interval (minutes)</label><input id="pref-fetch-interval" class="input" type="number" min="0" max="240" value="${attr(Number.isFinite(Number(prefs.fetchIntervalMinutes)) ? prefs.fetchIntervalMinutes : 10)}"/><small>Read-only remote awareness. Use 0 to disable; fetching never changes project files.</small></div><div class="field"><label>Actions poll interval</label><input id="pref-operation-poll" class="input" type="number" min="3" max="120" value="${attr(prefs.operationPollSeconds || 5)}"/></div><div class="field"><label>Preferred clone protocol</label><select id="pref-clone-protocol" class="select"><option value="https" ${prefs.preferredCloneProtocol !== "ssh" ? "selected" : ""}>HTTPS</option><option value="ssh" ${prefs.preferredCloneProtocol === "ssh" ? "selected" : ""}>SSH</option></select></div></div><div class="notice" style="margin-top:12px">${icon("shield")}Remote awareness only fetches branch metadata. ForgeFlow never resets, cleans or overwrites a workspace in the background.</div><button class="button primary" style="margin-top:12px" data-action="save-preferences">Save awareness settings</button></section>
<section class="settings-group"><h2>Desktop integration</h2><div class="form-grid"><div class="field"><label>Editor executable</label><input id="pref-editor-executable" class="input" value="${attr(prefs.editor?.executable || "code")}"/></div><div class="field"><label>Editor arguments</label><input id="pref-editor-args" class="input" value="${attr((prefs.editor?.args || ["--reuse-window", "--goto", "{file}:{line}"]).join(" | "))}"/><small>Separate arguments with |. Placeholders: {path}, {file}, {line}</small></div><div class="field"><label>Terminal executable</label><input id="pref-terminal-executable" class="input" value="${attr(prefs.terminal?.executable || "wt.exe")}"/></div><div class="field"><label>Terminal arguments</label><input id="pref-terminal-args" class="input" value="${attr((prefs.terminal?.args || ["-d", "{path}"]).join(" | "))}"/></div><label class="check-field"><input id="pref-notifications" type="checkbox" ${prefs.notificationsEnabled !== false ? "checked" : ""}/><span>Native deployment notifications</span></label><label class="check-field"><input id="pref-tray" type="checkbox" ${prefs.trayEnabled !== false ? "checked" : ""}/><span>Show system tray icon</span></label><label class="check-field"><input id="pref-close-tray" type="checkbox" ${prefs.closeToTray === true ? "checked" : ""}/><span>Hide to tray when closing</span></label><label class="check-field"><input id="pref-login" type="checkbox" ${prefs.startAtLogin === true ? "checked" : ""}/><span>Start ForgeFlow at login</span></label></div><button class="button primary" data-action="save-desktop-preferences">Save desktop integration</button></section>
<section class="settings-group"><h2>Encrypted configuration backup</h2><p>Repository mappings, servers, deployment profiles and preferences are encrypted. Tokens, passwords, passphrases and operation history are never exported.</p><div class="inline-form"><input id="backup-passphrase" class="input" type="password" minlength="12" placeholder="Passphrase of at least 12 characters"/><button class="button" data-action="export-config-backup">Export</button><button class="button" data-action="import-config-backup">Import</button></div></section>
<section class="settings-group"><h2>Appearance</h2><div class="field"><label for="appearance-select">Color theme</label><select id="appearance-select" class="select"><option value="dark" ${state.appearance === "dark" ? "selected" : ""}>Dark</option><option value="light" ${state.appearance === "light" ? "selected" : ""}>Light</option><option value="system" ${state.appearance === "system" ? "selected" : ""}>Follow system</option></select></div></section>
+4
View File
@@ -8,6 +8,10 @@ function normalizeBaseUrl(value) {
const url = new URL(raw);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported.');
if (url.username || url.password) throw new Error('Do not include credentials in the Gitea URL.');
const loopback = new Set(['localhost', '127.0.0.1', '[::1]']);
if (url.protocol !== 'https:' && !loopback.has(url.hostname.toLowerCase())) {
throw new Error('Gitea must use HTTPS so access tokens are never sent over plaintext HTTP. Loopback HTTP is allowed for local development only.');
}
url.hash = '';
url.search = '';
return url.toString().replace(/\/$/, '');
+2
View File
@@ -110,6 +110,8 @@ test("configuration mutations persist mappings, favorites, reviews, trends, oper
assert.equal(state.preferences.preferredCloneProtocol, "https");
assert.equal(state.preferences.diagnosticLevel, "info");
assert.equal(state.preferences.maxLogFileMb, 50);
const manualRemoteAwareness = await store.setPreferences({ fetchIntervalMinutes: 0 });
assert.equal(manualRemoteAwareness.preferences.fetchIntervalMinutes, 0);
await store.removeMapping("owner/app");
assert.equal(store.data.repositoryMappings["owner/app"], undefined);
});
+104
View File
@@ -53,6 +53,35 @@ test('GitService reads changes and commits/pushes selected files to a real bare
assert.equal(remoteLog.stdout.trim(), 'Add desktop cockpit copy');
});
test('untracked diff rendering refuses links outside the repository and oversized files', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-diff-boundary-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const repository = path.join(root, 'repository');
const outside = path.join(root, 'outside');
await fs.mkdir(repository, { recursive: true });
await fs.mkdir(outside, { recursive: true });
await git(['init'], repository);
await fs.writeFile(path.join(outside, 'secret.txt'), 'outside-secret');
try {
await fs.symlink(outside, path.join(repository, 'linked'), process.platform === 'win32' ? 'junction' : 'dir');
} catch {
t.skip('this platform does not allow creating directory links');
return;
}
const service = new GitService();
await assert.rejects(
service.diff(repository, 'linked/secret.txt'),
(error) => error.code === 'DIFF_TARGET_OUTSIDE_REPOSITORY',
);
await fs.writeFile(path.join(repository, 'too-large.txt'), Buffer.alloc(16 * 1024 * 1024 + 1, 0x61));
await assert.rejects(
service.diff(repository, 'too-large.txt'),
(error) => error.code === 'DIFF_FILE_TOO_LARGE' && error.recoverable === true,
);
});
test('stages and pushes deleted and renamed files selected from the working tree', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-git-delete-rename-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
@@ -204,6 +233,81 @@ test('detects and removes a stale HEAD.lock while skipping Git object storage',
assert.ok(await fs.stat(ignoredObjectLock));
});
test('previews and safely mirrors a workspace to Gitea while preserving every class of local work', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-workspace-sync-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const remote = path.join(root, 'remote.git');
const working = path.join(root, 'working');
const external = path.join(root, 'external');
await git(['init', '--bare', remote], root);
await git(['clone', remote, working], root);
await git(['config', 'user.name', 'ForgeFlow Test'], working);
await git(['config', 'user.email', 'forgeflow@example.invalid'], working);
await fs.writeFile(path.join(working, '.gitignore'), 'runtime/\n');
await fs.writeFile(path.join(working, 'README.md'), 'initial\n');
await fs.writeFile(path.join(working, 'obsolete.txt'), 'remove remotely\n');
await git(['add', '.'], working);
await git(['commit', '-m', 'Initial'], working);
await git(['branch', '-M', 'main'], working);
await git(['push', '-u', 'origin', 'main'], working);
await git(['clone', remote, external], root);
await git(['config', 'user.name', 'External Gitea Test'], external);
await git(['config', 'user.email', 'external@example.invalid'], external);
await git(['checkout', 'main'], external);
await fs.writeFile(path.join(external, 'README.md'), 'changed on Gitea\n');
await fs.rm(path.join(external, 'obsolete.txt'));
await fs.writeFile(path.join(external, 'remote-only.txt'), 'new on Gitea\n');
await git(['add', '-A'], external);
await git(['commit', '-m', 'External cleanup'], external);
await git(['push', 'origin', 'main'], external);
await fs.writeFile(path.join(working, 'local-commit.txt'), 'local committed work\n');
await git(['add', 'local-commit.txt'], working);
await git(['commit', '-m', 'Local Codex work'], working);
const localHead = (await git(['rev-parse', 'HEAD'], working)).stdout.trim();
await fs.appendFile(path.join(working, 'README.md'), 'local uncommitted edit\n');
await fs.writeFile(path.join(working, 'local-notes.txt'), 'untracked local notes\n');
await fs.mkdir(path.join(working, 'runtime'), { recursive: true });
await fs.writeFile(path.join(working, 'runtime', 'local.db'), 'ignored runtime state\n');
const service = new GitService();
const firstPlan = await service.previewWorkspaceSync(working);
assert.match(firstPlan.id, /^[0-9a-f]{64}$/);
assert.equal(firstPlan.summary.localCommitsToProtect, 1);
assert.equal(firstPlan.summary.incomingCommits, 1);
assert.equal(firstPlan.summary.localFilesToStash, 2);
assert.equal(firstPlan.summary.untrackedFilesToStash, 1);
assert.ok(firstPlan.changes.some((item) => item.path === 'obsolete.txt' && item.code === 'D'));
assert.equal(firstPlan.recovery.ignoredFilesPreserved, true);
await fs.writeFile(path.join(working, 'changed-after-preview.txt'), 'forces a stale plan\n');
await assert.rejects(
service.synchronizeWorkspace(working, firstPlan.id),
(error) => error.code === 'WORKSPACE_SYNC_PLAN_STALE'
);
assert.equal(await fs.readFile(path.join(working, 'changed-after-preview.txt'), 'utf8'), 'forces a stale plan\n');
const reviewedPlan = await service.previewWorkspaceSync(working);
const result = await service.synchronizeWorkspace(working, reviewedPlan.id);
assert.equal(result.applied, true);
assert.equal(result.status.clean, true);
assert.equal(result.status.head, reviewedPlan.targetSha);
assert.match(result.backupBranch, /^forgeflow\/recovery-main-/);
assert.ok(result.stash?.sha);
assert.equal((await git(['rev-parse', result.backupBranch], working)).stdout.trim(), localHead);
assert.equal((await fs.readFile(path.join(working, 'README.md'), 'utf8')).replace(/\r\n/g, '\n'), 'changed on Gitea\n');
assert.equal((await fs.readFile(path.join(working, 'remote-only.txt'), 'utf8')).replace(/\r\n/g, '\n'), 'new on Gitea\n');
await assert.rejects(fs.stat(path.join(working, 'obsolete.txt')), (error) => error.code === 'ENOENT');
await assert.rejects(fs.stat(path.join(working, 'local-commit.txt')), (error) => error.code === 'ENOENT');
await assert.rejects(fs.stat(path.join(working, 'local-notes.txt')), (error) => error.code === 'ENOENT');
assert.equal(await fs.readFile(path.join(working, 'runtime', 'local.db'), 'utf8'), 'ignored runtime state\n');
const stashedPaths = (await git(['stash', 'show', '--include-untracked', '--name-only', result.stash.ref], working)).stdout;
assert.match(stashedPaths, /README\.md/);
assert.match(stashedPaths, /local-notes\.txt/);
assert.match(stashedPaths, /changed-after-preview\.txt/);
});
test('repairs a diverged branch by creating a safety branch before resetting to upstream', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-diverged-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
+43
View File
@@ -121,6 +121,49 @@ test("repository troubleshooting offers personalized synchronization repair acti
assert.match(ipc, /repository:repair-sync/);
});
test("Gitea workspace sync is preview-driven, recoverable and never deletes ignored runtime data", async () => {
const renderer = await rendererSource();
const preload = await readFile(new URL("../preload.cjs", import.meta.url), "utf8");
const ipc = await ipcSource();
assert.match(renderer, /Gitea workspace sync/);
assert.match(renderer, /preview-workspace-sync/);
assert.match(renderer, /confirm-workspace-sync/);
assert.match(renderer, /Ignored runtime files remain in place/);
assert.match(renderer, /recovery branch/);
assert.match(renderer, /named Git stash/);
assert.match(renderer, /Gitea fetch interval/);
assert.match(preload, /previewWorkspaceSync/);
assert.match(preload, /applyWorkspaceSync/);
assert.match(ipc, /repository:workspace-sync-preview/);
assert.match(ipc, /repository:workspace-sync-apply/);
});
test("demo bridge implements the complete Git recovery flow", async () => {
const source = await readFile(
new URL("../src/renderer/mock-repository-bridge.js", import.meta.url),
"utf8",
);
for (const method of [
"gitRecoveryStatus",
"reconcileRepository",
"repairGitLocks",
"repairRepositorySync",
]) {
assert.match(source, new RegExp(`async ${method}\\(`));
}
});
test("Git tools rows retain their content height inside the scrollable tab", async () => {
const styles = await readFile(
new URL("../src/renderer/styles.css", import.meta.url),
"utf8",
);
assert.match(
styles,
/\.git-tools-grid\s*\{[^}]*grid-auto-rows:\s*max-content/s,
);
});
test("advanced Git, desktop, backup, policy and audit workflows are exposed in the renderer", async () => {
const renderer = await rendererSource();
const preload = await readFile(
+44
View File
@@ -102,3 +102,47 @@ test('a watched repository is read on filesystem activity instead of on every in
monitor.stop();
assert.equal(monitor.watchers.size, 0, 'stopping releases every watcher');
});
test('background Gitea awareness fetches read-only remote state with bounded concurrency', async () => {
let active = 0;
let peak = 0;
const changes = [];
const git = {
fetch: async (localPath) => {
active += 1;
peak = Math.max(peak, active);
await new Promise((resolve) => setTimeout(resolve, 15));
active -= 1;
return { status: { localPath, revision: 2, branch: { head: 'main', ahead: 0, behind: 1 }, counts: {} } };
},
statusFingerprint: (status) => String(status.revision),
};
const store = { data: { preferences: { autoRefresh: true, repositoryPollSeconds: 2, fetchIntervalMinutes: 1 } } };
const monitor = new RepositoryMonitor({ store, git, onChange: (change) => changes.push(change) });
const paths = Array.from({ length: 6 }, (_, index) => `/repo-${index}`);
monitor.setPaths(paths);
for (const localPath of paths) {
monitor.fingerprints.set(localPath, '1');
monitor.lastFetchedAt.set(localPath, Date.now() - 61_000);
}
await monitor.fetchRemoteUpdates();
assert.equal(peak, 2);
assert.equal(active, 0);
assert.equal(changes.length, paths.length);
assert.ok(changes.every((change) => change.reason === 'remote-state-changed'));
});
test('a zero remote fetch interval disables background network access', async () => {
let fetches = 0;
const git = {
fetch: async () => { fetches += 1; return { status: { revision: 2 } }; },
statusFingerprint: (status) => String(status.revision),
};
const store = { data: { preferences: { autoRefresh: true, repositoryPollSeconds: 2, fetchIntervalMinutes: 0 } } };
const monitor = new RepositoryMonitor({ store, git });
monitor.setPaths(['/repo']);
monitor.lastFetchedAt.set('/repo', 0);
await monitor.fetchRemoteUpdates(Date.now() + 24 * 60 * 60_000);
assert.equal(fetches, 0);
});
+2
View File
@@ -19,6 +19,8 @@ const { redactSecrets } = redaction;
test('rejects credentials embedded in service URLs', () => {
assert.throws(() => normalizeBaseUrl(`https://${['jens', 'secret'].join(':')}@gitea.example.test`), /credentials/i);
assert.throws(() => normalizeBaseUrl('http://gitea.example.test'), /must use HTTPS/i);
assert.equal(normalizeBaseUrl('http://127.0.0.1:3000/'), 'http://127.0.0.1:3000');
assert.throws(() => assertHttpUrl(`https://${['user', 'secret'].join(':')}@app.example.test/health`), /credentials/i);
});
+29
View File
@@ -73,6 +73,35 @@ test("connection options enforce host identity and support password credentials"
const firstUse = await service.connectionOptions({ id: 'one', host: 'server', username: 'root', authType: 'password' }, { trustOnFirstUse: true });
assert.equal(firstUse.options.port, 22);
assert.equal(firstUse.options.hostVerifier(key), true);
const previewBound = await service.connectionOptions(
{ id: 'one', host: 'server', username: 'root', authType: 'password' },
{ expectedFingerprint: fingerprint },
);
assert.equal(previewBound.options.hostVerifier(key), true);
assert.equal(previewBound.options.hostVerifier(Buffer.from('changed')), false);
});
test("SSH host fingerprint preview rejects the handshake before credentials are requested", async () => {
const key = Buffer.from("untrusted-server-key");
let connectedOptions = null;
class ProbeClient extends EventEmitter {
connect(options) {
connectedOptions = options;
assert.equal(options.hostVerifier(key), false);
queueMicrotask(() => this.emit("error", Object.assign(new Error("host rejected"), { code: "HOST_VERIFIER_REJECTED" })));
}
end() {}
}
const store = {
getServer: () => ({ id: "server", name: "Unraid", host: "192.0.2.10", port: 2222, username: "root", authType: "password" }),
getServerCredentials: () => { throw new Error("credentials must not be read during a fingerprint preview"); },
};
const service = new SshService({ store, diagnostics: null, clientFactory: () => ProbeClient });
const result = await service.probeHostFingerprint("server");
assert.equal(result.fingerprint, fingerprintKey(key));
assert.deepEqual(result.server, { id: "server", name: "Unraid", host: "192.0.2.10", port: 2222 });
assert.equal("password" in connectedOptions, false);
assert.equal("privateKey" in connectedOptions, false);
});
test("connection options report unreadable private keys without leaking credentials", async () => {
+80
View File
@@ -320,6 +320,86 @@ test("low-level inventory scan is read-only and user discovery auto-links exact
assert.deepEqual(discovery.refreshedProfileIds, [profiles[0].id]);
});
test("a stale deployment link cannot block adoption of its running replacement", async () => {
const repository = {
fullName: "Jens/DevRunBook",
name: "DevRunBook",
defaultBranch: "main",
sshUrl: "git@gitea.test:Jens/DevRunBook.git",
};
const stale = {
workloadId: "old-devrunbook",
status: "stale",
classification: { type: "stale-link" },
runtime: { running: false, health: "missing" },
link: { profileId: "old-profile", repositoryFullName: repository.fullName },
candidates: [{ repositoryFullName: repository.fullName, score: 100, exact: true }],
};
const replacement = {
workloadId: "devrunbook-runtime",
serverId: "unraid",
displayName: "DevRunBook",
status: "suggested",
classification: { type: "active-application" },
runtime: { running: true, health: "healthy" },
link: null,
candidates: [{
repositoryFullName: repository.fullName,
score: 85,
exact: false,
identityExact: true,
}],
compose: {
project: "devrunbook",
workingDir: "/mnt/user/appdata/DevRunBook",
configFiles: ["/mnt/user/appdata/DevRunBook/compose.yml"],
services: ["app"],
},
containers: [{ name: "DevRunBook", running: true, mounts: [], ports: [] }],
metadata: { branch: "main" },
remoteFolderCandidate: "DevRunBook",
};
const saved = [];
const service = new UnraidDeploymentService({
store: {
data: {
deploymentProfiles: {
[repository.fullName]: [{
id: "old-profile",
provider: "ssh-unraid",
serverId: "unraid",
workloadIdentity: { workloadId: stale.workloadId, linkSource: "automatic" },
}],
},
},
createRecoverySnapshot: async () => ({}),
saveDeploymentProfile: async (_fullName, profile) => {
saved.push(profile);
return profile;
},
saveDeploymentState: async () => ({}),
},
});
service.collectServerInventory = async () => ({
server: { id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata" },
inventory: { capabilities: {}, warnings: [] },
workloads: [stale, replacement],
});
const plan = service.reconciliationPlan(
{ id: "unraid" },
[stale, replacement],
[repository],
{ autoLink: true },
);
assert.deepEqual(plan.additions.map((item) => item.workloadId), [replacement.workloadId]);
const result = await service.scanServerInventory("unraid", [repository], { autoLink: true });
assert.equal(result.adopted, 1);
assert.equal(replacement.link.repositoryFullName, repository.fullName);
assert.equal(saved.length, 1);
});
test("server inventory includes stopped DockerMan containers without Git and keeps name matches manual", () => {
+124 -24
View File
@@ -5,7 +5,7 @@ import os from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
import { EventEmitter } from "node:events";
import { createHash } from "node:crypto";
import { createHash, generateKeyPairSync, sign } from "node:crypto";
import { execFile, spawn } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
@@ -14,10 +14,36 @@ const require = createRequire(import.meta.url);
const execFileAsync = promisify(execFile);
const {
UpdateService,
verifyReleaseManifest,
waitForUpdaterStarted,
windowsUpdaterSpawnOptions,
} = require("../src/main/update-service.cjs");
function createSignedReleaseFixture({
version,
remoteSha,
assetName,
binary,
}) {
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
const sha256 = createHash("sha256").update(binary).digest("hex");
const manifest = {
schemaVersion: 1,
product: "ForgeFlow",
version,
tag: `v${version}`,
commit: remoteSha,
buildId: "test-build",
signature: { algorithm: "Ed25519", keyId: "SHA256:test" },
artifacts: [{ name: assetName, bytes: binary.length, sha256 }],
};
const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
const signatureBytes = Buffer.from(
`${sign(null, manifestBytes, privateKey).toString("base64")}\n`,
);
return { publicKey, sha256, manifestBytes, signatureBytes };
}
test("Windows updater uses a hidden non-detached PowerShell child", () => {
assert.deepEqual(windowsUpdaterSpawnOptions("C:\\updates"), {
detached: false,
@@ -324,6 +350,8 @@ test("packaged updater passes Gitea browser download URLs to the asset downloade
);
assert.match(source, /downloadUrl: asset\.browser_download_url/);
assert.match(source, /downloadUrl: checksumAsset\.browser_download_url/);
assert.match(source, /downloadUrl: manifestAsset\.browser_download_url/);
assert.match(source, /downloadUrl: signatureAsset\.browser_download_url/);
assert.match(source, /RELEASE_ASSET_METADATA_RECEIVED/);
});
test("PowerShell helper replaces an existing launching status with a Windows-safe file API", async () => {
@@ -480,15 +508,22 @@ test("updater handshake rejects a stale status from another update request", asy
await rm(temp, { recursive: true, force: true });
});
test("packaged updater downloads only a published checksum-matched Windows asset", async () => {
test("packaged updater downloads only a publisher-signed Windows asset", async () => {
const temp = await mkdtemp(
path.join(os.tmpdir(), "forgeflow-binary-update-"),
);
const binary = Buffer.alloc(1_100_000, 0x5a);
binary[0] = 0x4d;
binary[1] = 0x5a;
const sha256 = createHash("sha256").update(binary).digest("hex");
const assetName = "ForgeFlow-Setup-0.8.2-win-x64.exe";
const remoteSha = "a".repeat(40);
const signed = createSignedReleaseFixture({
version: "0.8.2",
remoteSha,
assetName,
binary,
});
const manifestName = "ForgeFlow-0.8.2-release-manifest.json";
const gitea = {
async getReleaseByTag(_owner, _repo, tag) {
if (tag !== "v0.8.2") return null;
@@ -508,18 +543,32 @@ test("packaged updater downloads only a published checksum-matched Windows asset
id: 42,
browser_download_url: "http://wrong-origin.test/checksum",
},
{
name: manifestName,
id: 43,
browser_download_url: "http://wrong-origin.test/manifest",
},
{
name: `${manifestName}.sig`,
id: 44,
browser_download_url: "http://wrong-origin.test/signature",
},
],
};
},
async downloadReleaseAsset(_owner, _repo, releaseId, assetId, options) {
assert.equal(releaseId, 82);
assert.equal(
options.downloadUrl,
assetId === 42
? "http://wrong-origin.test/checksum"
: "http://wrong-origin.test/setup",
);
return assetId === 42 ? Buffer.from(`${sha256} ${assetName}\n`) : binary;
const downloads = {
41: ["http://wrong-origin.test/setup", binary],
42: [
"http://wrong-origin.test/checksum",
Buffer.from(`${signed.sha256} ${assetName}\n`),
],
43: ["http://wrong-origin.test/manifest", signed.manifestBytes],
44: ["http://wrong-origin.test/signature", signed.signatureBytes],
};
assert.equal(options.downloadUrl, downloads[assetId][0]);
return downloads[assetId][1];
},
};
const service = new UpdateService({
@@ -537,14 +586,17 @@ test("packaged updater downloads only a published checksum-matched Windows asset
sourcePath: temp,
userDataPath: temp,
platform: "win32",
updatePublicKey: signed.publicKey,
});
const result = await service.downloadPackaged({
owner: "Jens",
repo: "ForgeFlow",
remoteVersion: "0.8.2",
remoteSha,
});
assert.equal(result.downloaded, true);
assert.equal(result.sha256, sha256);
assert.equal(result.sha256, signed.sha256);
assert.equal(result.publisherKeyId, "SHA256:test");
assert.equal(result.portable, false);
assert.equal((await readFile(result.binaryPath)).length, binary.length);
await rm(temp, { recursive: true, force: true });
@@ -558,6 +610,14 @@ test("packaged updater rejects a binary whose checksum does not match", async ()
binary[0] = 0x4d;
binary[1] = 0x5a;
const assetName = "ForgeFlow-Portable-0.8.2-win-x64.exe";
const remoteSha = "b".repeat(40);
const signed = createSignedReleaseFixture({
version: "0.8.2",
remoteSha,
assetName,
binary,
});
const manifestName = "ForgeFlow-0.8.2-release-manifest.json";
const service = new UpdateService({
store: { data: { gitea: {} }, save: async () => {} },
gitea: {
@@ -576,14 +636,18 @@ test("packaged updater rejects a binary whose checksum does not match", async ()
name: `${assetName}.sha256`,
browser_download_url: "http://wrong-origin.test/checksum",
},
{ id: 53, name: manifestName },
{ id: 54, name: `${manifestName}.sig` },
],
};
},
async downloadReleaseAsset(_owner, _repo, releaseId, assetId) {
assert.equal(releaseId, 83);
return assetId === 52
? Buffer.from(`${"0".repeat(64)} ${assetName}`)
: binary;
if (assetId === 51) return binary;
if (assetId === 52)
return Buffer.from(`${"0".repeat(64)} ${assetName}`);
if (assetId === 53) return signed.manifestBytes;
return signed.signatureBytes;
},
},
diagnostics: null,
@@ -595,6 +659,7 @@ test("packaged updater rejects a binary whose checksum does not match", async ()
sourcePath: temp,
userDataPath: temp,
platform: "win32",
updatePublicKey: signed.publicKey,
});
await assert.rejects(
() =>
@@ -602,17 +667,45 @@ test("packaged updater rejects a binary whose checksum does not match", async ()
owner: "Jens",
repo: "ForgeFlow",
remoteVersion: "0.8.2",
remoteSha,
}),
/SHA-256 verification/,
/does not match the signed publisher manifest/,
);
await rm(temp, { recursive: true, force: true });
});
test("Windows release pipeline preserves optional signing checks and emits provenance plus SBOM", async () => {
const [pkgSource, signatureSource, checksumSource] = await Promise.all([
test("release manifest verification rejects a different publisher key", () => {
const binary = Buffer.alloc(1_100_000, 0x5a);
const assetName = "ForgeFlow-Setup-0.8.2-win-x64.exe";
const fixture = createSignedReleaseFixture({
version: "0.8.2",
remoteSha: "c".repeat(40),
assetName,
binary,
});
const otherKey = generateKeyPairSync("ed25519").publicKey;
assert.throws(
() =>
verifyReleaseManifest({
manifestBytes: fixture.manifestBytes,
signatureBytes: fixture.signatureBytes,
publicKey: otherKey,
update: {
remoteVersion: "0.8.2",
remoteSha: "c".repeat(40),
},
assetName,
}),
(error) => error.code === "RELEASE_SIGNATURE_INVALID",
);
});
test("Windows release pipeline emits signed provenance, manifest and SBOM evidence", async () => {
const [pkgSource, signatureSource, checksumSource, manifestSigner] = await Promise.all([
readFile(new URL("../package.json", import.meta.url), "utf8"),
readFile(new URL("../scripts/verify-release-signatures.mjs", import.meta.url), "utf8"),
readFile(new URL("../scripts/write-release-checksums.mjs", import.meta.url), "utf8"),
readFile(new URL("../scripts/sign-release-manifest.mjs", import.meta.url), "utf8"),
]);
assert.match(pkgSource, /verify-release-signatures\.mjs/);
assert.match(signatureSource, /FORGEFLOW_SIGNED_RELEASE/);
@@ -623,6 +716,10 @@ test("Windows release pipeline preserves optional signing checks and emits prove
assert.match(checksumSource, /provenance\.json/);
assert.match(checksumSource, /sbom\.cdx\.json/);
assert.match(checksumSource, /CycloneDX/);
assert.match(checksumSource, /publisherManifestSignature/);
assert.match(manifestSigner, /Ed25519/);
assert.match(manifestSigner, /release-manifest\.json/);
assert.match(pkgSource, /sign-release-manifest\.mjs/);
const publisher = await readFile(new URL("../scripts/publish-binary-release.cjs", import.meta.url), "utf8");
assert.match(publisher, /draft: true/);
assert.match(publisher, /requiredAssets/);
@@ -630,17 +727,20 @@ test("Windows release pipeline preserves optional signing checks and emits prove
assert.match(publisher, /sbom\.cdx\.json/);
});
test("the supported Windows build is free, checksum-protected and updater-compatible", async () => {
const [pkg, signatureCheck, checksumWriter] = await Promise.all([
test("the supported Windows build uses free offline Ed25519 publisher signing", async () => {
const [pkg, keySetup, manifestSigner, publicKey] = await Promise.all([
readFile(new URL("../package.json", import.meta.url), "utf8"),
readFile(new URL("../scripts/verify-release-signatures.mjs", import.meta.url), "utf8"),
readFile(new URL("../scripts/write-release-checksums.mjs", import.meta.url), "utf8"),
readFile(new URL("../scripts/setup-update-signing-key.mjs", import.meta.url), "utf8"),
readFile(new URL("../scripts/sign-release-manifest.mjs", import.meta.url), "utf8"),
readFile(new URL("../build/update-signing-public.pem", import.meta.url), "utf8"),
]);
assert.doesNotMatch(pkg, /dist:win:signed/);
assert.match(pkg, /dist:win/);
assert.match(pkg, /write-release-checksums\.mjs/);
assert.match(signatureCheck, /checksum-protected unsigned artifact/);
assert.match(checksumWriter, /sha256/);
assert.match(pkg, /signing:setup/);
assert.match(keySetup, /release-signing-private\.pem/);
assert.match(manifestSigner, /sign\(null, manifestBytes, privateKey\)/);
assert.match(publicKey, /BEGIN PUBLIC KEY/);
assert.doesNotMatch(publicKey, /PRIVATE KEY/);
});
test("binary update helper verifies, waits, applies and records restart state", async () => {