diff --git a/CHANGELOG.md b/CHANGELOG.md index bf9114f..08eded4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## 0.8.1 - 2026-07-26 + +- introduced a refined premium visual system with clearer hierarchy, richer depth, responsive density and reduced-motion support; +- added a live open pull-request overview per repository instead of only pull-request creation; +- added a safe installed-connection self-test for DPAPI, Gitea identity, repository access and Actions access; +- verified the existing dedicated Unraid Ed25519 key and strict host fingerprint against the configured server; +- expanded compact-window visual acceptance at 1120 × 720 and the dashboard check at 1440 × 900. + +## 0.8.0 - 2026-07-25 + +- Added partial-hunk staging, conflict guidance, protected-branch awareness and Gitea pull requests. +- Added configurable editor/terminal integration, tray, notifications and login startup. +- Added deployment policies, release notes, append-only audit export and encrypted configuration backup/restore. +- Added a guarded end-to-end environment acceptance harness. + +## 0.7.0 + +- Added server-authoritative adoption of existing Unraid/Compose deployments. +- Added Docker, Compose, Git and DockerMan discovery with provenance and complete runtime metadata. +- Added a general one-click troubleshooter for common Git and deployment failures. +- Added safe abort recovery for interrupted Git operations and protected divergence repair. +- Fixed Unraid WebUI placeholder validation, serialized config saves, malformed config recovery and CRLF manifest verification. + ## 0.6.1 - Fixed Windows PowerShell 5.1 updater status replacement and STARTED handshake. diff --git a/README.md b/README.md index e72ae2a..4d1f7ec 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # ForgeFlow +ForgeFlow 0.8 adds partial-hunk commits, guided conflict recovery, Gitea pull +requests and protected-branch awareness, configurable editor/terminal actions, +deployment policies and release notes, append-only audit export, encrypted +configuration backup, native tray/notifications and a guarded end-to-end +acceptance harness. + ## Publish this release to the built-in updater repository Extract the full source ZIP to a folder under Downloads and run: diff --git a/SOURCE_MANIFEST.txt b/SOURCE_MANIFEST.txt index ed96e48..9d11790 100644 --- a/SOURCE_MANIFEST.txt +++ b/SOURCE_MANIFEST.txt @@ -1,16 +1,8 @@ -ForgeFlow 0.6.1 source manifest +ForgeFlow 0.8.1 source manifest SHA-256 BYTES PATH -(The manifest excludes itself and generated release archives.) +(The manifest excludes itself, dependencies and generated release artifacts.) 755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 58 .gitignore -82438ae208c8d8eaac56fcdc68e52efcfd7a57890198d3f8b8d8c69280b600a6 6359 CHANGELOG.md -4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE -217817c7e10a287f852735f412c25098c0983866d0b45769c828534912895c1a 347 OVERLAY-INSTRUCTIONS.md -fced7dd332ad944fac79bc3979e5a360d95c028ad0c91e1cd6d0abddf5822fbe 8281 Publish-ForgeFlow-Release.ps1 -a94b84bb0c568b7c4f7df12f86a3fcca93ec2ae5ce25597e0a2d6feb6ea31106 13355 README.md -058aeaa5d9bfe377c7e322f213c7871ecc4151b5d08ef790992f4ee28d857658 743 START-FORGEFLOW-OVERLAY.ps1 -c7b1ecc475931577a914c94a326fbf25ec4a4a742286f6f5c2bae8a38528f6c0 2098 START_HERE.md -8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea 767 UPDATE_FROM_0.3.2.md -6337a7d0791e8749e3a576d7735257b0031db7a9eff4a2d6818cc251544774d2 1704 build-windows.ps1 +89545860bd6f7566da81edc8328cd2a1ebf33e81a4b0dcf2cec74338c05e8cac 1753 build-windows.ps1 0970821475a4452aa19e447e9397a95db836791f16890a1a83fd748ac033dc86 8830 build/icon-128.png 09112c1425ca953d8dd8b2bcfd221e5a84b9f81752f7168f360e295030cbc8f2 521 build/icon-16.png 510aa27935a63ad16cc22978ccfde3bdd441cb970ad42d9f05af52c0e5999195 28923 build/icon-256.png @@ -20,11 +12,13 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263 4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183 3364 build/icon-64.png 25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png -2d9836ae6d576bab5494b9f094bc673e5ca4772bf5771006583d1bc46fe46698 8296 docs/ARCHITECTURE.md +994243db23370527fd4cf1eeb2ec9c1cd5609daad55c1499a1158b911657bfdf 7811 CHANGELOG.md +21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md +a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md 30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md eb42f979666e05d51c587e4223282914926a2b9b1ade9f3fb75525019ce7f738 4616 docs/DIAGNOSTICS.md a0cd06a96f23a94e118feb012be0fa1ac51345951cb2ba8e67fb8c889c4c342a 5007 docs/LUMAOPS_SERVER_AUDIT.md -84ae90829ecd0eb9b56b7c7a9139e12a86f74c3d938c29b63020286955d317ca 4508 docs/RELEASE_AUDIT_0.6.0.md +f79908fb3dad98c38030c6e6be7c79a1999e0478ed9c2496923891954438daa1 4581 docs/RELEASE_AUDIT_0.6.0.md a0c00ff76acd1682bb5e0e8dcf6589c9480da436c9c6d30780a1ed58b4dad94f 1770 docs/RELEASE_NOTES_0.2.0.md 5773ead01aa4c522c556295553787482d01b1f5242f053b2c61f120c4de4fa76 5963 docs/RELEASE_NOTES_0.3.0.md d46de73cf6c4cd5c2ba3f455a7a2af2e0d64ee9d94a97fd1a0bfb44e35c1624a 1093 docs/RELEASE_NOTES_0.3.1.md @@ -40,21 +34,24 @@ f9554c10f56d41d916330f06175b07f099c9ed1534f00abc9ea7f94be70f4a97 1097 d7d007e4c2807698db07b2ebe1cb48c36bd162bf4daad77c9d299096c9654d5a 721 docs/RELEASE_NOTES_0.5.2.md 3e77df12a7ff4b545069933410bf14fe8891f39915182df25112c722cf4e243d 1030 docs/RELEASE_NOTES_0.5.3.md 61f6cbc1c3f263fa96b5c6a70a26baa7cd577d37ac633455eed45d9b63169a35 710 docs/RELEASE_NOTES_0.5.4.md -9f72a5d039615785ccd4771f38b060bb58386b3217ba2a56459067cebbbb812f 4875 docs/RELEASE_NOTES_0.6.0.md -179860938908bc65b8ce8ca5019fd0a24e79b1eb88d368c9856191d48ec00d3a 675 docs/RELEASE_NOTES_0.6.1.md -c465f1a9c4454c9a18f38f68a243037b8897c2c9929077a586604acd4ff26d35 3655 docs/ROADMAP.md -322624242d246d07180cc719e14c91e8fb69e123676a02e5046f4e576cca1ca1 5569 docs/SECURITY.md -c79123aa4c718ac3ab0d79771f2967710c28f939b58fca0094b02e3172f2c024 13067 docs/SETUP_GUIDE.md -43f73b6674ee0c5d7ace8ab39db95b5b42b61c73dd79ffc8d87fe214d8b51d7e 5070 docs/SSH_UNRAID_DEPLOYMENT.md -b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md -0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md -08640f1b5e26048b5ae501909f415d2426b07cc316a0bd2178023f2457aa7a2a 3978 docs/TEST_MATRIX.md -95b5b2915a11065a22eb2822ecce9fb30fec0c08874d63c8ffb5182cd20b3459 2328 docs/UPDATING.md +1ecca96cf8a6f01d7ead37d5a6b678549c2561bfd84011b6149f718a25971661 4936 docs/RELEASE_NOTES_0.6.0.md +23150c58e6416d48c2ed6e378fff99179ed810b766ed50b70d4d829c6774b8ef 685 docs/RELEASE_NOTES_0.6.1.md +f3d04f2d3419a7a010d5399cdd9351ff85ab2b3fdf8023977e559b0a5f8bcdc3 2571 docs/RELEASE_NOTES_0.7.0.md +d7bdc61d9b617ad5acf0b2d468eda547fd7509d4af08f33d2661393f25bdcb5a 576 docs/RELEASE_NOTES_0.8.0.md +1e056bfcf2105843402f4b14c63480240cc55456a4a63e229b3fdbaf3156b803 754 docs/RELEASE_NOTES_0.8.1.md +2b631b9d6d973bdd70869d84886ff339da351e29e17598970b3b27915674661d 4175 docs/ROADMAP.md 1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png 070e6700bdae8c628c907ba181bbf0dde0bbbbb4208f7a875503f933ff1b882e 118819 docs/screenshots/deployment-success.png bcb1e4daf1eeedc5b3f61d2406f1a65312dba130082528007e1629d9df99570a 153240 docs/screenshots/overview.png 224e34ab45877bbb97b07d2a14c4a5aa6e28339522a8015b33a2a81477177143 135102 docs/screenshots/repository-workspace.png +322624242d246d07180cc719e14c91e8fb69e123676a02e5046f4e576cca1ca1 5569 docs/SECURITY.md +32a34ec13a284d3f9ceebbc107b25a844e3db096f8cafa4e43951fc2050c9a03 13552 docs/SETUP_GUIDE.md +4dcdbd42550a4cc53fe948349b20bfe0e445d40144ef82df54b3aab8b00a8f46 5165 docs/SSH_UNRAID_DEPLOYMENT.md +b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md +0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md +4625a10ebd3c749f60b2a7bef6b1716cd05dbc44ccceba0491a1b46bc293c195 4883 docs/TEST_MATRIX.md +42f3bcb264fa772849782f163fdcaac28aedad15d754b93ed52ab3ab41477c3d 2377 docs/UPDATING.md c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 examples/gitea-actions/deploy.yml 4c792cc9fd57ed36da291300c252a6ef75b08a249cf6f2561e23c4c22522138a 1477 examples/gitea-actions/rollback.yml 1d2cde1bef4882f56006823d2806f6105882fa098a665a303150fdf18ada2004 5705 examples/server/forgeflow-deploy @@ -62,63 +59,85 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 0423fe2cc7f43fe793986a3f62a395668897cdf07348756aa7742a8cd40ac51c 569 examples/server/forgeflow-targets.conf 106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf 2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json -1e47552cfde3ca925ba1f24fbc471f3fc29468ca7ef3351beb9bde2bf0747bc7 7887 main.cjs -1dc73c9ec2393a3aeb655ade78eb81a5a6946c2760884bc4c653cbef171896cf 2745 package.json -0cd434cb21af86e7f6983416e4ed14762565df90edc2d00d4a60378df75b7419 6846 preload.cjs -874cabc5ff1abce4e1ba6560f35d5956f9ec69f1302164169f0da56cc62ca954 10056 scripts/apply-source-update.ps1 -f427dfcd7b5ee7079de13633c8d7d22a91115e0bbc4f2a9a96246f42176d4880 3596 scripts/doctor.mjs +4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE +7c7790e229bbe6035a47c29d17cddd187482e7cda03c5672ec9d7aca16bc4a5c 11357 main.cjs +91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md +e4c91f198af6235ea035f58268b85588bc1cbf0952c28785a30f15ee883feb84 134141 package-lock.json +f85119969c32226a5d5094616400936aaa161db65ed35753894a5bc75dc50f0b 3301 package.json +3d2ac366a13e9418e3ec6d13ce95b611f30f0228eb3a80ef9e7a936ce9578e24 9080 preload.cjs +b31c43d9355c13b5ae4efc0f3649d8cb8d509b2bb7ebb042ff546b7820fb7de8 8411 Publish-ForgeFlow-Release.ps1 +a6d32a742412b7836606be00f17be0465f1b6f55d3911f6c73a14029787ba206 14037 README.md +509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs +f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287 scripts/apply-source-update.ps1 +6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs +5e9a2a819522f6a32bbd9d3303263d5e5eaec95898ea2cd5776b221168008d75 1727 scripts/generate-source-manifest.mjs 444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs -4bd28f46cb2dda347e534b5d57a46425b9fe5862dd22cdb58b983d01b3174fcf 10161 scripts/verify.mjs -92524adae60aced3af23f8afe82c011873ae9f1e53d854e4d12a94e8d1be1aa9 2075 setup-windows.ps1 -87885d640a1148078426522c87d7c9b7fced1fabb371020a4781bb94b256ee00 19664 src/main/config-store.cjs -a970ff3f47d1641bf1ab9611e1122349aa65ff8fee4789585e078431368b8c6b 23655 src/main/deployment-service.cjs +42203f9e0fd4aae517284d387f265cf1b0b180379bc253a092b5c3c5c4caef0a 2992 scripts/validate-installed-connections.cjs +a2733f653e6abea7f27c3198631e6144c98458874b338704f99252451cce235b 11999 scripts/verify.mjs +619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1 +dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs +a381848a296c28f6d14093c96f722967acf9c994ffb867d54dd92bf5ada2729b 23648 src/main/config-store.cjs +2fb04b1494b39f5d7c0720fa5fd298cd46fa85dc1b696d77657592347fcf1819 2731 src/main/configuration-backup.cjs +9d0af5074093108a5248d0dde0ff70a666748e61f1954b630886a81e8f34072c 24079 src/main/deployment-service.cjs c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs -c50bf93d0d1abfc0319e59a545877413bd3aca1e5b57c9a48a2467db5e89d7f8 25731 src/main/git-service.cjs -ab7344b1951c87e982cab5c293891bc45dad48a48a3b4b63991b0e76ef785ba6 12759 src/main/gitea-service.cjs -743002a5dae3c8e6aa5236609dd58c786420408c321eebf6d6b9e32a684cd696 26042 src/main/ipc.cjs +a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs +0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs +113612b23f9c812e1dbe33eaaf398725c351678419e8304a8dfb4df881b862ff 15048 src/main/gitea-service.cjs +b2d768a9dfd1e494edee6609a233469e60c31c362143d5c37c3c9f908b7bca79 40559 src/main/ipc.cjs 62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs 958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs -1dc0c997bd2d837f3d27dff58a9443888597b7981c8a1dd1eaa4487176ef716c 1520 src/main/process-runner.cjs +3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508 src/main/repository-monitor.cjs -a302bdcfbf2e2b66fdb4e13d94cc7a78cd4065a779b980f4487b6075a6472017 7583 src/main/repository-service.cjs -ad9e8b67bd10f2f5708d00ebacf660ab4917dd02b3d22b3b6d730bcb2a7e1c18 8124 src/main/ssh-service.cjs -a716f7402d3039296f99f1a7958a9aa521032f091e4171e224fa548d131a4183 49354 src/main/unraid-deployment-service.cjs -709a6eb6a9b277cdc33f4d97558d2aca9d7ebe62a0e38fe52adc9dc3c9f94422 12791 src/main/update-service.cjs -cea387e7996de8d185cd11f7d8d4e0675a58df1ca8f3cb6c3c6d6363c72f5f12 150880 src/renderer/app.js +17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs +b31a63bf8cb1807b3e838e2bf8a0e742738f119d13de8ca9f42e471f072217d3 8328 src/main/ssh-service.cjs +720103f14cbedd7fd2776e49fd970a634f14e03d548d90bf93bcd878b6b3c674 58758 src/main/unraid-deployment-service.cjs +e87647c45cf06e2aa58e319adff96af0f927ca278ff0877eac3b8ff97d690ea8 13103 src/main/update-service.cjs +aab6597f0efd72cb27c12aab9866fb5cfe87710b9fbca335e2b5dd767c8bab13 191016 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 -394f901b0e4add6b788c006d9869a66dc021dcebf2916f5a732bc42786510efc 740 src/renderer/index.html -4f3d7fa6126fe609a64cbe2de4817c6a9f2b6b31ba8abee4f8e35a93a185c601 39109 src/renderer/mock-bridge.js -6f0bd7b898677b9bcf6a104d7b1292b9ed5db826e4afdc86907e1c55bd889456 48072 src/renderer/styles.css +e1c463d6cda9f2b9b78c468845c0a7e8688f0362be5642074a1a5f7122dfe811 762 src/renderer/index.html +24a32724ad412e9c3a2b93f2ddf4cb8db0cddf421968cc620b0552caac39076c 50942 src/renderer/mock-bridge.js +51f6777fd7d2dc73dc3ddd96c91a11882483099b0a62e0ce172e25e3dce474a6 59851 src/renderer/styles.css 0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs +5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs 029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs 2778ebcbdf60fdc1cb0749f15565e0e1bd66f3a0d31eb70ae7942a7511a3de75 1295 src/shared/repository-match.cjs -7f4d057a3c8e8d22eda9477eea7b144237824ef0f514737831d1881ff8e7f4a4 1120 src/shared/semver.cjs +c7e120ea53c5ef3c01b8cce71afe913f34bb461bb73aa3ade24656e09f99f338 1152 src/shared/semver.cjs ede2c95bb045c0005a3931709a0116d9fbcb3faa5f609848a0066c6ba382ca0b 2906 src/shared/shell-verification.cjs 2daa98fd421598bfe5fc9757c9b6f4d82c31d1bfece15829928473581d5d2639 1210 src/shared/tool-invocation.cjs -a97c83b8023d6c0cf49d6f2d5b626ef2341f02670f0de170e840026d28fd1f0e 5202 src/shared/validation.cjs +114f01be8bd54c91b90af82d8e1604e24cc0c5f8e64e63c40cf3f4042623a98e 5402 src/shared/validation.cjs 13b731c38863b1007b0312fd9d89562401b7cce875c952f52429bde74f77a8af 3096 src/shared/zip-writer.cjs +c1b0fd37a6ae74a56750138626fb2bc3523485c1124a0b8ac8ee3ef41089206a 2156 START_HERE.md +058aeaa5d9bfe377c7e322f213c7871ecc4151b5d08ef790992f4ee28d857658 743 START-FORGEFLOW-OVERLAY.ps1 +f5b0ea887fcdeadec78c1ad49b0ec7979723562f5c0b730703acb77a37281ee0 1009 tests/acceptance.test.mjs +a4e5947204ff6878e601e32477bc85b53cd0153baf95a161c8935b6e5466c257 1155 tests/audit-service.test.mjs 454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 4318 tests/clone-target.test.mjs -abb65b39f285da518a48be41aff40d89ceb9c5b0e6091772c2bde171f65daf9b 7523 tests/deployment-status.test.mjs +a984ddd5a29a4ccf55d78ba71202390e0bf1f0925a6a96f03ee74edbbf3bb2f7 1505 tests/configuration-backup.test.mjs +1dc6477bd07de78be189e6e8195ec339eb9d75820c4dbd5b073b8520ee21f6b5 1938 tests/deployment-policy.test.mjs +bf68c4dc91a2604235c6a7848088bcd9566fbeaa089b86ec1e0a4fcfc54ca9d2 7677 tests/deployment-status.test.mjs fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800 tests/diagnostics.test.mjs -efee4d7ae2a51b27d8643bd40e896aaddcdfdaa98bd4ac16ec83670b82b13791 12066 tests/git-integration.test.mjs +dd121d96ca265a027cd415a52064500a4541b2f8a662f4f4b25f2f996d52b5da 762 tests/external-tools.test.mjs +e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 tests/git-integration.test.mjs 5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db 1239 tests/git-status.test.mjs 681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.test.mjs -e914b2bcafbd674c06adfd9bd851ca04e134210691b7f91cd3de26cee37ef5f3 4154 tests/gitea-actions.test.mjs +52b96f0a6623778fbdc1e8dbfc892e1d77a3d6616058cd7b08d532b207aa5719 5531 tests/gitea-actions.test.mjs +267d76b868d8d06ea031c14acd09a7715fb44668a25ade51a9e62e0170888bc8 1522 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 -d837c7ee9f9c3f6ee37d6546c4f8bcee49463339dab165a1fc1edaa6735b4ff9 4550 tests/renderer-workflow.test.mjs +9b56c259cbf6c45c671267343c0871d56ac336082c531050b44fbcfca7476f62 6989 tests/renderer-workflow.test.mjs 2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 1068 tests/repository-monitor.test.mjs -6527813f2ae318f7a6aca57d375a3d3583173a208c540d2e8772f84f479d42b0 2205 tests/repository-service.test.mjs -3c71aa5fb30d9c6fbc4b0ccfcf5112f45cbcc2a60cb990e4551a8813f7155505 3397 tests/security-validation.test.mjs -ecfdad2a03c24898c822fcf05abac89c8f8fe452a05b16fdafc0236a64c27a23 614 tests/semver.test.mjs +75b5b83836c75675bb9a48fe4363fcb8a24fc425e6af6f822d7955c6f3c79eac 2265 tests/repository-service.test.mjs +d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463 tests/security-validation.test.mjs +bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs 020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs 8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs -7c0f5268028cf8904b446c5d9c5a8b6450966f493018777256104df2626d0f3e 15662 tests/unraid-deployment.test.mjs -44c82a2658f4286afb657c5952d12a6038c83f0adfe7ea0765e29af412a718dc 10233 tests/update-service.test.mjs -4d1f0a4c46190ca72b51fddf79ec6d4d02e65fa6f42ef3755de5d414f7da75bb 655 tests/validation.test.mjs +54f641103a91d98974c41a3c0a568c617b76fa9913a0e20710cd11adc39b0deb 18092 tests/unraid-deployment.test.mjs +4ea1acea5ca92e1360bcf1263f65d1be0de80ab44adc9af2b09f408951e0d9fa 10454 tests/update-service.test.mjs +9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs 7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs -3ea68269b66f639b3aba50c9605ccbfaa32c22a1d2cc842d8296c4ee59df6212 1537 update-windows.ps1 +8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea 767 UPDATE_FROM_0.3.2.md +2ebde94f664d86f42aea9bb3112b70b272c6706f4d364cad2f5c6191d2873e98 1655 update-windows.ps1 diff --git a/build-windows.ps1 b/build-windows.ps1 index 37d141f..5fe142e 100644 --- a/build-windows.ps1 +++ b/build-windows.ps1 @@ -21,7 +21,7 @@ function Invoke-Step { } } -Write-Host "ForgeFlow v0.4.2 Windows development build" -ForegroundColor Cyan +Write-Host "ForgeFlow Windows executable build" -ForegroundColor Cyan Write-Host "Artifacts are unsigned and intended for local testing." -ForegroundColor DarkGray Assert-Command node diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md new file mode 100644 index 0000000..e71ecb1 --- /dev/null +++ b/docs/ACCEPTANCE.md @@ -0,0 +1,20 @@ +# End-to-end acceptance + +ForgeFlow 0.8 includes a read-only acceptance harness for the real Local -> +Gitea -> Server chain. It never deploys unless an execution flag is supplied. + +Set `FORGEFLOW_GITEA_URL`, `FORGEFLOW_GITEA_TOKEN`, `FORGEFLOW_REPOSITORY` +(`owner/repository`), `FORGEFLOW_LOCAL_PATH`, `FORGEFLOW_BRANCH`, +`FORGEFLOW_STATUS_URL` and `FORGEFLOW_HEALTH_URL` locally. Optional variables +are `FORGEFLOW_WORKFLOW`, `FORGEFLOW_ROLLBACK_WORKFLOW` and +`FORGEFLOW_ENVIRONMENT`. Never commit the token. + +```powershell +npm run acceptance +npm run acceptance -- --execute-deployment +npm run acceptance -- --execute-rollback +``` + +The first command is read-only. The mutation flags require every read-only +check to pass, dispatch a controlled exact-SHA workflow with a unique request +ID, and wait for matching status plus a successful health endpoint. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b1cb207..51fd123 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -14,7 +14,7 @@ | Electron main process | | | | IPC validation + trusted sender checks | -| ConfigStore -------- schema 5 + protected Gitea/SSH secrets | +| ConfigStore -------- schema 8 + protected Gitea/SSH secrets | | GitService ----------- Git through execFile args | | GiteaService --------- repositories + Actions API | | RepositoryService ---- discovery + aggregation | @@ -69,7 +69,7 @@ per-environment lock. The runner receives no free-form command from ForgeFlow. ## Local state `forgeflow-config.json` lives below Electron's platform-specific user-data path -and is written atomically. Schema version 3 contains: +and is written atomically. Schema version 8 contains: - Gitea connection metadata and an OS-encrypted token blob where available; - workspace roots and explicit repository mappings; diff --git a/docs/RELEASE_NOTES_0.7.0.md b/docs/RELEASE_NOTES_0.7.0.md new file mode 100644 index 0000000..f61ae9f --- /dev/null +++ b/docs/RELEASE_NOTES_0.7.0.md @@ -0,0 +1,47 @@ +# ForgeFlow 0.7.0 + +## Existing deployment adoption + +ForgeFlow can now import an existing Unraid deployment directly from the server. The server is treated as the source of truth instead of relying on guessed defaults. + +The discovery pass reads: + +- the server-side Git checkout, origin, branch and live commit; +- the actual Compose file and normalized `docker compose config --format json` output; +- running and stopped containers through `docker inspect`; +- every detected port mapping, mount, network and environment-variable name; +- Compose project and service labels; +- image, restart policy and healthcheck metadata; +- the matching Unraid DockerMan XML template, including WebUI, icon and shell metadata. + +The primary values are imported into the deployment form. The complete multi-service and multi-port runtime description is retained as detected metadata. Imported values remain editable as explicit user overrides. + +ForgeFlow no longer invents a host port, container port, service name, WebUI or icon when the server does not report one. + +## One-click troubleshooter + +Diagnostics now contains a general troubleshooter that scans all linked repositories and SSH/Unraid deployment profiles. + +Safe one-click repairs cover: + +- interrupted rebase, merge, cherry-pick and revert operations; +- stale Git lock files; +- clean fast-forward synchronization; +- unpublished local commits; +- refresh and recalculation of repository truth. + +Diverged branches are treated as an explicit higher-impact repair. ForgeFlow creates a safety branch before resetting to the upstream version and never includes that action in the automatic safe-repair batch. + +The troubleshooter also reports non-automatic issues such as tracked server-side changes, missing deployment folders, SSH inspection failures and missing Docker context exclusions. + +## Reliability fixes + +- Unraid DockerMan WebUI templates such as `http://[IP]:[PORT:1223]/` are now accepted and preserved. +- Configuration writes are serialized so an older concurrent save cannot overwrite a newer snapshot. +- A malformed configuration file is preserved as a timestamped `.corrupt-*` file and replaced with safe defaults instead of making ForgeFlow unstartable. +- Source verification now handles Windows CRLF manifests correctly. +- Existing deployment metadata stores field provenance, detection time and server-source-of-truth status. + +## Validation + +The release includes real Git integration coverage for aborting an interrupted merge and server-discovery mapping coverage for Compose, Docker inspect and DockerMan metadata. diff --git a/docs/RELEASE_NOTES_0.8.0.md b/docs/RELEASE_NOTES_0.8.0.md new file mode 100644 index 0000000..b5dddd4 --- /dev/null +++ b/docs/RELEASE_NOTES_0.8.0.md @@ -0,0 +1,10 @@ +# ForgeFlow 0.8.0 + +- Select and stage individual diff hunks without staging the remainder. +- Guide interrupted merge, rebase, cherry-pick and revert resolution. +- Read Gitea branch protection and create pull requests. +- Open configurable editors and terminals without a shell. +- Enforce freezes, maintenance windows, release notes and reasoned overrides. +- Export append-only audits and credential-free encrypted configuration backups. +- Provide native notifications, tray, close-to-tray and start-at-login. +- Run guarded read-only, deployment and rollback acceptance checks. diff --git a/docs/RELEASE_NOTES_0.8.1.md b/docs/RELEASE_NOTES_0.8.1.md new file mode 100644 index 0000000..4483db2 --- /dev/null +++ b/docs/RELEASE_NOTES_0.8.1.md @@ -0,0 +1,13 @@ +# ForgeFlow 0.8.1 + +ForgeFlow 0.8.1 is a premium UX and connection-assurance release. + +## Highlights + +- A more deliberate desktop design system with refined hierarchy, depth, typography, focus states and responsive density. +- Repository settings now show live open Gitea pull requests and link directly to them. +- `npm run connections:check` validates the installed DPAPI-protected token against the Gitea user, ForgeFlow repository and Actions APIs without printing credentials. +- Reduced-motion preferences are respected throughout the interface. +- Windows packaging and compact-window behavior are revalidated after the visual redesign. + +The existing token and SSH private key remain local and are never copied into logs, backups or release artifacts. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9e9d3f9..e346ac0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,5 +1,16 @@ # ForgeFlow roadmap +## Delivered in v0.8 + +- partial-hunk staging with staged-only commits; +- guided conflict resolution and safe continue/abort controls; +- Gitea branch-protection awareness and pull-request creation; +- configurable editor/terminal integration; +- deployment freezes, maintenance windows, release notes and overrides; +- append-only audit export and encrypted credential-free configuration backup; +- native notifications, tray, close-to-tray and start-at-login; +- guarded real-environment deploy/rollback acceptance harness. + ## Delivered through v0.4 - coherent Local -> Gitea -> Server desktop model; diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index 668f4b2..9bc0fdc 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -90,6 +90,19 @@ The standalone demo should be checked at minimum at: Required views now include setup readiness, dashboard, repository workspace, deployment preflight, active run, success/failure and Diagnostics. +## v0.8 functional acceptance + +- real-repository partial hunk staging without staging the remaining changes; +- guided merge-conflict resolution and safe continue/abort actions; +- Gitea pull-request creation and protected-branch inspection; +- shell-free editor and terminal argument-template expansion; +- deployment freezes, maintenance windows, mandatory release notes and reasoned overrides; +- authenticated encrypted configuration backup without credentials or operation history; +- append-only audit JSONL and CSV export; +- desktop notification, tray and close-to-tray preference integration; +- read-only-by-default end-to-end Gitea Actions acceptance harness with explicit deploy/rollback flags; +- interactive demo verification for repository quick actions, hunk staging and pull-request dialogs. + ### v0.4 additions diff --git a/main.cjs b/main.cjs index 9eda575..71fd3f7 100644 --- a/main.cjs +++ b/main.cjs @@ -1,7 +1,7 @@ 'use strict'; const path = require('node:path'); -const { app, BrowserWindow, shell, session, safeStorage } = require('electron'); +const { app, BrowserWindow, shell, session, safeStorage, Tray, Menu, Notification } = require('electron'); const { ConfigStore } = require('./src/main/config-store.cjs'); const { GitService } = require('./src/main/git-service.cjs'); const { GiteaService } = require('./src/main/gitea-service.cjs'); @@ -13,12 +13,16 @@ const { PreflightService } = require('./src/main/preflight-service.cjs'); const { UpdateService } = require('./src/main/update-service.cjs'); const { SshService } = require('./src/main/ssh-service.cjs'); const { UnraidDeploymentService } = require('./src/main/unraid-deployment-service.cjs'); +const { AuditService } = require('./src/main/audit-service.cjs'); +const { ExternalToolsService } = require('./src/main/external-tools-service.cjs'); const { registerIpc } = require('./src/main/ipc.cjs'); let mainWindow; let repositoryMonitor; let operationTimer; let diagnostics; +let configStore; +let tray; let quitCleanupStarted = false; function broadcast(channel, payload) { @@ -27,6 +31,39 @@ function broadcast(channel, payload) { } } +function showMainWindow() { + if (!mainWindow || mainWindow.isDestroyed()) createWindow(); + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + mainWindow.focus(); +} + +function notify(title, body) { + if (!configStore?.data.preferences.notificationsEnabled || !Notification.isSupported()) return; + const notification = new Notification({ title, body, icon: path.join(__dirname, 'build', 'icon.png') }); + notification.on('click', showMainWindow); + notification.show(); +} + +function configureDesktopIntegration() { + const preferences = configStore?.data.preferences || {}; + if (preferences.trayEnabled && !tray) { + tray = new Tray(path.join(__dirname, 'build', process.platform === 'win32' ? 'icon.ico' : 'icon.png')); + tray.setToolTip('ForgeFlow'); + tray.on('double-click', showMainWindow); + } else if (!preferences.trayEnabled && tray) { + tray.destroy(); tray = null; + } + if (tray) tray.setContextMenu(Menu.buildFromTemplate([ + { label: 'Open ForgeFlow', click: showMainWindow }, + { type: 'separator' }, + { label: 'Quit', click: () => app.quit() } + ])); + if (app.isPackaged && ['win32', 'darwin'].includes(process.platform)) { + app.setLoginItemSettings({ openAtLogin: Boolean(preferences.startAtLogin) }); + } +} + function createWindow() { mainWindow = new BrowserWindow({ width: 1480, @@ -58,12 +95,18 @@ function createWindow() { mainWindow.webContents.on('render-process-gone', (_event, details) => diagnostics?.error('renderer.process.gone', details)); mainWindow.webContents.on('did-fail-load', (_event, code, description, validatedUrl) => diagnostics?.error('renderer.load.failed', { code, description, validatedUrl })); mainWindow.webContents.setWindowOpenHandler(({ url }) => { - if (/^https?:\/\//i.test(url)) shell.openExternal(url); + if (/^https?:\/\//i.test(url)) shell.openExternal(url).catch((error) => diagnostics?.warning('external-link.open.failed', { url, message: error.message })); return { action: 'deny' }; }); mainWindow.webContents.on('will-navigate', (event, url) => { if (url !== mainWindow.webContents.getURL()) event.preventDefault(); }); + mainWindow.on('close', (event) => { + if (!quitCleanupStarted && configStore?.data.preferences.closeToTray && configStore?.data.preferences.trayEnabled) { + event.preventDefault(); + mainWindow.hide(); + } + }); } app.whenReady().then(async () => { @@ -80,6 +123,7 @@ app.whenReady().then(async () => { const userDataPath = app.getPath('userData'); const store = new ConfigStore(userDataPath); + configStore = store; await store.load(); diagnostics = new DiagnosticsService({ userDataPath, @@ -96,6 +140,8 @@ app.whenReady().then(async () => { preferencesProvider: () => store.data.preferences }); await diagnostics.initialize(); + const audit = new AuditService({ userDataPath, appInfo: { version: app.getVersion() } }); + await audit.initialize(); process.on('uncaughtException', (error) => { diagnostics?.error('process.uncaught-exception', error).finally(() => app.exit(1)); @@ -103,11 +149,25 @@ app.whenReady().then(async () => { process.on('unhandledRejection', (reason) => diagnostics?.error('process.unhandled-rejection', reason instanceof Error ? reason : { reason })); const git = new GitService(); + const externalTools = new ExternalToolsService(store); const gitea = new GiteaService(store, diagnostics); const repositories = new RepositoryService(store, git, gitea, diagnostics); const deployments = new DeploymentService(store, gitea, git, diagnostics); const ssh = new SshService({ store, diagnostics }); - const unraid = new UnraidDeploymentService({ store, ssh, git, diagnostics, sourcePath: app.getAppPath(), onOperationChange: (payload) => broadcast('operations:changed', payload) }); + const auditedOperationStates = new Set(); + const reportOperationChange = (payload) => { + broadcast('operations:changed', payload); + const operation = payload?.operation; + if (operation && ['success', 'failed', 'rolled-back'].includes(operation.status)) { + const key = `${operation.id}:${operation.status}`; + if (!auditedOperationStates.has(key)) { + auditedOperationStates.add(key); + notify(`Deployment ${operation.status}`, `${operation.repository || 'Repository'} · ${operation.shortSha || operation.sha?.slice(0, 7) || ''}`); + audit.append('deployment.completed', { repository: operation.repository, profileId: operation.profileId, sha: operation.sha, result: operation.status, note: operation.releaseNote || '' }).catch((error) => diagnostics.warning('audit.write.failed', error)); + } + } + }; + const unraid = new UnraidDeploymentService({ store, ssh, git, diagnostics, sourcePath: app.getAppPath(), onOperationChange: reportOperationChange }); const updates = new UpdateService({ store, gitea, @@ -132,7 +192,8 @@ app.whenReady().then(async () => { onChange: (payload) => broadcast('repositories:changed', payload) }); repositoryMonitor.restart(); - registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh, updates, preflight, diagnostics, monitor: repositoryMonitor }); + registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh, updates, preflight, diagnostics, audit, externalTools, monitor: repositoryMonitor, onPreferencesChanged: configureDesktopIntegration }); + configureDesktopIntegration(); createWindow(); if (store.data.setupComplete && store.data.updates?.autoCheck && store.getToken()) { @@ -159,18 +220,26 @@ app.whenReady().then(async () => { if (operationTimer) clearTimeout(operationTimer); const intervalMs = Math.max(3, Number(store.data.preferences.operationPollSeconds) || 5) * 1000; operationTimer = setTimeout(async () => { - if (store.data.setupComplete) { - const active = store.data.operations.some((item) => item.type === 'deployment' && !['success', 'failed', 'cancelled', 'rolled-back'].includes(item.status)); - if (active) { - const [actions, sshOperations] = await Promise.all([ - store.getToken() ? deployments.refreshActiveOperations().catch(async (error) => { await diagnostics.error('operation-monitor.actions.failed', error); return []; }) : [], - unraid.refreshActiveOperations().catch(async (error) => { await diagnostics.error('operation-monitor.unraid.failed', error); return []; }) - ]); - const updated = [...actions, ...sshOperations]; - if (updated.length) broadcast('operations:changed', { operations: updated }); + try { + if (store.data.setupComplete) { + const active = store.data.operations.some((item) => item.type === 'deployment' && !['success', 'failed', 'cancelled', 'rolled-back'].includes(item.status)); + if (active) { + const [actions, sshOperations] = await Promise.all([ + store.getToken() ? deployments.refreshActiveOperations().catch(async (error) => { await diagnostics.error('operation-monitor.actions.failed', error); return []; }) : [], + unraid.refreshActiveOperations().catch(async (error) => { await diagnostics.error('operation-monitor.unraid.failed', error); return []; }) + ]); + const updated = [...actions, ...sshOperations]; + if (updated.length) { + broadcast('operations:changed', { operations: updated }); + for (const operation of updated.filter((item) => ['success', 'failed', 'rolled-back'].includes(item.status))) reportOperationChange({ operation }); + } + } } + } catch (error) { + await diagnostics.error('operation-monitor.tick.failed', error); + } finally { + if (!quitCleanupStarted) scheduleOperationPoll(); } - scheduleOperationPoll(); }, intervalMs); operationTimer.unref?.(); }; diff --git a/package-lock.json b/package-lock.json index a39cbb1..1db6099 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "forgeflow", - "version": "0.6.1", + "version": "0.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "forgeflow", - "version": "0.6.1", + "version": "0.8.1", "dependencies": { "ssh2": "1.17.0" }, diff --git a/package.json b/package.json index 7509025..e560f26 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "forgeflow", - "version": "0.6.1", + "version": "0.8.1", "private": true, "description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.", "main": "main.cjs", @@ -15,6 +15,9 @@ "dist:linux": "electron-builder --linux AppImage", "dist:mac": "electron-builder --mac dmg", "doctor": "node scripts/doctor.mjs", + "acceptance": "node scripts/acceptance.mjs", + "connections:check": "electron scripts/validate-installed-connections.cjs", + "manifest": "node scripts/generate-source-manifest.mjs", "check": "npm run verify && npm test" }, "devDependencies": { @@ -31,6 +34,7 @@ "src/**/*", "package.json", "build/icon.png", + "build/icon.ico", "docs/SETUP_GUIDE.md", "docs/DIAGNOSTICS.md", "docs/STATUS_ENDPOINT.md", @@ -63,7 +67,11 @@ "START-FORGEFLOW-OVERLAY.ps1", "docs/RELEASE_NOTES_0.6.0.md", "docs/RELEASE_AUDIT_0.6.0.md", - "docs/RELEASE_NOTES_0.6.1.md" + "docs/RELEASE_NOTES_0.6.1.md", + "docs/RELEASE_NOTES_0.7.0.md", + "docs/RELEASE_NOTES_0.8.0.md", + "docs/RELEASE_NOTES_0.8.1.md", + "docs/ACCEPTANCE.md" ], "directories": { "output": "dist" @@ -75,6 +83,12 @@ ], "icon": "build/icon.ico" }, + "nsis": { + "artifactName": "${productName}-Setup-${version}-${os}-${arch}.${ext}" + }, + "portable": { + "artifactName": "${productName}-Portable-${version}-${os}-${arch}.${ext}" + }, "linux": { "target": [ "AppImage" diff --git a/preload.cjs b/preload.cjs index 82c42c9..df6ea4a 100644 --- a/preload.cjs +++ b/preload.cjs @@ -34,6 +34,10 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({ setWorkspaceRoots: (roots) => invoke('settings:set-roots', { roots }), setAppearance: (appearance) => invoke('settings:set-appearance', { appearance }), setPreferences: (preferences) => invoke('settings:set-preferences', { preferences }), + exportConfigurationBackup: (passphrase) => invoke('settings:export-backup', { passphrase }), + importConfigurationBackup: (passphrase) => invoke('settings:import-backup', { passphrase }), + listAuditEvents: (limit = 250) => invoke('audit:list', { limit }), + exportAuditLog: (format = 'json') => invoke('audit:export', { format }), setUpdatePreferences: (updates) => invoke('updates:preferences', { updates }), checkForUpdates: () => invoke('updates:check'), downloadUpdate: () => invoke('updates:download'), @@ -42,6 +46,7 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({ deleteServer: (serverId) => invoke('server:delete', { serverId }), testServer: (serverId) => invoke('server:test', { serverId }), inspectServerProject: (repository, profileId) => invoke('server:inspect-project', { repository, profileId }), + discoverExistingDeployment: (repository, serverId, remoteFolder) => invoke('server:discover-existing', { repository, serverId, remoteFolder }), refreshRepositories: () => invoke('repositories:refresh'), discoverRepositories: (roots) => invoke('repositories:discover', { roots }), favoriteRepository: (fullName, favorite) => invoke('repository:favorite', { fullName, favorite }), @@ -49,14 +54,25 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({ unlinkRepository: (fullName) => invoke('repository:unlink', { fullName }), repositoryStatus: (localPath) => invoke('repository:status', { localPath }), repositoryDiff: (localPath, filePath, staged = false) => invoke('repository:diff', { localPath, filePath, staged }), + repositoryDiffHunks: (localPath, filePath) => invoke('repository:diff-hunks', { localPath, filePath }), + stageHunks: (localPath, filePath, hunkIndexes) => invoke('repository:stage-hunks', { localPath, filePath, hunkIndexes }), + conflictState: (localPath) => invoke('repository:conflicts', { localPath }), + resolveConflict: (localPath, filePath, resolution) => invoke('repository:resolve-conflict', { localPath, filePath, resolution }), + continueGitOperation: (localPath) => invoke('repository:continue-operation', { localPath }), + abortGitOperation: (localPath) => invoke('repository:abort-operation', { localPath }), stageFiles: (localPath, files) => invoke('repository:stage', { localPath, files }), unstageFiles: (localPath, files) => invoke('repository:unstage', { localPath, files }), commit: (localPath, message, files) => invoke('repository:commit', { localPath, message, files }), + commitStaged: (localPath, message) => invoke('repository:commit-staged', { localPath, message }), + commitStagedAndPush: (localPath, message) => invoke('repository:commit-staged-push', { localPath, message }), commitAndPush: (localPath, message, files) => invoke('repository:commit-push', { localPath, message, files }), push: (localPath) => invoke('repository:push', { localPath }), fetch: (localPath) => invoke('repository:fetch', { localPath }), pull: (localPath) => invoke('repository:pull', { localPath }), history: (localPath, limit = 20) => invoke('repository:history', { localPath, limit }), + branchProtection: (fullName, branch) => invoke('repository:branch-protection', { fullName, branch }), + pullRequests: (fullName, state = 'open') => invoke('repository:pull-requests', { fullName, state }), + createPullRequest: (fullName, title, body, base) => invoke('repository:create-pull-request', { fullName, title, body, base }), branches: (localPath) => invoke('repository:branches', { localPath }), checkoutBranch: (localPath, branch) => invoke('repository:checkout-branch', { localPath, branch }), createBranch: (localPath, branch) => invoke('repository:create-branch', { localPath, branch }), @@ -73,11 +89,13 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({ normalizeOrigins: () => invoke('repositories:normalize-origins'), cloneRepository: (fullName, mode = 'default') => invoke('repository:clone', { fullName, mode }), openPath: (localPath) => invoke('repository:open-path', { localPath }), + openEditor: (localPath, filePath = '', line = 1) => invoke('repository:open-editor', { localPath, filePath, line }), + openTerminal: (localPath) => invoke('repository:open-terminal', { localPath }), openExternal: (url) => invoke('external:open', { url }), saveDeploymentProfile: (fullName, profile) => invoke('deployment:save-profile', { fullName, profile }), deploymentPreflight: (repository, profileId) => invoke('deployment:preflight', { repository, profileId }), deleteDeploymentProfile: (fullName, profileId) => invoke('deployment:delete-profile', { fullName, profileId }), - deploy: (repository, profileId, sha) => invoke('deployment:dispatch', { repository, profileId, sha }), + deploy: (repository, profileId, sha, options = {}) => invoke('deployment:dispatch', { repository, profileId, sha, note: options.note || '', override: options.override === true, overrideReason: options.overrideReason || '' }), rollback: (repository, profileId, targetSha) => invoke('deployment:rollback', { repository, profileId, targetSha }), healthcheck: (url) => invoke('deployment:health', { url }), refreshProfileState: (fullName, profileId) => invoke('deployment:profile-state', { fullName, profileId }), @@ -85,6 +103,9 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({ reconcileDeployment: (fullName, profileId) => invoke('deployment:reconcile', { fullName, profileId }), refreshOperations: (operationId = null) => invoke('operations:refresh', { operationId }), getOperation: (operationId) => invoke('operations:get', { operationId }), + troubleshooterScan: (fullName = null) => invoke('troubleshooter:scan', { fullName }), + troubleshooterRepair: (issue) => invoke('troubleshooter:repair', { issue }), + troubleshooterAutoRepair: (issues) => invoke('troubleshooter:auto-repair', { issues }), diagnosticsStatus: () => invoke('diagnostics:status'), clearDiagnostics: () => invoke('diagnostics:clear'), openDiagnosticsFolder: () => invoke('diagnostics:open-folder'), diff --git a/scripts/acceptance.mjs b/scripts/acceptance.mjs new file mode 100644 index 0000000..5d83ee3 --- /dev/null +++ b/scripts/acceptance.mjs @@ -0,0 +1,88 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import crypto from 'node:crypto'; +import process from 'node:process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const exec = promisify(execFile); +const required = ['FORGEFLOW_GITEA_URL', 'FORGEFLOW_GITEA_TOKEN', 'FORGEFLOW_REPOSITORY', 'FORGEFLOW_LOCAL_PATH', 'FORGEFLOW_BRANCH', 'FORGEFLOW_STATUS_URL', 'FORGEFLOW_HEALTH_URL']; + +export function readAcceptanceConfig(env = process.env) { + const missing = required.filter((name) => !String(env[name] || '').trim()); + if (missing.length) throw new Error(`Missing acceptance environment variables: ${missing.join(', ')}`); + const [owner, repo, extra] = env.FORGEFLOW_REPOSITORY.split('/'); + if (!owner || !repo || extra) throw new Error('FORGEFLOW_REPOSITORY must use owner/repository.'); + return { + baseUrl: env.FORGEFLOW_GITEA_URL.replace(/\/+$/, ''), token: env.FORGEFLOW_GITEA_TOKEN, + owner, repo, localPath: env.FORGEFLOW_LOCAL_PATH, branch: env.FORGEFLOW_BRANCH, + workflow: env.FORGEFLOW_WORKFLOW || 'deploy.yml', rollbackWorkflow: env.FORGEFLOW_ROLLBACK_WORKFLOW || 'rollback.yml', + environment: env.FORGEFLOW_ENVIRONMENT || 'staging', statusUrl: env.FORGEFLOW_STATUS_URL, healthUrl: env.FORGEFLOW_HEALTH_URL + }; +} + +async function git(config, args) { return (await exec('git', args, { cwd: config.localPath, encoding: 'utf8' })).stdout.trim(); } +async function api(config, pathname, options = {}) { + const response = await fetch(`${config.baseUrl}/api/v1${pathname}`, { method: options.method || 'GET', headers: { Authorization: `token ${config.token}`, Accept: 'application/json', ...(options.body ? { 'Content-Type': 'application/json' } : {}) }, body: options.body ? JSON.stringify(options.body) : undefined, signal: AbortSignal.timeout(30_000) }); + const text = await response.text(); + if (!response.ok) throw new Error(`Gitea ${response.status}: ${text.slice(0, 500)}`); + return text ? JSON.parse(text) : null; +} +async function publicJson(url) { const response = await fetch(url, { signal: AbortSignal.timeout(15_000), cache: 'no-store' }); if (!response.ok) throw new Error(`${url} returned HTTP ${response.status}`); return response.json(); } +async function health(url) { const response = await fetch(url, { signal: AbortSignal.timeout(15_000), cache: 'no-store' }); return { ok: response.ok, status: response.status }; } + +export async function inspectAcceptanceEnvironment(config) { + const [head, branch, porcelain, upstream, repository, remoteBranch, workflow, server, healthResult] = await Promise.all([ + git(config, ['rev-parse', 'HEAD']), git(config, ['branch', '--show-current']), git(config, ['status', '--porcelain']), git(config, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']).catch(() => ''), + api(config, `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent(config.repo)}`), + api(config, `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent(config.repo)}/branches/${encodeURIComponent(config.branch)}`), + api(config, `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent(config.repo)}/contents/.gitea/workflows/${encodeURIComponent(config.workflow)}?ref=${encodeURIComponent(config.branch)}`), + publicJson(config.statusUrl), health(config.healthUrl) + ]); + const checks = [ + { id: 'clean', ok: !porcelain, detail: porcelain ? 'Working tree has changes' : 'Working tree clean' }, + { id: 'branch', ok: branch === config.branch, detail: `Local ${branch}; expected ${config.branch}` }, + { id: 'upstream', ok: Boolean(upstream), detail: upstream || 'No upstream' }, + { id: 'repository', ok: repository.full_name?.toLowerCase() === `${config.owner}/${config.repo}`.toLowerCase(), detail: repository.full_name }, + { id: 'remote-sha', ok: remoteBranch.commit?.id === head, detail: `local ${head.slice(0, 7)}; remote ${(remoteBranch.commit?.id || '').slice(0, 7)}` }, + { id: 'workflow', ok: workflow.type === 'file', detail: config.workflow }, + { id: 'status', ok: Boolean(server && typeof server === 'object'), detail: server?.liveSha || 'No live SHA' }, + { id: 'health', ok: healthResult.ok, detail: `HTTP ${healthResult.status}` } + ]; + return { generatedAt: new Date().toISOString(), head, server, checks, ready: checks.every((check) => check.ok) }; +} + +async function waitForSha(config, sha, requestId, timeoutMs = 15 * 60_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const state = await publicJson(config.statusUrl); + if (state.requestId === requestId && state.liveSha === sha) { + const probe = await health(config.healthUrl); + if (probe.ok) return state; + } + await new Promise((resolve) => setTimeout(resolve, 10_000)); + } + throw new Error(`Timed out waiting for exact live SHA ${sha}.`); +} + +export async function executeAcceptanceDeployment(config, sha, workflow = config.workflow, inputName = 'commit_sha') { + const requestId = crypto.randomUUID(); + await api(config, `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent(config.repo)}/actions/workflows/${encodeURIComponent(workflow)}/dispatches`, { method: 'POST', body: { ref: config.branch, inputs: { environment: config.environment, [inputName]: sha, request_id: requestId } } }); + return { requestId, state: await waitForSha(config, sha, requestId) }; +} + +if (process.argv[1] && path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1])) { + const config = readAcceptanceConfig(); + const report = await inspectAcceptanceEnvironment(config); + if (process.argv.includes('--execute-deployment')) { + if (!report.ready) throw new Error('Read-only acceptance checks must pass before deployment execution.'); + report.deployment = await executeAcceptanceDeployment(config, report.head); + } + if (process.argv.includes('--execute-rollback')) { + const target = report.server?.previousSha; + if (!target) throw new Error('Status endpoint does not report a previousSha for rollback acceptance.'); + report.rollback = await executeAcceptanceDeployment(config, target, config.rollbackWorkflow, 'target_sha'); + } + console.log(JSON.stringify(report, null, 2)); + if (!report.ready) process.exitCode = 1; +} diff --git a/scripts/doctor.mjs b/scripts/doctor.mjs index 6da4d9a..ba81a70 100644 --- a/scripts/doctor.mjs +++ b/scripts/doctor.mjs @@ -11,8 +11,9 @@ const packageJson = JSON.parse(await readFile(new URL('../package.json', import. const checks = []; const jsonMode = process.argv.includes('--json'); -function add(id, name, ok, detail, help = '') { - checks.push({ id, name, status: ok ? 'pass' : 'fail', ok, detail, help }); +function add(id, name, ok, detail, help = '', severity = 'required') { + const status = ok ? 'pass' : severity === 'warning' ? 'warning' : 'fail'; + checks.push({ id, name, status, ok: ok || severity === 'warning', detail, help, severity }); } const major = Number(process.versions.node.split('.')[0]); @@ -45,7 +46,7 @@ try { exec('git', ['config', '--global', '--get', 'user.name']).then((result) => result.stdout.trim()).catch(() => ''), exec('git', ['config', '--global', '--get', 'user.email']).then((result) => result.stdout.trim()).catch(() => '') ]); - add('git-identity', 'Git identity', Boolean(name && email), name && email ? `${name} <${email}>` : 'user.name or user.email is missing', 'Configure git config --global user.name and user.email.'); + add('git-identity', 'Git identity', Boolean(name && email), name && email ? `${name} <${email}>` : 'user.name or user.email is missing; commits will remain disabled until configured', 'Configure git config --global user.name and user.email.', 'warning'); } catch (error) { add('git', 'Git', false, error.message, 'Install Git and ensure git is on PATH.'); } @@ -68,20 +69,22 @@ try { if (markerDirectory) await rm(markerDirectory, { recursive: true, force: true }).catch(() => {}); } +const blockingChecks = checks.filter((check) => check.status === 'fail'); + const report = { product: 'ForgeFlow', version: packageJson.version, generatedAt: new Date().toISOString(), platform: process.platform, arch: process.arch, - ready: checks.every((check) => check.ok), + ready: blockingChecks.length === 0, checks }; if (jsonMode) console.log(JSON.stringify(report, null, 2)); else { console.log('ForgeFlow doctor\n'); - for (const check of checks) console.log(`${check.ok ? 'PASS' : 'FAIL'} ${check.name.padEnd(26)} ${check.detail}`); + for (const check of checks) console.log(`${check.status === 'pass' ? 'PASS' : check.status === 'warning' ? 'WARN' : 'FAIL'} ${check.name.padEnd(26)} ${check.detail}`); console.log(`\n${report.ready ? 'Environment is ready.' : 'Resolve failed checks before starting ForgeFlow.'}`); } diff --git a/scripts/generate-source-manifest.mjs b/scripts/generate-source-manifest.mjs new file mode 100644 index 0000000..a5f29d1 --- /dev/null +++ b/scripts/generate-source-manifest.mjs @@ -0,0 +1,37 @@ +import { createHash } from 'node:crypto'; +import { readdir, readFile, stat, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const excludedDirectories = new Set(['.git', 'dist', 'node_modules']); +const excludedFiles = new Set(['SOURCE_MANIFEST.txt']); + +async function collect(directory, output = []) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (entry.isDirectory() && excludedDirectories.has(entry.name)) continue; + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) await collect(absolute, output); + else if (!excludedFiles.has(entry.name)) output.push(absolute); + } + return output; +} + +const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')); +const files = (await collect(root)).sort((left, right) => left.localeCompare(right, 'en')); +const lines = [ + `ForgeFlow ${packageJson.version} source manifest`, + 'SHA-256 BYTES PATH', + '(The manifest excludes itself, dependencies and generated release artifacts.)' +]; + +for (const absolute of files) { + const bytes = await readFile(absolute); + const size = (await stat(absolute)).size; + const digest = createHash('sha256').update(bytes).digest('hex'); + const relative = path.relative(root, absolute).replaceAll('\\', '/'); + lines.push(`${digest} ${String(size).padStart(12)} ${relative}`); +} + +await writeFile(path.join(root, 'SOURCE_MANIFEST.txt'), `${lines.join('\n')}\n`, 'utf8'); +console.log(`Wrote ${files.length} entries for ForgeFlow ${packageJson.version}.`); diff --git a/scripts/validate-installed-connections.cjs b/scripts/validate-installed-connections.cjs new file mode 100644 index 0000000..f054e7d --- /dev/null +++ b/scripts/validate-installed-connections.cjs @@ -0,0 +1,94 @@ +"use strict"; + +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { app, safeStorage } = require("electron"); + +const configuredUserData = process.env.FORGEFLOW_USER_DATA; +if (configuredUserData) + app.setPath("userData", path.resolve(configuredUserData)); + +function result(name, ok, detail) { + console.log( + `${ok ? "PASS" : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`, + ); + return ok; +} + +app.whenReady().then(async () => { + let passed = true; + try { + const userDataPath = configuredUserData + ? path.resolve(configuredUserData) + : path.join(app.getPath("appData"), "forgeflow"); + const configPath = path.join(userDataPath, "forgeflow-config.json"); + const config = JSON.parse(await fs.readFile(configPath, "utf8")); + const baseUrl = String(config.gitea?.baseUrl || "").replace(/\/+$/, ""); + const encrypted = String(config.gitea?.encryptedToken || ""); + passed = + result( + "secure storage", + safeStorage.isEncryptionAvailable(), + "OS-backed encryption available", + ) && passed; + passed = + result( + "encrypted token", + Boolean(encrypted), + encrypted ? "present in ForgeFlow configuration" : "missing", + ) && passed; + if (!baseUrl || !encrypted) + throw new Error("ForgeFlow Gitea configuration is incomplete."); + + const token = safeStorage.decryptString(Buffer.from(encrypted, "base64")); + const headers = { + Accept: "application/json", + Authorization: `token ${token}`, + }; + const userResponse = await fetch(`${baseUrl}/api/v1/user`, { + headers, + signal: AbortSignal.timeout(15_000), + }); + const user = userResponse.ok ? await userResponse.json() : null; + passed = + result( + "Gitea API authentication", + userResponse.ok, + userResponse.ok + ? `authenticated as ${user.login}` + : `HTTP ${userResponse.status}`, + ) && passed; + + if (userResponse.ok) { + const repositoryResponse = await fetch( + `${baseUrl}/api/v1/repos/Jens/ForgeFlow`, + { headers, signal: AbortSignal.timeout(15_000) }, + ); + passed = + result( + "ForgeFlow repository access", + repositoryResponse.ok, + repositoryResponse.ok + ? "read access confirmed" + : `HTTP ${repositoryResponse.status}`, + ) && passed; + const actionsResponse = await fetch( + `${baseUrl}/api/v1/repos/Jens/ForgeFlow/actions/runs?limit=1`, + { headers, signal: AbortSignal.timeout(15_000) }, + ); + passed = + result( + "Gitea Actions access", + actionsResponse.ok, + actionsResponse.ok + ? "workflow access confirmed" + : `HTTP ${actionsResponse.status}`, + ) && passed; + } + } catch (error) { + passed = result("connection validation", false, error.message) && passed; + } finally { + process.exitCode = passed ? 0 : 1; + app.quit(); + } +}); diff --git a/scripts/verify.mjs b/scripts/verify.mjs index e2f8ee7..34169ea 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -1,54 +1,140 @@ -import { access, readFile, readdir } from 'node:fs/promises'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { spawnSync } from 'node:child_process'; -import shellVerification from '../src/shared/shell-verification.cjs'; +import { access, readFile, readdir } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import shellVerification from "../src/shared/shell-verification.cjs"; -const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const required = [ - 'package.json', 'main.cjs', 'preload.cjs', - 'src/renderer/index.html', 'src/renderer/styles.css', 'src/renderer/app.js', 'src/renderer/mock-bridge.js', - 'src/renderer/assets/itworx-mark.png', 'src/renderer/assets/itworx-wordmark.png', 'src/renderer/assets/itworx-wordmark-light.png', 'src/renderer/assets/itworx-wordmark-dark.png', - 'src/main/config-store.cjs', 'src/main/git-service.cjs', 'src/main/gitea-service.cjs', - 'src/main/repository-service.cjs', 'src/main/repository-monitor.cjs', 'src/main/deployment-service.cjs', - 'src/main/unraid-deployment-service.cjs', 'src/main/ssh-service.cjs', 'src/main/update-service.cjs', - 'src/main/diagnostics-service.cjs', 'src/main/preflight-service.cjs', 'src/main/log-redaction.cjs', 'src/main/ipc.cjs', - 'src/shared/clone-target.cjs', 'src/shared/semver.cjs', 'src/shared/zip-writer.cjs', - 'src/shared/tool-invocation.cjs', 'src/shared/shell-verification.cjs', 'START_HERE.md', 'README.md', 'SOURCE_MANIFEST.txt', - 'setup-windows.ps1', 'START-FORGEFLOW-OVERLAY.ps1', 'update-windows.ps1', 'build-windows.ps1', 'UPDATE_FROM_0.3.2.md', 'scripts/apply-source-update.ps1', - 'docs/ARCHITECTURE.md', 'docs/SECURITY.md', 'docs/ROADMAP.md', 'docs/SETUP_GUIDE.md', - 'docs/UPDATING.md', 'docs/DIAGNOSTICS.md', 'docs/DEPLOYMENT_SETUP.md', 'docs/SSH_UNRAID_DEPLOYMENT.md', - 'docs/LUMAOPS_SERVER_AUDIT.md', 'docs/STATUS_ENDPOINT.md', 'docs/TEST_MATRIX.md', 'docs/RELEASE_NOTES_0.4.0.md', 'docs/RELEASE_NOTES_0.4.1.md', 'docs/RELEASE_NOTES_0.4.2.md', 'docs/RELEASE_NOTES_0.4.3.md', - 'docs/RELEASE_AUDIT_0.6.0.md', 'docs/RELEASE_NOTES_0.6.1.md', 'docs/RELEASE_NOTES_0.5.0.md', 'docs/RELEASE_NOTES_0.5.1.md', 'docs/RELEASE_NOTES_0.5.2.md', 'docs/RELEASE_NOTES_0.5.3.md', 'docs/RELEASE_NOTES_0.5.4.md', 'docs/RELEASE_NOTES_0.6.0.md', - 'Publish-ForgeFlow-Release.ps1', 'docs/RELEASE_NOTES_0.4.4.md', 'docs/RELEASE_NOTES_0.4.5.md', - 'examples/gitea-actions/deploy.yml', 'examples/gitea-actions/rollback.yml', - 'examples/server/forgeflow-deploy', 'examples/server/forgeflow-targets.conf', - 'examples/server/forgeflow-runner.sudoers', 'examples/server/status-example.json', - 'build/icon.png', 'build/icon.ico' + "package.json", + "main.cjs", + "preload.cjs", + "src/renderer/index.html", + "src/renderer/styles.css", + "src/renderer/app.js", + "src/renderer/mock-bridge.js", + "src/renderer/assets/itworx-mark.png", + "src/renderer/assets/itworx-wordmark.png", + "src/renderer/assets/itworx-wordmark-light.png", + "src/renderer/assets/itworx-wordmark-dark.png", + "src/main/config-store.cjs", + "src/main/git-service.cjs", + "src/main/gitea-service.cjs", + "src/main/audit-service.cjs", + "src/main/configuration-backup.cjs", + "src/main/external-tools-service.cjs", + "src/main/repository-service.cjs", + "src/main/repository-monitor.cjs", + "src/main/deployment-service.cjs", + "src/main/unraid-deployment-service.cjs", + "src/main/ssh-service.cjs", + "src/main/update-service.cjs", + "src/main/diagnostics-service.cjs", + "src/main/preflight-service.cjs", + "src/main/log-redaction.cjs", + "src/main/ipc.cjs", + "src/shared/clone-target.cjs", + "src/shared/semver.cjs", + "src/shared/zip-writer.cjs", + "src/shared/tool-invocation.cjs", + "src/shared/shell-verification.cjs", + "START_HERE.md", + "README.md", + "SOURCE_MANIFEST.txt", + "src/shared/deployment-policy.cjs", + "scripts/acceptance.mjs", + "scripts/validate-installed-connections.cjs", + "scripts/generate-source-manifest.mjs", + "setup-windows.ps1", + "START-FORGEFLOW-OVERLAY.ps1", + "update-windows.ps1", + "build-windows.ps1", + "UPDATE_FROM_0.3.2.md", + "scripts/apply-source-update.ps1", + "docs/ARCHITECTURE.md", + "docs/SECURITY.md", + "docs/ROADMAP.md", + "docs/SETUP_GUIDE.md", + "docs/ACCEPTANCE.md", + "docs/RELEASE_NOTES_0.8.0.md", + "docs/RELEASE_NOTES_0.8.1.md", + "docs/UPDATING.md", + "docs/DIAGNOSTICS.md", + "docs/DEPLOYMENT_SETUP.md", + "docs/SSH_UNRAID_DEPLOYMENT.md", + "docs/LUMAOPS_SERVER_AUDIT.md", + "docs/STATUS_ENDPOINT.md", + "docs/TEST_MATRIX.md", + "docs/RELEASE_NOTES_0.4.0.md", + "docs/RELEASE_NOTES_0.4.1.md", + "docs/RELEASE_NOTES_0.4.2.md", + "docs/RELEASE_NOTES_0.4.3.md", + "docs/RELEASE_AUDIT_0.6.0.md", + "docs/RELEASE_NOTES_0.6.1.md", + "docs/RELEASE_NOTES_0.7.0.md", + "docs/RELEASE_NOTES_0.5.0.md", + "docs/RELEASE_NOTES_0.5.1.md", + "docs/RELEASE_NOTES_0.5.2.md", + "docs/RELEASE_NOTES_0.5.3.md", + "docs/RELEASE_NOTES_0.5.4.md", + "docs/RELEASE_NOTES_0.6.0.md", + "Publish-ForgeFlow-Release.ps1", + "docs/RELEASE_NOTES_0.4.4.md", + "docs/RELEASE_NOTES_0.4.5.md", + "examples/gitea-actions/deploy.yml", + "examples/gitea-actions/rollback.yml", + "examples/server/forgeflow-deploy", + "examples/server/forgeflow-targets.conf", + "examples/server/forgeflow-runner.sudoers", + "examples/server/status-example.json", + "build/icon.png", + "build/icon.ico", ]; for (const file of required) await access(path.join(root, file)); -const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')); -if (packageJson.version !== '0.6.1') throw new Error(`Expected package version 0.6.1, got ${packageJson.version}.`); -const sourceManifest = await readFile(path.join(root, 'SOURCE_MANIFEST.txt'), 'utf8'); -if (!sourceManifest.startsWith(`ForgeFlow ${packageJson.version} source manifest\n`)) throw new Error('SOURCE_MANIFEST.txt does not match the package version.'); -for (const group of ['dependencies', 'devDependencies']) { +const packageJson = JSON.parse( + await readFile(path.join(root, "package.json"), "utf8"), +); +if (packageJson.version !== "0.8.1") + throw new Error( + `Expected package version 0.8.1, got ${packageJson.version}.`, + ); +const sourceManifest = await readFile( + path.join(root, "SOURCE_MANIFEST.txt"), + "utf8", +); +if ( + !sourceManifest + .replace(/\r\n/g, "\n") + .startsWith(`ForgeFlow ${packageJson.version} source manifest\n`) +) + throw new Error("SOURCE_MANIFEST.txt does not match the package version."); +for (const group of ["dependencies", "devDependencies"]) { for (const [name, version] of Object.entries(packageJson[group] || {})) { - if (/^[~^*]/.test(version)) throw new Error(`${group} dependency ${name} must be pinned exactly, got ${version}.`); + if (/^[~^*]/.test(version)) + throw new Error( + `${group} dependency ${name} must be pinned exactly, got ${version}.`, + ); } } -if (packageJson.dependencies?.ssh2 !== '1.17.0') throw new Error('ssh2 must remain pinned to 1.17.0.'); -for (const script of ['start', 'demo', 'test', 'verify', 'check']) { - if (!packageJson.scripts?.[script]) throw new Error(`Required npm script is missing: ${script}`); +if (packageJson.dependencies?.ssh2 !== "1.17.0") + throw new Error("ssh2 must remain pinned to 1.17.0."); +for (const script of ["start", "demo", "test", "verify", "check"]) { + if (!packageJson.scripts?.[script]) + throw new Error(`Required npm script is missing: ${script}`); } -if (!packageJson.build?.win?.icon || !packageJson.build?.linux?.icon || !packageJson.build?.mac?.icon) { - throw new Error('Package icon configuration is incomplete.'); +if ( + !packageJson.build?.win?.icon || + !packageJson.build?.linux?.icon || + !packageJson.build?.mac?.icon +) { + throw new Error("Package icon configuration is incomplete."); } async function collect(directory, extensions, output = []) { for (const entry of await readdir(directory, { withFileTypes: true })) { - if (['node_modules', 'dist'].includes(entry.name)) continue; + if (["node_modules", "dist"].includes(entry.name)) continue; const absolute = path.join(directory, entry.name); if (entry.isDirectory()) await collect(absolute, extensions, output); else if (extensions.has(path.extname(entry.name))) output.push(absolute); @@ -56,13 +142,21 @@ async function collect(directory, extensions, output = []) { return output; } -const javascriptFiles = await collect(root, new Set(['.js', '.cjs', '.mjs'])); +const javascriptFiles = await collect(root, new Set([".js", ".cjs", ".mjs"])); for (const file of javascriptFiles) { - const result = spawnSync(process.execPath, ['--check', file], { encoding: 'utf8' }); - if (result.status !== 0) throw new Error(`${path.relative(root, file)} failed syntax validation:\n${result.stderr}`); + const result = spawnSync(process.execPath, ["--check", file], { + encoding: "utf8", + }); + if (result.status !== 0) + throw new Error( + `${path.relative(root, file)} failed syntax validation:\n${result.stderr}`, + ); } -const deploymentScript = await readFile(path.join(root, 'examples/server/forgeflow-deploy'), 'utf8'); +const deploymentScript = await readFile( + path.join(root, "examples/server/forgeflow-deploy"), + "utf8", +); shellVerification.validateShellScriptStructure(deploymentScript); // The server deployment script targets Linux/Unraid. On Windows, different tools may @@ -71,66 +165,221 @@ shellVerification.validateShellScriptStructure(deploymentScript); // a desktop update therefore never depend on a Windows Bash shim. Portable structural // validation always runs; GNU Bash syntax validation additionally runs on non-Windows. if (shellVerification.shouldRunExternalBash(process.platform)) { - const bashCheck = shellVerification.bashSyntaxCheckFromTextInvocation(deploymentScript); + const bashCheck = + shellVerification.bashSyntaxCheckFromTextInvocation(deploymentScript); const shell = spawnSync(bashCheck.command, bashCheck.args, bashCheck.options); - if (shell.error) throw new Error(`Unable to start Bash for server deployment syntax validation: ${shell.error.message}`); - if (shell.status !== 0) throw new Error(`Server deployment example failed bash syntax validation: -${shell.stderr || shell.stdout || 'Bash returned a non-zero status.'}`); + if (shell.error) + throw new Error( + `Unable to start Bash for server deployment syntax validation: ${shell.error.message}`, + ); + if (shell.status !== 0) + throw new Error(`Server deployment example failed bash syntax validation: +${shell.stderr || shell.stdout || "Bash returned a non-zero status."}`); } else { - console.log('Windows: external Bash syntax validation skipped; portable server-script validation passed.'); + console.log( + "Windows: external Bash syntax validation skipped; portable server-script validation passed.", + ); } -JSON.parse(await readFile(path.join(root, 'examples/server/status-example.json'), 'utf8')); -const setupGuide = await readFile(path.join(root, 'docs/SETUP_GUIDE.md'), 'utf8'); -const sshGuide = await readFile(path.join(root, 'docs/SSH_UNRAID_DEPLOYMENT.md'), 'utf8'); -const audit = await readFile(path.join(root, 'docs/LUMAOPS_SERVER_AUDIT.md'), 'utf8'); -const releaseNotes = await readFile(path.join(root, 'docs/RELEASE_NOTES_0.6.0.md'), 'utf8'); -const updaterReleaseNotes = await readFile(path.join(root, 'docs/RELEASE_NOTES_0.6.1.md'), 'utf8'); -if (!setupGuide.includes('Gitea access token') || !setupGuide.includes('diagnostic bundle')) { - throw new Error('Setup guide is missing required connection or diagnostics instructions.'); +JSON.parse( + await readFile( + path.join(root, "examples/server/status-example.json"), + "utf8", + ), +); +const setupGuide = await readFile( + path.join(root, "docs/SETUP_GUIDE.md"), + "utf8", +); +const sshGuide = await readFile( + path.join(root, "docs/SSH_UNRAID_DEPLOYMENT.md"), + "utf8", +); +const audit = await readFile( + path.join(root, "docs/LUMAOPS_SERVER_AUDIT.md"), + "utf8", +); +const releaseNotes = await readFile( + path.join(root, "docs/RELEASE_NOTES_0.6.0.md"), + "utf8", +); +const updaterReleaseNotes = await readFile( + path.join(root, "docs/RELEASE_NOTES_0.6.1.md"), + "utf8", +); +if ( + !setupGuide.includes("Gitea access token") || + !setupGuide.includes("diagnostic bundle") +) { + throw new Error( + "Setup guide is missing required connection or diagnostics instructions.", + ); } -if (!sshGuide.includes('/mnt/user/appdata') || !sshGuide.includes('host-key fingerprint')) { - throw new Error('SSH / Unraid guide is missing its base path or host identity policy.'); +if ( + !sshGuide.includes("/mnt/user/appdata") || + !sshGuide.includes("host-key fingerprint") +) { + throw new Error( + "SSH / Unraid guide is missing its base path or host identity policy.", + ); } -if (!audit.includes('d42d4a7f08240c478d07466e3fabec654dc71367') || !audit.includes('source/')) { - throw new Error('LumaOps audit is missing the exact matching SHA or nested repository finding.'); +if ( + !audit.includes("d42d4a7f08240c478d07466e3fabec654dc71367") || + !audit.includes("source/") +) { + throw new Error( + "LumaOps audit is missing the exact matching SHA or nested repository finding.", + ); } -for (const phrase of ['DockerMan', 'HEAD.lock', 'deployment reconciliation', 'Portfolio', 'safety branch', 'high-contrast ITWorx']) { - if (!releaseNotes.includes(phrase)) throw new Error(`Release notes are missing: ${phrase}`); +for (const phrase of [ + "DockerMan", + "HEAD.lock", + "deployment reconciliation", + "Portfolio", + "safety branch", + "high-contrast ITWorx", +]) { + if (!releaseNotes.includes(phrase)) + throw new Error(`Release notes are missing: ${phrase}`); } -for (const phrase of ['Windows PowerShell 5.1', 'File.Replace', 'handshake-only', 'updateId']) { - if (!updaterReleaseNotes.includes(phrase)) throw new Error(`Updater release notes are missing: ${phrase}`); +for (const phrase of [ + "Windows PowerShell 5.1", + "File.Replace", + "handshake-only", + "updateId", +]) { + if (!updaterReleaseNotes.includes(phrase)) + throw new Error(`Updater release notes are missing: ${phrase}`); } -const updateHelperPath = path.join(root, 'scripts/apply-source-update.ps1'); +const setupScript = await readFile( + path.join(root, "setup-windows.ps1"), + "utf8", +); +const sourceUpdateScript = await readFile( + path.join(root, "update-windows.ps1"), + "utf8", +); +for (const [name, script] of [ + ["setup-windows.ps1", setupScript], + ["update-windows.ps1", sourceUpdateScript], +]) { + if ( + !script.includes("$version = [string]$package.version") || + !script.includes("npm ci --no-audit --no-fund") + ) + throw new Error( + `${name} must use the package version dynamically and install from package-lock.json.`, + ); + if (/v0\.4\.2|version -ne "0\.4\.2"/.test(script)) + throw new Error( + `${name} still contains a stale hard-coded release version.`, + ); +} + +const updateHelperPath = path.join(root, "scripts/apply-source-update.ps1"); const updateHelperBytes = await readFile(updateHelperPath); -if (updateHelperBytes[0] === 0xef && updateHelperBytes[1] === 0xbb && updateHelperBytes[2] === 0xbf) throw new Error('PowerShell update helper must not contain a UTF-8 BOM.'); -const updateHelper = updateHelperBytes.toString('utf8'); -if (!updateHelper.trimStart().startsWith('param(') || updateHelper.trimStart().startsWith('\\')) throw new Error('PowerShell update helper must start directly with param(.'); +if ( + updateHelperBytes[0] === 0xef && + updateHelperBytes[1] === 0xbb && + updateHelperBytes[2] === 0xbf +) + throw new Error("PowerShell update helper must not contain a UTF-8 BOM."); +const updateHelper = updateHelperBytes.toString("utf8"); +if ( + !updateHelper.trimStart().startsWith("param(") || + updateHelper.trimStart().startsWith("\\") +) + throw new Error("PowerShell update helper must start directly with param(."); -const renderer = await readFile(path.join(root, 'src/renderer/app.js'), 'utf8'); -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'); -for (const phrase of ['Commit selected & push to Gitea', 'checkForUpdates', 'saveServer', 'profile-provider', 'profile-icon-mode', 'itworx-mark.png', 'Repair DockerMan integration', 'Repository troubleshooting', 'repair-repository-sync']) { - if (!renderer.includes(phrase) && !preload.includes(phrase)) throw new Error(`Frontend integration is missing: ${phrase}`); +const renderer = await readFile(path.join(root, "src/renderer/app.js"), "utf8"); +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"); +for (const phrase of [ + 'data-action="commit-push"', + "checkForUpdates", + "saveServer", + "profile-provider", + "profile-icon-mode", + "itworx-mark.png", + "Repair DockerMan integration", + "Repository troubleshooting", + "repair-repository-sync", +]) { + if (!renderer.includes(phrase) && !preload.includes(phrase)) + throw new Error(`Frontend integration is missing: ${phrase}`); } -if (!styles.includes('.file-list { flex: 1 1 auto;') || !styles.includes('.main-canvas.repository-canvas')) { - throw new Error('Changed-file scrolling constraints are missing.'); +if ( + !/\.file-list\s*\{[^}]*flex:\s*1 1 auto;/s.test(styles) || + !styles.includes(".main-canvas.repository-canvas") +) { + throw new Error("Changed-file scrolling constraints are missing."); } -for (const channel of ['updates:check', 'updates:download', 'updates:apply', 'server:save', 'server:test', 'server:inspect-project', 'repository:repair-git-locks', 'repository:repair-sync', 'deployment:apply-dockerman-metadata', 'deployment:reconcile']) { - if (!ipc.includes(channel)) throw new Error(`IPC registration is missing: ${channel}`); +for (const channel of [ + "server:discover-existing", + "troubleshooter:scan", + "troubleshooter:repair", + "troubleshooter:auto-repair", + "updates:check", + "updates:download", + "updates:apply", + "server:save", + "server:test", + "server:inspect-project", + "repository:repair-git-locks", + "repository:repair-sync", + "deployment:apply-dockerman-metadata", + "deployment:reconcile", +]) { + if (!ipc.includes(channel)) + throw new Error(`IPC registration is missing: ${channel}`); } -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 publisher = await readFile(path.join(root, 'Publish-ForgeFlow-Release.ps1'), 'utf8'); -for (const phrase of ['HEAD.lock', 'backup-reset', 'repairSync', "segments.includes('objects')"]) { - if (!gitSource.includes(phrase)) throw new Error(`Git recovery implementation is missing: ${phrase}`); +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 publisher = await readFile( + path.join(root, "Publish-ForgeFlow-Release.ps1"), + "utf8", +); +for (const phrase of [ + "HEAD.lock", + "backup-reset", + "repairSync", + "segments.includes('objects')", +]) { + if (!gitSource.includes(phrase)) + throw new Error(`Git recovery implementation is missing: ${phrase}`); } -for (const phrase of ['net.unraid.docker.managed', "'dockerman'", 'iconCacheRefresh', '[PORT:', 'Superseded by live commit']) { - if (!unraidSource.includes(phrase)) throw new Error(`Unraid recovery implementation is missing: ${phrase}`); +for (const phrase of [ + "discoverExisting", + "deriveDetectedProfile", + "docker inspect", + "net.unraid.docker.managed", + "'dockerman'", + "iconCacheRefresh", + "[PORT:", + "Superseded by live commit", +]) { + if (!unraidSource.includes(phrase)) + throw new Error(`Unraid recovery implementation is missing: ${phrase}`); } -for (const phrase of ['git ls-remote origin', 'apply-source-update.ps1', 'without changing its version']) { - if (!publisher.includes(phrase)) throw new Error(`Publishing workflow is missing: ${phrase}`); +for (const phrase of [ + "git ls-remote origin", + "apply-source-update.ps1", + "without changing its version", +]) { + if (!publisher.includes(phrase)) + throw new Error(`Publishing workflow is missing: ${phrase}`); } -console.log(`Verified ${required.length} required project files and ${javascriptFiles.length} JavaScript files for ForgeFlow ${packageJson.version}.`); +console.log( + `Verified ${required.length} required project files and ${javascriptFiles.length} JavaScript files for ForgeFlow ${packageJson.version}.`, +); diff --git a/setup-windows.ps1 b/setup-windows.ps1 index 752311a..cedf736 100644 --- a/setup-windows.ps1 +++ b/setup-windows.ps1 @@ -10,18 +10,17 @@ function Assert-Command { } function Invoke-Step { - param( - [Parameter(Mandatory = $true)][string]$Title, - [Parameter(Mandatory = $true)][scriptblock]$Action - ) + param([Parameter(Mandatory = $true)][string]$Title, [Parameter(Mandatory = $true)][scriptblock]$Action) Write-Host "`n$Title" -ForegroundColor Yellow & $Action - if ($LASTEXITCODE -ne 0) { - throw "$Title failed with exit code $LASTEXITCODE." - } + if ($LASTEXITCODE -ne 0) { throw "$Title failed with exit code $LASTEXITCODE." } } -Write-Host "ForgeFlow v0.4.2 self-service setup" -ForegroundColor Cyan +$package = Get-Content ".\package.json" -Raw | ConvertFrom-Json +$version = [string]$package.version +if ($package.name -ne "forgeflow" -or [string]::IsNullOrWhiteSpace($version)) { throw "This folder is not a valid ForgeFlow source release." } + +Write-Host "ForgeFlow v$version self-service setup" -ForegroundColor Cyan Write-Host "No Gitea token, SSH key or server password is requested by this script." -ForegroundColor DarkGray Assert-Command node @@ -30,33 +29,19 @@ Assert-Command git $nodeVersionText = (node --version).Trim() $nodeMajor = [int]($nodeVersionText.TrimStart('v').Split('.')[0]) -if ($nodeMajor -lt 22) { - throw "Node.js 22 or newer is required. Detected: $nodeVersionText" -} +if ($nodeMajor -lt 22) { throw "Node.js 22 or newer is required. Detected: $nodeVersionText" } Write-Host "Node: $nodeVersionText" -ForegroundColor DarkGray Write-Host "npm: $((npm --version).Trim())" -ForegroundColor DarkGray Write-Host "Git: $((git --version).Trim())" -ForegroundColor DarkGray -Invoke-Step "Installing project dependencies..." { - if (Test-Path ".\package-lock.json") { - npm ci --no-audit --no-fund - } else { - Write-Host "No package-lock.json is present; installing the pinned top-level dependency versions." -ForegroundColor DarkGray - npm install --no-audit --no-fund - } +Invoke-Step "Installing exact project dependencies..." { + if (-not (Test-Path ".\package-lock.json")) { throw "package-lock.json is required for a reproducible ForgeFlow installation." } + npm ci --no-audit --no-fund } +Invoke-Step "Running the environment doctor..." { npm run doctor } +Invoke-Step "Running source verification and automated tests..." { npm run check } -Invoke-Step "Running the environment doctor..." { - npm run doctor -} - -Invoke-Step "Running source verification and automated tests..." { - npm run check -} - -Write-Host "`nAll local checks passed." -ForegroundColor Green -Write-Host "ForgeFlow will now open the five-step setup wizard." -ForegroundColor Green -Write-Host "Enter credentials only inside the local ForgeFlow password field." -ForegroundColor DarkGray - +Write-Host "`nForgeFlow v$version is ready." -ForegroundColor Green +Write-Host "Starting ForgeFlow with your existing local configuration..." -ForegroundColor Green npm start diff --git a/src/main/audit-service.cjs b/src/main/audit-service.cjs new file mode 100644 index 0000000..a2893d6 --- /dev/null +++ b/src/main/audit-service.cjs @@ -0,0 +1,57 @@ +'use strict'; + +const fs = require('node:fs/promises'); +const path = require('node:path'); +const crypto = require('node:crypto'); + +class AuditService { + constructor({ userDataPath, appInfo = {} }) { + this.filePath = path.join(userDataPath, 'audit', 'forgeflow-audit.jsonl'); + this.appInfo = appInfo; + this.queue = Promise.resolve(); + } + + async initialize() { + await fs.mkdir(path.dirname(this.filePath), { recursive: true }); + try { await fs.chmod(path.dirname(this.filePath), 0o700); } catch {} + } + + append(event, details = {}) { + const entry = { + id: crypto.randomUUID(), + timestamp: new Date().toISOString(), + event: String(event || 'unknown').slice(0, 120), + appVersion: this.appInfo.version || null, + details: structuredClone(details || {}) + }; + const operation = async () => { + await this.initialize(); + await fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`, { encoding: 'utf8', mode: 0o600 }); + try { await fs.chmod(this.filePath, 0o600); } catch {} + return entry; + }; + this.queue = this.queue.then(operation, operation); + return this.queue; + } + + async list(limit = 250) { + await this.queue.catch(() => {}); + const text = await fs.readFile(this.filePath, 'utf8').catch((error) => error.code === 'ENOENT' ? '' : Promise.reject(error)); + return text.split(/\r?\n/).filter(Boolean).slice(-Math.min(Math.max(Number(limit) || 250, 1), 5000)).reverse().map((line) => JSON.parse(line)); + } + + async exportTo(destinationPath, format = 'json') { + const entries = await this.list(5000); + if (format === 'csv') { + const quote = (value) => `"${String(value ?? '').replace(/"/g, '""')}"`; + const rows = [['timestamp', 'event', 'repository', 'profile', 'sha', 'result', 'note'].map(quote).join(',')]; + for (const item of [...entries].reverse()) rows.push([item.timestamp, item.event, item.details?.repository, item.details?.profileId, item.details?.sha, item.details?.result, item.details?.note].map(quote).join(',')); + await fs.writeFile(destinationPath, `${rows.join('\r\n')}\r\n`, { mode: 0o600 }); + } else { + await fs.writeFile(destinationPath, JSON.stringify({ format: 'forgeflow-audit', version: 1, entries: [...entries].reverse() }, null, 2), { mode: 0o600 }); + } + return { filePath: destinationPath, count: entries.length }; + } +} + +module.exports = { AuditService }; diff --git a/src/main/config-store.cjs b/src/main/config-store.cjs index 4d2342f..3be846c 100644 --- a/src/main/config-store.cjs +++ b/src/main/config-store.cjs @@ -7,7 +7,7 @@ const { safeStorage } = require('electron'); const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePaths } = require('../shared/validation.cjs'); const DEFAULT_CONFIG = { - schemaVersion: 7, + schemaVersion: 8, setupComplete: false, appearance: 'dark', gitea: { baseUrl: '', user: null, encryptedToken: null }, @@ -33,7 +33,13 @@ const DEFAULT_CONFIG = { diagnosticsEnabled: true, diagnosticLevel: 'info', logRetentionDays: 14, - maxLogFileMb: 8 + maxLogFileMb: 8, + editor: { executable: 'code', args: ['--reuse-window', '--goto', '{file}:{line}'] }, + terminal: { executable: 'wt.exe', args: ['-d', '{path}'] }, + notificationsEnabled: true, + trayEnabled: true, + closeToTray: false, + startAtLogin: false }, operations: [] }; @@ -47,6 +53,7 @@ class ConfigStore { this.filePath = path.join(userDataPath, 'forgeflow-config.json'); this.sessionToken = null; this.data = structuredClone(DEFAULT_CONFIG); + this.saveQueue = Promise.resolve(); } migrate(parsed) { @@ -84,7 +91,15 @@ class ConfigStore { async load() { try { const raw = await fs.readFile(this.filePath, 'utf8'); - this.data = this.migrate(JSON.parse(raw)); + try { + this.data = this.migrate(JSON.parse(raw)); + } catch (parseError) { + const suffix = new Date().toISOString().replace(/[:.]/g, '-'); + const recoveryPath = `${this.filePath}.corrupt-${suffix}`; + await fs.rename(this.filePath, recoveryPath).catch(async () => fs.writeFile(recoveryPath, raw, { mode: 0o600 })); + this.data = structuredClone(DEFAULT_CONFIG); + console.error(`ForgeFlow recovered a malformed configuration file to ${recoveryPath}.`, parseError); + } await this.save(); } catch (error) { if (error.code !== 'ENOENT') throw error; @@ -94,11 +109,16 @@ class ConfigStore { } async save() { - await fs.mkdir(path.dirname(this.filePath), { recursive: true }); - const temporary = `${this.filePath}.${process.pid}.${Date.now()}.tmp`; - await fs.writeFile(temporary, JSON.stringify(this.data, null, 2), { mode: 0o600 }); - await fs.rename(temporary, this.filePath); - try { await fs.chmod(this.filePath, 0o600); } catch {} + const snapshot = JSON.stringify(this.data, null, 2); + const operation = async () => { + await fs.mkdir(path.dirname(this.filePath), { recursive: true }); + const temporary = `${this.filePath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`; + await fs.writeFile(temporary, snapshot, { mode: 0o600 }); + await fs.rename(temporary, this.filePath); + try { await fs.chmod(this.filePath, 0o600); } catch {} + }; + this.saveQueue = this.saveQueue.then(operation, operation); + return this.saveQueue; } setToken(token, { preserveExisting = false } = {}) { @@ -250,6 +270,28 @@ class ConfigStore { return this.getPublicState(); } + async restoreConfiguration(configuration) { + const restored = this.migrate(configuration); + restored.gitea.encryptedToken = String(restored.gitea.baseUrl || '').replace(/\/+$/, '').toLowerCase() === String(this.data.gitea.baseUrl || '').replace(/\/+$/, '').toLowerCase() + ? this.data.gitea.encryptedToken + : null; + const existingServers = new Map(this.data.servers.map((server) => [server.id, server])); + restored.servers = restored.servers.map((server) => { + const existing = existingServers.get(server.id); + const sameCredentialTarget = existing + && ['host', 'port', 'username', 'authType', 'privateKeyPath'].every((key) => String(existing[key] || '') === String(server[key] || '')); + return { + ...server, + encryptedPassword: sameCredentialTarget ? existing.encryptedPassword || null : null, + encryptedPassphrase: sameCredentialTarget ? existing.encryptedPassphrase || null : null + }; + }); + restored.operations = this.data.operations; + this.data = restored; + await this.save(); + return this.getPublicState(); + } + async updateGitea({ baseUrl, token, user }) { const tokenState = this.setToken(token, { preserveExisting: true }); this.data.gitea = { @@ -302,6 +344,16 @@ class ConfigStore { branch: assertBranchName(profile.branch || 'main'), healthcheckUrl, confirmationRequired: profile.confirmationRequired !== false, + deploymentPolicy: { + frozen: profile.deploymentPolicy?.frozen === true, + freezeReason: String(profile.deploymentPolicy?.freezeReason || '').trim().slice(0, 500), + requireNote: profile.deploymentPolicy?.requireNote === true, + maintenanceWindows: (Array.isArray(profile.deploymentPolicy?.maintenanceWindows) ? profile.deploymentPolicy.maintenanceWindows : []).slice(0, 20).map((window) => ({ + days: [...new Set((Array.isArray(window?.days) ? window.days : []).map(Number).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6))], + start: String(window?.start || '00:00'), + end: String(window?.end || '23:59') + })) + }, inputs: {} }; if (provider === 'ssh-unraid') { @@ -327,7 +379,7 @@ class ConfigStore { alignRemote: profile.alignRemote === true, hostPort: profile.hostPort ? Math.min(Math.max(Number(profile.hostPort), 1), 65535) : null, containerPort: profile.containerPort ? Math.min(Math.max(Number(profile.containerPort), 1), 65535) : null, - webUiUrl: assertHttpUrl(profile.webUiUrl, { optional: true, label: 'Web UI URL' }), + webUiUrl: assertHttpUrl(profile.webUiUrl, { optional: true, label: 'Web UI URL', allowUnraidTemplate: true }), iconMode: ['builtin', 'upload', 'url', 'none'].includes(profile.iconMode) ? profile.iconMode : profile.iconFilePath ? 'upload' : profile.iconUrl ? 'url' : 'builtin', @@ -335,7 +387,13 @@ class ConfigStore { iconFilePath: String(profile.iconFilePath || '').trim(), dockerShell: ['/bin/sh', '/bin/bash'].includes(profile.dockerShell) ? profile.dockerShell : '/bin/sh', preservePaths, - generatedCompose: profile.generatedCompose === true + generatedCompose: profile.generatedCompose === true, + adoptedFromServer: profile.adoptedFromServer === true, + serverSourceOfTruth: profile.serverSourceOfTruth === true, + detectedAt: profile.detectedAt || null, + provenance: profile.provenance && typeof profile.provenance === 'object' ? structuredClone(profile.provenance) : {}, + detectedMetadata: profile.detectedMetadata && typeof profile.detectedMetadata === 'object' ? structuredClone(profile.detectedMetadata) : {}, + serverIconReference: String(profile.serverIconReference || '').trim() }; } const statusUrl = assertHttpUrl(profile.statusUrl, { label: 'Application status URL' }); @@ -420,6 +478,16 @@ class ConfigStore { next.diagnosticLevel = ['debug', 'info', 'warning', 'error'].includes(next.diagnosticLevel) ? next.diagnosticLevel : 'info'; next.logRetentionDays = Math.min(Math.max(Number(next.logRetentionDays) || 14, 1), 90); next.maxLogFileMb = Math.min(Math.max(Number(next.maxLogFileMb) || 8, 1), 50); + const normalizeTool = (tool, fallback) => ({ + executable: String(tool?.executable || fallback.executable).trim().slice(0, 500), + args: (Array.isArray(tool?.args) ? tool.args : fallback.args).map((item) => String(item).slice(0, 500)).slice(0, 20) + }); + next.editor = normalizeTool(next.editor, DEFAULT_CONFIG.preferences.editor); + next.terminal = normalizeTool(next.terminal, DEFAULT_CONFIG.preferences.terminal); + next.notificationsEnabled = next.notificationsEnabled !== false; + next.trayEnabled = next.trayEnabled !== false; + next.closeToTray = next.closeToTray === true; + next.startAtLogin = next.startAtLogin === true; this.data.preferences = next; await this.save(); return this.getPublicState(); diff --git a/src/main/configuration-backup.cjs b/src/main/configuration-backup.cjs new file mode 100644 index 0000000..8da6918 --- /dev/null +++ b/src/main/configuration-backup.cjs @@ -0,0 +1,62 @@ +'use strict'; + +const crypto = require('node:crypto'); + +const FORMAT = 'forgeflow-config-backup'; +const VERSION = 1; + +function sanitizeConfiguration(data) { + const source = structuredClone(data || {}); + if (source.gitea) source.gitea.encryptedToken = null; + source.servers = (source.servers || []).map(({ encryptedPassword, encryptedPassphrase, ...server }) => server); + source.operations = []; + return source; +} + +function deriveKey(passphrase, salt) { + const secret = String(passphrase || ''); + if (secret.length < 12) throw new Error('Backup passphrase must contain at least 12 characters.'); + return crypto.scryptSync(secret, salt, 32, { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }); +} + +function createEncryptedBackup(data, passphrase) { + const salt = crypto.randomBytes(16); + const iv = crypto.randomBytes(12); + const key = deriveKey(passphrase, salt); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + const plaintext = Buffer.from(JSON.stringify({ exportedAt: new Date().toISOString(), configuration: sanitizeConfiguration(data) }), 'utf8'); + const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]); + return JSON.stringify({ + format: FORMAT, + version: VERSION, + kdf: 'scrypt', + cipher: 'aes-256-gcm', + salt: salt.toString('base64'), + iv: iv.toString('base64'), + tag: cipher.getAuthTag().toString('base64'), + data: encrypted.toString('base64') + }, null, 2); +} + +function readEncryptedBackup(serialized, passphrase) { + let envelope; + try { envelope = JSON.parse(String(serialized || '')); } + catch { throw new Error('The selected file is not a valid ForgeFlow backup.'); } + if (envelope.format !== FORMAT || envelope.version !== VERSION || envelope.kdf !== 'scrypt' || envelope.cipher !== 'aes-256-gcm') { + throw new Error('Unsupported ForgeFlow backup format or version.'); + } + try { + const key = deriveKey(passphrase, Buffer.from(envelope.salt, 'base64')); + const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(envelope.iv, 'base64')); + decipher.setAuthTag(Buffer.from(envelope.tag, 'base64')); + const decoded = Buffer.concat([decipher.update(Buffer.from(envelope.data, 'base64')), decipher.final()]); + const payload = JSON.parse(decoded.toString('utf8')); + if (!payload.configuration || typeof payload.configuration !== 'object') throw new Error('Configuration payload is missing.'); + return payload; + } catch (error) { + if (/passphrase|payload/i.test(error.message)) throw error; + throw new Error('The backup could not be decrypted. Check the passphrase and file integrity.'); + } +} + +module.exports = { FORMAT, VERSION, sanitizeConfiguration, createEncryptedBackup, readEncryptedBackup }; diff --git a/src/main/external-tools-service.cjs b/src/main/external-tools-service.cjs new file mode 100644 index 0000000..5d7a878 --- /dev/null +++ b/src/main/external-tools-service.cjs @@ -0,0 +1,38 @@ +'use strict'; + +const { spawn } = require('node:child_process'); +const path = require('node:path'); + +function normalizeTool(tool, defaults) { + const source = tool && typeof tool === 'object' ? tool : {}; + const executable = String(source.executable || defaults.executable).trim(); + if (!executable || /[\r\n\0]/.test(executable)) throw new Error('Tool executable is invalid.'); + const args = (Array.isArray(source.args) ? source.args : defaults.args).map((item) => String(item)).slice(0, 20); + if (args.some((item) => /[\r\n\0]/.test(item))) throw new Error('Tool argument is invalid.'); + return { executable, args }; +} + +function expandTool(tool, context) { + const values = { path: context.path, file: context.file || context.path, line: String(context.line || 1) }; + return { executable: tool.executable, args: tool.args.map((argument) => argument.replace(/\{(path|file|line)\}/g, (_, key) => values[key])) }; +} + +class ExternalToolsService { + constructor(store) { this.store = store; } + + launch(kind, repositoryPath, filePath = '', line = 1) { + const root = path.resolve(repositoryPath); + const candidate = filePath ? path.resolve(root, filePath) : root; + if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) throw new Error('External tool target escapes the repository.'); + const defaults = kind === 'terminal' + ? { executable: 'wt.exe', args: ['-d', '{path}'] } + : { executable: 'code', args: ['--reuse-window', '--goto', '{file}:{line}'] }; + const configured = normalizeTool(this.store.data.preferences?.[kind], defaults); + const invocation = expandTool(configured, { path: root, file: candidate, line }); + const child = spawn(invocation.executable, invocation.args, { cwd: root, detached: true, stdio: 'ignore', windowsHide: false, shell: false }); + child.unref(); + return { launched: true, executable: invocation.executable }; + } +} + +module.exports = { ExternalToolsService, normalizeTool, expandTool }; diff --git a/src/main/git-service.cjs b/src/main/git-service.cjs index b2fbf7e..328deba 100644 --- a/src/main/git-service.cjs +++ b/src/main/git-service.cjs @@ -16,6 +16,18 @@ const { assertCloneRemote } = require('../shared/validation.cjs'); +function parseUnifiedDiff(diffText) { + const text = String(diffText || '').replace(/\r\n/g, '\n'); + const firstHunk = text.search(/^@@ /m); + if (firstHunk < 0) return { header: text, hunks: [] }; + const header = text.slice(0, firstHunk); + const hunks = text.slice(firstHunk).split(/(?=^@@ )/m).filter(Boolean).map((patch, index) => { + const heading = patch.split('\n', 1)[0]; + return { index, heading, patch, additions: (patch.match(/^\+(?!\+\+)/gm) || []).length, deletions: (patch.match(/^-(?!---)/gm) || []).length }; + }); + return { header, hunks }; +} + class GitService { async isAvailable() { try { @@ -195,6 +207,40 @@ class GitService { }; } + async abortInterruptedOperation(repoPath) { + const root = await this.ensureRepository(repoPath); + const gitDirResult = await run('git', ['rev-parse', '--git-dir'], { cwd: root, timeout: 30_000 }); + const gitDir = path.resolve(root, gitDirResult.stdout.trim()); + const exists = async (name) => fs.access(path.join(gitDir, name)).then(() => true).catch(() => false); + let aborted = null; + if (await exists('rebase-merge') || await exists('rebase-apply')) { + await run('git', ['rebase', '--abort'], { cwd: root, timeout: 120_000 }); + aborted = 'rebase'; + } else if (await exists('MERGE_HEAD')) { + await run('git', ['merge', '--abort'], { cwd: root, timeout: 120_000 }); + aborted = 'merge'; + } else if (await exists('CHERRY_PICK_HEAD')) { + await run('git', ['cherry-pick', '--abort'], { cwd: root, timeout: 120_000 }); + aborted = 'cherry-pick'; + } else if (await exists('REVERT_HEAD')) { + await run('git', ['revert', '--abort'], { cwd: root, timeout: 120_000 }); + aborted = 'revert'; + } + return { aborted, status: await this.status(root), lockReport: await this.listGitLocks(root) }; + } + + async detectInterruptedOperation(repoPath) { + const root = await this.ensureRepository(repoPath); + const gitDirResult = await run('git', ['rev-parse', '--git-dir'], { cwd: root, timeout: 30_000 }); + const gitDir = path.resolve(root, gitDirResult.stdout.trim()); + const exists = async (name) => fs.access(path.join(gitDir, name)).then(() => true).catch(() => false); + if (await exists('rebase-merge') || await exists('rebase-apply')) return 'rebase'; + if (await exists('MERGE_HEAD')) return 'merge'; + if (await exists('CHERRY_PICK_HEAD')) return 'cherry-pick'; + if (await exists('REVERT_HEAD')) return 'revert'; + return null; + } + async repairSync(repoPath, strategy) { const root = await this.ensureRepository(repoPath); const requested = String(strategy || '').trim(); @@ -252,6 +298,54 @@ class GitService { return result.stdout; } + async diffHunks(repoPath, filePath) { + const safeFile = assertRepositoryRelativePath(filePath); + const diff = await this.diff(repoPath, safeFile, false); + const parsed = parseUnifiedDiff(diff); + return { filePath: safeFile, partialSupported: parsed.hunks.length > 0, hunks: parsed.hunks.map(({ patch, ...hunk }) => ({ ...hunk, lines: patch.split('\n') })) }; + } + + async stageHunks(repoPath, filePath, hunkIndexes) { + const root = await this.ensureRepository(repoPath); + const safeFile = assertRepositoryRelativePath(filePath); + const indexes = [...new Set((Array.isArray(hunkIndexes) ? hunkIndexes : []).map(Number))]; + if (!indexes.length || indexes.some((index) => !Number.isInteger(index) || index < 0)) throw new Error('Select at least one valid diff hunk.'); + const parsed = parseUnifiedDiff(await this.diff(root, safeFile, false)); + if (!parsed.hunks.length) throw new Error('Partial staging is unavailable for this file. Stage the complete file instead.'); + if (indexes.some((index) => index >= parsed.hunks.length)) throw new Error('The file changed after its diff was loaded. Refresh the diff and try again.'); + const patch = `${parsed.header}${indexes.map((index) => parsed.hunks[index].patch).join('')}`; + await run('git', ['apply', '--cached', '--whitespace=nowarn', '-'], { cwd: root, input: patch, timeout: 60_000, maxBuffer: 16 * 1024 * 1024 }); + return this.status(root); + } + + async conflictState(repoPath) { + const root = await this.ensureRepository(repoPath); + const operation = await this.detectInterruptedOperation(root); + const result = await run('git', ['diff', '--name-only', '--diff-filter=U', '-z'], { cwd: root, timeout: 30_000 }); + const files = result.stdout.split('\0').filter(Boolean).map(assertRepositoryRelativePath); + return { operation, files, canContinue: Boolean(operation) && files.length === 0, status: await this.status(root) }; + } + + async resolveConflict(repoPath, filePath, resolution) { + const root = await this.ensureRepository(repoPath); + const safeFile = assertRepositoryRelativePath(filePath); + const choice = String(resolution || 'resolved'); + if (!['ours', 'theirs', 'resolved'].includes(choice)) throw new Error('Unsupported conflict resolution choice.'); + if (choice !== 'resolved') await this.runWithPathspec(root, ['checkout', `--${choice}`], [safeFile], { timeout: 30_000 }); + await this.runWithPathspec(root, ['add'], [safeFile], { timeout: 30_000 }); + return this.conflictState(root); + } + + async continueInterruptedOperation(repoPath) { + const root = await this.ensureRepository(repoPath); + const state = await this.conflictState(root); + if (!state.operation) throw new Error('No interrupted Git operation is active.'); + if (state.files.length) throw new Error('Resolve every conflicted file before continuing.'); + const commands = { rebase: ['rebase', '--continue'], merge: ['merge', '--continue'], 'cherry-pick': ['cherry-pick', '--continue'], revert: ['revert', '--continue'] }; + await run('git', commands[state.operation], { cwd: root, env: { GIT_EDITOR: 'true' }, timeout: 120_000 }); + return this.conflictState(root); + } + selectedStatusFiles(status, files) { const selected = assertRepositoryRelativePaths(files); if (!selected.length) return { selected, matches: status.files }; @@ -334,6 +428,28 @@ class GitService { return { output: result.stdout.trim(), sha: status.head, shortSha: status.shortHead, status }; } + async commitStaged(repoPath, message) { + const root = await this.ensureRepository(repoPath); + const commitMessage = assertCommitMessage(message); + const stagedCheck = await run('git', ['diff', '--cached', '--quiet'], { cwd: root, allowExitCodes: [1] }); + if (stagedCheck.exitCode === 0) throw new Error('There are no staged changes to commit.'); + const result = await run('git', ['commit', '-m', commitMessage], { cwd: root, timeout: 120_000, maxBuffer: 16 * 1024 * 1024 }); + const status = await this.status(root); + return { output: result.stdout.trim(), sha: status.head, shortSha: status.shortHead, status }; + } + + async commitStagedAndPush(repoPath, message) { + const committed = await this.commitStaged(repoPath, message); + try { + const pushed = await this.push(repoPath); + return { commitOutput: committed.output, pushOutput: pushed.output, status: pushed.status, sha: committed.sha }; + } catch (error) { + const wrapped = new Error(`Commit ${committed.shortSha} was created locally, but push failed: ${error.message}`); + wrapped.code = 'PUSH_AFTER_COMMIT_FAILED'; wrapped.commitSha = committed.sha; wrapped.recoverable = true; + throw wrapped; + } + } + async commitAndPush(repoPath, message, files = []) { const committed = await this.commit(repoPath, message, files); try { @@ -512,4 +628,4 @@ class GitService { } } -module.exports = { GitService }; +module.exports = { GitService, parseUnifiedDiff }; diff --git a/src/main/gitea-service.cjs b/src/main/gitea-service.cjs index e4db77e..c127b12 100644 --- a/src/main/gitea-service.cjs +++ b/src/main/gitea-service.cjs @@ -1,6 +1,6 @@ 'use strict'; -const { normalizeBaseUrl } = require('../shared/validation.cjs'); +const { normalizeBaseUrl, assertBranchName } = require('../shared/validation.cjs'); const { redactSecrets } = require('./log-redaction.cjs'); class GiteaService { @@ -107,6 +107,44 @@ class GiteaService { return (await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches/${encodeURIComponent(branch)}`)).data; } + async getBranchProtection(owner, repo, branch) { + const branchInfo = await this.getBranch(owner, repo, branch); + let rule = null; + try { + const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branch_protections`); + const rules = Array.isArray(result.data) ? result.data : []; + rule = rules.find((item) => item.branch_name === branch || item.rule_name === branch) || null; + } catch (error) { + if (![403, 404].includes(error.status)) throw error; + } + return { + branch, + protected: Boolean(branchInfo?.protected || rule), + enablePush: rule?.enable_push ?? null, + enableForcePush: rule?.enable_force_push ?? false, + requiredApprovals: Number(rule?.required_approvals || 0), + requireSignedCommits: Boolean(rule?.require_signed_commits), + rule + }; + } + + async listPullRequests({ owner, repo, state = 'open', limit = 30 } = {}) { + const query = new URLSearchParams({ state, limit: String(Math.min(Math.max(Number(limit) || 30, 1), 50)) }); + const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls?${query}`); + return Array.isArray(result.data) ? result.data : []; + } + + async createPullRequest({ owner, repo, head, base, title, body = '' }) { + const cleanTitle = String(title || '').trim(); + if (!cleanTitle || cleanTitle.length > 255) throw new Error('Pull request title must contain 1-255 characters.'); + const cleanBody = String(body || '').trim().slice(0, 50_000); + const source = assertBranchName(head); + const target = assertBranchName(base); + if (source === target) throw new Error('Pull request source and target branches must be different.'); + const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`, { method: 'POST', body: { head: source, base: target, title: cleanTitle, body: cleanBody }, timeout: 60_000 }); + return result.data; + } + async getRepositoryFile({ owner, repo, filePath, ref }) { const encodedPath = String(filePath || '').split('/').map(encodeURIComponent).join('/'); const query = ref ? `?ref=${encodeURIComponent(ref)}` : ''; diff --git a/src/main/ipc.cjs b/src/main/ipc.cjs index cddf70e..5e0f749 100644 --- a/src/main/ipc.cjs +++ b/src/main/ipc.cjs @@ -6,6 +6,8 @@ const { fileURLToPath } = require('node:url'); const { ipcMain, dialog, shell, app } = require('electron'); const { matchRemoteToRepository } = require('../shared/repository-match.cjs'); const { cloneDirectoryName, resolveCloneTarget } = require('../shared/clone-target.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, '..', 'renderer', 'index.html'); @@ -53,7 +55,7 @@ function register(channel, handler) { }); } -function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh, updates, preflight, diagnostics, monitor }) { +function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh, updates, preflight, diagnostics, audit, externalTools, monitor, onPreferencesChanged }) { diagnosticsService = diagnostics; const repositoryMutations = new Map(); const withRepositoryPause = async (localPath, action) => { @@ -228,10 +230,46 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh register('settings:set-preferences', async ({ preferences }) => { const state = await store.setPreferences(preferences); monitor?.restart(); + onPreferencesChanged?.(); await diagnostics.info('settings.preferences.updated', { preferences: state.preferences }); return state; }); + register('settings:export-backup', async ({ passphrase }) => { + const result = await dialog.showSaveDialog({ + title: 'Export encrypted ForgeFlow configuration', + defaultPath: path.join(app.getPath('documents'), `ForgeFlow-Configuration-${new Date().toISOString().slice(0, 10)}.ffbackup`), + filters: [{ name: 'ForgeFlow encrypted backup', extensions: ['ffbackup'] }] + }); + if (result.canceled || !result.filePath) return null; + const destinationPath = result.filePath.toLowerCase().endsWith('.ffbackup') ? result.filePath : `${result.filePath}.ffbackup`; + await fs.writeFile(destinationPath, createEncryptedBackup(store.data, passphrase), { mode: 0o600, flag: 'wx' }).catch(async (error) => { + if (error.code !== 'EEXIST') throw error; + await fs.writeFile(destinationPath, createEncryptedBackup(store.data, passphrase), { mode: 0o600 }); + }); + await audit.append('configuration.backup.exported', { fileName: path.basename(destinationPath) }); + return { filePath: destinationPath }; + }); + + register('settings:import-backup', async ({ passphrase }) => { + const result = await dialog.showOpenDialog({ title: 'Import encrypted ForgeFlow configuration', properties: ['openFile'], filters: [{ name: 'ForgeFlow encrypted backup', extensions: ['ffbackup'] }] }); + if (result.canceled || !result.filePaths[0]) return null; + const payload = readEncryptedBackup(await fs.readFile(result.filePaths[0], 'utf8'), passphrase); + const state = await store.restoreConfiguration(payload.configuration); + monitor?.restart(); + await audit.append('configuration.backup.imported', { fileName: path.basename(result.filePaths[0]), exportedAt: payload.exportedAt }); + return { state, exportedAt: payload.exportedAt }; + }); + + register('audit:list', ({ limit = 250 }) => audit.list(limit)); + register('audit:export', async ({ format = 'json' }) => { + if (!['json', 'csv'].includes(format)) throw new Error('Unsupported audit export format.'); + const extension = format === 'csv' ? 'csv' : 'json'; + const result = await dialog.showSaveDialog({ title: 'Export ForgeFlow audit log', defaultPath: path.join(app.getPath('documents'), `ForgeFlow-Audit-${new Date().toISOString().slice(0, 10)}.${extension}`), filters: [{ name: `${extension.toUpperCase()} file`, extensions: [extension] }] }); + if (result.canceled || !result.filePath) return null; + return audit.exportTo(result.filePath.toLowerCase().endsWith(`.${extension}`) ? result.filePath : `${result.filePath}.${extension}`, format); + }); + register('updates:preferences', ({ updates: next }) => store.setUpdatePreferences(next)); register('updates:check', () => updates.check()); register('updates:download', () => updates.download()); @@ -271,6 +309,7 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh return { ...result, state: store.getPublicState() }; }); register('server:inspect-project', async ({ repository, profileId }) => unraid.inspect({ repository: await resolveRepository(repository), profileId })); + register('server:discover-existing', async ({ repository, serverId, remoteFolder }) => unraid.discoverExisting({ repository: await resolveRepository(repository), serverId, remoteFolder })); register('repositories:refresh', async () => { const result = await repositories.refresh(); @@ -308,14 +347,40 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh 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)); }); @@ -370,6 +435,8 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh 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); @@ -378,6 +445,75 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh return true; }); + register('troubleshooter:scan', async ({ fullName = null }) => { + const currentRepositories = await repositories.refresh(); + const candidates = fullName ? currentRepositories.filter((item) => item.fullName === fullName) : currentRepositories; + const issues = []; + for (const repository of candidates) { + if (!repository.localPath) { + issues.push({ id: `${repository.fullName}:not-linked`, repository: repository.fullName, severity: 'warning', title: 'Local repository is not linked', detail: 'Link or clone the repository before running local Git repairs.', repairable: false }); + continue; + } + try { + const interrupted = await git.detectInterruptedOperation(repository.localPath); + if (interrupted) issues.push({ id: `${repository.fullName}:abort-operation`, repository: repository.fullName, localPath: repository.localPath, severity: 'error', title: `Interrupted Git ${interrupted}`, detail: `A ${interrupted} is still active and blocks normal Git operations. Aborting it can discard conflict-resolution work and therefore always requires separate confirmation.`, repairable: true, action: 'abort-operation', safe: false }); + const report = await git.reconcile(repository.localPath); + for (const lock of report.lockReport?.locks || []) { + const stale = lock.ageMs >= 10_000; + const processProbeSafe = report.lockReport.processes?.available === true && !report.lockReport.processes.active?.length; + issues.push({ id: `${repository.fullName}:locks:${lock.name}`, repository: repository.fullName, localPath: repository.localPath, severity: stale ? 'error' : 'warning', title: stale ? 'Stale Git lock detected' : 'Recent Git lock detected', detail: lock.name, repairable: stale, action: 'repair-locks', safe: stale && processProbeSafe }); + } + const branch = report.status?.branch || {}; + if (branch.behind > 0 && branch.ahead === 0 && report.status.clean) issues.push({ id: `${repository.fullName}:fast-forward`, repository: repository.fullName, localPath: repository.localPath, severity: 'warning', title: 'Local branch is behind Gitea', detail: `${branch.behind} commit(s) can be fast-forwarded safely.`, repairable: true, action: 'fast-forward', safe: true }); + if (branch.ahead > 0 && branch.behind === 0) issues.push({ id: `${repository.fullName}:push`, repository: repository.fullName, localPath: repository.localPath, severity: 'warning', title: 'Local commits are not published', detail: `${branch.ahead} commit(s) can be pushed to Gitea after explicit confirmation.`, repairable: true, action: 'push', safe: false }); + if (branch.ahead > 0 && branch.behind > 0) issues.push({ id: `${repository.fullName}:diverged`, repository: repository.fullName, localPath: repository.localPath, severity: 'error', title: 'Local and Gitea branches have diverged', detail: `${branch.ahead} ahead and ${branch.behind} behind. ForgeFlow can preserve the local HEAD on a safety branch and use the upstream version.`, repairable: report.status.clean, action: 'backup-reset', safe: false }); + } catch (error) { + issues.push({ id: `${repository.fullName}:git-error`, repository: repository.fullName, severity: 'error', title: 'Git health scan failed', detail: error.message, repairable: false }); + } + for (const profile of repository.deploymentProfiles || []) { + if (profile.provider !== 'ssh-unraid') continue; + try { + const inspection = await unraid.inspect({ repository, profileId: profile.id }); + if (!inspection.exists) issues.push({ id: `${profile.id}:server-folder`, repository: repository.fullName, profileId: profile.id, severity: 'error', title: 'Deployment folder is missing on the server', detail: inspection.remotePath, repairable: false }); + if (inspection.trackedChanges?.length) issues.push({ id: `${profile.id}:tracked-server-changes`, repository: repository.fullName, profileId: profile.id, severity: 'error', title: 'Tracked server-side changes detected', detail: `${inspection.trackedChanges.length} tracked change(s) must be reviewed before deployment.`, repairable: false }); + if (inspection.dockerContextExclusionsMissing?.length) issues.push({ id: `${profile.id}:dockerignore`, repository: repository.fullName, profileId: profile.id, severity: 'warning', title: 'Runtime paths are missing from .dockerignore', detail: inspection.dockerContextExclusionsMissing.join(', '), repairable: false }); + } catch (error) { + issues.push({ id: `${profile.id}:server-error`, repository: repository.fullName, profileId: profile.id, severity: 'error', title: 'Server inspection failed', detail: error.message, repairable: false }); + } + } + } + const summary = { total: issues.length, errors: issues.filter((item) => item.severity === 'error').length, warnings: issues.filter((item) => item.severity === 'warning').length, repairable: issues.filter((item) => item.repairable).length }; + return { checkedAt: new Date().toISOString(), issues, summary }; + }); + + register('troubleshooter:repair', async ({ issue }) => { + if (!issue || !issue.action) throw new Error('No repair action was supplied.'); + const localPath = issue.localPath ? await assertKnownRepositoryPath(issue.localPath) : null; + let result; + if (issue.action === 'abort-operation') result = await withRepositoryMutation(localPath, () => git.abortInterruptedOperation(localPath)); + else if (issue.action === 'repair-locks') result = await withRepositoryMutation(localPath, () => git.repairStaleGitLocks(localPath, { minimumAgeMs: 2_000 })); + else if (['fast-forward', 'push', 'backup-reset', 'fetch'].includes(issue.action)) result = await withRepositoryMutation(localPath, () => git.repairSync(localPath, issue.action)); + else throw new Error('Unsupported troubleshooter repair action.'); + await diagnostics.info('troubleshooter.repair.completed', { repository: issue.repository, action: issue.action }); + return result; + }); + + register('troubleshooter:auto-repair', async ({ issues }) => { + const results = []; + for (const issue of (issues || []).filter((item) => item.repairable && item.safe)) { + try { + const localPath = issue.localPath ? await assertKnownRepositoryPath(issue.localPath) : null; + let result; + if (issue.action === 'repair-locks') result = await withRepositoryMutation(localPath, () => git.repairStaleGitLocks(localPath, { minimumAgeMs: 10_000 })); + else if (['fast-forward', 'fetch'].includes(issue.action)) result = await withRepositoryMutation(localPath, () => git.repairSync(localPath, issue.action)); + else continue; + results.push({ id: issue.id, ok: true, result }); + } catch (error) { results.push({ id: issue.id, ok: false, error: error.message }); } + } + await diagnostics.info('troubleshooter.auto-repair.completed', { attempted: results.length, succeeded: results.filter((item) => item.ok).length }); + 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 }); @@ -394,11 +530,16 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh if (profile?.provider === 'ssh-unraid') return unraid.preflight({ repository: current, profileId }); return preflight.runDeployment({ repository: current, profileId }); }); - register('deployment:dispatch', async ({ repository, profileId, sha }) => { + register('deployment:dispatch', async ({ repository, profileId, sha, note = '', override = false, overrideReason = '' }) => { const current = await resolveRepository(repository); const profile = store.getDeploymentProfile(current.fullName, profileId); - if (profile?.provider === 'ssh-unraid') return unraid.deploy({ repository: current, profileId, sha }); - return deployments.deploy({ repository: current, profileId, sha }); + 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); diff --git a/src/main/unraid-deployment-service.cjs b/src/main/unraid-deployment-service.cjs index 8871ede..8b7c5b9 100644 --- a/src/main/unraid-deployment-service.cjs +++ b/src/main/unraid-deployment-service.cjs @@ -97,6 +97,75 @@ function xmlEscape(value) { .replace(/'/g, '''); } + + +function decodeBase64Json(value, fallback) { + try { return value ? JSON.parse(Buffer.from(value, 'base64').toString('utf8')) : fallback; } + catch { return fallback; } +} + +function parseDockerManXml(xml) { + const text = String(xml || ''); + const tag = (name) => { + const match = text.match(new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`, 'i')); + return match ? match[1].replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').trim() : ''; + }; + return { name: tag('Name'), webUiUrl: tag('WebUI'), iconUrl: tag('Icon'), shell: tag('Shell') }; +} + +function deriveDetectedProfile({ repository, server, remoteFolder, remotePath, payload }) { + const compose = payload.compose || {}; + const services = compose.services && typeof compose.services === 'object' ? compose.services : {}; + const inspections = Array.isArray(payload.containers) ? payload.containers : []; + const primaryContainer = inspections.find((item) => item?.State?.Running) || inspections[0] || null; + const labels = primaryContainer?.Config?.Labels || {}; + const serviceName = labels['com.docker.compose.service'] || Object.keys(services)[0] || remoteFolder; + const service = services[serviceName] || {}; + const containerName = String(primaryContainer?.Name || service.container_name || serviceName).replace(/^\//, ''); + const ports = []; + for (const [containerKey, bindings] of Object.entries(primaryContainer?.NetworkSettings?.Ports || {})) { + const [containerPortText, protocol = 'tcp'] = containerKey.split('/'); + const containerPort = Number(containerPortText) || null; + if (Array.isArray(bindings) && bindings.length) { + for (const binding of bindings) ports.push({ hostIp: binding.HostIp || '', hostPort: Number(binding.HostPort) || null, containerPort, protocol }); + } else ports.push({ hostIp: '', hostPort: null, containerPort, protocol }); + } + const primaryPort = ports.find((item) => item.hostPort) || ports[0] || {}; + const mounts = (primaryContainer?.Mounts || []).map((item) => ({ type: item.Type, source: item.Source, target: item.Destination, readOnly: item.RW === false })); + const networks = Object.keys(primaryContainer?.NetworkSettings?.Networks || {}); + const envNames = (primaryContainer?.Config?.Env || []).map((item) => String(item).split('=')[0]).filter(Boolean); + const dockerMan = parseDockerManXml(payload.dockerManXml || ''); + const webUiUrl = dockerMan.webUiUrl || labels['net.unraid.docker.webui'] || ''; + const iconUrl = dockerMan.iconUrl || labels['net.unraid.docker.icon'] || ''; + const shell = dockerMan.shell || labels['net.unraid.docker.shell'] || '/bin/sh'; + const preservePaths = [...new Set([ + '.env', 'appdata', 'data', 'logs', 'config', 'compose.override.yml', + ...mounts.filter((item) => String(item.source || '').startsWith(`${remotePath}/`)).map((item) => String(item.source).slice(remotePath.length + 1).split('/')[0]).filter(Boolean) + ])]; + const source = (value, origin, confidence = 'confirmed') => ({ value, origin, confidence, detectedAt: new Date().toISOString(), overridden: false }); + const composeFiles = payload.composeFiles || []; + const composeFile = composeFiles[0] || labels['com.docker.compose.project.config_files']?.split(',')[0]?.replace(`${remotePath}/`, '') || 'docker-compose.yml'; + return { + profile: { + name: 'Production', environment: 'production', provider: 'ssh-unraid', branch: payload.branch || repository.defaultBranch || 'main', + serverId: server.id, remoteFolder, cloneUrl: payload.remote || repository.sshUrl || '', alignRemote: false, + generatedCompose: false, composeFile, composeService: serviceName, containerName, + hostPort: primaryPort.hostPort || null, containerPort: primaryPort.containerPort || null, + webUiUrl, iconMode: /^https?:\/\//i.test(iconUrl) ? 'url' : 'none', iconUrl: /^https?:\/\//i.test(iconUrl) ? iconUrl : '', serverIconReference: iconUrl, iconFilePath: '', dockerShell: ['/bin/bash','/bin/sh'].includes(shell) ? shell : '/bin/sh', + healthcheckUrl: '', preservePaths, confirmationRequired: true, + adoptedFromServer: true, serverSourceOfTruth: true, detectedAt: new Date().toISOString(), + detectedMetadata: { head: payload.head || null, composeProject: labels['com.docker.compose.project'] || '', composeFiles, services: Object.keys(services), ports, mounts, networks, envNames, restartPolicy: primaryContainer?.HostConfig?.RestartPolicy?.Name || '', healthcheck: primaryContainer?.Config?.Healthcheck || null, image: primaryContainer?.Config?.Image || service.image || '', dockerMan } + }, + provenance: { + remoteFolder: source(remoteFolder, 'server-path'), cloneUrl: source(payload.remote || '', 'git-origin'), branch: source(payload.branch || '', 'git'), + composeFile: source(composeFile, 'docker-compose'), composeService: source(serviceName, 'docker-labels'), containerName: source(containerName, 'docker-inspect'), + hostPort: source(primaryPort.hostPort || null, 'docker-inspect'), containerPort: source(primaryPort.containerPort || null, 'docker-inspect'), + webUiUrl: source(webUiUrl, dockerMan.webUiUrl ? 'unraid-dockerman' : 'docker-labels'), iconUrl: source(iconUrl, dockerMan.iconUrl ? 'unraid-dockerman' : 'docker-labels'), dockerShell: source(shell, dockerMan.shell ? 'unraid-dockerman' : 'docker-labels') + }, + runtime: { remotePath, containerRunning: Boolean(primaryContainer?.State?.Running), containers: inspections.length, services: Object.keys(services).length, ports, mounts, networks, envNames } + }; +} + function iconReferenceLocalPath(iconReference) { const value = String(iconReference || '').trim(); if (value.startsWith('file:///')) return `/${value.slice('file:///'.length)}`; @@ -131,6 +200,64 @@ class UnraidDeploymentService { return { profile, 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); + if (!remotePath.startsWith(`${server.basePath}/`)) throw new Error('Remote project path escapes the configured server base path.'); + const script = ` +root=${shellQuote(remotePath)} +test -d "$root" || { echo "Existing server folder not found: $root" >&2; exit 44; } +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) +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' \\) -printf '%P\\n' 2>/dev/null | sort) +compose_file=$(printf '%s\\n' "$compose_files" | head -n1) +compose_json='{}' +container_json='[]' +if [ -n "$compose_file" ] && command -v docker >/dev/null 2>&1; then + compose_json=$(cd "$root" && docker compose -f "$compose_file" config --format json 2>/dev/null || printf '{}') + ids=$(cd "$root" && docker compose -f "$compose_file" ps -aq 2>/dev/null || true) + [ -n "$ids" ] && container_json=$(docker inspect $ids 2>/dev/null || printf '[]') +fi +container_name=$(printf '%s' "$container_json" | sed -n 's/.*"Name"[[:space:]]*:[[:space:]]*"\\/\\([^" ]*\\)".*/\\1/p' | head -n1) +dockerman_xml='' +if [ -n "$container_name" ] && [ -d /boot/config/plugins/dockerMan/templates-user ]; then + template=$(grep -ril "${container_name}" /boot/config/plugins/dockerMan/templates-user 2>/dev/null | head -n1 || true) + [ -n "$template" ] && dockerman_xml=$(cat "$template") +fi +printf '__FORGEFLOW_DISCOVERY__\\n' +printf 'head=%s\\n' "$head" +printf 'branch=%s\\n' "$branch" +printf 'remote=%s\\n' "$(printf '%s' "$remote" | base64 | tr -d '\\r\\n')" +printf 'composeFiles=%s\\n' "$(printf '%s\\n' "$compose_files" | base64 | tr -d '\\r\\n')" +printf 'compose=%s\\n' "$(printf '%s' "$compose_json" | base64 | tr -d '\\r\\n')" +printf 'containers=%s\\n' "$(printf '%s' "$container_json" | base64 | tr -d '\\r\\n')" +printf 'dockerManXml=%s\\n' "$(printf '%s' "$dockerman_xml" | base64 | tr -d '\\r\\n')" +`; + const result = await this.ssh.exec(server.id, bash(script), { timeout: 90_000, maxOutput: 8 * 1024 * 1024 }); + const marker = '__FORGEFLOW_DISCOVERY__'; + const index = result.stdout.lastIndexOf(marker); + if (index < 0) throw new Error('The server did not return deployment discovery data.'); + const fields = {}; + for (const line of result.stdout.slice(index + marker.length).trim().split(/\r?\n/)) { + const split = line.indexOf('='); if (split > 0) fields[line.slice(0, split)] = line.slice(split + 1); + } + const payload = { + head: fields.head || null, branch: fields.branch || null, + remote: fields.remote ? Buffer.from(fields.remote, 'base64').toString('utf8') : '', + composeFiles: fields.composeFiles ? Buffer.from(fields.composeFiles, 'base64').toString('utf8').split(/\r?\n/).filter(Boolean) : [], + compose: decodeBase64Json(fields.compose, {}), containers: decodeBase64Json(fields.containers, []), + dockerManXml: fields.dockerManXml ? Buffer.from(fields.dockerManXml, 'base64').toString('utf8') : '' + }; + const discovery = deriveDetectedProfile({ repository, server, remoteFolder: folder, remotePath, payload }); + await this.diagnostics?.info('unraid.existing-discovered', { repository: repository.fullName, serverId, remotePath, containers: discovery.runtime.containers, services: discovery.runtime.services }); + return discovery; + } + async inspect({ repository, profileId }) { const { profile, server, remotePath } = this.resolve(repository, profileId); const preserveProbe = (profile.preservePaths || []).map((relativePath) => @@ -906,5 +1033,8 @@ module.exports = { checksSummary, xmlEscape, iconReferenceLocalPath, + decodeBase64Json, + parseDockerManXml, + deriveDetectedProfile, bash }; diff --git a/src/renderer/app.js b/src/renderer/app.js index e705dfd..b03a75b 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -1,16 +1,20 @@ -const app = document.querySelector('#app'); -const toastRoot = document.querySelector('#toast-root'); +const app = document.querySelector("#app"); +const toastRoot = document.querySelector("#toast-root"); const icons = { - overview: '', - repository: '', + overview: + '', + repository: + '', deploy: '', - settings: '', + settings: + '', search: '', refresh: '', folder: '', git: '', - branch: '', + branch: + '', file: '', check: '', warning: '', @@ -18,7 +22,8 @@ const icons = { arrowRight: '', arrowUp: '', arrowDown: '', - external: '', + external: + '', play: '', terminal: '', clock: '', @@ -30,133 +35,215 @@ const icons = { copy: '', chevron: '', link: '', - cloud: '', + cloud: + '', pulse: '', - history: '', + history: + '', more: '', star: '', archive: '', - shield: '', - rocket: '', - layers: '', + shield: + '', + rocket: + '', + layers: + '', undo: '', menu: '', download: '', - server: '', + server: + '', key: '', - update: '', - wrench: '' + update: + '', + wrench: + '', }; -function icon(name, className = '') { +function icon(name, className = "") { return ``; } function escapeHtml(value) { - return String(value ?? '').replace(/[&<>'"]/g, (character) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[character]); + return String(value ?? "").replace( + /[&<>'"]/g, + (character) => + ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ })[ + character + ], + ); +} +function attr(value) { + return escapeHtml(value).replace(/`/g, "`"); } -function attr(value) { return escapeHtml(value).replace(/`/g, '`'); } function formatDate(value) { - if (!value) return 'Unknown'; + if (!value) return "Unknown"; const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value); const diff = Date.now() - date.getTime(); - if (diff < 60_000) return 'just now'; + if (diff < 60_000) return "just now"; if (diff < 3_600_000) return `${Math.max(1, Math.floor(diff / 60_000))}m ago`; if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; if (diff < 604_800_000) return `${Math.floor(diff / 86_400_000)}d ago`; - return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: date.getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined }); + return date.toLocaleDateString(undefined, { + day: "2-digit", + month: "short", + year: + date.getFullYear() !== new Date().getFullYear() ? "numeric" : undefined, + }); +} +function truncate(value, length = 76) { + const text = String(value || ""); + return text.length > length ? `${text.slice(0, length - 1)}…` : text; +} +function shortSha(value) { + return String(value || "").slice(0, 7) || "—"; +} +function defaultWorkspaceRoot() { + return ui.boot?.state?.workspaceRoots?.[0] || null; } -function truncate(value, length = 76) { const text = String(value || ''); return text.length > length ? `${text.slice(0, length - 1)}…` : text; } -function shortSha(value) { return String(value || '').slice(0, 7) || '—'; } -function defaultWorkspaceRoot() { return ui.boot?.state?.workspaceRoots?.[0] || null; } function safeCloneFolderName(repository) { - return String(repository?.name || 'repository').replace(/\.git$/i, '').replace(/[^a-zA-Z0-9._-]/g, '-') || 'repository'; + return ( + String(repository?.name || "repository") + .replace(/\.git$/i, "") + .replace(/[^a-zA-Z0-9._-]/g, "-") || "repository" + ); } function displayCloneTarget(repository) { const root = defaultWorkspaceRoot(); if (!root) return null; - const separator = ui.boot?.platform === 'win32' ? '\\' : '/'; - return `${String(root).replace(/[\\/]+$/, '')}${separator}${safeCloneFolderName(repository)}`; + const separator = ui.boot?.platform === "win32" ? "\\" : "/"; + return `${String(root).replace(/[\\/]+$/, "")}${separator}${safeCloneFolderName(repository)}`; } function clonePrimaryLabel(repository) { - return defaultWorkspaceRoot() ? `Clone to ${safeCloneFolderName(repository)}` : 'Choose project root & clone'; + return defaultWorkspaceRoot() + ? `Clone to ${safeCloneFolderName(repository)}` + : "Choose project root & clone"; +} +function isTerminalOperation(status) { + return ["success", "failed", "cancelled", "rolled-back"].includes(status); } -function isTerminalOperation(status) { return ['success', 'failed', 'cancelled', 'rolled-back'].includes(status); } function toneForStatus(status) { - if (['success', 'healthy', 'complete', 'rolled-back'].includes(status)) return 'success'; - if (['failed', 'failure', 'unhealthy', 'danger'].includes(status)) return 'danger'; - if (['queued', 'running', 'requested', 'warning', 'active'].includes(status)) return 'warning'; - return ''; + if (["success", "healthy", "complete", "rolled-back"].includes(status)) + return "success"; + if (["failed", "failure", "unhealthy", "danger"].includes(status)) + return "danger"; + if (["queued", "running", "requested", "warning", "active"].includes(status)) + return "warning"; + return ""; } const ui = { boot: null, repositories: [], - currentView: 'overview', + currentView: "overview", selectedRepoId: null, - repositoryTab: 'changes', + repositoryTab: "changes", selectedFile: null, selectedFiles: new Set(), selectedProfileId: null, - diff: '', + diff: "", history: [], branches: [], stashes: [], - search: '', - repoSearch: '', - commitMessage: '', + search: "", + repoSearch: "", + commitMessage: "", loading: false, - loadingMessage: '', + loadingMessage: "", modal: null, setupStep: 0, systemPreflight: null, deploymentPreflight: null, diagnosticsStatus: null, + troubleshooter: null, + deploymentDiscovery: null, lastDiagnosticBundle: null, - setupDraft: { baseUrl: 'https://', token: '', user: null, roots: [], discovered: [] }, + setupDraft: { + baseUrl: "https://", + token: "", + user: null, + roots: [], + discovered: [], + }, setupValidation: null, activeDeployment: null, operationPollTimer: null, isMock: false, refreshError: null, autoRefreshPending: false, - paletteQuery: '', + paletteQuery: "", updateStatus: null, updateChecking: false, servers: [], serverInspection: null, - gitRecovery: null + gitRecovery: null, + diffHunks: null, + conflictState: null, + branchProtection: null, + pullRequests: [], + auditEvents: [], }; -function selectedRepository() { return ui.repositories.find((repository) => String(repository.id) === String(ui.selectedRepoId)) || null; } +function selectedRepository() { + return ( + ui.repositories.find( + (repository) => String(repository.id) === String(ui.selectedRepoId), + ) || null + ); +} function selectedProfile(repository = selectedRepository()) { if (!repository?.deploymentProfiles?.length) return null; - return repository.deploymentProfiles.find((profile) => profile.id === ui.selectedProfileId) - || repository.deploymentProfiles.find((profile) => profile.branch === repository.localStatus?.branch.head) - || repository.deploymentProfiles[0]; + return ( + repository.deploymentProfiles.find( + (profile) => profile.id === ui.selectedProfileId, + ) || + repository.deploymentProfiles.find( + (profile) => profile.branch === repository.localStatus?.branch.head, + ) || + repository.deploymentProfiles[0] + ); +} +function operations() { + return ui.boot?.state?.operations || []; +} +function repositoryOperations(repository) { + return operations().filter( + (operation) => operation.repository === repository?.fullName, + ); } -function operations() { return ui.boot?.state?.operations || []; } -function repositoryOperations(repository) { return operations().filter((operation) => operation.repository === repository?.fullName); } function applyTheme(appearance) { - const resolved = appearance === 'system' ? (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark') : appearance; - document.documentElement.dataset.theme = resolved || 'dark'; + const resolved = + appearance === "system" + ? matchMedia("(prefers-color-scheme: light)").matches + ? "light" + : "dark" + : appearance; + document.documentElement.dataset.theme = resolved || "dark"; } -function showToast(title, message, type = 'info') { - const toast = document.createElement('div'); +function showToast(title, message, type = "info") { + const toast = document.createElement("div"); toast.className = `toast ${type}`; - toast.innerHTML = `${icon(type === 'error' ? 'error' : type === 'success' ? 'check' : 'warning')}
${escapeHtml(title)}${escapeHtml(message)}
`; + toast.innerHTML = `${icon(type === "error" ? "error" : type === "success" ? "check" : "warning")}
${escapeHtml(title)}${escapeHtml(message)}
`; toastRoot.append(toast); setTimeout(() => toast.remove(), 5600); } -function setLoading(loading, message = '') { ui.loading = loading; ui.loadingMessage = message; render(); } +function setLoading(loading, message = "") { + ui.loading = loading; + ui.loadingMessage = message; + render(); +} function updateOperationInState(operation) { if (!operation || !ui.boot) return; const list = operations(); - ui.boot.state.operations = [operation, ...list.filter((item) => item.id !== operation.id)].slice(0, 250); + ui.boot.state.operations = [ + operation, + ...list.filter((item) => item.id !== operation.id), + ].slice(0, 250); if (ui.activeDeployment?.id === operation.id) ui.activeDeployment = operation; } @@ -169,16 +256,20 @@ function startOperationPolling() { stopOperationPolling(); const operationId = ui.activeDeployment?.id; if (!operationId || isTerminalOperation(ui.activeDeployment.status)) return; - const seconds = Math.max(2, Number(ui.boot?.state?.preferences?.operationPollSeconds) || 3); + const seconds = Math.max( + 2, + Number(ui.boot?.state?.preferences?.operationPollSeconds) || 3, + ); ui.operationPollTimer = setTimeout(async () => { try { const operation = await window.forgeflow.refreshOperations(operationId); if (operation) updateOperationInState(operation); render(); - if (operation && !isTerminalOperation(operation.status)) startOperationPolling(); + if (operation && !isTerminalOperation(operation.status)) + startOperationPolling(); else stopOperationPolling(); } catch (error) { - showToast('Deployment status refresh failed', error.message, 'error'); + showToast("Deployment status refresh failed", error.message, "error"); stopOperationPolling(); } }, seconds * 1000); @@ -187,45 +278,78 @@ function startOperationPolling() { async function bootstrap() { try { ui.boot = await window.forgeflow.bootstrap(); - ui.isMock = String(ui.boot.appVersion).includes('demo'); + ui.isMock = String(ui.boot.appVersion).includes("demo"); ui.diagnosticsStatus = ui.boot.diagnostics || null; applyTheme(ui.boot.state.appearance); ui.setupDraft.roots = [...(ui.boot.state.workspaceRoots || [])]; if (ui.boot.state.setupComplete) { await refreshRepositories(false); const reconciled = await refreshActiveOperations(false); - if ((Array.isArray(reconciled) ? reconciled : []).some((operation) => isTerminalOperation(operation.status))) await refreshRepositories(false); + if ( + (Array.isArray(reconciled) ? reconciled : []).some((operation) => + isTerminalOperation(operation.status), + ) + ) + await refreshRepositories(false); } window.forgeflow.onRepositoriesChanged?.(() => scheduleAutoRefresh()); window.forgeflow.onOperationsChanged?.((payload) => { const changed = payload?.operations || []; for (const operation of changed) updateOperationInState(operation); - if (changed.some((operation) => isTerminalOperation(operation.status))) scheduleAutoRefresh(250); + if (changed.some((operation) => isTerminalOperation(operation.status))) + scheduleAutoRefresh(250); render(); }); window.forgeflow.onUpdatesChanged?.((payload) => { ui.updateStatus = payload; render(); - if (payload?.available) showToast('ForgeFlow update available', `Version ${payload.remoteVersion} is ready to download.`, 'success'); + if (payload?.available) + showToast( + "ForgeFlow update available", + `Version ${payload.remoteVersion} is ready to download.`, + "success", + ); }); render(); const updateResult = ui.boot.updateResult; - if (updateResult?.state === 'success') { - const restartNote = updateResult.restartLaunched ? '' : ' Automatic restart was unavailable, but the update itself succeeded.'; - showToast('ForgeFlow updated successfully', `Version ${updateResult.installedVersion || updateResult.expectedVersion || ui.boot.appVersion} is installed.${restartNote}`, 'success'); - } else if (updateResult?.state === 'rolled-back') { - showToast('ForgeFlow update rolled back', updateResult.message || 'The update failed and the previous version was restored.', 'error'); - } else if (updateResult?.state === 'failed') { - showToast('ForgeFlow update failed', updateResult.message || 'See the update log for technical details.', 'error'); + if (updateResult?.state === "success") { + const restartNote = updateResult.restartLaunched + ? "" + : " Automatic restart was unavailable, but the update itself succeeded."; + showToast( + "ForgeFlow updated successfully", + `Version ${updateResult.installedVersion || updateResult.expectedVersion || ui.boot.appVersion} is installed.${restartNote}`, + "success", + ); + } else if (updateResult?.state === "rolled-back") { + showToast( + "ForgeFlow update rolled back", + updateResult.message || + "The update failed and the previous version was restored.", + "error", + ); + } else if (updateResult?.state === "failed") { + showToast( + "ForgeFlow update failed", + updateResult.message || "See the update log for technical details.", + "error", + ); } - setTimeout(() => { void refreshDeploymentTruth(false); }, 500); + setTimeout(() => { + void refreshDeploymentTruth(false); + }, 500); } catch (error) { - app.innerHTML = `
${icon('error')}ForgeFlow could not start${escapeHtml(error.message)}
`; + app.innerHTML = `
${icon("error")}ForgeFlow could not start${escapeHtml(error.message)}
`; } } function scheduleAutoRefresh() { - if (ui.loading || ui.autoRefreshPending || !ui.boot?.state?.preferences?.autoRefresh) return; + if ( + ui.loading || + ui.autoRefreshPending || + !ui.boot?.state?.preferences?.autoRefresh + ) + return; ui.autoRefreshPending = true; setTimeout(async () => { ui.autoRefreshPending = false; @@ -234,115 +358,233 @@ function scheduleAutoRefresh() { } async function refreshRepositories(withLoader = true, silent = false) { - if (withLoader) setLoading(true, 'Refreshing Local → Gitea → Server state…'); + if (withLoader) setLoading(true, "Refreshing Local → Gitea → Server state…"); try { const selectedId = ui.selectedRepoId; ui.repositories = await window.forgeflow.refreshRepositories(); ui.refreshError = null; if (selectedId && !selectedRepository()) ui.selectedRepoId = null; const repository = selectedRepository(); - if (repository && !repository.deploymentProfiles.some((profile) => profile.id === ui.selectedProfileId)) ui.selectedProfileId = selectedProfile(repository)?.id || null; + if ( + repository && + !repository.deploymentProfiles.some( + (profile) => profile.id === ui.selectedProfileId, + ) + ) + ui.selectedProfileId = selectedProfile(repository)?.id || null; if (repository) { - const availablePaths = new Set((repository.localStatus?.files || []).map((file) => file.path)); - ui.selectedFiles = new Set([...ui.selectedFiles].filter((filePath) => availablePaths.has(filePath))); + const availablePaths = new Set( + (repository.localStatus?.files || []).map((file) => file.path), + ); + ui.selectedFiles = new Set( + [...ui.selectedFiles].filter((filePath) => + availablePaths.has(filePath), + ), + ); if (ui.selectedFile && !availablePaths.has(ui.selectedFile)) { ui.selectedFile = repository.localStatus?.files?.[0]?.path || null; - ui.diff = ''; + ui.diff = ""; } } - if (!ui.selectedRepoId && ui.currentView === 'repository' && ui.repositories.length) selectRepository(ui.repositories[0].id, false); + if ( + !ui.selectedRepoId && + ui.currentView === "repository" && + ui.repositories.length + ) + selectRepository(ui.repositories[0].id, false); } catch (error) { ui.refreshError = error.message; - if (!silent) showToast('Refresh failed', error.message, 'error'); + if (!silent) showToast("Refresh failed", error.message, "error"); } finally { - if (withLoader) setLoading(false); else render(); + if (withLoader) setLoading(false); + else render(); } } async function refreshActiveOperations(showErrors = true) { try { const updated = await window.forgeflow.refreshOperations(); - for (const operation of Array.isArray(updated) ? updated : []) updateOperationInState(operation); + for (const operation of Array.isArray(updated) ? updated : []) + updateOperationInState(operation); return updated; } catch (error) { - if (showErrors) showToast('Deployment status unavailable', error.message, 'error'); + if (showErrors) + showToast("Deployment status unavailable", error.message, "error"); return []; } } async function refreshDeploymentTruth(showErrors = false) { const targets = ui.repositories.flatMap((repository) => - (repository.deploymentProfiles || []).map((profile) => ({ repository, profile })) + (repository.deploymentProfiles || []).map((profile) => ({ + repository, + profile, + })), ); if (!targets.length) return { checked: 0, failed: 0 }; const failures = []; const queue = [...targets]; - const workers = Array.from({ length: Math.min(3, queue.length) }, async () => { - while (queue.length) { - const target = queue.shift(); - try { - target.profile.state = await window.forgeflow.refreshProfileState(target.repository.fullName, target.profile.id); - } catch (error) { - failures.push({ repository: target.repository.fullName, profile: target.profile.name, message: error.message }); + const workers = Array.from( + { length: Math.min(3, queue.length) }, + async () => { + while (queue.length) { + const target = queue.shift(); + try { + target.profile.state = await window.forgeflow.refreshProfileState( + target.repository.fullName, + target.profile.id, + ); + } catch (error) { + failures.push({ + repository: target.repository.fullName, + profile: target.profile.name, + message: error.message, + }); + } } - } - }); + }, + ); await Promise.all(workers); await refreshRepositories(false); if (showErrors && failures.length) { - showToast('Some environments could not be checked', `${failures.length} profile${failures.length === 1 ? '' : 's'} could not be refreshed. Open Deployments for details.`, 'error'); + showToast( + "Some environments could not be checked", + `${failures.length} profile${failures.length === 1 ? "" : "s"} could not be refreshed. Open Deployments for details.`, + "error", + ); } return { checked: targets.length, failed: failures.length }; } function selectRepository(id, shouldRender = true) { ui.selectedRepoId = id; - ui.currentView = 'repository'; - ui.repositoryTab = 'changes'; - ui.commitMessage = ''; + ui.currentView = "repository"; + ui.repositoryTab = "changes"; + ui.commitMessage = ""; ui.history = []; ui.branches = []; ui.stashes = []; ui.gitRecovery = null; + ui.branchProtection = null; const repository = selectedRepository(); ui.selectedProfileId = selectedProfile(repository)?.id || null; const files = repository?.localStatus?.files || []; ui.selectedFiles = new Set(files.map((file) => file.path)); ui.selectedFile = files[0]?.path || null; - ui.diff = ''; - if (ui.selectedFile && repository?.localPath) loadDiff(repository, ui.selectedFile); + ui.diff = ""; + if (ui.selectedFile && repository?.localPath) + loadDiff(repository, ui.selectedFile); + if (repository?.owner?.login && repository?.localStatus?.branch?.head) { + window.forgeflow + .branchProtection(repository.fullName, repository.localStatus.branch.head) + .then((protection) => { + if (String(ui.selectedRepoId) === String(id)) { + ui.branchProtection = protection; + render(); + } + }) + .catch(() => {}); + } if (shouldRender) render(); } async function loadDiff(repository, filePath) { - ui.diff = 'Loading diff…'; + ui.diff = "Loading diff…"; render(); try { - const file = repository.localStatus?.files.find((item) => item.path === filePath); - ui.diff = await window.forgeflow.repositoryDiff(repository.localPath, filePath, Boolean(file?.staged && !file?.unstaged)); - } catch (error) { ui.diff = `Unable to load diff: ${error.message}`; } + const file = repository.localStatus?.files.find( + (item) => item.path === filePath, + ); + ui.diff = await window.forgeflow.repositoryDiff( + repository.localPath, + filePath, + Boolean(file?.staged && !file?.unstaged), + ); + ui.diffHunks = file?.unstaged + ? await window.forgeflow + .repositoryDiffHunks(repository.localPath, filePath) + .catch(() => null) + : null; + } catch (error) { + ui.diff = `Unable to load diff: ${error.message}`; + } render(); } function repositoryAction(repository) { - if (!repository.localPath) return { kind: 'link', title: 'Connect this repository', detail: 'Link an existing local folder or clone it from Gitea.' }; + if (!repository.localPath) + return { + kind: "link", + title: "Connect this repository", + detail: "Link an existing local folder or clone it from Gitea.", + }; const status = repository.localStatus; - if (!status) return { kind: 'error', title: 'Local repository unavailable', detail: repository.attentionReason || 'The linked folder could not be read.' }; - if (status.counts.conflicts) return { kind: 'conflict', title: 'Resolve merge conflicts', detail: `${status.counts.conflicts} conflicted file${status.counts.conflicts === 1 ? '' : 's'} block synchronization.` }; - if (status.counts.changed) return { kind: 'commit', title: 'Commit local changes', detail: `${status.counts.changed} changed file${status.counts.changed === 1 ? '' : 's'} detected.` }; - if (status.branch.behind && status.branch.ahead) return { kind: 'diverged', title: 'Branches have diverged', detail: `Local is ${status.branch.ahead} ahead and ${status.branch.behind} behind. ForgeFlow can create a safety branch and repair this from Git tools.` }; - if (status.branch.behind) return { kind: 'pull', title: 'Synchronize from Gitea', detail: `Local ${status.branch.head} is ${status.branch.behind} commit${status.branch.behind === 1 ? '' : 's'} behind.` }; - if (status.branch.ahead) return { kind: 'push', title: 'Push local commits', detail: `${status.branch.ahead} commit${status.branch.ahead === 1 ? '' : 's'} ready to push.` }; - if (!repository.deploymentProfiles?.length) return { kind: 'configure', title: 'Configure deployment', detail: 'Connect a predefined Gitea Actions workflow before deploying.' }; + if (!status) + return { + kind: "error", + title: "Local repository unavailable", + detail: + repository.attentionReason || "The linked folder could not be read.", + }; + if (status.counts.conflicts) + return { + kind: "conflict", + title: "Resolve merge conflicts", + detail: `${status.counts.conflicts} conflicted file${status.counts.conflicts === 1 ? "" : "s"} block synchronization.`, + }; + if (status.counts.changed) + return { + kind: "commit", + title: "Commit local changes", + detail: `${status.counts.changed} changed file${status.counts.changed === 1 ? "" : "s"} detected.`, + }; + if (status.branch.behind && status.branch.ahead) + return { + kind: "diverged", + title: "Branches have diverged", + detail: `Local is ${status.branch.ahead} ahead and ${status.branch.behind} behind. ForgeFlow can create a safety branch and repair this from Git tools.`, + }; + if (status.branch.behind) + return { + kind: "pull", + title: "Synchronize from Gitea", + detail: `Local ${status.branch.head} is ${status.branch.behind} commit${status.branch.behind === 1 ? "" : "s"} behind.`, + }; + if (status.branch.ahead) + return { + kind: "push", + title: "Push local commits", + detail: `${status.branch.ahead} commit${status.branch.ahead === 1 ? "" : "s"} ready to push.`, + }; + if (!repository.deploymentProfiles?.length) + return { + kind: "configure", + title: "Configure deployment", + detail: "Connect a predefined Gitea Actions workflow before deploying.", + }; const profile = selectedProfile(repository); - if (profile?.branch !== status.branch.head) return { kind: 'branch-profile', title: 'No deployment for this branch', detail: `The selected profile accepts ${profile.branch}; you are on ${status.branch.head}.` }; - if (repository.readyToDeploy) return { kind: 'deploy', title: 'Ready for deployment', detail: `Commit ${status.shortHead} can be released to ${profile.environment}.` }; - return { kind: 'clean', title: 'Repository synchronized', detail: 'No local or remote action is required.' }; + if (profile?.branch !== status.branch.head) + return { + kind: "branch-profile", + title: "No deployment for this branch", + detail: `The selected profile accepts ${profile.branch}; you are on ${status.branch.head}.`, + }; + if (repository.readyToDeploy) + return { + kind: "deploy", + title: "Ready for deployment", + detail: `Commit ${status.shortHead} can be released to ${profile.environment}.`, + }; + return { + kind: "clean", + title: "Repository synchronized", + detail: "No local or remote action is required.", + }; } -function navButton(view, label, iconName, count = '') { - return ``; +function navButton(view, label, iconName, count = "") { + return ``; } function renderTitlebar() { @@ -350,15 +592,24 @@ function renderTitlebar() { 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'); + 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'} - - + +
${icon("search")}
+ ${connected ? escapeHtml(user?.login || "Gitea") : "Offline"} + +
`; } @@ -366,157 +617,248 @@ function renderTitlebar() { 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 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(''); + 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 = '') { +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'] + 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 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 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)); + const active = recent.filter( + (operation) => !isTerminalOperation(operation.status), + ); return `
- - ${ui.refreshError ? `
${icon('error')} ${escapeHtml(ui.refreshError)}
` : ''} + + ${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')} + ${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.

'} + ${ + 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.

'}
+

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`)} + ${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 readinessRow(label, ok, detail) { + return `
${escapeHtml(label)}${escapeHtml(detail)}
`; +} +function releaseNode(label, value, description, tone = "") { + return `
${label}
${escapeHtml(value)}${escapeHtml(description)}
`; +} function renderDiff(diff) { - if (!diff) return '

No textual diff

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

'; - return escapeHtml(diff).split('\n').map((line) => { - const type = line.startsWith('+') && !line.startsWith('+++') ? 'add' : line.startsWith('-') && !line.startsWith('---') ? 'remove' : line.startsWith('@@') ? 'hunk' : ''; - return `${line || ' '}`; - }).join(''); + if (!diff) + return '

No textual diff

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

'; + return escapeHtml(diff) + .split("\n") + .map((line) => { + const type = + line.startsWith("+") && !line.startsWith("+++") + ? "add" + : line.startsWith("-") && !line.startsWith("---") + ? "remove" + : line.startsWith("@@") + ? "hunk" + : ""; + return `${line || " "}`; + }) + .join(""); } 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'); + 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.
'}
`; + 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.

`; - 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('')}
${escapeHtml(ui.selectedFile || 'Select a file')}
${ui.selectedFile ? escapeHtml(status.files.find((file) => file.path === ui.selectedFile)?.status || '') : ''}
${renderDiff(ui.diff)}
`; + 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)}
`; + 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 (profile.provider === 'ssh-unraid' || state.statusConfigured || state.healthConfigured) return { label: 'Not checked', tone: '' }; - return { label: 'Status not configured', tone: '' }; + if (state.healthy === false) return { label: "Unhealthy", tone: "danger" }; + if (state.healthy === true) return { label: "Healthy", tone: "success" }; + 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 iconMode = + profile.iconMode || + (profile.iconFilePath ? "upload" : profile.iconUrl ? "url" : "builtin"); const webUiExpected = Boolean(profile.webUiUrl || profile.hostPort); - const iconExpected = iconMode !== 'none'; + 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; + 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) + ready: Boolean(state.containerRunning && webUiReady && iconReady), }; } function renderProfileCard(repository, profile, compact = false) { const state = profile.state || {}; const health = environmentState(profile); - const ready = repository.readyToDeploy && repository.localStatus?.branch.head === profile.branch; - const isSsh = profile.provider === 'ssh-unraid'; + const ready = + repository.readyToDeploy && + repository.localStatus?.branch.head === profile.branch; + const isSsh = profile.provider === "ssh-unraid"; const providerDetail = isSsh ? `SSH / Unraid · ${profile.remoteFolder || repository.name} · ${profile.branch}` : `${profile.workflowFile} · ${profile.branch}`; @@ -524,75 +866,157 @@ function renderProfileCard(repository, profile, compact = false) { const dockerMan = dockerManIntegration(profile); const { templateReady, webUiReady, iconReady } = dockerMan; const dockerManReady = dockerMan.ready; - const webUi = profile.webUiUrl || state.webUiUrl || state.dockerMan?.webUi || ''; - return `
${escapeHtml(profile.environment)}

${escapeHtml(profile.name)}

${escapeHtml(providerDetail)}

${health.label}
${webUi ? `` : ''}${isSsh ? `` : ''}${ready ? `` : ''}${state.previousSha && rollbackConfigured ? `` : ''}
`; + const webUi = + profile.webUiUrl || state.webUiUrl || state.dockerMan?.webUi || ""; + return `
${escapeHtml(profile.environment)}

${escapeHtml(profile.name)}

${escapeHtml(providerDetail)}

${health.label}
${webUi ? `` : ""}${isSsh ? `` : ""}${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.

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

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

Repository identity

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

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.
`; + 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 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, settings: renderRepositorySettings }[ui.repositoryTab] || renderChanges)(repository); - return `

${escapeHtml(repository.fullName)}

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

-
${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}
`; + 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, + settings: renderRepositorySettings, + }[ui.repositoryTab] || renderChanges + )(repository); + return `

${escapeHtml(repository.fullName)}

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

+ ${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') { + 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 commitReady = Boolean(ui.selectedFiles.size && ui.commitMessage.trim()); - const commitBlocker = !ui.selectedFiles.size ? 'Select at least one changed file.' : !ui.commitMessage.trim() ? 'Enter a commit message to enable commit and push.' : 'Ready to commit. ForgeFlow stages the selected files automatically.'; - body = `
${ui.selectedFiles.size} of ${status.counts.changed} files selectedCtrl+Enter
${icon(commitReady ? 'check' : 'warning')}${escapeHtml(commitBlocker)}
Manual staging is optional. Use it only to review the Git index before committing.
`; - } - 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 ``; + 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 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.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.
` : ''}
${cards.length ? cards.map(({ repository, profile }) => renderProfileCard(repository, profile, true)).join('') : '

No deployment environments configured

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.

'}
`; + 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.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.
` : ""}
${cards.length ? cards.map(({ repository, profile }) => renderProfileCard(repository, profile, true)).join("") : '

No deployment environments configured

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() { @@ -600,431 +1024,1281 @@ function renderSettings() { 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

-

Appearance

+ 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' : ''; + 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)}` : ''}
${escapeHtml(item.status)}
`).join('')}
`; +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)}` : ""}
${escapeHtml(item.status)}
`).join("")}
`; } function renderDiagnostics() { const prefs = ui.boot.state.preferences || {}; const status = ui.diagnosticsStatus || ui.boot.diagnostics || {}; const report = ui.systemPreflight; - 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.
+ 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

+

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

-

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)}

` : ''}
+

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)}

` : ''}
`; + 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 || '')}
`; + 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 = ''; + 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.
`; + 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.
`}
`; + 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('')}
`; + 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.

'}
`; + 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}
`; + const nextAction = + ui.setupStep === 0 + ? ui.systemPreflight?.summary?.ready + ? '' + : '' + : ui.setupStep === 1 + ? '' + : ui.setupStep === 2 + ? `` + : ui.setupStep === 4 + ? '' + : ""; + return `
${body}
${nextAction}
`; } function renderModal() { - if (!ui.modal) return ''; - const repository = selectedRepository() || ui.repositories.find((repo) => repo.fullName === ui.modal.repositoryFullName); - if (ui.modal.type === 'deployment-config') { - const existing = repository?.deploymentProfiles?.find((profile) => profile.id === ui.modal.profileId) || {}; + if (!ui.modal) return ""; + const repository = + selectedRepository() || + ui.repositories.find( + (repo) => repo.fullName === ui.modal.repositoryFullName, + ); + 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 === 'deployment-preflight') { - const profile = repository?.deploymentProfiles?.find((item) => item.id === ui.modal.profileId) || selectedProfile(repository); + if (ui.modal.type === "deployment-preflight") { + const profile = + repository?.deploymentProfiles?.find( + (item) => item.id === ui.modal.profileId, + ) || selectedProfile(repository); const report = ui.deploymentPreflight; - return ``; + return ``; } - if (ui.modal.type === 'deploy-confirm') { - const profile = repository?.deploymentProfiles?.find((item) => item.id === ui.modal.profileId) || selectedProfile(repository); - return ``; + if (ui.modal.type === "deploy-confirm") { + const profile = + repository?.deploymentProfiles?.find( + (item) => item.id === ui.modal.profileId, + ) || selectedProfile(repository); + return ``; } - if (ui.modal.type === 'rollback-confirm') { - const profile = repository?.deploymentProfiles?.find((item) => item.id === ui.modal.profileId); + if (ui.modal.type === "rollback-confirm") { + const profile = repository?.deploymentProfiles?.find( + (item) => item.id === ui.modal.profileId, + ); const target = profile?.state?.previousSha; - return ``; + 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 || 'privateKey'; - 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 || "privateKey"; + return ``; } - if (ui.modal.type === 'command-palette') return renderCommandPalette(); - 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: repository?.readyToDeploy ? `${repository.name} ${repository.localStatus.shortHead}` : 'Not ready', icon: 'rocket', enabled: Boolean(repository?.readyToDeploy) } + { + 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: repository?.readyToDeploy + ? `${repository.name} ${repository.localStatus.shortHead}` + : "Not ready", + icon: "rocket", + enabled: Boolean(repository?.readyToDeploy), + }, ]; } function renderCommandPalette() { const query = ui.paletteQuery.toLowerCase(); - const commands = paletteCommands().filter((command) => !query || `${command.label} ${command.detail}`.toLowerCase().includes(query)); - return ``; + 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 === "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.

'}
`, + ); + } } 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()}`; - if (ui.modal?.type === 'command-palette') requestAnimationFrame(() => document.querySelector('#palette-input')?.focus()); + 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 } = {}) { +async function runOperation( + message, + operation, + successMessage, + { refresh = true } = {}, +) { setLoading(true, message); try { const result = await operation(); - if (successMessage) showToast('Done', successMessage, 'success'); + 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'); + 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 = ''; + ui.diff = ""; + ui.commitMessage = ""; render(); } return null; - } finally { setLoading(false); } + } finally { + setLoading(false); + } } async function executeDeployment(profileId) { const repository = selectedRepository(); - const profile = repository?.deploymentProfiles?.find((item) => item.id === profileId) || selectedProfile(repository); + const profile = + repository?.deploymentProfiles?.find((item) => item.id === profileId) || + selectedProfile(repository); if (!repository || !profile) 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…`); + 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, repository.localStatus.head); + ui.activeDeployment = await window.forgeflow.deploy( + repository, + profile.id, + repository.localStatus.head, + deploymentOptions, + ); updateOperationInState(ui.activeDeployment); - ui.currentView = 'deployment-run'; - showToast('Deployment started', `${repository.name} ${repository.localStatus.shortHead} → ${profile.environment}`, 'success'); + ui.currentView = "deployment-run"; + showToast( + "Deployment started", + `${repository.name} ${repository.localStatus.shortHead} → ${profile.environment}`, + "success", + ); startOperationPolling(); - } catch (error) { showToast('Deployment failed to start', error.message, 'error'); } + } catch (error) { + 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 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)}…`); + 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); + 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'); } + 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…'); + 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) + window.forgeflow.gitRecoveryStatus(repository.localPath), ]); - ui.repositoryTab = 'gittools'; - } catch (error) { showToast('Git tools unavailable', error.message, 'error'); } + 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)); + return ui.repositories.find((repository) => + repository.deploymentProfiles?.some((profile) => profile.id === profileId), + ); } async function runSystemPreflight({ setup = false } = {}) { - setLoading(true, 'Checking local readiness…'); + 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 + roots: setup ? ui.setupDraft.roots : ui.boot.state.workspaceRoots, }); - if (!setup) ui.diagnosticsStatus = await window.forgeflow.diagnosticsStatus(); + if (!setup) + ui.diagnosticsStatus = await window.forgeflow.diagnosticsStatus(); showToast( - ui.systemPreflight.summary.ready ? 'Readiness checks passed' : 'Readiness needs attention', + 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' + ui.systemPreflight.summary.ready ? "success" : "error", ); return ui.systemPreflight; } catch (error) { - showToast('Readiness check failed', error.message, 'error'); + showToast("Readiness check failed", error.message, "error"); return null; - } finally { setLoading(false); } + } finally { + setLoading(false); + } } -async function runDeploymentPreflight(repository, profileId, { showModal = true } = {}) { +async function runDeploymentPreflight( + repository, + profileId, + { showModal = true } = {}, +) { if (!repository || !profileId) return null; - if (String(repository.id) !== String(ui.selectedRepoId)) selectRepository(repository.id, false); + if (String(repository.id) !== String(ui.selectedRepoId)) + selectRepository(repository.id, false); ui.selectedProfileId = profileId; ui.deploymentPreflight = null; - setLoading(true, 'Verifying repository, workflow and server…'); + setLoading(true, "Verifying repository, workflow and server…"); try { - const report = await window.forgeflow.deploymentPreflight(repository, profileId); + const report = await window.forgeflow.deploymentPreflight( + repository, + profileId, + ); ui.deploymentPreflight = report; - if (showModal) ui.modal = { type: 'deployment-preflight', profileId, repositoryFullName: repository.fullName }; + if (showModal) + ui.modal = { + type: "deployment-preflight", + profileId, + repositoryFullName: repository.fullName, + }; return report; } catch (error) { - showToast('Deployment preflight failed', error.message, 'error'); + showToast("Deployment preflight failed", error.message, "error"); return null; - } finally { setLoading(false); } + } finally { + setLoading(false); + } } -app.addEventListener('click', async (event) => { - const target = event.target.closest('[data-action]'); +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)); + 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(); } - else if (action === 'select-repo') selectRepository(target.dataset.id); - else if (action === 'refresh') { + if (action === "navigate") { + ui.currentView = target.dataset.view; + ui.modal = null; + render(); + } 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…'); + } 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') { + } 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 render(); - } - 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 === '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) return; - const result = await runOperation(action === 'commit-push' ? 'Committing and pushing…' : 'Creating local commit…', () => action === 'commit-push' ? window.forgeflow.commitAndPush(repository.localPath, ui.commitMessage, [...ui.selectedFiles]) : window.forgeflow.commit(repository.localPath, ui.commitMessage, [...ui.selectedFiles]), 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'; + if (ui.repositoryTab === "gittools" && !ui.branches.length) + await loadGitTools(repository); + 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 === "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…`, + mode === "custom" + ? "Choosing location and cloning repository…" + : `Cloning ${repository.name} into the default project root…`, () => window.forgeflow.cloneRepository(repository.fullName, mode), null, - { refresh: false } + { 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(); + ui.repositories = + clone.repositories || (await window.forgeflow.refreshRepositories()); selectRepository(repository.id); - showToast(clone.reused ? 'Existing repository linked' : 'Repository cloned', clone.target, 'success'); + showToast( + clone.reused ? "Existing repository linked" : "Repository cloned", + clone.target, + "success", + ); + } + } 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; + if (!repository) repository = profileRepository(target.dataset.profileId); + 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; } - } - else if (action === 'configure-deployment') { ui.modal = { type: 'deployment-config', profileId: null, provider: (ui.boot.state.servers || []).length ? 'ssh-unraid' : 'gitea-actions' }; render(); } - else if (action === 'edit-deployment-profile') { if (!repository) repository = profileRepository(target.dataset.profileId); 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 === 'save-deployment-profile') { - const provider = document.querySelector('#profile-provider').value; 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, - ...(provider === 'ssh-unraid' ? { - serverId: document.querySelector('#profile-server').value, - remoteFolder: document.querySelector('#profile-remote-folder').value.trim(), - cloneUrl: document.querySelector('#profile-clone-url').value.trim(), - alignRemote: document.querySelector('#profile-align-remote').checked, - generatedCompose: document.querySelector('#profile-generated-compose').value === 'true', - composeFile: document.querySelector('#profile-compose-file').value.trim(), - composeService: document.querySelector('#profile-compose-service').value.trim(), - 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) - } : { - workflowFile: document.querySelector('#profile-workflow').value.trim(), - rollbackWorkflowFile: document.querySelector('#profile-rollback-workflow').value.trim(), - statusUrl: document.querySelector('#profile-status-url').value.trim() - }) + 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(), + cloneUrl: document.querySelector("#profile-clone-url").value.trim(), + alignRemote: document.querySelector("#profile-align-remote") + .checked, + generatedCompose: + document.querySelector("#profile-generated-compose").value === + "true", + composeFile: document + .querySelector("#profile-compose-file") + .value.trim(), + composeService: document + .querySelector("#profile-compose-service") + .value.trim(), + 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), + adoptedFromServer: Boolean(ui.deploymentDiscovery), + serverSourceOfTruth: Boolean(ui.deploymentDiscovery), + 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; 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(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') { + } 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 === '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); + } 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 }); + 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); + } 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 (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); - 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'); } + 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') { + } 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 })) + .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…'); + 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); + await window.forgeflow.applyDockerManMetadata( + item.repository, + item.profile.id, + ); repaired += 1; } catch (error) { failures.push(`${item.repository.name}: ${error.message}`); @@ -1032,270 +2306,944 @@ app.addEventListener('click', async (event) => { } 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' + 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') { + } 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( + 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') { + } 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(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'); } + } 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 === '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 archive check.`, 'success'); } - catch (error) { showToast('Update download failed', error.message, 'error'); } + } 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 === '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 === 'open-add-server') { ui.modal = { type: 'server-config', serverId: null, authType: 'privateKey' }; 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 || 'privateKey' }; 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; + } 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 archive 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 === "open-add-server") { + ui.modal = { + type: "server-config", + serverId: null, + authType: "privateKey", + }; + 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 || "privateKey", + }; + 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(), + 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(), - privateKeyPath: document.querySelector('#server-private-key')?.value.trim() || '', - hostFingerprint: document.querySelector('#server-fingerprint').value.trim() + basePath: document.querySelector("#server-base-path").value.trim(), + 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…'); + 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'); } + 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, 'Connecting to Unraid and checking Git and Docker Compose…'); - try { const result = await window.forgeflow.testServer(target.dataset.serverId); ui.boot.state = result.state; showToast('SSH server ready', `${result.server.name} presented ${result.fingerprint}.`, 'success'); } - catch (error) { showToast('SSH test failed', error.message, 'error'); } + } else if (action === "test-server") { + setLoading( + true, + "Connecting to Unraid and checking Git and Docker Compose…", + ); + try { + const result = await window.forgeflow.testServer(target.dataset.serverId); + ui.boot.state = result.state; + showToast( + "SSH server ready", + `${result.server.name} presented ${result.fingerprint}.`, + "success", + ); + } 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 === 'repair-origin') { + } 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'); } + 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…'); + } 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'); + 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 === 'reconcile-repository') { + setLoading(false); + render(); + } else if (action === "scan-git-recovery") { 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') { + 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 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.' + ? "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…'); + 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); + 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-system-preflight') await runSystemPreflight(); - else if (action === 'save-diagnostics-preferences') { + 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) + 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…'); + 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'); } + 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…'); + } 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'); + showToast( + "Diagnostic bundle created", + `${bundle.size} · SHA-256 ${shortSha(bundle.sha256)}`, + "success", + ); } - } catch (error) { showToast('Could not export diagnostics', error.message, 'error'); } + } catch (error) { + showToast("Could not export diagnostics", error.message, "error"); + } setLoading(false); - } - else if (action === 'show-diagnostic-bundle') { + } 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' && repository?.readyToDeploy) { const profile = selectedProfile(repository); if (profile) await runDeploymentPreflight(repository, profile.id); } + 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" && repository?.readyToDeploy) { + 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("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(); } +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("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(); + } }); -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("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) => { +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(() => {}); + window.forgeflow + .reportRendererEvent?.("error", "unhandled-rejection", { + message: reason?.message || String(reason || "Unknown rejection"), + stack: reason?.stack, + }) + .catch(() => {}); }); bootstrap(); diff --git a/src/renderer/mock-bridge.js b/src/renderer/mock-bridge.js index 794f781..a95247b 100644 --- a/src/renderer/mock-bridge.js +++ b/src/renderer/mock-bridge.js @@ -5,17 +5,43 @@ const clone = (value) => JSON.parse(JSON.stringify(value)); const iso = (offset = 0) => new Date(Date.now() + offset).toISOString(); const storage = { - get(key) { try { return localStorage.getItem(key); } catch { return null; } }, - set(key, value) { try { localStorage.setItem(key, value); } catch {} } + get(key) { + try { + return localStorage.getItem(key); + } catch { + return null; + } + }, + set(key, value) { + try { + localStorage.setItem(key, value); + } catch {} + }, }; const repositoryListeners = new Set(); const operationListeners = new Set(); const updateListeners = new Set(); - const emitRepositories = () => repositoryListeners.forEach((listener) => listener({ reason: 'demo-change' })); - const emitOperations = (operations) => operationListeners.forEach((listener) => listener({ operations: clone(operations) })); - const randomSha = () => `${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`.padEnd(40, 'a').slice(0, 40); + const emitRepositories = () => + repositoryListeners.forEach((listener) => + listener({ reason: "demo-change" }), + ); + const emitOperations = (operations) => + operationListeners.forEach((listener) => + listener({ operations: clone(operations) }), + ); + const randomSha = () => + `${Math.random().toString(16).slice(2)}${Date.now().toString(16)}` + .padEnd(40, "a") + .slice(0, 40); - const makeStatus = ({ head, branch = 'main', ahead = 0, behind = 0, upstream = `origin/${branch}`, files = [] }) => ({ + const makeStatus = ({ + head, + branch = "main", + ahead = 0, + behind = 0, + upstream = `origin/${branch}`, + files = [], + }) => ({ branch: { oid: head, head: branch, upstream, ahead, behind }, files, counts: { @@ -23,38 +49,55 @@ staged: files.filter((item) => item.staged).length, unstaged: files.filter((item) => item.unstaged).length, conflicts: files.filter((item) => item.conflict).length, - untracked: files.filter((item) => item.untracked).length + untracked: files.filter((item) => item.untracked).length, }, clean: files.length === 0, - root: '', - remoteUrl: '', + root: "", + remoteUrl: "", head, shortHead: head.slice(0, 7), - fingerprint: `${head}:${branch}:${ahead}:${behind}:${files.map((item) => `${item.path}:${item.indexCode}${item.worktreeCode}`).join('|')}` + fingerprint: `${head}:${branch}:${ahead}:${behind}:${files.map((item) => `${item.path}:${item.indexCode}${item.worktreeCode}`).join("|")}`, }); - const makeFile = (path, status = 'modified', options = {}) => ({ + const makeFile = (path, status = "modified", options = {}) => ({ path, originalPath: options.originalPath || null, - indexCode: options.staged ? (status === 'added' ? 'A' : status === 'deleted' ? 'D' : 'M') : '.', - worktreeCode: options.staged ? '.' : status === 'untracked' ? '?' : status === 'deleted' ? 'D' : status === 'conflict' ? 'U' : 'M', + indexCode: options.staged + ? status === "added" + ? "A" + : status === "deleted" + ? "D" + : "M" + : ".", + worktreeCode: options.staged + ? "." + : status === "untracked" + ? "?" + : status === "deleted" + ? "D" + : status === "conflict" + ? "U" + : "M", staged: Boolean(options.staged), unstaged: !options.staged, - untracked: status === 'untracked', - conflict: status === 'conflict', - status + untracked: status === "untracked", + conflict: status === "conflict", + status, }); const profile = (id, name, environment, options = {}) => ({ id, name, environment, - provider: 'gitea-actions', - branch: options.branch || 'main', - workflowFile: options.workflowFile || 'deploy.yml', - rollbackWorkflowFile: options.rollbackWorkflowFile ?? 'rollback.yml', - healthcheckUrl: options.healthcheckUrl || `https://${environment}.internal/health`, - statusUrl: options.statusUrl || `https://${environment}.internal/.well-known/forgeflow`, + provider: "gitea-actions", + branch: options.branch || "main", + workflowFile: options.workflowFile || "deploy.yml", + rollbackWorkflowFile: options.rollbackWorkflowFile ?? "rollback.yml", + healthcheckUrl: + options.healthcheckUrl || `https://${environment}.internal/health`, + statusUrl: + options.statusUrl || + `https://${environment}.internal/.well-known/forgeflow`, confirmationRequired: options.confirmationRequired !== false, inputs: {}, state: { @@ -65,8 +108,8 @@ statusConfigured: true, healthStatus: options.healthy === false ? 503 : 200, healthLatencyMs: 42, - checkedAt: options.checkedAt || iso(-120000) - } + checkedAt: options.checkedAt || iso(-120000), + }, }); const now = iso(); @@ -75,111 +118,283 @@ repositoryPollSeconds: 4, operationPollSeconds: 5, fetchIntervalMinutes: 10, - preferredCloneProtocol: 'https', + preferredCloneProtocol: "https", diagnosticsEnabled: true, - diagnosticLevel: 'info', + diagnosticLevel: "info", logRetentionDays: 14, - maxLogFileMb: 8 + maxLogFileMb: 8, }; let state = { - schemaVersion: 5, - setupComplete: storage.get('forgeflow-demo-setup') !== 'false', - appearance: storage.get('forgeflow-theme') || 'dark', - gitea: { baseUrl: 'https://gitea.internal', user: { login: 'jens', full_name: 'Jens' }, hasToken: true }, - workspaceRoots: ['C:\\Development'], + schemaVersion: 8, + setupComplete: storage.get("forgeflow-demo-setup") !== "false", + appearance: storage.get("forgeflow-theme") || "dark", + gitea: { + baseUrl: "https://gitea.internal", + user: { login: "jens", full_name: "Jens" }, + hasToken: true, + }, + workspaceRoots: ["C:\\Development"], repositoryMappings: {}, deploymentProfiles: {}, deploymentStates: {}, - favorites: ['jens/microsoft-cloud-operations-platform', 'jens/unraid-appops-gateway'], - updates: { owner: 'Jens', repo: 'ForgeFlow', branch: 'main', autoCheck: true, lastCheckedAt: null }, - servers: [{ id: 'server-unraid', name: 'Unraid', host: '192.168.1.10', port: 22, username: 'root', authType: 'privateKey', basePath: '/mnt/user/appdata', privateKeyPath: 'C:\\Users\\Jens\\.ssh\\id_ed25519', hostFingerprint: 'SHA256:demo', hasPassword: false, hasPassphrase: false }], + favorites: [ + "jens/microsoft-cloud-operations-platform", + "jens/unraid-appops-gateway", + ], + updates: { + owner: "Jens", + repo: "ForgeFlow", + branch: "main", + autoCheck: true, + lastCheckedAt: null, + }, + servers: [ + { + id: "server-unraid", + name: "Unraid", + host: "192.168.1.10", + port: 22, + username: "root", + authType: "privateKey", + basePath: "/mnt/user/appdata", + privateKeyPath: "C:\\Users\\Jens\\.ssh\\id_ed25519", + hostFingerprint: "SHA256:demo", + hasPassword: false, + hasPassphrase: false, + }, + ], preferences: { ...defaultPreferences }, operations: [ { - id: 'op-success', type: 'deployment', action: 'deploy', status: 'success', - repository: 'jens/microsoft-cloud-operations-platform', profileId: 'profile-mcop-prod', profileName: 'Production', - environment: 'production', workflowFile: 'deploy.yml', branch: 'main', - sha: 'b82f91ab0173cd4346ca0f0f7dcc3e8182cc8fd0', shortSha: 'b82f91a', createdAt: now, updatedAt: now, + id: "op-success", + type: "deployment", + action: "deploy", + status: "success", + repository: "jens/microsoft-cloud-operations-platform", + profileId: "profile-mcop-prod", + profileName: "Production", + environment: "production", + workflowFile: "deploy.yml", + branch: "main", + sha: "b82f91ab0173cd4346ca0f0f7dcc3e8182cc8fd0", + shortSha: "b82f91a", + createdAt: now, + updatedAt: now, stages: [ - { id: 'requested', label: 'Requested', status: 'complete' }, { id: 'verified', label: 'Verified', status: 'complete' }, - { id: 'queued', label: 'Workflow queued', status: 'complete' }, { id: 'runner', label: 'Runner execution', status: 'complete' }, - { id: 'healthcheck', label: 'Healthcheck', status: 'complete' }, { id: 'complete', label: 'Complete', status: 'complete' } + { id: "requested", label: "Requested", status: "complete" }, + { id: "verified", label: "Verified", status: "complete" }, + { id: "queued", label: "Workflow queued", status: "complete" }, + { id: "runner", label: "Runner execution", status: "complete" }, + { id: "healthcheck", label: "Healthcheck", status: "complete" }, + { id: "complete", label: "Complete", status: "complete" }, ], - logs: ['[info] Exact commit verified.', '[job] deploy: success', '[ok] Server reports b82f91a and healthcheck returned 200.'], - run: { id: 48, runNumber: 48, status: 'completed', conclusion: 'success', name: 'ForgeFlow deployment' }, - runUrl: 'https://gitea.internal/jens/microsoft-cloud-operations-platform/actions/runs/48' + logs: [ + "[info] Exact commit verified.", + "[job] deploy: success", + "[ok] Server reports b82f91a and healthcheck returned 200.", + ], + run: { + id: 48, + runNumber: 48, + status: "completed", + conclusion: "success", + name: "ForgeFlow deployment", + }, + runUrl: + "https://gitea.internal/jens/microsoft-cloud-operations-platform/actions/runs/48", }, { - id: 'op-failed', type: 'deployment', action: 'deploy', status: 'failed', - repository: 'jens/portfolio', profileId: 'profile-portfolio', profileName: 'Production', environment: 'production', - workflowFile: 'deploy.yml', branch: 'main', sha: 'a7f2e1c1bb6147fc8b6633d2b08500c93402a719', shortSha: 'a7f2e1c', - createdAt: iso(-86400000), updatedAt: iso(-86300000), - failure: { stage: 'healthcheck', message: 'Healthcheck returned 502.' }, + id: "op-failed", + type: "deployment", + action: "deploy", + status: "failed", + repository: "jens/portfolio", + profileId: "profile-portfolio", + profileName: "Production", + environment: "production", + workflowFile: "deploy.yml", + branch: "main", + sha: "a7f2e1c1bb6147fc8b6633d2b08500c93402a719", + shortSha: "a7f2e1c", + createdAt: iso(-86400000), + updatedAt: iso(-86300000), + failure: { stage: "healthcheck", message: "Healthcheck returned 502." }, stages: [ - { id: 'requested', label: 'Requested', status: 'complete' }, { id: 'verified', label: 'Verified', status: 'complete' }, - { id: 'queued', label: 'Workflow queued', status: 'complete' }, { id: 'runner', label: 'Runner execution', status: 'complete' }, - { id: 'healthcheck', label: 'Healthcheck', status: 'failed' }, { id: 'complete', label: 'Complete', status: 'failed' } + { id: "requested", label: "Requested", status: "complete" }, + { id: "verified", label: "Verified", status: "complete" }, + { id: "queued", label: "Workflow queued", status: "complete" }, + { id: "runner", label: "Runner execution", status: "complete" }, + { id: "healthcheck", label: "Healthcheck", status: "failed" }, + { id: "complete", label: "Complete", status: "failed" }, ], - logs: ['[job] deploy: success', '[error] Healthcheck returned 502.'] - } - ] + logs: ["[job] deploy: success", "[error] Healthcheck returned 502."], + }, + ], }; let repositories = [ { - id: 1, name: 'microsoft-cloud-operations-platform', fullName: 'jens/microsoft-cloud-operations-platform', owner: { login: 'jens' }, - description: 'Tenant-aware Microsoft cloud operations console.', private: true, defaultBranch: 'main', - htmlUrl: 'https://gitea.internal/jens/microsoft-cloud-operations-platform', cloneUrl: 'https://gitea.internal/jens/microsoft-cloud-operations-platform.git', sshUrl: 'git@gitea.internal:jens/microsoft-cloud-operations-platform.git', updatedAt: now, - localPath: 'C:\\Development\\Microsoft-Cloud-Operations-Platform', - localStatus: makeStatus({ head: 'b82f91ab0173cd4346ca0f0f7dcc3e8182cc8fd0' }), linkState: 'linked', + id: 1, + name: "microsoft-cloud-operations-platform", + fullName: "jens/microsoft-cloud-operations-platform", + owner: { login: "jens" }, + description: "Tenant-aware Microsoft cloud operations console.", + private: true, + defaultBranch: "main", + htmlUrl: + "https://gitea.internal/jens/microsoft-cloud-operations-platform", + cloneUrl: + "https://gitea.internal/jens/microsoft-cloud-operations-platform.git", + sshUrl: "git@gitea.internal:jens/microsoft-cloud-operations-platform.git", + updatedAt: now, + localPath: "C:\\Development\\Microsoft-Cloud-Operations-Platform", + localStatus: makeStatus({ + head: "b82f91ab0173cd4346ca0f0f7dcc3e8182cc8fd0", + }), + linkState: "linked", deploymentProfiles: [ - profile('profile-mcop-prod', 'Production', 'production', { liveSha: '72bd10eb0173cd4346ca0f0f7dcc3e8182cc8fd0', previousSha: '6ac991ab0173cd4346ca0f0f7dcc3e8182cc8fd0', healthy: true }), - profile('profile-mcop-stage', 'Staging', 'staging', { liveSha: 'b82f91ab0173cd4346ca0f0f7dcc3e8182cc8fd0', previousSha: '72bd10eb0173cd4346ca0f0f7dcc3e8182cc8fd0', healthy: true, confirmationRequired: false }) - ] + profile("profile-mcop-prod", "Production", "production", { + liveSha: "72bd10eb0173cd4346ca0f0f7dcc3e8182cc8fd0", + previousSha: "6ac991ab0173cd4346ca0f0f7dcc3e8182cc8fd0", + healthy: true, + }), + profile("profile-mcop-stage", "Staging", "staging", { + liveSha: "b82f91ab0173cd4346ca0f0f7dcc3e8182cc8fd0", + previousSha: "72bd10eb0173cd4346ca0f0f7dcc3e8182cc8fd0", + healthy: true, + confirmationRequired: false, + }), + ], }, { - id: 2, name: 'vacancyradar', fullName: 'jens/vacancyradar', owner: { login: 'jens' }, description: 'Local-first vacancy intelligence cockpit.', private: true, defaultBranch: 'main', - htmlUrl: 'https://gitea.internal/jens/vacancyradar', cloneUrl: 'https://gitea.internal/jens/vacancyradar.git', sshUrl: 'git@gitea.internal:jens/vacancyradar.git', updatedAt: now, - localPath: 'C:\\Development\\VacancyRadar', - localStatus: makeStatus({ head: 'c9182d0d28318c8cf0af109edc054732426aadf1', branch: 'feature/deployment-api', files: [makeFile('src/api/deploy.ts', 'added', { staged: true }), makeFile('src/main.tsx'), makeFile('src/components/Sidebar.tsx')] }), - linkState: 'linked', deploymentProfiles: [profile('profile-vr', 'Production', 'production', { liveSha: 'c117ab9d28318c8cf0af109edc054732426aadf1', previousSha: 'b1f57aad28318c8cf0af109edc054732426aadf1', healthy: true })] + id: 2, + name: "vacancyradar", + fullName: "jens/vacancyradar", + owner: { login: "jens" }, + description: "Local-first vacancy intelligence cockpit.", + private: true, + defaultBranch: "main", + htmlUrl: "https://gitea.internal/jens/vacancyradar", + cloneUrl: "https://gitea.internal/jens/vacancyradar.git", + sshUrl: "git@gitea.internal:jens/vacancyradar.git", + updatedAt: now, + localPath: "C:\\Development\\VacancyRadar", + localStatus: makeStatus({ + head: "c9182d0d28318c8cf0af109edc054732426aadf1", + branch: "feature/deployment-api", + files: [ + makeFile("src/api/deploy.ts", "added", { staged: true }), + makeFile("src/main.tsx"), + makeFile("src/components/Sidebar.tsx"), + ], + }), + linkState: "linked", + deploymentProfiles: [ + profile("profile-vr", "Production", "production", { + liveSha: "c117ab9d28318c8cf0af109edc054732426aadf1", + previousSha: "b1f57aad28318c8cf0af109edc054732426aadf1", + healthy: true, + }), + ], }, { - id: 3, name: 'unraid-appops-gateway', fullName: 'jens/unraid-appops-gateway', owner: { login: 'jens' }, description: 'Safe operations gateway for Unraid and Portainer.', private: true, defaultBranch: 'main', - htmlUrl: 'https://gitea.internal/jens/unraid-appops-gateway', cloneUrl: 'https://gitea.internal/jens/unraid-appops-gateway.git', sshUrl: 'git@gitea.internal:jens/unraid-appops-gateway.git', updatedAt: now, - localPath: 'C:\\Development\\Unraid-AppOps-Gateway', localStatus: makeStatus({ head: 'f2d1e0a1bb6147fc8b6633d2b08500c93402a719', ahead: 2 }), linkState: 'linked', - deploymentProfiles: [profile('profile-appops', 'Production', 'production', { liveSha: '8ac731b1bb6147fc8b6633d2b08500c93402a719', previousSha: '7bc198a1bb6147fc8b6633d2b08500c93402a719', healthy: true })] + id: 3, + name: "unraid-appops-gateway", + fullName: "jens/unraid-appops-gateway", + owner: { login: "jens" }, + description: "Safe operations gateway for Unraid and Portainer.", + private: true, + defaultBranch: "main", + htmlUrl: "https://gitea.internal/jens/unraid-appops-gateway", + cloneUrl: "https://gitea.internal/jens/unraid-appops-gateway.git", + sshUrl: "git@gitea.internal:jens/unraid-appops-gateway.git", + updatedAt: now, + localPath: "C:\\Development\\Unraid-AppOps-Gateway", + localStatus: makeStatus({ + head: "f2d1e0a1bb6147fc8b6633d2b08500c93402a719", + ahead: 2, + }), + linkState: "linked", + deploymentProfiles: [ + profile("profile-appops", "Production", "production", { + liveSha: "8ac731b1bb6147fc8b6633d2b08500c93402a719", + previousSha: "7bc198a1bb6147fc8b6633d2b08500c93402a719", + healthy: true, + }), + ], }, { - id: 4, name: 'support-bundle-collector', fullName: 'jens/support-bundle-collector', owner: { login: 'jens' }, description: 'Privacy-aware Windows support bundle collector.', private: true, defaultBranch: 'main', - htmlUrl: 'https://gitea.internal/jens/support-bundle-collector', cloneUrl: 'https://gitea.internal/jens/support-bundle-collector.git', sshUrl: 'git@gitea.internal:jens/support-bundle-collector.git', updatedAt: now, - localPath: null, localStatus: null, linkState: 'remote-only', deploymentProfiles: [] + id: 4, + name: "support-bundle-collector", + fullName: "jens/support-bundle-collector", + owner: { login: "jens" }, + description: "Privacy-aware Windows support bundle collector.", + private: true, + defaultBranch: "main", + htmlUrl: "https://gitea.internal/jens/support-bundle-collector", + cloneUrl: "https://gitea.internal/jens/support-bundle-collector.git", + sshUrl: "git@gitea.internal:jens/support-bundle-collector.git", + updatedAt: now, + localPath: null, + localStatus: null, + linkState: "remote-only", + deploymentProfiles: [], }, { - id: 5, name: 'portfolio', fullName: 'jens/portfolio', owner: { login: 'jens' }, description: 'Professional infrastructure and automation portfolio.', private: false, defaultBranch: 'main', - htmlUrl: 'https://gitea.internal/jens/portfolio', cloneUrl: 'https://gitea.internal/jens/portfolio.git', sshUrl: 'git@gitea.internal:jens/portfolio.git', updatedAt: now, - localPath: 'C:\\Development\\portfolio', localStatus: makeStatus({ head: 'a7f2e1c1bb6147fc8b6633d2b08500c93402a719', behind: 1 }), linkState: 'linked', - deploymentProfiles: [profile('profile-portfolio', 'Production', 'production', { liveSha: '4c20dd11bb6147fc8b6633d2b08500c93402a719', previousSha: '31adfe11bb6147fc8b6633d2b08500c93402a719', healthy: false })] - } + id: 5, + name: "portfolio", + fullName: "jens/portfolio", + owner: { login: "jens" }, + description: "Professional infrastructure and automation portfolio.", + private: false, + defaultBranch: "main", + htmlUrl: "https://gitea.internal/jens/portfolio", + cloneUrl: "https://gitea.internal/jens/portfolio.git", + sshUrl: "git@gitea.internal:jens/portfolio.git", + updatedAt: now, + localPath: "C:\\Development\\portfolio", + localStatus: makeStatus({ + head: "a7f2e1c1bb6147fc8b6633d2b08500c93402a719", + behind: 1, + }), + linkState: "linked", + deploymentProfiles: [ + profile("profile-portfolio", "Production", "production", { + liveSha: "4c20dd11bb6147fc8b6633d2b08500c93402a719", + previousSha: "31adfe11bb6147fc8b6633d2b08500c93402a719", + healthy: false, + }), + ], + }, ]; const diffs = { - 'src/api/deploy.ts': `diff --git a/src/api/deploy.ts b/src/api/deploy.ts\nnew file mode 100644\n--- /dev/null\n+++ b/src/api/deploy.ts\n@@ -0,0 +1,18 @@\n+export interface DeploymentRequest {\n+ environment: 'staging' | 'production';\n+ commitSha: string;\n+}\n+\n+export async function deploy(request: DeploymentRequest) {\n+ return api.post('/deployments', request);\n+}`, - 'src/main.tsx': `diff --git a/src/main.tsx b/src/main.tsx\nindex 45ad1a2..939fc17 100644\n--- a/src/main.tsx\n+++ b/src/main.tsx\n@@ -24,8 +24,9 @@ import { Router } from './routes';\n-const API_ENDPOINT = 'http://localhost:3000';\n+const API_ENDPOINT = process.env.VITE_API_URL || '/api';\n+const DEPLOY_VERSION = '1.0.4-rc1';`, - 'src/components/Sidebar.tsx': `diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx\nindex a7bbd82..bf21e90 100644\n--- a/src/components/Sidebar.tsx\n+++ b/src/components/Sidebar.tsx\n@@ -31,6 +31,7 @@ export function Sidebar() {\n+ Deployments` + "src/api/deploy.ts": `diff --git a/src/api/deploy.ts b/src/api/deploy.ts\nnew file mode 100644\n--- /dev/null\n+++ b/src/api/deploy.ts\n@@ -0,0 +1,18 @@\n+export interface DeploymentRequest {\n+ environment: 'staging' | 'production';\n+ commitSha: string;\n+}\n+\n+export async function deploy(request: DeploymentRequest) {\n+ return api.post('/deployments', request);\n+}`, + "src/main.tsx": `diff --git a/src/main.tsx b/src/main.tsx\nindex 45ad1a2..939fc17 100644\n--- a/src/main.tsx\n+++ b/src/main.tsx\n@@ -24,8 +24,9 @@ import { Router } from './routes';\n-const API_ENDPOINT = 'http://localhost:3000';\n+const API_ENDPOINT = process.env.VITE_API_URL || '/api';\n+const DEPLOY_VERSION = '1.0.4-rc1';`, + "src/components/Sidebar.tsx": `diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx\nindex a7bbd82..bf21e90 100644\n--- a/src/components/Sidebar.tsx\n+++ b/src/components/Sidebar.tsx\n@@ -31,6 +31,7 @@ export function Sidebar() {\n+ Deployments`, }; - const findRepo = (localPath) => repositories.find((item) => item.localPath === localPath); - const findProfileRepo = (profileId) => repositories.find((item) => item.deploymentProfiles.some((entry) => entry.id === profileId)); + const findRepo = (localPath) => + repositories.find((item) => item.localPath === localPath); + const findProfileRepo = (profileId) => + repositories.find((item) => + item.deploymentProfiles.some((entry) => entry.id === profileId), + ); const syncState = () => { state.deploymentProfiles = {}; state.deploymentStates = {}; state.repositoryMappings = {}; for (const repository of repositories) { - if (repository.localPath) state.repositoryMappings[repository.fullName.toLowerCase()] = repository.localPath; - state.deploymentProfiles[repository.fullName.toLowerCase()] = repository.deploymentProfiles.map(({ state: profileState, ...entry }) => entry); - for (const entry of repository.deploymentProfiles) if (entry.state) state.deploymentStates[entry.id] = clone(entry.state); + if (repository.localPath) + state.repositoryMappings[repository.fullName.toLowerCase()] = + repository.localPath; + state.deploymentProfiles[repository.fullName.toLowerCase()] = + repository.deploymentProfiles.map( + ({ state: profileState, ...entry }) => entry, + ); + for (const entry of repository.deploymentProfiles) + if (entry.state) state.deploymentStates[entry.id] = clone(entry.state); } }; const recompute = (repository) => { @@ -190,53 +405,148 @@ staged: status.files.filter((item) => item.staged).length, unstaged: status.files.filter((item) => item.unstaged).length, conflicts: status.files.filter((item) => item.conflict).length, - untracked: status.files.filter((item) => item.untracked).length + untracked: status.files.filter((item) => item.untracked).length, }; status.clean = status.files.length === 0; status.shortHead = status.head.slice(0, 7); status.branch.oid = status.head; } - repository.favorite = state.favorites.includes(repository.fullName.toLowerCase()); - repository.readyToDeploy = Boolean(repository.localPath && status?.clean && status.branch.upstream && status.branch.ahead === 0 && status.branch.behind === 0 && repository.deploymentProfiles.some((entry) => entry.branch === status.branch.head)); - repository.attention = !repository.localPath || Boolean(status?.counts.conflicts || status?.branch.behind || status?.branch.ahead || status?.counts.changed); - repository.attentionReason = !repository.localPath ? 'No local folder linked' : status?.counts.conflicts ? `${status.counts.conflicts} conflict(s)` : status?.counts.changed ? `${status.counts.changed} local change(s)` : status?.branch.behind ? `${status.branch.behind} commit(s) behind remote` : status?.branch.ahead ? `${status.branch.ahead} unpushed commit(s)` : null; - repository.preferredCloneUrl = state.preferences.preferredCloneProtocol === 'ssh' ? repository.sshUrl : repository.cloneUrl; + repository.favorite = state.favorites.includes( + repository.fullName.toLowerCase(), + ); + repository.readyToDeploy = Boolean( + repository.localPath && + status?.clean && + status.branch.upstream && + status.branch.ahead === 0 && + status.branch.behind === 0 && + repository.deploymentProfiles.some( + (entry) => entry.branch === status.branch.head, + ), + ); + repository.attention = + !repository.localPath || + Boolean( + status?.counts.conflicts || + status?.branch.behind || + status?.branch.ahead || + status?.counts.changed, + ); + repository.attentionReason = !repository.localPath + ? "No local folder linked" + : status?.counts.conflicts + ? `${status.counts.conflicts} conflict(s)` + : status?.counts.changed + ? `${status.counts.changed} local change(s)` + : status?.branch.behind + ? `${status.branch.behind} commit(s) behind remote` + : status?.branch.ahead + ? `${status.branch.ahead} unpushed commit(s)` + : null; + repository.preferredCloneUrl = + state.preferences.preferredCloneProtocol === "ssh" + ? repository.sshUrl + : repository.cloneUrl; + }; + const snapshot = () => { + repositories.forEach(recompute); + syncState(); + return clone(repositories); }; - const snapshot = () => { repositories.forEach(recompute); syncState(); return clone(repositories); }; syncState(); const commitHistory = [ - { sha: 'c9182d0d28318c8cf0af109edc054732426aadf1', shortSha: 'c9182d0', author: 'Jens', date: now, subject: 'feat: add deployment provider contract' }, - { sha: '1fa7399d28318c8cf0af109edc054732426aadf1', shortSha: '1fa7399', author: 'Jens', date: iso(-86400000), subject: 'refactor: consolidate repository state' }, - { sha: 'a251a11d28318c8cf0af109edc054732426aadf1', shortSha: 'a251a11', author: 'Jens', date: iso(-172800000), subject: 'docs: define deployment safety gates' } + { + sha: "c9182d0d28318c8cf0af109edc054732426aadf1", + shortSha: "c9182d0", + author: "Jens", + date: now, + subject: "feat: add deployment provider contract", + }, + { + sha: "1fa7399d28318c8cf0af109edc054732426aadf1", + shortSha: "1fa7399", + author: "Jens", + date: iso(-86400000), + subject: "refactor: consolidate repository state", + }, + { + sha: "a251a11d28318c8cf0af109edc054732426aadf1", + shortSha: "a251a11", + author: "Jens", + date: iso(-172800000), + subject: "docs: define deployment safety gates", + }, ]; const branchesByRepo = new Map(); const stashesByRepo = new Map(); function updateOperation(operation) { - state.operations = [clone(operation), ...state.operations.filter((item) => item.id !== operation.id)].slice(0, 250); + state.operations = [ + clone(operation), + ...state.operations.filter((item) => item.id !== operation.id), + ].slice(0, 250); emitOperations([operation]); return clone(operation); } function advanceOperation(operation) { - if (!operation || ['success', 'failed', 'cancelled', 'rolled-back'].includes(operation.status)) return operation; + if ( + !operation || + ["success", "failed", "cancelled", "rolled-back"].includes( + operation.status, + ) + ) + return operation; operation.demoPolls = (operation.demoPolls || 0) + 1; if (operation.demoPolls === 1) { - operation.status = 'running'; - operation.run = { id: 81, runNumber: 81, status: 'running', conclusion: null, name: operation.action === 'rollback' ? 'ForgeFlow rollback' : 'ForgeFlow deployment' }; + operation.status = "running"; + operation.run = { + id: 81, + runNumber: 81, + status: "running", + conclusion: null, + name: + operation.action === "rollback" + ? "ForgeFlow rollback" + : "ForgeFlow deployment", + }; operation.runUrl = `https://gitea.internal/${operation.repository}/actions/runs/81`; - operation.stages.find((item) => item.id === 'queued').status = 'complete'; - operation.stages.find((item) => item.id === 'runner').status = 'active'; - operation.jobs = [{ id: 201, name: operation.action === 'rollback' ? 'rollback' : 'deploy', status: 'running', conclusion: null }]; + operation.stages.find((item) => item.id === "queued").status = "complete"; + operation.stages.find((item) => item.id === "runner").status = "active"; + operation.jobs = [ + { + id: 201, + name: operation.action === "rollback" ? "rollback" : "deploy", + status: "running", + conclusion: null, + }, + ]; operation.logs.push(`[job] ${operation.jobs[0].name}: running`); } else if (operation.demoPolls >= 2) { - operation.status = operation.action === 'rollback' ? 'rolled-back' : 'success'; - operation.stages.forEach((item) => { item.status = 'complete'; }); - operation.jobs = [{ id: 201, name: operation.action === 'rollback' ? 'rollback' : 'deploy', status: 'completed', conclusion: 'success' }]; - operation.logs.push('[ok] Runner completed successfully.', `[ok] Server status endpoint confirms ${operation.shortSha}.`); - const repository = repositories.find((item) => item.fullName === operation.repository); - const targetProfile = repository?.deploymentProfiles.find((item) => item.id === operation.profileId); + operation.status = + operation.action === "rollback" ? "rolled-back" : "success"; + operation.stages.forEach((item) => { + item.status = "complete"; + }); + operation.jobs = [ + { + id: 201, + name: operation.action === "rollback" ? "rollback" : "deploy", + status: "completed", + conclusion: "success", + }, + ]; + operation.logs.push( + "[ok] Runner completed successfully.", + `[ok] Server status endpoint confirms ${operation.shortSha}.`, + ); + const repository = repositories.find( + (item) => item.fullName === operation.repository, + ); + const targetProfile = repository?.deploymentProfiles.find( + (item) => item.id === operation.profileId, + ); if (targetProfile) { const oldLive = targetProfile.state.liveSha; targetProfile.state.previousSha = oldLive; @@ -250,105 +560,980 @@ } window.forgeflow = Object.freeze({ - async bootstrap() { await wait(80); snapshot(); return { appVersion: '0.5.1-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 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 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 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') { + async bootstrap() { + await wait(80); + snapshot(); + return { + appVersion: "0.8.1-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]; + 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 target = `${root.replace(/[\\/]+$/, "")}\\${repository.name}`; const head = randomSha(); repository.localPath = target; - repository.localStatus = makeStatus({ head, branch: repository.defaultBranch || 'main' }); + repository.localStatus = makeStatus({ + head, + branch: repository.defaultBranch || "main", + }); repository.localStatus.root = target; - repository.localStatus.remoteUrl = repository.preferredCloneUrl || repository.cloneUrl; - repository.linkState = 'linked'; + 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) }; + 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: "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 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 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); }, - async openPath() { return true; }, - 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: '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 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 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 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/styles.css b/src/renderer/styles.css index 3289e3f..4ad498e 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -13,19 +13,21 @@ --text-faint: #6f7a8d; --primary: #8fb4ff; --primary-strong: #5b8ff9; - --primary-soft: rgba(91, 143, 249, .15); + --primary-soft: rgba(91, 143, 249, 0.15); --success: #54ddb0; - --success-soft: rgba(84, 221, 176, .12); + --success-soft: rgba(84, 221, 176, 0.12); --warning: #f2ba63; - --warning-soft: rgba(242, 186, 99, .13); + --warning-soft: rgba(242, 186, 99, 0.13); --danger: #ff817a; - --danger-soft: rgba(255, 129, 122, .13); - --shadow: 0 18px 70px rgba(0,0,0,.32); + --danger-soft: rgba(255, 129, 122, 0.13); + --shadow: 0 18px 70px rgba(0, 0, 0, 0.32); --radius: 7px; --sidebar: 286px; --action-panel: 352px; - --font-ui: Inter, "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif; - --font-mono: "Cascadia Code", "SFMono-Regular", Consolas, "Liberation Mono", monospace; + --font-ui: + Inter, "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + --font-mono: + "Cascadia Code", "SFMono-Regular", Consolas, "Liberation Mono", monospace; } html[data-theme="light"] { @@ -43,525 +45,3046 @@ html[data-theme="light"] { --text-faint: #7b879a; --primary: #295fca; --primary-strong: #326ee0; - --primary-soft: rgba(50, 110, 224, .10); + --primary-soft: rgba(50, 110, 224, 0.1); --success: #087a57; - --success-soft: rgba(8, 122, 87, .10); + --success-soft: rgba(8, 122, 87, 0.1); --warning: #9b5b00; - --warning-soft: rgba(155, 91, 0, .10); + --warning-soft: rgba(155, 91, 0, 0.1); --danger: #c73737; - --danger-soft: rgba(199, 55, 55, .10); - --shadow: 0 18px 70px rgba(43, 55, 77, .15); + --danger-soft: rgba(199, 55, 55, 0.1); + --shadow: 0 18px 70px rgba(43, 55, 77, 0.15); } -* { box-sizing: border-box; } -html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; } -body { background: var(--bg); color: var(--text); font-family: var(--font-ui); font-size: 13px; } -button, input, textarea, select { font: inherit; color: inherit; } -button { border: 0; } -button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; } -::selection { background: rgba(91, 143, 249, .35); } -::-webkit-scrollbar { width: 9px; height: 9px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--text-faint) 38%, transparent); border: 3px solid transparent; background-clip: padding-box; border-radius: 20px; } +* { + box-sizing: border-box; +} +html, +body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; +} +body { + background: var(--bg); + color: var(--text); + font-family: var(--font-ui); + font-size: 13px; +} +button, +input, +textarea, +select { + font: inherit; + color: inherit; +} +button { + border: 0; +} +button:focus-visible, +input:focus-visible, +textarea:focus-visible, +select:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 1px; +} +::selection { + background: rgba(91, 143, 249, 0.35); +} +::-webkit-scrollbar { + width: 9px; + height: 9px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--text-faint) 38%, transparent); + border: 3px solid transparent; + background-clip: padding-box; + border-radius: 20px; +} -.boot-screen { height: 100vh; display: grid; place-content: center; justify-items: center; gap: 10px; color: var(--text-muted); } -.boot-screen strong { color: var(--text); font-size: 16px; } -.boot-brand-logo { width: 72px; height: 56px; object-fit: contain; filter: drop-shadow(0 10px 28px rgba(0,174,255,.24)); } -.brand-mark { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 10px; background: linear-gradient(145deg, var(--primary), var(--primary-strong)); color: #07152e; font-weight: 800; font-size: 20px; box-shadow: 0 8px 30px rgba(91,143,249,.25); } +.boot-screen { + height: 100vh; + display: grid; + place-content: center; + justify-items: center; + gap: 10px; + color: var(--text-muted); +} +.boot-screen strong { + color: var(--text); + font-size: 16px; +} +.boot-brand-logo { + width: 72px; + height: 56px; + object-fit: contain; + filter: drop-shadow(0 10px 28px rgba(0, 174, 255, 0.24)); +} +.brand-mark { + width: 40px; + height: 40px; + display: grid; + place-items: center; + border-radius: 10px; + background: linear-gradient(145deg, var(--primary), var(--primary-strong)); + color: #07152e; + font-weight: 800; + font-size: 20px; + box-shadow: 0 8px 30px rgba(91, 143, 249, 0.25); +} -.app-shell { height: 100vh; display: grid; grid-template-rows: 48px minmax(0,1fr) 25px; background: var(--bg); } -.titlebar { display: flex; align-items: center; justify-content: space-between; padding: 0 10px 0 14px; border-bottom: 1px solid var(--line); background: var(--surface-1); -webkit-app-region: drag; } -.titlebar-left, .titlebar-right { display: flex; align-items: center; gap: 9px; min-width: 0; } -.titlebar button, .titlebar input { -webkit-app-region: no-drag; } -.wordmark { display: flex; align-items: center; gap: 9px; font-size: 15px; font-weight: 720; letter-spacing: -.02em; } -.wordmark .brand-mark { width: 25px; height: 25px; border-radius: 6px; font-size: 13px; box-shadow: none; } -.brand-logo { width: 32px; height: 25px; object-fit: contain; display: block; } -.wordmark small { color: var(--text-faint); font-size: 9px; font-weight: 620; letter-spacing: .01em; margin-left: -4px; } -.workspace-name { color: var(--text-muted); border-left: 1px solid var(--line); padding-left: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 280px; } -.connection-chip { display: inline-flex; align-items: center; gap: 6px; color: var(--text-muted); font-size: 11px; font-weight: 650; padding: 5px 8px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface-0); } -.connection-chip .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 3px var(--success-soft); } -.global-search { width: clamp(180px, 23vw, 330px); height: 30px; padding: 0 10px 0 31px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-0); color: var(--text); } -.search-wrap { position: relative; } -.search-wrap .icon { position: absolute; left: 9px; top: 7px; color: var(--text-faint); pointer-events: none; } +.app-shell { + height: 100vh; + display: grid; + grid-template-rows: 48px minmax(0, 1fr) 25px; + background: var(--bg); +} +.titlebar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 10px 0 14px; + border-bottom: 1px solid var(--line); + background: var(--surface-1); + -webkit-app-region: drag; +} +.titlebar-left, +.titlebar-right { + display: flex; + align-items: center; + gap: 9px; + min-width: 0; +} +.titlebar button, +.titlebar input { + -webkit-app-region: no-drag; +} +.wordmark { + display: flex; + align-items: center; + gap: 9px; + font-size: 15px; + font-weight: 720; + letter-spacing: -0.02em; +} +.wordmark .brand-mark { + width: 25px; + height: 25px; + border-radius: 6px; + font-size: 13px; + box-shadow: none; +} +.brand-logo { + width: 32px; + height: 25px; + object-fit: contain; + display: block; +} +.wordmark small { + color: var(--text-faint); + font-size: 9px; + font-weight: 620; + letter-spacing: 0.01em; + margin-left: -4px; +} +.workspace-name { + color: var(--text-muted); + border-left: 1px solid var(--line); + padding-left: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 280px; +} +.connection-chip { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text-muted); + font-size: 11px; + font-weight: 650; + padding: 5px 8px; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--surface-0); +} +.connection-chip .dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--success); + box-shadow: 0 0 0 3px var(--success-soft); +} +.global-search { + width: clamp(180px, 23vw, 330px); + height: 30px; + padding: 0 10px 0 31px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface-0); + color: var(--text); +} +.search-wrap { + position: relative; +} +.search-wrap .icon { + position: absolute; + left: 9px; + top: 7px; + color: var(--text-faint); + pointer-events: none; +} -.app-body { display: grid; grid-template-columns: var(--sidebar) minmax(0,1fr); min-height: 0; } -.sidebar { min-width: 0; display: flex; flex-direction: column; border-right: 1px solid var(--line); background: var(--surface-1); overflow: hidden; } -.primary-nav { padding: 10px 8px 8px; border-bottom: 1px solid var(--line-soft); } -.nav-button { width: 100%; height: 34px; display: flex; align-items: center; gap: 10px; padding: 0 10px; border-radius: 5px; background: transparent; color: var(--text-muted); cursor: pointer; text-align: left; font-weight: 560; } -.nav-button:hover { background: var(--surface-hover); color: var(--text); } -.nav-button.active { color: var(--primary); background: var(--primary-soft); } -.nav-button .nav-count { margin-left: auto; min-width: 20px; text-align: center; font-family: var(--font-mono); color: var(--text-faint); font-size: 10px; } -.sidebar-section { display: flex; align-items: center; justify-content: space-between; padding: 13px 12px 7px; color: var(--text-faint); font-size: 10px; font-weight: 750; letter-spacing: .09em; text-transform: uppercase; } -.sidebar-section button { background: none; color: inherit; cursor: pointer; padding: 2px; } -.repo-filter { margin: 0 9px 8px; width: calc(100% - 18px); height: 29px; border: 1px solid var(--line-soft); border-radius: 5px; background: var(--surface-0); padding: 0 9px; } -.repo-list { min-height: 0; overflow: auto; padding: 0 6px 10px; } -.repo-row { width: 100%; display: grid; grid-template-columns: 18px minmax(0,1fr) auto; gap: 8px; align-items: center; min-height: 44px; padding: 6px 8px; background: transparent; border-radius: 5px; color: var(--text-muted); cursor: pointer; text-align: left; border: 1px solid transparent; } -.repo-row:hover { background: var(--surface-hover); color: var(--text); } -.repo-row.active { background: var(--primary-soft); border-color: color-mix(in srgb, var(--primary) 26%, transparent); color: var(--text); } -.repo-row.attention .repo-icon { color: var(--warning); } -.repo-main { min-width: 0; } -.repo-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 620; } -.repo-sub { display: flex; gap: 6px; margin-top: 3px; color: var(--text-faint); font-family: var(--font-mono); font-size: 10px; overflow: hidden; } -.repo-sub span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.repo-badges { display: flex; gap: 3px; align-items: center; } -.mini-badge { min-width: 18px; height: 18px; display: inline-grid; place-items: center; padding: 0 5px; border-radius: 9px; font-family: var(--font-mono); font-size: 9px; font-weight: 700; color: var(--text-muted); background: var(--surface-3); } -.mini-badge.warning { background: var(--warning-soft); color: var(--warning); } -.mini-badge.success { background: var(--success-soft); color: var(--success); } -.mini-badge.danger { background: var(--danger-soft); color: var(--danger); } -.sidebar-footer { margin-top: auto; border-top: 1px solid var(--line-soft); padding: 10px 11px; } -.sidebar-diagnostic-state { display: grid; grid-template-columns: 9px minmax(0,1fr); gap: 9px; align-items: start; color: var(--text-muted); } -.sidebar-diagnostic-state .state-dot { margin-top: 4px; } -.sidebar-diagnostic-state strong, .sidebar-diagnostic-state span { display: block; } -.sidebar-diagnostic-state strong { color: var(--text); font-size: 10px; font-weight: 650; } -.sidebar-diagnostic-state span { margin-top: 2px; color: var(--text-faint); font-size: 9px; line-height: 1.35; } +.app-body { + display: grid; + grid-template-columns: var(--sidebar) minmax(0, 1fr); + min-height: 0; +} +.sidebar { + min-width: 0; + display: flex; + flex-direction: column; + border-right: 1px solid var(--line); + background: var(--surface-1); + overflow: hidden; +} +.primary-nav { + padding: 10px 8px 8px; + border-bottom: 1px solid var(--line-soft); +} +.nav-button { + width: 100%; + height: 34px; + display: flex; + align-items: center; + gap: 10px; + padding: 0 10px; + border-radius: 5px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + text-align: left; + font-weight: 560; +} +.nav-button:hover { + background: var(--surface-hover); + color: var(--text); +} +.nav-button.active { + color: var(--primary); + background: var(--primary-soft); +} +.nav-button .nav-count { + margin-left: auto; + min-width: 20px; + text-align: center; + font-family: var(--font-mono); + color: var(--text-faint); + font-size: 10px; +} +.sidebar-section { + display: flex; + align-items: center; + justify-content: space-between; + padding: 13px 12px 7px; + color: var(--text-faint); + font-size: 10px; + font-weight: 750; + letter-spacing: 0.09em; + text-transform: uppercase; +} +.sidebar-section button { + background: none; + color: inherit; + cursor: pointer; + padding: 2px; +} +.repo-filter { + margin: 0 9px 8px; + width: calc(100% - 18px); + height: 29px; + border: 1px solid var(--line-soft); + border-radius: 5px; + background: var(--surface-0); + padding: 0 9px; +} +.repo-list { + min-height: 0; + overflow: auto; + padding: 0 6px 10px; +} +.repo-row { + width: 100%; + display: grid; + grid-template-columns: 18px minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + min-height: 44px; + padding: 6px 8px; + background: transparent; + border-radius: 5px; + color: var(--text-muted); + cursor: pointer; + text-align: left; + border: 1px solid transparent; +} +.repo-row:hover { + background: var(--surface-hover); + color: var(--text); +} +.repo-row.active { + background: var(--primary-soft); + border-color: color-mix(in srgb, var(--primary) 26%, transparent); + color: var(--text); +} +.repo-row.attention .repo-icon { + color: var(--warning); +} +.repo-main { + min-width: 0; +} +.repo-name { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 620; +} +.repo-sub { + display: flex; + gap: 6px; + margin-top: 3px; + color: var(--text-faint); + font-family: var(--font-mono); + font-size: 10px; + overflow: hidden; +} +.repo-sub span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.repo-badges { + display: flex; + gap: 3px; + align-items: center; +} +.mini-badge { + min-width: 18px; + height: 18px; + display: inline-grid; + place-items: center; + padding: 0 5px; + border-radius: 9px; + font-family: var(--font-mono); + font-size: 9px; + font-weight: 700; + color: var(--text-muted); + background: var(--surface-3); +} +.mini-badge.warning { + background: var(--warning-soft); + color: var(--warning); +} +.mini-badge.success { + background: var(--success-soft); + color: var(--success); +} +.mini-badge.danger { + background: var(--danger-soft); + color: var(--danger); +} +.sidebar-footer { + margin-top: auto; + border-top: 1px solid var(--line-soft); + padding: 10px 11px; +} +.sidebar-diagnostic-state { + display: grid; + grid-template-columns: 9px minmax(0, 1fr); + gap: 9px; + align-items: start; + color: var(--text-muted); +} +.sidebar-diagnostic-state .state-dot { + margin-top: 4px; +} +.sidebar-diagnostic-state strong, +.sidebar-diagnostic-state span { + display: block; +} +.sidebar-diagnostic-state strong { + color: var(--text); + font-size: 10px; + font-weight: 650; +} +.sidebar-diagnostic-state span { + margin-top: 2px; + color: var(--text-faint); + font-size: 9px; + line-height: 1.35; +} -.workspace { min-width: 0; min-height: 0; display: grid; background: var(--surface-0); } -.workspace.with-panel { grid-template-columns: minmax(0,1fr) var(--action-panel); } -.main-canvas { min-width: 0; min-height: 0; overflow: auto; } -.main-canvas.repository-canvas { overflow: hidden; height: 100%; } -.action-panel { min-width: 0; border-left: 1px solid var(--line); background: var(--surface-1); overflow: auto; } -.page { min-height: 100%; padding: 22px 24px 40px; } -.page.nopad { padding: 0; } -.page-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; margin-bottom: 22px; } -.page-header h1, .repo-heading h1 { margin: 0; font-size: 20px; line-height: 1.3; letter-spacing: -.025em; } -.page-header p, .repo-heading p { margin: 5px 0 0; color: var(--text-muted); max-width: 720px; } -.eyebrow { color: var(--text-faint); font-size: 10px; font-weight: 760; letter-spacing: .09em; text-transform: uppercase; } +.workspace { + min-width: 0; + min-height: 0; + display: grid; + background: var(--surface-0); +} +.workspace.with-panel { + grid-template-columns: minmax(0, 1fr) var(--action-panel); +} +.main-canvas { + min-width: 0; + min-height: 0; + overflow: auto; +} +.main-canvas.repository-canvas { + overflow: hidden; + height: 100%; +} +.action-panel { + min-width: 0; + border-left: 1px solid var(--line); + background: var(--surface-1); + overflow: auto; +} +.page { + min-height: 100%; + padding: 22px 24px 40px; +} +.page.nopad { + padding: 0; +} +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 20px; + margin-bottom: 22px; +} +.page-header h1, +.repo-heading h1 { + margin: 0; + font-size: 20px; + line-height: 1.3; + letter-spacing: -0.025em; +} +.page-header p, +.repo-heading p { + margin: 5px 0 0; + color: var(--text-muted); + max-width: 720px; +} +.eyebrow { + color: var(--text-faint); + font-size: 10px; + font-weight: 760; + letter-spacing: 0.09em; + text-transform: uppercase; +} -.button { min-height: 32px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; padding: 0 11px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface-2); color: var(--text); cursor: pointer; font-weight: 620; white-space: nowrap; } -.button:hover { background: var(--surface-3); } -.button.primary { background: var(--primary-strong); border-color: var(--primary-strong); color: #fff; } -.button.primary:hover { filter: brightness(1.07); } -.button.success { background: var(--success); border-color: var(--success); color: #06251b; } -.button.danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, var(--line)); background: var(--danger-soft); } -.button.ghost { background: transparent; border-color: transparent; color: var(--text-muted); } -.button.ghost:hover { color: var(--text); background: var(--surface-hover); } -.button.block { width: 100%; min-height: 38px; } -.button:disabled { opacity: .45; cursor: not-allowed; } -.icon-button { width: 30px; height: 30px; display: inline-grid; place-items: center; border-radius: 5px; border: 1px solid transparent; background: transparent; color: var(--text-muted); cursor: pointer; } -.icon-button:hover { color: var(--text); background: var(--surface-hover); border-color: var(--line-soft); } -.icon { width: 16px; height: 16px; display: inline-block; flex: 0 0 auto; } -.icon svg { width: 100%; height: 100%; display: block; stroke: currentColor; fill: none; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +.button { + min-height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 0 11px; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--surface-2); + color: var(--text); + cursor: pointer; + font-weight: 620; + white-space: nowrap; +} +.button:hover { + background: var(--surface-3); +} +.button.primary { + background: var(--primary-strong); + border-color: var(--primary-strong); + color: #fff; +} +.button.primary:hover { + filter: brightness(1.07); +} +.button.success { + background: var(--success); + border-color: var(--success); + color: #06251b; +} +.button.danger { + color: var(--danger); + border-color: color-mix(in srgb, var(--danger) 45%, var(--line)); + background: var(--danger-soft); +} +.button.ghost { + background: transparent; + border-color: transparent; + color: var(--text-muted); +} +.button.ghost:hover { + color: var(--text); + background: var(--surface-hover); +} +.button.block { + width: 100%; + min-height: 38px; +} +.button:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.icon-button { + width: 30px; + height: 30px; + display: inline-grid; + place-items: center; + border-radius: 5px; + border: 1px solid transparent; + background: transparent; + color: var(--text-muted); + cursor: pointer; +} +.icon-button:hover { + color: var(--text); + background: var(--surface-hover); + border-color: var(--line-soft); +} +.icon { + width: 16px; + height: 16px; + display: inline-block; + flex: 0 0 auto; +} +.icon svg { + width: 100%; + height: 100%; + display: block; + stroke: currentColor; + fill: none; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} -.summary-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; background: var(--surface-1); } -.summary-card { min-height: 118px; padding: 15px; border-right: 1px solid var(--line); position: relative; } -.summary-card:last-child { border-right: 0; } -.summary-value { margin-top: 18px; font-size: 28px; font-weight: 720; letter-spacing: -.04em; } -.summary-label { color: var(--text-muted); margin-top: 2px; } -.summary-card .icon { position: absolute; top: 14px; right: 14px; color: var(--text-faint); } -.summary-card.warning .summary-value, .summary-card.warning .icon { color: var(--warning); } -.summary-card.success .summary-value, .summary-card.success .icon { color: var(--success); } -.summary-card.danger .summary-value, .summary-card.danger .icon { color: var(--danger); } +.summary-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + border: 1px solid var(--line); + border-radius: var(--radius); + overflow: hidden; + background: var(--surface-1); +} +.summary-card { + min-height: 118px; + padding: 15px; + border-right: 1px solid var(--line); + position: relative; +} +.summary-card:last-child { + border-right: 0; +} +.summary-value { + margin-top: 18px; + font-size: 28px; + font-weight: 720; + letter-spacing: -0.04em; +} +.summary-label { + color: var(--text-muted); + margin-top: 2px; +} +.summary-card .icon { + position: absolute; + top: 14px; + right: 14px; + color: var(--text-faint); +} +.summary-card.warning .summary-value, +.summary-card.warning .icon { + color: var(--warning); +} +.summary-card.success .summary-value, +.summary-card.success .icon { + color: var(--success); +} +.summary-card.danger .summary-value, +.summary-card.danger .icon { + color: var(--danger); +} -.section-block { margin-top: 24px; } -.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 9px; } -.section-heading h2 { margin: 0; font-size: 13px; letter-spacing: -.01em; } -.section-heading .meta { color: var(--text-faint); font-size: 11px; } -.action-queue { border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; background: var(--surface-1); } -.queue-row { display: grid; grid-template-columns: 28px minmax(160px,1.2fr) minmax(230px,2fr) auto; gap: 12px; align-items: center; min-height: 62px; padding: 10px 13px; border-bottom: 1px solid var(--line-soft); } -.queue-row:last-child { border-bottom: 0; } -.queue-row:hover { background: var(--surface-hover); } -.queue-icon { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 6px; background: var(--surface-2); color: var(--text-muted); } -.queue-icon.warning { background: var(--warning-soft); color: var(--warning); } -.queue-icon.success { background: var(--success-soft); color: var(--success); } -.queue-icon.danger { background: var(--danger-soft); color: var(--danger); } -.queue-title { font-weight: 640; } -.queue-sub { color: var(--text-faint); margin-top: 3px; font-size: 11px; font-family: var(--font-mono); } -.queue-reason { color: var(--text-muted); } -.queue-reason strong { color: var(--text); display: block; font-weight: 600; } +.section-block { + margin-top: 24px; +} +.section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 9px; +} +.section-heading h2 { + margin: 0; + font-size: 13px; + letter-spacing: -0.01em; +} +.section-heading .meta { + color: var(--text-faint); + font-size: 11px; +} +.action-queue { + border: 1px solid var(--line); + border-radius: var(--radius); + overflow: hidden; + background: var(--surface-1); +} +.queue-row { + display: grid; + grid-template-columns: 28px minmax(160px, 1.2fr) minmax(230px, 2fr) auto; + gap: 12px; + align-items: center; + min-height: 62px; + padding: 10px 13px; + border-bottom: 1px solid var(--line-soft); +} +.queue-row:last-child { + border-bottom: 0; +} +.queue-row:hover { + background: var(--surface-hover); +} +.queue-icon { + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 6px; + background: var(--surface-2); + color: var(--text-muted); +} +.queue-icon.warning { + background: var(--warning-soft); + color: var(--warning); +} +.queue-icon.success { + background: var(--success-soft); + color: var(--success); +} +.queue-icon.danger { + background: var(--danger-soft); + color: var(--danger); +} +.queue-title { + font-weight: 640; +} +.queue-sub { + color: var(--text-faint); + margin-top: 3px; + font-size: 11px; + font-family: var(--font-mono); +} +.queue-reason { + color: var(--text-muted); +} +.queue-reason strong { + color: var(--text); + display: block; + font-weight: 600; +} -.two-column { display: grid; grid-template-columns: minmax(0,1.5fr) minmax(280px,1fr); gap: 16px; } -.panel { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-1); } -.panel-header { min-height: 42px; display: flex; align-items: center; justify-content: space-between; padding: 0 13px; border-bottom: 1px solid var(--line-soft); } -.panel-header h2, .panel-header h3 { font-size: 12px; margin: 0; } -.panel-body { padding: 14px; } -.activity-list { padding: 3px 0; } -.activity-item { display: grid; grid-template-columns: 10px minmax(0,1fr) auto; gap: 10px; padding: 10px 13px; align-items: start; } -.activity-dot { width: 7px; height: 7px; margin-top: 5px; border-radius: 50%; background: var(--text-faint); } -.activity-dot.success { background: var(--success); } -.activity-dot.warning { background: var(--warning); } -.activity-dot.danger { background: var(--danger); } -.activity-title { font-weight: 590; } -.activity-sub, .activity-time { color: var(--text-faint); font-size: 11px; } +.two-column { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr); + gap: 16px; +} +.panel { + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface-1); +} +.panel-header { + min-height: 42px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 13px; + border-bottom: 1px solid var(--line-soft); +} +.panel-header h2, +.panel-header h3 { + font-size: 12px; + margin: 0; +} +.panel-body { + padding: 14px; +} +.activity-list { + padding: 3px 0; +} +.activity-item { + display: grid; + grid-template-columns: 10px minmax(0, 1fr) auto; + gap: 10px; + padding: 10px 13px; + align-items: start; +} +.activity-dot { + width: 7px; + height: 7px; + margin-top: 5px; + border-radius: 50%; + background: var(--text-faint); +} +.activity-dot.success { + background: var(--success); +} +.activity-dot.warning { + background: var(--warning); +} +.activity-dot.danger { + background: var(--danger); +} +.activity-title { + font-weight: 590; +} +.activity-sub, +.activity-time { + color: var(--text-faint); + font-size: 11px; +} -.repo-workspace { height: 100%; min-height: 0; display: grid; grid-template-rows: auto auto 39px minmax(0,1fr); } -.repo-header { padding: 16px 18px 13px; background: var(--surface-1); border-bottom: 1px solid var(--line); display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } -.repo-heading { min-width: 0; } -.repo-heading h1 { display: flex; align-items: center; gap: 9px; font-size: 17px; } -.repo-heading p { font-family: var(--font-mono); font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.repo-header-actions { display: flex; gap: 7px; } -.release-rail { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); background: var(--surface-0); border-bottom: 1px solid var(--line); } -.release-node { min-width: 0; padding: 10px 16px 11px; border-right: 1px solid var(--line-soft); position: relative; } -.release-node:last-child { border-right: 0; } -.release-node:not(:last-child)::after { content: '›'; position: absolute; right: -6px; top: 19px; z-index: 2; width: 12px; height: 12px; display: grid; place-items: center; border-radius: 50%; background: var(--surface-0); color: var(--text-faint); } -.release-label { color: var(--text-faint); font-size: 9px; font-weight: 760; letter-spacing: .08em; text-transform: uppercase; } -.release-value { display: flex; align-items: center; gap: 7px; margin-top: 4px; min-width: 0; } -.release-value strong { font-family: var(--font-mono); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.release-value span { color: var(--text-muted); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.state-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-faint); flex: 0 0 auto; } -.state-dot.success { background: var(--success); box-shadow: 0 0 0 3px var(--success-soft); } -.state-dot.warning { background: var(--warning); box-shadow: 0 0 0 3px var(--warning-soft); } -.state-dot.danger { background: var(--danger); box-shadow: 0 0 0 3px var(--danger-soft); } -.tabs { display: flex; align-items: flex-end; gap: 2px; padding: 0 12px; border-bottom: 1px solid var(--line); background: var(--surface-1); } -.tab { height: 38px; padding: 0 12px; background: transparent; color: var(--text-muted); border-bottom: 2px solid transparent; cursor: pointer; } -.tab:hover { color: var(--text); } -.tab.active { color: var(--primary); border-bottom-color: var(--primary); } -.repo-content { min-height: 0; overflow: hidden; } -.changes-layout { height: 100%; min-height: 0; display: grid; grid-template-columns: 290px minmax(0,1fr); } -.file-panel { min-width: 0; min-height: 0; overflow: hidden; border-right: 1px solid var(--line); background: var(--surface-1); display: flex; flex-direction: column; } -.file-panel-tools { min-height: 38px; padding: 0 9px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line-soft); } -.file-list { flex: 1 1 auto; overflow-x: hidden; overflow-y: auto; min-height: 0; padding: 5px; overscroll-behavior: contain; scrollbar-gutter: stable; } -.file-row { width: 100%; min-height: 34px; display: grid; grid-template-columns: 17px 17px minmax(0,1fr) 16px; gap: 7px; align-items: center; padding: 3px 6px; border-radius: 4px; background: transparent; color: var(--text-muted); cursor: pointer; text-align: left; } -.file-row:hover { background: var(--surface-hover); color: var(--text); } -.file-row.active { background: var(--primary-soft); color: var(--text); } -.file-row input { margin: 0; accent-color: var(--primary-strong); } -.file-path { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: var(--font-mono); font-size: 11px; } -.file-status { font-family: var(--font-mono); font-size: 10px; font-weight: 750; color: var(--warning); } -.file-status.added, .file-status.untracked { color: var(--success); } -.file-status.deleted, .file-status.conflict { color: var(--danger); } -.diff-panel { min-width: 0; min-height: 0; display: grid; grid-template-rows: 38px minmax(0,1fr); background: var(--bg); } -.diff-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 0 11px; border-bottom: 1px solid var(--line-soft); background: var(--surface-0); } -.diff-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-mono); color: var(--text-muted); } -.diff-view { overflow: auto; padding: 8px 0 36px; font-family: var(--font-mono); font-size: 11px; line-height: 19px; white-space: pre; tab-size: 2; } -.diff-line { display: block; min-height: 19px; padding: 0 14px; } -.diff-line.add { background: rgba(38, 166, 115, .14); color: #8ef0c6; } -.diff-line.remove { background: rgba(229, 83, 75, .14); color: #ffaaa5; } -html[data-theme="light"] .diff-line.add { color: #006642; } -html[data-theme="light"] .diff-line.remove { color: #a31f1f; } -.diff-line.meta { color: var(--primary); } -.diff-line.hunk { color: #caa7ff; background: rgba(148, 97, 214, .08); } -.empty-state { height: 100%; min-height: 260px; display: grid; place-content: center; justify-items: center; text-align: center; padding: 30px; color: var(--text-muted); } -.empty-state .large-icon { width: 48px; height: 48px; display: grid; place-items: center; border-radius: 12px; background: var(--surface-2); color: var(--text-faint); margin-bottom: 12px; } -.empty-state h3 { margin: 0 0 6px; color: var(--text); font-size: 14px; } -.empty-state p { margin: 0; max-width: 420px; line-height: 1.55; } +.repo-workspace { + height: 100%; + min-height: 0; + display: grid; + grid-template-rows: auto auto auto 39px minmax(0, 1fr); +} +.repo-header { + padding: 16px 18px 13px; + background: var(--surface-1); + border-bottom: 1px solid var(--line); + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} +.repo-heading { + min-width: 0; +} +.repo-heading h1 { + display: flex; + align-items: center; + gap: 9px; + font-size: 17px; +} +.repo-heading p { + font-family: var(--font-mono); + font-size: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.repo-header-actions { + display: flex; + gap: 7px; +} +.release-rail { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + background: var(--surface-0); + border-bottom: 1px solid var(--line); +} +.release-node { + min-width: 0; + padding: 10px 16px 11px; + border-right: 1px solid var(--line-soft); + position: relative; +} +.release-node:last-child { + border-right: 0; +} +.release-node:not(:last-child)::after { + content: "›"; + position: absolute; + right: -6px; + top: 19px; + z-index: 2; + width: 12px; + height: 12px; + display: grid; + place-items: center; + border-radius: 50%; + background: var(--surface-0); + color: var(--text-faint); +} +.release-label { + color: var(--text-faint); + font-size: 9px; + font-weight: 760; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.release-value { + display: flex; + align-items: center; + gap: 7px; + margin-top: 4px; + min-width: 0; +} +.release-value strong { + font-family: var(--font-mono); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.release-value span { + color: var(--text-muted); + font-size: 11px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.state-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--text-faint); + flex: 0 0 auto; +} +.state-dot.success { + background: var(--success); + box-shadow: 0 0 0 3px var(--success-soft); +} +.state-dot.warning { + background: var(--warning); + box-shadow: 0 0 0 3px var(--warning-soft); +} +.state-dot.danger { + background: var(--danger); + box-shadow: 0 0 0 3px var(--danger-soft); +} +.tabs { + display: flex; + align-items: flex-end; + gap: 2px; + padding: 0 12px; + border-bottom: 1px solid var(--line); + background: var(--surface-1); +} +.tab { + height: 38px; + padding: 0 12px; + background: transparent; + color: var(--text-muted); + border-bottom: 2px solid transparent; + cursor: pointer; +} +.tab:hover { + color: var(--text); +} +.tab.active { + color: var(--primary); + border-bottom-color: var(--primary); +} +.repo-content { + min-height: 0; + overflow: hidden; +} +.changes-layout { + height: 100%; + min-height: 0; + display: grid; + grid-template-columns: 290px minmax(0, 1fr); +} +.file-panel { + min-width: 0; + min-height: 0; + overflow: hidden; + border-right: 1px solid var(--line); + background: var(--surface-1); + display: flex; + flex-direction: column; +} +.file-panel-tools { + min-height: 38px; + padding: 0 9px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--line-soft); +} +.file-list { + flex: 1 1 auto; + overflow-x: hidden; + overflow-y: auto; + min-height: 0; + padding: 5px; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} +.file-row { + width: 100%; + min-height: 34px; + display: grid; + grid-template-columns: 17px 17px minmax(0, 1fr) 16px; + gap: 7px; + align-items: center; + padding: 3px 6px; + border-radius: 4px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + text-align: left; +} +.file-row:hover { + background: var(--surface-hover); + color: var(--text); +} +.file-row.active { + background: var(--primary-soft); + color: var(--text); +} +.file-row input { + margin: 0; + accent-color: var(--primary-strong); +} +.file-path { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-family: var(--font-mono); + font-size: 11px; +} +.file-status { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 750; + color: var(--warning); +} +.file-status.added, +.file-status.untracked { + color: var(--success); +} +.file-status.deleted, +.file-status.conflict { + color: var(--danger); +} +.diff-panel { + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: 38px minmax(0, 1fr); + background: var(--bg); +} +.diff-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 0 11px; + border-bottom: 1px solid var(--line-soft); + background: var(--surface-0); +} +.diff-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-mono); + color: var(--text-muted); +} +.diff-view { + overflow: auto; + padding: 8px 0 36px; + font-family: var(--font-mono); + font-size: 11px; + line-height: 19px; + white-space: pre; + tab-size: 2; +} +.diff-line { + display: block; + min-height: 19px; + padding: 0 14px; +} +.diff-line.add { + background: rgba(38, 166, 115, 0.14); + color: #8ef0c6; +} +.diff-line.remove { + background: rgba(229, 83, 75, 0.14); + color: #ffaaa5; +} +html[data-theme="light"] .diff-line.add { + color: #006642; +} +html[data-theme="light"] .diff-line.remove { + color: #a31f1f; +} +.diff-line.meta { + color: var(--primary); +} +.diff-line.hunk { + color: #caa7ff; + background: rgba(148, 97, 214, 0.08); +} +.empty-state { + height: 100%; + min-height: 260px; + display: grid; + place-content: center; + justify-items: center; + text-align: center; + padding: 30px; + color: var(--text-muted); +} +.empty-state .large-icon { + width: 48px; + height: 48px; + display: grid; + place-items: center; + border-radius: 12px; + background: var(--surface-2); + color: var(--text-faint); + margin-bottom: 12px; +} +.empty-state h3 { + margin: 0 0 6px; + color: var(--text); + font-size: 14px; +} +.empty-state p { + margin: 0; + max-width: 420px; + line-height: 1.55; +} -.inspector { padding: 16px; } -.inspector-header { margin-bottom: 16px; } -.inspector-header h2 { margin: 3px 0 0; font-size: 15px; } -.inspector-section { padding: 15px 0; border-top: 1px solid var(--line-soft); } -.inspector-section:first-of-type { border-top: 0; padding-top: 0; } -.inspector-label { color: var(--text-faint); font-size: 9px; font-weight: 760; letter-spacing: .09em; text-transform: uppercase; margin-bottom: 8px; } -.textarea, .input, .select { width: 100%; border: 1px solid var(--line); border-radius: 5px; background: var(--surface-0); color: var(--text); } -.input, .select { height: 33px; padding: 0 9px; } -.textarea { min-height: 98px; resize: vertical; padding: 9px 10px; line-height: 1.45; } -.field-hint { display: flex; justify-content: space-between; gap: 10px; margin-top: 6px; color: var(--text-faint); font-size: 10px; } -.context-summary { padding: 11px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-0); } -.context-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 5px 0; color: var(--text-muted); } -.context-row strong { color: var(--text); font-family: var(--font-mono); font-size: 11px; text-align: right; overflow: hidden; text-overflow: ellipsis; } -.notice { display: flex; align-items: flex-start; gap: 9px; padding: 10px 11px; border-radius: 5px; border: 1px solid var(--line); background: var(--surface-0); color: var(--text-muted); line-height: 1.45; } -.notice.warning { border-color: color-mix(in srgb, var(--warning) 35%, var(--line)); background: var(--warning-soft); color: var(--warning); } -.notice.danger { border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); background: var(--danger-soft); color: var(--danger); } -.notice.success { border-color: color-mix(in srgb, var(--success) 35%, var(--line)); background: var(--success-soft); color: var(--success); } -.stack { display: grid; gap: 8px; } -.divider-text { display: flex; align-items: center; gap: 9px; color: var(--text-faint); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; } -.divider-text::before, .divider-text::after { content: ''; height: 1px; background: var(--line-soft); flex: 1; } +.inspector { + padding: 16px; +} +.inspector-header { + margin-bottom: 16px; +} +.inspector-header h2 { + margin: 3px 0 0; + font-size: 15px; +} +.inspector-section { + padding: 15px 0; + border-top: 1px solid var(--line-soft); +} +.inspector-section:first-of-type { + border-top: 0; + padding-top: 0; +} +.inspector-label { + color: var(--text-faint); + font-size: 9px; + font-weight: 760; + letter-spacing: 0.09em; + text-transform: uppercase; + margin-bottom: 8px; +} +.textarea, +.input, +.select { + width: 100%; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--surface-0); + color: var(--text); +} +.input, +.select { + height: 33px; + padding: 0 9px; +} +.textarea { + min-height: 98px; + resize: vertical; + padding: 9px 10px; + line-height: 1.45; +} +.field-hint { + display: flex; + justify-content: space-between; + gap: 10px; + margin-top: 6px; + color: var(--text-faint); + font-size: 10px; +} +.context-summary { + padding: 11px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface-0); +} +.context-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 5px 0; + color: var(--text-muted); +} +.context-row strong { + color: var(--text); + font-family: var(--font-mono); + font-size: 11px; + text-align: right; + overflow: hidden; + text-overflow: ellipsis; +} +.notice { + display: flex; + align-items: flex-start; + gap: 9px; + padding: 10px 11px; + border-radius: 5px; + border: 1px solid var(--line); + background: var(--surface-0); + color: var(--text-muted); + line-height: 1.45; +} +.notice.warning { + border-color: color-mix(in srgb, var(--warning) 35%, var(--line)); + background: var(--warning-soft); + color: var(--warning); +} +.notice.danger { + border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); + background: var(--danger-soft); + color: var(--danger); +} +.notice.success { + border-color: color-mix(in srgb, var(--success) 35%, var(--line)); + background: var(--success-soft); + color: var(--success); +} +.stack { + display: grid; + gap: 8px; +} +.divider-text { + display: flex; + align-items: center; + gap: 9px; + color: var(--text-faint); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 700; +} +.divider-text::before, +.divider-text::after { + content: ""; + height: 1px; + background: var(--line-soft); + flex: 1; +} -.data-table { width: 100%; border-collapse: collapse; } -.data-table th { height: 34px; padding: 0 11px; color: var(--text-faint); text-align: left; font-size: 9px; letter-spacing: .07em; text-transform: uppercase; border-bottom: 1px solid var(--line); } -.data-table td { padding: 10px 11px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; } -.data-table tr:last-child td { border-bottom: 0; } -.data-table tbody tr:hover { background: var(--surface-hover); } -.mono { font-family: var(--font-mono); } -.status-pill { display: inline-flex; align-items: center; gap: 6px; min-height: 22px; padding: 0 8px; border-radius: 11px; background: var(--surface-3); color: var(--text-muted); font-size: 10px; font-weight: 680; } -.status-pill.success { background: var(--success-soft); color: var(--success); } -.status-pill.warning { background: var(--warning-soft); color: var(--warning); } -.status-pill.danger { background: var(--danger-soft); color: var(--danger); } +.data-table { + width: 100%; + border-collapse: collapse; +} +.data-table th { + height: 34px; + padding: 0 11px; + color: var(--text-faint); + text-align: left; + font-size: 9px; + letter-spacing: 0.07em; + text-transform: uppercase; + border-bottom: 1px solid var(--line); +} +.data-table td { + padding: 10px 11px; + border-bottom: 1px solid var(--line-soft); + vertical-align: middle; +} +.data-table tr:last-child td { + border-bottom: 0; +} +.data-table tbody tr:hover { + background: var(--surface-hover); +} +.mono { + font-family: var(--font-mono); +} +.status-pill { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 22px; + padding: 0 8px; + border-radius: 11px; + background: var(--surface-3); + color: var(--text-muted); + font-size: 10px; + font-weight: 680; +} +.status-pill.success { + background: var(--success-soft); + color: var(--success); +} +.status-pill.warning { + background: var(--warning-soft); + color: var(--warning); +} +.status-pill.danger { + background: var(--danger-soft); + color: var(--danger); +} -.settings-layout { display: grid; grid-template-columns: 210px minmax(0,1fr); min-height: 100%; } -.settings-nav { padding: 15px 8px; border-right: 1px solid var(--line); background: var(--surface-1); } -.settings-content { padding: 25px 30px 60px; overflow: auto; } -.settings-group { max-width: 860px; margin-bottom: 28px; } -.settings-group > h2 { font-size: 12px; margin: 0 0 12px; } -.form-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 14px; } -.field { display: grid; gap: 6px; } -.field label { color: var(--text-muted); font-size: 11px; font-weight: 650; } -.field.full { grid-column: 1 / -1; } -.connection-card { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 13px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-1); } -.root-row { display: flex; align-items: center; gap: 8px; } -.root-row .input { flex: 1; font-family: var(--font-mono); font-size: 11px; } +.settings-layout { + display: grid; + grid-template-columns: 210px minmax(0, 1fr); + min-height: 100%; +} +.settings-nav { + padding: 15px 8px; + border-right: 1px solid var(--line); + background: var(--surface-1); +} +.settings-content { + padding: 25px 30px 60px; + overflow: auto; +} +.settings-group { + max-width: 860px; + margin-bottom: 28px; +} +.settings-group > h2 { + font-size: 12px; + margin: 0 0 12px; +} +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} +.field { + display: grid; + gap: 6px; +} +.field label { + color: var(--text-muted); + font-size: 11px; + font-weight: 650; +} +.field.full { + grid-column: 1 / -1; +} +.connection-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 13px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface-1); +} +.root-row { + display: flex; + align-items: center; + gap: 8px; +} +.root-row .input { + flex: 1; + font-family: var(--font-mono); + font-size: 11px; +} -.deploy-card-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(310px,1fr)); gap: 12px; } -.deploy-card { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-1); overflow: hidden; } -.deploy-card-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 14px; border-bottom: 1px solid var(--line-soft); } -.deploy-card-body { padding: 13px 14px; } -.deploy-metadata { display: grid; grid-template-columns: 1fr auto; gap: 7px 15px; color: var(--text-muted); } -.deploy-metadata strong { font-family: var(--font-mono); font-size: 11px; color: var(--text); } +.deploy-card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); + gap: 12px; +} +.deploy-card { + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface-1); + overflow: hidden; +} +.deploy-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 14px; + border-bottom: 1px solid var(--line-soft); +} +.deploy-card-body { + padding: 13px 14px; +} +.deploy-metadata { + display: grid; + grid-template-columns: 1fr auto; + gap: 7px 15px; + color: var(--text-muted); +} +.deploy-metadata strong { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text); +} -.deployment-view { min-height: 100%; padding: 22px; } -.pipeline-card { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-1); overflow: hidden; } -.pipeline-head { display: flex; justify-content: space-between; gap: 15px; padding: 16px; border-bottom: 1px solid var(--line-soft); } -.pipeline-head h2 { margin: 0; font-size: 16px; } -.pipeline-head p { margin: 5px 0 0; color: var(--text-muted); font-family: var(--font-mono); font-size: 11px; } -.pipeline-stages { display: grid; grid-template-columns: repeat(5,1fr); padding: 25px 20px 20px; } -.pipeline-stage { position: relative; display: grid; justify-items: center; gap: 8px; color: var(--text-faint); text-align: center; font-size: 10px; font-weight: 680; } -.pipeline-stage::before { content: ''; position: absolute; height: 2px; left: -50%; right: 50%; top: 15px; background: var(--line); } -.pipeline-stage:first-child::before { display: none; } -.pipeline-stage.complete::before, .pipeline-stage.active::before { background: var(--success); } -.stage-icon { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 9px; background: var(--surface-3); border: 1px solid var(--line); z-index: 1; } -.pipeline-stage.complete { color: var(--success); } -.pipeline-stage.complete .stage-icon { background: var(--success); border-color: var(--success); color: #06251b; } -.pipeline-stage.active { color: var(--primary); } -.pipeline-stage.active .stage-icon { background: var(--primary-strong); border-color: var(--primary); color: #fff; box-shadow: 0 0 0 5px var(--primary-soft); } -.log-view { margin-top: 14px; border: 1px solid var(--line); border-radius: var(--radius); background: #080b11; overflow: hidden; } -.log-toolbar { height: 35px; display: flex; align-items: center; justify-content: space-between; padding: 0 11px; border-bottom: 1px solid #262d3b; color: #9aa4b6; } -.log-lines { min-height: 290px; max-height: 500px; overflow: auto; padding: 12px 14px; font: 11px/19px var(--font-mono); color: #c3cada; white-space: pre-wrap; } -.log-lines .ok { color: #54ddb0; } -.log-lines .warn { color: #f2ba63; } -.log-lines .err { color: #ff817a; } +.deployment-view { + min-height: 100%; + padding: 22px; +} +.pipeline-card { + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface-1); + overflow: hidden; +} +.pipeline-head { + display: flex; + justify-content: space-between; + gap: 15px; + padding: 16px; + border-bottom: 1px solid var(--line-soft); +} +.pipeline-head h2 { + margin: 0; + font-size: 16px; +} +.pipeline-head p { + margin: 5px 0 0; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 11px; +} +.pipeline-stages { + display: grid; + grid-template-columns: repeat(5, 1fr); + padding: 25px 20px 20px; +} +.pipeline-stage { + position: relative; + display: grid; + justify-items: center; + gap: 8px; + color: var(--text-faint); + text-align: center; + font-size: 10px; + font-weight: 680; +} +.pipeline-stage::before { + content: ""; + position: absolute; + height: 2px; + left: -50%; + right: 50%; + top: 15px; + background: var(--line); +} +.pipeline-stage:first-child::before { + display: none; +} +.pipeline-stage.complete::before, +.pipeline-stage.active::before { + background: var(--success); +} +.stage-icon { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border-radius: 9px; + background: var(--surface-3); + border: 1px solid var(--line); + z-index: 1; +} +.pipeline-stage.complete { + color: var(--success); +} +.pipeline-stage.complete .stage-icon { + background: var(--success); + border-color: var(--success); + color: #06251b; +} +.pipeline-stage.active { + color: var(--primary); +} +.pipeline-stage.active .stage-icon { + background: var(--primary-strong); + border-color: var(--primary); + color: #fff; + box-shadow: 0 0 0 5px var(--primary-soft); +} +.log-view { + margin-top: 14px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #080b11; + overflow: hidden; +} +.log-toolbar { + height: 35px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 11px; + border-bottom: 1px solid #262d3b; + color: #9aa4b6; +} +.log-lines { + min-height: 290px; + max-height: 500px; + overflow: auto; + padding: 12px 14px; + font: 11px/19px var(--font-mono); + color: #c3cada; + white-space: pre-wrap; +} +.log-lines .ok { + color: #54ddb0; +} +.log-lines .warn { + color: #f2ba63; +} +.log-lines .err { + color: #ff817a; +} -.setup-backdrop { position: fixed; inset: 0; z-index: 50; display: grid; place-items: center; padding: 25px; background: rgba(4,7,12,.75); backdrop-filter: blur(8px); } -.setup-window { width: min(920px,96vw); min-height: 590px; max-height: 92vh; display: grid; grid-template-columns: 230px minmax(0,1fr); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; background: var(--surface-1); box-shadow: var(--shadow); } -.setup-sidebar { padding: 24px 17px; border-right: 1px solid var(--line); background: var(--surface-0); } -.setup-sidebar h2 { margin: 16px 0 5px; font-size: 18px; } -.setup-sidebar p { margin: 0 0 22px; color: var(--text-muted); line-height: 1.5; } -.setup-step { min-height: 38px; display: flex; align-items: center; gap: 9px; padding: 0 9px; border-radius: 5px; color: var(--text-faint); margin-bottom: 3px; } -.setup-step .step-number { width: 22px; height: 22px; display: grid; place-items: center; border-radius: 50%; border: 1px solid var(--line); font-family: var(--font-mono); font-size: 9px; } -.setup-step.active { background: var(--primary-soft); color: var(--primary); } -.setup-step.complete { color: var(--success); } -.setup-content { min-width: 0; padding: 34px 38px 24px; display: grid; grid-template-rows: minmax(0,1fr) auto; overflow: auto; } -.setup-body h1 { margin: 0; font-size: 22px; } -.setup-body > p { color: var(--text-muted); line-height: 1.55; max-width: 620px; } -.setup-actions { display: flex; justify-content: space-between; gap: 10px; padding-top: 22px; border-top: 1px solid var(--line-soft); } -.discovery-list { border: 1px solid var(--line); border-radius: 6px; overflow: hidden; max-height: 260px; overflow-y: auto; } -.discovery-row { min-height: 45px; display: grid; grid-template-columns: 20px minmax(0,1fr) auto; gap: 10px; align-items: center; padding: 7px 10px; border-bottom: 1px solid var(--line-soft); } -.discovery-row:last-child { border-bottom: 0; } -.discovery-row strong { display: block; font-size: 12px; } -.discovery-row span { display: block; margin-top: 2px; color: var(--text-faint); font: 10px var(--font-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.setup-backdrop { + position: fixed; + inset: 0; + z-index: 50; + display: grid; + place-items: center; + padding: 25px; + background: rgba(4, 7, 12, 0.75); + backdrop-filter: blur(8px); +} +.setup-window { + width: min(920px, 96vw); + min-height: 590px; + max-height: 92vh; + display: grid; + grid-template-columns: 230px minmax(0, 1fr); + border: 1px solid var(--line); + border-radius: 10px; + overflow: hidden; + background: var(--surface-1); + box-shadow: var(--shadow); +} +.setup-sidebar { + padding: 24px 17px; + border-right: 1px solid var(--line); + background: var(--surface-0); +} +.setup-sidebar h2 { + margin: 16px 0 5px; + font-size: 18px; +} +.setup-sidebar p { + margin: 0 0 22px; + color: var(--text-muted); + line-height: 1.5; +} +.setup-step { + min-height: 38px; + display: flex; + align-items: center; + gap: 9px; + padding: 0 9px; + border-radius: 5px; + color: var(--text-faint); + margin-bottom: 3px; +} +.setup-step .step-number { + width: 22px; + height: 22px; + display: grid; + place-items: center; + border-radius: 50%; + border: 1px solid var(--line); + font-family: var(--font-mono); + font-size: 9px; +} +.setup-step.active { + background: var(--primary-soft); + color: var(--primary); +} +.setup-step.complete { + color: var(--success); +} +.setup-content { + min-width: 0; + padding: 34px 38px 24px; + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + overflow: auto; +} +.setup-body h1 { + margin: 0; + font-size: 22px; +} +.setup-body > p { + color: var(--text-muted); + line-height: 1.55; + max-width: 620px; +} +.setup-actions { + display: flex; + justify-content: space-between; + gap: 10px; + padding-top: 22px; + border-top: 1px solid var(--line-soft); +} +.discovery-list { + border: 1px solid var(--line); + border-radius: 6px; + overflow: hidden; + max-height: 260px; + overflow-y: auto; +} +.discovery-row { + min-height: 45px; + display: grid; + grid-template-columns: 20px minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + padding: 7px 10px; + border-bottom: 1px solid var(--line-soft); +} +.discovery-row:last-child { + border-bottom: 0; +} +.discovery-row strong { + display: block; + font-size: 12px; +} +.discovery-row span { + display: block; + margin-top: 2px; + color: var(--text-faint); + font: 10px var(--font-mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} -.modal-backdrop { position: fixed; inset: 0; z-index: 60; display: grid; align-items: center; justify-items: center; padding: clamp(8px,2vh,20px); overflow: auto; overscroll-behavior: contain; background: rgba(4,7,12,.7); backdrop-filter: blur(4px); } -.modal { width: min(520px,94vw); max-height: calc(100dvh - clamp(16px,4vh,40px)); min-height: 0; display: flex; flex-direction: column; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-1); box-shadow: var(--shadow); overflow: hidden; } -.modal-header { flex: 0 0 auto; display: flex; justify-content: space-between; align-items: center; padding: 15px 17px; border-bottom: 1px solid var(--line); background: var(--surface-1); } -.modal-header h2 { margin: 0; font-size: 15px; } -.modal-body { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding: 17px; } -.modal-footer { flex: 0 0 auto; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; padding: 12px 17px; border-top: 1px solid var(--line-soft); background: var(--surface-0); box-shadow: 0 -8px 18px rgba(0,0,0,.08); } +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 60; + display: grid; + align-items: center; + justify-items: center; + padding: clamp(8px, 2vh, 20px); + overflow: auto; + overscroll-behavior: contain; + background: rgba(4, 7, 12, 0.7); + backdrop-filter: blur(4px); +} +.modal { + width: min(520px, 94vw); + max-height: calc(100dvh - clamp(16px, 4vh, 40px)); + min-height: 0; + display: flex; + flex-direction: column; + border: 1px solid var(--line); + border-radius: 9px; + background: var(--surface-1); + box-shadow: var(--shadow); + overflow: hidden; +} +.modal-header { + flex: 0 0 auto; + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px 17px; + border-bottom: 1px solid var(--line); + background: var(--surface-1); +} +.modal-header h2 { + margin: 0; + font-size: 15px; +} +.modal-body { + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; + padding: 17px; +} +.modal-footer { + flex: 0 0 auto; + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + padding: 12px 17px; + border-top: 1px solid var(--line-soft); + background: var(--surface-0); + box-shadow: 0 -8px 18px rgba(0, 0, 0, 0.08); +} -.statusbar { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 0 9px; border-top: 1px solid var(--line); background: var(--surface-1); color: var(--text-faint); font-size: 10px; font-weight: 620; } -.statusbar-left, .statusbar-right { display: flex; align-items: center; gap: 13px; min-width: 0; } -.statusbar-item { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; } -.statusbar .success { color: var(--success); } -.statusbar .warning { color: var(--warning); } -.statusbar .danger { color: var(--danger); } +.statusbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 15px; + padding: 0 9px; + border-top: 1px solid var(--line); + background: var(--surface-1); + color: var(--text-faint); + font-size: 10px; + font-weight: 620; +} +.statusbar-left, +.statusbar-right { + display: flex; + align-items: center; + gap: 13px; + min-width: 0; +} +.statusbar-item { + display: inline-flex; + align-items: center; + gap: 5px; + white-space: nowrap; +} +.statusbar .success { + color: var(--success); +} +.statusbar .warning { + color: var(--warning); +} +.statusbar .danger { + color: var(--danger); +} -.toast-root { position: fixed; right: 15px; bottom: 38px; z-index: 90; display: grid; gap: 8px; pointer-events: none; } -.toast { width: min(380px,calc(100vw - 30px)); display: grid; grid-template-columns: 20px minmax(0,1fr); gap: 9px; padding: 11px 12px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface-2); box-shadow: var(--shadow); pointer-events: auto; animation: toast-in .18s ease-out; } -.toast.success { border-color: color-mix(in srgb, var(--success) 35%, var(--line)); } -.toast.error { border-color: color-mix(in srgb, var(--danger) 40%, var(--line)); } -.toast strong { display: block; margin-bottom: 2px; } -.toast span { color: var(--text-muted); line-height: 1.4; } -@keyframes toast-in { from { transform: translateY(8px); opacity: 0; } } +.toast-root { + position: fixed; + right: 15px; + bottom: 38px; + z-index: 90; + display: grid; + gap: 8px; + pointer-events: none; +} +.toast { + width: min(380px, calc(100vw - 30px)); + display: grid; + grid-template-columns: 20px minmax(0, 1fr); + gap: 9px; + padding: 11px 12px; + border: 1px solid var(--line); + border-radius: 7px; + background: var(--surface-2); + box-shadow: var(--shadow); + pointer-events: auto; + animation: toast-in 0.18s ease-out; +} +.toast.success { + border-color: color-mix(in srgb, var(--success) 35%, var(--line)); +} +.toast.error { + border-color: color-mix(in srgb, var(--danger) 40%, var(--line)); +} +.toast strong { + display: block; + margin-bottom: 2px; +} +.toast span { + color: var(--text-muted); + line-height: 1.4; +} +@keyframes toast-in { + from { + transform: translateY(8px); + opacity: 0; + } +} -.loading-overlay { position: absolute; inset: 0; z-index: 20; display: grid; place-items: center; background: color-mix(in srgb, var(--surface-0) 72%, transparent); backdrop-filter: blur(2px); } -.spinner { width: 22px; height: 22px; border: 2px solid var(--line); border-top-color: var(--primary); border-radius: 50%; animation: spin .8s linear infinite; } -@keyframes spin { to { transform: rotate(360deg); } } +.loading-overlay { + position: absolute; + inset: 0; + z-index: 20; + display: grid; + place-items: center; + background: color-mix(in srgb, var(--surface-0) 72%, transparent); + backdrop-filter: blur(2px); +} +.spinner { + width: 22px; + height: 22px; + border: 2px solid var(--line); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} @media (max-width: 1250px) { - :root { --sidebar: 250px; --action-panel: 320px; } - .summary-grid { grid-template-columns: repeat(2,1fr); } - .summary-card:nth-child(2) { border-right: 0; } - .summary-card:nth-child(-n+2) { border-bottom: 1px solid var(--line); } - .queue-row { grid-template-columns: 28px minmax(130px,1fr) minmax(180px,1.5fr) auto; } + :root { + --sidebar: 250px; + --action-panel: 320px; + } + .summary-grid { + grid-template-columns: repeat(2, 1fr); + } + .summary-card:nth-child(2) { + border-right: 0; + } + .summary-card:nth-child(-n + 2) { + border-bottom: 1px solid var(--line); + } + .queue-row { + grid-template-columns: 28px minmax(130px, 1fr) minmax(180px, 1.5fr) auto; + } } @media (max-width: 1120px) { - :root { --sidebar: 220px; --action-panel: 300px; } - .global-search { width: 190px; } - .changes-layout { grid-template-columns: 245px minmax(0,1fr); } - .page { padding-left: 18px; padding-right: 18px; } + :root { + --sidebar: 220px; + --action-panel: 300px; + } + .global-search { + width: 190px; + } + .changes-layout { + grid-template-columns: 245px minmax(0, 1fr); + } + .page { + padding-left: 18px; + padding-right: 18px; + } } /* ForgeFlow v0.2 interaction and workflow refinements */ -.command-trigger { height: 30px; display: inline-flex; align-items: center; gap: 7px; padding: 0 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-0); color: var(--text-muted); cursor: pointer; -webkit-app-region: no-drag; } -.command-trigger:hover { color: var(--text); background: var(--surface-hover); } -kbd { min-width: 24px; padding: 2px 5px; border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 4px; background: var(--surface-2); color: var(--text-faint); font: 9px var(--font-mono); text-align: center; } -.repo-group-label { padding: 10px 8px 4px; color: var(--text-faint); font-size: 9px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; } -.favorite-button { width: 25px; height: 25px; display: inline-grid; place-items: center; margin-left: -5px; border-radius: 5px; background: transparent; color: var(--text-faint); cursor: pointer; } -.favorite-button:hover, .favorite-button.active { color: var(--warning); background: var(--warning-soft); } -.favorite-button.active svg { fill: currentColor; } -.repo-row .repo-icon .icon { width: 15px; height: 15px; } -.repo-row .repo-icon:has(svg path[d^="m12 3"]) { color: var(--warning); } -.button.small { min-height: 25px; padding: 0 7px; font-size: 10px; } -.stack.horizontal.compact { gap: 5px; } -.empty-state.compact { min-height: 105px; padding: 16px; } -.empty-state.full { height: 100%; min-height: 320px; } -.readiness-list { display: grid; gap: 3px; } -.readiness-row { display: grid; grid-template-columns: 12px minmax(0,1fr); gap: 9px; align-items: start; padding: 9px 4px; border-bottom: 1px solid var(--line-soft); } -.readiness-row:last-child { border-bottom: 0; } -.readiness-row strong { display: block; font-size: 11px; } -.readiness-row span:not(.state-dot) { display: block; margin-top: 2px; color: var(--text-faint); font-size: 10px; } -.action-panel-head { padding: 18px 17px 14px; border-bottom: 1px solid var(--line); } -.action-panel-head h2 { margin: 5px 0 5px; font-size: 16px; } -.action-panel-head p { margin: 0; color: var(--text-muted); line-height: 1.45; } -.action-panel-body { padding: 15px 16px; } -.action-panel-footer { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; margin: auto 10px 10px; padding-top: 10px; border-top: 1px solid var(--line-soft); } -.action-panel { display: flex; flex-direction: column; } -.panel-callout { display: grid; gap: 9px; } -.panel-callout h2 { margin: 3px 0 0; font-size: 15px; } -.panel-callout p { margin: 0 0 5px; color: var(--text-muted); line-height: 1.5; } -.callout-icon { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 9px; background: var(--primary-soft); color: var(--primary); } -.callout-icon.success { background: var(--success-soft); color: var(--success); } -.callout-icon.warning { background: var(--warning-soft); color: var(--warning); } -.callout-icon.danger { background: var(--danger-soft); color: var(--danger); } -.field-hint { display: flex; justify-content: space-between; gap: 10px; margin-top: 6px; color: var(--text-faint); font-size: 10px; } -.field-label { display: block; margin-top: 8px; color: var(--text-muted); font-size: 10px; font-weight: 650; } -.textarea { width: 100%; min-height: 92px; resize: vertical; padding: 9px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-0); line-height: 1.45; } -.input, .select { width: 100%; min-height: 34px; padding: 0 10px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface-0); color: var(--text); } -.select { cursor: pointer; } -.stack { display: grid; gap: 8px; } -.stack.horizontal { display: flex; flex-wrap: wrap; align-items: center; } -.deploy-proof { display: grid; grid-template-columns: 1fr auto; gap: 7px 12px; margin: 7px 0 5px; padding: 11px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-0); } -.deploy-proof span { color: var(--text-faint); } -.deploy-proof strong { font: 11px var(--font-mono); } -.tab-page { min-height: 100%; padding: 18px 19px 42px; overflow: auto; } -.git-tools-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 15px; align-items: start; } -.inline-form { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 8px; margin-bottom: 13px; } -.tool-list { display: grid; border: 1px solid var(--line-soft); border-radius: 6px; overflow: hidden; } -.tool-row { min-height: 51px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 10px; border-bottom: 1px solid var(--line-soft); } -.tool-row:last-child { border-bottom: 0; } -.tool-row:hover { background: var(--surface-hover); } -.tool-row strong { display: block; } -.tool-row span { display: block; margin-top: 3px; color: var(--text-faint); font: 10px var(--font-mono); } -.deploy-card-header h3 { margin: 4px 0 3px; font-size: 14px; } -.deploy-card-header p { margin: 0; color: var(--text-faint); font: 10px var(--font-mono); } -.card-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line-soft); } -.compact-card .deploy-card-body { padding-bottom: 11px; } -.check-field { display: flex; align-items: center; gap: 9px; padding: 8px 0; color: var(--text-muted); } -.check-field input { accent-color: var(--primary-strong); } -.wide-modal { width: min(650px,95vw); } -.modal-spacer { flex: 1; } -.confirm-hero { display: flex; align-items: center; gap: 12px; padding: 12px; border: 1px solid color-mix(in srgb, var(--success) 30%, var(--line)); border-radius: 7px; background: var(--success-soft); } -.confirm-hero.danger { border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); background: var(--danger-soft); } -.confirm-hero > .icon { width: 27px; height: 27px; color: var(--success); } -.confirm-hero.danger > .icon { color: var(--danger); } -.confirm-hero strong, .confirm-hero span { display: block; } -.confirm-hero span { margin-top: 3px; color: var(--text-muted); } -.confirm-grid { display: grid; grid-template-columns: 120px minmax(0,1fr); gap: 9px 14px; margin-top: 16px; } -.confirm-grid span { color: var(--text-faint); } -.confirm-grid strong { overflow-wrap: anywhere; } -.notice.danger { border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); background: var(--danger-soft); color: var(--danger); } -.notice.warning { border-color: color-mix(in srgb, var(--warning) 35%, var(--line)); background: var(--warning-soft); color: var(--warning); } -.danger-zone { padding: 15px; border: 1px solid color-mix(in srgb, var(--danger) 25%, var(--line)); border-radius: 7px; background: var(--danger-soft); } -.danger-zone p { color: var(--text-muted); line-height: 1.5; } -.pipeline-stages { grid-template-columns: repeat(6,1fr); } -.pipeline-stage.failed { color: var(--danger); } -.pipeline-stage.failed .stage-icon { background: var(--danger); border-color: var(--danger); color: #fff; } -.pipeline-stage.cancelled, .pipeline-stage.skipped { color: var(--text-faint); } -.log-lines { word-break: break-word; } -.palette-backdrop { align-items: start; padding-top: 12vh; } -.command-palette { width: min(650px,94vw); border: 1px solid var(--line); border-radius: 10px; background: var(--surface-1); box-shadow: var(--shadow); overflow: hidden; } -.palette-search { height: 54px; display: grid; grid-template-columns: 20px minmax(0,1fr); gap: 9px; align-items: center; padding: 0 15px; border-bottom: 1px solid var(--line); } -.palette-search input { height: 100%; border: 0; outline: 0; background: transparent; font-size: 15px; } -.palette-list { max-height: 380px; overflow: auto; padding: 6px; } -.palette-row { width: 100%; min-height: 52px; display: grid; grid-template-columns: 22px minmax(0,1fr) auto; gap: 10px; align-items: center; padding: 7px 10px; border-radius: 6px; background: transparent; color: var(--text-muted); text-align: left; cursor: pointer; } -.palette-row:hover, .palette-row:focus-visible { background: var(--primary-soft); color: var(--text); } -.palette-row:disabled { opacity: .4; cursor: not-allowed; } -.palette-row strong, .palette-row small { display: block; } -.palette-row small { margin-top: 3px; color: var(--text-faint); } -.palette-footer { padding: 8px 13px; border-top: 1px solid var(--line-soft); color: var(--text-faint); font-size: 10px; } -.discovery-progress { min-height: 260px; display: grid; place-content: center; justify-items: center; gap: 13px; color: var(--text-muted); } +.command-trigger { + height: 30px; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 0 8px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface-0); + color: var(--text-muted); + cursor: pointer; + -webkit-app-region: no-drag; +} +.command-trigger:hover { + color: var(--text); + background: var(--surface-hover); +} +kbd { + min-width: 24px; + padding: 2px 5px; + border: 1px solid var(--line); + border-bottom-width: 2px; + border-radius: 4px; + background: var(--surface-2); + color: var(--text-faint); + font: 9px var(--font-mono); + text-align: center; +} +.repo-group-label { + padding: 10px 8px 4px; + color: var(--text-faint); + font-size: 9px; + font-weight: 750; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.favorite-button { + width: 25px; + height: 25px; + display: inline-grid; + place-items: center; + margin-left: -5px; + border-radius: 5px; + background: transparent; + color: var(--text-faint); + cursor: pointer; +} +.favorite-button:hover, +.favorite-button.active { + color: var(--warning); + background: var(--warning-soft); +} +.favorite-button.active svg { + fill: currentColor; +} +.repo-row .repo-icon .icon { + width: 15px; + height: 15px; +} +.repo-row .repo-icon:has(svg path[d^="m12 3"]) { + color: var(--warning); +} +.button.small { + min-height: 25px; + padding: 0 7px; + font-size: 10px; +} +.stack.horizontal.compact { + gap: 5px; +} +.empty-state.compact { + min-height: 105px; + padding: 16px; +} +.empty-state.full { + height: 100%; + min-height: 320px; +} +.readiness-list { + display: grid; + gap: 3px; +} +.readiness-row { + display: grid; + grid-template-columns: 12px minmax(0, 1fr); + gap: 9px; + align-items: start; + padding: 9px 4px; + border-bottom: 1px solid var(--line-soft); +} +.readiness-row:last-child { + border-bottom: 0; +} +.readiness-row strong { + display: block; + font-size: 11px; +} +.readiness-row span:not(.state-dot) { + display: block; + margin-top: 2px; + color: var(--text-faint); + font-size: 10px; +} +.action-panel-head { + padding: 18px 17px 14px; + border-bottom: 1px solid var(--line); +} +.action-panel-head h2 { + margin: 5px 0 5px; + font-size: 16px; +} +.action-panel-head p { + margin: 0; + color: var(--text-muted); + line-height: 1.45; +} +.action-panel-body { + padding: 15px 16px; +} +.action-panel-footer { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 5px; + margin: auto 10px 10px; + padding-top: 10px; + border-top: 1px solid var(--line-soft); +} +.action-panel { + display: flex; + flex-direction: column; +} +.panel-callout { + display: grid; + gap: 9px; +} +.panel-callout h2 { + margin: 3px 0 0; + font-size: 15px; +} +.panel-callout p { + margin: 0 0 5px; + color: var(--text-muted); + line-height: 1.5; +} +.callout-icon { + width: 38px; + height: 38px; + display: grid; + place-items: center; + border-radius: 9px; + background: var(--primary-soft); + color: var(--primary); +} +.callout-icon.success { + background: var(--success-soft); + color: var(--success); +} +.callout-icon.warning { + background: var(--warning-soft); + color: var(--warning); +} +.callout-icon.danger { + background: var(--danger-soft); + color: var(--danger); +} +.field-hint { + display: flex; + justify-content: space-between; + gap: 10px; + margin-top: 6px; + color: var(--text-faint); + font-size: 10px; +} +.field-label { + display: block; + margin-top: 8px; + color: var(--text-muted); + font-size: 10px; + font-weight: 650; +} +.textarea { + width: 100%; + min-height: 92px; + resize: vertical; + padding: 9px 10px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface-0); + line-height: 1.45; +} +.input, +.select { + width: 100%; + min-height: 34px; + padding: 0 10px; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--surface-0); + color: var(--text); +} +.select { + cursor: pointer; +} +.stack { + display: grid; + gap: 8px; +} +.stack.horizontal { + display: flex; + flex-wrap: wrap; + align-items: center; +} +.deploy-proof { + display: grid; + grid-template-columns: 1fr auto; + gap: 7px 12px; + margin: 7px 0 5px; + padding: 11px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface-0); +} +.deploy-proof span { + color: var(--text-faint); +} +.deploy-proof strong { + font: 11px var(--font-mono); +} +.tab-page { + min-height: 100%; + padding: 18px 19px 42px; + overflow: auto; +} +.git-tools-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 15px; + align-items: start; +} +.inline-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + margin-bottom: 13px; +} +.tool-list { + display: grid; + border: 1px solid var(--line-soft); + border-radius: 6px; + overflow: hidden; +} +.tool-row { + min-height: 51px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 10px; + border-bottom: 1px solid var(--line-soft); +} +.tool-row:last-child { + border-bottom: 0; +} +.tool-row:hover { + background: var(--surface-hover); +} +.tool-row strong { + display: block; +} +.tool-row span { + display: block; + margin-top: 3px; + color: var(--text-faint); + font: 10px var(--font-mono); +} +.deploy-card-header h3 { + margin: 4px 0 3px; + font-size: 14px; +} +.deploy-card-header p { + margin: 0; + color: var(--text-faint); + font: 10px var(--font-mono); +} +.card-actions { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid var(--line-soft); +} +.compact-card .deploy-card-body { + padding-bottom: 11px; +} +.check-field { + display: flex; + align-items: center; + gap: 9px; + padding: 8px 0; + color: var(--text-muted); +} +.check-field input { + accent-color: var(--primary-strong); +} +.wide-modal { + width: min(650px, 95vw); +} +.modal-spacer { + flex: 1; +} +.confirm-hero { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border: 1px solid color-mix(in srgb, var(--success) 30%, var(--line)); + border-radius: 7px; + background: var(--success-soft); +} +.confirm-hero.danger { + border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); + background: var(--danger-soft); +} +.confirm-hero > .icon { + width: 27px; + height: 27px; + color: var(--success); +} +.confirm-hero.danger > .icon { + color: var(--danger); +} +.confirm-hero strong, +.confirm-hero span { + display: block; +} +.confirm-hero span { + margin-top: 3px; + color: var(--text-muted); +} +.confirm-grid { + display: grid; + grid-template-columns: 120px minmax(0, 1fr); + gap: 9px 14px; + margin-top: 16px; +} +.confirm-grid span { + color: var(--text-faint); +} +.confirm-grid strong { + overflow-wrap: anywhere; +} +.notice.danger { + border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); + background: var(--danger-soft); + color: var(--danger); +} +.notice.warning { + border-color: color-mix(in srgb, var(--warning) 35%, var(--line)); + background: var(--warning-soft); + color: var(--warning); +} +.danger-zone { + padding: 15px; + border: 1px solid color-mix(in srgb, var(--danger) 25%, var(--line)); + border-radius: 7px; + background: var(--danger-soft); +} +.danger-zone p { + color: var(--text-muted); + line-height: 1.5; +} +.pipeline-stages { + grid-template-columns: repeat(6, 1fr); +} +.pipeline-stage.failed { + color: var(--danger); +} +.pipeline-stage.failed .stage-icon { + background: var(--danger); + border-color: var(--danger); + color: #fff; +} +.pipeline-stage.cancelled, +.pipeline-stage.skipped { + color: var(--text-faint); +} +.log-lines { + word-break: break-word; +} +.palette-backdrop { + align-items: start; + padding-top: 12vh; +} +.command-palette { + width: min(650px, 94vw); + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface-1); + box-shadow: var(--shadow); + overflow: hidden; +} +.palette-search { + height: 54px; + display: grid; + grid-template-columns: 20px minmax(0, 1fr); + gap: 9px; + align-items: center; + padding: 0 15px; + border-bottom: 1px solid var(--line); +} +.palette-search input { + height: 100%; + border: 0; + outline: 0; + background: transparent; + font-size: 15px; +} +.palette-list { + max-height: 380px; + overflow: auto; + padding: 6px; +} +.palette-row { + width: 100%; + min-height: 52px; + display: grid; + grid-template-columns: 22px minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + padding: 7px 10px; + border-radius: 6px; + background: transparent; + color: var(--text-muted); + text-align: left; + cursor: pointer; +} +.palette-row:hover, +.palette-row:focus-visible { + background: var(--primary-soft); + color: var(--text); +} +.palette-row:disabled { + opacity: 0.4; + cursor: not-allowed; +} +.palette-row strong, +.palette-row small { + display: block; +} +.palette-row small { + margin-top: 3px; + color: var(--text-faint); +} +.palette-footer { + padding: 8px 13px; + border-top: 1px solid var(--line-soft); + color: var(--text-faint); + font-size: 10px; +} +.discovery-progress { + min-height: 260px; + display: grid; + place-content: center; + justify-items: center; + gap: 13px; + color: var(--text-muted); +} @media (max-width: 1240px) { - .command-trigger span { display: none; } - .command-trigger kbd { display: none; } - .git-tools-grid { grid-template-columns: 1fr; } - .pipeline-stages { grid-template-columns: repeat(3,1fr); row-gap: 18px; } - .pipeline-stage:nth-child(4)::before { display: none; } + .command-trigger span { + display: none; + } + .command-trigger kbd { + display: none; + } + .git-tools-grid { + grid-template-columns: 1fr; + } + .pipeline-stages { + grid-template-columns: repeat(3, 1fr); + row-gap: 18px; + } + .pipeline-stage:nth-child(4)::before { + display: none; + } } /* v0.3 diagnostics and preflight */ -.diagnostics-page { display: grid; gap: 18px; padding: 20px 22px 44px; overflow: auto; } -.diagnostic-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; align-items: start; } -.diagnostic-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border: 1px solid var(--line-soft); border-radius: 7px; overflow: hidden; } -.diagnostic-metrics > div { min-height: 68px; display: grid; align-content: center; gap: 5px; padding: 11px 12px; border-right: 1px solid var(--line-soft); border-bottom: 1px solid var(--line-soft); background: var(--surface-0); } -.diagnostic-metrics > div:nth-child(2n) { border-right: 0; } -.diagnostic-metrics > div:nth-last-child(-n + 2) { border-bottom: 0; } -.diagnostic-metrics span { color: var(--text-faint); font-size: 9px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; } -.diagnostic-metrics strong { overflow-wrap: anywhere; font-size: 12px; } -.preflight-summary { min-height: 48px; display: flex; flex-wrap: wrap; align-items: center; gap: 9px; padding: 11px 13px; border-bottom: 1px solid var(--line-soft); color: var(--text-muted); } -.preflight-list { display: grid; } -.preflight-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; gap: 10px; align-items: start; padding: 12px 13px; border-bottom: 1px solid var(--line-soft); } -.preflight-row:last-child { border-bottom: 0; } -.preflight-row > div { min-width: 0; } -.preflight-row strong { display: block; margin-top: 1px; font-size: 11px; } -.preflight-row span:not(.preflight-state):not(.status-pill), .preflight-row small { display: block; margin-top: 3px; color: var(--text-faint); line-height: 1.4; overflow-wrap: anywhere; } -.preflight-row small { color: var(--text-muted); } -.preflight-state { width: 25px; height: 25px; display: grid; place-items: center; border-radius: 50%; background: var(--surface-2); color: var(--text-faint); } -.preflight-state .icon { width: 14px; height: 14px; } -.preflight-state.success { background: var(--success-soft); color: var(--success); } -.preflight-state.warning { background: var(--warning-soft); color: var(--warning); } -.preflight-state.danger { background: var(--danger-soft); color: var(--danger); } -.setup-preflight { max-height: 330px; margin-top: 17px; border: 1px solid var(--line); border-radius: 8px; overflow: auto; background: var(--surface-0); } -.setup-summary { display: grid; margin: 18px 0; border: 1px solid var(--line); border-radius: 8px; padding: 0 12px; background: var(--surface-0); } -.setup-support-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; } -.diagnostics-page .panel-body { padding: 14px; } -.diagnostics-page .section-block { margin: 0; } +.diagnostics-page { + display: grid; + gap: 18px; + padding: 20px 22px 44px; + overflow: auto; +} +.diagnostic-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + align-items: start; +} +.diagnostic-metrics { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border: 1px solid var(--line-soft); + border-radius: 7px; + overflow: hidden; +} +.diagnostic-metrics > div { + min-height: 68px; + display: grid; + align-content: center; + gap: 5px; + padding: 11px 12px; + border-right: 1px solid var(--line-soft); + border-bottom: 1px solid var(--line-soft); + background: var(--surface-0); +} +.diagnostic-metrics > div:nth-child(2n) { + border-right: 0; +} +.diagnostic-metrics > div:nth-last-child(-n + 2) { + border-bottom: 0; +} +.diagnostic-metrics span { + color: var(--text-faint); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.diagnostic-metrics strong { + overflow-wrap: anywhere; + font-size: 12px; +} +.preflight-summary { + min-height: 48px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 9px; + padding: 11px 13px; + border-bottom: 1px solid var(--line-soft); + color: var(--text-muted); +} +.preflight-list { + display: grid; +} +.preflight-row { + display: grid; + grid-template-columns: 28px minmax(0, 1fr) auto; + gap: 10px; + align-items: start; + padding: 12px 13px; + border-bottom: 1px solid var(--line-soft); +} +.preflight-row:last-child { + border-bottom: 0; +} +.preflight-row > div { + min-width: 0; +} +.preflight-row strong { + display: block; + margin-top: 1px; + font-size: 11px; +} +.preflight-row span:not(.preflight-state):not(.status-pill), +.preflight-row small { + display: block; + margin-top: 3px; + color: var(--text-faint); + line-height: 1.4; + overflow-wrap: anywhere; +} +.preflight-row small { + color: var(--text-muted); +} +.preflight-state { + width: 25px; + height: 25px; + display: grid; + place-items: center; + border-radius: 50%; + background: var(--surface-2); + color: var(--text-faint); +} +.preflight-state .icon { + width: 14px; + height: 14px; +} +.preflight-state.success { + background: var(--success-soft); + color: var(--success); +} +.preflight-state.warning { + background: var(--warning-soft); + color: var(--warning); +} +.preflight-state.danger { + background: var(--danger-soft); + color: var(--danger); +} +.setup-preflight { + max-height: 330px; + margin-top: 17px; + border: 1px solid var(--line); + border-radius: 8px; + overflow: auto; + background: var(--surface-0); +} +.setup-summary { + display: grid; + margin: 18px 0; + border: 1px solid var(--line); + border-radius: 8px; + padding: 0 12px; + background: var(--surface-0); +} +.setup-support-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; +} +.diagnostics-page .panel-body { + padding: 14px; +} +.diagnostics-page .section-block { + margin: 0; +} @media (max-width: 1100px) { - .diagnostic-grid { grid-template-columns: 1fr; } + .diagnostic-grid { + grid-template-columns: 1fr; + } } @media (max-width: 760px) { - .diagnostics-page { padding: 15px; } - .diagnostic-metrics { grid-template-columns: 1fr; } - .diagnostic-metrics > div { border-right: 0; } - .diagnostic-metrics > div:nth-last-child(-n + 2) { border-bottom: 1px solid var(--line-soft); } - .diagnostic-metrics > div:last-child { border-bottom: 0; } - .preflight-row { grid-template-columns: 28px minmax(0, 1fr); } - .preflight-row > .status-pill { grid-column: 2; justify-self: start; } + .diagnostics-page { + padding: 15px; + } + .diagnostic-metrics { + grid-template-columns: 1fr; + } + .diagnostic-metrics > div { + border-right: 0; + } + .diagnostic-metrics > div:nth-last-child(-n + 2) { + border-bottom: 1px solid var(--line-soft); + } + .diagnostic-metrics > div:last-child { + border-bottom: 0; + } + .preflight-row { + grid-template-columns: 28px minmax(0, 1fr); + } + .preflight-row > .status-pill { + grid-column: 2; + justify-self: start; + } } -.required-mark { color: var(--warning); font-size: 10px; font-weight: 700; text-transform: uppercase; margin-left: 5px; } -.commit-readiness { margin-top: 9px; padding: 8px 9px; display: flex; align-items: flex-start; gap: 7px; border: 1px solid var(--line); border-radius: 5px; color: var(--text-muted); background: var(--surface-0); font-size: 11px; line-height: 1.4; } -.commit-readiness.blocked { border-color: color-mix(in srgb, var(--warning) 35%, var(--line)); background: var(--warning-soft); color: var(--text); } -.commit-readiness.ready { border-color: color-mix(in srgb, var(--success) 35%, var(--line)); background: var(--success-soft); color: var(--text); } -.commit-readiness .icon { flex: 0 0 auto; margin-top: 1px; } -.stage-note { margin-top: 10px; color: var(--text-faint); font-size: 10px; line-height: 1.45; } +.required-mark { + color: var(--warning); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + margin-left: 5px; +} +.commit-readiness { + margin-top: 9px; + padding: 8px 9px; + display: flex; + align-items: flex-start; + gap: 7px; + border: 1px solid var(--line); + border-radius: 5px; + color: var(--text-muted); + background: var(--surface-0); + font-size: 11px; + line-height: 1.4; +} +.commit-readiness.blocked { + border-color: color-mix(in srgb, var(--warning) 35%, var(--line)); + background: var(--warning-soft); + color: var(--text); +} +.commit-readiness.ready { + border-color: color-mix(in srgb, var(--success) 35%, var(--line)); + background: var(--success-soft); + color: var(--text); +} +.commit-readiness .icon { + flex: 0 0 auto; + margin-top: 1px; +} +.stage-note { + margin-top: 10px; + color: var(--text-faint); + font-size: 10px; + line-height: 1.45; +} -.update-card { margin-top: 12px; padding: 12px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface-0); } -.update-card.available { border-color: color-mix(in srgb, var(--primary) 45%, var(--line)); background: var(--primary-soft); } -.update-card > div:first-child { display: flex; align-items: center; gap: 10px; min-width: 0; } -.update-card > div:first-child > span { min-width: 0; display: grid; gap: 2px; } -.update-card small, .server-card small { color: var(--text-faint); } -.server-list { display: grid; gap: 8px; } -.server-card { padding: 11px 12px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface-0); } -.server-card-main { min-width: 0; display: flex; align-items: center; gap: 10px; } -.server-card-main > div { min-width: 0; display: grid; gap: 2px; } -.server-card-main span { color: var(--text-muted); font-family: var(--font-mono); font-size: 10px; overflow: hidden; text-overflow: ellipsis; } -.provider-choice { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; margin-bottom: 14px; } -.provider-note { padding: 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-0); color: var(--text-muted); font-size: 11px; } -.server-inspection { margin-top: 12px; } +.update-card { + margin-top: 12px; + padding: 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + border: 1px solid var(--line); + border-radius: 7px; + background: var(--surface-0); +} +.update-card.available { + border-color: color-mix(in srgb, var(--primary) 45%, var(--line)); + background: var(--primary-soft); +} +.update-card > div:first-child { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} +.update-card > div:first-child > span { + min-width: 0; + display: grid; + gap: 2px; +} +.update-card small, +.server-card small { + color: var(--text-faint); +} +.server-list { + display: grid; + gap: 8px; +} +.server-card { + padding: 11px 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + border: 1px solid var(--line); + border-radius: 7px; + background: var(--surface-0); +} +.server-card-main { + min-width: 0; + display: flex; + align-items: center; + gap: 10px; +} +.server-card-main > div { + min-width: 0; + display: grid; + gap: 2px; +} +.server-card-main span { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 10px; + overflow: hidden; + text-overflow: ellipsis; +} +.provider-choice { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 14px; +} +.provider-note { + padding: 10px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface-0); + color: var(--text-muted); + font-size: 11px; +} +.server-inspection { + margin-top: 12px; +} @media (max-width: 1240px) { - .wordmark small { display: none; } - .update-card, .server-card { align-items: flex-start; flex-direction: column; } + .wordmark small { + display: none; + } + .update-card, + .server-card { + align-items: flex-start; + flex-direction: column; + } } -.setup-brand-logo { width: 180px; max-height: 76px; object-fit: contain; object-position: left center; display: block; margin-bottom: 12px; } -.setup-brand-logo-light { display: none; } -html[data-theme="light"] .setup-brand-logo-dark { display: none; } -html[data-theme="light"] .setup-brand-logo-light { display: block; } - +.setup-brand-logo { + width: 180px; + max-height: 76px; + object-fit: contain; + object-position: left center; + display: block; + margin-bottom: 12px; +} +.setup-brand-logo-light { + display: none; +} +html[data-theme="light"] .setup-brand-logo-dark { + display: none; +} +html[data-theme="light"] .setup-brand-logo-light { + display: block; +} /* v0.5 viewport-safe dialogs */ -.modal-body > .preflight-list:last-child { margin-bottom: 2px; } -.modal-footer .button { flex: 0 0 auto; } +.modal-body > .preflight-list:last-child { + margin-bottom: 2px; +} +.modal-footer .button { + flex: 0 0 auto; +} @media (max-height: 720px) { - .modal-backdrop { align-items: start; } - .modal { max-height: calc(100dvh - 16px); } - .modal-header { padding-block: 11px; } - .modal-body { padding-block: 13px; } - .modal-footer { padding-block: 10px; } + .modal-backdrop { + align-items: start; + } + .modal { + max-height: calc(100dvh - 16px); + } + .modal-header { + padding-block: 11px; + } + .modal-body { + padding-block: 13px; + } + .modal-footer { + padding-block: 10px; + } } @media (max-width: 680px) { - .modal-backdrop { padding: 0; align-items: stretch; } - .modal, .wide-modal { width: 100vw; max-height: 100dvh; border-radius: 0; } - .modal-footer .modal-spacer { display: none; } - .modal-footer .button { flex: 1 1 auto; } - .form-grid { grid-template-columns: 1fr; } - .field.full, .check-field.full { grid-column: 1; } + .modal-backdrop { + padding: 0; + align-items: stretch; + } + .modal, + .wide-modal { + width: 100vw; + max-height: 100dvh; + border-radius: 0; + } + .modal-footer .modal-spacer { + display: none; + } + .modal-footer .button { + flex: 1 1 auto; + } + .form-grid { + grid-template-columns: 1fr; + } + .field.full, + .check-field.full { + grid-column: 1; + } } /* ForgeFlow 0.6 recovery and DockerMan controls */ -.git-tools-grid .troubleshooting-panel { grid-column: 1 / -1; } -.troubleshooting-summary { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; color: var(--text-muted); } -.text-success { color: var(--success) !important; } -.text-warning { color: var(--warning) !important; } -.deploy-card .card-actions { flex-wrap: wrap; } -.field small { display: block; margin-top: 5px; color: var(--text-faint); line-height: 1.35; } +.git-tools-grid .troubleshooting-panel { + grid-column: 1 / -1; +} +.troubleshooting-summary { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; + color: var(--text-muted); +} +.text-success { + color: var(--success) !important; +} +.text-warning { + color: var(--warning) !important; +} +.deploy-card .card-actions { + flex-wrap: wrap; +} +.field small { + display: block; + margin-top: 5px; + color: var(--text-faint); + line-height: 1.35; +} +.repo-quick-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin: 0 20px 12px; +} +.repo-quick-actions .status-pill { + margin-left: auto; +} +@media (max-width: 760px) { + .repo-quick-actions { + margin-inline: 12px; + } + .repo-quick-actions .button { + flex: 1 1 145px; + } + .repo-quick-actions .status-pill { + width: 100%; + margin-left: 0; + justify-content: center; + } +} + +/* ForgeFlow 0.8 premium visual system */ +:root { + --bg: #080b12; + --surface-0: #0b1019; + --surface-1: #101722; + --surface-2: #16202e; + --surface-3: #1c2939; + --surface-hover: #172434; + --line: rgba(148, 163, 184, 0.17); + --line-soft: rgba(148, 163, 184, 0.09); + --text: #f3f7fc; + --text-muted: #a8b4c5; + --text-faint: #718096; + --primary: #6ee7f9; + --primary-strong: #0ea5e9; + --primary-soft: rgba(14, 165, 233, 0.13); + --success: #5ee5b0; + --warning: #f4c56a; + --danger: #ff8585; + --shadow: 0 24px 80px rgba(0, 0, 0, 0.42); + --shadow-soft: 0 10px 36px rgba(0, 0, 0, 0.2); + --radius: 12px; + --sidebar: 292px; + --action-panel: 368px; +} + +html[data-theme="light"] { + --bg: #edf2f7; + --surface-0: #f5f8fb; + --surface-1: rgba(255, 255, 255, 0.94); + --surface-2: #f1f5f9; + --surface-3: #e7eef6; + --surface-hover: #edf4fa; + --line: rgba(51, 65, 85, 0.16); + --line-soft: rgba(51, 65, 85, 0.08); + --text: #101828; + --text-muted: #526277; + --text-faint: #7d899a; + --primary: #0284c7; + --primary-strong: #0369a1; + --primary-soft: rgba(2, 132, 199, 0.1); + --shadow: 0 24px 72px rgba(30, 41, 59, 0.16); + --shadow-soft: 0 8px 30px rgba(30, 41, 59, 0.08); +} + +body { + letter-spacing: -0.005em; + background: + radial-gradient( + circle at 78% -12%, + rgba(14, 165, 233, 0.1), + transparent 34% + ), + var(--bg); +} + +.app-shell { + grid-template-rows: 56px minmax(0, 1fr) 28px; + background: transparent; +} + +.titlebar { + padding-inline: 18px 14px; + border-bottom-color: var(--line-soft); + background: color-mix(in srgb, var(--surface-1) 91%, transparent); + backdrop-filter: blur(18px) saturate(145%); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.025); +} + +.wordmark { + font-size: 15px; + font-weight: 760; +} + +.brand-logo { + width: 34px; + filter: drop-shadow(0 0 14px rgba(110, 231, 249, 0.2)); +} + +.workspace-name { + color: var(--text-muted); + font-weight: 560; +} + +.global-search, +.repo-filter, +.input, +input, +textarea, +select { + border-radius: 9px; + border-color: var(--line); + transition: + border-color 150ms ease, + box-shadow 150ms ease, + background 150ms ease; +} + +.global-search:focus, +.repo-filter:focus, +.input:focus, +input:focus, +textarea:focus, +select:focus { + border-color: color-mix(in srgb, var(--primary) 58%, var(--line)); + box-shadow: 0 0 0 3px var(--primary-soft); +} + +.connection-chip { + min-height: 31px; + padding-inline: 10px; + border-radius: 999px; + background: color-mix(in srgb, var(--surface-2) 82%, transparent); +} + +.sidebar { + border-right-color: var(--line-soft); + background: + linear-gradient(180deg, rgba(110, 231, 249, 0.025), transparent 28%), + color-mix(in srgb, var(--surface-1) 96%, var(--bg)); +} + +.primary-nav { + padding: 14px 10px 12px; + gap: 4px; +} + +.primary-nav button, +.repo-row { + border-radius: 9px; + transition: + color 150ms ease, + background 150ms ease, + transform 150ms ease; +} + +.primary-nav button { + min-height: 38px; + padding-inline: 11px; +} + +.primary-nav button.active { + color: var(--text); + background: linear-gradient( + 90deg, + rgba(14, 165, 233, 0.19), + rgba(14, 165, 233, 0.07) + ); + box-shadow: + inset 2px 0 var(--primary), + inset 0 0 0 1px rgba(110, 231, 249, 0.08); +} + +.repo-row { + margin: 1px 6px; + padding: 8px 9px; +} + +.repo-row:hover { + transform: translateX(2px); +} + +.repo-row.active { + background: linear-gradient( + 90deg, + rgba(14, 165, 233, 0.16), + rgba(14, 165, 233, 0.045) + ); + box-shadow: inset 0 0 0 1px rgba(110, 231, 249, 0.1); +} + +.sidebar-section > span, +.repo-group-label, +.eyebrow { + letter-spacing: 0.12em; + font-weight: 780; +} + +.page { + max-width: 1640px; + margin-inline: auto; + padding: 30px 34px 48px; +} + +.page-header { + margin-bottom: 26px; +} + +.page-header h1 { + font-size: clamp(24px, 2vw, 30px); + font-weight: 735; + letter-spacing: -0.04em; +} + +.page-header p { + margin-top: 8px; + font-size: 13px; + line-height: 1.6; +} + +.button, +.icon-button { + border-radius: 8px; + transition: + transform 140ms ease, + background 140ms ease, + border-color 140ms ease, + box-shadow 140ms ease, + color 140ms ease; +} + +.button { + min-height: 34px; + padding-inline: 13px; + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--surface-3) 82%, transparent), + var(--surface-2) + ); + box-shadow: + inset 0 1px rgba(255, 255, 255, 0.035), + 0 1px 2px rgba(0, 0, 0, 0.14); +} + +.button:hover:not(:disabled), +.icon-button:hover:not(:disabled) { + transform: translateY(-1px); +} + +.button.primary { + background: linear-gradient(135deg, #0ea5e9, #2563eb); + border-color: rgba(110, 231, 249, 0.34); + box-shadow: + 0 8px 24px rgba(14, 165, 233, 0.2), + inset 0 1px rgba(255, 255, 255, 0.18); +} + +.button.primary:hover:not(:disabled) { + filter: brightness(1.08); + box-shadow: + 0 10px 30px rgba(14, 165, 233, 0.27), + inset 0 1px rgba(255, 255, 255, 0.2); +} + +.summary-grid { + gap: 12px; + border: 0; + overflow: visible; + background: transparent; +} + +.summary-card { + min-height: 130px; + padding: 18px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: + linear-gradient(145deg, rgba(255, 255, 255, 0.035), transparent 46%), + var(--surface-1); + box-shadow: var(--shadow-soft); + overflow: hidden; +} + +.summary-card::after { + content: ""; + position: absolute; + inset: auto -24px -42px auto; + width: 96px; + height: 96px; + border-radius: 50%; + background: currentColor; + opacity: 0.035; + filter: blur(3px); +} + +.summary-card:last-child { + border-right: 1px solid var(--line); +} + +.summary-value { + margin-top: 21px; + font-size: 34px; + font-weight: 760; +} + +.action-queue, +.panel { + border-color: var(--line); + box-shadow: var(--shadow-soft); + background: color-mix(in srgb, var(--surface-1) 96%, transparent); +} + +.queue-row { + min-height: 68px; + padding: 12px 15px; +} + +.queue-row:hover { + background: linear-gradient( + 90deg, + var(--surface-hover), + color-mix(in srgb, var(--surface-hover) 55%, transparent) + ); +} + +.queue-icon { + width: 32px; + height: 32px; + border-radius: 9px; +} + +.repo-header { + padding: 20px 22px 16px; + background: + linear-gradient(180deg, rgba(110, 231, 249, 0.025), transparent), + var(--surface-1); +} + +.repo-heading h1 { + font-size: 19px; + font-weight: 730; + letter-spacing: -0.03em; +} + +.repo-quick-actions { + margin: 12px 22px 14px; + padding: 10px; + border: 1px solid var(--line-soft); + border-radius: 11px; + background: color-mix(in srgb, var(--surface-2) 60%, transparent); +} + +.release-rail { + margin: 0 22px 12px; + border: 1px solid var(--line); + border-radius: 11px; + overflow: hidden; + background: var(--surface-0); +} + +.tabs { + padding-inline: 18px; + background: var(--surface-1); +} + +.tab.active { + color: var(--primary); + box-shadow: + inset 0 -2px var(--primary), + 0 5px 16px -12px var(--primary); +} + +.status-pill { + border-radius: 999px; + padding-inline: 9px; +} + +.modal-backdrop { + background: rgba(3, 7, 18, 0.74); + backdrop-filter: blur(10px) saturate(120%); +} + +.modal { + border-color: color-mix(in srgb, var(--line) 82%, var(--primary)); + border-radius: 16px; + box-shadow: + 0 36px 120px rgba(0, 0, 0, 0.62), + inset 0 1px rgba(255, 255, 255, 0.04); +} + +.modal-header { + background: + linear-gradient(180deg, rgba(110, 231, 249, 0.035), transparent), + var(--surface-1); +} + +.toast { + border-radius: 12px; + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.42); + backdrop-filter: blur(16px); +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} + +@media (max-width: 1180px) { + :root { + --sidebar: 264px; + --action-panel: 340px; + } + .page { + padding-inline: 24px; + } + .summary-grid { + gap: 8px; + } +} diff --git a/src/shared/deployment-policy.cjs b/src/shared/deployment-policy.cjs new file mode 100644 index 0000000..dff0f44 --- /dev/null +++ b/src/shared/deployment-policy.cjs @@ -0,0 +1,44 @@ +'use strict'; + +function parseClock(value) { + const match = String(value || '').match(/^([01]\d|2[0-3]):([0-5]\d)$/); + if (!match) throw new Error('Maintenance window times must use HH:mm.'); + return Number(match[1]) * 60 + Number(match[2]); +} + +function normalizeMaintenanceWindows(windows) { + return (Array.isArray(windows) ? windows : []).slice(0, 20).map((window) => ({ + days: [...new Set((Array.isArray(window?.days) ? window.days : []).map(Number).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6))], + start: String(window?.start || '00:00'), + end: String(window?.end || '23:59') + })).map((window) => ({ ...window, startMinutes: parseClock(window.start), endMinutes: parseClock(window.end) })); +} + +function isInsideWindow(window, date) { + const minutes = date.getHours() * 60 + date.getMinutes(); + if (window.startMinutes <= window.endMinutes) return window.days.includes(date.getDay()) && minutes >= window.startMinutes && minutes <= window.endMinutes; + if (minutes >= window.startMinutes) return window.days.includes(date.getDay()); + const previousDay = (date.getDay() + 6) % 7; + return minutes <= window.endMinutes && window.days.includes(previousDay); +} + +function evaluateDeploymentPolicy(profile, { now = new Date(), override = false, reason = '', note = '' } = {}) { + const cleanReason = String(reason || '').trim(); + const cleanNote = String(note || '').trim(); + const policy = profile?.deploymentPolicy || {}; + const windows = normalizeMaintenanceWindows(policy.maintenanceWindows); + const violations = []; + if (policy.frozen) violations.push(policy.freezeReason ? `Deployment frozen: ${policy.freezeReason}` : 'Deployment is frozen.'); + if (windows.length && !windows.some((window) => isInsideWindow(window, now))) violations.push('Current time is outside the configured maintenance windows.'); + if (policy.requireNote && !cleanNote) violations.push('A release note is required for this environment.'); + if (violations.length && override && !cleanReason) throw new Error('An override reason is required to bypass deployment policy.'); + if (violations.length && !override) { + const error = new Error(violations.join(' ')); + error.code = 'DEPLOYMENT_POLICY_BLOCKED'; + error.recoverable = true; + throw error; + } + return { allowed: true, overridden: violations.length > 0, violations, reason: cleanReason, note: cleanNote }; +} + +module.exports = { parseClock, normalizeMaintenanceWindows, isInsideWindow, evaluateDeploymentPolicy }; diff --git a/src/shared/validation.cjs b/src/shared/validation.cjs index 3ca1bcd..be5c1ed 100644 --- a/src/shared/validation.cjs +++ b/src/shared/validation.cjs @@ -87,14 +87,15 @@ function assertDeploymentRequest(profile, sha) { assertHttpUrl(profile.statusUrl, { label: 'Application status URL' }); } -function assertHttpUrl(value, { optional = false, label = 'URL' } = {}) { +function assertHttpUrl(value, { optional = false, label = 'URL', allowUnraidTemplate = false } = {}) { const raw = String(value || '').trim(); if (!raw && optional) return ''; if (!raw) throw new Error(`${label} is required.`); - const url = new URL(raw); + const validationValue = allowUnraidTemplate ? raw.replace(/\[IP\]/gi, '127.0.0.1').replace(/\[PORT(?::\d+)?\]/gi, '8080') : raw; + const url = new URL(validationValue); if (!['http:', 'https:'].includes(url.protocol)) throw new Error(`${label} must use HTTP or HTTPS.`); if (url.username || url.password) throw new Error(`${label} may not contain credentials.`); - return url.toString(); + return allowUnraidTemplate ? raw : url.toString(); } function assertCloneRemote(value) { diff --git a/tests/acceptance.test.mjs b/tests/acceptance.test.mjs new file mode 100644 index 0000000..607f683 --- /dev/null +++ b/tests/acceptance.test.mjs @@ -0,0 +1,11 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readAcceptanceConfig } from '../scripts/acceptance.mjs'; + +test('acceptance harness requires an explicit complete environment', () => { + assert.throws(() => readAcceptanceConfig({}), /Missing acceptance environment variables/); + const config = readAcceptanceConfig({ FORGEFLOW_GITEA_URL: 'https://gitea.test/', FORGEFLOW_GITEA_TOKEN: 'token', FORGEFLOW_REPOSITORY: 'owner/app', FORGEFLOW_LOCAL_PATH: 'C:/Projects/App', FORGEFLOW_BRANCH: 'main', FORGEFLOW_STATUS_URL: 'https://app.test/status', FORGEFLOW_HEALTH_URL: 'https://app.test/health' }); + assert.equal(config.baseUrl, 'https://gitea.test'); + assert.equal(config.workflow, 'deploy.yml'); + assert.throws(() => readAcceptanceConfig({ ...process.env, FORGEFLOW_GITEA_URL: 'x', FORGEFLOW_GITEA_TOKEN: 'x', FORGEFLOW_REPOSITORY: 'invalid', FORGEFLOW_LOCAL_PATH: 'x', FORGEFLOW_BRANCH: 'x', FORGEFLOW_STATUS_URL: 'x', FORGEFLOW_HEALTH_URL: 'x' }), /owner\/repository/); +}); diff --git a/tests/audit-service.test.mjs b/tests/audit-service.test.mjs new file mode 100644 index 0000000..9e5612d --- /dev/null +++ b/tests/audit-service.test.mjs @@ -0,0 +1,25 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import auditModule from '../src/main/audit-service.cjs'; + +const { AuditService } = auditModule; + +test('audit service appends ordered records and exports CSV', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-audit-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const audit = new AuditService({ userDataPath: root, appInfo: { version: 'test' } }); + await Promise.all([ + audit.append('deployment.requested', { repository: 'owner/app', sha: 'a'.repeat(40), note: 'Release, wave 1' }), + audit.append('deployment.completed', { repository: 'owner/app', result: 'success' }) + ]); + const entries = await audit.list(); + assert.equal(entries.length, 2); + assert.equal(entries[0].event, 'deployment.completed'); + const destination = path.join(root, 'audit.csv'); + const result = await audit.exportTo(destination, 'csv'); + assert.equal(result.count, 2); + assert.match(await fs.readFile(destination, 'utf8'), /"Release, wave 1"/); +}); diff --git a/tests/configuration-backup.test.mjs b/tests/configuration-backup.test.mjs new file mode 100644 index 0000000..0784ccd --- /dev/null +++ b/tests/configuration-backup.test.mjs @@ -0,0 +1,27 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import backupModule from '../src/main/configuration-backup.cjs'; + +const { sanitizeConfiguration, createEncryptedBackup, readEncryptedBackup } = backupModule; + +test('configuration backups exclude credentials and operation history', () => { + const clean = sanitizeConfiguration({ + gitea: { baseUrl: 'https://gitea.test', encryptedToken: 'secret-token' }, + servers: [{ id: 'server', host: 'unraid.test', encryptedPassword: 'password', encryptedPassphrase: 'passphrase' }], + operations: [{ id: 'operation', sha: 'a'.repeat(40) }], + preferences: { autoRefresh: true } + }); + assert.equal(clean.gitea.encryptedToken, null); + assert.equal('encryptedPassword' in clean.servers[0], false); + assert.equal('encryptedPassphrase' in clean.servers[0], false); + assert.deepEqual(clean.operations, []); +}); + +test('configuration backups round-trip with authenticated encryption', () => { + const serialized = createEncryptedBackup({ workspaceRoots: ['C:/Projects'], gitea: { encryptedToken: 'secret' } }, 'correct horse battery staple'); + assert.doesNotMatch(serialized, /C:\/Projects|secret/); + const restored = readEncryptedBackup(serialized, 'correct horse battery staple'); + assert.deepEqual(restored.configuration.workspaceRoots, ['C:/Projects']); + assert.equal(restored.configuration.gitea.encryptedToken, null); + assert.throws(() => readEncryptedBackup(serialized, 'incorrect passphrase'), /could not be decrypted/i); +}); diff --git a/tests/deployment-policy.test.mjs b/tests/deployment-policy.test.mjs new file mode 100644 index 0000000..374d823 --- /dev/null +++ b/tests/deployment-policy.test.mjs @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import policyModule from '../src/shared/deployment-policy.cjs'; + +const { evaluateDeploymentPolicy } = policyModule; + +test('deployment freeze and maintenance windows fail closed with reasoned overrides', () => { + const profile = { deploymentPolicy: { frozen: true, freezeReason: 'Incident', requireNote: true, maintenanceWindows: [{ days: [1], start: '09:00', end: '10:00' }] } }; + const now = new Date(2026, 6, 28, 12, 0); // Tuesday + assert.throws(() => evaluateDeploymentPolicy(profile, { now, note: 'Release' }), (error) => error.code === 'DEPLOYMENT_POLICY_BLOCKED'); + assert.throws(() => evaluateDeploymentPolicy(profile, { now, note: 'Release', override: true }), /override reason/i); + const result = evaluateDeploymentPolicy(profile, { now, note: 'Release', override: true, reason: 'Emergency recovery' }); + assert.equal(result.overridden, true); + assert.equal(result.violations.length, 2); +}); + +test('deployment policy accepts an in-window release with required note', () => { + const now = new Date(2026, 6, 27, 9, 30); // Monday + const profile = { deploymentPolicy: { requireNote: true, maintenanceWindows: [{ days: [1], start: '09:00', end: '10:00' }] } }; + assert.equal(evaluateDeploymentPolicy(profile, { now, note: 'Version 1.2' }).allowed, true); + assert.throws(() => evaluateDeploymentPolicy(profile, { now }), /release note/i); +}); + +test('overnight maintenance windows continue into the following day', () => { + const profile = { deploymentPolicy: { maintenanceWindows: [{ days: [1], start: '22:00', end: '02:00' }] } }; + assert.equal(evaluateDeploymentPolicy(profile, { now: new Date(2026, 6, 27, 23, 0) }).allowed, true); + assert.equal(evaluateDeploymentPolicy(profile, { now: new Date(2026, 6, 28, 1, 0) }).allowed, true); + assert.throws(() => evaluateDeploymentPolicy(profile, { now: new Date(2026, 6, 28, 3, 0) }), /outside/); +}); diff --git a/tests/external-tools.test.mjs b/tests/external-tools.test.mjs new file mode 100644 index 0000000..36e9ca9 --- /dev/null +++ b/tests/external-tools.test.mjs @@ -0,0 +1,13 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import toolsModule from '../src/main/external-tools-service.cjs'; + +const { normalizeTool, expandTool } = toolsModule; + +test('external tool templates expand as argument arrays without a shell', () => { + const tool = normalizeTool({ executable: 'code.exe', args: ['--goto', '{file}:{line}', '{path}'] }, {}); + const invocation = expandTool(tool, { path: 'C:\\Projects\\App', file: 'C:\\Projects\\App\\src\\app.js', line: 12 }); + assert.equal(invocation.executable, 'code.exe'); + assert.deepEqual(invocation.args, ['--goto', 'C:\\Projects\\App\\src\\app.js:12', 'C:\\Projects\\App']); + assert.throws(() => normalizeTool({ executable: 'code.exe\ncalc.exe', args: [] }, {}), /invalid/); +}); diff --git a/tests/git-integration.test.mjs b/tests/git-integration.test.mjs index 252786e..b8a488e 100644 --- a/tests/git-integration.test.mjs +++ b/tests/git-integration.test.mjs @@ -243,3 +243,30 @@ test('repairs a diverged branch by creating a safety branch before resetting to const backupSha = (await git(['rev-parse', repaired.backupBranch], working)).stdout.trim(); assert.equal(backupSha, localBefore); }); + +test('troubleshooter detects and aborts an interrupted merge without discarding committed history', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-interrupted-merge-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + await git(['init'], root); + await git(['config', 'user.name', 'ForgeFlow Test'], root); + await git(['config', 'user.email', 'forgeflow@example.invalid'], root); + await fs.writeFile(path.join(root, 'file.txt'), 'base\n'); + await git(['add', '.'], root); + await git(['commit', '-m', 'Base'], root); + await git(['checkout', '-b', 'other'], root); + await fs.writeFile(path.join(root, 'file.txt'), 'other\n'); + await git(['commit', '-am', 'Other'], root); + await git(['checkout', 'master'], root); + await fs.writeFile(path.join(root, 'file.txt'), 'main\n'); + await git(['commit', '-am', 'Main'], root); + await assert.rejects(git(['merge', 'other'], root)); + + const service = new GitService(); + assert.equal(await service.detectInterruptedOperation(root), 'merge'); + const result = await service.abortInterruptedOperation(root); + assert.equal(result.aborted, 'merge'); + assert.equal(await service.detectInterruptedOperation(root), null); + assert.equal(result.status.clean, true); + const subject = await git(['log', '-1', '--pretty=%s'], root); + assert.equal(subject.stdout.trim(), 'Main'); +}); diff --git a/tests/gitea-actions.test.mjs b/tests/gitea-actions.test.mjs index 680d9fb..1c4dc80 100644 --- a/tests/gitea-actions.test.mjs +++ b/tests/gitea-actions.test.mjs @@ -88,3 +88,22 @@ test('checks repository workflow files through the contents API', async () => { service.request = async () => { const error = new Error('missing'); error.status = 404; throw error; }; assert.equal(await service.repositoryFileExists({ owner: 'jens', repo: 'app', filePath: '.gitea/workflows/missing.yml', ref: 'main' }), false); }); + +test('creates controlled pull requests and reads branch protection', async () => { + const service = new GiteaService(makeStore()); + const calls = []; + service.request = async (pathname, options = {}) => { + calls.push({ pathname, options }); + if (pathname.includes('/branches/main')) return { data: { name: 'main', protected: true } }; + if (pathname.endsWith('/branch_protections')) return { data: [{ branch_name: 'main', required_approvals: 2, require_signed_commits: true }] }; + return { data: { number: 12, html_url: 'https://gitea.test/owner/app/pulls/12' } }; + }; + const protection = await service.getBranchProtection('owner', 'app', 'main'); + assert.equal(protection.protected, true); + assert.equal(protection.requiredApprovals, 2); + const pull = await service.createPullRequest({ owner: 'owner', repo: 'app', head: 'feature', base: 'main', title: 'Release feature', body: 'Summary' }); + assert.equal(pull.number, 12); + const create = calls.find((call) => call.options.method === 'POST'); + assert.deepEqual(create.options.body, { head: 'feature', base: 'main', title: 'Release feature', body: 'Summary' }); + await assert.rejects(() => service.createPullRequest({ owner: 'owner', repo: 'app', head: 'main', base: 'main', title: 'Invalid' }), /different/); +}); diff --git a/tests/ipc-contract.test.mjs b/tests/ipc-contract.test.mjs new file mode 100644 index 0000000..586d76f --- /dev/null +++ b/tests/ipc-contract.test.mjs @@ -0,0 +1,51 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +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 invokes = [...preload.matchAll(/invoke\(\s*['"]([^'"]+)['"]/g)].map( + (match) => match[1], + ); + const handlers = new Set( + [...ipc.matchAll(/register\(\s*['"]([^'"]+)['"]/g)].map( + (match) => match[1], + ), + ); + assert.ok(invokes.length > 40, "expected the complete renderer API surface"); + assert.deepEqual( + invokes.filter((channel) => !handlers.has(channel)), + [], + ); +}); + +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 preload = await readFile( + new URL("../preload.cjs", import.meta.url), + "utf8", + ); + const calls = new Set( + [...renderer.matchAll(/window\.forgeflow\.([A-Za-z0-9_]+)\s*\(/g)].map( + (match) => match[1], + ), + ); + const exposed = new Set( + [...preload.matchAll(/^\s{2}([A-Za-z0-9_]+):/gm)].map((match) => match[1]), + ); + assert.ok(calls.size > 40, "expected the complete renderer bridge surface"); + assert.deepEqual( + [...calls].filter((method) => !exposed.has(method)), + [], + ); +}); diff --git a/tests/partial-staging.test.mjs b/tests/partial-staging.test.mjs new file mode 100644 index 0000000..57fc1a8 --- /dev/null +++ b/tests/partial-staging.test.mjs @@ -0,0 +1,44 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import gitModule from '../src/main/git-service.cjs'; + +const exec = promisify(execFile); +const git = (args, cwd) => exec('git', args, { cwd, encoding: 'utf8' }); +const { GitService, parseUnifiedDiff } = gitModule; + +test('unified diff parser separates selectable hunks', () => { + const parsed = parseUnifiedDiff('diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1 +1 @@\n-old\n+new\n@@ -10 +10 @@\n-x\n+y\n'); + assert.equal(parsed.hunks.length, 2); + assert.equal(parsed.hunks[0].additions, 1); + assert.equal(parsed.hunks[1].deletions, 1); +}); + +test('stages only selected hunks using a server-generated patch', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-hunks-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + await git(['init'], root); + await git(['config', 'user.name', 'ForgeFlow Test'], root); + await git(['config', 'user.email', 'forgeflow@example.invalid'], root); + const original = [...Array(20)].map((_, index) => `line ${index + 1}`).join('\n') + '\n'; + await fs.writeFile(path.join(root, 'file.txt'), original); + await git(['add', '.'], root); await git(['commit', '-m', 'Initial'], root); + const lines = original.trimEnd().split('\n'); lines[0] = 'first changed'; lines[19] = 'last changed'; + await fs.writeFile(path.join(root, 'file.txt'), `${lines.join('\n')}\n`); + const service = new GitService(); + const hunks = await service.diffHunks(root, 'file.txt'); + assert.equal(hunks.hunks.length, 2); + await service.stageHunks(root, 'file.txt', [0]); + const staged = (await git(['diff', '--cached'], root)).stdout; + const unstaged = (await git(['diff'], root)).stdout; + assert.match(staged, /first changed/); assert.doesNotMatch(staged, /last changed/); + assert.match(unstaged, /last changed/); assert.doesNotMatch(unstaged, /first changed/); + await service.commitStaged(root, 'Commit reviewed hunk'); + const afterCommit = (await git(['diff'], root)).stdout; + assert.match(afterCommit, /last changed/); + assert.doesNotMatch(afterCommit, /first changed/); +}); diff --git a/tests/renderer-workflow.test.mjs b/tests/renderer-workflow.test.mjs index 847157c..eb107fc 100644 --- a/tests/renderer-workflow.test.mjs +++ b/tests/renderer-workflow.test.mjs @@ -1,46 +1,81 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; -test('changed file list has an independently scrollable bounded layout', async () => { - const css = await readFile(new URL('../src/renderer/styles.css', import.meta.url), 'utf8'); - assert.match(css, /\.main-canvas\.repository-canvas\s*\{[^}]*overflow:\s*hidden/); - assert.match(css, /\.file-panel\s*\{[^}]*min-height:\s*0[^}]*overflow:\s*hidden/); - assert.match(css, /\.file-list\s*\{[^}]*flex:\s*1 1 auto[^}]*overflow-y:\s*auto/); +test("changed file list has an independently scrollable bounded layout", async () => { + const css = await readFile( + new URL("../src/renderer/styles.css", import.meta.url), + "utf8", + ); + assert.match( + css, + /\.main-canvas\.repository-canvas\s*\{[^}]*overflow:\s*hidden/, + ); + assert.match( + css, + /\.file-panel\s*\{[^}]*min-height:\s*0[^}]*overflow:\s*hidden/, + ); + assert.match( + css, + /\.file-list\s*\{[^}]*flex:\s*1 1 auto[^}]*overflow-y:\s*auto/, + ); }); -test('commit workflow explains every disabled prerequisite', async () => { - const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8'); +test("commit workflow explains every disabled prerequisite", async () => { + const renderer = await readFile( + new URL("../src/renderer/app.js", import.meta.url), + "utf8", + ); 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/); - assert.match(renderer, /Commit selected & push to Gitea/); + assert.match(renderer, /data-action="commit-push"/); + assert.match(renderer, /Commit staged hunks/); }); -test('ITWorx branding is integrated into titlebar and setup', async () => { - const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8'); +test("ITWorx branding is integrated into titlebar and setup", async () => { + const renderer = await readFile( + new URL("../src/renderer/app.js", import.meta.url), + "utf8", + ); assert.match(renderer, /itworx-mark\.png/); assert.match(renderer, /itworx-wordmark-(?:light|dark)\.png/); }); - -test('all modal content stays inside the viewport with a persistent action footer', async () => { - const css = await readFile(new URL('../src/renderer/styles.css', import.meta.url), 'utf8'); - assert.match(css, /\.modal\s*\{[^}]*max-height:\s*calc\(100dvh[^}]*display:\s*flex[^}]*flex-direction:\s*column/); - assert.match(css, /\.modal-body\s*\{[^}]*min-height:\s*0[^}]*overflow-y:\s*auto/); +test("all modal content stays inside the viewport with a persistent action footer", async () => { + const css = await readFile( + new URL("../src/renderer/styles.css", import.meta.url), + "utf8", + ); + assert.match( + css, + /\.modal\s*\{[^}]*max-height:\s*calc\(100dvh[^}]*display:\s*flex[^}]*flex-direction:\s*column/, + ); + assert.match( + css, + /\.modal-body\s*\{[^}]*min-height:\s*0[^}]*overflow-y:\s*auto/, + ); assert.match(css, /\.modal-footer\s*\{[^}]*flex:\s*0 0 auto/); }); -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'); +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", + ); 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'); +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", + ); assert.match(ipc, /repositoryMutations = new Map/); assert.match(ipc, /withRepositoryMutation/); assert.match(ipc, /GIT_LOCKS_RECENT/); @@ -51,36 +86,122 @@ test('Git mutations are serialized per repository and expose repair actions', as assert.match(renderer, /Open guided repository repair/); }); - -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 passwordCapture = renderer.indexOf("const password = document.querySelector('#server-password')"); - const loading = renderer.indexOf("setLoading(true, 'Saving encrypted SSH configuration…')"); +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 passwordCapture = renderer.search( + /const password = document\.querySelector\(["']#server-password["']\)/, + ); + const loading = renderer.search( + /setLoading\(true, ["']Saving encrypted SSH configuration/, + ); assert.ok(passwordCapture >= 0 && loading > passwordCapture); }); -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'); +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", + ); assert.match(renderer, /function startOperationPolling\(\)/); assert.match(renderer, /startOperationPolling\(\);/); assert.match(renderer, /Visible container name/); assert.match(renderer, /Compose service \(internal\)/); }); - -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'); +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", + ); assert.match(renderer, /Built-in high-contrast ITWorx mark/); assert.match(renderer, /profile-icon-mode/); assert.match(renderer, /Repair DockerMan integration/); assert.match(renderer, /reconcile-deployment/); }); -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'); +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", + ); 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 preload = await readFile( + new URL("../preload.cjs", import.meta.url), + "utf8", + ); + for (const phrase of [ + "Stage hunks", + "Conflict guide", + "Create pull request", + "Open pull requests", + "load-pull-requests", + "Check branch protection", + "Encrypted configuration backup", + "Deployment policy", + "Operational audit log", + ]) + assert.match(renderer, new RegExp(phrase, "i")); + for (const method of [ + "stageHunks", + "resolveConflict", + "createPullRequest", + "branchProtection", + "openEditor", + "openTerminal", + "exportConfigurationBackup", + "listAuditEvents", + ]) + assert.match(preload, new RegExp(`${method}:`)); +}); + +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 ipc = await readFile( + new URL("../src/main/ipc.cjs", import.meta.url), + "utf8", + ); + assert.match(ipc, /action: 'abort-operation', safe: false/); + assert.match(ipc, /action: 'push', safe: false/); + assert.match(ipc, /const stale = lock\.ageMs >= 10_000/); + assert.match(ipc, /\['fast-forward', 'fetch'\]\.includes\(issue\.action\)/); + assert.doesNotMatch( + ipc, + /\['fast-forward', 'push', 'fetch'\]\.includes\(issue\.action\)/, + ); + assert.match( + renderer, + /trouble\?\.issues\?\.some\(\(item\) => item\.repairable && item\.safe\)/, + ); +}); + +test("premium repository workspace reserves separate rows for actions and release status", async () => { + const styles = await readFile( + new URL("../src/renderer/styles.css", import.meta.url), + "utf8", + ); + assert.match( + styles, + /\.repo-workspace\s*\{[^}]*grid-template-rows:\s*auto auto auto 39px minmax\(0, 1fr\)/s, + ); + assert.match(styles, /prefers-reduced-motion/); + assert.match(styles, /ForgeFlow 0\.8 premium visual system/); +}); diff --git a/tests/unraid-deployment.test.mjs b/tests/unraid-deployment.test.mjs index 5ee4efd..7f5093e 100644 --- a/tests/unraid-deployment.test.mjs +++ b/tests/unraid-deployment.test.mjs @@ -265,3 +265,39 @@ test('stuck deployment is cleared as superseded when a different healthy commit assert.match(result.error, /Superseded/); assert.equal(saved.at(-1).status, 'cancelled'); }); + +test('existing Unraid deployment discovery derives profile values from Docker, Compose and DockerMan truth', () => { + const { deriveDetectedProfile } = require('../src/main/unraid-deployment-service.cjs'); + const result = deriveDetectedProfile({ + repository: { name: 'blockpilot-autonomous', defaultBranch: 'main', sshUrl: 'ssh://git@gitea/Jens/blockpilot-autonomous.git' }, + server: { id: 'unraid', host: '192.168.10.150' }, + remoteFolder: 'blockpilot-autonomous', + remotePath: '/mnt/user/appdata/blockpilot-autonomous', + payload: { + head: 'a'.repeat(40), + branch: 'main', + remote: 'ssh://git@gitea/Jens/blockpilot-autonomous.git', + composeFiles: ['compose.yml'], + compose: { services: { app: { image: 'blockpilot:test' } } }, + containers: [{ + Name: '/blockpilot', + State: { Running: true }, + Config: { Image: 'blockpilot:test', Env: ['TOKEN=secret', 'MODE=prod'], Labels: { 'com.docker.compose.service': 'app', 'com.docker.compose.project': 'blockpilot' } }, + HostConfig: { RestartPolicy: { Name: 'unless-stopped' } }, + NetworkSettings: { Ports: { '8080/tcp': [{ HostIp: '0.0.0.0', HostPort: '1223' }] }, Networks: { bridge: {} } }, + Mounts: [{ Type: 'bind', Source: '/mnt/user/appdata/blockpilot-autonomous/data', Destination: '/data', RW: true }] + }], + dockerManXml: 'blockpilothttp://[IP]:[PORT:1223]/https://example.test/icon.png/bin/bash' + } + }); + assert.equal(result.profile.hostPort, 1223); + assert.equal(result.profile.containerPort, 8080); + assert.equal(result.profile.containerName, 'blockpilot'); + assert.equal(result.profile.composeService, 'app'); + assert.equal(result.profile.webUiUrl, 'http://[IP]:[PORT:1223]/'); + assert.equal(result.profile.iconUrl, 'https://example.test/icon.png'); + assert.equal(result.profile.dockerShell, '/bin/bash'); + assert.deepEqual(result.profile.detectedMetadata.envNames, ['TOKEN', 'MODE']); + assert.ok(result.profile.preservePaths.includes('data')); + assert.equal(result.provenance.hostPort.origin, 'docker-inspect'); +}); diff --git a/tests/validation.test.mjs b/tests/validation.test.mjs index c40e79c..e1c64fb 100644 --- a/tests/validation.test.mjs +++ b/tests/validation.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import validation from '../src/shared/validation.cjs'; -const { normalizeBaseUrl, assertCommitMessage, assertDeploymentRequest } = validation; +const { normalizeBaseUrl, assertCommitMessage, assertDeploymentRequest, assertHttpUrl } = validation; test('normalizes Gitea base URL', () => { assert.equal(normalizeBaseUrl('https://gitea.example.com/'), 'https://gitea.example.com'); @@ -15,3 +15,8 @@ test('rejects blank commit messages', () => { test('requires exact SHA and workflow profile', () => { assert.throws(() => assertDeploymentRequest({ branch: 'main', workflowFile: 'deploy.yml' }, 'nope'), /commit SHA/i); }); + +test('accepts Unraid DockerMan WebUI placeholders only when explicitly enabled', () => { + assert.equal(assertHttpUrl('http://[IP]:[PORT:1223]/', { allowUnraidTemplate: true }), 'http://[IP]:[PORT:1223]/'); + assert.throws(() => assertHttpUrl('http://[IP]:[PORT:1223]/')); +}); diff --git a/update-windows.ps1 b/update-windows.ps1 index f448d1d..04b884c 100644 --- a/update-windows.ps1 +++ b/update-windows.ps1 @@ -4,47 +4,32 @@ Set-Location $PSScriptRoot function Assert-Command { param([Parameter(Mandatory = $true)][string]$Name) - if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { - throw "Required command '$Name' was not found on PATH." - } + if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { throw "Required command '$Name' was not found on PATH." } } - function Invoke-Step { - param( - [Parameter(Mandatory = $true)][string]$Title, - [Parameter(Mandatory = $true)][scriptblock]$Action - ) + param([Parameter(Mandatory = $true)][string]$Title, [Parameter(Mandatory = $true)][scriptblock]$Action) Write-Host "`n$Title" -ForegroundColor Yellow & $Action - if ($LASTEXITCODE -ne 0) { - throw "$Title failed with exit code $LASTEXITCODE." - } + if ($LASTEXITCODE -ne 0) { throw "$Title failed with exit code $LASTEXITCODE." } } -Write-Host "ForgeFlow v0.4.2 source update" -ForegroundColor Cyan -Write-Host "Your Gitea token, repository mappings and deployment profiles are stored outside this source folder and are not reset." -ForegroundColor DarkGray +$package = Get-Content ".\package.json" -Raw | ConvertFrom-Json +$version = [string]$package.version +if ($package.name -ne "forgeflow" -or [string]::IsNullOrWhiteSpace($version)) { throw "This folder is not a valid ForgeFlow source release." } +Write-Host "ForgeFlow v$version source update" -ForegroundColor Cyan +Write-Host "Your Gitea token, repository mappings and deployment profiles are stored outside this source folder and are not reset." -ForegroundColor DarkGray Assert-Command node Assert-Command npm Assert-Command git -$package = Get-Content ".\package.json" -Raw | ConvertFrom-Json -if ($package.version -ne "0.4.2") { - throw "This folder does not contain ForgeFlow v0.4.2. Detected version: $($package.version)" +Invoke-Step "Installing exact project dependencies..." { + if (-not (Test-Path ".\package-lock.json")) { throw "package-lock.json is required for a reproducible ForgeFlow update." } + npm ci --no-audit --no-fund } +Invoke-Step "Running the environment doctor..." { npm run doctor } +Invoke-Step "Running source verification and automated tests..." { npm run check } -Invoke-Step "Updating project dependencies..." { - npm install --no-audit --no-fund -} - -Invoke-Step "Running the environment doctor..." { - npm run doctor -} - -Invoke-Step "Running source verification and all automated tests..." { - npm run check -} - -Write-Host "`nForgeFlow v0.4.2 is ready." -ForegroundColor Green +Write-Host "`nForgeFlow v$version is ready." -ForegroundColor Green Write-Host "Starting ForgeFlow with your existing local configuration..." -ForegroundColor Green npm start