From 7b05c953b6c604eca5727554a6cecc64aa88c869 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:11:07 +0200 Subject: [PATCH] refactor: split renderer ipc and unraid domains --- SOURCE_MANIFEST.txt | 81 +- package.json | 1 + reports/architecture-audit.json | 669 ++++ reports/architecture-audit.md | 27 + scripts/architecture-audit.mjs | 53 + scripts/verify.mjs | 18 +- src/main/ipc.cjs | 758 +--- src/main/ipc/deployment-handlers.cjs | 284 ++ src/main/ipc/operations-handlers.cjs | 100 + src/main/ipc/repository-handlers.cjs | 389 ++ src/main/unraid-access-methods.cjs | 447 +++ src/main/unraid-deployment-methods.cjs | 576 +++ src/main/unraid-deployment-service.cjs | 2828 +-------------- src/main/unraid-inventory-methods.cjs | 579 +++ src/main/unraid-preflight-methods.cjs | 593 +++ src/main/unraid-runtime-methods.cjs | 387 ++ src/main/unraid-state-methods.cjs | 282 ++ src/renderer/actions/command.js | 22 + src/renderer/actions/deployment-operation.js | 183 + src/renderer/actions/deployment-profile.js | 401 ++ src/renderer/actions/inventory.js | 185 + src/renderer/actions/recovery.js | 355 ++ src/renderer/actions/setup-and-settings.js | 426 +++ src/renderer/actions/shell.js | 480 +++ src/renderer/app.js | 3412 ------------------ src/renderer/dialogs.js | 319 ++ src/renderer/events.js | 169 + src/renderer/index.html | 13 + src/renderer/mock-bridge.js | 1288 +------ src/renderer/mock-deployment-bridge.js | 660 ++++ src/renderer/mock-repository-bridge.js | 635 ++++ src/renderer/operations.js | 211 ++ src/renderer/views.js | 694 ++++ tests/ipc-contract.test.mjs | 14 +- tests/renderer-workflow.test.mjs | 85 +- tests/unraid-deployment.test.mjs | 22 +- 36 files changed, 9316 insertions(+), 8330 deletions(-) create mode 100644 reports/architecture-audit.json create mode 100644 reports/architecture-audit.md create mode 100644 scripts/architecture-audit.mjs create mode 100644 src/main/ipc/deployment-handlers.cjs create mode 100644 src/main/ipc/operations-handlers.cjs create mode 100644 src/main/ipc/repository-handlers.cjs create mode 100644 src/main/unraid-access-methods.cjs create mode 100644 src/main/unraid-deployment-methods.cjs create mode 100644 src/main/unraid-inventory-methods.cjs create mode 100644 src/main/unraid-preflight-methods.cjs create mode 100644 src/main/unraid-runtime-methods.cjs create mode 100644 src/main/unraid-state-methods.cjs create mode 100644 src/renderer/actions/command.js create mode 100644 src/renderer/actions/deployment-operation.js create mode 100644 src/renderer/actions/deployment-profile.js create mode 100644 src/renderer/actions/inventory.js create mode 100644 src/renderer/actions/recovery.js create mode 100644 src/renderer/actions/setup-and-settings.js create mode 100644 src/renderer/actions/shell.js create mode 100644 src/renderer/dialogs.js create mode 100644 src/renderer/events.js create mode 100644 src/renderer/mock-deployment-bridge.js create mode 100644 src/renderer/mock-repository-bridge.js create mode 100644 src/renderer/operations.js create mode 100644 src/renderer/views.js diff --git a/SOURCE_MANIFEST.txt b/SOURCE_MANIFEST.txt index 4ac375a..f722ba4 100644 --- a/SOURCE_MANIFEST.txt +++ b/SOURCE_MANIFEST.txt @@ -15,8 +15,40 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263 25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png 5f4aca19a35cbcaffa1a6993ce96b7d66052ec2b286022f2af74594e8a310568 15712 CHANGELOG.md -2889007aef2cba3e324cda97892a7292ccf9b194e38b2a41c851f65a8a91bc17 89174 coverage/tmp/coverage-22080-1785338379849-0.json -cd725a6cfaacbe4580c61193a8772674279692b9632b4f41612c7eb4e349a57c 345759 coverage/tmp/coverage-40380-1785338384553-0.json +1101ae8a14a4f0cf0a56a3a73a60e7a231e07ea7872d936904c605abf6298a34 288319 coverage/tmp/coverage-16016-1785339516187-0.json +d655dca9239887ef791e144308830fe97ae99b3531449944254d9733b27a1cae 111306 coverage/tmp/coverage-20500-1785339516683-0.json +9f13cddf4882c263f5da10e82eda888aa80d60eba5a4880646fde183ea41451f 329357 coverage/tmp/coverage-23076-1785339516366-0.json +bd1b7d71c9b2d6a89c7b96c0da51d634d7e8f9e335822f9601998eb7d1264b66 367435 coverage/tmp/coverage-30956-1785339516246-0.json +a27032411a329ff88d2ca8b0365f1c9c04f86951763bfdbdc6b1c76dcb4dbb10 327419 coverage/tmp/coverage-33844-1785339515162-0.json +59c785d1cabcee6d1069421425732d67ede026fdf907922724b894ec60f23f7c 303089 coverage/tmp/coverage-36076-1785339516543-0.json +6ccdab75e41e28365282114305041c342bf0924b488673d4afbebe8dc0b2bf4d 426817 coverage/tmp/coverage-39452-1785339516633-0.json +7019945829625166aafaf3fac38ed2095c9b6ccef4db451b103c8e641c5b31c4 301396 coverage/tmp/coverage-39516-1785339515312-0.json +204f45a7f69e83e727b5d06713b59fe7cf76fa2b8acfb9909b8a226cfebe6919 299000 coverage/tmp/coverage-41400-1785339515472-0.json +28305a502a85cc95ad37adab0c80abe7ad9923e1fb7739c8b3c01694cfd5303a 335944 coverage/tmp/coverage-43744-1785339515697-0.json +61d18db7fece14e6ffc192cd0c93556c176276c5738438a6cbc96bac4b6ebab7 282471 coverage/tmp/coverage-44320-1785339516169-0.json +10ebff421ee2f56d9a2b3ea87ec43d397609111a1e028a810472757dc1fbc6c2 346825 coverage/tmp/coverage-44612-1785339515256-0.json +f9179452c930d41972809015ef90e2d5cdbfd9a460f50420fb2131869f510f8d 354683 coverage/tmp/coverage-50832-1785339515755-0.json +6265af5161de8807eff28cba2834f6a29c93216b4b4ac3fb0955f14e7a8d677e 89173 coverage/tmp/coverage-52036-1785339515055-0.json +bd8a4f824da29801902c1f305d3804f249efb6233d44f902c04a0c35e2d4ba76 342639 coverage/tmp/coverage-52864-1785339515537-0.json +ca7431ed712d082aca6348fdeda4c67207fdab56656e0feda5fad74e60ccf4db 336007 coverage/tmp/coverage-53260-1785339515757-0.json +f834e80b44c37263816fa5ac9f29870658690453aa826db1f8a6841e5ed554b2 335634 coverage/tmp/coverage-53596-1785339519653-0.json +b8c977c8dc0f34e968c59071c376ec1e19ddc45440e3ff96d844f48eda12be05 284302 coverage/tmp/coverage-55056-1785339515769-0.json +40cad1955f780f9fd42cdcbe61d04acba2d3f7662bb27639f16e5f0820df83b3 321055 coverage/tmp/coverage-55928-1785339516726-0.json +b9559b72bc441f91eed9e65122636e5e76c8ffcb2dbfe0501fea8860d6868b12 320075 coverage/tmp/coverage-56416-1785339517848-0.json +2e027822e3475abb5caef8d1326f76d5e2f0a0512656bfd9747135a86bf1e4f3 344498 coverage/tmp/coverage-56544-1785339516170-0.json +30eada55184a94693954401a5cb9e1f37ebaee78075ed569b7ad0762aae0d123 344612 coverage/tmp/coverage-56608-1785339518494-0.json +09ac59a2ff03d78b2b025a266af53e27b67c6f33a562afba9da62b89938c8991 318324 coverage/tmp/coverage-57192-1785339516055-0.json +1b35c0e476c27889b191455271562550e146e0b4129721e8938836e9e5e25414 344933 coverage/tmp/coverage-57204-1785339516743-0.json +9fb213f290c39ec6c4d89dab2e86dde5109551a0c0f64b6b1cc020557e4a515d 302345 coverage/tmp/coverage-57868-1785339516192-0.json +993ece7e08147c4d88bce3fe791aacb6af20c487a9ad3f59f0d8ed25a0661892 304507 coverage/tmp/coverage-58136-1785339515403-0.json +344bd1ec56b83794737ea3c0c889fe5629a0baf4993d550048deb6fe1c2f1f03 352004 coverage/tmp/coverage-58704-1785339515317-0.json +3106fd06d0269f55a759c491d8d73a844ad9b13db96a0ed839407630daad1cd4 310268 coverage/tmp/coverage-58996-1785339516528-0.json +2a1b4078c040a602b68d7c684e7b4a70b330ea424866bc043baa5f3f7eb867aa 360892 coverage/tmp/coverage-59356-1785339516204-0.json +deb5210ec65db9e7aaf85bb4b3bda1ec5c9fdc89709b048712ca4a82836f84b7 358381 coverage/tmp/coverage-59756-1785339516948-0.json +3d435d5982a18f5876080c2cf0e181beb0e3759d838c429e1d1d4e6dcbe97634 302512 coverage/tmp/coverage-59972-1785339516032-0.json +0dbfa219b3a04e5854b09c803e5092b09320b2c87ebfb9c6ed69f88c2730944c 360066 coverage/tmp/coverage-59988-1785339547987-0.json +bdce38d30ef6798ae45146d58a16f794910c9016fe3f0591aa6b65613cc600a0 345121 coverage/tmp/coverage-60804-1785339522999-0.json +de23d1f8a3a19c8297c97fc44d0cccd2dff77242d7789112c5177738c0ab40a7 296834 coverage/tmp/coverage-61136-1785339515987-0.json 21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md 72e846f591c47a0291e7466e58e052d3d5afcf551c4e6c848632ac3c552a1244 3043 docs/CURRENT_STATE.md @@ -89,15 +121,18 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 910e179eb2743989725e7fe18efd291ff098b4335aae4b9eb530b579efa6fd3f 13654 main.cjs 91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md f3e2a262e251eb165f342dc3c184625ec10d56e536b5a987199b9944f463eee9 181069 package-lock.json -48e25abda43f5463c5c4631f5a29f7e9b2e744383a23489b4e54dedf38c74940 4817 package.json +43725a143cb927cc70bd823d789788d785d872119c008043c270bbf73948851f 4882 package.json 86876cd502f51ad65e9c9280b189a80579df99ba65c81daf338048693d6ec78f 11732 preload.cjs abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1 688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1 794bbe1937077788f34c64398fd73dc9a3c43d095084aa32673f3e968b115de2 9150 README.md +adbed8dcc399e1c55393391f81ca6a8789840ce17ee942980d2593813aee166d 13680 reports/architecture-audit.json +9683d4988b4fbdfeef6cbb8c50e515e34c3c09bc55fb5f5913c6bb14e592f0c2 1105 reports/architecture-audit.md 509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs 00d57bda5af8c8eda294b72d18b318f024a307b81b0d9205a0821f5240151e31 3814 scripts/apply-binary-update.ps1 f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287 scripts/apply-source-update.ps1 +02e924227f6cad3777fd06660230c85df590d8ce95e134194a4d18970a240b88 4145 scripts/architecture-audit.mjs fca922d7d1de598a0153f3828300c7d0327e0189c06a53a23d9fc33b36d45d91 4741 scripts/audit-installed-deployments.cjs 6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs f6f89e893195b9c8ef0ff01e256005b9b3cd7d4a278722979a5e0e616c86a89f 1733 scripts/generate-source-manifest.mjs @@ -106,7 +141,7 @@ b83d443f5724ac15393567f3a688aed8315fbe3e5966832c864a9466e0669464 8102 444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs 4393f7dc5f417e6d601a68238f4e26791799a3634acec228fe4d79deaee85eb5 3109 scripts/validate-installed-connections.cjs d2dd98055e50f11b4e1484531e43fb5ac7f876bea4b9cf5bca2cb0a15022b60a 1913 scripts/verify-release-signatures.mjs -20cc93c86efaf4d23851225e031639b093cafb23dabb083e1804cade17651e82 16542 scripts/verify.mjs +e8c5d00737d4c5e2c37ff683e569e8d7a0510be233db3840460c78be69f2c4bc 17163 scripts/verify.mjs 0b9f03ba3c67ff7cdb2916a902ad8ce25e81a7c90b210e4ae52d2ad029efabf3 2353 scripts/write-release-checksums.mjs 619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1 dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs @@ -122,7 +157,10 @@ a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 2faaef0eeec1e473db82b94243674e3812ef5869358b0ec6abf8d645a8794623 20768 src/main/gitea-service.cjs 8c3c7b9bf0893276f5ab520efba17fc143c64377128d754411cd911180025c4a 5743 src/main/inventory-classifier.cjs dafdb09133d2b6ec2161a3f0b09354551e54fc606c8107976fca37405643be91 3404 src/main/inventory-review-service.cjs -92c1afbfeaa02dec1dc6fd646f6f633d774cbf733635e40b74957809a3796b70 54676 src/main/ipc.cjs +00989577aed509a7ddfdf9f4df09a196a393a85b5ea59b21089002215e69f065 25949 src/main/ipc.cjs +0eb1cfdcd3a37a0ec9502bf753966f87230c03580335798bef9265add42ee6fa 12530 src/main/ipc/deployment-handlers.cjs +dc9b5971c9fefe8c374aa31916f5513601ce86003fd48b1d0e51330a909ae3a5 3442 src/main/ipc/operations-handlers.cjs +7c6628caf16228500f15309766cdbbe54f8ea8238a32052123abdc445bb7d51f 14478 src/main/ipc/repository-handlers.cjs 62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs 958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs 3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs @@ -130,17 +168,36 @@ e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508 17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs 52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs afef3841a3948b2121f8fba809aae4ea3da71bd2fda86973ba50200a5b1f89b2 14894 src/main/ssh-service.cjs +e880894fb9626d804617e8e993b88e9cdc8b9972baad4c3751e86932da45b7b3 25506 src/main/unraid-access-methods.cjs 2ede80cd1565a7f2c282cc58d35dc0889d58d7465346bc723026b9c8be4df0ac 9501 src/main/unraid-deploy-key-host.cjs -4f418cfd745ad5e02ac4c83d9f85047a64084ccdbba9d7a3b3f22a7e306686e3 152137 src/main/unraid-deployment-service.cjs +803a079499f7b8148495209dea43b505f6eb9bb587de19e82054e47183186c6e 30461 src/main/unraid-deployment-methods.cjs +2e63fdc0be0bf4d8e5c3d7d45ff5811d786b0821a08b82f511f1301696e12c9d 17157 src/main/unraid-deployment-service.cjs +08f082a2d902ce65dcf65e20a2f90631542b515eecb31cfe09520b5b0ec32ba4 35212 src/main/unraid-inventory-methods.cjs +b385460ea1ea53bb9f10463a0b036a0128efe35ef4f8a453e76181d990ef68e9 26487 src/main/unraid-preflight-methods.cjs +d45220176aed72d692f9ae5534f9d40bcc359a2d08e025e74a3b3b505b8b9ed4 16559 src/main/unraid-runtime-methods.cjs +d4b3a07eeca687a49544a94ea574f7c6f7e0bc3aa9311b614d5244a468ca4a1b 11307 src/main/unraid-state-methods.cjs b654a9e45044ad32c61fabe4a6d897288615ec83739b53e3241ff881e32f56bd 21677 src/main/update-service.cjs -fe4deba8107baf9869689b9c9c12b0be3f3c6735abcc5e779498d000deb35931 254738 src/renderer/app.js +b5c304531bec358d059189a27cd9db8fa20cefb7f817e5eb0287001f7353f6a7 985 src/renderer/actions/command.js +d0bf607dd1de9d55f2947d0adf0997cd3ca5c269d10a5362cc1d8bc4d1a2a8ae 6706 src/renderer/actions/deployment-operation.js +0f2070aa3b5c404aedf643837dfd7c5d547e8f45b2e9c97e4cfcf11951555474 17705 src/renderer/actions/deployment-profile.js +48bed91dd2a85bb51ee7307f7acc3b79c881ce8cf63b22ba79d5d079b265eb4b 7785 src/renderer/actions/inventory.js +4227a05a20580a31127d2c929640defc3d36e8e3e89d6be830aab1940da81082 12267 src/renderer/actions/recovery.js +cdfaacdcd5ae04b0e5c79fefa21f5e09d5c810bcea504c5b6e1d6b744182ff84 15567 src/renderer/actions/setup-and-settings.js +b17be89568a5602d67dc226bf8ca4464731c88664de8c72a880493c96abc1963 16825 src/renderer/actions/shell.js +d33bdc89a17fbe921dacbb035dee84c9cc10c161259a02772196d9f26a17c4e9 24089 src/renderer/app.js 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png 094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png -fb7ed47f9aac50d9259d7d3c3bb2010c7bfdd2fe8e8e47ca2744bb22f0057d54 830 src/renderer/index.html -b7f0b3ab5c42e008ce436a61c30d6226ceb6c821c21e7dd7b0c12f37e121757d 66811 src/renderer/mock-bridge.js +5d6c87d51e55c9e976cf83ce242be0c28cf831d9c52082f02e745193500e6348 47992 src/renderer/dialogs.js +7abdd25671edcc63b134a13f013890c67a5988d2d12c0e969506d75abe83ec3c 5630 src/renderer/events.js +a84da5aecbb16ce7983dba1f6d6aab1bf47b2e9a87c2933fa1afb8123f7ef7d6 1497 src/renderer/index.html +06180d9656dd254edfb6949c397f8e313954fc560ddcb22b3a35fce3c3e35655 21350 src/renderer/mock-bridge.js +6da0d3e9fcbe6040cf30bdefb566dd8a7a26df2352233124abd8a011034f477e 26667 src/renderer/mock-deployment-bridge.js +26065ffa2359cd27b9c9b5b9fb67bcad83ba0f118960c7c024e7e9392dbb16a3 20032 src/renderer/mock-repository-bridge.js +94fa265c2fe9ca8d644f0ce9b620b6f85d9b25dca5802c4e9195b66dcbe80120 6522 src/renderer/operations.js 45692591428575b518678a6b548c25d3de95f568541e5648b0f06f42c48b5bbf 77872 src/renderer/styles.css +314d0db4d5ae2042ab76516daf452bc7c49c3e667dfc06c731dd3f47b153e12a 93872 src/renderer/views.js 0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs 5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs 029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs @@ -167,11 +224,11 @@ e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.test.mjs 771eeb4fa5443d581991cedb4107d7c414ce1b7d2e14bac7cf719ec8ba131647 8969 tests/gitea-actions.test.mjs 8f260f35aaf162999ddcd0f851a4f215222d9de0880d602b8322facdaa4c2cb0 9190 tests/inventory-classifier.test.mjs -48bca4711e7c193d19c78a0cb45ea1c83179b3c23640195f66058268e8a11b52 1520 tests/ipc-contract.test.mjs +9643622a03ea0a88fb7d72ce43e469ff4f814902f4b3d2a672990637d66ef075 2009 tests/ipc-contract.test.mjs caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs 96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 3718 tests/preflight.test.mjs -69a86fd630425227bf1e1881fe9b2c8db39a694310948a33cd4074c801326c92 9807 tests/renderer-workflow.test.mjs +676a5a791e94dcda11ee1f1826d5c4de2f94389072b4f53a5228a57cb0c5d9e0 9493 tests/renderer-workflow.test.mjs 2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 1068 tests/repository-monitor.test.mjs 75b5b83836c75675bb9a48fe4363fcb8a24fc425e6af6f822d7955c6f3c79eac 2265 tests/repository-service.test.mjs @@ -180,7 +237,7 @@ bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs 2571128f0b8e650071df17755baa09c4dfc441af0c20a7a4e9aa445b59e87d11 1654 tests/ssh-service.test.mjs 8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs -da10db992c0c34d2c4c955ac81806ba6d3dee9be936b70adb550adaef5dbc462 44170 tests/unraid-deployment.test.mjs +fb5213c5f8ae8e98deed620eb93eb27a2317ef5a1ea671dd5ea548f0fd072362 44283 tests/unraid-deployment.test.mjs 861bad3f118c89bd17acf4373170c208c6e29c89af1d40fb2cf010f587a5016f 19008 tests/update-service.test.mjs 9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs 7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs diff --git a/package.json b/package.json index 49fe58c..817ef64 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "dist:mac": "electron-builder --mac dmg && node scripts/prune-dist.mjs", "doctor": "node scripts/doctor.mjs", "acceptance": "node scripts/acceptance.mjs", + "architecture:audit": "node scripts/architecture-audit.mjs", "connections:check": "electron scripts/validate-installed-connections.cjs", "deployments:audit": "electron scripts/audit-installed-deployments.cjs", "release:binary": "electron scripts/publish-binary-release.cjs", diff --git a/reports/architecture-audit.json b/reports/architecture-audit.json new file mode 100644 index 0000000..b2945c4 --- /dev/null +++ b/reports/architecture-audit.json @@ -0,0 +1,669 @@ +{ + "generatedAt": "2026-07-29T16:10:50.148Z", + "thresholds": { + "preferredMaximumLines": 750, + "justificationRequiredLines": 1000 + }, + "over750": [], + "over1000": [], + "cyclomaticHotspots": [ + { + "file": "src/main/server-inventory.cjs", + "lines": 578, + "branches": 89, + "functions": 104, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "security", + "updates" + ], + "hotspotScore": 119 + }, + { + "file": "src/renderer/actions/shell.js", + "lines": 481, + "branches": 89, + "functions": 81, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "renderer" + ], + "hotspotScore": 109 + }, + { + "file": "src/main/git-service.cjs", + "lines": 632, + "branches": 98, + "functions": 109, + "ipcHandlers": 0, + "responsibilities": [ + "git" + ], + "hotspotScore": 98 + } + ], + "mixedResponsibilityModules": [ + { + "file": "src/main/server-inventory.cjs", + "lines": 578, + "branches": 89, + "functions": 104, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "security", + "updates" + ], + "hotspotScore": 119 + }, + { + "file": "src/renderer/app.js", + "lines": 669, + "branches": 74, + "functions": 110, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "renderer", + "security", + "updates" + ], + "hotspotScore": 114 + }, + { + "file": "src/renderer/actions/shell.js", + "lines": 481, + "branches": 89, + "functions": 81, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "renderer" + ], + "hotspotScore": 109 + }, + { + "file": "src/main/ipc.cjs", + "lines": 749, + "branches": 55, + "functions": 79, + "ipcHandlers": 27, + "responsibilities": [ + "inventory", + "deployment", + "git", + "ipc", + "renderer", + "security", + "updates" + ], + "hotspotScore": 105 + }, + { + "file": "src/main/unraid-deployment-methods.cjs", + "lines": 577, + "branches": 69, + "functions": 31, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "security", + "updates" + ], + "hotspotScore": 99 + }, + { + "file": "src/renderer/views.js", + "lines": 695, + "branches": 52, + "functions": 138, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "renderer", + "security", + "updates" + ], + "hotspotScore": 92 + }, + { + "file": "src/renderer/actions/setup-and-settings.js", + "lines": 427, + "branches": 58, + "functions": 58, + "ipcHandlers": 0, + "responsibilities": [ + "deployment", + "git", + "renderer", + "security", + "updates" + ], + "hotspotScore": 88 + }, + { + "file": "src/main/gitea-service.cjs", + "lines": 618, + "branches": 66, + "functions": 57, + "ipcHandlers": 0, + "responsibilities": [ + "deployment", + "git", + "security", + "updates" + ], + "hotspotScore": 86 + }, + { + "file": "src/main/unraid-inventory-methods.cjs", + "lines": 580, + "branches": 56, + "functions": 75, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "security", + "updates" + ], + "hotspotScore": 86 + }, + { + "file": "src/renderer/actions/deployment-profile.js", + "lines": 402, + "branches": 56, + "functions": 43, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "renderer", + "security" + ], + "hotspotScore": 86 + }, + { + "file": "main.cjs", + "lines": 442, + "branches": 33, + "functions": 53, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "ipc", + "renderer", + "security", + "updates" + ], + "hotspotScore": 83 + }, + { + "file": "src/main/unraid-preflight-methods.cjs", + "lines": 594, + "branches": 38, + "functions": 42, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "renderer", + "security", + "updates" + ], + "hotspotScore": 78 + }, + { + "file": "src/main/config-store.cjs", + "lines": 615, + "branches": 47, + "functions": 82, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "security", + "updates" + ], + "hotspotScore": 77 + }, + { + "file": "src/main/unraid-runtime-methods.cjs", + "lines": 388, + "branches": 37, + "functions": 38, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "renderer", + "security", + "updates" + ], + "hotspotScore": 77 + }, + { + "file": "src/renderer/actions/recovery.js", + "lines": 356, + "branches": 56, + "functions": 38, + "ipcHandlers": 0, + "responsibilities": [ + "deployment", + "git", + "renderer", + "updates" + ], + "hotspotScore": 76 + }, + { + "file": "src/renderer/dialogs.js", + "lines": 320, + "branches": 34, + "functions": 65, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "renderer", + "security", + "updates" + ], + "hotspotScore": 74 + }, + { + "file": "src/main/unraid-access-methods.cjs", + "lines": 448, + "branches": 43, + "functions": 42, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "security", + "updates" + ], + "hotspotScore": 73 + }, + { + "file": "src/main/ssh-service.cjs", + "lines": 333, + "branches": 41, + "functions": 69, + "ipcHandlers": 0, + "responsibilities": [ + "deployment", + "git", + "security", + "updates" + ], + "hotspotScore": 61 + }, + { + "file": "src/main/deploy-key-lifecycle-service.cjs", + "lines": 192, + "branches": 31, + "functions": 38, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "security", + "updates" + ], + "hotspotScore": 61 + }, + { + "file": "src/renderer/events.js", + "lines": 170, + "branches": 30, + "functions": 25, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "renderer", + "security" + ], + "hotspotScore": 60 + }, + { + "file": "src/main/unraid-deployment-service.cjs", + "lines": 525, + "branches": 35, + "functions": 48, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "security" + ], + "hotspotScore": 55 + }, + { + "file": "src/main/diagnostics-service.cjs", + "lines": 357, + "branches": 34, + "functions": 50, + "ipcHandlers": 0, + "responsibilities": [ + "deployment", + "git", + "security", + "updates" + ], + "hotspotScore": 54 + }, + { + "file": "src/renderer/actions/inventory.js", + "lines": 186, + "branches": 24, + "functions": 32, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "renderer", + "updates" + ], + "hotspotScore": 54 + }, + { + "file": "src/renderer/actions/deployment-operation.js", + "lines": 184, + "branches": 34, + "functions": 26, + "ipcHandlers": 0, + "responsibilities": [ + "deployment", + "git", + "renderer", + "updates" + ], + "hotspotScore": 54 + }, + { + "file": "src/main/ipc/deployment-handlers.cjs", + "lines": 285, + "branches": 13, + "functions": 38, + "ipcHandlers": 24, + "responsibilities": [ + "inventory", + "deployment", + "git", + "ipc", + "security", + "updates" + ], + "hotspotScore": 53 + }, + { + "file": "preload.cjs", + "lines": 158, + "branches": 2, + "functions": 119, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "ipc", + "renderer", + "security", + "updates" + ], + "hotspotScore": 52 + }, + { + "file": "src/renderer/mock-deployment-bridge.js", + "lines": 661, + "branches": 11, + "functions": 83, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "renderer", + "security", + "updates" + ], + "hotspotScore": 51 + }, + { + "file": "src/main/inventory-classifier.cjs", + "lines": 72, + "branches": 20, + "functions": 11, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "security", + "updates" + ], + "hotspotScore": 50 + }, + { + "file": "src/renderer/operations.js", + "lines": 212, + "branches": 19, + "functions": 26, + "ipcHandlers": 0, + "responsibilities": [ + "deployment", + "git", + "renderer", + "security", + "updates" + ], + "hotspotScore": 49 + }, + { + "file": "src/main/unraid-state-methods.cjs", + "lines": 283, + "branches": 15, + "functions": 20, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "security", + "updates" + ], + "hotspotScore": 45 + }, + { + "file": "src/main/preflight-service.cjs", + "lines": 209, + "branches": 24, + "functions": 34, + "ipcHandlers": 0, + "responsibilities": [ + "deployment", + "git", + "renderer", + "security" + ], + "hotspotScore": 44 + }, + { + "file": "src/renderer/mock-bridge.js", + "lines": 590, + "branches": 17, + "functions": 54, + "ipcHandlers": 0, + "responsibilities": [ + "deployment", + "git", + "security", + "updates" + ], + "hotspotScore": 37 + }, + { + "file": "src/renderer/mock-repository-bridge.js", + "lines": 636, + "branches": 13, + "functions": 89, + "ipcHandlers": 0, + "responsibilities": [ + "deployment", + "git", + "security", + "updates" + ], + "hotspotScore": 33 + }, + { + "file": "src/main/repository-service.cjs", + "lines": 200, + "branches": 12, + "functions": 32, + "ipcHandlers": 0, + "responsibilities": [ + "deployment", + "git", + "security", + "updates" + ], + "hotspotScore": 32 + }, + { + "file": "src/main/deployment-identity.cjs", + "lines": 36, + "branches": 0, + "functions": 11, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "security", + "updates" + ], + "hotspotScore": 30 + }, + { + "file": "src/main/ipc/operations-handlers.cjs", + "lines": 101, + "branches": 8, + "functions": 14, + "ipcHandlers": 9, + "responsibilities": [ + "deployment", + "git", + "ipc", + "renderer" + ], + "hotspotScore": 28 + }, + { + "file": "src/main/inventory-review-service.cjs", + "lines": 32, + "branches": 8, + "functions": 4, + "ipcHandlers": 0, + "responsibilities": [ + "inventory", + "deployment", + "git", + "updates" + ], + "hotspotScore": 28 + }, + { + "file": "src/main/unraid-deploy-key-host.cjs", + "lines": 77, + "branches": 7, + "functions": 22, + "ipcHandlers": 0, + "responsibilities": [ + "deployment", + "git", + "security", + "updates" + ], + "hotspotScore": 27 + } + ], + "ipcHotspots": [ + { + "file": "src/main/ipc.cjs", + "lines": 749, + "branches": 55, + "functions": 79, + "ipcHandlers": 27, + "responsibilities": [ + "inventory", + "deployment", + "git", + "ipc", + "renderer", + "security", + "updates" + ], + "hotspotScore": 105 + }, + { + "file": "src/main/ipc/deployment-handlers.cjs", + "lines": 285, + "branches": 13, + "functions": 38, + "ipcHandlers": 24, + "responsibilities": [ + "inventory", + "deployment", + "git", + "ipc", + "security", + "updates" + ], + "hotspotScore": 53 + }, + { + "file": "src/main/ipc/repository-handlers.cjs", + "lines": 390, + "branches": 16, + "functions": 75, + "ipcHandlers": 47, + "responsibilities": [ + "git", + "ipc" + ], + "hotspotScore": 16 + } + ] +} diff --git a/reports/architecture-audit.md b/reports/architecture-audit.md new file mode 100644 index 0000000..d39937e --- /dev/null +++ b/reports/architecture-audit.md @@ -0,0 +1,27 @@ +# ForgeFlow architecture audit + +Generated 2026-07-29T16:10:50.148Z. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity. + +## Files above 750 lines + +| File | Lines | Decisions | Functions | IPC handlers | Responsibilities | +|---|---:|---:|---:|---:|---| +No findings. + +## Files above 1,000 lines + +| File | Lines | Decisions | Functions | IPC handlers | Responsibilities | +|---|---:|---:|---:|---:|---| +No findings. + +## Cyclomatic hotspots + +| File | Lines | Decisions | Functions | IPC handlers | Responsibilities | +|---|---:|---:|---:|---:|---| +| `src/main/server-inventory.cjs` | 578 | 89 | 104 | 0 | inventory, deployment, git, security, updates | +| `src/renderer/actions/shell.js` | 481 | 89 | 81 | 0 | inventory, deployment, git, renderer | +| `src/main/git-service.cjs` | 632 | 98 | 109 | 0 | git | + +## Interpretation + +Files above 750 lines require decomposition. Files above 1,000 lines are release blockers unless a concrete technical exception is documented. Mixed responsibility and IPC hotspot lists are available in the JSON report. diff --git a/scripts/architecture-audit.mjs b/scripts/architecture-audit.mjs new file mode 100644 index 0000000..fb34978 --- /dev/null +++ b/scripts/architecture-audit.mjs @@ -0,0 +1,53 @@ +import { readdir, readFile, writeFile, mkdir } from "node:fs/promises"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, ".."); +const sourceRoots = ["main.cjs", "preload.cjs", "src/main", "src/renderer", "src/shared"]; +const extensions = new Set([".js", ".cjs", ".mjs"]); + +async function filesBelow(entry) { + const absolute = path.join(root, entry); + const stat = await import("node:fs/promises").then(({ stat }) => stat(absolute)); + if (stat.isFile()) return [entry]; + const result = []; + for (const child of await readdir(absolute, { withFileTypes: true })) { + const relative = path.join(entry, child.name); + if (child.isDirectory()) result.push(...await filesBelow(relative)); + else if (extensions.has(path.extname(child.name))) result.push(relative); + } + return result; +} + +function analyze(relative, source) { + const lines = source.split(/\r?\n/).length; + const branches = (source.match(/\b(?:if|else if|for|while|case|catch)\b|\?\?/g) || []).length; + const functions = (source.match(/\b(?:async\s+)?function\b|=>|\b(?:async\s+)?[A-Za-z_$][\w$]*\s*\([^)]*\)\s*\{/g) || []).length; + const ipcHandlers = (source.match(/\bregister\(\s*["']/g) || []).length; + const responsibilities = [ + ["inventory", /inventory|workload/i], ["deployment", /deploy|rollback|activation/i], + ["git", /\bgit|repository/i], ["ipc", /ipc|register\(/i], ["renderer", /render|modal|document\./i], + ["security", /key|credential|signature|checksum/i], ["updates", /update|release|artifact/i], + ].filter(([, pattern]) => pattern.test(source)).map(([name]) => name); + return { file: relative.replaceAll("\\", "/"), lines, branches, functions, ipcHandlers, responsibilities, hotspotScore: branches + Math.max(0, responsibilities.length - 2) * 10 }; +} + +const files = (await Promise.all(sourceRoots.map(filesBelow))).flat(); +const results = []; +for (const file of files) results.push(analyze(file, await readFile(path.join(root, file), "utf8"))); +results.sort((a, b) => b.hotspotScore - a.hotspotScore || b.lines - a.lines); +const report = { + generatedAt: new Date().toISOString(), + thresholds: { preferredMaximumLines: 750, justificationRequiredLines: 1000 }, + over750: results.filter((item) => item.lines > 750), + over1000: results.filter((item) => item.lines > 1000), + cyclomaticHotspots: results.filter((item) => item.branches >= 75).slice(0, 20), + mixedResponsibilityModules: results.filter((item) => item.responsibilities.length >= 4), + ipcHotspots: results.filter((item) => item.ipcHandlers >= 10), +}; +const reportDir = path.join(root, "reports"); +await mkdir(reportDir, { recursive: true }); +await writeFile(path.join(reportDir, "architecture-audit.json"), `${JSON.stringify(report, null, 2)}\n`); +const table = (items) => items.length ? items.map((item) => `| \`${item.file}\` | ${item.lines} | ${item.branches} | ${item.functions} | ${item.ipcHandlers} | ${item.responsibilities.join(", ")} |`).join("\n") : "No findings."; +const markdown = `# ForgeFlow architecture audit\n\nGenerated ${report.generatedAt}. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity.\n\n## Files above 750 lines\n\n| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |\n|---|---:|---:|---:|---:|---|\n${table(report.over750)}\n\n## Files above 1,000 lines\n\n| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |\n|---|---:|---:|---:|---:|---|\n${table(report.over1000)}\n\n## Cyclomatic hotspots\n\n| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |\n|---|---:|---:|---:|---:|---|\n${table(report.cyclomaticHotspots)}\n\n## Interpretation\n\nFiles above 750 lines require decomposition. Files above 1,000 lines are release blockers unless a concrete technical exception is documented. Mixed responsibility and IPC hotspot lists are available in the JSON report.\n`; +await writeFile(path.join(reportDir, "architecture-audit.md"), markdown); +console.log(`Audited ${results.length} source files; ${report.over750.length} exceed 750 lines and ${report.over1000.length} exceed 1,000 lines.`); diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 0d03a68..74ec7de 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -313,13 +313,17 @@ if ( ) throw new Error("PowerShell update helper must start directly with param(."); -const renderer = await readFile(path.join(root, "src/renderer/app.js"), "utf8"); +const renderer = (await Promise.all(["app.js", "views.js", "dialogs.js", "operations.js", "actions/shell.js", "actions/inventory.js", "actions/deployment-profile.js", "actions/deployment-operation.js", "actions/setup-and-settings.js", "actions/recovery.js", "actions/command.js", "events.js"].map((file) => + readFile(path.join(root, "src/renderer", file), "utf8"), +))).join("\n"); const styles = await readFile( path.join(root, "src/renderer/styles.css"), "utf8", ); const preload = await readFile(path.join(root, "preload.cjs"), "utf8"); -const ipc = await readFile(path.join(root, "src/main/ipc.cjs"), "utf8"); +const ipc = (await Promise.all(["ipc.cjs", "ipc/repository-handlers.cjs", "ipc/deployment-handlers.cjs", "ipc/operations-handlers.cjs"].map((file) => + readFile(path.join(root, "src/main", file), "utf8"), +))).join("\n"); for (const phrase of [ 'data-action="commit-push"', "checkForUpdates", @@ -434,7 +438,10 @@ const configSource = await readFile(path.join(root, "src/main/config-store.cjs") for (const mode of ["server-git", "push-bundle", "monitor-only"]) { if (!configSource.includes(mode)) throw new Error(`Deployment configuration is missing mode: ${mode}`); } -const unraidDirectSource = await readFile(path.join(root, "src/main/unraid-deployment-service.cjs"), "utf8"); +const unraidDirectSource = (await Promise.all([ + "unraid-deployment-service.cjs", "unraid-access-methods.cjs", "unraid-preflight-methods.cjs", + "unraid-runtime-methods.cjs", "unraid-deployment-methods.cjs", "unraid-inventory-methods.cjs", "unraid-state-methods.cjs", +].map((file) => readFile(path.join(root, "src/main", file), "utf8")))).join("\n"); for (const requiredPhrase of [ "executePushBundle", "executeServerGitBundle", @@ -462,10 +469,7 @@ const gitSource = await readFile( path.join(root, "src/main/git-service.cjs"), "utf8", ); -const unraidSource = await readFile( - path.join(root, "src/main/unraid-deployment-service.cjs"), - "utf8", -); +const unraidSource = unraidDirectSource; const publisher = await readFile( path.join(root, "Publish-ForgeFlow-Release.ps1"), "utf8", diff --git a/src/main/ipc.cjs b/src/main/ipc.cjs index 435fad3..34b90f0 100644 --- a/src/main/ipc.cjs +++ b/src/main/ipc.cjs @@ -1,5 +1,4 @@ "use strict"; - const path = require("node:path"); const fs = require("node:fs/promises"); const { fileURLToPath } = require("node:url"); @@ -9,12 +8,14 @@ const { cloneDirectoryName, resolveCloneTarget, } = require("../shared/clone-target.cjs"); +const { registerRepositoryIpc } = require("./ipc/repository-handlers.cjs"); +const { registerDeploymentIpc } = require("./ipc/deployment-handlers.cjs"); +const { registerOperationsIpc } = require("./ipc/operations-handlers.cjs"); const { createEncryptedBackup, readEncryptedBackup, } = require("./configuration-backup.cjs"); const { evaluateDeploymentPolicy } = require("../shared/deployment-policy.cjs"); - let diagnosticsService = null; const TRUSTED_RENDERER_PATH = path.resolve( __dirname, @@ -76,7 +77,6 @@ function register(channel, handler) { } }); } - function registerIpc({ store, git, @@ -502,383 +502,11 @@ function registerIpc({ }), ); - register("repositories:refresh", async () => { - const result = await repositories.refresh(); - monitor?.setPaths(repositories.getWatchPaths()); - return result; - }); - - register("repositories:discover", async ({ roots }) => { - const paths = await repositories.discoverAll( - roots || store.data.workspaceRoots, - ); - return repositories.getLocalDescriptors(paths); - }); - - register("repository:favorite", async ({ fullName, favorite }) => - store.setFavorite(fullName, favorite), - ); - - register("repository:link", async ({ fullName, localPath }) => { - await git.ensureRepository(localPath); - const remoteUrl = await git.getRemoteUrl(localPath).catch(() => ""); - if ( - !remoteUrl || - !matchRemoteToRepository(remoteUrl, [{ full_name: fullName }]) - ) { - throw new Error( - `The selected folder's origin does not match ${fullName}.`, - ); - } - await store.saveMapping(fullName, localPath); - await diagnostics.info("repository.linked", { fullName, localPath }); - const result = await repositories.refresh(); - monitor?.setPaths(repositories.getWatchPaths()); - return result; - }); - - register("repository:unlink", async ({ fullName }) => { - await store.removeMapping(fullName); - await diagnostics.info("repository.unlinked", { fullName }); - const result = await repositories.refresh(); - monitor?.setPaths(repositories.getWatchPaths()); - return result; - }); - - register("repository:status", async ({ localPath }) => - git.status(await assertKnownRepositoryPath(localPath)), - ); - register("repository:diff", async ({ localPath, filePath, staged }) => - git.diff(await assertKnownRepositoryPath(localPath), filePath, staged), - ); - register("repository:diff-hunks", async ({ localPath, filePath }) => - git.diffHunks(await assertKnownRepositoryPath(localPath), filePath), - ); - register( - "repository:stage-hunks", - async ({ localPath, filePath, hunkIndexes }) => { - const safePath = await assertKnownRepositoryPath(localPath); - return withRepositoryMutation(safePath, () => - git.stageHunks(safePath, filePath, hunkIndexes), - ); - }, - ); - register("repository:conflicts", async ({ localPath }) => - git.conflictState(await assertKnownRepositoryPath(localPath)), - ); - register( - "repository:resolve-conflict", - async ({ localPath, filePath, resolution }) => { - const safePath = await assertKnownRepositoryPath(localPath); - const result = await withRepositoryMutation(safePath, () => - git.resolveConflict(safePath, filePath, resolution), - ); - await audit.append("git.conflict.resolved", { - localPath: safePath, - filePath, - resolution, - }); - return result; - }, - ); - register("repository:continue-operation", async ({ localPath }) => { - const safePath = await assertKnownRepositoryPath(localPath); - const result = await withRepositoryMutation(safePath, () => - git.continueInterruptedOperation(safePath), - ); - await audit.append("git.operation.continued", { localPath: safePath }); - return result; - }); - register("repository:abort-operation", async ({ localPath }) => { - const safePath = await assertKnownRepositoryPath(localPath); - const result = await withRepositoryMutation(safePath, () => - git.abortInterruptedOperation(safePath), - ); - await audit.append("git.operation.aborted", { - localPath: safePath, - operation: result.aborted, - }); - return result; - }); - register("repository:stage", async ({ localPath, files }) => { - const safePath = await assertKnownRepositoryPath(localPath); - return withRepositoryMutation(safePath, () => git.stage(safePath, files)); - }); - register("repository:unstage", async ({ localPath, files }) => { - const safePath = await assertKnownRepositoryPath(localPath); - return withRepositoryMutation(safePath, () => git.unstage(safePath, files)); - }); - register("repository:commit", async ({ localPath, message, files }) => { - const safePath = await assertKnownRepositoryPath(localPath); - return withRepositoryMutation(safePath, () => - git.commit(safePath, message, files), - ); - }); - register("repository:commit-staged", async ({ localPath, message }) => { - const safePath = await assertKnownRepositoryPath(localPath); - return withRepositoryMutation(safePath, () => - git.commitStaged(safePath, message), - ); - }); - register("repository:commit-staged-push", async ({ localPath, message }) => { - const safePath = await assertKnownRepositoryPath(localPath); - return withRepositoryMutation(safePath, () => - git.commitStagedAndPush(safePath, message), - ); - }); - register("repository:commit-push", async ({ localPath, message, files }) => { - const safePath = await assertKnownRepositoryPath(localPath); - return withRepositoryMutation(safePath, () => - git.commitAndPush(safePath, message, files), - ); - }); - register("repository:push", async ({ localPath }) => { - const safePath = await assertKnownRepositoryPath(localPath); - return withRepositoryMutation(safePath, () => git.push(safePath)); - }); - register("repository:fetch", async ({ localPath }) => { - const safePath = await assertKnownRepositoryPath(localPath); - return withRepositoryMutation(safePath, () => git.fetch(safePath)); - }); - register("repository:pull", async ({ localPath }) => { - const safePath = await assertKnownRepositoryPath(localPath); - return withRepositoryMutation(safePath, () => - git.pullFastForward(safePath), - ); - }); - register("repository:history", async ({ localPath, limit }) => - git.history(await assertKnownRepositoryPath(localPath), limit), - ); - register("repository:branch-protection", async ({ fullName, branch }) => { - const repository = await resolveRepository({ fullName }); - return gitea.getBranchProtection( - repository.owner.login, - repository.name, - branch || - repository.localStatus?.branch?.head || - repository.defaultBranch, - ); - }); - register("repository:pull-requests", async ({ fullName, state = "open" }) => { - const repository = await resolveRepository({ fullName }); - return gitea.listPullRequests({ - owner: repository.owner.login, - repo: repository.name, - state, - }); - }); - register( - "repository:create-pull-request", - async ({ fullName, title, body, base }) => { - const repository = await resolveRepository({ fullName }); - if (!repository.localPath || !repository.localStatus?.clean) - throw new Error( - "A clean linked repository is required before creating a pull request.", - ); - const head = repository.localStatus.branch?.head; - if (!head || !repository.localStatus.branch?.upstream) - throw new Error( - "Publish the current branch before creating a pull request.", - ); - if (repository.localStatus.branch.ahead > 0) - throw new Error( - "Push all local commits before creating a pull request.", - ); - const pullRequest = await gitea.createPullRequest({ - owner: repository.owner.login, - repo: repository.name, - head, - base: base || repository.defaultBranch, - title, - body, - }); - await audit.append("pull-request.created", { - repository: repository.fullName, - number: pullRequest.number, - head, - base: base || repository.defaultBranch, - url: pullRequest.html_url, - }); - return pullRequest; - }, - ); - register("repository:branches", async ({ localPath }) => - git.branches(await assertKnownRepositoryPath(localPath)), - ); - register("repository:checkout-branch", async ({ localPath, branch }) => { - const safePath = await assertKnownRepositoryPath(localPath); - return withRepositoryMutation(safePath, () => - git.checkoutBranch(safePath, branch), - ); - }); - register("repository:create-branch", async ({ localPath, branch }) => { - const safePath = await assertKnownRepositoryPath(localPath); - return withRepositoryMutation(safePath, () => - git.createBranch(safePath, branch), - ); - }); - register("repository:stash", async ({ localPath, message }) => { - const safePath = await assertKnownRepositoryPath(localPath); - return withRepositoryMutation(safePath, () => git.stash(safePath, message)); - }); - register("repository:stash-list", async ({ localPath }) => - git.stashList(await assertKnownRepositoryPath(localPath)), - ); - register("repository:stash-pop", async ({ localPath, ref }) => { - const safePath = await assertKnownRepositoryPath(localPath); - return withRepositoryMutation(safePath, () => git.popStash(safePath, ref)); - }); - register("repository:index-lock", async ({ localPath }) => - git.getIndexLockInfo(await assertKnownRepositoryPath(localPath)), - ); - register("repository:git-recovery-status", async ({ localPath }) => - git.reconcile(await assertKnownRepositoryPath(localPath)), - ); - register("repository:repair-index-lock", async ({ localPath }) => { - const safePath = await assertKnownRepositoryPath(localPath); - return withRepositoryMutation(safePath, () => - git.removeStaleIndexLock(safePath), - ); - }); - register( - "repository:repair-git-locks", - async ({ localPath, force = false }) => { - const safePath = await assertKnownRepositoryPath(localPath); - return withRepositoryMutation(safePath, () => - git.repairStaleGitLocks(safePath, { - minimumAgeMs: force ? 0 : 10_000, - allowWithoutProcessProbe: force === true, - }), - ); - }, - ); - register("repository:reconcile", async ({ localPath }) => - git.reconcile(await assertKnownRepositoryPath(localPath)), - ); - register("repository:repair-sync", async ({ localPath, strategy }) => { - const safePath = await assertKnownRepositoryPath(localPath); - return withRepositoryMutation(safePath, () => - git.repairSync(safePath, strategy), - ); - }); - register("repository:set-origin", async ({ localPath, remoteUrl }) => { - const safePath = await assertKnownRepositoryPath(localPath); - return withRepositoryMutation(safePath, () => - git.setRemoteUrl(safePath, remoteUrl), - ); - }); - - register("repositories:normalize-origins", async () => { - const current = await repositories.refresh(); - const changes = []; - for (const repository of current) { - if (!repository.localPath || !repository.sshUrl) continue; - const actual = await git - .getRemoteUrl(repository.localPath) - .catch(() => ""); - if (actual === repository.sshUrl) continue; - await withRepositoryMutation(repository.localPath, () => - git.setRemoteUrl(repository.localPath, repository.sshUrl), - ); - changes.push({ - fullName: repository.fullName, - previous: actual, - next: repository.sshUrl, - }); - } - const refreshed = await repositories.refresh(); - monitor?.setPaths(repositories.getWatchPaths()); - await diagnostics.info("repositories.origins.normalized", { - count: changes.length, - changes, - }); - return { changes, repositories: refreshed }; - }); - - register("repository:clone", async ({ fullName, mode = "default" }) => { - if (!["default", "custom"].includes(mode)) - throw new Error("Unsupported clone location mode."); - - let projectRoot = store.data.workspaceRoots[0] || null; - if (mode === "custom" || !projectRoot) { - const result = await dialog.showOpenDialog({ - title: `Choose a project root for ${String(fullName || "repository")}`, - defaultPath: projectRoot || undefined, - buttonLabel: "Use this project root", - properties: ["openDirectory", "createDirectory"], - }); - if (result.canceled || !result.filePaths[0]) return { cancelled: true }; - projectRoot = result.filePaths[0]; - } - - return cloneRepositoryInto(fullName, projectRoot); - }); - - register("repository:open-path", async ({ localPath }) => { - const safePath = await assertKnownRepositoryPath(localPath); - const error = await shell.openPath(safePath); - if (error) throw new Error(error); - return true; - }); - register( - "repository:open-editor", - async ({ localPath, filePath = "", line = 1 }) => - externalTools.launch( - "editor", - await assertKnownRepositoryPath(localPath), - filePath, - line, - ), - ); - register("repository:open-terminal", async ({ localPath }) => - externalTools.launch( - "terminal", - await assertKnownRepositoryPath(localPath), - ), - ); - - register("external:open", async ({ url }) => { - const parsed = new URL(url); - if (!["http:", "https:"].includes(parsed.protocol)) - throw new Error("Only HTTP and HTTPS links can be opened."); - await shell.openExternal(parsed.toString()); - return true; - }); - - register("git-validator:scan", async ({ fullName }) => { - const repository = await resolveRepository({ fullName }); - const report = await gitValidator.scan(repository); - await diagnostics.info("git-validator.scan.completed", { - repository: repository.fullName, - score: report.score, - summary: report.summary, - }); - return report; - }); - register("git-validator:repair", async ({ fullName, check }) => { - const repository = await resolveRepository({ fullName }); - const allowed = new Set([ - "align-origin", - "configure-local-safety", - "add-gitignore", - "add-gitattributes", - "add-editorconfig", - "protect-default-branch", - ]); - if (!allowed.has(check?.fixAction)) - throw new Error("Unsupported Git Validator repair request."); - const result = await gitValidator.repair(repository, check); - await audit.append("git-validator.repair", { - repository: repository.fullName, - checkId: check.id, - action: check.fixAction, - }); - await diagnostics.info("git-validator.repair.completed", { - repository: repository.fullName, - checkId: check.id, - action: check.fixAction, - }); - return result; + registerRepositoryIpc({ + register, repositories, store, git, gitea, monitor, diagnostics, audit, + externalTools, gitValidator, withRepositoryMutation, assertKnownRepositoryPath, + resolveRepository, cloneRepositoryInto, cloneDirectoryName, + matchRemoteToRepository, shell, dialog, }); register("troubleshooter:scan", async ({ fullName = null }) => { @@ -1102,371 +730,13 @@ function registerIpc({ return results; }); - register("deployment:save-profile", async ({ fullName, profile }) => { - const saved = await store.saveDeploymentProfile(fullName, profile); - await diagnostics.info("deployment.profile.saved", { - repository: fullName, - profile: saved, - }); - return { profile: saved, state: store.getPublicState() }; + registerDeploymentIpc({ + register, store, resolveRepository, unraid, deployments, evaluateDeploymentPolicy, + audit, deployKeys, repositories, inventoryReviews, diagnostics, git, gitea, ssh, }); - register("deployment:delete-profile", async ({ fullName, profileId }) => { - const profiles = await store.deleteDeploymentProfile(fullName, profileId); - await diagnostics.info("deployment.profile.deleted", { - repository: fullName, - profileId, - }); - return { profiles, state: store.getPublicState() }; - }); - register("deployment:preflight", async ({ repository, profileId }) => { - const current = await resolveRepository(repository); - const profile = store.getDeploymentProfile(current.fullName, profileId); - if (profile?.provider === "ssh-unraid") - return unraid.preflight({ repository: current, profileId }); - return preflight.runDeployment({ repository: current, profileId }); - }); - register("deployment:repair-write-access", async ({ repository, profileId }) => { - const current = await resolveRepository(repository); - const profile = store.getDeploymentProfile(current.fullName, profileId); - if (profile?.provider !== "ssh-unraid") - throw new Error("Write-access repair is available only for SSH / Unraid deployment profiles."); - const result = await unraid.repairWriteAccess({ repository: current, profileId }); - await audit.append("deployment.write-access.repaired", { - repository: current.fullName, - profileId, - changed: result.changed, - remotePath: result.after?.remotePath || result.before?.remotePath || null, - }); - return result; - }); - register( - "deployment:dispatch", - async ({ - repository, - profileId, - sha, - note = "", - override = false, - overrideReason = "", - }) => { - const current = await resolveRepository(repository); - const profile = store.getDeploymentProfile(current.fullName, profileId); - const policy = evaluateDeploymentPolicy(profile, { - note, - override, - reason: overrideReason, - }); - await audit.append("deployment.requested", { - repository: current.fullName, - profileId, - sha, - note: policy.note, - overridden: policy.overridden, - overrideReason: policy.reason, - }); - const operation = - profile?.provider === "ssh-unraid" - ? await unraid.deploy({ repository: current, profileId, sha }) - : await deployments.deploy({ repository: current, profileId, sha }); - if (operation?.id) - await store.addOperation({ - ...operation, - releaseNote: policy.note, - policyOverride: policy.overridden - ? { reason: policy.reason, violations: policy.violations } - : null, - }); - return operation; - }, - ); - register( - "deployment:rollback", - async ({ repository, profileId, targetSha }) => { - const current = await resolveRepository(repository); - const profile = store.getDeploymentProfile(current.fullName, profileId); - if (profile?.provider === "ssh-unraid") - return unraid.rollback({ repository: current, profileId, targetSha }); - return deployments.rollback({ - repository: current, - profileId, - targetSha, - }); - }, - ); - register("deployment:health", ({ url }) => deployments.checkHealth(url)); - register("deployment:link-server-workload", async ({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) => { - const current = await resolveRepository(repository); - const result = await unraid.linkServerWorkload({ - repository: current, - serverId, - workloadId, - deploymentMode, - remoteFolder, - }); - return { ...result, state: store.getPublicState() }; - }); - register("deployment:configure-server-git-access", async ({ repository, profileId }) => { - const current = await resolveRepository(repository); - const result = await unraid.configureServerGitAccess({ repository: current, profileId }); - await audit.append("deployment.server-git-access-configured", { - repository: current.fullName, - profileId, - keyFingerprint: result.keyFingerprint, - hostFingerprint: result.hostFingerprint, - }); - return { ...result, state: store.getPublicState() }; - }); - register("deployment:verify-server-git-profile", async ({ repository, profileId }) => { - const current = await resolveRepository(repository); - const result = await unraid.verifyServerGitProfile({ repository: current, profileId }); - await audit.append("deployment.server-git-access-verified", { - repository: current.fullName, - profileId, - readiness: result.readiness, - ready: result.ready, - checkedAt: result.checkedAt, - }); - return result; - }); - register("deployment:deploy-key-inventory", async ({ repository, profileId }) => { - const current = await resolveRepository(repository); - return deployKeys.inventory({ repository: current, profileId }); - }); - register("deployment:plan-deploy-key-rotation", async ({ repository, profileId }) => { - const current = await resolveRepository(repository); - return deployKeys.planRotation({ repository: current, profileId }); - }); - register("deployment:apply-deploy-key-rotation", async ({ repository, profileId, planId }) => { - const current = await resolveRepository(repository); - const result = await deployKeys.rotate({ repository: current, profileId, expectedPlanId: planId }); - return { ...result, state: store.getPublicState() }; - }); - register("deployment:plan-deploy-key-revocation", async ({ repository, profileId }) => { - const current = await resolveRepository(repository); - return deployKeys.planRevocation({ repository: current, profileId }); - }); - register("deployment:apply-deploy-key-revocation", async ({ repository, profileId, planId }) => { - const current = await resolveRepository(repository); - const result = await deployKeys.revoke({ repository: current, profileId, expectedPlanId: planId }); - return { ...result, state: store.getPublicState() }; - }); - register("deployment:restore-deploy-key", async ({ repository, profileId }) => { - const current = await resolveRepository(repository); - const result = await deployKeys.restore({ repository: current, profileId }); - return { ...result, state: store.getPublicState() }; - }); - register("deployment:discover-server-workloads", async () => { - const repositoryList = await repositories.refresh(); - const remoteRepositories = repositoryList.filter( - (repository) => repository.owner?.login !== "local", - ); - const results = []; - for (const server of store.data.servers || []) { - try { - results.push( - await unraid.discoverServerWorkloads(server.id, remoteRepositories), - ); - } catch (error) { - results.push({ - serverId: server.id, - serverName: server.name, - detected: 0, - adopted: 0, - verified: 0, - linked: 0, - unmatched: 0, - needsReview: 0, - capabilities: {}, - warnings: [], - workloads: [], - error: error.message, - }); - } - } - return results; - }); - register("deployment:plan-server-reconciliation", async ({ serverId }) => { - const repositoryList = await repositories.refresh(); - const remoteRepositories = repositoryList.filter((repository) => repository.owner?.login !== "local"); - const result = await unraid.planServerInventoryReconciliation(serverId, remoteRepositories, { autoLink: true }); - await audit.append("deployment.server-reconciliation-planned", { - serverId, - planId: result.plan.id, - summary: result.plan.summary, - }); - return result; - }); - register("deployment:apply-server-reconciliation", async ({ serverId, planId }) => { - const repositoryList = await repositories.refresh(); - const remoteRepositories = repositoryList.filter((repository) => repository.owner?.login !== "local"); - const result = await unraid.reconcileServerInventory(serverId, remoteRepositories, { autoLink: true, expectedPlanId: planId }); - await audit.append("deployment.server-reconciliation-applied", { - serverId, - planId, - adopted: result.adopted, - refreshed: result.refreshed, - retired: result.retired, - recoverySnapshot: result.recoverySnapshot?.filePath || null, - }); - return { ...result, state: store.getPublicState() }; - }); - register("deployment:plan-inventory-review", async ({ serverId, workloadId, action, reason = "", repositoryFullName = null }) => { - const repositoryList = await repositories.refresh(); - const inventory = await unraid.scanServerInventory(serverId, repositoryList.filter((item) => item.owner?.login !== "local")); - const workload = inventory.workloads.find((item) => item.workloadId === workloadId); - if (!workload) throw Object.assign(new Error("The workload changed or disappeared. Rescan before reviewing it."), { code: "INVENTORY_REVIEW_WORKLOAD_STALE" }); - return inventoryReviews.preview({ serverId, workload, action, reason, repositoryFullName }); - }); - register("deployment:apply-inventory-review", async ({ serverId, workloadId, action, reason = "", repositoryFullName = null, planId }) => { - const repositoryList = await repositories.refresh(); - const inventory = await unraid.scanServerInventory(serverId, repositoryList.filter((item) => item.owner?.login !== "local")); - const workload = inventory.workloads.find((item) => item.workloadId === workloadId); - if (!workload) throw Object.assign(new Error("The workload changed or disappeared. Rescan before applying the review."), { code: "INVENTORY_REVIEW_WORKLOAD_STALE" }); - const plan = inventoryReviews.preview({ serverId, workload, action, reason, repositoryFullName }); - const result = await inventoryReviews.apply({ plan, expectedPlanId: planId }); - return { ...result, inventory: await unraid.scanServerInventory(serverId, repositoryList.filter((item) => item.owner?.login !== "local")), state: store.getPublicState() }; - }); - register("deployment:profile-state", async ({ fullName, profileId }) => { - const profile = store.getDeploymentProfile(fullName, profileId); - if (profile?.provider === "ssh-unraid") { - let giteaSha = null; - try { - const [owner, repo] = String(fullName || "").split("/"); - const branch = await gitea.getBranch(owner, repo, profile.branch); - giteaSha = branch?.commit?.id || branch?.commit?.sha || null; - } catch {} - return unraid.refreshProfileState(fullName, profileId, giteaSha); - } - return deployments.refreshProfileState(fullName, profileId); - }); - register( - "deployment:apply-dockerman-metadata", - async ({ repository, profileId }) => { - const current = await resolveRepository(repository); - return unraid.applyDockerManMetadata({ repository: current, profileId }); - }, - ); - register("deployment:reconcile", async ({ fullName, profileId }) => { - const profile = store.getDeploymentProfile(fullName, profileId); - if (profile?.provider !== "ssh-unraid") - return deployments.refreshProfileState(fullName, profileId); - const [owner, repo] = String(fullName || "").split("/"); - const branch = await gitea.getBranch(owner, repo, profile.branch); - const giteaSha = - branch?.commit?.id || - branch?.commit?.sha || - branch?.commit?.commit?.id || - null; - const state = await unraid.refreshProfileState( - fullName, - profileId, - giteaSha, - ); - const operations = store.data.operations.filter( - (item) => - item.profileId === profileId && - item.provider === "ssh-unraid" && - !["success", "failed", "cancelled", "rolled-back"].includes( - item.status, - ), - ); - for (const operation of operations) - await unraid.refreshOperation(operation.id); - return { - state, - operations: await unraid.reconcileRecordedOperations(profileId, state), - }; - }); - register("operations:refresh", async ({ operationId }) => { - if (operationId) { - const operation = store.getOperation(operationId); - if (operation?.provider === "ssh-unraid") - return unraid.refreshOperation(operationId); - return deployments.refreshOperation(operationId); - } - const [actions, sshOperations] = await Promise.all([ - deployments.refreshActiveOperations(), - unraid.refreshActiveOperations(), - ]); - return [...actions, ...sshOperations]; - }); - register("operations:get", ({ operationId }) => - store.getOperation(operationId), - ); - - register("diagnostics:status", () => diagnostics.getStatus()); - register("diagnostics:clear", () => diagnostics.clear()); - register("diagnostics:open-folder", async () => { - const error = await shell.openPath(diagnostics.logDirectory); - if (error) throw new Error(error); - return true; - }); - register("diagnostics:export", async ({ privacyMode = "standard" }) => { - if (!["standard", "strict"].includes(privacyMode)) - throw new Error("Unsupported diagnostic privacy mode."); - const result = await dialog.showSaveDialog({ - title: "Export ForgeFlow diagnostic bundle", - defaultPath: path.join( - app.getPath("downloads"), - `ForgeFlow-Diagnostics-${new Date().toISOString().replace(/[:.]/g, "-")}.zip`, - ), - filters: [{ name: "ZIP archive", extensions: ["zip"] }], - }); - if (result.canceled || !result.filePath) return null; - const repositoryState = await repositories.refresh().catch((error) => { - diagnostics.warning("diagnostics.repository-snapshot.failed", error); - return []; - }); - const systemPreflight = await preflight - .runSystem() - .catch((error) => ({ error: error.message })); - const destinationPath = - path.extname(result.filePath).toLowerCase() === ".zip" - ? result.filePath - : `${result.filePath}.zip`; - return diagnostics.exportSupportBundle({ - destinationPath, - publicState: store.getPublicState(), - repositories: repositoryState, - operations: store.data.operations, - preflight: systemPreflight, - privacyMode, - extra: { - appVersion: app.getVersion(), - setupComplete: store.data.setupComplete, - }, - }); - }); - register("diagnostics:show-bundle", async ({ filePath }) => { - if (!diagnostics.isKnownBundlePath(filePath)) - throw new Error( - "Only the most recently generated support bundle can be revealed.", - ); - shell.showItemInFolder(filePath); - return true; - }); - register( - "renderer:report", - async ({ level = "info", event = "renderer.event", details = {} }) => { - const method = ["debug", "info", "warning", "error"].includes(level) - ? level - : "info"; - await diagnostics[method]( - `renderer.${String(event || "event").slice(0, 120)}`, - details, - ); - return true; - }, - ); - - register("app:reset", async () => { - await diagnostics.info("app.reset.requested", {}); - store.data = store.migrate({}); - store.sessionToken = null; - await store.save(); - monitor?.setPaths([]); - monitor?.restart(); - return store.getPublicState(); + registerOperationsIpc({ + register, store, unraid, deployments, diagnostics, shell, dialog, path, app, + repositories, preflight, monitor, }); } diff --git a/src/main/ipc/deployment-handlers.cjs b/src/main/ipc/deployment-handlers.cjs new file mode 100644 index 0000000..5969774 --- /dev/null +++ b/src/main/ipc/deployment-handlers.cjs @@ -0,0 +1,284 @@ +"use strict"; + +function registerDeploymentIpc({ + register, store, resolveRepository, unraid, deployments, evaluateDeploymentPolicy, + audit, deployKeys, repositories, inventoryReviews, diagnostics, git, gitea, ssh, +}) { + register("deployment:save-profile", async ({ fullName, profile }) => { + const saved = await store.saveDeploymentProfile(fullName, profile); + await diagnostics.info("deployment.profile.saved", { + repository: fullName, + profile: saved, + }); + return { profile: saved, state: store.getPublicState() }; + }); + register("deployment:delete-profile", async ({ fullName, profileId }) => { + const profiles = await store.deleteDeploymentProfile(fullName, profileId); + await diagnostics.info("deployment.profile.deleted", { + repository: fullName, + profileId, + }); + return { profiles, state: store.getPublicState() }; + }); + register("deployment:preflight", async ({ repository, profileId }) => { + const current = await resolveRepository(repository); + const profile = store.getDeploymentProfile(current.fullName, profileId); + if (profile?.provider === "ssh-unraid") + return unraid.preflight({ repository: current, profileId }); + return preflight.runDeployment({ repository: current, profileId }); + }); + register("deployment:repair-write-access", async ({ repository, profileId }) => { + const current = await resolveRepository(repository); + const profile = store.getDeploymentProfile(current.fullName, profileId); + if (profile?.provider !== "ssh-unraid") + throw new Error("Write-access repair is available only for SSH / Unraid deployment profiles."); + const result = await unraid.repairWriteAccess({ repository: current, profileId }); + await audit.append("deployment.write-access.repaired", { + repository: current.fullName, + profileId, + changed: result.changed, + remotePath: result.after?.remotePath || result.before?.remotePath || null, + }); + return result; + }); + register( + "deployment:dispatch", + async ({ + repository, + profileId, + sha, + note = "", + override = false, + overrideReason = "", + }) => { + const current = await resolveRepository(repository); + const profile = store.getDeploymentProfile(current.fullName, profileId); + const policy = evaluateDeploymentPolicy(profile, { + note, + override, + reason: overrideReason, + }); + await audit.append("deployment.requested", { + repository: current.fullName, + profileId, + sha, + note: policy.note, + overridden: policy.overridden, + overrideReason: policy.reason, + }); + const operation = + profile?.provider === "ssh-unraid" + ? await unraid.deploy({ repository: current, profileId, sha }) + : await deployments.deploy({ repository: current, profileId, sha }); + if (operation?.id) + await store.addOperation({ + ...operation, + releaseNote: policy.note, + policyOverride: policy.overridden + ? { reason: policy.reason, violations: policy.violations } + : null, + }); + return operation; + }, + ); + register( + "deployment:rollback", + async ({ repository, profileId, targetSha }) => { + const current = await resolveRepository(repository); + const profile = store.getDeploymentProfile(current.fullName, profileId); + if (profile?.provider === "ssh-unraid") + return unraid.rollback({ repository: current, profileId, targetSha }); + return deployments.rollback({ + repository: current, + profileId, + targetSha, + }); + }, + ); + register("deployment:health", ({ url }) => deployments.checkHealth(url)); + register("deployment:link-server-workload", async ({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) => { + const current = await resolveRepository(repository); + const result = await unraid.linkServerWorkload({ + repository: current, + serverId, + workloadId, + deploymentMode, + remoteFolder, + }); + return { ...result, state: store.getPublicState() }; + }); + register("deployment:configure-server-git-access", async ({ repository, profileId }) => { + const current = await resolveRepository(repository); + const result = await unraid.configureServerGitAccess({ repository: current, profileId }); + await audit.append("deployment.server-git-access-configured", { + repository: current.fullName, + profileId, + keyFingerprint: result.keyFingerprint, + hostFingerprint: result.hostFingerprint, + }); + return { ...result, state: store.getPublicState() }; + }); + register("deployment:verify-server-git-profile", async ({ repository, profileId }) => { + const current = await resolveRepository(repository); + const result = await unraid.verifyServerGitProfile({ repository: current, profileId }); + await audit.append("deployment.server-git-access-verified", { + repository: current.fullName, + profileId, + readiness: result.readiness, + ready: result.ready, + checkedAt: result.checkedAt, + }); + return result; + }); + register("deployment:deploy-key-inventory", async ({ repository, profileId }) => { + const current = await resolveRepository(repository); + return deployKeys.inventory({ repository: current, profileId }); + }); + register("deployment:plan-deploy-key-rotation", async ({ repository, profileId }) => { + const current = await resolveRepository(repository); + return deployKeys.planRotation({ repository: current, profileId }); + }); + register("deployment:apply-deploy-key-rotation", async ({ repository, profileId, planId }) => { + const current = await resolveRepository(repository); + const result = await deployKeys.rotate({ repository: current, profileId, expectedPlanId: planId }); + return { ...result, state: store.getPublicState() }; + }); + register("deployment:plan-deploy-key-revocation", async ({ repository, profileId }) => { + const current = await resolveRepository(repository); + return deployKeys.planRevocation({ repository: current, profileId }); + }); + register("deployment:apply-deploy-key-revocation", async ({ repository, profileId, planId }) => { + const current = await resolveRepository(repository); + const result = await deployKeys.revoke({ repository: current, profileId, expectedPlanId: planId }); + return { ...result, state: store.getPublicState() }; + }); + register("deployment:restore-deploy-key", async ({ repository, profileId }) => { + const current = await resolveRepository(repository); + const result = await deployKeys.restore({ repository: current, profileId }); + return { ...result, state: store.getPublicState() }; + }); + register("deployment:discover-server-workloads", async () => { + const repositoryList = await repositories.refresh(); + const remoteRepositories = repositoryList.filter( + (repository) => repository.owner?.login !== "local", + ); + const results = []; + for (const server of store.data.servers || []) { + try { + results.push( + await unraid.discoverServerWorkloads(server.id, remoteRepositories), + ); + } catch (error) { + results.push({ + serverId: server.id, + serverName: server.name, + detected: 0, + adopted: 0, + verified: 0, + linked: 0, + unmatched: 0, + needsReview: 0, + capabilities: {}, + warnings: [], + workloads: [], + error: error.message, + }); + } + } + return results; + }); + register("deployment:plan-server-reconciliation", async ({ serverId }) => { + const repositoryList = await repositories.refresh(); + const remoteRepositories = repositoryList.filter((repository) => repository.owner?.login !== "local"); + const result = await unraid.planServerInventoryReconciliation(serverId, remoteRepositories, { autoLink: true }); + await audit.append("deployment.server-reconciliation-planned", { + serverId, + planId: result.plan.id, + summary: result.plan.summary, + }); + return result; + }); + register("deployment:apply-server-reconciliation", async ({ serverId, planId }) => { + const repositoryList = await repositories.refresh(); + const remoteRepositories = repositoryList.filter((repository) => repository.owner?.login !== "local"); + const result = await unraid.reconcileServerInventory(serverId, remoteRepositories, { autoLink: true, expectedPlanId: planId }); + await audit.append("deployment.server-reconciliation-applied", { + serverId, + planId, + adopted: result.adopted, + refreshed: result.refreshed, + retired: result.retired, + recoverySnapshot: result.recoverySnapshot?.filePath || null, + }); + return { ...result, state: store.getPublicState() }; + }); + register("deployment:plan-inventory-review", async ({ serverId, workloadId, action, reason = "", repositoryFullName = null }) => { + const repositoryList = await repositories.refresh(); + const inventory = await unraid.scanServerInventory(serverId, repositoryList.filter((item) => item.owner?.login !== "local")); + const workload = inventory.workloads.find((item) => item.workloadId === workloadId); + if (!workload) throw Object.assign(new Error("The workload changed or disappeared. Rescan before reviewing it."), { code: "INVENTORY_REVIEW_WORKLOAD_STALE" }); + return inventoryReviews.preview({ serverId, workload, action, reason, repositoryFullName }); + }); + register("deployment:apply-inventory-review", async ({ serverId, workloadId, action, reason = "", repositoryFullName = null, planId }) => { + const repositoryList = await repositories.refresh(); + const inventory = await unraid.scanServerInventory(serverId, repositoryList.filter((item) => item.owner?.login !== "local")); + const workload = inventory.workloads.find((item) => item.workloadId === workloadId); + if (!workload) throw Object.assign(new Error("The workload changed or disappeared. Rescan before applying the review."), { code: "INVENTORY_REVIEW_WORKLOAD_STALE" }); + const plan = inventoryReviews.preview({ serverId, workload, action, reason, repositoryFullName }); + const result = await inventoryReviews.apply({ plan, expectedPlanId: planId }); + return { ...result, inventory: await unraid.scanServerInventory(serverId, repositoryList.filter((item) => item.owner?.login !== "local")), state: store.getPublicState() }; + }); + register("deployment:profile-state", async ({ fullName, profileId }) => { + const profile = store.getDeploymentProfile(fullName, profileId); + if (profile?.provider === "ssh-unraid") { + let giteaSha = null; + try { + const [owner, repo] = String(fullName || "").split("/"); + const branch = await gitea.getBranch(owner, repo, profile.branch); + giteaSha = branch?.commit?.id || branch?.commit?.sha || null; + } catch {} + return unraid.refreshProfileState(fullName, profileId, giteaSha); + } + return deployments.refreshProfileState(fullName, profileId); + }); + register( + "deployment:apply-dockerman-metadata", + async ({ repository, profileId }) => { + const current = await resolveRepository(repository); + return unraid.applyDockerManMetadata({ repository: current, profileId }); + }, + ); + register("deployment:reconcile", async ({ fullName, profileId }) => { + const profile = store.getDeploymentProfile(fullName, profileId); + if (profile?.provider !== "ssh-unraid") + return deployments.refreshProfileState(fullName, profileId); + const [owner, repo] = String(fullName || "").split("/"); + const branch = await gitea.getBranch(owner, repo, profile.branch); + const giteaSha = + branch?.commit?.id || + branch?.commit?.sha || + branch?.commit?.commit?.id || + null; + const state = await unraid.refreshProfileState( + fullName, + profileId, + giteaSha, + ); + const operations = store.data.operations.filter( + (item) => + item.profileId === profileId && + item.provider === "ssh-unraid" && + !["success", "failed", "cancelled", "rolled-back"].includes( + item.status, + ), + ); + for (const operation of operations) + await unraid.refreshOperation(operation.id); + return { + state, + operations: await unraid.reconcileRecordedOperations(profileId, state), + }; + }); +} + +module.exports = { registerDeploymentIpc }; diff --git a/src/main/ipc/operations-handlers.cjs b/src/main/ipc/operations-handlers.cjs new file mode 100644 index 0000000..821a842 --- /dev/null +++ b/src/main/ipc/operations-handlers.cjs @@ -0,0 +1,100 @@ +"use strict"; + +function registerOperationsIpc({ + register, store, unraid, deployments, diagnostics, shell, dialog, path, app, + repositories, preflight, monitor, +}) { + register("operations:refresh", async ({ operationId }) => { + if (operationId) { + const operation = store.getOperation(operationId); + if (operation?.provider === "ssh-unraid") + return unraid.refreshOperation(operationId); + return deployments.refreshOperation(operationId); + } + const [actions, sshOperations] = await Promise.all([ + deployments.refreshActiveOperations(), + unraid.refreshActiveOperations(), + ]); + return [...actions, ...sshOperations]; + }); + register("operations:get", ({ operationId }) => + store.getOperation(operationId), + ); + + register("diagnostics:status", () => diagnostics.getStatus()); + register("diagnostics:clear", () => diagnostics.clear()); + register("diagnostics:open-folder", async () => { + const error = await shell.openPath(diagnostics.logDirectory); + if (error) throw new Error(error); + return true; + }); + register("diagnostics:export", async ({ privacyMode = "standard" }) => { + if (!["standard", "strict"].includes(privacyMode)) + throw new Error("Unsupported diagnostic privacy mode."); + const result = await dialog.showSaveDialog({ + title: "Export ForgeFlow diagnostic bundle", + defaultPath: path.join( + app.getPath("downloads"), + `ForgeFlow-Diagnostics-${new Date().toISOString().replace(/[:.]/g, "-")}.zip`, + ), + filters: [{ name: "ZIP archive", extensions: ["zip"] }], + }); + if (result.canceled || !result.filePath) return null; + const repositoryState = await repositories.refresh().catch((error) => { + diagnostics.warning("diagnostics.repository-snapshot.failed", error); + return []; + }); + const systemPreflight = await preflight + .runSystem() + .catch((error) => ({ error: error.message })); + const destinationPath = + path.extname(result.filePath).toLowerCase() === ".zip" + ? result.filePath + : `${result.filePath}.zip`; + return diagnostics.exportSupportBundle({ + destinationPath, + publicState: store.getPublicState(), + repositories: repositoryState, + operations: store.data.operations, + preflight: systemPreflight, + privacyMode, + extra: { + appVersion: app.getVersion(), + setupComplete: store.data.setupComplete, + }, + }); + }); + register("diagnostics:show-bundle", async ({ filePath }) => { + if (!diagnostics.isKnownBundlePath(filePath)) + throw new Error( + "Only the most recently generated support bundle can be revealed.", + ); + shell.showItemInFolder(filePath); + return true; + }); + register( + "renderer:report", + async ({ level = "info", event = "renderer.event", details = {} }) => { + const method = ["debug", "info", "warning", "error"].includes(level) + ? level + : "info"; + await diagnostics[method]( + `renderer.${String(event || "event").slice(0, 120)}`, + details, + ); + return true; + }, + ); + + register("app:reset", async () => { + await diagnostics.info("app.reset.requested", {}); + store.data = store.migrate({}); + store.sessionToken = null; + await store.save(); + monitor?.setPaths([]); + monitor?.restart(); + return store.getPublicState(); + }); +} + +module.exports = { registerOperationsIpc }; diff --git a/src/main/ipc/repository-handlers.cjs b/src/main/ipc/repository-handlers.cjs new file mode 100644 index 0000000..f52e99b --- /dev/null +++ b/src/main/ipc/repository-handlers.cjs @@ -0,0 +1,389 @@ +"use strict"; + +function registerRepositoryIpc({ + register, repositories, store, git, gitea, monitor, diagnostics, audit, + externalTools, gitValidator, withRepositoryMutation, assertKnownRepositoryPath, + resolveRepository, cloneRepositoryInto, cloneDirectoryName, + matchRemoteToRepository, shell, dialog, +}) { + register("repositories:refresh", async () => { + const result = await repositories.refresh(); + monitor?.setPaths(repositories.getWatchPaths()); + return result; + }); + + register("repositories:discover", async ({ roots }) => { + const paths = await repositories.discoverAll( + roots || store.data.workspaceRoots, + ); + return repositories.getLocalDescriptors(paths); + }); + + register("repository:favorite", async ({ fullName, favorite }) => + store.setFavorite(fullName, favorite), + ); + + register("repository:link", async ({ fullName, localPath }) => { + await git.ensureRepository(localPath); + const remoteUrl = await git.getRemoteUrl(localPath).catch(() => ""); + if ( + !remoteUrl || + !matchRemoteToRepository(remoteUrl, [{ full_name: fullName }]) + ) { + throw new Error( + `The selected folder's origin does not match ${fullName}.`, + ); + } + await store.saveMapping(fullName, localPath); + await diagnostics.info("repository.linked", { fullName, localPath }); + const result = await repositories.refresh(); + monitor?.setPaths(repositories.getWatchPaths()); + return result; + }); + + register("repository:unlink", async ({ fullName }) => { + await store.removeMapping(fullName); + await diagnostics.info("repository.unlinked", { fullName }); + const result = await repositories.refresh(); + monitor?.setPaths(repositories.getWatchPaths()); + return result; + }); + + register("repository:status", async ({ localPath }) => + git.status(await assertKnownRepositoryPath(localPath)), + ); + register("repository:diff", async ({ localPath, filePath, staged }) => + git.diff(await assertKnownRepositoryPath(localPath), filePath, staged), + ); + register("repository:diff-hunks", async ({ localPath, filePath }) => + git.diffHunks(await assertKnownRepositoryPath(localPath), filePath), + ); + register( + "repository:stage-hunks", + async ({ localPath, filePath, hunkIndexes }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.stageHunks(safePath, filePath, hunkIndexes), + ); + }, + ); + register("repository:conflicts", async ({ localPath }) => + git.conflictState(await assertKnownRepositoryPath(localPath)), + ); + register( + "repository:resolve-conflict", + async ({ localPath, filePath, resolution }) => { + const safePath = await assertKnownRepositoryPath(localPath); + const result = await withRepositoryMutation(safePath, () => + git.resolveConflict(safePath, filePath, resolution), + ); + await audit.append("git.conflict.resolved", { + localPath: safePath, + filePath, + resolution, + }); + return result; + }, + ); + register("repository:continue-operation", async ({ localPath }) => { + const safePath = await assertKnownRepositoryPath(localPath); + const result = await withRepositoryMutation(safePath, () => + git.continueInterruptedOperation(safePath), + ); + await audit.append("git.operation.continued", { localPath: safePath }); + return result; + }); + register("repository:abort-operation", async ({ localPath }) => { + const safePath = await assertKnownRepositoryPath(localPath); + const result = await withRepositoryMutation(safePath, () => + git.abortInterruptedOperation(safePath), + ); + await audit.append("git.operation.aborted", { + localPath: safePath, + operation: result.aborted, + }); + return result; + }); + register("repository:stage", async ({ localPath, files }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => git.stage(safePath, files)); + }); + register("repository:unstage", async ({ localPath, files }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => git.unstage(safePath, files)); + }); + register("repository:commit", async ({ localPath, message, files }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.commit(safePath, message, files), + ); + }); + register("repository:commit-staged", async ({ localPath, message }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.commitStaged(safePath, message), + ); + }); + register("repository:commit-staged-push", async ({ localPath, message }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.commitStagedAndPush(safePath, message), + ); + }); + register("repository:commit-push", async ({ localPath, message, files }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.commitAndPush(safePath, message, files), + ); + }); + register("repository:push", async ({ localPath }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => git.push(safePath)); + }); + register("repository:fetch", async ({ localPath }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => git.fetch(safePath)); + }); + register("repository:pull", async ({ localPath }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.pullFastForward(safePath), + ); + }); + register("repository:history", async ({ localPath, limit }) => + git.history(await assertKnownRepositoryPath(localPath), limit), + ); + register("repository:branch-protection", async ({ fullName, branch }) => { + const repository = await resolveRepository({ fullName }); + return gitea.getBranchProtection( + repository.owner.login, + repository.name, + branch || + repository.localStatus?.branch?.head || + repository.defaultBranch, + ); + }); + register("repository:pull-requests", async ({ fullName, state = "open" }) => { + const repository = await resolveRepository({ fullName }); + return gitea.listPullRequests({ + owner: repository.owner.login, + repo: repository.name, + state, + }); + }); + register( + "repository:create-pull-request", + async ({ fullName, title, body, base }) => { + const repository = await resolveRepository({ fullName }); + if (!repository.localPath || !repository.localStatus?.clean) + throw new Error( + "A clean linked repository is required before creating a pull request.", + ); + const head = repository.localStatus.branch?.head; + if (!head || !repository.localStatus.branch?.upstream) + throw new Error( + "Publish the current branch before creating a pull request.", + ); + if (repository.localStatus.branch.ahead > 0) + throw new Error( + "Push all local commits before creating a pull request.", + ); + const pullRequest = await gitea.createPullRequest({ + owner: repository.owner.login, + repo: repository.name, + head, + base: base || repository.defaultBranch, + title, + body, + }); + await audit.append("pull-request.created", { + repository: repository.fullName, + number: pullRequest.number, + head, + base: base || repository.defaultBranch, + url: pullRequest.html_url, + }); + return pullRequest; + }, + ); + register("repository:branches", async ({ localPath }) => + git.branches(await assertKnownRepositoryPath(localPath)), + ); + register("repository:checkout-branch", async ({ localPath, branch }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.checkoutBranch(safePath, branch), + ); + }); + register("repository:create-branch", async ({ localPath, branch }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.createBranch(safePath, branch), + ); + }); + register("repository:stash", async ({ localPath, message }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => git.stash(safePath, message)); + }); + register("repository:stash-list", async ({ localPath }) => + git.stashList(await assertKnownRepositoryPath(localPath)), + ); + register("repository:stash-pop", async ({ localPath, ref }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => git.popStash(safePath, ref)); + }); + register("repository:index-lock", async ({ localPath }) => + git.getIndexLockInfo(await assertKnownRepositoryPath(localPath)), + ); + register("repository:git-recovery-status", async ({ localPath }) => + git.reconcile(await assertKnownRepositoryPath(localPath)), + ); + register("repository:repair-index-lock", async ({ localPath }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.removeStaleIndexLock(safePath), + ); + }); + register( + "repository:repair-git-locks", + async ({ localPath, force = false }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.repairStaleGitLocks(safePath, { + minimumAgeMs: force ? 0 : 10_000, + allowWithoutProcessProbe: force === true, + }), + ); + }, + ); + register("repository:reconcile", async ({ localPath }) => + git.reconcile(await assertKnownRepositoryPath(localPath)), + ); + register("repository:repair-sync", async ({ localPath, strategy }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.repairSync(safePath, strategy), + ); + }); + register("repository:set-origin", async ({ localPath, remoteUrl }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.setRemoteUrl(safePath, remoteUrl), + ); + }); + + register("repositories:normalize-origins", async () => { + const current = await repositories.refresh(); + const changes = []; + for (const repository of current) { + if (!repository.localPath || !repository.sshUrl) continue; + const actual = await git + .getRemoteUrl(repository.localPath) + .catch(() => ""); + if (actual === repository.sshUrl) continue; + await withRepositoryMutation(repository.localPath, () => + git.setRemoteUrl(repository.localPath, repository.sshUrl), + ); + changes.push({ + fullName: repository.fullName, + previous: actual, + next: repository.sshUrl, + }); + } + const refreshed = await repositories.refresh(); + monitor?.setPaths(repositories.getWatchPaths()); + await diagnostics.info("repositories.origins.normalized", { + count: changes.length, + changes, + }); + return { changes, repositories: refreshed }; + }); + + register("repository:clone", async ({ fullName, mode = "default" }) => { + if (!["default", "custom"].includes(mode)) + throw new Error("Unsupported clone location mode."); + + let projectRoot = store.data.workspaceRoots[0] || null; + if (mode === "custom" || !projectRoot) { + const result = await dialog.showOpenDialog({ + title: `Choose a project root for ${String(fullName || "repository")}`, + defaultPath: projectRoot || undefined, + buttonLabel: "Use this project root", + properties: ["openDirectory", "createDirectory"], + }); + if (result.canceled || !result.filePaths[0]) return { cancelled: true }; + projectRoot = result.filePaths[0]; + } + + return cloneRepositoryInto(fullName, projectRoot); + }); + + register("repository:open-path", async ({ localPath }) => { + const safePath = await assertKnownRepositoryPath(localPath); + const error = await shell.openPath(safePath); + if (error) throw new Error(error); + return true; + }); + register( + "repository:open-editor", + async ({ localPath, filePath = "", line = 1 }) => + externalTools.launch( + "editor", + await assertKnownRepositoryPath(localPath), + filePath, + line, + ), + ); + register("repository:open-terminal", async ({ localPath }) => + externalTools.launch( + "terminal", + await assertKnownRepositoryPath(localPath), + ), + ); + + register("external:open", async ({ url }) => { + const parsed = new URL(url); + if (!["http:", "https:"].includes(parsed.protocol)) + throw new Error("Only HTTP and HTTPS links can be opened."); + await shell.openExternal(parsed.toString()); + return true; + }); + + register("git-validator:scan", async ({ fullName }) => { + const repository = await resolveRepository({ fullName }); + const report = await gitValidator.scan(repository); + await diagnostics.info("git-validator.scan.completed", { + repository: repository.fullName, + score: report.score, + summary: report.summary, + }); + return report; + }); + register("git-validator:repair", async ({ fullName, check }) => { + const repository = await resolveRepository({ fullName }); + const allowed = new Set([ + "align-origin", + "configure-local-safety", + "add-gitignore", + "add-gitattributes", + "add-editorconfig", + "protect-default-branch", + ]); + if (!allowed.has(check?.fixAction)) + throw new Error("Unsupported Git Validator repair request."); + const result = await gitValidator.repair(repository, check); + await audit.append("git-validator.repair", { + repository: repository.fullName, + checkId: check.id, + action: check.fixAction, + }); + await diagnostics.info("git-validator.repair.completed", { + repository: repository.fullName, + checkId: check.id, + action: check.fixAction, + }); + return result; + }); +} + +module.exports = { registerRepositoryIpc }; diff --git a/src/main/unraid-access-methods.cjs b/src/main/unraid-access-methods.cjs new file mode 100644 index 0000000..1bdd7ed --- /dev/null +++ b/src/main/unraid-access-methods.cjs @@ -0,0 +1,447 @@ +"use strict"; + +function createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto }) { + class UnraidAccessMethods { + serverGitRemote(repository, profile) { + const candidates = [repository.sshUrl, profile.cloneUrl, repository.preferredCloneUrl] + .map((value) => String(value || "").trim()) + .filter(Boolean); + const value = candidates.find((candidate) => /^ssh:\/\//i.test(candidate) || /^[^@\s]+@[^:\s]+:.+/.test(candidate)); + if (!value) { + const error = new Error("Server pull requires the repository SSH clone URL from Gitea."); + error.code = "SERVER_GIT_SSH_URL_REQUIRED"; + throw error; + } + return value; + } + + serverGitHost(repository, profile) { + const remote = this.serverGitRemote(repository, profile); + if (/^ssh:\/\//i.test(remote)) { + const parsed = new URL(remote); + return { host: parsed.hostname, port: Number(parsed.port || 22) }; + } + const match = remote.match(/^[^@\s]+@([^:\s]+):/); + if (!match) throw new Error("Could not determine the Gitea SSH host from the clone URL."); + return { host: match[1], port: 22 }; + } + + serverGitCredentialPaths(repository, server) { + const repositoryId = crypto.createHash("sha256").update(String(repository.fullName).toLowerCase()).digest("hex").slice(0, 24); + const directory = path.join(server.basePath, ".forgeflow", "git-credentials", repositoryId); + return { + directory, + privateKey: path.join(directory, "deploy-key"), + publicKey: path.join(directory, "deploy-key.pub"), + knownHosts: path.join(directory, "known_hosts"), + }; + } + + serverGitEnvironment(repository, profile, server) { + const credentials = this.serverGitCredentialPaths(repository, server); + return `GIT_SSH_COMMAND=${shellQuote(`ssh -i ${credentials.privateKey} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${credentials.knownHosts}`)}`; + } + + async configureServerGitAccess({ repository, profileId }) { + const { profile, server } = this.resolve(repository, profileId); + const remote = this.serverGitRemote(repository, profile); + const { host, port } = this.serverGitHost(repository, profile); + const credentials = this.serverGitCredentialPaths(repository, server); + const trustedHostFingerprint = String(profile.serverGitAccess?.hostFingerprint || "").trim(); + const marker = "__FORGEFLOW_DEPLOY_KEY__"; + const setupScript = ` + command -v git >/dev/null 2>&1 || { echo "Git is not installed on the server" >&2; exit 41; } + command -v ssh-keygen >/dev/null 2>&1 || { echo "ssh-keygen is not installed on the server" >&2; exit 42; } + command -v ssh-keyscan >/dev/null 2>&1 || { echo "ssh-keyscan is not installed on the server" >&2; exit 43; } + credential_dir=${shellQuote(credentials.directory)} + private_key=${shellQuote(credentials.privateKey)} + public_key=${shellQuote(credentials.publicKey)} + known_hosts=${shellQuote(credentials.knownHosts)} + expected_host_fingerprint=${shellQuote(trustedHostFingerprint)} + mkdir -p "$credential_dir" + chmod 700 "$credential_dir" + if [ ! -s "$private_key" ] || [ ! -s "$public_key" ]; then + rm -f "$private_key" "$public_key" + ssh-keygen -q -t ed25519 -N '' -C ${shellQuote(`forgeflow:${repository.fullName}`)} -f "$private_key" + fi + chmod 600 "$private_key" + chmod 644 "$public_key" + scan_tmp="$known_hosts.$$.tmp" + scan_ok=false + for attempt in 1 2 3; do + ssh-keyscan -T 10 -H -p ${Number(port)} ${shellQuote(host)} > "$scan_tmp" 2>/dev/null || true + if [ -s "$scan_tmp" ]; then scan_ok=true; break; fi + sleep $((attempt * 2)) + done + [ "$scan_ok" = true ] || { rm -f "$scan_tmp"; echo "Gitea SSH host did not return a host key after three attempts" >&2; exit 44; } + scanned_host_fingerprint="$(ssh-keygen -lf "$scan_tmp" -E sha256 2>/dev/null | awk '{print $2}' | sort -u | paste -sd, -)" + if [ -n "$expected_host_fingerprint" ] && [ "$scanned_host_fingerprint" != "$expected_host_fingerprint" ]; then + rm -f "$scan_tmp" + echo "The Gitea SSH host key changed. Verify the Gitea server before replacing trust." >&2 + exit 46 + fi + mv "$scan_tmp" "$known_hosts" + chmod 600 "$known_hosts" + printf '%s\n' ${shellQuote(marker)} + printf 'publicKey=%s\n' "$(base64 < "$public_key" | tr -d '\\r\\n')" + printf 'fingerprint=%s\n' "$(ssh-keygen -lf "$public_key" -E sha256 | awk '{print $2}')" + printf 'hostFingerprint=%s\n' "$scanned_host_fingerprint" + `; + const setup = await this.ssh.exec(server.id, bash(setupScript), { timeout: 60_000, maxOutput: 256 * 1024 }); + const output = String(setup.stdout || ""); + const markerIndex = output.lastIndexOf(marker); + if (markerIndex < 0) throw new Error("The server did not return the generated deploy key."); + const fields = Object.fromEntries(output.slice(markerIndex + marker.length).trim().split(/\r?\n/).map((line) => { + const separator = line.indexOf("="); + return separator > 0 ? [line.slice(0, separator), line.slice(separator + 1)] : [line, ""]; + })); + if (trustedHostFingerprint && fields.hostFingerprint && trustedHostFingerprint !== fields.hostFingerprint) { + const error = new Error("The Gitea SSH host key changed. Server pull was not reconfigured. Verify the Gitea server before replacing trust."); + error.code = "GITEA_SSH_HOST_KEY_MISMATCH"; + throw error; + } + const publicKey = Buffer.from(fields.publicKey || "", "base64").toString("utf8").trim(); + const [owner, repo] = String(repository.fullName || "").split("/"); + if (!owner || !repo) throw new Error("A full Gitea repository name is required to configure server pull."); + const deployKey = await this.gitea.ensureReadOnlyDeployKey({ + owner, + repo, + title: `ForgeFlow · ${server.name} · read-only`, + publicKey, + }); + const probeCommand = `${this.serverGitEnvironment(repository, profile, server)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)}`; + const probe = await this.ssh.exec( + server.id, + bash(`probe_error='' + for attempt in 1 2 3; do + if probe_output=$(${probeCommand} 2>&1); then printf '%s\n' "$probe_output"; exit 0; fi + probe_error=$probe_output + sleep $((attempt * 2)) + done + printf '%s\n' "$probe_error" >&2 + exit 45`), + { timeout: 45_000, maxOutput: 256 * 1024 }, + ); + const remoteSha = String(probe.stdout || "").trim().split(/\s+/)[0] || null; + const updated = await this.store.saveDeploymentProfile(repository.fullName, { + ...profile, + deploymentMode: "server-git", + cloneUrl: remote, + serverGitAccess: { + configured: true, + deployKeyId: deployKey.id || null, + keyFingerprint: fields.fingerprint || null, + hostFingerprint: fields.hostFingerprint || null, + configuredAt: new Date().toISOString(), + }, + }); + return { + profile: updated, + created: deployKey.created === true, + remoteSha, + keyFingerprint: fields.fingerprint || null, + hostFingerprint: fields.hostFingerprint || null, + }; + } + + async probeServerGitAccess({ repository, profile, server }) { + try { + const remote = this.serverGitRemote(repository, profile); + const credentials = this.serverGitCredentialPaths(repository, server); + const trustedHostFingerprint = String(profile.serverGitAccess?.hostFingerprint || "").trim(); + const trustedKeyFingerprint = String(profile.serverGitAccess?.keyFingerprint || "").trim(); + const command = `[ -s ${shellQuote(credentials.privateKey)} ] && [ -s ${shellQuote(credentials.publicKey)} ] && [ -s ${shellQuote(credentials.knownHosts)} ] && actual_host_fingerprint="$(ssh-keygen -lf ${shellQuote(credentials.knownHosts)} -E sha256 2>/dev/null | awk '{print $2}' | sort -u | paste -sd, -)" && actual_key_fingerprint="$(ssh-keygen -lf ${shellQuote(credentials.publicKey)} -E sha256 2>/dev/null | awk '{print $2}')" && { [ -z ${shellQuote(trustedHostFingerprint)} ] || [ "$actual_host_fingerprint" = ${shellQuote(trustedHostFingerprint)} ]; } && { [ -z ${shellQuote(trustedKeyFingerprint)} ] || [ "$actual_key_fingerprint" = ${shellQuote(trustedKeyFingerprint)} ]; } && remote_output="$(${this.serverGitEnvironment(repository, profile, server)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)})" && remote_sha="$(printf '%s' "$remote_output" | awk 'NR==1 {print $1}')" && printf '__FORGEFLOW_SERVER_GIT_PROBE__\nremoteSha=%s\nkeyFingerprint=%s\nhostFingerprint=%s\n' "$remote_sha" "$actual_key_fingerprint" "$actual_host_fingerprint"`; + const result = await this.ssh.exec(server.id, bash(command), { timeout: 45_000, maxOutput: 256 * 1024 }); + const output = String(result.stdout || ""); + const marker = output.lastIndexOf("__FORGEFLOW_SERVER_GIT_PROBE__"); + if (marker < 0) throw new Error("The server pull probe did not return verifiable fingerprint evidence."); + const fields = Object.fromEntries(output.slice(marker + "__FORGEFLOW_SERVER_GIT_PROBE__".length).trim().split(/\r?\n/).map((line) => { + const separator = line.indexOf("="); + return separator > 0 ? [line.slice(0, separator), line.slice(separator + 1)] : [line, ""]; + })); + return { ready: true, remoteSha: fields.remoteSha || null, keyFingerprint: fields.keyFingerprint || null, hostFingerprint: fields.hostFingerprint || null }; + } catch (error) { + return { ready: false, error: error.message }; + } + } + + async verifyServerGitProfile({ repository, profileId }) { + const { profile, server, remotePath } = this.resolve(repository, profileId); + const checks = []; + const add = (id, label, status, detail, evidence = {}) => checks.push({ id, label, status, detail, evidence }); + if (profile.deploymentMode === "monitor-only") { + add("mode", "Deployment mode", "warning", "This profile is monitoring only and cannot deploy."); + return { readiness: "Monitoring only", ready: false, checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, checks }; + } + if (profile.deploymentMode !== "server-git") { + add("mode", "Deployment mode", "unsupported", "Read-only server-pull verification applies only to Server pull profiles."); + return { readiness: "Unsupported", ready: false, checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, checks }; + } + let branchSha = null; + try { + const [owner, repo] = String(repository.fullName || "").split("/"); + const branch = await this.gitea.getBranch(owner, repo, profile.branch); + branchSha = branch?.commit?.id || branch?.commit?.sha || null; + add("remote-branch", "Gitea branch", branchSha ? "pass" : "fail", branchSha ? `${profile.branch} at ${branchSha}` : `${profile.branch} did not return a commit SHA.`, { branch: profile.branch, sha: branchSha }); + const keys = await this.gitea.listDeployKeys(owner, repo); + const keyId = Number(profile.serverGitAccess?.deployKeyId); + const key = keys.find((item) => Number(item.id) === keyId); + add("deploy-key-scope", "Repository deploy key", key?.read_only === true ? "pass" : "fail", !key ? "The configured deploy key is no longer present in Gitea." : key.read_only === true ? `Key ${key.id} is repository-scoped and read-only.` : `Key ${key.id} has write access and is blocked.`, { keyId: key?.id || keyId || null, readOnly: key?.read_only === true }); + } catch (error) { + add("gitea-access", "Gitea verification", "fail", error.message); + } + const access = await this.probeServerGitAccess({ repository, profile, server }); + add("server-git-access", "Unraid to Gitea", access.ready ? "pass" : "fail", access.ready ? `Exact branch access verified at ${String(access.remoteSha || "unknown").slice(0, 12)}.` : access.error, access); + let inspection = null; + try { + inspection = await this.inspect({ repository, profileId }); + const expectedCompose = profile.generatedCompose ? [".forgeflow/compose.forgeflow.yml"] : this.deploymentComposeFiles(profile); + const composePresent = !inspection.exists || expectedCompose.every((file) => inspection.composeFiles.includes(file)); + add("deployment-directory", "Deployment directory", inspection.exists ? "pass" : "warning", inspection.exists ? remotePath : `${remotePath} will be created on first deployment.`, { remotePath, exists: inspection.exists }); + add("compose", "Compose configuration", composePresent ? "pass" : "warning", composePresent ? expectedCompose.join(", ") : `Expected after deployment: ${expectedCompose.join(", ")}.`, { files: expectedCompose }); + add("preserved-paths", "Preserved runtime paths", "pass", (profile.preservePaths || []).length ? profile.preservePaths.join(", ") : "No preserved runtime paths configured.", { paths: profile.preservePaths || [] }); + add("environment-requirements", "Environment requirements", "pass", (profile.detectedMetadata?.envNames || []).length ? `${profile.detectedMetadata.envNames.length} variable name(s) detected; values remain hidden.` : "No environment variable names were detected in server metadata.", { names: profile.detectedMetadata?.envNames || [] }); + } catch (error) { + add("server-inspection", "Server inspection", "fail", error.message); + } + const state = this.store.getDeploymentState(profile.id) || {}; + const liveSha = state.liveSha || inspection?.head || null; + const running = state.containerRunning; + const healthy = state.healthy; + add("live-commit", "Live server commit", liveSha ? "pass" : "warning", liveSha || "No verifiable live commit is currently recorded.", { 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("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 failed = checks.some((item) => item.status === "fail"); + 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"; + 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 }; + } + + permissionTargets(profile, server, remotePath) { + const targets = [ + { + id: "server-base", + label: "Configured deployment base", + path: server.basePath, + kind: "directory", + required: false, + }, + { + id: "project-root", + label: "Project folder", + path: remotePath, + kind: "directory", + required: true, + }, + { + id: "forgeflow-state", + label: "ForgeFlow upload and rollback storage", + path: path.join(remotePath, ".forgeflow"), + kind: "directory", + required: true, + }, + { + id: "forgeflow-incoming", + label: "ForgeFlow incoming upload folder", + path: path.join(remotePath, ".forgeflow", "incoming"), + kind: "directory", + required: true, + }, + ]; + if (!profile.generatedCompose) { + for (const file of this.deploymentComposeFiles(profile)) { + targets.push({ + id: `compose:${file}`, + label: `Compose file ${file}`, + path: path.join(remotePath, file), + kind: "file", + required: true, + }); + } + } + const unique = new Map(); + for (const target of targets) unique.set(`${target.kind}:${target.path}`, target); + return [...unique.values()]; + } + + permissionInspectionScript(profile, server, remotePath) { + const targetCalls = this.permissionTargets(profile, server, remotePath) + .map( + (target) => + `probe ${shellQuote(target.id)} ${shellQuote(target.label)} ${shellQuote(target.path)} ${shellQuote(target.kind)} ${target.required ? "true" : "false"}`, + ) + .join("\n"); + return ` + encode() { printf '%s' "$1" | base64 | tr -d '\\r\\n'; } + can_elevate=false + [ "$(id -u)" = 0 ] && can_elevate=true + if [ "$can_elevate" != true ] && command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then can_elevate=true; fi + has_acl=false + command -v setfacl >/dev/null 2>&1 && has_acl=true + printf '__FORGEFLOW_PERMISSIONS__\\n' + printf 'I\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \ + "$(encode "$(id -un 2>/dev/null || echo unknown)")" \ + "$(id -u 2>/dev/null || echo -1)" \ + "$(id -g 2>/dev/null || echo -1)" \ + "$(encode "$(id -Gn 2>/dev/null || true)")" \ + "$has_acl" "$can_elevate" + probe() { + target_id=$1 + label=$2 + target=$3 + kind=$4 + required=$5 + exists=false; readable=false; writable=false; parent_writable=false; effective=false + owner=''; group=''; mode=''; detail=''; nearest='' + if [ -e "$target" ] || [ -L "$target" ]; then + exists=true + [ -r "$target" ] && readable=true + [ -w "$target" ] && writable=true + owner=$(stat -c '%U' "$target" 2>/dev/null || true) + group=$(stat -c '%G' "$target" 2>/dev/null || true) + mode=$(stat -c '%a' "$target" 2>/dev/null || true) + fi + parent=$(dirname "$target") + ancestor=$parent + while [ ! -d "$ancestor" ] && [ "$ancestor" != / ]; do ancestor=$(dirname "$ancestor"); done + nearest=$ancestor + marker="$ancestor/.forgeflow-write-test-$$-\${RANDOM:-0}" + if [ -d "$ancestor" ] && (umask 077; : > "$marker") 2>/dev/null; then + rm -f -- "$marker" >/dev/null 2>&1 || true + parent_writable=true + fi + if [ "$kind" = directory ]; then + if [ -d "$target" ]; then + marker="$target/.forgeflow-write-test-$$-\${RANDOM:-0}" + if (umask 077; : > "$marker") 2>/dev/null; then + rm -f -- "$marker" >/dev/null 2>&1 || true + effective=true + fi + elif [ "$parent_writable" = true ]; then + effective=true + fi + else + if [ "$exists" = true ] && [ ! -f "$target" ]; then + detail='Path exists but is not a regular file.' + elif [ "$exists" = true ] && [ "$readable" = true ] && { [ "$writable" = true ] || [ "$parent_writable" = true ]; }; then + effective=true + elif [ "$exists" = false ] && [ "$parent_writable" = true ]; then + effective=true + detail='File is absent but can be created by the deployment user.' + fi + fi + if [ -z "$detail" ]; then + if [ "$effective" = true ]; then detail='Read/write probe passed.' + else detail="No safe create/replace access for $(id -un 2>/dev/null || echo 'the SSH user')."; fi + fi + printf 'P\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \ + "$(encode "$target_id")" "$(encode "$label")" "$(encode "$target")" "$kind" "$required" \ + "$exists" "$readable" "$writable" "$parent_writable" "$effective" \ + "$(encode "$owner")" "$(encode "$group")" "$mode" "$(encode "$nearest")" "$(encode "$detail")" + } + ${targetCalls} + `; + } + + async inspectWriteAccess({ repository, profileId }) { + const { profile, server, remotePath } = this.resolve(repository, profileId); + const result = await this.ssh.exec( + server.id, + bash(this.permissionInspectionScript(profile, server, remotePath)), + { timeout: 45_000, maxOutput: 2 * 1024 * 1024 }, + ); + const report = parsePermissionInspection(result.stdout); + report.serverId = server.id; + report.remotePath = remotePath; + return report; + } + + permissionRepairScript(profile, server, remotePath) { + const preserve = [ + ".git", + "node_modules", + ".venv", + "venv", + "__pycache__", + ...(profile.preservePaths || []), + ] + .map((value) => safeRelativeRemoteFile(value)) + .filter(Boolean); + const pruneExpression = preserve.length + ? preserve + .map((value) => `-path ${shellQuote(path.join(remotePath, value))} -o -path ${shellQuote(path.join(remotePath, value, "*"))}`) + .join(" -o ") + : "-false"; + const composePaths = this.deploymentComposeFiles(profile) + .map((file) => shellQuote(path.join(remotePath, file))) + .join(" "); + return ` + root=${shellQuote(remotePath)} + base=${shellQuote(server.basePath)} + case "$root" in "$base"|"$base"/*) ;; *) echo "Refusing permission repair outside configured base path: $root" >&2; exit 81 ;; esac + run_privileged() { + if [ "$(id -u)" = 0 ]; then "$@"; + elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then sudo -n "$@"; + else "$@"; + fi + } + mkdir_cmd=mkdir + if ! mkdir -p "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null; then + run_privileged mkdir -p "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" + fi + share_group=$(getent group users >/dev/null 2>&1 && echo users || id -gn) + if command -v setfacl >/dev/null 2>&1; then + run_privileged setfacl -m "u:$(id -un):rwx,g:$share_group:rwx,m:rwx" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true + run_privileged setfacl -d -m "u:$(id -un):rwx,g:$share_group:rwx,m:rwx" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true + fi + run_privileged chgrp "$share_group" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true + run_privileged chmod 2775 "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" + if [ -d "$root" ]; then + while IFS= read -r -d '' entry; do + case "$entry" in + "$root/.forgeflow"|"$root/.forgeflow"/*) continue ;; + esac + run_privileged chgrp "$share_group" "$entry" 2>/dev/null || true + if [ -d "$entry" ]; then run_privileged chmod u+rwx,g+rwx,g+s "$entry"; else run_privileged chmod u+rw,g+rw "$entry"; fi + done < <(find "$root" -mindepth 1 \\( ${pruneExpression} \\) -prune -o -print0) + fi + for compose_file in ${composePaths || ""}; do + [ -e "$compose_file" ] || continue + run_privileged chgrp "$share_group" "$compose_file" 2>/dev/null || true + run_privileged chmod u+rw,g+rw "$compose_file" + done + echo "ForgeFlow repaired project write access for $(id -un) and group $share_group without changing preserved runtime paths." + `; + } + + async repairWriteAccess({ repository, profileId }) { + const { profile, server, remotePath } = this.resolve(repository, profileId); + const before = await this.inspectWriteAccess({ repository, profileId }); + await this.ssh.exec(server.id, bash(this.permissionRepairScript(profile, server, remotePath)), { + timeout: 5 * 60_000, + maxOutput: 4 * 1024 * 1024, + }); + const after = await this.inspectWriteAccess({ repository, profileId }); + if (!after.ready) { + const error = new Error( + `Write-access repair did not make every required path writable: ${after.blocking.map((item) => item.path).join(", ")}`, + ); + error.code = "WRITE_ACCESS_REPAIR_INCOMPLETE"; + error.permissionReport = after; + throw error; + } + await this.diagnostics?.info("unraid.write-access.repaired", { + repository: repository.fullName, + profileId, + serverId: server.id, + remotePath, + user: after.identity.user, + }); + return { changed: true, normalized: true, before, after }; + } + } + return UnraidAccessMethods.prototype; +} + +module.exports = { createUnraidAccessMethods }; diff --git a/src/main/unraid-deployment-methods.cjs b/src/main/unraid-deployment-methods.cjs new file mode 100644 index 0000000..3a65d2e --- /dev/null +++ b/src/main/unraid-deployment-methods.cjs @@ -0,0 +1,576 @@ +"use strict"; + +function createUnraidDeploymentMethods({ + path, crypto, bash, shellQuote, assertFullCommitSha, nativePath, fs, +}) { + class UnraidDeploymentMethods { + pushBundleScript({ repository, profile, remotePath, targetSha, requestId, remotePart, digest, metadata, generated, iconReference, rollback = false }) { + const compose = this.composeInvocation(profile, repository); + const project = String( + profile.composeProject || this.internalSlug(profile, repository), + ).trim(); + const candidateFiles = [...this.deploymentComposeFiles(profile)]; + if (profile.generatedCompose) candidateFiles.push(".forgeflow/compose.metadata.yml"); + const candidateCompose = `forgeflow_compose -p ${shellQuote(project)} ${candidateFiles + .map((file) => `-f "$release"/${shellQuote(file)}`) + .join(" ")}`; + const preservePayload = Buffer.from( + [".forgeflow", ".git", ...(profile.preservePaths || [])].join("\n"), + "utf8", + ).toString("base64"); + const statusJson = this.deploymentStatusDocument({ + repository, + profile, + targetSha, + requestId, + rollback, + }); + const verification = this.containerVerificationScript( + profile, + repository, + compose, + { requireRecreated: true }, + ); + const containerHint = String( + profile.containerName || profile.remoteFolder || repository.name || "", + ).trim(); + return ` + root=${shellQuote(remotePath)} + expected_project=${shellQuote(project)} + tracked_container_hint=${shellQuote(containerHint)} + target=${shellQuote(targetSha)} + request_id=${shellQuote(requestId)} + incoming=${shellQuote(remotePart)} + expected_digest=${shellQuote(digest)} + release_root="$root/.forgeflow/releases/$target" + release="$release_root/source" + staging="$root/.forgeflow/staging/$request_id" + backup="$root/.forgeflow/backups/$request_id" + lock="$root/.forgeflow/deploy.lock" + mkdir -p "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" + if [ -d "$lock" ] && find "$lock" -maxdepth 0 -mmin +120 -print -quit | grep -q .; then + lock_pid=$(cat "$lock/pid" 2>/dev/null || true) + if [ -z "$lock_pid" ] || ! kill -0 "$lock_pid" 2>/dev/null; then rm -rf "$lock"; fi + fi + mkdir "$lock" 2>/dev/null || { echo "Another ForgeFlow deployment is active for $root" >&2; exit 70; } + printf '%s\n' "$request_id" > "$lock/request-id" + printf '%s\n' "$$" > "$lock/pid" + date -u +%Y-%m-%dT%H:%M:%SZ > "$lock/started-at" + restore_needed=false + activation_started=false + is_preserved() { + rel="$1" + while IFS= read -r keep; do + [ -n "$keep" ] || continue + if [ "$rel" = "$keep" ] || [[ "$rel" == "$keep/"* ]]; then return 0; fi + done < "$staging.preserve" + return 1 + } + restore_files() { + if [ -f "$backup/present" ]; then + while IFS= read -r rel; do + [ -n "$rel" ] || continue + mkdir -p -- "$root/$(dirname "$rel")" + temp="$root/$rel.forgeflow-restore-$request_id" + cp -a -- "$backup/source/$rel" "$temp" && mv -f -- "$temp" "$root/$rel" + done < "$backup/present" + fi + if [ -f "$backup/absent" ]; then + while IFS= read -r rel; do + [ -n "$rel" ] || continue + case "$rel" in .forgeflow/*) continue ;; esac + [ -e "$root/$rel" ] || [ -L "$root/$rel" ] || continue + rm -f -- "$root/$rel" + done < "$backup/absent" + fi + if [ -f "$backup/generated.present" ]; then + cp -a "$backup/compose.forgeflow.yml" "$root/.forgeflow/compose.forgeflow.yml" + elif [ -f "$backup/generated.created" ]; then + rm -f "$root/.forgeflow/compose.forgeflow.yml" + fi + if [ -f "$backup/metadata.present" ]; then + cp -a "$backup/compose.metadata.yml" "$root/.forgeflow/compose.metadata.yml" + elif [ -f "$backup/metadata.created" ]; then + rm -f "$root/.forgeflow/compose.metadata.yml" + fi + } + restore_images() { + [ -f "$backup/containers.before" ] || return 0 + while IFS=$'\t' read -r service container_id image_id image_ref_b64; do + [ -n "$image_id" ] || continue + docker image inspect "$image_id" >/dev/null 2>&1 || continue + image_ref=$(printf '%s' "$image_ref_b64" | base64 -d 2>/dev/null || true) + case "$image_ref" in ''|sha256:*|*@sha256:*) continue ;; esac + docker image tag "$image_id" "$image_ref" >/dev/null 2>&1 || true + done < "$backup/containers.before" + } + restore_runtime() { + [ "$activation_started" = true ] || return 0 + restore_images + if cd "$root" 2>/dev/null && ${compose} config >/dev/null 2>&1; then + ${compose} up -d --no-build >/dev/null 2>&1 || return 1 + old_services=$(${compose} config --services 2>/dev/null | sed '/^$/d') + printf '%s\n' "$old_services" | while IFS= read -r service; do + [ -n "$service" ] || continue + old_id=$(${compose} ps -q "$service" | head -n1) + [ -n "$old_id" ] || exit 1 + [ "$(docker inspect -f '{{.State.Running}}' "$old_id" 2>/dev/null || echo false)" = true ] || exit 1 + done + fi + } + finish() { + status=$? + trap - EXIT + set +e + if [ "$status" -ne 0 ] && [ "$restore_needed" = true ]; then + restore_files + if ! restore_runtime; then + echo "CRITICAL: source files were restored, but the previous Compose runtime could not be restarted automatically. Backup: $backup" >&2 + else + echo "ForgeFlow restored the previous source and runtime after the failed activation." >&2 + fi + fi + rm -rf "$staging" "$lock" + exit "$status" + } + trap finish EXIT + actual_digest=$(if command -v sha256sum >/dev/null 2>&1; then sha256sum "$incoming" | awk '{print $1}'; else shasum -a 256 "$incoming" | awk '{print $1}'; fi) + [ "$actual_digest" = "$expected_digest" ] || { echo "Uploaded bundle checksum mismatch" >&2; exit 71; } + tar -tf "$incoming" > "$staging.entries" + if grep -E '(^/|(^|/)\\.\\.(/|$))' "$staging.entries" >/dev/null; then echo "Unsafe path detected in deployment bundle" >&2; exit 72; fi + rm -rf "$staging" "$release_root.pending" + mkdir -p "$staging/source" "$release_root.pending" + tar -xf "$incoming" -C "$staging/source" + if find "$staging/source" -type l -print -quit | grep -q .; then echo "Symbolic links are not accepted in push bundles" >&2; exit 73; fi + mv "$staging/source" "$release_root.pending/source" + find "$release_root.pending/source" -type f -printf '%P\n' | LC_ALL=C sort > "$release_root.pending/managed-files" + rm -rf "$release_root" + mv "$release_root.pending" "$release_root" + rm -f "$incoming" "$staging.entries" + printf '%s' ${shellQuote(preservePayload)} | base64 -d > "$staging.preserve" + for runtime_config in .env compose.override.yml compose.override.yaml docker-compose.override.yml docker-compose.override.yaml; do + if [ -f "$root/$runtime_config" ] && [ ! -e "$release/$runtime_config" ]; then + mkdir -p "$release/$(dirname "$runtime_config")" + cp -a "$root/$runtime_config" "$release/$runtime_config" + fi + done + ${profile.generatedCompose ? `mkdir -p "$release/.forgeflow" + cat > "$release/.forgeflow/compose.forgeflow.yml" <<'FORGEFLOW_COMPOSE' + ${generated}FORGEFLOW_COMPOSE + cat > "$release/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA' + ${metadata}FORGEFLOW_METADATA` : ""} + cd "$release" + ${candidateCompose} config >/dev/null + candidate_services=$(${candidateCompose} config --services 2>/dev/null | sed '/^$/d') + [ -n "$candidate_services" ] || { echo "Candidate Compose project defines no services" >&2; exit 60; } + mkdir -p "$backup/source" + : > "$backup/present" + : > "$backup/absent" + : > "$backup/containers.before" + had_existing_compose=false + if cd "$root" 2>/dev/null && ${compose} config >/dev/null 2>&1; then + had_existing_compose=true + old_services=$(${compose} config --services 2>/dev/null | sed '/^$/d') + printf '%s\n' "$old_services" | while IFS= read -r service; do + [ -n "$service" ] || continue + container_id=$(${compose} ps -q "$service" | head -n1) + image_id=''; image_ref='' + if [ -n "$container_id" ] && docker inspect "$container_id" >/dev/null 2>&1; then + image_id=$(docker inspect -f '{{.Image}}' "$container_id" 2>/dev/null || true) + image_ref=$(docker inspect -f '{{.Config.Image}}' "$container_id" 2>/dev/null || true) + fi + printf '%s\t%s\t%s\t%s\n' "$service" "$container_id" "$image_id" "$(printf '%s' "$image_ref" | base64 | tr -d '\r\n')" + done >> "$backup/containers.before" + fi + if [ -n "$tracked_container_hint" ] && docker inspect "$tracked_container_hint" >/dev/null 2>&1; then + hint_project=$(docker inspect -f '{{index .Config.Labels "com.docker.compose.project"}}' "$tracked_container_hint" 2>/dev/null || true) + if [ -n "$hint_project" ] && [ "$hint_project" != "$expected_project" ]; then + echo "Refusing activation: container $tracked_container_hint belongs to Compose project $hint_project, not $expected_project" >&2 + exit 67 + fi + fi + # Build all candidate images before any running container is touched. + cd "$release" + ${candidateCompose} build + new_manifest="$release_root/managed-files" + while IFS= read -r rel; do + [ -n "$rel" ] || continue + is_preserved "$rel" && continue + parent=$(dirname "$rel") + current="$root" + if [ "$parent" != . ]; then + old_ifs=$IFS; IFS='/'; read -r -a parts <<< "$parent"; IFS=$old_ifs + for part in "\${parts[@]}"; do + current="$current/$part" + [ ! -L "$current" ] || { echo "Refusing to deploy through symlinked parent $current" >&2; exit 74; } + done + fi + [ ! -L "$root/$rel" ] || { echo "Refusing to replace symlinked managed path $rel" >&2; exit 74; } + if [ -d "$root/$rel" ]; then echo "A directory conflicts with managed file $rel" >&2; exit 75; fi + if [ -e "$root/$rel" ]; then + mkdir -p "$backup/source/$(dirname "$rel")" + cp -a -- "$root/$rel" "$backup/source/$rel" + printf '%s\n' "$rel" >> "$backup/present" + else + printf '%s\n' "$rel" >> "$backup/absent" + fi + done < "$new_manifest" + [ -f "$root/.forgeflow/compose.metadata.yml" ] && { cp -a "$root/.forgeflow/compose.metadata.yml" "$backup/compose.metadata.yml"; touch "$backup/metadata.present"; } + [ -f "$root/.forgeflow/compose.forgeflow.yml" ] && { cp -a "$root/.forgeflow/compose.forgeflow.yml" "$backup/compose.forgeflow.yml"; touch "$backup/generated.present"; } + restore_needed=true + while IFS= read -r rel; do + [ -n "$rel" ] || continue + is_preserved "$rel" && continue + mkdir -p -- "$root/$(dirname "$rel")" + temp="$root/$rel.forgeflow-new-$request_id" + cp -a -- "$release/$rel" "$temp" + mv -f -- "$temp" "$root/$rel" + done < "$new_manifest" + mkdir -p "$root/.forgeflow" + ${profile.generatedCompose ? `if [ ! -f "$backup/generated.present" ]; then touch "$backup/generated.created"; fi + if [ ! -f "$backup/metadata.present" ]; then touch "$backup/metadata.created"; fi + cat > "$root/.forgeflow/compose.forgeflow.yml.pending" <<'FORGEFLOW_COMPOSE' + ${generated}FORGEFLOW_COMPOSE + mv "$root/.forgeflow/compose.forgeflow.yml.pending" "$root/.forgeflow/compose.forgeflow.yml" + cat > "$root/.forgeflow/compose.metadata.yml.pending" <<'FORGEFLOW_METADATA' + ${metadata}FORGEFLOW_METADATA + mv "$root/.forgeflow/compose.metadata.yml.pending" "$root/.forgeflow/compose.metadata.yml"` : `cat > "$root/.forgeflow/deployment-metadata.json.pending" <<'FORGEFLOW_METADATA_JSON' + ${JSON.stringify({ repository: repository.fullName, environment: profile.environment, commit: targetSha, requestId })} + FORGEFLOW_METADATA_JSON + mv "$root/.forgeflow/deployment-metadata.json.pending" "$root/.forgeflow/deployment-metadata.json"`} + share_group=$(getent group users >/dev/null 2>&1 && echo users || id -gn) + chgrp "$share_group" "$root" "$root/.forgeflow" 2>/dev/null || true + chmod g+rwx "$root" "$root/.forgeflow" 2>/dev/null || true + chmod g+s "$root" "$root/.forgeflow" 2>/dev/null || true + while IFS= read -r rel; do + [ -n "$rel" ] || continue + is_preserved "$rel" && continue + chgrp "$share_group" "$root/$rel" 2>/dev/null || true + chmod u+rw,g+rw "$root/$rel" 2>/dev/null || true + parent="$root/$(dirname "$rel")" + chgrp "$share_group" "$parent" 2>/dev/null || true + chmod g+rwx,g+s "$parent" 2>/dev/null || true + done < "$new_manifest" + cd "$root" + ${compose} config >/dev/null + actual_services=$(${compose} config --services 2>/dev/null | sed '/^$/d') + [ -n "$actual_services" ] || { echo "Compose project defines no services" >&2; exit 60; } + if [ "$(printf '%s\n' "$candidate_services" | LC_ALL=C sort)" != "$(printf '%s\n' "$actual_services" | LC_ALL=C sort)" ]; then + echo "Refusing activation because candidate and server Compose service sets differ" >&2 + exit 68 + fi + before_containers="$backup/containers.before" + hint_before_id='' + if [ -n "$tracked_container_hint" ] && docker inspect "$tracked_container_hint" >/dev/null 2>&1; then + hint_before_id=$(docker inspect -f '{{.Id}}' "$tracked_container_hint" 2>/dev/null || true) + fi + activation_started=true + ${compose} up -d --no-build + ${verification} + if [ -n "$hint_before_id" ] && docker inspect "$hint_before_id" >/dev/null 2>&1; then + old_hint_running=$(docker inspect -f '{{.State.Running}}' "$hint_before_id" 2>/dev/null || echo false) + [ "$old_hint_running" != true ] || { echo "Compose left the previous container $tracked_container_hint ($hint_before_id) running" >&2; exit 66; } + fi + ${this.dockerManRefreshScript(profile, repository, iconReference)} + previous=$(cat "$root/.forgeflow/current-sha" 2>/dev/null || true) + [ -n "$previous" ] || previous=$(git -C "$root" rev-parse HEAD 2>/dev/null || true) + [ -n "$previous" ] && printf '%s' "$previous" > "$root/.forgeflow/previous-sha" + cp "$new_manifest" "$root/.forgeflow/managed-files.pending" + mv "$root/.forgeflow/managed-files.pending" "$root/.forgeflow/managed-files" + printf '%s' "$target" > "$root/.forgeflow/current-sha.pending" + mv "$root/.forgeflow/current-sha.pending" "$root/.forgeflow/current-sha" + cat > "$root/.forgeflow/status.json.pending" <<'FORGEFLOW_STATUS' + ${statusJson} + FORGEFLOW_STATUS + mv "$root/.forgeflow/status.json.pending" "$root/.forgeflow/status.json" + restore_needed=false + printf '%s\n' "successful" > "$backup/result" + date -u +%Y-%m-%dT%H:%M:%SZ > "$backup/completed-at" + echo "ForgeFlow safely activated push bundle $target; rollback evidence retained at $backup" + `; + } + + async executePushBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference, rollback = false }) { + const permissionReport = await this.inspectWriteAccess({ + repository, + profileId: profile.id, + }); + if (!permissionReport.ready) { + const error = new Error( + `Deployment stopped before upload because write access is missing for: ${permissionReport.blocking.map((item) => item.path).join(", ")}`, + ); + error.code = "REMOTE_WRITE_ACCESS_REQUIRED"; + error.permissionReport = permissionReport; + throw error; + } + const bundle = await this.createCommitBundle(repository, targetSha, requestId); + const remotePart = path.join(remotePath, ".forgeflow", "incoming", `${requestId}-${targetSha}.tar.part`); + try { + await this.ssh.exec(server.id, bash(`mkdir -p ${shellQuote(path.dirname(remotePart))}`), { timeout: 30_000 }); + await this.ssh.uploadFile(server.id, bundle.archivePath, remotePart, { mode: 0o600 }); + const script = this.pushBundleScript({ repository, profile, remotePath, targetSha, requestId, remotePart, digest: bundle.sha256, metadata, generated, iconReference, rollback }); + return await this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 8 * 1024 * 1024 }); + } finally { + await fs.rm(bundle.archivePath, { force: true }).catch(() => {}); + } + } + + async createServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId }) { + const remote = this.serverGitRemote(repository, profile); + const repositoryId = crypto.createHash("sha256").update(String(repository.fullName).toLowerCase()).digest("hex").slice(0, 24); + const cache = path.join(server.basePath, ".forgeflow", "git-cache", `${repositoryId}.git`); + const remotePart = path.join(remotePath, ".forgeflow", "incoming", `${requestId}-${targetSha}.tar.part`); + const marker = "__FORGEFLOW_SERVER_ARCHIVE__"; + const script = ` + cache=${shellQuote(cache)} + incoming=${shellQuote(remotePart)} + remote=${shellQuote(remote)} + branch=${shellQuote(profile.branch)} + target=${shellQuote(targetSha)} + mkdir -p "$(dirname "$cache")" "$(dirname "$incoming")" + if [ ! -d "$cache" ]; then git init --bare "$cache" >/dev/null; fi + if git --git-dir="$cache" remote get-url origin >/dev/null 2>&1; then + git --git-dir="$cache" remote set-url origin "$remote" + else + git --git-dir="$cache" remote add origin "$remote" + fi + ${this.serverGitEnvironment(repository, profile, server)} git --git-dir="$cache" fetch --force --prune origin "+refs/heads/$branch:refs/remotes/origin/$branch" + git --git-dir="$cache" cat-file -e "$target^{commit}" + git --git-dir="$cache" merge-base --is-ancestor "$target" "refs/remotes/origin/$branch" + archive_tmp="$incoming.$$.tmp" + git --git-dir="$cache" archive --format=tar --output="$archive_tmp" "$target" + [ -s "$archive_tmp" ] || { rm -f "$archive_tmp"; echo "Gitea produced an empty deployment archive" >&2; exit 45; } + mv "$archive_tmp" "$incoming" + digest=$(if command -v sha256sum >/dev/null 2>&1; then sha256sum "$incoming" | awk '{print $1}'; else shasum -a 256 "$incoming" | awk '{print $1}'; fi) + printf '%s\n' ${shellQuote(marker)} + printf 'digest=%s\n' "$digest" + `; + const result = await this.ssh.exec(server.id, bash(script), { timeout: 5 * 60_000, maxOutput: 512 * 1024 }); + const output = String(result.stdout || ""); + const markerIndex = output.lastIndexOf(marker); + const digest = markerIndex >= 0 + ? String(output.slice(markerIndex + marker.length).match(/(?:^|\n)digest=([0-9a-f]{64})(?:\n|$)/i)?.[1] || "").toLowerCase() + : ""; + if (!digest) throw new Error("The server did not return a valid checksum for the Gitea archive."); + return { remotePart, digest }; + } + + async executeServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference, rollback = false }) { + const permissionReport = await this.inspectWriteAccess({ repository, profileId: profile.id }); + if (!permissionReport.ready) { + const error = new Error(`Deployment stopped before the Gitea fetch because write access is missing for: ${permissionReport.blocking.map((item) => item.path).join(", ")}`); + error.code = "REMOTE_WRITE_ACCESS_REQUIRED"; + error.permissionReport = permissionReport; + throw error; + } + const bundle = await this.createServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId }); + const script = this.pushBundleScript({ repository, profile, remotePath, targetSha, requestId, remotePart: bundle.remotePart, digest: bundle.digest, metadata, generated, iconReference, rollback }); + return this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 8 * 1024 * 1024 }); + } + + async deploy({ repository, profileId, sha }) { + const targetSha = assertFullCommitSha(sha); + const { profile, server, remotePath } = this.resolve(repository, profileId); + if (profile.deploymentMode === "server-git") { + const verification = await this.verifyServerGitProfile({ repository, profileId }); + const requiredChecks = ["remote-branch", "deploy-key-scope", "server-git-access"]; + const blocked = verification.checks.filter((check) => requiredChecks.includes(check.id) && check.status !== "pass"); + if (blocked.length || !verification.branchSha) { + const error = new Error(`Server pull verification failed: ${blocked.map((check) => check.detail).join("; ") || "the target branch could not be proven"}`); + error.code = "SERVER_GIT_VERIFICATION_FAILED"; + error.verification = verification; + throw error; + } + } + const preflight = await this.preflight({ repository, profileId, sha: targetSha }); + if (!preflight.summary.ready) { + const error = new Error(`SSH deployment preflight failed: ${preflight.summary.blocking.join(", ")}`); + error.code = "SSH_DEPLOYMENT_PREFLIGHT_FAILED"; + throw error; + } + const requestId = crypto.randomUUID(); + const mode = ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode) + ? profile.deploymentMode + : "push-bundle"; + const operation = await this.saveOperation({ + id: requestId, + type: "deployment", + action: "deploy", + provider: "ssh-unraid", + repository: repository.fullName, + environment: profile.environment, + profileId, + serverId: server.id, + remotePath, + sha: targetSha, + shortSha: targetSha.slice(0, 7), + status: "running", + logs: [ + "Preflight passed.", + mode === "push-bundle" + ? "Creating and uploading the exact committed local project directly to Unraid." + : mode === "server-git" + ? "Fetching the exact commit from Gitea with a repository-scoped read-only deploy key." + : "This workload is monitor-only and cannot be deployed.", + `Deploying exact commit ${targetSha} in the background.`, + ], + }); + + const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : ""; + const iconReference = await this.prepareIcon(profile, repository, server); + const metadata = this.metadataCompose(profile, repository, iconReference, { + sha: targetSha, + repositoryUrl: profile.cloneUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName, + }); + const previousState = this.store.getDeploymentState?.(profileId) || null; + + void (async () => { + try { + if (mode === "monitor-only") throw new Error("This workload is monitor-only. Select Server pull or Direct copy before deploying."); + const result = mode === "server-git" + ? await this.executeServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference }) + : await this.executePushBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference }); + const health = await this.checkHealth(profile.healthcheckUrl); + const finalStatus = health.healthy === false ? "failed" : "success"; + const completed = await this.saveOperation({ + ...operation, + status: finalStatus, + previousSha: previousState?.liveSha || preflight.inspection?.head || null, + health, + logs: [ + ...operation.logs, + ...result.stdout.trim().split("\n").filter(Boolean).slice(-80), + "Docker Compose activation and runtime verification completed.", + health.configured + ? `Healthcheck ${health.healthy ? "passed" : "failed"}${health.status ? ` with HTTP ${health.status}` : ""}.` + : "No desktop healthcheck configured; running containers were verified and health remains unverified.", + ], + error: health.healthy === false ? "The application healthcheck did not pass after deployment." : null, + }); + await this.store.saveDeploymentState(profileId, { + liveSha: targetSha, + previousSha: previousState?.liveSha || preflight.inspection?.head || null, + healthy: health.configured ? health.healthy : null, + runtimeVerification: health.configured ? "desktop-healthcheck" : "running-unverified", + healthStatus: health.status ?? null, + healthLatencyMs: health.latencyMs ?? null, + requestId, + remotePath, + provider: "ssh-unraid", + deploymentMode: mode, + containerName: String(profile.containerName || profile.remoteFolder || repository.name), + containerRunning: true, + dockerMan: { + webUi: this.dockerManWebUi(profile), + icon: iconReference, + shell: this.dockerManShell(profile), + templateExists: profile.manageDockerMan === true || previousState?.dockerMan?.templateExists === true, + configured: Boolean(this.dockerManWebUi(profile) || iconReference || previousState?.dockerMan?.configured), + }, + webUiUrl: profile.webUiUrl || (profile.hostPort ? `http://${server.host}:${profile.hostPort}/` : null), + }); + void this.refreshProfileState(repository.fullName, profileId).catch(() => {}); + await this.diagnostics?.info("unraid.deployment.completed", { requestId, repository: repository.fullName, serverId: server.id, remotePath, sha: targetSha, status: completed.status }); + } catch (error) { + await this.saveOperation({ + ...operation, + status: "failed", + error: error.message, + failure: { stage: mode === "server-git" ? "Gitea server pull / Compose activation" : "Direct copy / Compose activation", message: error.message }, + logs: [...operation.logs, error.message, "The live SHA was not promoted. Previous release evidence remains authoritative."], + }); + await this.diagnostics?.error("unraid.deployment.failed", { requestId, repository: repository.fullName, serverId: server.id, remotePath, sha: targetSha, error }); + } + })(); + + return operation; + } + + async rollback({ repository, profileId, targetSha }) { + const target = assertFullCommitSha(targetSha); + const { profile, server, remotePath } = this.resolve(repository, profileId); + const deploymentState = this.store.getDeploymentState(profileId); + if (!deploymentState?.previousSha || deploymentState.previousSha !== target) { + const error = new Error("Rollback is allowed only to the exact previous SHA reported by ForgeFlow for this deployment profile."); + error.code = "ROLLBACK_TARGET_NOT_PREVIOUS_SHA"; + throw error; + } + const rollbackMode = ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode) + ? profile.deploymentMode + : "push-bundle"; + if (rollbackMode === "push-bundle" && !repository.localPath) + throw new Error("A linked local repository is required for Direct copy rollback verification."); + const requestId = crypto.randomUUID(); + const operation = await this.saveOperation({ + id: requestId, + type: "deployment", + action: "rollback", + provider: "ssh-unraid", + repository: repository.fullName, + environment: profile.environment, + profileId, + serverId: server.id, + remotePath, + sha: target, + shortSha: target.slice(0, 7), + status: "running", + logs: [`Rolling back to exact commit ${target}.`], + }); + const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : ""; + const iconReference = await this.prepareIcon(profile, repository, server); + const metadata = this.metadataCompose(profile, repository, iconReference, { + sha: target, + repositoryUrl: profile.cloneUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName, + }); + try { + const mode = rollbackMode; + if (mode === "monitor-only") throw new Error("This workload is monitor-only. Select Server pull or Direct copy before rolling back."); + const result = mode === "server-git" + ? await this.executeServerGitBundle({ repository, profile, server, remotePath, targetSha: target, requestId, metadata, generated, iconReference, rollback: true }) + : await this.executePushBundle({ repository, profile, server, remotePath, targetSha: target, requestId, metadata, generated, iconReference, rollback: true }); + const health = await this.checkHealth(profile.healthcheckUrl); + const finalStatus = health.healthy === false ? "failed" : "rolled-back"; + const completed = await this.saveOperation({ + ...operation, + status: finalStatus, + previousSha: deploymentState.liveSha || null, + health, + error: health.healthy === false ? "The application healthcheck did not pass after rollback." : null, + logs: [ + ...operation.logs, + ...result.stdout.trim().split("\n").filter(Boolean).slice(-80), + "Rollback activation completed.", + health.configured ? `Healthcheck ${health.healthy ? "passed" : "failed"}.` : "Runtime is running; no desktop healthcheck was configured.", + ], + }); + await this.store.saveDeploymentState(profileId, { + liveSha: target, + previousSha: deploymentState.liveSha || null, + healthy: health.configured ? health.healthy : null, + runtimeVerification: health.configured ? "desktop-healthcheck" : "running-unverified", + healthStatus: health.status ?? null, + healthLatencyMs: health.latencyMs ?? null, + requestId, + remotePath, + provider: "ssh-unraid", + containerRunning: true, + }); + if (health.healthy === false) { + const error = new Error("Rollback completed, but the configured healthcheck failed."); + error.code = "ROLLBACK_HEALTHCHECK_FAILED"; + error.operationId = completed.id; + throw error; + } + return completed; + } catch (error) { + if (error.code !== "ROLLBACK_HEALTHCHECK_FAILED") { + await this.saveOperation({ ...operation, status: "failed", error: error.message, logs: [...operation.logs, error.message] }); + } + throw error; + } + } + } + return UnraidDeploymentMethods.prototype; +} + +module.exports = { createUnraidDeploymentMethods }; diff --git a/src/main/unraid-deployment-service.cjs b/src/main/unraid-deployment-service.cjs index 170eaf5..113b9d8 100644 --- a/src/main/unraid-deployment-service.cjs +++ b/src/main/unraid-deployment-service.cjs @@ -16,6 +16,12 @@ const { remoteIdentity: inventoryRemoteIdentity, } = require("./server-inventory.cjs"); const { classifyInventory } = require("./inventory-classifier.cjs"); +const { createUnraidInventoryMethods } = require("./unraid-inventory-methods.cjs"); +const { createUnraidAccessMethods } = require("./unraid-access-methods.cjs"); +const { createUnraidPreflightMethods } = require("./unraid-preflight-methods.cjs"); +const { createUnraidRuntimeMethods } = require("./unraid-runtime-methods.cjs"); +const { createUnraidDeploymentMethods } = require("./unraid-deployment-methods.cjs"); +const { createUnraidStateMethods } = require("./unraid-state-methods.cjs"); function safeRemoteFolder(value) { const text = String(value || "").trim().replace(/\\/g, "/").replace(/^\.\//, ""); @@ -447,2809 +453,55 @@ class UnraidDeploymentService { this.onOperationChange = onOperationChange; } - serverGitRemote(repository, profile) { - const candidates = [repository.sshUrl, profile.cloneUrl, repository.preferredCloneUrl] - .map((value) => String(value || "").trim()) - .filter(Boolean); - const value = candidates.find((candidate) => /^ssh:\/\//i.test(candidate) || /^[^@\s]+@[^:\s]+:.+/.test(candidate)); - if (!value) { - const error = new Error("Server pull requires the repository SSH clone URL from Gitea."); - error.code = "SERVER_GIT_SSH_URL_REQUIRED"; - throw error; - } - return value; - } - serverGitHost(repository, profile) { - const remote = this.serverGitRemote(repository, profile); - if (/^ssh:\/\//i.test(remote)) { - const parsed = new URL(remote); - return { host: parsed.hostname, port: Number(parsed.port || 22) }; - } - const match = remote.match(/^[^@\s]+@([^:\s]+):/); - if (!match) throw new Error("Could not determine the Gitea SSH host from the clone URL."); - return { host: match[1], port: 22 }; - } - serverGitCredentialPaths(repository, server) { - const repositoryId = crypto.createHash("sha256").update(String(repository.fullName).toLowerCase()).digest("hex").slice(0, 24); - const directory = path.join(server.basePath, ".forgeflow", "git-credentials", repositoryId); - return { - directory, - privateKey: path.join(directory, "deploy-key"), - publicKey: path.join(directory, "deploy-key.pub"), - knownHosts: path.join(directory, "known_hosts"), - }; - } - serverGitEnvironment(repository, profile, server) { - const credentials = this.serverGitCredentialPaths(repository, server); - return `GIT_SSH_COMMAND=${shellQuote(`ssh -i ${credentials.privateKey} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${credentials.knownHosts}`)}`; - } - async configureServerGitAccess({ repository, profileId }) { - const { profile, server } = this.resolve(repository, profileId); - const remote = this.serverGitRemote(repository, profile); - const { host, port } = this.serverGitHost(repository, profile); - const credentials = this.serverGitCredentialPaths(repository, server); - const trustedHostFingerprint = String(profile.serverGitAccess?.hostFingerprint || "").trim(); - const marker = "__FORGEFLOW_DEPLOY_KEY__"; - const setupScript = ` -command -v git >/dev/null 2>&1 || { echo "Git is not installed on the server" >&2; exit 41; } -command -v ssh-keygen >/dev/null 2>&1 || { echo "ssh-keygen is not installed on the server" >&2; exit 42; } -command -v ssh-keyscan >/dev/null 2>&1 || { echo "ssh-keyscan is not installed on the server" >&2; exit 43; } -credential_dir=${shellQuote(credentials.directory)} -private_key=${shellQuote(credentials.privateKey)} -public_key=${shellQuote(credentials.publicKey)} -known_hosts=${shellQuote(credentials.knownHosts)} -expected_host_fingerprint=${shellQuote(trustedHostFingerprint)} -mkdir -p "$credential_dir" -chmod 700 "$credential_dir" -if [ ! -s "$private_key" ] || [ ! -s "$public_key" ]; then - rm -f "$private_key" "$public_key" - ssh-keygen -q -t ed25519 -N '' -C ${shellQuote(`forgeflow:${repository.fullName}`)} -f "$private_key" -fi -chmod 600 "$private_key" -chmod 644 "$public_key" -scan_tmp="$known_hosts.$$.tmp" -scan_ok=false -for attempt in 1 2 3; do - ssh-keyscan -T 10 -H -p ${Number(port)} ${shellQuote(host)} > "$scan_tmp" 2>/dev/null || true - if [ -s "$scan_tmp" ]; then scan_ok=true; break; fi - sleep $((attempt * 2)) -done -[ "$scan_ok" = true ] || { rm -f "$scan_tmp"; echo "Gitea SSH host did not return a host key after three attempts" >&2; exit 44; } -scanned_host_fingerprint="$(ssh-keygen -lf "$scan_tmp" -E sha256 2>/dev/null | awk '{print $2}' | sort -u | paste -sd, -)" -if [ -n "$expected_host_fingerprint" ] && [ "$scanned_host_fingerprint" != "$expected_host_fingerprint" ]; then - rm -f "$scan_tmp" - echo "The Gitea SSH host key changed. Verify the Gitea server before replacing trust." >&2 - exit 46 -fi -mv "$scan_tmp" "$known_hosts" -chmod 600 "$known_hosts" -printf '%s\n' ${shellQuote(marker)} -printf 'publicKey=%s\n' "$(base64 < "$public_key" | tr -d '\\r\\n')" -printf 'fingerprint=%s\n' "$(ssh-keygen -lf "$public_key" -E sha256 | awk '{print $2}')" -printf 'hostFingerprint=%s\n' "$scanned_host_fingerprint" -`; - const setup = await this.ssh.exec(server.id, bash(setupScript), { timeout: 60_000, maxOutput: 256 * 1024 }); - const output = String(setup.stdout || ""); - const markerIndex = output.lastIndexOf(marker); - if (markerIndex < 0) throw new Error("The server did not return the generated deploy key."); - const fields = Object.fromEntries(output.slice(markerIndex + marker.length).trim().split(/\r?\n/).map((line) => { - const separator = line.indexOf("="); - return separator > 0 ? [line.slice(0, separator), line.slice(separator + 1)] : [line, ""]; - })); - if (trustedHostFingerprint && fields.hostFingerprint && trustedHostFingerprint !== fields.hostFingerprint) { - const error = new Error("The Gitea SSH host key changed. Server pull was not reconfigured. Verify the Gitea server before replacing trust."); - error.code = "GITEA_SSH_HOST_KEY_MISMATCH"; - throw error; - } - const publicKey = Buffer.from(fields.publicKey || "", "base64").toString("utf8").trim(); - const [owner, repo] = String(repository.fullName || "").split("/"); - if (!owner || !repo) throw new Error("A full Gitea repository name is required to configure server pull."); - const deployKey = await this.gitea.ensureReadOnlyDeployKey({ - owner, - repo, - title: `ForgeFlow · ${server.name} · read-only`, - publicKey, - }); - const probeCommand = `${this.serverGitEnvironment(repository, profile, server)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)}`; - const probe = await this.ssh.exec( - server.id, - bash(`probe_error='' -for attempt in 1 2 3; do - if probe_output=$(${probeCommand} 2>&1); then printf '%s\n' "$probe_output"; exit 0; fi - probe_error=$probe_output - sleep $((attempt * 2)) -done -printf '%s\n' "$probe_error" >&2 -exit 45`), - { timeout: 45_000, maxOutput: 256 * 1024 }, - ); - const remoteSha = String(probe.stdout || "").trim().split(/\s+/)[0] || null; - const updated = await this.store.saveDeploymentProfile(repository.fullName, { - ...profile, - deploymentMode: "server-git", - cloneUrl: remote, - serverGitAccess: { - configured: true, - deployKeyId: deployKey.id || null, - keyFingerprint: fields.fingerprint || null, - hostFingerprint: fields.hostFingerprint || null, - configuredAt: new Date().toISOString(), - }, - }); - return { - profile: updated, - created: deployKey.created === true, - remoteSha, - keyFingerprint: fields.fingerprint || null, - hostFingerprint: fields.hostFingerprint || null, - }; - } - async probeServerGitAccess({ repository, profile, server }) { - try { - const remote = this.serverGitRemote(repository, profile); - const credentials = this.serverGitCredentialPaths(repository, server); - const trustedHostFingerprint = String(profile.serverGitAccess?.hostFingerprint || "").trim(); - const trustedKeyFingerprint = String(profile.serverGitAccess?.keyFingerprint || "").trim(); - const command = `[ -s ${shellQuote(credentials.privateKey)} ] && [ -s ${shellQuote(credentials.publicKey)} ] && [ -s ${shellQuote(credentials.knownHosts)} ] && actual_host_fingerprint="$(ssh-keygen -lf ${shellQuote(credentials.knownHosts)} -E sha256 2>/dev/null | awk '{print $2}' | sort -u | paste -sd, -)" && actual_key_fingerprint="$(ssh-keygen -lf ${shellQuote(credentials.publicKey)} -E sha256 2>/dev/null | awk '{print $2}')" && { [ -z ${shellQuote(trustedHostFingerprint)} ] || [ "$actual_host_fingerprint" = ${shellQuote(trustedHostFingerprint)} ]; } && { [ -z ${shellQuote(trustedKeyFingerprint)} ] || [ "$actual_key_fingerprint" = ${shellQuote(trustedKeyFingerprint)} ]; } && remote_output="$(${this.serverGitEnvironment(repository, profile, server)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)})" && remote_sha="$(printf '%s' "$remote_output" | awk 'NR==1 {print $1}')" && printf '__FORGEFLOW_SERVER_GIT_PROBE__\nremoteSha=%s\nkeyFingerprint=%s\nhostFingerprint=%s\n' "$remote_sha" "$actual_key_fingerprint" "$actual_host_fingerprint"`; - const result = await this.ssh.exec(server.id, bash(command), { timeout: 45_000, maxOutput: 256 * 1024 }); - const output = String(result.stdout || ""); - const marker = output.lastIndexOf("__FORGEFLOW_SERVER_GIT_PROBE__"); - if (marker < 0) throw new Error("The server pull probe did not return verifiable fingerprint evidence."); - const fields = Object.fromEntries(output.slice(marker + "__FORGEFLOW_SERVER_GIT_PROBE__".length).trim().split(/\r?\n/).map((line) => { - const separator = line.indexOf("="); - return separator > 0 ? [line.slice(0, separator), line.slice(separator + 1)] : [line, ""]; - })); - return { ready: true, remoteSha: fields.remoteSha || null, keyFingerprint: fields.keyFingerprint || null, hostFingerprint: fields.hostFingerprint || null }; - } catch (error) { - return { ready: false, error: error.message }; - } - } - async verifyServerGitProfile({ repository, profileId }) { - const { profile, server, remotePath } = this.resolve(repository, profileId); - const checks = []; - const add = (id, label, status, detail, evidence = {}) => checks.push({ id, label, status, detail, evidence }); - if (profile.deploymentMode === "monitor-only") { - add("mode", "Deployment mode", "warning", "This profile is monitoring only and cannot deploy."); - return { readiness: "Monitoring only", ready: false, checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, checks }; - } - if (profile.deploymentMode !== "server-git") { - add("mode", "Deployment mode", "unsupported", "Read-only server-pull verification applies only to Server pull profiles."); - return { readiness: "Unsupported", ready: false, checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, checks }; - } - let branchSha = null; - try { - const [owner, repo] = String(repository.fullName || "").split("/"); - const branch = await this.gitea.getBranch(owner, repo, profile.branch); - branchSha = branch?.commit?.id || branch?.commit?.sha || null; - add("remote-branch", "Gitea branch", branchSha ? "pass" : "fail", branchSha ? `${profile.branch} at ${branchSha}` : `${profile.branch} did not return a commit SHA.`, { branch: profile.branch, sha: branchSha }); - const keys = await this.gitea.listDeployKeys(owner, repo); - const keyId = Number(profile.serverGitAccess?.deployKeyId); - const key = keys.find((item) => Number(item.id) === keyId); - add("deploy-key-scope", "Repository deploy key", key?.read_only === true ? "pass" : "fail", !key ? "The configured deploy key is no longer present in Gitea." : key.read_only === true ? `Key ${key.id} is repository-scoped and read-only.` : `Key ${key.id} has write access and is blocked.`, { keyId: key?.id || keyId || null, readOnly: key?.read_only === true }); - } catch (error) { - add("gitea-access", "Gitea verification", "fail", error.message); - } - const access = await this.probeServerGitAccess({ repository, profile, server }); - add("server-git-access", "Unraid to Gitea", access.ready ? "pass" : "fail", access.ready ? `Exact branch access verified at ${String(access.remoteSha || "unknown").slice(0, 12)}.` : access.error, access); - let inspection = null; - try { - inspection = await this.inspect({ repository, profileId }); - const expectedCompose = profile.generatedCompose ? [".forgeflow/compose.forgeflow.yml"] : this.deploymentComposeFiles(profile); - const composePresent = !inspection.exists || expectedCompose.every((file) => inspection.composeFiles.includes(file)); - add("deployment-directory", "Deployment directory", inspection.exists ? "pass" : "warning", inspection.exists ? remotePath : `${remotePath} will be created on first deployment.`, { remotePath, exists: inspection.exists }); - add("compose", "Compose configuration", composePresent ? "pass" : "warning", composePresent ? expectedCompose.join(", ") : `Expected after deployment: ${expectedCompose.join(", ")}.`, { files: expectedCompose }); - add("preserved-paths", "Preserved runtime paths", "pass", (profile.preservePaths || []).length ? profile.preservePaths.join(", ") : "No preserved runtime paths configured.", { paths: profile.preservePaths || [] }); - add("environment-requirements", "Environment requirements", "pass", (profile.detectedMetadata?.envNames || []).length ? `${profile.detectedMetadata.envNames.length} variable name(s) detected; values remain hidden.` : "No environment variable names were detected in server metadata.", { names: profile.detectedMetadata?.envNames || [] }); - } catch (error) { - add("server-inspection", "Server inspection", "fail", error.message); - } - const state = this.store.getDeploymentState(profile.id) || {}; - const liveSha = state.liveSha || inspection?.head || null; - const running = state.containerRunning; - const healthy = state.healthy; - add("live-commit", "Live server commit", liveSha ? "pass" : "warning", liveSha || "No verifiable live commit is currently recorded.", { 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("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 failed = checks.some((item) => item.status === "fail"); - 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"; - 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 }; - } - - permissionTargets(profile, server, remotePath) { - const targets = [ - { - id: "server-base", - label: "Configured deployment base", - path: server.basePath, - kind: "directory", - required: false, - }, - { - id: "project-root", - label: "Project folder", - path: remotePath, - kind: "directory", - required: true, - }, - { - id: "forgeflow-state", - label: "ForgeFlow upload and rollback storage", - path: path.join(remotePath, ".forgeflow"), - kind: "directory", - required: true, - }, - { - id: "forgeflow-incoming", - label: "ForgeFlow incoming upload folder", - path: path.join(remotePath, ".forgeflow", "incoming"), - kind: "directory", - required: true, - }, - ]; - if (!profile.generatedCompose) { - for (const file of this.deploymentComposeFiles(profile)) { - targets.push({ - id: `compose:${file}`, - label: `Compose file ${file}`, - path: path.join(remotePath, file), - kind: "file", - required: true, - }); - } - } - const unique = new Map(); - for (const target of targets) unique.set(`${target.kind}:${target.path}`, target); - return [...unique.values()]; - } - - permissionInspectionScript(profile, server, remotePath) { - const targetCalls = this.permissionTargets(profile, server, remotePath) - .map( - (target) => - `probe ${shellQuote(target.id)} ${shellQuote(target.label)} ${shellQuote(target.path)} ${shellQuote(target.kind)} ${target.required ? "true" : "false"}`, - ) - .join("\n"); - return ` -encode() { printf '%s' "$1" | base64 | tr -d '\\r\\n'; } -can_elevate=false -[ "$(id -u)" = 0 ] && can_elevate=true -if [ "$can_elevate" != true ] && command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then can_elevate=true; fi -has_acl=false -command -v setfacl >/dev/null 2>&1 && has_acl=true -printf '__FORGEFLOW_PERMISSIONS__\\n' -printf 'I\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \ - "$(encode "$(id -un 2>/dev/null || echo unknown)")" \ - "$(id -u 2>/dev/null || echo -1)" \ - "$(id -g 2>/dev/null || echo -1)" \ - "$(encode "$(id -Gn 2>/dev/null || true)")" \ - "$has_acl" "$can_elevate" -probe() { - target_id=$1 - label=$2 - target=$3 - kind=$4 - required=$5 - exists=false; readable=false; writable=false; parent_writable=false; effective=false - owner=''; group=''; mode=''; detail=''; nearest='' - if [ -e "$target" ] || [ -L "$target" ]; then - exists=true - [ -r "$target" ] && readable=true - [ -w "$target" ] && writable=true - owner=$(stat -c '%U' "$target" 2>/dev/null || true) - group=$(stat -c '%G' "$target" 2>/dev/null || true) - mode=$(stat -c '%a' "$target" 2>/dev/null || true) - fi - parent=$(dirname "$target") - ancestor=$parent - while [ ! -d "$ancestor" ] && [ "$ancestor" != / ]; do ancestor=$(dirname "$ancestor"); done - nearest=$ancestor - marker="$ancestor/.forgeflow-write-test-$$-\${RANDOM:-0}" - if [ -d "$ancestor" ] && (umask 077; : > "$marker") 2>/dev/null; then - rm -f -- "$marker" >/dev/null 2>&1 || true - parent_writable=true - fi - if [ "$kind" = directory ]; then - if [ -d "$target" ]; then - marker="$target/.forgeflow-write-test-$$-\${RANDOM:-0}" - if (umask 077; : > "$marker") 2>/dev/null; then - rm -f -- "$marker" >/dev/null 2>&1 || true - effective=true - fi - elif [ "$parent_writable" = true ]; then - effective=true - fi - else - if [ "$exists" = true ] && [ ! -f "$target" ]; then - detail='Path exists but is not a regular file.' - elif [ "$exists" = true ] && [ "$readable" = true ] && { [ "$writable" = true ] || [ "$parent_writable" = true ]; }; then - effective=true - elif [ "$exists" = false ] && [ "$parent_writable" = true ]; then - effective=true - detail='File is absent but can be created by the deployment user.' - fi - fi - if [ -z "$detail" ]; then - if [ "$effective" = true ]; then detail='Read/write probe passed.' - else detail="No safe create/replace access for $(id -un 2>/dev/null || echo 'the SSH user')."; fi - fi - printf 'P\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \ - "$(encode "$target_id")" "$(encode "$label")" "$(encode "$target")" "$kind" "$required" \ - "$exists" "$readable" "$writable" "$parent_writable" "$effective" \ - "$(encode "$owner")" "$(encode "$group")" "$mode" "$(encode "$nearest")" "$(encode "$detail")" } -${targetCalls} -`; - } - async inspectWriteAccess({ repository, profileId }) { - const { profile, server, remotePath } = this.resolve(repository, profileId); - const result = await this.ssh.exec( - server.id, - bash(this.permissionInspectionScript(profile, server, remotePath)), - { timeout: 45_000, maxOutput: 2 * 1024 * 1024 }, - ); - const report = parsePermissionInspection(result.stdout); - report.serverId = server.id; - report.remotePath = remotePath; - return report; - } - - permissionRepairScript(profile, server, remotePath) { - const preserve = [ - ".git", - "node_modules", - ".venv", - "venv", - "__pycache__", - ...(profile.preservePaths || []), - ] - .map((value) => safeRelativeRemoteFile(value)) - .filter(Boolean); - const pruneExpression = preserve.length - ? preserve - .map((value) => `-path ${shellQuote(path.join(remotePath, value))} -o -path ${shellQuote(path.join(remotePath, value, "*"))}`) - .join(" -o ") - : "-false"; - const composePaths = this.deploymentComposeFiles(profile) - .map((file) => shellQuote(path.join(remotePath, file))) - .join(" "); - return ` -root=${shellQuote(remotePath)} -base=${shellQuote(server.basePath)} -case "$root" in "$base"|"$base"/*) ;; *) echo "Refusing permission repair outside configured base path: $root" >&2; exit 81 ;; esac -run_privileged() { - if [ "$(id -u)" = 0 ]; then "$@"; - elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then sudo -n "$@"; - else "$@"; - fi +const stateMethods = createUnraidStateMethods({ path, bash, shellQuote, inventoryRemoteIdentity }); +for (const name of Object.getOwnPropertyNames(stateMethods)) { + if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(stateMethods, name)); } -mkdir_cmd=mkdir -if ! mkdir -p "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null; then - run_privileged mkdir -p "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" -fi -share_group=$(getent group users >/dev/null 2>&1 && echo users || id -gn) -if command -v setfacl >/dev/null 2>&1; then - run_privileged setfacl -m "u:$(id -un):rwx,g:$share_group:rwx,m:rwx" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true - run_privileged setfacl -d -m "u:$(id -un):rwx,g:$share_group:rwx,m:rwx" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true -fi -run_privileged chgrp "$share_group" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true -run_privileged chmod 2775 "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" -if [ -d "$root" ]; then - while IFS= read -r -d '' entry; do - case "$entry" in - "$root/.forgeflow"|"$root/.forgeflow"/*) continue ;; - esac - run_privileged chgrp "$share_group" "$entry" 2>/dev/null || true - if [ -d "$entry" ]; then run_privileged chmod u+rwx,g+rwx,g+s "$entry"; else run_privileged chmod u+rw,g+rw "$entry"; fi - done < <(find "$root" -mindepth 1 \\( ${pruneExpression} \\) -prune -o -print0) -fi -for compose_file in ${composePaths || ""}; do - [ -e "$compose_file" ] || continue - run_privileged chgrp "$share_group" "$compose_file" 2>/dev/null || true - run_privileged chmod u+rw,g+rw "$compose_file" -done -echo "ForgeFlow repaired project write access for $(id -un) and group $share_group without changing preserved runtime paths." -`; - } - async repairWriteAccess({ repository, profileId }) { - const { profile, server, remotePath } = this.resolve(repository, profileId); - const before = await this.inspectWriteAccess({ repository, profileId }); - await this.ssh.exec(server.id, bash(this.permissionRepairScript(profile, server, remotePath)), { - timeout: 5 * 60_000, - maxOutput: 4 * 1024 * 1024, - }); - const after = await this.inspectWriteAccess({ repository, profileId }); - if (!after.ready) { - const error = new Error( - `Write-access repair did not make every required path writable: ${after.blocking.map((item) => item.path).join(", ")}`, - ); - error.code = "WRITE_ACCESS_REPAIR_INCOMPLETE"; - error.permissionReport = after; - throw error; - } - await this.diagnostics?.info("unraid.write-access.repaired", { - repository: repository.fullName, - profileId, - serverId: server.id, - remotePath, - user: after.identity.user, - }); - return { changed: true, normalized: true, before, after }; - } - - inventoryScript(server) { - const configuredRoots = [...new Set([server.basePath, ...(server.scanRoots || [])])].map((root) => ` add_scan_root ${shellQuote(root)}`).join("\n"); - const configuredExcludes = (server.scanExcludes || []).map((name) => ` -o -name ${shellQuote(name)}`).join(""); - return ` -base=${shellQuote(server.basePath)} -platform=$(uname -srm 2>/dev/null || true) -docker_ok=false; compose_ok=false; compose_v2=false; git_ok=false; tar_ok=false; checksum_ok=false; base_writable=false; compose_version='' -command -v docker >/dev/null 2>&1 && docker_ok=true -if [ "$docker_ok" = true ]; then - if docker compose version >/dev/null 2>&1; then compose_ok=true; compose_v2=true; compose_version=$(docker compose version 2>/dev/null | head -n1); elif command -v docker-compose >/dev/null 2>&1; then compose_ok=true; compose_version=$(docker-compose version 2>/dev/null | head -n1); fi -fi -command -v git >/dev/null 2>&1 && git_ok=true -command -v tar >/dev/null 2>&1 && tar_ok=true -(command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1) && checksum_ok=true -if [ -d "$base" ]; then [ -w "$base" ] && base_writable=true; else parent=$(dirname "$base"); [ -d "$parent" ] && [ -w "$parent" ] && base_writable=true; fi -printf '__FORGEFLOW_INVENTORY__\\n' -printf 'H\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' "$docker_ok" "$compose_ok" "$git_ok" "$tar_ok" "$checksum_ok" "$base_writable" "$(printf '%s' "$compose_version" | base64 | tr -d '\\r\\n')" "$(printf '%s' "$platform" | base64 | tr -d '\\r\\n')" -ids='' -if [ "$docker_ok" != true ]; then - printf 'W\\t%s\\n' "$(printf '%s' 'Docker is not installed or not in PATH. Compose files and DockerMan templates will still be scanned.' | base64 | tr -d '\\r\\n')" -else - if ! ids=$(docker ps -aq --no-trunc 2>&1); then - printf 'W\\t%s\\n' "$(printf '%s' "Docker inventory failed: $ids. Compose files and DockerMan templates will still be scanned." | head -c 2000 | base64 | tr -d '\\r\\n')" - ids='' - fi -fi -if [ -n "$ids" ]; then - disappeared=0 - while IFS= read -r container_id; do - [ -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 - printf 'C\\t%s\\n' "$(printf '%s' "$inspect" | base64 | tr -d '\\r\\n')" - else - disappeared=$((disappeared + 1)) - fi - done <<< "$ids" - if [ "$disappeared" -gt 0 ]; then - printf 'W\\t%s\\n' "$(printf '%s' "$disappeared stale container reference(s) disappeared during inventory; current containers were still processed." | base64 | tr -d '\\r\\n')" - fi -fi -templates_dir=/boot/config/plugins/dockerMan/templates-user -if [ -d "$templates_dir" ]; then - find "$templates_dir" -maxdepth 1 -type f -name '*.xml' -print0 2>/dev/null | while IFS= read -r -d '' template; do - read_tag() { sed -n "s#.*<$1>\\(.*\\).*#\\1#p" "$template" | head -n1; } - name=$(read_tag Name) - [ -n "$name" ] || continue - printf 'D\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \\ - "$(printf '%s' "$name" | base64 | tr -d '\\r\\n')" \\ - "$(printf '%s' "$template" | base64 | tr -d '\\r\\n')" \\ - "$(printf '%s' "$(read_tag WebUI)" | base64 | tr -d '\\r\\n')" \\ - "$(printf '%s' "$(read_tag Icon)" | base64 | tr -d '\\r\\n')" \\ - "$(printf '%s' "$(read_tag Shell)" | base64 | tr -d '\\r\\n')" \\ - "$(printf '%s' "$(read_tag Repository)" | base64 | tr -d '\\r\\n')" \\ - "$(printf '%s' "$(read_tag Network)" | base64 | tr -d '\\r\\n')" - done -fi -if [ "$compose_ok" = true ]; then - compose_projects=$(docker compose ls --all --format json 2>/dev/null || docker-compose ls --all --format json 2>/dev/null || true) - if [ -n "$compose_projects" ]; then - printf 'P\\t%s\\n' "$(printf '%s' "$compose_projects" | base64 | tr -d '\\r\\n')" - fi - -fi - - scan_roots=() - add_scan_root() { - candidate=$1 - [ -d "$candidate" ] || return 0 - for existing in "\${scan_roots[@]}"; do [ "$existing" = "$candidate" ] && return 0; done - scan_roots+=("$candidate") - } -${configuredRoots} - - for root in "\${scan_roots[@]}"; do - scan_error=$(mktemp) - while IFS= read -r -d '' primary; do - dir=$(dirname "$primary") - filename=$(basename "$primary") - case "$filename" in - compose.override.yml|compose.override.yaml|docker-compose.override.yml|docker-compose.override.yaml) continue ;; - compose.yml) ;; - compose.yaml) [ -f "$dir/compose.yml" ] && continue ;; - docker-compose.yml) { [ -f "$dir/compose.yml" ] || [ -f "$dir/compose.yaml" ]; } && continue ;; - docker-compose.yaml) { [ -f "$dir/compose.yml" ] || [ -f "$dir/compose.yaml" ] || [ -f "$dir/docker-compose.yml" ]; } && continue ;; - *) { [ -f "$dir/compose.yml" ] || [ -f "$dir/compose.yaml" ] || [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/docker-compose.yaml" ]; } && continue ;; - esac - ( - set -- -f "$primary" - files_text=$primary - for extra in "$dir/compose.override.yml" "$dir/compose.override.yaml" "$dir/docker-compose.override.yml" "$dir/docker-compose.override.yaml"; do - [ -f "$extra" ] || continue - set -- "$@" -f "$extra" - files_text="$files_text -$extra" - done - project_name=$(sed -n 's/^name:[[:space:]]*//p' "$primary" 2>/dev/null | head -n1 | cut -d'#' -f1 | tr -d '"' | tr -d "'" | xargs 2>/dev/null || true) - [ -n "$project_name" ] || project_name=$(basename "$dir") - valid=false; services=''; images=''; compose_error='' - if [ "$compose_ok" != true ]; then - compose_error='Docker Compose is unavailable; file metadata was still detected.' - elif [ "$compose_v2" = true ]; then - if services=$(cd "$dir" && docker compose "$@" config --services 2>&1); then - valid=true - images=$(cd "$dir" && docker compose "$@" config --images 2>/dev/null || true) - else compose_error=$services; services=''; fi - else - if services=$(cd "$dir" && docker-compose "$@" config --services 2>&1); then - valid=true - images=$(cd "$dir" && docker-compose "$@" config --images 2>/dev/null || true) - else compose_error=$services; services=''; fi - fi - if [ -z "$services" ]; then - services=$(awk ' - /^[[:space:]]*services:[[:space:]]*($|#)/ { in_services=1; next } - in_services && /^[^[:space:]]/ { exit } - in_services && /^ [A-Za-z0-9._-]+:[[:space:]]*($|#)/ { - line=$0; sub(/^[[:space:]]*/, "", line); sub(/:.*/, "", line); print line - } - ' "$primary" 2>/dev/null || true) - fi - if [ -z "$images" ]; then - images=$(awk ' - /^[[:space:]]*services:[[:space:]]*($|#)/ { in_services=1; next } - in_services && /^[^[:space:]]/ { exit } - in_services && /^[[:space:]]+image:[[:space:]]*/ { - line=$0; sub(/^[[:space:]]*image:[[:space:]]*/, "", line); sub(/[[:space:]]+#.*/, "", line); gsub(/"/, "", line); print line - } - ' "$primary" 2>/dev/null || true) - fi - printf 'Y\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \\ - "$(printf '%s' "$dir" | base64 | tr -d '\\r\\n')" \\ - "$(printf '%s' "$files_text" | base64 | tr -d '\\r\\n')" \\ - "$(printf '%s' "$project_name" | base64 | tr -d '\\r\\n')" \\ - "$(printf '%s' "$services" | base64 | tr -d '\\r\\n')" \\ - "$(printf '%s' "$images" | base64 | tr -d '\\r\\n')" \\ - "$valid" \\ - "$(printf '%s' "$compose_error" | head -c 2000 | base64 | tr -d '\\r\\n')" - ) - done < <(find "$root" -mindepth 2 -maxdepth 4 \\( -type d \\( -name .git -o -name node_modules -o -name .forgeflow -o -name releases -o -name backups -o -name staging -o -name incoming -o -name '_audit_quarantine' -o -name 'devrunbook-validation' -o -name 'source-pre-*' -o -name cache -o -name caches -o -name logs -o -name database -o -name databases${configuredExcludes} \\) -prune \\) -o \\( -type f \\( -name '*compose*.yml' -o -name '*compose*.yaml' -o -name 'stack.yml' -o -name 'stack.yaml' \\) -print0 \\) 2>"$scan_error" || true) - if [ -s "$scan_error" ]; then - scan_message=$(printf 'Inventory scan partially failed for %s: %s' "$root" "$(head -n 1 "$scan_error")") - printf 'W\\t%s\\n' "$(printf '%s' "$scan_message" | base64 | tr -d '\\r\\n')" - fi - rm -f "$scan_error" - done -`; - } - - allSshProfiles() { - const result = []; - for (const [repositoryFullName, profiles] of Object.entries(this.store.data?.deploymentProfiles || {})) { - for (const profile of profiles || []) { - if (profile?.provider === "ssh-unraid") result.push({ ...profile, _repositoryFullName: repositoryFullName }); - } - } - return result; - } - - relativeComposeFiles(workload) { - const workingDir = String(workload.compose?.workingDir || "").replace(/\/+$/, ""); - const files = (workload.compose?.configFiles || []).map((file) => { - const value = String(file || "").trim(); - if (workingDir && value.startsWith(`${workingDir}/`)) return value.slice(workingDir.length + 1); - return value.startsWith("/") ? path.basename(value) : value; - }).filter(Boolean); - return [...new Set(files.length ? files : ["docker-compose.yml"])]; - } - - profileFromWorkload(repository, server, workload, { linkSource = "manual", deploymentMode = "server-git", remoteFolder = "" } = {}) { - const effectiveDeploymentMode = ["push-bundle", "server-git", "monitor-only"].includes(deploymentMode) - ? deploymentMode - : "server-git"; - const selectedFolder = safeRemoteFolder(remoteFolder || workload.remoteFolderCandidate || repository.name); - const composeFiles = this.relativeComposeFiles(workload); - const services = [...new Set((workload.compose?.services || []) - .map((service) => String(service || "").trim().toLowerCase().replace(/[^a-z0-9._-]/g, "-")) - .filter(Boolean))]; - const primary = workload.containers.find((container) => container.running) || workload.containers[0] || {}; - const primaryPort = (primary.ports || []).find((item) => item.hostPort) || primary.ports?.[0] || {}; - const remotePath = path.join(server.basePath, selectedFolder); - const preservePaths = new Set([".env", "appdata", "data", "logs", "config", "compose.override.yml"]); - for (const container of workload.containers || []) { - for (const mount of container.mounts || []) { - const source = String(mount.source || ""); - if (!source.startsWith(`${remotePath}/`)) continue; - const relative = source.slice(remotePath.length + 1).split("/")[0]; - if (relative) preservePaths.add(relative); - } - } - const idPrefix = String(linkSource).startsWith("automatic") ? "auto" : "link"; - const profileId = `${idPrefix}-${crypto.createHash("sha256").update(`${server.id}:${repository.fullName}:${workload.workloadId}`).digest("hex").slice(0, 20)}`; - return { - id: profileId, - name: `${server.name} · ${workload.displayName}`, - environment: "production", - provider: "ssh-unraid", - branch: workload.metadata?.branch || repository.defaultBranch || "main", - serverId: server.id, - remoteFolder: selectedFolder, - deploymentMode: effectiveDeploymentMode, - composeFile: composeFiles[0], - composeFiles, - composeProject: workload.compose?.project || "", - composeWorkingDir: workload.compose?.workingDir || "", - composeService: services[0] || String(primary.service || selectedFolder.split("/").pop()).toLowerCase().replace(/[^a-z0-9._-]/g, "-") || "app", - composeServices: services.length ? services : [String(primary.service || selectedFolder.split("/").pop()).toLowerCase().replace(/[^a-z0-9._-]/g, "-") || "app"], - containerName: primary.name || selectedFolder.split("/").pop(), - cloneUrl: workload.metadata?.sourceRepository || repository.sshUrl || repository.cloneUrl || "", - alignRemote: false, - hostPort: primaryPort.hostPort || null, - containerPort: primaryPort.containerPort || null, - webUiUrl: workload.metadata?.webUiUrl || workload.dockerMan?.webUiUrl || "", - iconMode: "none", - iconUrl: "", - iconFilePath: "", - serverIconReference: workload.metadata?.iconUrl || workload.dockerMan?.iconUrl || "", - dockerShell: ["/bin/bash", "/bin/sh"].includes(workload.metadata?.shell) ? workload.metadata.shell : "/bin/sh", - preservePaths: [...preservePaths], - generatedCompose: false, - adoptedFromServer: true, - serverSourceOfTruth: true, - manageDockerMan: false, - forceRecreate: false, - removeOrphans: false, - workloadIdentity: { - workloadId: workload.workloadId, - selector: workload.selector, - linkSource, - linkedAt: new Date().toISOString(), - }, - detectedAt: new Date().toISOString(), - detectedMetadata: { - source: `${linkSource}-server-inventory`, - kind: workload.kind, - composeProject: workload.compose?.project || "", - composeFiles, - services, - image: primary.image || "", - dockerManTemplatePath: workload.dockerMan?.templatePath || "", - }, - confirmationRequired: !String(linkSource).startsWith("automatic"), - }; - } - - refreshedProfileFromWorkload(repository, server, workload, existingProfile) { - const detected = this.profileFromWorkload(repository, server, workload, { - linkSource: existingProfile.workloadIdentity?.linkSource || "automatic-compose", - deploymentMode: ["push-bundle", "server-git", "monitor-only"].includes(existingProfile.deploymentMode) - ? existingProfile.deploymentMode - : "push-bundle", - remoteFolder: workload.remoteFolderCandidate || existingProfile.remoteFolder, - }); - return { - ...existingProfile, - deploymentMode: detected.deploymentMode, - remoteFolder: detected.remoteFolder, - composeFile: detected.composeFile, - composeFiles: detected.composeFiles, - composeProject: detected.composeProject, - composeWorkingDir: detected.composeWorkingDir, - composeService: detected.composeService, - composeServices: detected.composeServices, - containerName: detected.containerName || existingProfile.containerName, - hostPort: detected.hostPort || existingProfile.hostPort || null, - containerPort: detected.containerPort || existingProfile.containerPort || null, - webUiUrl: detected.webUiUrl || existingProfile.webUiUrl || "", - serverIconReference: detected.serverIconReference || existingProfile.serverIconReference || "", - dockerShell: detected.dockerShell || existingProfile.dockerShell || "/bin/sh", - preservePaths: [...new Set([...(existingProfile.preservePaths || []), ...(detected.preservePaths || [])])], - generatedCompose: false, - adoptedFromServer: true, - serverSourceOfTruth: true, - manageDockerMan: false, - forceRecreate: false, - removeOrphans: false, - workloadIdentity: detected.workloadIdentity, - detectedAt: detected.detectedAt, - detectedMetadata: detected.detectedMetadata, - }; - } - - async saveWorkloadState(profile, workload, server) { - const candidateSha = String(workload.metadata?.liveRevision || ""); - const previousState = this.store.getDeploymentState?.(profile.id) || {}; - const observedLiveSha = /^[0-9a-f]{40,64}$/i.test(candidateSha) ? candidateSha.toLowerCase() : null; - const liveSha = observedLiveSha || previousState.liveSha || null; - const profileRemote = inventoryRemoteIdentity(profile.cloneUrl); - const workloadRemote = inventoryRemoteIdentity(workload.metadata?.sourceRepository); - const repositoryMatches = Boolean(observedLiveSha && profileRemote && workloadRemote && profileRemote === workloadRemote); - const primary = workload.containers.find((container) => container.running) || workload.containers[0] || {}; - return this.store.saveDeploymentState(profile.id, { - liveSha, - healthy: workload.runtime.health === "healthy" ? true : workload.runtime.health === "unhealthy" ? false : null, - runtimeVerification: workload.runtime.health === "unverified" ? "running-unverified" : workload.runtime.health, - containerRunning: workload.runtime.running, - dockerHealth: primary.health || null, - containerName: primary.name || profile.containerName, - remotePath: path.join(server.basePath, profile.remoteFolder), - provider: "ssh-unraid", - workloadId: workload.workloadId, - composeProject: workload.compose?.project || null, - observedAt: workload.observedAt, - evidence: liveSha ? "container-provenance-label" : "runtime-only", - giteaSha: repositoryMatches ? observedLiveSha : previousState.giteaSha || null, - matchesGitea: repositoryMatches ? true : previousState.matchesGitea === true && previousState.liveSha === liveSha, - previousSha: previousState.previousSha || null, - }); - } - - async collectServerInventory(serverId, repositories) { - const server = this.store.getServer(serverId); - if (!server) throw new Error("The deployment server no longer exists."); - const result = await this.ssh.exec(server.id, bash(this.inventoryScript(server)), { - timeout: 180_000, - maxOutput: 64 * 1024 * 1024, - }); - const inventory = parseWorkloadInventory(result.stdout); - const profiles = this.allSshProfiles(); - const detectedWorkloads = buildWorkloadInventory({ - inventory, - server, - repositories, - profiles, - }); - const detectedIds = new Set(detectedWorkloads.map((item) => item.workloadId)); - const staleLinks = profiles.filter((profile) => profile.serverId === serverId && profile.workloadIdentity?.workloadId && !detectedIds.has(profile.workloadIdentity.workloadId)).map((profile) => ({ - workloadId: profile.workloadIdentity.workloadId, - serverId, - displayName: profile.name || profile.remoteFolder || profile._repositoryFullName, - status: "stale", - link: { profileId: profile.id, repositoryFullName: profile._repositoryFullName }, - compose: { project: profile.composeProject || "", workingDir: profile.composeWorkingDir || path.join(server.basePath, profile.remoteFolder || ""), configFiles: profile.composeFiles || [profile.composeFile].filter(Boolean), services: profile.composeServices || [profile.composeService].filter(Boolean) }, - containers: [], - runtime: { running: false, health: "missing" }, - metadata: { sourceRepository: profile.cloneUrl || "", liveRevision: "", branch: profile.branch || "", staleLink: true }, - candidates: [{ repositoryFullName: profile._repositoryFullName, repositoryName: profile._repositoryFullName.split("/").pop(), score: 100, exact: true, reasons: ["persisted deployment profile"] }], - remoteFolderCandidate: profile.remoteFolder || "", - observedAt: new Date().toISOString(), - })); - const workloads = classifyInventory([...detectedWorkloads, ...staleLinks], profiles, this.store.getInventoryReviewDecisions?.(serverId) || []); - return { server, inventory, workloads }; - } - - inventoryResponse(server, inventory, workloads, changes = {}) { - const summary = { - serverId: server.id, - serverName: server.name, - detected: workloads.length, - adopted: Number(changes.adopted || 0), - refreshed: Number(changes.refreshed || 0), - retired: Number(changes.retired || 0), - staleProfiles: Array.isArray(changes.staleProfiles) ? changes.staleProfiles : [], - verified: workloads.filter((item) => item.runtime.health === "healthy" && item.link).length, - linked: workloads.filter((item) => item.status === "linked").length, - unmatched: workloads.filter((item) => !item.link).length, - needsReview: workloads.filter((item) => !item.reviewDecision && (["suggested", "ambiguous", "unmatched", "duplicate", "stale"].includes(item.status) || ["orphan-container", "historical-compose", "stale-link"].includes(item.classification?.type))).length, - duplicates: workloads.filter((item) => item.classification?.type === "duplicate").length, - excluded: workloads.filter((item) => ["system-container", "backup", "release-folder", "historical-compose", "manually-excluded"].includes(item.classification?.type)).length, - running: workloads.filter((item) => item.runtime.running).length, - stopped: workloads.filter((item) => !item.runtime.running).length, - }; - return { - ...summary, - server: { id: server.id, name: server.name, host: server.host, basePath: server.basePath }, - capabilities: inventory.capabilities, - warnings: inventory.warnings, - workloads, - observedAt: new Date().toISOString(), - }; - } - - async scanServerInventory(serverId, repositories) { - const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories); - const response = this.inventoryResponse(server, inventory, workloads); - await this.diagnostics?.info("unraid.workloads.scanned", { - serverId, - detected: response.detected, - linked: response.linked, - needsReview: response.needsReview, - readOnly: true, - }); - return response; - } - - reconciliationPlan(server, workloads, repositories, { autoLink = true } = {}) { - const profiles = this.allSshProfiles().filter((profile) => profile.serverId === server.id); - const activeWorkloadIds = new Set(workloads.filter((item) => item.classification?.type !== "stale-link").map((item) => item.workloadId)); - const linkedRepositories = new Set(workloads.filter((item) => item.link?.repositoryFullName).map((item) => String(item.link.repositoryFullName).toLowerCase())); - const additions = []; - const updates = []; - const conflicts = []; - for (const workload of workloads) { - if (["duplicate", "backup", "release-folder", "historical-compose", "system-container", "manually-excluded", "stale-link"].includes(workload.classification?.type)) { - if (!workload.reviewDecision && ["duplicate", "historical-compose", "stale-link"].includes(workload.classification?.type)) conflicts.push({ workloadId: workload.workloadId, displayName: workload.displayName, status: workload.classification.type, reason: workload.classification.reason, candidates: (workload.candidates || []).slice(0, 5).map((item) => ({ repositoryFullName: item.repositoryFullName, score: item.score, exact: item.exact === true })) }); - continue; - } - if (workload.link?.profileId && workload.link?.repositoryFullName) { - updates.push({ - workloadId: workload.workloadId, - profileId: workload.link.profileId, - repositoryFullName: workload.link.repositoryFullName, - impact: "Refresh detected Compose identity and observed deployment state", - }); - continue; - } - const candidate = workload.candidates?.[0]; - const unique = workload.candidates?.length === 1; - const exact = unique && (candidate?.exact === true || (candidate?.identityExact === true && candidate.score >= 70)); - if (autoLink && exact && workload.runtime?.running && !linkedRepositories.has(String(candidate.repositoryFullName).toLowerCase())) { - additions.push({ - workloadId: workload.workloadId, - repositoryFullName: candidate.repositoryFullName, - evidence: candidate.exact ? "exact-provenance" : "exact-runtime-identity", - impact: "Create a server-pull deployment profile; no container changes", - }); - } else if (["suggested", "ambiguous"].includes(workload.status) || (workload.runtime?.running && workload.candidates?.length)) { - conflicts.push({ - workloadId: workload.workloadId, - displayName: workload.displayName, - status: workload.status, - candidates: (workload.candidates || []).slice(0, 5).map((item) => ({ repositoryFullName: item.repositoryFullName, score: item.score, exact: item.exact === true })), - }); - } - } - const stale = profiles.filter((profile) => - String(profile.workloadIdentity?.linkSource || "").startsWith("automatic") - && profile.workloadIdentity?.workloadId - && !activeWorkloadIds.has(profile.workloadIdentity.workloadId), - ).map((profile) => ({ - profileId: profile.id, - repositoryFullName: profile._repositoryFullName, - reason: "workload-missing", - impact: "Review only; ForgeFlow will not remove this profile automatically", - })); - const payload = { serverId: server.id, additions, updates, stale, conflicts }; - return { - id: crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex"), - createdAt: new Date().toISOString(), - ...payload, - summary: { additions: additions.length, updates: updates.length, stale: stale.length, conflicts: conflicts.length }, - }; - } - - async planServerInventoryReconciliation(serverId, repositories, options = {}) { - const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories); - const plan = this.reconciliationPlan(server, workloads, repositories, options); - return { inventory: this.inventoryResponse(server, inventory, workloads), plan }; - } - - async reconcileServerInventory(serverId, repositories, { autoLink = true, expectedPlanId = "" } = {}) { - const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories); - const plan = this.reconciliationPlan(server, workloads, repositories, { autoLink }); - if (!expectedPlanId || expectedPlanId !== plan.id) { - const error = new Error(expectedPlanId ? "The server inventory changed after the reconciliation preview. Review a fresh plan before applying it." : "Apply reconciliation only with an explicitly reviewed plan ID."); - error.code = expectedPlanId ? "RECONCILIATION_PLAN_STALE" : "RECONCILIATION_PLAN_REQUIRED"; - error.plan = plan; - throw error; - } - const recoverySnapshot = await this.store.createRecoverySnapshot?.(`server-reconciliation-${serverId}`) || null; - let adopted = 0; - let refreshed = 0; - let retired = 0; - let staleProfiles = []; - const inventoryStable = (inventory.warnings || []).every((warning) => /stale container reference\(s\) disappeared during inventory/i.test(warning)); - if (inventoryStable && workloads.length) { - const activeWorkloadIds = new Set(workloads.filter((item) => item.classification?.type !== "stale-link").map((item) => item.workloadId)); - const staleAutomaticProfiles = this.allSshProfiles().filter((profile) => - profile.serverId === serverId - && String(profile.workloadIdentity?.linkSource || "").startsWith("automatic") - && profile.workloadIdentity?.workloadId - && !activeWorkloadIds.has(profile.workloadIdentity.workloadId), - ); - const runningRepositoryLinks = new Set(workloads - .filter((workload) => workload.runtime?.running && workload.link?.repositoryFullName) - .map((workload) => String(workload.link.repositoryFullName).toLowerCase())); - const runningProfileIds = new Set(workloads - .filter((workload) => workload.runtime?.running && workload.link?.profileId) - .map((workload) => workload.link.profileId)); - const shadowedAutomaticProfiles = workloads - .filter((workload) => !workload.runtime?.running && workload.link?.profileId && !runningProfileIds.has(workload.link.profileId) && runningRepositoryLinks.has(String(workload.link.repositoryFullName).toLowerCase())) - .map((workload) => this.allSshProfiles().find((profile) => profile.id === workload.link.profileId && String(profile._repositoryFullName).toLowerCase() === String(workload.link.repositoryFullName).toLowerCase())) - .filter((profile) => profile && String(profile.workloadIdentity?.linkSource || "").startsWith("automatic")); - staleProfiles = [...new Map([...staleAutomaticProfiles, ...shadowedAutomaticProfiles].map((profile) => [profile.id, { - profileId: profile.id, - repositoryFullName: profile._repositoryFullName, - reason: staleAutomaticProfiles.includes(profile) ? "workload-missing" : "shadowed-by-running-workload", - }])).values()]; - } - for (const workload of workloads) { - if (workload.status !== "linked" || !workload.link?.profileId || !workload.link?.repositoryFullName) continue; - const repository = (repositories || []).find((item) => item.fullName === workload.link.repositoryFullName); - const existingProfile = this.store.getDeploymentProfile?.(workload.link.repositoryFullName, workload.link.profileId) - || this.allSshProfiles().find((item) => item.id === workload.link.profileId && item._repositoryFullName === workload.link.repositoryFullName); - if (!repository || !existingProfile) continue; - const updated = this.refreshedProfileFromWorkload(repository, server, workload, existingProfile); - const saved = await this.store.saveDeploymentProfile(repository.fullName, updated); - await this.saveWorkloadState(saved, workload, server); - refreshed += 1; - } - if (autoLink) { - const alreadyLinkedRepositories = new Set(workloads - .filter((item) => item.runtime?.running && item.link?.repositoryFullName) - .map((item) => String(item.link.repositoryFullName).toLowerCase())); - for (const workload of workloads) { - if (workload.status === "linked") continue; - if (["duplicate", "backup", "release-folder", "historical-compose", "system-container", "manually-excluded"].includes(workload.classification?.type)) continue; - const candidate = workload.candidates[0]; - const uniqueCandidate = workload.candidates.length === 1; - if (candidate && alreadyLinkedRepositories.has(String(candidate.repositoryFullName).toLowerCase())) continue; - const exactMatch = uniqueCandidate && candidate?.exact === true; - const exactRuntimeIdentity = uniqueCandidate - && candidate?.identityExact === true - && candidate.score >= 70 - && workload.runtime?.running === true - && Boolean(workload.remoteFolderCandidate) - && !alreadyLinkedRepositories.has(String(candidate.repositoryFullName).toLowerCase()); - if (!exactMatch && !exactRuntimeIdentity) continue; - const repository = (repositories || []).find((item) => item.fullName === candidate.repositoryFullName); - if (!repository) continue; - const linkSource = exactMatch ? "automatic" : "automatic-runtime-identity"; - const profile = this.profileFromWorkload(repository, server, workload, { linkSource, deploymentMode: "server-git" }); - const saved = await this.store.saveDeploymentProfile(repository.fullName, profile); - await this.saveWorkloadState(saved, workload, server); - workload.status = "linked"; - workload.link = { status: "linked", profileId: saved.id, repositoryFullName: repository.fullName, source: linkSource }; - alreadyLinkedRepositories.add(String(repository.fullName).toLowerCase()); - adopted += 1; - } - } - const response = this.inventoryResponse(server, inventory, workloads, { adopted, refreshed, retired, staleProfiles }); - response.recoverySnapshot = recoverySnapshot; - await this.diagnostics?.info("unraid.workloads.reconciled", { - serverId, - detected: response.detected, - adopted, - refreshed, - retired, - }); - return response; - } - - async discoverServerWorkloads(serverId, repositories) { - return this.scanServerInventory(serverId, repositories); - } - - async linkServerWorkload({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) { - const effectiveDeploymentMode = ["push-bundle", "server-git", "monitor-only"].includes(deploymentMode) - ? deploymentMode - : "server-git"; - const server = this.store.getServer(serverId); - if (!server) throw new Error("The deployment server no longer exists."); - const inventory = await this.scanServerInventory(serverId, [repository]); - const workload = inventory.workloads.find((item) => item.workloadId === workloadId); - if (!workload) throw new Error("The selected server workload no longer exists. Scan the server again."); - const existing = this.allSshProfiles().find((profile) => profile.workloadIdentity?.workloadId === workloadId && profile.serverId === serverId); - if (existing && String(existing._repositoryFullName).toLowerCase() !== String(repository.fullName).toLowerCase()) { - const error = new Error(`This workload is already linked to ${existing._repositoryFullName}. Remove or edit that link first.`); - error.code = "WORKLOAD_ALREADY_LINKED"; - throw error; - } - const profile = this.profileFromWorkload(repository, server, workload, { linkSource: "manual", deploymentMode: effectiveDeploymentMode, remoteFolder }); - const saved = await this.store.saveDeploymentProfile(repository.fullName, profile); - const state = await this.saveWorkloadState(saved, workload, server); - await this.diagnostics?.info("unraid.workload.linked", { serverId, workloadId, repository: repository.fullName, profileId: saved.id, deploymentMode: effectiveDeploymentMode }); - return { profile: saved, state, workload }; - } - - - async saveOperation(operation) { - const saved = await this.store.addOperation(operation); - this.onOperationChange?.({ operations: [saved] }); - return saved; - } - - resolve(repository, profileId) { - const profile = this.store.getDeploymentProfile( - repository.fullName, - profileId, - ); - if (!profile || profile.provider !== "ssh-unraid") - throw new Error("The SSH / Unraid deployment profile no longer exists."); - const server = this.store.getServer(profile.serverId); - if (!server) throw new Error("The deployment server no longer exists."); - const remoteFolder = safeRemoteFolder( - profile.remoteFolder || repository.name, - ); - const remotePath = path.join(server.basePath, remoteFolder); - if (!remotePath.startsWith(`${server.basePath}/`)) - throw new Error( - "Remote project path escapes the configured server base path.", - ); - const effectiveProfile = { - ...profile, - deploymentMode: ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode) - ? profile.deploymentMode - : "push-bundle", - }; - return { profile: effectiveProfile, server, remoteFolder, remotePath }; - } - - async discoverExisting({ repository, serverId, remoteFolder = "" }) { - const server = this.store.getServer(serverId); - if (!server) throw new Error("The deployment server no longer exists."); - const folder = safeRemoteFolder(remoteFolder || repository.name); - const remotePath = path.join(server.basePath, folder); - const inventory = await this.scanServerInventory(serverId, [repository], { autoLink: false }); - const workload = inventory.workloads.find((item) => - item.remoteFolderCandidate === folder || - item.compose?.workingDir === remotePath || - item.containers.some((container) => (container.mounts || []).some((mount) => { - const source = String(mount.source || "").replace(/\/+$/, ""); - return source === remotePath || source.startsWith(`${remotePath}/`); - })) - ); - if (!workload) { - const error = new Error(`No Docker or Compose workload could be matched to ${remotePath}. Use Server Inventory to select the running container directly.`); - error.code = "SERVER_WORKLOAD_NOT_FOUND"; - throw error; - } - const profile = this.profileFromWorkload(repository, server, workload, { - linkSource: "manual", - deploymentMode: "server-git", - remoteFolder: folder, - }); - const source = (value, origin, confidence = "confirmed") => ({ - value, - origin, - confidence, - detectedAt: new Date().toISOString(), - overridden: false, - }); - const provenance = { - remoteFolder: source(folder, "server-inventory"), - cloneUrl: source(profile.cloneUrl, workload.metadata?.sourceRepository ? "container-provenance" : "repository"), - branch: source(profile.branch, workload.metadata?.branch ? "container-provenance" : "repository"), - composeFile: source(profile.composeFile, "docker-compose-labels"), - composeService: source(profile.composeService, "docker-compose-labels"), - containerName: source(profile.containerName, "docker-inspect"), - hostPort: source(profile.hostPort, "docker-inspect"), - containerPort: source(profile.containerPort, "docker-inspect"), - webUiUrl: source(profile.webUiUrl, workload.dockerMan?.webUiUrl ? "unraid-dockerman" : "docker-labels"), - iconUrl: source(profile.serverIconReference, workload.dockerMan?.iconUrl ? "unraid-dockerman" : "docker-labels"), - dockerShell: source(profile.dockerShell, workload.dockerMan?.shell ? "unraid-dockerman" : "docker-labels"), - }; - return { - repository: repository.fullName, - profile: { ...profile, id: undefined, provenance }, - provenance, - workload, - runtime: { - remotePath, - containerRunning: workload.runtime.running, - containers: workload.containers.length, - services: workload.compose?.services?.length || workload.containers.length, - ports: workload.runtime.ports, - mounts: workload.containers.flatMap((container) => container.mounts || []), - networks: [...new Set(workload.containers.flatMap((container) => container.networks || []))], - envNames: [], - }, - }; - } - - async inspect({ repository, profileId }) { - const { profile, server, remotePath } = this.resolve(repository, profileId); - const preserveProbe = (profile.preservePaths || []) - .map( - (relativePath) => - `if [ -e "$root"/${shellQuote(relativePath)} ]; then printf '%s\\n' ${shellQuote(relativePath)}; fi`, - ) - .join("\n"); - const script = ` -root=${shellQuote(remotePath)} -exists=false; root_git=false; head=""; branch=""; remote=""; tracked_changes=""; compose_files=""; nested_git=""; dockerfile=false; dockerignore_content=""; existing_preserve_paths="" -if [ -d "$root" ]; then - exists=true - if [ -d "$root/.git" ]; then - root_git=true - head=$(git -C "$root" rev-parse HEAD 2>/dev/null || true) - branch=$(git -C "$root" branch --show-current 2>/dev/null || true) - remote=$(git -C "$root" remote get-url origin 2>/dev/null || true) - tracked_changes=$(git -C "$root" status --porcelain --untracked-files=no 2>/dev/null | head -n 25 | base64 | tr -d '\\r\\n' || true) - fi - compose_files=$(find "$root" -maxdepth 2 -type f \\( -name 'docker-compose.yml' -o -name 'docker-compose.yaml' -o -name 'compose.yml' -o -name 'compose.yaml' -o -name 'compose.forgeflow.yml' \\) -printf '%P\\n' 2>/dev/null | sort | base64 | tr -d '\\r\\n' || true) - nested_git=$(find "$root" -mindepth 2 -maxdepth 4 -type d -name .git -printf '%h\\n' 2>/dev/null | sed "s#^$root/##" | sort | base64 | tr -d '\\r\\n' || true) - [ -f "$root/Dockerfile" ] && dockerfile=true - [ -f "$root/.dockerignore" ] && dockerignore_content=$(base64 < "$root/.dockerignore" | tr -d '\\r\\n' || true) - existing_preserve_paths=$({ ${preserveProbe || ":"}; } | sort -u | base64 | tr -d '\\r\\n' || true) -fi -printf '__FORGEFLOW_KV__\\n' -printf 'exists=%s\\n' "$exists" -printf 'rootGit=%s\\n' "$root_git" -printf 'head=%s\\n' "$head" -printf 'branch=%s\\n' "$branch" -printf 'remote=%s\\n' "$(printf '%s' "$remote" | base64 | tr -d '\\r\\n')" -printf 'trackedChanges=%s\\n' "$tracked_changes" -printf 'composeFiles=%s\\n' "$compose_files" -printf 'nestedGit=%s\\n' "$nested_git" -printf 'dockerfile=%s\\n' "$dockerfile" -printf 'dockerignoreContent=%s\\n' "$dockerignore_content" -printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths" -`; - const wrapped = bash(script); - const result = await this.ssh.exec(server.id, wrapped, { timeout: 60_000 }); - const parsed = parseInspection(result.stdout); - const contextCandidates = [ - ...new Set([ - ...(parsed.existingPreservePaths || []), - ...(parsed.nestedGit || []), - ]), - ]; - const inspection = { - ...parsed, - dockerignore: Boolean(parsed.dockerignoreContent), - dockerignoreGitExcluded: dockerIgnoreHasPath( - parsed.dockerignoreContent, - ".git", - ), - dockerContextExclusionsMissing: parsed.dockerfile - ? contextCandidates.filter( - (item) => !dockerIgnoreHasPath(parsed.dockerignoreContent, item), - ) - : [], - serverId: server.id, - serverName: server.name, - remotePath, - profileId: profile.id, - }; - await this.diagnostics?.info("unraid.inspected", { - repository: repository.fullName, - serverId: server.id, - remotePath, - exists: inspection.exists, - rootGit: inspection.rootGit, - head: inspection.head, - composeFiles: inspection.composeFiles, - nestedGitCount: inspection.nestedGit.length, - trackedChangeCount: inspection.trackedChanges.length, - dockerContextExclusionsMissing: inspection.dockerContextExclusionsMissing, - }); - return inspection; - } - - async preflight({ repository, profileId, sha = null }) { - const { profile, server, remotePath } = this.resolve(repository, profileId); - const deploymentMode = ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode) - ? profile.deploymentMode - : "push-bundle"; - let requestedSha = sha || repository.localStatus?.head; - if (deploymentMode === "server-git" && !sha) { - const [owner, repo] = String(repository.fullName || "").split("/"); - const branch = await this.gitea.getBranch(owner, repo, profile.branch); - requestedSha = branch?.commit?.id || branch?.commit?.sha || null; - } - const targetSha = assertFullCommitSha(requestedSha); - const checks = []; - let inspection = null; - let connectionCapabilities = null; - let permissions = null; - - if (deploymentMode === "monitor-only") checks.push({ - id: "deployment-mode", - label: "Deployment mode", - status: "fail", - detail: "This workload is linked for monitoring only. Select Server pull or Direct copy before deploying.", - }); - else checks.push({ - id: "deployment-mode", - label: "Deployment mode", - status: "pass", - detail: deploymentMode === "server-git" - ? "Unraid fetches the exact Gitea commit with a repository-scoped read-only deploy key." - : "ForgeFlow copies the exact committed local project directly to Unraid and runs Docker Compose there.", - }); - - if (!repository.localPath) { - checks.push({ - id: "local-repository", - label: "Local repository", - status: deploymentMode === "server-git" ? "pass" : "fail", - detail: deploymentMode === "server-git" - ? "Not required: the exact commit is fetched from Gitea by the server." - : "Link or clone the repository locally before using Direct copy.", - }); - } else { - try { - const localStatus = await this.git.status(repository.localPath); - checks.push({ - id: "local-repository", - label: "Local repository", - status: "pass", - detail: localStatus.root, - }); - checks.push({ - id: "local-branch", - label: "Allowed branch", - status: localStatus.branch.head === profile.branch ? "pass" : deploymentMode === "server-git" ? "warning" : "fail", - detail: `Current: ${localStatus.branch.head || "detached"}; required: ${profile.branch}.`, - }); - checks.push({ - id: "local-clean", - label: "Clean local working tree", - status: localStatus.clean ? "pass" : deploymentMode === "server-git" ? "warning" : "fail", - detail: localStatus.clean - ? "No uncommitted changes." - : `${localStatus.counts.changed} changed file(s) remain.`, - }); - checks.push({ - id: "deployment-source", - label: deploymentMode === "server-git" ? "Gitea deployment source" : "Direct deployment source", - status: "pass", - detail: deploymentMode === "server-git" - ? "Local files are not uploaded; the exact requested commit is fetched from Gitea." - : "The exact committed local HEAD is archived and copied directly to Unraid. No server-side repository access is involved.", - }); - - - const localDeploymentFiles = deploymentMode === "push-bundle" && profile.generatedCompose - ? [nativePath.join(repository.localPath, "Dockerfile")] - : deploymentMode === "push-bundle" ? this.deploymentComposeFiles(profile).map((file) => - nativePath.join(repository.localPath, safeRelativeRemoteFile(file)), - ) : []; - const missingDeploymentFiles = []; - for (const file of localDeploymentFiles) { - if (!(await fs.stat(file).catch(() => null))?.isFile()) missingDeploymentFiles.push(file); - } - if (deploymentMode === "push-bundle") checks.push({ - id: "local-deployment-file", - label: profile.generatedCompose - ? "Dockerfile in repository" - : localDeploymentFiles.length > 1 ? "Compose files in repository" : "Compose file in repository", - status: missingDeploymentFiles.length ? "fail" : "pass", - detail: missingDeploymentFiles.length - ? `Missing from the exact local checkout: ${missingDeploymentFiles.join(", ")}` - : localDeploymentFiles.join(", "), - }); - } catch (error) { - checks.push({ - id: "local-repository", - label: "Local repository", - status: "fail", - detail: error.message, - }); - } - } - - try { - const connection = await this.ssh.test(server.id, { trustOnFirstUse: false }); - connectionCapabilities = connection.capabilities || {}; - checks.push({ - id: "ssh", - label: "Desktop → Unraid SSH", - status: "pass", - detail: `${server.username}@${server.host}:${server.port}`, - }); - checks.push({ - id: "docker-runtime", - label: "Docker runtime", - status: connectionCapabilities.docker && connectionCapabilities.dockerReady ? "pass" : "fail", - detail: connectionCapabilities.dockerReady - ? "Docker is reachable by the configured SSH user." - : connectionCapabilities.docker - ? "Docker is installed, but the configured SSH user cannot query the daemon." - : "Docker was not detected on the server.", - }); - checks.push({ - id: "compose-command", - label: "Docker Compose", - status: connectionCapabilities.compose ? "pass" : "fail", - detail: connectionCapabilities.composeVersion || "Docker Compose was not detected on the server.", - }); - checks.push({ - id: "bundle-tools", - label: deploymentMode === "server-git" ? "Server pull tools" : "Direct copy tools", - status: connectionCapabilities.tar && connectionCapabilities.checksum && (deploymentMode !== "server-git" || connectionCapabilities.git) ? "pass" : "fail", - detail: deploymentMode === "server-git" - ? `Git ${connectionCapabilities.git ? "available" : "missing"}; tar ${connectionCapabilities.tar ? "available" : "missing"}; checksum ${connectionCapabilities.checksum ? "available" : "missing"}.` - : connectionCapabilities.tar && connectionCapabilities.checksum - ? "tar and a SHA-256 checksum tool are available." - : `tar ${connectionCapabilities.tar ? "available" : "missing"}; checksum tool ${connectionCapabilities.checksum ? "available" : "missing"}.`, - }); - checks.push({ - id: "server-base-writable", - label: "Deployment storage writable", - status: connectionCapabilities.baseWritable ? "pass" : "fail", - detail: connectionCapabilities.baseWritable ? `${server.basePath} is writable.` : `${server.basePath} cannot be created or written by this SSH user.`, - }); - } catch (error) { - checks.push({ - id: "ssh", - label: "Desktop → Unraid SSH", - status: "fail", - detail: error.message, - }); - } - if (!server.hostFingerprint) checks.push({ - id: "host-key", - label: "Server identity", - status: "fail", - detail: "Test and trust the SSH host key first.", - }); - else checks.push({ - id: "host-key", - label: "Server identity", - status: "pass", - detail: server.hostFingerprint, - }); - - if (deploymentMode === "server-git") { - const access = await this.probeServerGitAccess({ repository, profile, server }); - checks.push({ - id: "server-git-access", - label: "Unraid → Gitea read access", - status: access.ready ? "pass" : "fail", - detail: access.ready - ? `Read-only deploy key verified${access.remoteSha ? ` at ${access.remoteSha.slice(0, 7)}` : ""}.` - : access.error, - repairAction: access.ready ? null : "configure-server-git-access", - repairLabel: "Configure read-only deploy key", - }); - } else checks.push({ - id: "transfer-path", - label: "Desktop → Unraid transfer", - status: "pass", - detail: "Files are copied over the configured server connection. No Gitea credential is required on Unraid.", - }); - - try { - permissions = await this.inspectWriteAccess({ repository, profileId }); - const blockingPaths = permissions.blocking.map((target) => target.path); - checks.push({ - id: "project-write-access", - label: "Project write access", - status: permissions.ready ? "pass" : "fail", - detail: permissions.ready - ? `${permissions.identity.user} can create and atomically replace deployment files in ${remotePath}.` - : `No safe write access for ${permissions.identity.user}: ${blockingPaths.join(", ")}`, - help: permissions.ready - ? "ForgeFlow rechecks these paths immediately before every upload and Compose activation." - : "Use Fix write access to repair only the linked project source and ForgeFlow state folders. Preserved runtime data is excluded.", - repairAction: permissions.ready ? null : "repair-deployment-write-access", - repairLabel: "Fix write access", - }); - for (const target of permissions.targets.filter( - (item) => item.required && !item.effectiveWritable, - )) { - checks.push({ - id: `write-path:${target.id}`, - label: target.label, - status: "fail", - detail: `${target.path} · owner ${target.owner || "unknown"}:${target.group || "unknown"} · mode ${target.mode || "unknown"}. ${target.detail}`, - repairAction: "repair-deployment-write-access", - repairLabel: "Fix write access", - }); - } - } catch (error) { - checks.push({ - id: "project-write-access", - label: "Project write access", - status: "fail", - detail: error.message, - }); - } - - - try { - inspection = await this.inspect({ repository, profileId }); - if (!inspection.exists) { - checks.push({ - id: "remote-folder", - label: "Remote project folder", - status: "pass", - detail: `${remotePath} will be created.`, - }); - } else { - checks.push({ - id: "remote-folder", - label: inspection.rootGit ? "Remote project folder" : "Existing server installation", - status: "pass", - detail: inspection.rootGit - ? `${remotePath} currently contains Git commit ${String(inspection.head || "").slice(0, 7) || "unknown"}.` - : `${remotePath} will receive managed release files while preserved and unknown runtime data remains untouched.`, - }); - } - if (inspection.rootGit) { - checks.push({ - id: "tracked-changes", - label: "Server-side tracked changes", - status: inspection.trackedChanges.length ? "warning" : "pass", - detail: inspection.trackedChanges.length - ? `${inspection.trackedChanges.length} tracked server edit(s) exist. Direct copy preserves unknown runtime data and does not depend on the server Git checkout.` - : "No tracked server-only edits detected.", - }); - } - - if (inspection.nestedGit.length) { - checks.push({ - id: "nested-git", - label: "Nested Git repositories", - status: "warning", - detail: `Detected: ${inspection.nestedGit.join(", ")}. ForgeFlow will not delete them automatically.`, - }); - } - if (inspection.dockerfile && !inspection.dockerignore) { - checks.push({ - id: "dockerignore", - label: "Docker build context", - status: "warning", - detail: - "A Dockerfile exists but .dockerignore is missing. Add one in the repository before large builds.", - }); - } else if (inspection.dockerfile && !inspection.dockerignoreGitExcluded) { - checks.push({ - id: "dockerignore-git", - label: "Git metadata excluded from Docker", - status: "warning", - detail: ".dockerignore does not explicitly exclude .git.", - }); - } else if (inspection.dockerfile) { - checks.push({ - id: "dockerignore-git", - label: "Git metadata excluded from Docker", - status: "pass", - detail: ".git is excluded from the Docker build context.", - }); - } - if (inspection.dockerContextExclusionsMissing.length) { - checks.push({ - id: "dockerignore-runtime", - label: "Runtime data excluded from Docker", - status: "warning", - detail: `Add these existing runtime or legacy paths to .dockerignore: ${inspection.dockerContextExclusionsMissing.join(", ")}.`, - }); - } else if ( - inspection.dockerfile && - inspection.existingPreservePaths.length - ) { - checks.push({ - id: "dockerignore-runtime", - label: "Runtime data excluded from Docker", - status: "pass", - detail: - "Detected preserved runtime paths are excluded from the Docker build context.", - }); - } - const composeFiles = profile.generatedCompose - ? [".forgeflow/compose.forgeflow.yml"] - : (profile.composeFiles?.length ? profile.composeFiles : [profile.composeFile || "docker-compose.yml"]) - .map((value) => safeRelativeRemoteFile(value)); - const missingRemoteCompose = composeFiles.filter((composeFile) => !inspection.composeFiles.includes(composeFile)); - checks.push({ - id: "compose-file", - label: "Compose configuration", - status: "pass", - detail: profile.generatedCompose - ? "ForgeFlow will generate an isolated Compose file." - : missingRemoteCompose.length - ? `${composeFiles.join(", ")} will be uploaded from the exact local commit.` - : composeFiles.join(", "), - }); - } catch (error) { - checks.push({ - id: "inspection", - label: "Server project inspection", - status: "fail", - detail: error.message, - }); - } - const iconMode = - profile.iconMode || - (profile.iconFilePath ? "upload" : profile.iconUrl ? "url" : "builtin"); - if (iconMode === "upload") { - const iconStat = await fs.stat(profile.iconFilePath).catch(() => null); - checks.push({ - id: "dockerman-icon-file", - label: "DockerMan icon upload", - status: - iconStat?.isFile() && - nativePath.extname(profile.iconFilePath).toLowerCase() === ".png" - ? "pass" - : "fail", - detail: iconStat?.isFile() - ? profile.iconFilePath - : "The selected local PNG icon file was not found.", - }); - } else if (iconMode === "builtin") { - const builtinIcon = nativePath.join( - this.sourcePath, - "src", - "renderer", - "assets", - "itworx-mark.png", - ); - const iconStat = await fs.stat(builtinIcon).catch(() => null); - checks.push({ - id: "dockerman-icon-builtin", - label: "DockerMan icon", - status: iconStat?.isFile() ? "pass" : "fail", - detail: iconStat?.isFile() - ? "Built-in high-contrast ITWorx mark." - : "The built-in ITWorx icon asset is missing.", - }); - } else if (iconMode === "url") - checks.push({ - id: "dockerman-icon", - label: "DockerMan icon", - status: profile.iconUrl ? "pass" : "fail", - detail: - profile.iconUrl || "Icon URL mode requires an HTTPS or HTTP PNG URL.", - }); - else - checks.push({ - id: "dockerman-icon", - label: "DockerMan icon", - status: "warning", - detail: "Custom DockerMan icon disabled.", - }); - const webUiLabel = this.dockerManWebUi(profile); - checks.push({ - id: "dockerman-webui", - label: "DockerMan Web UI action", - status: webUiLabel ? "pass" : "warning", - detail: webUiLabel || "No Web UI URL or host port is configured.", - }); - checks.push({ - id: "compose-identity", - label: "Safe Docker Compose identity", - status: "pass", - detail: `Internal project/image: ${this.internalSlug(profile, repository)}; visible container: ${profile.containerName || profile.remoteFolder || repository.name}.`, - }); - checks.push({ - id: "exact-sha", - label: "Exact deployment commit", - status: "pass", - detail: targetSha, - }); - return { - provider: "ssh-unraid", - repository: repository.fullName, - environment: profile.environment, - sha: targetSha, - server: { id: server.id, name: server.name, host: server.host }, - remotePath, - inspection, - permissions, - checks, - summary: checksSummary(checks), - }; - } - - internalSlug(profile, repository) { - return ( - String( - profile.remoteFolder || - repository.name || - profile.composeService || - "app", - ) - .toLowerCase() - .replace(/[^a-z0-9._-]+/g, "-") - .replace(/^-+|-+$/g, "") || "app" - ); - } - - generatedCompose(profile, repository) { - const service = - String(profile.composeService || repository.name || "app") - .toLowerCase() - .replace(/[^a-z0-9._-]/g, "-") || "app"; - const containerName = - String( - profile.containerName || - profile.remoteFolder || - repository.name || - service, - ).replace(/[^A-Za-z0-9._-]/g, "-") || service; - if (!profile.hostPort || !profile.containerPort) - throw new Error( - "Host and container ports are required for generated Compose.", - ); - return ( - [ - "services:", - ` ${service}:`, - ` image: forgeflow/${this.internalSlug(profile, repository)}:${String(profile.environment || "production").toLowerCase()}`, - " build:", - " context: ..", - ` container_name: ${containerName}`, - " restart: unless-stopped", - " ports:", - ` - "${profile.hostPort}:${profile.containerPort}"`, - ].join("\n") + "\n" - ); - } - - dockerManWebUi(profile) { - if (profile.hostPort) { - let suffix = "/"; - try { - const parsed = profile.webUiUrl ? new URL(profile.webUiUrl) : null; - suffix = parsed - ? `${parsed.pathname || "/"}${parsed.search || ""}${parsed.hash || ""}` - : "/"; - } catch {} - if (!suffix.startsWith("/")) suffix = `/${suffix}`; - return `http://[IP]:[PORT:${profile.hostPort}]${suffix}`; - } - return profile.webUiUrl || ""; - } - - dockerManShell(profile) { - return String(profile.dockerShell || "/bin/sh") - .toLowerCase() - .includes("bash") - ? "bash" - : "sh"; - } - - dockerManTemplatePath(profile, repository) { - const containerName = - String( - profile.containerName || - profile.remoteFolder || - repository.name || - "app", - ).replace(/[^A-Za-z0-9._-]/g, "-") || "app"; - return `/boot/config/plugins/dockerMan/templates-user/my-${containerName}.xml`; - } - - dockerManTemplate(profile, repository, iconReference = "") { - const containerName = - String( - profile.containerName || - profile.remoteFolder || - repository.name || - "app", - ).replace(/[^A-Za-z0-9._-]/g, "-") || "app"; - const slug = this.internalSlug(profile, repository); - const environment = - String(profile.environment || "production") - .toLowerCase() - .replace(/[^a-z0-9._-]/g, "-") || "production"; - const image = `forgeflow/${slug}:${environment}`; - const webUi = this.dockerManWebUi(profile); - return ( - [ - '', - '', - ` ${xmlEscape(containerName)}`, - ` ${xmlEscape(image)}`, - " ", - " bridge", - " ", - ` ${xmlEscape(this.dockerManShell(profile))}`, - " false", - " ", - " ", - " Managed by ForgeFlow through Docker Compose. Use ForgeFlow or the Compose files for configuration changes.", - " Tools:", - ` ${xmlEscape(webUi)}`, - " ", - ` ${xmlEscape(iconReference)}`, - " ", - " ", - " ", - " ", - " ", - "", - ].join("\n") + "\n" - ); - } - - iconCacheRefresh(profile, repository, iconReference = "") { - const containerName = - String( - profile.containerName || - profile.remoteFolder || - repository.name || - "app", - ).replace(/[^A-Za-z0-9._-]/g, "-") || "app"; - const cacheLoop = `for icon_dir in /var/lib/docker/unraid/images /usr/local/emhttp/state/plugins/dynamix.docker.manager/images /var/local/emhttp/plugins/dynamix.docker.manager/images; do [ -d "$icon_dir" ] || continue; rm -f "$icon_dir/${containerName}-icon.png" "$icon_dir/${containerName}.png"; done`; - const invalidateMetadata = `rm -f /usr/local/emhttp/state/plugins/dynamix.docker.manager/docker.json`; - const localIconPath = iconReferenceLocalPath(iconReference); - if (!localIconPath) return `${cacheLoop}\n${invalidateMetadata}`; - return `${cacheLoop} -if [ -f ${shellQuote(localIconPath)} ]; then for icon_dir in /var/lib/docker/unraid/images /usr/local/emhttp/state/plugins/dynamix.docker.manager/images /var/local/emhttp/plugins/dynamix.docker.manager/images; do [ -d "$icon_dir" ] || continue; cp ${shellQuote(localIconPath)} "$icon_dir/${containerName}-icon.png"; chmod 0644 "$icon_dir/${containerName}-icon.png"; done; fi -${invalidateMetadata}`; - } - - dockerManRefreshScript(profile, repository, iconReference = "") { - if (profile.manageDockerMan !== true || profile.adoptedFromServer === true || profile.generatedCompose !== true) { - return `echo 'ForgeFlow left the existing DockerMan template unchanged.'`; - } - const templatePath = this.dockerManTemplatePath(profile, repository); - const template = this.dockerManTemplate(profile, repository, iconReference); - return `mkdir -p /boot/config/plugins/dockerMan/templates-user -cat > ${shellQuote(templatePath)} <<'FORGEFLOW_DOCKERMAN_TEMPLATE' -${template}FORGEFLOW_DOCKERMAN_TEMPLATE -chmod 0644 ${shellQuote(templatePath)} -${this.iconCacheRefresh(profile, repository, iconReference)}`; - } - - deploymentServices(profile, repository) { - const values = profile.generatedCompose - ? [profile.composeService || repository.name || "app"] - : (profile.composeServices?.length ? profile.composeServices : [profile.composeService || repository.name || "app"]); - return [...new Set(values.map((value) => String(value || "").trim().toLowerCase().replace(/[^a-z0-9._-]/g, "-")).filter(Boolean))]; - } - - deploymentComposeFiles(profile) { - if (profile.generatedCompose) return [".forgeflow/compose.forgeflow.yml"]; - const values = profile.composeFiles?.length ? profile.composeFiles : [profile.composeFile || "docker-compose.yml"]; - return [...new Set(values - .map((value) => safeRelativeRemoteFile(value)) - .filter((value) => value !== ".forgeflow/compose.metadata.yml" && value !== ".forgeflow/compose.forgeflow.yml"))]; - } - - metadataCompose(profile, repository, iconReference = "", deployment = {}) { - const services = this.deploymentServices(profile, repository); - const labels = { - "net.unraid.docker.managed": "dockerman", - "net.unraid.docker.shell": this.dockerManShell(profile), - "tech.itworx.forgeflow.repository": - deployment.repositoryUrl || - profile.cloneUrl || - repository.sshUrl || - repository.cloneUrl || - repository.htmlUrl || - repository.fullName || repository.name || "unknown", - "tech.itworx.forgeflow.branch": profile.branch || "main", - }; - if (deployment.sha) labels["tech.itworx.forgeflow.commit"] = deployment.sha; - const webUiLabel = this.dockerManWebUi(profile); - if (webUiLabel) labels["net.unraid.docker.webui"] = webUiLabel; - if (iconReference) labels["net.unraid.docker.icon"] = iconReference; - - const output = ["services:"]; - for (const service of services) { - output.push(` ${service}:`); - if (profile.generatedCompose) { - const containerName = String( - profile.containerName || profile.remoteFolder || repository.name || service, - ).replace(/[^A-Za-z0-9._-]/g, "-") || "app"; - output.push(` image: forgeflow/${this.internalSlug(profile, repository)}:${String(profile.environment || "production").toLowerCase().replace(/[^a-z0-9._-]/g, "-")}`); - output.push(` container_name: ${containerName}`); - } - output.push(" labels:"); - output.push(...Object.entries(labels).map(([key, value]) => ` ${JSON.stringify(key)}: ${JSON.stringify(value)}`)); - } - return `${output.join("\n")}\n`; - } - - async prepareIcon(profile, repository, server) { - const mode = - profile.iconMode || - (profile.iconFilePath ? "upload" : profile.iconUrl ? "url" : "builtin"); - if (mode === "none") return profile.serverIconReference || ""; - if (mode === "url") { - if (!profile.iconUrl) - throw new Error( - "DockerMan icon URL mode is selected, but no icon URL is configured.", - ); - return profile.iconUrl; - } - const localIconPath = - mode === "builtin" - ? nativePath.join( - this.sourcePath, - "src", - "renderer", - "assets", - "itworx-mark.png", - ) - : profile.iconFilePath; - const stat = await fs.stat(localIconPath).catch(() => null); - if (!stat?.isFile()) - throw new Error( - mode === "builtin" - ? "The built-in ITWorx DockerMan icon is missing." - : `The selected DockerMan icon file no longer exists: ${localIconPath}`, - ); - if (nativePath.extname(localIconPath).toLowerCase() !== ".png") - throw new Error( - "DockerMan icon upload currently accepts PNG files only.", - ); - const containerName = - String( - profile.containerName || - profile.remoteFolder || - repository.name || - "app", - ).replace(/[^A-Za-z0-9._-]/g, "-") || "app"; - const remoteIconPath = `/boot/config/plugins/dockerMan/images/${containerName}-icon.png`; - await this.ssh.uploadFile(server.id, localIconPath, remoteIconPath, { - mode: 0o644, - }); - return `file://${remoteIconPath}`; - } - - composeInvocation(profile, repository) { - const project = String(profile.composeProject || this.internalSlug(profile, repository)).trim(); - const files = [...this.deploymentComposeFiles(profile)]; - // A labels-only Compose fragment is valid only when every service key also - // exists in the base definition. Imported profiles can contain stale service - // hints, so adopted workloads must activate from their real server Compose - // files only. ForgeFlow tracks the deployed SHA in .forgeflow/status.json. - if (profile.generatedCompose) files.push(".forgeflow/compose.metadata.yml"); - return `forgeflow_compose -p ${shellQuote(project)} ${files.map((file) => `-f ${shellQuote(file)}`).join(" ")}`; - } - - composeUpFlags(profile) { - // ForgeFlow never adds destructive recreation or orphan-removal flags. - // Compose may replace a service when its built image or configuration changed, - // but unrelated containers are never deleted by ForgeFlow. - void profile; - return ""; - } - - containerVerificationScript(profile, repository, compose, { requireRecreated = false } = {}) { - const recreationCheck = requireRecreated - ? ` before_id=$(awk -F '\t' -v wanted="$service" '$1 == wanted { print $2; exit }' "$before_containers" 2>/dev/null || true) - if [ -n "$before_id" ] && [ "$before_id" = "$container_id" ]; then - echo "Compose reported success but service $service still uses the previous container $container_id" >&2 - exit 65 - fi` - : ` before_id=""`; - return `actual_services=$(${compose} config --services 2>/dev/null | sed '/^$/d') -[ -n "$actual_services" ] || { echo "Compose project defines no services" >&2; exit 60; } -printf '%s\n' "$actual_services" | while IFS= read -r service; do - [ -n "$service" ] || continue - attempt=0; container_id=''; running=false; health='' - while [ "$attempt" -lt 30 ]; do - container_id=$(${compose} ps -q "$service" | head -n1) - if [ -n "$container_id" ]; then - running=$(docker inspect -f '{{.State.Running}}' "$container_id" 2>/dev/null || echo false) - health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container_id" 2>/dev/null || true) - if [ "$running" = true ] && [ "$health" != unhealthy ] && [ "$health" != starting ]; then break; fi - fi - attempt=$((attempt + 1)); sleep 2 - done - [ -n "$container_id" ] || { echo "Compose service $service did not create a container" >&2; exit 61; } - [ "$running" = true ] || { echo "Compose service $service is not running after 60 seconds" >&2; exit 62; } - [ "$health" != unhealthy ] && [ "$health" != starting ] || { echo "Compose service $service did not become healthy" >&2; exit 63; } -${recreationCheck} - image_id=$(docker inspect -f '{{.Image}}' "$container_id" 2>/dev/null || true) - printf 'ForgeFlow verified service %s: container=%s previous=%s image=%s\n' "$service" "$container_id" "\${before_id:-none}" "\${image_id:-unknown}" -done`; - } - - async checkHealth(url) { - if (!url) - return { - configured: false, - healthy: null, - status: null, - latencyMs: null, - }; - let last = null; - for (let attempt = 1; attempt <= 5; attempt += 1) { - const started = Date.now(); - try { - const response = await fetch(url, { - signal: AbortSignal.timeout(8_000), - redirect: "manual", - }); - last = { - configured: true, - healthy: response.ok, - status: response.status, - latencyMs: Date.now() - started, - }; - if (response.ok) return last; - } catch (error) { - last = { - configured: true, - healthy: false, - status: null, - latencyMs: Date.now() - started, - error: error.message, - }; - } - if (attempt < 5) - await new Promise((resolve) => setTimeout(resolve, 3_000)); - } - return last; - } - - hashFile(filePath) { - return new Promise((resolve, reject) => { - const hash = crypto.createHash("sha256"); - const stream = fileSystem.createReadStream(filePath); - stream.on("error", reject); - stream.on("data", (chunk) => hash.update(chunk)); - stream.on("end", () => resolve(hash.digest("hex"))); - }); - } - - async createCommitBundle(repository, sha, requestId) { - if (!repository.localPath) throw new Error("A linked local repository is required to create a push bundle."); - const bundleDirectory = nativePath.join(os.tmpdir(), "forgeflow-bundles"); - await fs.mkdir(bundleDirectory, { recursive: true }); - const archivePath = nativePath.join(bundleDirectory, `${requestId}-${sha}.tar`); - await run("git", ["-C", repository.localPath, "archive", "--format=tar", `--output=${archivePath}`, sha], { - timeout: 5 * 60_000, - maxBuffer: 4 * 1024 * 1024, - }); - const stat = await fs.stat(archivePath); - if (!stat.isFile() || stat.size <= 0) throw new Error("Git produced an empty deployment bundle."); - return { archivePath, bytes: stat.size, sha256: await this.hashFile(archivePath) }; - } - - deploymentStatusDocument({ repository, profile, targetSha, requestId, rollback = false }) { - return JSON.stringify({ - repository: repository.fullName, - environment: profile.environment, - requested_sha: targetSha, - live_sha: targetSha, - request_id: requestId, - healthy: null, - healthcheck_url_configured: Boolean(profile.healthcheckUrl), - rollback, - deployment_mode: profile.deploymentMode || "push-bundle", - deployed_at: new Date().toISOString(), - }); - } - - pushBundleScript({ repository, profile, remotePath, targetSha, requestId, remotePart, digest, metadata, generated, iconReference, rollback = false }) { - const compose = this.composeInvocation(profile, repository); - const project = String( - profile.composeProject || this.internalSlug(profile, repository), - ).trim(); - const candidateFiles = [...this.deploymentComposeFiles(profile)]; - if (profile.generatedCompose) candidateFiles.push(".forgeflow/compose.metadata.yml"); - const candidateCompose = `forgeflow_compose -p ${shellQuote(project)} ${candidateFiles - .map((file) => `-f "$release"/${shellQuote(file)}`) - .join(" ")}`; - const preservePayload = Buffer.from( - [".forgeflow", ".git", ...(profile.preservePaths || [])].join("\n"), - "utf8", - ).toString("base64"); - const statusJson = this.deploymentStatusDocument({ - repository, - profile, - targetSha, - requestId, - rollback, - }); - const verification = this.containerVerificationScript( - profile, - repository, - compose, - { requireRecreated: true }, - ); - const containerHint = String( - profile.containerName || profile.remoteFolder || repository.name || "", - ).trim(); - return ` -root=${shellQuote(remotePath)} -expected_project=${shellQuote(project)} -tracked_container_hint=${shellQuote(containerHint)} -target=${shellQuote(targetSha)} -request_id=${shellQuote(requestId)} -incoming=${shellQuote(remotePart)} -expected_digest=${shellQuote(digest)} -release_root="$root/.forgeflow/releases/$target" -release="$release_root/source" -staging="$root/.forgeflow/staging/$request_id" -backup="$root/.forgeflow/backups/$request_id" -lock="$root/.forgeflow/deploy.lock" -mkdir -p "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" -if [ -d "$lock" ] && find "$lock" -maxdepth 0 -mmin +120 -print -quit | grep -q .; then - lock_pid=$(cat "$lock/pid" 2>/dev/null || true) - if [ -z "$lock_pid" ] || ! kill -0 "$lock_pid" 2>/dev/null; then rm -rf "$lock"; fi -fi -mkdir "$lock" 2>/dev/null || { echo "Another ForgeFlow deployment is active for $root" >&2; exit 70; } -printf '%s\n' "$request_id" > "$lock/request-id" -printf '%s\n' "$$" > "$lock/pid" -date -u +%Y-%m-%dT%H:%M:%SZ > "$lock/started-at" -restore_needed=false -activation_started=false -is_preserved() { - rel="$1" - while IFS= read -r keep; do - [ -n "$keep" ] || continue - if [ "$rel" = "$keep" ] || [[ "$rel" == "$keep/"* ]]; then return 0; fi - done < "$staging.preserve" - return 1 +const deploymentMethods = createUnraidDeploymentMethods({ + path, crypto, bash, shellQuote, assertFullCommitSha, nativePath, fs, +}); +for (const name of Object.getOwnPropertyNames(deploymentMethods)) { + if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(deploymentMethods, name)); } -restore_files() { - if [ -f "$backup/present" ]; then - while IFS= read -r rel; do - [ -n "$rel" ] || continue - mkdir -p -- "$root/$(dirname "$rel")" - temp="$root/$rel.forgeflow-restore-$request_id" - cp -a -- "$backup/source/$rel" "$temp" && mv -f -- "$temp" "$root/$rel" - done < "$backup/present" - fi - if [ -f "$backup/absent" ]; then - while IFS= read -r rel; do - [ -n "$rel" ] || continue - case "$rel" in .forgeflow/*) continue ;; esac - [ -e "$root/$rel" ] || [ -L "$root/$rel" ] || continue - rm -f -- "$root/$rel" - done < "$backup/absent" - fi - if [ -f "$backup/generated.present" ]; then - cp -a "$backup/compose.forgeflow.yml" "$root/.forgeflow/compose.forgeflow.yml" - elif [ -f "$backup/generated.created" ]; then - rm -f "$root/.forgeflow/compose.forgeflow.yml" - fi - if [ -f "$backup/metadata.present" ]; then - cp -a "$backup/compose.metadata.yml" "$root/.forgeflow/compose.metadata.yml" - elif [ -f "$backup/metadata.created" ]; then - rm -f "$root/.forgeflow/compose.metadata.yml" - fi + +const runtimeMethods = createUnraidRuntimeMethods({ + safeRelativeRemoteFile, xmlEscape, nativePath, fileSystem, fs, crypto, os, run, + bash, shellQuote, iconReferenceLocalPath, +}); +for (const name of Object.getOwnPropertyNames(runtimeMethods)) { + if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(runtimeMethods, name)); } -restore_images() { - [ -f "$backup/containers.before" ] || return 0 - while IFS=$'\t' read -r service container_id image_id image_ref_b64; do - [ -n "$image_id" ] || continue - docker image inspect "$image_id" >/dev/null 2>&1 || continue - image_ref=$(printf '%s' "$image_ref_b64" | base64 -d 2>/dev/null || true) - case "$image_ref" in ''|sha256:*|*@sha256:*) continue ;; esac - docker image tag "$image_id" "$image_ref" >/dev/null 2>&1 || true - done < "$backup/containers.before" + +const preflightMethods = createUnraidPreflightMethods({ + safeRemoteFolder, path, bash, parseInspection, dockerIgnoreHasPath, checksSummary, + inventoryRemoteIdentity, deriveDetectedProfile, decodeBase64Json, shellQuote, + assertFullCommitSha, nativePath, safeRelativeRemoteFile, fs, +}); +for (const name of Object.getOwnPropertyNames(preflightMethods)) { + if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(preflightMethods, name)); } -restore_runtime() { - [ "$activation_started" = true ] || return 0 - restore_images - if cd "$root" 2>/dev/null && ${compose} config >/dev/null 2>&1; then - ${compose} up -d --no-build >/dev/null 2>&1 || return 1 - old_services=$(${compose} config --services 2>/dev/null | sed '/^$/d') - printf '%s\n' "$old_services" | while IFS= read -r service; do - [ -n "$service" ] || continue - old_id=$(${compose} ps -q "$service" | head -n1) - [ -n "$old_id" ] || exit 1 - [ "$(docker inspect -f '{{.State.Running}}' "$old_id" 2>/dev/null || echo false)" = true ] || exit 1 - done - fi + +const accessMethods = createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto }); +for (const name of Object.getOwnPropertyNames(accessMethods)) { + if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(accessMethods, name)); } -finish() { - status=$? - trap - EXIT - set +e - if [ "$status" -ne 0 ] && [ "$restore_needed" = true ]; then - restore_files - if ! restore_runtime; then - echo "CRITICAL: source files were restored, but the previous Compose runtime could not be restarted automatically. Backup: $backup" >&2 - else - echo "ForgeFlow restored the previous source and runtime after the failed activation." >&2 - fi - fi - rm -rf "$staging" "$lock" - exit "$status" -} -trap finish EXIT -actual_digest=$(if command -v sha256sum >/dev/null 2>&1; then sha256sum "$incoming" | awk '{print $1}'; else shasum -a 256 "$incoming" | awk '{print $1}'; fi) -[ "$actual_digest" = "$expected_digest" ] || { echo "Uploaded bundle checksum mismatch" >&2; exit 71; } -tar -tf "$incoming" > "$staging.entries" -if grep -E '(^/|(^|/)\\.\\.(/|$))' "$staging.entries" >/dev/null; then echo "Unsafe path detected in deployment bundle" >&2; exit 72; fi -rm -rf "$staging" "$release_root.pending" -mkdir -p "$staging/source" "$release_root.pending" -tar -xf "$incoming" -C "$staging/source" -if find "$staging/source" -type l -print -quit | grep -q .; then echo "Symbolic links are not accepted in push bundles" >&2; exit 73; fi -mv "$staging/source" "$release_root.pending/source" -find "$release_root.pending/source" -type f -printf '%P\n' | LC_ALL=C sort > "$release_root.pending/managed-files" -rm -rf "$release_root" -mv "$release_root.pending" "$release_root" -rm -f "$incoming" "$staging.entries" -printf '%s' ${shellQuote(preservePayload)} | base64 -d > "$staging.preserve" -for runtime_config in .env compose.override.yml compose.override.yaml docker-compose.override.yml docker-compose.override.yaml; do - if [ -f "$root/$runtime_config" ] && [ ! -e "$release/$runtime_config" ]; then - mkdir -p "$release/$(dirname "$runtime_config")" - cp -a "$root/$runtime_config" "$release/$runtime_config" - fi -done -${profile.generatedCompose ? `mkdir -p "$release/.forgeflow" -cat > "$release/.forgeflow/compose.forgeflow.yml" <<'FORGEFLOW_COMPOSE' -${generated}FORGEFLOW_COMPOSE -cat > "$release/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA' -${metadata}FORGEFLOW_METADATA` : ""} -cd "$release" -${candidateCompose} config >/dev/null -candidate_services=$(${candidateCompose} config --services 2>/dev/null | sed '/^$/d') -[ -n "$candidate_services" ] || { echo "Candidate Compose project defines no services" >&2; exit 60; } -mkdir -p "$backup/source" -: > "$backup/present" -: > "$backup/absent" -: > "$backup/containers.before" -had_existing_compose=false -if cd "$root" 2>/dev/null && ${compose} config >/dev/null 2>&1; then - had_existing_compose=true - old_services=$(${compose} config --services 2>/dev/null | sed '/^$/d') - printf '%s\n' "$old_services" | while IFS= read -r service; do - [ -n "$service" ] || continue - container_id=$(${compose} ps -q "$service" | head -n1) - image_id=''; image_ref='' - if [ -n "$container_id" ] && docker inspect "$container_id" >/dev/null 2>&1; then - image_id=$(docker inspect -f '{{.Image}}' "$container_id" 2>/dev/null || true) - image_ref=$(docker inspect -f '{{.Config.Image}}' "$container_id" 2>/dev/null || true) - fi - printf '%s\t%s\t%s\t%s\n' "$service" "$container_id" "$image_id" "$(printf '%s' "$image_ref" | base64 | tr -d '\r\n')" - done >> "$backup/containers.before" -fi -if [ -n "$tracked_container_hint" ] && docker inspect "$tracked_container_hint" >/dev/null 2>&1; then - hint_project=$(docker inspect -f '{{index .Config.Labels "com.docker.compose.project"}}' "$tracked_container_hint" 2>/dev/null || true) - if [ -n "$hint_project" ] && [ "$hint_project" != "$expected_project" ]; then - echo "Refusing activation: container $tracked_container_hint belongs to Compose project $hint_project, not $expected_project" >&2 - exit 67 - fi -fi -# Build all candidate images before any running container is touched. -cd "$release" -${candidateCompose} build -new_manifest="$release_root/managed-files" -while IFS= read -r rel; do - [ -n "$rel" ] || continue - is_preserved "$rel" && continue - parent=$(dirname "$rel") - current="$root" - if [ "$parent" != . ]; then - old_ifs=$IFS; IFS='/'; read -r -a parts <<< "$parent"; IFS=$old_ifs - for part in "\${parts[@]}"; do - current="$current/$part" - [ ! -L "$current" ] || { echo "Refusing to deploy through symlinked parent $current" >&2; exit 74; } - done - fi - [ ! -L "$root/$rel" ] || { echo "Refusing to replace symlinked managed path $rel" >&2; exit 74; } - if [ -d "$root/$rel" ]; then echo "A directory conflicts with managed file $rel" >&2; exit 75; fi - if [ -e "$root/$rel" ]; then - mkdir -p "$backup/source/$(dirname "$rel")" - cp -a -- "$root/$rel" "$backup/source/$rel" - printf '%s\n' "$rel" >> "$backup/present" - else - printf '%s\n' "$rel" >> "$backup/absent" - fi -done < "$new_manifest" -[ -f "$root/.forgeflow/compose.metadata.yml" ] && { cp -a "$root/.forgeflow/compose.metadata.yml" "$backup/compose.metadata.yml"; touch "$backup/metadata.present"; } -[ -f "$root/.forgeflow/compose.forgeflow.yml" ] && { cp -a "$root/.forgeflow/compose.forgeflow.yml" "$backup/compose.forgeflow.yml"; touch "$backup/generated.present"; } -restore_needed=true -while IFS= read -r rel; do - [ -n "$rel" ] || continue - is_preserved "$rel" && continue - mkdir -p -- "$root/$(dirname "$rel")" - temp="$root/$rel.forgeflow-new-$request_id" - cp -a -- "$release/$rel" "$temp" - mv -f -- "$temp" "$root/$rel" -done < "$new_manifest" -mkdir -p "$root/.forgeflow" -${profile.generatedCompose ? `if [ ! -f "$backup/generated.present" ]; then touch "$backup/generated.created"; fi -if [ ! -f "$backup/metadata.present" ]; then touch "$backup/metadata.created"; fi -cat > "$root/.forgeflow/compose.forgeflow.yml.pending" <<'FORGEFLOW_COMPOSE' -${generated}FORGEFLOW_COMPOSE -mv "$root/.forgeflow/compose.forgeflow.yml.pending" "$root/.forgeflow/compose.forgeflow.yml" -cat > "$root/.forgeflow/compose.metadata.yml.pending" <<'FORGEFLOW_METADATA' -${metadata}FORGEFLOW_METADATA -mv "$root/.forgeflow/compose.metadata.yml.pending" "$root/.forgeflow/compose.metadata.yml"` : `cat > "$root/.forgeflow/deployment-metadata.json.pending" <<'FORGEFLOW_METADATA_JSON' -${JSON.stringify({ repository: repository.fullName, environment: profile.environment, commit: targetSha, requestId })} -FORGEFLOW_METADATA_JSON -mv "$root/.forgeflow/deployment-metadata.json.pending" "$root/.forgeflow/deployment-metadata.json"`} -share_group=$(getent group users >/dev/null 2>&1 && echo users || id -gn) -chgrp "$share_group" "$root" "$root/.forgeflow" 2>/dev/null || true -chmod g+rwx "$root" "$root/.forgeflow" 2>/dev/null || true -chmod g+s "$root" "$root/.forgeflow" 2>/dev/null || true -while IFS= read -r rel; do - [ -n "$rel" ] || continue - is_preserved "$rel" && continue - chgrp "$share_group" "$root/$rel" 2>/dev/null || true - chmod u+rw,g+rw "$root/$rel" 2>/dev/null || true - parent="$root/$(dirname "$rel")" - chgrp "$share_group" "$parent" 2>/dev/null || true - chmod g+rwx,g+s "$parent" 2>/dev/null || true -done < "$new_manifest" -cd "$root" -${compose} config >/dev/null -actual_services=$(${compose} config --services 2>/dev/null | sed '/^$/d') -[ -n "$actual_services" ] || { echo "Compose project defines no services" >&2; exit 60; } -if [ "$(printf '%s\n' "$candidate_services" | LC_ALL=C sort)" != "$(printf '%s\n' "$actual_services" | LC_ALL=C sort)" ]; then - echo "Refusing activation because candidate and server Compose service sets differ" >&2 - exit 68 -fi -before_containers="$backup/containers.before" -hint_before_id='' -if [ -n "$tracked_container_hint" ] && docker inspect "$tracked_container_hint" >/dev/null 2>&1; then - hint_before_id=$(docker inspect -f '{{.Id}}' "$tracked_container_hint" 2>/dev/null || true) -fi -activation_started=true -${compose} up -d --no-build -${verification} -if [ -n "$hint_before_id" ] && docker inspect "$hint_before_id" >/dev/null 2>&1; then - old_hint_running=$(docker inspect -f '{{.State.Running}}' "$hint_before_id" 2>/dev/null || echo false) - [ "$old_hint_running" != true ] || { echo "Compose left the previous container $tracked_container_hint ($hint_before_id) running" >&2; exit 66; } -fi -${this.dockerManRefreshScript(profile, repository, iconReference)} -previous=$(cat "$root/.forgeflow/current-sha" 2>/dev/null || true) -[ -n "$previous" ] || previous=$(git -C "$root" rev-parse HEAD 2>/dev/null || true) -[ -n "$previous" ] && printf '%s' "$previous" > "$root/.forgeflow/previous-sha" -cp "$new_manifest" "$root/.forgeflow/managed-files.pending" -mv "$root/.forgeflow/managed-files.pending" "$root/.forgeflow/managed-files" -printf '%s' "$target" > "$root/.forgeflow/current-sha.pending" -mv "$root/.forgeflow/current-sha.pending" "$root/.forgeflow/current-sha" -cat > "$root/.forgeflow/status.json.pending" <<'FORGEFLOW_STATUS' -${statusJson} -FORGEFLOW_STATUS -mv "$root/.forgeflow/status.json.pending" "$root/.forgeflow/status.json" -restore_needed=false -printf '%s\n' "successful" > "$backup/result" -date -u +%Y-%m-%dT%H:%M:%SZ > "$backup/completed-at" -echo "ForgeFlow safely activated push bundle $target; rollback evidence retained at $backup" -`; - } - async executePushBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference, rollback = false }) { - const permissionReport = await this.inspectWriteAccess({ - repository, - profileId: profile.id, - }); - if (!permissionReport.ready) { - const error = new Error( - `Deployment stopped before upload because write access is missing for: ${permissionReport.blocking.map((item) => item.path).join(", ")}`, - ); - error.code = "REMOTE_WRITE_ACCESS_REQUIRED"; - error.permissionReport = permissionReport; - throw error; - } - const bundle = await this.createCommitBundle(repository, targetSha, requestId); - const remotePart = path.join(remotePath, ".forgeflow", "incoming", `${requestId}-${targetSha}.tar.part`); - try { - await this.ssh.exec(server.id, bash(`mkdir -p ${shellQuote(path.dirname(remotePart))}`), { timeout: 30_000 }); - await this.ssh.uploadFile(server.id, bundle.archivePath, remotePart, { mode: 0o600 }); - const script = this.pushBundleScript({ repository, profile, remotePath, targetSha, requestId, remotePart, digest: bundle.sha256, metadata, generated, iconReference, rollback }); - return await this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 8 * 1024 * 1024 }); - } finally { - await fs.rm(bundle.archivePath, { force: true }).catch(() => {}); - } - } - - async createServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId }) { - const remote = this.serverGitRemote(repository, profile); - const repositoryId = crypto.createHash("sha256").update(String(repository.fullName).toLowerCase()).digest("hex").slice(0, 24); - const cache = path.join(server.basePath, ".forgeflow", "git-cache", `${repositoryId}.git`); - const remotePart = path.join(remotePath, ".forgeflow", "incoming", `${requestId}-${targetSha}.tar.part`); - const marker = "__FORGEFLOW_SERVER_ARCHIVE__"; - const script = ` -cache=${shellQuote(cache)} -incoming=${shellQuote(remotePart)} -remote=${shellQuote(remote)} -branch=${shellQuote(profile.branch)} -target=${shellQuote(targetSha)} -mkdir -p "$(dirname "$cache")" "$(dirname "$incoming")" -if [ ! -d "$cache" ]; then git init --bare "$cache" >/dev/null; fi -if git --git-dir="$cache" remote get-url origin >/dev/null 2>&1; then - git --git-dir="$cache" remote set-url origin "$remote" -else - git --git-dir="$cache" remote add origin "$remote" -fi -${this.serverGitEnvironment(repository, profile, server)} git --git-dir="$cache" fetch --force --prune origin "+refs/heads/$branch:refs/remotes/origin/$branch" -git --git-dir="$cache" cat-file -e "$target^{commit}" -git --git-dir="$cache" merge-base --is-ancestor "$target" "refs/remotes/origin/$branch" -archive_tmp="$incoming.$$.tmp" -git --git-dir="$cache" archive --format=tar --output="$archive_tmp" "$target" -[ -s "$archive_tmp" ] || { rm -f "$archive_tmp"; echo "Gitea produced an empty deployment archive" >&2; exit 45; } -mv "$archive_tmp" "$incoming" -digest=$(if command -v sha256sum >/dev/null 2>&1; then sha256sum "$incoming" | awk '{print $1}'; else shasum -a 256 "$incoming" | awk '{print $1}'; fi) -printf '%s\n' ${shellQuote(marker)} -printf 'digest=%s\n' "$digest" -`; - const result = await this.ssh.exec(server.id, bash(script), { timeout: 5 * 60_000, maxOutput: 512 * 1024 }); - const output = String(result.stdout || ""); - const markerIndex = output.lastIndexOf(marker); - const digest = markerIndex >= 0 - ? String(output.slice(markerIndex + marker.length).match(/(?:^|\n)digest=([0-9a-f]{64})(?:\n|$)/i)?.[1] || "").toLowerCase() - : ""; - if (!digest) throw new Error("The server did not return a valid checksum for the Gitea archive."); - return { remotePart, digest }; - } - - async executeServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference, rollback = false }) { - const permissionReport = await this.inspectWriteAccess({ repository, profileId: profile.id }); - if (!permissionReport.ready) { - const error = new Error(`Deployment stopped before the Gitea fetch because write access is missing for: ${permissionReport.blocking.map((item) => item.path).join(", ")}`); - error.code = "REMOTE_WRITE_ACCESS_REQUIRED"; - error.permissionReport = permissionReport; - throw error; - } - const bundle = await this.createServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId }); - const script = this.pushBundleScript({ repository, profile, remotePath, targetSha, requestId, remotePart: bundle.remotePart, digest: bundle.digest, metadata, generated, iconReference, rollback }); - return this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 8 * 1024 * 1024 }); - } - - async deploy({ repository, profileId, sha }) { - const targetSha = assertFullCommitSha(sha); - const { profile, server, remotePath } = this.resolve(repository, profileId); - if (profile.deploymentMode === "server-git") { - const verification = await this.verifyServerGitProfile({ repository, profileId }); - const requiredChecks = ["remote-branch", "deploy-key-scope", "server-git-access"]; - const blocked = verification.checks.filter((check) => requiredChecks.includes(check.id) && check.status !== "pass"); - if (blocked.length || !verification.branchSha) { - const error = new Error(`Server pull verification failed: ${blocked.map((check) => check.detail).join("; ") || "the target branch could not be proven"}`); - error.code = "SERVER_GIT_VERIFICATION_FAILED"; - error.verification = verification; - throw error; - } - } - const preflight = await this.preflight({ repository, profileId, sha: targetSha }); - if (!preflight.summary.ready) { - const error = new Error(`SSH deployment preflight failed: ${preflight.summary.blocking.join(", ")}`); - error.code = "SSH_DEPLOYMENT_PREFLIGHT_FAILED"; - throw error; - } - const requestId = crypto.randomUUID(); - const mode = ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode) - ? profile.deploymentMode - : "push-bundle"; - const operation = await this.saveOperation({ - id: requestId, - type: "deployment", - action: "deploy", - provider: "ssh-unraid", - repository: repository.fullName, - environment: profile.environment, - profileId, - serverId: server.id, - remotePath, - sha: targetSha, - shortSha: targetSha.slice(0, 7), - status: "running", - logs: [ - "Preflight passed.", - mode === "push-bundle" - ? "Creating and uploading the exact committed local project directly to Unraid." - : mode === "server-git" - ? "Fetching the exact commit from Gitea with a repository-scoped read-only deploy key." - : "This workload is monitor-only and cannot be deployed.", - `Deploying exact commit ${targetSha} in the background.`, - ], - }); - - const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : ""; - const iconReference = await this.prepareIcon(profile, repository, server); - const metadata = this.metadataCompose(profile, repository, iconReference, { - sha: targetSha, - repositoryUrl: profile.cloneUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName, - }); - const previousState = this.store.getDeploymentState?.(profileId) || null; - - void (async () => { - try { - if (mode === "monitor-only") throw new Error("This workload is monitor-only. Select Server pull or Direct copy before deploying."); - const result = mode === "server-git" - ? await this.executeServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference }) - : await this.executePushBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference }); - const health = await this.checkHealth(profile.healthcheckUrl); - const finalStatus = health.healthy === false ? "failed" : "success"; - const completed = await this.saveOperation({ - ...operation, - status: finalStatus, - previousSha: previousState?.liveSha || preflight.inspection?.head || null, - health, - logs: [ - ...operation.logs, - ...result.stdout.trim().split("\n").filter(Boolean).slice(-80), - "Docker Compose activation and runtime verification completed.", - health.configured - ? `Healthcheck ${health.healthy ? "passed" : "failed"}${health.status ? ` with HTTP ${health.status}` : ""}.` - : "No desktop healthcheck configured; running containers were verified and health remains unverified.", - ], - error: health.healthy === false ? "The application healthcheck did not pass after deployment." : null, - }); - await this.store.saveDeploymentState(profileId, { - liveSha: targetSha, - previousSha: previousState?.liveSha || preflight.inspection?.head || null, - healthy: health.configured ? health.healthy : null, - runtimeVerification: health.configured ? "desktop-healthcheck" : "running-unverified", - healthStatus: health.status ?? null, - healthLatencyMs: health.latencyMs ?? null, - requestId, - remotePath, - provider: "ssh-unraid", - deploymentMode: mode, - containerName: String(profile.containerName || profile.remoteFolder || repository.name), - containerRunning: true, - dockerMan: { - webUi: this.dockerManWebUi(profile), - icon: iconReference, - shell: this.dockerManShell(profile), - templateExists: profile.manageDockerMan === true || previousState?.dockerMan?.templateExists === true, - configured: Boolean(this.dockerManWebUi(profile) || iconReference || previousState?.dockerMan?.configured), - }, - webUiUrl: profile.webUiUrl || (profile.hostPort ? `http://${server.host}:${profile.hostPort}/` : null), - }); - void this.refreshProfileState(repository.fullName, profileId).catch(() => {}); - await this.diagnostics?.info("unraid.deployment.completed", { requestId, repository: repository.fullName, serverId: server.id, remotePath, sha: targetSha, status: completed.status }); - } catch (error) { - await this.saveOperation({ - ...operation, - status: "failed", - error: error.message, - failure: { stage: mode === "server-git" ? "Gitea server pull / Compose activation" : "Direct copy / Compose activation", message: error.message }, - logs: [...operation.logs, error.message, "The live SHA was not promoted. Previous release evidence remains authoritative."], - }); - await this.diagnostics?.error("unraid.deployment.failed", { requestId, repository: repository.fullName, serverId: server.id, remotePath, sha: targetSha, error }); - } - })(); - - return operation; - } - - async rollback({ repository, profileId, targetSha }) { - const target = assertFullCommitSha(targetSha); - const { profile, server, remotePath } = this.resolve(repository, profileId); - const deploymentState = this.store.getDeploymentState(profileId); - if (!deploymentState?.previousSha || deploymentState.previousSha !== target) { - const error = new Error("Rollback is allowed only to the exact previous SHA reported by ForgeFlow for this deployment profile."); - error.code = "ROLLBACK_TARGET_NOT_PREVIOUS_SHA"; - throw error; - } - const rollbackMode = ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode) - ? profile.deploymentMode - : "push-bundle"; - if (rollbackMode === "push-bundle" && !repository.localPath) - throw new Error("A linked local repository is required for Direct copy rollback verification."); - const requestId = crypto.randomUUID(); - const operation = await this.saveOperation({ - id: requestId, - type: "deployment", - action: "rollback", - provider: "ssh-unraid", - repository: repository.fullName, - environment: profile.environment, - profileId, - serverId: server.id, - remotePath, - sha: target, - shortSha: target.slice(0, 7), - status: "running", - logs: [`Rolling back to exact commit ${target}.`], - }); - const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : ""; - const iconReference = await this.prepareIcon(profile, repository, server); - const metadata = this.metadataCompose(profile, repository, iconReference, { - sha: target, - repositoryUrl: profile.cloneUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName, - }); - try { - const mode = rollbackMode; - if (mode === "monitor-only") throw new Error("This workload is monitor-only. Select Server pull or Direct copy before rolling back."); - const result = mode === "server-git" - ? await this.executeServerGitBundle({ repository, profile, server, remotePath, targetSha: target, requestId, metadata, generated, iconReference, rollback: true }) - : await this.executePushBundle({ repository, profile, server, remotePath, targetSha: target, requestId, metadata, generated, iconReference, rollback: true }); - const health = await this.checkHealth(profile.healthcheckUrl); - const finalStatus = health.healthy === false ? "failed" : "rolled-back"; - const completed = await this.saveOperation({ - ...operation, - status: finalStatus, - previousSha: deploymentState.liveSha || null, - health, - error: health.healthy === false ? "The application healthcheck did not pass after rollback." : null, - logs: [ - ...operation.logs, - ...result.stdout.trim().split("\n").filter(Boolean).slice(-80), - "Rollback activation completed.", - health.configured ? `Healthcheck ${health.healthy ? "passed" : "failed"}.` : "Runtime is running; no desktop healthcheck was configured.", - ], - }); - await this.store.saveDeploymentState(profileId, { - liveSha: target, - previousSha: deploymentState.liveSha || null, - healthy: health.configured ? health.healthy : null, - runtimeVerification: health.configured ? "desktop-healthcheck" : "running-unverified", - healthStatus: health.status ?? null, - healthLatencyMs: health.latencyMs ?? null, - requestId, - remotePath, - provider: "ssh-unraid", - containerRunning: true, - }); - if (health.healthy === false) { - const error = new Error("Rollback completed, but the configured healthcheck failed."); - error.code = "ROLLBACK_HEALTHCHECK_FAILED"; - error.operationId = completed.id; - throw error; - } - return completed; - } catch (error) { - if (error.code !== "ROLLBACK_HEALTHCHECK_FAILED") { - await this.saveOperation({ ...operation, status: "failed", error: error.message, logs: [...operation.logs, error.message] }); - } - throw error; - } - } - - async refreshProfileState(fullName, profileId, expectedGiteaSha = null) { - const repository = { fullName, name: fullName.split("/").pop() }; - const { profile, server, remotePath } = this.resolve(repository, profileId); - const containerName = String( - profile.containerName || profile.remoteFolder || repository.name, - ); - const script = ` -root=${shellQuote(remotePath)} -container=${shellQuote(containerName)} -template_path=${shellQuote("/boot/config/plugins/dockerMan/templates-user/my-" + containerName + ".xml")} -live=""; previous=""; running=false; docker_health=""; webui=""; icon=""; shell_label=""; template_exists=false -[ -f "$template_path" ] && template_exists=true -[ -f "$root/.forgeflow/current-sha" ] && live=$(cat "$root/.forgeflow/current-sha") -[ -z "$live" ] && [ -d "$root/.git" ] && live=$(git -C "$root" rev-parse HEAD 2>/dev/null || true) -[ -f "$root/.forgeflow/previous-sha" ] && previous=$(cat "$root/.forgeflow/previous-sha") -if docker inspect "$container" >/dev/null 2>&1; then - running=$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null || echo false) - docker_health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container" 2>/dev/null || true) - webui=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.webui"}}' "$container" 2>/dev/null || true) - icon=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.icon"}}' "$container" 2>/dev/null || true) - shell_label=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.shell"}}' "$container" 2>/dev/null || true) - [ -z "$live" ] && live=$(docker inspect -f '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$container" 2>/dev/null || true) - [ -z "$live" ] && live=$(docker inspect -f '{{index .Config.Labels "tech.itworx.forgeflow.commit"}}' "$container" 2>/dev/null || true) -fi -printf '__FORGEFLOW_KV__\n' -printf 'liveSha=%s\n' "$live" -printf 'previousSha=%s\n' "$previous" -printf 'containerRunning=%s\n' "$running" -printf 'dockerHealth=%s\n' "$docker_health" -printf 'webUiLabel=%s\n' "$(printf '%s' "$webui" | base64 | tr -d '\r\n')" -printf 'iconLabel=%s\n' "$(printf '%s' "$icon" | base64 | tr -d '\r\n')" -printf 'shellLabel=%s\n' "$(printf '%s' "$shell_label" | base64 | tr -d '\r\n')" -printf 'templateExists=%s\n' "$template_exists" -`; - const result = await this.ssh.exec(server.id, bash(script), { - timeout: 30_000, - }); - const marker = result.stdout.lastIndexOf("__FORGEFLOW_KV__"); - if (marker < 0) - throw new Error( - "Unraid state inspection did not return a ForgeFlow marker.", - ); - const fields = {}; - for (const line of result.stdout - .slice(marker + "__FORGEFLOW_KV__".length) - .trim() - .split(/\r?\n/)) { - const index = line.indexOf("="); - if (index > 0) fields[line.slice(0, index)] = line.slice(index + 1); - } - const decode = (value) => { - try { - return value ? Buffer.from(value, "base64").toString("utf8") : ""; - } catch { - return ""; - } - }; - const health = await this.checkHealth(profile.healthcheckUrl); - const dockerHealthy = fields.dockerHealth - ? fields.dockerHealth === "healthy" - : null; - const effectiveHealthy = health.configured ? health.healthy : dockerHealthy; - const runtimeVerification = health.configured - ? "desktop-healthcheck" - : dockerHealthy === true - ? "docker-healthcheck" - : dockerHealthy === false - ? "docker-unhealthy" - : fields.containerRunning === "true" - ? "running-unverified" - : "stopped"; - return this.store.saveDeploymentState(profile.id, { - liveSha: /^[0-9a-f]{40}$/i.test(fields.liveSha || "") - ? fields.liveSha - : null, - previousSha: /^[0-9a-f]{40}$/i.test(fields.previousSha || "") - ? fields.previousSha - : null, - healthy: effectiveHealthy, - runtimeVerification, - healthStatus: health.status, - healthLatencyMs: health.latencyMs, - containerName, - containerRunning: fields.containerRunning === "true", - dockerHealth: fields.dockerHealth || null, - dockerMan: { - webUi: decode(fields.webUiLabel), - icon: decode(fields.iconLabel), - shell: decode(fields.shellLabel), - templateExists: fields.templateExists === "true", - configured: Boolean( - decode(fields.webUiLabel) || - decode(fields.iconLabel) || - fields.templateExists === "true", - ), - }, - webUiUrl: - profile.webUiUrl || - (profile.hostPort - ? `http://${server.host}:${profile.hostPort}/` - : null), - remotePath, - provider: "ssh-unraid", - giteaSha: /^[0-9a-f]{40}$/i.test(String(expectedGiteaSha || "")) - ? expectedGiteaSha - : null, - matchesGitea: - /^[0-9a-f]{40}$/i.test(String(expectedGiteaSha || "")) && - fields.liveSha === expectedGiteaSha, - }); - } - - async applyDockerManMetadata({ repository, profileId }) { - const { profile, server, remotePath } = this.resolve(repository, profileId); - if (profile.generatedCompose !== true) { - // Existing Compose files remain authoritative. Applying a generated - // labels-only service fragment can create a phantom service when a stale - // profile hint no longer matches the real Compose service keys. - return this.refreshProfileState(repository.fullName, profileId); - } - const composeFile = profile.generatedCompose - ? ".forgeflow/compose.forgeflow.yml" - : safeRelativeRemoteFile(profile.composeFile || "docker-compose.yml"); - const iconReference = await this.prepareIcon(profile, repository, server); - const metadata = this.metadataCompose(profile, repository, iconReference); - const compose = this.composeInvocation(profile, repository); - const flags = this.composeUpFlags(profile); - const script = ` -root=${shellQuote(remotePath)} -test -d "$root" -mkdir -p "$root/.forgeflow" -cat > "$root/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA' -${metadata}FORGEFLOW_METADATA -cd "$root" -${compose} config >/dev/null -${compose} up -d --build ${flags} -${this.containerVerificationScript(profile, repository, compose)} -${this.dockerManRefreshScript(profile, repository, iconReference)} -`; - await this.ssh.exec(server.id, bash(script), { - timeout: 10 * 60_000, - maxOutput: 2 * 1024 * 1024, - }); - return this.refreshProfileState(repository.fullName, profileId); - } - - async refreshOperation( - operationId, - { includeTerminal = false, state: suppliedState = null } = {}, - ) { - const operation = this.store.getOperation(operationId); - if (!operation || operation.provider !== "ssh-unraid") return operation; - if ( - !includeTerminal && - ["success", "failed", "cancelled", "rolled-back"].includes( - operation.status, - ) - ) - return operation; - try { - const state = - suppliedState || - (await this.refreshProfileState( - operation.repository, - operation.profileId, - )); - if ( - state.liveSha === operation.sha && - state.containerRunning && - state.healthy !== false - ) { - return this.saveOperation({ - ...operation, - status: operation.action === "rollback" ? "rolled-back" : "success", - health: { healthy: state.healthy, status: state.healthStatus }, - logs: [ - ...(operation.logs || []), - "Deployment state reconciled from Unraid.", - ], - }); - } - if ( - /^[0-9a-f]{40}$/i.test(String(state.liveSha || "")) && - state.liveSha !== operation.sha && - state.containerRunning && - state.healthy !== false - ) { - return this.saveOperation({ - ...operation, - status: "cancelled", - error: `Superseded by live commit ${state.liveSha.slice(0, 7)}.`, - health: { healthy: state.healthy, status: state.healthStatus }, - logs: [ - ...(operation.logs || []), - `Operation superseded by live Unraid commit ${state.liveSha}.`, - ], - }); - } - const ageMs = - Date.now() - - new Date(operation.updatedAt || operation.createdAt || 0).getTime(); - if (ageMs > 45 * 60_000) { - return this.saveOperation({ - ...operation, - status: "failed", - error: - "Deployment was interrupted or did not reach the requested commit within 45 minutes.", - logs: [ - ...(operation.logs || []), - "Stale deployment was marked failed during reconciliation.", - ], - }); - } - return operation; - } catch { - return operation; - } - } - - async reconcileRecordedOperations(profileId, state) { - const operations = this.store.data.operations - .filter( - (item) => - item.profileId === profileId && item.provider === "ssh-unraid", - ) - .sort( - (left, right) => - new Date(right.updatedAt || right.createdAt || 0) - - new Date(left.updatedAt || left.createdAt || 0), - ); - const matching = operations.find( - (item) => item.sha === state.liveSha && item.status === "failed", - ); - if (matching && state.containerRunning && state.healthy !== false) { - await this.refreshOperation(matching.id, { - includeTerminal: true, - state, - }); - } - const latestFailed = operations.find((item) => item.status === "failed"); - if ( - latestFailed && - latestFailed.id !== matching?.id && - state.matchesGitea && - state.containerRunning && - state.healthy !== false - ) { - await this.saveOperation({ - ...latestFailed, - status: "cancelled", - error: `Superseded by Gitea/live commit ${state.liveSha.slice(0, 7)}.`, - logs: [ - ...(latestFailed.logs || []), - `Reconciled: Gitea and Unraid now both report ${state.liveSha}.`, - ], - }); - } - return this.store.data.operations - .filter((item) => item.profileId === profileId) - .slice(0, 10); - } - - async refreshActiveOperations() { - const active = this.store.data.operations.filter( - (item) => - item.provider === "ssh-unraid" && - item.type === "deployment" && - !["success", "failed", "cancelled", "rolled-back"].includes( - item.status, - ), - ); - return Promise.all(active.map((item) => this.refreshOperation(item.id))); - } +const inventoryMethods = createUnraidInventoryMethods({ + shellQuote, path, parseWorkloadInventory, buildWorkloadInventory, classifyInventory, + inventoryRemoteIdentity, deriveDetectedProfile, crypto, matchInventoryContainer, + safeRemoteFolder, bash, +}); +for (const name of Object.getOwnPropertyNames(inventoryMethods)) { + if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(inventoryMethods, name)); } module.exports = { diff --git a/src/main/unraid-inventory-methods.cjs b/src/main/unraid-inventory-methods.cjs new file mode 100644 index 0000000..7d77666 --- /dev/null +++ b/src/main/unraid-inventory-methods.cjs @@ -0,0 +1,579 @@ +"use strict"; + +function createUnraidInventoryMethods({ + shellQuote, path, parseWorkloadInventory, buildWorkloadInventory, classifyInventory, + inventoryRemoteIdentity, deriveDetectedProfile, crypto, matchInventoryContainer, + safeRemoteFolder, bash, +}) { + class UnraidInventoryMethods { + inventoryScript(server) { + const configuredRoots = [...new Set([server.basePath, ...(server.scanRoots || [])])].map((root) => ` add_scan_root ${shellQuote(root)}`).join("\n"); + const configuredExcludes = (server.scanExcludes || []).map((name) => ` -o -name ${shellQuote(name)}`).join(""); + return ` + base=${shellQuote(server.basePath)} + platform=$(uname -srm 2>/dev/null || true) + docker_ok=false; compose_ok=false; compose_v2=false; git_ok=false; tar_ok=false; checksum_ok=false; base_writable=false; compose_version='' + command -v docker >/dev/null 2>&1 && docker_ok=true + if [ "$docker_ok" = true ]; then + if docker compose version >/dev/null 2>&1; then compose_ok=true; compose_v2=true; compose_version=$(docker compose version 2>/dev/null | head -n1); elif command -v docker-compose >/dev/null 2>&1; then compose_ok=true; compose_version=$(docker-compose version 2>/dev/null | head -n1); fi + fi + command -v git >/dev/null 2>&1 && git_ok=true + command -v tar >/dev/null 2>&1 && tar_ok=true + (command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1) && checksum_ok=true + if [ -d "$base" ]; then [ -w "$base" ] && base_writable=true; else parent=$(dirname "$base"); [ -d "$parent" ] && [ -w "$parent" ] && base_writable=true; fi + printf '__FORGEFLOW_INVENTORY__\\n' + printf 'H\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' "$docker_ok" "$compose_ok" "$git_ok" "$tar_ok" "$checksum_ok" "$base_writable" "$(printf '%s' "$compose_version" | base64 | tr -d '\\r\\n')" "$(printf '%s' "$platform" | base64 | tr -d '\\r\\n')" + ids='' + if [ "$docker_ok" != true ]; then + printf 'W\\t%s\\n' "$(printf '%s' 'Docker is not installed or not in PATH. Compose files and DockerMan templates will still be scanned.' | base64 | tr -d '\\r\\n')" + else + if ! ids=$(docker ps -aq --no-trunc 2>&1); then + printf 'W\\t%s\\n' "$(printf '%s' "Docker inventory failed: $ids. Compose files and DockerMan templates will still be scanned." | head -c 2000 | base64 | tr -d '\\r\\n')" + ids='' + fi + fi + if [ -n "$ids" ]; then + disappeared=0 + while IFS= read -r container_id; do + [ -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 + printf 'C\\t%s\\n' "$(printf '%s' "$inspect" | base64 | tr -d '\\r\\n')" + else + disappeared=$((disappeared + 1)) + fi + done <<< "$ids" + if [ "$disappeared" -gt 0 ]; then + printf 'W\\t%s\\n' "$(printf '%s' "$disappeared stale container reference(s) disappeared during inventory; current containers were still processed." | base64 | tr -d '\\r\\n')" + fi + fi + templates_dir=/boot/config/plugins/dockerMan/templates-user + if [ -d "$templates_dir" ]; then + find "$templates_dir" -maxdepth 1 -type f -name '*.xml' -print0 2>/dev/null | while IFS= read -r -d '' template; do + read_tag() { sed -n "s#.*<$1>\\(.*\\).*#\\1#p" "$template" | head -n1; } + name=$(read_tag Name) + [ -n "$name" ] || continue + printf 'D\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \\ + "$(printf '%s' "$name" | base64 | tr -d '\\r\\n')" \\ + "$(printf '%s' "$template" | base64 | tr -d '\\r\\n')" \\ + "$(printf '%s' "$(read_tag WebUI)" | base64 | tr -d '\\r\\n')" \\ + "$(printf '%s' "$(read_tag Icon)" | base64 | tr -d '\\r\\n')" \\ + "$(printf '%s' "$(read_tag Shell)" | base64 | tr -d '\\r\\n')" \\ + "$(printf '%s' "$(read_tag Repository)" | base64 | tr -d '\\r\\n')" \\ + "$(printf '%s' "$(read_tag Network)" | base64 | tr -d '\\r\\n')" + done + fi + if [ "$compose_ok" = true ]; then + compose_projects=$(docker compose ls --all --format json 2>/dev/null || docker-compose ls --all --format json 2>/dev/null || true) + if [ -n "$compose_projects" ]; then + printf 'P\\t%s\\n' "$(printf '%s' "$compose_projects" | base64 | tr -d '\\r\\n')" + fi + + fi + + scan_roots=() + add_scan_root() { + candidate=$1 + [ -d "$candidate" ] || return 0 + for existing in "\${scan_roots[@]}"; do [ "$existing" = "$candidate" ] && return 0; done + scan_roots+=("$candidate") + } + ${configuredRoots} + + for root in "\${scan_roots[@]}"; do + scan_error=$(mktemp) + while IFS= read -r -d '' primary; do + dir=$(dirname "$primary") + filename=$(basename "$primary") + case "$filename" in + compose.override.yml|compose.override.yaml|docker-compose.override.yml|docker-compose.override.yaml) continue ;; + compose.yml) ;; + compose.yaml) [ -f "$dir/compose.yml" ] && continue ;; + docker-compose.yml) { [ -f "$dir/compose.yml" ] || [ -f "$dir/compose.yaml" ]; } && continue ;; + docker-compose.yaml) { [ -f "$dir/compose.yml" ] || [ -f "$dir/compose.yaml" ] || [ -f "$dir/docker-compose.yml" ]; } && continue ;; + *) { [ -f "$dir/compose.yml" ] || [ -f "$dir/compose.yaml" ] || [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/docker-compose.yaml" ]; } && continue ;; + esac + ( + set -- -f "$primary" + files_text=$primary + for extra in "$dir/compose.override.yml" "$dir/compose.override.yaml" "$dir/docker-compose.override.yml" "$dir/docker-compose.override.yaml"; do + [ -f "$extra" ] || continue + set -- "$@" -f "$extra" + files_text="$files_text + $extra" + done + project_name=$(sed -n 's/^name:[[:space:]]*//p' "$primary" 2>/dev/null | head -n1 | cut -d'#' -f1 | tr -d '"' | tr -d "'" | xargs 2>/dev/null || true) + [ -n "$project_name" ] || project_name=$(basename "$dir") + valid=false; services=''; images=''; compose_error='' + if [ "$compose_ok" != true ]; then + compose_error='Docker Compose is unavailable; file metadata was still detected.' + elif [ "$compose_v2" = true ]; then + if services=$(cd "$dir" && docker compose "$@" config --services 2>&1); then + valid=true + images=$(cd "$dir" && docker compose "$@" config --images 2>/dev/null || true) + else compose_error=$services; services=''; fi + else + if services=$(cd "$dir" && docker-compose "$@" config --services 2>&1); then + valid=true + images=$(cd "$dir" && docker-compose "$@" config --images 2>/dev/null || true) + else compose_error=$services; services=''; fi + fi + if [ -z "$services" ]; then + services=$(awk ' + /^[[:space:]]*services:[[:space:]]*($|#)/ { in_services=1; next } + in_services && /^[^[:space:]]/ { exit } + in_services && /^ [A-Za-z0-9._-]+:[[:space:]]*($|#)/ { + line=$0; sub(/^[[:space:]]*/, "", line); sub(/:.*/, "", line); print line + } + ' "$primary" 2>/dev/null || true) + fi + if [ -z "$images" ]; then + images=$(awk ' + /^[[:space:]]*services:[[:space:]]*($|#)/ { in_services=1; next } + in_services && /^[^[:space:]]/ { exit } + in_services && /^[[:space:]]+image:[[:space:]]*/ { + line=$0; sub(/^[[:space:]]*image:[[:space:]]*/, "", line); sub(/[[:space:]]+#.*/, "", line); gsub(/"/, "", line); print line + } + ' "$primary" 2>/dev/null || true) + fi + printf 'Y\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \\ + "$(printf '%s' "$dir" | base64 | tr -d '\\r\\n')" \\ + "$(printf '%s' "$files_text" | base64 | tr -d '\\r\\n')" \\ + "$(printf '%s' "$project_name" | base64 | tr -d '\\r\\n')" \\ + "$(printf '%s' "$services" | base64 | tr -d '\\r\\n')" \\ + "$(printf '%s' "$images" | base64 | tr -d '\\r\\n')" \\ + "$valid" \\ + "$(printf '%s' "$compose_error" | head -c 2000 | base64 | tr -d '\\r\\n')" + ) + done < <(find "$root" -mindepth 2 -maxdepth 4 \\( -type d \\( -name .git -o -name node_modules -o -name .forgeflow -o -name releases -o -name backups -o -name staging -o -name incoming -o -name '_audit_quarantine' -o -name 'devrunbook-validation' -o -name 'source-pre-*' -o -name cache -o -name caches -o -name logs -o -name database -o -name databases${configuredExcludes} \\) -prune \\) -o \\( -type f \\( -name '*compose*.yml' -o -name '*compose*.yaml' -o -name 'stack.yml' -o -name 'stack.yaml' \\) -print0 \\) 2>"$scan_error" || true) + if [ -s "$scan_error" ]; then + scan_message=$(printf 'Inventory scan partially failed for %s: %s' "$root" "$(head -n 1 "$scan_error")") + printf 'W\\t%s\\n' "$(printf '%s' "$scan_message" | base64 | tr -d '\\r\\n')" + fi + rm -f "$scan_error" + done + `; + } + + allSshProfiles() { + const result = []; + for (const [repositoryFullName, profiles] of Object.entries(this.store.data?.deploymentProfiles || {})) { + for (const profile of profiles || []) { + if (profile?.provider === "ssh-unraid") result.push({ ...profile, _repositoryFullName: repositoryFullName }); + } + } + return result; + } + + relativeComposeFiles(workload) { + const workingDir = String(workload.compose?.workingDir || "").replace(/\/+$/, ""); + const files = (workload.compose?.configFiles || []).map((file) => { + const value = String(file || "").trim(); + if (workingDir && value.startsWith(`${workingDir}/`)) return value.slice(workingDir.length + 1); + return value.startsWith("/") ? path.basename(value) : value; + }).filter(Boolean); + return [...new Set(files.length ? files : ["docker-compose.yml"])]; + } + + profileFromWorkload(repository, server, workload, { linkSource = "manual", deploymentMode = "server-git", remoteFolder = "" } = {}) { + const effectiveDeploymentMode = ["push-bundle", "server-git", "monitor-only"].includes(deploymentMode) + ? deploymentMode + : "server-git"; + const selectedFolder = safeRemoteFolder(remoteFolder || workload.remoteFolderCandidate || repository.name); + const composeFiles = this.relativeComposeFiles(workload); + const services = [...new Set((workload.compose?.services || []) + .map((service) => String(service || "").trim().toLowerCase().replace(/[^a-z0-9._-]/g, "-")) + .filter(Boolean))]; + const primary = workload.containers.find((container) => container.running) || workload.containers[0] || {}; + const primaryPort = (primary.ports || []).find((item) => item.hostPort) || primary.ports?.[0] || {}; + const remotePath = path.join(server.basePath, selectedFolder); + const preservePaths = new Set([".env", "appdata", "data", "logs", "config", "compose.override.yml"]); + for (const container of workload.containers || []) { + for (const mount of container.mounts || []) { + const source = String(mount.source || ""); + if (!source.startsWith(`${remotePath}/`)) continue; + const relative = source.slice(remotePath.length + 1).split("/")[0]; + if (relative) preservePaths.add(relative); + } + } + const idPrefix = String(linkSource).startsWith("automatic") ? "auto" : "link"; + const profileId = `${idPrefix}-${crypto.createHash("sha256").update(`${server.id}:${repository.fullName}:${workload.workloadId}`).digest("hex").slice(0, 20)}`; + return { + id: profileId, + name: `${server.name} · ${workload.displayName}`, + environment: "production", + provider: "ssh-unraid", + branch: workload.metadata?.branch || repository.defaultBranch || "main", + serverId: server.id, + remoteFolder: selectedFolder, + deploymentMode: effectiveDeploymentMode, + composeFile: composeFiles[0], + composeFiles, + composeProject: workload.compose?.project || "", + composeWorkingDir: workload.compose?.workingDir || "", + composeService: services[0] || String(primary.service || selectedFolder.split("/").pop()).toLowerCase().replace(/[^a-z0-9._-]/g, "-") || "app", + composeServices: services.length ? services : [String(primary.service || selectedFolder.split("/").pop()).toLowerCase().replace(/[^a-z0-9._-]/g, "-") || "app"], + containerName: primary.name || selectedFolder.split("/").pop(), + cloneUrl: workload.metadata?.sourceRepository || repository.sshUrl || repository.cloneUrl || "", + alignRemote: false, + hostPort: primaryPort.hostPort || null, + containerPort: primaryPort.containerPort || null, + webUiUrl: workload.metadata?.webUiUrl || workload.dockerMan?.webUiUrl || "", + iconMode: "none", + iconUrl: "", + iconFilePath: "", + serverIconReference: workload.metadata?.iconUrl || workload.dockerMan?.iconUrl || "", + dockerShell: ["/bin/bash", "/bin/sh"].includes(workload.metadata?.shell) ? workload.metadata.shell : "/bin/sh", + preservePaths: [...preservePaths], + generatedCompose: false, + adoptedFromServer: true, + serverSourceOfTruth: true, + manageDockerMan: false, + forceRecreate: false, + removeOrphans: false, + workloadIdentity: { + workloadId: workload.workloadId, + selector: workload.selector, + linkSource, + linkedAt: new Date().toISOString(), + }, + detectedAt: new Date().toISOString(), + detectedMetadata: { + source: `${linkSource}-server-inventory`, + kind: workload.kind, + composeProject: workload.compose?.project || "", + composeFiles, + services, + image: primary.image || "", + dockerManTemplatePath: workload.dockerMan?.templatePath || "", + }, + confirmationRequired: !String(linkSource).startsWith("automatic"), + }; + } + + refreshedProfileFromWorkload(repository, server, workload, existingProfile) { + const detected = this.profileFromWorkload(repository, server, workload, { + linkSource: existingProfile.workloadIdentity?.linkSource || "automatic-compose", + deploymentMode: ["push-bundle", "server-git", "monitor-only"].includes(existingProfile.deploymentMode) + ? existingProfile.deploymentMode + : "push-bundle", + remoteFolder: workload.remoteFolderCandidate || existingProfile.remoteFolder, + }); + return { + ...existingProfile, + deploymentMode: detected.deploymentMode, + remoteFolder: detected.remoteFolder, + composeFile: detected.composeFile, + composeFiles: detected.composeFiles, + composeProject: detected.composeProject, + composeWorkingDir: detected.composeWorkingDir, + composeService: detected.composeService, + composeServices: detected.composeServices, + containerName: detected.containerName || existingProfile.containerName, + hostPort: detected.hostPort || existingProfile.hostPort || null, + containerPort: detected.containerPort || existingProfile.containerPort || null, + webUiUrl: detected.webUiUrl || existingProfile.webUiUrl || "", + serverIconReference: detected.serverIconReference || existingProfile.serverIconReference || "", + dockerShell: detected.dockerShell || existingProfile.dockerShell || "/bin/sh", + preservePaths: [...new Set([...(existingProfile.preservePaths || []), ...(detected.preservePaths || [])])], + generatedCompose: false, + adoptedFromServer: true, + serverSourceOfTruth: true, + manageDockerMan: false, + forceRecreate: false, + removeOrphans: false, + workloadIdentity: detected.workloadIdentity, + detectedAt: detected.detectedAt, + detectedMetadata: detected.detectedMetadata, + }; + } + + async saveWorkloadState(profile, workload, server) { + const candidateSha = String(workload.metadata?.liveRevision || ""); + const previousState = this.store.getDeploymentState?.(profile.id) || {}; + const observedLiveSha = /^[0-9a-f]{40,64}$/i.test(candidateSha) ? candidateSha.toLowerCase() : null; + const liveSha = observedLiveSha || previousState.liveSha || null; + const profileRemote = inventoryRemoteIdentity(profile.cloneUrl); + const workloadRemote = inventoryRemoteIdentity(workload.metadata?.sourceRepository); + const repositoryMatches = Boolean(observedLiveSha && profileRemote && workloadRemote && profileRemote === workloadRemote); + const primary = workload.containers.find((container) => container.running) || workload.containers[0] || {}; + return this.store.saveDeploymentState(profile.id, { + liveSha, + healthy: workload.runtime.health === "healthy" ? true : workload.runtime.health === "unhealthy" ? false : null, + runtimeVerification: workload.runtime.health === "unverified" ? "running-unverified" : workload.runtime.health, + containerRunning: workload.runtime.running, + dockerHealth: primary.health || null, + containerName: primary.name || profile.containerName, + remotePath: path.join(server.basePath, profile.remoteFolder), + provider: "ssh-unraid", + workloadId: workload.workloadId, + composeProject: workload.compose?.project || null, + observedAt: workload.observedAt, + evidence: liveSha ? "container-provenance-label" : "runtime-only", + giteaSha: repositoryMatches ? observedLiveSha : previousState.giteaSha || null, + matchesGitea: repositoryMatches ? true : previousState.matchesGitea === true && previousState.liveSha === liveSha, + previousSha: previousState.previousSha || null, + }); + } + + async collectServerInventory(serverId, repositories) { + const server = this.store.getServer(serverId); + if (!server) throw new Error("The deployment server no longer exists."); + const result = await this.ssh.exec(server.id, bash(this.inventoryScript(server)), { + timeout: 180_000, + maxOutput: 64 * 1024 * 1024, + }); + const inventory = parseWorkloadInventory(result.stdout); + const profiles = this.allSshProfiles(); + const detectedWorkloads = buildWorkloadInventory({ + inventory, + server, + repositories, + profiles, + }); + const detectedIds = new Set(detectedWorkloads.map((item) => item.workloadId)); + const staleLinks = profiles.filter((profile) => profile.serverId === serverId && profile.workloadIdentity?.workloadId && !detectedIds.has(profile.workloadIdentity.workloadId)).map((profile) => ({ + workloadId: profile.workloadIdentity.workloadId, + serverId, + displayName: profile.name || profile.remoteFolder || profile._repositoryFullName, + status: "stale", + link: { profileId: profile.id, repositoryFullName: profile._repositoryFullName }, + compose: { project: profile.composeProject || "", workingDir: profile.composeWorkingDir || path.join(server.basePath, profile.remoteFolder || ""), configFiles: profile.composeFiles || [profile.composeFile].filter(Boolean), services: profile.composeServices || [profile.composeService].filter(Boolean) }, + containers: [], + runtime: { running: false, health: "missing" }, + metadata: { sourceRepository: profile.cloneUrl || "", liveRevision: "", branch: profile.branch || "", staleLink: true }, + candidates: [{ repositoryFullName: profile._repositoryFullName, repositoryName: profile._repositoryFullName.split("/").pop(), score: 100, exact: true, reasons: ["persisted deployment profile"] }], + remoteFolderCandidate: profile.remoteFolder || "", + observedAt: new Date().toISOString(), + })); + const workloads = classifyInventory([...detectedWorkloads, ...staleLinks], profiles, this.store.getInventoryReviewDecisions?.(serverId) || []); + return { server, inventory, workloads }; + } + + inventoryResponse(server, inventory, workloads, changes = {}) { + const summary = { + serverId: server.id, + serverName: server.name, + detected: workloads.length, + adopted: Number(changes.adopted || 0), + refreshed: Number(changes.refreshed || 0), + retired: Number(changes.retired || 0), + staleProfiles: Array.isArray(changes.staleProfiles) ? changes.staleProfiles : [], + verified: workloads.filter((item) => item.runtime.health === "healthy" && item.link).length, + linked: workloads.filter((item) => item.status === "linked").length, + unmatched: workloads.filter((item) => !item.link).length, + needsReview: workloads.filter((item) => !item.reviewDecision && (["suggested", "ambiguous", "unmatched", "duplicate", "stale"].includes(item.status) || ["orphan-container", "historical-compose", "stale-link"].includes(item.classification?.type))).length, + duplicates: workloads.filter((item) => item.classification?.type === "duplicate").length, + excluded: workloads.filter((item) => ["system-container", "backup", "release-folder", "historical-compose", "manually-excluded"].includes(item.classification?.type)).length, + running: workloads.filter((item) => item.runtime.running).length, + stopped: workloads.filter((item) => !item.runtime.running).length, + }; + return { + ...summary, + server: { id: server.id, name: server.name, host: server.host, basePath: server.basePath }, + capabilities: inventory.capabilities, + warnings: inventory.warnings, + workloads, + observedAt: new Date().toISOString(), + }; + } + + async scanServerInventory(serverId, repositories) { + const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories); + const response = this.inventoryResponse(server, inventory, workloads); + await this.diagnostics?.info("unraid.workloads.scanned", { + serverId, + detected: response.detected, + linked: response.linked, + needsReview: response.needsReview, + readOnly: true, + }); + return response; + } + + reconciliationPlan(server, workloads, repositories, { autoLink = true } = {}) { + const profiles = this.allSshProfiles().filter((profile) => profile.serverId === server.id); + const activeWorkloadIds = new Set(workloads.filter((item) => item.classification?.type !== "stale-link").map((item) => item.workloadId)); + const linkedRepositories = new Set(workloads.filter((item) => item.link?.repositoryFullName).map((item) => String(item.link.repositoryFullName).toLowerCase())); + const additions = []; + const updates = []; + const conflicts = []; + for (const workload of workloads) { + if (["duplicate", "backup", "release-folder", "historical-compose", "system-container", "manually-excluded", "stale-link"].includes(workload.classification?.type)) { + if (!workload.reviewDecision && ["duplicate", "historical-compose", "stale-link"].includes(workload.classification?.type)) conflicts.push({ workloadId: workload.workloadId, displayName: workload.displayName, status: workload.classification.type, reason: workload.classification.reason, candidates: (workload.candidates || []).slice(0, 5).map((item) => ({ repositoryFullName: item.repositoryFullName, score: item.score, exact: item.exact === true })) }); + continue; + } + if (workload.link?.profileId && workload.link?.repositoryFullName) { + updates.push({ + workloadId: workload.workloadId, + profileId: workload.link.profileId, + repositoryFullName: workload.link.repositoryFullName, + impact: "Refresh detected Compose identity and observed deployment state", + }); + continue; + } + const candidate = workload.candidates?.[0]; + const unique = workload.candidates?.length === 1; + const exact = unique && (candidate?.exact === true || (candidate?.identityExact === true && candidate.score >= 70)); + if (autoLink && exact && workload.runtime?.running && !linkedRepositories.has(String(candidate.repositoryFullName).toLowerCase())) { + additions.push({ + workloadId: workload.workloadId, + repositoryFullName: candidate.repositoryFullName, + evidence: candidate.exact ? "exact-provenance" : "exact-runtime-identity", + impact: "Create a server-pull deployment profile; no container changes", + }); + } else if (["suggested", "ambiguous"].includes(workload.status) || (workload.runtime?.running && workload.candidates?.length)) { + conflicts.push({ + workloadId: workload.workloadId, + displayName: workload.displayName, + status: workload.status, + candidates: (workload.candidates || []).slice(0, 5).map((item) => ({ repositoryFullName: item.repositoryFullName, score: item.score, exact: item.exact === true })), + }); + } + } + const stale = profiles.filter((profile) => + String(profile.workloadIdentity?.linkSource || "").startsWith("automatic") + && profile.workloadIdentity?.workloadId + && !activeWorkloadIds.has(profile.workloadIdentity.workloadId), + ).map((profile) => ({ + profileId: profile.id, + repositoryFullName: profile._repositoryFullName, + reason: "workload-missing", + impact: "Review only; ForgeFlow will not remove this profile automatically", + })); + const payload = { serverId: server.id, additions, updates, stale, conflicts }; + return { + id: crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex"), + createdAt: new Date().toISOString(), + ...payload, + summary: { additions: additions.length, updates: updates.length, stale: stale.length, conflicts: conflicts.length }, + }; + } + + async planServerInventoryReconciliation(serverId, repositories, options = {}) { + const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories); + const plan = this.reconciliationPlan(server, workloads, repositories, options); + return { inventory: this.inventoryResponse(server, inventory, workloads), plan }; + } + + async reconcileServerInventory(serverId, repositories, { autoLink = true, expectedPlanId = "" } = {}) { + const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories); + const plan = this.reconciliationPlan(server, workloads, repositories, { autoLink }); + if (!expectedPlanId || expectedPlanId !== plan.id) { + const error = new Error(expectedPlanId ? "The server inventory changed after the reconciliation preview. Review a fresh plan before applying it." : "Apply reconciliation only with an explicitly reviewed plan ID."); + error.code = expectedPlanId ? "RECONCILIATION_PLAN_STALE" : "RECONCILIATION_PLAN_REQUIRED"; + error.plan = plan; + throw error; + } + const recoverySnapshot = await this.store.createRecoverySnapshot?.(`server-reconciliation-${serverId}`) || null; + let adopted = 0; + let refreshed = 0; + let retired = 0; + let staleProfiles = []; + const inventoryStable = (inventory.warnings || []).every((warning) => /stale container reference\(s\) disappeared during inventory/i.test(warning)); + if (inventoryStable && workloads.length) { + const activeWorkloadIds = new Set(workloads.filter((item) => item.classification?.type !== "stale-link").map((item) => item.workloadId)); + const staleAutomaticProfiles = this.allSshProfiles().filter((profile) => + profile.serverId === serverId + && String(profile.workloadIdentity?.linkSource || "").startsWith("automatic") + && profile.workloadIdentity?.workloadId + && !activeWorkloadIds.has(profile.workloadIdentity.workloadId), + ); + const runningRepositoryLinks = new Set(workloads + .filter((workload) => workload.runtime?.running && workload.link?.repositoryFullName) + .map((workload) => String(workload.link.repositoryFullName).toLowerCase())); + const runningProfileIds = new Set(workloads + .filter((workload) => workload.runtime?.running && workload.link?.profileId) + .map((workload) => workload.link.profileId)); + const shadowedAutomaticProfiles = workloads + .filter((workload) => !workload.runtime?.running && workload.link?.profileId && !runningProfileIds.has(workload.link.profileId) && runningRepositoryLinks.has(String(workload.link.repositoryFullName).toLowerCase())) + .map((workload) => this.allSshProfiles().find((profile) => profile.id === workload.link.profileId && String(profile._repositoryFullName).toLowerCase() === String(workload.link.repositoryFullName).toLowerCase())) + .filter((profile) => profile && String(profile.workloadIdentity?.linkSource || "").startsWith("automatic")); + staleProfiles = [...new Map([...staleAutomaticProfiles, ...shadowedAutomaticProfiles].map((profile) => [profile.id, { + profileId: profile.id, + repositoryFullName: profile._repositoryFullName, + reason: staleAutomaticProfiles.includes(profile) ? "workload-missing" : "shadowed-by-running-workload", + }])).values()]; + } + for (const workload of workloads) { + if (workload.status !== "linked" || !workload.link?.profileId || !workload.link?.repositoryFullName) continue; + const repository = (repositories || []).find((item) => item.fullName === workload.link.repositoryFullName); + const existingProfile = this.store.getDeploymentProfile?.(workload.link.repositoryFullName, workload.link.profileId) + || this.allSshProfiles().find((item) => item.id === workload.link.profileId && item._repositoryFullName === workload.link.repositoryFullName); + if (!repository || !existingProfile) continue; + const updated = this.refreshedProfileFromWorkload(repository, server, workload, existingProfile); + const saved = await this.store.saveDeploymentProfile(repository.fullName, updated); + await this.saveWorkloadState(saved, workload, server); + refreshed += 1; + } + if (autoLink) { + const alreadyLinkedRepositories = new Set(workloads + .filter((item) => item.runtime?.running && item.link?.repositoryFullName) + .map((item) => String(item.link.repositoryFullName).toLowerCase())); + for (const workload of workloads) { + if (workload.status === "linked") continue; + if (["duplicate", "backup", "release-folder", "historical-compose", "system-container", "manually-excluded"].includes(workload.classification?.type)) continue; + const candidate = workload.candidates[0]; + const uniqueCandidate = workload.candidates.length === 1; + if (candidate && alreadyLinkedRepositories.has(String(candidate.repositoryFullName).toLowerCase())) continue; + const exactMatch = uniqueCandidate && candidate?.exact === true; + const exactRuntimeIdentity = uniqueCandidate + && candidate?.identityExact === true + && candidate.score >= 70 + && workload.runtime?.running === true + && Boolean(workload.remoteFolderCandidate) + && !alreadyLinkedRepositories.has(String(candidate.repositoryFullName).toLowerCase()); + if (!exactMatch && !exactRuntimeIdentity) continue; + const repository = (repositories || []).find((item) => item.fullName === candidate.repositoryFullName); + if (!repository) continue; + const linkSource = exactMatch ? "automatic" : "automatic-runtime-identity"; + const profile = this.profileFromWorkload(repository, server, workload, { linkSource, deploymentMode: "server-git" }); + const saved = await this.store.saveDeploymentProfile(repository.fullName, profile); + await this.saveWorkloadState(saved, workload, server); + workload.status = "linked"; + workload.link = { status: "linked", profileId: saved.id, repositoryFullName: repository.fullName, source: linkSource }; + alreadyLinkedRepositories.add(String(repository.fullName).toLowerCase()); + adopted += 1; + } + } + const response = this.inventoryResponse(server, inventory, workloads, { adopted, refreshed, retired, staleProfiles }); + response.recoverySnapshot = recoverySnapshot; + await this.diagnostics?.info("unraid.workloads.reconciled", { + serverId, + detected: response.detected, + adopted, + refreshed, + retired, + }); + return response; + } + + async discoverServerWorkloads(serverId, repositories) { + return this.scanServerInventory(serverId, repositories); + } + + async linkServerWorkload({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) { + const effectiveDeploymentMode = ["push-bundle", "server-git", "monitor-only"].includes(deploymentMode) + ? deploymentMode + : "server-git"; + const server = this.store.getServer(serverId); + if (!server) throw new Error("The deployment server no longer exists."); + const inventory = await this.scanServerInventory(serverId, [repository]); + const workload = inventory.workloads.find((item) => item.workloadId === workloadId); + if (!workload) throw new Error("The selected server workload no longer exists. Scan the server again."); + const existing = this.allSshProfiles().find((profile) => profile.workloadIdentity?.workloadId === workloadId && profile.serverId === serverId); + if (existing && String(existing._repositoryFullName).toLowerCase() !== String(repository.fullName).toLowerCase()) { + const error = new Error(`This workload is already linked to ${existing._repositoryFullName}. Remove or edit that link first.`); + error.code = "WORKLOAD_ALREADY_LINKED"; + throw error; + } + const profile = this.profileFromWorkload(repository, server, workload, { linkSource: "manual", deploymentMode: effectiveDeploymentMode, remoteFolder }); + const saved = await this.store.saveDeploymentProfile(repository.fullName, profile); + const state = await this.saveWorkloadState(saved, workload, server); + await this.diagnostics?.info("unraid.workload.linked", { serverId, workloadId, repository: repository.fullName, profileId: saved.id, deploymentMode: effectiveDeploymentMode }); + return { profile: saved, state, workload }; + } + } + return UnraidInventoryMethods.prototype; +} + +module.exports = { createUnraidInventoryMethods }; diff --git a/src/main/unraid-preflight-methods.cjs b/src/main/unraid-preflight-methods.cjs new file mode 100644 index 0000000..b16b957 --- /dev/null +++ b/src/main/unraid-preflight-methods.cjs @@ -0,0 +1,593 @@ +"use strict"; + +function createUnraidPreflightMethods({ + safeRemoteFolder, path, bash, parseInspection, dockerIgnoreHasPath, checksSummary, + inventoryRemoteIdentity, deriveDetectedProfile, decodeBase64Json, shellQuote, + assertFullCommitSha, nativePath, safeRelativeRemoteFile, fs, +}) { + class UnraidPreflightMethods { + async saveOperation(operation) { + const saved = await this.store.addOperation(operation); + this.onOperationChange?.({ operations: [saved] }); + return saved; + } + + resolve(repository, profileId) { + const profile = this.store.getDeploymentProfile( + repository.fullName, + profileId, + ); + if (!profile || profile.provider !== "ssh-unraid") + throw new Error("The SSH / Unraid deployment profile no longer exists."); + const server = this.store.getServer(profile.serverId); + if (!server) throw new Error("The deployment server no longer exists."); + const remoteFolder = safeRemoteFolder( + profile.remoteFolder || repository.name, + ); + const remotePath = path.join(server.basePath, remoteFolder); + if (!remotePath.startsWith(`${server.basePath}/`)) + throw new Error( + "Remote project path escapes the configured server base path.", + ); + const effectiveProfile = { + ...profile, + deploymentMode: ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode) + ? profile.deploymentMode + : "push-bundle", + }; + return { profile: effectiveProfile, server, remoteFolder, remotePath }; + } + + async discoverExisting({ repository, serverId, remoteFolder = "" }) { + const server = this.store.getServer(serverId); + if (!server) throw new Error("The deployment server no longer exists."); + const folder = safeRemoteFolder(remoteFolder || repository.name); + const remotePath = path.join(server.basePath, folder); + const inventory = await this.scanServerInventory(serverId, [repository], { autoLink: false }); + const workload = inventory.workloads.find((item) => + item.remoteFolderCandidate === folder || + item.compose?.workingDir === remotePath || + item.containers.some((container) => (container.mounts || []).some((mount) => { + const source = String(mount.source || "").replace(/\/+$/, ""); + return source === remotePath || source.startsWith(`${remotePath}/`); + })) + ); + if (!workload) { + const error = new Error(`No Docker or Compose workload could be matched to ${remotePath}. Use Server Inventory to select the running container directly.`); + error.code = "SERVER_WORKLOAD_NOT_FOUND"; + throw error; + } + const profile = this.profileFromWorkload(repository, server, workload, { + linkSource: "manual", + deploymentMode: "server-git", + remoteFolder: folder, + }); + const source = (value, origin, confidence = "confirmed") => ({ + value, + origin, + confidence, + detectedAt: new Date().toISOString(), + overridden: false, + }); + const provenance = { + remoteFolder: source(folder, "server-inventory"), + cloneUrl: source(profile.cloneUrl, workload.metadata?.sourceRepository ? "container-provenance" : "repository"), + branch: source(profile.branch, workload.metadata?.branch ? "container-provenance" : "repository"), + composeFile: source(profile.composeFile, "docker-compose-labels"), + composeService: source(profile.composeService, "docker-compose-labels"), + containerName: source(profile.containerName, "docker-inspect"), + hostPort: source(profile.hostPort, "docker-inspect"), + containerPort: source(profile.containerPort, "docker-inspect"), + webUiUrl: source(profile.webUiUrl, workload.dockerMan?.webUiUrl ? "unraid-dockerman" : "docker-labels"), + iconUrl: source(profile.serverIconReference, workload.dockerMan?.iconUrl ? "unraid-dockerman" : "docker-labels"), + dockerShell: source(profile.dockerShell, workload.dockerMan?.shell ? "unraid-dockerman" : "docker-labels"), + }; + return { + repository: repository.fullName, + profile: { ...profile, id: undefined, provenance }, + provenance, + workload, + runtime: { + remotePath, + containerRunning: workload.runtime.running, + containers: workload.containers.length, + services: workload.compose?.services?.length || workload.containers.length, + ports: workload.runtime.ports, + mounts: workload.containers.flatMap((container) => container.mounts || []), + networks: [...new Set(workload.containers.flatMap((container) => container.networks || []))], + envNames: [], + }, + }; + } + + async inspect({ repository, profileId }) { + const { profile, server, remotePath } = this.resolve(repository, profileId); + const preserveProbe = (profile.preservePaths || []) + .map( + (relativePath) => + `if [ -e "$root"/${shellQuote(relativePath)} ]; then printf '%s\\n' ${shellQuote(relativePath)}; fi`, + ) + .join("\n"); + const script = ` + root=${shellQuote(remotePath)} + exists=false; root_git=false; head=""; branch=""; remote=""; tracked_changes=""; compose_files=""; nested_git=""; dockerfile=false; dockerignore_content=""; existing_preserve_paths="" + if [ -d "$root" ]; then + exists=true + if [ -d "$root/.git" ]; then + root_git=true + head=$(git -C "$root" rev-parse HEAD 2>/dev/null || true) + branch=$(git -C "$root" branch --show-current 2>/dev/null || true) + remote=$(git -C "$root" remote get-url origin 2>/dev/null || true) + tracked_changes=$(git -C "$root" status --porcelain --untracked-files=no 2>/dev/null | head -n 25 | base64 | tr -d '\\r\\n' || true) + fi + compose_files=$(find "$root" -maxdepth 2 -type f \\( -name 'docker-compose.yml' -o -name 'docker-compose.yaml' -o -name 'compose.yml' -o -name 'compose.yaml' -o -name 'compose.forgeflow.yml' \\) -printf '%P\\n' 2>/dev/null | sort | base64 | tr -d '\\r\\n' || true) + nested_git=$(find "$root" -mindepth 2 -maxdepth 4 -type d -name .git -printf '%h\\n' 2>/dev/null | sed "s#^$root/##" | sort | base64 | tr -d '\\r\\n' || true) + [ -f "$root/Dockerfile" ] && dockerfile=true + [ -f "$root/.dockerignore" ] && dockerignore_content=$(base64 < "$root/.dockerignore" | tr -d '\\r\\n' || true) + existing_preserve_paths=$({ ${preserveProbe || ":"}; } | sort -u | base64 | tr -d '\\r\\n' || true) + fi + printf '__FORGEFLOW_KV__\\n' + printf 'exists=%s\\n' "$exists" + printf 'rootGit=%s\\n' "$root_git" + printf 'head=%s\\n' "$head" + printf 'branch=%s\\n' "$branch" + printf 'remote=%s\\n' "$(printf '%s' "$remote" | base64 | tr -d '\\r\\n')" + printf 'trackedChanges=%s\\n' "$tracked_changes" + printf 'composeFiles=%s\\n' "$compose_files" + printf 'nestedGit=%s\\n' "$nested_git" + printf 'dockerfile=%s\\n' "$dockerfile" + printf 'dockerignoreContent=%s\\n' "$dockerignore_content" + printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths" + `; + const wrapped = bash(script); + const result = await this.ssh.exec(server.id, wrapped, { timeout: 60_000 }); + const parsed = parseInspection(result.stdout); + const contextCandidates = [ + ...new Set([ + ...(parsed.existingPreservePaths || []), + ...(parsed.nestedGit || []), + ]), + ]; + const inspection = { + ...parsed, + dockerignore: Boolean(parsed.dockerignoreContent), + dockerignoreGitExcluded: dockerIgnoreHasPath( + parsed.dockerignoreContent, + ".git", + ), + dockerContextExclusionsMissing: parsed.dockerfile + ? contextCandidates.filter( + (item) => !dockerIgnoreHasPath(parsed.dockerignoreContent, item), + ) + : [], + serverId: server.id, + serverName: server.name, + remotePath, + profileId: profile.id, + }; + await this.diagnostics?.info("unraid.inspected", { + repository: repository.fullName, + serverId: server.id, + remotePath, + exists: inspection.exists, + rootGit: inspection.rootGit, + head: inspection.head, + composeFiles: inspection.composeFiles, + nestedGitCount: inspection.nestedGit.length, + trackedChangeCount: inspection.trackedChanges.length, + dockerContextExclusionsMissing: inspection.dockerContextExclusionsMissing, + }); + return inspection; + } + + async preflight({ repository, profileId, sha = null }) { + const { profile, server, remotePath } = this.resolve(repository, profileId); + const deploymentMode = ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode) + ? profile.deploymentMode + : "push-bundle"; + let requestedSha = sha || repository.localStatus?.head; + if (deploymentMode === "server-git" && !sha) { + const [owner, repo] = String(repository.fullName || "").split("/"); + const branch = await this.gitea.getBranch(owner, repo, profile.branch); + requestedSha = branch?.commit?.id || branch?.commit?.sha || null; + } + const targetSha = assertFullCommitSha(requestedSha); + const checks = []; + let inspection = null; + let connectionCapabilities = null; + let permissions = null; + + if (deploymentMode === "monitor-only") checks.push({ + id: "deployment-mode", + label: "Deployment mode", + status: "fail", + detail: "This workload is linked for monitoring only. Select Server pull or Direct copy before deploying.", + }); + else checks.push({ + id: "deployment-mode", + label: "Deployment mode", + status: "pass", + detail: deploymentMode === "server-git" + ? "Unraid fetches the exact Gitea commit with a repository-scoped read-only deploy key." + : "ForgeFlow copies the exact committed local project directly to Unraid and runs Docker Compose there.", + }); + + if (!repository.localPath) { + checks.push({ + id: "local-repository", + label: "Local repository", + status: deploymentMode === "server-git" ? "pass" : "fail", + detail: deploymentMode === "server-git" + ? "Not required: the exact commit is fetched from Gitea by the server." + : "Link or clone the repository locally before using Direct copy.", + }); + } else { + try { + const localStatus = await this.git.status(repository.localPath); + checks.push({ + id: "local-repository", + label: "Local repository", + status: "pass", + detail: localStatus.root, + }); + checks.push({ + id: "local-branch", + label: "Allowed branch", + status: localStatus.branch.head === profile.branch ? "pass" : deploymentMode === "server-git" ? "warning" : "fail", + detail: `Current: ${localStatus.branch.head || "detached"}; required: ${profile.branch}.`, + }); + checks.push({ + id: "local-clean", + label: "Clean local working tree", + status: localStatus.clean ? "pass" : deploymentMode === "server-git" ? "warning" : "fail", + detail: localStatus.clean + ? "No uncommitted changes." + : `${localStatus.counts.changed} changed file(s) remain.`, + }); + checks.push({ + id: "deployment-source", + label: deploymentMode === "server-git" ? "Gitea deployment source" : "Direct deployment source", + status: "pass", + detail: deploymentMode === "server-git" + ? "Local files are not uploaded; the exact requested commit is fetched from Gitea." + : "The exact committed local HEAD is archived and copied directly to Unraid. No server-side repository access is involved.", + }); + + + const localDeploymentFiles = deploymentMode === "push-bundle" && profile.generatedCompose + ? [nativePath.join(repository.localPath, "Dockerfile")] + : deploymentMode === "push-bundle" ? this.deploymentComposeFiles(profile).map((file) => + nativePath.join(repository.localPath, safeRelativeRemoteFile(file)), + ) : []; + const missingDeploymentFiles = []; + for (const file of localDeploymentFiles) { + if (!(await fs.stat(file).catch(() => null))?.isFile()) missingDeploymentFiles.push(file); + } + if (deploymentMode === "push-bundle") checks.push({ + id: "local-deployment-file", + label: profile.generatedCompose + ? "Dockerfile in repository" + : localDeploymentFiles.length > 1 ? "Compose files in repository" : "Compose file in repository", + status: missingDeploymentFiles.length ? "fail" : "pass", + detail: missingDeploymentFiles.length + ? `Missing from the exact local checkout: ${missingDeploymentFiles.join(", ")}` + : localDeploymentFiles.join(", "), + }); + } catch (error) { + checks.push({ + id: "local-repository", + label: "Local repository", + status: "fail", + detail: error.message, + }); + } + } + + try { + const connection = await this.ssh.test(server.id, { trustOnFirstUse: false }); + connectionCapabilities = connection.capabilities || {}; + checks.push({ + id: "ssh", + label: "Desktop → Unraid SSH", + status: "pass", + detail: `${server.username}@${server.host}:${server.port}`, + }); + checks.push({ + id: "docker-runtime", + label: "Docker runtime", + status: connectionCapabilities.docker && connectionCapabilities.dockerReady ? "pass" : "fail", + detail: connectionCapabilities.dockerReady + ? "Docker is reachable by the configured SSH user." + : connectionCapabilities.docker + ? "Docker is installed, but the configured SSH user cannot query the daemon." + : "Docker was not detected on the server.", + }); + checks.push({ + id: "compose-command", + label: "Docker Compose", + status: connectionCapabilities.compose ? "pass" : "fail", + detail: connectionCapabilities.composeVersion || "Docker Compose was not detected on the server.", + }); + checks.push({ + id: "bundle-tools", + label: deploymentMode === "server-git" ? "Server pull tools" : "Direct copy tools", + status: connectionCapabilities.tar && connectionCapabilities.checksum && (deploymentMode !== "server-git" || connectionCapabilities.git) ? "pass" : "fail", + detail: deploymentMode === "server-git" + ? `Git ${connectionCapabilities.git ? "available" : "missing"}; tar ${connectionCapabilities.tar ? "available" : "missing"}; checksum ${connectionCapabilities.checksum ? "available" : "missing"}.` + : connectionCapabilities.tar && connectionCapabilities.checksum + ? "tar and a SHA-256 checksum tool are available." + : `tar ${connectionCapabilities.tar ? "available" : "missing"}; checksum tool ${connectionCapabilities.checksum ? "available" : "missing"}.`, + }); + checks.push({ + id: "server-base-writable", + label: "Deployment storage writable", + status: connectionCapabilities.baseWritable ? "pass" : "fail", + detail: connectionCapabilities.baseWritable ? `${server.basePath} is writable.` : `${server.basePath} cannot be created or written by this SSH user.`, + }); + } catch (error) { + checks.push({ + id: "ssh", + label: "Desktop → Unraid SSH", + status: "fail", + detail: error.message, + }); + } + if (!server.hostFingerprint) checks.push({ + id: "host-key", + label: "Server identity", + status: "fail", + detail: "Test and trust the SSH host key first.", + }); + else checks.push({ + id: "host-key", + label: "Server identity", + status: "pass", + detail: server.hostFingerprint, + }); + + if (deploymentMode === "server-git") { + const access = await this.probeServerGitAccess({ repository, profile, server }); + checks.push({ + id: "server-git-access", + label: "Unraid → Gitea read access", + status: access.ready ? "pass" : "fail", + detail: access.ready + ? `Read-only deploy key verified${access.remoteSha ? ` at ${access.remoteSha.slice(0, 7)}` : ""}.` + : access.error, + repairAction: access.ready ? null : "configure-server-git-access", + repairLabel: "Configure read-only deploy key", + }); + } else checks.push({ + id: "transfer-path", + label: "Desktop → Unraid transfer", + status: "pass", + detail: "Files are copied over the configured server connection. No Gitea credential is required on Unraid.", + }); + + try { + permissions = await this.inspectWriteAccess({ repository, profileId }); + const blockingPaths = permissions.blocking.map((target) => target.path); + checks.push({ + id: "project-write-access", + label: "Project write access", + status: permissions.ready ? "pass" : "fail", + detail: permissions.ready + ? `${permissions.identity.user} can create and atomically replace deployment files in ${remotePath}.` + : `No safe write access for ${permissions.identity.user}: ${blockingPaths.join(", ")}`, + help: permissions.ready + ? "ForgeFlow rechecks these paths immediately before every upload and Compose activation." + : "Use Fix write access to repair only the linked project source and ForgeFlow state folders. Preserved runtime data is excluded.", + repairAction: permissions.ready ? null : "repair-deployment-write-access", + repairLabel: "Fix write access", + }); + for (const target of permissions.targets.filter( + (item) => item.required && !item.effectiveWritable, + )) { + checks.push({ + id: `write-path:${target.id}`, + label: target.label, + status: "fail", + detail: `${target.path} · owner ${target.owner || "unknown"}:${target.group || "unknown"} · mode ${target.mode || "unknown"}. ${target.detail}`, + repairAction: "repair-deployment-write-access", + repairLabel: "Fix write access", + }); + } + } catch (error) { + checks.push({ + id: "project-write-access", + label: "Project write access", + status: "fail", + detail: error.message, + }); + } + + + try { + inspection = await this.inspect({ repository, profileId }); + if (!inspection.exists) { + checks.push({ + id: "remote-folder", + label: "Remote project folder", + status: "pass", + detail: `${remotePath} will be created.`, + }); + } else { + checks.push({ + id: "remote-folder", + label: inspection.rootGit ? "Remote project folder" : "Existing server installation", + status: "pass", + detail: inspection.rootGit + ? `${remotePath} currently contains Git commit ${String(inspection.head || "").slice(0, 7) || "unknown"}.` + : `${remotePath} will receive managed release files while preserved and unknown runtime data remains untouched.`, + }); + } + if (inspection.rootGit) { + checks.push({ + id: "tracked-changes", + label: "Server-side tracked changes", + status: inspection.trackedChanges.length ? "warning" : "pass", + detail: inspection.trackedChanges.length + ? `${inspection.trackedChanges.length} tracked server edit(s) exist. Direct copy preserves unknown runtime data and does not depend on the server Git checkout.` + : "No tracked server-only edits detected.", + }); + } + + if (inspection.nestedGit.length) { + checks.push({ + id: "nested-git", + label: "Nested Git repositories", + status: "warning", + detail: `Detected: ${inspection.nestedGit.join(", ")}. ForgeFlow will not delete them automatically.`, + }); + } + if (inspection.dockerfile && !inspection.dockerignore) { + checks.push({ + id: "dockerignore", + label: "Docker build context", + status: "warning", + detail: + "A Dockerfile exists but .dockerignore is missing. Add one in the repository before large builds.", + }); + } else if (inspection.dockerfile && !inspection.dockerignoreGitExcluded) { + checks.push({ + id: "dockerignore-git", + label: "Git metadata excluded from Docker", + status: "warning", + detail: ".dockerignore does not explicitly exclude .git.", + }); + } else if (inspection.dockerfile) { + checks.push({ + id: "dockerignore-git", + label: "Git metadata excluded from Docker", + status: "pass", + detail: ".git is excluded from the Docker build context.", + }); + } + if (inspection.dockerContextExclusionsMissing.length) { + checks.push({ + id: "dockerignore-runtime", + label: "Runtime data excluded from Docker", + status: "warning", + detail: `Add these existing runtime or legacy paths to .dockerignore: ${inspection.dockerContextExclusionsMissing.join(", ")}.`, + }); + } else if ( + inspection.dockerfile && + inspection.existingPreservePaths.length + ) { + checks.push({ + id: "dockerignore-runtime", + label: "Runtime data excluded from Docker", + status: "pass", + detail: + "Detected preserved runtime paths are excluded from the Docker build context.", + }); + } + const composeFiles = profile.generatedCompose + ? [".forgeflow/compose.forgeflow.yml"] + : (profile.composeFiles?.length ? profile.composeFiles : [profile.composeFile || "docker-compose.yml"]) + .map((value) => safeRelativeRemoteFile(value)); + const missingRemoteCompose = composeFiles.filter((composeFile) => !inspection.composeFiles.includes(composeFile)); + checks.push({ + id: "compose-file", + label: "Compose configuration", + status: "pass", + detail: profile.generatedCompose + ? "ForgeFlow will generate an isolated Compose file." + : missingRemoteCompose.length + ? `${composeFiles.join(", ")} will be uploaded from the exact local commit.` + : composeFiles.join(", "), + }); + } catch (error) { + checks.push({ + id: "inspection", + label: "Server project inspection", + status: "fail", + detail: error.message, + }); + } + const iconMode = + profile.iconMode || + (profile.iconFilePath ? "upload" : profile.iconUrl ? "url" : "builtin"); + if (iconMode === "upload") { + const iconStat = await fs.stat(profile.iconFilePath).catch(() => null); + checks.push({ + id: "dockerman-icon-file", + label: "DockerMan icon upload", + status: + iconStat?.isFile() && + nativePath.extname(profile.iconFilePath).toLowerCase() === ".png" + ? "pass" + : "fail", + detail: iconStat?.isFile() + ? profile.iconFilePath + : "The selected local PNG icon file was not found.", + }); + } else if (iconMode === "builtin") { + const builtinIcon = nativePath.join( + this.sourcePath, + "src", + "renderer", + "assets", + "itworx-mark.png", + ); + const iconStat = await fs.stat(builtinIcon).catch(() => null); + checks.push({ + id: "dockerman-icon-builtin", + label: "DockerMan icon", + status: iconStat?.isFile() ? "pass" : "fail", + detail: iconStat?.isFile() + ? "Built-in high-contrast ITWorx mark." + : "The built-in ITWorx icon asset is missing.", + }); + } else if (iconMode === "url") + checks.push({ + id: "dockerman-icon", + label: "DockerMan icon", + status: profile.iconUrl ? "pass" : "fail", + detail: + profile.iconUrl || "Icon URL mode requires an HTTPS or HTTP PNG URL.", + }); + else + checks.push({ + id: "dockerman-icon", + label: "DockerMan icon", + status: "warning", + detail: "Custom DockerMan icon disabled.", + }); + const webUiLabel = this.dockerManWebUi(profile); + checks.push({ + id: "dockerman-webui", + label: "DockerMan Web UI action", + status: webUiLabel ? "pass" : "warning", + detail: webUiLabel || "No Web UI URL or host port is configured.", + }); + checks.push({ + id: "compose-identity", + label: "Safe Docker Compose identity", + status: "pass", + detail: `Internal project/image: ${this.internalSlug(profile, repository)}; visible container: ${profile.containerName || profile.remoteFolder || repository.name}.`, + }); + checks.push({ + id: "exact-sha", + label: "Exact deployment commit", + status: "pass", + detail: targetSha, + }); + return { + provider: "ssh-unraid", + repository: repository.fullName, + environment: profile.environment, + sha: targetSha, + server: { id: server.id, name: server.name, host: server.host }, + remotePath, + inspection, + permissions, + checks, + summary: checksSummary(checks), + }; + } + } + return UnraidPreflightMethods.prototype; +} + +module.exports = { createUnraidPreflightMethods }; diff --git a/src/main/unraid-runtime-methods.cjs b/src/main/unraid-runtime-methods.cjs new file mode 100644 index 0000000..520b854 --- /dev/null +++ b/src/main/unraid-runtime-methods.cjs @@ -0,0 +1,387 @@ +"use strict"; + +function createUnraidRuntimeMethods({ + safeRelativeRemoteFile, xmlEscape, nativePath, fileSystem, fs, crypto, os, run, + bash, shellQuote, iconReferenceLocalPath, +}) { + class UnraidRuntimeMethods { + internalSlug(profile, repository) { + return ( + String( + profile.remoteFolder || + repository.name || + profile.composeService || + "app", + ) + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") || "app" + ); + } + + generatedCompose(profile, repository) { + const service = + String(profile.composeService || repository.name || "app") + .toLowerCase() + .replace(/[^a-z0-9._-]/g, "-") || "app"; + const containerName = + String( + profile.containerName || + profile.remoteFolder || + repository.name || + service, + ).replace(/[^A-Za-z0-9._-]/g, "-") || service; + if (!profile.hostPort || !profile.containerPort) + throw new Error( + "Host and container ports are required for generated Compose.", + ); + return ( + [ + "services:", + ` ${service}:`, + ` image: forgeflow/${this.internalSlug(profile, repository)}:${String(profile.environment || "production").toLowerCase()}`, + " build:", + " context: ..", + ` container_name: ${containerName}`, + " restart: unless-stopped", + " ports:", + ` - "${profile.hostPort}:${profile.containerPort}"`, + ].join("\n") + "\n" + ); + } + + dockerManWebUi(profile) { + if (profile.hostPort) { + let suffix = "/"; + try { + const parsed = profile.webUiUrl ? new URL(profile.webUiUrl) : null; + suffix = parsed + ? `${parsed.pathname || "/"}${parsed.search || ""}${parsed.hash || ""}` + : "/"; + } catch {} + if (!suffix.startsWith("/")) suffix = `/${suffix}`; + return `http://[IP]:[PORT:${profile.hostPort}]${suffix}`; + } + return profile.webUiUrl || ""; + } + + dockerManShell(profile) { + return String(profile.dockerShell || "/bin/sh") + .toLowerCase() + .includes("bash") + ? "bash" + : "sh"; + } + + dockerManTemplatePath(profile, repository) { + const containerName = + String( + profile.containerName || + profile.remoteFolder || + repository.name || + "app", + ).replace(/[^A-Za-z0-9._-]/g, "-") || "app"; + return `/boot/config/plugins/dockerMan/templates-user/my-${containerName}.xml`; + } + + dockerManTemplate(profile, repository, iconReference = "") { + const containerName = + String( + profile.containerName || + profile.remoteFolder || + repository.name || + "app", + ).replace(/[^A-Za-z0-9._-]/g, "-") || "app"; + const slug = this.internalSlug(profile, repository); + const environment = + String(profile.environment || "production") + .toLowerCase() + .replace(/[^a-z0-9._-]/g, "-") || "production"; + const image = `forgeflow/${slug}:${environment}`; + const webUi = this.dockerManWebUi(profile); + return ( + [ + '', + '', + ` ${xmlEscape(containerName)}`, + ` ${xmlEscape(image)}`, + " ", + " bridge", + " ", + ` ${xmlEscape(this.dockerManShell(profile))}`, + " false", + " ", + " ", + " Managed by ForgeFlow through Docker Compose. Use ForgeFlow or the Compose files for configuration changes.", + " Tools:", + ` ${xmlEscape(webUi)}`, + " ", + ` ${xmlEscape(iconReference)}`, + " ", + " ", + " ", + " ", + " ", + "", + ].join("\n") + "\n" + ); + } + + iconCacheRefresh(profile, repository, iconReference = "") { + const containerName = + String( + profile.containerName || + profile.remoteFolder || + repository.name || + "app", + ).replace(/[^A-Za-z0-9._-]/g, "-") || "app"; + const cacheLoop = `for icon_dir in /var/lib/docker/unraid/images /usr/local/emhttp/state/plugins/dynamix.docker.manager/images /var/local/emhttp/plugins/dynamix.docker.manager/images; do [ -d "$icon_dir" ] || continue; rm -f "$icon_dir/${containerName}-icon.png" "$icon_dir/${containerName}.png"; done`; + const invalidateMetadata = `rm -f /usr/local/emhttp/state/plugins/dynamix.docker.manager/docker.json`; + const localIconPath = iconReferenceLocalPath(iconReference); + if (!localIconPath) return `${cacheLoop}\n${invalidateMetadata}`; + return `${cacheLoop} + if [ -f ${shellQuote(localIconPath)} ]; then for icon_dir in /var/lib/docker/unraid/images /usr/local/emhttp/state/plugins/dynamix.docker.manager/images /var/local/emhttp/plugins/dynamix.docker.manager/images; do [ -d "$icon_dir" ] || continue; cp ${shellQuote(localIconPath)} "$icon_dir/${containerName}-icon.png"; chmod 0644 "$icon_dir/${containerName}-icon.png"; done; fi + ${invalidateMetadata}`; + } + + dockerManRefreshScript(profile, repository, iconReference = "") { + if (profile.manageDockerMan !== true || profile.adoptedFromServer === true || profile.generatedCompose !== true) { + return `echo 'ForgeFlow left the existing DockerMan template unchanged.'`; + } + const templatePath = this.dockerManTemplatePath(profile, repository); + const template = this.dockerManTemplate(profile, repository, iconReference); + return `mkdir -p /boot/config/plugins/dockerMan/templates-user + cat > ${shellQuote(templatePath)} <<'FORGEFLOW_DOCKERMAN_TEMPLATE' + ${template}FORGEFLOW_DOCKERMAN_TEMPLATE + chmod 0644 ${shellQuote(templatePath)} + ${this.iconCacheRefresh(profile, repository, iconReference)}`; + } + + deploymentServices(profile, repository) { + const values = profile.generatedCompose + ? [profile.composeService || repository.name || "app"] + : (profile.composeServices?.length ? profile.composeServices : [profile.composeService || repository.name || "app"]); + return [...new Set(values.map((value) => String(value || "").trim().toLowerCase().replace(/[^a-z0-9._-]/g, "-")).filter(Boolean))]; + } + + deploymentComposeFiles(profile) { + if (profile.generatedCompose) return [".forgeflow/compose.forgeflow.yml"]; + const values = profile.composeFiles?.length ? profile.composeFiles : [profile.composeFile || "docker-compose.yml"]; + return [...new Set(values + .map((value) => safeRelativeRemoteFile(value)) + .filter((value) => value !== ".forgeflow/compose.metadata.yml" && value !== ".forgeflow/compose.forgeflow.yml"))]; + } + + metadataCompose(profile, repository, iconReference = "", deployment = {}) { + const services = this.deploymentServices(profile, repository); + const labels = { + "net.unraid.docker.managed": "dockerman", + "net.unraid.docker.shell": this.dockerManShell(profile), + "tech.itworx.forgeflow.repository": + deployment.repositoryUrl || + profile.cloneUrl || + repository.sshUrl || + repository.cloneUrl || + repository.htmlUrl || + repository.fullName || repository.name || "unknown", + "tech.itworx.forgeflow.branch": profile.branch || "main", + }; + if (deployment.sha) labels["tech.itworx.forgeflow.commit"] = deployment.sha; + const webUiLabel = this.dockerManWebUi(profile); + if (webUiLabel) labels["net.unraid.docker.webui"] = webUiLabel; + if (iconReference) labels["net.unraid.docker.icon"] = iconReference; + + const output = ["services:"]; + for (const service of services) { + output.push(` ${service}:`); + if (profile.generatedCompose) { + const containerName = String( + profile.containerName || profile.remoteFolder || repository.name || service, + ).replace(/[^A-Za-z0-9._-]/g, "-") || "app"; + output.push(` image: forgeflow/${this.internalSlug(profile, repository)}:${String(profile.environment || "production").toLowerCase().replace(/[^a-z0-9._-]/g, "-")}`); + output.push(` container_name: ${containerName}`); + } + output.push(" labels:"); + output.push(...Object.entries(labels).map(([key, value]) => ` ${JSON.stringify(key)}: ${JSON.stringify(value)}`)); + } + return `${output.join("\n")}\n`; + } + + async prepareIcon(profile, repository, server) { + const mode = + profile.iconMode || + (profile.iconFilePath ? "upload" : profile.iconUrl ? "url" : "builtin"); + if (mode === "none") return profile.serverIconReference || ""; + if (mode === "url") { + if (!profile.iconUrl) + throw new Error( + "DockerMan icon URL mode is selected, but no icon URL is configured.", + ); + return profile.iconUrl; + } + const localIconPath = + mode === "builtin" + ? nativePath.join( + this.sourcePath, + "src", + "renderer", + "assets", + "itworx-mark.png", + ) + : profile.iconFilePath; + const stat = await fs.stat(localIconPath).catch(() => null); + if (!stat?.isFile()) + throw new Error( + mode === "builtin" + ? "The built-in ITWorx DockerMan icon is missing." + : `The selected DockerMan icon file no longer exists: ${localIconPath}`, + ); + if (nativePath.extname(localIconPath).toLowerCase() !== ".png") + throw new Error( + "DockerMan icon upload currently accepts PNG files only.", + ); + const containerName = + String( + profile.containerName || + profile.remoteFolder || + repository.name || + "app", + ).replace(/[^A-Za-z0-9._-]/g, "-") || "app"; + const remoteIconPath = `/boot/config/plugins/dockerMan/images/${containerName}-icon.png`; + await this.ssh.uploadFile(server.id, localIconPath, remoteIconPath, { + mode: 0o644, + }); + return `file://${remoteIconPath}`; + } + + composeInvocation(profile, repository) { + const project = String(profile.composeProject || this.internalSlug(profile, repository)).trim(); + const files = [...this.deploymentComposeFiles(profile)]; + // A labels-only Compose fragment is valid only when every service key also + // exists in the base definition. Imported profiles can contain stale service + // hints, so adopted workloads must activate from their real server Compose + // files only. ForgeFlow tracks the deployed SHA in .forgeflow/status.json. + if (profile.generatedCompose) files.push(".forgeflow/compose.metadata.yml"); + return `forgeflow_compose -p ${shellQuote(project)} ${files.map((file) => `-f ${shellQuote(file)}`).join(" ")}`; + } + + composeUpFlags(profile) { + // ForgeFlow never adds destructive recreation or orphan-removal flags. + // Compose may replace a service when its built image or configuration changed, + // but unrelated containers are never deleted by ForgeFlow. + void profile; + return ""; + } + + containerVerificationScript(profile, repository, compose, { requireRecreated = false } = {}) { + const recreationCheck = requireRecreated + ? ` before_id=$(awk -F '\t' -v wanted="$service" '$1 == wanted { print $2; exit }' "$before_containers" 2>/dev/null || true) + if [ -n "$before_id" ] && [ "$before_id" = "$container_id" ]; then + echo "Compose reported success but service $service still uses the previous container $container_id" >&2 + exit 65 + fi` + : ` before_id=""`; + return `actual_services=$(${compose} config --services 2>/dev/null | sed '/^$/d') + [ -n "$actual_services" ] || { echo "Compose project defines no services" >&2; exit 60; } + printf '%s\n' "$actual_services" | while IFS= read -r service; do + [ -n "$service" ] || continue + attempt=0; container_id=''; running=false; health='' + while [ "$attempt" -lt 30 ]; do + container_id=$(${compose} ps -q "$service" | head -n1) + if [ -n "$container_id" ]; then + running=$(docker inspect -f '{{.State.Running}}' "$container_id" 2>/dev/null || echo false) + health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container_id" 2>/dev/null || true) + if [ "$running" = true ] && [ "$health" != unhealthy ] && [ "$health" != starting ]; then break; fi + fi + attempt=$((attempt + 1)); sleep 2 + done + [ -n "$container_id" ] || { echo "Compose service $service did not create a container" >&2; exit 61; } + [ "$running" = true ] || { echo "Compose service $service is not running after 60 seconds" >&2; exit 62; } + [ "$health" != unhealthy ] && [ "$health" != starting ] || { echo "Compose service $service did not become healthy" >&2; exit 63; } + ${recreationCheck} + image_id=$(docker inspect -f '{{.Image}}' "$container_id" 2>/dev/null || true) + printf 'ForgeFlow verified service %s: container=%s previous=%s image=%s\n' "$service" "$container_id" "\${before_id:-none}" "\${image_id:-unknown}" + done`; + } + + async checkHealth(url) { + if (!url) + return { + configured: false, + healthy: null, + status: null, + latencyMs: null, + }; + let last = null; + for (let attempt = 1; attempt <= 5; attempt += 1) { + const started = Date.now(); + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(8_000), + redirect: "manual", + }); + last = { + configured: true, + healthy: response.ok, + status: response.status, + latencyMs: Date.now() - started, + }; + if (response.ok) return last; + } catch (error) { + last = { + configured: true, + healthy: false, + status: null, + latencyMs: Date.now() - started, + error: error.message, + }; + } + if (attempt < 5) + await new Promise((resolve) => setTimeout(resolve, 3_000)); + } + return last; + } + + hashFile(filePath) { + return new Promise((resolve, reject) => { + const hash = crypto.createHash("sha256"); + const stream = fileSystem.createReadStream(filePath); + stream.on("error", reject); + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("end", () => resolve(hash.digest("hex"))); + }); + } + + async createCommitBundle(repository, sha, requestId) { + if (!repository.localPath) throw new Error("A linked local repository is required to create a push bundle."); + const bundleDirectory = nativePath.join(os.tmpdir(), "forgeflow-bundles"); + await fs.mkdir(bundleDirectory, { recursive: true }); + const archivePath = nativePath.join(bundleDirectory, `${requestId}-${sha}.tar`); + await run("git", ["-C", repository.localPath, "archive", "--format=tar", `--output=${archivePath}`, sha], { + timeout: 5 * 60_000, + maxBuffer: 4 * 1024 * 1024, + }); + const stat = await fs.stat(archivePath); + if (!stat.isFile() || stat.size <= 0) throw new Error("Git produced an empty deployment bundle."); + return { archivePath, bytes: stat.size, sha256: await this.hashFile(archivePath) }; + } + + deploymentStatusDocument({ repository, profile, targetSha, requestId, rollback = false }) { + return JSON.stringify({ + repository: repository.fullName, + environment: profile.environment, + requested_sha: targetSha, + live_sha: targetSha, + request_id: requestId, + healthy: null, + healthcheck_url_configured: Boolean(profile.healthcheckUrl), + rollback, + deployment_mode: profile.deploymentMode || "push-bundle", + deployed_at: new Date().toISOString(), + }); + } + } + return UnraidRuntimeMethods.prototype; +} + +module.exports = { createUnraidRuntimeMethods }; diff --git a/src/main/unraid-state-methods.cjs b/src/main/unraid-state-methods.cjs new file mode 100644 index 0000000..82f4520 --- /dev/null +++ b/src/main/unraid-state-methods.cjs @@ -0,0 +1,282 @@ +"use strict"; + +function createUnraidStateMethods({ path, bash, shellQuote, inventoryRemoteIdentity }) { + class UnraidStateMethods { + async refreshProfileState(fullName, profileId, expectedGiteaSha = null) { + const repository = { fullName, name: fullName.split("/").pop() }; + const { profile, server, remotePath } = this.resolve(repository, profileId); + const containerName = String( + profile.containerName || profile.remoteFolder || repository.name, + ); + const script = ` + root=${shellQuote(remotePath)} + container=${shellQuote(containerName)} + template_path=${shellQuote("/boot/config/plugins/dockerMan/templates-user/my-" + containerName + ".xml")} + live=""; previous=""; running=false; docker_health=""; webui=""; icon=""; shell_label=""; template_exists=false + [ -f "$template_path" ] && template_exists=true + [ -f "$root/.forgeflow/current-sha" ] && live=$(cat "$root/.forgeflow/current-sha") + [ -z "$live" ] && [ -d "$root/.git" ] && live=$(git -C "$root" rev-parse HEAD 2>/dev/null || true) + [ -f "$root/.forgeflow/previous-sha" ] && previous=$(cat "$root/.forgeflow/previous-sha") + if docker inspect "$container" >/dev/null 2>&1; then + running=$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null || echo false) + docker_health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container" 2>/dev/null || true) + webui=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.webui"}}' "$container" 2>/dev/null || true) + icon=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.icon"}}' "$container" 2>/dev/null || true) + shell_label=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.shell"}}' "$container" 2>/dev/null || true) + [ -z "$live" ] && live=$(docker inspect -f '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$container" 2>/dev/null || true) + [ -z "$live" ] && live=$(docker inspect -f '{{index .Config.Labels "tech.itworx.forgeflow.commit"}}' "$container" 2>/dev/null || true) + fi + printf '__FORGEFLOW_KV__\n' + printf 'liveSha=%s\n' "$live" + printf 'previousSha=%s\n' "$previous" + printf 'containerRunning=%s\n' "$running" + printf 'dockerHealth=%s\n' "$docker_health" + printf 'webUiLabel=%s\n' "$(printf '%s' "$webui" | base64 | tr -d '\r\n')" + printf 'iconLabel=%s\n' "$(printf '%s' "$icon" | base64 | tr -d '\r\n')" + printf 'shellLabel=%s\n' "$(printf '%s' "$shell_label" | base64 | tr -d '\r\n')" + printf 'templateExists=%s\n' "$template_exists" + `; + const result = await this.ssh.exec(server.id, bash(script), { + timeout: 30_000, + }); + const marker = result.stdout.lastIndexOf("__FORGEFLOW_KV__"); + if (marker < 0) + throw new Error( + "Unraid state inspection did not return a ForgeFlow marker.", + ); + const fields = {}; + for (const line of result.stdout + .slice(marker + "__FORGEFLOW_KV__".length) + .trim() + .split(/\r?\n/)) { + const index = line.indexOf("="); + if (index > 0) fields[line.slice(0, index)] = line.slice(index + 1); + } + const decode = (value) => { + try { + return value ? Buffer.from(value, "base64").toString("utf8") : ""; + } catch { + return ""; + } + }; + const health = await this.checkHealth(profile.healthcheckUrl); + const dockerHealthy = fields.dockerHealth + ? fields.dockerHealth === "healthy" + : null; + const effectiveHealthy = health.configured ? health.healthy : dockerHealthy; + const runtimeVerification = health.configured + ? "desktop-healthcheck" + : dockerHealthy === true + ? "docker-healthcheck" + : dockerHealthy === false + ? "docker-unhealthy" + : fields.containerRunning === "true" + ? "running-unverified" + : "stopped"; + return this.store.saveDeploymentState(profile.id, { + liveSha: /^[0-9a-f]{40}$/i.test(fields.liveSha || "") + ? fields.liveSha + : null, + previousSha: /^[0-9a-f]{40}$/i.test(fields.previousSha || "") + ? fields.previousSha + : null, + healthy: effectiveHealthy, + runtimeVerification, + healthStatus: health.status, + healthLatencyMs: health.latencyMs, + containerName, + containerRunning: fields.containerRunning === "true", + dockerHealth: fields.dockerHealth || null, + dockerMan: { + webUi: decode(fields.webUiLabel), + icon: decode(fields.iconLabel), + shell: decode(fields.shellLabel), + templateExists: fields.templateExists === "true", + configured: Boolean( + decode(fields.webUiLabel) || + decode(fields.iconLabel) || + fields.templateExists === "true", + ), + }, + webUiUrl: + profile.webUiUrl || + (profile.hostPort + ? `http://${server.host}:${profile.hostPort}/` + : null), + remotePath, + provider: "ssh-unraid", + giteaSha: /^[0-9a-f]{40}$/i.test(String(expectedGiteaSha || "")) + ? expectedGiteaSha + : null, + matchesGitea: + /^[0-9a-f]{40}$/i.test(String(expectedGiteaSha || "")) && + fields.liveSha === expectedGiteaSha, + }); + } + + async applyDockerManMetadata({ repository, profileId }) { + const { profile, server, remotePath } = this.resolve(repository, profileId); + if (profile.generatedCompose !== true) { + // Existing Compose files remain authoritative. Applying a generated + // labels-only service fragment can create a phantom service when a stale + // profile hint no longer matches the real Compose service keys. + return this.refreshProfileState(repository.fullName, profileId); + } + const composeFile = profile.generatedCompose + ? ".forgeflow/compose.forgeflow.yml" + : safeRelativeRemoteFile(profile.composeFile || "docker-compose.yml"); + const iconReference = await this.prepareIcon(profile, repository, server); + const metadata = this.metadataCompose(profile, repository, iconReference); + const compose = this.composeInvocation(profile, repository); + const flags = this.composeUpFlags(profile); + const script = ` + root=${shellQuote(remotePath)} + test -d "$root" + mkdir -p "$root/.forgeflow" + cat > "$root/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA' + ${metadata}FORGEFLOW_METADATA + cd "$root" + ${compose} config >/dev/null + ${compose} up -d --build ${flags} + ${this.containerVerificationScript(profile, repository, compose)} + ${this.dockerManRefreshScript(profile, repository, iconReference)} + `; + await this.ssh.exec(server.id, bash(script), { + timeout: 10 * 60_000, + maxOutput: 2 * 1024 * 1024, + }); + return this.refreshProfileState(repository.fullName, profileId); + } + + async refreshOperation( + operationId, + { includeTerminal = false, state: suppliedState = null } = {}, + ) { + const operation = this.store.getOperation(operationId); + if (!operation || operation.provider !== "ssh-unraid") return operation; + if ( + !includeTerminal && + ["success", "failed", "cancelled", "rolled-back"].includes( + operation.status, + ) + ) + return operation; + try { + const state = + suppliedState || + (await this.refreshProfileState( + operation.repository, + operation.profileId, + )); + if ( + state.liveSha === operation.sha && + state.containerRunning && + state.healthy !== false + ) { + return this.saveOperation({ + ...operation, + status: operation.action === "rollback" ? "rolled-back" : "success", + health: { healthy: state.healthy, status: state.healthStatus }, + logs: [ + ...(operation.logs || []), + "Deployment state reconciled from Unraid.", + ], + }); + } + if ( + /^[0-9a-f]{40}$/i.test(String(state.liveSha || "")) && + state.liveSha !== operation.sha && + state.containerRunning && + state.healthy !== false + ) { + return this.saveOperation({ + ...operation, + status: "cancelled", + error: `Superseded by live commit ${state.liveSha.slice(0, 7)}.`, + health: { healthy: state.healthy, status: state.healthStatus }, + logs: [ + ...(operation.logs || []), + `Operation superseded by live Unraid commit ${state.liveSha}.`, + ], + }); + } + const ageMs = + Date.now() - + new Date(operation.updatedAt || operation.createdAt || 0).getTime(); + if (ageMs > 45 * 60_000) { + return this.saveOperation({ + ...operation, + status: "failed", + error: + "Deployment was interrupted or did not reach the requested commit within 45 minutes.", + logs: [ + ...(operation.logs || []), + "Stale deployment was marked failed during reconciliation.", + ], + }); + } + return operation; + } catch { + return operation; + } + } + + async reconcileRecordedOperations(profileId, state) { + const operations = this.store.data.operations + .filter( + (item) => + item.profileId === profileId && item.provider === "ssh-unraid", + ) + .sort( + (left, right) => + new Date(right.updatedAt || right.createdAt || 0) - + new Date(left.updatedAt || left.createdAt || 0), + ); + const matching = operations.find( + (item) => item.sha === state.liveSha && item.status === "failed", + ); + if (matching && state.containerRunning && state.healthy !== false) { + await this.refreshOperation(matching.id, { + includeTerminal: true, + state, + }); + } + const latestFailed = operations.find((item) => item.status === "failed"); + if ( + latestFailed && + latestFailed.id !== matching?.id && + state.matchesGitea && + state.containerRunning && + state.healthy !== false + ) { + await this.saveOperation({ + ...latestFailed, + status: "cancelled", + error: `Superseded by Gitea/live commit ${state.liveSha.slice(0, 7)}.`, + logs: [ + ...(latestFailed.logs || []), + `Reconciled: Gitea and Unraid now both report ${state.liveSha}.`, + ], + }); + } + return this.store.data.operations + .filter((item) => item.profileId === profileId) + .slice(0, 10); + } + + async refreshActiveOperations() { + const active = this.store.data.operations.filter( + (item) => + item.provider === "ssh-unraid" && + item.type === "deployment" && + !["success", "failed", "cancelled", "rolled-back"].includes( + item.status, + ), + ); + return Promise.all(active.map((item) => this.refreshOperation(item.id))); + } + } + return UnraidStateMethods.prototype; +} + +module.exports = { createUnraidStateMethods }; diff --git a/src/renderer/actions/command.js b/src/renderer/actions/command.js new file mode 100644 index 0000000..970f4a5 --- /dev/null +++ b/src/renderer/actions/command.js @@ -0,0 +1,22 @@ +async function handleCommandActions(event, target, action, repository) { + if (action === "run-command") { + const command = target.dataset.command; + ui.modal = null; + if (command === "overview") ui.currentView = "overview"; + else if (command === "deployments") ui.currentView = "deployments"; + else if (command === "diagnostics") ui.currentView = "diagnostics"; + else if (command === "settings") ui.currentView = "settings"; + else if (command === "refresh") await refreshRepositories(true); + else if (command === "open-folder" && repository?.localPath) + await window.forgeflow.openPath(repository.localPath); + else if (command === "git-tools" && repository) + await loadGitTools(repository); + else if (command === "deploy-selected" && canDeploy(repository)) { + const profile = selectedProfile(repository); + if (profile) await runDeploymentPreflight(repository, profile.id); + } + render(); + } + else return false; + return true; +} diff --git a/src/renderer/actions/deployment-operation.js b/src/renderer/actions/deployment-operation.js new file mode 100644 index 0000000..8a06369 --- /dev/null +++ b/src/renderer/actions/deployment-operation.js @@ -0,0 +1,183 @@ +async function handleDeploymentOperationActions(event, target, action, repository) { + if (action === "deploy-profile") { + if (repository && String(repository.id) !== String(ui.selectedRepoId)) + selectRepository(repository.id, false); + const profile = + repository?.deploymentProfiles?.find( + (item) => item.id === target.dataset.profileId, + ) || selectedProfile(repository); + ui.selectedProfileId = profile?.id || null; + if (!profile) return; + const report = await runDeploymentPreflight(repository, profile.id, { + showModal: true, + }); + if (!report) return; + } else if (action === "continue-after-preflight") { + const profile = selectedRepository()?.deploymentProfiles?.find( + (item) => item.id === target.dataset.profileId, + ); + if (!profile || !ui.deploymentPreflight?.summary?.ready) return; + if (profile.confirmationRequired !== false) { + ui.modal = { type: "deploy-confirm", profileId: profile.id }; + render(); + } else await executeDeployment(profile.id); + } else if (action === "confirm-deploy") + await executeDeployment(target.dataset.profileId); + else if (action === "rollback-profile") { + if (!repository) repository = profileRepository(target.dataset.profileId); + if (repository && String(repository.id) !== String(ui.selectedRepoId)) + selectRepository(repository.id, false); + ui.modal = { + type: "rollback-confirm", + profileId: target.dataset.profileId, + }; + render(); + } else if (action === "confirm-rollback") + await executeRollback(target.dataset.profileId); + else if (action === "refresh-profile-state") { + if (!repository) repository = profileRepository(target.dataset.profileId); + const profile = repository?.deploymentProfiles?.find( + (item) => item.id === target.dataset.profileId, + ); + setLoading(true, `Checking ${profile?.environment || "environment"}…`); + try { + const state = await window.forgeflow.refreshProfileState( + repository.fullName, + target.dataset.profileId, + ); + profile.state = state; + showToast( + "Environment checked", + state.healthy === false + ? "Healthcheck reports an unhealthy state." + : state.liveSha + ? `Server reports ${shortSha(state.liveSha)}.` + : "Connection checked; no live SHA reported.", + state.healthy === false ? "error" : "success", + ); + } catch (error) { + showToast("Status check failed", error.message, "error"); + } + setLoading(false); + } else if (action === "repair-missing-dockerman") { + const targets = ui.repositories.flatMap((candidate) => + (candidate.deploymentProfiles || []) + .filter( + (profile) => + profile.provider === "ssh-unraid" && + profile.state?.containerRunning && + !dockerManIntegration(profile).ready, + ) + .map((profile) => ({ repository: candidate, profile })), + ); + if (!targets.length) return; + if ( + !confirm( + `Recreate ${targets.length} running container${targets.length === 1 ? "" : "s"} with the missing DockerMan WebUI, icon and template metadata?`, + ) + ) + return; + setLoading(true, "Repairing missing DockerMan integrations…"); + let repaired = 0; + const failures = []; + for (const item of targets) { + try { + await window.forgeflow.applyDockerManMetadata( + item.repository, + item.profile.id, + ); + repaired += 1; + } catch (error) { + failures.push(`${item.repository.name}: ${error.message}`); + } + } + await refreshDeploymentTruth(false); + showToast( + failures.length + ? "DockerMan repair partially completed" + : "DockerMan integrations repaired", + failures.length + ? `${repaired} repaired, ${failures.length} failed.` + : `${repaired} running container${repaired === 1 ? "" : "s"} updated.`, + failures.length ? "error" : "success", + ); + setLoading(false); + } else if (action === "apply-dockerman-metadata") { + if (!repository) repository = profileRepository(target.dataset.profileId); + setLoading( + true, + "Applying DockerMan labels, template, icon and WebUI metadata…", + ); + try { + await window.forgeflow.applyDockerManMetadata( + repository, + target.dataset.profileId, + ); + await refreshRepositories(false); + showToast( + "DockerMan integration repaired", + "The container was recreated with labels, a persistent template, WebUI and icon metadata.", + "success", + ); + } catch (error) { + showToast( + "Could not repair DockerMan integration", + error.message, + "error", + ); + } + setLoading(false); + } else if (action === "reconcile-deployment") { + if (!repository) repository = profileRepository(target.dataset.profileId); + setLoading(true, "Reconciling ForgeFlow with the live Unraid container…"); + try { + await window.forgeflow.reconcileDeployment( + repository.fullName, + target.dataset.profileId, + ); + await refreshActiveOperations(false); + await refreshRepositories(false); + showToast( + "Deployment reconciled", + "Live SHA, container health and operation status were refreshed.", + "success", + ); + } catch (error) { + showToast("Could not reconcile deployment", error.message, "error"); + } + setLoading(false); + } else if (action === "open-profile-webui") + await window.forgeflow.openExternal(target.dataset.url); + else if (action === "open-operation") { + const operation = await window.forgeflow.getOperation( + target.dataset.operationId, + ); + if (operation) { + ui.activeDeployment = operation; + ui.currentView = "deployment-run"; + render(); + startOperationPolling(); + } + } else if (action === "refresh-current-operation") { + setLoading(true, "Refreshing deployment status…"); + try { + const operation = await window.forgeflow.refreshOperations( + ui.activeDeployment.id, + ); + updateOperationInState(operation); + if (!isTerminalOperation(operation.status)) startOperationPolling(); + } catch (error) { + showToast("Status refresh failed", error.message, "error"); + } + setLoading(false); + } else if (action === "open-run-url") + await window.forgeflow.openExternal(ui.activeDeployment.runUrl); + else if (action === "close-deployment") { + stopOperationPolling(); + ui.activeDeployment = null; + ui.currentView = selectedRepository() ? "repository" : "deployments"; + render(); + } + else return false; + return true; +} diff --git a/src/renderer/actions/deployment-profile.js b/src/renderer/actions/deployment-profile.js new file mode 100644 index 0000000..069fec7 --- /dev/null +++ b/src/renderer/actions/deployment-profile.js @@ -0,0 +1,401 @@ +async function handleDeploymentProfileActions(event, target, action, repository) { + if (action === "configure-deployment") { + ui.deploymentDiscovery = null; + ui.modal = { + type: "deployment-config", + profileId: null, + provider: (ui.boot.state.servers || []).length + ? "ssh-unraid" + : "gitea-actions", + }; + render(); + } else if (action === "edit-deployment-profile") { + ui.deploymentDiscovery = null; + repository = profileRepository(target.dataset.profileId) || repository; + if (repository && String(repository.id) !== String(ui.selectedRepoId)) + selectRepository(repository.id, false); + ui.modal = { + type: "deployment-config", + profileId: target.dataset.profileId || null, + provider: repository?.deploymentProfiles?.find( + (item) => item.id === target.dataset.profileId, + )?.provider, + }; + render(); + } else if (action === "close-modal") { + ui.modal = null; + render(); + } else if (action === "select-profile-icon") { + const iconPath = await window.forgeflow.selectImageFile({ + title: "Select DockerMan PNG icon", + defaultPath: + document.querySelector("#profile-icon-file")?.value || undefined, + }); + if (iconPath) { + document.querySelector("#profile-icon-file").value = iconPath; + const mode = document.querySelector("#profile-icon-mode"); + if (mode) mode.value = "upload"; + } + } else if (action === "clear-profile-icon") { + const input = document.querySelector("#profile-icon-file"); + if (input) input.value = ""; + const mode = document.querySelector("#profile-icon-mode"); + if (mode) mode.value = "builtin"; + } else if (action === "discover-existing-deployment") { + const serverId = document.querySelector("#profile-server")?.value; + const remoteFolder = + document.querySelector("#profile-remote-folder")?.value.trim() || + safeCloneFolderName(repository); + if (!serverId) { + showToast( + "Select an Unraid server", + "Configure and select the server before importing an existing deployment.", + "error", + ); + return; + } + setLoading( + true, + "Reading Git, Compose, Docker and DockerMan from the server…", + ); + try { + const result = await window.forgeflow.discoverExistingDeployment( + repository, + serverId, + remoteFolder, + ); + ui.deploymentDiscovery = { ...result, repository: repository.fullName }; + showToast( + "Existing deployment imported", + `${result.runtime.containers} container(s), ${result.runtime.services} service(s) and ${result.runtime.ports.length} port mapping(s) detected.`, + "success", + ); + render(); + } catch (error) { + showToast("Could not import deployment", error.message, "error"); + } + setLoading(false); + } else if (action === "save-deployment-profile") { + const previousProfile = + repository?.deploymentProfiles?.find( + (item) => item.id === target.dataset.profileId, + ) || {}; + const provider = document.querySelector("#profile-provider").value; + let maintenanceWindows = []; + try { + maintenanceWindows = ( + document.querySelector("#profile-policy-windows")?.value || "" + ) + .split("|") + .map((item) => item.trim()) + .filter(Boolean) + .map((item) => { + const match = item.match( + /^([0-6](?:,[0-6])*)\s*:\s*((?:[01]\d|2[0-3]):[0-5]\d)-((?:[01]\d|2[0-3]):[0-5]\d)$/, + ); + if (!match) throw new Error(`Invalid maintenance window: ${item}`); + return { + days: match[1].split(",").map(Number), + start: match[2], + end: match[3], + }; + }); + } catch (error) { + showToast("Could not save profile", error.message, "error"); + return; + } + const composeFiles = + provider === "ssh-unraid" + ? (document.querySelector("#profile-compose-files")?.value || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean) + : []; + const composeServices = + provider === "ssh-unraid" + ? (document.querySelector("#profile-compose-services")?.value || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean) + : []; + const profile = { + id: target.dataset.profileId || undefined, + provider, + name: document.querySelector("#profile-name").value.trim(), + environment: document.querySelector("#profile-environment").value.trim(), + branch: document.querySelector("#profile-branch").value.trim(), + healthcheckUrl: + document.querySelector("#profile-healthcheck")?.value.trim() || "", + confirmationRequired: document.querySelector("#profile-confirmation") + .checked, + deploymentPolicy: { + frozen: + document.querySelector("#profile-policy-frozen")?.checked === true, + freezeReason: + document + .querySelector("#profile-policy-freeze-reason") + ?.value.trim() || "", + requireNote: + document.querySelector("#profile-policy-note")?.checked === true, + maintenanceWindows, + }, + ...(provider === "ssh-unraid" + ? { + serverId: document.querySelector("#profile-server").value, + remoteFolder: document + .querySelector("#profile-remote-folder") + .value.trim(), + deploymentMode: ["server-git", "push-bundle", "monitor-only"].includes( + document.querySelector("#profile-deployment-mode")?.value, + ) ? document.querySelector("#profile-deployment-mode").value : "server-git", + cloneUrl: previousProfile.cloneUrl || "", + alignRemote: false, + generatedCompose: + document.querySelector("#profile-generated-compose").value === + "true", + composeProject: + document.querySelector("#profile-compose-project")?.value.trim() || + previousProfile.composeProject || + "", + composeWorkingDir: previousProfile.composeWorkingDir || "", + composeFiles: composeFiles.length ? composeFiles : ["docker-compose.yml"], + composeFile: composeFiles[0] || "docker-compose.yml", + composeServices: composeServices.length + ? composeServices + : [safeCloneFolderName(repository).toLowerCase()], + composeService: + composeServices[0] || safeCloneFolderName(repository).toLowerCase(), + containerName: document + .querySelector("#profile-container-name") + .value.trim(), + hostPort: + Number(document.querySelector("#profile-host-port").value) || + null, + containerPort: + Number(document.querySelector("#profile-container-port").value) || + null, + webUiUrl: document.querySelector("#profile-web-ui").value.trim(), + iconMode: document.querySelector("#profile-icon-mode").value, + iconUrl: document.querySelector("#profile-icon-url").value.trim(), + iconFilePath: document + .querySelector("#profile-icon-file") + .value.trim(), + dockerShell: document.querySelector("#profile-docker-shell").value, + preservePaths: document + .querySelector("#profile-preserve-paths") + .value.split(",") + .map((item) => item.trim()) + .filter(Boolean), + manageDockerMan: + document.querySelector("#profile-manage-dockerman")?.checked === + true, + forceRecreate: false, + removeOrphans: false, + adoptedFromServer: Boolean( + ui.deploymentDiscovery || previousProfile.adoptedFromServer, + ), + serverSourceOfTruth: Boolean( + ui.deploymentDiscovery || previousProfile.serverSourceOfTruth, + ), + workloadIdentity: + ui.deploymentDiscovery?.profile?.workloadIdentity || + previousProfile.workloadIdentity || + null, + detectedAt: + ui.deploymentDiscovery?.profile?.detectedAt || + previousProfile.detectedAt || + null, + provenance: + ui.deploymentDiscovery?.provenance || + previousProfile.provenance || + {}, + detectedMetadata: + ui.deploymentDiscovery?.profile?.detectedMetadata || + previousProfile.detectedMetadata || + {}, + } + : { + workflowFile: document + .querySelector("#profile-workflow") + .value.trim(), + rollbackWorkflowFile: document + .querySelector("#profile-rollback-workflow") + .value.trim(), + statusUrl: document + .querySelector("#profile-status-url") + .value.trim(), + }), + }; + setLoading(true, "Saving deployment environment…"); + try { + const result = await window.forgeflow.saveDeploymentProfile( + repository.fullName, + profile, + ); + ui.boot.state = result.state; + ui.modal = null; + ui.deploymentDiscovery = null; + await refreshRepositories(false); + ui.selectedProfileId = result.profile.id; + showToast( + "Deployment configured", + `${profile.name} targets ${profile.environment}.`, + "success", + ); + } catch (error) { + showToast("Could not save profile", error.message, "error"); + } + setLoading(false); + } else if (action === "delete-deployment-profile") { + if ( + !confirm("Delete this deployment profile? Operation history is retained.") + ) + return; + setLoading(true, "Deleting deployment profile…"); + try { + const result = await window.forgeflow.deleteDeploymentProfile( + repository.fullName, + target.dataset.profileId, + ); + ui.boot.state = result.state; + ui.modal = null; + await refreshRepositories(false); + showToast( + "Profile deleted", + "Deployment environment removed.", + "success", + ); + } catch (error) { + showToast("Could not delete profile", error.message, "error"); + } + setLoading(false); + } else if (action === "run-deployment-preflight") { + if (!repository) repository = profileRepository(target.dataset.profileId); + await runDeploymentPreflight(repository, target.dataset.profileId); + } else if (action === "manage-deploy-key") { + const profileId = target.dataset.profileId || ui.selectedProfileId; + if (!repository) repository = profileRepository(profileId); + if (!repository || !profileId) return; + setLoading(true, "Inspecting deploy-key lifecycle without changing access…"); + try { + const [inventory, rotation, revocation] = await Promise.all([ + window.forgeflow.deployKeyInventory(repository, profileId), + window.forgeflow.planDeployKeyRotation(repository, profileId), + window.forgeflow.planDeployKeyRevocation(repository, profileId), + ]); + ui.deployKeyLifecycle = { repositoryId: repository.id, profileId, inventory, rotation, revocation }; + ui.modal = { type: "deploy-key-lifecycle" }; + render(); + } catch (error) { + showToast("Could not inspect deploy key", error.message, "error"); + } finally { setLoading(false); } + } else if (action === "confirm-rotate-deploy-key") { + const lifecycle = ui.deployKeyLifecycle; + const targetRepository = repositories().find((item) => item.id === lifecycle?.repositoryId); + if (!targetRepository || !lifecycle?.rotation?.id) return; + setLoading(true, "Rotating and verifying the repository deploy key…"); + try { + const result = await window.forgeflow.applyDeployKeyRotation(targetRepository, lifecycle.profileId, lifecycle.rotation.id); + if (result.state) ui.boot.state = result.state; + ui.modal = null; ui.deployKeyLifecycle = null; + await refreshRepositories(false, true); + showToast("Deploy key rotated", `New fingerprint ${result.profile?.serverGitAccess?.keyFingerprint || "verified"}.`, "success"); + } catch (error) { showToast("Deploy-key rotation failed safely", error.message, "error"); } + finally { setLoading(false); } + } else if (action === "confirm-revoke-deploy-key") { + const lifecycle = ui.deployKeyLifecycle; + const targetRepository = repositories().find((item) => item.id === lifecycle?.repositoryId); + if (!targetRepository || !lifecycle?.revocation?.id) return; + setLoading(true, "Revoking repository access while preserving recovery…"); + try { + const result = await window.forgeflow.applyDeployKeyRevocation(targetRepository, lifecycle.profileId, lifecycle.revocation.id); + if (result.state) ui.boot.state = result.state; + ui.modal = null; ui.deployKeyLifecycle = null; + await refreshRepositories(false, true); + showToast("Deploy key revoked", "Server pull is disabled; containers were not changed and recovery is available.", "success"); + } catch (error) { showToast("Deploy-key revocation failed", error.message, "error"); } + finally { setLoading(false); } + } else if (action === "restore-deploy-key") { + const lifecycle = ui.deployKeyLifecycle; + const targetRepository = repositories().find((item) => item.id === lifecycle?.repositoryId); + if (!targetRepository || !lifecycle?.profileId) return; + setLoading(true, "Restoring and verifying repository access…"); + try { + const result = await window.forgeflow.restoreDeployKey(targetRepository, lifecycle.profileId); + if (result.state) ui.boot.state = result.state; + ui.modal = null; ui.deployKeyLifecycle = null; + await refreshRepositories(false, true); + showToast("Deploy key restored", "Read-only server pull access is verified again.", "success"); + } catch (error) { showToast("Deploy-key recovery failed", error.message, "error"); } + finally { setLoading(false); } + } else if (action === "verify-server-git-access") { + const profileId = target.dataset.profileId || ui.selectedProfileId; + if (!repository) repository = profileRepository(profileId); + if (!repository || !profileId) return; + setLoading(true, "Verifying Gitea, deploy key, server commit and runtime…"); + try { + const result = await window.forgeflow.verifyServerGitProfile(repository, profileId); + ui.serverGitVerifications[profileId] = result; + render(); + const failures = result.checks.filter((check) => check.status === "fail"); + showToast(result.readiness, failures[0]?.detail || `Verified ${result.checks.length} server-pull checks without changing the server.`, result.ready ? "success" : "warning"); + } catch (error) { + showToast("Server-pull verification failed", error.message, "error"); + } finally { + setLoading(false); + } + } else if (action === "configure-server-git-access") { + const profileId = target.dataset.profileId || ui.selectedProfileId; + if (!repository) repository = profileRepository(profileId); + if (!repository || !profileId) return; + const approved = confirm( + `Configure read-only Gitea access for ${repository.fullName}?\n\nForgeFlow creates a dedicated SSH deploy key on the selected server, adds only its public key to this Gitea repository and pins the observed Gitea SSH host key. The private key never leaves the server.`, + ); + if (!approved) return; + setLoading(true, "Configuring repository-scoped Gitea access…"); + try { + const result = await window.forgeflow.configureServerGitAccess(repository, profileId); + if (result.state) ui.boot.state = result.state; + await refreshRepositories(false, true); + await refreshDeploymentTruth(false); + showToast( + "Server pull ready", + `Read-only Gitea access verified at ${shortSha(result.remoteSha)}.`, + "success", + ); + await runDeploymentPreflight(repository, profileId, { showModal: true }); + } catch (error) { + showToast("Could not configure Gitea access", error.message, "error"); + } finally { + setLoading(false); + } + } else if (action === "repair-deployment-write-access") { + const profileId = target.dataset.profileId || ui.selectedProfileId; + if (!repository) repository = profileRepository(profileId); + if (!repository || !profileId) return; + const profile = repository.deploymentProfiles?.find((item) => item.id === profileId); + const approved = confirm( + `Repair write access for ${repository.fullName} on ${profile?.name || profile?.environment || "the linked Unraid deployment"}?\n\nForgeFlow will only adjust the linked project source tree and its .forgeflow state folders. Preserved runtime paths such as appdata, data, config and logs are excluded. No container will be stopped, removed or recreated.`, + ); + if (!approved) return; + setLoading(true, "Repairing scoped Unraid write access…"); + try { + const result = await window.forgeflow.repairDeploymentWriteAccess( + repository, + profileId, + ); + showToast( + "Write access normalized", + "Project source and ForgeFlow upload folders now use safe shared write permissions. Preserved runtime data was not changed.", + "success", + ); + await runDeploymentPreflight(repository, profileId, { showModal: true }); + } catch (error) { + showToast("Write-access repair failed", error.message, "error"); + } finally { + setLoading(false); + } + } + else return false; + return true; +} diff --git a/src/renderer/actions/inventory.js b/src/renderer/actions/inventory.js new file mode 100644 index 0000000..f8feb30 --- /dev/null +++ b/src/renderer/actions/inventory.js @@ -0,0 +1,185 @@ +async function handleInventoryActions(event, target, action, repository) { + if (action === "scan-server-inventory") { + setLoading(true, "Scanning Docker, Compose and DockerMan workloads…"); + try { + await refreshDeploymentTruth(true); + const detected = (ui.serverDiscovery || []).reduce( + (total, item) => total + Number(item.detected || 0), + 0, + ); + const review = (ui.serverDiscovery || []).reduce( + (total, item) => total + Number(item.needsReview || 0), + 0, + ); + const failures = (ui.serverDiscovery || []).filter((item) => item.error); + if (failures.length) { + showToast( + "Server scan failed", + failures.map((item) => `${item.serverName || item.serverId}: ${item.error}`).join(" · "), + "error", + ); + } else { + showToast( + "Server inventory updated", + `${detected} workload${detected === 1 ? "" : "s"} detected; ${review} require manual review.`, + review ? "info" : "success", + ); + } + } catch (error) { + showToast("Server scan failed", error.message, "error"); + } + setLoading(false); + } else if (action === "plan-server-reconciliation") { + setLoading(true, "Building a read-only reconciliation preview…"); + try { + const result = await window.forgeflow.planServerReconciliation(target.dataset.serverId); + ui.modal = { type: "server-reconciliation-plan", result }; + render(); + } catch (error) { + showToast("Could not build reconciliation plan", error.message, "error"); + } + setLoading(false); + } else if (action === "apply-server-reconciliation") { + setLoading(true, "Applying the reviewed configuration plan…"); + try { + const result = await window.forgeflow.applyServerReconciliation(target.dataset.serverId, target.dataset.planId); + if (result.state) ui.boot.state = result.state; + ui.modal = null; + await refreshRepositories(false, true); + await refreshDeploymentTruth(false); + showToast("Reconciliation applied", `${result.adopted || 0} link(s) added and ${result.refreshed || 0} profile(s) refreshed. No containers were changed.`, "success"); + } catch (error) { + showToast("Reconciliation was not applied", error.message, "error"); + } + setLoading(false); + } else if (action === "quick-link-server-workload") { + const serverResult = (ui.serverDiscovery || []).find( + (item) => item.serverId === target.dataset.serverId, + ); + const workload = serverResult?.workloads?.find( + (item) => item.workloadId === target.dataset.workloadId, + ); + const linkedRepository = ui.repositories.find( + (item) => item.fullName === target.dataset.repository, + ); + if (!workload || !linkedRepository || !workload.remoteFolderCandidate) { + showToast("Automatic link unavailable", "Scan the server again and use Review & link.", "error"); + return; + } + setLoading(true, `Linking ${workload.displayName} to ${linkedRepository.fullName}…`); + try { + const result = await window.forgeflow.linkServerWorkload( + linkedRepository, + target.dataset.serverId, + target.dataset.workloadId, + "server-git", + workload.remoteFolderCandidate, + ); + if (result.state) ui.boot.state = result.state; + ui.selectedProfileId = result.profile?.id || null; + await refreshRepositories(false, true); + await refreshDeploymentTruth(false); + showToast( + "Deployment linked", + `${linkedRepository.fullName} is linked to ${workload.compose?.workingDir || workload.remoteFolderCandidate}. Compose values were read from the server.`, + "success", + ); + } catch (error) { + showToast("Could not link deployment", error.message, "error"); + } + setLoading(false); + } else if (action === "preview-inventory-review") { + const reviewAction = document.querySelector("#inventory-review-action")?.value || "ignore"; + const reason = document.querySelector("#inventory-review-reason")?.value.trim() || ""; + const serverId = target.dataset.serverId; + const workloadId = target.dataset.workloadId; + try { + ui.inventoryReviewPlan = await window.forgeflow.planInventoryReview(serverId, workloadId, reviewAction, reason, document.querySelector("#workload-repository")?.value || null); + ui.modal = { type: "inventory-review-plan" }; + render(); + } catch (error) { showToast("Review preview unavailable", error.message, "error"); } + } else if (action === "apply-inventory-review") { + const plan = ui.inventoryReviewPlan; + if (!plan?.id) return; + setLoading(true, "Saving the evidence-bound inventory decision…"); + try { + const result = await window.forgeflow.applyInventoryReview(plan.serverId, plan.workloadId, plan.action, plan.reason, plan.repositoryFullName, plan.id); + if (result.state) ui.boot.state = result.state; + ui.serverDiscovery = (ui.serverDiscovery || []).map((item) => item.serverId === plan.serverId ? result.inventory : item); + ui.inventoryReviewPlan = null; ui.modal = null; render(); + showToast("Inventory decision saved", "Containers and Compose runtime were not changed.", "success"); + } catch (error) { showToast("Inventory review failed safely", error.message, "error"); } + finally { setLoading(false); } + } else if (action === "link-server-workload") { + const serverResult = (ui.serverDiscovery || []).find( + (item) => item.serverId === target.dataset.serverId, + ); + const workload = serverResult?.workloads?.find( + (item) => item.workloadId === target.dataset.workloadId, + ); + if (!workload) { + showToast( + "Workload unavailable", + "Scan the server inventory again before linking this workload.", + "error", + ); + return; + } + ui.modal = { + type: "workload-link", + serverId: target.dataset.serverId, + workloadId: target.dataset.workloadId, + repositoryFullName: + workload.candidates?.[0]?.repositoryFullName || + repository?.fullName || + ui.repositories[0]?.fullName || + "", + remoteFolder: workload.remoteFolderCandidate || "", + }; + render(); + } else if (action === "confirm-link-server-workload") { + const repositoryFullName = document + .querySelector("#workload-repository") + ?.value.trim(); + const deploymentMode = document.querySelector("#workload-deployment-mode")?.value || "server-git"; + const remoteFolder = document + .querySelector("#workload-remote-folder") + ?.value.trim(); + const linkedRepository = ui.repositories.find( + (item) => item.fullName === repositoryFullName, + ); + if (!linkedRepository) { + showToast( + "Choose a repository", + "The workload must be linked to a ForgeFlow project.", + "error", + ); + return; + } + setLoading(true, "Saving the permanent server workload link…"); + try { + const result = await window.forgeflow.linkServerWorkload( + linkedRepository, + target.dataset.serverId, + target.dataset.workloadId, + deploymentMode, + remoteFolder, + ); + if (result.state) ui.boot.state = result.state; + ui.modal = null; + ui.selectedProfileId = result.profile?.id || null; + await refreshRepositories(false, true); + await refreshDeploymentTruth(false); + showToast( + "Workload linked", + `${linkedRepository.fullName} now uses direct desktop-to-Unraid copy and the Compose configuration detected on the server.`, + "success", + ); + } catch (error) { + showToast("Could not link workload", error.message, "error"); + } + setLoading(false); + } + else return false; + return true; +} diff --git a/src/renderer/actions/recovery.js b/src/renderer/actions/recovery.js new file mode 100644 index 0000000..92cf111 --- /dev/null +++ b/src/renderer/actions/recovery.js @@ -0,0 +1,355 @@ +async function handleRecoveryActions(event, target, action, repository) { + if (action === "repair-origin") { + if (!repository?.localPath || !repository.sshUrl) return; + if ( + !confirm( + `Replace origin with ${repository.sshUrl}? Local files and commits are not changed.`, + ) + ) + return; + setLoading(true, "Updating Git origin…"); + try { + await window.forgeflow.setOrigin(repository.localPath, repository.sshUrl); + await refreshRepositories(false); + showToast("Git origin updated", repository.sshUrl, "success"); + } catch (error) { + showToast("Could not update origin", error.message, "error"); + } + setLoading(false); + } else if (action === "normalize-origins") { + if ( + !confirm( + "Replace legacy origin URLs for every linked repository with the current Gitea SSH URL? Local files and commits are not changed.", + ) + ) + return; + setLoading(true, "Normalizing linked Git origins…"); + try { + const result = await window.forgeflow.normalizeOrigins(); + ui.repositories = result.repositories; + showToast( + "Git origins normalized", + `${result.changes.length} repository origin${result.changes.length === 1 ? "" : "s"} updated.`, + "success", + ); + } catch (error) { + showToast("Could not normalize origins", error.message, "error"); + } + setLoading(false); + render(); + } else if (action === "scan-git-recovery") { + if (!repository?.localPath) return; + setLoading(true, "Scanning Git directory and active processes…"); + try { + ui.gitRecovery = await window.forgeflow.gitRecoveryStatus( + repository.localPath, + ); + ui.repositoryTab = "gittools"; + showToast( + "Git health scan complete", + `${ui.gitRecovery.lockReport.locks.length} lock file(s) found.`, + ui.gitRecovery.lockReport.locks.length ? "info" : "success", + ); + } catch (error) { + showToast("Git health scan failed", error.message, "error"); + } + setLoading(false); + render(); + } else if (action === "repair-git-locks" || action === "repair-index-lock") { + if ( + !repository?.localPath || + !confirm( + "Repair stale Git lock files for this repository? ForgeFlow refuses while a matching Git process is active.", + ) + ) + return; + setLoading(true, "Safely repairing stale Git locks…"); + try { + const result = await window.forgeflow.repairGitLocks( + repository.localPath, + false, + ); + ui.gitRecovery = await window.forgeflow.gitRecoveryStatus( + repository.localPath, + ); + await refreshRepositories(false); + showToast( + "Git locks repaired", + `${result.removed.length} stale lock file(s) removed.`, + "success", + ); + } catch (error) { + if ( + error.code === "GIT_PROCESS_PROBE_UNAVAILABLE" && + confirm(`${error.message} + +Force repair after you have closed all Git tools for this repository?`) + ) { + try { + const result = await window.forgeflow.repairGitLocks( + repository.localPath, + true, + ); + showToast( + "Git locks force-repaired", + `${result.removed.length} lock file(s) removed.`, + "success", + ); + await refreshRepositories(false); + } catch (forceError) { + showToast("Could not repair Git locks", forceError.message, "error"); + } + } else showToast("Could not repair Git locks", error.message, "error"); + } + setLoading(false); + render(); + } else if (action === "reconcile-repository") { + if (!repository?.localPath) return; + setLoading(true, "Refreshing repository truth from Git…"); + try { + ui.gitRecovery = await window.forgeflow.reconcileRepository( + repository.localPath, + ); + await refreshRepositories(false); + showToast( + "Repository reconciled", + "Branch, upstream, lock and working-tree state were refreshed.", + "success", + ); + } catch (error) { + showToast("Could not reconcile repository", error.message, "error"); + } + setLoading(false); + render(); + } else if (action === "repair-repository-sync") { + if (!repository?.localPath) return; + const strategy = target.dataset.strategy; + const destructive = strategy === "backup-reset"; + const message = destructive + ? "Create a safety branch from the current HEAD and reset this branch to its upstream? Uncommitted changes are never discarded." + : `Run the repository-specific ${strategy} repair now?`; + if (!confirm(message)) return; + setLoading( + true, + destructive + ? "Creating safety branch and repairing divergence…" + : "Repairing repository synchronization…", + ); + try { + const result = await window.forgeflow.repairRepositorySync( + repository.localPath, + strategy, + ); + ui.gitRecovery = await window.forgeflow.gitRecoveryStatus( + repository.localPath, + ); + await refreshRepositories(false); + showToast( + "Repository synchronization repaired", + result.backupBranch + ? `Safety branch created: ${result.backupBranch}` + : `Completed ${strategy}.`, + "success", + ); + } catch (error) { + showToast("Synchronization repair failed", error.message, "error"); + } + setLoading(false); + render(); + } else if (action === "run-troubleshooter") { + setLoading( + true, + "Scanning repositories, Git operations and deployment servers…", + ); + try { + ui.troubleshooter = await window.forgeflow.troubleshooterScan(); + showToast( + "Troubleshooter completed", + ui.troubleshooter.summary.total + ? `${ui.troubleshooter.summary.total} issue(s) found; ${ui.troubleshooter.summary.repairable} repairable.` + : "No problems were detected.", + ui.troubleshooter.summary.errors ? "error" : "success", + ); + } catch (error) { + showToast("Troubleshooter failed", error.message, "error"); + } + setLoading(false); + render(); + } else if (action === "troubleshooter-auto-repair") { + const safeIssues = (ui.troubleshooter?.issues || []).filter( + (item) => item.repairable && item.safe, + ); + if ( + !safeIssues.length || + !confirm( + `Repair ${safeIssues.length} safe issue(s) now? ForgeFlow will not run destructive reset actions automatically.`, + ) + ) + return; + setLoading(true, "Applying safe one-click repairs…"); + try { + const results = + await window.forgeflow.troubleshooterAutoRepair(safeIssues); + ui.troubleshooter = await window.forgeflow.troubleshooterScan(); + await refreshRepositories(false); + const failed = results.filter((item) => !item.ok); + showToast( + failed.length + ? "Repairs partially completed" + : "Safe repairs completed", + `${results.length - failed.length} repaired, ${failed.length} failed.`, + failed.length ? "error" : "success", + ); + } catch (error) { + showToast("Automatic repair failed", error.message, "error"); + } + setLoading(false); + render(); + } else if (action === "troubleshooter-repair") { + const issue = + ui.troubleshooter?.issues?.[Number(target.dataset.issueIndex)]; + if (!issue) return; + const warning = issue.safe + ? `Repair “${issue.title}” now?` + : `“${issue.title}” requires a safety branch or another potentially destructive change. Continue?`; + if (!confirm(warning)) return; + setLoading(true, `Repairing ${issue.title}…`); + try { + const result = await window.forgeflow.troubleshooterRepair(issue); + ui.troubleshooter = await window.forgeflow.troubleshooterScan(); + await refreshRepositories(false); + showToast( + "Problem repaired", + result?.backupBranch + ? `Safety branch created: ${result.backupBranch}` + : issue.title, + "success", + ); + } catch (error) { + showToast("Repair failed", error.message, "error"); + } + setLoading(false); + render(); + } else if (action === "run-system-preflight") await runSystemPreflight(); + else if (action === "load-audit-log") { + try { + ui.auditEvents = await window.forgeflow.listAuditEvents(500); + render(); + } catch (error) { + showToast("Could not load audit log", error.message, "error"); + } + } else if (action === "export-audit-json" || action === "export-audit-csv") { + try { + const result = await window.forgeflow.exportAuditLog( + action.endsWith("csv") ? "csv" : "json", + ); + if (result) + showToast( + "Audit log exported", + `${result.count} records exported.`, + "success", + ); + } catch (error) { + showToast("Could not export audit log", error.message, "error"); + } + } else if (action === "save-diagnostics-preferences") { + const preferences = { + diagnosticsEnabled: + document.querySelector("#diagnostics-enabled").value === "true", + diagnosticLevel: document.querySelector("#diagnostic-level").value, + logRetentionDays: Number( + document.querySelector("#diagnostic-retention").value, + ), + maxLogFileMb: Number( + document.querySelector("#diagnostic-max-file").value, + ), + }; + setLoading(true, "Saving diagnostic policy…"); + try { + ui.boot.state = await window.forgeflow.setPreferences(preferences); + ui.diagnosticsStatus = await window.forgeflow.diagnosticsStatus(); + showToast( + "Diagnostic policy saved", + "New events now use the updated retention and logging level.", + "success", + ); + } catch (error) { + showToast("Could not save diagnostics", error.message, "error"); + } + setLoading(false); + } else if (action === "export-diagnostics") { + const privacyMode = + document.querySelector("#diagnostic-privacy")?.value || "standard"; + setLoading(true, "Creating redacted diagnostic bundle…"); + try { + const bundle = await window.forgeflow.exportDiagnostics(privacyMode); + if (bundle) { + ui.lastDiagnosticBundle = bundle; + ui.diagnosticsStatus = await window.forgeflow.diagnosticsStatus(); + showToast( + "Diagnostic bundle created", + `${bundle.size} · SHA-256 ${shortSha(bundle.sha256)}`, + "success", + ); + } + } catch (error) { + showToast("Could not export diagnostics", error.message, "error"); + } + setLoading(false); + } else if (action === "show-diagnostic-bundle") { + if (!ui.lastDiagnosticBundle?.path) return; + await window.forgeflow + .showDiagnosticBundle(ui.lastDiagnosticBundle.path) + .catch((error) => + showToast("Could not show bundle", error.message, "error"), + ); + } else if (action === "open-diagnostics-folder") + await window.forgeflow + .openDiagnosticsFolder() + .catch((error) => + showToast("Could not open diagnostic folder", error.message, "error"), + ); + else if (action === "clear-diagnostics") { + if ( + !confirm( + "Clear local ForgeFlow diagnostic logs? This does not affect repositories or configuration.", + ) + ) + return; + try { + ui.diagnosticsStatus = await window.forgeflow.clearDiagnostics(); + showToast( + "Diagnostic logs cleared", + "A new session marker was created.", + "success", + ); + render(); + } catch (error) { + showToast("Could not clear logs", error.message, "error"); + } + } else if (action === "reset-app") { + if ( + !confirm( + "Reset ForgeFlow configuration? Your Git repositories and Gitea data are not modified.", + ) + ) + return; + ui.boot.state = await window.forgeflow.reset(); + ui.repositories = []; + ui.setupStep = 0; + ui.setupValidation = null; + ui.systemPreflight = null; + ui.deploymentPreflight = null; + ui.lastDiagnosticBundle = null; + ui.setupDraft = { + baseUrl: "https://", + token: "", + user: null, + roots: [], + discovered: [], + }; + render(); + } + else return false; + return true; +} diff --git a/src/renderer/actions/setup-and-settings.js b/src/renderer/actions/setup-and-settings.js new file mode 100644 index 0000000..99906bf --- /dev/null +++ b/src/renderer/actions/setup-and-settings.js @@ -0,0 +1,426 @@ +async function handleSetupAndSettingsActions(event, target, action, repository) { + if (action === "setup-run-preflight") + await runSystemPreflight({ setup: true }); + else if (action === "setup-continue") { + if (ui.systemPreflight?.summary?.ready) { + ui.setupStep = 1; + render(); + } + } else if (action === "setup-validate") { + setLoading(true, "Validating Gitea connection…"); + try { + ui.setupValidation = await window.forgeflow.validateGitea(ui.setupDraft); + ui.setupDraft.baseUrl = ui.setupValidation.baseUrl; + ui.setupDraft.user = ui.setupValidation.user; + ui.setupStep = 2; + } catch (error) { + showToast("Connection failed", error.message, "error"); + } + setLoading(false); + } else if (action === "setup-add-root") { + const root = await window.forgeflow.selectDirectory({ + title: "Select a development folder", + }); + if (root && !ui.setupDraft.roots.includes(root)) + ui.setupDraft.roots.push(root); + render(); + } else if (action === "setup-remove-root") { + ui.setupDraft.roots.splice(Number(target.dataset.index), 1); + render(); + } else if (action === "setup-next") { + if (ui.setupStep === 2) { + ui.setupStep = 3; + ui.setupDraft.discovered = []; + render(); + try { + ui.setupDraft.discovered = await window.forgeflow.discoverRepositories( + ui.setupDraft.roots, + ); + } catch (error) { + showToast("Discovery failed", error.message, "error"); + } + ui.setupStep = 4; + render(); + } + } else if (action === "setup-back") { + ui.setupStep = Math.max(0, ui.setupStep - 1); + render(); + } else if (action === "setup-finish") { + setLoading(true, "Saving configuration…"); + try { + const result = await window.forgeflow.completeSetup({ + baseUrl: ui.setupDraft.baseUrl, + token: ui.setupDraft.token, + user: ui.setupDraft.user, + workspaceRoots: ui.setupDraft.roots, + }); + ui.boot.state = result.state; + await refreshRepositories(false); + showToast( + "Setup complete", + result.tokenState.persistent + ? "Your token is stored securely." + : "Your token is available for this session only.", + "success", + ); + } catch (error) { + showToast("Could not complete setup", error.message, "error"); + } + setLoading(false); + } else if (action === "check-updates") { + ui.updateChecking = true; + render(); + try { + ui.updateStatus = await window.forgeflow.checkForUpdates(); + showToast( + ui.updateStatus.available ? "Update available" : "ForgeFlow is current", + ui.updateStatus.available + ? `Version ${ui.updateStatus.remoteVersion} can be downloaded.` + : `Version ${ui.updateStatus.currentVersion} is the newest release.`, + ui.updateStatus.available ? "success" : "info", + ); + } catch (error) { + showToast("Update check failed", error.message, "error"); + } + ui.updateChecking = false; + render(); + } else if (action === "save-update-settings") { + const updates = { + owner: document.querySelector("#update-owner").value.trim(), + repo: document.querySelector("#update-repo").value.trim(), + branch: document.querySelector("#update-branch").value.trim(), + autoCheck: document.querySelector("#update-auto-check").value === "true", + }; + try { + ui.boot.state = await window.forgeflow.setUpdatePreferences(updates); + ui.updateStatus = null; + showToast( + "Update settings saved", + "The next check will use this repository and branch.", + "success", + ); + } catch (error) { + showToast("Could not save update settings", error.message, "error"); + } + render(); + } else if (action === "download-update") { + setLoading(true, "Downloading and verifying the exact ForgeFlow update…"); + try { + ui.updateStatus = await window.forgeflow.downloadUpdate(); + showToast( + "Update downloaded", + `Version ${ui.updateStatus.remoteVersion} passed the integrity check.`, + "success", + ); + } catch (error) { + showToast("Update download failed", error.message, "error"); + } + setLoading(false); + } else if (action === "apply-update") { + if ( + !confirm( + `Apply ForgeFlow ${ui.updateStatus?.remoteVersion || "update"} now? ForgeFlow closes, validates the update and restarts automatically.`, + ) + ) + return; + setLoading(true, "Launching safe updater…"); + try { + await window.forgeflow.applyUpdate(); + showToast( + "Update launched", + "ForgeFlow will close and restart after validation.", + "success", + ); + } catch (error) { + showToast("Could not launch update", error.message, "error"); + setLoading(false); + } + } else if (action === "use-server-password") { + ui.modal = { + type: "server-password", + serverId: target.dataset.serverId, + retry: { type: target.dataset.retry || "scan" }, + }; + render(); + } else if (action === "confirm-server-password") { + const server = (ui.boot?.state?.servers || []).find((item) => item.id === target.dataset.serverId); + const password = document.querySelector("#quick-server-password")?.value || ""; + if (!server || !password) { + showToast("Password required", "Enter the Unraid SSH password.", "error"); + return; + } + const retry = ui.modal?.retry || { type: "scan" }; + setLoading(true, "Switching the server connection to password authentication…"); + try { + const saved = await window.forgeflow.saveServer( + { ...server, authType: "password", privateKeyPath: "" }, + password, + "", + ); + ui.boot.state = saved.state; + const tested = await window.forgeflow.testServer(server.id); + ui.boot.state = tested.state; + ui.modal = null; + showToast("Server password saved", "ForgeFlow will no longer use an SSH key for this server.", "success"); + if (retry.type === "deploy") { + const retryRepository = ui.repositories.find((item) => item.fullName === retry.repositoryFullName); + if (retryRepository) ui.selectedRepoId = retryRepository.id; + await executeDeployment(retry.profileId); + } else { + await refreshDeploymentTruth(true); + } + } catch (error) { + showToast("Server authentication failed", error.message, "error"); + } + setLoading(false); + } else if (action === "open-add-server") { + ui.modal = { + type: "server-config", + serverId: null, + authType: "password", + }; + render(); + } else if (action === "edit-server") { + const server = (ui.boot.state.servers || []).find( + (item) => item.id === target.dataset.serverId, + ); + ui.modal = { + type: "server-config", + serverId: target.dataset.serverId, + authType: server?.authType || "password", + }; + render(); + } else if (action === "select-private-key") { + const keyPath = await window.forgeflow.selectKeyFile({ + title: "Select SSH private key", + defaultPath: + document.querySelector("#server-private-key")?.value || undefined, + }); + if (keyPath) document.querySelector("#server-private-key").value = keyPath; + } else if (action === "save-server") { + const authType = document.querySelector("#server-auth-type").value; + const server = { + id: target.dataset.serverId || undefined, + name: document.querySelector("#server-name").value.trim(), + host: document.querySelector("#server-host").value.trim(), + port: Number(document.querySelector("#server-port").value), + username: document.querySelector("#server-username").value.trim(), + authType, + basePath: document.querySelector("#server-base-path").value.trim(), + scanRoots: document.querySelector("#server-scan-roots").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), + scanExcludes: document.querySelector("#server-scan-excludes").value.split(",").map((value) => value.trim()).filter(Boolean), + privateKeyPath: + document.querySelector("#server-private-key")?.value.trim() || "", + hostFingerprint: document + .querySelector("#server-fingerprint") + .value.trim(), + }; + const password = document.querySelector("#server-password")?.value || ""; + const passphrase = + document.querySelector("#server-passphrase")?.value || ""; + setLoading(true, "Saving encrypted SSH configuration…"); + try { + const result = await window.forgeflow.saveServer( + server, + password, + passphrase, + ); + ui.boot.state = result.state; + ui.modal = null; + showToast( + "Server saved", + "Run Test & trust before creating a deployment.", + "success", + ); + } catch (error) { + showToast("Could not save server", error.message, "error"); + } + setLoading(false); + } else if (action === "test-server") { + setLoading( + true, + "Checking SSH identity, Docker, Compose and optional Git capabilities…", + ); + try { + const result = await window.forgeflow.testServer(target.dataset.serverId); + ui.boot.state = result.state; + const capabilities = result.capabilities || {}; + const deploymentReady = + capabilities.docker && capabilities.dockerReady && capabilities.compose; + showToast( + deploymentReady ? "SSH server ready" : "SSH connected with missing tools", + `${result.server.name} presented ${result.fingerprint}. Docker ${capabilities.dockerReady ? "ready" : "unavailable"}; Compose ${capabilities.compose ? "ready" : "missing"}.`, + deploymentReady ? "success" : "info", + ); + } catch (error) { + showToast("SSH test failed", error.message, "error"); + } + setLoading(false); + } else if (action === "delete-server") { + if ( + !confirm("Delete this server and all deployment profiles linked to it?") + ) + return; + try { + ui.boot.state = await window.forgeflow.deleteServer( + target.dataset.serverId, + ); + ui.modal = null; + await refreshRepositories(false); + showToast( + "Server deleted", + "Linked SSH deployment profiles were removed.", + "success", + ); + } catch (error) { + showToast("Could not delete server", error.message, "error"); + } + } else if (action === "add-root") { + const root = await window.forgeflow.selectDirectory({ + title: "Add development folder", + }); + if (root && !ui.boot.state.workspaceRoots.includes(root)) + ui.boot.state.workspaceRoots.push(root); + render(); + } else if (action === "remove-root") { + ui.boot.state.workspaceRoots.splice(Number(target.dataset.index), 1); + render(); + } else if (action === "save-roots") { + const roots = [...document.querySelectorAll("[data-root-index]")] + .map((input) => input.value.trim()) + .filter(Boolean); + setLoading(true, "Saving workspace folders…"); + try { + ui.boot.state = await window.forgeflow.setWorkspaceRoots(roots); + await refreshRepositories(false); + showToast( + "Folders saved", + "Repository discovery has been refreshed.", + "success", + ); + } catch (error) { + showToast("Could not save folders", error.message, "error"); + } + setLoading(false); + } else if (action === "save-gitea-settings") { + const baseUrl = document.querySelector("#settings-gitea-url").value.trim(); + const token = document.querySelector("#settings-gitea-token").value.trim(); + setLoading(true, "Validating Gitea…"); + try { + const result = await window.forgeflow.updateGitea({ baseUrl, token }); + ui.boot.state = result.state; + await refreshRepositories(false); + showToast( + "Gitea connected", + `Signed in as ${result.validation.user.login}.`, + "success", + ); + } catch (error) { + showToast("Connection failed", error.message, "error"); + } + setLoading(false); + } else if (action === "save-preferences") { + const preferences = { + autoRefresh: + document.querySelector("#pref-auto-refresh").value === "true", + repositoryPollSeconds: Number( + document.querySelector("#pref-repo-poll").value, + ), + operationPollSeconds: Number( + document.querySelector("#pref-operation-poll").value, + ), + preferredCloneProtocol: document.querySelector("#pref-clone-protocol") + .value, + }; + setLoading(true, "Saving background settings…"); + try { + ui.boot.state = await window.forgeflow.setPreferences(preferences); + await refreshRepositories(false); + showToast( + "Settings saved", + "Background awareness has been updated.", + "success", + ); + } catch (error) { + showToast("Could not save settings", error.message, "error"); + } + setLoading(false); + } else if (action === "save-desktop-preferences") { + const splitArgs = (selector) => + document + .querySelector(selector) + .value.split("|") + .map((item) => item.trim()) + .filter(Boolean); + const preferences = { + editor: { + executable: document + .querySelector("#pref-editor-executable") + .value.trim(), + args: splitArgs("#pref-editor-args"), + }, + terminal: { + executable: document + .querySelector("#pref-terminal-executable") + .value.trim(), + args: splitArgs("#pref-terminal-args"), + }, + notificationsEnabled: document.querySelector("#pref-notifications") + .checked, + trayEnabled: document.querySelector("#pref-tray").checked, + closeToTray: document.querySelector("#pref-close-tray").checked, + startAtLogin: document.querySelector("#pref-login").checked, + }; + try { + ui.boot.state = await window.forgeflow.setPreferences(preferences); + showToast( + "Desktop integration saved", + "Editor, terminal, tray and notification settings are active.", + "success", + ); + } catch (error) { + showToast("Could not save desktop integration", error.message, "error"); + } + render(); + } else if ( + action === "export-config-backup" || + action === "import-config-backup" + ) { + const passphrase = document.querySelector("#backup-passphrase").value; + if (passphrase.length < 12) { + showToast("Passphrase too short", "Use at least 12 characters.", "error"); + return; + } + setLoading( + true, + action === "export-config-backup" + ? "Encrypting configuration backup…" + : "Decrypting and validating configuration…", + ); + try { + const result = + action === "export-config-backup" + ? await window.forgeflow.exportConfigurationBackup(passphrase) + : await window.forgeflow.importConfigurationBackup(passphrase); + if (result?.state) { + ui.boot.state = result.state; + await refreshRepositories(false); + } + if (result) + showToast( + action === "export-config-backup" + ? "Encrypted backup created" + : "Configuration restored", + action === "export-config-backup" + ? result.filePath + : `Backup from ${result.exportedAt} imported; credentials were preserved only where already present.`, + "success", + ); + } catch (error) { + showToast("Configuration backup failed", error.message, "error"); + } + setLoading(false); + } + else return false; + return true; +} diff --git a/src/renderer/actions/shell.js b/src/renderer/actions/shell.js new file mode 100644 index 0000000..7071467 --- /dev/null +++ b/src/renderer/actions/shell.js @@ -0,0 +1,480 @@ +async function handleShellActions(event, target, action, repository) { + if (action === "navigate") { + ui.currentView = target.dataset.view; + ui.modal = null; + render(); + if (ui.currentView === "deployments" && (ui.boot?.state?.servers || []).length && !(ui.serverDiscovery || []).length) { + setLoading(true, "Reading Docker, Compose and DockerMan inventory from Unraid…"); + await refreshDeploymentTruth(true); + setLoading(false); + } + } else if (action === "select-repo") selectRepository(target.dataset.id); + else if (action === "refresh") { + await refreshRepositories(true); + await refreshActiveOperations(false); + await refreshDeploymentTruth(false); + } else if (action === "refresh-operations") { + setLoading(true, "Refreshing deployment operations and live server state…"); + await refreshActiveOperations(); + await refreshDeploymentTruth(true); + setLoading(false); + } else if (action === "toggle-theme") { + const appearance = + document.documentElement.dataset.theme === "dark" ? "light" : "dark"; + applyTheme(appearance); + ui.boot.state = await window.forgeflow.setAppearance(appearance); + render(); + } else if (action === "open-palette") { + ui.paletteQuery = ""; + ui.modal = { type: "command-palette" }; + render(); + } else if (action === "repo-tab") { + ui.repositoryTab = target.dataset.tab; + if (ui.repositoryTab === "gittools" && !ui.branches.length) + await loadGitTools(repository); + else if (ui.repositoryTab === "validator" && !ui.gitValidation) { + setLoading(true, "Validating Git and Gitea best practices…"); + try { + ui.gitValidation = await window.forgeflow.gitValidatorScan( + repository.fullName, + ); + } catch (error) { + showToast("Git Validator failed", error.message, "error"); + } finally { + setLoading(false); + } + render(); + } else if (ui.repositoryTab === "settings") { + try { + ui.pullRequests = await window.forgeflow.pullRequests( + repository.fullName, + "open", + ); + } catch (error) { + ui.pullRequests = []; + showToast("Could not load pull requests", error.message, "error"); + } + render(); + } else render(); + } else if (action === "git-validator-scan") { + setLoading(true, "Validating Git and Gitea best practices…"); + try { + ui.gitValidation = await window.forgeflow.gitValidatorScan( + repository.fullName, + ); + showToast( + "Git validation complete", + `${ui.gitValidation.score}/100 · ${ui.gitValidation.grade}`, + ui.gitValidation.summary.errors ? "error" : "success", + ); + } catch (error) { + showToast("Git Validator failed", error.message, "error"); + } finally { + setLoading(false); + } + } else if (action === "git-validator-repair") { + const check = ui.gitValidation?.checks?.[Number(target.dataset.checkIndex)]; + if (!check?.fixAction) return; + if (!check.safe && !confirm(check.confirmation || `Apply ${check.title}?`)) + return; + setLoading(true, `Repairing ${check.title}…`); + try { + await window.forgeflow.gitValidatorRepair(repository.fullName, check); + await refreshRepositories(false, true); + ui.gitValidation = await window.forgeflow.gitValidatorScan( + repository.fullName, + ); + showToast("Git best practice repaired", check.title, "success"); + } catch (error) { + showToast("Repair failed", error.message, "error"); + } finally { + setLoading(false); + } + } else if (action === "git-validator-repair-safe") { + const checks = (ui.gitValidation?.checks || []).filter( + (check) => check.fixAction && check.safe, + ); + setLoading(true, `Applying ${checks.length} safe Git fixes…`); + let repaired = 0; + try { + for (const check of checks) { + await window.forgeflow.gitValidatorRepair(repository.fullName, check); + repaired += 1; + } + await refreshRepositories(false, true); + ui.gitValidation = await window.forgeflow.gitValidatorScan( + repository.fullName, + ); + showToast("Safe Git fixes applied", `${repaired} repaired.`, "success"); + } catch (error) { + showToast("Safe repair stopped", error.message, "error"); + } finally { + setLoading(false); + } + } else if (action === "toggle-favorite") { + ui.boot.state = await window.forgeflow.favoriteRepository( + repository.fullName, + !repository.favorite, + ); + repository.favorite = !repository.favorite; + render(); + } else if (action === "select-file") { + if (event.target.matches("input[type=checkbox]")) return; + ui.selectedFile = target.dataset.path; + await loadDiff(repository, ui.selectedFile); + } else if (action === "toggle-all-files") { + const files = repository.localStatus?.files || []; + ui.selectedFiles = + ui.selectedFiles.size === files.length + ? new Set() + : new Set(files.map((file) => file.path)); + render(); + } else if (action === "copy-diff") { + await navigator.clipboard.writeText(ui.diff || ""); + showToast("Copied", "Diff copied to clipboard.", "success"); + } else if (action === "open-hunk-staging") { + if (ui.diffHunks?.partialSupported) { + ui.modal = { type: "hunk-staging" }; + render(); + } + } else if (action === "stage-chosen-hunks") { + const indexes = [ + ...document.querySelectorAll("[data-hunk-index]:checked"), + ].map((input) => Number(input.dataset.hunkIndex)); + if (!indexes.length) return; + const result = await runOperation( + "Staging selected hunks…", + () => + window.forgeflow.stageHunks( + repository.localPath, + ui.selectedFile, + indexes, + ), + "Selected hunks staged.", + ); + if (result) { + ui.modal = null; + await loadDiff(selectedRepository(), ui.selectedFile); + } + } else if (action === "open-file-editor") { + await window.forgeflow + .openEditor(repository.localPath, ui.selectedFile) + .catch((error) => + showToast("Could not open editor", error.message, "error"), + ); + } else if (action === "open-editor") { + await window.forgeflow + .openEditor(repository.localPath) + .catch((error) => + showToast("Could not open editor", error.message, "error"), + ); + } else if (action === "open-terminal") { + await window.forgeflow + .openTerminal(repository.localPath) + .catch((error) => + showToast("Could not open terminal", error.message, "error"), + ); + } else if (action === "load-conflicts") { + ui.conflictState = await window.forgeflow.conflictState( + repository.localPath, + ); + ui.modal = { type: "conflict-guide" }; + render(); + } else if (action === "resolve-conflict") { + if ( + !ui.selectedFile || + !confirm(`Apply “${target.dataset.resolution}” to ${ui.selectedFile}?`) + ) + return; + ui.conflictState = await window.forgeflow.resolveConflict( + repository.localPath, + ui.selectedFile, + target.dataset.resolution, + ); + await refreshRepositories(false); + ui.modal = { type: "conflict-guide" }; + render(); + } else if (action === "open-conflict-file") { + await window.forgeflow.openEditor( + repository.localPath, + target.dataset.path, + ); + } else if (action === "continue-git-operation") { + ui.conflictState = await window.forgeflow.continueGitOperation( + repository.localPath, + ); + ui.modal = null; + await refreshRepositories(false); + showToast( + "Git operation continued", + "The repository operation completed.", + "success", + ); + } else if (action === "abort-git-operation") { + if ( + !confirm( + "Abort the active Git operation? Conflict-resolution work may be discarded.", + ) + ) + return; + await window.forgeflow.abortGitOperation(repository.localPath); + ui.modal = null; + await refreshRepositories(false); + } else if (action === "check-branch-protection") { + ui.branchProtection = await window.forgeflow.branchProtection( + repository.fullName, + repository.localStatus.branch.head, + ); + showToast( + ui.branchProtection.protected + ? "Protected branch" + : "Branch is not protected", + ui.branchProtection.protected + ? `${ui.branchProtection.requiredApprovals} approval(s) required.` + : "Direct pushes are permitted by the reported branch rule.", + ui.branchProtection.protected ? "info" : "success", + ); + render(); + } else if (action === "open-pull-request") { + const subject = + ui.history[0]?.subject || repository.localStatus.branch.head; + ui.modal = { + type: "pull-request", + title: subject, + body: `## Summary\n\nChanges from ${repository.localStatus.branch.head}.`, + }; + render(); + } else if (action === "load-pull-requests") { + try { + ui.pullRequests = await window.forgeflow.pullRequests( + repository.fullName, + "open", + ); + render(); + } catch (error) { + showToast("Could not load pull requests", error.message, "error"); + } + } else if (action === "open-pull-request-url") { + if (target.dataset.url) + await window.forgeflow.openExternal(target.dataset.url); + } else if (action === "create-pull-request") { + setLoading(true, "Creating pull request…"); + try { + const pull = await window.forgeflow.createPullRequest( + repository.fullName, + document.querySelector("#pr-title").value, + document.querySelector("#pr-body").value, + document.querySelector("#pr-base").value, + ); + ui.modal = null; + ui.pullRequests = await window.forgeflow + .pullRequests(repository.fullName, "open") + .catch(() => ui.pullRequests); + showToast("Pull request created", `#${pull.number}`, "success"); + if (pull.html_url) await window.forgeflow.openExternal(pull.html_url); + } catch (error) { + showToast("Could not create pull request", error.message, "error"); + } + setLoading(false); + } else if (action === "copy-logs") { + const text = (ui.activeDeployment?.logs || []).join("\n"); + await navigator.clipboard.writeText(text); + showToast( + "Copied", + "Safe operation output copied. Open Gitea for raw runner logs.", + "success", + ); + } else if (action === "stage-selected") { + if (!repository?.localPath || !ui.selectedFiles.size) return; + await runOperation( + "Staging selected files…", + () => + window.forgeflow.stageFiles(repository.localPath, [ + ...ui.selectedFiles, + ]), + "Files staged.", + ); + } else if (action === "unstage-selected") { + if (!repository?.localPath || !ui.selectedFiles.size) return; + await runOperation( + "Unstaging selected files…", + () => + window.forgeflow.unstageFiles(repository.localPath, [ + ...ui.selectedFiles, + ]), + "Files unstaged.", + ); + } else if (action === "commit-push" || action === "commit-only") { + if ( + !repository?.localPath || + !ui.commitMessage.trim() || + (!ui.selectedFiles.size && !repository.localStatus?.counts?.staged) + ) + return; + const selected = [...ui.selectedFiles]; + const result = await runOperation( + action === "commit-push" + ? "Committing and pushing…" + : "Creating local commit…", + () => + action === "commit-push" + ? selected.length + ? window.forgeflow.commitAndPush( + repository.localPath, + ui.commitMessage, + selected, + ) + : window.forgeflow.commitStagedAndPush( + repository.localPath, + ui.commitMessage, + ) + : selected.length + ? window.forgeflow.commit( + repository.localPath, + ui.commitMessage, + selected, + ) + : window.forgeflow.commitStaged( + repository.localPath, + ui.commitMessage, + ), + action === "commit-push" + ? "Changes committed and pushed to Gitea." + : "Local commit created.", + ); + if (result) { + ui.commitMessage = ""; + ui.selectedFiles.clear(); + ui.selectedFile = null; + ui.diff = ""; + } + } else if (action === "push") + await runOperation( + "Pushing local commits…", + () => window.forgeflow.push(repository.localPath), + "Push completed.", + ); + else if (action === "fetch") + await runOperation( + "Fetching from Gitea…", + () => window.forgeflow.fetch(repository.localPath), + "Remote state refreshed.", + ); + else if (action === "pull") + await runOperation( + "Synchronizing from Gitea…", + () => window.forgeflow.pull(repository.localPath), + "Local branch fast-forwarded.", + ); + else if (action === "load-history") { + setLoading(true, "Loading commit history…"); + try { + ui.history = await window.forgeflow.history(repository.localPath, 50); + } catch (error) { + showToast("History unavailable", error.message, "error"); + } + setLoading(false); + } else if (action === "load-git-tools") await loadGitTools(repository); + else if (action === "create-branch") { + const branch = document.querySelector("#new-branch-name")?.value.trim(); + if (branch) + await runOperation( + `Creating ${branch}…`, + () => window.forgeflow.createBranch(repository.localPath, branch), + `Switched to ${branch}.`, + ); + await loadGitTools(selectedRepository()); + } else if (action === "checkout-branch") { + await runOperation( + `Switching to ${target.dataset.branch}…`, + () => + window.forgeflow.checkoutBranch( + repository.localPath, + target.dataset.branch, + ), + `Switched to ${target.dataset.branch}.`, + ); + await loadGitTools(selectedRepository()); + } else if (action === "stash-changes") { + const result = await runOperation( + "Stashing local changes…", + () => + window.forgeflow.stash( + repository.localPath, + `ForgeFlow ${new Date().toLocaleString()}`, + ), + "Local changes stashed.", + ); + if (result) ui.stashes = result.stashes; + } else if (action === "pop-stash") { + const result = await runOperation( + `Applying ${target.dataset.stashRef}…`, + () => + window.forgeflow.popStash( + repository.localPath, + target.dataset.stashRef, + ), + "Stash applied.", + ); + if (result) ui.stashes = result.stashes; + } else if (action === "open-path") + await window.forgeflow + .openPath(repository.localPath) + .catch((error) => + showToast("Could not open folder", error.message, "error"), + ); + else if (action === "open-gitea") + await window.forgeflow + .openExternal(repository.htmlUrl) + .catch((error) => + showToast("Could not open Gitea", error.message, "error"), + ); + else if (action === "link-repo") { + const localPath = await window.forgeflow.selectDirectory({ + title: `Link local folder for ${repository.name}`, + }); + if (localPath) { + ui.repositories = + (await runOperation( + "Linking local repository…", + () => window.forgeflow.linkRepository(repository.fullName, localPath), + "Local folder linked.", + { refresh: false }, + )) || ui.repositories; + selectRepository(repository.id); + } + } else if (action === "unlink-repo") { + ui.repositories = + (await runOperation( + "Removing local link…", + () => window.forgeflow.unlinkRepository(repository.fullName), + "Repository link removed.", + { refresh: false }, + )) || ui.repositories; + selectRepository(repository.id); + } else if (action === "clone-repo" || action === "clone-repo-custom") { + const mode = action === "clone-repo-custom" ? "custom" : "default"; + const clone = await runOperation( + mode === "custom" + ? "Choosing location and cloning repository…" + : `Cloning ${repository.name} into the default project root…`, + () => window.forgeflow.cloneRepository(repository.fullName, mode), + null, + { refresh: false }, + ); + if (clone?.cancelled) return; + if (clone?.target) { + if (clone.state) ui.boot.state = clone.state; + ui.repositories = + clone.repositories || (await window.forgeflow.refreshRepositories()); + selectRepository(repository.id); + showToast( + clone.reused ? "Existing repository linked" : "Repository cloned", + clone.target, + "success", + ); + } + } + else return false; + return true; +} diff --git a/src/renderer/app.js b/src/renderer/app.js index 8914be1..3c86a75 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -64,7 +64,6 @@ const icons = { function icon(name, className = "") { return ``; } - function escapeHtml(value) { return String(value ?? "").replace( /[&<>'"]/g, @@ -665,3414 +664,3 @@ function repositoryAction(repository) { detail: "No local or remote action is required.", }; } - -function navButton(view, label, iconName, count = "") { - return ``; -} - -function renderTitlebar() { - const state = ui.boot?.state; - const user = state?.gitea?.user; - const connected = Boolean(state?.gitea?.hasToken); - const repository = selectedRepository(); - const title = - ui.currentView === "repository" && repository - ? repository.fullName - : { - overview: "Release overview", - deployments: "Deployments", - diagnostics: "Diagnostics", - settings: "Settings", - "deployment-run": "Deployment run", - }[ui.currentView] || "Workspace"; - return `
-
ForgeFlowby ITWorx.tech
${escapeHtml(title)}
-
- -
${icon("search")}
- ${connected ? escapeHtml(user?.login || "Gitea") : "Offline"} - - -
-
`; -} - -function renderRepositoryRow(repository) { - const status = repository.localStatus; - const badges = []; - if (status?.counts.conflicts) - badges.push('!'); - else if (status?.counts.changed) - badges.push( - `${status.counts.changed}`, - ); - if (status?.branch.ahead) - badges.push( - `↑${status.branch.ahead}`, - ); - if (status?.branch.behind) - badges.push( - `↓${status.branch.behind}`, - ); - if (repository.readyToDeploy) - badges.push( - '', - ); - if (!repository.localPath) - badges.push(''); - const branch = status?.branch.head || repository.defaultBranch || "remote"; - return ``; -} - -function renderSidebar() { - const query = `${ui.search} ${ui.repoSearch}`.trim().toLowerCase(); - const repositories = ui.repositories.filter( - (repository) => - !query || - `${repository.name} ${repository.fullName} ${repository.description}` - .toLowerCase() - .includes(query), - ); - const favorites = repositories.filter((repository) => repository.favorite); - const others = repositories.filter((repository) => !repository.favorite); - const attention = ui.repositories.filter( - (repository) => - repository.attention || - repository.localStatus?.counts.changed || - repository.localStatus?.branch.ahead || - repository.readyToDeploy, - ).length; - const rows = (list) => list.map(renderRepositoryRow).join(""); - return ``; -} - -function renderSummaryCard(label, value, note, iconName, tone = "") { - return `
${icon(iconName)}
${label}
${value}
${note}
`; -} - -function queueActionFor(repository) { - const action = repositoryAction(repository); - const mapping = { - link: ["folder", "Link folder", "Local project is not connected", ""], - error: ["error", "Inspect problem", action.detail, "danger"], - conflict: ["warning", "Resolve conflicts", action.detail, "danger"], - commit: ["file", "Review & commit", action.detail, "warning"], - diverged: ["warning", "Resolve divergence", action.detail, "danger"], - pull: ["arrowDown", "Synchronize", action.detail, "warning"], - push: ["arrowUp", "Push commits", action.detail, ""], - configure: ["settings", "Configure deploy", action.detail, ""], - "branch-profile": ["branch", "Select profile", action.detail, ""], - deploy: ["rocket", "Deploy release", action.detail, "success"], - clean: ["check", "Synchronized", action.detail, "success"], - }; - return mapping[action.kind] || mapping.clean; -} - -function projectIllustration(kind = "flow") { - return ``; -} - -function renderOverview() { - const changed = ui.repositories.filter( - (repository) => repository.localStatus?.counts.changed, - ).length; - const unpushed = ui.repositories.filter( - (repository) => repository.localStatus?.branch.ahead, - ).length; - const deployable = ui.repositories.filter( - (repository) => repository.readyToDeploy, - ).length; - const unhealthy = ui.repositories - .flatMap((repository) => repository.deploymentProfiles || []) - .filter((profile) => profile.state?.healthy === false).length; - const queue = ui.repositories - .filter((repository) => repositoryAction(repository).kind !== "clean") - .slice(0, 8); - const recent = operations().slice(0, 7); - const active = recent.filter( - (operation) => !isTerminalOperation(operation.status), - ); - return `
- - ${ui.refreshError ? `
${icon("error")} ${escapeHtml(ui.refreshError)}
` : ""} -
- ${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("Ready", deployable, "exact commits ready to deploy", "rocket", deployable ? "success" : "")} - ${renderSummaryCard("Health", unhealthy || active.length, unhealthy ? "unhealthy environments" : active.length ? "operations in progress" : "all checked environments healthy", "pulse", unhealthy ? "danger" : active.length ? "warning" : "success")} -
-

Action queue

Sorted by required attention
- ${ - queue.length - ? queue - .map((repository) => { - const [iconName, label, reason, tone] = - queueActionFor(repository); - return `
${icon(iconName)}
${escapeHtml(repository.name)}
${escapeHtml(repository.localStatus?.branch.head || repository.defaultBranch || "remote")} ${repository.localStatus?.shortHead ? `• ${repository.localStatus.shortHead}` : ""}
${escapeHtml(label)}${escapeHtml(reason)}
`; - }) - .join("") - : '

Everything is synchronized

No repository needs immediate attention.

' - } -
-
-

Recent deployments

${recent.length ? recent.map((operation) => `
${escapeHtml(operation.repository)} → ${escapeHtml(operation.environment || "environment")}
${escapeHtml(operation.action === "rollback" ? "Rollback" : "Deploy")} ${escapeHtml(operation.shortSha || shortSha(operation.sha))} · ${escapeHtml(operation.status)}
${formatDate(operation.updatedAt || operation.createdAt)}
`).join("") : '

No deployment history yet.

'}
-

Workspace readiness

- ${readinessRow("Git executable", ui.boot.git.available, ui.boot.git.version || ui.boot.git.error)} - ${readinessRow("Gitea connection", ui.boot.state.gitea.hasToken, ui.boot.state.gitea.baseUrl || "Not configured")} - ${readinessRow("Workspace folders", ui.boot.state.workspaceRoots.length > 0, `${ui.boot.state.workspaceRoots.length} configured`)} - ${readinessRow("Automatic awareness", ui.boot.state.preferences?.autoRefresh !== false, ui.boot.state.preferences?.autoRefresh === false ? "Manual refresh only" : `Every ${ui.boot.state.preferences?.repositoryPollSeconds || 4}s`)} -
-
-
`; -} - -function readinessRow(label, ok, detail) { - return `
${escapeHtml(label)}${escapeHtml(detail)}
`; -} -function releaseNode(label, value, description, tone = "") { - return `
${label}
${escapeHtml(value)}${escapeHtml(description)}
`; -} - -function diffAtmosphere(diff) { - if (!ui.selectedFile) return ""; - const lines = String(diff || "").split("\n"); - const additions = lines.filter( - (line) => line.startsWith("+") && !line.startsWith("+++"), - ).length; - const removals = lines.filter( - (line) => line.startsWith("-") && !line.startsWith("---"), - ).length; - const extension = - String(ui.selectedFile).split(".").pop()?.slice(0, 8).toUpperCase() || - "FILE"; - return ``; -} - -function renderDiff(diff) { - if (!diff) - return '

No textual diff

Select another file or open the project folder for binary changes.

'; - const rendered = escapeHtml(diff) - .split("\n") - .map((line) => { - const type = - line.startsWith("+") && !line.startsWith("+++") - ? "add" - : line.startsWith("-") && !line.startsWith("---") - ? "remove" - : line.startsWith("@@") - ? "hunk" - : ""; - return `${line || " "}`; - }) - .join(""); - return `${rendered}${diffAtmosphere(diff)}`; -} -function fileStatusCode(file) { - if (file.conflict) return "U"; - if (file.untracked) return "?"; - return ( - { - modified: "M", - added: "A", - deleted: "D", - renamed: "R", - copied: "C", - "type-changed": "T", - }[file.status] || "M" - ); -} - -function renderChanges(repository) { - const status = repository.localStatus; - if (!repository.localPath) { - const target = displayCloneTarget(repository); - return `
${icon("link")}

Connect a local project

Clone directly into your default project root, or link an existing working tree.

${target ? `
${icon("folder")}Automatic destination${escapeHtml(target)}
` : '
No default project root is configured. ForgeFlow will ask for one.
'}
`; - } - if (!status) - return `
${icon("error")}

Repository unavailable

${escapeHtml(repository.attentionReason || "The local working tree could not be read.")}

`; - if (!status.files.length) - return `
${icon("check")}

Working tree clean

Local ${escapeHtml(status.branch.head)} is at ${escapeHtml(status.shortHead)} with no uncommitted files.

`; - const selected = status.files.find((file) => file.path === ui.selectedFile); - const conflictActions = selected?.conflict - ? `
Conflicted file

Choose one side, or edit the file and mark it resolved.

` - : ""; - return `
${ui.selectedFiles.size} selected · ${status.counts.changed} changed · ${status.counts.staged} staged
${status.files.map((file) => `
${fileStatusCode(file)}${escapeHtml(file.path)}${file.staged ? "●" : "○"}
`).join("")}
${status.counts.conflicts ? `
` : ""}
${conflictActions}
${escapeHtml(ui.selectedFile || "Select a file")}
${ui.diffHunks?.partialSupported ? `` : ""}${ui.selectedFile ? `` : ""}${ui.selectedFile ? escapeHtml(selected?.status || "") : ""}
${renderDiff(ui.diff)}
`; -} - -function renderHistory(repository) { - if (!repository.localPath) - return '

Link a local repository to view commit history.

'; - if (!ui.history.length) - return `
${icon("history")}

Load local commit history

Review the last commits from this working tree.

`; - return `
${ui.history.map((commit) => ``).join("")}
CommitMessageAuthorDate
${escapeHtml(commit.shortSha)}${escapeHtml(commit.subject)}${escapeHtml(commit.author)}${formatDate(commit.date)}
`; -} - -function environmentState(profile) { - const state = profile.state || {}; - if (state.healthy === false) return { label: "Unhealthy", tone: "danger" }; - if (state.healthy === true) return { label: "Healthy", tone: "success" }; - if (state.containerRunning === true) return { label: "Running · unverified", tone: "warning" }; - if (state.containerRunning === false) return { label: "Stopped", tone: "danger" }; - if (profile.provider === "ssh-unraid" || state.statusConfigured || state.healthConfigured) - return { label: "Not checked", tone: "" }; - return { label: "Status not configured", tone: "" }; -} - -function dockerManIntegration(profile) { - const state = profile.state || {}; - const iconMode = - profile.iconMode || - (profile.iconFilePath ? "upload" : profile.iconUrl ? "url" : "builtin"); - const webUiExpected = Boolean(profile.webUiUrl || profile.hostPort); - const iconExpected = iconMode !== "none"; - const templateReady = Boolean(state.dockerMan?.templateExists); - const webUiReady = - !webUiExpected || Boolean(state.dockerMan?.webUi) || templateReady; - const iconReady = - !iconExpected || Boolean(state.dockerMan?.icon) || templateReady; - return { - iconMode, - templateReady, - webUiReady, - iconReady, - ready: Boolean(state.containerRunning && webUiReady && iconReady), - }; -} - -function deploymentIdentity(profile, repository) { - const name = String( - profile.state?.containerName || - profile.containerName || - profile.remoteFolder || - repository.name || - "container", - ); - let hash = 0; - for (const character of name) - hash = (hash * 31 + character.charCodeAt(0)) >>> 0; - return { name, initial: name.slice(0, 1).toUpperCase(), accent: hash % 6 }; -} - -function renderProfileCard(repository, profile, compact = false) { - const state = profile.state || {}; - const health = environmentState(profile); - const isSsh = profile.provider === "ssh-unraid"; - const mode = deploymentMode(profile); - const verification = ui.serverGitVerifications[profile.id]; - const targetSha = deploymentTargetSha(repository, profile); - const ready = canDeploy(repository, profile); - const modeLabel = { - "push-bundle": "Direct copy", - "server-git": "Server pull from Gitea", - "monitor-only": "Monitor only", - }[mode] || mode; - const providerDetail = isSsh - ? `SSH / Unraid · ${modeLabel} · ${profile.remoteFolder || repository.name} · ${profile.branch}${profile.adoptedFromServer ? " · server-linked" : ""}` - : `${profile.workflowFile} · ${profile.branch}`; - const rollbackConfigured = (isSsh && mode !== "monitor-only") || Boolean(profile.rollbackWorkflowFile); - const dockerMan = dockerManIntegration(profile); - const { templateReady, webUiReady, iconReady } = dockerMan; - const dockerManReady = dockerMan.ready; - const managesDockerMan = isSsh && profile.manageDockerMan === true; - const webUi = profile.webUiUrl || state.webUiUrl || state.dockerMan?.webUi || ""; - const identity = deploymentIdentity(profile, repository); - const syncLabel = isSsh - ? state.matchesGitea - ? `${icon("check")}Live = Gitea · ${shortSha(state.liveSha)}` - : state.liveSha && state.giteaSha - ? `Live ${shortSha(state.liveSha)} · Gitea ${shortSha(state.giteaSha)}` - : state.liveSha ? `${icon("check")}Live · ${shortSha(state.liveSha)}` : "" - : state.matchesGitea - ? `${icon("check")}Live = Gitea · ${shortSha(state.liveSha)}` - : state.giteaSha && state.liveSha - ? `Live ${shortSha(state.liveSha)} · Gitea ${shortSha(state.giteaSha)}` - : ""; - const dockerManLabel = managesDockerMan - ? dockerManReady - ? templateReady - ? "Managed labels/template active" - : "Managed labels active" - : `Managed · WebUI ${webUiReady ? "ready" : "missing"} · icon ${iconReady ? "ready" : "missing"}` - : "Existing DockerMan template preserved"; - const sourceLabel = isSsh - ? mode === "server-git" ? `Gitea ${state.giteaSha ? shortSha(state.giteaSha) : "refresh required"}` : "Committed local HEAD" - : state.giteaSha ? shortSha(state.giteaSha) : "Refresh to compare"; - const serverAccessAction = isSsh && mode === "server-git" - ? `` - : ""; - return `
${escapeHtml(identity.initial)}
Container${escapeHtml(identity.name)}${escapeHtml(repository.fullName)} · ${escapeHtml(profile.environment)}
${syncLabel}
${escapeHtml(isSsh ? "SSH / UNRAID" : "GITEA ACTIONS")}

${escapeHtml(profile.name)}

${escapeHtml(providerDetail)}

${health.label}
${isSsh ? `` : ""}${serverAccessAction}${webUi ? `` : ""}${managesDockerMan ? `` : ""}${ready ? `` : ""}${state.previousSha && rollbackConfigured ? `` : ""}
`; -} -function renderRepositoryDeployments(repository) { - const profiles = repository.deploymentProfiles || []; - const repoOps = repositoryOperations(repository).slice(0, 10); - return `

Deployment environments

Exact-commit Gitea Actions or pinned SSH / Unraid deployments
${profiles.length ? `
${profiles.map((profile) => renderProfileCard(repository, profile)).join("")}
` : '

No deployment profile

Connect a Gitea Actions workflow or a trusted SSH / Unraid server.

'}

Release history

${repoOps.length ? `${repoOps.map((operation) => ``).join("")}
ActionEnvironmentCommitStatusUpdated
${escapeHtml(operation.action || "deploy")}${escapeHtml(operation.environment)}${escapeHtml(operation.shortSha || shortSha(operation.sha))}${escapeHtml(operation.status)}${formatDate(operation.updatedAt || operation.createdAt)}
` : '

No releases for this repository yet.

'}
`; -} - -function renderGitTools(repository) { - if (!repository.localPath) - return '

Link a local repository to manage branches and stashes.

'; - const recovery = ui.gitRecovery; - const locks = recovery?.lockReport?.locks || []; - const activeProcesses = recovery?.lockReport?.processes?.active || []; - const recommendations = recovery?.recommendations || []; - return `

Branches

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

Load branch information.

'}

Stashes

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

No stashes, or Git tools have not been loaded.

'}

Repository troubleshooting

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

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

'}
${repository.sshUrl && repository.localStatus?.remoteUrl !== repository.sshUrl ? `` : ""}
Lock repair refuses to run while a matching Git process is active. A force option is shown only when process detection itself is unavailable.
`; -} - -function renderRepositorySettings(repository) { - const automaticTarget = displayCloneTarget(repository); - const currentOrigin = repository.localStatus?.remoteUrl || "Unavailable"; - const desiredOrigin = repository.sshUrl || repository.preferredCloneUrl || ""; - const originNeedsRepair = Boolean( - repository.localPath && desiredOrigin && currentOrigin !== desiredOrigin, - ); - const pullRequests = ui.pullRequests || []; - return `

Repository identity

${desiredOrigin ? `
` : ""}
${originNeedsRepair ? `` : ""}${repository.localPath ? `` : ``}

Open pull requests

Live from Gitea
${pullRequests.length ? `
${pullRequests.map((pull) => `
#${pull.number} · ${escapeHtml(pull.title)}${escapeHtml(pull.head?.ref || pull.head?.label || "source")} → ${escapeHtml(pull.base?.ref || pull.base?.label || "target")} · ${formatDate(pull.updated_at || pull.created_at)}
`).join("")}
` : '

No open pull requests.

'}

Repository behavior

${icon("shield")}Origin repair changes only the Git remote URL. Git health scans the actual Git directory, repairs only proven stale lock files and never changes source files or commits.
`; -} - -function renderGitValidator(repository) { - const report = ui.gitValidation; - if (!report) - return `
${projectIllustration("diagnostics")}
Repository assurance

Validate Git best practices

Inspect repository identity, branch governance, tracked secrets, file hygiene and safe local synchronization settings.

`; - const tone = - report.score >= 90 ? "success" : report.score >= 70 ? "warning" : "danger"; - const safeFixes = report.checks.filter( - (check) => check.fixAction && check.safe, - ); - const groups = report.checks.reduce((grouped, check) => { - (grouped[check.category] ||= []).push(check); - return grouped; - }, {}); - return `
${report.score}/ 100
Git assurance score

${escapeHtml(report.grade)}

${report.summary.passed} passed · ${report.summary.warnings} recommendations · ${report.summary.errors} critical

${projectIllustration("diagnostics")}
${safeFixes.length ? `` : ""}
${Object.entries( - groups, - ) - .map( - ([category, checks]) => - `

${escapeHtml(category)}

${checks.filter((check) => check.status === "pass").length}/${checks.length} passed
${checks - .map((check) => { - const checkIndex = report.checks.indexOf(check); - return `
${icon(check.status === "pass" ? "check" : check.status === "error" ? "error" : "warning")}
${escapeHtml(check.title)}

${escapeHtml(check.detail)}

${check.fixAction ? `` : `${check.status === "pass" ? "Best practice" : "Review"}`}
`; - }) - .join("")}
`, - ) - .join("")}
`; -} - -function renderRepositoryWorkspace(repository) { - const status = repository.localStatus; - const profile = selectedProfile(repository); - const serverState = profile?.state || {}; - const localTone = status?.counts.conflicts - ? "danger" - : status?.counts.changed - ? "warning" - : status - ? "success" - : ""; - const remoteTone = status?.branch.behind - ? "danger" - : status?.branch.ahead - ? "warning" - : status?.branch.upstream - ? "success" - : ""; - const serverTone = - serverState.healthy === false - ? "danger" - : serverState.healthy === true - ? "success" - : ""; - const content = ( - { - changes: renderChanges, - history: renderHistory, - deployments: renderRepositoryDeployments, - gittools: renderGitTools, - validator: renderGitValidator, - settings: renderRepositorySettings, - }[ui.repositoryTab] || renderChanges - )(repository); - return `

${escapeHtml(repository.fullName)}

${escapeHtml(repository.localPath || "No local working tree linked")}

${projectIllustration("repo")}
- ${repository.localPath ? `
${ui.branchProtection ? `${ui.branchProtection.protected ? `Protected · ${ui.branchProtection.requiredApprovals || 0} approval(s)` : "Direct pushes allowed"}` : ""}
` : ""} -
${releaseNode("Local", status?.shortHead || "Not linked", status ? `${status.counts.changed} changes · ${status.branch.head}` : "No working tree", localTone)}${releaseNode("Gitea", status?.shortHead || "Unknown", status?.branch.upstream ? `${status.branch.ahead} ahead · ${status.branch.behind} behind` : "Branch not published", remoteTone)}${releaseNode(`Server${profile ? ` · ${profile.environment}` : ""}`, serverState.liveSha ? shortSha(serverState.liveSha) : "Unknown", profile ? (serverState.checkedAt ? `checked ${formatDate(serverState.checkedAt)}` : "not checked") : "No deployment profile", serverTone)}
-
${content}
`; -} - -function renderActionPanel(repository) { - const action = repositoryAction(repository); - const status = repository.localStatus; - const profile = selectedProfile(repository); - let body = ""; - if (action.kind === "link") { - const target = displayCloneTarget(repository); - body = `
${icon("link")}

${action.title}

${action.detail}

${target ? `
Project root${escapeHtml(defaultWorkspaceRoot())}New folder${escapeHtml(safeCloneFolderName(repository))}
` : '
No default project root is configured yet.
'}
`; - } else if (action.kind === "commit") { - const hasStagedSelection = status.counts.staged > 0; - const commitReady = Boolean( - (ui.selectedFiles.size || hasStagedSelection) && ui.commitMessage.trim(), - ); - const commitBlocker = - !ui.selectedFiles.size && !hasStagedSelection - ? "Select files or stage one or more hunks." - : !ui.commitMessage.trim() - ? "Enter a commit message to enable commit and push." - : ui.selectedFiles.size - ? "Ready to commit. ForgeFlow stages the selected files automatically." - : "Ready to commit only the reviewed staged hunks."; - body = `
${ui.selectedFiles.size ? `${ui.selectedFiles.size} of ${status.counts.changed} files selected` : `${status.counts.staged} staged file(s)`}Ctrl+Enter
${icon(commitReady ? "check" : "warning")}${escapeHtml(commitBlocker)}
Partial hunk staging is preserved when no complete files are selected.
`; - } else if (action.kind === "pull") - body = `
${icon("arrowDown")}

${action.title}

${action.detail}

`; - else if (action.kind === "push") - body = `
${icon("arrowUp")}

${action.title}

${action.detail}

`; - else if ( - action.kind === "diverged" || - action.kind === "conflict" || - action.kind === "error" - ) - body = `
${icon("error")}

${action.title}

${action.detail}

${action.kind === "diverged" ? `` : ""}
`; - else if (action.kind === "configure") - body = `
${icon("settings")}

${action.title}

${action.detail}

`; - else if (action.kind === "branch-profile") - body = `
${icon("branch")}

${action.title}

${action.detail}

${repository.deploymentProfiles.length > 1 ? `` : ""}
`; - else if (action.kind === "deploy") - body = `
${icon("rocket")}

Release ${escapeHtml(status.shortHead)}

${escapeHtml(profile.name)} will deploy the exact commit from ${escapeHtml(profile.branch)} to ${escapeHtml(profile.environment)}.

${repository.deploymentProfiles.length > 1 ? `` : ""}
Local${escapeHtml(status.shortHead)}Gitea${escapeHtml(status.shortHead)}Target${escapeHtml(profile.environment)}
${profile.state?.previousSha && profile.rollbackWorkflowFile ? `` : ""}
`; - else - body = `
${icon("check")}

${action.title}

${action.detail}

${profile ? `` : ""}
`; - return ``; -} - -function renderServerInventory() { - const servers = ui.serverDiscovery || []; - const configuredServers = ui.boot?.state?.servers || []; - const hiddenClassifications = new Set(["backup", "release-folder", "system-container", "manually-excluded"]); - const visibleForServer = (server) => (server.workloads || []).filter((workload) => - workload.reviewDecisionStale || workload.classification?.type === "duplicate" || (!hiddenClassifications.has(workload.classification?.type) && (workload.link || workload.runtime?.running || ["ambiguous", "orphan-container", "stopped-application", "historical-compose", "stale-link", "monitor-only"].includes(workload.classification?.type))), - ); - const reviewCount = servers.reduce((total, server) => total + visibleForServer(server).filter((workload) => !workload.link || workload.classification?.type === "stale-link" || workload.reviewDecisionStale).length, 0); - const serverCards = servers.map((server) => { - const capabilities = server.capabilities || {}; - const capabilityText = [ - capabilities.docker ? "Docker" : "Docker missing", - capabilities.compose ? "Compose" : "Compose missing", - capabilities.git ? "Git available" : "Git optional", - capabilities.tar && capabilities.checksum ? "Push ready" : "Push tools incomplete", - ].join(" · "); - const errorBlock = server.error - ? `
${icon("error")}
Server scan failed

${escapeHtml(server.error)}

` - : ""; - const warnings = (server.warnings || []).map((warning) => `
${icon("warning")}${escapeHtml(warning)}
`).join(""); - const visibleWorkloads = visibleForServer(server); - const hiddenCount = Math.max(0, (server.workloads || []).length - visibleWorkloads.length); - const workloads = visibleWorkloads.length - ? visibleWorkloads.map((workload) => { - const containers = (workload.containers || []).map((container) => container.name).filter(Boolean).join(", "); - const topCandidate = workload.candidates?.[0]; - const linked = (workload.status === "linked" || Boolean(workload.link)) && workload.classification?.type !== "stale-link"; - const classification = workload.classification?.type || workload.status || "review"; - const statusTone = linked && !workload.reviewDecisionStale ? "success" : ["ambiguous", "duplicate", "orphan-container"].includes(classification) || workload.reviewDecisionStale ? "danger" : "warning"; - const detail = workload.compose?.project - ? `Compose ${workload.compose.project} · ${(workload.compose.services || []).join(", ") || "services unknown"}` - : workload.dockerMan?.templatePath - ? `DockerMan ${workload.dockerMan.name || workload.displayName} · ${containers || "template only"}` - : `Container installation · ${containers || "unnamed"}`; - const candidate = linked - ? `Linked to ${workload.link?.repositoryFullName || "repository"}` - : topCandidate - ? `${topCandidate.repositoryFullName} suggested · ${topCandidate.confidence || topCandidate.status || "review required"}` - : "No repository candidate; select one manually"; - const canQuickLink = !linked && topCandidate && ["exact", "strong"].includes(topCandidate.confidence) && Boolean(workload.remoteFolderCandidate); - const linkButton = canQuickLink - ? `` - : ``; - const evidenceNote = workload.reviewDecisionStale ? "Saved decision is stale because server evidence changed" : workload.classification?.reason || "Awaiting review"; - return `
${escapeHtml(workload.displayName)}${escapeHtml(detail)} · ${workload.runtime?.running ? "running" : "stopped"}${escapeHtml(candidate)}${escapeHtml(evidenceNote)}${workload.metadata?.composeDefinitionError ? `Compose file found; validation warning: ${escapeHtml(workload.metadata.composeDefinitionError)}` : ""}
${escapeHtml(workload.reviewDecisionStale ? "Decision stale" : linked ? "Linked" : classification)}${linked ? `` : linkButton}
`; - }).join("") - : `

${server.error ? "No inventory could be read until the SSH connection works." : "Docker returned no containers, Compose projects or DockerMan templates."}

`; - return `

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

${server.running || 0} running · ${server.linked || 0} repository links · ${visibleWorkloads.filter((workload) => !workload.link).length} to review${hiddenCount ? ` · ${hiddenCount} unrelated/system workloads hidden` : ""}
${server.error ? "Scan failed" : escapeHtml(capabilityText)}${server.error ? "" : ``}
${errorBlock}${warnings}
${workloads}
`; - }).join(""); - const empty = configuredServers.length - ? `

Server inventory has not completed

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

` - : `

No Unraid server configured

Add the server with password authentication and ForgeFlow can copy and deploy projects directly.

`; - return `

Server inventory

Live Docker, Compose and DockerMan discovery, linked to Gitea
${servers.length ? `
${serverCards}
` : empty}
${icon("shield")}Server pull fetches an exact Gitea commit through a repository-scoped read-only deploy key, validates Compose and only then promotes the release. Direct copy remains an explicit fallback.
`; -} - -function renderDeployments() { - const cards = ui.repositories.flatMap((repository) => - (repository.deploymentProfiles || []).map((profile) => ({ repository, profile })), - ); - const active = operations().filter((operation) => !isTerminalOperation(operation.status)); - const missingDockerMan = cards.filter(({ profile }) => - profile.provider === "ssh-unraid" && - profile.manageDockerMan === true && - profile.state?.containerRunning && - !dockerManIntegration(profile).ready, - ); - return `
${active.length ? `
${icon("pulse")} ${active.length} deployment operation${active.length === 1 ? " is" : "s are"} still active. ForgeFlow reconciles these against the live server automatically.
` : ""}${renderServerInventory()}

Linked deployment environments

Stable Compose identity, live container health and exact Gitea commit parity
${cards.length ? cards.map(({ repository, profile }) => renderProfileCard(repository, profile, true)).join("") : '

No deployment environments configured

Scan a server and link an existing workload, or open a repository and add an environment.

'}

All operations

Newest first
${operations().length ? `${operations().map((operation) => ``).join("")}
RepositoryActionEnvironmentCommitStatusUpdated
${escapeHtml(operation.repository)}${escapeHtml(operation.action || "deploy")}${escapeHtml(operation.environment || "—")}${escapeHtml(operation.shortSha || shortSha(operation.sha))}${escapeHtml(operation.status)}${formatDate(operation.updatedAt || operation.createdAt)}
` : '

No operations recorded.

'}
`; -} -function renderSettings() { - const state = ui.boot.state; - const prefs = state.preferences || {}; - const update = ui.updateStatus; - const servers = state.servers || []; - return `
-

Gitea connection

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

ForgeFlow updates

Secure source update from ${escapeHtml(state.updates?.owner || "Jens")}/${escapeHtml(state.updates?.repo || "ForgeFlow")}
${icon(update?.available ? "download" : "check")}${update ? (update.available ? `ForgeFlow ${escapeHtml(update.remoteVersion)} is available` : `ForgeFlow ${escapeHtml(update.currentVersion)} is up to date`) : `Current version ${escapeHtml(ui.boot.appVersion)}`}${update ? `Branch ${escapeHtml(update.branch)} · commit ${escapeHtml(update.shortSha)} · checked ${formatDate(update.checkedAt)}` : "No update check in this session."}
${update?.available && !update.downloaded ? `` : ""}${update?.downloaded ? `` : ""}
${icon("shield")}The updater downloads an authenticated ZIP for the exact remote commit, verifies its SHA-256 checksum, runs the complete quality gate and restores the previous source version if validation fails.
-

SSH / Unraid servers

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

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

'}
-

Git remote maintenance

Standardize linked repositories to the current Gitea SSH URLs.

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

-

Project roots

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

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

Background awareness

-

Desktop integration

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

Encrypted configuration backup

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

-

Appearance

-

Danger zone

Reset removes local ForgeFlow configuration, repository links, profiles and operation history. It does not modify Git repositories or Gitea.

-
`; -} - -function preflightTone(status) { - return status === "pass" - ? "success" - : status === "fail" - ? "danger" - : status === "warning" - ? "warning" - : ""; -} - -function renderPreflightChecks( - report, - emptyMessage = "Run the preflight to verify this configuration.", -) { - if (!report?.checks?.length) - return `

${escapeHtml(emptyMessage)}

`; - return `
${report.checks.map((item) => `
${item.status === "pass" ? icon("check") : item.status === "fail" ? icon("error") : icon("warning")}
${escapeHtml(item.label)}${escapeHtml(item.detail)}${item.help ? `${escapeHtml(item.help)}` : ""}${item.repairAction ? `` : ""}
${escapeHtml(item.status)}
`).join("")}
`; -} - -function renderDiagnostics() { - const prefs = ui.boot.state.preferences || {}; - const status = ui.diagnosticsStatus || ui.boot.diagnostics || {}; - const report = ui.systemPreflight; - const trouble = ui.troubleshooter; - const troubleRows = - trouble?.issues - ?.map( - (item, index) => - `
${icon(item.severity === "error" ? "error" : "warning")}
${escapeHtml(item.title)}${escapeHtml(item.repository || "System")} · ${escapeHtml(item.detail)}
${item.repairable ? `` : 'Manual review'}
`, - ) - .join("") || ""; - return `
-
${icon("shield")}Credentials are never added to the diagnostic bundle. Known runtime secrets are redacted again during export. You can inspect the ZIP before sharing it.
-
-

Log storage

${status.lastWriteError ? "Write error" : status.enabled ? "Recording" : "Disabled"}
Files${escapeHtml(status.fileCount ?? "—")}
Total size${escapeHtml(status.totalSize || "—")}
Latest event${status.latestAt ? formatDate(status.latestAt) : "None"}
Retention${escapeHtml(status.retentionDays || prefs.logRetentionDays || 14)} days
Location${escapeHtml(status.directory || "Unavailable")}
Level${escapeHtml(status.level || prefs.diagnosticLevel || "info")}
${status.lastWriteError ? `
Error${escapeHtml(status.lastWriteError)}
` : ""}
-

Recording policy

-
-

One-click troubleshooter

Git locks, interrupted operations, branch synchronization and deployment/server inconsistencies
${trouble?.issues?.some((item) => item.repairable && item.safe) ? `` : ""}
${trouble ? `${trouble.summary.total ? `${trouble.summary.total} issue(s)` : "Healthy"}${trouble.summary.errors} errors · ${trouble.summary.warnings} warnings · ${trouble.summary.repairable} repairable` : "Run the troubleshooter to inspect all linked repositories and deployments."}
${troubleRows || '

No problems detected.

'}
-

System preflight

Git, writable storage, credential protection, folders and Gitea
${report ? `${report.summary.ready ? "Ready" : `${report.summary.blocking.length} blocking`}${report.summary.counts.pass} passed · ${report.summary.counts.warning} warnings · ${report.summary.counts.fail} failed` : "Not run in this session"}
${renderPreflightChecks(report)}
-

Export support bundle

Configuration summary, repository states, operations, preflight and redacted JSONL logs
${ui.lastDiagnosticBundle ? `
${icon("check")}
${escapeHtml(ui.lastDiagnosticBundle.size)} bundle created

SHA-256 ${escapeHtml(ui.lastDiagnosticBundle.sha256)}

` : ""}
-
`; -} - -function renderPipelineView() { - const operation = ui.activeDeployment; - if (!operation) - return '

No deployment operation selected.

'; - const logs = (operation.logs || []).join("\n"); - return `

${escapeHtml(operation.status)}

${escapeHtml(operation.profileName || operation.workflowFile || "")} · ${escapeHtml(operation.shortSha || shortSha(operation.sha))}

${escapeHtml(operation.status)}
${(operation.stages || []).map((stage) => `
${stage.status === "complete" ? icon("check") : stage.status === "failed" ? icon("error") : stage.status === "active" ? icon("pulse") : icon("clock")}${escapeHtml(stage.label)}
`).join("")}
${operation.jobs?.length ? `

Runner jobs

${operation.jobs.map((job) => ``).join("")}
JobStatusStartedCompleted
${escapeHtml(job.name)}${escapeHtml(job.conclusion || job.status)}${job.startedAt ? formatDate(job.startedAt) : "—"}${job.completedAt ? formatDate(job.completedAt) : "—"}
` : ""}
Deployment output
${escapeHtml(logs || "Waiting for operation output…")}
${operation.failure ? `
${icon("error")}
${escapeHtml(operation.failure.stage)}

${escapeHtml(operation.failure.message)}

` : ""}
`; -} - -function renderStatusbar() { - const state = ui.boot?.state; - const repository = selectedRepository(); - const active = operations().filter( - (operation) => !isTerminalOperation(operation.status), - ).length; - return ``; -} - -function renderSetup() { - const steps = ["Readiness", "Gitea", "Folders", "Discovery", "Ready"]; - let body = ""; - if (ui.setupStep === 0) { - body = `

Check this computer

ForgeFlow verifies Git, writable storage and protected credential support before you enter any connection details.

${icon("shield")}Your Gitea token is entered only inside this local desktop application. It is never included in diagnostic logs or support bundles.
${renderPreflightChecks(ui.systemPreflight, "Run the readiness check to verify this computer.")}
Available even before Gitea is connected.
`; - } else if (ui.setupStep === 1) { - body = `

Connect your Gitea instance

Enter the URL and a personal access token created on your own Gitea server. ForgeFlow validates it locally and stores it using operating-system encryption when available.

${ui.setupValidation ? `
${icon("check")}Connected as ${escapeHtml(ui.setupValidation.user.login)} · ${ui.setupValidation.repositoryCount} repositories · Gitea ${escapeHtml(ui.setupValidation.version || "version unknown")}
` : `
${icon("shield")}Use the narrowest permissions that allow repository reads and Actions workflow dispatch. The setup guide explains this without requiring you to share the token.
`}
`; - } else if (ui.setupStep === 2) { - body = `

Select development folders

Choose parent folders. ForgeFlow discovers Git working trees below them and matches their origin to Gitea.

${ui.setupDraft.roots.map((root, index) => `
`).join("")}
`; - } else if (ui.setupStep === 3) { - body = `

Discovering repositories

Inspecting local Git metadata. Generated folders and nested dependency trees are skipped.

Scanning configured folders…
`; - } else { - body = `

ForgeFlow is ready

${ui.setupDraft.discovered.length} local repositories were found. You can add deployment environments after opening a repository.

Gitea connected${escapeHtml(ui.setupDraft.baseUrl)} · ${escapeHtml(ui.setupDraft.user?.login || "user")}
Workspace discovery${ui.setupDraft.roots.length} root folder(s), ${ui.setupDraft.discovered.length} repository/repositories
Safe diagnosticsStructured local logs with credential redaction are enabled by default.
${ - ui.setupDraft.discovered.length - ? ui.setupDraft.discovered - .slice(0, 8) - .map( - (item) => - `
${icon(item.error ? "error" : "git")}
${escapeHtml(item.localPath.split(/[\\/]/).pop())}${escapeHtml(item.localPath)}
${item.error ? "Unreadable" : "Ready"}
`, - ) - .join("") - : '

No repositories found. You can link or clone repositories later.

' - }
`; - } - const nextAction = - ui.setupStep === 0 - ? ui.systemPreflight?.summary?.ready - ? '' - : '' - : ui.setupStep === 1 - ? '' - : ui.setupStep === 2 - ? `` - : ui.setupStep === 4 - ? '' - : ""; - return `
${body}
`; -} - -function renderModal() { - if (!ui.modal) return ""; - const repository = - selectedRepository() || - ui.repositories.find( - (repo) => repo.fullName === ui.modal.repositoryFullName, - ); - if (ui.modal.type === "server-password") { - const server = (ui.boot?.state?.servers || []).find((item) => item.id === ui.modal.serverId); - if (!server) return ``; - const retryText = ui.modal.retry?.type === "deploy" ? "Save password & redeploy" : "Save password & rescan"; - return ``; - } - if (ui.modal.type === "server-reconciliation-plan") { - const plan = ui.modal.result?.plan || {}; - const summary = plan.summary || {}; - const rows = [ - ...(plan.additions || []).map((item) => ({ tone: "success", title: `Link ${item.repositoryFullName}`, detail: `${item.evidence} · ${item.impact}` })), - ...(plan.updates || []).map((item) => ({ tone: "", title: `Refresh ${item.repositoryFullName}`, detail: item.impact })), - ...(plan.stale || []).map((item) => ({ tone: "warning", title: `Review stale link ${item.repositoryFullName}`, detail: `${item.reason} · no automatic removal` })), - ...(plan.conflicts || []).map((item) => ({ tone: "danger", title: `Manual review: ${item.displayName}`, detail: `${item.status} · ${(item.candidates || []).map((candidate) => candidate.repositoryFullName).join(", ") || "no unique repository"}` })), - ]; - return ``; - } - if (ui.modal.type === "workload-link") { - const serverResult = (ui.serverDiscovery || []).find( - (item) => item.serverId === ui.modal.serverId, - ); - const workload = serverResult?.workloads?.find( - (item) => item.workloadId === ui.modal.workloadId, - ); - if (!workload) { - return ``; - } - const availableRepositories = ui.repositories.filter((item) => item.fullName); - const suggestedRepository = - ui.modal.repositoryFullName || - workload.candidates?.[0]?.repositoryFullName || - selectedRepository()?.fullName || - availableRepositories[0]?.fullName || - ""; - const selectedLinkRepository = availableRepositories.find( - (item) => item.fullName === suggestedRepository, - ); - const remoteFolder = - ui.modal.remoteFolder || - workload.remoteFolderCandidate || - safeCloneFolderName(selectedLinkRepository); - const candidateSummary = workload.candidates?.length - ? workload.candidates - .slice(0, 4) - .map( - (candidate) => - `
${escapeHtml(candidate.repositoryFullName)}${escapeHtml(candidate.exact ? "Exact provenance" : `${candidate.score} confidence`)} · ${escapeHtml((candidate.reasons || []).join(", ") || "name similarity")}
`, - ) - .join("") - : '
Repository candidatesNo confident match; choose manually.
'; - const containerNames = (workload.containers || []) - .map((container) => container.name) - .filter(Boolean) - .join(", "); - return ``; - } - if (ui.modal.type === "deployment-config") { - const storedProfile = - repository?.deploymentProfiles?.find( - (profile) => profile.id === ui.modal.profileId, - ) || {}; - const discovery = - ui.deploymentDiscovery?.repository === repository?.fullName - ? ui.deploymentDiscovery - : null; - const existing = { ...storedProfile, ...(discovery?.profile || {}) }; - if (discovery?.provenance) existing.provenance = discovery.provenance; - const servers = ui.boot.state.servers || []; - const provider = - ui.modal.provider || - existing.provider || - (servers.length ? "ssh-unraid" : "gitea-actions"); - const ssh = provider === "ssh-unraid"; - const remoteFolder = - existing.remoteFolder || safeCloneFolderName(repository); - return ``; - } - if (ui.modal.type === "inventory-review-plan") { - const plan = ui.inventoryReviewPlan; - return ``; - } - if (ui.modal.type === "deploy-key-lifecycle") { - const lifecycle = ui.deployKeyLifecycle; - const inventory = lifecycle?.inventory; - const rotation = lifecycle?.rotation; - const revocation = lifecycle?.revocation; - return ``; - } - if (ui.modal.type === "deployment-preflight") { - const profile = - repository?.deploymentProfiles?.find( - (item) => item.id === ui.modal.profileId, - ) || selectedProfile(repository); - const report = ui.deploymentPreflight; - return ``; - } - if (ui.modal.type === "deploy-confirm") { - const profile = - repository?.deploymentProfiles?.find( - (item) => item.id === ui.modal.profileId, - ) || selectedProfile(repository); - const targetSha = deploymentTargetSha(repository, profile); - return ``; - } - if (ui.modal.type === "rollback-confirm") { - const profile = repository?.deploymentProfiles?.find( - (item) => item.id === ui.modal.profileId, - ); - const target = profile?.state?.previousSha; - return ``; - } - if (ui.modal.type === "server-config") { - const server = - (ui.boot.state.servers || []).find( - (item) => item.id === ui.modal.serverId, - ) || {}; - const authType = ui.modal.authType || server.authType || "password"; - return ``; - } - if (ui.modal.type === "hunk-staging") { - const hunks = ui.diffHunks?.hunks || []; - return ``; - } - if (ui.modal.type === "pull-request") { - return ``; - } - if (ui.modal.type === "conflict-guide") { - const state = ui.conflictState || {}; - return ``; - } - if (ui.modal.type === "command-palette") return renderCommandPalette(); - return ""; -} - -function paletteCommands() { - const repository = selectedRepository(); - return [ - { - id: "overview", - label: "Go to release overview", - detail: "Workspace", - icon: "overview", - enabled: true, - }, - { - id: "refresh", - label: "Refresh all repositories", - detail: "Local and Gitea", - icon: "refresh", - enabled: true, - }, - { - id: "deployments", - label: "Open deployments", - detail: "Release history", - icon: "deploy", - enabled: true, - }, - { - id: "diagnostics", - label: "Open diagnostics", - detail: "Logs, preflight and support bundle", - icon: "shield", - enabled: true, - }, - { - id: "settings", - label: "Open settings", - detail: "Connections and awareness", - icon: "settings", - enabled: true, - }, - { - id: "open-folder", - label: "Open selected project folder", - detail: repository?.name || "No repository selected", - icon: "folder", - enabled: Boolean(repository?.localPath), - }, - { - id: "git-tools", - label: "Open branch and stash tools", - detail: repository?.name || "No repository selected", - icon: "branch", - enabled: Boolean(repository?.localPath), - }, - { - id: "deploy-selected", - label: "Deploy selected repository", - detail: canDeploy(repository) - ? `${repository.name} ${shortSha(deploymentTargetSha(repository))}` - : "Not ready", - icon: "rocket", - enabled: canDeploy(repository), - }, - ]; -} -function renderCommandPalette() { - const query = ui.paletteQuery.toLowerCase(); - const commands = paletteCommands().filter( - (command) => - !query || - `${command.label} ${command.detail}`.toLowerCase().includes(query), - ); - return ``; -} - -function enhanceRenderedUi() { - const repository = selectedRepository(); - if (ui.modal?.type === "deployment-config") { - const profile = - repository?.deploymentProfiles?.find( - (item) => item.id === ui.modal.profileId, - ) || {}; - const policy = profile.deploymentPolicy || {}; - document - .querySelector(".modal-body .form-grid") - ?.insertAdjacentHTML( - "beforeend", - `

Deployment policy

Day 0 is Sunday. Separate windows with |.
`, - ); - } - if (ui.modal?.type === "workload-link") { - const workload = (ui.serverDiscovery || []).find((server) => server.serverId === ui.modal.serverId)?.workloads?.find((item) => item.workloadId === ui.modal.workloadId); - const type = workload?.classification?.type || "ambiguous"; - const recommended = type === "duplicate" ? "select-authoritative" : type === "stale-link" ? "archive-link" : type === "historical-compose" ? "mark-historical" : type === "orphan-container" ? "monitor-only" : "manual-link"; - const actions = [["manual-link", "Confirm selected repository match"], ["select-authoritative", "Select as authoritative instance"], ["mark-historical", "Mark historical definition"], ["archive-link", "Archive stale link"], ["monitor-only", "Keep for monitoring only"], ["manual-exclude", "Exclude this workload"], ["ignore", "Ignore with reason"]]; - const options = actions.map(([value, label]) => ``).join(""); - document.querySelector(".modal-body")?.insertAdjacentHTML("beforeend", `

Classify without touching containers

${icon("info")}
${escapeHtml(type)}

${escapeHtml(workload?.classification?.reason || "ForgeFlow needs an explicit decision for this workload.")}

The decision is tied to current evidence and becomes stale automatically when server truth changes.

`); - } - if (ui.modal?.type === "deploy-confirm") { - const profile = repository?.deploymentProfiles?.find( - (item) => item.id === ui.modal.profileId, - ); - document - .querySelector(".modal-body") - ?.insertAdjacentHTML( - "beforeend", - `
`, - ); - } - if (ui.currentView === "diagnostics") { - const container = document.querySelector(".diagnostics-page"); - container?.insertAdjacentHTML( - "beforeend", - `

Operational audit log

Append-only release, pull-request and recovery events
${ui.auditEvents.length ? `${ui.auditEvents.map((item) => ``).join("")}
TimeEventRepositoryResult
${formatDate(item.timestamp)}${escapeHtml(item.event)}${escapeHtml(item.details?.repository || "—")}${escapeHtml(item.details?.result || item.details?.note || "—")}
` : '

Load the operational audit log.

'}
`, - ); - } - document.querySelectorAll("button.icon-button:not([aria-label])").forEach((button) => { - const action = String(button.title || button.dataset.action || "Action").replaceAll("-", " "); - button.setAttribute("aria-label", action.charAt(0).toUpperCase() + action.slice(1)); - }); - document.querySelectorAll(".field > label:not([for])").forEach((label, index) => { - const control = label.parentElement?.querySelector("input, select, textarea"); - if (!control) return; - if (!control.id) control.id = `forgeflow-field-${index}`; - label.htmlFor = control.id; - }); -} - -function render() { - if (!ui.boot) return; - const repository = selectedRepository(); - const main = - ui.currentView === "overview" - ? renderOverview() - : ui.currentView === "deployments" - ? renderDeployments() - : ui.currentView === "settings" - ? renderSettings() - : ui.currentView === "diagnostics" - ? renderDiagnostics() - : ui.currentView === "deployment-run" - ? renderPipelineView() - : repository - ? renderRepositoryWorkspace(repository) - : renderOverview(); - const withPanel = ui.currentView === "repository" && repository; - app.innerHTML = `
${renderTitlebar()}
${renderSidebar()}
${main}
${withPanel ? renderActionPanel(repository) : ""}${ui.loading ? `
${escapeHtml(ui.loadingMessage || "Working…")}
` : ""}
${renderStatusbar()}
${ui.boot.state.setupComplete ? "" : renderSetup()}${renderModal()}`; - enhanceRenderedUi(); - if (ui.modal?.type === "command-palette") - requestAnimationFrame(() => - document.querySelector("#palette-input")?.focus(), - ); -} - -async function runOperation( - message, - operation, - successMessage, - { refresh = true } = {}, -) { - setLoading(true, message); - try { - const result = await operation(); - if (successMessage) showToast("Done", successMessage, "success"); - if (refresh) await refreshRepositories(false); - return result; - } catch (error) { - const pushAfterCommit = error.code === "PUSH_AFTER_COMMIT_FAILED"; - showToast( - pushAfterCommit - ? "Commit saved locally; push failed" - : "Operation failed", - error.message, - "error", - ); - // Always reload the real Git state. A failed stage must keep changes visible, while a - // failed push after a successful commit must immediately surface as an ahead branch. - await refreshRepositories(false, true); - if (pushAfterCommit) { - ui.selectedFiles.clear(); - ui.selectedFile = null; - ui.diff = ""; - ui.commitMessage = ""; - render(); - } - return null; - } finally { - setLoading(false); - } -} - -async function executeDeployment(profileId) { - const repository = selectedRepository(); - const profile = - repository?.deploymentProfiles?.find((item) => item.id === profileId) || - selectedProfile(repository); - if (!repository || !profile) return; - const targetSha = deploymentTargetSha(repository, profile); - if (!targetSha) { - showToast("Refresh required", "Refresh Gitea and server truth before deploying this environment.", "error"); - return; - } - const deploymentOptions = { - note: document.querySelector("#deployment-note")?.value.trim() || "", - override: document.querySelector("#deployment-override")?.checked === true, - overrideReason: - document.querySelector("#deployment-override-reason")?.value.trim() || "", - }; - ui.modal = null; - setLoading( - true, - profile.provider === "ssh-unraid" - ? `Deploying ${repository.name} to ${profile.remoteFolder} over SSH…` - : `Dispatching ${profile.name} workflow…`, - ); - try { - ui.activeDeployment = await window.forgeflow.deploy( - repository, - profile.id, - targetSha, - deploymentOptions, - ); - updateOperationInState(ui.activeDeployment); - ui.currentView = "deployment-run"; - showToast( - "Deployment started", - `${repository.name} ${shortSha(targetSha)} → ${profile.environment}`, - "success", - ); - startOperationPolling(); - } catch (error) { - if (profile.provider === "ssh-unraid" && isSshCredentialError(error)) { - ui.modal = { - type: "server-password", - serverId: profile.serverId, - retry: { - type: "deploy", - repositoryFullName: repository.fullName, - profileId: profile.id, - }, - }; - showToast("SSH key rejected", "Enter the Unraid server password once; ForgeFlow will retry the direct desktop → Unraid connection.", "error"); - render(); - } else { - showToast("Deployment failed to start", error.message, "error"); - } - } - setLoading(false); -} - -async function executeRollback(profileId) { - const repository = selectedRepository(); - const profile = repository?.deploymentProfiles?.find( - (item) => item.id === profileId, - ); - const target = profile?.state?.previousSha; - if (!repository || !profile || !target) return; - ui.modal = null; - setLoading( - true, - profile?.provider === "ssh-unraid" - ? `Rolling back ${profile.remoteFolder} over SSH…` - : `Dispatching rollback to ${shortSha(target)}…`, - ); - try { - ui.activeDeployment = await window.forgeflow.rollback( - repository, - profile.id, - target, - ); - updateOperationInState(ui.activeDeployment); - ui.currentView = "deployment-run"; - showToast( - "Rollback requested", - `${profile.environment} → ${shortSha(target)}`, - "success", - ); - } catch (error) { - showToast("Rollback failed to start", error.message, "error"); - } - setLoading(false); -} - -async function loadGitTools(repository) { - if (!repository?.localPath) return; - setLoading(true, "Loading branches and stashes…"); - try { - [ui.branches, ui.stashes, ui.gitRecovery] = await Promise.all([ - window.forgeflow.branches(repository.localPath), - window.forgeflow.stashList(repository.localPath), - window.forgeflow.gitRecoveryStatus(repository.localPath), - ]); - ui.repositoryTab = "gittools"; - } catch (error) { - showToast("Git tools unavailable", error.message, "error"); - } - setLoading(false); -} - -function profileRepository(profileId) { - return ui.repositories.find((repository) => - repository.deploymentProfiles?.some((profile) => profile.id === profileId), - ); -} - -async function runSystemPreflight({ setup = false } = {}) { - setLoading(true, "Checking local readiness…"); - try { - ui.systemPreflight = await window.forgeflow.setupPreflight({ - baseUrl: ui.setupDraft.baseUrl, - token: ui.setupDraft.token, - roots: setup ? ui.setupDraft.roots : ui.boot.state.workspaceRoots, - }); - if (!setup) - ui.diagnosticsStatus = await window.forgeflow.diagnosticsStatus(); - showToast( - ui.systemPreflight.summary.ready - ? "Readiness checks passed" - : "Readiness needs attention", - ui.systemPreflight.summary.ready - ? `${ui.systemPreflight.summary.counts.pass} checks passed.` - : `${ui.systemPreflight.summary.blocking.length} blocking check(s) must be resolved.`, - ui.systemPreflight.summary.ready ? "success" : "error", - ); - return ui.systemPreflight; - } catch (error) { - showToast("Readiness check failed", error.message, "error"); - return null; - } finally { - setLoading(false); - } -} - -async function runDeploymentPreflight( - repository, - profileId, - { showModal = true } = {}, -) { - if (!repository || !profileId) return null; - if (String(repository.id) !== String(ui.selectedRepoId)) - selectRepository(repository.id, false); - ui.selectedProfileId = profileId; - ui.deploymentPreflight = null; - setLoading(true, "Verifying repository, workflow and server…"); - try { - const report = await window.forgeflow.deploymentPreflight( - repository, - profileId, - ); - ui.deploymentPreflight = report; - if (showModal) - ui.modal = { - type: "deployment-preflight", - profileId, - repositoryFullName: repository.fullName, - }; - return report; - } catch (error) { - showToast("Deployment preflight failed", error.message, "error"); - return null; - } finally { - setLoading(false); - } -} - -app.addEventListener("click", async (event) => { - const target = event.target.closest("[data-action]"); - if (!target) return; - const action = target.dataset.action; - let repository = selectedRepository(); - if (target.dataset.repositoryId) { - const actionRepository = ui.repositories.find( - (item) => String(item.id) === String(target.dataset.repositoryId), - ); - if (actionRepository) repository = actionRepository; - } - - if (action === "navigate") { - ui.currentView = target.dataset.view; - ui.modal = null; - render(); - if (ui.currentView === "deployments" && (ui.boot?.state?.servers || []).length && !(ui.serverDiscovery || []).length) { - setLoading(true, "Reading Docker, Compose and DockerMan inventory from Unraid…"); - await refreshDeploymentTruth(true); - setLoading(false); - } - } else if (action === "select-repo") selectRepository(target.dataset.id); - else if (action === "refresh") { - await refreshRepositories(true); - await refreshActiveOperations(false); - await refreshDeploymentTruth(false); - } else if (action === "refresh-operations") { - setLoading(true, "Refreshing deployment operations and live server state…"); - await refreshActiveOperations(); - await refreshDeploymentTruth(true); - setLoading(false); - } else if (action === "toggle-theme") { - const appearance = - document.documentElement.dataset.theme === "dark" ? "light" : "dark"; - applyTheme(appearance); - ui.boot.state = await window.forgeflow.setAppearance(appearance); - render(); - } else if (action === "open-palette") { - ui.paletteQuery = ""; - ui.modal = { type: "command-palette" }; - render(); - } else if (action === "repo-tab") { - ui.repositoryTab = target.dataset.tab; - if (ui.repositoryTab === "gittools" && !ui.branches.length) - await loadGitTools(repository); - else if (ui.repositoryTab === "validator" && !ui.gitValidation) { - setLoading(true, "Validating Git and Gitea best practices…"); - try { - ui.gitValidation = await window.forgeflow.gitValidatorScan( - repository.fullName, - ); - } catch (error) { - showToast("Git Validator failed", error.message, "error"); - } finally { - setLoading(false); - } - render(); - } else if (ui.repositoryTab === "settings") { - try { - ui.pullRequests = await window.forgeflow.pullRequests( - repository.fullName, - "open", - ); - } catch (error) { - ui.pullRequests = []; - showToast("Could not load pull requests", error.message, "error"); - } - render(); - } else render(); - } else if (action === "git-validator-scan") { - setLoading(true, "Validating Git and Gitea best practices…"); - try { - ui.gitValidation = await window.forgeflow.gitValidatorScan( - repository.fullName, - ); - showToast( - "Git validation complete", - `${ui.gitValidation.score}/100 · ${ui.gitValidation.grade}`, - ui.gitValidation.summary.errors ? "error" : "success", - ); - } catch (error) { - showToast("Git Validator failed", error.message, "error"); - } finally { - setLoading(false); - } - } else if (action === "git-validator-repair") { - const check = ui.gitValidation?.checks?.[Number(target.dataset.checkIndex)]; - if (!check?.fixAction) return; - if (!check.safe && !confirm(check.confirmation || `Apply ${check.title}?`)) - return; - setLoading(true, `Repairing ${check.title}…`); - try { - await window.forgeflow.gitValidatorRepair(repository.fullName, check); - await refreshRepositories(false, true); - ui.gitValidation = await window.forgeflow.gitValidatorScan( - repository.fullName, - ); - showToast("Git best practice repaired", check.title, "success"); - } catch (error) { - showToast("Repair failed", error.message, "error"); - } finally { - setLoading(false); - } - } else if (action === "git-validator-repair-safe") { - const checks = (ui.gitValidation?.checks || []).filter( - (check) => check.fixAction && check.safe, - ); - setLoading(true, `Applying ${checks.length} safe Git fixes…`); - let repaired = 0; - try { - for (const check of checks) { - await window.forgeflow.gitValidatorRepair(repository.fullName, check); - repaired += 1; - } - await refreshRepositories(false, true); - ui.gitValidation = await window.forgeflow.gitValidatorScan( - repository.fullName, - ); - showToast("Safe Git fixes applied", `${repaired} repaired.`, "success"); - } catch (error) { - showToast("Safe repair stopped", error.message, "error"); - } finally { - setLoading(false); - } - } else if (action === "toggle-favorite") { - ui.boot.state = await window.forgeflow.favoriteRepository( - repository.fullName, - !repository.favorite, - ); - repository.favorite = !repository.favorite; - render(); - } else if (action === "select-file") { - if (event.target.matches("input[type=checkbox]")) return; - ui.selectedFile = target.dataset.path; - await loadDiff(repository, ui.selectedFile); - } else if (action === "toggle-all-files") { - const files = repository.localStatus?.files || []; - ui.selectedFiles = - ui.selectedFiles.size === files.length - ? new Set() - : new Set(files.map((file) => file.path)); - render(); - } else if (action === "copy-diff") { - await navigator.clipboard.writeText(ui.diff || ""); - showToast("Copied", "Diff copied to clipboard.", "success"); - } else if (action === "open-hunk-staging") { - if (ui.diffHunks?.partialSupported) { - ui.modal = { type: "hunk-staging" }; - render(); - } - } else if (action === "stage-chosen-hunks") { - const indexes = [ - ...document.querySelectorAll("[data-hunk-index]:checked"), - ].map((input) => Number(input.dataset.hunkIndex)); - if (!indexes.length) return; - const result = await runOperation( - "Staging selected hunks…", - () => - window.forgeflow.stageHunks( - repository.localPath, - ui.selectedFile, - indexes, - ), - "Selected hunks staged.", - ); - if (result) { - ui.modal = null; - await loadDiff(selectedRepository(), ui.selectedFile); - } - } else if (action === "open-file-editor") { - await window.forgeflow - .openEditor(repository.localPath, ui.selectedFile) - .catch((error) => - showToast("Could not open editor", error.message, "error"), - ); - } else if (action === "open-editor") { - await window.forgeflow - .openEditor(repository.localPath) - .catch((error) => - showToast("Could not open editor", error.message, "error"), - ); - } else if (action === "open-terminal") { - await window.forgeflow - .openTerminal(repository.localPath) - .catch((error) => - showToast("Could not open terminal", error.message, "error"), - ); - } else if (action === "load-conflicts") { - ui.conflictState = await window.forgeflow.conflictState( - repository.localPath, - ); - ui.modal = { type: "conflict-guide" }; - render(); - } else if (action === "resolve-conflict") { - if ( - !ui.selectedFile || - !confirm(`Apply “${target.dataset.resolution}” to ${ui.selectedFile}?`) - ) - return; - ui.conflictState = await window.forgeflow.resolveConflict( - repository.localPath, - ui.selectedFile, - target.dataset.resolution, - ); - await refreshRepositories(false); - ui.modal = { type: "conflict-guide" }; - render(); - } else if (action === "open-conflict-file") { - await window.forgeflow.openEditor( - repository.localPath, - target.dataset.path, - ); - } else if (action === "continue-git-operation") { - ui.conflictState = await window.forgeflow.continueGitOperation( - repository.localPath, - ); - ui.modal = null; - await refreshRepositories(false); - showToast( - "Git operation continued", - "The repository operation completed.", - "success", - ); - } else if (action === "abort-git-operation") { - if ( - !confirm( - "Abort the active Git operation? Conflict-resolution work may be discarded.", - ) - ) - return; - await window.forgeflow.abortGitOperation(repository.localPath); - ui.modal = null; - await refreshRepositories(false); - } else if (action === "check-branch-protection") { - ui.branchProtection = await window.forgeflow.branchProtection( - repository.fullName, - repository.localStatus.branch.head, - ); - showToast( - ui.branchProtection.protected - ? "Protected branch" - : "Branch is not protected", - ui.branchProtection.protected - ? `${ui.branchProtection.requiredApprovals} approval(s) required.` - : "Direct pushes are permitted by the reported branch rule.", - ui.branchProtection.protected ? "info" : "success", - ); - render(); - } else if (action === "open-pull-request") { - const subject = - ui.history[0]?.subject || repository.localStatus.branch.head; - ui.modal = { - type: "pull-request", - title: subject, - body: `## Summary\n\nChanges from ${repository.localStatus.branch.head}.`, - }; - render(); - } else if (action === "load-pull-requests") { - try { - ui.pullRequests = await window.forgeflow.pullRequests( - repository.fullName, - "open", - ); - render(); - } catch (error) { - showToast("Could not load pull requests", error.message, "error"); - } - } else if (action === "open-pull-request-url") { - if (target.dataset.url) - await window.forgeflow.openExternal(target.dataset.url); - } else if (action === "create-pull-request") { - setLoading(true, "Creating pull request…"); - try { - const pull = await window.forgeflow.createPullRequest( - repository.fullName, - document.querySelector("#pr-title").value, - document.querySelector("#pr-body").value, - document.querySelector("#pr-base").value, - ); - ui.modal = null; - ui.pullRequests = await window.forgeflow - .pullRequests(repository.fullName, "open") - .catch(() => ui.pullRequests); - showToast("Pull request created", `#${pull.number}`, "success"); - if (pull.html_url) await window.forgeflow.openExternal(pull.html_url); - } catch (error) { - showToast("Could not create pull request", error.message, "error"); - } - setLoading(false); - } else if (action === "copy-logs") { - const text = (ui.activeDeployment?.logs || []).join("\n"); - await navigator.clipboard.writeText(text); - showToast( - "Copied", - "Safe operation output copied. Open Gitea for raw runner logs.", - "success", - ); - } else if (action === "stage-selected") { - if (!repository?.localPath || !ui.selectedFiles.size) return; - await runOperation( - "Staging selected files…", - () => - window.forgeflow.stageFiles(repository.localPath, [ - ...ui.selectedFiles, - ]), - "Files staged.", - ); - } else if (action === "unstage-selected") { - if (!repository?.localPath || !ui.selectedFiles.size) return; - await runOperation( - "Unstaging selected files…", - () => - window.forgeflow.unstageFiles(repository.localPath, [ - ...ui.selectedFiles, - ]), - "Files unstaged.", - ); - } else if (action === "commit-push" || action === "commit-only") { - if ( - !repository?.localPath || - !ui.commitMessage.trim() || - (!ui.selectedFiles.size && !repository.localStatus?.counts?.staged) - ) - return; - const selected = [...ui.selectedFiles]; - const result = await runOperation( - action === "commit-push" - ? "Committing and pushing…" - : "Creating local commit…", - () => - action === "commit-push" - ? selected.length - ? window.forgeflow.commitAndPush( - repository.localPath, - ui.commitMessage, - selected, - ) - : window.forgeflow.commitStagedAndPush( - repository.localPath, - ui.commitMessage, - ) - : selected.length - ? window.forgeflow.commit( - repository.localPath, - ui.commitMessage, - selected, - ) - : window.forgeflow.commitStaged( - repository.localPath, - ui.commitMessage, - ), - action === "commit-push" - ? "Changes committed and pushed to Gitea." - : "Local commit created.", - ); - if (result) { - ui.commitMessage = ""; - ui.selectedFiles.clear(); - ui.selectedFile = null; - ui.diff = ""; - } - } else if (action === "push") - await runOperation( - "Pushing local commits…", - () => window.forgeflow.push(repository.localPath), - "Push completed.", - ); - else if (action === "fetch") - await runOperation( - "Fetching from Gitea…", - () => window.forgeflow.fetch(repository.localPath), - "Remote state refreshed.", - ); - else if (action === "pull") - await runOperation( - "Synchronizing from Gitea…", - () => window.forgeflow.pull(repository.localPath), - "Local branch fast-forwarded.", - ); - else if (action === "load-history") { - setLoading(true, "Loading commit history…"); - try { - ui.history = await window.forgeflow.history(repository.localPath, 50); - } catch (error) { - showToast("History unavailable", error.message, "error"); - } - setLoading(false); - } else if (action === "load-git-tools") await loadGitTools(repository); - else if (action === "create-branch") { - const branch = document.querySelector("#new-branch-name")?.value.trim(); - if (branch) - await runOperation( - `Creating ${branch}…`, - () => window.forgeflow.createBranch(repository.localPath, branch), - `Switched to ${branch}.`, - ); - await loadGitTools(selectedRepository()); - } else if (action === "checkout-branch") { - await runOperation( - `Switching to ${target.dataset.branch}…`, - () => - window.forgeflow.checkoutBranch( - repository.localPath, - target.dataset.branch, - ), - `Switched to ${target.dataset.branch}.`, - ); - await loadGitTools(selectedRepository()); - } else if (action === "stash-changes") { - const result = await runOperation( - "Stashing local changes…", - () => - window.forgeflow.stash( - repository.localPath, - `ForgeFlow ${new Date().toLocaleString()}`, - ), - "Local changes stashed.", - ); - if (result) ui.stashes = result.stashes; - } else if (action === "pop-stash") { - const result = await runOperation( - `Applying ${target.dataset.stashRef}…`, - () => - window.forgeflow.popStash( - repository.localPath, - target.dataset.stashRef, - ), - "Stash applied.", - ); - if (result) ui.stashes = result.stashes; - } else if (action === "open-path") - await window.forgeflow - .openPath(repository.localPath) - .catch((error) => - showToast("Could not open folder", error.message, "error"), - ); - else if (action === "open-gitea") - await window.forgeflow - .openExternal(repository.htmlUrl) - .catch((error) => - showToast("Could not open Gitea", error.message, "error"), - ); - else if (action === "link-repo") { - const localPath = await window.forgeflow.selectDirectory({ - title: `Link local folder for ${repository.name}`, - }); - if (localPath) { - ui.repositories = - (await runOperation( - "Linking local repository…", - () => window.forgeflow.linkRepository(repository.fullName, localPath), - "Local folder linked.", - { refresh: false }, - )) || ui.repositories; - selectRepository(repository.id); - } - } else if (action === "unlink-repo") { - ui.repositories = - (await runOperation( - "Removing local link…", - () => window.forgeflow.unlinkRepository(repository.fullName), - "Repository link removed.", - { refresh: false }, - )) || ui.repositories; - selectRepository(repository.id); - } else if (action === "clone-repo" || action === "clone-repo-custom") { - const mode = action === "clone-repo-custom" ? "custom" : "default"; - const clone = await runOperation( - mode === "custom" - ? "Choosing location and cloning repository…" - : `Cloning ${repository.name} into the default project root…`, - () => window.forgeflow.cloneRepository(repository.fullName, mode), - null, - { refresh: false }, - ); - if (clone?.cancelled) return; - if (clone?.target) { - if (clone.state) ui.boot.state = clone.state; - ui.repositories = - clone.repositories || (await window.forgeflow.refreshRepositories()); - selectRepository(repository.id); - showToast( - clone.reused ? "Existing repository linked" : "Repository cloned", - clone.target, - "success", - ); - } - } else if (action === "scan-server-inventory") { - setLoading(true, "Scanning Docker, Compose and DockerMan workloads…"); - try { - await refreshDeploymentTruth(true); - const detected = (ui.serverDiscovery || []).reduce( - (total, item) => total + Number(item.detected || 0), - 0, - ); - const review = (ui.serverDiscovery || []).reduce( - (total, item) => total + Number(item.needsReview || 0), - 0, - ); - const failures = (ui.serverDiscovery || []).filter((item) => item.error); - if (failures.length) { - showToast( - "Server scan failed", - failures.map((item) => `${item.serverName || item.serverId}: ${item.error}`).join(" · "), - "error", - ); - } else { - showToast( - "Server inventory updated", - `${detected} workload${detected === 1 ? "" : "s"} detected; ${review} require manual review.`, - review ? "info" : "success", - ); - } - } catch (error) { - showToast("Server scan failed", error.message, "error"); - } - setLoading(false); - } else if (action === "plan-server-reconciliation") { - setLoading(true, "Building a read-only reconciliation preview…"); - try { - const result = await window.forgeflow.planServerReconciliation(target.dataset.serverId); - ui.modal = { type: "server-reconciliation-plan", result }; - render(); - } catch (error) { - showToast("Could not build reconciliation plan", error.message, "error"); - } - setLoading(false); - } else if (action === "apply-server-reconciliation") { - setLoading(true, "Applying the reviewed configuration plan…"); - try { - const result = await window.forgeflow.applyServerReconciliation(target.dataset.serverId, target.dataset.planId); - if (result.state) ui.boot.state = result.state; - ui.modal = null; - await refreshRepositories(false, true); - await refreshDeploymentTruth(false); - showToast("Reconciliation applied", `${result.adopted || 0} link(s) added and ${result.refreshed || 0} profile(s) refreshed. No containers were changed.`, "success"); - } catch (error) { - showToast("Reconciliation was not applied", error.message, "error"); - } - setLoading(false); - } else if (action === "quick-link-server-workload") { - const serverResult = (ui.serverDiscovery || []).find( - (item) => item.serverId === target.dataset.serverId, - ); - const workload = serverResult?.workloads?.find( - (item) => item.workloadId === target.dataset.workloadId, - ); - const linkedRepository = ui.repositories.find( - (item) => item.fullName === target.dataset.repository, - ); - if (!workload || !linkedRepository || !workload.remoteFolderCandidate) { - showToast("Automatic link unavailable", "Scan the server again and use Review & link.", "error"); - return; - } - setLoading(true, `Linking ${workload.displayName} to ${linkedRepository.fullName}…`); - try { - const result = await window.forgeflow.linkServerWorkload( - linkedRepository, - target.dataset.serverId, - target.dataset.workloadId, - "server-git", - workload.remoteFolderCandidate, - ); - if (result.state) ui.boot.state = result.state; - ui.selectedProfileId = result.profile?.id || null; - await refreshRepositories(false, true); - await refreshDeploymentTruth(false); - showToast( - "Deployment linked", - `${linkedRepository.fullName} is linked to ${workload.compose?.workingDir || workload.remoteFolderCandidate}. Compose values were read from the server.`, - "success", - ); - } catch (error) { - showToast("Could not link deployment", error.message, "error"); - } - setLoading(false); - } else if (action === "preview-inventory-review") { - const reviewAction = document.querySelector("#inventory-review-action")?.value || "ignore"; - const reason = document.querySelector("#inventory-review-reason")?.value.trim() || ""; - const serverId = target.dataset.serverId; - const workloadId = target.dataset.workloadId; - try { - ui.inventoryReviewPlan = await window.forgeflow.planInventoryReview(serverId, workloadId, reviewAction, reason, document.querySelector("#workload-repository")?.value || null); - ui.modal = { type: "inventory-review-plan" }; - render(); - } catch (error) { showToast("Review preview unavailable", error.message, "error"); } - } else if (action === "apply-inventory-review") { - const plan = ui.inventoryReviewPlan; - if (!plan?.id) return; - setLoading(true, "Saving the evidence-bound inventory decision…"); - try { - const result = await window.forgeflow.applyInventoryReview(plan.serverId, plan.workloadId, plan.action, plan.reason, plan.repositoryFullName, plan.id); - if (result.state) ui.boot.state = result.state; - ui.serverDiscovery = (ui.serverDiscovery || []).map((item) => item.serverId === plan.serverId ? result.inventory : item); - ui.inventoryReviewPlan = null; ui.modal = null; render(); - showToast("Inventory decision saved", "Containers and Compose runtime were not changed.", "success"); - } catch (error) { showToast("Inventory review failed safely", error.message, "error"); } - finally { setLoading(false); } - } else if (action === "link-server-workload") { - const serverResult = (ui.serverDiscovery || []).find( - (item) => item.serverId === target.dataset.serverId, - ); - const workload = serverResult?.workloads?.find( - (item) => item.workloadId === target.dataset.workloadId, - ); - if (!workload) { - showToast( - "Workload unavailable", - "Scan the server inventory again before linking this workload.", - "error", - ); - return; - } - ui.modal = { - type: "workload-link", - serverId: target.dataset.serverId, - workloadId: target.dataset.workloadId, - repositoryFullName: - workload.candidates?.[0]?.repositoryFullName || - repository?.fullName || - ui.repositories[0]?.fullName || - "", - remoteFolder: workload.remoteFolderCandidate || "", - }; - render(); - } else if (action === "confirm-link-server-workload") { - const repositoryFullName = document - .querySelector("#workload-repository") - ?.value.trim(); - const deploymentMode = document.querySelector("#workload-deployment-mode")?.value || "server-git"; - const remoteFolder = document - .querySelector("#workload-remote-folder") - ?.value.trim(); - const linkedRepository = ui.repositories.find( - (item) => item.fullName === repositoryFullName, - ); - if (!linkedRepository) { - showToast( - "Choose a repository", - "The workload must be linked to a ForgeFlow project.", - "error", - ); - return; - } - setLoading(true, "Saving the permanent server workload link…"); - try { - const result = await window.forgeflow.linkServerWorkload( - linkedRepository, - target.dataset.serverId, - target.dataset.workloadId, - deploymentMode, - remoteFolder, - ); - if (result.state) ui.boot.state = result.state; - ui.modal = null; - ui.selectedProfileId = result.profile?.id || null; - await refreshRepositories(false, true); - await refreshDeploymentTruth(false); - showToast( - "Workload linked", - `${linkedRepository.fullName} now uses direct desktop-to-Unraid copy and the Compose configuration detected on the server.`, - "success", - ); - } catch (error) { - showToast("Could not link workload", error.message, "error"); - } - setLoading(false); - } else if (action === "configure-deployment") { - ui.deploymentDiscovery = null; - ui.modal = { - type: "deployment-config", - profileId: null, - provider: (ui.boot.state.servers || []).length - ? "ssh-unraid" - : "gitea-actions", - }; - render(); - } else if (action === "edit-deployment-profile") { - ui.deploymentDiscovery = null; - repository = profileRepository(target.dataset.profileId) || repository; - if (repository && String(repository.id) !== String(ui.selectedRepoId)) - selectRepository(repository.id, false); - ui.modal = { - type: "deployment-config", - profileId: target.dataset.profileId || null, - provider: repository?.deploymentProfiles?.find( - (item) => item.id === target.dataset.profileId, - )?.provider, - }; - render(); - } else if (action === "close-modal") { - ui.modal = null; - render(); - } else if (action === "select-profile-icon") { - const iconPath = await window.forgeflow.selectImageFile({ - title: "Select DockerMan PNG icon", - defaultPath: - document.querySelector("#profile-icon-file")?.value || undefined, - }); - if (iconPath) { - document.querySelector("#profile-icon-file").value = iconPath; - const mode = document.querySelector("#profile-icon-mode"); - if (mode) mode.value = "upload"; - } - } else if (action === "clear-profile-icon") { - const input = document.querySelector("#profile-icon-file"); - if (input) input.value = ""; - const mode = document.querySelector("#profile-icon-mode"); - if (mode) mode.value = "builtin"; - } else if (action === "discover-existing-deployment") { - const serverId = document.querySelector("#profile-server")?.value; - const remoteFolder = - document.querySelector("#profile-remote-folder")?.value.trim() || - safeCloneFolderName(repository); - if (!serverId) { - showToast( - "Select an Unraid server", - "Configure and select the server before importing an existing deployment.", - "error", - ); - return; - } - setLoading( - true, - "Reading Git, Compose, Docker and DockerMan from the server…", - ); - try { - const result = await window.forgeflow.discoverExistingDeployment( - repository, - serverId, - remoteFolder, - ); - ui.deploymentDiscovery = { ...result, repository: repository.fullName }; - showToast( - "Existing deployment imported", - `${result.runtime.containers} container(s), ${result.runtime.services} service(s) and ${result.runtime.ports.length} port mapping(s) detected.`, - "success", - ); - render(); - } catch (error) { - showToast("Could not import deployment", error.message, "error"); - } - setLoading(false); - } else if (action === "save-deployment-profile") { - const previousProfile = - repository?.deploymentProfiles?.find( - (item) => item.id === target.dataset.profileId, - ) || {}; - const provider = document.querySelector("#profile-provider").value; - let maintenanceWindows = []; - try { - maintenanceWindows = ( - document.querySelector("#profile-policy-windows")?.value || "" - ) - .split("|") - .map((item) => item.trim()) - .filter(Boolean) - .map((item) => { - const match = item.match( - /^([0-6](?:,[0-6])*)\s*:\s*((?:[01]\d|2[0-3]):[0-5]\d)-((?:[01]\d|2[0-3]):[0-5]\d)$/, - ); - if (!match) throw new Error(`Invalid maintenance window: ${item}`); - return { - days: match[1].split(",").map(Number), - start: match[2], - end: match[3], - }; - }); - } catch (error) { - showToast("Could not save profile", error.message, "error"); - return; - } - const composeFiles = - provider === "ssh-unraid" - ? (document.querySelector("#profile-compose-files")?.value || "") - .split(",") - .map((item) => item.trim()) - .filter(Boolean) - : []; - const composeServices = - provider === "ssh-unraid" - ? (document.querySelector("#profile-compose-services")?.value || "") - .split(",") - .map((item) => item.trim()) - .filter(Boolean) - : []; - const profile = { - id: target.dataset.profileId || undefined, - provider, - name: document.querySelector("#profile-name").value.trim(), - environment: document.querySelector("#profile-environment").value.trim(), - branch: document.querySelector("#profile-branch").value.trim(), - healthcheckUrl: - document.querySelector("#profile-healthcheck")?.value.trim() || "", - confirmationRequired: document.querySelector("#profile-confirmation") - .checked, - deploymentPolicy: { - frozen: - document.querySelector("#profile-policy-frozen")?.checked === true, - freezeReason: - document - .querySelector("#profile-policy-freeze-reason") - ?.value.trim() || "", - requireNote: - document.querySelector("#profile-policy-note")?.checked === true, - maintenanceWindows, - }, - ...(provider === "ssh-unraid" - ? { - serverId: document.querySelector("#profile-server").value, - remoteFolder: document - .querySelector("#profile-remote-folder") - .value.trim(), - deploymentMode: ["server-git", "push-bundle", "monitor-only"].includes( - document.querySelector("#profile-deployment-mode")?.value, - ) ? document.querySelector("#profile-deployment-mode").value : "server-git", - cloneUrl: previousProfile.cloneUrl || "", - alignRemote: false, - generatedCompose: - document.querySelector("#profile-generated-compose").value === - "true", - composeProject: - document.querySelector("#profile-compose-project")?.value.trim() || - previousProfile.composeProject || - "", - composeWorkingDir: previousProfile.composeWorkingDir || "", - composeFiles: composeFiles.length ? composeFiles : ["docker-compose.yml"], - composeFile: composeFiles[0] || "docker-compose.yml", - composeServices: composeServices.length - ? composeServices - : [safeCloneFolderName(repository).toLowerCase()], - composeService: - composeServices[0] || safeCloneFolderName(repository).toLowerCase(), - containerName: document - .querySelector("#profile-container-name") - .value.trim(), - hostPort: - Number(document.querySelector("#profile-host-port").value) || - null, - containerPort: - Number(document.querySelector("#profile-container-port").value) || - null, - webUiUrl: document.querySelector("#profile-web-ui").value.trim(), - iconMode: document.querySelector("#profile-icon-mode").value, - iconUrl: document.querySelector("#profile-icon-url").value.trim(), - iconFilePath: document - .querySelector("#profile-icon-file") - .value.trim(), - dockerShell: document.querySelector("#profile-docker-shell").value, - preservePaths: document - .querySelector("#profile-preserve-paths") - .value.split(",") - .map((item) => item.trim()) - .filter(Boolean), - manageDockerMan: - document.querySelector("#profile-manage-dockerman")?.checked === - true, - forceRecreate: false, - removeOrphans: false, - adoptedFromServer: Boolean( - ui.deploymentDiscovery || previousProfile.adoptedFromServer, - ), - serverSourceOfTruth: Boolean( - ui.deploymentDiscovery || previousProfile.serverSourceOfTruth, - ), - workloadIdentity: - ui.deploymentDiscovery?.profile?.workloadIdentity || - previousProfile.workloadIdentity || - null, - detectedAt: - ui.deploymentDiscovery?.profile?.detectedAt || - previousProfile.detectedAt || - null, - provenance: - ui.deploymentDiscovery?.provenance || - previousProfile.provenance || - {}, - detectedMetadata: - ui.deploymentDiscovery?.profile?.detectedMetadata || - previousProfile.detectedMetadata || - {}, - } - : { - workflowFile: document - .querySelector("#profile-workflow") - .value.trim(), - rollbackWorkflowFile: document - .querySelector("#profile-rollback-workflow") - .value.trim(), - statusUrl: document - .querySelector("#profile-status-url") - .value.trim(), - }), - }; - setLoading(true, "Saving deployment environment…"); - try { - const result = await window.forgeflow.saveDeploymentProfile( - repository.fullName, - profile, - ); - ui.boot.state = result.state; - ui.modal = null; - ui.deploymentDiscovery = null; - await refreshRepositories(false); - ui.selectedProfileId = result.profile.id; - showToast( - "Deployment configured", - `${profile.name} targets ${profile.environment}.`, - "success", - ); - } catch (error) { - showToast("Could not save profile", error.message, "error"); - } - setLoading(false); - } else if (action === "delete-deployment-profile") { - if ( - !confirm("Delete this deployment profile? Operation history is retained.") - ) - return; - setLoading(true, "Deleting deployment profile…"); - try { - const result = await window.forgeflow.deleteDeploymentProfile( - repository.fullName, - target.dataset.profileId, - ); - ui.boot.state = result.state; - ui.modal = null; - await refreshRepositories(false); - showToast( - "Profile deleted", - "Deployment environment removed.", - "success", - ); - } catch (error) { - showToast("Could not delete profile", error.message, "error"); - } - setLoading(false); - } else if (action === "run-deployment-preflight") { - if (!repository) repository = profileRepository(target.dataset.profileId); - await runDeploymentPreflight(repository, target.dataset.profileId); - } else if (action === "manage-deploy-key") { - const profileId = target.dataset.profileId || ui.selectedProfileId; - if (!repository) repository = profileRepository(profileId); - if (!repository || !profileId) return; - setLoading(true, "Inspecting deploy-key lifecycle without changing access…"); - try { - const [inventory, rotation, revocation] = await Promise.all([ - window.forgeflow.deployKeyInventory(repository, profileId), - window.forgeflow.planDeployKeyRotation(repository, profileId), - window.forgeflow.planDeployKeyRevocation(repository, profileId), - ]); - ui.deployKeyLifecycle = { repositoryId: repository.id, profileId, inventory, rotation, revocation }; - ui.modal = { type: "deploy-key-lifecycle" }; - render(); - } catch (error) { - showToast("Could not inspect deploy key", error.message, "error"); - } finally { setLoading(false); } - } else if (action === "confirm-rotate-deploy-key") { - const lifecycle = ui.deployKeyLifecycle; - const targetRepository = repositories().find((item) => item.id === lifecycle?.repositoryId); - if (!targetRepository || !lifecycle?.rotation?.id) return; - setLoading(true, "Rotating and verifying the repository deploy key…"); - try { - const result = await window.forgeflow.applyDeployKeyRotation(targetRepository, lifecycle.profileId, lifecycle.rotation.id); - if (result.state) ui.boot.state = result.state; - ui.modal = null; ui.deployKeyLifecycle = null; - await refreshRepositories(false, true); - showToast("Deploy key rotated", `New fingerprint ${result.profile?.serverGitAccess?.keyFingerprint || "verified"}.`, "success"); - } catch (error) { showToast("Deploy-key rotation failed safely", error.message, "error"); } - finally { setLoading(false); } - } else if (action === "confirm-revoke-deploy-key") { - const lifecycle = ui.deployKeyLifecycle; - const targetRepository = repositories().find((item) => item.id === lifecycle?.repositoryId); - if (!targetRepository || !lifecycle?.revocation?.id) return; - setLoading(true, "Revoking repository access while preserving recovery…"); - try { - const result = await window.forgeflow.applyDeployKeyRevocation(targetRepository, lifecycle.profileId, lifecycle.revocation.id); - if (result.state) ui.boot.state = result.state; - ui.modal = null; ui.deployKeyLifecycle = null; - await refreshRepositories(false, true); - showToast("Deploy key revoked", "Server pull is disabled; containers were not changed and recovery is available.", "success"); - } catch (error) { showToast("Deploy-key revocation failed", error.message, "error"); } - finally { setLoading(false); } - } else if (action === "restore-deploy-key") { - const lifecycle = ui.deployKeyLifecycle; - const targetRepository = repositories().find((item) => item.id === lifecycle?.repositoryId); - if (!targetRepository || !lifecycle?.profileId) return; - setLoading(true, "Restoring and verifying repository access…"); - try { - const result = await window.forgeflow.restoreDeployKey(targetRepository, lifecycle.profileId); - if (result.state) ui.boot.state = result.state; - ui.modal = null; ui.deployKeyLifecycle = null; - await refreshRepositories(false, true); - showToast("Deploy key restored", "Read-only server pull access is verified again.", "success"); - } catch (error) { showToast("Deploy-key recovery failed", error.message, "error"); } - finally { setLoading(false); } - } else if (action === "verify-server-git-access") { - const profileId = target.dataset.profileId || ui.selectedProfileId; - if (!repository) repository = profileRepository(profileId); - if (!repository || !profileId) return; - setLoading(true, "Verifying Gitea, deploy key, server commit and runtime…"); - try { - const result = await window.forgeflow.verifyServerGitProfile(repository, profileId); - ui.serverGitVerifications[profileId] = result; - render(); - const failures = result.checks.filter((check) => check.status === "fail"); - showToast(result.readiness, failures[0]?.detail || `Verified ${result.checks.length} server-pull checks without changing the server.`, result.ready ? "success" : "warning"); - } catch (error) { - showToast("Server-pull verification failed", error.message, "error"); - } finally { - setLoading(false); - } - } else if (action === "configure-server-git-access") { - const profileId = target.dataset.profileId || ui.selectedProfileId; - if (!repository) repository = profileRepository(profileId); - if (!repository || !profileId) return; - const approved = confirm( - `Configure read-only Gitea access for ${repository.fullName}?\n\nForgeFlow creates a dedicated SSH deploy key on the selected server, adds only its public key to this Gitea repository and pins the observed Gitea SSH host key. The private key never leaves the server.`, - ); - if (!approved) return; - setLoading(true, "Configuring repository-scoped Gitea access…"); - try { - const result = await window.forgeflow.configureServerGitAccess(repository, profileId); - if (result.state) ui.boot.state = result.state; - await refreshRepositories(false, true); - await refreshDeploymentTruth(false); - showToast( - "Server pull ready", - `Read-only Gitea access verified at ${shortSha(result.remoteSha)}.`, - "success", - ); - await runDeploymentPreflight(repository, profileId, { showModal: true }); - } catch (error) { - showToast("Could not configure Gitea access", error.message, "error"); - } finally { - setLoading(false); - } - } else if (action === "repair-deployment-write-access") { - const profileId = target.dataset.profileId || ui.selectedProfileId; - if (!repository) repository = profileRepository(profileId); - if (!repository || !profileId) return; - const profile = repository.deploymentProfiles?.find((item) => item.id === profileId); - const approved = confirm( - `Repair write access for ${repository.fullName} on ${profile?.name || profile?.environment || "the linked Unraid deployment"}?\n\nForgeFlow will only adjust the linked project source tree and its .forgeflow state folders. Preserved runtime paths such as appdata, data, config and logs are excluded. No container will be stopped, removed or recreated.`, - ); - if (!approved) return; - setLoading(true, "Repairing scoped Unraid write access…"); - try { - const result = await window.forgeflow.repairDeploymentWriteAccess( - repository, - profileId, - ); - showToast( - "Write access normalized", - "Project source and ForgeFlow upload folders now use safe shared write permissions. Preserved runtime data was not changed.", - "success", - ); - await runDeploymentPreflight(repository, profileId, { showModal: true }); - } catch (error) { - showToast("Write-access repair failed", error.message, "error"); - } finally { - setLoading(false); - } - } else if (action === "deploy-profile") { - if (repository && String(repository.id) !== String(ui.selectedRepoId)) - selectRepository(repository.id, false); - const profile = - repository?.deploymentProfiles?.find( - (item) => item.id === target.dataset.profileId, - ) || selectedProfile(repository); - ui.selectedProfileId = profile?.id || null; - if (!profile) return; - const report = await runDeploymentPreflight(repository, profile.id, { - showModal: true, - }); - if (!report) return; - } else if (action === "continue-after-preflight") { - const profile = selectedRepository()?.deploymentProfiles?.find( - (item) => item.id === target.dataset.profileId, - ); - if (!profile || !ui.deploymentPreflight?.summary?.ready) return; - if (profile.confirmationRequired !== false) { - ui.modal = { type: "deploy-confirm", profileId: profile.id }; - render(); - } else await executeDeployment(profile.id); - } else if (action === "confirm-deploy") - await executeDeployment(target.dataset.profileId); - else if (action === "rollback-profile") { - if (!repository) repository = profileRepository(target.dataset.profileId); - if (repository && String(repository.id) !== String(ui.selectedRepoId)) - selectRepository(repository.id, false); - ui.modal = { - type: "rollback-confirm", - profileId: target.dataset.profileId, - }; - render(); - } else if (action === "confirm-rollback") - await executeRollback(target.dataset.profileId); - else if (action === "refresh-profile-state") { - if (!repository) repository = profileRepository(target.dataset.profileId); - const profile = repository?.deploymentProfiles?.find( - (item) => item.id === target.dataset.profileId, - ); - setLoading(true, `Checking ${profile?.environment || "environment"}…`); - try { - const state = await window.forgeflow.refreshProfileState( - repository.fullName, - target.dataset.profileId, - ); - profile.state = state; - showToast( - "Environment checked", - state.healthy === false - ? "Healthcheck reports an unhealthy state." - : state.liveSha - ? `Server reports ${shortSha(state.liveSha)}.` - : "Connection checked; no live SHA reported.", - state.healthy === false ? "error" : "success", - ); - } catch (error) { - showToast("Status check failed", error.message, "error"); - } - setLoading(false); - } else if (action === "repair-missing-dockerman") { - const targets = ui.repositories.flatMap((candidate) => - (candidate.deploymentProfiles || []) - .filter( - (profile) => - profile.provider === "ssh-unraid" && - profile.state?.containerRunning && - !dockerManIntegration(profile).ready, - ) - .map((profile) => ({ repository: candidate, profile })), - ); - if (!targets.length) return; - if ( - !confirm( - `Recreate ${targets.length} running container${targets.length === 1 ? "" : "s"} with the missing DockerMan WebUI, icon and template metadata?`, - ) - ) - return; - setLoading(true, "Repairing missing DockerMan integrations…"); - let repaired = 0; - const failures = []; - for (const item of targets) { - try { - await window.forgeflow.applyDockerManMetadata( - item.repository, - item.profile.id, - ); - repaired += 1; - } catch (error) { - failures.push(`${item.repository.name}: ${error.message}`); - } - } - await refreshDeploymentTruth(false); - showToast( - failures.length - ? "DockerMan repair partially completed" - : "DockerMan integrations repaired", - failures.length - ? `${repaired} repaired, ${failures.length} failed.` - : `${repaired} running container${repaired === 1 ? "" : "s"} updated.`, - failures.length ? "error" : "success", - ); - setLoading(false); - } else if (action === "apply-dockerman-metadata") { - if (!repository) repository = profileRepository(target.dataset.profileId); - setLoading( - true, - "Applying DockerMan labels, template, icon and WebUI metadata…", - ); - try { - await window.forgeflow.applyDockerManMetadata( - repository, - target.dataset.profileId, - ); - await refreshRepositories(false); - showToast( - "DockerMan integration repaired", - "The container was recreated with labels, a persistent template, WebUI and icon metadata.", - "success", - ); - } catch (error) { - showToast( - "Could not repair DockerMan integration", - error.message, - "error", - ); - } - setLoading(false); - } else if (action === "reconcile-deployment") { - if (!repository) repository = profileRepository(target.dataset.profileId); - setLoading(true, "Reconciling ForgeFlow with the live Unraid container…"); - try { - await window.forgeflow.reconcileDeployment( - repository.fullName, - target.dataset.profileId, - ); - await refreshActiveOperations(false); - await refreshRepositories(false); - showToast( - "Deployment reconciled", - "Live SHA, container health and operation status were refreshed.", - "success", - ); - } catch (error) { - showToast("Could not reconcile deployment", error.message, "error"); - } - setLoading(false); - } else if (action === "open-profile-webui") - await window.forgeflow.openExternal(target.dataset.url); - else if (action === "open-operation") { - const operation = await window.forgeflow.getOperation( - target.dataset.operationId, - ); - if (operation) { - ui.activeDeployment = operation; - ui.currentView = "deployment-run"; - render(); - startOperationPolling(); - } - } else if (action === "refresh-current-operation") { - setLoading(true, "Refreshing deployment status…"); - try { - const operation = await window.forgeflow.refreshOperations( - ui.activeDeployment.id, - ); - updateOperationInState(operation); - if (!isTerminalOperation(operation.status)) startOperationPolling(); - } catch (error) { - showToast("Status refresh failed", error.message, "error"); - } - setLoading(false); - } else if (action === "open-run-url") - await window.forgeflow.openExternal(ui.activeDeployment.runUrl); - else if (action === "close-deployment") { - stopOperationPolling(); - ui.activeDeployment = null; - ui.currentView = selectedRepository() ? "repository" : "deployments"; - render(); - } else if (action === "setup-run-preflight") - await runSystemPreflight({ setup: true }); - else if (action === "setup-continue") { - if (ui.systemPreflight?.summary?.ready) { - ui.setupStep = 1; - render(); - } - } else if (action === "setup-validate") { - setLoading(true, "Validating Gitea connection…"); - try { - ui.setupValidation = await window.forgeflow.validateGitea(ui.setupDraft); - ui.setupDraft.baseUrl = ui.setupValidation.baseUrl; - ui.setupDraft.user = ui.setupValidation.user; - ui.setupStep = 2; - } catch (error) { - showToast("Connection failed", error.message, "error"); - } - setLoading(false); - } else if (action === "setup-add-root") { - const root = await window.forgeflow.selectDirectory({ - title: "Select a development folder", - }); - if (root && !ui.setupDraft.roots.includes(root)) - ui.setupDraft.roots.push(root); - render(); - } else if (action === "setup-remove-root") { - ui.setupDraft.roots.splice(Number(target.dataset.index), 1); - render(); - } else if (action === "setup-next") { - if (ui.setupStep === 2) { - ui.setupStep = 3; - ui.setupDraft.discovered = []; - render(); - try { - ui.setupDraft.discovered = await window.forgeflow.discoverRepositories( - ui.setupDraft.roots, - ); - } catch (error) { - showToast("Discovery failed", error.message, "error"); - } - ui.setupStep = 4; - render(); - } - } else if (action === "setup-back") { - ui.setupStep = Math.max(0, ui.setupStep - 1); - render(); - } else if (action === "setup-finish") { - setLoading(true, "Saving configuration…"); - try { - const result = await window.forgeflow.completeSetup({ - baseUrl: ui.setupDraft.baseUrl, - token: ui.setupDraft.token, - user: ui.setupDraft.user, - workspaceRoots: ui.setupDraft.roots, - }); - ui.boot.state = result.state; - await refreshRepositories(false); - showToast( - "Setup complete", - result.tokenState.persistent - ? "Your token is stored securely." - : "Your token is available for this session only.", - "success", - ); - } catch (error) { - showToast("Could not complete setup", error.message, "error"); - } - setLoading(false); - } else if (action === "check-updates") { - ui.updateChecking = true; - render(); - try { - ui.updateStatus = await window.forgeflow.checkForUpdates(); - showToast( - ui.updateStatus.available ? "Update available" : "ForgeFlow is current", - ui.updateStatus.available - ? `Version ${ui.updateStatus.remoteVersion} can be downloaded.` - : `Version ${ui.updateStatus.currentVersion} is the newest release.`, - ui.updateStatus.available ? "success" : "info", - ); - } catch (error) { - showToast("Update check failed", error.message, "error"); - } - ui.updateChecking = false; - render(); - } else if (action === "save-update-settings") { - const updates = { - owner: document.querySelector("#update-owner").value.trim(), - repo: document.querySelector("#update-repo").value.trim(), - branch: document.querySelector("#update-branch").value.trim(), - autoCheck: document.querySelector("#update-auto-check").value === "true", - }; - try { - ui.boot.state = await window.forgeflow.setUpdatePreferences(updates); - ui.updateStatus = null; - showToast( - "Update settings saved", - "The next check will use this repository and branch.", - "success", - ); - } catch (error) { - showToast("Could not save update settings", error.message, "error"); - } - render(); - } else if (action === "download-update") { - setLoading(true, "Downloading and verifying the exact ForgeFlow update…"); - try { - ui.updateStatus = await window.forgeflow.downloadUpdate(); - showToast( - "Update downloaded", - `Version ${ui.updateStatus.remoteVersion} passed the integrity check.`, - "success", - ); - } catch (error) { - showToast("Update download failed", error.message, "error"); - } - setLoading(false); - } else if (action === "apply-update") { - if ( - !confirm( - `Apply ForgeFlow ${ui.updateStatus?.remoteVersion || "update"} now? ForgeFlow closes, validates the update and restarts automatically.`, - ) - ) - return; - setLoading(true, "Launching safe updater…"); - try { - await window.forgeflow.applyUpdate(); - showToast( - "Update launched", - "ForgeFlow will close and restart after validation.", - "success", - ); - } catch (error) { - showToast("Could not launch update", error.message, "error"); - setLoading(false); - } - } else if (action === "use-server-password") { - ui.modal = { - type: "server-password", - serverId: target.dataset.serverId, - retry: { type: target.dataset.retry || "scan" }, - }; - render(); - } else if (action === "confirm-server-password") { - const server = (ui.boot?.state?.servers || []).find((item) => item.id === target.dataset.serverId); - const password = document.querySelector("#quick-server-password")?.value || ""; - if (!server || !password) { - showToast("Password required", "Enter the Unraid SSH password.", "error"); - return; - } - const retry = ui.modal?.retry || { type: "scan" }; - setLoading(true, "Switching the server connection to password authentication…"); - try { - const saved = await window.forgeflow.saveServer( - { ...server, authType: "password", privateKeyPath: "" }, - password, - "", - ); - ui.boot.state = saved.state; - const tested = await window.forgeflow.testServer(server.id); - ui.boot.state = tested.state; - ui.modal = null; - showToast("Server password saved", "ForgeFlow will no longer use an SSH key for this server.", "success"); - if (retry.type === "deploy") { - const retryRepository = ui.repositories.find((item) => item.fullName === retry.repositoryFullName); - if (retryRepository) ui.selectedRepoId = retryRepository.id; - await executeDeployment(retry.profileId); - } else { - await refreshDeploymentTruth(true); - } - } catch (error) { - showToast("Server authentication failed", error.message, "error"); - } - setLoading(false); - } else if (action === "open-add-server") { - ui.modal = { - type: "server-config", - serverId: null, - authType: "password", - }; - render(); - } else if (action === "edit-server") { - const server = (ui.boot.state.servers || []).find( - (item) => item.id === target.dataset.serverId, - ); - ui.modal = { - type: "server-config", - serverId: target.dataset.serverId, - authType: server?.authType || "password", - }; - render(); - } else if (action === "select-private-key") { - const keyPath = await window.forgeflow.selectKeyFile({ - title: "Select SSH private key", - defaultPath: - document.querySelector("#server-private-key")?.value || undefined, - }); - if (keyPath) document.querySelector("#server-private-key").value = keyPath; - } else if (action === "save-server") { - const authType = document.querySelector("#server-auth-type").value; - const server = { - id: target.dataset.serverId || undefined, - name: document.querySelector("#server-name").value.trim(), - host: document.querySelector("#server-host").value.trim(), - port: Number(document.querySelector("#server-port").value), - username: document.querySelector("#server-username").value.trim(), - authType, - basePath: document.querySelector("#server-base-path").value.trim(), - scanRoots: document.querySelector("#server-scan-roots").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), - scanExcludes: document.querySelector("#server-scan-excludes").value.split(",").map((value) => value.trim()).filter(Boolean), - privateKeyPath: - document.querySelector("#server-private-key")?.value.trim() || "", - hostFingerprint: document - .querySelector("#server-fingerprint") - .value.trim(), - }; - const password = document.querySelector("#server-password")?.value || ""; - const passphrase = - document.querySelector("#server-passphrase")?.value || ""; - setLoading(true, "Saving encrypted SSH configuration…"); - try { - const result = await window.forgeflow.saveServer( - server, - password, - passphrase, - ); - ui.boot.state = result.state; - ui.modal = null; - showToast( - "Server saved", - "Run Test & trust before creating a deployment.", - "success", - ); - } catch (error) { - showToast("Could not save server", error.message, "error"); - } - setLoading(false); - } else if (action === "test-server") { - setLoading( - true, - "Checking SSH identity, Docker, Compose and optional Git capabilities…", - ); - try { - const result = await window.forgeflow.testServer(target.dataset.serverId); - ui.boot.state = result.state; - const capabilities = result.capabilities || {}; - const deploymentReady = - capabilities.docker && capabilities.dockerReady && capabilities.compose; - showToast( - deploymentReady ? "SSH server ready" : "SSH connected with missing tools", - `${result.server.name} presented ${result.fingerprint}. Docker ${capabilities.dockerReady ? "ready" : "unavailable"}; Compose ${capabilities.compose ? "ready" : "missing"}.`, - deploymentReady ? "success" : "info", - ); - } catch (error) { - showToast("SSH test failed", error.message, "error"); - } - setLoading(false); - } else if (action === "delete-server") { - if ( - !confirm("Delete this server and all deployment profiles linked to it?") - ) - return; - try { - ui.boot.state = await window.forgeflow.deleteServer( - target.dataset.serverId, - ); - ui.modal = null; - await refreshRepositories(false); - showToast( - "Server deleted", - "Linked SSH deployment profiles were removed.", - "success", - ); - } catch (error) { - showToast("Could not delete server", error.message, "error"); - } - } else if (action === "add-root") { - const root = await window.forgeflow.selectDirectory({ - title: "Add development folder", - }); - if (root && !ui.boot.state.workspaceRoots.includes(root)) - ui.boot.state.workspaceRoots.push(root); - render(); - } else if (action === "remove-root") { - ui.boot.state.workspaceRoots.splice(Number(target.dataset.index), 1); - render(); - } else if (action === "save-roots") { - const roots = [...document.querySelectorAll("[data-root-index]")] - .map((input) => input.value.trim()) - .filter(Boolean); - setLoading(true, "Saving workspace folders…"); - try { - ui.boot.state = await window.forgeflow.setWorkspaceRoots(roots); - await refreshRepositories(false); - showToast( - "Folders saved", - "Repository discovery has been refreshed.", - "success", - ); - } catch (error) { - showToast("Could not save folders", error.message, "error"); - } - setLoading(false); - } else if (action === "save-gitea-settings") { - const baseUrl = document.querySelector("#settings-gitea-url").value.trim(); - const token = document.querySelector("#settings-gitea-token").value.trim(); - setLoading(true, "Validating Gitea…"); - try { - const result = await window.forgeflow.updateGitea({ baseUrl, token }); - ui.boot.state = result.state; - await refreshRepositories(false); - showToast( - "Gitea connected", - `Signed in as ${result.validation.user.login}.`, - "success", - ); - } catch (error) { - showToast("Connection failed", error.message, "error"); - } - setLoading(false); - } else if (action === "save-preferences") { - const preferences = { - autoRefresh: - document.querySelector("#pref-auto-refresh").value === "true", - repositoryPollSeconds: Number( - document.querySelector("#pref-repo-poll").value, - ), - operationPollSeconds: Number( - document.querySelector("#pref-operation-poll").value, - ), - preferredCloneProtocol: document.querySelector("#pref-clone-protocol") - .value, - }; - setLoading(true, "Saving background settings…"); - try { - ui.boot.state = await window.forgeflow.setPreferences(preferences); - await refreshRepositories(false); - showToast( - "Settings saved", - "Background awareness has been updated.", - "success", - ); - } catch (error) { - showToast("Could not save settings", error.message, "error"); - } - setLoading(false); - } else if (action === "save-desktop-preferences") { - const splitArgs = (selector) => - document - .querySelector(selector) - .value.split("|") - .map((item) => item.trim()) - .filter(Boolean); - const preferences = { - editor: { - executable: document - .querySelector("#pref-editor-executable") - .value.trim(), - args: splitArgs("#pref-editor-args"), - }, - terminal: { - executable: document - .querySelector("#pref-terminal-executable") - .value.trim(), - args: splitArgs("#pref-terminal-args"), - }, - notificationsEnabled: document.querySelector("#pref-notifications") - .checked, - trayEnabled: document.querySelector("#pref-tray").checked, - closeToTray: document.querySelector("#pref-close-tray").checked, - startAtLogin: document.querySelector("#pref-login").checked, - }; - try { - ui.boot.state = await window.forgeflow.setPreferences(preferences); - showToast( - "Desktop integration saved", - "Editor, terminal, tray and notification settings are active.", - "success", - ); - } catch (error) { - showToast("Could not save desktop integration", error.message, "error"); - } - render(); - } else if ( - action === "export-config-backup" || - action === "import-config-backup" - ) { - const passphrase = document.querySelector("#backup-passphrase").value; - if (passphrase.length < 12) { - showToast("Passphrase too short", "Use at least 12 characters.", "error"); - return; - } - setLoading( - true, - action === "export-config-backup" - ? "Encrypting configuration backup…" - : "Decrypting and validating configuration…", - ); - try { - const result = - action === "export-config-backup" - ? await window.forgeflow.exportConfigurationBackup(passphrase) - : await window.forgeflow.importConfigurationBackup(passphrase); - if (result?.state) { - ui.boot.state = result.state; - await refreshRepositories(false); - } - if (result) - showToast( - action === "export-config-backup" - ? "Encrypted backup created" - : "Configuration restored", - action === "export-config-backup" - ? result.filePath - : `Backup from ${result.exportedAt} imported; credentials were preserved only where already present.`, - "success", - ); - } catch (error) { - showToast("Configuration backup failed", error.message, "error"); - } - setLoading(false); - } else if (action === "repair-origin") { - if (!repository?.localPath || !repository.sshUrl) return; - if ( - !confirm( - `Replace origin with ${repository.sshUrl}? Local files and commits are not changed.`, - ) - ) - return; - setLoading(true, "Updating Git origin…"); - try { - await window.forgeflow.setOrigin(repository.localPath, repository.sshUrl); - await refreshRepositories(false); - showToast("Git origin updated", repository.sshUrl, "success"); - } catch (error) { - showToast("Could not update origin", error.message, "error"); - } - setLoading(false); - } else if (action === "normalize-origins") { - if ( - !confirm( - "Replace legacy origin URLs for every linked repository with the current Gitea SSH URL? Local files and commits are not changed.", - ) - ) - return; - setLoading(true, "Normalizing linked Git origins…"); - try { - const result = await window.forgeflow.normalizeOrigins(); - ui.repositories = result.repositories; - showToast( - "Git origins normalized", - `${result.changes.length} repository origin${result.changes.length === 1 ? "" : "s"} updated.`, - "success", - ); - } catch (error) { - showToast("Could not normalize origins", error.message, "error"); - } - setLoading(false); - render(); - } else if (action === "scan-git-recovery") { - if (!repository?.localPath) return; - setLoading(true, "Scanning Git directory and active processes…"); - try { - ui.gitRecovery = await window.forgeflow.gitRecoveryStatus( - repository.localPath, - ); - ui.repositoryTab = "gittools"; - showToast( - "Git health scan complete", - `${ui.gitRecovery.lockReport.locks.length} lock file(s) found.`, - ui.gitRecovery.lockReport.locks.length ? "info" : "success", - ); - } catch (error) { - showToast("Git health scan failed", error.message, "error"); - } - setLoading(false); - render(); - } else if (action === "repair-git-locks" || action === "repair-index-lock") { - if ( - !repository?.localPath || - !confirm( - "Repair stale Git lock files for this repository? ForgeFlow refuses while a matching Git process is active.", - ) - ) - return; - setLoading(true, "Safely repairing stale Git locks…"); - try { - const result = await window.forgeflow.repairGitLocks( - repository.localPath, - false, - ); - ui.gitRecovery = await window.forgeflow.gitRecoveryStatus( - repository.localPath, - ); - await refreshRepositories(false); - showToast( - "Git locks repaired", - `${result.removed.length} stale lock file(s) removed.`, - "success", - ); - } catch (error) { - if ( - error.code === "GIT_PROCESS_PROBE_UNAVAILABLE" && - confirm(`${error.message} - -Force repair after you have closed all Git tools for this repository?`) - ) { - try { - const result = await window.forgeflow.repairGitLocks( - repository.localPath, - true, - ); - showToast( - "Git locks force-repaired", - `${result.removed.length} lock file(s) removed.`, - "success", - ); - await refreshRepositories(false); - } catch (forceError) { - showToast("Could not repair Git locks", forceError.message, "error"); - } - } else showToast("Could not repair Git locks", error.message, "error"); - } - setLoading(false); - render(); - } else if (action === "reconcile-repository") { - if (!repository?.localPath) return; - setLoading(true, "Refreshing repository truth from Git…"); - try { - ui.gitRecovery = await window.forgeflow.reconcileRepository( - repository.localPath, - ); - await refreshRepositories(false); - showToast( - "Repository reconciled", - "Branch, upstream, lock and working-tree state were refreshed.", - "success", - ); - } catch (error) { - showToast("Could not reconcile repository", error.message, "error"); - } - setLoading(false); - render(); - } else if (action === "repair-repository-sync") { - if (!repository?.localPath) return; - const strategy = target.dataset.strategy; - const destructive = strategy === "backup-reset"; - const message = destructive - ? "Create a safety branch from the current HEAD and reset this branch to its upstream? Uncommitted changes are never discarded." - : `Run the repository-specific ${strategy} repair now?`; - if (!confirm(message)) return; - setLoading( - true, - destructive - ? "Creating safety branch and repairing divergence…" - : "Repairing repository synchronization…", - ); - try { - const result = await window.forgeflow.repairRepositorySync( - repository.localPath, - strategy, - ); - ui.gitRecovery = await window.forgeflow.gitRecoveryStatus( - repository.localPath, - ); - await refreshRepositories(false); - showToast( - "Repository synchronization repaired", - result.backupBranch - ? `Safety branch created: ${result.backupBranch}` - : `Completed ${strategy}.`, - "success", - ); - } catch (error) { - showToast("Synchronization repair failed", error.message, "error"); - } - setLoading(false); - render(); - } else if (action === "run-troubleshooter") { - setLoading( - true, - "Scanning repositories, Git operations and deployment servers…", - ); - try { - ui.troubleshooter = await window.forgeflow.troubleshooterScan(); - showToast( - "Troubleshooter completed", - ui.troubleshooter.summary.total - ? `${ui.troubleshooter.summary.total} issue(s) found; ${ui.troubleshooter.summary.repairable} repairable.` - : "No problems were detected.", - ui.troubleshooter.summary.errors ? "error" : "success", - ); - } catch (error) { - showToast("Troubleshooter failed", error.message, "error"); - } - setLoading(false); - render(); - } else if (action === "troubleshooter-auto-repair") { - const safeIssues = (ui.troubleshooter?.issues || []).filter( - (item) => item.repairable && item.safe, - ); - if ( - !safeIssues.length || - !confirm( - `Repair ${safeIssues.length} safe issue(s) now? ForgeFlow will not run destructive reset actions automatically.`, - ) - ) - return; - setLoading(true, "Applying safe one-click repairs…"); - try { - const results = - await window.forgeflow.troubleshooterAutoRepair(safeIssues); - ui.troubleshooter = await window.forgeflow.troubleshooterScan(); - await refreshRepositories(false); - const failed = results.filter((item) => !item.ok); - showToast( - failed.length - ? "Repairs partially completed" - : "Safe repairs completed", - `${results.length - failed.length} repaired, ${failed.length} failed.`, - failed.length ? "error" : "success", - ); - } catch (error) { - showToast("Automatic repair failed", error.message, "error"); - } - setLoading(false); - render(); - } else if (action === "troubleshooter-repair") { - const issue = - ui.troubleshooter?.issues?.[Number(target.dataset.issueIndex)]; - if (!issue) return; - const warning = issue.safe - ? `Repair “${issue.title}” now?` - : `“${issue.title}” requires a safety branch or another potentially destructive change. Continue?`; - if (!confirm(warning)) return; - setLoading(true, `Repairing ${issue.title}…`); - try { - const result = await window.forgeflow.troubleshooterRepair(issue); - ui.troubleshooter = await window.forgeflow.troubleshooterScan(); - await refreshRepositories(false); - showToast( - "Problem repaired", - result?.backupBranch - ? `Safety branch created: ${result.backupBranch}` - : issue.title, - "success", - ); - } catch (error) { - showToast("Repair failed", error.message, "error"); - } - setLoading(false); - render(); - } else if (action === "run-system-preflight") await runSystemPreflight(); - else if (action === "load-audit-log") { - try { - ui.auditEvents = await window.forgeflow.listAuditEvents(500); - render(); - } catch (error) { - showToast("Could not load audit log", error.message, "error"); - } - } else if (action === "export-audit-json" || action === "export-audit-csv") { - try { - const result = await window.forgeflow.exportAuditLog( - action.endsWith("csv") ? "csv" : "json", - ); - if (result) - showToast( - "Audit log exported", - `${result.count} records exported.`, - "success", - ); - } catch (error) { - showToast("Could not export audit log", error.message, "error"); - } - } else if (action === "save-diagnostics-preferences") { - const preferences = { - diagnosticsEnabled: - document.querySelector("#diagnostics-enabled").value === "true", - diagnosticLevel: document.querySelector("#diagnostic-level").value, - logRetentionDays: Number( - document.querySelector("#diagnostic-retention").value, - ), - maxLogFileMb: Number( - document.querySelector("#diagnostic-max-file").value, - ), - }; - setLoading(true, "Saving diagnostic policy…"); - try { - ui.boot.state = await window.forgeflow.setPreferences(preferences); - ui.diagnosticsStatus = await window.forgeflow.diagnosticsStatus(); - showToast( - "Diagnostic policy saved", - "New events now use the updated retention and logging level.", - "success", - ); - } catch (error) { - showToast("Could not save diagnostics", error.message, "error"); - } - setLoading(false); - } else if (action === "export-diagnostics") { - const privacyMode = - document.querySelector("#diagnostic-privacy")?.value || "standard"; - setLoading(true, "Creating redacted diagnostic bundle…"); - try { - const bundle = await window.forgeflow.exportDiagnostics(privacyMode); - if (bundle) { - ui.lastDiagnosticBundle = bundle; - ui.diagnosticsStatus = await window.forgeflow.diagnosticsStatus(); - showToast( - "Diagnostic bundle created", - `${bundle.size} · SHA-256 ${shortSha(bundle.sha256)}`, - "success", - ); - } - } catch (error) { - showToast("Could not export diagnostics", error.message, "error"); - } - setLoading(false); - } else if (action === "show-diagnostic-bundle") { - if (!ui.lastDiagnosticBundle?.path) return; - await window.forgeflow - .showDiagnosticBundle(ui.lastDiagnosticBundle.path) - .catch((error) => - showToast("Could not show bundle", error.message, "error"), - ); - } else if (action === "open-diagnostics-folder") - await window.forgeflow - .openDiagnosticsFolder() - .catch((error) => - showToast("Could not open diagnostic folder", error.message, "error"), - ); - else if (action === "clear-diagnostics") { - if ( - !confirm( - "Clear local ForgeFlow diagnostic logs? This does not affect repositories or configuration.", - ) - ) - return; - try { - ui.diagnosticsStatus = await window.forgeflow.clearDiagnostics(); - showToast( - "Diagnostic logs cleared", - "A new session marker was created.", - "success", - ); - render(); - } catch (error) { - showToast("Could not clear logs", error.message, "error"); - } - } else if (action === "reset-app") { - if ( - !confirm( - "Reset ForgeFlow configuration? Your Git repositories and Gitea data are not modified.", - ) - ) - return; - ui.boot.state = await window.forgeflow.reset(); - ui.repositories = []; - ui.setupStep = 0; - ui.setupValidation = null; - ui.systemPreflight = null; - ui.deploymentPreflight = null; - ui.lastDiagnosticBundle = null; - ui.setupDraft = { - baseUrl: "https://", - token: "", - user: null, - roots: [], - discovered: [], - }; - render(); - } else if (action === "run-command") { - const command = target.dataset.command; - ui.modal = null; - if (command === "overview") ui.currentView = "overview"; - else if (command === "deployments") ui.currentView = "deployments"; - else if (command === "diagnostics") ui.currentView = "diagnostics"; - else if (command === "settings") ui.currentView = "settings"; - else if (command === "refresh") await refreshRepositories(true); - else if (command === "open-folder" && repository?.localPath) - await window.forgeflow.openPath(repository.localPath); - else if (command === "git-tools" && repository) - await loadGitTools(repository); - else if (command === "deploy-selected" && canDeploy(repository)) { - const profile = selectedProfile(repository); - if (profile) await runDeploymentPreflight(repository, profile.id); - } - render(); - } -}); - -app.addEventListener("input", (event) => { - if (event.target.id === "global-search") { - ui.search = event.target.value; - render(); - document.querySelector("#global-search")?.focus(); - } else if (event.target.id === "repo-filter") { - ui.repoSearch = event.target.value; - render(); - document.querySelector("#repo-filter")?.focus(); - } else if (event.target.id === "commit-message") { - ui.commitMessage = event.target.value; - const position = event.target.selectionStart; - render(); - const next = document.querySelector("#commit-message"); - if (next) { - next.focus(); - next.setSelectionRange(position, position); - } - } else if (event.target.id === "setup-url") - ui.setupDraft.baseUrl = event.target.value; - else if (event.target.id === "setup-token") - ui.setupDraft.token = event.target.value; - else if (event.target.id === "palette-input") { - ui.paletteQuery = event.target.value; - render(); - } -}); - -app.addEventListener("change", async (event) => { - if (event.target.matches("[data-file-select]")) { - const filePath = event.target.dataset.fileSelect; - if (event.target.checked) ui.selectedFiles.add(filePath); - else ui.selectedFiles.delete(filePath); - render(); - } else if (event.target.id === "appearance-select") { - ui.boot.state = await window.forgeflow.setAppearance(event.target.value); - applyTheme(event.target.value); - render(); - } else if (event.target.id === "action-profile-select") { - ui.selectedProfileId = event.target.value; - render(); - } else if (event.target.id === "profile-provider") { - ui.modal.provider = event.target.value; - render(); - } else if (event.target.id === "server-auth-type") { - ui.modal.authType = event.target.value; - render(); - } -}); - -document.addEventListener("keydown", (event) => { - if ( - (event.key === "Enter" || event.key === " ") && - event.target.matches('.file-row[data-action="select-file"]') - ) { - event.preventDefault(); - event.target.click(); - return; - } - if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") { - event.preventDefault(); - ui.paletteQuery = ""; - ui.modal = { type: "command-palette" }; - render(); - return; - } - if ( - (event.ctrlKey || event.metaKey) && - event.key === "Enter" && - ui.currentView === "repository" - ) { - const button = document.querySelector( - '[data-action="commit-push"]:not(:disabled)', - ); - if (button) button.click(); - } - if (event.key === "F5") { - event.preventDefault(); - refreshRepositories(true); - } - if (event.key === "Escape" && ui.modal) { - ui.modal = null; - render(); - } -}); - -document.addEventListener("pointermove", (event) => { - const illustration = event.target.closest?.("[data-project-illustration]"); - if (illustration) { - const bounds = illustration.getBoundingClientRect(); - illustration.style.setProperty( - "--tilt-x", - `${((event.clientY - bounds.top) / bounds.height - 0.5) * -7}deg`, - ); - illustration.style.setProperty( - "--tilt-y", - `${((event.clientX - bounds.left) / bounds.width - 0.5) * 9}deg`, - ); - } - - const diffPanel = event.target.closest?.(".diff-panel"); - const atmosphere = diffPanel?.querySelector("[data-diff-atmosphere]"); - if (atmosphere) { - const bounds = diffPanel.getBoundingClientRect(); - atmosphere.style.setProperty( - "--diff-tilt-x", - `${((event.clientY - bounds.top) / bounds.height - 0.5) * -3}deg`, - ); - atmosphere.style.setProperty( - "--diff-tilt-y", - `${((event.clientX - bounds.left) / bounds.width - 0.5) * 4}deg`, - ); - } -}); - -document.addEventListener("pointerout", (event) => { - const illustration = event.target.closest?.("[data-project-illustration]"); - if (illustration && !illustration.contains(event.relatedTarget)) { - illustration.style.removeProperty("--tilt-x"); - illustration.style.removeProperty("--tilt-y"); - } - - const diffPanel = event.target.closest?.(".diff-panel"); - if (diffPanel && !diffPanel.contains(event.relatedTarget)) { - const atmosphere = diffPanel.querySelector("[data-diff-atmosphere]"); - atmosphere?.style.removeProperty("--diff-tilt-x"); - atmosphere?.style.removeProperty("--diff-tilt-y"); - } -}); - -window.addEventListener("error", (event) => { - window.forgeflow - .reportRendererEvent?.("error", "uncaught-error", { - message: event.message, - filename: event.filename, - line: event.lineno, - column: event.colno, - stack: event.error?.stack, - }) - .catch(() => {}); -}); - -window.addEventListener("unhandledrejection", (event) => { - const reason = event.reason; - window.forgeflow - .reportRendererEvent?.("error", "unhandled-rejection", { - message: reason?.message || String(reason || "Unknown rejection"), - stack: reason?.stack, - }) - .catch(() => {}); -}); - -bootstrap(); diff --git a/src/renderer/dialogs.js b/src/renderer/dialogs.js new file mode 100644 index 0000000..9c685ab --- /dev/null +++ b/src/renderer/dialogs.js @@ -0,0 +1,319 @@ +function renderModal() { + if (!ui.modal) return ""; + const repository = + selectedRepository() || + ui.repositories.find( + (repo) => repo.fullName === ui.modal.repositoryFullName, + ); + if (ui.modal.type === "server-password") { + const server = (ui.boot?.state?.servers || []).find((item) => item.id === ui.modal.serverId); + if (!server) return ``; + const retryText = ui.modal.retry?.type === "deploy" ? "Save password & redeploy" : "Save password & rescan"; + return ``; + } + if (ui.modal.type === "server-reconciliation-plan") { + const plan = ui.modal.result?.plan || {}; + const summary = plan.summary || {}; + const rows = [ + ...(plan.additions || []).map((item) => ({ tone: "success", title: `Link ${item.repositoryFullName}`, detail: `${item.evidence} · ${item.impact}` })), + ...(plan.updates || []).map((item) => ({ tone: "", title: `Refresh ${item.repositoryFullName}`, detail: item.impact })), + ...(plan.stale || []).map((item) => ({ tone: "warning", title: `Review stale link ${item.repositoryFullName}`, detail: `${item.reason} · no automatic removal` })), + ...(plan.conflicts || []).map((item) => ({ tone: "danger", title: `Manual review: ${item.displayName}`, detail: `${item.status} · ${(item.candidates || []).map((candidate) => candidate.repositoryFullName).join(", ") || "no unique repository"}` })), + ]; + return ``; + } + if (ui.modal.type === "workload-link") { + const serverResult = (ui.serverDiscovery || []).find( + (item) => item.serverId === ui.modal.serverId, + ); + const workload = serverResult?.workloads?.find( + (item) => item.workloadId === ui.modal.workloadId, + ); + if (!workload) { + return ``; + } + const availableRepositories = ui.repositories.filter((item) => item.fullName); + const suggestedRepository = + ui.modal.repositoryFullName || + workload.candidates?.[0]?.repositoryFullName || + selectedRepository()?.fullName || + availableRepositories[0]?.fullName || + ""; + const selectedLinkRepository = availableRepositories.find( + (item) => item.fullName === suggestedRepository, + ); + const remoteFolder = + ui.modal.remoteFolder || + workload.remoteFolderCandidate || + safeCloneFolderName(selectedLinkRepository); + const candidateSummary = workload.candidates?.length + ? workload.candidates + .slice(0, 4) + .map( + (candidate) => + `
${escapeHtml(candidate.repositoryFullName)}${escapeHtml(candidate.exact ? "Exact provenance" : `${candidate.score} confidence`)} · ${escapeHtml((candidate.reasons || []).join(", ") || "name similarity")}
`, + ) + .join("") + : '
Repository candidatesNo confident match; choose manually.
'; + const containerNames = (workload.containers || []) + .map((container) => container.name) + .filter(Boolean) + .join(", "); + return ``; + } + if (ui.modal.type === "deployment-config") { + const storedProfile = + repository?.deploymentProfiles?.find( + (profile) => profile.id === ui.modal.profileId, + ) || {}; + const discovery = + ui.deploymentDiscovery?.repository === repository?.fullName + ? ui.deploymentDiscovery + : null; + const existing = { ...storedProfile, ...(discovery?.profile || {}) }; + if (discovery?.provenance) existing.provenance = discovery.provenance; + const servers = ui.boot.state.servers || []; + const provider = + ui.modal.provider || + existing.provider || + (servers.length ? "ssh-unraid" : "gitea-actions"); + const ssh = provider === "ssh-unraid"; + const remoteFolder = + existing.remoteFolder || safeCloneFolderName(repository); + return ``; + } + if (ui.modal.type === "inventory-review-plan") { + const plan = ui.inventoryReviewPlan; + return ``; + } + if (ui.modal.type === "deploy-key-lifecycle") { + const lifecycle = ui.deployKeyLifecycle; + const inventory = lifecycle?.inventory; + const rotation = lifecycle?.rotation; + const revocation = lifecycle?.revocation; + return ``; + } + if (ui.modal.type === "deployment-preflight") { + const profile = + repository?.deploymentProfiles?.find( + (item) => item.id === ui.modal.profileId, + ) || selectedProfile(repository); + const report = ui.deploymentPreflight; + return ``; + } + if (ui.modal.type === "deploy-confirm") { + const profile = + repository?.deploymentProfiles?.find( + (item) => item.id === ui.modal.profileId, + ) || selectedProfile(repository); + const targetSha = deploymentTargetSha(repository, profile); + return ``; + } + if (ui.modal.type === "rollback-confirm") { + const profile = repository?.deploymentProfiles?.find( + (item) => item.id === ui.modal.profileId, + ); + const target = profile?.state?.previousSha; + return ``; + } + if (ui.modal.type === "server-config") { + const server = + (ui.boot.state.servers || []).find( + (item) => item.id === ui.modal.serverId, + ) || {}; + const authType = ui.modal.authType || server.authType || "password"; + return ``; + } + if (ui.modal.type === "hunk-staging") { + const hunks = ui.diffHunks?.hunks || []; + return ``; + } + if (ui.modal.type === "pull-request") { + return ``; + } + if (ui.modal.type === "conflict-guide") { + const state = ui.conflictState || {}; + return ``; + } + if (ui.modal.type === "command-palette") return renderCommandPalette(); + return ""; +} + +function paletteCommands() { + const repository = selectedRepository(); + return [ + { + id: "overview", + label: "Go to release overview", + detail: "Workspace", + icon: "overview", + enabled: true, + }, + { + id: "refresh", + label: "Refresh all repositories", + detail: "Local and Gitea", + icon: "refresh", + enabled: true, + }, + { + id: "deployments", + label: "Open deployments", + detail: "Release history", + icon: "deploy", + enabled: true, + }, + { + id: "diagnostics", + label: "Open diagnostics", + detail: "Logs, preflight and support bundle", + icon: "shield", + enabled: true, + }, + { + id: "settings", + label: "Open settings", + detail: "Connections and awareness", + icon: "settings", + enabled: true, + }, + { + id: "open-folder", + label: "Open selected project folder", + detail: repository?.name || "No repository selected", + icon: "folder", + enabled: Boolean(repository?.localPath), + }, + { + id: "git-tools", + label: "Open branch and stash tools", + detail: repository?.name || "No repository selected", + icon: "branch", + enabled: Boolean(repository?.localPath), + }, + { + id: "deploy-selected", + label: "Deploy selected repository", + detail: canDeploy(repository) + ? `${repository.name} ${shortSha(deploymentTargetSha(repository))}` + : "Not ready", + icon: "rocket", + enabled: canDeploy(repository), + }, + ]; +} +function renderCommandPalette() { + const query = ui.paletteQuery.toLowerCase(); + const commands = paletteCommands().filter( + (command) => + !query || + `${command.label} ${command.detail}`.toLowerCase().includes(query), + ); + return ``; +} + +function enhanceRenderedUi() { + const repository = selectedRepository(); + if (ui.modal?.type === "deployment-config") { + const profile = + repository?.deploymentProfiles?.find( + (item) => item.id === ui.modal.profileId, + ) || {}; + const policy = profile.deploymentPolicy || {}; + document + .querySelector(".modal-body .form-grid") + ?.insertAdjacentHTML( + "beforeend", + `

Deployment policy

Day 0 is Sunday. Separate windows with |.
`, + ); + } + if (ui.modal?.type === "workload-link") { + const workload = (ui.serverDiscovery || []).find((server) => server.serverId === ui.modal.serverId)?.workloads?.find((item) => item.workloadId === ui.modal.workloadId); + const type = workload?.classification?.type || "ambiguous"; + const recommended = type === "duplicate" ? "select-authoritative" : type === "stale-link" ? "archive-link" : type === "historical-compose" ? "mark-historical" : type === "orphan-container" ? "monitor-only" : "manual-link"; + const actions = [["manual-link", "Confirm selected repository match"], ["select-authoritative", "Select as authoritative instance"], ["mark-historical", "Mark historical definition"], ["archive-link", "Archive stale link"], ["monitor-only", "Keep for monitoring only"], ["manual-exclude", "Exclude this workload"], ["ignore", "Ignore with reason"]]; + const options = actions.map(([value, label]) => ``).join(""); + document.querySelector(".modal-body")?.insertAdjacentHTML("beforeend", `

Classify without touching containers

${icon("info")}
${escapeHtml(type)}

${escapeHtml(workload?.classification?.reason || "ForgeFlow needs an explicit decision for this workload.")}

The decision is tied to current evidence and becomes stale automatically when server truth changes.

`); + } + if (ui.modal?.type === "deploy-confirm") { + const profile = repository?.deploymentProfiles?.find( + (item) => item.id === ui.modal.profileId, + ); + document + .querySelector(".modal-body") + ?.insertAdjacentHTML( + "beforeend", + `
`, + ); + } + if (ui.currentView === "diagnostics") { + const container = document.querySelector(".diagnostics-page"); + container?.insertAdjacentHTML( + "beforeend", + `

Operational audit log

Append-only release, pull-request and recovery events
${ui.auditEvents.length ? `${ui.auditEvents.map((item) => ``).join("")}
TimeEventRepositoryResult
${formatDate(item.timestamp)}${escapeHtml(item.event)}${escapeHtml(item.details?.repository || "—")}${escapeHtml(item.details?.result || item.details?.note || "—")}
` : '

Load the operational audit log.

'}
`, + ); + } + document.querySelectorAll("button.icon-button:not([aria-label])").forEach((button) => { + const action = String(button.title || button.dataset.action || "Action").replaceAll("-", " "); + button.setAttribute("aria-label", action.charAt(0).toUpperCase() + action.slice(1)); + }); + document.querySelectorAll(".field > label:not([for])").forEach((label, index) => { + const control = label.parentElement?.querySelector("input, select, textarea"); + if (!control) return; + if (!control.id) control.id = `forgeflow-field-${index}`; + label.htmlFor = control.id; + }); +} + +function render() { + if (!ui.boot) return; + const repository = selectedRepository(); + const main = + ui.currentView === "overview" + ? renderOverview() + : ui.currentView === "deployments" + ? renderDeployments() + : ui.currentView === "settings" + ? renderSettings() + : ui.currentView === "diagnostics" + ? renderDiagnostics() + : ui.currentView === "deployment-run" + ? renderPipelineView() + : repository + ? renderRepositoryWorkspace(repository) + : renderOverview(); + const withPanel = ui.currentView === "repository" && repository; + app.innerHTML = `
${renderTitlebar()}
${renderSidebar()}
${main}
${withPanel ? renderActionPanel(repository) : ""}${ui.loading ? `
${escapeHtml(ui.loadingMessage || "Working…")}
` : ""}
${renderStatusbar()}
${ui.boot.state.setupComplete ? "" : renderSetup()}${renderModal()}`; + enhanceRenderedUi(); + if (ui.modal?.type === "command-palette") + requestAnimationFrame(() => + document.querySelector("#palette-input")?.focus(), + ); +} + diff --git a/src/renderer/events.js b/src/renderer/events.js new file mode 100644 index 0000000..689c5c9 --- /dev/null +++ b/src/renderer/events.js @@ -0,0 +1,169 @@ +app.addEventListener("click", async (event) => { + const target = event.target.closest("[data-action]"); + if (!target) return; + const action = target.dataset.action; + let repository = selectedRepository(); + if (target.dataset.repositoryId) { + const actionRepository = ui.repositories.find( + (item) => String(item.id) === String(target.dataset.repositoryId), + ); + if (actionRepository) repository = actionRepository; + } + + const handlers = [handleShellActions, handleInventoryActions, handleDeploymentProfileActions, handleDeploymentOperationActions, handleSetupAndSettingsActions, handleRecoveryActions, handleCommandActions]; + for (const handler of handlers) if (await handler(event, target, action, repository)) return; +}); + +app.addEventListener("input", (event) => { + if (event.target.id === "global-search") { + ui.search = event.target.value; + render(); + document.querySelector("#global-search")?.focus(); + } else if (event.target.id === "repo-filter") { + ui.repoSearch = event.target.value; + render(); + document.querySelector("#repo-filter")?.focus(); + } else if (event.target.id === "commit-message") { + ui.commitMessage = event.target.value; + const position = event.target.selectionStart; + render(); + const next = document.querySelector("#commit-message"); + if (next) { + next.focus(); + next.setSelectionRange(position, position); + } + } else if (event.target.id === "setup-url") + ui.setupDraft.baseUrl = event.target.value; + else if (event.target.id === "setup-token") + ui.setupDraft.token = event.target.value; + else if (event.target.id === "palette-input") { + ui.paletteQuery = event.target.value; + render(); + } +}); + +app.addEventListener("change", async (event) => { + if (event.target.matches("[data-file-select]")) { + const filePath = event.target.dataset.fileSelect; + if (event.target.checked) ui.selectedFiles.add(filePath); + else ui.selectedFiles.delete(filePath); + render(); + } else if (event.target.id === "appearance-select") { + ui.boot.state = await window.forgeflow.setAppearance(event.target.value); + applyTheme(event.target.value); + render(); + } else if (event.target.id === "action-profile-select") { + ui.selectedProfileId = event.target.value; + render(); + } else if (event.target.id === "profile-provider") { + ui.modal.provider = event.target.value; + render(); + } else if (event.target.id === "server-auth-type") { + ui.modal.authType = event.target.value; + render(); + } +}); + +document.addEventListener("keydown", (event) => { + if ( + (event.key === "Enter" || event.key === " ") && + event.target.matches('.file-row[data-action="select-file"]') + ) { + event.preventDefault(); + event.target.click(); + return; + } + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") { + event.preventDefault(); + ui.paletteQuery = ""; + ui.modal = { type: "command-palette" }; + render(); + return; + } + if ( + (event.ctrlKey || event.metaKey) && + event.key === "Enter" && + ui.currentView === "repository" + ) { + const button = document.querySelector( + '[data-action="commit-push"]:not(:disabled)', + ); + if (button) button.click(); + } + if (event.key === "F5") { + event.preventDefault(); + refreshRepositories(true); + } + if (event.key === "Escape" && ui.modal) { + ui.modal = null; + render(); + } +}); + +document.addEventListener("pointermove", (event) => { + const illustration = event.target.closest?.("[data-project-illustration]"); + if (illustration) { + const bounds = illustration.getBoundingClientRect(); + illustration.style.setProperty( + "--tilt-x", + `${((event.clientY - bounds.top) / bounds.height - 0.5) * -7}deg`, + ); + illustration.style.setProperty( + "--tilt-y", + `${((event.clientX - bounds.left) / bounds.width - 0.5) * 9}deg`, + ); + } + + const diffPanel = event.target.closest?.(".diff-panel"); + const atmosphere = diffPanel?.querySelector("[data-diff-atmosphere]"); + if (atmosphere) { + const bounds = diffPanel.getBoundingClientRect(); + atmosphere.style.setProperty( + "--diff-tilt-x", + `${((event.clientY - bounds.top) / bounds.height - 0.5) * -3}deg`, + ); + atmosphere.style.setProperty( + "--diff-tilt-y", + `${((event.clientX - bounds.left) / bounds.width - 0.5) * 4}deg`, + ); + } +}); + +document.addEventListener("pointerout", (event) => { + const illustration = event.target.closest?.("[data-project-illustration]"); + if (illustration && !illustration.contains(event.relatedTarget)) { + illustration.style.removeProperty("--tilt-x"); + illustration.style.removeProperty("--tilt-y"); + } + + const diffPanel = event.target.closest?.(".diff-panel"); + if (diffPanel && !diffPanel.contains(event.relatedTarget)) { + const atmosphere = diffPanel.querySelector("[data-diff-atmosphere]"); + atmosphere?.style.removeProperty("--diff-tilt-x"); + atmosphere?.style.removeProperty("--diff-tilt-y"); + } +}); + +window.addEventListener("error", (event) => { + window.forgeflow + .reportRendererEvent?.("error", "uncaught-error", { + message: event.message, + filename: event.filename, + line: event.lineno, + column: event.colno, + stack: event.error?.stack, + }) + .catch(() => {}); +}); + +window.addEventListener("unhandledrejection", (event) => { + const reason = event.reason; + window.forgeflow + .reportRendererEvent?.("error", "unhandled-rejection", { + message: reason?.message || String(reason || "Unknown rejection"), + stack: reason?.stack, + }) + .catch(() => {}); +}); + +bootstrap(); diff --git a/src/renderer/index.html b/src/renderer/index.html index 4f876c6..510f1c1 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -17,7 +17,20 @@
+ + + + + + + + + + + + + diff --git a/src/renderer/mock-bridge.js b/src/renderer/mock-bridge.js index fd243c2..434dd85 100644 --- a/src/renderer/mock-bridge.js +++ b/src/renderer/mock-bridge.js @@ -581,1291 +581,9 @@ return operation; } + const bridgeContext = { wait, clone, iso, storage, repositoryListeners, operationListeners, updateListeners, emitRepositories, emitOperations, randomSha, now, profile, state, repositories, recompute, snapshot, commitHistory, advanceOperation, syncState, diffs, findRepo, findProfileRepo, branchesByRepo, stashesByRepo, updateOperation }; window.forgeflow = Object.freeze({ - async bootstrap() { - await wait(80); - snapshot(); - return { - appVersion: "0.10.0-demo", - platform: "win32", - state: clone(state), - git: { available: true, version: "git version 2.47.3" }, - diagnostics: { - enabled: true, - level: state.preferences.diagnosticLevel, - retentionDays: state.preferences.logRetentionDays, - maxFileMb: state.preferences.maxLogFileMb, - directory: "/AppData/Roaming/ForgeFlow/diagnostics", - fileCount: 2, - totalBytes: 18432, - totalSize: "18.0 KB", - latestAt: iso(-2000), - lastWriteError: null, - }, - }; - }, - async selectDirectory() { - await wait(); - return "C:\\Development"; - }, - async selectKeyFile() { - await wait(); - return "C:\\Users\\Jens\\.ssh\\id_ed25519"; - }, - async setupPreflight({ baseUrl, token, roots = [] }) { - await wait(240); - const checks = [ - { - id: "git.available", - label: "Git command line", - status: "pass", - detail: "git version 2.47.3", - required: true, - }, - { - id: "git.identity", - label: "Git author identity", - status: "pass", - detail: "Jens ", - required: false, - }, - { - id: "storage.userdata", - label: "Application data storage", - status: "pass", - detail: "ForgeFlow can write its local configuration.", - required: true, - }, - { - id: "storage.diagnostics", - label: "Diagnostic log storage", - status: "pass", - detail: "The diagnostic directory is writable.", - required: true, - }, - { - id: "storage.credentials", - label: "Protected credential storage", - status: "pass", - detail: "The operating system can encrypt the Gitea token at rest.", - required: false, - }, - { - id: "workspace.roots", - label: "Development folders", - status: roots.length ? "pass" : "warning", - detail: roots.length - ? `${roots.length} folder(s) selected.` - : "No development folder selected yet.", - required: false, - }, - { - id: "gitea.connection", - label: "Gitea connection", - status: baseUrl && token ? "pass" : "warning", - detail: - baseUrl && token - ? "Connection parameters are ready for validation." - : "Enter the Gitea URL and token.", - required: false, - }, - ]; - return { - kind: "system", - startedAt: iso(-100), - completedAt: iso(), - checks, - summary: { - counts: { - pass: checks.filter((i) => i.status === "pass").length, - warning: checks.filter((i) => i.status === "warning").length, - fail: 0, - skipped: 0, - }, - blocking: [], - ready: true, - }, - }; - }, - async validateGitea({ baseUrl, token }) { - await wait(320); - if (!baseUrl || !token) - throw new Error("Enter an instance URL and access token."); - return { - baseUrl: baseUrl.replace(/\/$/, ""), - user: { login: "jens", full_name: "Jens" }, - repositoryCount: repositories.length, - version: "1.26.0", - }; - }, - async completeSetup(payload) { - await wait(300); - state.setupComplete = true; - state.gitea = { - baseUrl: payload.baseUrl, - user: payload.user, - hasToken: true, - }; - state.workspaceRoots = payload.workspaceRoots; - storage.set("forgeflow-demo-setup", "true"); - return { state: clone(state), tokenState: { persistent: true } }; - }, - async updateGitea(payload) { - const validation = await this.validateGitea({ - ...payload, - token: payload.token || "preserved-demo-token", - }); - state.gitea = { - baseUrl: validation.baseUrl, - user: validation.user, - hasToken: true, - }; - return { - validation, - tokenState: { persistent: true, preserved: !payload.token }, - state: clone(state), - }; - }, - async setWorkspaceRoots(roots) { - state.workspaceRoots = [...new Set(roots)]; - return clone(state); - }, - async setAppearance(appearance) { - state.appearance = appearance; - storage.set("forgeflow-theme", appearance); - return clone(state); - }, - async setPreferences(preferences) { - state.preferences = { ...state.preferences, ...preferences }; - snapshot(); - return clone(state); - }, - async setUpdatePreferences(updates) { - state.updates = { ...state.updates, ...updates }; - return clone(state); - }, - async checkForUpdates() { - await wait(300); - return { - checkedAt: iso(), - owner: state.updates.owner, - repo: state.updates.repo, - branch: state.updates.branch, - currentVersion: "0.5.4", - remoteVersion: "0.6.0", - remoteSha: "a".repeat(40), - shortSha: "aaaaaaa", - available: true, - mode: "source", - }; - }, - async downloadUpdate() { - await wait(500); - return { - ...(await this.checkForUpdates()), - downloaded: true, - archivePath: "C:\\Temp\\ForgeFlow-0.4.1.zip", - sha256: "b".repeat(64), - }; - }, - async applyUpdate() { - await wait(200); - return { launched: true, confirmed: true, version: "0.6.0" }; - }, - async saveServer(server) { - const saved = { - ...server, - id: server.id || `server-${Date.now()}`, - hasPassword: server.authType === "password", - hasPassphrase: false, - }; - state.servers = [ - saved, - ...state.servers.filter((item) => item.id !== saved.id), - ]; - return { server: clone(saved), state: clone(state) }; - }, - async deleteServer(serverId) { - state.servers = state.servers.filter((item) => item.id !== serverId); - return clone(state); - }, - async testServer(serverId) { - const server = state.servers.find((item) => item.id === serverId); - server.hostFingerprint = server.hostFingerprint || "SHA256:demo"; - return { - connected: true, - fingerprint: server.hostFingerprint, - server: clone(server), - output: "Linux\n/usr/bin/git\nDocker Compose version v2", - state: clone(state), - }; - }, - async inspectServerProject() { - return { - exists: true, - rootGit: true, - head: "d42d4a7".padEnd(40, "0"), - branch: "main", - trackedChanges: [], - composeFiles: ["docker-compose.yml"], - nestedGit: ["source"], - dockerfile: true, - }; - }, - async refreshRepositories() { - await wait(260); - return snapshot(); - }, - async discoverRepositories() { - await wait(360); - return snapshot() - .filter((repo) => repo.localPath) - .map((repo) => ({ - localPath: repo.localPath, - remoteUrl: repo.cloneUrl, - status: repo.localStatus, - })); - }, - async favoriteRepository(fullName, favorite) { - const key = fullName.toLowerCase(); - state.favorites = favorite - ? [...new Set([...state.favorites, key])] - : state.favorites.filter((item) => item !== key); - snapshot(); - return clone(state); - }, - async linkRepository(fullName, localPath) { - const repo = repositories.find((item) => item.fullName === fullName); - repo.localPath = localPath; - repo.linkState = "linked"; - repo.localStatus = makeStatus({ head: randomSha() }); - emitRepositories(); - return snapshot(); - }, - async unlinkRepository(fullName) { - const repo = repositories.find((item) => item.fullName === fullName); - repo.localPath = null; - repo.localStatus = null; - repo.linkState = "remote-only"; - emitRepositories(); - return snapshot(); - }, - async repositoryStatus(localPath) { - return clone(findRepo(localPath)?.localStatus); - }, - async repositoryDiff(localPath, filePath) { - await wait(80); - return ( - diffs[filePath] || - `diff --git a/${filePath} b/${filePath}\n--- a/${filePath}\n+++ b/${filePath}\n@@ -1 +1 @@\n-old\n+new` - ); - }, - async repositoryDiffHunks(localPath, filePath) { - const diff = await this.repositoryDiff(localPath, filePath); - return { - filePath, - partialSupported: true, - hunks: [ - { - index: 0, - heading: "@@ -1 +1 @@", - additions: 1, - deletions: 1, - lines: diff.split("\n").slice(-4), - }, - ], - }; - }, - async stageHunks(localPath, filePath) { - return this.stageFiles(localPath, [filePath]); - }, - async conflictState(localPath) { - const repo = findRepo(localPath); - const files = repo.localStatus.files - .filter((item) => item.conflict) - .map((item) => item.path); - return { - operation: files.length ? "merge" : null, - files, - canContinue: false, - status: clone(repo.localStatus), - }; - }, - async resolveConflict(localPath, filePath) { - const repo = findRepo(localPath); - const file = repo.localStatus.files.find( - (item) => item.path === filePath, - ); - if (file) { - file.conflict = false; - file.staged = true; - file.unstaged = false; - } - recompute(repo); - return this.conflictState(localPath); - }, - async continueGitOperation(localPath) { - return this.conflictState(localPath); - }, - async abortGitOperation(localPath) { - return this.conflictState(localPath); - }, - async stageFiles(localPath, files) { - const repo = findRepo(localPath); - repo.localStatus.files.forEach((item) => { - if (!files?.length || files.includes(item.path)) { - item.staged = true; - item.unstaged = false; - item.indexCode = item.untracked ? "A" : "M"; - item.worktreeCode = "."; - } - }); - recompute(repo); - emitRepositories(); - return clone(repo.localStatus); - }, - async unstageFiles(localPath, files) { - const repo = findRepo(localPath); - repo.localStatus.files.forEach((item) => { - if (!files?.length || files.includes(item.path)) { - item.staged = false; - item.unstaged = true; - item.indexCode = "."; - item.worktreeCode = item.untracked ? "?" : "M"; - } - }); - recompute(repo); - emitRepositories(); - return clone(repo.localStatus); - }, - async commit(localPath, message, files) { - await wait(520); - if (!message?.trim()) throw new Error("Enter a commit message."); - const repo = findRepo(localPath); - repo.localStatus.files = repo.localStatus.files.filter( - (item) => !files?.includes(item.path), - ); - repo.localStatus.head = randomSha(); - repo.localStatus.branch.ahead += 1; - recompute(repo); - emitRepositories(); - return { - commitOutput: `[${repo.localStatus.branch.head} ${repo.localStatus.shortHead}] ${message}`, - commitSha: repo.localStatus.head, - status: clone(repo.localStatus), - }; - }, - async commitAndPush(localPath, message, files) { - const result = await this.commit(localPath, message, files); - const repo = findRepo(localPath); - await wait(240); - repo.localStatus.branch.ahead = 0; - recompute(repo); - emitRepositories(); - return { - ...result, - pushOutput: "Push completed.", - status: clone(repo.localStatus), - }; - }, - async commitStaged(localPath, message) { - const repo = findRepo(localPath); - return this.commit( - localPath, - message, - repo.localStatus.files - .filter((item) => item.staged) - .map((item) => item.path), - ); - }, - async commitStagedAndPush(localPath, message) { - const repo = findRepo(localPath); - return this.commitAndPush( - localPath, - message, - repo.localStatus.files - .filter((item) => item.staged) - .map((item) => item.path), - ); - }, - async push(localPath) { - await wait(360); - const repo = findRepo(localPath); - repo.localStatus.branch.ahead = 0; - recompute(repo); - emitRepositories(); - return { output: "Push completed.", status: clone(repo.localStatus) }; - }, - async fetch() { - await wait(260); - return { output: "Fetch completed." }; - }, - async pull(localPath) { - await wait(380); - const repo = findRepo(localPath); - repo.localStatus.branch.behind = 0; - recompute(repo); - emitRepositories(); - return { output: "Fast-forwarded.", status: clone(repo.localStatus) }; - }, - async history() { - await wait(100); - return clone(commitHistory); - }, - async branchProtection(fullName, branch) { - return { - branch, - protected: branch === "main", - requiredApprovals: branch === "main" ? 1 : 0, - requireSignedCommits: false, - }; - }, - async pullRequests() { - return [ - { - number: 42, - title: "Harden deployment preflight", - html_url: "https://gitea.internal/jens/vacancyradar/pulls/42", - created_at: iso(-7_200_000), - updated_at: iso(-900_000), - head: { ref: "feature/deployment-api" }, - base: { ref: "main" }, - }, - ]; - }, - async createPullRequest(fullName, title, body, base) { - return { - number: 42, - title, - body, - base, - html_url: `https://gitea.internal/${fullName}/pulls/42`, - }; - }, - async branches(localPath) { - const repo = findRepo(localPath); - if (!branchesByRepo.has(localPath)) - branchesByRepo.set(localPath, [ - { - name: repo.localStatus.branch.head, - current: true, - sha: repo.localStatus.head, - shortSha: repo.localStatus.shortHead, - upstream: repo.localStatus.branch.upstream, - }, - { - name: "main", - current: repo.localStatus.branch.head === "main", - sha: repo.localStatus.head, - shortSha: repo.localStatus.shortHead, - upstream: "origin/main", - }, - ]); - return clone(branchesByRepo.get(localPath)); - }, - async checkoutBranch(localPath, branch) { - const repo = findRepo(localPath); - if (!repo.localStatus.clean) - throw new Error( - "Commit or stash local changes before switching branches.", - ); - const list = await this.branches(localPath); - list.forEach((item) => { - item.current = item.name === branch; - }); - branchesByRepo.set(localPath, list); - repo.localStatus.branch.head = branch; - repo.localStatus.branch.upstream = `origin/${branch}`; - recompute(repo); - emitRepositories(); - return { status: clone(repo.localStatus), branches: clone(list) }; - }, - async createBranch(localPath, branch) { - const repo = findRepo(localPath); - const list = await this.branches(localPath); - list.forEach((item) => { - item.current = false; - }); - list.unshift({ - name: branch, - current: true, - sha: repo.localStatus.head, - shortSha: repo.localStatus.shortHead, - upstream: null, - }); - branchesByRepo.set(localPath, list); - repo.localStatus.branch.head = branch; - repo.localStatus.branch.upstream = null; - recompute(repo); - emitRepositories(); - return { status: clone(repo.localStatus), branches: clone(list) }; - }, - async stash(localPath, message) { - const repo = findRepo(localPath); - const list = stashesByRepo.get(localPath) || []; - list.unshift({ - ref: `stash@{${list.length}}`, - subject: message || "ForgeFlow stash", - date: iso(), - }); - stashesByRepo.set(localPath, list); - repo.localStatus.files = []; - recompute(repo); - emitRepositories(); - return { - output: "Saved working directory and index state.", - status: clone(repo.localStatus), - stashes: clone(list), - }; - }, - async stashList(localPath) { - return clone(stashesByRepo.get(localPath) || []); - }, - async popStash(localPath, ref) { - const repo = findRepo(localPath); - const list = stashesByRepo.get(localPath) || []; - const index = list.findIndex((item) => item.ref === ref); - if (index < 0) throw new Error("Stash not found."); - list.splice(index, 1); - stashesByRepo.set(localPath, list); - repo.localStatus.files = [makeFile("src/restored-from-stash.ts")]; - recompute(repo); - emitRepositories(); - return { - output: "Stash applied.", - status: clone(repo.localStatus), - stashes: clone(list), - }; - }, - async indexLockInfo() { - return { exists: false, ageMs: 0 }; - }, - async repairIndexLock() { - return { removed: true }; - }, - async setOrigin(localPath, remoteUrl) { - const repo = findRepo(localPath); - repo.localStatus.remoteUrl = remoteUrl; - repo.sshUrl = remoteUrl; - emitRepositories(); - return clone(repo.localStatus); - }, - async normalizeOrigins() { - const changes = []; - repositories - .filter((repo) => repo.localPath && repo.sshUrl) - .forEach((repo) => { - if (repo.localStatus.remoteUrl !== repo.sshUrl) { - changes.push({ - fullName: repo.fullName, - previous: repo.localStatus.remoteUrl, - next: repo.sshUrl, - }); - repo.localStatus.remoteUrl = repo.sshUrl; - } - }); - emitRepositories(); - return { changes, repositories: snapshot() }; - }, - async cloneRepository(fullName, mode = "default") { - await wait(620); - const repository = repositories.find( - (item) => item.fullName === fullName, - ); - if (!repository) throw new Error("Repository not found."); - if (repository.localPath) - throw new Error("This repository already has a linked local folder."); - const root = - mode === "custom" ? "D:\\OtherProjects" : state.workspaceRoots[0]; - if (!root) return { cancelled: true }; - const target = `${root.replace(/[\\/]+$/, "")}\\${repository.name}`; - const head = randomSha(); - repository.localPath = target; - repository.localStatus = makeStatus({ - head, - branch: repository.defaultBranch || "main", - }); - repository.localStatus.root = target; - repository.localStatus.remoteUrl = - repository.preferredCloneUrl || repository.cloneUrl; - repository.linkState = "linked"; - recompute(repository); - const current = snapshot(); - emitRepositories(); - return { - target, - status: clone(repository.localStatus), - reused: false, - repositories: current, - state: clone(state), - }; - }, - async openPath() { - return true; - }, - async openEditor() { - return { launched: true, executable: "code" }; - }, - async openTerminal() { - return { launched: true, executable: "wt.exe" }; - }, - async openExternal() { - return true; - }, - async saveDeploymentProfile(fullName, input) { - const repo = repositories.find((item) => item.fullName === fullName); - const existing = repo.deploymentProfiles.find( - (item) => item.id === input.id, - ); - const saved = { - ...(existing || - profile( - input.id || `profile-${Date.now()}`, - input.name || input.environment, - input.environment || "production", - )), - ...input, - id: input.id || `profile-${Date.now()}`, - provider: input.provider || existing?.provider || "gitea-actions", - inputs: existing?.inputs || {}, - state: existing?.state || { - liveSha: null, - previousSha: null, - healthy: null, - healthConfigured: Boolean(input.healthcheckUrl), - statusConfigured: Boolean(input.statusUrl), - checkedAt: null, - }, - }; - repo.deploymentProfiles = [ - ...repo.deploymentProfiles.filter((item) => item.id !== saved.id), - saved, - ]; - snapshot(); - return { profile: clone(saved), state: clone(state) }; - }, - async deleteDeploymentProfile(fullName, profileId) { - const repo = repositories.find((item) => item.fullName === fullName); - repo.deploymentProfiles = repo.deploymentProfiles.filter( - (item) => item.id !== profileId, - ); - snapshot(); - return { profiles: clone(repo.deploymentProfiles), state: clone(state) }; - }, - async deploymentPreflight(repository, profileId) { - await wait(280); - const profile = repository.deploymentProfiles.find( - (item) => item.id === profileId, - ); - const status = repository.localStatus; - const checks = [ - { - id: "repository.linked", - label: "Local repository link", - status: repository.localPath ? "pass" : "fail", - detail: repository.localPath || "No local folder linked.", - required: true, - }, - { - id: "git.branch", - label: "Allowed branch", - status: status?.branch.head === profile?.branch ? "pass" : "fail", - detail: `Current: ${status?.branch.head || "unknown"}; required: ${profile?.branch || "unknown"}.`, - required: true, - }, - { - id: "git.clean", - label: "Clean working tree", - status: status?.clean ? "pass" : "fail", - detail: status?.clean - ? "No uncommitted changes." - : `${status?.counts.changed || 0} changed file(s).`, - required: true, - }, - { - id: "git.sync", - label: "Local and Gitea synchronized", - status: - !status?.branch.ahead && !status?.branch.behind ? "pass" : "fail", - detail: `${status?.branch.ahead || 0} ahead, ${status?.branch.behind || 0} behind.`, - required: true, - }, - { - id: "workflow.deploy.remote", - label: "Deploy workflow on Gitea branch", - status: "pass", - detail: `${profile?.workflowFile || "deploy.yml"} exists on ${profile?.branch || "main"}.`, - required: true, - }, - { - id: "gitea.actions", - label: "Gitea Actions API", - status: "pass", - detail: "The Actions runs endpoint is accessible.", - required: true, - }, - { - id: "server.status", - label: "Server version endpoint", - status: profile?.statusUrl ? "pass" : "warning", - detail: profile?.statusUrl - ? `Endpoint reachable; live ${profile.state?.liveSha?.slice(0, 7) || "unknown"}.` - : "No status URL configured.", - required: false, - }, - { - id: "server.health", - label: "Application healthcheck", - status: profile?.healthcheckUrl ? "pass" : "warning", - detail: profile?.healthcheckUrl - ? "HTTP 200 in 42 ms." - : "No healthcheck URL configured.", - required: false, - }, - ]; - const blocking = checks - .filter((i) => i.required && i.status === "fail") - .map((i) => i.id); - return { - kind: "deployment", - repository: repository.fullName, - profileId, - startedAt: iso(-100), - completedAt: iso(), - checks, - summary: { - counts: { - pass: checks.filter((i) => i.status === "pass").length, - warning: checks.filter((i) => i.status === "warning").length, - fail: checks.filter((i) => i.status === "fail").length, - skipped: 0, - }, - blocking, - ready: blocking.length === 0, - }, - head: status?.head || null, - }; - }, - async deploy(repository, profileId, sha) { - await wait(320); - const selected = repository.deploymentProfiles.find( - (item) => item.id === profileId, - ); - const operation = { - id: `deploy-${Date.now()}`, - type: "deployment", - action: "deploy", - status: "queued", - repository: repository.fullName, - profileId, - profileName: selected.name, - environment: selected.environment, - workflowFile: selected.workflowFile, - branch: selected.branch, - sha, - shortSha: sha.slice(0, 7), - dispatchedAt: iso(), - createdAt: iso(), - updatedAt: iso(), - demoPolls: 0, - stages: [ - { id: "requested", label: "Requested", status: "complete" }, - { id: "verified", label: "Verified", status: "complete" }, - { id: "queued", label: "Workflow queued", status: "active" }, - { id: "runner", label: "Runner execution", status: "pending" }, - { id: "healthcheck", label: "Healthcheck", status: "pending" }, - { id: "complete", label: "Complete", status: "pending" }, - ], - logs: [ - `[info] Verified clean ${selected.branch} at ${sha}`, - `[ok] Gitea accepted ${selected.workflowFile}.`, - ], - }; - return updateOperation(operation); - }, - async rollback(repository, profileId, targetSha) { - await wait(320); - const selected = repository.deploymentProfiles.find( - (item) => item.id === profileId, - ); - const operation = { - id: `rollback-${Date.now()}`, - type: "deployment", - action: "rollback", - status: "queued", - repository: repository.fullName, - profileId, - profileName: selected.name, - environment: selected.environment, - workflowFile: selected.rollbackWorkflowFile, - branch: selected.branch, - sha: targetSha, - shortSha: targetSha.slice(0, 7), - dispatchedAt: iso(), - createdAt: iso(), - updatedAt: iso(), - demoPolls: 0, - stages: [ - { id: "requested", label: "Requested", status: "complete" }, - { id: "verified", label: "Verified", status: "complete" }, - { id: "queued", label: "Workflow queued", status: "active" }, - { id: "runner", label: "Runner execution", status: "pending" }, - { id: "healthcheck", label: "Healthcheck", status: "pending" }, - { id: "complete", label: "Complete", status: "pending" }, - ], - logs: [ - `[warning] Rollback target verified: ${targetSha}`, - `[ok] Gitea accepted ${selected.rollbackWorkflowFile}.`, - ], - }; - return updateOperation(operation); - }, - async healthcheck() { - await wait(160); - return { configured: true, healthy: true, status: 200, latencyMs: 42 }; - }, - async refreshProfileState(fullName, profileId) { - await wait(240); - const repo = - repositories.find((item) => item.fullName === fullName) || - findProfileRepo(profileId); - const target = repo?.deploymentProfiles.find( - (item) => item.id === profileId, - ); - if (!target) throw new Error("Deployment profile not found."); - target.state = { - ...target.state, - checkedAt: iso(), - healthy: target.state.healthy !== false, - healthConfigured: Boolean(target.healthcheckUrl), - statusConfigured: Boolean(target.statusUrl), - }; - syncState(); - return clone(target.state); - }, - async discoverServerDeployments() { - await wait(80); - return [ - { - serverId: "server-unraid", - serverName: "Unraid", - detected: 2, - adopted: 0, - verified: 1, - linked: 1, - unmatched: 0, - needsReview: 1, - running: 2, - stopped: 0, - capabilities: { - docker: true, - dockerReady: true, - compose: true, - git: false, - tar: true, - checksum: true, - }, - warnings: [], - workloads: [ - { - workloadId: "workload-demo-linked", - displayName: "Portfolio", - status: "linked", - runtime: { running: true, health: "healthy" }, - compose: { - project: "portfolio", - workingDir: "/mnt/user/appdata/portfolio", - configFiles: ["/mnt/user/appdata/portfolio/docker-compose.yml"], - services: ["web"], - }, - containers: [{ name: "Portfolio", running: true }], - candidates: [], - link: { - profileId: "profile-portfolio", - repositoryFullName: "jens/portfolio", - source: "manual", - }, - }, - { - workloadId: "workload-demo-review", - displayName: "OmniRoute", - status: "suggested", - runtime: { running: true, health: "unverified" }, - compose: { - project: "omniroute", - workingDir: "/mnt/user/appdata/OmniRoute", - configFiles: ["/mnt/user/appdata/OmniRoute/docker-compose.yml"], - services: ["omniroute"], - }, - containers: [{ name: "omniroute", running: true }], - remoteFolderCandidate: "OmniRoute", - candidates: repositories.slice(0, 1).map((repository) => ({ - repositoryFullName: repository.fullName, - repositoryName: repository.name, - score: 55, - exact: false, - reasons: ["container and repository names are similar"], - })), - }, - ], - }, - ]; - }, - async planServerReconciliation(serverId) { - await wait(90); - const id = "a".repeat(64); - return { - inventory: (await this.discoverServerDeployments()).find((item) => item.serverId === serverId), - plan: { - id, - serverId, - summary: { additions: 0, updates: 1, stale: 0, conflicts: 1 }, - additions: [], - updates: [{ workloadId: "workload-demo-linked", profileId: "profile-portfolio", repositoryFullName: "jens/portfolio", impact: "Refresh detected Compose identity and observed deployment state" }], - stale: [], - conflicts: [{ workloadId: "workload-demo-review", displayName: "OmniRoute", status: "suggested", candidates: [{ repositoryFullName: repositories[0].fullName, score: 55, exact: false }] }], - }, - }; - }, - async applyServerReconciliation(serverId, planId) { - await wait(120); - if (serverId !== "server-unraid" || planId !== "a".repeat(64)) throw new Error("The reconciliation plan is stale."); - return { adopted: 0, refreshed: 1, retired: 0, state: clone(state) }; - }, - async planInventoryReview(serverId, workloadId, action, reason = "", repositoryFullName = null) { - if (["ignore", "manual-exclude", "exclude-scan-root"].includes(action) && reason.length < 5) throw new Error("A meaningful review reason is required."); - return { id: "b".repeat(64), serverId, workloadId, action, reason, repositoryFullName, evidenceHash: "c".repeat(64), classification: "ambiguous", containersUnaffected: true, configurationChanges: [`Persist review decision ${action}`], recovery: "Remove the decision or rescan after evidence changes." }; - }, - async applyInventoryReview(serverId, workloadId, action, reason, repositoryFullName, planId) { - if (planId !== "b".repeat(64)) throw new Error("The inventory review plan is stale."); - const inventory = (await this.discoverServerDeployments()).find((item) => item.serverId === serverId); - const workload = inventory.workloads.find((item) => item.workloadId === workloadId); - if (workload) workload.reviewDecision = { action, reason, repositoryFullName, evidenceHash: "c".repeat(64) }; - return { decision: workload?.reviewDecision, inventory, state: clone(state) }; - }, - async linkServerWorkload(repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "") { - await wait(120); - const repo = repositories.find((item) => item.fullName === repository.fullName); - if (!repo) throw new Error("Repository not found."); - const id = `profile-${workloadId}`; - const saved = { - id, - name: `Unraid · ${remoteFolder || repo.name}`, - environment: "production", - provider: "ssh-unraid", - branch: repo.defaultBranch || "main", - serverId, - remoteFolder: remoteFolder || repo.name, - deploymentMode, - composeFile: "docker-compose.yml", - composeFiles: ["docker-compose.yml"], - composeProject: String(remoteFolder || repo.name).toLowerCase(), - composeService: String(remoteFolder || repo.name).toLowerCase(), - composeServices: [String(remoteFolder || repo.name).toLowerCase()], - containerName: remoteFolder || repo.name, - preservePaths: [".env", "appdata", "data", "logs", "config"], - generatedCompose: false, - adoptedFromServer: true, - serverSourceOfTruth: true, - manageDockerMan: false, - forceRecreate: false, - removeOrphans: false, - workloadIdentity: { workloadId, linkSource: "manual", linkedAt: iso() }, - confirmationRequired: true, - state: { - liveSha: null, - healthy: null, - containerRunning: true, - runtimeVerification: "running-unverified", - checkedAt: iso(), - }, - }; - repo.deploymentProfiles = [ - ...repo.deploymentProfiles.filter((item) => item.id !== id), - saved, - ]; - syncState(); - return { profile: clone(saved), state: clone(state) }; - }, - async configureServerGitAccess(repository, profileId) { - const repo = repositories.find((item) => item.fullName === repository.fullName); - const target = repo?.deploymentProfiles.find((item) => item.id === profileId); - if (!target) throw new Error("Deployment profile not found."); - target.deploymentMode = "server-git"; - target.serverGitAccess = { configured: true, keyFingerprint: "SHA256:demo", hostFingerprint: "SHA256:gitea", configuredAt: iso() }; - syncState(); - return { profile: clone(target), created: true, remoteSha: target.state?.giteaSha || repo.localStatus?.head }; - }, - async verifyServerGitProfile(repository, profileId) { - const repo = repositories.find((item) => item.fullName === repository.fullName); - const target = repo?.deploymentProfiles.find((item) => item.id === profileId); - if (!target) throw new Error("Deployment profile not found."); - const branchSha = target.state?.giteaSha || repo.localStatus?.head || null; - const liveSha = target.state?.liveSha || null; - return { - readiness: branchSha && liveSha === branchSha ? "Ready" : "Commit mismatch", - ready: true, - checkedAt: iso(), - repository: repo.fullName, - profileId, - branchSha, - liveSha, - checks: [ - { id: "remote-branch", label: "Gitea branch", status: "pass", detail: "Exact branch resolved." }, - { id: "deploy-key-scope", label: "Repository deploy key", status: "pass", detail: "Repository-scoped and read-only." }, - { id: "server-git-access", label: "Unraid to Gitea", status: "pass", detail: "Pinned SSH access verified." }, - ], - }; - }, - async deployKeyInventory(repository, profileId) { - const repo = repositories.find((item) => item.fullName === repository.fullName); - const profile = repo?.deploymentProfiles.find((item) => item.id === profileId); - return { repository: repo.fullName, profileId, server: { id: profile.serverId, name: "Unraid" }, configuredKey: { id: profile.serverGitAccess?.deployKeyId || 17, readOnly: true }, serverKey: { privateKeyPresent: true, fingerprint: profile.serverGitAccess?.keyFingerprint || "SHA256:demo" }, stale: false, orphaned: [], shared: [], conflicts: [], ready: true, checkedAt: iso() }; - }, - async planDeployKeyRotation(repository, profileId) { - const evidence = await this.deployKeyInventory(repository, profileId); - return { id: `rotation-${profileId}`, operation: "rotate-deploy-key", impact: ["Generate a new server-side key", "Verify read-only access", "Switch atomically", "Revoke the previous key"], recovery: "Previous access remains recoverable until verification succeeds.", evidence }; - }, - async applyDeployKeyRotation(repository, profileId) { - const repo = repositories.find((item) => item.fullName === repository.fullName); const profile = repo.deploymentProfiles.find((item) => item.id === profileId); - profile.serverGitAccess = { ...profile.serverGitAccess, configured: true, deployKeyId: 18, keyFingerprint: "SHA256:rotated", rotatedAt: iso() }; syncState(); return { profile: clone(profile), state: clone(state) }; - }, - async planDeployKeyRevocation(repository, profileId) { - const evidence = await this.deployKeyInventory(repository, profileId); - return { id: `revocation-${profileId}`, operation: "revoke-deploy-key", impact: ["Remove the repository key", "Disable server pull", "Preserve recovery material"], containersUnaffected: true, evidence }; - }, - async applyDeployKeyRevocation(repository, profileId) { - const repo = repositories.find((item) => item.fullName === repository.fullName); const profile = repo.deploymentProfiles.find((item) => item.id === profileId); - profile.deploymentMode = "monitor-only"; profile.serverGitAccess = { ...profile.serverGitAccess, configured: false, revokedAt: iso(), recoveryAvailable: true }; syncState(); return { profile: clone(profile), state: clone(state) }; - }, - async restoreDeployKey(repository, profileId) { - const repo = repositories.find((item) => item.fullName === repository.fullName); const profile = repo.deploymentProfiles.find((item) => item.id === profileId); - profile.deploymentMode = "server-git"; profile.serverGitAccess = { ...profile.serverGitAccess, configured: true, deployKeyId: 19, keyFingerprint: "SHA256:restored", restoredAt: iso() }; syncState(); return { profile: clone(profile), state: clone(state), proof: { ready: true } }; - }, - async refreshOperations(operationId = null) { - await wait(300); - if (operationId) { - const operation = state.operations.find( - (item) => item.id === operationId, - ); - if (!operation) throw new Error("Operation not found."); - return updateOperation(advanceOperation(operation)); - } - const active = state.operations - .filter( - (item) => - !["success", "failed", "cancelled", "rolled-back"].includes( - item.status, - ), - ) - .map(advanceOperation); - if (active.length) emitOperations(active); - state.operations = state.operations.map( - (item) => active.find((entry) => entry.id === item.id) || item, - ); - return clone(active); - }, - async getOperation(operationId) { - return clone( - state.operations.find((item) => item.id === operationId) || null, - ); - }, - async gitValidatorScan(fullName) { - await wait(260); - return { - repository: fullName, - checkedAt: iso(), - score: 78, - grade: "Good", - summary: { passed: 7, warnings: 3, errors: 0, repairable: 2 }, - checks: [ - { - id: "origin", - category: "Repository identity", - title: "Origin matches Gitea", - status: "pass", - detail: "The local origin resolves to this Gitea repository.", - weight: 15, - }, - { - id: "default-branch-protection", - category: "Gitea governance", - title: "Default branch protection", - status: "warning", - detail: "main accepts unprotected direct changes.", - weight: 18, - fixAction: "protect-default-branch", - safe: false, - confirmation: - "Protect main on Gitea and block direct and force pushes?", - }, - { - id: "force-push", - category: "Gitea governance", - title: "Force-push protection", - status: "pass", - detail: "Force pushes are blocked.", - weight: 8, - }, - { - id: "upstream", - category: "Branch hygiene", - title: "Current branch has an upstream", - status: "pass", - detail: "main tracks origin/main.", - weight: 8, - }, - { - id: "working-tree", - category: "Branch hygiene", - title: "Working tree is intentional", - status: "warning", - detail: "3 changed files require review, commit or stash.", - weight: 5, - }, - { - id: "identity", - category: "Commit integrity", - title: "Repository author identity", - status: "pass", - detail: "Jens ", - weight: 7, - }, - { - id: "local-safety", - category: "Local configuration", - title: "Safe synchronization defaults", - status: "warning", - detail: "Recommended repository-local safeguards are incomplete.", - weight: 10, - fixAction: "configure-local-safety", - safe: true, - }, - { - id: "readme", - category: "Repository documentation", - title: "README is versioned", - status: "pass", - detail: "Repository documentation is tracked.", - weight: 7, - }, - { - id: "gitignore", - category: "Repository hygiene", - title: ".gitignore is versioned", - status: "pass", - detail: "Generated files are excluded centrally.", - weight: 8, - }, - { - id: "tracked-secrets", - category: "Security", - title: "No secret-shaped files are tracked", - status: "pass", - detail: - "No tracked environment, key or credential filenames detected.", - weight: 22, - }, - { - id: "large-files", - category: "Repository performance", - title: "No oversized tracked files", - status: "pass", - detail: "No tracked files above 10 MB were found.", - weight: 7, - }, - ], - }; - }, - async gitValidatorRepair() { - await wait(180); - return { repaired: true }; - }, - async diagnosticsStatus() { - return { - enabled: state.preferences.diagnosticsEnabled !== false, - level: state.preferences.diagnosticLevel, - retentionDays: state.preferences.logRetentionDays, - maxFileMb: state.preferences.maxLogFileMb, - directory: "/AppData/Roaming/ForgeFlow/diagnostics", - fileCount: 2, - totalBytes: 18432, - totalSize: "18.0 KB", - latestAt: iso(-2000), - lastWriteError: null, - }; - }, - async exportConfigurationBackup() { - return { - filePath: "C:\\Downloads\\ForgeFlow-Configuration-demo.ffbackup", - }; - }, - async importConfigurationBackup() { - return { state: clone(state), exportedAt: iso(-86400000) }; - }, - async listAuditEvents() { - return [ - { - id: "audit-1", - timestamp: iso(-60000), - event: "deployment.completed", - details: { repository: "Jens/ForgeFlow", result: "success" }, - }, - ]; - }, - async exportAuditLog() { - return { filePath: "C:\\Downloads\\ForgeFlow-Audit-demo.json", count: 1 }; - }, - async clearDiagnostics() { - return { - enabled: true, - level: state.preferences.diagnosticLevel, - retentionDays: state.preferences.logRetentionDays, - maxFileMb: state.preferences.maxLogFileMb, - directory: "/AppData/Roaming/ForgeFlow/diagnostics", - fileCount: 1, - totalBytes: 256, - totalSize: "256 B", - latestAt: iso(), - lastWriteError: null, - }; - }, - async openDiagnosticsFolder() { - return true; - }, - async exportDiagnostics(privacyMode = "standard") { - await wait(500); - return { - path: `C:\Users\Jens\Downloads\ForgeFlow-Diagnostics-demo.zip`, - bytes: 38221, - size: "37.3 KB", - sha256: "b".repeat(64), - privacyMode, - generatedAt: iso(), - }; - }, - async showDiagnosticBundle() { - return true; - }, - async reportRendererEvent() { - return true; - }, - onRepositoriesChanged(listener) { - repositoryListeners.add(listener); - return () => repositoryListeners.delete(listener); - }, - onOperationsChanged(listener) { - operationListeners.add(listener); - return () => operationListeners.delete(listener); - }, - onUpdatesChanged(listener) { - updateListeners.add(listener); - return () => updateListeners.delete(listener); - }, - async reset() { - state.setupComplete = false; - storage.set("forgeflow-demo-setup", "false"); - return clone(state); - }, + ...createMockRepositoryBridge(bridgeContext), + ...createMockDeploymentBridge(bridgeContext), }); })(); diff --git a/src/renderer/mock-deployment-bridge.js b/src/renderer/mock-deployment-bridge.js new file mode 100644 index 0000000..6c37df6 --- /dev/null +++ b/src/renderer/mock-deployment-bridge.js @@ -0,0 +1,660 @@ +function createMockDeploymentBridge(context) { + const { wait, clone, iso, storage, repositoryListeners, operationListeners, updateListeners, emitRepositories, emitOperations, randomSha, now, profile, state, repositories, recompute, snapshot, commitHistory, advanceOperation, syncState, diffs, findRepo, findProfileRepo, branchesByRepo, stashesByRepo, updateOperation } = context; + return { + async saveDeploymentProfile(fullName, input) { + const repo = repositories.find((item) => item.fullName === fullName); + const existing = repo.deploymentProfiles.find( + (item) => item.id === input.id, + ); + const saved = { + ...(existing || + profile( + input.id || `profile-${Date.now()}`, + input.name || input.environment, + input.environment || "production", + )), + ...input, + id: input.id || `profile-${Date.now()}`, + provider: input.provider || existing?.provider || "gitea-actions", + inputs: existing?.inputs || {}, + state: existing?.state || { + liveSha: null, + previousSha: null, + healthy: null, + healthConfigured: Boolean(input.healthcheckUrl), + statusConfigured: Boolean(input.statusUrl), + checkedAt: null, + }, + }; + repo.deploymentProfiles = [ + ...repo.deploymentProfiles.filter((item) => item.id !== saved.id), + saved, + ]; + snapshot(); + return { profile: clone(saved), state: clone(state) }; + }, + async deleteDeploymentProfile(fullName, profileId) { + const repo = repositories.find((item) => item.fullName === fullName); + repo.deploymentProfiles = repo.deploymentProfiles.filter( + (item) => item.id !== profileId, + ); + snapshot(); + return { profiles: clone(repo.deploymentProfiles), state: clone(state) }; + }, + async deploymentPreflight(repository, profileId) { + await wait(280); + const profile = repository.deploymentProfiles.find( + (item) => item.id === profileId, + ); + const status = repository.localStatus; + const checks = [ + { + id: "repository.linked", + label: "Local repository link", + status: repository.localPath ? "pass" : "fail", + detail: repository.localPath || "No local folder linked.", + required: true, + }, + { + id: "git.branch", + label: "Allowed branch", + status: status?.branch.head === profile?.branch ? "pass" : "fail", + detail: `Current: ${status?.branch.head || "unknown"}; required: ${profile?.branch || "unknown"}.`, + required: true, + }, + { + id: "git.clean", + label: "Clean working tree", + status: status?.clean ? "pass" : "fail", + detail: status?.clean + ? "No uncommitted changes." + : `${status?.counts.changed || 0} changed file(s).`, + required: true, + }, + { + id: "git.sync", + label: "Local and Gitea synchronized", + status: + !status?.branch.ahead && !status?.branch.behind ? "pass" : "fail", + detail: `${status?.branch.ahead || 0} ahead, ${status?.branch.behind || 0} behind.`, + required: true, + }, + { + id: "workflow.deploy.remote", + label: "Deploy workflow on Gitea branch", + status: "pass", + detail: `${profile?.workflowFile || "deploy.yml"} exists on ${profile?.branch || "main"}.`, + required: true, + }, + { + id: "gitea.actions", + label: "Gitea Actions API", + status: "pass", + detail: "The Actions runs endpoint is accessible.", + required: true, + }, + { + id: "server.status", + label: "Server version endpoint", + status: profile?.statusUrl ? "pass" : "warning", + detail: profile?.statusUrl + ? `Endpoint reachable; live ${profile.state?.liveSha?.slice(0, 7) || "unknown"}.` + : "No status URL configured.", + required: false, + }, + { + id: "server.health", + label: "Application healthcheck", + status: profile?.healthcheckUrl ? "pass" : "warning", + detail: profile?.healthcheckUrl + ? "HTTP 200 in 42 ms." + : "No healthcheck URL configured.", + required: false, + }, + ]; + const blocking = checks + .filter((i) => i.required && i.status === "fail") + .map((i) => i.id); + return { + kind: "deployment", + repository: repository.fullName, + profileId, + startedAt: iso(-100), + completedAt: iso(), + checks, + summary: { + counts: { + pass: checks.filter((i) => i.status === "pass").length, + warning: checks.filter((i) => i.status === "warning").length, + fail: checks.filter((i) => i.status === "fail").length, + skipped: 0, + }, + blocking, + ready: blocking.length === 0, + }, + head: status?.head || null, + }; + }, + async deploy(repository, profileId, sha) { + await wait(320); + const selected = repository.deploymentProfiles.find( + (item) => item.id === profileId, + ); + const operation = { + id: `deploy-${Date.now()}`, + type: "deployment", + action: "deploy", + status: "queued", + repository: repository.fullName, + profileId, + profileName: selected.name, + environment: selected.environment, + workflowFile: selected.workflowFile, + branch: selected.branch, + sha, + shortSha: sha.slice(0, 7), + dispatchedAt: iso(), + createdAt: iso(), + updatedAt: iso(), + demoPolls: 0, + stages: [ + { id: "requested", label: "Requested", status: "complete" }, + { id: "verified", label: "Verified", status: "complete" }, + { id: "queued", label: "Workflow queued", status: "active" }, + { id: "runner", label: "Runner execution", status: "pending" }, + { id: "healthcheck", label: "Healthcheck", status: "pending" }, + { id: "complete", label: "Complete", status: "pending" }, + ], + logs: [ + `[info] Verified clean ${selected.branch} at ${sha}`, + `[ok] Gitea accepted ${selected.workflowFile}.`, + ], + }; + return updateOperation(operation); + }, + async rollback(repository, profileId, targetSha) { + await wait(320); + const selected = repository.deploymentProfiles.find( + (item) => item.id === profileId, + ); + const operation = { + id: `rollback-${Date.now()}`, + type: "deployment", + action: "rollback", + status: "queued", + repository: repository.fullName, + profileId, + profileName: selected.name, + environment: selected.environment, + workflowFile: selected.rollbackWorkflowFile, + branch: selected.branch, + sha: targetSha, + shortSha: targetSha.slice(0, 7), + dispatchedAt: iso(), + createdAt: iso(), + updatedAt: iso(), + demoPolls: 0, + stages: [ + { id: "requested", label: "Requested", status: "complete" }, + { id: "verified", label: "Verified", status: "complete" }, + { id: "queued", label: "Workflow queued", status: "active" }, + { id: "runner", label: "Runner execution", status: "pending" }, + { id: "healthcheck", label: "Healthcheck", status: "pending" }, + { id: "complete", label: "Complete", status: "pending" }, + ], + logs: [ + `[warning] Rollback target verified: ${targetSha}`, + `[ok] Gitea accepted ${selected.rollbackWorkflowFile}.`, + ], + }; + return updateOperation(operation); + }, + async healthcheck() { + await wait(160); + return { configured: true, healthy: true, status: 200, latencyMs: 42 }; + }, + async refreshProfileState(fullName, profileId) { + await wait(240); + const repo = + repositories.find((item) => item.fullName === fullName) || + findProfileRepo(profileId); + const target = repo?.deploymentProfiles.find( + (item) => item.id === profileId, + ); + if (!target) throw new Error("Deployment profile not found."); + target.state = { + ...target.state, + checkedAt: iso(), + healthy: target.state.healthy !== false, + healthConfigured: Boolean(target.healthcheckUrl), + statusConfigured: Boolean(target.statusUrl), + }; + syncState(); + return clone(target.state); + }, + async discoverServerDeployments() { + await wait(80); + return [ + { + serverId: "server-unraid", + serverName: "Unraid", + detected: 2, + adopted: 0, + verified: 1, + linked: 1, + unmatched: 0, + needsReview: 1, + running: 2, + stopped: 0, + capabilities: { + docker: true, + dockerReady: true, + compose: true, + git: false, + tar: true, + checksum: true, + }, + warnings: [], + workloads: [ + { + workloadId: "workload-demo-linked", + displayName: "Portfolio", + status: "linked", + runtime: { running: true, health: "healthy" }, + compose: { + project: "portfolio", + workingDir: "/mnt/user/appdata/portfolio", + configFiles: ["/mnt/user/appdata/portfolio/docker-compose.yml"], + services: ["web"], + }, + containers: [{ name: "Portfolio", running: true }], + candidates: [], + link: { + profileId: "profile-portfolio", + repositoryFullName: "jens/portfolio", + source: "manual", + }, + }, + { + workloadId: "workload-demo-review", + displayName: "OmniRoute", + status: "suggested", + runtime: { running: true, health: "unverified" }, + compose: { + project: "omniroute", + workingDir: "/mnt/user/appdata/OmniRoute", + configFiles: ["/mnt/user/appdata/OmniRoute/docker-compose.yml"], + services: ["omniroute"], + }, + containers: [{ name: "omniroute", running: true }], + remoteFolderCandidate: "OmniRoute", + candidates: repositories.slice(0, 1).map((repository) => ({ + repositoryFullName: repository.fullName, + repositoryName: repository.name, + score: 55, + exact: false, + reasons: ["container and repository names are similar"], + })), + }, + ], + }, + ]; + }, + async planServerReconciliation(serverId) { + await wait(90); + const id = "a".repeat(64); + return { + inventory: (await this.discoverServerDeployments()).find((item) => item.serverId === serverId), + plan: { + id, + serverId, + summary: { additions: 0, updates: 1, stale: 0, conflicts: 1 }, + additions: [], + updates: [{ workloadId: "workload-demo-linked", profileId: "profile-portfolio", repositoryFullName: "jens/portfolio", impact: "Refresh detected Compose identity and observed deployment state" }], + stale: [], + conflicts: [{ workloadId: "workload-demo-review", displayName: "OmniRoute", status: "suggested", candidates: [{ repositoryFullName: repositories[0].fullName, score: 55, exact: false }] }], + }, + }; + }, + async applyServerReconciliation(serverId, planId) { + await wait(120); + if (serverId !== "server-unraid" || planId !== "a".repeat(64)) throw new Error("The reconciliation plan is stale."); + return { adopted: 0, refreshed: 1, retired: 0, state: clone(state) }; + }, + async planInventoryReview(serverId, workloadId, action, reason = "", repositoryFullName = null) { + if (["ignore", "manual-exclude", "exclude-scan-root"].includes(action) && reason.length < 5) throw new Error("A meaningful review reason is required."); + return { id: "b".repeat(64), serverId, workloadId, action, reason, repositoryFullName, evidenceHash: "c".repeat(64), classification: "ambiguous", containersUnaffected: true, configurationChanges: [`Persist review decision ${action}`], recovery: "Remove the decision or rescan after evidence changes." }; + }, + async applyInventoryReview(serverId, workloadId, action, reason, repositoryFullName, planId) { + if (planId !== "b".repeat(64)) throw new Error("The inventory review plan is stale."); + const inventory = (await this.discoverServerDeployments()).find((item) => item.serverId === serverId); + const workload = inventory.workloads.find((item) => item.workloadId === workloadId); + if (workload) workload.reviewDecision = { action, reason, repositoryFullName, evidenceHash: "c".repeat(64) }; + return { decision: workload?.reviewDecision, inventory, state: clone(state) }; + }, + async linkServerWorkload(repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "") { + await wait(120); + const repo = repositories.find((item) => item.fullName === repository.fullName); + if (!repo) throw new Error("Repository not found."); + const id = `profile-${workloadId}`; + const saved = { + id, + name: `Unraid · ${remoteFolder || repo.name}`, + environment: "production", + provider: "ssh-unraid", + branch: repo.defaultBranch || "main", + serverId, + remoteFolder: remoteFolder || repo.name, + deploymentMode, + composeFile: "docker-compose.yml", + composeFiles: ["docker-compose.yml"], + composeProject: String(remoteFolder || repo.name).toLowerCase(), + composeService: String(remoteFolder || repo.name).toLowerCase(), + composeServices: [String(remoteFolder || repo.name).toLowerCase()], + containerName: remoteFolder || repo.name, + preservePaths: [".env", "appdata", "data", "logs", "config"], + generatedCompose: false, + adoptedFromServer: true, + serverSourceOfTruth: true, + manageDockerMan: false, + forceRecreate: false, + removeOrphans: false, + workloadIdentity: { workloadId, linkSource: "manual", linkedAt: iso() }, + confirmationRequired: true, + state: { + liveSha: null, + healthy: null, + containerRunning: true, + runtimeVerification: "running-unverified", + checkedAt: iso(), + }, + }; + repo.deploymentProfiles = [ + ...repo.deploymentProfiles.filter((item) => item.id !== id), + saved, + ]; + syncState(); + return { profile: clone(saved), state: clone(state) }; + }, + async configureServerGitAccess(repository, profileId) { + const repo = repositories.find((item) => item.fullName === repository.fullName); + const target = repo?.deploymentProfiles.find((item) => item.id === profileId); + if (!target) throw new Error("Deployment profile not found."); + target.deploymentMode = "server-git"; + target.serverGitAccess = { configured: true, keyFingerprint: "SHA256:demo", hostFingerprint: "SHA256:gitea", configuredAt: iso() }; + syncState(); + return { profile: clone(target), created: true, remoteSha: target.state?.giteaSha || repo.localStatus?.head }; + }, + async verifyServerGitProfile(repository, profileId) { + const repo = repositories.find((item) => item.fullName === repository.fullName); + const target = repo?.deploymentProfiles.find((item) => item.id === profileId); + if (!target) throw new Error("Deployment profile not found."); + const branchSha = target.state?.giteaSha || repo.localStatus?.head || null; + const liveSha = target.state?.liveSha || null; + return { + readiness: branchSha && liveSha === branchSha ? "Ready" : "Commit mismatch", + ready: true, + checkedAt: iso(), + repository: repo.fullName, + profileId, + branchSha, + liveSha, + checks: [ + { id: "remote-branch", label: "Gitea branch", status: "pass", detail: "Exact branch resolved." }, + { id: "deploy-key-scope", label: "Repository deploy key", status: "pass", detail: "Repository-scoped and read-only." }, + { id: "server-git-access", label: "Unraid to Gitea", status: "pass", detail: "Pinned SSH access verified." }, + ], + }; + }, + async deployKeyInventory(repository, profileId) { + const repo = repositories.find((item) => item.fullName === repository.fullName); + const profile = repo?.deploymentProfiles.find((item) => item.id === profileId); + return { repository: repo.fullName, profileId, server: { id: profile.serverId, name: "Unraid" }, configuredKey: { id: profile.serverGitAccess?.deployKeyId || 17, readOnly: true }, serverKey: { privateKeyPresent: true, fingerprint: profile.serverGitAccess?.keyFingerprint || "SHA256:demo" }, stale: false, orphaned: [], shared: [], conflicts: [], ready: true, checkedAt: iso() }; + }, + async planDeployKeyRotation(repository, profileId) { + const evidence = await this.deployKeyInventory(repository, profileId); + return { id: `rotation-${profileId}`, operation: "rotate-deploy-key", impact: ["Generate a new server-side key", "Verify read-only access", "Switch atomically", "Revoke the previous key"], recovery: "Previous access remains recoverable until verification succeeds.", evidence }; + }, + async applyDeployKeyRotation(repository, profileId) { + const repo = repositories.find((item) => item.fullName === repository.fullName); const profile = repo.deploymentProfiles.find((item) => item.id === profileId); + profile.serverGitAccess = { ...profile.serverGitAccess, configured: true, deployKeyId: 18, keyFingerprint: "SHA256:rotated", rotatedAt: iso() }; syncState(); return { profile: clone(profile), state: clone(state) }; + }, + async planDeployKeyRevocation(repository, profileId) { + const evidence = await this.deployKeyInventory(repository, profileId); + return { id: `revocation-${profileId}`, operation: "revoke-deploy-key", impact: ["Remove the repository key", "Disable server pull", "Preserve recovery material"], containersUnaffected: true, evidence }; + }, + async applyDeployKeyRevocation(repository, profileId) { + const repo = repositories.find((item) => item.fullName === repository.fullName); const profile = repo.deploymentProfiles.find((item) => item.id === profileId); + profile.deploymentMode = "monitor-only"; profile.serverGitAccess = { ...profile.serverGitAccess, configured: false, revokedAt: iso(), recoveryAvailable: true }; syncState(); return { profile: clone(profile), state: clone(state) }; + }, + async restoreDeployKey(repository, profileId) { + const repo = repositories.find((item) => item.fullName === repository.fullName); const profile = repo.deploymentProfiles.find((item) => item.id === profileId); + profile.deploymentMode = "server-git"; profile.serverGitAccess = { ...profile.serverGitAccess, configured: true, deployKeyId: 19, keyFingerprint: "SHA256:restored", restoredAt: iso() }; syncState(); return { profile: clone(profile), state: clone(state), proof: { ready: true } }; + }, + async refreshOperations(operationId = null) { + await wait(300); + if (operationId) { + const operation = state.operations.find( + (item) => item.id === operationId, + ); + if (!operation) throw new Error("Operation not found."); + return updateOperation(advanceOperation(operation)); + } + const active = state.operations + .filter( + (item) => + !["success", "failed", "cancelled", "rolled-back"].includes( + item.status, + ), + ) + .map(advanceOperation); + if (active.length) emitOperations(active); + state.operations = state.operations.map( + (item) => active.find((entry) => entry.id === item.id) || item, + ); + return clone(active); + }, + async getOperation(operationId) { + return clone( + state.operations.find((item) => item.id === operationId) || null, + ); + }, + async gitValidatorScan(fullName) { + await wait(260); + return { + repository: fullName, + checkedAt: iso(), + score: 78, + grade: "Good", + summary: { passed: 7, warnings: 3, errors: 0, repairable: 2 }, + checks: [ + { + id: "origin", + category: "Repository identity", + title: "Origin matches Gitea", + status: "pass", + detail: "The local origin resolves to this Gitea repository.", + weight: 15, + }, + { + id: "default-branch-protection", + category: "Gitea governance", + title: "Default branch protection", + status: "warning", + detail: "main accepts unprotected direct changes.", + weight: 18, + fixAction: "protect-default-branch", + safe: false, + confirmation: + "Protect main on Gitea and block direct and force pushes?", + }, + { + id: "force-push", + category: "Gitea governance", + title: "Force-push protection", + status: "pass", + detail: "Force pushes are blocked.", + weight: 8, + }, + { + id: "upstream", + category: "Branch hygiene", + title: "Current branch has an upstream", + status: "pass", + detail: "main tracks origin/main.", + weight: 8, + }, + { + id: "working-tree", + category: "Branch hygiene", + title: "Working tree is intentional", + status: "warning", + detail: "3 changed files require review, commit or stash.", + weight: 5, + }, + { + id: "identity", + category: "Commit integrity", + title: "Repository author identity", + status: "pass", + detail: "Jens ", + weight: 7, + }, + { + id: "local-safety", + category: "Local configuration", + title: "Safe synchronization defaults", + status: "warning", + detail: "Recommended repository-local safeguards are incomplete.", + weight: 10, + fixAction: "configure-local-safety", + safe: true, + }, + { + id: "readme", + category: "Repository documentation", + title: "README is versioned", + status: "pass", + detail: "Repository documentation is tracked.", + weight: 7, + }, + { + id: "gitignore", + category: "Repository hygiene", + title: ".gitignore is versioned", + status: "pass", + detail: "Generated files are excluded centrally.", + weight: 8, + }, + { + id: "tracked-secrets", + category: "Security", + title: "No secret-shaped files are tracked", + status: "pass", + detail: + "No tracked environment, key or credential filenames detected.", + weight: 22, + }, + { + id: "large-files", + category: "Repository performance", + title: "No oversized tracked files", + status: "pass", + detail: "No tracked files above 10 MB were found.", + weight: 7, + }, + ], + }; + }, + async gitValidatorRepair() { + await wait(180); + return { repaired: true }; + }, + async diagnosticsStatus() { + return { + enabled: state.preferences.diagnosticsEnabled !== false, + level: state.preferences.diagnosticLevel, + retentionDays: state.preferences.logRetentionDays, + maxFileMb: state.preferences.maxLogFileMb, + directory: "/AppData/Roaming/ForgeFlow/diagnostics", + fileCount: 2, + totalBytes: 18432, + totalSize: "18.0 KB", + latestAt: iso(-2000), + lastWriteError: null, + }; + }, + async exportConfigurationBackup() { + return { + filePath: "C:\\Downloads\\ForgeFlow-Configuration-demo.ffbackup", + }; + }, + async importConfigurationBackup() { + return { state: clone(state), exportedAt: iso(-86400000) }; + }, + async listAuditEvents() { + return [ + { + id: "audit-1", + timestamp: iso(-60000), + event: "deployment.completed", + details: { repository: "Jens/ForgeFlow", result: "success" }, + }, + ]; + }, + async exportAuditLog() { + return { filePath: "C:\\Downloads\\ForgeFlow-Audit-demo.json", count: 1 }; + }, + async clearDiagnostics() { + return { + enabled: true, + level: state.preferences.diagnosticLevel, + retentionDays: state.preferences.logRetentionDays, + maxFileMb: state.preferences.maxLogFileMb, + directory: "/AppData/Roaming/ForgeFlow/diagnostics", + fileCount: 1, + totalBytes: 256, + totalSize: "256 B", + latestAt: iso(), + lastWriteError: null, + }; + }, + async openDiagnosticsFolder() { + return true; + }, + async exportDiagnostics(privacyMode = "standard") { + await wait(500); + return { + path: `C:\Users\Jens\Downloads\ForgeFlow-Diagnostics-demo.zip`, + bytes: 38221, + size: "37.3 KB", + sha256: "b".repeat(64), + privacyMode, + generatedAt: iso(), + }; + }, + async showDiagnosticBundle() { + return true; + }, + async reportRendererEvent() { + return true; + }, + onRepositoriesChanged(listener) { + repositoryListeners.add(listener); + return () => repositoryListeners.delete(listener); + }, + onOperationsChanged(listener) { + operationListeners.add(listener); + return () => operationListeners.delete(listener); + }, + onUpdatesChanged(listener) { + updateListeners.add(listener); + return () => updateListeners.delete(listener); + }, + async reset() { + state.setupComplete = false; + storage.set("forgeflow-demo-setup", "false"); + return clone(state); + }, + }; +} diff --git a/src/renderer/mock-repository-bridge.js b/src/renderer/mock-repository-bridge.js new file mode 100644 index 0000000..4705b8c --- /dev/null +++ b/src/renderer/mock-repository-bridge.js @@ -0,0 +1,635 @@ +function createMockRepositoryBridge(context) { + const { wait, clone, iso, storage, repositoryListeners, operationListeners, updateListeners, emitRepositories, emitOperations, randomSha, now, profile, state, repositories, recompute, snapshot, commitHistory, advanceOperation, syncState, diffs, findRepo, findProfileRepo, branchesByRepo, stashesByRepo, updateOperation } = context; + return { + async bootstrap() { + await wait(80); + snapshot(); + return { + appVersion: "0.10.0-demo", + platform: "win32", + state: clone(state), + git: { available: true, version: "git version 2.47.3" }, + diagnostics: { + enabled: true, + level: state.preferences.diagnosticLevel, + retentionDays: state.preferences.logRetentionDays, + maxFileMb: state.preferences.maxLogFileMb, + directory: "/AppData/Roaming/ForgeFlow/diagnostics", + fileCount: 2, + totalBytes: 18432, + totalSize: "18.0 KB", + latestAt: iso(-2000), + lastWriteError: null, + }, + }; + }, + async selectDirectory() { + await wait(); + return "C:\\Development"; + }, + async selectKeyFile() { + await wait(); + return "C:\\Users\\Jens\\.ssh\\id_ed25519"; + }, + async setupPreflight({ baseUrl, token, roots = [] }) { + await wait(240); + const checks = [ + { + id: "git.available", + label: "Git command line", + status: "pass", + detail: "git version 2.47.3", + required: true, + }, + { + id: "git.identity", + label: "Git author identity", + status: "pass", + detail: "Jens ", + required: false, + }, + { + id: "storage.userdata", + label: "Application data storage", + status: "pass", + detail: "ForgeFlow can write its local configuration.", + required: true, + }, + { + id: "storage.diagnostics", + label: "Diagnostic log storage", + status: "pass", + detail: "The diagnostic directory is writable.", + required: true, + }, + { + id: "storage.credentials", + label: "Protected credential storage", + status: "pass", + detail: "The operating system can encrypt the Gitea token at rest.", + required: false, + }, + { + id: "workspace.roots", + label: "Development folders", + status: roots.length ? "pass" : "warning", + detail: roots.length + ? `${roots.length} folder(s) selected.` + : "No development folder selected yet.", + required: false, + }, + { + id: "gitea.connection", + label: "Gitea connection", + status: baseUrl && token ? "pass" : "warning", + detail: + baseUrl && token + ? "Connection parameters are ready for validation." + : "Enter the Gitea URL and token.", + required: false, + }, + ]; + return { + kind: "system", + startedAt: iso(-100), + completedAt: iso(), + checks, + summary: { + counts: { + pass: checks.filter((i) => i.status === "pass").length, + warning: checks.filter((i) => i.status === "warning").length, + fail: 0, + skipped: 0, + }, + blocking: [], + ready: true, + }, + }; + }, + async validateGitea({ baseUrl, token }) { + await wait(320); + if (!baseUrl || !token) + throw new Error("Enter an instance URL and access token."); + return { + baseUrl: baseUrl.replace(/\/$/, ""), + user: { login: "jens", full_name: "Jens" }, + repositoryCount: repositories.length, + version: "1.26.0", + }; + }, + async completeSetup(payload) { + await wait(300); + state.setupComplete = true; + state.gitea = { + baseUrl: payload.baseUrl, + user: payload.user, + hasToken: true, + }; + state.workspaceRoots = payload.workspaceRoots; + storage.set("forgeflow-demo-setup", "true"); + return { state: clone(state), tokenState: { persistent: true } }; + }, + async updateGitea(payload) { + const validation = await this.validateGitea({ + ...payload, + token: payload.token || "preserved-demo-token", + }); + state.gitea = { + baseUrl: validation.baseUrl, + user: validation.user, + hasToken: true, + }; + return { + validation, + tokenState: { persistent: true, preserved: !payload.token }, + state: clone(state), + }; + }, + async setWorkspaceRoots(roots) { + state.workspaceRoots = [...new Set(roots)]; + return clone(state); + }, + async setAppearance(appearance) { + state.appearance = appearance; + storage.set("forgeflow-theme", appearance); + return clone(state); + }, + async setPreferences(preferences) { + state.preferences = { ...state.preferences, ...preferences }; + snapshot(); + return clone(state); + }, + async setUpdatePreferences(updates) { + state.updates = { ...state.updates, ...updates }; + return clone(state); + }, + async checkForUpdates() { + await wait(300); + return { + checkedAt: iso(), + owner: state.updates.owner, + repo: state.updates.repo, + branch: state.updates.branch, + currentVersion: "0.5.4", + remoteVersion: "0.6.0", + remoteSha: "a".repeat(40), + shortSha: "aaaaaaa", + available: true, + mode: "source", + }; + }, + async downloadUpdate() { + await wait(500); + return { + ...(await this.checkForUpdates()), + downloaded: true, + archivePath: "C:\\Temp\\ForgeFlow-0.4.1.zip", + sha256: "b".repeat(64), + }; + }, + async applyUpdate() { + await wait(200); + return { launched: true, confirmed: true, version: "0.6.0" }; + }, + async saveServer(server) { + const saved = { + ...server, + id: server.id || `server-${Date.now()}`, + hasPassword: server.authType === "password", + hasPassphrase: false, + }; + state.servers = [ + saved, + ...state.servers.filter((item) => item.id !== saved.id), + ]; + return { server: clone(saved), state: clone(state) }; + }, + async deleteServer(serverId) { + state.servers = state.servers.filter((item) => item.id !== serverId); + return clone(state); + }, + async testServer(serverId) { + const server = state.servers.find((item) => item.id === serverId); + server.hostFingerprint = server.hostFingerprint || "SHA256:demo"; + return { + connected: true, + fingerprint: server.hostFingerprint, + server: clone(server), + output: "Linux\n/usr/bin/git\nDocker Compose version v2", + state: clone(state), + }; + }, + async inspectServerProject() { + return { + exists: true, + rootGit: true, + head: "d42d4a7".padEnd(40, "0"), + branch: "main", + trackedChanges: [], + composeFiles: ["docker-compose.yml"], + nestedGit: ["source"], + dockerfile: true, + }; + }, + async refreshRepositories() { + await wait(260); + return snapshot(); + }, + async discoverRepositories() { + await wait(360); + return snapshot() + .filter((repo) => repo.localPath) + .map((repo) => ({ + localPath: repo.localPath, + remoteUrl: repo.cloneUrl, + status: repo.localStatus, + })); + }, + async favoriteRepository(fullName, favorite) { + const key = fullName.toLowerCase(); + state.favorites = favorite + ? [...new Set([...state.favorites, key])] + : state.favorites.filter((item) => item !== key); + snapshot(); + return clone(state); + }, + async linkRepository(fullName, localPath) { + const repo = repositories.find((item) => item.fullName === fullName); + repo.localPath = localPath; + repo.linkState = "linked"; + repo.localStatus = makeStatus({ head: randomSha() }); + emitRepositories(); + return snapshot(); + }, + async unlinkRepository(fullName) { + const repo = repositories.find((item) => item.fullName === fullName); + repo.localPath = null; + repo.localStatus = null; + repo.linkState = "remote-only"; + emitRepositories(); + return snapshot(); + }, + async repositoryStatus(localPath) { + return clone(findRepo(localPath)?.localStatus); + }, + async repositoryDiff(localPath, filePath) { + await wait(80); + return ( + diffs[filePath] || + `diff --git a/${filePath} b/${filePath}\n--- a/${filePath}\n+++ b/${filePath}\n@@ -1 +1 @@\n-old\n+new` + ); + }, + async repositoryDiffHunks(localPath, filePath) { + const diff = await this.repositoryDiff(localPath, filePath); + return { + filePath, + partialSupported: true, + hunks: [ + { + index: 0, + heading: "@@ -1 +1 @@", + additions: 1, + deletions: 1, + lines: diff.split("\n").slice(-4), + }, + ], + }; + }, + async stageHunks(localPath, filePath) { + return this.stageFiles(localPath, [filePath]); + }, + async conflictState(localPath) { + const repo = findRepo(localPath); + const files = repo.localStatus.files + .filter((item) => item.conflict) + .map((item) => item.path); + return { + operation: files.length ? "merge" : null, + files, + canContinue: false, + status: clone(repo.localStatus), + }; + }, + async resolveConflict(localPath, filePath) { + const repo = findRepo(localPath); + const file = repo.localStatus.files.find( + (item) => item.path === filePath, + ); + if (file) { + file.conflict = false; + file.staged = true; + file.unstaged = false; + } + recompute(repo); + return this.conflictState(localPath); + }, + async continueGitOperation(localPath) { + return this.conflictState(localPath); + }, + async abortGitOperation(localPath) { + return this.conflictState(localPath); + }, + async stageFiles(localPath, files) { + const repo = findRepo(localPath); + repo.localStatus.files.forEach((item) => { + if (!files?.length || files.includes(item.path)) { + item.staged = true; + item.unstaged = false; + item.indexCode = item.untracked ? "A" : "M"; + item.worktreeCode = "."; + } + }); + recompute(repo); + emitRepositories(); + return clone(repo.localStatus); + }, + async unstageFiles(localPath, files) { + const repo = findRepo(localPath); + repo.localStatus.files.forEach((item) => { + if (!files?.length || files.includes(item.path)) { + item.staged = false; + item.unstaged = true; + item.indexCode = "."; + item.worktreeCode = item.untracked ? "?" : "M"; + } + }); + recompute(repo); + emitRepositories(); + return clone(repo.localStatus); + }, + async commit(localPath, message, files) { + await wait(520); + if (!message?.trim()) throw new Error("Enter a commit message."); + const repo = findRepo(localPath); + repo.localStatus.files = repo.localStatus.files.filter( + (item) => !files?.includes(item.path), + ); + repo.localStatus.head = randomSha(); + repo.localStatus.branch.ahead += 1; + recompute(repo); + emitRepositories(); + return { + commitOutput: `[${repo.localStatus.branch.head} ${repo.localStatus.shortHead}] ${message}`, + commitSha: repo.localStatus.head, + status: clone(repo.localStatus), + }; + }, + async commitAndPush(localPath, message, files) { + const result = await this.commit(localPath, message, files); + const repo = findRepo(localPath); + await wait(240); + repo.localStatus.branch.ahead = 0; + recompute(repo); + emitRepositories(); + return { + ...result, + pushOutput: "Push completed.", + status: clone(repo.localStatus), + }; + }, + async commitStaged(localPath, message) { + const repo = findRepo(localPath); + return this.commit( + localPath, + message, + repo.localStatus.files + .filter((item) => item.staged) + .map((item) => item.path), + ); + }, + async commitStagedAndPush(localPath, message) { + const repo = findRepo(localPath); + return this.commitAndPush( + localPath, + message, + repo.localStatus.files + .filter((item) => item.staged) + .map((item) => item.path), + ); + }, + async push(localPath) { + await wait(360); + const repo = findRepo(localPath); + repo.localStatus.branch.ahead = 0; + recompute(repo); + emitRepositories(); + return { output: "Push completed.", status: clone(repo.localStatus) }; + }, + async fetch() { + await wait(260); + return { output: "Fetch completed." }; + }, + async pull(localPath) { + await wait(380); + const repo = findRepo(localPath); + repo.localStatus.branch.behind = 0; + recompute(repo); + emitRepositories(); + return { output: "Fast-forwarded.", status: clone(repo.localStatus) }; + }, + async history() { + await wait(100); + return clone(commitHistory); + }, + async branchProtection(fullName, branch) { + return { + branch, + protected: branch === "main", + requiredApprovals: branch === "main" ? 1 : 0, + requireSignedCommits: false, + }; + }, + async pullRequests() { + return [ + { + number: 42, + title: "Harden deployment preflight", + html_url: "https://gitea.internal/jens/vacancyradar/pulls/42", + created_at: iso(-7_200_000), + updated_at: iso(-900_000), + head: { ref: "feature/deployment-api" }, + base: { ref: "main" }, + }, + ]; + }, + async createPullRequest(fullName, title, body, base) { + return { + number: 42, + title, + body, + base, + html_url: `https://gitea.internal/${fullName}/pulls/42`, + }; + }, + async branches(localPath) { + const repo = findRepo(localPath); + if (!branchesByRepo.has(localPath)) + branchesByRepo.set(localPath, [ + { + name: repo.localStatus.branch.head, + current: true, + sha: repo.localStatus.head, + shortSha: repo.localStatus.shortHead, + upstream: repo.localStatus.branch.upstream, + }, + { + name: "main", + current: repo.localStatus.branch.head === "main", + sha: repo.localStatus.head, + shortSha: repo.localStatus.shortHead, + upstream: "origin/main", + }, + ]); + return clone(branchesByRepo.get(localPath)); + }, + async checkoutBranch(localPath, branch) { + const repo = findRepo(localPath); + if (!repo.localStatus.clean) + throw new Error( + "Commit or stash local changes before switching branches.", + ); + const list = await this.branches(localPath); + list.forEach((item) => { + item.current = item.name === branch; + }); + branchesByRepo.set(localPath, list); + repo.localStatus.branch.head = branch; + repo.localStatus.branch.upstream = `origin/${branch}`; + recompute(repo); + emitRepositories(); + return { status: clone(repo.localStatus), branches: clone(list) }; + }, + async createBranch(localPath, branch) { + const repo = findRepo(localPath); + const list = await this.branches(localPath); + list.forEach((item) => { + item.current = false; + }); + list.unshift({ + name: branch, + current: true, + sha: repo.localStatus.head, + shortSha: repo.localStatus.shortHead, + upstream: null, + }); + branchesByRepo.set(localPath, list); + repo.localStatus.branch.head = branch; + repo.localStatus.branch.upstream = null; + recompute(repo); + emitRepositories(); + return { status: clone(repo.localStatus), branches: clone(list) }; + }, + async stash(localPath, message) { + const repo = findRepo(localPath); + const list = stashesByRepo.get(localPath) || []; + list.unshift({ + ref: `stash@{${list.length}}`, + subject: message || "ForgeFlow stash", + date: iso(), + }); + stashesByRepo.set(localPath, list); + repo.localStatus.files = []; + recompute(repo); + emitRepositories(); + return { + output: "Saved working directory and index state.", + status: clone(repo.localStatus), + stashes: clone(list), + }; + }, + async stashList(localPath) { + return clone(stashesByRepo.get(localPath) || []); + }, + async popStash(localPath, ref) { + const repo = findRepo(localPath); + const list = stashesByRepo.get(localPath) || []; + const index = list.findIndex((item) => item.ref === ref); + if (index < 0) throw new Error("Stash not found."); + list.splice(index, 1); + stashesByRepo.set(localPath, list); + repo.localStatus.files = [makeFile("src/restored-from-stash.ts")]; + recompute(repo); + emitRepositories(); + return { + output: "Stash applied.", + status: clone(repo.localStatus), + stashes: clone(list), + }; + }, + async indexLockInfo() { + return { exists: false, ageMs: 0 }; + }, + async repairIndexLock() { + return { removed: true }; + }, + async setOrigin(localPath, remoteUrl) { + const repo = findRepo(localPath); + repo.localStatus.remoteUrl = remoteUrl; + repo.sshUrl = remoteUrl; + emitRepositories(); + return clone(repo.localStatus); + }, + async normalizeOrigins() { + const changes = []; + repositories + .filter((repo) => repo.localPath && repo.sshUrl) + .forEach((repo) => { + if (repo.localStatus.remoteUrl !== repo.sshUrl) { + changes.push({ + fullName: repo.fullName, + previous: repo.localStatus.remoteUrl, + next: repo.sshUrl, + }); + repo.localStatus.remoteUrl = repo.sshUrl; + } + }); + emitRepositories(); + return { changes, repositories: snapshot() }; + }, + async cloneRepository(fullName, mode = "default") { + await wait(620); + const repository = repositories.find( + (item) => item.fullName === fullName, + ); + if (!repository) throw new Error("Repository not found."); + if (repository.localPath) + throw new Error("This repository already has a linked local folder."); + const root = + mode === "custom" ? "D:\\OtherProjects" : state.workspaceRoots[0]; + if (!root) return { cancelled: true }; + const target = `${root.replace(/[\\/]+$/, "")}\\${repository.name}`; + const head = randomSha(); + repository.localPath = target; + repository.localStatus = makeStatus({ + head, + branch: repository.defaultBranch || "main", + }); + repository.localStatus.root = target; + repository.localStatus.remoteUrl = + repository.preferredCloneUrl || repository.cloneUrl; + repository.linkState = "linked"; + recompute(repository); + const current = snapshot(); + emitRepositories(); + return { + target, + status: clone(repository.localStatus), + reused: false, + repositories: current, + state: clone(state), + }; + }, + async openPath() { + return true; + }, + async openEditor() { + return { launched: true, executable: "code" }; + }, + async openTerminal() { + return { launched: true, executable: "wt.exe" }; + }, + async openExternal() { + return true; + }, + }; +} diff --git a/src/renderer/operations.js b/src/renderer/operations.js new file mode 100644 index 0000000..6538dc6 --- /dev/null +++ b/src/renderer/operations.js @@ -0,0 +1,211 @@ +async function runOperation( + message, + operation, + successMessage, + { refresh = true } = {}, +) { + setLoading(true, message); + try { + const result = await operation(); + if (successMessage) showToast("Done", successMessage, "success"); + if (refresh) await refreshRepositories(false); + return result; + } catch (error) { + const pushAfterCommit = error.code === "PUSH_AFTER_COMMIT_FAILED"; + showToast( + pushAfterCommit + ? "Commit saved locally; push failed" + : "Operation failed", + error.message, + "error", + ); + // Always reload the real Git state. A failed stage must keep changes visible, while a + // failed push after a successful commit must immediately surface as an ahead branch. + await refreshRepositories(false, true); + if (pushAfterCommit) { + ui.selectedFiles.clear(); + ui.selectedFile = null; + ui.diff = ""; + ui.commitMessage = ""; + render(); + } + return null; + } finally { + setLoading(false); + } +} + +async function executeDeployment(profileId) { + const repository = selectedRepository(); + const profile = + repository?.deploymentProfiles?.find((item) => item.id === profileId) || + selectedProfile(repository); + if (!repository || !profile) return; + const targetSha = deploymentTargetSha(repository, profile); + if (!targetSha) { + showToast("Refresh required", "Refresh Gitea and server truth before deploying this environment.", "error"); + return; + } + const deploymentOptions = { + note: document.querySelector("#deployment-note")?.value.trim() || "", + override: document.querySelector("#deployment-override")?.checked === true, + overrideReason: + document.querySelector("#deployment-override-reason")?.value.trim() || "", + }; + ui.modal = null; + setLoading( + true, + profile.provider === "ssh-unraid" + ? `Deploying ${repository.name} to ${profile.remoteFolder} over SSH…` + : `Dispatching ${profile.name} workflow…`, + ); + try { + ui.activeDeployment = await window.forgeflow.deploy( + repository, + profile.id, + targetSha, + deploymentOptions, + ); + updateOperationInState(ui.activeDeployment); + ui.currentView = "deployment-run"; + showToast( + "Deployment started", + `${repository.name} ${shortSha(targetSha)} → ${profile.environment}`, + "success", + ); + startOperationPolling(); + } catch (error) { + if (profile.provider === "ssh-unraid" && isSshCredentialError(error)) { + ui.modal = { + type: "server-password", + serverId: profile.serverId, + retry: { + type: "deploy", + repositoryFullName: repository.fullName, + profileId: profile.id, + }, + }; + showToast("SSH key rejected", "Enter the Unraid server password once; ForgeFlow will retry the direct desktop → Unraid connection.", "error"); + render(); + } else { + showToast("Deployment failed to start", error.message, "error"); + } + } + setLoading(false); +} + +async function executeRollback(profileId) { + const repository = selectedRepository(); + const profile = repository?.deploymentProfiles?.find( + (item) => item.id === profileId, + ); + const target = profile?.state?.previousSha; + if (!repository || !profile || !target) return; + ui.modal = null; + setLoading( + true, + profile?.provider === "ssh-unraid" + ? `Rolling back ${profile.remoteFolder} over SSH…` + : `Dispatching rollback to ${shortSha(target)}…`, + ); + try { + ui.activeDeployment = await window.forgeflow.rollback( + repository, + profile.id, + target, + ); + updateOperationInState(ui.activeDeployment); + ui.currentView = "deployment-run"; + showToast( + "Rollback requested", + `${profile.environment} → ${shortSha(target)}`, + "success", + ); + } catch (error) { + showToast("Rollback failed to start", error.message, "error"); + } + setLoading(false); +} + +async function loadGitTools(repository) { + if (!repository?.localPath) return; + setLoading(true, "Loading branches and stashes…"); + try { + [ui.branches, ui.stashes, ui.gitRecovery] = await Promise.all([ + window.forgeflow.branches(repository.localPath), + window.forgeflow.stashList(repository.localPath), + window.forgeflow.gitRecoveryStatus(repository.localPath), + ]); + ui.repositoryTab = "gittools"; + } catch (error) { + showToast("Git tools unavailable", error.message, "error"); + } + setLoading(false); +} + +function profileRepository(profileId) { + return ui.repositories.find((repository) => + repository.deploymentProfiles?.some((profile) => profile.id === profileId), + ); +} + +async function runSystemPreflight({ setup = false } = {}) { + setLoading(true, "Checking local readiness…"); + try { + ui.systemPreflight = await window.forgeflow.setupPreflight({ + baseUrl: ui.setupDraft.baseUrl, + token: ui.setupDraft.token, + roots: setup ? ui.setupDraft.roots : ui.boot.state.workspaceRoots, + }); + if (!setup) + ui.diagnosticsStatus = await window.forgeflow.diagnosticsStatus(); + showToast( + ui.systemPreflight.summary.ready + ? "Readiness checks passed" + : "Readiness needs attention", + ui.systemPreflight.summary.ready + ? `${ui.systemPreflight.summary.counts.pass} checks passed.` + : `${ui.systemPreflight.summary.blocking.length} blocking check(s) must be resolved.`, + ui.systemPreflight.summary.ready ? "success" : "error", + ); + return ui.systemPreflight; + } catch (error) { + showToast("Readiness check failed", error.message, "error"); + return null; + } finally { + setLoading(false); + } +} + +async function runDeploymentPreflight( + repository, + profileId, + { showModal = true } = {}, +) { + if (!repository || !profileId) return null; + if (String(repository.id) !== String(ui.selectedRepoId)) + selectRepository(repository.id, false); + ui.selectedProfileId = profileId; + ui.deploymentPreflight = null; + setLoading(true, "Verifying repository, workflow and server…"); + try { + const report = await window.forgeflow.deploymentPreflight( + repository, + profileId, + ); + ui.deploymentPreflight = report; + if (showModal) + ui.modal = { + type: "deployment-preflight", + profileId, + repositoryFullName: repository.fullName, + }; + return report; + } catch (error) { + showToast("Deployment preflight failed", error.message, "error"); + return null; + } finally { + setLoading(false); + } +} + diff --git a/src/renderer/views.js b/src/renderer/views.js new file mode 100644 index 0000000..bfba6e7 --- /dev/null +++ b/src/renderer/views.js @@ -0,0 +1,694 @@ +function navButton(view, label, iconName, count = "") { + return ``; +} + +function renderTitlebar() { + const state = ui.boot?.state; + const user = state?.gitea?.user; + const connected = Boolean(state?.gitea?.hasToken); + const repository = selectedRepository(); + const title = + ui.currentView === "repository" && repository + ? repository.fullName + : { + overview: "Release overview", + deployments: "Deployments", + diagnostics: "Diagnostics", + settings: "Settings", + "deployment-run": "Deployment run", + }[ui.currentView] || "Workspace"; + return `
+
ForgeFlowby ITWorx.tech
${escapeHtml(title)}
+
+ +
${icon("search")}
+ ${connected ? escapeHtml(user?.login || "Gitea") : "Offline"} + + +
+
`; +} + +function renderRepositoryRow(repository) { + const status = repository.localStatus; + const badges = []; + if (status?.counts.conflicts) + badges.push('!'); + else if (status?.counts.changed) + badges.push( + `${status.counts.changed}`, + ); + if (status?.branch.ahead) + badges.push( + `↑${status.branch.ahead}`, + ); + if (status?.branch.behind) + badges.push( + `↓${status.branch.behind}`, + ); + if (repository.readyToDeploy) + badges.push( + '', + ); + if (!repository.localPath) + badges.push(''); + const branch = status?.branch.head || repository.defaultBranch || "remote"; + return ``; +} + +function renderSidebar() { + const query = `${ui.search} ${ui.repoSearch}`.trim().toLowerCase(); + const repositories = ui.repositories.filter( + (repository) => + !query || + `${repository.name} ${repository.fullName} ${repository.description}` + .toLowerCase() + .includes(query), + ); + const favorites = repositories.filter((repository) => repository.favorite); + const others = repositories.filter((repository) => !repository.favorite); + const attention = ui.repositories.filter( + (repository) => + repository.attention || + repository.localStatus?.counts.changed || + repository.localStatus?.branch.ahead || + repository.readyToDeploy, + ).length; + const rows = (list) => list.map(renderRepositoryRow).join(""); + return ``; +} + +function renderSummaryCard(label, value, note, iconName, tone = "") { + return `
${icon(iconName)}
${label}
${value}
${note}
`; +} + +function queueActionFor(repository) { + const action = repositoryAction(repository); + const mapping = { + link: ["folder", "Link folder", "Local project is not connected", ""], + error: ["error", "Inspect problem", action.detail, "danger"], + conflict: ["warning", "Resolve conflicts", action.detail, "danger"], + commit: ["file", "Review & commit", action.detail, "warning"], + diverged: ["warning", "Resolve divergence", action.detail, "danger"], + pull: ["arrowDown", "Synchronize", action.detail, "warning"], + push: ["arrowUp", "Push commits", action.detail, ""], + configure: ["settings", "Configure deploy", action.detail, ""], + "branch-profile": ["branch", "Select profile", action.detail, ""], + deploy: ["rocket", "Deploy release", action.detail, "success"], + clean: ["check", "Synchronized", action.detail, "success"], + }; + return mapping[action.kind] || mapping.clean; +} + +function projectIllustration(kind = "flow") { + return ``; +} + +function renderOverview() { + const changed = ui.repositories.filter( + (repository) => repository.localStatus?.counts.changed, + ).length; + const unpushed = ui.repositories.filter( + (repository) => repository.localStatus?.branch.ahead, + ).length; + const deployable = ui.repositories.filter( + (repository) => repository.readyToDeploy, + ).length; + const unhealthy = ui.repositories + .flatMap((repository) => repository.deploymentProfiles || []) + .filter((profile) => profile.state?.healthy === false).length; + const queue = ui.repositories + .filter((repository) => repositoryAction(repository).kind !== "clean") + .slice(0, 8); + const recent = operations().slice(0, 7); + const active = recent.filter( + (operation) => !isTerminalOperation(operation.status), + ); + return `
+ + ${ui.refreshError ? `
${icon("error")} ${escapeHtml(ui.refreshError)}
` : ""} +
+ ${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("Ready", deployable, "exact commits ready to deploy", "rocket", deployable ? "success" : "")} + ${renderSummaryCard("Health", unhealthy || active.length, unhealthy ? "unhealthy environments" : active.length ? "operations in progress" : "all checked environments healthy", "pulse", unhealthy ? "danger" : active.length ? "warning" : "success")} +
+

Action queue

Sorted by required attention
+ ${ + queue.length + ? queue + .map((repository) => { + const [iconName, label, reason, tone] = + queueActionFor(repository); + return `
${icon(iconName)}
${escapeHtml(repository.name)}
${escapeHtml(repository.localStatus?.branch.head || repository.defaultBranch || "remote")} ${repository.localStatus?.shortHead ? `• ${repository.localStatus.shortHead}` : ""}
${escapeHtml(label)}${escapeHtml(reason)}
`; + }) + .join("") + : '

Everything is synchronized

No repository needs immediate attention.

' + } +
+
+

Recent deployments

${recent.length ? recent.map((operation) => `
${escapeHtml(operation.repository)} → ${escapeHtml(operation.environment || "environment")}
${escapeHtml(operation.action === "rollback" ? "Rollback" : "Deploy")} ${escapeHtml(operation.shortSha || shortSha(operation.sha))} · ${escapeHtml(operation.status)}
${formatDate(operation.updatedAt || operation.createdAt)}
`).join("") : '

No deployment history yet.

'}
+

Workspace readiness

+ ${readinessRow("Git executable", ui.boot.git.available, ui.boot.git.version || ui.boot.git.error)} + ${readinessRow("Gitea connection", ui.boot.state.gitea.hasToken, ui.boot.state.gitea.baseUrl || "Not configured")} + ${readinessRow("Workspace folders", ui.boot.state.workspaceRoots.length > 0, `${ui.boot.state.workspaceRoots.length} configured`)} + ${readinessRow("Automatic awareness", ui.boot.state.preferences?.autoRefresh !== false, ui.boot.state.preferences?.autoRefresh === false ? "Manual refresh only" : `Every ${ui.boot.state.preferences?.repositoryPollSeconds || 4}s`)} +
+
+
`; +} + +function readinessRow(label, ok, detail) { + return `
${escapeHtml(label)}${escapeHtml(detail)}
`; +} +function releaseNode(label, value, description, tone = "") { + return `
${label}
${escapeHtml(value)}${escapeHtml(description)}
`; +} + +function diffAtmosphere(diff) { + if (!ui.selectedFile) return ""; + const lines = String(diff || "").split("\n"); + const additions = lines.filter( + (line) => line.startsWith("+") && !line.startsWith("+++"), + ).length; + const removals = lines.filter( + (line) => line.startsWith("-") && !line.startsWith("---"), + ).length; + const extension = + String(ui.selectedFile).split(".").pop()?.slice(0, 8).toUpperCase() || + "FILE"; + return ``; +} + +function renderDiff(diff) { + if (!diff) + return '

No textual diff

Select another file or open the project folder for binary changes.

'; + const rendered = escapeHtml(diff) + .split("\n") + .map((line) => { + const type = + line.startsWith("+") && !line.startsWith("+++") + ? "add" + : line.startsWith("-") && !line.startsWith("---") + ? "remove" + : line.startsWith("@@") + ? "hunk" + : ""; + return `${line || " "}`; + }) + .join(""); + return `${rendered}${diffAtmosphere(diff)}`; +} +function fileStatusCode(file) { + if (file.conflict) return "U"; + if (file.untracked) return "?"; + return ( + { + modified: "M", + added: "A", + deleted: "D", + renamed: "R", + copied: "C", + "type-changed": "T", + }[file.status] || "M" + ); +} + +function renderChanges(repository) { + const status = repository.localStatus; + if (!repository.localPath) { + const target = displayCloneTarget(repository); + return `
${icon("link")}

Connect a local project

Clone directly into your default project root, or link an existing working tree.

${target ? `
${icon("folder")}Automatic destination${escapeHtml(target)}
` : '
No default project root is configured. ForgeFlow will ask for one.
'}
`; + } + if (!status) + return `
${icon("error")}

Repository unavailable

${escapeHtml(repository.attentionReason || "The local working tree could not be read.")}

`; + if (!status.files.length) + return `
${icon("check")}

Working tree clean

Local ${escapeHtml(status.branch.head)} is at ${escapeHtml(status.shortHead)} with no uncommitted files.

`; + const selected = status.files.find((file) => file.path === ui.selectedFile); + const conflictActions = selected?.conflict + ? `
Conflicted file

Choose one side, or edit the file and mark it resolved.

` + : ""; + return `
${ui.selectedFiles.size} selected · ${status.counts.changed} changed · ${status.counts.staged} staged
${status.files.map((file) => `
${fileStatusCode(file)}${escapeHtml(file.path)}${file.staged ? "●" : "○"}
`).join("")}
${status.counts.conflicts ? `
` : ""}
${conflictActions}
${escapeHtml(ui.selectedFile || "Select a file")}
${ui.diffHunks?.partialSupported ? `` : ""}${ui.selectedFile ? `` : ""}${ui.selectedFile ? escapeHtml(selected?.status || "") : ""}
${renderDiff(ui.diff)}
`; +} + +function renderHistory(repository) { + if (!repository.localPath) + return '

Link a local repository to view commit history.

'; + if (!ui.history.length) + return `
${icon("history")}

Load local commit history

Review the last commits from this working tree.

`; + return `
${ui.history.map((commit) => ``).join("")}
CommitMessageAuthorDate
${escapeHtml(commit.shortSha)}${escapeHtml(commit.subject)}${escapeHtml(commit.author)}${formatDate(commit.date)}
`; +} + +function environmentState(profile) { + const state = profile.state || {}; + if (state.healthy === false) return { label: "Unhealthy", tone: "danger" }; + if (state.healthy === true) return { label: "Healthy", tone: "success" }; + if (state.containerRunning === true) return { label: "Running · unverified", tone: "warning" }; + if (state.containerRunning === false) return { label: "Stopped", tone: "danger" }; + if (profile.provider === "ssh-unraid" || state.statusConfigured || state.healthConfigured) + return { label: "Not checked", tone: "" }; + return { label: "Status not configured", tone: "" }; +} + +function dockerManIntegration(profile) { + const state = profile.state || {}; + const iconMode = + profile.iconMode || + (profile.iconFilePath ? "upload" : profile.iconUrl ? "url" : "builtin"); + const webUiExpected = Boolean(profile.webUiUrl || profile.hostPort); + const iconExpected = iconMode !== "none"; + const templateReady = Boolean(state.dockerMan?.templateExists); + const webUiReady = + !webUiExpected || Boolean(state.dockerMan?.webUi) || templateReady; + const iconReady = + !iconExpected || Boolean(state.dockerMan?.icon) || templateReady; + return { + iconMode, + templateReady, + webUiReady, + iconReady, + ready: Boolean(state.containerRunning && webUiReady && iconReady), + }; +} + +function deploymentIdentity(profile, repository) { + const name = String( + profile.state?.containerName || + profile.containerName || + profile.remoteFolder || + repository.name || + "container", + ); + let hash = 0; + for (const character of name) + hash = (hash * 31 + character.charCodeAt(0)) >>> 0; + return { name, initial: name.slice(0, 1).toUpperCase(), accent: hash % 6 }; +} + +function renderProfileCard(repository, profile, compact = false) { + const state = profile.state || {}; + const health = environmentState(profile); + const isSsh = profile.provider === "ssh-unraid"; + const mode = deploymentMode(profile); + const verification = ui.serverGitVerifications[profile.id]; + const targetSha = deploymentTargetSha(repository, profile); + const ready = canDeploy(repository, profile); + const modeLabel = { + "push-bundle": "Direct copy", + "server-git": "Server pull from Gitea", + "monitor-only": "Monitor only", + }[mode] || mode; + const providerDetail = isSsh + ? `SSH / Unraid · ${modeLabel} · ${profile.remoteFolder || repository.name} · ${profile.branch}${profile.adoptedFromServer ? " · server-linked" : ""}` + : `${profile.workflowFile} · ${profile.branch}`; + const rollbackConfigured = (isSsh && mode !== "monitor-only") || Boolean(profile.rollbackWorkflowFile); + const dockerMan = dockerManIntegration(profile); + const { templateReady, webUiReady, iconReady } = dockerMan; + const dockerManReady = dockerMan.ready; + const managesDockerMan = isSsh && profile.manageDockerMan === true; + const webUi = profile.webUiUrl || state.webUiUrl || state.dockerMan?.webUi || ""; + const identity = deploymentIdentity(profile, repository); + const syncLabel = isSsh + ? state.matchesGitea + ? `${icon("check")}Live = Gitea · ${shortSha(state.liveSha)}` + : state.liveSha && state.giteaSha + ? `Live ${shortSha(state.liveSha)} · Gitea ${shortSha(state.giteaSha)}` + : state.liveSha ? `${icon("check")}Live · ${shortSha(state.liveSha)}` : "" + : state.matchesGitea + ? `${icon("check")}Live = Gitea · ${shortSha(state.liveSha)}` + : state.giteaSha && state.liveSha + ? `Live ${shortSha(state.liveSha)} · Gitea ${shortSha(state.giteaSha)}` + : ""; + const dockerManLabel = managesDockerMan + ? dockerManReady + ? templateReady + ? "Managed labels/template active" + : "Managed labels active" + : `Managed · WebUI ${webUiReady ? "ready" : "missing"} · icon ${iconReady ? "ready" : "missing"}` + : "Existing DockerMan template preserved"; + const sourceLabel = isSsh + ? mode === "server-git" ? `Gitea ${state.giteaSha ? shortSha(state.giteaSha) : "refresh required"}` : "Committed local HEAD" + : state.giteaSha ? shortSha(state.giteaSha) : "Refresh to compare"; + const serverAccessAction = isSsh && mode === "server-git" + ? `` + : ""; + return `
${escapeHtml(identity.initial)}
Container${escapeHtml(identity.name)}${escapeHtml(repository.fullName)} · ${escapeHtml(profile.environment)}
${syncLabel}
${escapeHtml(isSsh ? "SSH / UNRAID" : "GITEA ACTIONS")}

${escapeHtml(profile.name)}

${escapeHtml(providerDetail)}

${health.label}
${isSsh ? `` : ""}${serverAccessAction}${webUi ? `` : ""}${managesDockerMan ? `` : ""}${ready ? `` : ""}${state.previousSha && rollbackConfigured ? `` : ""}
`; +} +function renderRepositoryDeployments(repository) { + const profiles = repository.deploymentProfiles || []; + const repoOps = repositoryOperations(repository).slice(0, 10); + return `

Deployment environments

Exact-commit Gitea Actions or pinned SSH / Unraid deployments
${profiles.length ? `
${profiles.map((profile) => renderProfileCard(repository, profile)).join("")}
` : '

No deployment profile

Connect a Gitea Actions workflow or a trusted SSH / Unraid server.

'}

Release history

${repoOps.length ? `${repoOps.map((operation) => ``).join("")}
ActionEnvironmentCommitStatusUpdated
${escapeHtml(operation.action || "deploy")}${escapeHtml(operation.environment)}${escapeHtml(operation.shortSha || shortSha(operation.sha))}${escapeHtml(operation.status)}${formatDate(operation.updatedAt || operation.createdAt)}
` : '

No releases for this repository yet.

'}
`; +} + +function renderGitTools(repository) { + if (!repository.localPath) + return '

Link a local repository to manage branches and stashes.

'; + const recovery = ui.gitRecovery; + const locks = recovery?.lockReport?.locks || []; + const activeProcesses = recovery?.lockReport?.processes?.active || []; + const recommendations = recovery?.recommendations || []; + return `

Branches

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

Load branch information.

'}

Stashes

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

No stashes, or Git tools have not been loaded.

'}

Repository troubleshooting

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

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

'}
${repository.sshUrl && repository.localStatus?.remoteUrl !== repository.sshUrl ? `` : ""}
Lock repair refuses to run while a matching Git process is active. A force option is shown only when process detection itself is unavailable.
`; +} + +function renderRepositorySettings(repository) { + const automaticTarget = displayCloneTarget(repository); + const currentOrigin = repository.localStatus?.remoteUrl || "Unavailable"; + const desiredOrigin = repository.sshUrl || repository.preferredCloneUrl || ""; + const originNeedsRepair = Boolean( + repository.localPath && desiredOrigin && currentOrigin !== desiredOrigin, + ); + const pullRequests = ui.pullRequests || []; + return `

Repository identity

${desiredOrigin ? `
` : ""}
${originNeedsRepair ? `` : ""}${repository.localPath ? `` : ``}

Open pull requests

Live from Gitea
${pullRequests.length ? `
${pullRequests.map((pull) => `
#${pull.number} · ${escapeHtml(pull.title)}${escapeHtml(pull.head?.ref || pull.head?.label || "source")} → ${escapeHtml(pull.base?.ref || pull.base?.label || "target")} · ${formatDate(pull.updated_at || pull.created_at)}
`).join("")}
` : '

No open pull requests.

'}

Repository behavior

${icon("shield")}Origin repair changes only the Git remote URL. Git health scans the actual Git directory, repairs only proven stale lock files and never changes source files or commits.
`; +} + +function renderGitValidator(repository) { + const report = ui.gitValidation; + if (!report) + return `
${projectIllustration("diagnostics")}
Repository assurance

Validate Git best practices

Inspect repository identity, branch governance, tracked secrets, file hygiene and safe local synchronization settings.

`; + const tone = + report.score >= 90 ? "success" : report.score >= 70 ? "warning" : "danger"; + const safeFixes = report.checks.filter( + (check) => check.fixAction && check.safe, + ); + const groups = report.checks.reduce((grouped, check) => { + (grouped[check.category] ||= []).push(check); + return grouped; + }, {}); + return `
${report.score}/ 100
Git assurance score

${escapeHtml(report.grade)}

${report.summary.passed} passed · ${report.summary.warnings} recommendations · ${report.summary.errors} critical

${projectIllustration("diagnostics")}
${safeFixes.length ? `` : ""}
${Object.entries( + groups, + ) + .map( + ([category, checks]) => + `

${escapeHtml(category)}

${checks.filter((check) => check.status === "pass").length}/${checks.length} passed
${checks + .map((check) => { + const checkIndex = report.checks.indexOf(check); + return `
${icon(check.status === "pass" ? "check" : check.status === "error" ? "error" : "warning")}
${escapeHtml(check.title)}

${escapeHtml(check.detail)}

${check.fixAction ? `` : `${check.status === "pass" ? "Best practice" : "Review"}`}
`; + }) + .join("")}
`, + ) + .join("")}
`; +} + +function renderRepositoryWorkspace(repository) { + const status = repository.localStatus; + const profile = selectedProfile(repository); + const serverState = profile?.state || {}; + const localTone = status?.counts.conflicts + ? "danger" + : status?.counts.changed + ? "warning" + : status + ? "success" + : ""; + const remoteTone = status?.branch.behind + ? "danger" + : status?.branch.ahead + ? "warning" + : status?.branch.upstream + ? "success" + : ""; + const serverTone = + serverState.healthy === false + ? "danger" + : serverState.healthy === true + ? "success" + : ""; + const content = ( + { + changes: renderChanges, + history: renderHistory, + deployments: renderRepositoryDeployments, + gittools: renderGitTools, + validator: renderGitValidator, + settings: renderRepositorySettings, + }[ui.repositoryTab] || renderChanges + )(repository); + return `

${escapeHtml(repository.fullName)}

${escapeHtml(repository.localPath || "No local working tree linked")}

${projectIllustration("repo")}
+ ${repository.localPath ? `
${ui.branchProtection ? `${ui.branchProtection.protected ? `Protected · ${ui.branchProtection.requiredApprovals || 0} approval(s)` : "Direct pushes allowed"}` : ""}
` : ""} +
${releaseNode("Local", status?.shortHead || "Not linked", status ? `${status.counts.changed} changes · ${status.branch.head}` : "No working tree", localTone)}${releaseNode("Gitea", status?.shortHead || "Unknown", status?.branch.upstream ? `${status.branch.ahead} ahead · ${status.branch.behind} behind` : "Branch not published", remoteTone)}${releaseNode(`Server${profile ? ` · ${profile.environment}` : ""}`, serverState.liveSha ? shortSha(serverState.liveSha) : "Unknown", profile ? (serverState.checkedAt ? `checked ${formatDate(serverState.checkedAt)}` : "not checked") : "No deployment profile", serverTone)}
+
${content}
`; +} + +function renderActionPanel(repository) { + const action = repositoryAction(repository); + const status = repository.localStatus; + const profile = selectedProfile(repository); + let body = ""; + if (action.kind === "link") { + const target = displayCloneTarget(repository); + body = `
${icon("link")}

${action.title}

${action.detail}

${target ? `
Project root${escapeHtml(defaultWorkspaceRoot())}New folder${escapeHtml(safeCloneFolderName(repository))}
` : '
No default project root is configured yet.
'}
`; + } else if (action.kind === "commit") { + const hasStagedSelection = status.counts.staged > 0; + const commitReady = Boolean( + (ui.selectedFiles.size || hasStagedSelection) && ui.commitMessage.trim(), + ); + const commitBlocker = + !ui.selectedFiles.size && !hasStagedSelection + ? "Select files or stage one or more hunks." + : !ui.commitMessage.trim() + ? "Enter a commit message to enable commit and push." + : ui.selectedFiles.size + ? "Ready to commit. ForgeFlow stages the selected files automatically." + : "Ready to commit only the reviewed staged hunks."; + body = `
${ui.selectedFiles.size ? `${ui.selectedFiles.size} of ${status.counts.changed} files selected` : `${status.counts.staged} staged file(s)`}Ctrl+Enter
${icon(commitReady ? "check" : "warning")}${escapeHtml(commitBlocker)}
Partial hunk staging is preserved when no complete files are selected.
`; + } else if (action.kind === "pull") + body = `
${icon("arrowDown")}

${action.title}

${action.detail}

`; + else if (action.kind === "push") + body = `
${icon("arrowUp")}

${action.title}

${action.detail}

`; + else if ( + action.kind === "diverged" || + action.kind === "conflict" || + action.kind === "error" + ) + body = `
${icon("error")}

${action.title}

${action.detail}

${action.kind === "diverged" ? `` : ""}
`; + else if (action.kind === "configure") + body = `
${icon("settings")}

${action.title}

${action.detail}

`; + else if (action.kind === "branch-profile") + body = `
${icon("branch")}

${action.title}

${action.detail}

${repository.deploymentProfiles.length > 1 ? `` : ""}
`; + else if (action.kind === "deploy") + body = `
${icon("rocket")}

Release ${escapeHtml(status.shortHead)}

${escapeHtml(profile.name)} will deploy the exact commit from ${escapeHtml(profile.branch)} to ${escapeHtml(profile.environment)}.

${repository.deploymentProfiles.length > 1 ? `` : ""}
Local${escapeHtml(status.shortHead)}Gitea${escapeHtml(status.shortHead)}Target${escapeHtml(profile.environment)}
${profile.state?.previousSha && profile.rollbackWorkflowFile ? `` : ""}
`; + else + body = `
${icon("check")}

${action.title}

${action.detail}

${profile ? `` : ""}
`; + return ``; +} + +function renderServerInventory() { + const servers = ui.serverDiscovery || []; + const configuredServers = ui.boot?.state?.servers || []; + const hiddenClassifications = new Set(["backup", "release-folder", "system-container", "manually-excluded"]); + const visibleForServer = (server) => (server.workloads || []).filter((workload) => + workload.reviewDecisionStale || workload.classification?.type === "duplicate" || (!hiddenClassifications.has(workload.classification?.type) && (workload.link || workload.runtime?.running || ["ambiguous", "orphan-container", "stopped-application", "historical-compose", "stale-link", "monitor-only"].includes(workload.classification?.type))), + ); + const reviewCount = servers.reduce((total, server) => total + visibleForServer(server).filter((workload) => !workload.link || workload.classification?.type === "stale-link" || workload.reviewDecisionStale).length, 0); + const serverCards = servers.map((server) => { + const capabilities = server.capabilities || {}; + const capabilityText = [ + capabilities.docker ? "Docker" : "Docker missing", + capabilities.compose ? "Compose" : "Compose missing", + capabilities.git ? "Git available" : "Git optional", + capabilities.tar && capabilities.checksum ? "Push ready" : "Push tools incomplete", + ].join(" · "); + const errorBlock = server.error + ? `
${icon("error")}
Server scan failed

${escapeHtml(server.error)}

` + : ""; + const warnings = (server.warnings || []).map((warning) => `
${icon("warning")}${escapeHtml(warning)}
`).join(""); + const visibleWorkloads = visibleForServer(server); + const hiddenCount = Math.max(0, (server.workloads || []).length - visibleWorkloads.length); + const workloads = visibleWorkloads.length + ? visibleWorkloads.map((workload) => { + const containers = (workload.containers || []).map((container) => container.name).filter(Boolean).join(", "); + const topCandidate = workload.candidates?.[0]; + const linked = (workload.status === "linked" || Boolean(workload.link)) && workload.classification?.type !== "stale-link"; + const classification = workload.classification?.type || workload.status || "review"; + const statusTone = linked && !workload.reviewDecisionStale ? "success" : ["ambiguous", "duplicate", "orphan-container"].includes(classification) || workload.reviewDecisionStale ? "danger" : "warning"; + const detail = workload.compose?.project + ? `Compose ${workload.compose.project} · ${(workload.compose.services || []).join(", ") || "services unknown"}` + : workload.dockerMan?.templatePath + ? `DockerMan ${workload.dockerMan.name || workload.displayName} · ${containers || "template only"}` + : `Container installation · ${containers || "unnamed"}`; + const candidate = linked + ? `Linked to ${workload.link?.repositoryFullName || "repository"}` + : topCandidate + ? `${topCandidate.repositoryFullName} suggested · ${topCandidate.confidence || topCandidate.status || "review required"}` + : "No repository candidate; select one manually"; + const canQuickLink = !linked && topCandidate && ["exact", "strong"].includes(topCandidate.confidence) && Boolean(workload.remoteFolderCandidate); + const linkButton = canQuickLink + ? `` + : ``; + const evidenceNote = workload.reviewDecisionStale ? "Saved decision is stale because server evidence changed" : workload.classification?.reason || "Awaiting review"; + return `
${escapeHtml(workload.displayName)}${escapeHtml(detail)} · ${workload.runtime?.running ? "running" : "stopped"}${escapeHtml(candidate)}${escapeHtml(evidenceNote)}${workload.metadata?.composeDefinitionError ? `Compose file found; validation warning: ${escapeHtml(workload.metadata.composeDefinitionError)}` : ""}
${escapeHtml(workload.reviewDecisionStale ? "Decision stale" : linked ? "Linked" : classification)}${linked ? `` : linkButton}
`; + }).join("") + : `

${server.error ? "No inventory could be read until the SSH connection works." : "Docker returned no containers, Compose projects or DockerMan templates."}

`; + return `

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

${server.running || 0} running · ${server.linked || 0} repository links · ${visibleWorkloads.filter((workload) => !workload.link).length} to review${hiddenCount ? ` · ${hiddenCount} unrelated/system workloads hidden` : ""}
${server.error ? "Scan failed" : escapeHtml(capabilityText)}${server.error ? "" : ``}
${errorBlock}${warnings}
${workloads}
`; + }).join(""); + const empty = configuredServers.length + ? `

Server inventory has not completed

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

` + : `

No Unraid server configured

Add the server with password authentication and ForgeFlow can copy and deploy projects directly.

`; + return `

Server inventory

Live Docker, Compose and DockerMan discovery, linked to Gitea
${servers.length ? `
${serverCards}
` : empty}
${icon("shield")}Server pull fetches an exact Gitea commit through a repository-scoped read-only deploy key, validates Compose and only then promotes the release. Direct copy remains an explicit fallback.
`; +} + +function renderDeployments() { + const cards = ui.repositories.flatMap((repository) => + (repository.deploymentProfiles || []).map((profile) => ({ repository, profile })), + ); + const active = operations().filter((operation) => !isTerminalOperation(operation.status)); + const missingDockerMan = cards.filter(({ profile }) => + profile.provider === "ssh-unraid" && + profile.manageDockerMan === true && + profile.state?.containerRunning && + !dockerManIntegration(profile).ready, + ); + return `
${active.length ? `
${icon("pulse")} ${active.length} deployment operation${active.length === 1 ? " is" : "s are"} still active. ForgeFlow reconciles these against the live server automatically.
` : ""}${renderServerInventory()}

Linked deployment environments

Stable Compose identity, live container health and exact Gitea commit parity
${cards.length ? cards.map(({ repository, profile }) => renderProfileCard(repository, profile, true)).join("") : '

No deployment environments configured

Scan a server and link an existing workload, or open a repository and add an environment.

'}

All operations

Newest first
${operations().length ? `${operations().map((operation) => ``).join("")}
RepositoryActionEnvironmentCommitStatusUpdated
${escapeHtml(operation.repository)}${escapeHtml(operation.action || "deploy")}${escapeHtml(operation.environment || "—")}${escapeHtml(operation.shortSha || shortSha(operation.sha))}${escapeHtml(operation.status)}${formatDate(operation.updatedAt || operation.createdAt)}
` : '

No operations recorded.

'}
`; +} +function renderSettings() { + const state = ui.boot.state; + const prefs = state.preferences || {}; + const update = ui.updateStatus; + const servers = state.servers || []; + return `
+

Gitea connection

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

ForgeFlow updates

Secure source update from ${escapeHtml(state.updates?.owner || "Jens")}/${escapeHtml(state.updates?.repo || "ForgeFlow")}
${icon(update?.available ? "download" : "check")}${update ? (update.available ? `ForgeFlow ${escapeHtml(update.remoteVersion)} is available` : `ForgeFlow ${escapeHtml(update.currentVersion)} is up to date`) : `Current version ${escapeHtml(ui.boot.appVersion)}`}${update ? `Branch ${escapeHtml(update.branch)} · commit ${escapeHtml(update.shortSha)} · checked ${formatDate(update.checkedAt)}` : "No update check in this session."}
${update?.available && !update.downloaded ? `` : ""}${update?.downloaded ? `` : ""}
${icon("shield")}The updater downloads an authenticated ZIP for the exact remote commit, verifies its SHA-256 checksum, runs the complete quality gate and restores the previous source version if validation fails.
+

SSH / Unraid servers

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

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

'}
+

Git remote maintenance

Standardize linked repositories to the current Gitea SSH URLs.

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

+

Project roots

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

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

Background awareness

+

Desktop integration

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

Encrypted configuration backup

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

+

Appearance

+

Danger zone

Reset removes local ForgeFlow configuration, repository links, profiles and operation history. It does not modify Git repositories or Gitea.

+
`; +} + +function preflightTone(status) { + return status === "pass" + ? "success" + : status === "fail" + ? "danger" + : status === "warning" + ? "warning" + : ""; +} + +function renderPreflightChecks( + report, + emptyMessage = "Run the preflight to verify this configuration.", +) { + if (!report?.checks?.length) + return `

${escapeHtml(emptyMessage)}

`; + return `
${report.checks.map((item) => `
${item.status === "pass" ? icon("check") : item.status === "fail" ? icon("error") : icon("warning")}
${escapeHtml(item.label)}${escapeHtml(item.detail)}${item.help ? `${escapeHtml(item.help)}` : ""}${item.repairAction ? `` : ""}
${escapeHtml(item.status)}
`).join("")}
`; +} + +function renderDiagnostics() { + const prefs = ui.boot.state.preferences || {}; + const status = ui.diagnosticsStatus || ui.boot.diagnostics || {}; + const report = ui.systemPreflight; + const trouble = ui.troubleshooter; + const troubleRows = + trouble?.issues + ?.map( + (item, index) => + `
${icon(item.severity === "error" ? "error" : "warning")}
${escapeHtml(item.title)}${escapeHtml(item.repository || "System")} · ${escapeHtml(item.detail)}
${item.repairable ? `` : 'Manual review'}
`, + ) + .join("") || ""; + return `
+
${icon("shield")}Credentials are never added to the diagnostic bundle. Known runtime secrets are redacted again during export. You can inspect the ZIP before sharing it.
+
+

Log storage

${status.lastWriteError ? "Write error" : status.enabled ? "Recording" : "Disabled"}
Files${escapeHtml(status.fileCount ?? "—")}
Total size${escapeHtml(status.totalSize || "—")}
Latest event${status.latestAt ? formatDate(status.latestAt) : "None"}
Retention${escapeHtml(status.retentionDays || prefs.logRetentionDays || 14)} days
Location${escapeHtml(status.directory || "Unavailable")}
Level${escapeHtml(status.level || prefs.diagnosticLevel || "info")}
${status.lastWriteError ? `
Error${escapeHtml(status.lastWriteError)}
` : ""}
+

Recording policy

+
+

One-click troubleshooter

Git locks, interrupted operations, branch synchronization and deployment/server inconsistencies
${trouble?.issues?.some((item) => item.repairable && item.safe) ? `` : ""}
${trouble ? `${trouble.summary.total ? `${trouble.summary.total} issue(s)` : "Healthy"}${trouble.summary.errors} errors · ${trouble.summary.warnings} warnings · ${trouble.summary.repairable} repairable` : "Run the troubleshooter to inspect all linked repositories and deployments."}
${troubleRows || '

No problems detected.

'}
+

System preflight

Git, writable storage, credential protection, folders and Gitea
${report ? `${report.summary.ready ? "Ready" : `${report.summary.blocking.length} blocking`}${report.summary.counts.pass} passed · ${report.summary.counts.warning} warnings · ${report.summary.counts.fail} failed` : "Not run in this session"}
${renderPreflightChecks(report)}
+

Export support bundle

Configuration summary, repository states, operations, preflight and redacted JSONL logs
${ui.lastDiagnosticBundle ? `
${icon("check")}
${escapeHtml(ui.lastDiagnosticBundle.size)} bundle created

SHA-256 ${escapeHtml(ui.lastDiagnosticBundle.sha256)}

` : ""}
+
`; +} + +function renderPipelineView() { + const operation = ui.activeDeployment; + if (!operation) + return '

No deployment operation selected.

'; + const logs = (operation.logs || []).join("\n"); + return `

${escapeHtml(operation.status)}

${escapeHtml(operation.profileName || operation.workflowFile || "")} · ${escapeHtml(operation.shortSha || shortSha(operation.sha))}

${escapeHtml(operation.status)}
${(operation.stages || []).map((stage) => `
${stage.status === "complete" ? icon("check") : stage.status === "failed" ? icon("error") : stage.status === "active" ? icon("pulse") : icon("clock")}${escapeHtml(stage.label)}
`).join("")}
${operation.jobs?.length ? `

Runner jobs

${operation.jobs.map((job) => ``).join("")}
JobStatusStartedCompleted
${escapeHtml(job.name)}${escapeHtml(job.conclusion || job.status)}${job.startedAt ? formatDate(job.startedAt) : "—"}${job.completedAt ? formatDate(job.completedAt) : "—"}
` : ""}
Deployment output
${escapeHtml(logs || "Waiting for operation output…")}
${operation.failure ? `
${icon("error")}
${escapeHtml(operation.failure.stage)}

${escapeHtml(operation.failure.message)}

` : ""}
`; +} + +function renderStatusbar() { + const state = ui.boot?.state; + const repository = selectedRepository(); + const active = operations().filter( + (operation) => !isTerminalOperation(operation.status), + ).length; + return `
${icon("git")}${escapeHtml(ui.boot?.git?.version || "Git unavailable")}${icon("folder")}${state?.workspaceRoots?.length || 0} roots${repository?.localStatus ? `${icon("branch")}${escapeHtml(repository.localStatus.branch.head)}` : ""}
${ui.autoRefreshPending ? `${icon("refresh")}Change detected` : ""}${active ? `${icon("pulse")}${active} active` : ""}ForgeFlow ${escapeHtml(ui.boot?.appVersion || "")}
`; +} + +function renderSetup() { + const steps = ["Readiness", "Gitea", "Folders", "Discovery", "Ready"]; + let body = ""; + if (ui.setupStep === 0) { + body = `

Check this computer

ForgeFlow verifies Git, writable storage and protected credential support before you enter any connection details.

${icon("shield")}Your Gitea token is entered only inside this local desktop application. It is never included in diagnostic logs or support bundles.
${renderPreflightChecks(ui.systemPreflight, "Run the readiness check to verify this computer.")}
Available even before Gitea is connected.
`; + } else if (ui.setupStep === 1) { + body = `

Connect your Gitea instance

Enter the URL and a personal access token created on your own Gitea server. ForgeFlow validates it locally and stores it using operating-system encryption when available.

${ui.setupValidation ? `
${icon("check")}Connected as ${escapeHtml(ui.setupValidation.user.login)} · ${ui.setupValidation.repositoryCount} repositories · Gitea ${escapeHtml(ui.setupValidation.version || "version unknown")}
` : `
${icon("shield")}Use the narrowest permissions that allow repository reads and Actions workflow dispatch. The setup guide explains this without requiring you to share the token.
`}
`; + } else if (ui.setupStep === 2) { + body = `

Select development folders

Choose parent folders. ForgeFlow discovers Git working trees below them and matches their origin to Gitea.

${ui.setupDraft.roots.map((root, index) => `
`).join("")}
`; + } else if (ui.setupStep === 3) { + body = `

Discovering repositories

Inspecting local Git metadata. Generated folders and nested dependency trees are skipped.

Scanning configured folders…
`; + } else { + body = `

ForgeFlow is ready

${ui.setupDraft.discovered.length} local repositories were found. You can add deployment environments after opening a repository.

Gitea connected${escapeHtml(ui.setupDraft.baseUrl)} · ${escapeHtml(ui.setupDraft.user?.login || "user")}
Workspace discovery${ui.setupDraft.roots.length} root folder(s), ${ui.setupDraft.discovered.length} repository/repositories
Safe diagnosticsStructured local logs with credential redaction are enabled by default.
${ + ui.setupDraft.discovered.length + ? ui.setupDraft.discovered + .slice(0, 8) + .map( + (item) => + `
${icon(item.error ? "error" : "git")}
${escapeHtml(item.localPath.split(/[\\/]/).pop())}${escapeHtml(item.localPath)}
${item.error ? "Unreadable" : "Ready"}
`, + ) + .join("") + : '

No repositories found. You can link or clone repositories later.

' + }
`; + } + const nextAction = + ui.setupStep === 0 + ? ui.systemPreflight?.summary?.ready + ? '' + : '' + : ui.setupStep === 1 + ? '' + : ui.setupStep === 2 + ? `` + : ui.setupStep === 4 + ? '' + : ""; + return `
${body}
${nextAction}
`; +} diff --git a/tests/ipc-contract.test.mjs b/tests/ipc-contract.test.mjs index 09c2b9e..c2cb3a7 100644 --- a/tests/ipc-contract.test.mjs +++ b/tests/ipc-contract.test.mjs @@ -2,15 +2,16 @@ import test from "node:test"; import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; +async function rendererSource() { + return (await Promise.all(["app.js", "views.js", "dialogs.js", "operations.js", "actions/shell.js", "actions/inventory.js", "actions/deployment-profile.js", "actions/deployment-operation.js", "actions/setup-and-settings.js", "actions/recovery.js", "actions/command.js", "events.js"].map((file) => readFile(new URL(`../src/renderer/${file}`, import.meta.url), "utf8")))).join("\n"); +} + test("every preload invoke channel has a registered IPC handler", async () => { const preload = await readFile( new URL("../preload.cjs", import.meta.url), "utf8", ); - const ipc = await readFile( - new URL("../src/main/ipc.cjs", import.meta.url), - "utf8", - ); + const ipc = (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"); const invokes = [...preload.matchAll(/invoke\(\s*['"]([^'"]+)['"]/g)].map( (match) => match[1], ); @@ -27,10 +28,7 @@ test("every preload invoke channel has a registered IPC handler", async () => { }); test("every renderer bridge call is exposed by the preload contract", async () => { - const renderer = await readFile( - new URL("../src/renderer/app.js", import.meta.url), - "utf8", - ); + const renderer = await rendererSource(); const preload = await readFile( new URL("../preload.cjs", import.meta.url), "utf8", diff --git a/tests/renderer-workflow.test.mjs b/tests/renderer-workflow.test.mjs index 0e5faf0..14ab6c6 100644 --- a/tests/renderer-workflow.test.mjs +++ b/tests/renderer-workflow.test.mjs @@ -2,6 +2,14 @@ import test from "node:test"; import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; +const rendererFiles = ["app.js", "views.js", "dialogs.js", "operations.js", "actions/shell.js", "actions/inventory.js", "actions/deployment-profile.js", "actions/deployment-operation.js", "actions/setup-and-settings.js", "actions/recovery.js", "actions/command.js", "events.js"]; +async function rendererSource() { + return (await Promise.all(rendererFiles.map((file) => readFile(new URL(`../src/renderer/${file}`, import.meta.url), "utf8")))).join("\n"); +} +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"); +} + test("changed file list has an independently scrollable bounded layout", async () => { const css = await readFile( new URL("../src/renderer/styles.css", import.meta.url), @@ -22,10 +30,7 @@ test("changed file list has an independently scrollable bounded layout", async ( }); test("commit workflow explains every disabled prerequisite", async () => { - const renderer = await readFile( - new URL("../src/renderer/app.js", import.meta.url), - "utf8", - ); + const renderer = await rendererSource(); assert.match(renderer, /Commit message required/); assert.match(renderer, /Enter a commit message to enable commit and push/); assert.match(renderer, /ForgeFlow stages the selected files automatically/); @@ -34,10 +39,7 @@ test("commit workflow explains every disabled prerequisite", async () => { }); test("ITWorx branding is integrated into titlebar and setup", async () => { - const renderer = await readFile( - new URL("../src/renderer/app.js", import.meta.url), - "utf8", - ); + const renderer = await rendererSource(); assert.match(renderer, /itworx-mark\.png/); assert.match(renderer, /itworx-wordmark-(?:light|dark)\.png/); }); @@ -59,23 +61,14 @@ test("all modal content stays inside the viewport with a persistent action foote }); test("settings provides one-click normalization for legacy Gitea origins", async () => { - const renderer = await readFile( - new URL("../src/renderer/app.js", import.meta.url), - "utf8", - ); + const renderer = await rendererSource(); assert.match(renderer, /data-action="normalize-origins"/); assert.match(renderer, /Normalize all origins/); }); test("Git mutations are serialized per repository and expose repair actions", async () => { - const ipc = await readFile( - new URL("../src/main/ipc.cjs", import.meta.url), - "utf8", - ); - const renderer = await readFile( - new URL("../src/renderer/app.js", import.meta.url), - "utf8", - ); + const ipc = await ipcSource(); + const renderer = await rendererSource(); assert.match(ipc, /repositoryMutations = new Map/); assert.match(ipc, /withRepositoryMutation/); assert.match(ipc, /GIT_LOCKS_RECENT/); @@ -87,10 +80,7 @@ test("Git mutations are serialized per repository and expose repair actions", as }); test("SSH secrets are captured before the loading render clears password inputs", async () => { - const renderer = await readFile( - new URL("../src/renderer/app.js", import.meta.url), - "utf8", - ); + const renderer = await rendererSource(); const passwordCapture = renderer.search( /const password = document\.querySelector\(["']#server-password["']\)/, ); @@ -101,10 +91,7 @@ test("SSH secrets are captured before the loading render clears password inputs" }); test("SSH deployments are polled in the background and Portfolio casing is preserved", async () => { - const renderer = await readFile( - new URL("../src/renderer/app.js", import.meta.url), - "utf8", - ); + const renderer = await rendererSource(); assert.match(renderer, /function startOperationPolling\(\)/); assert.match(renderer, /startOperationPolling\(\);/); assert.match(renderer, /Visible container name/); @@ -112,10 +99,7 @@ test("SSH deployments are polled in the background and Portfolio casing is prese }); test("deployment profiles expose built-in/uploaded DockerMan icons and automatic metadata repair", async () => { - const renderer = await readFile( - new URL("../src/renderer/app.js", import.meta.url), - "utf8", - ); + const renderer = await rendererSource(); assert.match(renderer, /Built-in high-contrast ITWorx mark/); assert.match(renderer, /profile-icon-mode/); assert.match(renderer, /Repair DockerMan integration/); @@ -123,24 +107,15 @@ test("deployment profiles expose built-in/uploaded DockerMan icons and automatic }); test("repository troubleshooting offers personalized synchronization repair actions", async () => { - const renderer = await readFile( - new URL("../src/renderer/app.js", import.meta.url), - "utf8", - ); - const ipc = await readFile( - new URL("../src/main/ipc.cjs", import.meta.url), - "utf8", - ); + const renderer = await rendererSource(); + const ipc = await ipcSource(); assert.match(renderer, /repair-repository-sync/); assert.match(renderer, /safety branch/); assert.match(ipc, /repository:repair-sync/); }); test("advanced Git, desktop, backup, policy and audit workflows are exposed in the renderer", async () => { - const renderer = await readFile( - new URL("../src/renderer/app.js", import.meta.url), - "utf8", - ); + const renderer = await rendererSource(); const preload = await readFile( new URL("../preload.cjs", import.meta.url), "utf8", @@ -171,10 +146,7 @@ test("advanced Git, desktop, backup, policy and audit workflows are exposed in t }); test("one-click troubleshooting excludes destructive or publishing Git actions", async () => { - const renderer = await readFile( - new URL("../src/renderer/app.js", import.meta.url), - "utf8", - ); + const renderer = await rendererSource(); const ipc = await readFile( new URL("../src/main/ipc.cjs", import.meta.url), "utf8", @@ -210,10 +182,7 @@ test("premium repository workspace reserves separate rows for actions and releas }); test("interactive project illustrations are semantic, responsive and motion-safe", async () => { - const renderer = await readFile( - new URL("../src/renderer/app.js", import.meta.url), - "utf8", - ); + const renderer = await rendererSource(); const styles = await readFile( new URL("../src/renderer/styles.css", import.meta.url), "utf8", @@ -228,10 +197,7 @@ test("interactive project illustrations are semantic, responsive and motion-safe }); test("the diff canvas uses a contextual and motion-safe code illustration", async () => { - const renderer = await readFile( - new URL("../src/renderer/app.js", import.meta.url), - "utf8", - ); + const renderer = await rendererSource(); const styles = await readFile( new URL("../src/renderer/styles.css", import.meta.url), "utf8", @@ -245,10 +211,7 @@ test("the diff canvas uses a contextual and motion-safe code illustration", asyn }); test("Git Validator exposes scored best-practice checks and bounded repairs", async () => { - const renderer = await readFile( - new URL("../src/renderer/app.js", import.meta.url), - "utf8", - ); + const renderer = await rendererSource(); const styles = await readFile( new URL("../src/renderer/styles.css", import.meta.url), "utf8", @@ -267,7 +230,7 @@ test("Git Validator exposes scored best-practice checks and bounded repairs", as }); test("renderer guards accessible names, labels and uncertain inventory evidence", async () => { - const renderer = await readFile(new URL("../src/renderer/app.js", import.meta.url), "utf8"); + const renderer = await rendererSource(); const styles = await readFile(new URL("../src/renderer/styles.css", import.meta.url), "utf8"); assert.match(renderer, /button\.icon-button:not\(\[aria-label\]\)/); assert.match(renderer, /\.field > label:not\(\[for\]\)/); diff --git a/tests/unraid-deployment.test.mjs b/tests/unraid-deployment.test.mjs index db9ca9d..d6578c1 100644 --- a/tests/unraid-deployment.test.mjs +++ b/tests/unraid-deployment.test.mjs @@ -1,6 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { createRequire } from "node:module"; +import { readFile } from "node:fs/promises"; const require = createRequire(import.meta.url); const { UnraidDeploymentService, @@ -18,6 +19,11 @@ const { const { fingerprintKey, shellQuote } = require("../src/main/ssh-service.cjs"); const { buildWorkloadInventory, deploymentRootCandidate } = require("../src/main/server-inventory.cjs"); +async function unraidSource() { + const files = ["unraid-deployment-service.cjs", "unraid-access-methods.cjs", "unraid-preflight-methods.cjs", "unraid-runtime-methods.cjs", "unraid-deployment-methods.cjs", "unraid-inventory-methods.cjs"]; + return (await Promise.all(files.map((file) => readFile(new URL(`../src/main/${file}`, import.meta.url), "utf8")))).join("\n"); +} + test("Unraid remote paths cannot escape appdata project folder", () => { assert.equal(safeRemoteFolder("lumaops"), "lumaops"); assert.throws(() => safeRemoteFolder("../lumaops")); @@ -656,15 +662,10 @@ test("SSH deployment dispatch returns a running operation while the remote build }); test("Unraid preflight verifies repository access before a deployment can start", async () => { - const source = await import("node:fs/promises").then(({ readFile }) => - readFile( - new URL("../src/main/unraid-deployment-service.cjs", import.meta.url), - "utf8", - ), - ); + const source = await unraidSource(); assert.match(source, /server-git-access/); assert.match(source, /git ls-remote --exit-code/); - assert.match(source, /Unraid → Gitea read access/); + assert.match(source, /Unraid .* Gitea read access/); }); test("DockerMan metadata uses dockerman labels, a template WebUI and lowercase-safe service/image names", () => { @@ -855,12 +856,7 @@ test("stuck SSH deployment is reconciled to success when exact SHA and container }); test("DockerMan metadata repair refreshes known Unraid icon caches after container recreation", async () => { - const source = await import("node:fs/promises").then(({ readFile }) => - readFile( - new URL("../src/main/unraid-deployment-service.cjs", import.meta.url), - "utf8", - ), - ); + const source = await unraidSource(); assert.match(source, /\/var\/lib\/docker\/unraid\/images/); assert.match(source, /dynamix\.docker\.manager\/images/); assert.match(source, /-icon\.png/);