Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38e221cbd1 | ||
|
|
bffad670ef | ||
|
|
4c21616e72 |
@@ -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.8** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest)
|
> Huidige release: **0.10.11** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ npm install
|
|||||||
npm run demo
|
npm run demo
|
||||||
```
|
```
|
||||||
|
|
||||||
Open daarna `http://127.0.0.1:4173`.
|
Open daarna `http://127.0.0.1:41737`.
|
||||||
|
|
||||||
## De dagelijkse workflow
|
## De dagelijkse workflow
|
||||||
|
|
||||||
|
|||||||
+31
-26
@@ -1,4 +1,4 @@
|
|||||||
ForgeFlow 0.10.8 source manifest
|
ForgeFlow 0.10.11 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,6 +34,8 @@ 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
|
||||||
|
36edb4f096a248fb8679bd13e5766befb478cf628c6ccfb21e3eda71bbec7633 992 docs/RELEASE_NOTES_0.10.11.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
|
||||||
@@ -41,6 +43,7 @@ f79908fb3dad98c38030c6e6be7c79a1999e0478ed9c2496923891954438daa1 4581
|
|||||||
94bfb2783c1befad1197e1c5e32fc002222c94a28d70d48360a8d53ecd260d5c 772 docs/RELEASE_NOTES_0.10.6.md
|
94bfb2783c1befad1197e1c5e32fc002222c94a28d70d48360a8d53ecd260d5c 772 docs/RELEASE_NOTES_0.10.6.md
|
||||||
226a3b2d4bc7f54841749a283fcdd71b643cd585ba74d673084bee829fef6ea2 903 docs/RELEASE_NOTES_0.10.7.md
|
226a3b2d4bc7f54841749a283fcdd71b643cd585ba74d673084bee829fef6ea2 903 docs/RELEASE_NOTES_0.10.7.md
|
||||||
4be29ad0cb7ebcf5625172b8d2bd7a67cdc6d64d3a94e2c3f0656cdfd42dcb7a 642 docs/RELEASE_NOTES_0.10.8.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
|
||||||
@@ -100,32 +103,32 @@ 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
|
d7cae22a646c8f06f339db799f2b9921ba1799cdc44e5d05578c04aaace103ab 14357 main.cjs
|
||||||
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
|
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
|
||||||
894cdb5af0b545aad556e7db0cc2f5c27be1cd809e951885edd8df460f7a0380 179806 package-lock.json
|
9957d6e9730a6d357647f829a878a265bf5aade1bcc55312df1c09a1fa12ad75 179808 package-lock.json
|
||||||
23e1e94840ffae0ae62d3f9fc07d5d75501699c325b8fceaaaa01ac92df6ebdd 5626 package.json
|
728b894b767f1b4050fb0e08d19d08decb32489afe1011fa0e3dfaeed1510544 5743 package.json
|
||||||
2a597a5704c576783b8a72407fbc377fa7506b36a4596ea7f7bce126e394f837 1326 playwright.config.mjs
|
1237df9ddcbb5ac7dc4316f18c34ff4a7030e3e0d56216ade6dd07369e5e2a04 1353 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
|
||||||
539ea27276d134d92b0d34122a34f9d46491ba02aa0220f9ef93f5c90dc9661d 10313 README.md
|
3351f252c6fec606e9f164ea9d20f695845a8eae64855a5562e193329862f729 10315 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
|
||||||
d0745072321aca2c80f44460974a7926715a9f429164aaf7660dced40b52c736 4790 scripts/apply-binary-update.ps1
|
d0745072321aca2c80f44460974a7926715a9f429164aaf7660dced40b52c736 4790 scripts/apply-binary-update.ps1
|
||||||
404863bcbe7292355662e3a326455df864d7279badc29f90866a3b837420df54 10745 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
|
||||||
b83d443f5724ac15393567f3a688aed8315fbe3e5966832c864a9466e0669464 8102 scripts/publish-binary-release.cjs
|
b83d443f5724ac15393567f3a688aed8315fbe3e5966832c864a9466e0669464 8102 scripts/publish-binary-release.cjs
|
||||||
444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs
|
558ff442988f1396c174c7161ff5bd3ef0b2f43cfc31459ec7c3967faa146bc3 1694 scripts/serve-demo.mjs
|
||||||
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
|
||||||
b50d081c956c7ede91899c52dc26615e249d295a1b484419dcafabafb5c65a7c 19896 scripts/verify.mjs
|
eba4a4540ec5f62884a72f1ee80ba35a2a6b7243b2a9e095f7310896aa55f58f 21034 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
|
||||||
@@ -142,35 +145,36 @@ e28fc1ca2fd4c0116148f5005d793feddf04c36ef711d2d348560394d209a613 7253
|
|||||||
3cc53e24e023aa0d8bf36c35ce9672ca98e6c74066512c8b59ab42274e838c22 21307 src/main/gitea-service.cjs
|
3cc53e24e023aa0d8bf36c35ce9672ca98e6c74066512c8b59ab42274e838c22 21307 src/main/gitea-service.cjs
|
||||||
2ad3b2e647377f687ad987fe248a142ad399ecac98e4b49965aa7efc6093e5fa 6914 src/main/inventory-classifier.cjs
|
2ad3b2e647377f687ad987fe248a142ad399ecac98e4b49965aa7efc6093e5fa 6914 src/main/inventory-classifier.cjs
|
||||||
dafdb09133d2b6ec2161a3f0b09354551e54fc606c8107976fca37405643be91 3404 src/main/inventory-review-service.cjs
|
dafdb09133d2b6ec2161a3f0b09354551e54fc606c8107976fca37405643be91 3404 src/main/inventory-review-service.cjs
|
||||||
00989577aed509a7ddfdf9f4df09a196a393a85b5ea59b21089002215e69f065 25949 src/main/ipc.cjs
|
f263ab067edea8dfeb962958d045ed4ab20b49cbeb965936f4ffa8c1dd0b7d59 25905 src/main/ipc.cjs
|
||||||
0eb1cfdcd3a37a0ec9502bf753966f87230c03580335798bef9265add42ee6fa 12530 src/main/ipc/deployment-handlers.cjs
|
0eb1cfdcd3a37a0ec9502bf753966f87230c03580335798bef9265add42ee6fa 12530 src/main/ipc/deployment-handlers.cjs
|
||||||
dc9b5971c9fefe8c374aa31916f5513601ce86003fd48b1d0e51330a909ae3a5 3442 src/main/ipc/operations-handlers.cjs
|
dc9b5971c9fefe8c374aa31916f5513601ce86003fd48b1d0e51330a909ae3a5 3442 src/main/ipc/operations-handlers.cjs
|
||||||
629b0f4704cda6ed6288c5a6ec9e5d73d6ae3e2692c43581b258431b65b58e51 15887 src/main/ipc/repository-handlers.cjs
|
629b0f4704cda6ed6288c5a6ec9e5d73d6ae3e2692c43581b258431b65b58e51 15887 src/main/ipc/repository-handlers.cjs
|
||||||
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs
|
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs
|
||||||
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs
|
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs
|
||||||
|
720c4a0c554f46386d87c3ab6607d1fbcae66e50b69483c7dbba169d5128c851 680 src/main/process-error-policy.cjs
|
||||||
3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs
|
3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs
|
||||||
e64f7257d478955c675a133b3735b6afe138a69d2ad090898061e56f557c43e5 9926 src/main/production-acceptance-harness.cjs
|
e64f7257d478955c675a133b3735b6afe138a69d2ad090898061e56f557c43e5 9926 src/main/production-acceptance-harness.cjs
|
||||||
fc7a5a2a42579a311f60a96bec4776942bf3f63428928820029b0dcf4874d1aa 2745 src/main/repository-monitor.cjs
|
fc7a5a2a42579a311f60a96bec4776942bf3f63428928820029b0dcf4874d1aa 2745 src/main/repository-monitor.cjs
|
||||||
17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs
|
d955b13609bab728a07faab89c4998686f6fc7e220c46fe04a3bcb4a63c5b4f5 9122 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
|
5394d16c1ceceef700a4b2a47cda088713e24c2e42a4bf4f86b1fa6006e796a2 25877 src/main/unraid-access-methods.cjs
|
||||||
2ede80cd1565a7f2c282cc58d35dc0889d58d7465346bc723026b9c8be4df0ac 9501 src/main/unraid-deploy-key-host.cjs
|
5ba5a90ad9b7a0d05dd64fa64968cbdd7271bec4df6971f9685a9dd411e4fad4 9536 src/main/unraid-deploy-key-host.cjs
|
||||||
803a079499f7b8148495209dea43b505f6eb9bb587de19e82054e47183186c6e 30461 src/main/unraid-deployment-methods.cjs
|
6d9910dace52625f88e066a8485af2663c3735ff15e9ce9031441ce742710a21 30793 src/main/unraid-deployment-methods.cjs
|
||||||
2e63fdc0be0bf4d8e5c3d7d45ff5811d786b0821a08b82f511f1301696e12c9d 17157 src/main/unraid-deployment-service.cjs
|
b8a6ef72cfd5c5405e3f1a48848b5ef1575ca82ebbe8cad816dff0dc7335c8ec 17184 src/main/unraid-deployment-service.cjs
|
||||||
e998db9bc9a51b5ef182ef23f7e286af8b92c34903fbc7d6211ef1218701cc79 37937 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
|
||||||
a387ac9735624ef89d964ea59e035dae256b9915ba2cee22ae5cbc2894f5808a 22000 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
|
||||||
a980f2e86d8286ea605a7259b9e9adcf3fda4f657b8d54a5d2d8765a7bbbec13 19065 src/renderer/actions/shell.js
|
a980f2e86d8286ea605a7259b9e9adcf3fda4f657b8d54a5d2d8765a7bbbec13 19065 src/renderer/actions/shell.js
|
||||||
edcfa0585af4b11ec3b7458969132bdf215830505859f1519f1635dc111ce48a 24333 src/renderer/app.js
|
e8521a5dfc5f8b7462589ae6d9bfb89280b348297da831be74b94fe7987fe7b5 24724 src/renderer/app.js
|
||||||
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png
|
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png
|
||||||
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png
|
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png
|
||||||
094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png
|
094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png
|
||||||
@@ -180,10 +184,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
|
||||||
c259dca30378e7462f94c736b266b7f1c97ad46a56c28ca9d5f832248ffa4fb5 20032 src/renderer/mock-repository-bridge.js
|
d8b2a9bb7e51f9e7d06e3f449d7e4c6b4f1aaa66ffb0fa5c10f4ff9d9c6c143a 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
|
||||||
8d366214f5ce9a87911439632de1c68fe5724e564412f651190c4066f9818fac 101196 src/renderer/views.js
|
8bc794e191aa22a13cbbc89c9b3882e0c3070e1365a7c02cfe381a62d81877d9 101549 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
|
||||||
@@ -197,7 +201,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
|
||||||
809bb7addda0762098289451b4df66bde87f75f730cacc7aa7c56ecae3ff29da 11405 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
|
||||||
@@ -217,18 +221,19 @@ fcc9a063882840dd89d74c2785284c8f2f6a9e5acec482b6d89ed8de62efdb85 9635
|
|||||||
caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs
|
caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs
|
||||||
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
|
||||||
|
7f2751ea2621f76b5427f442e931344d13e97faa7b6ef3151949bbd6a03097cf 1205 tests/process-error-policy.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
|
b4132272eda39edcb40c36d2fba73a57782b13e9f7e6902985d795f7c720349f 9400 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
|
||||||
eff3cc63d2622a4924e43516b3b00bef61fa62a489995bebaee5c48b11cdf8c9 48945 tests/unraid-deployment.test.mjs
|
c48739160c0e5a7a4ba75b3528835f4a93e39a4947b3d887147c2c0195a819b0 54354 tests/unraid-deployment.test.mjs
|
||||||
bf95071f8d8bec6dcf2c889f1f9d83be1b56178dc0d2be84da555848b6319cbc 25650 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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# ForgeFlow 0.10.11
|
||||||
|
|
||||||
|
## Resilient repository refresh and consistent server pull
|
||||||
|
|
||||||
|
- Temporary Gitea list failures now use the in-session **last-known-good** repository inventory while local and server state continue to refresh. The UI clearly reports that remote data is stale.
|
||||||
|
- A **closed output pipe** from a detached parent process is no longer treated as a fatal desktop-app exception.
|
||||||
|
- Server pull, deploy-key verification, deployment, rollback and metadata now consistently prefer the verified **linked checkout origin** over a stale URL detected earlier on the server.
|
||||||
|
- Repository-scoped **read-only deploy key** checks remain fail-closed; a changed SSH host still requires explicit trust and access reconfiguration.
|
||||||
|
- The local **browser test server** now has a dedicated port and identity endpoint, preventing another localhost application from being mistaken for ForgeFlow.
|
||||||
|
- All 42 responsive browser flows pass across dark/light, compact/wide and reduced-motion configurations.
|
||||||
@@ -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.
|
||||||
@@ -33,6 +33,10 @@ const {
|
|||||||
ExternalToolsService,
|
ExternalToolsService,
|
||||||
} = require("./src/main/external-tools-service.cjs");
|
} = require("./src/main/external-tools-service.cjs");
|
||||||
const { registerIpc } = require("./src/main/ipc.cjs");
|
const { registerIpc } = require("./src/main/ipc.cjs");
|
||||||
|
const {
|
||||||
|
installOutputPipeGuards,
|
||||||
|
isBrokenPipeError,
|
||||||
|
} = require("./src/main/process-error-policy.cjs");
|
||||||
|
|
||||||
let mainWindow;
|
let mainWindow;
|
||||||
let repositoryMonitor;
|
let repositoryMonitor;
|
||||||
@@ -41,6 +45,18 @@ let diagnostics;
|
|||||||
let configStore;
|
let configStore;
|
||||||
let tray;
|
let tray;
|
||||||
let quitCleanupStarted = false;
|
let quitCleanupStarted = false;
|
||||||
|
const reportBrokenOutputPipe = (error) => {
|
||||||
|
const report = diagnostics?.warning("process.output-pipe.closed", {
|
||||||
|
code: error?.code || null,
|
||||||
|
message: error?.message || "The parent output pipe was closed.",
|
||||||
|
});
|
||||||
|
report?.catch(() => {});
|
||||||
|
};
|
||||||
|
installOutputPipeGuards({ onBrokenPipe: reportBrokenOutputPipe });
|
||||||
|
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 +187,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: {
|
||||||
@@ -214,6 +231,10 @@ app
|
|||||||
await audit.initialize();
|
await audit.initialize();
|
||||||
|
|
||||||
process.on("uncaughtException", (error) => {
|
process.on("uncaughtException", (error) => {
|
||||||
|
if (isBrokenPipeError(error)) {
|
||||||
|
reportBrokenOutputPipe(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
diagnostics
|
diagnostics
|
||||||
?.error("process.uncaught-exception", error)
|
?.error("process.uncaught-exception", error)
|
||||||
.finally(() => app.exit(1));
|
.finally(() => app.exit(1));
|
||||||
@@ -419,7 +440,6 @@ app
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(async (error) => {
|
.catch(async (error) => {
|
||||||
console.error("[startup]", error);
|
|
||||||
await diagnostics?.error("app.startup.failed", error);
|
await diagnostics?.error("app.startup.failed", error);
|
||||||
await diagnostics?.flush();
|
await diagnostics?.flush();
|
||||||
app.exit(1);
|
app.exit(1);
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "forgeflow",
|
"name": "forgeflow",
|
||||||
"version": "0.10.8",
|
"version": "0.10.11",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "forgeflow",
|
"name": "forgeflow",
|
||||||
"version": "0.10.8",
|
"version": "0.10.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ssh2": "1.17.0"
|
"ssh2": "1.17.0"
|
||||||
},
|
},
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "forgeflow",
|
"name": "forgeflow",
|
||||||
"version": "0.10.8",
|
"version": "0.10.11",
|
||||||
"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",
|
||||||
@@ -113,6 +113,9 @@
|
|||||||
"docs/RELEASE_NOTES_0.10.6.md",
|
"docs/RELEASE_NOTES_0.10.6.md",
|
||||||
"docs/RELEASE_NOTES_0.10.7.md",
|
"docs/RELEASE_NOTES_0.10.7.md",
|
||||||
"docs/RELEASE_NOTES_0.10.8.md",
|
"docs/RELEASE_NOTES_0.10.8.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.9.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.10.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.11.md",
|
||||||
"docs/CURRENT_STATE.md",
|
"docs/CURRENT_STATE.md",
|
||||||
"docs/MUTATION_MODEL.md",
|
"docs/MUTATION_MODEL.md",
|
||||||
"docs/RELEASING.md",
|
"docs/RELEASING.md",
|
||||||
|
|||||||
@@ -18,14 +18,14 @@ export default defineConfig({
|
|||||||
workers: process.env.CI ? 2 : 3,
|
workers: process.env.CI ? 2 : 3,
|
||||||
reporter: [["line"], ["html", { outputFolder: "artifacts/browser-report", open: "never" }]],
|
reporter: [["line"], ["html", { outputFolder: "artifacts/browser-report", open: "never" }]],
|
||||||
use: {
|
use: {
|
||||||
baseURL: "http://127.0.0.1:4173",
|
baseURL: "http://127.0.0.1:41737",
|
||||||
screenshot: "only-on-failure",
|
screenshot: "only-on-failure",
|
||||||
trace: "retain-on-failure",
|
trace: "retain-on-failure",
|
||||||
video: "retain-on-failure",
|
video: "retain-on-failure",
|
||||||
},
|
},
|
||||||
webServer: {
|
webServer: {
|
||||||
command: "node scripts/serve-demo.mjs",
|
command: "node scripts/serve-demo.mjs",
|
||||||
url: "http://127.0.0.1:4173",
|
url: "http://127.0.0.1:41737/__forgeflow_test_ready__",
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -4,12 +4,17 @@ import path from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'renderer');
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'renderer');
|
||||||
const port = Number(process.env.PORT || 4173);
|
const port = Number(process.env.PORT || 41737);
|
||||||
const mime = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml' };
|
const mime = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml' };
|
||||||
|
|
||||||
const server = http.createServer(async (request, response) => {
|
const server = http.createServer(async (request, response) => {
|
||||||
try {
|
try {
|
||||||
const pathname = decodeURIComponent(new URL(request.url, `http://${request.headers.host}`).pathname);
|
const pathname = decodeURIComponent(new URL(request.url, `http://${request.headers.host}`).pathname);
|
||||||
|
if (pathname === '/__forgeflow_test_ready__') {
|
||||||
|
response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||||
|
response.end('forgeflow-demo-ready');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const relative = pathname === '/' ? 'index.html' : pathname.replace(/^\//, '');
|
const relative = pathname === '/' ? 'index.html' : pathname.replace(/^\//, '');
|
||||||
const target = path.resolve(root, relative);
|
const target = path.resolve(root, relative);
|
||||||
if (!target.startsWith(root)) throw Object.assign(new Error('Forbidden'), { code: 'EACCES' });
|
if (!target.startsWith(root)) throw Object.assign(new Error('Forbidden'), { code: 'EACCES' });
|
||||||
|
|||||||
+17
-2
@@ -88,6 +88,9 @@ const required = [
|
|||||||
"docs/RELEASE_NOTES_0.10.6.md",
|
"docs/RELEASE_NOTES_0.10.6.md",
|
||||||
"docs/RELEASE_NOTES_0.10.7.md",
|
"docs/RELEASE_NOTES_0.10.7.md",
|
||||||
"docs/RELEASE_NOTES_0.10.8.md",
|
"docs/RELEASE_NOTES_0.10.8.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.9.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.10.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.11.md",
|
||||||
"docs/UPDATING.md",
|
"docs/UPDATING.md",
|
||||||
"docs/DIAGNOSTICS.md",
|
"docs/DIAGNOSTICS.md",
|
||||||
"docs/DEPLOYMENT_SETUP.md",
|
"docs/DEPLOYMENT_SETUP.md",
|
||||||
@@ -126,9 +129,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.8")
|
if (packageJson.version !== "0.10.11")
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Expected package version 0.10.8, got ${packageJson.version}.`,
|
`Expected package version 0.10.11, got ${packageJson.version}.`,
|
||||||
);
|
);
|
||||||
const sourceManifest = await readFile(
|
const sourceManifest = await readFile(
|
||||||
path.join(root, "SOURCE_MANIFEST.txt"),
|
path.join(root, "SOURCE_MANIFEST.txt"),
|
||||||
@@ -474,6 +477,18 @@ const release0108 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.8.md
|
|||||||
for (const phrase of ["Get-FileHash", ".NET SHA-256", "PSModulePath", "binary", "source update helpers"]) {
|
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}`);
|
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 release01011 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.11.md"), "utf8");
|
||||||
|
for (const phrase of ["last-known-good", "closed output pipe", "linked checkout origin", "read-only deploy key", "browser test server"]) {
|
||||||
|
if (!release01011.includes(phrase)) throw new Error(`0.10.11 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}`);
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ function register(channel, handler) {
|
|||||||
stack: error?.stack,
|
stack: error?.stack,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.error(`[${channel}]`, error);
|
|
||||||
return { ok: false, error: toErrorPayload(error) };
|
return { ok: false, error: toErrorPayload(error) };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
function isBrokenPipeError(error) {
|
||||||
|
return error?.code === 'EPIPE';
|
||||||
|
}
|
||||||
|
|
||||||
|
function installOutputPipeGuards({
|
||||||
|
stdout = process.stdout,
|
||||||
|
stderr = process.stderr,
|
||||||
|
onBrokenPipe = () => {}
|
||||||
|
} = {}) {
|
||||||
|
const guardedStreams = [stdout, stderr].filter(Boolean);
|
||||||
|
const handlers = guardedStreams.map((stream) => {
|
||||||
|
const handler = (error) => {
|
||||||
|
if (!isBrokenPipeError(error)) throw error;
|
||||||
|
onBrokenPipe(error);
|
||||||
|
};
|
||||||
|
stream.on('error', handler);
|
||||||
|
return { stream, handler };
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
for (const { stream, handler } of handlers) stream.off('error', handler);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { installOutputPipeGuards, isBrokenPipeError };
|
||||||
@@ -29,6 +29,8 @@ class RepositoryService {
|
|||||||
this.gitea = giteaService;
|
this.gitea = giteaService;
|
||||||
this.diagnostics = diagnostics;
|
this.diagnostics = diagnostics;
|
||||||
this.lastKnownLocalPaths = [];
|
this.lastKnownLocalPaths = [];
|
||||||
|
this.lastKnownRemoteRepositories = [];
|
||||||
|
this.lastSuccessfulRemoteRefreshAt = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async discoverInRoot(root, maxDepth = 4) {
|
async discoverInRoot(root, maxDepth = 4) {
|
||||||
@@ -80,11 +82,37 @@ class RepositoryService {
|
|||||||
return [...this.lastKnownLocalPaths];
|
return [...this.lastKnownLocalPaths];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getRemoteRepositories() {
|
||||||
|
if (!this.store.data.gitea.baseUrl || !this.store.getToken()) {
|
||||||
|
this.lastKnownRemoteRepositories = [];
|
||||||
|
this.lastSuccessfulRemoteRefreshAt = null;
|
||||||
|
return { repositories: [], stale: false, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const repositories = await this.gitea.listRepositories();
|
||||||
|
this.lastKnownRemoteRepositories = repositories.map((repository) => ({ ...repository }));
|
||||||
|
this.lastSuccessfulRemoteRefreshAt = new Date().toISOString();
|
||||||
|
return { repositories, stale: false, error: null };
|
||||||
|
} catch (error) {
|
||||||
|
if (!this.lastSuccessfulRemoteRefreshAt) throw error;
|
||||||
|
await this.diagnostics?.warning('repositories.remote-refresh.degraded', {
|
||||||
|
message: error.message,
|
||||||
|
cachedCount: this.lastKnownRemoteRepositories.length,
|
||||||
|
lastSuccessfulAt: this.lastSuccessfulRemoteRefreshAt
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
repositories: this.lastKnownRemoteRepositories.map((repository) => ({ ...repository })),
|
||||||
|
stale: true,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async refresh() {
|
async refresh() {
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
const remoteRepositories = this.store.data.gitea.baseUrl && this.store.getToken()
|
const remoteResult = await this.getRemoteRepositories();
|
||||||
? await this.gitea.listRepositories()
|
const remoteRepositories = remoteResult.repositories;
|
||||||
: [];
|
|
||||||
|
|
||||||
const discoveredPaths = await this.discoverAll(this.store.data.workspaceRoots);
|
const discoveredPaths = await this.discoverAll(this.store.data.workspaceRoots);
|
||||||
const mappedPaths = Object.values(this.store.data.repositoryMappings || {});
|
const mappedPaths = Object.values(this.store.data.repositoryMappings || {});
|
||||||
@@ -106,7 +134,12 @@ class RepositoryService {
|
|||||||
...profile,
|
...profile,
|
||||||
state: this.store.getDeploymentState(profile.id)
|
state: this.store.getDeploymentState(profile.id)
|
||||||
}));
|
}));
|
||||||
repositories.push(this.decorate(remote, local, profiles));
|
repositories.push({
|
||||||
|
...this.decorate(remote, local, profiles),
|
||||||
|
remoteStale: remoteResult.stale,
|
||||||
|
remoteRefreshError: remoteResult.error,
|
||||||
|
remoteLastRefreshedAt: this.lastSuccessfulRemoteRefreshAt
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const local of localDescriptors.filter((item) => !usedLocalPaths.has(item.localPath))) {
|
for (const local of localDescriptors.filter((item) => !usedLocalPaths.has(item.localPath))) {
|
||||||
@@ -145,6 +178,7 @@ class RepositoryService {
|
|||||||
await this.diagnostics?.debug('repositories.refresh.completed', {
|
await this.diagnostics?.debug('repositories.refresh.completed', {
|
||||||
durationMs: Date.now() - started,
|
durationMs: Date.now() - started,
|
||||||
remoteCount: remoteRepositories.length,
|
remoteCount: remoteRepositories.length,
|
||||||
|
remoteStale: remoteResult.stale,
|
||||||
discoveredCount: discoveredPaths.length,
|
discoveredCount: discoveredPaths.length,
|
||||||
linkedCount: sorted.filter((item) => item.localPath).length,
|
linkedCount: sorted.filter((item) => item.localPath).length,
|
||||||
attentionCount: sorted.filter((item) => item.attention).length,
|
attentionCount: sorted.filter((item) => item.attention).length,
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
"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.localStatus?.remoteUrl,
|
||||||
|
repository.sshUrl,
|
||||||
|
repository.preferredCloneUrl,
|
||||||
|
profile.cloneUrl,
|
||||||
|
]
|
||||||
.map((value) => String(value || "").trim())
|
.map((value) => String(value || "").trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const value = candidates.find((candidate) => /^ssh:\/\//i.test(candidate) || /^[^@\s]+@[^:\s]+:.+/.test(candidate));
|
const value = candidates.find((candidate) => /^ssh:\/\//i.test(candidate) || /^[^@\s]+@[^:\s]+:.+/.test(candidate));
|
||||||
@@ -212,10 +217,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) {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class UnraidDeployKeyHost {
|
|||||||
return { directory, privateKey: path.join(directory, "deploy-key"), publicKey: path.join(directory, "deploy-key.pub"), knownHosts: path.join(directory, "known_hosts"), recovery: path.join(directory, "recovery") };
|
return { directory, privateKey: path.join(directory, "deploy-key"), publicKey: path.join(directory, "deploy-key.pub"), knownHosts: path.join(directory, "known_hosts"), recovery: path.join(directory, "recovery") };
|
||||||
}
|
}
|
||||||
remote(repository, profile) {
|
remote(repository, profile) {
|
||||||
const value = [repository.sshUrl, profile.cloneUrl, repository.preferredCloneUrl].map((item) => String(item || "").trim()).find((item) => /^ssh:\/\//i.test(item) || /^[^@\s]+@[^:\s]+:.+/.test(item));
|
const value = [repository.localStatus?.remoteUrl, repository.sshUrl, repository.preferredCloneUrl, profile.cloneUrl].map((item) => String(item || "").trim()).find((item) => /^ssh:\/\//i.test(item) || /^[^@\s]+@[^:\s]+:.+/.test(item));
|
||||||
if (!value) throw Object.assign(new Error("Server pull requires a Gitea SSH URL."), { code: "SERVER_GIT_SSH_URL_REQUIRED" });
|
if (!value) throw Object.assign(new Error("Server pull requires a Gitea SSH URL."), { code: "SERVER_GIT_SSH_URL_REQUIRED" });
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -418,9 +418,12 @@ function createUnraidDeploymentMethods({
|
|||||||
|
|
||||||
const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : "";
|
const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : "";
|
||||||
const iconReference = await this.prepareIcon(profile, repository, server);
|
const iconReference = await this.prepareIcon(profile, repository, server);
|
||||||
|
const deploymentRepositoryUrl = mode === "server-git"
|
||||||
|
? this.serverGitRemote(repository, profile)
|
||||||
|
: repository.localStatus?.remoteUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName;
|
||||||
const metadata = this.metadataCompose(profile, repository, iconReference, {
|
const metadata = this.metadataCompose(profile, repository, iconReference, {
|
||||||
sha: targetSha,
|
sha: targetSha,
|
||||||
repositoryUrl: profile.cloneUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName,
|
repositoryUrl: deploymentRepositoryUrl,
|
||||||
});
|
});
|
||||||
const previousState = this.store.getDeploymentState?.(profileId) || null;
|
const previousState = this.store.getDeploymentState?.(profileId) || null;
|
||||||
|
|
||||||
@@ -518,9 +521,12 @@ function createUnraidDeploymentMethods({
|
|||||||
});
|
});
|
||||||
const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : "";
|
const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : "";
|
||||||
const iconReference = await this.prepareIcon(profile, repository, server);
|
const iconReference = await this.prepareIcon(profile, repository, server);
|
||||||
|
const rollbackRepositoryUrl = rollbackMode === "server-git"
|
||||||
|
? this.serverGitRemote(repository, profile)
|
||||||
|
: repository.localStatus?.remoteUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName;
|
||||||
const metadata = this.metadataCompose(profile, repository, iconReference, {
|
const metadata = this.metadataCompose(profile, repository, iconReference, {
|
||||||
sha: target,
|
sha: target,
|
||||||
repositoryUrl: profile.cloneUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName,
|
repositoryUrl: rollbackRepositoryUrl,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const mode = rollbackMode;
|
const mode = rollbackMode;
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -283,6 +283,35 @@ 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 || {};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ const ui = {
|
|||||||
inputRenderTimer: null,
|
inputRenderTimer: null,
|
||||||
isMock: false,
|
isMock: false,
|
||||||
refreshError: null,
|
refreshError: null,
|
||||||
|
refreshWarning: null,
|
||||||
autoRefreshPending: false,
|
autoRefreshPending: false,
|
||||||
paletteQuery: "",
|
paletteQuery: "",
|
||||||
updateStatus: null,
|
updateStatus: null,
|
||||||
@@ -428,6 +429,12 @@ async function refreshRepositories(withLoader = true, silent = false) {
|
|||||||
const selectedId = ui.selectedRepoId;
|
const selectedId = ui.selectedRepoId;
|
||||||
ui.repositories = await window.forgeflow.refreshRepositories();
|
ui.repositories = await window.forgeflow.refreshRepositories();
|
||||||
ui.refreshError = null;
|
ui.refreshError = null;
|
||||||
|
const staleRepository = ui.repositories.find(
|
||||||
|
(repository) => repository.remoteStale,
|
||||||
|
);
|
||||||
|
ui.refreshWarning = staleRepository
|
||||||
|
? `Gitea could not be reached. Showing repository data last refreshed ${formatDate(staleRepository.remoteLastRefreshedAt)} while local and server state continue to refresh.`
|
||||||
|
: null;
|
||||||
if (selectedId && !selectedRepository()) ui.selectedRepoId = null;
|
if (selectedId && !selectedRepository()) ui.selectedRepoId = null;
|
||||||
const repository = selectedRepository();
|
const repository = selectedRepository();
|
||||||
if (
|
if (
|
||||||
@@ -459,6 +466,7 @@ async function refreshRepositories(withLoader = true, silent = false) {
|
|||||||
selectRepository(ui.repositories[0].id, false);
|
selectRepository(ui.repositories[0].id, false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ui.refreshError = error.message;
|
ui.refreshError = error.message;
|
||||||
|
ui.refreshWarning = null;
|
||||||
if (!silent) showToast("Refresh failed", error.message, "error");
|
if (!silent) showToast("Refresh failed", error.message, "error");
|
||||||
} finally {
|
} finally {
|
||||||
if (withLoader) setLoading(false);
|
if (withLoader) setLoading(false);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ function createMockRepositoryBridge(context) {
|
|||||||
await wait(80);
|
await wait(80);
|
||||||
snapshot();
|
snapshot();
|
||||||
return {
|
return {
|
||||||
appVersion: "0.10.8-demo",
|
appVersion: "0.10.11-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" },
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ function renderOverview() {
|
|||||||
return `<div class="page">
|
return `<div class="page">
|
||||||
<div class="page-header visual-page-header"><div><div class="eyebrow">Coding flow</div><h1>Release overview</h1><p>One decision surface for local work, Gitea synchronization and the exact version running on your server.</p></div>${projectIllustration("flow")}<button class="button" data-action="refresh">${icon("refresh")}Refresh all</button></div>
|
<div class="page-header visual-page-header"><div><div class="eyebrow">Coding flow</div><h1>Release overview</h1><p>One decision surface for local work, Gitea synchronization and the exact version running on your server.</p></div>${projectIllustration("flow")}<button class="button" data-action="refresh">${icon("refresh")}Refresh all</button></div>
|
||||||
${ui.refreshError ? `<div class="notice danger">${icon("error")} ${escapeHtml(ui.refreshError)}</div>` : ""}
|
${ui.refreshError ? `<div class="notice danger">${icon("error")} ${escapeHtml(ui.refreshError)}</div>` : ""}
|
||||||
|
${ui.refreshWarning ? `<div class="notice warning">${icon("warning")} ${escapeHtml(ui.refreshWarning)}</div>` : ""}
|
||||||
<div class="summary-grid">
|
<div class="summary-grid">
|
||||||
${renderSummaryCard("Local work", changed, changed === 1 ? "repository has changes" : "repositories have changes", "file", changed ? "warning" : "success")}
|
${renderSummaryCard("Local work", changed, changed === 1 ? "repository has changes" : "repositories have changes", "file", changed ? "warning" : "success")}
|
||||||
${renderSummaryCard("Unpushed", unpushed, "repositories ahead of Gitea", "arrowUp", unpushed ? "warning" : "success")}
|
${renderSummaryCard("Unpushed", unpushed, "repositories ahead of Gitea", "arrowUp", unpushed ? "warning" : "success")}
|
||||||
@@ -372,7 +373,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 || [];
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
const navigation = page.locator(`.nav-button[data-view="${view}"]`);
|
||||||
|
await navigation.click();
|
||||||
|
await expect(navigation).toHaveClass(/active/);
|
||||||
await assertScrollableWhenOverflowing(page, ".main-canvas");
|
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"]) {
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import policyModule from '../src/main/process-error-policy.cjs';
|
||||||
|
|
||||||
|
const { installOutputPipeGuards, isBrokenPipeError } = policyModule;
|
||||||
|
|
||||||
|
test('broken output pipes are recognized without treating unrelated failures as EPIPE', () => {
|
||||||
|
assert.equal(isBrokenPipeError(Object.assign(new Error('closed'), { code: 'EPIPE' })), true);
|
||||||
|
assert.equal(isBrokenPipeError(Object.assign(new Error('denied'), { code: 'EACCES' })), false);
|
||||||
|
assert.equal(isBrokenPipeError(null), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('output pipe guard absorbs EPIPE and can be cleanly removed', () => {
|
||||||
|
const stdout = new EventEmitter();
|
||||||
|
const stderr = new EventEmitter();
|
||||||
|
const observed = [];
|
||||||
|
const remove = installOutputPipeGuards({ stdout, stderr, onBrokenPipe: (error) => observed.push(error.code) });
|
||||||
|
|
||||||
|
stdout.emit('error', Object.assign(new Error('closed'), { code: 'EPIPE' }));
|
||||||
|
stderr.emit('error', Object.assign(new Error('closed'), { code: 'EPIPE' }));
|
||||||
|
assert.deepEqual(observed, ['EPIPE', 'EPIPE']);
|
||||||
|
remove();
|
||||||
|
assert.equal(stdout.listenerCount('error'), 0);
|
||||||
|
assert.equal(stderr.listenerCount('error'), 0);
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -143,6 +143,47 @@ test('refresh remains local-only without configured Gitea credentials', async ()
|
|||||||
assert.deepEqual(await instance.refresh(), []);
|
assert.deepEqual(await instance.refresh(), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('refresh uses last-known Gitea repositories after a transient remote failure', async () => {
|
||||||
|
const warnings = [];
|
||||||
|
let remoteAvailable = true;
|
||||||
|
const store = {
|
||||||
|
data: {
|
||||||
|
gitea: { baseUrl: 'https://gitea.example' }, workspaceRoots: [], repositoryMappings: {},
|
||||||
|
preferences: { preferredCloneProtocol: 'ssh' }, favorites: []
|
||||||
|
},
|
||||||
|
getToken: () => 'token', getDeploymentProfiles: () => [], getDeploymentState: () => null
|
||||||
|
};
|
||||||
|
const instance = new RepositoryService(store, {}, {
|
||||||
|
listRepositories: async () => {
|
||||||
|
if (!remoteAvailable) throw new Error('Gitea timed out');
|
||||||
|
return [remote];
|
||||||
|
}
|
||||||
|
}, { debug: async () => {}, warning: async (...args) => warnings.push(args) });
|
||||||
|
instance.discoverAll = async () => [];
|
||||||
|
|
||||||
|
const fresh = await instance.refresh();
|
||||||
|
remoteAvailable = false;
|
||||||
|
const degraded = await instance.refresh();
|
||||||
|
|
||||||
|
assert.equal(fresh[0].remoteStale, false);
|
||||||
|
assert.equal(degraded[0].fullName, remote.full_name);
|
||||||
|
assert.equal(degraded[0].remoteStale, true);
|
||||||
|
assert.equal(degraded[0].remoteRefreshError, 'Gitea timed out');
|
||||||
|
assert.ok(degraded[0].remoteLastRefreshedAt);
|
||||||
|
assert.equal(warnings[0][0], 'repositories.remote-refresh.degraded');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initial Gitea failure remains visible when no safe cache exists', async () => {
|
||||||
|
const store = {
|
||||||
|
data: { gitea: { baseUrl: 'https://gitea.example' } },
|
||||||
|
getToken: () => 'token'
|
||||||
|
};
|
||||||
|
const instance = new RepositoryService(store, {}, {
|
||||||
|
listRepositories: async () => { throw new Error('Gitea unavailable'); }
|
||||||
|
});
|
||||||
|
await assert.rejects(() => instance.refresh(), /Gitea unavailable/);
|
||||||
|
});
|
||||||
|
|
||||||
test('decoration reports conflicts, behind branches, errors and remote-only repositories', () => {
|
test('decoration reports conflicts, behind branches, errors and remote-only repositories', () => {
|
||||||
const instance = service();
|
const instance = service();
|
||||||
const conflicted = instance.decorate(remote, { localPath: 'repo', status: { ...status(), counts: { changed: 1, conflicts: 2 }, branch: { ...status().branch, behind: 3 } } }, []);
|
const conflicted = instance.decorate(remote, { localPath: 'repo', status: { ...status(), counts: { changed: 1, conflicts: 2 }, branch: { ...status().branch, behind: 3 } } }, []);
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,36 @@ 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("server pull prefers the linked checkout origin over stale detected SSH endpoints", () => {
|
||||||
|
const service = new UnraidDeploymentService({});
|
||||||
|
const repository = {
|
||||||
|
fullName: "Jens/Portfolio",
|
||||||
|
localStatus: { remoteUrl: "git@gitea.itworx.tech:Jens/Portfolio.git" },
|
||||||
|
sshUrl: "ssh://git@192.168.10.150:222/Jens/Portfolio.git",
|
||||||
|
preferredCloneUrl: "ssh://git@192.168.10.150:222/Jens/Portfolio.git",
|
||||||
|
};
|
||||||
|
const profile = { cloneUrl: "ssh://git@192.168.10.150:222/Jens/Portfolio.git" };
|
||||||
|
|
||||||
|
assert.equal(service.serverGitRemote(repository, profile), "git@gitea.itworx.tech:Jens/Portfolio.git");
|
||||||
|
assert.deepEqual(service.serverGitHost(repository, profile), { host: "gitea.itworx.tech", port: 22 });
|
||||||
|
});
|
||||||
|
|
||||||
|
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 +172,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 = {
|
||||||
@@ -1071,6 +1158,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/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1294,7 +1383,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 });
|
||||||
@@ -1308,6 +1400,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");
|
||||||
|
|||||||
Reference in New Issue
Block a user