diff --git a/Publish-ForgeFlow-Release.ps1 b/Publish-ForgeFlow-Release.ps1 index 888eec8..689988a 100644 --- a/Publish-ForgeFlow-Release.ps1 +++ b/Publish-ForgeFlow-Release.ps1 @@ -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"))) { diff --git a/Publish-Missing-Binary-Release.ps1 b/Publish-Missing-Binary-Release.ps1 index 53739c6..9efb7c3 100644 --- a/Publish-Missing-Binary-Release.ps1 +++ b/Publish-Missing-Binary-Release.ps1 @@ -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" diff --git a/README.md b/README.md index 9e6d846..9cc6703 100644 --- a/README.md +++ b/README.md @@ -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. | diff --git a/SOURCE_MANIFEST.txt b/SOURCE_MANIFEST.txt index cc2294e..b56c88a 100644 --- a/SOURCE_MANIFEST.txt +++ b/SOURCE_MANIFEST.txt @@ -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 diff --git a/build/update-signing-public.pem b/build/update-signing-public.pem new file mode 100644 index 0000000..d3061e9 --- /dev/null +++ b/build/update-signing-public.pem @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEApGKe81NzC5mU3jfMNAQUnAOfQnCnMFry8cNpmjQsdtE= +-----END PUBLIC KEY----- diff --git a/docs/CURRENT_STATE.md b/docs/CURRENT_STATE.md index 51d0b27..4bcbe78 100644 --- a/docs/CURRENT_STATE.md +++ b/docs/CURRENT_STATE.md @@ -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. diff --git a/docs/RELEASE_NOTES_0.10.13.md b/docs/RELEASE_NOTES_0.10.13.md new file mode 100644 index 0000000..0f894b2 --- /dev/null +++ b/docs/RELEASE_NOTES_0.10.13.md @@ -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, 100–150% schaal en reduced motion. +- Windows installer en portable build, SHA-256-sidecars, provenance, CycloneDX-SBOM en ondertekend releasemanifest. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 0a755d0..fc9f4bc 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -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: diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 92bd0bf..df68e9e 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -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. diff --git a/docs/UPDATING.md b/docs/UPDATING.md index f544567..c85ada8 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -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--win-x64.exe` - `ForgeFlow-Setup--win-x64.exe.sha256` - `ForgeFlow-Portable--win-x64.exe` - `ForgeFlow-Portable--win-x64.exe.sha256` +- `ForgeFlow--provenance.json` +- `ForgeFlow--sbom.cdx.json` +- `ForgeFlow--release-manifest.json` +- `ForgeFlow--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. diff --git a/package-lock.json b/package-lock.json index 851cc91..71fcbfa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" }, diff --git a/package.json b/package.json index 28fd46b..2695941 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/preload.cjs b/preload.cjs index 17ae4af..676c317 100644 --- a/preload.cjs +++ b/preload.cjs @@ -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 }), diff --git a/reports/architecture-audit.json b/reports/architecture-audit.json index 6f001af..8ef89e3 100644 --- a/reports/architecture-audit.json +++ b/reports/architecture-audit.json @@ -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" diff --git a/reports/architecture-audit.md b/reports/architecture-audit.md index 0de4932..3a2202d 100644 --- a/reports/architecture-audit.md +++ b/reports/architecture-audit.md @@ -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 diff --git a/scripts/publish-binary-release.cjs b/scripts/publish-binary-release.cjs index ad12304..b8cc6b2 100644 --- a/scripts/publish-binary-release.cjs +++ b/scripts/publish-binary-release.cjs @@ -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(", ")}`); diff --git a/scripts/setup-update-signing-key.mjs b/scripts/setup-update-signing-key.mjs new file mode 100644 index 0000000..8587d6e --- /dev/null +++ b/scripts/setup-update-signing-key.mjs @@ -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}`); diff --git a/scripts/sign-release-manifest.mjs b/scripts/sign-release-manifest.mjs new file mode 100644 index 0000000..8499132 --- /dev/null +++ b/scripts/sign-release-manifest.mjs @@ -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}`); diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 89fc3ff..18b036f 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -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}`); diff --git a/scripts/write-release-checksums.mjs b/scripts/write-release-checksums.mjs index d5838e0..39d1e0c 100644 --- a/scripts/write-release-checksums.mjs +++ b/scripts/write-release-checksums.mjs @@ -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)); diff --git a/src/main/config-store.cjs b/src/main/config-store.cjs index e29ec9e..8b0f397 100644 --- a/src/main/config-store.cjs +++ b/src/main/config-store.cjs @@ -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; diff --git a/src/main/diagnostics-service.cjs b/src/main/diagnostics-service.cjs index 6167076..ff88277 100644 --- a/src/main/diagnostics-service.cjs +++ b/src/main/diagnostics-service.cjs @@ -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, diff --git a/src/main/git-service.cjs b/src/main/git-service.cjs index 966e580..d1912ae 100644 --- a/src/main/git-service.cjs +++ b/src/main/git-service.cjs @@ -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; diff --git a/src/main/ipc.cjs b/src/main/ipc.cjs index 2050aed..10620e0 100644 --- a/src/main/ipc.cjs +++ b/src/main/ipc.cjs @@ -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 }, {}, diff --git a/src/main/ipc/repository-handlers.cjs b/src/main/ipc/repository-handlers.cjs index 3f93850..aeec63e 100644 --- a/src/main/ipc/repository-handlers.cjs +++ b/src/main/ipc/repository-handlers.cjs @@ -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, () => diff --git a/src/main/repository-monitor.cjs b/src/main/repository-monitor.cjs index 9528e6e..a319c10 100644 --- a/src/main/repository-monitor.cjs +++ b/src/main/repository-monitor.cjs @@ -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); diff --git a/src/main/ssh-service.cjs b/src/main/ssh-service.cjs index 624678e..45c1fe8 100644 --- a/src/main/ssh-service.cjs +++ b/src/main/ssh-service.cjs @@ -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 = {}) { diff --git a/src/main/unraid-inventory-methods.cjs b/src/main/unraid-inventory-methods.cjs index f7d3621..d058fd9 100644 --- a/src/main/unraid-inventory-methods.cjs +++ b/src/main/unraid-inventory-methods.cjs @@ -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 = []; diff --git a/src/main/update-service.cjs b/src/main/update-service.cjs index bcff3db..2c70b79 100644 --- a/src/main/update-service.cjs +++ b/src/main/update-service.cjs @@ -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, diff --git a/src/renderer/actions/deployment-profile.js b/src/renderer/actions/deployment-profile.js index 3d3eb4b..49dfd92 100644 --- a/src/renderer/actions/deployment-profile.js +++ b/src/renderer/actions/deployment-profile.js @@ -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") { diff --git a/src/renderer/actions/recovery.js b/src/renderer/actions/recovery.js index 92cf111..58dde35 100644 --- a/src/renderer/actions/recovery.js +++ b/src/renderer/actions/recovery.js @@ -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; diff --git a/src/renderer/actions/setup-and-settings.js b/src/renderer/actions/setup-and-settings.js index 99906bf..339c3f8 100644 --- a/src/renderer/actions/setup-and-settings.js +++ b/src/renderer/actions/setup-and-settings.js @@ -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, }; diff --git a/src/renderer/app.js b/src/renderer/app.js index b38fab2..ef24e01 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -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(); diff --git a/src/renderer/dialogs.js b/src/renderer/dialogs.js index 4ef50f1..4a1be1e 100644 --- a/src/renderer/dialogs.js +++ b/src/renderer/dialogs.js @@ -44,6 +44,20 @@ function renderModal() { ]; return ``; } + 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) => `
${escapeHtml(change.path)}${change.originalPath ? `${escapeHtml(change.originalPath)} → ` : ""}${escapeHtml(change.status)}
${escapeHtml(change.code)}
`).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 ``; + } if (ui.modal.type === "workload-link") { const serverResult = (ui.serverDiscovery || []).find( (item) => item.serverId === ui.modal.serverId, diff --git a/src/renderer/mock-repository-bridge.js b/src/renderer/mock-repository-bridge.js index 15c0ce6..b130d7c 100644 --- a/src/renderer/mock-repository-bridge.js +++ b/src/renderer/mock-repository-bridge.js @@ -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 }; }, diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 78b93b0..3ebdccb 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -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; } diff --git a/src/renderer/views.js b/src/renderer/views.js index ffe5740..b1186c1 100644 --- a/src/renderer/views.js +++ b/src/renderer/views.js @@ -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" : ""}`)} `; @@ -361,7 +361,28 @@ function renderGitTools(repository) { const locks = recovery?.lockReport?.locks || []; const activeProcesses = recovery?.lockReport?.processes?.active || []; const recommendations = recovery?.recommendations || []; - return `

Branches

${ui.branches.length ? ui.branches.map((branch) => `
${escapeHtml(branch.name)}${escapeHtml(branch.shortSha)}${branch.upstream ? ` · ${escapeHtml(branch.upstream)}` : " · unpublished"}
${branch.current ? 'Current' : ``}
`).join("") : '

Load branch information.

'}

Stashes

${ui.stashes.length ? ui.stashes.map((stash) => `
${escapeHtml(stash.ref)}${escapeHtml(stash.subject)} · ${formatDate(stash.date)}
`).join("") : '

No stashes, or Git tools have not been loaded.

'}

Repository troubleshooting

Safe, repository-specific recovery actions
${recovery ? `
${locks.length ? `${locks.length} lock${locks.length === 1 ? "" : "s"}` : "No Git locks"}${activeProcesses.length ? `${activeProcesses.length} active Git process(es)` : "No matching active Git process detected"}
${locks.length ? `
${locks.map((lock) => `
${escapeHtml(lock.name)}${Math.round(lock.ageMs / 1000)}s old · ${escapeHtml(lock.modifiedAt)}
`).join("")}
` : ""}${recommendations.length ? `
${recommendations.map((item) => `
${escapeHtml(item.label)}${item.safe ? "Safe automated action" : item.action ? "Creates a safety branch before changing history" : "Review required"}
${item.action ? `` : ""}
`).join("")}
` : ""}` : '

Scan before repairing. ForgeFlow checks every .lock file in the actual Git directory, not only index.lock.

'}
${repository.sshUrl && repository.localStatus?.remoteUrl !== repository.sshUrl ? `` : ""}
Lock repair refuses to run while a matching Git process is active. A force option is shown only when process detection itself is unavailable.
`; + const status = repository.localStatus || {}; + const branchRows = ui.branches.length + ? ui.branches.map((branch) => `
${escapeHtml(branch.name)}${escapeHtml(branch.shortSha)}${branch.upstream ? ` · ${escapeHtml(branch.upstream)}` : " · unpublished"}
${branch.current ? 'Current' : ``}
`).join("") + : '

Load branch information.

'; + const stashRows = ui.stashes.length + ? ui.stashes.map((stash) => `
${escapeHtml(stash.ref)}${escapeHtml(stash.subject)} · ${formatDate(stash.date)}
`).join("") + : '

No stashes, or Git tools have not been loaded.

'; + const recoveryBody = recovery + ? `
${locks.length ? `${locks.length} lock${locks.length === 1 ? "" : "s"}` : "No Git locks"}${activeProcesses.length ? `${activeProcesses.length} active Git process(es)` : "No matching active Git process detected"}
${locks.length ? `
${locks.map((lock) => `
${escapeHtml(lock.name)}${Math.round(lock.ageMs / 1000)}s old · ${escapeHtml(lock.modifiedAt)}
`).join("")}
` : ""}${recommendations.length ? `
${recommendations.map((item) => `
${escapeHtml(item.label)}${item.safe ? "Safe automated action" : item.action ? "Creates a safety branch before changing history" : "Review required"}
${item.action ? `` : ""}
`).join("")}
` : ""}` + : '

Scan before repairing. ForgeFlow checks every .lock file in the actual Git directory, not only index.lock.

'; + 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 `
+

Branches

${branchRows}
+

Stashes

${stashRows}
+

Gitea workspace sync

Make tracked files match the current upstream branch exactly
${escapeHtml(syncState)}

Safe mirror, never silent overwrite

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.

${icon("shield")}Ignored runtime data such as .env, dependency folders and local databases is preserved. Background awareness only fetches; it never applies this sync automatically.
${escapeHtml(status.branch?.head || "No branch")} → ${escapeHtml(status.branch?.upstream || "No upstream")}
+

Repository troubleshooting

Safe, repository-specific recovery actions
${recoveryBody}
${repository.sshUrl && status.remoteUrl !== repository.sshUrl ? `` : ""}
Lock repair refuses to run while a matching Git process is active. A force option is shown only when process detection itself is unavailable.
+
`; } 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 `

${escapeHtml(server.serverName || server.server?.name || server.serverId)}

${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` : ""}
${server.error ? "Scan failed" : escapeHtml(capabilityText)}${server.error ? "" : ``}
${errorBlock}${warnings}
${workloads}
`; + return `

${escapeHtml(server.serverName || server.server?.name || server.serverId)}

${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` : ""}
${server.error ? "Scan failed" : escapeHtml(capabilityText)}${server.error ? "" : ``}
${errorBlock}${warnings}
${workloads}
`; }).join(""); const empty = configuredServers.length ? `

Server inventory has not completed

ForgeFlow will query Docker directly. A failed connection is shown explicitly instead of being reported as zero deployments.

` @@ -602,10 +623,10 @@ function renderSettings() { return `

Gitea connection

${state.gitea.hasToken ? `Connected as ${escapeHtml(state.gitea.user?.login || "user")}` : "Not connected"}
${escapeHtml(state.gitea.baseUrl || "No Gitea instance configured")}

ForgeFlow updates

Secure source update from ${escapeHtml(state.updates?.owner || "Jens")}/${escapeHtml(state.updates?.repo || "ForgeFlow")}
${icon(update?.available ? "download" : "check")}${update ? (update.available ? `ForgeFlow ${escapeHtml(update.remoteVersion)} is available` : `ForgeFlow ${escapeHtml(update.currentVersion)} is up to date`) : `Current version ${escapeHtml(ui.boot.appVersion)}`}${update ? `Branch ${escapeHtml(update.branch)} · commit ${escapeHtml(update.shortSha)} · checked ${formatDate(update.checkedAt)}` : "No update check in this session."}
${update?.available && !update.downloaded ? `` : ""}${update?.downloaded ? `` : ""}
${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.
-

SSH / Unraid servers

Credentials are entered locally and encrypted with the Windows credential protection used by Electron.
${servers.length ? `
${servers.map((server) => `
${icon("server")}
${escapeHtml(server.name)}${escapeHtml(server.username)}@${escapeHtml(server.host)}:${escapeHtml(server.port)} · ${escapeHtml(server.basePath)}${server.hostFingerprint ? `Trusted ${escapeHtml(server.hostFingerprint)}` : "Host identity not trusted yet"}
`).join("")}
` : '

No SSH server configured. Add your Unraid server before creating an SSH deployment profile.

'}
+

SSH / Unraid servers

Credentials are encrypted locally; a new host fingerprint is shown before authentication.
${servers.length ? `
${servers.map((server) => `
${icon("server")}
${escapeHtml(server.name)}${escapeHtml(server.username)}@${escapeHtml(server.host)}:${escapeHtml(server.port)} · ${escapeHtml(server.basePath)}${server.hostFingerprint ? `Trusted ${escapeHtml(server.hostFingerprint)}` : "Host identity not trusted yet"}
`).join("")}
` : '

No SSH server configured. Add your Unraid server before creating an SSH deployment profile.

'}

Git remote maintenance

Standardize linked repositories to the current Gitea SSH URLs.

This replaces legacy aliases and renamed owners only after an explicit click. Local commits and files are not changed.

Project roots

The first folder is the default clone destination. ForgeFlow automatically creates one subfolder per repository.

${state.workspaceRoots.map((root, index) => `
${index === 0 ? 'Default' : ""}
`).join("")}
-

Background awareness

+

Background awareness

Read-only remote awareness. Use 0 to disable; fetching never changes project files.
${icon("shield")}Remote awareness only fetches branch metadata. ForgeFlow never resets, cleans or overwrites a workspace in the background.

Desktop integration

Separate arguments with |. Placeholders: {path}, {file}, {line}

Encrypted configuration backup

Repository mappings, servers, deployment profiles and preferences are encrypted. Tokens, passwords, passphrases and operation history are never exported.

Appearance

diff --git a/src/shared/validation.cjs b/src/shared/validation.cjs index be5c1ed..f40d608 100644 --- a/src/shared/validation.cjs +++ b/src/shared/validation.cjs @@ -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(/\/$/, ''); diff --git a/tests/config-store.test.mjs b/tests/config-store.test.mjs index 2bbe194..2a71323 100644 --- a/tests/config-store.test.mjs +++ b/tests/config-store.test.mjs @@ -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); }); diff --git a/tests/git-integration.test.mjs b/tests/git-integration.test.mjs index b8a488e..40a886b 100644 --- a/tests/git-integration.test.mjs +++ b/tests/git-integration.test.mjs @@ -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 })); diff --git a/tests/renderer-workflow.test.mjs b/tests/renderer-workflow.test.mjs index acc4296..2f98018 100644 --- a/tests/renderer-workflow.test.mjs +++ b/tests/renderer-workflow.test.mjs @@ -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( diff --git a/tests/repository-monitor.test.mjs b/tests/repository-monitor.test.mjs index 8d89ffb..52062e1 100644 --- a/tests/repository-monitor.test.mjs +++ b/tests/repository-monitor.test.mjs @@ -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); +}); diff --git a/tests/security-validation.test.mjs b/tests/security-validation.test.mjs index f7baa57..0f816f8 100644 --- a/tests/security-validation.test.mjs +++ b/tests/security-validation.test.mjs @@ -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); }); diff --git a/tests/ssh-service.test.mjs b/tests/ssh-service.test.mjs index a1bab2b..c6774f7 100644 --- a/tests/ssh-service.test.mjs +++ b/tests/ssh-service.test.mjs @@ -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 () => { diff --git a/tests/unraid-deployment.test.mjs b/tests/unraid-deployment.test.mjs index 185d311..31f8863 100644 --- a/tests/unraid-deployment.test.mjs +++ b/tests/unraid-deployment.test.mjs @@ -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", () => { diff --git a/tests/update-service.test.mjs b/tests/update-service.test.mjs index 2b8f6d0..f0609ed 100644 --- a/tests/update-service.test.mjs +++ b/tests/update-service.test.mjs @@ -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 () => {