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.
This commit is contained in:
NuklearRabbit
2026-07-26 00:42:17 +02:00
parent 971896a1d5
commit 4ad698c4eb
47 changed files with 9114 additions and 1648 deletions
+23
View File
@@ -1,5 +1,28 @@
# Changelog # 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 ## 0.6.1
- Fixed Windows PowerShell 5.1 updater status replacement and STARTED handshake. - Fixed Windows PowerShell 5.1 updater status replacement and STARTED handshake.
+6
View File
@@ -1,5 +1,11 @@
# ForgeFlow # 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 ## Publish this release to the built-in updater repository
Extract the full source ZIP to a folder under Downloads and run: Extract the full source ZIP to a folder under Downloads and run:
+76 -57
View File
@@ -1,16 +1,8 @@
ForgeFlow 0.6.1 source manifest ForgeFlow 0.8.1 source manifest
SHA-256 BYTES PATH SHA-256 BYTES PATH
(The manifest excludes itself and generated release archives.) (The manifest excludes itself, dependencies and generated release artifacts.)
755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 58 .gitignore 755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 58 .gitignore
82438ae208c8d8eaac56fcdc68e52efcfd7a57890198d3f8b8d8c69280b600a6 6359 CHANGELOG.md 89545860bd6f7566da81edc8328cd2a1ebf33e81a4b0dcf2cec74338c05e8cac 1753 build-windows.ps1
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
0970821475a4452aa19e447e9397a95db836791f16890a1a83fd748ac033dc86 8830 build/icon-128.png 0970821475a4452aa19e447e9397a95db836791f16890a1a83fd748ac033dc86 8830 build/icon-128.png
09112c1425ca953d8dd8b2bcfd221e5a84b9f81752f7168f360e295030cbc8f2 521 build/icon-16.png 09112c1425ca953d8dd8b2bcfd221e5a84b9f81752f7168f360e295030cbc8f2 521 build/icon-16.png
510aa27935a63ad16cc22978ccfde3bdd441cb970ad42d9f05af52c0e5999195 28923 build/icon-256.png 510aa27935a63ad16cc22978ccfde3bdd441cb970ad42d9f05af52c0e5999195 28923 build/icon-256.png
@@ -20,11 +12,13 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263
4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183 3364 build/icon-64.png 4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183 3364 build/icon-64.png
25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico 25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png 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 30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md
eb42f979666e05d51c587e4223282914926a2b9b1ade9f3fb75525019ce7f738 4616 docs/DIAGNOSTICS.md eb42f979666e05d51c587e4223282914926a2b9b1ade9f3fb75525019ce7f738 4616 docs/DIAGNOSTICS.md
a0cd06a96f23a94e118feb012be0fa1ac51345951cb2ba8e67fb8c889c4c342a 5007 docs/LUMAOPS_SERVER_AUDIT.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 a0c00ff76acd1682bb5e0e8dcf6589c9480da436c9c6d30780a1ed58b4dad94f 1770 docs/RELEASE_NOTES_0.2.0.md
5773ead01aa4c522c556295553787482d01b1f5242f053b2c61f120c4de4fa76 5963 docs/RELEASE_NOTES_0.3.0.md 5773ead01aa4c522c556295553787482d01b1f5242f053b2c61f120c4de4fa76 5963 docs/RELEASE_NOTES_0.3.0.md
d46de73cf6c4cd5c2ba3f455a7a2af2e0d64ee9d94a97fd1a0bfb44e35c1624a 1093 docs/RELEASE_NOTES_0.3.1.md d46de73cf6c4cd5c2ba3f455a7a2af2e0d64ee9d94a97fd1a0bfb44e35c1624a 1093 docs/RELEASE_NOTES_0.3.1.md
@@ -40,21 +34,24 @@ f9554c10f56d41d916330f06175b07f099c9ed1534f00abc9ea7f94be70f4a97 1097
d7d007e4c2807698db07b2ebe1cb48c36bd162bf4daad77c9d299096c9654d5a 721 docs/RELEASE_NOTES_0.5.2.md d7d007e4c2807698db07b2ebe1cb48c36bd162bf4daad77c9d299096c9654d5a 721 docs/RELEASE_NOTES_0.5.2.md
3e77df12a7ff4b545069933410bf14fe8891f39915182df25112c722cf4e243d 1030 docs/RELEASE_NOTES_0.5.3.md 3e77df12a7ff4b545069933410bf14fe8891f39915182df25112c722cf4e243d 1030 docs/RELEASE_NOTES_0.5.3.md
61f6cbc1c3f263fa96b5c6a70a26baa7cd577d37ac633455eed45d9b63169a35 710 docs/RELEASE_NOTES_0.5.4.md 61f6cbc1c3f263fa96b5c6a70a26baa7cd577d37ac633455eed45d9b63169a35 710 docs/RELEASE_NOTES_0.5.4.md
9f72a5d039615785ccd4771f38b060bb58386b3217ba2a56459067cebbbb812f 4875 docs/RELEASE_NOTES_0.6.0.md 1ecca96cf8a6f01d7ead37d5a6b678549c2561bfd84011b6149f718a25971661 4936 docs/RELEASE_NOTES_0.6.0.md
179860938908bc65b8ce8ca5019fd0a24e79b1eb88d368c9856191d48ec00d3a 675 docs/RELEASE_NOTES_0.6.1.md 23150c58e6416d48c2ed6e378fff99179ed810b766ed50b70d4d829c6774b8ef 685 docs/RELEASE_NOTES_0.6.1.md
c465f1a9c4454c9a18f38f68a243037b8897c2c9929077a586604acd4ff26d35 3655 docs/ROADMAP.md f3d04f2d3419a7a010d5399cdd9351ff85ab2b3fdf8023977e559b0a5f8bcdc3 2571 docs/RELEASE_NOTES_0.7.0.md
322624242d246d07180cc719e14c91e8fb69e123676a02e5046f4e576cca1ca1 5569 docs/SECURITY.md d7bdc61d9b617ad5acf0b2d468eda547fd7509d4af08f33d2661393f25bdcb5a 576 docs/RELEASE_NOTES_0.8.0.md
c79123aa4c718ac3ab0d79771f2967710c28f939b58fca0094b02e3172f2c024 13067 docs/SETUP_GUIDE.md 1e056bfcf2105843402f4b14c63480240cc55456a4a63e229b3fdbaf3156b803 754 docs/RELEASE_NOTES_0.8.1.md
43f73b6674ee0c5d7ace8ab39db95b5b42b61c73dd79ffc8d87fe214d8b51d7e 5070 docs/SSH_UNRAID_DEPLOYMENT.md 2b631b9d6d973bdd70869d84886ff339da351e29e17598970b3b27915674661d 4175 docs/ROADMAP.md
b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md
0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md
08640f1b5e26048b5ae501909f415d2426b07cc316a0bd2178023f2457aa7a2a 3978 docs/TEST_MATRIX.md
95b5b2915a11065a22eb2822ecce9fb30fec0c08874d63c8ffb5182cd20b3459 2328 docs/UPDATING.md
1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png 1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png
b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png
070e6700bdae8c628c907ba181bbf0dde0bbbbb4208f7a875503f933ff1b882e 118819 docs/screenshots/deployment-success.png 070e6700bdae8c628c907ba181bbf0dde0bbbbb4208f7a875503f933ff1b882e 118819 docs/screenshots/deployment-success.png
bcb1e4daf1eeedc5b3f61d2406f1a65312dba130082528007e1629d9df99570a 153240 docs/screenshots/overview.png bcb1e4daf1eeedc5b3f61d2406f1a65312dba130082528007e1629d9df99570a 153240 docs/screenshots/overview.png
224e34ab45877bbb97b07d2a14c4a5aa6e28339522a8015b33a2a81477177143 135102 docs/screenshots/repository-workspace.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 c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 examples/gitea-actions/deploy.yml
4c792cc9fd57ed36da291300c252a6ef75b08a249cf6f2561e23c4c22522138a 1477 examples/gitea-actions/rollback.yml 4c792cc9fd57ed36da291300c252a6ef75b08a249cf6f2561e23c4c22522138a 1477 examples/gitea-actions/rollback.yml
1d2cde1bef4882f56006823d2806f6105882fa098a665a303150fdf18ada2004 5705 examples/server/forgeflow-deploy 1d2cde1bef4882f56006823d2806f6105882fa098a665a303150fdf18ada2004 5705 examples/server/forgeflow-deploy
@@ -62,63 +59,85 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532
0423fe2cc7f43fe793986a3f62a395668897cdf07348756aa7742a8cd40ac51c 569 examples/server/forgeflow-targets.conf 0423fe2cc7f43fe793986a3f62a395668897cdf07348756aa7742a8cd40ac51c 569 examples/server/forgeflow-targets.conf
106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf 106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf
2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json 2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json
1e47552cfde3ca925ba1f24fbc471f3fc29468ca7ef3351beb9bde2bf0747bc7 7887 main.cjs 4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE
1dc73c9ec2393a3aeb655ade78eb81a5a6946c2760884bc4c653cbef171896cf 2745 package.json 7c7790e229bbe6035a47c29d17cddd187482e7cda03c5672ec9d7aca16bc4a5c 11357 main.cjs
0cd434cb21af86e7f6983416e4ed14762565df90edc2d00d4a60378df75b7419 6846 preload.cjs 91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
874cabc5ff1abce4e1ba6560f35d5956f9ec69f1302164169f0da56cc62ca954 10056 scripts/apply-source-update.ps1 e4c91f198af6235ea035f58268b85588bc1cbf0952c28785a30f15ee883feb84 134141 package-lock.json
f427dfcd7b5ee7079de13633c8d7d22a91115e0bbc4f2a9a96246f42176d4880 3596 scripts/doctor.mjs 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 444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs
4bd28f46cb2dda347e534b5d57a46425b9fe5862dd22cdb58b983d01b3174fcf 10161 scripts/verify.mjs 42203f9e0fd4aae517284d387f265cf1b0b180379bc253a092b5c3c5c4caef0a 2992 scripts/validate-installed-connections.cjs
92524adae60aced3af23f8afe82c011873ae9f1e53d854e4d12a94e8d1be1aa9 2075 setup-windows.ps1 a2733f653e6abea7f27c3198631e6144c98458874b338704f99252451cce235b 11999 scripts/verify.mjs
87885d640a1148078426522c87d7c9b7fced1fabb371020a4781bb94b256ee00 19664 src/main/config-store.cjs 619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1
a970ff3f47d1641bf1ab9611e1122349aa65ff8fee4789585e078431368b8c6b 23655 src/main/deployment-service.cjs 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 c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs
c50bf93d0d1abfc0319e59a545877413bd3aca1e5b57c9a48a2467db5e89d7f8 25731 src/main/git-service.cjs a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs
ab7344b1951c87e982cab5c293891bc45dad48a48a3b4b63991b0e76ef785ba6 12759 src/main/gitea-service.cjs 0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs
743002a5dae3c8e6aa5236609dd58c786420408c321eebf6d6b9e32a684cd696 26042 src/main/ipc.cjs 113612b23f9c812e1dbe33eaaf398725c351678419e8304a8dfb4df881b862ff 15048 src/main/gitea-service.cjs
b2d768a9dfd1e494edee6609a233469e60c31c362143d5c37c3c9f908b7bca79 40559 src/main/ipc.cjs
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs 62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs 958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs
1dc0c997bd2d837f3d27dff58a9443888597b7981c8a1dd1eaa4487176ef716c 1520 src/main/process-runner.cjs 3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs
e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508 src/main/repository-monitor.cjs e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508 src/main/repository-monitor.cjs
a302bdcfbf2e2b66fdb4e13d94cc7a78cd4065a779b980f4487b6075a6472017 7583 src/main/repository-service.cjs 17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs
ad9e8b67bd10f2f5708d00ebacf660ab4917dd02b3d22b3b6d730bcb2a7e1c18 8124 src/main/ssh-service.cjs b31a63bf8cb1807b3e838e2bf8a0e742738f119d13de8ca9f42e471f072217d3 8328 src/main/ssh-service.cjs
a716f7402d3039296f99f1a7958a9aa521032f091e4171e224fa548d131a4183 49354 src/main/unraid-deployment-service.cjs 720103f14cbedd7fd2776e49fd970a634f14e03d548d90bf93bcd878b6b3c674 58758 src/main/unraid-deployment-service.cjs
709a6eb6a9b277cdc33f4d97558d2aca9d7ebe62a0e38fe52adc9dc3c9f94422 12791 src/main/update-service.cjs e87647c45cf06e2aa58e319adff96af0f927ca278ff0877eac3b8ff97d690ea8 13103 src/main/update-service.cjs
cea387e7996de8d185cd11f7d8d4e0675a58df1ca8f3cb6c3c6d6363c72f5f12 150880 src/renderer/app.js aab6597f0efd72cb27c12aab9866fb5cfe87710b9fbca335e2b5dd767c8bab13 191016 src/renderer/app.js
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png
094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png 094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png
394f901b0e4add6b788c006d9869a66dc021dcebf2916f5a732bc42786510efc 740 src/renderer/index.html e1c463d6cda9f2b9b78c468845c0a7e8688f0362be5642074a1a5f7122dfe811 762 src/renderer/index.html
4f3d7fa6126fe609a64cbe2de4817c6a9f2b6b31ba8abee4f8e35a93a185c601 39109 src/renderer/mock-bridge.js 24a32724ad412e9c3a2b93f2ddf4cb8db0cddf421968cc620b0552caac39076c 50942 src/renderer/mock-bridge.js
6f0bd7b898677b9bcf6a104d7b1292b9ed5db826e4afdc86907e1c55bd889456 48072 src/renderer/styles.css 51f6777fd7d2dc73dc3ddd96c91a11882483099b0a62e0ce172e25e3dce474a6 59851 src/renderer/styles.css
0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs 0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs
5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs
029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs 029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs
2778ebcbdf60fdc1cb0749f15565e0e1bd66f3a0d31eb70ae7942a7511a3de75 1295 src/shared/repository-match.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 ede2c95bb045c0005a3931709a0116d9fbcb3faa5f609848a0066c6ba382ca0b 2906 src/shared/shell-verification.cjs
2daa98fd421598bfe5fc9757c9b6f4d82c31d1bfece15829928473581d5d2639 1210 src/shared/tool-invocation.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 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 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 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 5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db 1239 tests/git-status.test.mjs
681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.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 caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs
96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs
c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 3718 tests/preflight.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 2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs
f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 1068 tests/repository-monitor.test.mjs f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 1068 tests/repository-monitor.test.mjs
6527813f2ae318f7a6aca57d375a3d3583173a208c540d2e8772f84f479d42b0 2205 tests/repository-service.test.mjs 75b5b83836c75675bb9a48fe4363fcb8a24fc425e6af6f822d7955c6f3c79eac 2265 tests/repository-service.test.mjs
3c71aa5fb30d9c6fbc4b0ccfcf5112f45cbcc2a60cb990e4551a8813f7155505 3397 tests/security-validation.test.mjs d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463 tests/security-validation.test.mjs
ecfdad2a03c24898c822fcf05abac89c8f8fe452a05b16fdafc0236a64c27a23 614 tests/semver.test.mjs bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs
020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs 020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs 8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs
7c0f5268028cf8904b446c5d9c5a8b6450966f493018777256104df2626d0f3e 15662 tests/unraid-deployment.test.mjs 54f641103a91d98974c41a3c0a568c617b76fa9913a0e20710cd11adc39b0deb 18092 tests/unraid-deployment.test.mjs
44c82a2658f4286afb657c5952d12a6038c83f0adfe7ea0765e29af412a718dc 10233 tests/update-service.test.mjs 4ea1acea5ca92e1360bcf1263f65d1be0de80ab44adc9af2b09f408951e0d9fa 10454 tests/update-service.test.mjs
4d1f0a4c46190ca72b51fddf79ec6d4d02e65fa6f42ef3755de5d414f7da75bb 655 tests/validation.test.mjs 9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs
7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.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
+1 -1
View File
@@ -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 Write-Host "Artifacts are unsigned and intended for local testing." -ForegroundColor DarkGray
Assert-Command node Assert-Command node
+20
View File
@@ -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.
+2 -2
View File
@@ -14,7 +14,7 @@
| Electron main process | | Electron main process |
| | | |
| IPC validation + trusted sender checks | | IPC validation + trusted sender checks |
| ConfigStore -------- schema 5 + protected Gitea/SSH secrets | | ConfigStore -------- schema 8 + protected Gitea/SSH secrets |
| GitService ----------- Git through execFile args | | GitService ----------- Git through execFile args |
| GiteaService --------- repositories + Actions API | | GiteaService --------- repositories + Actions API |
| RepositoryService ---- discovery + aggregation | | RepositoryService ---- discovery + aggregation |
@@ -69,7 +69,7 @@ per-environment lock. The runner receives no free-form command from ForgeFlow.
## Local state ## Local state
`forgeflow-config.json` lives below Electron's platform-specific user-data path `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; - Gitea connection metadata and an OS-encrypted token blob where available;
- workspace roots and explicit repository mappings; - workspace roots and explicit repository mappings;
+47
View File
@@ -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.
+10
View File
@@ -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.
+13
View File
@@ -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.
+11
View File
@@ -1,5 +1,16 @@
# ForgeFlow roadmap # 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 ## Delivered through v0.4
- coherent Local -> Gitea -> Server desktop model; - coherent Local -> Gitea -> Server desktop model;
+13
View File
@@ -90,6 +90,19 @@ The standalone demo should be checked at minimum at:
Required views now include setup readiness, dashboard, repository workspace, Required views now include setup readiness, dashboard, repository workspace,
deployment preflight, active run, success/failure and Diagnostics. 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 ### v0.4 additions
+83 -14
View File
@@ -1,7 +1,7 @@
'use strict'; 'use strict';
const path = require('node:path'); 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 { ConfigStore } = require('./src/main/config-store.cjs');
const { GitService } = require('./src/main/git-service.cjs'); const { GitService } = require('./src/main/git-service.cjs');
const { GiteaService } = require('./src/main/gitea-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 { UpdateService } = require('./src/main/update-service.cjs');
const { SshService } = require('./src/main/ssh-service.cjs'); const { SshService } = require('./src/main/ssh-service.cjs');
const { UnraidDeploymentService } = require('./src/main/unraid-deployment-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'); const { registerIpc } = require('./src/main/ipc.cjs');
let mainWindow; let mainWindow;
let repositoryMonitor; let repositoryMonitor;
let operationTimer; let operationTimer;
let diagnostics; let diagnostics;
let configStore;
let tray;
let quitCleanupStarted = false; let quitCleanupStarted = false;
function broadcast(channel, payload) { 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() { function createWindow() {
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1480, 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('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.on('did-fail-load', (_event, code, description, validatedUrl) => diagnostics?.error('renderer.load.failed', { code, description, validatedUrl }));
mainWindow.webContents.setWindowOpenHandler(({ url }) => { 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' }; return { action: 'deny' };
}); });
mainWindow.webContents.on('will-navigate', (event, url) => { mainWindow.webContents.on('will-navigate', (event, url) => {
if (url !== mainWindow.webContents.getURL()) event.preventDefault(); 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 () => { app.whenReady().then(async () => {
@@ -80,6 +123,7 @@ app.whenReady().then(async () => {
const userDataPath = app.getPath('userData'); const userDataPath = app.getPath('userData');
const store = new ConfigStore(userDataPath); const store = new ConfigStore(userDataPath);
configStore = store;
await store.load(); await store.load();
diagnostics = new DiagnosticsService({ diagnostics = new DiagnosticsService({
userDataPath, userDataPath,
@@ -96,6 +140,8 @@ app.whenReady().then(async () => {
preferencesProvider: () => store.data.preferences preferencesProvider: () => store.data.preferences
}); });
await diagnostics.initialize(); await diagnostics.initialize();
const audit = new AuditService({ userDataPath, appInfo: { version: app.getVersion() } });
await audit.initialize();
process.on('uncaughtException', (error) => { process.on('uncaughtException', (error) => {
diagnostics?.error('process.uncaught-exception', error).finally(() => app.exit(1)); 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 })); process.on('unhandledRejection', (reason) => diagnostics?.error('process.unhandled-rejection', reason instanceof Error ? reason : { reason }));
const git = new GitService(); const git = new GitService();
const externalTools = new ExternalToolsService(store);
const gitea = new GiteaService(store, diagnostics); const gitea = new GiteaService(store, diagnostics);
const repositories = new RepositoryService(store, git, gitea, diagnostics); const repositories = new RepositoryService(store, git, gitea, diagnostics);
const deployments = new DeploymentService(store, gitea, git, diagnostics); const deployments = new DeploymentService(store, gitea, git, diagnostics);
const ssh = new SshService({ store, 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({ const updates = new UpdateService({
store, store,
gitea, gitea,
@@ -132,7 +192,8 @@ app.whenReady().then(async () => {
onChange: (payload) => broadcast('repositories:changed', payload) onChange: (payload) => broadcast('repositories:changed', payload)
}); });
repositoryMonitor.restart(); 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(); createWindow();
if (store.data.setupComplete && store.data.updates?.autoCheck && store.getToken()) { if (store.data.setupComplete && store.data.updates?.autoCheck && store.getToken()) {
@@ -159,18 +220,26 @@ app.whenReady().then(async () => {
if (operationTimer) clearTimeout(operationTimer); if (operationTimer) clearTimeout(operationTimer);
const intervalMs = Math.max(3, Number(store.data.preferences.operationPollSeconds) || 5) * 1000; const intervalMs = Math.max(3, Number(store.data.preferences.operationPollSeconds) || 5) * 1000;
operationTimer = setTimeout(async () => { operationTimer = setTimeout(async () => {
if (store.data.setupComplete) { try {
const active = store.data.operations.some((item) => item.type === 'deployment' && !['success', 'failed', 'cancelled', 'rolled-back'].includes(item.status)); if (store.data.setupComplete) {
if (active) { const active = store.data.operations.some((item) => item.type === 'deployment' && !['success', 'failed', 'cancelled', 'rolled-back'].includes(item.status));
const [actions, sshOperations] = await Promise.all([ if (active) {
store.getToken() ? deployments.refreshActiveOperations().catch(async (error) => { await diagnostics.error('operation-monitor.actions.failed', error); return []; }) : [], const [actions, sshOperations] = await Promise.all([
unraid.refreshActiveOperations().catch(async (error) => { await diagnostics.error('operation-monitor.unraid.failed', error); return []; }) 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 }); 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); }, intervalMs);
operationTimer.unref?.(); operationTimer.unref?.();
}; };
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "forgeflow", "name": "forgeflow",
"version": "0.6.1", "version": "0.8.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "forgeflow", "name": "forgeflow",
"version": "0.6.1", "version": "0.8.1",
"dependencies": { "dependencies": {
"ssh2": "1.17.0" "ssh2": "1.17.0"
}, },
+16 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "forgeflow", "name": "forgeflow",
"version": "0.6.1", "version": "0.8.1",
"private": true, "private": true,
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.", "description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
"main": "main.cjs", "main": "main.cjs",
@@ -15,6 +15,9 @@
"dist:linux": "electron-builder --linux AppImage", "dist:linux": "electron-builder --linux AppImage",
"dist:mac": "electron-builder --mac dmg", "dist:mac": "electron-builder --mac dmg",
"doctor": "node scripts/doctor.mjs", "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" "check": "npm run verify && npm test"
}, },
"devDependencies": { "devDependencies": {
@@ -31,6 +34,7 @@
"src/**/*", "src/**/*",
"package.json", "package.json",
"build/icon.png", "build/icon.png",
"build/icon.ico",
"docs/SETUP_GUIDE.md", "docs/SETUP_GUIDE.md",
"docs/DIAGNOSTICS.md", "docs/DIAGNOSTICS.md",
"docs/STATUS_ENDPOINT.md", "docs/STATUS_ENDPOINT.md",
@@ -63,7 +67,11 @@
"START-FORGEFLOW-OVERLAY.ps1", "START-FORGEFLOW-OVERLAY.ps1",
"docs/RELEASE_NOTES_0.6.0.md", "docs/RELEASE_NOTES_0.6.0.md",
"docs/RELEASE_AUDIT_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": { "directories": {
"output": "dist" "output": "dist"
@@ -75,6 +83,12 @@
], ],
"icon": "build/icon.ico" "icon": "build/icon.ico"
}, },
"nsis": {
"artifactName": "${productName}-Setup-${version}-${os}-${arch}.${ext}"
},
"portable": {
"artifactName": "${productName}-Portable-${version}-${os}-${arch}.${ext}"
},
"linux": { "linux": {
"target": [ "target": [
"AppImage" "AppImage"
+22 -1
View File
@@ -34,6 +34,10 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({
setWorkspaceRoots: (roots) => invoke('settings:set-roots', { roots }), setWorkspaceRoots: (roots) => invoke('settings:set-roots', { roots }),
setAppearance: (appearance) => invoke('settings:set-appearance', { appearance }), setAppearance: (appearance) => invoke('settings:set-appearance', { appearance }),
setPreferences: (preferences) => invoke('settings:set-preferences', { preferences }), 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 }), setUpdatePreferences: (updates) => invoke('updates:preferences', { updates }),
checkForUpdates: () => invoke('updates:check'), checkForUpdates: () => invoke('updates:check'),
downloadUpdate: () => invoke('updates:download'), downloadUpdate: () => invoke('updates:download'),
@@ -42,6 +46,7 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({
deleteServer: (serverId) => invoke('server:delete', { serverId }), deleteServer: (serverId) => invoke('server:delete', { serverId }),
testServer: (serverId) => invoke('server:test', { serverId }), testServer: (serverId) => invoke('server:test', { serverId }),
inspectServerProject: (repository, profileId) => invoke('server:inspect-project', { repository, profileId }), inspectServerProject: (repository, profileId) => invoke('server:inspect-project', { repository, profileId }),
discoverExistingDeployment: (repository, serverId, remoteFolder) => invoke('server:discover-existing', { repository, serverId, remoteFolder }),
refreshRepositories: () => invoke('repositories:refresh'), refreshRepositories: () => invoke('repositories:refresh'),
discoverRepositories: (roots) => invoke('repositories:discover', { roots }), discoverRepositories: (roots) => invoke('repositories:discover', { roots }),
favoriteRepository: (fullName, favorite) => invoke('repository:favorite', { fullName, favorite }), favoriteRepository: (fullName, favorite) => invoke('repository:favorite', { fullName, favorite }),
@@ -49,14 +54,25 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({
unlinkRepository: (fullName) => invoke('repository:unlink', { fullName }), unlinkRepository: (fullName) => invoke('repository:unlink', { fullName }),
repositoryStatus: (localPath) => invoke('repository:status', { localPath }), repositoryStatus: (localPath) => invoke('repository:status', { localPath }),
repositoryDiff: (localPath, filePath, staged = false) => invoke('repository:diff', { localPath, filePath, staged }), 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 }), stageFiles: (localPath, files) => invoke('repository:stage', { localPath, files }),
unstageFiles: (localPath, files) => invoke('repository:unstage', { localPath, files }), unstageFiles: (localPath, files) => invoke('repository:unstage', { localPath, files }),
commit: (localPath, message, files) => invoke('repository:commit', { localPath, message, 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 }), commitAndPush: (localPath, message, files) => invoke('repository:commit-push', { localPath, message, files }),
push: (localPath) => invoke('repository:push', { localPath }), push: (localPath) => invoke('repository:push', { localPath }),
fetch: (localPath) => invoke('repository:fetch', { localPath }), fetch: (localPath) => invoke('repository:fetch', { localPath }),
pull: (localPath) => invoke('repository:pull', { localPath }), pull: (localPath) => invoke('repository:pull', { localPath }),
history: (localPath, limit = 20) => invoke('repository:history', { localPath, limit }), 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 }), branches: (localPath) => invoke('repository:branches', { localPath }),
checkoutBranch: (localPath, branch) => invoke('repository:checkout-branch', { localPath, branch }), checkoutBranch: (localPath, branch) => invoke('repository:checkout-branch', { localPath, branch }),
createBranch: (localPath, branch) => invoke('repository:create-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'), normalizeOrigins: () => invoke('repositories:normalize-origins'),
cloneRepository: (fullName, mode = 'default') => invoke('repository:clone', { fullName, mode }), cloneRepository: (fullName, mode = 'default') => invoke('repository:clone', { fullName, mode }),
openPath: (localPath) => invoke('repository:open-path', { localPath }), 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 }), openExternal: (url) => invoke('external:open', { url }),
saveDeploymentProfile: (fullName, profile) => invoke('deployment:save-profile', { fullName, profile }), saveDeploymentProfile: (fullName, profile) => invoke('deployment:save-profile', { fullName, profile }),
deploymentPreflight: (repository, profileId) => invoke('deployment:preflight', { repository, profileId }), deploymentPreflight: (repository, profileId) => invoke('deployment:preflight', { repository, profileId }),
deleteDeploymentProfile: (fullName, profileId) => invoke('deployment:delete-profile', { fullName, 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 }), rollback: (repository, profileId, targetSha) => invoke('deployment:rollback', { repository, profileId, targetSha }),
healthcheck: (url) => invoke('deployment:health', { url }), healthcheck: (url) => invoke('deployment:health', { url }),
refreshProfileState: (fullName, profileId) => invoke('deployment:profile-state', { fullName, profileId }), 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 }), reconcileDeployment: (fullName, profileId) => invoke('deployment:reconcile', { fullName, profileId }),
refreshOperations: (operationId = null) => invoke('operations:refresh', { operationId }), refreshOperations: (operationId = null) => invoke('operations:refresh', { operationId }),
getOperation: (operationId) => invoke('operations:get', { 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'), diagnosticsStatus: () => invoke('diagnostics:status'),
clearDiagnostics: () => invoke('diagnostics:clear'), clearDiagnostics: () => invoke('diagnostics:clear'),
openDiagnosticsFolder: () => invoke('diagnostics:open-folder'), openDiagnosticsFolder: () => invoke('diagnostics:open-folder'),
+88
View File
@@ -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;
}
+8 -5
View File
@@ -11,8 +11,9 @@ const packageJson = JSON.parse(await readFile(new URL('../package.json', import.
const checks = []; const checks = [];
const jsonMode = process.argv.includes('--json'); const jsonMode = process.argv.includes('--json');
function add(id, name, ok, detail, help = '') { function add(id, name, ok, detail, help = '', severity = 'required') {
checks.push({ id, name, status: ok ? 'pass' : 'fail', ok, detail, help }); 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]); 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.name']).then((result) => result.stdout.trim()).catch(() => ''),
exec('git', ['config', '--global', '--get', 'user.email']).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) { } catch (error) {
add('git', 'Git', false, error.message, 'Install Git and ensure git is on PATH.'); 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(() => {}); if (markerDirectory) await rm(markerDirectory, { recursive: true, force: true }).catch(() => {});
} }
const blockingChecks = checks.filter((check) => check.status === 'fail');
const report = { const report = {
product: 'ForgeFlow', product: 'ForgeFlow',
version: packageJson.version, version: packageJson.version,
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
platform: process.platform, platform: process.platform,
arch: process.arch, arch: process.arch,
ready: checks.every((check) => check.ok), ready: blockingChecks.length === 0,
checks checks
}; };
if (jsonMode) console.log(JSON.stringify(report, null, 2)); if (jsonMode) console.log(JSON.stringify(report, null, 2));
else { else {
console.log('ForgeFlow doctor\n'); 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.'}`); console.log(`\n${report.ready ? 'Environment is ready.' : 'Resolve failed checks before starting ForgeFlow.'}`);
} }
+37
View File
@@ -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}.`);
@@ -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();
}
});
+335 -86
View File
@@ -1,54 +1,140 @@
import { access, readFile, readdir } from 'node:fs/promises'; import { access, readFile, readdir } from "node:fs/promises";
import path from 'node:path'; import path from "node:path";
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from "node:url";
import { spawnSync } from 'node:child_process'; import { spawnSync } from "node:child_process";
import shellVerification from '../src/shared/shell-verification.cjs'; 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 = [ const required = [
'package.json', 'main.cjs', 'preload.cjs', "package.json",
'src/renderer/index.html', 'src/renderer/styles.css', 'src/renderer/app.js', 'src/renderer/mock-bridge.js', "main.cjs",
'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', "preload.cjs",
'src/main/config-store.cjs', 'src/main/git-service.cjs', 'src/main/gitea-service.cjs', "src/renderer/index.html",
'src/main/repository-service.cjs', 'src/main/repository-monitor.cjs', 'src/main/deployment-service.cjs', "src/renderer/styles.css",
'src/main/unraid-deployment-service.cjs', 'src/main/ssh-service.cjs', 'src/main/update-service.cjs', "src/renderer/app.js",
'src/main/diagnostics-service.cjs', 'src/main/preflight-service.cjs', 'src/main/log-redaction.cjs', 'src/main/ipc.cjs', "src/renderer/mock-bridge.js",
'src/shared/clone-target.cjs', 'src/shared/semver.cjs', 'src/shared/zip-writer.cjs', "src/renderer/assets/itworx-mark.png",
'src/shared/tool-invocation.cjs', 'src/shared/shell-verification.cjs', 'START_HERE.md', 'README.md', 'SOURCE_MANIFEST.txt', "src/renderer/assets/itworx-wordmark.png",
'setup-windows.ps1', 'START-FORGEFLOW-OVERLAY.ps1', 'update-windows.ps1', 'build-windows.ps1', 'UPDATE_FROM_0.3.2.md', 'scripts/apply-source-update.ps1', "src/renderer/assets/itworx-wordmark-light.png",
'docs/ARCHITECTURE.md', 'docs/SECURITY.md', 'docs/ROADMAP.md', 'docs/SETUP_GUIDE.md', "src/renderer/assets/itworx-wordmark-dark.png",
'docs/UPDATING.md', 'docs/DIAGNOSTICS.md', 'docs/DEPLOYMENT_SETUP.md', 'docs/SSH_UNRAID_DEPLOYMENT.md', "src/main/config-store.cjs",
'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', "src/main/git-service.cjs",
'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', "src/main/gitea-service.cjs",
'Publish-ForgeFlow-Release.ps1', 'docs/RELEASE_NOTES_0.4.4.md', 'docs/RELEASE_NOTES_0.4.5.md', "src/main/audit-service.cjs",
'examples/gitea-actions/deploy.yml', 'examples/gitea-actions/rollback.yml', "src/main/configuration-backup.cjs",
'examples/server/forgeflow-deploy', 'examples/server/forgeflow-targets.conf', "src/main/external-tools-service.cjs",
'examples/server/forgeflow-runner.sudoers', 'examples/server/status-example.json', "src/main/repository-service.cjs",
'build/icon.png', 'build/icon.ico' "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)); for (const file of required) await access(path.join(root, file));
const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')); const packageJson = JSON.parse(
if (packageJson.version !== '0.6.1') throw new Error(`Expected package version 0.6.1, got ${packageJson.version}.`); await readFile(path.join(root, "package.json"), "utf8"),
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.'); if (packageJson.version !== "0.8.1")
for (const group of ['dependencies', 'devDependencies']) { 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] || {})) { 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.'); if (packageJson.dependencies?.ssh2 !== "1.17.0")
for (const script of ['start', 'demo', 'test', 'verify', 'check']) { throw new Error("ssh2 must remain pinned to 1.17.0.");
if (!packageJson.scripts?.[script]) throw new Error(`Required npm script is missing: ${script}`); 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) { if (
throw new Error('Package icon configuration is incomplete.'); !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 = []) { async function collect(directory, extensions, output = []) {
for (const entry of await readdir(directory, { withFileTypes: true })) { 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); const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) await collect(absolute, extensions, output); if (entry.isDirectory()) await collect(absolute, extensions, output);
else if (extensions.has(path.extname(entry.name))) output.push(absolute); else if (extensions.has(path.extname(entry.name))) output.push(absolute);
@@ -56,13 +142,21 @@ async function collect(directory, extensions, output = []) {
return 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) { for (const file of javascriptFiles) {
const result = spawnSync(process.execPath, ['--check', file], { encoding: 'utf8' }); const result = spawnSync(process.execPath, ["--check", file], {
if (result.status !== 0) throw new Error(`${path.relative(root, file)} failed syntax validation:\n${result.stderr}`); 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); shellVerification.validateShellScriptStructure(deploymentScript);
// The server deployment script targets Linux/Unraid. On Windows, different tools may // 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 // 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. // validation always runs; GNU Bash syntax validation additionally runs on non-Windows.
if (shellVerification.shouldRunExternalBash(process.platform)) { if (shellVerification.shouldRunExternalBash(process.platform)) {
const bashCheck = shellVerification.bashSyntaxCheckFromTextInvocation(deploymentScript); const bashCheck =
shellVerification.bashSyntaxCheckFromTextInvocation(deploymentScript);
const shell = spawnSync(bashCheck.command, bashCheck.args, bashCheck.options); 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.error)
if (shell.status !== 0) throw new Error(`Server deployment example failed bash syntax validation: throw new Error(
${shell.stderr || shell.stdout || 'Bash returned a non-zero status.'}`); `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 { } 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')); JSON.parse(
const setupGuide = await readFile(path.join(root, 'docs/SETUP_GUIDE.md'), 'utf8'); await readFile(
const sshGuide = await readFile(path.join(root, 'docs/SSH_UNRAID_DEPLOYMENT.md'), 'utf8'); path.join(root, "examples/server/status-example.json"),
const audit = await readFile(path.join(root, 'docs/LUMAOPS_SERVER_AUDIT.md'), 'utf8'); "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')) { const setupGuide = await readFile(
throw new Error('Setup guide is missing required connection or diagnostics instructions.'); 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')) { if (
throw new Error('SSH / Unraid guide is missing its base path or host identity policy.'); !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/')) { if (
throw new Error('LumaOps audit is missing the exact matching SHA or nested repository finding.'); !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']) { for (const phrase of [
if (!releaseNotes.includes(phrase)) throw new Error(`Release notes are missing: ${phrase}`); "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']) { for (const phrase of [
if (!updaterReleaseNotes.includes(phrase)) throw new Error(`Updater release notes are missing: ${phrase}`); "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); 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.'); if (
const updateHelper = updateHelperBytes.toString('utf8'); updateHelperBytes[0] === 0xef &&
if (!updateHelper.trimStart().startsWith('param(') || updateHelper.trimStart().startsWith('\\')) throw new Error('PowerShell update helper must start directly with param(.'); 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 renderer = await readFile(path.join(root, "src/renderer/app.js"), "utf8");
const styles = await readFile(path.join(root, 'src/renderer/styles.css'), 'utf8'); const styles = await readFile(
const preload = await readFile(path.join(root, 'preload.cjs'), 'utf8'); path.join(root, "src/renderer/styles.css"),
const ipc = await readFile(path.join(root, 'src/main/ipc.cjs'), 'utf8'); "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 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')) { if (
throw new Error('Changed-file scrolling constraints are missing.'); !/\.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']) { for (const channel of [
if (!ipc.includes(channel)) throw new Error(`IPC registration is missing: ${channel}`); "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 gitSource = await readFile(
const unraidSource = await readFile(path.join(root, 'src/main/unraid-deployment-service.cjs'), 'utf8'); path.join(root, "src/main/git-service.cjs"),
const publisher = await readFile(path.join(root, 'Publish-ForgeFlow-Release.ps1'), 'utf8'); "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 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']) { for (const phrase of [
if (!unraidSource.includes(phrase)) throw new Error(`Unraid recovery implementation is missing: ${phrase}`); "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']) { for (const phrase of [
if (!publisher.includes(phrase)) throw new Error(`Publishing workflow is missing: ${phrase}`); "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}.`,
);
+15 -30
View File
@@ -10,18 +10,17 @@ function Assert-Command {
} }
function Invoke-Step { function Invoke-Step {
param( param([Parameter(Mandatory = $true)][string]$Title, [Parameter(Mandatory = $true)][scriptblock]$Action)
[Parameter(Mandatory = $true)][string]$Title,
[Parameter(Mandatory = $true)][scriptblock]$Action
)
Write-Host "`n$Title" -ForegroundColor Yellow Write-Host "`n$Title" -ForegroundColor Yellow
& $Action & $Action
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) { throw "$Title failed with exit code $LASTEXITCODE." }
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 Write-Host "No Gitea token, SSH key or server password is requested by this script." -ForegroundColor DarkGray
Assert-Command node Assert-Command node
@@ -30,33 +29,19 @@ Assert-Command git
$nodeVersionText = (node --version).Trim() $nodeVersionText = (node --version).Trim()
$nodeMajor = [int]($nodeVersionText.TrimStart('v').Split('.')[0]) $nodeMajor = [int]($nodeVersionText.TrimStart('v').Split('.')[0])
if ($nodeMajor -lt 22) { if ($nodeMajor -lt 22) { throw "Node.js 22 or newer is required. Detected: $nodeVersionText" }
throw "Node.js 22 or newer is required. Detected: $nodeVersionText"
}
Write-Host "Node: $nodeVersionText" -ForegroundColor DarkGray Write-Host "Node: $nodeVersionText" -ForegroundColor DarkGray
Write-Host "npm: $((npm --version).Trim())" -ForegroundColor DarkGray Write-Host "npm: $((npm --version).Trim())" -ForegroundColor DarkGray
Write-Host "Git: $((git --version).Trim())" -ForegroundColor DarkGray Write-Host "Git: $((git --version).Trim())" -ForegroundColor DarkGray
Invoke-Step "Installing project dependencies..." { Invoke-Step "Installing exact project dependencies..." {
if (Test-Path ".\package-lock.json") { if (-not (Test-Path ".\package-lock.json")) { throw "package-lock.json is required for a reproducible ForgeFlow installation." }
npm ci --no-audit --no-fund 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 "Running the environment doctor..." { npm run doctor }
Invoke-Step "Running source verification and automated tests..." { npm run check }
Invoke-Step "Running the environment doctor..." { Write-Host "`nForgeFlow v$version is ready." -ForegroundColor Green
npm run doctor Write-Host "Starting ForgeFlow with your existing local configuration..." -ForegroundColor Green
}
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
npm start npm start
+57
View File
@@ -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 };
+78 -10
View File
@@ -7,7 +7,7 @@ const { safeStorage } = require('electron');
const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePaths } = require('../shared/validation.cjs'); const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
const DEFAULT_CONFIG = { const DEFAULT_CONFIG = {
schemaVersion: 7, schemaVersion: 8,
setupComplete: false, setupComplete: false,
appearance: 'dark', appearance: 'dark',
gitea: { baseUrl: '', user: null, encryptedToken: null }, gitea: { baseUrl: '', user: null, encryptedToken: null },
@@ -33,7 +33,13 @@ const DEFAULT_CONFIG = {
diagnosticsEnabled: true, diagnosticsEnabled: true,
diagnosticLevel: 'info', diagnosticLevel: 'info',
logRetentionDays: 14, 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: [] operations: []
}; };
@@ -47,6 +53,7 @@ class ConfigStore {
this.filePath = path.join(userDataPath, 'forgeflow-config.json'); this.filePath = path.join(userDataPath, 'forgeflow-config.json');
this.sessionToken = null; this.sessionToken = null;
this.data = structuredClone(DEFAULT_CONFIG); this.data = structuredClone(DEFAULT_CONFIG);
this.saveQueue = Promise.resolve();
} }
migrate(parsed) { migrate(parsed) {
@@ -84,7 +91,15 @@ class ConfigStore {
async load() { async load() {
try { try {
const raw = await fs.readFile(this.filePath, 'utf8'); 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(); await this.save();
} catch (error) { } catch (error) {
if (error.code !== 'ENOENT') throw error; if (error.code !== 'ENOENT') throw error;
@@ -94,11 +109,16 @@ class ConfigStore {
} }
async save() { async save() {
await fs.mkdir(path.dirname(this.filePath), { recursive: true }); const snapshot = JSON.stringify(this.data, null, 2);
const temporary = `${this.filePath}.${process.pid}.${Date.now()}.tmp`; const operation = async () => {
await fs.writeFile(temporary, JSON.stringify(this.data, null, 2), { mode: 0o600 }); await fs.mkdir(path.dirname(this.filePath), { recursive: true });
await fs.rename(temporary, this.filePath); const temporary = `${this.filePath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`;
try { await fs.chmod(this.filePath, 0o600); } catch {} 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 } = {}) { setToken(token, { preserveExisting = false } = {}) {
@@ -250,6 +270,28 @@ class ConfigStore {
return this.getPublicState(); 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 }) { async updateGitea({ baseUrl, token, user }) {
const tokenState = this.setToken(token, { preserveExisting: true }); const tokenState = this.setToken(token, { preserveExisting: true });
this.data.gitea = { this.data.gitea = {
@@ -302,6 +344,16 @@ class ConfigStore {
branch: assertBranchName(profile.branch || 'main'), branch: assertBranchName(profile.branch || 'main'),
healthcheckUrl, healthcheckUrl,
confirmationRequired: profile.confirmationRequired !== false, 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: {} inputs: {}
}; };
if (provider === 'ssh-unraid') { if (provider === 'ssh-unraid') {
@@ -327,7 +379,7 @@ class ConfigStore {
alignRemote: profile.alignRemote === true, alignRemote: profile.alignRemote === true,
hostPort: profile.hostPort ? Math.min(Math.max(Number(profile.hostPort), 1), 65535) : null, 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, 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) iconMode: ['builtin', 'upload', 'url', 'none'].includes(profile.iconMode)
? profile.iconMode ? profile.iconMode
: profile.iconFilePath ? 'upload' : profile.iconUrl ? 'url' : 'builtin', : profile.iconFilePath ? 'upload' : profile.iconUrl ? 'url' : 'builtin',
@@ -335,7 +387,13 @@ class ConfigStore {
iconFilePath: String(profile.iconFilePath || '').trim(), iconFilePath: String(profile.iconFilePath || '').trim(),
dockerShell: ['/bin/sh', '/bin/bash'].includes(profile.dockerShell) ? profile.dockerShell : '/bin/sh', dockerShell: ['/bin/sh', '/bin/bash'].includes(profile.dockerShell) ? profile.dockerShell : '/bin/sh',
preservePaths, 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' }); 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.diagnosticLevel = ['debug', 'info', 'warning', 'error'].includes(next.diagnosticLevel) ? next.diagnosticLevel : 'info';
next.logRetentionDays = Math.min(Math.max(Number(next.logRetentionDays) || 14, 1), 90); next.logRetentionDays = Math.min(Math.max(Number(next.logRetentionDays) || 14, 1), 90);
next.maxLogFileMb = Math.min(Math.max(Number(next.maxLogFileMb) || 8, 1), 50); 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; this.data.preferences = next;
await this.save(); await this.save();
return this.getPublicState(); return this.getPublicState();
+62
View File
@@ -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 };
+38
View File
@@ -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 };
+117 -1
View File
@@ -16,6 +16,18 @@ const {
assertCloneRemote assertCloneRemote
} = require('../shared/validation.cjs'); } = 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 { class GitService {
async isAvailable() { async isAvailable() {
try { 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) { async repairSync(repoPath, strategy) {
const root = await this.ensureRepository(repoPath); const root = await this.ensureRepository(repoPath);
const requested = String(strategy || '').trim(); const requested = String(strategy || '').trim();
@@ -252,6 +298,54 @@ class GitService {
return result.stdout; 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) { selectedStatusFiles(status, files) {
const selected = assertRepositoryRelativePaths(files); const selected = assertRepositoryRelativePaths(files);
if (!selected.length) return { selected, matches: status.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 }; 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 = []) { async commitAndPush(repoPath, message, files = []) {
const committed = await this.commit(repoPath, message, files); const committed = await this.commit(repoPath, message, files);
try { try {
@@ -512,4 +628,4 @@ class GitService {
} }
} }
module.exports = { GitService }; module.exports = { GitService, parseUnifiedDiff };
+39 -1
View File
@@ -1,6 +1,6 @@
'use strict'; 'use strict';
const { normalizeBaseUrl } = require('../shared/validation.cjs'); const { normalizeBaseUrl, assertBranchName } = require('../shared/validation.cjs');
const { redactSecrets } = require('./log-redaction.cjs'); const { redactSecrets } = require('./log-redaction.cjs');
class GiteaService { class GiteaService {
@@ -107,6 +107,44 @@ class GiteaService {
return (await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches/${encodeURIComponent(branch)}`)).data; 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 }) { async getRepositoryFile({ owner, repo, filePath, ref }) {
const encodedPath = String(filePath || '').split('/').map(encodeURIComponent).join('/'); const encodedPath = String(filePath || '').split('/').map(encodeURIComponent).join('/');
const query = ref ? `?ref=${encodeURIComponent(ref)}` : ''; const query = ref ? `?ref=${encodeURIComponent(ref)}` : '';
+145 -4
View File
@@ -6,6 +6,8 @@ const { fileURLToPath } = require('node:url');
const { ipcMain, dialog, shell, app } = require('electron'); const { ipcMain, dialog, shell, app } = require('electron');
const { matchRemoteToRepository } = require('../shared/repository-match.cjs'); const { matchRemoteToRepository } = require('../shared/repository-match.cjs');
const { cloneDirectoryName, resolveCloneTarget } = require('../shared/clone-target.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; let diagnosticsService = null;
const TRUSTED_RENDERER_PATH = path.resolve(__dirname, '..', 'renderer', 'index.html'); 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; diagnosticsService = diagnostics;
const repositoryMutations = new Map(); const repositoryMutations = new Map();
const withRepositoryPause = async (localPath, action) => { const withRepositoryPause = async (localPath, action) => {
@@ -228,10 +230,46 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
register('settings:set-preferences', async ({ preferences }) => { register('settings:set-preferences', async ({ preferences }) => {
const state = await store.setPreferences(preferences); const state = await store.setPreferences(preferences);
monitor?.restart(); monitor?.restart();
onPreferencesChanged?.();
await diagnostics.info('settings.preferences.updated', { preferences: state.preferences }); await diagnostics.info('settings.preferences.updated', { preferences: state.preferences });
return state; 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:preferences', ({ updates: next }) => store.setUpdatePreferences(next));
register('updates:check', () => updates.check()); register('updates:check', () => updates.check());
register('updates:download', () => updates.download()); register('updates:download', () => updates.download());
@@ -271,6 +309,7 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
return { ...result, state: store.getPublicState() }; return { ...result, state: store.getPublicState() };
}); });
register('server:inspect-project', async ({ repository, profileId }) => unraid.inspect({ repository: await resolveRepository(repository), profileId })); 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 () => { register('repositories:refresh', async () => {
const result = await repositories.refresh(); 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: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', 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: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: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', 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: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: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: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: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: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: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:checkout-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.checkoutBranch(safePath, branch)); });
register('repository:create-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.createBranch(safePath, branch)); }); register('repository: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); if (error) throw new Error(error);
return true; 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 }) => { register('external:open', async ({ url }) => {
const parsed = new URL(url); const parsed = new URL(url);
@@ -378,6 +445,75 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
return true; 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 }) => { register('deployment:save-profile', async ({ fullName, profile }) => {
const saved = await store.saveDeploymentProfile(fullName, profile); const saved = await store.saveDeploymentProfile(fullName, profile);
await diagnostics.info('deployment.profile.saved', { repository: fullName, profile: saved }); 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 }); if (profile?.provider === 'ssh-unraid') return unraid.preflight({ repository: current, profileId });
return preflight.runDeployment({ 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 current = await resolveRepository(repository);
const profile = store.getDeploymentProfile(current.fullName, profileId); const profile = store.getDeploymentProfile(current.fullName, profileId);
if (profile?.provider === 'ssh-unraid') return unraid.deploy({ repository: current, profileId, sha }); const policy = evaluateDeploymentPolicy(profile, { note, override, reason: overrideReason });
return deployments.deploy({ repository: current, profileId, sha }); 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 }) => { register('deployment:rollback', async ({ repository, profileId, targetSha }) => {
const current = await resolveRepository(repository); const current = await resolveRepository(repository);
+130
View File
@@ -97,6 +97,75 @@ function xmlEscape(value) {
.replace(/'/g, '&apos;'); .replace(/'/g, '&apos;');
} }
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(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/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) { function iconReferenceLocalPath(iconReference) {
const value = String(iconReference || '').trim(); const value = String(iconReference || '').trim();
if (value.startsWith('file:///')) return `/${value.slice('file:///'.length)}`; if (value.startsWith('file:///')) return `/${value.slice('file:///'.length)}`;
@@ -131,6 +200,64 @@ class UnraidDeploymentService {
return { profile, server, remoteFolder, remotePath }; 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 "<Name>${container_name}</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 }) { async inspect({ repository, profileId }) {
const { profile, server, remotePath } = this.resolve(repository, profileId); const { profile, server, remotePath } = this.resolve(repository, profileId);
const preserveProbe = (profile.preservePaths || []).map((relativePath) => const preserveProbe = (profile.preservePaths || []).map((relativePath) =>
@@ -906,5 +1033,8 @@ module.exports = {
checksSummary, checksSummary,
xmlEscape, xmlEscape,
iconReferenceLocalPath, iconReferenceLocalPath,
decodeBase64Json,
parseDockerManXml,
deriveDetectedProfile,
bash bash
}; };
+2634 -686
View File
File diff suppressed because it is too large Load Diff
+1388 -203
View File
File diff suppressed because it is too large Load Diff
+2996 -473
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
'use strict';
function parseClock(value) {
const match = String(value || '').match(/^([01]\d|2[0-3]):([0-5]\d)$/);
if (!match) throw new Error('Maintenance window times must use HH:mm.');
return Number(match[1]) * 60 + Number(match[2]);
}
function normalizeMaintenanceWindows(windows) {
return (Array.isArray(windows) ? windows : []).slice(0, 20).map((window) => ({
days: [...new Set((Array.isArray(window?.days) ? window.days : []).map(Number).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6))],
start: String(window?.start || '00:00'),
end: String(window?.end || '23:59')
})).map((window) => ({ ...window, startMinutes: parseClock(window.start), endMinutes: parseClock(window.end) }));
}
function isInsideWindow(window, date) {
const minutes = date.getHours() * 60 + date.getMinutes();
if (window.startMinutes <= window.endMinutes) return window.days.includes(date.getDay()) && minutes >= window.startMinutes && minutes <= window.endMinutes;
if (minutes >= window.startMinutes) return window.days.includes(date.getDay());
const previousDay = (date.getDay() + 6) % 7;
return minutes <= window.endMinutes && window.days.includes(previousDay);
}
function evaluateDeploymentPolicy(profile, { now = new Date(), override = false, reason = '', note = '' } = {}) {
const cleanReason = String(reason || '').trim();
const cleanNote = String(note || '').trim();
const policy = profile?.deploymentPolicy || {};
const windows = normalizeMaintenanceWindows(policy.maintenanceWindows);
const violations = [];
if (policy.frozen) violations.push(policy.freezeReason ? `Deployment frozen: ${policy.freezeReason}` : 'Deployment is frozen.');
if (windows.length && !windows.some((window) => isInsideWindow(window, now))) violations.push('Current time is outside the configured maintenance windows.');
if (policy.requireNote && !cleanNote) violations.push('A release note is required for this environment.');
if (violations.length && override && !cleanReason) throw new Error('An override reason is required to bypass deployment policy.');
if (violations.length && !override) {
const error = new Error(violations.join(' '));
error.code = 'DEPLOYMENT_POLICY_BLOCKED';
error.recoverable = true;
throw error;
}
return { allowed: true, overridden: violations.length > 0, violations, reason: cleanReason, note: cleanNote };
}
module.exports = { parseClock, normalizeMaintenanceWindows, isInsideWindow, evaluateDeploymentPolicy };
+4 -3
View File
@@ -87,14 +87,15 @@ function assertDeploymentRequest(profile, sha) {
assertHttpUrl(profile.statusUrl, { label: 'Application status URL' }); assertHttpUrl(profile.statusUrl, { label: 'Application status URL' });
} }
function assertHttpUrl(value, { optional = false, label = 'URL' } = {}) { function assertHttpUrl(value, { optional = false, label = 'URL', allowUnraidTemplate = false } = {}) {
const raw = String(value || '').trim(); const raw = String(value || '').trim();
if (!raw && optional) return ''; if (!raw && optional) return '';
if (!raw) throw new Error(`${label} is required.`); if (!raw) throw new Error(`${label} is required.`);
const url = new URL(raw); const validationValue = allowUnraidTemplate ? raw.replace(/\[IP\]/gi, '127.0.0.1').replace(/\[PORT(?::\d+)?\]/gi, '8080') : raw;
const url = new URL(validationValue);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error(`${label} must use HTTP or HTTPS.`); if (!['http:', 'https:'].includes(url.protocol)) throw new Error(`${label} must use HTTP or HTTPS.`);
if (url.username || url.password) throw new Error(`${label} may not contain credentials.`); if (url.username || url.password) throw new Error(`${label} may not contain credentials.`);
return url.toString(); return allowUnraidTemplate ? raw : url.toString();
} }
function assertCloneRemote(value) { function assertCloneRemote(value) {
+11
View File
@@ -0,0 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readAcceptanceConfig } from '../scripts/acceptance.mjs';
test('acceptance harness requires an explicit complete environment', () => {
assert.throws(() => readAcceptanceConfig({}), /Missing acceptance environment variables/);
const config = readAcceptanceConfig({ FORGEFLOW_GITEA_URL: 'https://gitea.test/', FORGEFLOW_GITEA_TOKEN: 'token', FORGEFLOW_REPOSITORY: 'owner/app', FORGEFLOW_LOCAL_PATH: 'C:/Projects/App', FORGEFLOW_BRANCH: 'main', FORGEFLOW_STATUS_URL: 'https://app.test/status', FORGEFLOW_HEALTH_URL: 'https://app.test/health' });
assert.equal(config.baseUrl, 'https://gitea.test');
assert.equal(config.workflow, 'deploy.yml');
assert.throws(() => readAcceptanceConfig({ ...process.env, FORGEFLOW_GITEA_URL: 'x', FORGEFLOW_GITEA_TOKEN: 'x', FORGEFLOW_REPOSITORY: 'invalid', FORGEFLOW_LOCAL_PATH: 'x', FORGEFLOW_BRANCH: 'x', FORGEFLOW_STATUS_URL: 'x', FORGEFLOW_HEALTH_URL: 'x' }), /owner\/repository/);
});
+25
View File
@@ -0,0 +1,25 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs/promises';
import auditModule from '../src/main/audit-service.cjs';
const { AuditService } = auditModule;
test('audit service appends ordered records and exports CSV', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-audit-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const audit = new AuditService({ userDataPath: root, appInfo: { version: 'test' } });
await Promise.all([
audit.append('deployment.requested', { repository: 'owner/app', sha: 'a'.repeat(40), note: 'Release, wave 1' }),
audit.append('deployment.completed', { repository: 'owner/app', result: 'success' })
]);
const entries = await audit.list();
assert.equal(entries.length, 2);
assert.equal(entries[0].event, 'deployment.completed');
const destination = path.join(root, 'audit.csv');
const result = await audit.exportTo(destination, 'csv');
assert.equal(result.count, 2);
assert.match(await fs.readFile(destination, 'utf8'), /"Release, wave 1"/);
});
+27
View File
@@ -0,0 +1,27 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import backupModule from '../src/main/configuration-backup.cjs';
const { sanitizeConfiguration, createEncryptedBackup, readEncryptedBackup } = backupModule;
test('configuration backups exclude credentials and operation history', () => {
const clean = sanitizeConfiguration({
gitea: { baseUrl: 'https://gitea.test', encryptedToken: 'secret-token' },
servers: [{ id: 'server', host: 'unraid.test', encryptedPassword: 'password', encryptedPassphrase: 'passphrase' }],
operations: [{ id: 'operation', sha: 'a'.repeat(40) }],
preferences: { autoRefresh: true }
});
assert.equal(clean.gitea.encryptedToken, null);
assert.equal('encryptedPassword' in clean.servers[0], false);
assert.equal('encryptedPassphrase' in clean.servers[0], false);
assert.deepEqual(clean.operations, []);
});
test('configuration backups round-trip with authenticated encryption', () => {
const serialized = createEncryptedBackup({ workspaceRoots: ['C:/Projects'], gitea: { encryptedToken: 'secret' } }, 'correct horse battery staple');
assert.doesNotMatch(serialized, /C:\/Projects|secret/);
const restored = readEncryptedBackup(serialized, 'correct horse battery staple');
assert.deepEqual(restored.configuration.workspaceRoots, ['C:/Projects']);
assert.equal(restored.configuration.gitea.encryptedToken, null);
assert.throws(() => readEncryptedBackup(serialized, 'incorrect passphrase'), /could not be decrypted/i);
});
+29
View File
@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import policyModule from '../src/shared/deployment-policy.cjs';
const { evaluateDeploymentPolicy } = policyModule;
test('deployment freeze and maintenance windows fail closed with reasoned overrides', () => {
const profile = { deploymentPolicy: { frozen: true, freezeReason: 'Incident', requireNote: true, maintenanceWindows: [{ days: [1], start: '09:00', end: '10:00' }] } };
const now = new Date(2026, 6, 28, 12, 0); // Tuesday
assert.throws(() => evaluateDeploymentPolicy(profile, { now, note: 'Release' }), (error) => error.code === 'DEPLOYMENT_POLICY_BLOCKED');
assert.throws(() => evaluateDeploymentPolicy(profile, { now, note: 'Release', override: true }), /override reason/i);
const result = evaluateDeploymentPolicy(profile, { now, note: 'Release', override: true, reason: 'Emergency recovery' });
assert.equal(result.overridden, true);
assert.equal(result.violations.length, 2);
});
test('deployment policy accepts an in-window release with required note', () => {
const now = new Date(2026, 6, 27, 9, 30); // Monday
const profile = { deploymentPolicy: { requireNote: true, maintenanceWindows: [{ days: [1], start: '09:00', end: '10:00' }] } };
assert.equal(evaluateDeploymentPolicy(profile, { now, note: 'Version 1.2' }).allowed, true);
assert.throws(() => evaluateDeploymentPolicy(profile, { now }), /release note/i);
});
test('overnight maintenance windows continue into the following day', () => {
const profile = { deploymentPolicy: { maintenanceWindows: [{ days: [1], start: '22:00', end: '02:00' }] } };
assert.equal(evaluateDeploymentPolicy(profile, { now: new Date(2026, 6, 27, 23, 0) }).allowed, true);
assert.equal(evaluateDeploymentPolicy(profile, { now: new Date(2026, 6, 28, 1, 0) }).allowed, true);
assert.throws(() => evaluateDeploymentPolicy(profile, { now: new Date(2026, 6, 28, 3, 0) }), /outside/);
});
+13
View File
@@ -0,0 +1,13 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import toolsModule from '../src/main/external-tools-service.cjs';
const { normalizeTool, expandTool } = toolsModule;
test('external tool templates expand as argument arrays without a shell', () => {
const tool = normalizeTool({ executable: 'code.exe', args: ['--goto', '{file}:{line}', '{path}'] }, {});
const invocation = expandTool(tool, { path: 'C:\\Projects\\App', file: 'C:\\Projects\\App\\src\\app.js', line: 12 });
assert.equal(invocation.executable, 'code.exe');
assert.deepEqual(invocation.args, ['--goto', 'C:\\Projects\\App\\src\\app.js:12', 'C:\\Projects\\App']);
assert.throws(() => normalizeTool({ executable: 'code.exe\ncalc.exe', args: [] }, {}), /invalid/);
});
+27
View File
@@ -243,3 +243,30 @@ test('repairs a diverged branch by creating a safety branch before resetting to
const backupSha = (await git(['rev-parse', repaired.backupBranch], working)).stdout.trim(); const backupSha = (await git(['rev-parse', repaired.backupBranch], working)).stdout.trim();
assert.equal(backupSha, localBefore); assert.equal(backupSha, localBefore);
}); });
test('troubleshooter detects and aborts an interrupted merge without discarding committed history', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-interrupted-merge-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
await git(['init'], root);
await git(['config', 'user.name', 'ForgeFlow Test'], root);
await git(['config', 'user.email', 'forgeflow@example.invalid'], root);
await fs.writeFile(path.join(root, 'file.txt'), 'base\n');
await git(['add', '.'], root);
await git(['commit', '-m', 'Base'], root);
await git(['checkout', '-b', 'other'], root);
await fs.writeFile(path.join(root, 'file.txt'), 'other\n');
await git(['commit', '-am', 'Other'], root);
await git(['checkout', 'master'], root);
await fs.writeFile(path.join(root, 'file.txt'), 'main\n');
await git(['commit', '-am', 'Main'], root);
await assert.rejects(git(['merge', 'other'], root));
const service = new GitService();
assert.equal(await service.detectInterruptedOperation(root), 'merge');
const result = await service.abortInterruptedOperation(root);
assert.equal(result.aborted, 'merge');
assert.equal(await service.detectInterruptedOperation(root), null);
assert.equal(result.status.clean, true);
const subject = await git(['log', '-1', '--pretty=%s'], root);
assert.equal(subject.stdout.trim(), 'Main');
});
+19
View File
@@ -88,3 +88,22 @@ test('checks repository workflow files through the contents API', async () => {
service.request = async () => { const error = new Error('missing'); error.status = 404; throw error; }; service.request = async () => { const error = new Error('missing'); error.status = 404; throw error; };
assert.equal(await service.repositoryFileExists({ owner: 'jens', repo: 'app', filePath: '.gitea/workflows/missing.yml', ref: 'main' }), false); assert.equal(await service.repositoryFileExists({ owner: 'jens', repo: 'app', filePath: '.gitea/workflows/missing.yml', ref: 'main' }), false);
}); });
test('creates controlled pull requests and reads branch protection', async () => {
const service = new GiteaService(makeStore());
const calls = [];
service.request = async (pathname, options = {}) => {
calls.push({ pathname, options });
if (pathname.includes('/branches/main')) return { data: { name: 'main', protected: true } };
if (pathname.endsWith('/branch_protections')) return { data: [{ branch_name: 'main', required_approvals: 2, require_signed_commits: true }] };
return { data: { number: 12, html_url: 'https://gitea.test/owner/app/pulls/12' } };
};
const protection = await service.getBranchProtection('owner', 'app', 'main');
assert.equal(protection.protected, true);
assert.equal(protection.requiredApprovals, 2);
const pull = await service.createPullRequest({ owner: 'owner', repo: 'app', head: 'feature', base: 'main', title: 'Release feature', body: 'Summary' });
assert.equal(pull.number, 12);
const create = calls.find((call) => call.options.method === 'POST');
assert.deepEqual(create.options.body, { head: 'feature', base: 'main', title: 'Release feature', body: 'Summary' });
await assert.rejects(() => service.createPullRequest({ owner: 'owner', repo: 'app', head: 'main', base: 'main', title: 'Invalid' }), /different/);
});
+51
View File
@@ -0,0 +1,51 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
test("every preload invoke channel has a registered IPC handler", async () => {
const preload = await readFile(
new URL("../preload.cjs", import.meta.url),
"utf8",
);
const ipc = await readFile(
new URL("../src/main/ipc.cjs", import.meta.url),
"utf8",
);
const invokes = [...preload.matchAll(/invoke\(\s*['"]([^'"]+)['"]/g)].map(
(match) => match[1],
);
const handlers = new Set(
[...ipc.matchAll(/register\(\s*['"]([^'"]+)['"]/g)].map(
(match) => match[1],
),
);
assert.ok(invokes.length > 40, "expected the complete renderer API surface");
assert.deepEqual(
invokes.filter((channel) => !handlers.has(channel)),
[],
);
});
test("every renderer bridge call is exposed by the preload contract", async () => {
const renderer = await readFile(
new URL("../src/renderer/app.js", import.meta.url),
"utf8",
);
const preload = await readFile(
new URL("../preload.cjs", import.meta.url),
"utf8",
);
const calls = new Set(
[...renderer.matchAll(/window\.forgeflow\.([A-Za-z0-9_]+)\s*\(/g)].map(
(match) => match[1],
),
);
const exposed = new Set(
[...preload.matchAll(/^\s{2}([A-Za-z0-9_]+):/gm)].map((match) => match[1]),
);
assert.ok(calls.size > 40, "expected the complete renderer bridge surface");
assert.deepEqual(
[...calls].filter((method) => !exposed.has(method)),
[],
);
});
+44
View File
@@ -0,0 +1,44 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs/promises';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import gitModule from '../src/main/git-service.cjs';
const exec = promisify(execFile);
const git = (args, cwd) => exec('git', args, { cwd, encoding: 'utf8' });
const { GitService, parseUnifiedDiff } = gitModule;
test('unified diff parser separates selectable hunks', () => {
const parsed = parseUnifiedDiff('diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1 +1 @@\n-old\n+new\n@@ -10 +10 @@\n-x\n+y\n');
assert.equal(parsed.hunks.length, 2);
assert.equal(parsed.hunks[0].additions, 1);
assert.equal(parsed.hunks[1].deletions, 1);
});
test('stages only selected hunks using a server-generated patch', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-hunks-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
await git(['init'], root);
await git(['config', 'user.name', 'ForgeFlow Test'], root);
await git(['config', 'user.email', 'forgeflow@example.invalid'], root);
const original = [...Array(20)].map((_, index) => `line ${index + 1}`).join('\n') + '\n';
await fs.writeFile(path.join(root, 'file.txt'), original);
await git(['add', '.'], root); await git(['commit', '-m', 'Initial'], root);
const lines = original.trimEnd().split('\n'); lines[0] = 'first changed'; lines[19] = 'last changed';
await fs.writeFile(path.join(root, 'file.txt'), `${lines.join('\n')}\n`);
const service = new GitService();
const hunks = await service.diffHunks(root, 'file.txt');
assert.equal(hunks.hunks.length, 2);
await service.stageHunks(root, 'file.txt', [0]);
const staged = (await git(['diff', '--cached'], root)).stdout;
const unstaged = (await git(['diff'], root)).stdout;
assert.match(staged, /first changed/); assert.doesNotMatch(staged, /last changed/);
assert.match(unstaged, /last changed/); assert.doesNotMatch(unstaged, /first changed/);
await service.commitStaged(root, 'Commit reviewed hunk');
const afterCommit = (await git(['diff'], root)).stdout;
assert.match(afterCommit, /last changed/);
assert.doesNotMatch(afterCommit, /first changed/);
});
+158 -37
View File
@@ -1,46 +1,81 @@
import test from 'node:test'; import test from "node:test";
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import { readFile } from 'node:fs/promises'; import { readFile } from "node:fs/promises";
test('changed file list has an independently scrollable bounded layout', async () => { test("changed file list has an independently scrollable bounded layout", async () => {
const css = await readFile(new URL('../src/renderer/styles.css', import.meta.url), 'utf8'); const css = await readFile(
assert.match(css, /\.main-canvas\.repository-canvas\s*\{[^}]*overflow:\s*hidden/); new URL("../src/renderer/styles.css", import.meta.url),
assert.match(css, /\.file-panel\s*\{[^}]*min-height:\s*0[^}]*overflow:\s*hidden/); "utf8",
assert.match(css, /\.file-list\s*\{[^}]*flex:\s*1 1 auto[^}]*overflow-y:\s*auto/); );
assert.match(
css,
/\.main-canvas\.repository-canvas\s*\{[^}]*overflow:\s*hidden/,
);
assert.match(
css,
/\.file-panel\s*\{[^}]*min-height:\s*0[^}]*overflow:\s*hidden/,
);
assert.match(
css,
/\.file-list\s*\{[^}]*flex:\s*1 1 auto[^}]*overflow-y:\s*auto/,
);
}); });
test('commit workflow explains every disabled prerequisite', async () => { test("commit workflow explains every disabled prerequisite", async () => {
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8'); const renderer = await readFile(
new URL("../src/renderer/app.js", import.meta.url),
"utf8",
);
assert.match(renderer, /Commit message <span class="required-mark">required/); assert.match(renderer, /Commit message <span class="required-mark">required/);
assert.match(renderer, /Enter a commit message to enable commit and push/); assert.match(renderer, /Enter a commit message to enable commit and push/);
assert.match(renderer, /ForgeFlow stages the selected files automatically/); assert.match(renderer, /ForgeFlow stages the selected files automatically/);
assert.match(renderer, /Commit selected & push to Gitea/); assert.match(renderer, /data-action="commit-push"/);
assert.match(renderer, /Commit staged hunks/);
}); });
test('ITWorx branding is integrated into titlebar and setup', async () => { test("ITWorx branding is integrated into titlebar and setup", async () => {
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8'); const renderer = await readFile(
new URL("../src/renderer/app.js", import.meta.url),
"utf8",
);
assert.match(renderer, /itworx-mark\.png/); assert.match(renderer, /itworx-mark\.png/);
assert.match(renderer, /itworx-wordmark-(?:light|dark)\.png/); assert.match(renderer, /itworx-wordmark-(?:light|dark)\.png/);
}); });
test("all modal content stays inside the viewport with a persistent action footer", async () => {
test('all modal content stays inside the viewport with a persistent action footer', async () => { const css = await readFile(
const css = await readFile(new URL('../src/renderer/styles.css', import.meta.url), 'utf8'); new URL("../src/renderer/styles.css", import.meta.url),
assert.match(css, /\.modal\s*\{[^}]*max-height:\s*calc\(100dvh[^}]*display:\s*flex[^}]*flex-direction:\s*column/); "utf8",
assert.match(css, /\.modal-body\s*\{[^}]*min-height:\s*0[^}]*overflow-y:\s*auto/); );
assert.match(
css,
/\.modal\s*\{[^}]*max-height:\s*calc\(100dvh[^}]*display:\s*flex[^}]*flex-direction:\s*column/,
);
assert.match(
css,
/\.modal-body\s*\{[^}]*min-height:\s*0[^}]*overflow-y:\s*auto/,
);
assert.match(css, /\.modal-footer\s*\{[^}]*flex:\s*0 0 auto/); assert.match(css, /\.modal-footer\s*\{[^}]*flex:\s*0 0 auto/);
}); });
test('settings provides one-click normalization for legacy Gitea origins', async () => { test("settings provides one-click normalization for legacy Gitea origins", async () => {
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8'); const renderer = await readFile(
new URL("../src/renderer/app.js", import.meta.url),
"utf8",
);
assert.match(renderer, /data-action="normalize-origins"/); assert.match(renderer, /data-action="normalize-origins"/);
assert.match(renderer, /Normalize all origins/); assert.match(renderer, /Normalize all origins/);
}); });
test("Git mutations are serialized per repository and expose repair actions", async () => {
test('Git mutations are serialized per repository and expose repair actions', async () => { const ipc = await readFile(
const ipc = await readFile(new URL('../src/main/ipc.cjs', import.meta.url), 'utf8'); new URL("../src/main/ipc.cjs", import.meta.url),
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8'); "utf8",
);
const renderer = await readFile(
new URL("../src/renderer/app.js", import.meta.url),
"utf8",
);
assert.match(ipc, /repositoryMutations = new Map/); assert.match(ipc, /repositoryMutations = new Map/);
assert.match(ipc, /withRepositoryMutation/); assert.match(ipc, /withRepositoryMutation/);
assert.match(ipc, /GIT_LOCKS_RECENT/); assert.match(ipc, /GIT_LOCKS_RECENT/);
@@ -51,36 +86,122 @@ test('Git mutations are serialized per repository and expose repair actions', as
assert.match(renderer, /Open guided repository repair/); assert.match(renderer, /Open guided repository repair/);
}); });
test("SSH secrets are captured before the loading render clears password inputs", async () => {
test('SSH secrets are captured before the loading render clears password inputs', async () => { const renderer = await readFile(
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8'); new URL("../src/renderer/app.js", import.meta.url),
const passwordCapture = renderer.indexOf("const password = document.querySelector('#server-password')"); "utf8",
const loading = renderer.indexOf("setLoading(true, 'Saving encrypted SSH configuration…')"); );
const passwordCapture = renderer.search(
/const password = document\.querySelector\(["']#server-password["']\)/,
);
const loading = renderer.search(
/setLoading\(true, ["']Saving encrypted SSH configuration/,
);
assert.ok(passwordCapture >= 0 && loading > passwordCapture); assert.ok(passwordCapture >= 0 && loading > passwordCapture);
}); });
test('SSH deployments are polled in the background and Portfolio casing is preserved', async () => { test("SSH deployments are polled in the background and Portfolio casing is preserved", async () => {
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8'); const renderer = await readFile(
new URL("../src/renderer/app.js", import.meta.url),
"utf8",
);
assert.match(renderer, /function startOperationPolling\(\)/); assert.match(renderer, /function startOperationPolling\(\)/);
assert.match(renderer, /startOperationPolling\(\);/); assert.match(renderer, /startOperationPolling\(\);/);
assert.match(renderer, /Visible container name/); assert.match(renderer, /Visible container name/);
assert.match(renderer, /Compose service \(internal\)/); assert.match(renderer, /Compose service \(internal\)/);
}); });
test("deployment profiles expose built-in/uploaded DockerMan icons and automatic metadata repair", async () => {
test('deployment profiles expose built-in/uploaded DockerMan icons and automatic metadata repair', async () => { const renderer = await readFile(
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8'); new URL("../src/renderer/app.js", import.meta.url),
"utf8",
);
assert.match(renderer, /Built-in high-contrast ITWorx mark/); assert.match(renderer, /Built-in high-contrast ITWorx mark/);
assert.match(renderer, /profile-icon-mode/); assert.match(renderer, /profile-icon-mode/);
assert.match(renderer, /Repair DockerMan integration/); assert.match(renderer, /Repair DockerMan integration/);
assert.match(renderer, /reconcile-deployment/); assert.match(renderer, /reconcile-deployment/);
}); });
test('repository troubleshooting offers personalized synchronization repair actions', async () => { test("repository troubleshooting offers personalized synchronization repair actions", async () => {
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8'); const renderer = await readFile(
const ipc = await readFile(new URL('../src/main/ipc.cjs', import.meta.url), 'utf8'); new URL("../src/renderer/app.js", import.meta.url),
"utf8",
);
const ipc = await readFile(
new URL("../src/main/ipc.cjs", import.meta.url),
"utf8",
);
assert.match(renderer, /repair-repository-sync/); assert.match(renderer, /repair-repository-sync/);
assert.match(renderer, /safety branch/); assert.match(renderer, /safety branch/);
assert.match(ipc, /repository:repair-sync/); assert.match(ipc, /repository:repair-sync/);
}); });
test("advanced Git, desktop, backup, policy and audit workflows are exposed in the renderer", async () => {
const renderer = await readFile(
new URL("../src/renderer/app.js", import.meta.url),
"utf8",
);
const preload = await readFile(
new URL("../preload.cjs", import.meta.url),
"utf8",
);
for (const phrase of [
"Stage hunks",
"Conflict guide",
"Create pull request",
"Open pull requests",
"load-pull-requests",
"Check branch protection",
"Encrypted configuration backup",
"Deployment policy",
"Operational audit log",
])
assert.match(renderer, new RegExp(phrase, "i"));
for (const method of [
"stageHunks",
"resolveConflict",
"createPullRequest",
"branchProtection",
"openEditor",
"openTerminal",
"exportConfigurationBackup",
"listAuditEvents",
])
assert.match(preload, new RegExp(`${method}:`));
});
test("one-click troubleshooting excludes destructive or publishing Git actions", async () => {
const renderer = await readFile(
new URL("../src/renderer/app.js", import.meta.url),
"utf8",
);
const ipc = await readFile(
new URL("../src/main/ipc.cjs", import.meta.url),
"utf8",
);
assert.match(ipc, /action: 'abort-operation', safe: false/);
assert.match(ipc, /action: 'push', safe: false/);
assert.match(ipc, /const stale = lock\.ageMs >= 10_000/);
assert.match(ipc, /\['fast-forward', 'fetch'\]\.includes\(issue\.action\)/);
assert.doesNotMatch(
ipc,
/\['fast-forward', 'push', 'fetch'\]\.includes\(issue\.action\)/,
);
assert.match(
renderer,
/trouble\?\.issues\?\.some\(\(item\) => item\.repairable && item\.safe\)/,
);
});
test("premium repository workspace reserves separate rows for actions and release status", async () => {
const styles = await readFile(
new URL("../src/renderer/styles.css", import.meta.url),
"utf8",
);
assert.match(
styles,
/\.repo-workspace\s*\{[^}]*grid-template-rows:\s*auto auto auto 39px minmax\(0, 1fr\)/s,
);
assert.match(styles, /prefers-reduced-motion/);
assert.match(styles, /ForgeFlow 0\.8 premium visual system/);
});
+36
View File
@@ -265,3 +265,39 @@ test('stuck deployment is cleared as superseded when a different healthy commit
assert.match(result.error, /Superseded/); assert.match(result.error, /Superseded/);
assert.equal(saved.at(-1).status, 'cancelled'); assert.equal(saved.at(-1).status, 'cancelled');
}); });
test('existing Unraid deployment discovery derives profile values from Docker, Compose and DockerMan truth', () => {
const { deriveDetectedProfile } = require('../src/main/unraid-deployment-service.cjs');
const result = deriveDetectedProfile({
repository: { name: 'blockpilot-autonomous', defaultBranch: 'main', sshUrl: 'ssh://git@gitea/Jens/blockpilot-autonomous.git' },
server: { id: 'unraid', host: '192.168.10.150' },
remoteFolder: 'blockpilot-autonomous',
remotePath: '/mnt/user/appdata/blockpilot-autonomous',
payload: {
head: 'a'.repeat(40),
branch: 'main',
remote: 'ssh://git@gitea/Jens/blockpilot-autonomous.git',
composeFiles: ['compose.yml'],
compose: { services: { app: { image: 'blockpilot:test' } } },
containers: [{
Name: '/blockpilot',
State: { Running: true },
Config: { Image: 'blockpilot:test', Env: ['TOKEN=secret', 'MODE=prod'], Labels: { 'com.docker.compose.service': 'app', 'com.docker.compose.project': 'blockpilot' } },
HostConfig: { RestartPolicy: { Name: 'unless-stopped' } },
NetworkSettings: { Ports: { '8080/tcp': [{ HostIp: '0.0.0.0', HostPort: '1223' }] }, Networks: { bridge: {} } },
Mounts: [{ Type: 'bind', Source: '/mnt/user/appdata/blockpilot-autonomous/data', Destination: '/data', RW: true }]
}],
dockerManXml: '<Container><Name>blockpilot</Name><WebUI>http://[IP]:[PORT:1223]/</WebUI><Icon>https://example.test/icon.png</Icon><Shell>/bin/bash</Shell></Container>'
}
});
assert.equal(result.profile.hostPort, 1223);
assert.equal(result.profile.containerPort, 8080);
assert.equal(result.profile.containerName, 'blockpilot');
assert.equal(result.profile.composeService, 'app');
assert.equal(result.profile.webUiUrl, 'http://[IP]:[PORT:1223]/');
assert.equal(result.profile.iconUrl, 'https://example.test/icon.png');
assert.equal(result.profile.dockerShell, '/bin/bash');
assert.deepEqual(result.profile.detectedMetadata.envNames, ['TOKEN', 'MODE']);
assert.ok(result.profile.preservePaths.includes('data'));
assert.equal(result.provenance.hostPort.origin, 'docker-inspect');
});
+6 -1
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import validation from '../src/shared/validation.cjs'; import validation from '../src/shared/validation.cjs';
const { normalizeBaseUrl, assertCommitMessage, assertDeploymentRequest } = validation; const { normalizeBaseUrl, assertCommitMessage, assertDeploymentRequest, assertHttpUrl } = validation;
test('normalizes Gitea base URL', () => { test('normalizes Gitea base URL', () => {
assert.equal(normalizeBaseUrl('https://gitea.example.com/'), 'https://gitea.example.com'); assert.equal(normalizeBaseUrl('https://gitea.example.com/'), 'https://gitea.example.com');
@@ -15,3 +15,8 @@ test('rejects blank commit messages', () => {
test('requires exact SHA and workflow profile', () => { test('requires exact SHA and workflow profile', () => {
assert.throws(() => assertDeploymentRequest({ branch: 'main', workflowFile: 'deploy.yml' }, 'nope'), /commit SHA/i); assert.throws(() => assertDeploymentRequest({ branch: 'main', workflowFile: 'deploy.yml' }, 'nope'), /commit SHA/i);
}); });
test('accepts Unraid DockerMan WebUI placeholders only when explicitly enabled', () => {
assert.equal(assertHttpUrl('http://[IP]:[PORT:1223]/', { allowUnraidTemplate: true }), 'http://[IP]:[PORT:1223]/');
assert.throws(() => assertHttpUrl('http://[IP]:[PORT:1223]/'));
});
+14 -29
View File
@@ -4,47 +4,32 @@ Set-Location $PSScriptRoot
function Assert-Command { function Assert-Command {
param([Parameter(Mandatory = $true)][string]$Name) param([Parameter(Mandatory = $true)][string]$Name)
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { throw "Required command '$Name' was not found on PATH." }
throw "Required command '$Name' was not found on PATH."
}
} }
function Invoke-Step { function Invoke-Step {
param( param([Parameter(Mandatory = $true)][string]$Title, [Parameter(Mandatory = $true)][scriptblock]$Action)
[Parameter(Mandatory = $true)][string]$Title,
[Parameter(Mandatory = $true)][scriptblock]$Action
)
Write-Host "`n$Title" -ForegroundColor Yellow Write-Host "`n$Title" -ForegroundColor Yellow
& $Action & $Action
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) { throw "$Title failed with exit code $LASTEXITCODE." }
throw "$Title failed with exit code $LASTEXITCODE."
}
} }
Write-Host "ForgeFlow v0.4.2 source update" -ForegroundColor Cyan $package = Get-Content ".\package.json" -Raw | ConvertFrom-Json
Write-Host "Your Gitea token, repository mappings and deployment profiles are stored outside this source folder and are not reset." -ForegroundColor DarkGray $version = [string]$package.version
if ($package.name -ne "forgeflow" -or [string]::IsNullOrWhiteSpace($version)) { throw "This folder is not a valid ForgeFlow source release." }
Write-Host "ForgeFlow v$version source update" -ForegroundColor Cyan
Write-Host "Your Gitea token, repository mappings and deployment profiles are stored outside this source folder and are not reset." -ForegroundColor DarkGray
Assert-Command node Assert-Command node
Assert-Command npm Assert-Command npm
Assert-Command git Assert-Command git
$package = Get-Content ".\package.json" -Raw | ConvertFrom-Json Invoke-Step "Installing exact project dependencies..." {
if ($package.version -ne "0.4.2") { if (-not (Test-Path ".\package-lock.json")) { throw "package-lock.json is required for a reproducible ForgeFlow update." }
throw "This folder does not contain ForgeFlow v0.4.2. Detected version: $($package.version)" npm ci --no-audit --no-fund
} }
Invoke-Step "Running the environment doctor..." { npm run doctor }
Invoke-Step "Running source verification and automated tests..." { npm run check }
Invoke-Step "Updating project dependencies..." { Write-Host "`nForgeFlow v$version is ready." -ForegroundColor Green
npm install --no-audit --no-fund
}
Invoke-Step "Running the environment doctor..." {
npm run doctor
}
Invoke-Step "Running source verification and all automated tests..." {
npm run check
}
Write-Host "`nForgeFlow v0.4.2 is ready." -ForegroundColor Green
Write-Host "Starting ForgeFlow with your existing local configuration..." -ForegroundColor Green Write-Host "Starting ForgeFlow with your existing local configuration..." -ForegroundColor Green
npm start npm start