9 Commits
Author SHA1 Message Date
NuklearRabbit 8fa4891075 chore: release ForgeFlow 0.10.5
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-01 17:32:40 +02:00
NuklearRabbit 58d361bbab fix: align deployment links across repository views
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-01 17:05:03 +02:00
NuklearRabbit acad1f8932 fix: make packaged updater handshake Windows-safe
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-01 15:09:47 +02:00
NuklearRabbit 44aa452a76 fix: tolerate unavailable unsigned signature inspection
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-01 14:35:59 +02:00
NuklearRabbit a0435f4316 chore: release ForgeFlow 0.10.3
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-01 14:33:08 +02:00
NuklearRabbit 13f4fe7cd0 perf: reduce renderer and repository polling work
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-01 14:24:53 +02:00
NuklearRabbit 258f0b1324 fix: restore scrolling and validator enforcement
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-01 12:44:31 +02:00
NuklearRabbit f8c505e525 docs: refresh user guide and screenshots
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-07-30 02:02:18 +02:00
NuklearRabbit 398f986d95 fix: repair packaged release downloads
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-07-30 00:58:34 +02:00
36 changed files with 694 additions and 231 deletions
+18 -8
View File
@@ -2,9 +2,9 @@
**Van lokale wijziging naar aantoonbaar juiste serverversie — zonder de Git- en deploymentcontext over verschillende tools te verspreiden.**
ForgeFlow is een desktopapp voor teams die met Git, Gitea en eigen servers werken. De app toont wat lokaal gewijzigd is, wat al op Gitea staat en welke exacte commit op de server draait. Daarna begeleidt ForgeFlow je door review, commit, push, deployment en verificatie.
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.1** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest)
> Huidige release: **0.10.5** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest)
![ForgeFlow release-overzicht](docs/screenshots/overview.png)
@@ -13,7 +13,7 @@ ForgeFlow is een desktopapp voor teams die met Git, Gitea en eigen servers werke
- **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.
- **Deployment op een exacte commit:** ForgeFlow gebruikt volledige commit-SHA's en toont lokaal, Gitea en server naast elkaar.
- **Volledige serverinventaris:** zie ook gestopte, DockerMan- en niet-Git-installaties, koppel twijfelgevallen handmatig en behoud hun bestaande Compose-identiteit.
- **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.
- **Ingebouwde Git Validator:** controleer repository-identiteit, branch protection, synchronisatie-instellingen, documentatie, geheimen en grote bestanden; veilige verbeteringen kunnen gericht worden toegepast.
- **Lokale controle:** configuratie en credentials blijven op het toestel en diagnostische exports worden lokaal geredigeerd.
@@ -26,9 +26,9 @@ ForgeFlow is een desktopapp voor teams die met Git, Gitea en eigen servers werke
2. Download de Windows-installer of portable executable.
3. Start ForgeFlow en doorloop de setupwizard.
4. Voeg je Gitea-server, token en lokale projectmappen toe.
5. Configureer optioneel een serververbinding en één of meer deploymentprofielen.
5. Voeg optioneel een Docker- of Unraid-server toe. Start daarna **Scan servers** om bestaande deployments te ontdekken en veilig aan repositories te koppelen.
Na installatie kun je nieuwe packaged releases vanuit **Settings → Updates** ophalen. Downloads worden tegen de gepubliceerde SHA-256-checksums gecontroleerd. Zie [UPDATING.md](docs/UPDATING.md) wanneer een oudere of source-only build nog niet binair kan updaten.
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.
### Eerst vrijblijvend bekijken
@@ -57,9 +57,18 @@ In de repositorywerkruimte zie je de volledige keten **Local → Gitea → Serve
![Deploymentsoverzicht met herkenbare containerkaarten](docs/screenshots/deployments.png)
Elke deploymentkaart benoemt repository, container, omgeving, uitvoeringsmethode, live commit, Gitea-commit, vorige versie en healthstatus. ForgeFlow ondersteunt gecontroleerde deployments via Gitea Actions en SSH/Unraid, met preflightcontrole en rollback waar beschikbaar.
Elke deploymentkaart benoemt repository, container, omgeving, uitvoeringsmethode, live commit, Gitea-commit, vorige versie en healthstatus. Zo blijven ook tientallen containers visueel van elkaar te onderscheiden. ForgeFlow ondersteunt gecontroleerde deployments via Gitea Actions en SSH/Unraid, met preflightcontrole en rollback waar beschikbaar.
Bij server discovery probeert ForgeFlow bestaande containers aan Gitea-repositories te koppelen. Een exacte overeenkomst tussen de volledige live SHA en de actuele Gitea-SHA wordt als gelijklopende versie weergegeven; een runtime-healthcheck blijft een afzonderlijke voorwaarde voor een gezonde deployment.
Bij server discovery vergelijkt ForgeFlow runtime-, Compose-, DockerMan- en Git-bewijs met Gitea. Exact bewezen matches worden automatisch gekoppeld; kandidaten, historische mappen en externe containers worden niet als productie-deployment geforceerd. Een exacte overeenkomst tussen de volledige live SHA en de actuele Gitea-SHA wordt als gelijklopende versie weergegeven. Ontbreekt de live SHA, dan meldt ForgeFlow eerlijk dat verificatie nog onvolledig is.
De belangrijkste statussen zijn:
| Status | Wat je ermee doet |
| --- | --- |
| **Ready** | De repository, servertoegang, live commit en runtime zijn geverifieerd. |
| **Commit mismatch** | De workload is correct gekoppeld, maar Gitea en de server draaien niet dezelfde commit. |
| **Verification incomplete** | De koppeling bestaat, maar de server bevat nog onvoldoende commitbewijs. Een ForgeFlow-beheerde deployment vult dit veilig aan. |
| **Access failed** | Controleer of herstel de repositorygebonden read-only deploy key voordat je deployt. |
### 4. Verbeter de repository met Git Validator
@@ -94,9 +103,10 @@ Een gelijke commit bewijst welke code draait; een geslaagde healthcheck bewijst
- deploymentprofielen per repository en omgeving;
- Gitea Actions en SSH/Unraid als gecontroleerde uitvoeringsroutes;
- serverinventaris van draaiende en gestopte Docker-, Compose- en DockerMan-workloads;
- automatische koppeling op exact bewijs en een handmatige koppelwizard voor twijfelgevallen;
- automatische koppeling op exact bewijs, expliciete review voor echte twijfelgevallen en herkenning van tijdelijke, historische en externe workloads;
- server-pull als aanbevolen route, met een afzonderlijke read-only deploy key per repository;
- directe checksum-gecontroleerde copy als alternatief zonder servertoegang tot Gitea;
- reconciliatie van deployments die buiten ForgeFlow werden bijgewerkt, op basis van de actuele Gitea- en serverwaarheid;
- verificatie op volledige SHA, runtime health en recente serverwaarheid;
- preflight, live logs, deploymenthistoriek en rollback naar de vorige bekende versie.
+36 -32
View File
@@ -1,4 +1,4 @@
ForgeFlow 0.10.1 source manifest
ForgeFlow 0.10.5 source manifest
SHA-256 BYTES PATH
(The manifest excludes itself, dependencies and generated release artifacts.)
cedceb71eb846d99c7c4019031833c1c7f93b84a1c6073aec7d2435dc744ca3d 703 .gitea/workflows/quality.yml
@@ -34,6 +34,10 @@ a0cd06a96f23a94e118feb012be0fa1ac51345951cb2ba8e67fb8c889c4c342a 5007
f79908fb3dad98c38030c6e6be7c79a1999e0478ed9c2496923891954438daa1 4581 docs/RELEASE_AUDIT_0.6.0.md
979a0b8e129979be6b265e8571d0a3c1e9ddd4ddb6b0bf55ae748d3478e51854 2296 docs/RELEASE_NOTES_0.10.0.md
0eb44bda2209a5979a6ac693ac4cd4d235c0015031e54b9e895990f37bf60054 1433 docs/RELEASE_NOTES_0.10.1.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
05ed618f5a74a854363930128ca98939808517eedd28b9a508660a0c46e91d97 884 docs/RELEASE_NOTES_0.10.5.md
a0c00ff76acd1682bb5e0e8dcf6589c9480da436c9c6d30780a1ed58b4dad94f 1770 docs/RELEASE_NOTES_0.2.0.md
5773ead01aa4c522c556295553787482d01b1f5242f053b2c61f120c4de4fa76 5963 docs/RELEASE_NOTES_0.3.0.md
d46de73cf6c4cd5c2ba3f455a7a2af2e0d64ee9d94a97fd1a0bfb44e35c1624a 1093 docs/RELEASE_NOTES_0.3.1.md
@@ -73,10 +77,10 @@ ac76cb50fabde6a00f28d7e9eccd3ef1129a40665eabdc90d78690a38d424652 4195
1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png
b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png
070e6700bdae8c628c907ba181bbf0dde0bbbbb4208f7a875503f933ff1b882e 118819 docs/screenshots/deployment-success.png
581375ee0727911f85b0441f09734c6215ea8dd6cfaba4a7555599df0edb24f1 333382 docs/screenshots/deployments.png
87546583580e8591b1306f997d27445725b0bf5a5a79a839af3a727964e65bc2 103901 docs/screenshots/git-validator.png
bbdbe91679b486cc92dec4758ce1cdaf24e3277d038c57e794a04c0dee7e3a5b 84046 docs/screenshots/overview.png
c8a5e80bb9fd2d442d2d23d30e6ac1528cf2330e6e19492b7c6799e2d1508b53 112868 docs/screenshots/repository-workspace.png
ed69b8beb948a2cf9a6deb6c82368e2bb44ffe8d8990a900dc878b0938d1084f 95937 docs/screenshots/deployments.png
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
32a34ec13a284d3f9ceebbc107b25a844e3db096f8cafa4e43951fc2050c9a03 13552 docs/SETUP_GUIDE.md
2fd71e9bcaeb4cb10c3fa2496b7e52fedf70c5b7f871cd587e22dc060c399079 4421 docs/SSH_UNRAID_DEPLOYMENT.md
@@ -95,19 +99,19 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE
1f0f388df4397e548887bbc7579fd3c864581b86469c01703201ece7a6cbf931 13667 main.cjs
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
d281af8b6fc3fc8a84bca985c6feb13c1edeaa72b5f0144816ea91dd53774a08 179806 package-lock.json
8587168403c255c8acb1bc829629f171598d7697df362836858a0fc89b500b78 5359 package.json
790e0d43b1e4858b72132657bafd83a55c044732cfffbc4ef6e622d74d453a86 179806 package-lock.json
9567d1f28ab27a02361fbe7fce8e17c13558e6c0bfa58763ec27a2b5728dcf42 5512 package.json
2a597a5704c576783b8a72407fbc377fa7506b36a4596ea7f7bce126e394f837 1326 playwright.config.mjs
69318fdf054be7aa2fe86ead9847da9da65745d8d5de548c8346f3ba0afc4892 12175 preload.cjs
abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd
f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1
688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1
75602a7e0ce9744d5fc5a73869eec4b47877e00cfcfea68695373a04f8aa5f31 9150 README.md
1fa7bf646321e07e40d98f4a7529d5f748c600edd5283f62ee13574b4e97280f 14329 reports/architecture-audit.json
c1ff18f1367691332b189bb7589843a5e0bbde4817e3dd29df4ade7ea71dbd52 1114 reports/architecture-audit.md
49a88c060629741cb6e16e597a2d432ce4350d0e1fb4b574802b67d6b872ebe6 10313 README.md
8f8dc5ff0c2967563457e4027d795f6f515253e0a459ecca5a9f5bcf0b658870 14650 reports/architecture-audit.json
5e923f0096895f588fe66b187779cdb7cb5af6644010c6dcc4d55876930ae376 1220 reports/architecture-audit.md
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
00d57bda5af8c8eda294b72d18b318f024a307b81b0d9205a0821f5240151e31 3814 scripts/apply-binary-update.ps1
f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287 scripts/apply-source-update.ps1
8fe616851e6a089a59fd134905465019c33d26f6408ebd0613fff587b6c589e0 4400 scripts/apply-binary-update.ps1
706b49e001837c91134044d3af132d58fb14955245bc1d5c36b413eb65867002 10486 scripts/apply-source-update.ps1
02e924227f6cad3777fd06660230c85df590d8ce95e134194a4d18970a240b88 4145 scripts/architecture-audit.mjs
4490bed84761f76e1fd87ee3117fe53e82e60b7760329c97d68772d6d820ebae 8521 scripts/audit-installed-deployments.cjs
6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs
@@ -117,8 +121,8 @@ b83d443f5724ac15393567f3a688aed8315fbe3e5966832c864a9466e0669464 8102
444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs
c76507857292c5713e1c699cf02e24b80265da39af2cecd148034bdb874adbb6 5246 scripts/test-authenticode-chain.ps1
4393f7dc5f417e6d601a68238f4e26791799a3634acec228fe4d79deaee85eb5 3109 scripts/validate-installed-connections.cjs
50880ac76b7d681dc65019dc794efc3cea4ffd379507fe0518985312f5b39304 2096 scripts/verify-release-signatures.mjs
fb18540c46c0be38bbb0133354838ad75c11187fe6bc0c61860d975fe7761008 17502 scripts/verify.mjs
e6127e1e62f39c70ddb1abf72f4d7e7b8e3f19ff1f219e1a3660353c2e0cdfac 2411 scripts/verify-release-signatures.mjs
b7fc78dfc7c94d62822109cb3c8c2e9566c9506b0a08f6cff8141ee93a04e530 18854 scripts/verify.mjs
0b9f03ba3c67ff7cdb2916a902ad8ce25e81a7c90b210e4ae52d2ad029efabf3 2353 scripts/write-release-checksums.mjs
619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1
dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs
@@ -130,20 +134,20 @@ dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412
c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs
a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs
0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs
b23dfa041d9f4597d144601ab8569e69ba748a0d7a075c3ef01154eacc2640ae 7018 src/main/git-validator-policy.cjs
ca050e9820528b555e6a1dfd89ac8be2f9a0cec6e91254897e6e3b6e116a7eb2 26317 src/main/git-validator-service.cjs
2faaef0eeec1e473db82b94243674e3812ef5869358b0ec6abf8d645a8794623 20768 src/main/gitea-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
00989577aed509a7ddfdf9f4df09a196a393a85b5ea59b21089002215e69f065 25949 src/main/ipc.cjs
0eb1cfdcd3a37a0ec9502bf753966f87230c03580335798bef9265add42ee6fa 12530 src/main/ipc/deployment-handlers.cjs
dc9b5971c9fefe8c374aa31916f5513601ce86003fd48b1d0e51330a909ae3a5 3442 src/main/ipc/operations-handlers.cjs
2e2d0b26480b395906b7881e50b596c9a7701e6e534497d04d541bf3f3b3c057 15673 src/main/ipc/repository-handlers.cjs
629b0f4704cda6ed6288c5a6ec9e5d73d6ae3e2692c43581b258431b65b58e51 15887 src/main/ipc/repository-handlers.cjs
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs
3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs
e64f7257d478955c675a133b3735b6afe138a69d2ad090898061e56f557c43e5 9926 src/main/production-acceptance-harness.cjs
e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508 src/main/repository-monitor.cjs
fc7a5a2a42579a311f60a96bec4776942bf3f63428928820029b0dcf4874d1aa 2745 src/main/repository-monitor.cjs
17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs
52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs
afef3841a3948b2121f8fba809aae4ea3da71bd2fda86973ba50200a5b1f89b2 14894 src/main/ssh-service.cjs
@@ -162,21 +166,21 @@ d0bf607dd1de9d55f2947d0adf0997cd3ca5c269d10a5362cc1d8bc4d1a2a8ae 6706
48bed91dd2a85bb51ee7307f7acc3b79c881ce8cf63b22ba79d5d079b265eb4b 7785 src/renderer/actions/inventory.js
4227a05a20580a31127d2c929640defc3d36e8e3e89d6be830aab1940da81082 12267 src/renderer/actions/recovery.js
cdfaacdcd5ae04b0e5c79fefa21f5e09d5c810bcea504c5b6e1d6b744182ff84 15567 src/renderer/actions/setup-and-settings.js
5d8110918b2957889047e38eb4ab2953b2b4476394d9328bd40ae6e4246e65ef 18584 src/renderer/actions/shell.js
100077a82f14d5252753d017369ecef48ed6d00532166ae5a0b356ead549e02b 24087 src/renderer/app.js
a980f2e86d8286ea605a7259b9e9adcf3fda4f657b8d54a5d2d8765a7bbbec13 19065 src/renderer/actions/shell.js
edcfa0585af4b11ec3b7458969132bdf215830505859f1519f1635dc111ce48a 24333 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
1a577af2459715cf38d49d118da8c5f897a7ff1d3eb42ad41f121675eb871732 48389 src/renderer/dialogs.js
977af9585074f0a404830c5a93de6939b4897d2eb98ba8ae75c3cf0023dea673 5782 src/renderer/events.js
eef2f269ba4fbb76bf66ad328d481b461255d0acb753b30878dd4d4eaac57dc6 6924 src/renderer/events.js
a84da5aecbb16ce7983dba1f6d6aab1bf47b2e9a87c2933fa1afb8123f7ef7d6 1497 src/renderer/index.html
06180d9656dd254edfb6949c397f8e313954fc560ddcb22b3a35fce3c3e35655 21350 src/renderer/mock-bridge.js
4b0a64610da0c446f15a43e4753b26f29de72e07793e2fa7b7322d4f07cf8050 27806 src/renderer/mock-deployment-bridge.js
26065ffa2359cd27b9c9b5b9fb67bcad83ba0f118960c7c024e7e9392dbb16a3 20032 src/renderer/mock-repository-bridge.js
5a2c0616a8a0ba23c182a8928abdf40a69612ce34c4cba47b1fd5f21fe5843d4 28617 src/renderer/mock-deployment-bridge.js
f1d341459689f879b403b3769bca37209afe0f227cc8ac1dad7db7a96c747013 20032 src/renderer/mock-repository-bridge.js
94fa265c2fe9ca8d644f0ce9b620b6f85d9b25dca5802c4e9195b66dcbe80120 6522 src/renderer/operations.js
abe196f5ecdd73e7b6ca67a41c90e55bfc507e084f786227264cc780b1ce83a3 78001 src/renderer/styles.css
1703e64533b7e2717b27c5776296c7dd76331e6f97e8005aea9fd688f1aee3ae 94834 src/renderer/views.js
1fc4c28859f7283aa2b771a50f1fd26addc8862b97a09250f806930ab4483f7a 79874 src/renderer/styles.css
5230c238acb87381303ec30026906cf8a7163c33380859d351b268adfc26b4f4 100196 src/renderer/views.js
0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs
5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs
029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs
@@ -190,7 +194,7 @@ f8853dce6fdf360d5df2fbe2b6df3e5687630c807fee5ba8436679b34ec737ea 2436
058aeaa5d9bfe377c7e322f213c7871ecc4151b5d08ef790992f4ee28d857658 743 START-FORGEFLOW-OVERLAY.ps1
f5b0ea887fcdeadec78c1ad49b0ec7979723562f5c0b730703acb77a37281ee0 1009 tests/acceptance.test.mjs
a4e5947204ff6878e601e32477bc85b53cd0153baf95a161c8935b6e5466c257 1155 tests/audit-service.test.mjs
33bc892963e89b868235b959498308a456b1285057bda524ba7c1d5a9ee2159e 8763 tests/browser/forgeflow.spec.mjs
b46c9624c20bae767e50b4ab85e6b0ea6a9ee4a5c5c30f55554217f25134f1e7 11218 tests/browser/forgeflow.spec.mjs
454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 4318 tests/clone-target.test.mjs
ac17f8bbe9e388b80abef7792c8b184a1fd482c93f13d23a478e433961020f75 17214 tests/config-store.test.mjs
f1463326aee79842d265687ae628189ce54e92544600f2bd14073780287cfb14 2502 tests/configuration-backup.test.mjs
@@ -201,10 +205,10 @@ fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800
dd121d96ca265a027cd415a52064500a4541b2f8a662f4f4b25f2f996d52b5da 762 tests/external-tools.test.mjs
e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 tests/git-integration.test.mjs
5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db 1239 tests/git-status.test.mjs
c00bbd8eae5cef7856c8283d6b40dedb81083bf57ad762e89ab79e0f312da351 3271 tests/git-validator-policy.test.mjs
73d00729696e5067ba33dd6d43b018d89ce7fdd561a60ab66648d3283fb54d21 5370 tests/git-validator.test.mjs
61e0b8cad926acd22b5b17e4044f7edcbe96b6977cbbe2b6fbe123406626fc89 4283 tests/git-validator-policy.test.mjs
2b31459f14a5e36e30cf84c1054f634f4dba8676d29adeb9c2a8e18179f56fa0 6097 tests/git-validator.test.mjs
681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.test.mjs
ca2c2c47b2532a74c7a77b9473ff417e0a34f0dbd8801936fd0e301a38f06a9a 18128 tests/gitea-actions.test.mjs
d633c59bd910008223c834c6d7f3e5666c685a0881944263ede2d42cc69d3151 18710 tests/gitea-actions.test.mjs
fcc9a063882840dd89d74c2785284c8f2f6a9e5acec482b6d89ed8de62efdb85 9635 tests/inventory-classifier.test.mjs
9643622a03ea0a88fb7d72ce43e469ff4f814902f4b3d2a672990637d66ef075 2009 tests/ipc-contract.test.mjs
caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs
@@ -213,7 +217,7 @@ caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392
0cb884cf62c1cb02cf59a81662be055bcb5339d176de85e2a3eeb8e8573e11b3 6435 tests/production-acceptance.test.mjs
629ba26395c0b49cc5fdee6b0646d75369eb6338e1cc7b59509938f97eea08ec 9601 tests/renderer-workflow.test.mjs
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs
f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 1068 tests/repository-monitor.test.mjs
62e6d3df7051778124648c808caec634a6b2c439660dc556392ea10beec926d1 1870 tests/repository-monitor.test.mjs
ebd3c0825bc9e2f1690cfd51e93a96bd33e939eb9c546aa373dd948b8cf71a69 7781 tests/repository-service.test.mjs
d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463 tests/security-validation.test.mjs
bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs
@@ -222,7 +226,7 @@ e631e9ca49a5bac7075860aac2ff4d377a32a78377b70e06ecf833f0f192fd5f 11552
0d1bc4d623ce299337736c577ec61c8ffd6974ebe20335b72838d10eae35ecb1 7993 tests/ssh-service.test.mjs
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs
4182b61e395aff310b9a964c973a43c3566df0b44c454054c3abdd9459e86e3b 49134 tests/unraid-deployment.test.mjs
c93d9706eb206b17db8ba490a1e93067f654c66595325f34532d0b1d7617fedc 19691 tests/update-service.test.mjs
074031cac5018aa74c2bb57ecaf114521d69da707aa6170dda65983690bb1691 21487 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
+9
View File
@@ -0,0 +1,9 @@
# ForgeFlow 0.10.2
## Packaged updater origin repair
- Gitea release assets that expose an internal HTTP `ROOT_URL` are safely rewritten to ForgeFlow's configured public HTTPS Gitea origin.
- Authentication remains same-origin: the Gitea token is never forwarded to an internal address, CDN or unrelated redirect target.
- Published Windows executables are still validated as PE files and against their release SHA-256 sidecars before staging.
- The live authenticated updater acceptance downloads the exact published installer and proves its byte count and SHA-256 digest.
+19
View File
@@ -0,0 +1,19 @@
# ForgeFlow 0.10.3
## Responsive large workspaces
- Repository monitoring checks up to four local working trees concurrently while retaining overlap protection and per-repository pause controls.
- Global search, repository filtering and the command palette debounce full interface renders during rapid typing.
- Commit-message input updates readiness and action controls in place, preserving focus and cursor responsiveness.
- Interactive project illustrations and diff atmosphere effects perform at most one layout update per animation frame.
## Git Validator reliability
- Git Validator and every long repository tab now retain an explicit vertical scroll owner across compact, desktop and wide layouts.
- Standard, Strict and Production policies correctly treat configured warning severities as blockers; Minimal remains error-only.
- Documented suppressions no longer reduce the hygiene score or remain counted as active blockers.
- Repair requests are rescanned immediately before preview or execution, preventing stale or forged fixes.
## Verification
- Full quality gate, coverage thresholds and all responsive browser scenarios pass for this release.
+9
View File
@@ -0,0 +1,9 @@
# ForgeFlow 0.10.4
## Permanent packaged updater handshake repair
- Binary and source update helpers now use a Windows PowerShell 5.1-compatible atomic status replacement with a real temporary backup path.
- A deterministic overwrite fallback preserves lifecycle reporting on filesystems that do not implement atomic replacement.
- The binary helper exposes a side-effect-free handshake-only verification mode exercised by the real Windows PowerShell executable during tests.
- existing installations with the defective helper require this one-time installer upgrade; every subsequent packaged update uses the repaired helper automatically.
- Startup failures retain request-scoped status and helper-log evidence instead of collapsing into an unexplained exit-code message.
+10
View File
@@ -0,0 +1,10 @@
# ForgeFlow 0.10.5
## Consistent repository and deployment links
- Every repository workspace now shows all configured deployment environments in a compact, directly actionable strip.
- The repository deployment tab includes every detected server workload linked to that repository, including its container, Compose identity, server and runtime state.
- A workload is only labelled linked when its repository and resolved profile both exist in the current ForgeFlow configuration.
- Stale or incomplete metadata is shown as **Link unresolved** and routed through explicit reconciliation instead of being presented as a healthy deployment.
- The global deployment inventory links directly to the correct repository deployment profile.
- Responsive browser coverage now verifies valid links, unresolved links, repository navigation and scrolling across dark/light and scaled layouts.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 110 KiB

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "forgeflow",
"version": "0.10.1",
"version": "0.10.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "forgeflow",
"version": "0.10.1",
"version": "0.10.5",
"dependencies": {
"ssh2": "1.17.0"
},
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "forgeflow",
"version": "0.10.1",
"version": "0.10.5",
"private": true,
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
"main": "main.cjs",
@@ -106,6 +106,10 @@
"docs/RELEASE_NOTES_0.9.5.md",
"docs/RELEASE_NOTES_0.10.0.md",
"docs/RELEASE_NOTES_0.10.1.md",
"docs/RELEASE_NOTES_0.10.2.md",
"docs/RELEASE_NOTES_0.10.3.md",
"docs/RELEASE_NOTES_0.10.4.md",
"docs/RELEASE_NOTES_0.10.5.md",
"docs/CURRENT_STATE.md",
"docs/MUTATION_MODEL.md",
"docs/RELEASING.md",
+106 -90
View File
@@ -1,5 +1,5 @@
{
"generatedAt": "2026-07-29T22:49:48.766Z",
"generatedAt": "2026-08-01T15:23:33.157Z",
"thresholds": {
"preferredMaximumLines": 750,
"justificationRequiredLines": 1000
@@ -9,9 +9,9 @@
"cyclomaticHotspots": [
{
"file": "src/renderer/actions/shell.js",
"lines": 506,
"branches": 98,
"functions": 84,
"lines": 518,
"branches": 101,
"functions": 86,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
@@ -20,50 +20,7 @@
"renderer",
"updates"
],
"hotspotScore": 128
},
{
"file": "src/main/server-inventory.cjs",
"lines": 578,
"branches": 89,
"functions": 104,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 119
},
{
"file": "src/main/git-service.cjs",
"lines": 632,
"branches": 98,
"functions": 109,
"ipcHandlers": 0,
"responsibilities": [
"git"
],
"hotspotScore": 98
}
],
"mixedResponsibilityModules": [
{
"file": "src/renderer/actions/shell.js",
"lines": 506,
"branches": 98,
"functions": 84,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"updates"
],
"hotspotScore": 128
"hotspotScore": 131
},
{
"file": "src/main/server-inventory.cjs",
@@ -82,9 +39,9 @@
},
{
"file": "src/renderer/app.js",
"lines": 667,
"branches": 74,
"functions": 110,
"lines": 676,
"branches": 75,
"functions": 113,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
@@ -94,7 +51,66 @@
"security",
"updates"
],
"hotspotScore": 114
"hotspotScore": 115
},
{
"file": "src/main/git-service.cjs",
"lines": 632,
"branches": 98,
"functions": 109,
"ipcHandlers": 0,
"responsibilities": [
"git"
],
"hotspotScore": 98
}
],
"mixedResponsibilityModules": [
{
"file": "src/renderer/actions/shell.js",
"lines": 518,
"branches": 101,
"functions": 86,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"updates"
],
"hotspotScore": 131
},
{
"file": "src/main/server-inventory.cjs",
"lines": 578,
"branches": 89,
"functions": 104,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 119
},
{
"file": "src/renderer/app.js",
"lines": 676,
"branches": 75,
"functions": 113,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"security",
"updates"
],
"hotspotScore": 115
},
{
"file": "src/main/ipc.cjs",
@@ -130,9 +146,9 @@
},
{
"file": "src/renderer/views.js",
"lines": 693,
"branches": 53,
"functions": 138,
"lines": 737,
"branches": 55,
"functions": 156,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
@@ -142,7 +158,7 @@
"security",
"updates"
],
"hotspotScore": 93
"hotspotScore": 95
},
{
"file": "src/main/unraid-inventory-methods.cjs",
@@ -176,8 +192,8 @@
},
{
"file": "src/main/gitea-service.cjs",
"lines": 618,
"branches": 66,
"lines": 624,
"branches": 67,
"functions": 57,
"ipcHandlers": 0,
"responsibilities": [
@@ -186,7 +202,7 @@
"security",
"updates"
],
"hotspotScore": 86
"hotspotScore": 87
},
{
"file": "src/renderer/actions/deployment-profile.js",
@@ -312,6 +328,35 @@
],
"hotspotScore": 73
},
{
"file": "src/renderer/events.js",
"lines": 181,
"branches": 35,
"functions": 27,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"security"
],
"hotspotScore": 65
},
{
"file": "src/main/git-validator-service.cjs",
"lines": 600,
"branches": 43,
"functions": 77,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 63
},
{
"file": "src/main/ssh-service.cjs",
"lines": 333,
@@ -341,35 +386,6 @@
],
"hotspotScore": 61
},
{
"file": "src/renderer/events.js",
"lines": 172,
"branches": 31,
"functions": 26,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"security"
],
"hotspotScore": 61
},
{
"file": "src/main/git-validator-service.cjs",
"lines": 588,
"branches": 40,
"functions": 74,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 60
},
{
"file": "src/main/unraid-deployment-service.cjs",
"lines": 525,
@@ -477,7 +493,7 @@
},
{
"file": "src/renderer/mock-deployment-bridge.js",
"lines": 679,
"lines": 699,
"branches": 11,
"functions": 87,
"ipcHandlers": 0,
@@ -686,7 +702,7 @@
},
{
"file": "src/main/ipc/repository-handlers.cjs",
"lines": 411,
"lines": 413,
"branches": 16,
"functions": 79,
"ipcHandlers": 51,
+3 -2
View File
@@ -1,6 +1,6 @@
# ForgeFlow architecture audit
Generated 2026-07-29T22:49:48.766Z. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity.
Generated 2026-08-01T15:23:33.157Z. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity.
## Files above 750 lines
@@ -18,8 +18,9 @@ No findings.
| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |
|---|---:|---:|---:|---:|---|
| `src/renderer/actions/shell.js` | 506 | 98 | 84 | 0 | inventory, deployment, git, renderer, updates |
| `src/renderer/actions/shell.js` | 518 | 101 | 86 | 0 | inventory, deployment, git, renderer, updates |
| `src/main/server-inventory.cjs` | 578 | 89 | 104 | 0 | inventory, deployment, git, security, updates |
| `src/renderer/app.js` | 676 | 75 | 113 | 0 | inventory, deployment, git, renderer, security, updates |
| `src/main/git-service.cjs` | 632 | 98 | 109 | 0 | git |
## Interpretation
+24 -5
View File
@@ -7,7 +7,8 @@ param(
[Parameter(Mandatory = $true)][int]$ParentPid,
[Parameter(Mandatory = $true)][string]$LogPath,
[Parameter(Mandatory = $true)][string]$StatusPath,
[Parameter(Mandatory = $true)][string]$UpdateId
[Parameter(Mandatory = $true)][string]$UpdateId,
[switch]$HandshakeOnly
)
$ErrorActionPreference = "Stop"
@@ -27,12 +28,26 @@ function Write-UpdateState {
updatedAt = [DateTime]::UtcNow.ToString("o")
}
if ($State -in @("success", "failed", "rolled-back")) { $payload.completedAt = [DateTime]::UtcNow.ToString("o") }
$directory = Split-Path -Parent $StatusPath
if ($directory) { New-Item -ItemType Directory -Force -Path $directory | Out-Null }
$temporary = "$StatusPath.$PID.tmp"
$payload | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $temporary -Encoding UTF8
if (Test-Path -LiteralPath $StatusPath) { [IO.File]::Replace($temporary, $StatusPath, $null) }
else { Move-Item -LiteralPath $temporary -Destination $StatusPath }
$backup = "$StatusPath.$PID.bak"
$json = $payload | ConvertTo-Json -Depth 4
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[IO.File]::WriteAllText($temporary, $json, $utf8NoBom)
try {
if ([IO.File]::Exists($StatusPath)) {
[IO.File]::Replace($temporary, $StatusPath, $backup)
[IO.File]::Delete($backup)
} else {
[IO.File]::Move($temporary, $StatusPath)
}
} catch {
[IO.File]::Copy($temporary, $StatusPath, $true)
[IO.File]::Delete($temporary)
if ([IO.File]::Exists($backup)) { [IO.File]::Delete($backup) }
}
}
function Write-Log([string]$Message) {
"{0} {1}" -f [DateTime]::UtcNow.ToString("o"), $Message | Add-Content -LiteralPath $LogPath -Encoding UTF8
}
@@ -40,6 +55,10 @@ function Write-Log([string]$Message) {
try {
Write-UpdateState -State "started" -Message "Binary updater owns the update request."
Write-Log "Validating ForgeFlow $ExpectedVersion binary update."
if ($HandshakeOnly) {
Write-Log "Handshake-only verification completed successfully."
exit 0
}
$actualSha256 = (Get-FileHash -LiteralPath $BinaryPath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualSha256 -ne $ExpectedSha256.ToLowerInvariant()) { throw "Binary update SHA-256 verification failed." }
if (-not (Test-Path -LiteralPath $CurrentExecutable -PathType Leaf)) { throw "Current ForgeFlow executable was not found." }
+6 -1
View File
@@ -54,16 +54,21 @@ function Write-UpdateState {
if ([System.IO.File]::Exists($StatusPath)) {
# Windows PowerShell 5.1 does not reliably let Move-Item -Force replace
# an existing file. File.Replace is atomic on the local NTFS volume.
[System.IO.File]::Replace($temporary, $StatusPath, $null)
$backup = "$StatusPath.$PID.bak"
[System.IO.File]::Replace($temporary, $StatusPath, $backup)
} else {
[System.IO.File]::Move($temporary, $StatusPath)
}
} catch {
# Some filesystems do not implement File.Replace. Copy with overwrite is
# the deterministic fallback; the temporary file is removed afterwards.
if ([System.IO.File]::Exists($temporary)) {
[System.IO.File]::Copy($temporary, $StatusPath, $true)
[System.IO.File]::Delete($temporary)
}
} finally {
if ([System.IO.File]::Exists($backup)) { [System.IO.File]::Delete($backup) }
}
}
function Invoke-Robocopy {
+8 -1
View File
@@ -14,7 +14,14 @@ if (signedRelease && !/^CN=.+/i.test(expectedPublisher)) throw new Error("FORGEF
const artifacts = ["Setup", "Portable"].map((kind) => path.join(root, "dist", `ForgeFlow-${kind}-${pkg.version}-win-x64.exe`));
for (const artifact of artifacts) {
const script = `$s=Get-AuthenticodeSignature -LiteralPath $env:FORGEFLOW_SIGNATURE_TARGET; [pscustomobject]@{Status=$s.Status.ToString();Subject=$s.SignerCertificate.Subject;Thumbprint=$s.SignerCertificate.Thumbprint;TimestampSubject=$s.TimeStamperCertificate.Subject}|ConvertTo-Json -Compress`;
const { stdout } = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { windowsHide: true, env: { ...process.env, FORGEFLOW_SIGNATURE_TARGET: artifact } });
let stdout;
try {
({ stdout } = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { windowsHide: true, env: { ...process.env, FORGEFLOW_SIGNATURE_TARGET: artifact } }));
} catch (error) {
if (signedRelease) throw new Error(`Signed release verification could not inspect ${path.basename(artifact)}: ${error.message}`);
console.log(`${path.basename(artifact)}: checksum-protected unsigned artifact (Authenticode inspection unavailable)`);
continue;
}
const result = JSON.parse(stdout.trim());
const valid = result.Status === "Valid" && Boolean(result.TimestampSubject);
const publisherMatches = !expectedPublisher || String(result.Subject || "").trim() === expectedPublisher;
+22 -2
View File
@@ -81,6 +81,10 @@ const required = [
"docs/RELEASE_NOTES_0.9.5.md",
"docs/RELEASE_NOTES_0.10.0.md",
"docs/RELEASE_NOTES_0.10.1.md",
"docs/RELEASE_NOTES_0.10.2.md",
"docs/RELEASE_NOTES_0.10.3.md",
"docs/RELEASE_NOTES_0.10.4.md",
"docs/RELEASE_NOTES_0.10.5.md",
"docs/UPDATING.md",
"docs/DIAGNOSTICS.md",
"docs/DEPLOYMENT_SETUP.md",
@@ -119,9 +123,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.1")
if (packageJson.version !== "0.10.5")
throw new Error(
`Expected package version 0.10.1, got ${packageJson.version}.`,
`Expected package version 0.10.5, got ${packageJson.version}.`,
);
const sourceManifest = await readFile(
path.join(root, "SOURCE_MANIFEST.txt"),
@@ -439,6 +443,22 @@ const release0101 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.1.md
for (const phrase of ["certificate-free updates", "case-insensitive", "read-only deploy keys", "SHA-256"]) {
if (!release0101.includes(phrase)) throw new Error(`0.10.1 release notes are missing: ${phrase}`);
}
const release0102 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.2.md"), "utf8");
for (const phrase of ["internal HTTP", "public HTTPS", "same-origin", "SHA-256"]) {
if (!release0102.includes(phrase)) throw new Error(`0.10.2 release notes are missing: ${phrase}`);
}
const release0103 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.3.md"), "utf8");
for (const phrase of ["concurrently", "debounce", "animation frame", "Git Validator", "stale or forged"]) {
if (!release0103.includes(phrase)) throw new Error(`0.10.3 release notes are missing: ${phrase}`);
}
const release0104 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.4.md"), "utf8");
for (const phrase of ["Windows PowerShell 5.1", "atomic status", "handshake-only", "existing installations"]) {
if (!release0104.includes(phrase)) throw new Error(`0.10.4 release notes are missing: ${phrase}`);
}
const release0105 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.5.md"), "utf8");
for (const phrase of ["repository workspace", "resolved profile", "Link unresolved", "reconciliation", "server workload"]) {
if (!release0105.includes(phrase)) throw new Error(`0.10.5 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}`);
+3 -1
View File
@@ -20,6 +20,8 @@ function normalizePolicy(policy = {}) {
enabledChecks: Array.isArray(custom.enabledChecks) ? [...new Set(custom.enabledChecks.map(String))] : null,
severityOverrides: custom.severityOverrides && typeof custom.severityOverrides === "object" ? { ...custom.severityOverrides } : {},
blockingChecks: [...new Set((custom.blockingChecks || policy.blockingChecks || []).map(String))],
blockingSeverities: [...new Set((custom.blockingSeverities || policy.blockingSeverities || base.severities || ["error"]).map(String))]
.filter((severity) => ["warning", "error"].includes(severity)),
allowSuppressions: custom.allowSuppressions ?? base.allowSuppressions ?? true,
maxSuppressionDays: Math.max(1, Number(custom.maxSuppressionDays ?? base.maxSuppressionDays ?? 30)),
};
@@ -57,7 +59,7 @@ function applyPolicy(checks, policyInput, suppressions = [], now = new Date()) {
suppressed: Boolean(suppression),
suppression: suppression || null,
expiredSuppression: expiredSuppression || null,
blocking: !suppression && status !== "pass" && (status === "error" || policy.blockingChecks.includes(check.id)),
blocking: !suppression && status !== "pass" && (policy.blockingSeverities.includes(status) || policy.blockingChecks.includes(check.id)),
};
});
return { policy, checks: relevant };
+15 -3
View File
@@ -484,12 +484,23 @@ class GitValidatorService {
throw new Error("Unsupported Git Validator repair action.");
}
async resolveRepairCheck(repository, candidate) {
const checkId = String(candidate?.id || candidate?.checkId || "").trim();
if (!checkId) throw new Error("A current Git Validator check ID is required.");
const report = await this.scan(repository);
const current = report.checks.find((check) => check.id === checkId);
if (!current?.fixAction)
throw new Error("This finding is resolved, suppressed or no longer repairable. Scan again before repairing.");
if (candidate?.fixAction && candidate.fixAction !== current.fixAction)
throw new Error("The Git Validator repair request is stale. Scan again before repairing.");
return current;
}
summarize(repository, checks) {
const totalWeight = checks.reduce((sum, check) => sum + check.weight, 0);
const earned = checks.reduce(
(sum, check) =>
sum +
(check.status === "pass"
(check.status === "pass" || check.suppressed
? check.weight
: check.status === "warning"
? check.weight * 0.45
@@ -512,8 +523,9 @@ class GitValidatorService {
checks,
summary: {
passed: checks.filter((check) => check.status === "pass").length,
warnings: checks.filter((check) => check.status === "warning").length,
errors: checks.filter((check) => check.status === "error").length,
warnings: checks.filter((check) => check.status === "warning" && !check.suppressed).length,
errors: checks.filter((check) => check.status === "error" && !check.suppressed).length,
suppressed: checks.filter((check) => check.suppressed).length,
repairable: checks.filter((check) => check.fixAction).length,
},
};
+6
View File
@@ -426,6 +426,12 @@ class GiteaService {
if (!downloadUrl) {
throw new Error("Gitea did not provide a release asset download URL.");
}
const configuredBase = new URL(normalizeBaseUrl(this.store.data.gitea.baseUrl));
const publishedUrl = new URL(downloadUrl, configuredBase);
const releasePrefix = `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/download/`.toLowerCase();
if (publishedUrl.origin !== configuredBase.origin && publishedUrl.protocol === "http:" && publishedUrl.pathname.toLowerCase().startsWith(releasePrefix)) {
downloadUrl = new URL(`${publishedUrl.pathname}${publishedUrl.search}`, configuredBase).toString();
}
return this.downloadAuthenticated(downloadUrl, options);
}
+9 -7
View File
@@ -373,7 +373,8 @@ function registerRepositoryIpc({
});
register("git-validator:preview-repair", async ({ fullName, check }) => {
const repository = await resolveRepository({ fullName });
return gitValidator.previewRepair(repository, check);
const currentCheck = await gitValidator.resolveRepairCheck(repository, check);
return gitValidator.previewRepair(repository, currentCheck);
});
register("git-validator:export", async ({ fullName, format = "json" }) => {
const repository = await resolveRepository({ fullName });
@@ -390,18 +391,19 @@ function registerRepositoryIpc({
"add-editorconfig",
"protect-default-branch",
]);
if (!allowed.has(check?.fixAction))
const currentCheck = await gitValidator.resolveRepairCheck(repository, check);
if (!allowed.has(currentCheck.fixAction))
throw new Error("Unsupported Git Validator repair request.");
const result = await gitValidator.repair(repository, check);
const result = await gitValidator.repair(repository, currentCheck);
await audit.append("git-validator.repair", {
repository: repository.fullName,
checkId: check.id,
action: check.fixAction,
checkId: currentCheck.id,
action: currentCheck.fixAction,
});
await diagnostics.info("git-validator.repair.completed", {
repository: repository.fullName,
checkId: check.id,
action: check.fixAction,
checkId: currentCheck.id,
action: currentCheck.fixAction,
});
return result;
});
+6 -1
View File
@@ -40,7 +40,10 @@ class RepositoryMonitor {
if (this.running || !this.paths.length) return;
this.running = true;
try {
for (const localPath of this.paths) {
const queue = [...this.paths];
const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
while (queue.length) {
const localPath = queue.shift();
if (this.paused.has(localPath)) continue;
try {
const status = await this.git.status(localPath);
@@ -61,6 +64,8 @@ class RepositoryMonitor {
}
}
}
});
await Promise.all(workers);
} finally {
this.running = false;
}
+12
View File
@@ -9,6 +9,18 @@ async function handleShellActions(event, target, action, repository) {
setLoading(false);
}
} else if (action === "select-repo") selectRepository(target.dataset.id);
else if (action === "open-deployment-link") {
if (!repository) return true;
selectRepository(repository.id, false);
ui.selectedProfileId = target.dataset.profileId || selectedProfile(repository)?.id || null;
ui.repositoryTab = "deployments";
ui.currentView = "repository";
render();
} else if (action === "select-deployment-profile") {
ui.selectedProfileId = target.dataset.profileId || null;
ui.repositoryTab = "deployments";
render();
}
else if (action === "refresh") {
await refreshRepositories(true);
await refreshActiveOperations(false);
+9
View File
@@ -173,6 +173,7 @@ const ui = {
setupValidation: null,
activeDeployment: null,
operationPollTimer: null,
inputRenderTimer: null,
isMock: false,
refreshError: null,
autoRefreshPending: false,
@@ -190,6 +191,14 @@ const ui = {
auditEvents: [],
};
function scheduleInputRender(delay = 120) {
if (ui.inputRenderTimer) clearTimeout(ui.inputRenderTimer);
ui.inputRenderTimer = setTimeout(() => {
ui.inputRenderTimer = null;
render();
}, delay);
}
function selectedRepository() {
return (
ui.repositories.find(
+40 -31
View File
@@ -17,20 +17,32 @@ app.addEventListener("click", async (event) => {
app.addEventListener("input", (event) => {
if (event.target.id === "global-search") {
ui.search = event.target.value;
render();
document.querySelector("#global-search")?.focus();
scheduleInputRender();
} else if (event.target.id === "repo-filter") {
ui.repoSearch = event.target.value;
render();
document.querySelector("#repo-filter")?.focus();
scheduleInputRender();
} else if (event.target.id === "commit-message") {
ui.commitMessage = event.target.value;
const position = event.target.selectionStart;
render();
const next = document.querySelector("#commit-message");
if (next) {
next.focus();
next.setSelectionRange(position, position);
const repository = selectedRepository();
const hasSelection = Boolean(ui.selectedFiles.size || repository?.localStatus?.counts?.staged);
const ready = Boolean(hasSelection && ui.commitMessage.trim());
const blocker = !hasSelection
? "Select files or stage one or more hunks."
: ready
? ui.selectedFiles.size
? "Ready to commit. ForgeFlow stages the selected files automatically."
: "Ready to commit only the reviewed staged hunks."
: "Enter a commit message to enable commit and push.";
const readiness = document.querySelector(".commit-readiness");
if (readiness) {
readiness.classList.toggle("ready", ready);
readiness.classList.toggle("blocked", !ready);
readiness.innerHTML = `${icon(ready ? "check" : "warning")}<span>${escapeHtml(blocker)}</span>`;
}
for (const button of document.querySelectorAll('[data-action="commit-push"], [data-action="commit-only"]')) {
button.disabled = !ready;
if (ready) button.removeAttribute("title");
else button.title = blocker;
}
} else if (event.target.id === "setup-url")
ui.setupDraft.baseUrl = event.target.value;
@@ -38,7 +50,7 @@ app.addEventListener("input", (event) => {
ui.setupDraft.token = event.target.value;
else if (event.target.id === "palette-input") {
ui.paletteQuery = event.target.value;
render();
scheduleInputRender(60);
}
});
@@ -102,35 +114,32 @@ document.addEventListener("keydown", (event) => {
}
});
let pointerAnimationFrame = null;
let pendingPointer = null;
document.addEventListener("pointermove", (event) => {
const illustration = event.target.closest?.("[data-project-illustration]");
pendingPointer = { target: event.target, clientX: event.clientX, clientY: event.clientY };
if (pointerAnimationFrame) return;
pointerAnimationFrame = requestAnimationFrame(() => {
pointerAnimationFrame = null;
const current = pendingPointer;
pendingPointer = null;
if (!current) return;
const illustration = current.target.closest?.("[data-project-illustration]");
if (illustration) {
const bounds = illustration.getBoundingClientRect();
illustration.style.setProperty(
"--tilt-x",
`${((event.clientY - bounds.top) / bounds.height - 0.5) * -7}deg`,
);
illustration.style.setProperty(
"--tilt-y",
`${((event.clientX - bounds.left) / bounds.width - 0.5) * 9}deg`,
);
illustration.style.setProperty("--tilt-x", `${((current.clientY - bounds.top) / bounds.height - 0.5) * -7}deg`);
illustration.style.setProperty("--tilt-y", `${((current.clientX - bounds.left) / bounds.width - 0.5) * 9}deg`);
}
const diffPanel = event.target.closest?.(".diff-panel");
const diffPanel = current.target.closest?.(".diff-panel");
const atmosphere = diffPanel?.querySelector("[data-diff-atmosphere]");
if (atmosphere) {
const bounds = diffPanel.getBoundingClientRect();
atmosphere.style.setProperty(
"--diff-tilt-x",
`${((event.clientY - bounds.top) / bounds.height - 0.5) * -3}deg`,
);
atmosphere.style.setProperty(
"--diff-tilt-y",
`${((event.clientX - bounds.left) / bounds.width - 0.5) * 4}deg`,
);
atmosphere.style.setProperty("--diff-tilt-x", `${((current.clientY - bounds.top) / bounds.height - 0.5) * -3}deg`);
atmosphere.style.setProperty("--diff-tilt-y", `${((current.clientX - bounds.left) / bounds.width - 0.5) * 4}deg`);
}
});
});
document.addEventListener("pointerout", (event) => {
const illustration = event.target.closest?.("[data-project-illustration]");
if (illustration && !illustration.contains(event.relatedTarget)) {
+27 -7
View File
@@ -238,13 +238,13 @@ function createMockDeploymentBridge(context) {
{
serverId: "server-unraid",
serverName: "Unraid",
detected: 2,
detected: 3,
adopted: 0,
verified: 1,
linked: 1,
linked: 2,
unmatched: 0,
needsReview: 1,
running: 2,
needsReview: 2,
running: 3,
stopped: 0,
capabilities: {
docker: true,
@@ -296,6 +296,19 @@ function createMockDeploymentBridge(context) {
reasons: ["container and repository names are similar"],
})),
},
{
workloadId: "workload-demo-unresolved",
displayName: "Legacy Worker",
status: "linked",
runtime: { running: true, health: "healthy" },
containers: [{ name: "legacy-worker", running: true }],
candidates: [],
link: {
profileId: "profile-that-no-longer-exists",
repositoryFullName: "jens/removed-repository",
source: "manual",
},
},
],
},
];
@@ -461,13 +474,15 @@ function createMockDeploymentBridge(context) {
},
async gitValidatorScan(fullName) {
await wait(260);
const policy = state.gitValidatorPolicy || { id: "standard", label: "Standard", requiredScore: 70 };
const activeWarnings = 3;
return {
repository: fullName,
checkedAt: iso(),
score: 78,
grade: "Good",
policy: state.gitValidatorPolicy || { id: "standard", label: "Standard", requiredScore: 70 },
ready: true,
policy,
ready: 78 >= policy.requiredScore && (policy.id === "minimal" || activeWarnings === 0),
commitSha: "8cbaf303aa3bb9b4023a7c89aa13fb70ce612847",
trend: { newlyFound: ["working-tree"], resolved: ["editorconfig"], regressions: [], suppressions: [] },
expiredSuppressions: [],
@@ -572,7 +587,12 @@ function createMockDeploymentBridge(context) {
};
},
async gitValidatorSetPolicy(_fullName, policy) {
state.gitValidatorPolicy = { id: policy.id, label: policy.id[0].toUpperCase() + policy.id.slice(1) };
const requiredScores = { minimal: 55, standard: 70, strict: 82, production: 90 };
state.gitValidatorPolicy = {
id: policy.id,
label: policy.id[0].toUpperCase() + policy.id.slice(1),
requiredScore: requiredScores[policy.id] || 70,
};
return clone(state.gitValidatorPolicy);
},
async gitValidatorSuppress(_fullName, suppression) {
+1 -1
View File
@@ -5,7 +5,7 @@ function createMockRepositoryBridge(context) {
await wait(80);
snapshot();
return {
appVersion: "0.10.0-demo",
appVersion: "0.10.5-demo",
platform: "win32",
state: clone(state),
git: { available: true, version: "git version 2.47.3" },
+75 -3
View File
@@ -849,6 +849,64 @@ select:focus-visible {
.release-node:last-child {
border-right: 0;
}
.repository-deployment-summary {
display: flex;
align-items: center;
gap: 10px;
min-height: 48px;
padding: 7px 12px;
border-bottom: 1px solid var(--line);
background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 7%, var(--surface-1)), var(--surface-1) 42%);
}
.repository-deployment-summary-label {
display: inline-flex;
align-items: center;
gap: 6px;
flex: 0 0 auto;
color: var(--text-muted);
font-size: 10px;
font-weight: 760;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.repository-deployment-summary-label svg {
width: 14px;
height: 14px;
color: var(--accent);
}
.repository-deployment-chips {
display: flex;
gap: 7px;
min-width: 0;
flex: 1;
overflow-x: auto;
scrollbar-width: thin;
}
.repository-deployment-chip {
display: inline-flex;
align-items: center;
gap: 6px;
flex: 0 0 auto;
max-width: 280px;
padding: 6px 9px;
border: 1px solid var(--line);
border-radius: 8px;
background: color-mix(in srgb, var(--surface-2) 88%, transparent);
color: var(--text);
cursor: pointer;
}
.repository-deployment-chip:hover {
border-color: color-mix(in srgb, var(--accent) 48%, var(--line));
background: color-mix(in srgb, var(--accent) 10%, var(--surface-2));
}
.repository-deployment-chip strong,
.repository-deployment-chip span:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.repository-deployment-chip strong { font-size: 11px; }
.repository-deployment-chip span:last-child { color: var(--text-muted); font-size: 10px; }
.release-node:not(:last-child)::after {
content: "";
position: absolute;
@@ -937,6 +995,21 @@ select:focus-visible {
min-height: 0;
overflow: hidden;
}
.repo-content > .tab-page,
.repo-content > .validator-page {
height: 100%;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.repo-content > .validator-empty,
.repo-content > .empty-state {
max-height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
}
.changes-layout {
height: 100%;
min-height: 0;
@@ -1286,7 +1359,7 @@ html[data-theme="light"] .diff-line.remove {
padding: 18px;
display: grid;
gap: 14px;
overflow: auto;
align-content: start;
}
.validator-empty {
min-height: 360px;
@@ -2572,9 +2645,8 @@ kbd {
font: 11px var(--font-mono);
}
.tab-page {
min-height: 100%;
min-height: 0;
padding: 18px 19px 42px;
overflow: auto;
}
.git-tools-grid {
display: grid;
+49 -5
View File
@@ -188,6 +188,16 @@ function releaseNode(label, value, description, tone = "") {
return `<div class="release-node"><div class="release-label">${label}</div><div class="release-value"><span class="state-dot ${tone}"></span><strong>${escapeHtml(value)}</strong><span>${escapeHtml(description)}</span></div></div>`;
}
function linkedWorkloadsForRepository(repository) {
const fullName = String(repository?.fullName || "").toLowerCase();
if (!fullName) return [];
return (ui.serverDiscovery || []).flatMap((server) =>
(server.workloads || [])
.filter((workload) => String(workload.link?.repositoryFullName || "").toLowerCase() === fullName)
.map((workload) => ({ ...workload, serverId: server.serverId, serverName: server.serverName || server.server?.name || "Server" })),
);
}
function diffAtmosphere(diff) {
if (!ui.selectedFile) return "";
const lines = String(diff || "").split("\n");
@@ -359,8 +369,15 @@ function renderProfileCard(repository, profile, compact = false) {
}
function renderRepositoryDeployments(repository) {
const profiles = repository.deploymentProfiles || [];
const workloads = linkedWorkloadsForRepository(repository);
const profileIds = new Set(profiles.map((profile) => profile.id));
const workloadRows = workloads.map((workload) => {
const containers = (workload.containers || []).map((container) => container.name).filter(Boolean);
const profileResolved = Boolean(workload.link?.profileId && profileIds.has(workload.link.profileId));
return `<div class="tool-row repository-workload-row"><div><strong>${escapeHtml(workload.displayName || containers[0] || "Server workload")}</strong><span>${escapeHtml(workload.serverName)} · ${containers.length ? escapeHtml(containers.join(", ")) : "container identity unavailable"} · ${workload.runtime?.running ? "running" : "stopped"}</span><span>${escapeHtml(workload.compose?.project ? `Compose ${workload.compose.project}` : workload.remoteFolderCandidate || "Docker workload")}</span></div><div class="stack horizontal compact"><span class="status-pill ${profileResolved ? "success" : "danger"}">${profileResolved ? "Repository linked" : "Link needs reconciliation"}</span>${profileResolved ? `<button class="button ghost" data-action="select-deployment-profile" data-profile-id="${attr(workload.link.profileId)}">Open profile</button>` : `<button class="button" data-action="navigate" data-view="deployments">Review inventory</button>`}</div></div>`;
}).join("");
const repoOps = repositoryOperations(repository).slice(0, 10);
return `<div class="tab-page"><div class="section-heading"><div><h2>Deployment environments</h2><span class="meta">Exact-commit Gitea Actions or pinned SSH / Unraid deployments</span></div><button class="button primary" data-action="configure-deployment">${icon("plus")}Add environment</button></div>${profiles.length ? `<div class="deploy-card-grid">${profiles.map((profile) => renderProfileCard(repository, profile)).join("")}</div>` : '<div class="empty-state panel"><div class="empty-icon">↗</div><h3>No deployment profile</h3><p>Connect a Gitea Actions workflow or a trusted SSH / Unraid server.</p><button class="button primary" data-action="configure-deployment">Configure deployment</button></div>'}<section class="section-block"><div class="section-heading"><h2>Release history</h2></div><div class="panel">${repoOps.length ? `<table class="data-table"><thead><tr><th>Action</th><th>Environment</th><th>Commit</th><th>Status</th><th>Updated</th><th></th></tr></thead><tbody>${repoOps.map((operation) => `<tr><td>${escapeHtml(operation.action || "deploy")}</td><td>${escapeHtml(operation.environment)}</td><td class="mono">${escapeHtml(operation.shortSha || shortSha(operation.sha))}</td><td><span class="status-pill ${toneForStatus(operation.status)}">${escapeHtml(operation.status)}</span></td><td>${formatDate(operation.updatedAt || operation.createdAt)}</td><td><button class="button ghost" data-action="open-operation" data-operation-id="${attr(operation.id)}">Open</button></td></tr>`).join("")}</tbody></table>` : '<div class="empty-state compact"><p>No releases for this repository yet.</p></div>'}</div></section></div>`;
return `<div class="tab-page"><div class="section-heading"><div><h2>Deployment environments</h2><span class="meta">${profiles.length} configured profile${profiles.length === 1 ? "" : "s"} · ${workloads.length} server workload${workloads.length === 1 ? "" : "s"} linked to this repository</span></div><button class="button primary" data-action="configure-deployment">${icon("plus")}Add environment</button></div>${workloads.length ? `<section class="panel repository-workloads"><div class="panel-header"><div><h3>Detected on server</h3><span class="meta">Live Docker / Compose identities resolved back to this repository</span></div></div><div class="panel-body"><div class="tool-list">${workloadRows}</div></div></section>` : ""}${profiles.length ? `<div class="deploy-card-grid">${profiles.map((profile) => renderProfileCard(repository, profile)).join("")}</div>` : '<div class="empty-state panel"><div class="empty-icon">↗</div><h3>No deployment profile</h3><p>Connect a Gitea Actions workflow or a trusted SSH / Unraid server.</p><button class="button primary" data-action="configure-deployment">Configure deployment</button></div>'}<section class="section-block"><div class="section-heading"><h2>Release history</h2></div><div class="panel">${repoOps.length ? `<table class="data-table"><thead><tr><th>Action</th><th>Environment</th><th>Commit</th><th>Status</th><th>Updated</th><th></th></tr></thead><tbody>${repoOps.map((operation) => `<tr><td>${escapeHtml(operation.action || "deploy")}</td><td>${escapeHtml(operation.environment)}</td><td class="mono">${escapeHtml(operation.shortSha || shortSha(operation.sha))}</td><td><span class="status-pill ${toneForStatus(operation.status)}">${escapeHtml(operation.status)}</span></td><td>${formatDate(operation.updatedAt || operation.createdAt)}</td><td><button class="button ghost" data-action="open-operation" data-operation-id="${attr(operation.id)}">Open</button></td></tr>`).join("")}</tbody></table>` : '<div class="empty-state compact"><p>No releases for this repository yet.</p></div>'}</div></section></div>`;
}
function renderGitTools(repository) {
@@ -412,6 +429,8 @@ function renderGitValidator(repository) {
function renderRepositoryWorkspace(repository) {
const status = repository.localStatus;
const profiles = repository.deploymentProfiles || [];
const linkedWorkloads = linkedWorkloadsForRepository(repository);
const profile = selectedProfile(repository);
const serverState = profile?.state || {};
const localTone = status?.counts.conflicts
@@ -444,9 +463,19 @@ function renderRepositoryWorkspace(repository) {
settings: renderRepositorySettings,
}[ui.repositoryTab] || renderChanges
)(repository);
const deploymentLinks = profiles.length
? `<div class="repository-deployment-summary"><span class="repository-deployment-summary-label">${icon("server")}Linked deployments</span><div class="repository-deployment-chips">${profiles.map((item) => {
const workload = linkedWorkloads.find((candidate) => candidate.link?.profileId === item.id);
const itemState = item.state || {};
const tone = itemState.healthy === false ? "danger" : itemState.healthy === true ? "success" : workload?.runtime?.running ? "success" : "warning";
const identity = workload?.displayName || item.containerName || item.remoteFolder || item.environment;
return `<button class="repository-deployment-chip" data-action="select-deployment-profile" data-profile-id="${attr(item.id)}"><span class="state-dot ${tone}"></span><strong>${escapeHtml(identity)}</strong><span>${escapeHtml(item.environment)}${workload?.serverName ? ` · ${escapeHtml(workload.serverName)}` : ""}</span></button>`;
}).join("")}</div><button class="button ghost" data-action="select-deployment-profile" data-profile-id="${attr(profile?.id || profiles[0].id)}">View all</button></div>`
: "";
return `<div class="repo-workspace"><header class="repo-header illustrated-repo-header"><div class="repo-heading"><h1><button class="favorite-button ${repository.favorite ? "active" : ""}" data-action="toggle-favorite" title="Toggle favorite">${icon("star")}</button>${escapeHtml(repository.fullName)}</h1><p>${escapeHtml(repository.localPath || "No local working tree linked")}</p></div>${projectIllustration("repo")}<div class="repo-header-actions"><button class="button" data-action="fetch" ${!repository.localPath ? "disabled" : ""}>${icon("refresh")}Fetch</button><button class="button" data-action="open-path" ${!repository.localPath ? "disabled" : ""}>${icon("folder")}Folder</button><button class="button" data-action="open-gitea" ${!repository.htmlUrl ? "disabled" : ""}>${icon("external")}Gitea</button></div></header>
${repository.localPath ? `<div class="repo-quick-actions"><button class="button" data-action="open-editor">${icon("external")}Open in editor</button><button class="button" data-action="open-terminal">${icon("terminal")}Open terminal</button><button class="button" data-action="check-branch-protection">${icon("shield")}Check branch protection</button><button class="button primary" data-action="open-pull-request">${icon("git")}Create pull request</button>${ui.branchProtection ? `<span class="status-pill ${ui.branchProtection.protected ? "warning" : "success"}">${ui.branchProtection.protected ? `Protected · ${ui.branchProtection.requiredApprovals || 0} approval(s)` : "Direct pushes allowed"}</span>` : ""}</div>` : ""}
<div class="release-rail">${releaseNode("Local", status?.shortHead || "Not linked", status ? `${status.counts.changed} changes · ${status.branch.head}` : "No working tree", localTone)}${releaseNode("Gitea", status?.shortHead || "Unknown", status?.branch.upstream ? `${status.branch.ahead} ahead · ${status.branch.behind} behind` : "Branch not published", remoteTone)}${releaseNode(`Server${profile ? ` · ${profile.environment}` : ""}`, serverState.liveSha ? shortSha(serverState.liveSha) : "Unknown", profile ? (serverState.checkedAt ? `checked ${formatDate(serverState.checkedAt)}` : "not checked") : "No deployment profile", serverTone)}</div>
${deploymentLinks}
<nav class="tabs">${[
["changes", "Changes"],
["history", "History"],
@@ -531,9 +560,13 @@ function renderServerInventory() {
? visibleWorkloads.map((workload) => {
const containers = (workload.containers || []).map((container) => container.name).filter(Boolean).join(", ");
const topCandidate = workload.candidates?.[0];
const linked = (workload.status === "linked" || Boolean(workload.link)) && workload.classification?.type !== "stale-link";
const linkedRepository = ui.repositories.find((repository) => String(repository.fullName).toLowerCase() === String(workload.link?.repositoryFullName || "").toLowerCase());
const linkedProfile = linkedRepository?.deploymentProfiles?.find((profile) => profile.id === workload.link?.profileId);
const claimsLink = workload.status === "linked" || Boolean(workload.link);
const linked = Boolean(claimsLink && linkedRepository && linkedProfile) && workload.classification?.type !== "stale-link";
const inconsistentLink = claimsLink && !linked;
const classification = workload.classification?.type || workload.status || "review";
const statusTone = linked && !workload.reviewDecisionStale ? "success" : ["ambiguous", "duplicate", "orphan-container"].includes(classification) || workload.reviewDecisionStale ? "danger" : "warning";
const statusTone = linked && !workload.reviewDecisionStale ? "success" : inconsistentLink || ["ambiguous", "duplicate", "orphan-container"].includes(classification) || workload.reviewDecisionStale ? "danger" : "warning";
const detail = workload.compose?.project
? `Compose ${workload.compose.project} · ${(workload.compose.services || []).join(", ") || "services unknown"}`
: workload.dockerMan?.templatePath
@@ -541,6 +574,8 @@ function renderServerInventory() {
: `Container installation · ${containers || "unnamed"}`;
const candidate = linked
? `Linked to ${workload.link?.repositoryFullName || "repository"}`
: inconsistentLink
? `Stored link cannot be resolved to a loaded repository profile`
: topCandidate
? `${topCandidate.repositoryFullName} suggested · ${topCandidate.confidence || topCandidate.status || "review required"}`
: "No repository candidate; select one manually";
@@ -549,10 +584,19 @@ function renderServerInventory() {
? `<button class="button primary" data-action="quick-link-server-workload" data-server-id="${attr(server.serverId)}" data-workload-id="${attr(workload.workloadId)}" data-repository="${attr(topCandidate.repositoryFullName)}">${icon("link")}Link to ${escapeHtml(topCandidate.repositoryName || topCandidate.repositoryFullName)}</button>`
: `<button class="button primary" data-action="link-server-workload" data-server-id="${attr(server.serverId)}" data-workload-id="${attr(workload.workloadId)}">${icon("link")}Review & link</button>`;
const evidenceNote = workload.reviewDecisionStale ? "Saved decision is stale because server evidence changed" : workload.classification?.reason || "Awaiting review";
return `<div class="tool-row"><div><strong>${escapeHtml(workload.displayName)}</strong><span>${escapeHtml(detail)} · ${workload.runtime?.running ? "running" : "stopped"}</span><span>${escapeHtml(candidate)}</span><span class="${workload.reviewDecisionStale ? "text-warning" : "meta"}">${escapeHtml(evidenceNote)}</span>${workload.metadata?.composeDefinitionError ? `<span class="text-warning">Compose file found; validation warning: ${escapeHtml(workload.metadata.composeDefinitionError)}</span>` : ""}</div><div class="stack horizontal compact"><span class="status-pill ${statusTone}">${escapeHtml(workload.reviewDecisionStale ? "Decision stale" : linked ? "Linked" : classification)}</span>${linked ? `<button class="button ghost" data-action="edit-deployment-profile" data-profile-id="${attr(workload.link?.profileId || "")}">Open link</button>` : linkButton}</div></div>`;
return `<div class="tool-row"><div><strong>${escapeHtml(workload.displayName)}</strong><span>${escapeHtml(detail)} · ${workload.runtime?.running ? "running" : "stopped"}</span><span>${escapeHtml(candidate)}</span><span class="${workload.reviewDecisionStale || inconsistentLink ? "text-warning" : "meta"}">${escapeHtml(inconsistentLink ? "Reconcile this inventory link before deployment" : evidenceNote)}</span>${workload.metadata?.composeDefinitionError ? `<span class="text-warning">Compose file found; validation warning: ${escapeHtml(workload.metadata.composeDefinitionError)}</span>` : ""}</div><div class="stack horizontal compact"><span class="status-pill ${statusTone}">${escapeHtml(workload.reviewDecisionStale ? "Decision stale" : linked ? "Linked" : inconsistentLink ? "Link unresolved" : classification)}</span>${linked ? `<button class="button ghost" data-action="open-deployment-link" data-repository-id="${attr(linkedRepository.id)}" data-profile-id="${attr(linkedProfile.id)}">Open in repository</button>` : inconsistentLink ? `<button class="button" data-action="plan-server-reconciliation" data-server-id="${attr(server.serverId)}">Reconcile</button>` : linkButton}</div></div>`;
}).join("")
: `<div class="empty-state compact"><p>${server.error ? "No inventory could be read until the SSH connection works." : "Docker returned no containers, Compose projects or DockerMan templates."}</p></div>`;
return `<section class="panel server-inventory-panel"><div class="panel-header"><div><h3>${escapeHtml(server.serverName || server.server?.name || server.serverId)}</h3><span class="meta">${server.running || 0} running · ${server.linked || 0} repository links · ${visibleWorkloads.filter((workload) => !workload.link).length} to review${hiddenCount ? ` · ${hiddenCount} unrelated/system workloads hidden` : ""}</span></div><div class="stack horizontal compact"><span class="status-pill ${server.error ? "danger" : capabilities.docker && capabilities.compose ? "success" : "warning"}">${server.error ? "Scan failed" : escapeHtml(capabilityText)}</span>${server.error ? "" : `<button class="button" data-action="plan-server-reconciliation" data-server-id="${attr(server.serverId)}">${icon("shield")}Review reconciliation</button>`}</div></div><div class="panel-body">${errorBlock}${warnings}<div class="tool-list">${workloads}</div></div></section>`;
const resolvedLinks = visibleWorkloads.filter((workload) => {
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;
const unresolvedLinks = visibleWorkloads.filter((workload) => {
if (!(workload.status === "linked" || workload.link)) return false;
const repository = ui.repositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link?.repositoryFullName || "").toLowerCase());
return !repository?.deploymentProfiles?.some((profile) => profile.id === workload.link?.profileId);
}).length;
return `<section class="panel server-inventory-panel"><div class="panel-header"><div><h3>${escapeHtml(server.serverName || server.server?.name || server.serverId)}</h3><span class="meta">${server.running || 0} running · ${resolvedLinks} visible repository links${unresolvedLinks ? ` · ${unresolvedLinks} unresolved` : ""} · ${visibleWorkloads.filter((workload) => !workload.link).length} to review${hiddenCount ? ` · ${hiddenCount} unrelated/system workloads hidden` : ""}</span></div><div class="stack horizontal compact"><span class="status-pill ${server.error ? "danger" : capabilities.docker && capabilities.compose ? "success" : "warning"}">${server.error ? "Scan failed" : escapeHtml(capabilityText)}</span>${server.error ? "" : `<button class="button" data-action="plan-server-reconciliation" data-server-id="${attr(server.serverId)}">${icon("shield")}Review reconciliation</button>`}</div></div><div class="panel-body">${errorBlock}${warnings}<div class="tool-list">${workloads}</div></div></section>`;
}).join("");
const empty = configuredServers.length
? `<div class="empty-state panel"><h3>Server inventory has not completed</h3><p>ForgeFlow will query Docker directly. A failed connection is shown explicitly instead of being reported as zero deployments.</p><button class="button primary" data-action="scan-server-inventory">Scan servers now</button></div>`
+44
View File
@@ -60,6 +60,24 @@ async function assertSurface(page) {
expect(audit.headings.length).toBeGreaterThan(0);
}
async function assertScrollableWhenOverflowing(page, selector) {
const target = page.locator(selector);
await expect(target).toBeVisible();
await expect(target).toHaveCSS("overflow-y", /auto|scroll/);
let metrics;
await expect.poll(async () => {
metrics = await target.evaluate((element) => ({
connected: element.isConnected,
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
}));
return metrics.connected && metrics.clientHeight > 0;
}).toBe(true);
if (metrics.scrollHeight > metrics.clientHeight + 1) {
await target.evaluate((element) => { element.scrollTop = element.scrollHeight; });
await expect.poll(() => target.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
}
}
test("shell, overview, repositories and settings remain responsive and accessible", async ({ page }, testInfo) => {
await assertSurface(page);
for (const view of ["overview", "deployments", "settings"]) {
@@ -86,13 +104,28 @@ test("repository changes, Git tools and Git Validator complete their primary flo
}
await page.locator('[data-action="repo-tab"][data-tab="validator"]').click();
await expect(page.locator(".validator-score")).toBeVisible();
await assertScrollableWhenOverflowing(page, ".validator-page");
await expect(page.locator("#validator-policy")).toBeVisible();
await page.locator("#validator-policy").selectOption("production");
await expect(page.locator(".validator-hero")).toContainText(/Production policy/i);
await expect(page.locator(".validator-hero")).toContainText(/review required/i);
await page.keyboard.press("Tab");
await expect(page.locator(":focus")).toBeVisible();
});
test("every long application surface retains a working vertical scroll owner", async ({ page }) => {
for (const view of ["overview", "deployments", "diagnostics", "settings"]) {
await page.locator(`.nav-button[data-view="${view}"]`).click();
await assertScrollableWhenOverflowing(page, ".main-canvas");
}
await page.locator('[data-action="select-repo"]').first().click();
for (const tab of ["history", "deployments", "gittools", "validator", "settings"]) {
await page.locator(`[data-action="repo-tab"][data-tab="${tab}"]`).click();
const scrollRoot = page.locator(".repo-content > .tab-page, .repo-content > .validator-page");
if (await scrollRoot.count())
await assertScrollableWhenOverflowing(page, ".repo-content > .tab-page, .repo-content > .validator-page");
}
});
test("deployment inventory supports dense workloads without ambiguous blank cards", async ({ page }) => {
await page.locator('.nav-button[data-action="navigate"][data-view="deployments"]').click();
await expect(page.locator(".deploy-card, .server-inventory-panel .tool-row").first()).toBeVisible();
@@ -102,6 +135,17 @@ test("deployment inventory supports dense workloads without ambiguous blank card
for (let index = 0; index < Math.min(count, 25); index += 1) {
await expect(cards.nth(index)).not.toHaveText(/^\s*$/);
}
const unresolved = page.locator(".tool-row", { hasText: "Legacy Worker" });
await expect(unresolved).toContainText("Link unresolved");
await expect(unresolved).not.toContainText(/^Linked$/);
await expect(page.locator(".server-inventory-panel").first()).toContainText("1 unresolved");
const repositoryLink = page.locator('[data-action="open-deployment-link"]');
if (await repositoryLink.count()) {
await repositoryLink.first().click();
await expect(page.locator('.tab[data-action="repo-tab"][data-tab="deployments"]')).toHaveClass(/active/);
await expect(page.locator(".repository-workloads")).toBeVisible();
await expect(page.locator(".repository-workload-row").first()).toContainText("Repository linked");
}
await assertSurface(page);
});
+14
View File
@@ -23,6 +23,20 @@ test("Git Validator policies enforce score, blockers and enabled checks", () =>
assert.equal(governed.checks[0].blocking, true);
});
test("built-in policies enforce their declared blocking severities", () => {
const finding = [{ id: "readme", status: "warning", category: "Documentation", weight: 5 }];
assert.equal(applyPolicy(finding, { id: "minimal" }, []).checks[0].blocking, false);
for (const id of ["standard", "strict", "production"])
assert.equal(applyPolicy(finding, { id }, []).checks[0].blocking, true, `${id} must block active warnings`);
});
test("documented suppressions remove active blockers", () => {
const now = new Date("2026-07-01T00:00:00.000Z");
const suppression = validateSuppression({ checkId: "readme", reason: "Tracked remediation work", author: "Jens", expiresAt: "2026-07-08T00:00:00.000Z", evidence: "ticket:FF-7" }, normalizePolicy({ id: "standard" }), now);
const check = applyPolicy([{ id: "readme", status: "warning", category: "Documentation", weight: 5 }], { id: "standard" }, [suppression], now).checks[0];
assert.equal(check.suppressed, true);
assert.equal(check.blocking, false);
});
test("suppressions require accountable evidence and reactivate after expiry", () => {
const now = new Date("2026-07-01T00:00:00.000Z");
const suppression = validateSuppression({ checkId: "signed-tags", reason: "Tracked under release hardening", author: "Jens", ticket: "FF-42", expiresAt: "2026-07-08T00:00:00.000Z", scope: "repository", evidence: "sha:abc" }, normalizePolicy({ id: "standard" }), now);
+18
View File
@@ -100,6 +100,24 @@ test("Git Validator recognizes remote aliases and secret-shaped tracked paths",
assert.equal(isSensitiveTrackedPath(".env.example"), false);
});
test("Git Validator rejects stale or forged repair requests", async () => {
const validator = new GitValidatorService({ git: new GitService() });
validator.scan = async () => ({
checks: [{ id: "local-safety", fixAction: "configure-local-safety", status: "warning" }],
});
assert.equal(
(await validator.resolveRepairCheck({}, { id: "local-safety", fixAction: "configure-local-safety" })).id,
"local-safety",
);
await assert.rejects(
validator.resolveRepairCheck({}, { id: "local-safety", fixAction: "align-origin" }),
/stale/i,
);
await assert.rejects(
validator.resolveRepairCheck({}, { id: "resolved-check", fixAction: "align-origin" }),
/resolved|no longer repairable/i,
);
});
test("Git Validator reports reproducibility, CI and editor hygiene and creates reviewable defaults", async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), "forgeflow-hygiene-"));
t.after(() => rm(root, { recursive: true, force: true }));
+13
View File
@@ -156,6 +156,19 @@ test('uses a release-provided browser download URL without requesting metadata a
assert.equal(requested, 'https://gitea.example.test/attachments/direct.exe');
});
test('rewrites Gitea internal HTTP release URLs to the configured public origin', async () => {
const service = new GiteaService(makeStore());
let requested = '';
service.downloadAuthenticated = async (pathname) => {
requested = pathname;
return Buffer.from('asset');
};
await service.downloadReleaseAsset('Jens', 'ForgeFlow', 107, 412, {
downloadUrl: 'http://192.168.10.150:3000/Jens/ForgeFlow/releases/download/v0.10.1/ForgeFlow.exe',
});
assert.equal(requested, 'https://gitea.example.test/Jens/ForgeFlow/releases/download/v0.10.1/ForgeFlow.exe');
});
test('creates conservative default branch protection rules', async () => {
const service = new GiteaService(makeStore());
let request = null;
+21
View File
@@ -28,3 +28,24 @@ test('repository monitor establishes a baseline and emits only on later changes'
await monitor.tick();
assert.equal(changes.length, 2);
});
test('repository monitor checks multiple repositories concurrently with a bounded worker pool', async () => {
let active = 0;
let peak = 0;
const git = {
status: async (localPath) => {
active += 1;
peak = Math.max(peak, active);
await new Promise((resolve) => setTimeout(resolve, 15));
active -= 1;
return { localPath, revision: 1 };
},
statusFingerprint: (status) => String(status.revision)
};
const store = { data: { preferences: { autoRefresh: true, repositoryPollSeconds: 2 } } };
const monitor = new RepositoryMonitor({ store, git });
monitor.setPaths(Array.from({ length: 10 }, (_, index) => `/repo-${index}`));
await monitor.tick();
assert.equal(peak, 4);
assert.equal(active, 0);
assert.equal(monitor.fingerprints.size, 10);
});
+28 -1
View File
@@ -6,7 +6,11 @@ import path from "node:path";
import { createRequire } from "node:module";
import { EventEmitter } from "node:events";
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
const require = createRequire(import.meta.url);
const execFileAsync = promisify(execFile);
const {
UpdateService,
waitForUpdaterStarted,
@@ -318,7 +322,7 @@ test("PowerShell helper replaces an existing launching status with a Windows-saf
);
assert.match(
script,
/System\.IO\.File\]::Replace\(\$temporary, \$StatusPath, \$null\)/,
/System\.IO\.File\]::Replace\(\$temporary, \$StatusPath, \$backup\)/,
);
assert.match(
script,
@@ -332,6 +336,28 @@ test("PowerShell helper replaces an existing launching status with a Windows-saf
assert.match(script, /Handshake-only verification completed successfully/);
});
test("binary helper confirms startup through real Windows PowerShell", { skip: process.platform !== "win32" }, async () => {
const temp = await mkdtemp(path.join(os.tmpdir(), "forgeflow-binary-handshake-"));
const statusPath = path.join(temp, "status.json");
const logPath = path.join(temp, "helper.log");
await writeFile(statusPath, JSON.stringify({ state: "launching", updateId: "binary-handshake" }));
const powershell = path.join(process.env.SystemRoot || process.env.WINDIR, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
const scriptPath = fileURLToPath(new URL("../scripts/apply-binary-update.ps1", import.meta.url));
const { stdout, stderr } = await execFileAsync(powershell, [
"-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", scriptPath,
"-BinaryPath", path.join(temp, "unused.exe"), "-ExpectedSha256", "0".repeat(64),
"-ExpectedVersion", "9.9.9", "-CurrentExecutable", path.join(temp, "unused-current.exe"),
"-Portable", "False", "-ParentPid", "999999", "-LogPath", logPath,
"-StatusPath", statusPath, "-UpdateId", "binary-handshake", "-HandshakeOnly"
], { windowsHide: true });
assert.equal(stdout, "");
assert.equal(stderr, "");
const status = JSON.parse(await readFile(statusPath, "utf8"));
assert.equal(status.updateId, "binary-handshake");
assert.equal(status.state, "started");
assert.match(await readFile(logPath, "utf8"), /Handshake-only verification completed successfully/);
await rm(temp, { recursive: true, force: true });
});
test("early helper exit reports the helper log instead of only an exit code", async () => {
const temp = await mkdtemp(
path.join(os.tmpdir(), "forgeflow-update-log-tail-"),
@@ -518,6 +544,7 @@ test("Windows release pipeline preserves optional signing checks and emits prove
assert.match(signatureSource, /FORGEFLOW_EXPECTED_PUBLISHER/);
assert.match(signatureSource, /TimestampSubject/);
assert.match(signatureSource, /Signed release verification failed/);
assert.match(signatureSource, /Authenticode inspection unavailable/);
assert.match(checksumSource, /provenance\.json/);
assert.match(checksumSource, /sbom\.cdx\.json/);
assert.match(checksumSource, /CycloneDX/);