5 Commits
Author SHA1 Message Date
NuklearRabbit bffad670ef fix: complete server pull deployment setup
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-09 00:43:08 +02:00
NuklearRabbit 4c21616e72 fix: harden deployment discovery and preflight
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-08 23:59:27 +02:00
NuklearRabbit f866b12fbf fix: make updater checksum verification self-contained
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-01 19:13:05 +02:00
NuklearRabbit f7d6bc374f fix: reconcile server deployments across repository views
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-01 18:57:55 +02:00
NuklearRabbit 958d5b84d3 fix: make Windows updater helper launch reliable
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-01 17:59:55 +02:00
27 changed files with 494 additions and 79 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
ForgeFlow is een Windows-desktopapp voor wie Git, Gitea en eigen Docker- of Unraid-servers gebruikt. Je ziet in één werkruimte wat lokaal gewijzigd is, wat op Gitea staat en welke exacte commit op de server draait. ForgeFlow begeleidt je daarna veilig door review, commit, push, deployment en verificatie. 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.5** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest) > Huidige release: **0.10.10** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest)
![ForgeFlow release-overzicht](docs/screenshots/overview.png) ![ForgeFlow release-overzicht](docs/screenshots/overview.png)
+27 -22
View File
@@ -1,4 +1,4 @@
ForgeFlow 0.10.5 source manifest ForgeFlow 0.10.10 source manifest
SHA-256 BYTES PATH SHA-256 BYTES PATH
(The manifest excludes itself, dependencies and generated release artifacts.) (The manifest excludes itself, dependencies and generated release artifacts.)
cedceb71eb846d99c7c4019031833c1c7f93b84a1c6073aec7d2435dc744ca3d 703 .gitea/workflows/quality.yml cedceb71eb846d99c7c4019031833c1c7f93b84a1c6073aec7d2435dc744ca3d 703 .gitea/workflows/quality.yml
@@ -34,10 +34,15 @@ a0cd06a96f23a94e118feb012be0fa1ac51345951cb2ba8e67fb8c889c4c342a 5007
f79908fb3dad98c38030c6e6be7c79a1999e0478ed9c2496923891954438daa1 4581 docs/RELEASE_AUDIT_0.6.0.md f79908fb3dad98c38030c6e6be7c79a1999e0478ed9c2496923891954438daa1 4581 docs/RELEASE_AUDIT_0.6.0.md
979a0b8e129979be6b265e8571d0a3c1e9ddd4ddb6b0bf55ae748d3478e51854 2296 docs/RELEASE_NOTES_0.10.0.md 979a0b8e129979be6b265e8571d0a3c1e9ddd4ddb6b0bf55ae748d3478e51854 2296 docs/RELEASE_NOTES_0.10.0.md
0eb44bda2209a5979a6ac693ac4cd4d235c0015031e54b9e895990f37bf60054 1433 docs/RELEASE_NOTES_0.10.1.md 0eb44bda2209a5979a6ac693ac4cd4d235c0015031e54b9e895990f37bf60054 1433 docs/RELEASE_NOTES_0.10.1.md
5d3240169765e3fb1d3cd391d09547101227e76dd4670ee46be8ca3a21553a03 894 docs/RELEASE_NOTES_0.10.10.md
8d713471a437a8a55b00d7e1dd95290680862107bc4e586cf27d727f6274e46c 577 docs/RELEASE_NOTES_0.10.2.md 8d713471a437a8a55b00d7e1dd95290680862107bc4e586cf27d727f6274e46c 577 docs/RELEASE_NOTES_0.10.2.md
0942fb2c4a4f972296423b5232687f7389e2c6417a9d48a3244beef9dec907b9 1164 docs/RELEASE_NOTES_0.10.3.md 0942fb2c4a4f972296423b5232687f7389e2c6417a9d48a3244beef9dec907b9 1164 docs/RELEASE_NOTES_0.10.3.md
8f4a0fe6dc250ae210cc2fc1c57c46091822ae6c2a58caa76e0091f255f9f30d 775 docs/RELEASE_NOTES_0.10.4.md 8f4a0fe6dc250ae210cc2fc1c57c46091822ae6c2a58caa76e0091f255f9f30d 775 docs/RELEASE_NOTES_0.10.4.md
05ed618f5a74a854363930128ca98939808517eedd28b9a508660a0c46e91d97 884 docs/RELEASE_NOTES_0.10.5.md 05ed618f5a74a854363930128ca98939808517eedd28b9a508660a0c46e91d97 884 docs/RELEASE_NOTES_0.10.5.md
94bfb2783c1befad1197e1c5e32fc002222c94a28d70d48360a8d53ecd260d5c 772 docs/RELEASE_NOTES_0.10.6.md
226a3b2d4bc7f54841749a283fcdd71b643cd585ba74d673084bee829fef6ea2 903 docs/RELEASE_NOTES_0.10.7.md
4be29ad0cb7ebcf5625172b8d2bd7a67cdc6d64d3a94e2c3f0656cdfd42dcb7a 642 docs/RELEASE_NOTES_0.10.8.md
fb64517aa64d3ecfe8b51b09e198c2c9fbba96d0cd24a87301c7f6dea3076095 961 docs/RELEASE_NOTES_0.10.9.md
a0c00ff76acd1682bb5e0e8dcf6589c9480da436c9c6d30780a1ed58b4dad94f 1770 docs/RELEASE_NOTES_0.2.0.md a0c00ff76acd1682bb5e0e8dcf6589c9480da436c9c6d30780a1ed58b4dad94f 1770 docs/RELEASE_NOTES_0.2.0.md
5773ead01aa4c522c556295553787482d01b1f5242f053b2c61f120c4de4fa76 5963 docs/RELEASE_NOTES_0.3.0.md 5773ead01aa4c522c556295553787482d01b1f5242f053b2c61f120c4de4fa76 5963 docs/RELEASE_NOTES_0.3.0.md
d46de73cf6c4cd5c2ba3f455a7a2af2e0d64ee9d94a97fd1a0bfb44e35c1624a 1093 docs/RELEASE_NOTES_0.3.1.md d46de73cf6c4cd5c2ba3f455a7a2af2e0d64ee9d94a97fd1a0bfb44e35c1624a 1093 docs/RELEASE_NOTES_0.3.1.md
@@ -97,23 +102,23 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532
106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf 106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf
2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json 2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE 4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE
1f0f388df4397e548887bbc7579fd3c864581b86469c01703201ece7a6cbf931 13667 main.cjs 970c2afb09f2b9d1e97a5a92c0bd824c054b72298c5a5d234396d5e34de065ae 13870 main.cjs
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md 91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
790e0d43b1e4858b72132657bafd83a55c044732cfffbc4ef6e622d74d453a86 179806 package-lock.json 9c2567281d405b608b5381d79360288601a2a4211bdacf5a8cae990caa952f98 179808 package-lock.json
9567d1f28ab27a02361fbe7fce8e17c13558e6c0bfa58763ec27a2b5728dcf42 5512 package.json da986418af193d1334e4ab9e09a5dab41ebe32aace73127a145be834ef20bbd9 5704 package.json
2a597a5704c576783b8a72407fbc377fa7506b36a4596ea7f7bce126e394f837 1326 playwright.config.mjs 2a597a5704c576783b8a72407fbc377fa7506b36a4596ea7f7bce126e394f837 1326 playwright.config.mjs
69318fdf054be7aa2fe86ead9847da9da65745d8d5de548c8346f3ba0afc4892 12175 preload.cjs 69318fdf054be7aa2fe86ead9847da9da65745d8d5de548c8346f3ba0afc4892 12175 preload.cjs
abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd
f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1 f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1
688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1 688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1
49a88c060629741cb6e16e597a2d432ce4350d0e1fb4b574802b67d6b872ebe6 10313 README.md 0438b8de9bb3409543e4f3bc121bd3ccc094a5a879655db11aef1403c323f96e 10314 README.md
8f8dc5ff0c2967563457e4027d795f6f515253e0a459ecca5a9f5bcf0b658870 14650 reports/architecture-audit.json 8f8dc5ff0c2967563457e4027d795f6f515253e0a459ecca5a9f5bcf0b658870 14650 reports/architecture-audit.json
5e923f0096895f588fe66b187779cdb7cb5af6644010c6dcc4d55876930ae376 1220 reports/architecture-audit.md 5e923f0096895f588fe66b187779cdb7cb5af6644010c6dcc4d55876930ae376 1220 reports/architecture-audit.md
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs 509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
8fe616851e6a089a59fd134905465019c33d26f6408ebd0613fff587b6c589e0 4400 scripts/apply-binary-update.ps1 d0745072321aca2c80f44460974a7926715a9f429164aaf7660dced40b52c736 4790 scripts/apply-binary-update.ps1
706b49e001837c91134044d3af132d58fb14955245bc1d5c36b413eb65867002 10486 scripts/apply-source-update.ps1 404863bcbe7292355662e3a326455df864d7279badc29f90866a3b837420df54 10745 scripts/apply-source-update.ps1
02e924227f6cad3777fd06660230c85df590d8ce95e134194a4d18970a240b88 4145 scripts/architecture-audit.mjs 02e924227f6cad3777fd06660230c85df590d8ce95e134194a4d18970a240b88 4145 scripts/architecture-audit.mjs
4490bed84761f76e1fd87ee3117fe53e82e60b7760329c97d68772d6d820ebae 8521 scripts/audit-installed-deployments.cjs 47a5b16e95934bfe510c18bf94547ae65acb980c0f0506ae156d1a486dfbdfc9 8985 scripts/audit-installed-deployments.cjs
6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs 6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs
0244d42896b8c44f734d0bb6cdcb29b5981342be2f070ce89f8d9eaf3e4d49e6 1793 scripts/generate-source-manifest.mjs 0244d42896b8c44f734d0bb6cdcb29b5981342be2f070ce89f8d9eaf3e4d49e6 1793 scripts/generate-source-manifest.mjs
842436680521311594e798848b050ae4e488d0595f0de57315f6ec081c049fb9 1266 scripts/prune-dist.mjs 842436680521311594e798848b050ae4e488d0595f0de57315f6ec081c049fb9 1266 scripts/prune-dist.mjs
@@ -122,7 +127,7 @@ b83d443f5724ac15393567f3a688aed8315fbe3e5966832c864a9466e0669464 8102
c76507857292c5713e1c699cf02e24b80265da39af2cecd148034bdb874adbb6 5246 scripts/test-authenticode-chain.ps1 c76507857292c5713e1c699cf02e24b80265da39af2cecd148034bdb874adbb6 5246 scripts/test-authenticode-chain.ps1
4393f7dc5f417e6d601a68238f4e26791799a3634acec228fe4d79deaee85eb5 3109 scripts/validate-installed-connections.cjs 4393f7dc5f417e6d601a68238f4e26791799a3634acec228fe4d79deaee85eb5 3109 scripts/validate-installed-connections.cjs
e6127e1e62f39c70ddb1abf72f4d7e7b8e3f19ff1f219e1a3660353c2e0cdfac 2411 scripts/verify-release-signatures.mjs e6127e1e62f39c70ddb1abf72f4d7e7b8e3f19ff1f219e1a3660353c2e0cdfac 2411 scripts/verify-release-signatures.mjs
b7fc78dfc7c94d62822109cb3c8c2e9566c9506b0a08f6cff8141ee93a04e530 18854 scripts/verify.mjs 2a80bfc448aed0a50597947afc75ae42c6830d5bcfd5c5f712957c85268b23d3 20660 scripts/verify.mjs
0b9f03ba3c67ff7cdb2916a902ad8ce25e81a7c90b210e4ae52d2ad029efabf3 2353 scripts/write-release-checksums.mjs 0b9f03ba3c67ff7cdb2916a902ad8ce25e81a7c90b210e4ae52d2ad029efabf3 2353 scripts/write-release-checksums.mjs
619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1 619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1
dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs
@@ -151,18 +156,18 @@ fc7a5a2a42579a311f60a96bec4776942bf3f63428928820029b0dcf4874d1aa 2745
17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs 17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs
52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs 52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs
afef3841a3948b2121f8fba809aae4ea3da71bd2fda86973ba50200a5b1f89b2 14894 src/main/ssh-service.cjs afef3841a3948b2121f8fba809aae4ea3da71bd2fda86973ba50200a5b1f89b2 14894 src/main/ssh-service.cjs
90504e27bfabcd2f927ba29930fad9bb65520fdf871a2a9e49cbcefcb837d84c 25492 src/main/unraid-access-methods.cjs d85780a19cac9ed5c89721a25916c35235e76012159363ff61924dba4215d171 25801 src/main/unraid-access-methods.cjs
2ede80cd1565a7f2c282cc58d35dc0889d58d7465346bc723026b9c8be4df0ac 9501 src/main/unraid-deploy-key-host.cjs 2ede80cd1565a7f2c282cc58d35dc0889d58d7465346bc723026b9c8be4df0ac 9501 src/main/unraid-deploy-key-host.cjs
803a079499f7b8148495209dea43b505f6eb9bb587de19e82054e47183186c6e 30461 src/main/unraid-deployment-methods.cjs 803a079499f7b8148495209dea43b505f6eb9bb587de19e82054e47183186c6e 30461 src/main/unraid-deployment-methods.cjs
2e63fdc0be0bf4d8e5c3d7d45ff5811d786b0821a08b82f511f1301696e12c9d 17157 src/main/unraid-deployment-service.cjs b8a6ef72cfd5c5405e3f1a48848b5ef1575ca82ebbe8cad816dff0dc7335c8ec 17184 src/main/unraid-deployment-service.cjs
f0861356c1ff4ca361c7004c1f7d935858f51cc74335a7ad2136021f2e612220 36016 src/main/unraid-inventory-methods.cjs c3439857c985a44f8298f76aca46ad71f5fa30eabb32ddca9d18fc4f65dc4a38 38809 src/main/unraid-inventory-methods.cjs
9e682411f73450f595b5cc4dfb28939c6c58b9547d0a91e287c3efb4955e8840 26299 src/main/unraid-preflight-methods.cjs 2c0cf07921ca7ee5a9085ced44498c2e6798e5cc1e8a5ecf704c3cecabe39a25 27607 src/main/unraid-preflight-methods.cjs
d45220176aed72d692f9ae5534f9d40bcc359a2d08e025e74a3b3b505b8b9ed4 16559 src/main/unraid-runtime-methods.cjs d45220176aed72d692f9ae5534f9d40bcc359a2d08e025e74a3b3b505b8b9ed4 16559 src/main/unraid-runtime-methods.cjs
d4b3a07eeca687a49544a94ea574f7c6f7e0bc3aa9311b614d5244a468ca4a1b 11307 src/main/unraid-state-methods.cjs d4b3a07eeca687a49544a94ea574f7c6f7e0bc3aa9311b614d5244a468ca4a1b 11307 src/main/unraid-state-methods.cjs
b654a9e45044ad32c61fabe4a6d897288615ec83739b53e3241ff881e32f56bd 21677 src/main/update-service.cjs a387ac9735624ef89d964ea59e035dae256b9915ba2cee22ae5cbc2894f5808a 22000 src/main/update-service.cjs
b5c304531bec358d059189a27cd9db8fa20cefb7f817e5eb0287001f7353f6a7 985 src/renderer/actions/command.js b5c304531bec358d059189a27cd9db8fa20cefb7f817e5eb0287001f7353f6a7 985 src/renderer/actions/command.js
d0bf607dd1de9d55f2947d0adf0997cd3ca5c269d10a5362cc1d8bc4d1a2a8ae 6706 src/renderer/actions/deployment-operation.js d0bf607dd1de9d55f2947d0adf0997cd3ca5c269d10a5362cc1d8bc4d1a2a8ae 6706 src/renderer/actions/deployment-operation.js
0f2070aa3b5c404aedf643837dfd7c5d547e8f45b2e9c97e4cfcf11951555474 17705 src/renderer/actions/deployment-profile.js 33e65457e44708cecbe859b8c8a0c51b4ab33219c3b7a9f9dad884089f07d367 17904 src/renderer/actions/deployment-profile.js
48bed91dd2a85bb51ee7307f7acc3b79c881ce8cf63b22ba79d5d079b265eb4b 7785 src/renderer/actions/inventory.js 48bed91dd2a85bb51ee7307f7acc3b79c881ce8cf63b22ba79d5d079b265eb4b 7785 src/renderer/actions/inventory.js
4227a05a20580a31127d2c929640defc3d36e8e3e89d6be830aab1940da81082 12267 src/renderer/actions/recovery.js 4227a05a20580a31127d2c929640defc3d36e8e3e89d6be830aab1940da81082 12267 src/renderer/actions/recovery.js
cdfaacdcd5ae04b0e5c79fefa21f5e09d5c810bcea504c5b6e1d6b744182ff84 15567 src/renderer/actions/setup-and-settings.js cdfaacdcd5ae04b0e5c79fefa21f5e09d5c810bcea504c5b6e1d6b744182ff84 15567 src/renderer/actions/setup-and-settings.js
@@ -177,10 +182,10 @@ eef2f269ba4fbb76bf66ad328d481b461255d0acb753b30878dd4d4eaac57dc6 6924
a84da5aecbb16ce7983dba1f6d6aab1bf47b2e9a87c2933fa1afb8123f7ef7d6 1497 src/renderer/index.html a84da5aecbb16ce7983dba1f6d6aab1bf47b2e9a87c2933fa1afb8123f7ef7d6 1497 src/renderer/index.html
06180d9656dd254edfb6949c397f8e313954fc560ddcb22b3a35fce3c3e35655 21350 src/renderer/mock-bridge.js 06180d9656dd254edfb6949c397f8e313954fc560ddcb22b3a35fce3c3e35655 21350 src/renderer/mock-bridge.js
5a2c0616a8a0ba23c182a8928abdf40a69612ce34c4cba47b1fd5f21fe5843d4 28617 src/renderer/mock-deployment-bridge.js 5a2c0616a8a0ba23c182a8928abdf40a69612ce34c4cba47b1fd5f21fe5843d4 28617 src/renderer/mock-deployment-bridge.js
f1d341459689f879b403b3769bca37209afe0f227cc8ac1dad7db7a96c747013 20032 src/renderer/mock-repository-bridge.js 041daf59931aff37075d9ee238bdf9c397c4dd799580540f30216276f95cfd7a 20033 src/renderer/mock-repository-bridge.js
94fa265c2fe9ca8d644f0ce9b620b6f85d9b25dca5802c4e9195b66dcbe80120 6522 src/renderer/operations.js 94fa265c2fe9ca8d644f0ce9b620b6f85d9b25dca5802c4e9195b66dcbe80120 6522 src/renderer/operations.js
1fc4c28859f7283aa2b771a50f1fd26addc8862b97a09250f806930ab4483f7a 79874 src/renderer/styles.css 1fc4c28859f7283aa2b771a50f1fd26addc8862b97a09250f806930ab4483f7a 79874 src/renderer/styles.css
5230c238acb87381303ec30026906cf8a7163c33380859d351b268adfc26b4f4 100196 src/renderer/views.js 4fbd3d7af3279a02c1da508758f6c7d9ca8de3d82f869be8439030ec81e8d941 101429 src/renderer/views.js
0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs 0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs
5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs 5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs
029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs 029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs
@@ -194,7 +199,7 @@ f8853dce6fdf360d5df2fbe2b6df3e5687630c807fee5ba8436679b34ec737ea 2436
058aeaa5d9bfe377c7e322f213c7871ecc4151b5d08ef790992f4ee28d857658 743 START-FORGEFLOW-OVERLAY.ps1 058aeaa5d9bfe377c7e322f213c7871ecc4151b5d08ef790992f4ee28d857658 743 START-FORGEFLOW-OVERLAY.ps1
f5b0ea887fcdeadec78c1ad49b0ec7979723562f5c0b730703acb77a37281ee0 1009 tests/acceptance.test.mjs f5b0ea887fcdeadec78c1ad49b0ec7979723562f5c0b730703acb77a37281ee0 1009 tests/acceptance.test.mjs
a4e5947204ff6878e601e32477bc85b53cd0153baf95a161c8935b6e5466c257 1155 tests/audit-service.test.mjs a4e5947204ff6878e601e32477bc85b53cd0153baf95a161c8935b6e5466c257 1155 tests/audit-service.test.mjs
b46c9624c20bae767e50b4ab85e6b0ea6a9ee4a5c5c30f55554217f25134f1e7 11218 tests/browser/forgeflow.spec.mjs e8802b78b94af7245f7752e0a5f8697064aad2aae8bd002195ae052d69e59670 11619 tests/browser/forgeflow.spec.mjs
454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 4318 tests/clone-target.test.mjs 454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 4318 tests/clone-target.test.mjs
ac17f8bbe9e388b80abef7792c8b184a1fd482c93f13d23a478e433961020f75 17214 tests/config-store.test.mjs ac17f8bbe9e388b80abef7792c8b184a1fd482c93f13d23a478e433961020f75 17214 tests/config-store.test.mjs
f1463326aee79842d265687ae628189ce54e92544600f2bd14073780287cfb14 2502 tests/configuration-backup.test.mjs f1463326aee79842d265687ae628189ce54e92544600f2bd14073780287cfb14 2502 tests/configuration-backup.test.mjs
@@ -215,18 +220,18 @@ caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392
96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs 96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs
1b6c920e18a248f78acaed6187197c88ec8d911b62d5e2a9f8ad57b91ae80499 11827 tests/preflight.test.mjs 1b6c920e18a248f78acaed6187197c88ec8d911b62d5e2a9f8ad57b91ae80499 11827 tests/preflight.test.mjs
0cb884cf62c1cb02cf59a81662be055bcb5339d176de85e2a3eeb8e8573e11b3 6435 tests/production-acceptance.test.mjs 0cb884cf62c1cb02cf59a81662be055bcb5339d176de85e2a3eeb8e8573e11b3 6435 tests/production-acceptance.test.mjs
629ba26395c0b49cc5fdee6b0646d75369eb6338e1cc7b59509938f97eea08ec 9601 tests/renderer-workflow.test.mjs d4980aa7d2d364793f38771011383348be472d5447b6ee88a4d9e5f3a3b3ffa9 9911 tests/renderer-workflow.test.mjs
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs 2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs
62e6d3df7051778124648c808caec634a6b2c439660dc556392ea10beec926d1 1870 tests/repository-monitor.test.mjs 62e6d3df7051778124648c808caec634a6b2c439660dc556392ea10beec926d1 1870 tests/repository-monitor.test.mjs
ebd3c0825bc9e2f1690cfd51e93a96bd33e939eb9c546aa373dd948b8cf71a69 7781 tests/repository-service.test.mjs ebd3c0825bc9e2f1690cfd51e93a96bd33e939eb9c546aa373dd948b8cf71a69 7781 tests/repository-service.test.mjs
d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463 tests/security-validation.test.mjs d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463 tests/security-validation.test.mjs
bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs
e631e9ca49a5bac7075860aac2ff4d377a32a78377b70e06ecf833f0f192fd5f 11552 tests/server-inventory-branches.test.mjs 12cb3b240bdd0922566323c0014838ca067ad10d9d4009943165ae2c4e93bc6f 11786 tests/server-inventory-branches.test.mjs
020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs 020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs
0d1bc4d623ce299337736c577ec61c8ffd6974ebe20335b72838d10eae35ecb1 7993 tests/ssh-service.test.mjs 0d1bc4d623ce299337736c577ec61c8ffd6974ebe20335b72838d10eae35ecb1 7993 tests/ssh-service.test.mjs
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs 8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs
4182b61e395aff310b9a964c973a43c3566df0b44c454054c3abdd9459e86e3b 49134 tests/unraid-deployment.test.mjs 7b8e9118c91503c499e3194223f98ab3f689d6071e7f2316be2b700db592e06f 53633 tests/unraid-deployment.test.mjs
074031cac5018aa74c2bb57ecaf114521d69da707aa6170dda65983690bb1691 21487 tests/update-service.test.mjs bf95071f8d8bec6dcf2c889f1f9d83be1b56178dc0d2be84da555848b6319cbc 25650 tests/update-service.test.mjs
9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs 9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs
7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs 7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs
8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea 767 UPDATE_FROM_0.3.2.md 8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea 767 UPDATE_FROM_0.3.2.md
+10
View File
@@ -0,0 +1,10 @@
# ForgeFlow 0.10.10
## Complete server-pull deployment repair
- Missing repository-scoped read-only deploy keys can be provisioned and verified from Unraid against the exact Gitea branch.
- A repository deployment root can now remain above its Compose working directory without breaking workload recognition or being overwritten by inventory refresh.
- Nested Compose files are preserved as repository-relative deployment paths, including Ludarium, Launchpad and ITWorx MCP Hub layouts.
- The **Fix write access** action now receives its permission-report parser correctly instead of reporting a false write-access failure.
- Server-pull preflight proves every required deployment file at the exact Gitea commit before any container activation starts.
- Runtime secrets remain in server-side `.env` files and preserved appdata paths; no secret values are written to Git or diagnostic output.
+9
View File
@@ -0,0 +1,9 @@
# ForgeFlow 0.10.6
## Permanent Windows updater launch repair
- ForgeFlow no longer launches hidden PowerShell update helpers with Node's defective Windows `detached` process mode.
- Binary and source updater processes remain hidden, are explicitly unreferenced after their verified handshake, and continue independently when ForgeFlow closes.
- A real Windows regression test now exercises the exact production Node spawn options instead of using a different process API.
- Startup is still fail-closed: ForgeFlow remains open unless the request-scoped helper status reaches `started`.
- Versions 0.10.4 and 0.10.5 need a one-time direct installation of 0.10.6 because their installed launcher cannot execute its own helper; updates after 0.10.6 use the repaired path.
+10
View File
@@ -0,0 +1,10 @@
# ForgeFlow 0.10.7
## Reliable server-to-repository recognition
- Live, running workloads with one unique exact provenance or runtime-identity match are now linked automatically during normal server discovery.
- Automatic adoption creates only ForgeFlow configuration and observed state; it performs no container changes and never automatically removes stale profiles.
- Ambiguous, duplicate, external and monitoring-only workloads remain behind explicit **Review & link** confirmation.
- Every linked repository now displays an `S` deployment badge with its profile count in the repository sidebar.
- The repository release rail reports **Linked** with container and server identity even when a legacy workload has no verifiable live commit yet.
- DevRunbook-style DockerMan deployments therefore show the same linked relationship in Deployments, the repository sidebar and the repository workspace.
+9
View File
@@ -0,0 +1,9 @@
# ForgeFlow 0.10.8
## Self-contained checksum verification
- Binary and source update helpers no longer depend on the optional PowerShell `Get-FileHash` cmdlet.
- Both helpers calculate checksums directly with the built-in .NET SHA-256 implementation.
- A real Windows regression test clears `PSModulePath` and verifies the downloaded binary successfully in that minimal environment.
- The helper still validates the exact published checksum before waiting for ForgeFlow to exit or changing installed files.
- This release retains the reliable non-detached launcher and server-to-repository recognition improvements from 0.10.6 and 0.10.7.
+10
View File
@@ -0,0 +1,10 @@
# ForgeFlow 0.10.9
## Reliable deployment inventory and preflight
- Unraid inventory now includes containers without healthchecks. Docker's complete JSON state is parsed safely instead of using a failing Go-template lookup.
- ForgeFlow is single-instance: opening it again focuses the existing window, preventing concurrent inventory scans and configuration writes.
- Server pull verifies required Compose files or the Dockerfile at the exact Gitea commit before any deployment operation starts.
- Server-pull verification now separates deploy-ready access from optional live-SHA and runtime-health evidence. A recoverable workload is no longer shown as blocked merely because parity is not yet provable.
- Deployment cards and audit output show concrete access blockers and non-blocking warnings instead of a generic incomplete result.
- All discovery, verification and preflight checks remain non-destructive; no containers are changed during these checks.
+5
View File
@@ -41,6 +41,10 @@ let diagnostics;
let configStore; let configStore;
let tray; let tray;
let quitCleanupStarted = false; let quitCleanupStarted = false;
const ownsSingleInstanceLock = app.requestSingleInstanceLock();
if (!ownsSingleInstanceLock) app.quit();
else app.on("second-instance", () => showMainWindow());
function broadcast(channel, payload) { function broadcast(channel, payload) {
for (const window of BrowserWindow.getAllWindows()) { for (const window of BrowserWindow.getAllWindows()) {
@@ -171,6 +175,7 @@ function createWindow() {
app app
.whenReady() .whenReady()
.then(async () => { .then(async () => {
if (!ownsSingleInstanceLock) return;
session.defaultSession.webRequest.onHeadersReceived((details, callback) => { session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
callback({ callback({
responseHeaders: { responseHeaders: {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "forgeflow", "name": "forgeflow",
"version": "0.10.5", "version": "0.10.10",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "forgeflow", "name": "forgeflow",
"version": "0.10.5", "version": "0.10.10",
"dependencies": { "dependencies": {
"ssh2": "1.17.0" "ssh2": "1.17.0"
}, },
+6 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "forgeflow", "name": "forgeflow",
"version": "0.10.5", "version": "0.10.10",
"private": true, "private": true,
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.", "description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
"main": "main.cjs", "main": "main.cjs",
@@ -110,6 +110,11 @@
"docs/RELEASE_NOTES_0.10.3.md", "docs/RELEASE_NOTES_0.10.3.md",
"docs/RELEASE_NOTES_0.10.4.md", "docs/RELEASE_NOTES_0.10.4.md",
"docs/RELEASE_NOTES_0.10.5.md", "docs/RELEASE_NOTES_0.10.5.md",
"docs/RELEASE_NOTES_0.10.6.md",
"docs/RELEASE_NOTES_0.10.7.md",
"docs/RELEASE_NOTES_0.10.8.md",
"docs/RELEASE_NOTES_0.10.9.md",
"docs/RELEASE_NOTES_0.10.10.md",
"docs/CURRENT_STATE.md", "docs/CURRENT_STATE.md",
"docs/MUTATION_MODEL.md", "docs/MUTATION_MODEL.md",
"docs/RELEASING.md", "docs/RELEASING.md",
+17 -2
View File
@@ -8,7 +8,8 @@ param(
[Parameter(Mandatory = $true)][string]$LogPath, [Parameter(Mandatory = $true)][string]$LogPath,
[Parameter(Mandatory = $true)][string]$StatusPath, [Parameter(Mandatory = $true)][string]$StatusPath,
[Parameter(Mandatory = $true)][string]$UpdateId, [Parameter(Mandatory = $true)][string]$UpdateId,
[switch]$HandshakeOnly [switch]$HandshakeOnly,
[switch]$VerifyOnly
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@@ -51,6 +52,16 @@ function Write-UpdateState {
function Write-Log([string]$Message) { function Write-Log([string]$Message) {
"{0} {1}" -f [DateTime]::UtcNow.ToString("o"), $Message | Add-Content -LiteralPath $LogPath -Encoding UTF8 "{0} {1}" -f [DateTime]::UtcNow.ToString("o"), $Message | Add-Content -LiteralPath $LogPath -Encoding UTF8
} }
function Get-Sha256([string]$Path) {
$stream = [IO.File]::OpenRead($Path)
$algorithm = [Security.Cryptography.SHA256]::Create()
try {
return ([BitConverter]::ToString($algorithm.ComputeHash($stream))).Replace("-", "").ToLowerInvariant()
} finally {
$algorithm.Dispose()
$stream.Dispose()
}
}
try { try {
Write-UpdateState -State "started" -Message "Binary updater owns the update request." Write-UpdateState -State "started" -Message "Binary updater owns the update request."
@@ -59,9 +70,13 @@ try {
Write-Log "Handshake-only verification completed successfully." Write-Log "Handshake-only verification completed successfully."
exit 0 exit 0
} }
$actualSha256 = (Get-FileHash -LiteralPath $BinaryPath -Algorithm SHA256).Hash.ToLowerInvariant() $actualSha256 = Get-Sha256 -Path $BinaryPath
if ($actualSha256 -ne $ExpectedSha256.ToLowerInvariant()) { throw "Binary update SHA-256 verification failed." } 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." } if (-not (Test-Path -LiteralPath $CurrentExecutable -PathType Leaf)) { throw "Current ForgeFlow executable was not found." }
if ($VerifyOnly) {
Write-Log "Verification-only SHA-256 check completed successfully."
exit 0
}
Write-UpdateState -State "waiting-for-exit" -Message "Waiting for ForgeFlow to close." Write-UpdateState -State "waiting-for-exit" -Message "Waiting for ForgeFlow to close."
try { Wait-Process -Id $ParentPid -Timeout 60 -ErrorAction Stop } catch { try { Wait-Process -Id $ParentPid -Timeout 60 -ErrorAction Stop } catch {
+12 -1
View File
@@ -71,6 +71,17 @@ function Write-UpdateState {
} }
} }
function Get-Sha256([string]$Path) {
$stream = [IO.File]::OpenRead($Path)
$algorithm = [Security.Cryptography.SHA256]::Create()
try {
return ([BitConverter]::ToString($algorithm.ComputeHash($stream))).Replace("-", "").ToLowerInvariant()
} finally {
$algorithm.Dispose()
$stream.Dispose()
}
}
function Invoke-Robocopy { function Invoke-Robocopy {
param([string]$From, [string]$To) param([string]$From, [string]$To)
New-Item -ItemType Directory -Force -Path $To | Out-Null New-Item -ItemType Directory -Force -Path $To | Out-Null
@@ -120,7 +131,7 @@ try {
Start-Sleep -Milliseconds 500 Start-Sleep -Milliseconds 500
} }
$actualHash = (Get-FileHash -LiteralPath $ArchivePath -Algorithm SHA256).Hash.ToLowerInvariant() $actualHash = Get-Sha256 -Path $ArchivePath
if ($actualHash -ne $ExpectedSha256.ToLowerInvariant()) { throw "Update archive checksum mismatch." } if ($actualHash -ne $ExpectedSha256.ToLowerInvariant()) { throw "Update archive checksum mismatch." }
$working = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-update-" + [guid]::NewGuid().ToString("N")) $working = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-update-" + [guid]::NewGuid().ToString("N"))
+12 -1
View File
@@ -117,7 +117,18 @@ app.whenReady().then(async () => {
reviewBreakdown: Object.fromEntries(Object.entries(report.reviewBreakdown || {}).sort(([left], [right]) => left.localeCompare(right))), reviewBreakdown: Object.fromEntries(Object.entries(report.reviewBreakdown || {}).sort(([left], [right]) => left.localeCompare(right))),
reviewSamples: report.reviewSamples, reviewSamples: report.reviewSamples,
reconciliation: report.reconciliation, reconciliation: report.reconciliation,
access: report.access.map((item) => ({ repository: item.repository, profileId: item.profileId || null, ready: item.ready, readiness: item.readiness || item.action || null, remoteSha: item.remoteSha || item.branchSha || null, liveSha: item.liveSha || null, error: item.error || null })), access: report.access.map((item) => ({
repository: item.repository,
profileId: item.profileId || null,
ready: item.ready,
deployReady: item.deployReady ?? item.ready,
readiness: item.readiness || item.action || null,
remoteSha: item.remoteSha || item.branchSha || null,
liveSha: item.liveSha || null,
blockers: (item.deploymentBlockers || []).map((check) => ({ id: check.id, detail: check.detail })),
warnings: (item.checks || []).filter((check) => check.status !== "pass" && !(item.deploymentBlockers || []).some((blocker) => blocker.id === check.id)).map((check) => ({ id: check.id, status: check.status, detail: check.detail })),
error: item.error || null,
})),
review: report.workloads.filter((item) => !item.repository && item.running).map((item) => ({ name: item.name, confidence: item.confidence, folder: item.folder })), review: report.workloads.filter((item) => !item.repository && item.running).map((item) => ({ name: item.name, confidence: item.confidence, folder: item.folder })),
})) : reports; })) : reports;
console.log(JSON.stringify(output, null, 2)); console.log(JSON.stringify(output, null, 2));
+27 -2
View File
@@ -85,6 +85,11 @@ const required = [
"docs/RELEASE_NOTES_0.10.3.md", "docs/RELEASE_NOTES_0.10.3.md",
"docs/RELEASE_NOTES_0.10.4.md", "docs/RELEASE_NOTES_0.10.4.md",
"docs/RELEASE_NOTES_0.10.5.md", "docs/RELEASE_NOTES_0.10.5.md",
"docs/RELEASE_NOTES_0.10.6.md",
"docs/RELEASE_NOTES_0.10.7.md",
"docs/RELEASE_NOTES_0.10.8.md",
"docs/RELEASE_NOTES_0.10.9.md",
"docs/RELEASE_NOTES_0.10.10.md",
"docs/UPDATING.md", "docs/UPDATING.md",
"docs/DIAGNOSTICS.md", "docs/DIAGNOSTICS.md",
"docs/DEPLOYMENT_SETUP.md", "docs/DEPLOYMENT_SETUP.md",
@@ -123,9 +128,9 @@ for (const file of required) await access(path.join(root, file));
const packageJson = JSON.parse( const packageJson = JSON.parse(
await readFile(path.join(root, "package.json"), "utf8"), await readFile(path.join(root, "package.json"), "utf8"),
); );
if (packageJson.version !== "0.10.5") if (packageJson.version !== "0.10.10")
throw new Error( throw new Error(
`Expected package version 0.10.5, got ${packageJson.version}.`, `Expected package version 0.10.10, got ${packageJson.version}.`,
); );
const sourceManifest = await readFile( const sourceManifest = await readFile(
path.join(root, "SOURCE_MANIFEST.txt"), path.join(root, "SOURCE_MANIFEST.txt"),
@@ -459,6 +464,26 @@ const release0105 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.5.md
for (const phrase of ["repository workspace", "resolved profile", "Link unresolved", "reconciliation", "server workload"]) { 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}`); if (!release0105.includes(phrase)) throw new Error(`0.10.5 release notes are missing: ${phrase}`);
} }
const release0106 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.6.md"), "utf8");
for (const phrase of ["detached", "PowerShell", "production Node spawn", "source updater", "one-time direct installation"]) {
if (!release0106.includes(phrase)) throw new Error(`0.10.6 release notes are missing: ${phrase}`);
}
const release0107 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.7.md"), "utf8");
for (const phrase of ["exact provenance", "automatic", "repository sidebar", "DevRunbook", "no container changes"]) {
if (!release0107.includes(phrase)) throw new Error(`0.10.7 release notes are missing: ${phrase}`);
}
const release0108 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.8.md"), "utf8");
for (const phrase of ["Get-FileHash", ".NET SHA-256", "PSModulePath", "binary", "source update helpers"]) {
if (!release0108.includes(phrase)) throw new Error(`0.10.8 release notes are missing: ${phrase}`);
}
const release0109 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.9.md"), "utf8");
for (const phrase of ["containers without healthchecks", "single-instance", "exact Gitea commit", "deploy-ready", "no containers are changed"]) {
if (!release0109.includes(phrase)) throw new Error(`0.10.9 release notes are missing: ${phrase}`);
}
const release01010 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.10.md"), "utf8");
for (const phrase of ["read-only deploy keys", "repository deployment root", "Compose working directory", "Fix write access", "exact Gitea commit"]) {
if (!release01010.includes(phrase)) throw new Error(`0.10.10 release notes are missing: ${phrase}`);
}
const configSource = await readFile(path.join(root, "src/main/config-store.cjs"), "utf8"); const configSource = await readFile(path.join(root, "src/main/config-store.cjs"), "utf8");
for (const mode of ["server-git", "push-bundle", "monitor-only"]) { for (const mode of ["server-git", "push-bundle", "monitor-only"]) {
if (!configSource.includes(mode)) throw new Error(`Deployment configuration is missing mode: ${mode}`); if (!configSource.includes(mode)) throw new Error(`Deployment configuration is missing mode: ${mode}`);
+12 -3
View File
@@ -1,6 +1,6 @@
"use strict"; "use strict";
function createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto }) { function createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto, parsePermissionInspection }) {
class UnraidAccessMethods { class UnraidAccessMethods {
serverGitRemote(repository, profile) { serverGitRemote(repository, profile) {
const candidates = [repository.sshUrl, profile.cloneUrl, repository.preferredCloneUrl] const candidates = [repository.sshUrl, profile.cloneUrl, repository.preferredCloneUrl]
@@ -212,10 +212,19 @@ function createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIden
add("commit-parity", "Gitea and server parity", branchSha && liveSha && branchSha === liveSha ? "pass" : branchSha && liveSha ? "warning" : "incomplete", branchSha && liveSha ? branchSha === liveSha ? "The exact Gitea commit is live." : `Live ${String(liveSha).slice(0, 12)} differs from Gitea ${String(branchSha).slice(0, 12)}.` : "Parity cannot be proven until both SHAs are available.", { branchSha, liveSha }); add("commit-parity", "Gitea and server parity", branchSha && liveSha && branchSha === liveSha ? "pass" : branchSha && liveSha ? "warning" : "incomplete", branchSha && liveSha ? branchSha === liveSha ? "The exact Gitea commit is live." : `Live ${String(liveSha).slice(0, 12)} differs from Gitea ${String(branchSha).slice(0, 12)}.` : "Parity cannot be proven until both SHAs are available.", { branchSha, liveSha });
add("runtime", "Container runtime", running === true ? "pass" : running === false ? "fail" : "incomplete", running === true ? "The linked container is running." : running === false ? "The linked container is stopped." : "Runtime state has not been verified."); add("runtime", "Container runtime", running === true ? "pass" : running === false ? "fail" : "incomplete", running === true ? "The linked container is running." : running === false ? "The linked container is stopped." : "Runtime state has not been verified.");
add("health", "Runtime health", healthy === true ? "pass" : healthy === false ? "fail" : "incomplete", healthy === true ? "Runtime health passed." : healthy === false ? "Runtime health failed." : "No conclusive runtime health evidence is available."); add("health", "Runtime health", healthy === true ? "pass" : healthy === false ? "fail" : "incomplete", healthy === true ? "Runtime health passed." : healthy === false ? "Runtime health failed." : "No conclusive runtime health evidence is available.");
const deploymentCheckIds = new Set(["gitea-access", "remote-branch", "deploy-key-scope", "server-git-access", "server-inspection"]);
const deploymentBlockers = checks.filter((item) => deploymentCheckIds.has(item.id) && item.status !== "pass");
const deployReady = Boolean(branchSha) && deploymentBlockers.length === 0;
const failed = checks.some((item) => item.status === "fail"); const failed = checks.some((item) => item.status === "fail");
const incomplete = checks.some((item) => ["warning", "incomplete", "unsupported"].includes(item.status)); const incomplete = checks.some((item) => ["warning", "incomplete", "unsupported"].includes(item.status));
const readiness = failed ? (checks.some((item) => item.id.includes("access") || item.id.includes("key")) ? "Access failed" : checks.some((item) => item.id === "runtime" || item.id === "health") ? "Runtime unhealthy" : "Configuration required") : incomplete ? (branchSha && liveSha && branchSha !== liveSha ? "Commit mismatch" : "Verification incomplete") : "Ready"; const readiness = deploymentBlockers.length
return { readiness, ready: readiness === "Ready" || readiness === "Commit mismatch", checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, server: { id: server.id, name: server.name }, remotePath, branch: profile.branch, branchSha, liveSha, checks }; ? "Access failed"
: failed
? "Deploy-ready; runtime unhealthy"
: incomplete
? (branchSha && liveSha && branchSha !== liveSha ? "Deployable update available" : "Deploy-ready; runtime verification incomplete")
: "Ready";
return { readiness, ready: deployReady, deployReady, deploymentBlockers, checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, server: { id: server.id, name: server.name }, remotePath, branch: profile.branch, branchSha, liveSha, checks };
} }
permissionTargets(profile, server, remotePath) { permissionTargets(profile, server, remotePath) {
+1 -1
View File
@@ -490,7 +490,7 @@ for (const name of Object.getOwnPropertyNames(preflightMethods)) {
if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(preflightMethods, name)); if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(preflightMethods, name));
} }
const accessMethods = createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto }); const accessMethods = createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto, parsePermissionInspection });
for (const name of Object.getOwnPropertyNames(accessMethods)) { for (const name of Object.getOwnPropertyNames(accessMethods)) {
if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(accessMethods, name)); if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(accessMethods, name));
} }
+58 -8
View File
@@ -36,7 +36,11 @@ function createUnraidInventoryMethods({
disappeared=0 disappeared=0
while IFS= read -r container_id; do while IFS= read -r container_id; do
[ -n "$container_id" ] || continue [ -n "$container_id" ] || continue
if inspect=$(docker inspect --format '{"id":{{json .Id}},"name":{{json .Name}},"image":{{json .Config.Image}},"imageId":{{json .Image}},"running":{{json .State.Running}},"status":{{json .State.Status}},"health":{{if .State.Health}}{{json .State.Health.Status}}{{else}}null{{end}},"labels":{{json .Config.Labels}},"ports":{{json .NetworkSettings.Ports}},"mounts":{{json .Mounts}},"networks":{{json .NetworkSettings.Networks}},"restartPolicy":{{json .HostConfig.RestartPolicy.Name}}}' "$container_id" 2>/dev/null); then # Use Docker's own JSON document instead of a Go template. Accessing an
# absent .State.Health map key makes the formatted Docker inspect fail for
# every container without a healthcheck, which previously made those
# containers look as if they disappeared during the scan.
if inspect=$(docker inspect "$container_id" 2>/dev/null); then
printf 'C\\t%s\\n' "$(printf '%s' "$inspect" | base64 | tr -d '\\r\\n')" printf 'C\\t%s\\n' "$(printf '%s' "$inspect" | base64 | tr -d '\\r\\n')"
else else
disappeared=$((disappeared + 1)) disappeared=$((disappeared + 1))
@@ -251,19 +255,38 @@ function createUnraidInventoryMethods({
} }
refreshedProfileFromWorkload(repository, server, workload, existingProfile) { refreshedProfileFromWorkload(repository, server, workload, existingProfile) {
const configuredRoot = path.join(server.basePath, existingProfile.remoteFolder || "").replace(/\/+$/, "");
const composeWorkingDir = String(workload.compose?.workingDir || "").replace(/\/+$/, "");
const configuredRootOwnsCompose = Boolean(
configuredRoot
&& composeWorkingDir
&& (composeWorkingDir === configuredRoot || composeWorkingDir.startsWith(`${configuredRoot}/`)),
);
const detected = this.profileFromWorkload(repository, server, workload, { const detected = this.profileFromWorkload(repository, server, workload, {
linkSource: existingProfile.workloadIdentity?.linkSource || "automatic-compose", linkSource: existingProfile.workloadIdentity?.linkSource || "automatic-compose",
deploymentMode: ["push-bundle", "server-git", "monitor-only"].includes(existingProfile.deploymentMode) deploymentMode: ["push-bundle", "server-git", "monitor-only"].includes(existingProfile.deploymentMode)
? existingProfile.deploymentMode ? existingProfile.deploymentMode
: "push-bundle", : "push-bundle",
remoteFolder: workload.remoteFolderCandidate || existingProfile.remoteFolder, remoteFolder: configuredRootOwnsCompose
? existingProfile.remoteFolder
: workload.remoteFolderCandidate || existingProfile.remoteFolder,
}); });
const repositoryRelativeComposeFiles = configuredRootOwnsCompose
? [...new Set((workload.compose?.configFiles || []).map((file) => {
const value = String(file || "").trim().replace(/\\/g, "/");
if (value.startsWith(`${configuredRoot}/`)) return value.slice(configuredRoot.length + 1);
return value.startsWith("/") ? "" : value;
}).filter(Boolean))]
: [];
const composeFiles = repositoryRelativeComposeFiles.length
? repositoryRelativeComposeFiles
: detected.composeFiles;
return { return {
...existingProfile, ...existingProfile,
deploymentMode: detected.deploymentMode, deploymentMode: detected.deploymentMode,
remoteFolder: detected.remoteFolder, remoteFolder: detected.remoteFolder,
composeFile: detected.composeFile, composeFile: composeFiles[0],
composeFiles: detected.composeFiles, composeFiles,
composeProject: detected.composeProject, composeProject: detected.composeProject,
composeWorkingDir: detected.composeWorkingDir, composeWorkingDir: detected.composeWorkingDir,
composeService: detected.composeService, composeService: detected.composeService,
@@ -384,15 +407,41 @@ function createUnraidInventoryMethods({
}; };
} }
async scanServerInventory(serverId, repositories) { async scanServerInventory(serverId, repositories, { autoLink = false } = {}) {
const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories); const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories);
const response = this.inventoryResponse(server, inventory, workloads); let adopted = 0;
const adoptedLinks = [];
if (autoLink) {
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)
.map((workload) => String(workload.link.repositoryFullName).toLowerCase()));
for (const addition of plan.additions) {
const workload = workloads.find((item) => item.workloadId === addition.workloadId);
const repository = (repositories || []).find((item) => String(item.fullName).toLowerCase() === String(addition.repositoryFullName).toLowerCase());
const key = String(repository?.fullName || "").toLowerCase();
if (!workload || !repository || linkedRepositories.has(key)) continue;
const linkSource = addition.evidence === "exact-provenance" ? "automatic" : "automatic-runtime-identity";
const profile = this.profileFromWorkload(repository, server, workload, { linkSource, deploymentMode: "server-git" });
const saved = await this.store.saveDeploymentProfile(repository.fullName, profile);
await this.saveWorkloadState(saved, workload, server);
workload.status = "linked";
workload.link = { status: "linked", profileId: saved.id, repositoryFullName: repository.fullName, source: linkSource };
linkedRepositories.add(key);
adopted += 1;
adoptedLinks.push({ repositoryFullName: repository.fullName, profileId: saved.id, workloadId: workload.workloadId });
}
}
const response = this.inventoryResponse(server, inventory, workloads, { adopted });
await this.diagnostics?.info("unraid.workloads.scanned", { await this.diagnostics?.info("unraid.workloads.scanned", {
serverId, serverId,
detected: response.detected, detected: response.detected,
linked: response.linked, linked: response.linked,
needsReview: response.needsReview, needsReview: response.needsReview,
readOnly: true, adopted,
adoptedLinks,
readOnly: !autoLink,
}); });
return response; return response;
} }
@@ -428,6 +477,7 @@ function createUnraidInventoryMethods({
evidence: candidate.exact ? "exact-provenance" : "exact-runtime-identity", evidence: candidate.exact ? "exact-provenance" : "exact-runtime-identity",
impact: "Create a server-pull deployment profile; no container changes", impact: "Create a server-pull deployment profile; no container changes",
}); });
linkedRepositories.add(String(candidate.repositoryFullName).toLowerCase());
} else if (["suggested", "ambiguous"].includes(workload.status) || (workload.runtime?.running && workload.candidates?.length)) { } else if (["suggested", "ambiguous"].includes(workload.status) || (workload.runtime?.running && workload.candidates?.length)) {
conflicts.push({ conflicts.push({
workloadId: workload.workloadId, workloadId: workload.workloadId,
@@ -559,7 +609,7 @@ function createUnraidInventoryMethods({
} }
async discoverServerWorkloads(serverId, repositories) { async discoverServerWorkloads(serverId, repositories) {
return this.scanServerInventory(serverId, repositories); return this.scanServerInventory(serverId, repositories, { autoLink: true });
} }
async linkServerWorkload({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) { async linkServerWorkload({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) {
+30 -1
View File
@@ -282,7 +282,36 @@ function createUnraidPreflightMethods({
}); });
} }
} }
if (deploymentMode === "server-git") {
const [owner, repo] = String(repository.fullName || "").split("/");
const deploymentFiles = profile.generatedCompose
? ["Dockerfile"]
: this.deploymentComposeFiles(profile);
try {
const existence = await Promise.all(deploymentFiles.map(async (filePath) => ({
filePath,
exists: await this.gitea.repositoryFileExists({ owner, repo, filePath, ref: targetSha }),
})));
const missing = existence.filter((item) => !item.exists).map((item) => item.filePath);
checks.push({
id: "gitea-deployment-files",
label: profile.generatedCompose ? "Dockerfile at Gitea commit" : "Compose files at Gitea commit",
status: missing.length ? "fail" : "pass",
detail: missing.length
? `Missing at exact commit ${targetSha.slice(0, 12)}: ${missing.join(", ")}.`
: `${deploymentFiles.join(", ")} verified at exact commit ${targetSha.slice(0, 12)}.`,
});
} catch (error) {
checks.push({
id: "gitea-deployment-files",
label: "Deployment files at Gitea commit",
status: "fail",
detail: error.message,
});
}
}
try { try {
const connection = await this.ssh.test(server.id, { trustOnFirstUse: false }); const connection = await this.ssh.test(server.id, { trustOnFirstUse: false });
connectionCapabilities = connection.capabilities || {}; connectionCapabilities = connection.capabilities || {};
+23 -12
View File
@@ -33,6 +33,18 @@ function resolveWindowsPowerShellPath(environment = process.env) {
return "powershell.exe"; return "powershell.exe";
} }
function windowsUpdaterSpawnOptions(cwd) {
return {
// A detached hidden PowerShell child can exit successfully on Windows
// without ever executing its -File script. Normal Windows children survive
// their parent; unref() below releases the event-loop reference instead.
detached: false,
stdio: "ignore",
windowsHide: true,
cwd,
};
}
async function readJsonFile(filePath) { async function readJsonFile(filePath) {
try { try {
return JSON.parse(await fs.readFile(filePath, "utf8")); return JSON.parse(await fs.readFile(filePath, "utf8"));
@@ -450,12 +462,11 @@ class UpdateService {
const childState = { exited: false, code: null, error: null }; const childState = { exited: false, code: null, error: null };
let child; let child;
try { try {
child = this.spawnProcess(executable, args, { child = this.spawnProcess(
detached: true, executable,
stdio: "ignore", args,
windowsHide: true, windowsUpdaterSpawnOptions(this.sourcePath),
cwd: this.sourcePath, );
});
} catch (error) { } catch (error) {
error.code ||= "UPDATE_HELPER_SPAWN_FAILED"; error.code ||= "UPDATE_HELPER_SPAWN_FAILED";
throw error; throw error;
@@ -591,12 +602,11 @@ class UpdateService {
"-UpdateId", "-UpdateId",
updateId, updateId,
]; ];
const child = this.spawnProcess(executable, args, { const child = this.spawnProcess(
detached: true, executable,
stdio: "ignore", args,
windowsHide: true, windowsUpdaterSpawnOptions(this.updateDirectory),
cwd: this.updateDirectory, );
});
const childState = { exited: false, code: null, error: null }; const childState = { exited: false, code: null, error: null };
child.once?.("error", (error) => { child.once?.("error", (error) => {
childState.error = error; childState.error = error;
@@ -700,6 +710,7 @@ module.exports = {
UpdateService, UpdateService,
safeRepositoryPart, safeRepositoryPart,
resolveWindowsPowerShellPath, resolveWindowsPowerShellPath,
windowsUpdaterSpawnOptions,
waitForUpdaterStarted, waitForUpdaterStarted,
readJsonFile, readJsonFile,
readLogTail, readLogTail,
+7 -2
View File
@@ -337,8 +337,13 @@ async function handleDeploymentProfileActions(event, target, action, repository)
const result = await window.forgeflow.verifyServerGitProfile(repository, profileId); const result = await window.forgeflow.verifyServerGitProfile(repository, profileId);
ui.serverGitVerifications[profileId] = result; ui.serverGitVerifications[profileId] = result;
render(); render();
const failures = result.checks.filter((check) => check.status === "fail"); const blockers = result.deploymentBlockers || [];
showToast(result.readiness, failures[0]?.detail || `Verified ${result.checks.length} server-pull checks without changing the server.`, result.ready ? "success" : "warning"); const warnings = result.checks.filter((check) => check.status !== "pass" && !blockers.some((blocker) => blocker.id === check.id));
showToast(
result.readiness,
blockers[0]?.detail || warnings[0]?.detail || `Verified ${result.checks.length} server-pull checks without changing the server.`,
blockers.length ? "error" : warnings.length ? "warning" : "success",
);
} catch (error) { } catch (error) {
showToast("Server-pull verification failed", error.message, "error"); showToast("Server-pull verification failed", error.message, "error");
} finally { } finally {
+1 -1
View File
@@ -5,7 +5,7 @@ function createMockRepositoryBridge(context) {
await wait(80); await wait(80);
snapshot(); snapshot();
return { return {
appVersion: "0.10.5-demo", appVersion: "0.10.10-demo",
platform: "win32", platform: "win32",
state: clone(state), state: clone(state),
git: { available: true, version: "git version 2.47.3" }, git: { available: true, version: "git version 2.47.3" },
+13 -3
View File
@@ -31,6 +31,9 @@ function renderTitlebar() {
function renderRepositoryRow(repository) { function renderRepositoryRow(repository) {
const status = repository.localStatus; const status = repository.localStatus;
const profiles = repository.deploymentProfiles || [];
const workloads = linkedWorkloadsForRepository(repository);
const runningWorkloads = workloads.filter((workload) => workload.runtime?.running);
const badges = []; const badges = [];
if (status?.counts.conflicts) if (status?.counts.conflicts)
badges.push('<span class="mini-badge danger" title="Conflicts">!</span>'); badges.push('<span class="mini-badge danger" title="Conflicts">!</span>');
@@ -50,10 +53,14 @@ function renderRepositoryRow(repository) {
badges.push( badges.push(
'<span class="mini-badge success" title="Ready to deploy">↗</span>', '<span class="mini-badge success" title="Ready to deploy">↗</span>',
); );
if (profiles.length)
badges.push(
`<span class="mini-badge ${runningWorkloads.length ? "success" : "warning"} deployment-badge" title="${attr(`${profiles.length} server deployment${profiles.length === 1 ? "" : "s"} linked${runningWorkloads.length ? ` · ${runningWorkloads.length} running` : ""}`)}">S${profiles.length}</span>`,
);
if (!repository.localPath) if (!repository.localPath)
badges.push('<span class="mini-badge" title="No local folder">—</span>'); badges.push('<span class="mini-badge" title="No local folder">—</span>');
const branch = status?.branch.head || repository.defaultBranch || "remote"; const branch = status?.branch.head || repository.defaultBranch || "remote";
return `<button class="repo-row ${String(repository.id) === String(ui.selectedRepoId) ? "active" : ""} ${repository.attention ? "attention" : ""}" data-action="select-repo" data-id="${attr(repository.id)}"> return `<button class="repo-row ${String(repository.id) === String(ui.selectedRepoId) ? "active" : ""} ${repository.attention ? "attention" : ""}" data-action="select-repo" data-id="${attr(repository.id)}" data-deployment-count="${profiles.length}">
<span class="repo-icon">${repository.favorite ? icon("star") : icon(repository.localPath ? "git" : "cloud")}</span> <span class="repo-icon">${repository.favorite ? icon("star") : icon(repository.localPath ? "git" : "cloud")}</span>
<span class="repo-main"><span class="repo-name">${escapeHtml(repository.name)}</span><span class="repo-sub"><span>${escapeHtml(branch)}</span>${status?.shortHead ? `<span> ${escapeHtml(status.shortHead)}</span>` : ""}</span></span> <span class="repo-main"><span class="repo-name">${escapeHtml(repository.name)}</span><span class="repo-sub"><span>${escapeHtml(branch)}</span>${status?.shortHead ? `<span> ${escapeHtml(status.shortHead)}</span>` : ""}</span></span>
<span class="repo-badges">${badges.join("")}</span> <span class="repo-badges">${badges.join("")}</span>
@@ -365,7 +372,7 @@ function renderProfileCard(repository, profile, compact = false) {
const serverAccessAction = isSsh && mode === "server-git" const serverAccessAction = isSsh && mode === "server-git"
? `<button class="button" data-action="verify-server-git-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Verify server pull</button><button class="button" data-action="manage-deploy-key" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("key")}Deploy key lifecycle</button><button class="button" data-action="configure-server-git-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("key")}Configure Gitea access</button>` ? `<button class="button" data-action="verify-server-git-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Verify server pull</button><button class="button" data-action="manage-deploy-key" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("key")}Deploy key lifecycle</button><button class="button" data-action="configure-server-git-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("key")}Configure Gitea access</button>`
: ""; : "";
return `<article class="deploy-card accent-${identity.accent} ${compact ? "compact-card" : ""}"><div class="container-identity"><span class="container-avatar">${escapeHtml(identity.initial)}</span><div><span>Container</span><strong>${escapeHtml(identity.name)}</strong><small>${escapeHtml(repository.fullName)} · ${escapeHtml(profile.environment)}</small></div>${syncLabel}</div><div class="deploy-card-header"><div><div class="eyebrow">${escapeHtml(isSsh ? "SSH / UNRAID" : "GITEA ACTIONS")}</div><h3>${escapeHtml(profile.name)}</h3><p>${escapeHtml(providerDetail)}</p></div><span class="status-pill ${health.tone}"><span class="state-dot ${health.tone}"></span>${health.label}</span></div><div class="deploy-card-body"><div class="deploy-metadata"><span>Live commit</span><strong>${state.liveSha ? shortSha(state.liveSha) : "Unknown"}</strong><span>Deploy source</span><strong>${escapeHtml(sourceLabel)}</strong><span>Previous version</span><strong>${state.previousSha ? shortSha(state.previousSha) : "Unknown"}</strong><span>Last checked</span><strong>${state.checkedAt ? formatDate(state.checkedAt) : "Never"}</strong>${isSsh ? `<span>Deployment mode</span><strong>${escapeHtml(modeLabel)}</strong><span>Compose project</span><strong>${escapeHtml(profile.composeProject || "ForgeFlow-generated identity")}</strong><span>Runtime</span><strong>${state.containerRunning === false ? "Stopped" : state.containerRunning ? state.runtimeVerification === "running-unverified" ? "Running · unverified" : "Running" : "Unknown"}</strong><span>DockerMan</span><strong class="${managesDockerMan && !dockerManReady ? "text-warning" : "text-success"}">${escapeHtml(dockerManLabel)}</strong>` : ""}<span>Rollback</span><strong>${rollbackConfigured ? "Available after first deploy" : "Not configured"}</strong></div><div class="card-actions"><button class="button" data-action="run-deployment-preflight" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Preflight</button>${isSsh ? `<button class="button" data-action="repair-deployment-write-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}Check / fix write access</button>` : ""}${serverAccessAction}<button class="button" data-action="reconcile-deployment" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("refresh")}Refresh truth</button>${webUi ? `<button class="button" data-action="open-profile-webui" data-url="${attr(webUi)}">${icon("external")}Open Web UI</button>` : ""}${managesDockerMan ? `<button class="button ${dockerManReady ? "ghost" : ""}" data-action="apply-dockerman-metadata" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}${dockerManReady ? "Reapply DockerMan integration" : "Repair DockerMan integration"}</button>` : ""}${ready ? `<button class="button primary" data-action="deploy-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("rocket")}Deploy ${escapeHtml(shortSha(targetSha))}</button>` : ""}<button class="button ghost" data-action="edit-deployment-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">Edit</button>${state.previousSha && rollbackConfigured ? `<button class="button danger" data-action="rollback-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("undo")}Rollback</button>` : ""}</div></div></article>`; return `<article class="deploy-card accent-${identity.accent} ${compact ? "compact-card" : ""}"><div class="container-identity"><span class="container-avatar">${escapeHtml(identity.initial)}</span><div><span>Container</span><strong>${escapeHtml(identity.name)}</strong><small>${escapeHtml(repository.fullName)} · ${escapeHtml(profile.environment)}</small></div>${syncLabel}</div><div class="deploy-card-header"><div><div class="eyebrow">${escapeHtml(isSsh ? "SSH / UNRAID" : "GITEA ACTIONS")}</div><h3>${escapeHtml(profile.name)}</h3><p>${escapeHtml(providerDetail)}</p></div><span class="status-pill ${health.tone}"><span class="state-dot ${health.tone}"></span>${health.label}</span></div><div class="deploy-card-body"><div class="deploy-metadata"><span>Live commit</span><strong>${state.liveSha ? shortSha(state.liveSha) : "Unknown"}</strong><span>Deploy source</span><strong>${escapeHtml(sourceLabel)}</strong><span>Previous version</span><strong>${state.previousSha ? shortSha(state.previousSha) : "Unknown"}</strong><span>Last checked</span><strong>${state.checkedAt ? formatDate(state.checkedAt) : "Never"}</strong>${isSsh ? `<span>Deployment mode</span><strong>${escapeHtml(modeLabel)}</strong>${mode === "server-git" ? `<span>Server pull</span><strong class="${verification ? verification.deployReady ? "text-success" : "text-warning" : ""}">${escapeHtml(verification?.readiness || "Verify before deployment")}</strong>` : ""}<span>Compose project</span><strong>${escapeHtml(profile.composeProject || "ForgeFlow-generated identity")}</strong><span>Runtime</span><strong>${state.containerRunning === false ? "Stopped" : state.containerRunning ? state.runtimeVerification === "running-unverified" ? "Running · unverified" : "Running" : "Unknown"}</strong><span>DockerMan</span><strong class="${managesDockerMan && !dockerManReady ? "text-warning" : "text-success"}">${escapeHtml(dockerManLabel)}</strong>` : ""}<span>Rollback</span><strong>${rollbackConfigured ? "Available after first deploy" : "Not configured"}</strong></div><div class="card-actions"><button class="button" data-action="run-deployment-preflight" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Preflight</button>${isSsh ? `<button class="button" data-action="repair-deployment-write-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}Check / fix write access</button>` : ""}${serverAccessAction}<button class="button" data-action="reconcile-deployment" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("refresh")}Refresh truth</button>${webUi ? `<button class="button" data-action="open-profile-webui" data-url="${attr(webUi)}">${icon("external")}Open Web UI</button>` : ""}${managesDockerMan ? `<button class="button ${dockerManReady ? "ghost" : ""}" data-action="apply-dockerman-metadata" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}${dockerManReady ? "Reapply DockerMan integration" : "Repair DockerMan integration"}</button>` : ""}${ready ? `<button class="button primary" data-action="deploy-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("rocket")}Deploy ${escapeHtml(shortSha(targetSha))}</button>` : ""}<button class="button ghost" data-action="edit-deployment-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">Edit</button>${state.previousSha && rollbackConfigured ? `<button class="button danger" data-action="rollback-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("undo")}Rollback</button>` : ""}</div></div></article>`;
} }
function renderRepositoryDeployments(repository) { function renderRepositoryDeployments(repository) {
const profiles = repository.deploymentProfiles || []; const profiles = repository.deploymentProfiles || [];
@@ -432,6 +439,7 @@ function renderRepositoryWorkspace(repository) {
const profiles = repository.deploymentProfiles || []; const profiles = repository.deploymentProfiles || [];
const linkedWorkloads = linkedWorkloadsForRepository(repository); const linkedWorkloads = linkedWorkloadsForRepository(repository);
const profile = selectedProfile(repository); const profile = selectedProfile(repository);
const profileWorkload = linkedWorkloads.find((workload) => workload.link?.profileId === profile?.id);
const serverState = profile?.state || {}; const serverState = profile?.state || {};
const localTone = status?.counts.conflicts const localTone = status?.counts.conflicts
? "danger" ? "danger"
@@ -452,6 +460,8 @@ function renderRepositoryWorkspace(repository) {
? "danger" ? "danger"
: serverState.healthy === true : serverState.healthy === true
? "success" ? "success"
: profileWorkload?.runtime?.running
? "success"
: ""; : "";
const content = ( const content = (
{ {
@@ -474,7 +484,7 @@ function renderRepositoryWorkspace(repository) {
: ""; : "";
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> 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>` : ""} ${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> <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) : profile ? "Linked" : "Unknown", profileWorkload ? `${profileWorkload.displayName || profile.containerName || "Container"} · ${profileWorkload.runtime?.running ? "running" : "stopped"} on ${profileWorkload.serverName}` : profile ? (serverState.checkedAt ? `checked ${formatDate(serverState.checkedAt)}` : "profile linked · awaiting live scan") : "No deployment profile", serverTone)}</div>
${deploymentLinks} ${deploymentLinks}
<nav class="tabs">${[ <nav class="tabs">${[
["changes", "Changes"], ["changes", "Changes"],
+13 -4
View File
@@ -74,8 +74,11 @@ async function assertScrollableWhenOverflowing(page, selector) {
return metrics.connected && metrics.clientHeight > 0; return metrics.connected && metrics.clientHeight > 0;
}).toBe(true); }).toBe(true);
if (metrics.scrollHeight > metrics.clientHeight + 1) { if (metrics.scrollHeight > metrics.clientHeight + 1) {
await target.evaluate((element) => { element.scrollTop = element.scrollHeight; }); await expect.poll(() => target.evaluate((element) => {
await expect.poll(() => target.evaluate((element) => element.scrollTop)).toBeGreaterThan(0); if (element.scrollHeight <= element.clientHeight + 1) return 1;
element.scrollTop = element.scrollHeight;
return element.scrollTop;
})).toBeGreaterThan(0);
} }
} }
test("shell, overview, repositories and settings remain responsive and accessible", async ({ page }, testInfo) => { test("shell, overview, repositories and settings remain responsive and accessible", async ({ page }, testInfo) => {
@@ -115,8 +118,12 @@ test("repository changes, Git tools and Git Validator complete their primary flo
test("every long application surface retains a working vertical scroll owner", async ({ page }) => { test("every long application surface retains a working vertical scroll owner", async ({ page }) => {
for (const view of ["overview", "deployments", "diagnostics", "settings"]) { for (const view of ["overview", "deployments", "diagnostics", "settings"]) {
await page.locator(`.nav-button[data-view="${view}"]`).click(); await test.step(`${view} view scrolls`, async () => {
await assertScrollableWhenOverflowing(page, ".main-canvas"); const navigation = page.locator(`.nav-button[data-view="${view}"]`);
await navigation.click();
await expect(navigation).toHaveClass(/active/);
await assertScrollableWhenOverflowing(page, ".main-canvas");
});
} }
await page.locator('[data-action="select-repo"]').first().click(); await page.locator('[data-action="select-repo"]').first().click();
for (const tab of ["history", "deployments", "gittools", "validator", "settings"]) { for (const tab of ["history", "deployments", "gittools", "validator", "settings"]) {
@@ -142,6 +149,8 @@ test("deployment inventory supports dense workloads without ambiguous blank card
const repositoryLink = page.locator('[data-action="open-deployment-link"]'); const repositoryLink = page.locator('[data-action="open-deployment-link"]');
if (await repositoryLink.count()) { if (await repositoryLink.count()) {
await repositoryLink.first().click(); await repositoryLink.first().click();
await expect(page.locator('.repo-row.active')).toHaveAttribute("data-deployment-count", /^[1-9]/);
await expect(page.locator('.repo-row.active .deployment-badge')).toBeVisible();
await expect(page.locator('.tab[data-action="repo-tab"][data-tab="deployments"]')).toHaveClass(/active/); 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-workloads")).toBeVisible();
await expect(page.locator(".repository-workload-row").first()).toContainText("Repository linked"); await expect(page.locator(".repository-workload-row").first()).toContainText("Repository linked");
+7
View File
@@ -10,6 +10,13 @@ async function ipcSource() {
return (await Promise.all(["ipc.cjs", "ipc/repository-handlers.cjs", "ipc/deployment-handlers.cjs", "ipc/operations-handlers.cjs"].map((file) => readFile(new URL(`../src/main/${file}`, import.meta.url), "utf8")))).join("\n"); return (await Promise.all(["ipc.cjs", "ipc/repository-handlers.cjs", "ipc/deployment-handlers.cjs", "ipc/operations-handlers.cjs"].map((file) => readFile(new URL(`../src/main/${file}`, import.meta.url), "utf8")))).join("\n");
} }
test("desktop shell serializes ForgeFlow to one configuration writer", async () => {
const main = await readFile(new URL("../main.cjs", import.meta.url), "utf8");
assert.match(main, /requestSingleInstanceLock\(\)/);
assert.match(main, /second-instance/);
assert.match(main, /showMainWindow\(\)/);
});
test("changed file list has an independently scrollable bounded layout", async () => { test("changed file list has an independently scrollable bounded layout", async () => {
const css = await readFile( const css = await readFile(
new URL("../src/renderer/styles.css", import.meta.url), new URL("../src/renderer/styles.css", import.meta.url),
+6
View File
@@ -141,4 +141,10 @@ test('legacy container sanitizer applies safe defaults to partial Docker inspect
'tech.itworx.forgeflow.branch': '', 'net.unraid.docker.webui': '', 'net.unraid.docker.icon': '', 'net.unraid.docker.shell': '', 'net.unraid.docker.managed': '', 'tech.itworx.forgeflow.branch': '', 'net.unraid.docker.webui': '', 'net.unraid.docker.icon': '', 'net.unraid.docker.shell': '', 'net.unraid.docker.managed': '',
}, ports: {}, mounts: [], networks: {}, restartPolicy: '', }, ports: {}, mounts: [], networks: {}, restartPolicy: '',
}); });
assert.equal(sanitizeLegacyContainer({
Id: 'no-healthcheck',
Name: '/NoHealthcheck',
State: { Running: true, Status: 'running' },
Config: { Image: 'example/no-healthcheck:latest', Labels: null },
}).health, null);
}); });
+88 -10
View File
@@ -40,6 +40,36 @@ test("server release directories resolve to the stable deployment root", () => {
assert.equal(deploymentRootCandidate("ludarium/source/deploy"), "ludarium/source/deploy"); assert.equal(deploymentRootCandidate("ludarium/source/deploy"), "ludarium/source/deploy");
}); });
test("inventory refresh preserves a repository deployment root above its Compose working directory", () => {
const service = new UnraidDeploymentService({});
const existing = {
id: "profile", provider: "ssh-unraid", serverId: "server", environment: "production",
branch: "main", deploymentMode: "server-git", remoteFolder: "App/source",
composeWorkingDir: "/mnt/user/appdata/App/source/ops", composeProject: "app",
composeFiles: ["ops/compose.yml"], composeServices: ["web"], containerName: "app-web",
workloadIdentity: { linkSource: "automatic-compose" }, preservePaths: [".env"],
};
const workload = {
workloadId: "workload", kind: "compose-project", displayName: "app",
remoteFolderCandidate: "App/source/ops", selector: { kind: "compose-project", composeProject: "app" },
compose: {
project: "app", workingDir: "/mnt/user/appdata/App/source/ops",
configFiles: ["/mnt/user/appdata/App/source/ops/compose.yml"], services: ["web"],
},
containers: [{ name: "app-web", running: true, service: "web", mounts: [], ports: [] }],
metadata: {}, dockerMan: null,
};
const refreshed = service.refreshedProfileFromWorkload(
{ fullName: "Owner/App", name: "App", defaultBranch: "main", sshUrl: "git@gitea.test:Owner/App.git" },
{ id: "server", name: "Server", basePath: "/mnt/user/appdata" },
workload,
existing,
);
assert.equal(refreshed.remoteFolder, "App/source");
assert.equal(refreshed.composeWorkingDir, "/mnt/user/appdata/App/source/ops");
assert.deepEqual(refreshed.composeFiles, ["ops/compose.yml"]);
});
test("server inspection key-value payload is decoded safely", () => { test("server inspection key-value payload is decoded safely", () => {
const b64 = (value) => Buffer.from(value).toString("base64"); const b64 = (value) => Buffer.from(value).toString("base64");
const parsed = parseInspection( const parsed = parseInspection(
@@ -86,6 +116,22 @@ test("server pull provisions a pinned repository-scoped key and records access m
assert.equal(result.remoteSha, "a".repeat(40)); assert.equal(result.remoteSha, "a".repeat(40));
}); });
test("write-access inspection parses the remote permission report through the access module", async () => {
const encoded = (value) => Buffer.from(value).toString("base64");
const profile = { id: "profile", provider: "ssh-unraid", serverId: "server", remoteFolder: "App", composeFiles: ["compose.yml"] };
const service = new UnraidDeploymentService({
store: {
getDeploymentProfile: () => profile,
getServer: () => ({ id: "server", basePath: "/mnt/user/appdata" }),
},
ssh: { exec: async () => ({ stdout: `__FORGEFLOW_PERMISSIONS__\nI\t${encoded("deploy")}\t1000\t1000\t${encoded("users")}\tfalse\tfalse\nP\t${encoded("project-root")}\t${encoded("Project folder")}\t${encoded("/mnt/user/appdata/App")}\tdirectory\ttrue\ttrue\ttrue\ttrue\ttrue\ttrue\t${encoded("deploy")}\t${encoded("users")}\t2775\t${encoded("/mnt/user/appdata/App")}\t${encoded("Read/write probe passed.")}\n` }) },
});
const report = await service.inspectWriteAccess({ repository: { fullName: "Owner/App" }, profileId: "profile" });
assert.equal(report.ready, true);
assert.equal(report.identity.user, "deploy");
assert.equal(report.targets[0].effectiveWritable, true);
});
test("server pull verification proves a repository-scoped read-only key and exact commit parity", async () => { test("server pull verification proves a repository-scoped read-only key and exact commit parity", async () => {
const sha = "c".repeat(40); const sha = "c".repeat(40);
const profile = { const profile = {
@@ -112,6 +158,33 @@ test("server pull verification proves a repository-scoped read-only key and exac
assert.equal(report.checks.find((check) => check.id === "deploy-key-scope").status, "pass"); assert.equal(report.checks.find((check) => check.id === "deploy-key-scope").status, "pass");
}); });
test("server pull remains deploy-ready when only live runtime evidence is incomplete", async () => {
const sha = "c".repeat(40);
const profile = {
id: "profile-runtime-incomplete", provider: "ssh-unraid", serverId: "unraid", remoteFolder: "portfolio",
environment: "production", branch: "main", deploymentMode: "server-git", composeFiles: ["compose.yml"],
serverGitAccess: { deployKeyId: 17, keyFingerprint: "SHA256:key", hostFingerprint: "SHA256:host" },
};
const service = new UnraidDeploymentService({
store: {
getDeploymentProfile: () => profile,
getServer: () => ({ id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata" }),
getDeploymentState: () => ({ containerRunning: true, healthy: null }),
},
ssh: { exec: async () => ({ stdout: `__FORGEFLOW_SERVER_GIT_PROBE__\nremoteSha=${sha}\nkeyFingerprint=SHA256:key\nhostFingerprint=SHA256:host\n` }) },
gitea: {
getBranch: async () => ({ commit: { id: sha } }),
listDeployKeys: async () => [{ id: 17, read_only: true }],
},
});
service.inspect = async () => ({ exists: true, composeFiles: ["compose.yml"], head: null });
const report = await service.verifyServerGitProfile({ repository: { fullName: "Jens/Portfolio", sshUrl: "git@gitea.test:Jens/Portfolio.git" }, profileId: profile.id });
assert.equal(report.deployReady, true);
assert.equal(report.ready, true);
assert.equal(report.readiness, "Deploy-ready; runtime verification incomplete");
assert.deepEqual(report.deploymentBlockers, []);
});
test("server pull verification blocks a writable Gitea deploy key", async () => { test("server pull verification blocks a writable Gitea deploy key", async () => {
const sha = "d".repeat(40); const sha = "d".repeat(40);
const profile = { const profile = {
@@ -168,7 +241,7 @@ test("server workload inventory links running containers to exact Gitea checkout
); );
}); });
test("server discovery is read-only and explicit reconciliation adopts a verified deployment", async () => { test("low-level inventory scan is read-only and user discovery auto-links exact provenance", async () => {
const b64 = (value) => Buffer.from(value).toString("base64"); const b64 = (value) => Buffer.from(value).toString("base64");
const sha = "b".repeat(40); const sha = "b".repeat(40);
const container = { const container = {
@@ -218,17 +291,15 @@ test("server discovery is read-only and explicit reconciliation adopts a verifie
sshUrl: "git@gitea.itworx.tech:Jens/Portfolio.git", sshUrl: "git@gitea.itworx.tech:Jens/Portfolio.git",
}, },
]; ];
const discovery = await service.discoverServerWorkloads("unraid", repositories); const readOnlyDiscovery = await service.scanServerInventory("unraid", repositories);
assert.equal(discovery.adopted, 0); assert.equal(readOnlyDiscovery.adopted, 0);
assert.equal(discovery.verified, 0); assert.equal(readOnlyDiscovery.verified, 0);
assert.equal(profiles.length, 0); assert.equal(profiles.length, 0);
assert.equal(states.size, 0); assert.equal(states.size, 0);
const preview = await service.planServerInventoryReconciliation("unraid", repositories, { autoLink: true }); const discovery = await service.discoverServerWorkloads("unraid", repositories);
assert.equal(preview.plan.summary.additions, 1); assert.equal(discovery.adopted, 1);
const result = await service.reconcileServerInventory("unraid", repositories, { autoLink: true, expectedPlanId: preview.plan.id }); assert.equal(discovery.verified, 1);
assert.equal(result.adopted, 1);
assert.equal(result.verified, 1);
assert.equal(profiles[0].containerName, "Portfolio"); assert.equal(profiles[0].containerName, "Portfolio");
assert.equal(profiles[0].adoptedFromServer, true); assert.equal(profiles[0].adoptedFromServer, true);
assert.equal(states.get(profiles[0].id).matchesGitea, true); assert.equal(states.get(profiles[0].id).matchesGitea, true);
@@ -1073,6 +1144,8 @@ test("inventory scan uses only configured roots and reports partial find failure
assert.match(script, /-name 'scratch'/); assert.match(script, /-name 'scratch'/);
assert.match(script, /Inventory scan partially failed/); assert.match(script, /Inventory scan partially failed/);
assert.match(script, /2>"\$scan_error" \|\| true/); assert.match(script, /2>"\$scan_error" \|\| true/);
assert.match(script, /docker inspect "\$container_id"/);
assert.doesNotMatch(script, /docker inspect --format/);
assert.doesNotMatch(script, /add_scan_root \/mnt\/cache\/appdata/); assert.doesNotMatch(script, /add_scan_root \/mnt\/cache\/appdata/);
}); });
@@ -1296,7 +1369,10 @@ test("server-pull preflight resolves Gitea SHA and reports every degraded capabi
}, },
git: { status: async () => ({ root: "/local", clean: false, counts: { changed: 3 }, branch: { head: "main" } }) }, git: { status: async () => ({ root: "/local", clean: false, counts: { changed: 3 }, branch: { head: "main" } }) },
ssh: { test: async () => ({ capabilities: { docker: true, dockerReady: false, compose: false, git: false, tar: true, checksum: false, baseWritable: false } }) }, ssh: { test: async () => ({ capabilities: { docker: true, dockerReady: false, compose: false, git: false, tar: true, checksum: false, baseWritable: false } }) },
gitea: { getBranch: async () => ({ commit: { sha } }) }, gitea: {
getBranch: async () => ({ commit: { sha } }),
repositoryFileExists: async ({ filePath, ref }) => ref === sha && filePath === "compose.yml",
},
sourcePath: process.cwd() sourcePath: process.cwd()
}); });
service.probeServerGitAccess = async () => ({ ready: false, error: "deploy key missing", remoteSha: null }); service.probeServerGitAccess = async () => ({ ready: false, error: "deploy key missing", remoteSha: null });
@@ -1310,6 +1386,8 @@ test("server-pull preflight resolves Gitea SHA and reports every degraded capabi
assert.equal(result.sha, sha); assert.equal(result.sha, sha);
assert.equal(byId("local-branch").status, "warning"); assert.equal(byId("local-branch").status, "warning");
assert.equal(byId("local-clean").status, "warning"); assert.equal(byId("local-clean").status, "warning");
assert.equal(byId("gitea-deployment-files").status, "fail");
assert.match(byId("gitea-deployment-files").detail, /compose\.prod\.yml/);
assert.equal(byId("docker-runtime").status, "fail"); assert.equal(byId("docker-runtime").status, "fail");
assert.match(byId("docker-runtime").detail, /cannot query/i); assert.match(byId("docker-runtime").detail, /cannot query/i);
assert.equal(byId("compose-command").status, "fail"); assert.equal(byId("compose-command").status, "fail");
+78 -2
View File
@@ -6,16 +6,27 @@ import path from "node:path";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { execFile } from "node:child_process"; import { execFile, spawn } from "node:child_process";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { setTimeout as delay } from "node:timers/promises";
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const { const {
UpdateService, UpdateService,
waitForUpdaterStarted, waitForUpdaterStarted,
windowsUpdaterSpawnOptions,
} = require("../src/main/update-service.cjs"); } = require("../src/main/update-service.cjs");
test("Windows updater uses a hidden non-detached PowerShell child", () => {
assert.deepEqual(windowsUpdaterSpawnOptions("C:\\updates"), {
detached: false,
stdio: "ignore",
windowsHide: true,
cwd: "C:\\updates",
});
});
test("update check pins version to an exact branch commit", async () => { test("update check pins version to an exact branch commit", async () => {
const temp = await mkdtemp(path.join(os.tmpdir(), "forgeflow-update-test-")); const temp = await mkdtemp(path.join(os.tmpdir(), "forgeflow-update-test-"));
const saved = []; const saved = [];
@@ -358,6 +369,70 @@ test("binary helper confirms startup through real Windows PowerShell", { skip: p
assert.match(await readFile(logPath, "utf8"), /Handshake-only verification completed successfully/); assert.match(await readFile(logPath, "utf8"), /Handshake-only verification completed successfully/);
await rm(temp, { recursive: true, force: true }); await rm(temp, { recursive: true, force: true });
}); });
test("binary helper confirms startup through the production Node spawn options", { skip: process.platform !== "win32" }, async () => {
const temp = await mkdtemp(path.join(os.tmpdir(), "forgeflow-binary-node-spawn-"));
const statusPath = path.join(temp, "status.json");
const logPath = path.join(temp, "helper.log");
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 updateId = "binary-node-spawn";
await writeFile(statusPath, JSON.stringify({ state: "launching", updateId }));
const child = spawn(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", String(process.pid), "-LogPath", logPath,
"-StatusPath", statusPath, "-UpdateId", updateId, "-HandshakeOnly",
], windowsUpdaterSpawnOptions(temp));
const childState = { exited: false, code: null, error: null };
child.once("error", (error) => { childState.error = error; });
child.once("exit", (code) => { childState.exited = true; childState.code = code; });
const status = await waitForUpdaterStarted(statusPath, {
timeoutMs: 5000,
pollMs: 25,
childState,
expectedUpdateId: updateId,
logPath,
});
assert.equal(status.state, "started");
let log = "";
for (let attempt = 0; attempt < 40 && !log.includes("Handshake-only verification completed successfully"); attempt += 1) {
await delay(25);
log = await readFile(logPath, "utf8").catch(() => "");
}
assert.match(log, /Handshake-only verification completed successfully/);
if (child.exitCode === null) {
await new Promise((resolve, reject) => {
child.once("exit", resolve);
child.once("error", reject);
});
}
await rm(temp, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
test("binary helper verifies SHA-256 without PowerShell module autoloading", { skip: process.platform !== "win32" }, async () => {
const temp = await mkdtemp(path.join(os.tmpdir(), "forgeflow-binary-dotnet-sha-"));
const binaryPath = path.join(temp, "update.exe");
const currentPath = path.join(temp, "current.exe");
const statusPath = path.join(temp, "status.json");
const logPath = path.join(temp, "helper.log");
const bytes = Buffer.from("verified update bytes");
await writeFile(binaryPath, bytes);
await writeFile(currentPath, "current");
const expectedSha256 = createHash("sha256").update(bytes).digest("hex");
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 { stderr } = await execFileAsync(powershell, [
"-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", scriptPath,
"-BinaryPath", binaryPath, "-ExpectedSha256", expectedSha256, "-ExpectedVersion", "9.9.9",
"-CurrentExecutable", currentPath, "-Portable", "False", "-ParentPid", String(process.pid),
"-LogPath", logPath, "-StatusPath", statusPath, "-UpdateId", "dotnet-sha", "-VerifyOnly",
], { windowsHide: true, env: { ...process.env, PSModulePath: "" } });
assert.equal(stderr, "");
assert.match(await readFile(logPath, "utf8"), /Verification-only SHA-256 check completed successfully/);
await rm(temp, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
test("early helper exit reports the helper log instead of only an exit code", async () => { test("early helper exit reports the helper log instead of only an exit code", async () => {
const temp = await mkdtemp( const temp = await mkdtemp(
path.join(os.tmpdir(), "forgeflow-update-log-tail-"), path.join(os.tmpdir(), "forgeflow-update-log-tail-"),
@@ -574,7 +649,7 @@ test("binary update helper verifies, waits, applies and records restart state",
"utf8", "utf8",
); );
for (const marker of [ for (const marker of [
"Get-FileHash", "Security.Cryptography.SHA256",
"Wait-Process", "Wait-Process",
'Write-UpdateState -State "started"', 'Write-UpdateState -State "started"',
'Write-UpdateState -State "waiting-for-exit"', 'Write-UpdateState -State "waiting-for-exit"',
@@ -588,4 +663,5 @@ test("binary update helper verifies, waits, applies and records restart state",
`missing binary updater marker: ${marker}`, `missing binary updater marker: ${marker}`,
); );
} }
assert.doesNotMatch(helper, /Get-FileHash/);
}); });