From 4ad698c4ebf14b0e4ce38465a94a54fca3274327 Mon Sep 17 00:00:00 2001
From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com>
Date: Sun, 26 Jul 2026 00:42:17 +0200
Subject: [PATCH] Release ForgeFlow 0.8.1
Add advanced Git and deployment workflows, secure backups and auditing, live Gitea integration, desktop notifications, connection validation, and the premium responsive UX refresh.
---
CHANGELOG.md | 23 +
README.md | 6 +
SOURCE_MANIFEST.txt | 133 +-
build-windows.ps1 | 2 +-
docs/ACCEPTANCE.md | 20 +
docs/ARCHITECTURE.md | 4 +-
docs/RELEASE_NOTES_0.7.0.md | 47 +
docs/RELEASE_NOTES_0.8.0.md | 10 +
docs/RELEASE_NOTES_0.8.1.md | 13 +
docs/ROADMAP.md | 11 +
docs/TEST_MATRIX.md | 13 +
main.cjs | 97 +-
package-lock.json | 4 +-
package.json | 18 +-
preload.cjs | 23 +-
scripts/acceptance.mjs | 88 +
scripts/doctor.mjs | 13 +-
scripts/generate-source-manifest.mjs | 37 +
scripts/validate-installed-connections.cjs | 94 +
scripts/verify.mjs | 421 ++-
setup-windows.ps1 | 45 +-
src/main/audit-service.cjs | 57 +
src/main/config-store.cjs | 88 +-
src/main/configuration-backup.cjs | 62 +
src/main/external-tools-service.cjs | 38 +
src/main/git-service.cjs | 118 +-
src/main/gitea-service.cjs | 40 +-
src/main/ipc.cjs | 149 +-
src/main/unraid-deployment-service.cjs | 130 +
src/renderer/app.js | 3320 +++++++++++++++----
src/renderer/mock-bridge.js | 1591 +++++++--
src/renderer/styles.css | 3469 +++++++++++++++++---
src/shared/deployment-policy.cjs | 44 +
src/shared/validation.cjs | 7 +-
tests/acceptance.test.mjs | 11 +
tests/audit-service.test.mjs | 25 +
tests/configuration-backup.test.mjs | 27 +
tests/deployment-policy.test.mjs | 29 +
tests/external-tools.test.mjs | 13 +
tests/git-integration.test.mjs | 27 +
tests/gitea-actions.test.mjs | 19 +
tests/ipc-contract.test.mjs | 51 +
tests/partial-staging.test.mjs | 44 +
tests/renderer-workflow.test.mjs | 195 +-
tests/unraid-deployment.test.mjs | 36 +
tests/validation.test.mjs | 7 +-
update-windows.ps1 | 43 +-
47 files changed, 9114 insertions(+), 1648 deletions(-)
create mode 100644 docs/ACCEPTANCE.md
create mode 100644 docs/RELEASE_NOTES_0.7.0.md
create mode 100644 docs/RELEASE_NOTES_0.8.0.md
create mode 100644 docs/RELEASE_NOTES_0.8.1.md
create mode 100644 scripts/acceptance.mjs
create mode 100644 scripts/generate-source-manifest.mjs
create mode 100644 scripts/validate-installed-connections.cjs
create mode 100644 src/main/audit-service.cjs
create mode 100644 src/main/configuration-backup.cjs
create mode 100644 src/main/external-tools-service.cjs
create mode 100644 src/shared/deployment-policy.cjs
create mode 100644 tests/acceptance.test.mjs
create mode 100644 tests/audit-service.test.mjs
create mode 100644 tests/configuration-backup.test.mjs
create mode 100644 tests/deployment-policy.test.mjs
create mode 100644 tests/external-tools.test.mjs
create mode 100644 tests/ipc-contract.test.mjs
create mode 100644 tests/partial-staging.test.mjs
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 `