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:
@@ -1,5 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## 0.8.1 - 2026-07-26
|
||||
|
||||
- introduced a refined premium visual system with clearer hierarchy, richer depth, responsive density and reduced-motion support;
|
||||
- added a live open pull-request overview per repository instead of only pull-request creation;
|
||||
- added a safe installed-connection self-test for DPAPI, Gitea identity, repository access and Actions access;
|
||||
- verified the existing dedicated Unraid Ed25519 key and strict host fingerprint against the configured server;
|
||||
- expanded compact-window visual acceptance at 1120 × 720 and the dashboard check at 1440 × 900.
|
||||
|
||||
## 0.8.0 - 2026-07-25
|
||||
|
||||
- Added partial-hunk staging, conflict guidance, protected-branch awareness and Gitea pull requests.
|
||||
- Added configurable editor/terminal integration, tray, notifications and login startup.
|
||||
- Added deployment policies, release notes, append-only audit export and encrypted configuration backup/restore.
|
||||
- Added a guarded end-to-end environment acceptance harness.
|
||||
|
||||
## 0.7.0
|
||||
|
||||
- Added server-authoritative adoption of existing Unraid/Compose deployments.
|
||||
- Added Docker, Compose, Git and DockerMan discovery with provenance and complete runtime metadata.
|
||||
- Added a general one-click troubleshooter for common Git and deployment failures.
|
||||
- Added safe abort recovery for interrupted Git operations and protected divergence repair.
|
||||
- Fixed Unraid WebUI placeholder validation, serialized config saves, malformed config recovery and CRLF manifest verification.
|
||||
|
||||
## 0.6.1
|
||||
|
||||
- Fixed Windows PowerShell 5.1 updater status replacement and STARTED handshake.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# ForgeFlow
|
||||
|
||||
ForgeFlow 0.8 adds partial-hunk commits, guided conflict recovery, Gitea pull
|
||||
requests and protected-branch awareness, configurable editor/terminal actions,
|
||||
deployment policies and release notes, append-only audit export, encrypted
|
||||
configuration backup, native tray/notifications and a guarded end-to-end
|
||||
acceptance harness.
|
||||
|
||||
## Publish this release to the built-in updater repository
|
||||
|
||||
Extract the full source ZIP to a folder under Downloads and run:
|
||||
|
||||
+76
-57
@@ -1,16 +1,8 @@
|
||||
ForgeFlow 0.6.1 source manifest
|
||||
ForgeFlow 0.8.1 source manifest
|
||||
SHA-256 BYTES PATH
|
||||
(The manifest excludes itself and generated release archives.)
|
||||
(The manifest excludes itself, dependencies and generated release artifacts.)
|
||||
755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 58 .gitignore
|
||||
82438ae208c8d8eaac56fcdc68e52efcfd7a57890198d3f8b8d8c69280b600a6 6359 CHANGELOG.md
|
||||
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE
|
||||
217817c7e10a287f852735f412c25098c0983866d0b45769c828534912895c1a 347 OVERLAY-INSTRUCTIONS.md
|
||||
fced7dd332ad944fac79bc3979e5a360d95c028ad0c91e1cd6d0abddf5822fbe 8281 Publish-ForgeFlow-Release.ps1
|
||||
a94b84bb0c568b7c4f7df12f86a3fcca93ec2ae5ce25597e0a2d6feb6ea31106 13355 README.md
|
||||
058aeaa5d9bfe377c7e322f213c7871ecc4151b5d08ef790992f4ee28d857658 743 START-FORGEFLOW-OVERLAY.ps1
|
||||
c7b1ecc475931577a914c94a326fbf25ec4a4a742286f6f5c2bae8a38528f6c0 2098 START_HERE.md
|
||||
8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea 767 UPDATE_FROM_0.3.2.md
|
||||
6337a7d0791e8749e3a576d7735257b0031db7a9eff4a2d6818cc251544774d2 1704 build-windows.ps1
|
||||
89545860bd6f7566da81edc8328cd2a1ebf33e81a4b0dcf2cec74338c05e8cac 1753 build-windows.ps1
|
||||
0970821475a4452aa19e447e9397a95db836791f16890a1a83fd748ac033dc86 8830 build/icon-128.png
|
||||
09112c1425ca953d8dd8b2bcfd221e5a84b9f81752f7168f360e295030cbc8f2 521 build/icon-16.png
|
||||
510aa27935a63ad16cc22978ccfde3bdd441cb970ad42d9f05af52c0e5999195 28923 build/icon-256.png
|
||||
@@ -20,11 +12,13 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263
|
||||
4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183 3364 build/icon-64.png
|
||||
25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico
|
||||
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png
|
||||
2d9836ae6d576bab5494b9f094bc673e5ca4772bf5771006583d1bc46fe46698 8296 docs/ARCHITECTURE.md
|
||||
994243db23370527fd4cf1eeb2ec9c1cd5609daad55c1499a1158b911657bfdf 7811 CHANGELOG.md
|
||||
21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md
|
||||
a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md
|
||||
30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md
|
||||
eb42f979666e05d51c587e4223282914926a2b9b1ade9f3fb75525019ce7f738 4616 docs/DIAGNOSTICS.md
|
||||
a0cd06a96f23a94e118feb012be0fa1ac51345951cb2ba8e67fb8c889c4c342a 5007 docs/LUMAOPS_SERVER_AUDIT.md
|
||||
84ae90829ecd0eb9b56b7c7a9139e12a86f74c3d938c29b63020286955d317ca 4508 docs/RELEASE_AUDIT_0.6.0.md
|
||||
f79908fb3dad98c38030c6e6be7c79a1999e0478ed9c2496923891954438daa1 4581 docs/RELEASE_AUDIT_0.6.0.md
|
||||
a0c00ff76acd1682bb5e0e8dcf6589c9480da436c9c6d30780a1ed58b4dad94f 1770 docs/RELEASE_NOTES_0.2.0.md
|
||||
5773ead01aa4c522c556295553787482d01b1f5242f053b2c61f120c4de4fa76 5963 docs/RELEASE_NOTES_0.3.0.md
|
||||
d46de73cf6c4cd5c2ba3f455a7a2af2e0d64ee9d94a97fd1a0bfb44e35c1624a 1093 docs/RELEASE_NOTES_0.3.1.md
|
||||
@@ -40,21 +34,24 @@ f9554c10f56d41d916330f06175b07f099c9ed1534f00abc9ea7f94be70f4a97 1097
|
||||
d7d007e4c2807698db07b2ebe1cb48c36bd162bf4daad77c9d299096c9654d5a 721 docs/RELEASE_NOTES_0.5.2.md
|
||||
3e77df12a7ff4b545069933410bf14fe8891f39915182df25112c722cf4e243d 1030 docs/RELEASE_NOTES_0.5.3.md
|
||||
61f6cbc1c3f263fa96b5c6a70a26baa7cd577d37ac633455eed45d9b63169a35 710 docs/RELEASE_NOTES_0.5.4.md
|
||||
9f72a5d039615785ccd4771f38b060bb58386b3217ba2a56459067cebbbb812f 4875 docs/RELEASE_NOTES_0.6.0.md
|
||||
179860938908bc65b8ce8ca5019fd0a24e79b1eb88d368c9856191d48ec00d3a 675 docs/RELEASE_NOTES_0.6.1.md
|
||||
c465f1a9c4454c9a18f38f68a243037b8897c2c9929077a586604acd4ff26d35 3655 docs/ROADMAP.md
|
||||
322624242d246d07180cc719e14c91e8fb69e123676a02e5046f4e576cca1ca1 5569 docs/SECURITY.md
|
||||
c79123aa4c718ac3ab0d79771f2967710c28f939b58fca0094b02e3172f2c024 13067 docs/SETUP_GUIDE.md
|
||||
43f73b6674ee0c5d7ace8ab39db95b5b42b61c73dd79ffc8d87fe214d8b51d7e 5070 docs/SSH_UNRAID_DEPLOYMENT.md
|
||||
b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md
|
||||
0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md
|
||||
08640f1b5e26048b5ae501909f415d2426b07cc316a0bd2178023f2457aa7a2a 3978 docs/TEST_MATRIX.md
|
||||
95b5b2915a11065a22eb2822ecce9fb30fec0c08874d63c8ffb5182cd20b3459 2328 docs/UPDATING.md
|
||||
1ecca96cf8a6f01d7ead37d5a6b678549c2561bfd84011b6149f718a25971661 4936 docs/RELEASE_NOTES_0.6.0.md
|
||||
23150c58e6416d48c2ed6e378fff99179ed810b766ed50b70d4d829c6774b8ef 685 docs/RELEASE_NOTES_0.6.1.md
|
||||
f3d04f2d3419a7a010d5399cdd9351ff85ab2b3fdf8023977e559b0a5f8bcdc3 2571 docs/RELEASE_NOTES_0.7.0.md
|
||||
d7bdc61d9b617ad5acf0b2d468eda547fd7509d4af08f33d2661393f25bdcb5a 576 docs/RELEASE_NOTES_0.8.0.md
|
||||
1e056bfcf2105843402f4b14c63480240cc55456a4a63e229b3fdbaf3156b803 754 docs/RELEASE_NOTES_0.8.1.md
|
||||
2b631b9d6d973bdd70869d84886ff339da351e29e17598970b3b27915674661d 4175 docs/ROADMAP.md
|
||||
1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png
|
||||
b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png
|
||||
070e6700bdae8c628c907ba181bbf0dde0bbbbb4208f7a875503f933ff1b882e 118819 docs/screenshots/deployment-success.png
|
||||
bcb1e4daf1eeedc5b3f61d2406f1a65312dba130082528007e1629d9df99570a 153240 docs/screenshots/overview.png
|
||||
224e34ab45877bbb97b07d2a14c4a5aa6e28339522a8015b33a2a81477177143 135102 docs/screenshots/repository-workspace.png
|
||||
322624242d246d07180cc719e14c91e8fb69e123676a02e5046f4e576cca1ca1 5569 docs/SECURITY.md
|
||||
32a34ec13a284d3f9ceebbc107b25a844e3db096f8cafa4e43951fc2050c9a03 13552 docs/SETUP_GUIDE.md
|
||||
4dcdbd42550a4cc53fe948349b20bfe0e445d40144ef82df54b3aab8b00a8f46 5165 docs/SSH_UNRAID_DEPLOYMENT.md
|
||||
b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md
|
||||
0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md
|
||||
4625a10ebd3c749f60b2a7bef6b1716cd05dbc44ccceba0491a1b46bc293c195 4883 docs/TEST_MATRIX.md
|
||||
42f3bcb264fa772849782f163fdcaac28aedad15d754b93ed52ab3ab41477c3d 2377 docs/UPDATING.md
|
||||
c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 examples/gitea-actions/deploy.yml
|
||||
4c792cc9fd57ed36da291300c252a6ef75b08a249cf6f2561e23c4c22522138a 1477 examples/gitea-actions/rollback.yml
|
||||
1d2cde1bef4882f56006823d2806f6105882fa098a665a303150fdf18ada2004 5705 examples/server/forgeflow-deploy
|
||||
@@ -62,63 +59,85 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532
|
||||
0423fe2cc7f43fe793986a3f62a395668897cdf07348756aa7742a8cd40ac51c 569 examples/server/forgeflow-targets.conf
|
||||
106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf
|
||||
2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json
|
||||
1e47552cfde3ca925ba1f24fbc471f3fc29468ca7ef3351beb9bde2bf0747bc7 7887 main.cjs
|
||||
1dc73c9ec2393a3aeb655ade78eb81a5a6946c2760884bc4c653cbef171896cf 2745 package.json
|
||||
0cd434cb21af86e7f6983416e4ed14762565df90edc2d00d4a60378df75b7419 6846 preload.cjs
|
||||
874cabc5ff1abce4e1ba6560f35d5956f9ec69f1302164169f0da56cc62ca954 10056 scripts/apply-source-update.ps1
|
||||
f427dfcd7b5ee7079de13633c8d7d22a91115e0bbc4f2a9a96246f42176d4880 3596 scripts/doctor.mjs
|
||||
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE
|
||||
7c7790e229bbe6035a47c29d17cddd187482e7cda03c5672ec9d7aca16bc4a5c 11357 main.cjs
|
||||
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
|
||||
e4c91f198af6235ea035f58268b85588bc1cbf0952c28785a30f15ee883feb84 134141 package-lock.json
|
||||
f85119969c32226a5d5094616400936aaa161db65ed35753894a5bc75dc50f0b 3301 package.json
|
||||
3d2ac366a13e9418e3ec6d13ce95b611f30f0228eb3a80ef9e7a936ce9578e24 9080 preload.cjs
|
||||
b31c43d9355c13b5ae4efc0f3649d8cb8d509b2bb7ebb042ff546b7820fb7de8 8411 Publish-ForgeFlow-Release.ps1
|
||||
a6d32a742412b7836606be00f17be0465f1b6f55d3911f6c73a14029787ba206 14037 README.md
|
||||
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
|
||||
f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287 scripts/apply-source-update.ps1
|
||||
6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs
|
||||
5e9a2a819522f6a32bbd9d3303263d5e5eaec95898ea2cd5776b221168008d75 1727 scripts/generate-source-manifest.mjs
|
||||
444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs
|
||||
4bd28f46cb2dda347e534b5d57a46425b9fe5862dd22cdb58b983d01b3174fcf 10161 scripts/verify.mjs
|
||||
92524adae60aced3af23f8afe82c011873ae9f1e53d854e4d12a94e8d1be1aa9 2075 setup-windows.ps1
|
||||
87885d640a1148078426522c87d7c9b7fced1fabb371020a4781bb94b256ee00 19664 src/main/config-store.cjs
|
||||
a970ff3f47d1641bf1ab9611e1122349aa65ff8fee4789585e078431368b8c6b 23655 src/main/deployment-service.cjs
|
||||
42203f9e0fd4aae517284d387f265cf1b0b180379bc253a092b5c3c5c4caef0a 2992 scripts/validate-installed-connections.cjs
|
||||
a2733f653e6abea7f27c3198631e6144c98458874b338704f99252451cce235b 11999 scripts/verify.mjs
|
||||
619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1
|
||||
dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs
|
||||
a381848a296c28f6d14093c96f722967acf9c994ffb867d54dd92bf5ada2729b 23648 src/main/config-store.cjs
|
||||
2fb04b1494b39f5d7c0720fa5fd298cd46fa85dc1b696d77657592347fcf1819 2731 src/main/configuration-backup.cjs
|
||||
9d0af5074093108a5248d0dde0ff70a666748e61f1954b630886a81e8f34072c 24079 src/main/deployment-service.cjs
|
||||
c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs
|
||||
c50bf93d0d1abfc0319e59a545877413bd3aca1e5b57c9a48a2467db5e89d7f8 25731 src/main/git-service.cjs
|
||||
ab7344b1951c87e982cab5c293891bc45dad48a48a3b4b63991b0e76ef785ba6 12759 src/main/gitea-service.cjs
|
||||
743002a5dae3c8e6aa5236609dd58c786420408c321eebf6d6b9e32a684cd696 26042 src/main/ipc.cjs
|
||||
a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs
|
||||
0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs
|
||||
113612b23f9c812e1dbe33eaaf398725c351678419e8304a8dfb4df881b862ff 15048 src/main/gitea-service.cjs
|
||||
b2d768a9dfd1e494edee6609a233469e60c31c362143d5c37c3c9f908b7bca79 40559 src/main/ipc.cjs
|
||||
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs
|
||||
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs
|
||||
1dc0c997bd2d837f3d27dff58a9443888597b7981c8a1dd1eaa4487176ef716c 1520 src/main/process-runner.cjs
|
||||
3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs
|
||||
e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508 src/main/repository-monitor.cjs
|
||||
a302bdcfbf2e2b66fdb4e13d94cc7a78cd4065a779b980f4487b6075a6472017 7583 src/main/repository-service.cjs
|
||||
ad9e8b67bd10f2f5708d00ebacf660ab4917dd02b3d22b3b6d730bcb2a7e1c18 8124 src/main/ssh-service.cjs
|
||||
a716f7402d3039296f99f1a7958a9aa521032f091e4171e224fa548d131a4183 49354 src/main/unraid-deployment-service.cjs
|
||||
709a6eb6a9b277cdc33f4d97558d2aca9d7ebe62a0e38fe52adc9dc3c9f94422 12791 src/main/update-service.cjs
|
||||
cea387e7996de8d185cd11f7d8d4e0675a58df1ca8f3cb6c3c6d6363c72f5f12 150880 src/renderer/app.js
|
||||
17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs
|
||||
b31a63bf8cb1807b3e838e2bf8a0e742738f119d13de8ca9f42e471f072217d3 8328 src/main/ssh-service.cjs
|
||||
720103f14cbedd7fd2776e49fd970a634f14e03d548d90bf93bcd878b6b3c674 58758 src/main/unraid-deployment-service.cjs
|
||||
e87647c45cf06e2aa58e319adff96af0f927ca278ff0877eac3b8ff97d690ea8 13103 src/main/update-service.cjs
|
||||
aab6597f0efd72cb27c12aab9866fb5cfe87710b9fbca335e2b5dd767c8bab13 191016 src/renderer/app.js
|
||||
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png
|
||||
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png
|
||||
094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png
|
||||
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png
|
||||
394f901b0e4add6b788c006d9869a66dc021dcebf2916f5a732bc42786510efc 740 src/renderer/index.html
|
||||
4f3d7fa6126fe609a64cbe2de4817c6a9f2b6b31ba8abee4f8e35a93a185c601 39109 src/renderer/mock-bridge.js
|
||||
6f0bd7b898677b9bcf6a104d7b1292b9ed5db826e4afdc86907e1c55bd889456 48072 src/renderer/styles.css
|
||||
e1c463d6cda9f2b9b78c468845c0a7e8688f0362be5642074a1a5f7122dfe811 762 src/renderer/index.html
|
||||
24a32724ad412e9c3a2b93f2ddf4cb8db0cddf421968cc620b0552caac39076c 50942 src/renderer/mock-bridge.js
|
||||
51f6777fd7d2dc73dc3ddd96c91a11882483099b0a62e0ce172e25e3dce474a6 59851 src/renderer/styles.css
|
||||
0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs
|
||||
5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs
|
||||
029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs
|
||||
2778ebcbdf60fdc1cb0749f15565e0e1bd66f3a0d31eb70ae7942a7511a3de75 1295 src/shared/repository-match.cjs
|
||||
7f4d057a3c8e8d22eda9477eea7b144237824ef0f514737831d1881ff8e7f4a4 1120 src/shared/semver.cjs
|
||||
c7e120ea53c5ef3c01b8cce71afe913f34bb461bb73aa3ade24656e09f99f338 1152 src/shared/semver.cjs
|
||||
ede2c95bb045c0005a3931709a0116d9fbcb3faa5f609848a0066c6ba382ca0b 2906 src/shared/shell-verification.cjs
|
||||
2daa98fd421598bfe5fc9757c9b6f4d82c31d1bfece15829928473581d5d2639 1210 src/shared/tool-invocation.cjs
|
||||
a97c83b8023d6c0cf49d6f2d5b626ef2341f02670f0de170e840026d28fd1f0e 5202 src/shared/validation.cjs
|
||||
114f01be8bd54c91b90af82d8e1604e24cc0c5f8e64e63c40cf3f4042623a98e 5402 src/shared/validation.cjs
|
||||
13b731c38863b1007b0312fd9d89562401b7cce875c952f52429bde74f77a8af 3096 src/shared/zip-writer.cjs
|
||||
c1b0fd37a6ae74a56750138626fb2bc3523485c1124a0b8ac8ee3ef41089206a 2156 START_HERE.md
|
||||
058aeaa5d9bfe377c7e322f213c7871ecc4151b5d08ef790992f4ee28d857658 743 START-FORGEFLOW-OVERLAY.ps1
|
||||
f5b0ea887fcdeadec78c1ad49b0ec7979723562f5c0b730703acb77a37281ee0 1009 tests/acceptance.test.mjs
|
||||
a4e5947204ff6878e601e32477bc85b53cd0153baf95a161c8935b6e5466c257 1155 tests/audit-service.test.mjs
|
||||
454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 4318 tests/clone-target.test.mjs
|
||||
abb65b39f285da518a48be41aff40d89ceb9c5b0e6091772c2bde171f65daf9b 7523 tests/deployment-status.test.mjs
|
||||
a984ddd5a29a4ccf55d78ba71202390e0bf1f0925a6a96f03ee74edbbf3bb2f7 1505 tests/configuration-backup.test.mjs
|
||||
1dc6477bd07de78be189e6e8195ec339eb9d75820c4dbd5b073b8520ee21f6b5 1938 tests/deployment-policy.test.mjs
|
||||
bf68c4dc91a2604235c6a7848088bcd9566fbeaa089b86ec1e0a4fcfc54ca9d2 7677 tests/deployment-status.test.mjs
|
||||
fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800 tests/diagnostics.test.mjs
|
||||
efee4d7ae2a51b27d8643bd40e896aaddcdfdaa98bd4ac16ec83670b82b13791 12066 tests/git-integration.test.mjs
|
||||
dd121d96ca265a027cd415a52064500a4541b2f8a662f4f4b25f2f996d52b5da 762 tests/external-tools.test.mjs
|
||||
e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 tests/git-integration.test.mjs
|
||||
5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db 1239 tests/git-status.test.mjs
|
||||
681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.test.mjs
|
||||
e914b2bcafbd674c06adfd9bd851ca04e134210691b7f91cd3de26cee37ef5f3 4154 tests/gitea-actions.test.mjs
|
||||
52b96f0a6623778fbdc1e8dbfc892e1d77a3d6616058cd7b08d532b207aa5719 5531 tests/gitea-actions.test.mjs
|
||||
267d76b868d8d06ea031c14acd09a7715fb44668a25ade51a9e62e0170888bc8 1522 tests/ipc-contract.test.mjs
|
||||
caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs
|
||||
96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs
|
||||
c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 3718 tests/preflight.test.mjs
|
||||
d837c7ee9f9c3f6ee37d6546c4f8bcee49463339dab165a1fc1edaa6735b4ff9 4550 tests/renderer-workflow.test.mjs
|
||||
9b56c259cbf6c45c671267343c0871d56ac336082c531050b44fbcfca7476f62 6989 tests/renderer-workflow.test.mjs
|
||||
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs
|
||||
f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 1068 tests/repository-monitor.test.mjs
|
||||
6527813f2ae318f7a6aca57d375a3d3583173a208c540d2e8772f84f479d42b0 2205 tests/repository-service.test.mjs
|
||||
3c71aa5fb30d9c6fbc4b0ccfcf5112f45cbcc2a60cb990e4551a8813f7155505 3397 tests/security-validation.test.mjs
|
||||
ecfdad2a03c24898c822fcf05abac89c8f8fe452a05b16fdafc0236a64c27a23 614 tests/semver.test.mjs
|
||||
75b5b83836c75675bb9a48fe4363fcb8a24fc425e6af6f822d7955c6f3c79eac 2265 tests/repository-service.test.mjs
|
||||
d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463 tests/security-validation.test.mjs
|
||||
bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs
|
||||
020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs
|
||||
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs
|
||||
7c0f5268028cf8904b446c5d9c5a8b6450966f493018777256104df2626d0f3e 15662 tests/unraid-deployment.test.mjs
|
||||
44c82a2658f4286afb657c5952d12a6038c83f0adfe7ea0765e29af412a718dc 10233 tests/update-service.test.mjs
|
||||
4d1f0a4c46190ca72b51fddf79ec6d4d02e65fa6f42ef3755de5d414f7da75bb 655 tests/validation.test.mjs
|
||||
54f641103a91d98974c41a3c0a568c617b76fa9913a0e20710cd11adc39b0deb 18092 tests/unraid-deployment.test.mjs
|
||||
4ea1acea5ca92e1360bcf1263f65d1be0de80ab44adc9af2b09f408951e0d9fa 10454 tests/update-service.test.mjs
|
||||
9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs
|
||||
7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs
|
||||
3ea68269b66f639b3aba50c9605ccbfaa32c22a1d2cc842d8296c4ee59df6212 1537 update-windows.ps1
|
||||
8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea 767 UPDATE_FROM_0.3.2.md
|
||||
2ebde94f664d86f42aea9bb3112b70b272c6706f4d364cad2f5c6191d2873e98 1655 update-windows.ps1
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ function Invoke-Step {
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "ForgeFlow v0.4.2 Windows development build" -ForegroundColor Cyan
|
||||
Write-Host "ForgeFlow Windows executable build" -ForegroundColor Cyan
|
||||
Write-Host "Artifacts are unsigned and intended for local testing." -ForegroundColor DarkGray
|
||||
|
||||
Assert-Command node
|
||||
|
||||
@@ -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.
|
||||
@@ -14,7 +14,7 @@
|
||||
| Electron main process |
|
||||
| |
|
||||
| IPC validation + trusted sender checks |
|
||||
| ConfigStore -------- schema 5 + protected Gitea/SSH secrets |
|
||||
| ConfigStore -------- schema 8 + protected Gitea/SSH secrets |
|
||||
| GitService ----------- Git through execFile args |
|
||||
| GiteaService --------- repositories + Actions API |
|
||||
| RepositoryService ---- discovery + aggregation |
|
||||
@@ -69,7 +69,7 @@ per-environment lock. The runner receives no free-form command from ForgeFlow.
|
||||
## Local state
|
||||
|
||||
`forgeflow-config.json` lives below Electron's platform-specific user-data path
|
||||
and is written atomically. Schema version 3 contains:
|
||||
and is written atomically. Schema version 8 contains:
|
||||
|
||||
- Gitea connection metadata and an OS-encrypted token blob where available;
|
||||
- workspace roots and explicit repository mappings;
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,5 +1,16 @@
|
||||
# ForgeFlow roadmap
|
||||
|
||||
## Delivered in v0.8
|
||||
|
||||
- partial-hunk staging with staged-only commits;
|
||||
- guided conflict resolution and safe continue/abort controls;
|
||||
- Gitea branch-protection awareness and pull-request creation;
|
||||
- configurable editor/terminal integration;
|
||||
- deployment freezes, maintenance windows, release notes and overrides;
|
||||
- append-only audit export and encrypted credential-free configuration backup;
|
||||
- native notifications, tray, close-to-tray and start-at-login;
|
||||
- guarded real-environment deploy/rollback acceptance harness.
|
||||
|
||||
## Delivered through v0.4
|
||||
|
||||
- coherent Local -> Gitea -> Server desktop model;
|
||||
|
||||
@@ -90,6 +90,19 @@ The standalone demo should be checked at minimum at:
|
||||
Required views now include setup readiness, dashboard, repository workspace,
|
||||
deployment preflight, active run, success/failure and Diagnostics.
|
||||
|
||||
## v0.8 functional acceptance
|
||||
|
||||
- real-repository partial hunk staging without staging the remaining changes;
|
||||
- guided merge-conflict resolution and safe continue/abort actions;
|
||||
- Gitea pull-request creation and protected-branch inspection;
|
||||
- shell-free editor and terminal argument-template expansion;
|
||||
- deployment freezes, maintenance windows, mandatory release notes and reasoned overrides;
|
||||
- authenticated encrypted configuration backup without credentials or operation history;
|
||||
- append-only audit JSONL and CSV export;
|
||||
- desktop notification, tray and close-to-tray preference integration;
|
||||
- read-only-by-default end-to-end Gitea Actions acceptance harness with explicit deploy/rollback flags;
|
||||
- interactive demo verification for repository quick actions, hunk staging and pull-request dialogs.
|
||||
|
||||
|
||||
### v0.4 additions
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('node:path');
|
||||
const { app, BrowserWindow, shell, session, safeStorage } = require('electron');
|
||||
const { app, BrowserWindow, shell, session, safeStorage, Tray, Menu, Notification } = require('electron');
|
||||
const { ConfigStore } = require('./src/main/config-store.cjs');
|
||||
const { GitService } = require('./src/main/git-service.cjs');
|
||||
const { GiteaService } = require('./src/main/gitea-service.cjs');
|
||||
@@ -13,12 +13,16 @@ const { PreflightService } = require('./src/main/preflight-service.cjs');
|
||||
const { UpdateService } = require('./src/main/update-service.cjs');
|
||||
const { SshService } = require('./src/main/ssh-service.cjs');
|
||||
const { UnraidDeploymentService } = require('./src/main/unraid-deployment-service.cjs');
|
||||
const { AuditService } = require('./src/main/audit-service.cjs');
|
||||
const { ExternalToolsService } = require('./src/main/external-tools-service.cjs');
|
||||
const { registerIpc } = require('./src/main/ipc.cjs');
|
||||
|
||||
let mainWindow;
|
||||
let repositoryMonitor;
|
||||
let operationTimer;
|
||||
let diagnostics;
|
||||
let configStore;
|
||||
let tray;
|
||||
let quitCleanupStarted = false;
|
||||
|
||||
function broadcast(channel, payload) {
|
||||
@@ -27,6 +31,39 @@ function broadcast(channel, payload) {
|
||||
}
|
||||
}
|
||||
|
||||
function showMainWindow() {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) createWindow();
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
|
||||
function notify(title, body) {
|
||||
if (!configStore?.data.preferences.notificationsEnabled || !Notification.isSupported()) return;
|
||||
const notification = new Notification({ title, body, icon: path.join(__dirname, 'build', 'icon.png') });
|
||||
notification.on('click', showMainWindow);
|
||||
notification.show();
|
||||
}
|
||||
|
||||
function configureDesktopIntegration() {
|
||||
const preferences = configStore?.data.preferences || {};
|
||||
if (preferences.trayEnabled && !tray) {
|
||||
tray = new Tray(path.join(__dirname, 'build', process.platform === 'win32' ? 'icon.ico' : 'icon.png'));
|
||||
tray.setToolTip('ForgeFlow');
|
||||
tray.on('double-click', showMainWindow);
|
||||
} else if (!preferences.trayEnabled && tray) {
|
||||
tray.destroy(); tray = null;
|
||||
}
|
||||
if (tray) tray.setContextMenu(Menu.buildFromTemplate([
|
||||
{ label: 'Open ForgeFlow', click: showMainWindow },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Quit', click: () => app.quit() }
|
||||
]));
|
||||
if (app.isPackaged && ['win32', 'darwin'].includes(process.platform)) {
|
||||
app.setLoginItemSettings({ openAtLogin: Boolean(preferences.startAtLogin) });
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1480,
|
||||
@@ -58,12 +95,18 @@ function createWindow() {
|
||||
mainWindow.webContents.on('render-process-gone', (_event, details) => diagnostics?.error('renderer.process.gone', details));
|
||||
mainWindow.webContents.on('did-fail-load', (_event, code, description, validatedUrl) => diagnostics?.error('renderer.load.failed', { code, description, validatedUrl }));
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (/^https?:\/\//i.test(url)) shell.openExternal(url);
|
||||
if (/^https?:\/\//i.test(url)) shell.openExternal(url).catch((error) => diagnostics?.warning('external-link.open.failed', { url, message: error.message }));
|
||||
return { action: 'deny' };
|
||||
});
|
||||
mainWindow.webContents.on('will-navigate', (event, url) => {
|
||||
if (url !== mainWindow.webContents.getURL()) event.preventDefault();
|
||||
});
|
||||
mainWindow.on('close', (event) => {
|
||||
if (!quitCleanupStarted && configStore?.data.preferences.closeToTray && configStore?.data.preferences.trayEnabled) {
|
||||
event.preventDefault();
|
||||
mainWindow.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
@@ -80,6 +123,7 @@ app.whenReady().then(async () => {
|
||||
|
||||
const userDataPath = app.getPath('userData');
|
||||
const store = new ConfigStore(userDataPath);
|
||||
configStore = store;
|
||||
await store.load();
|
||||
diagnostics = new DiagnosticsService({
|
||||
userDataPath,
|
||||
@@ -96,6 +140,8 @@ app.whenReady().then(async () => {
|
||||
preferencesProvider: () => store.data.preferences
|
||||
});
|
||||
await diagnostics.initialize();
|
||||
const audit = new AuditService({ userDataPath, appInfo: { version: app.getVersion() } });
|
||||
await audit.initialize();
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
diagnostics?.error('process.uncaught-exception', error).finally(() => app.exit(1));
|
||||
@@ -103,11 +149,25 @@ app.whenReady().then(async () => {
|
||||
process.on('unhandledRejection', (reason) => diagnostics?.error('process.unhandled-rejection', reason instanceof Error ? reason : { reason }));
|
||||
|
||||
const git = new GitService();
|
||||
const externalTools = new ExternalToolsService(store);
|
||||
const gitea = new GiteaService(store, diagnostics);
|
||||
const repositories = new RepositoryService(store, git, gitea, diagnostics);
|
||||
const deployments = new DeploymentService(store, gitea, git, diagnostics);
|
||||
const ssh = new SshService({ store, diagnostics });
|
||||
const unraid = new UnraidDeploymentService({ store, ssh, git, diagnostics, sourcePath: app.getAppPath(), onOperationChange: (payload) => broadcast('operations:changed', payload) });
|
||||
const auditedOperationStates = new Set();
|
||||
const reportOperationChange = (payload) => {
|
||||
broadcast('operations:changed', payload);
|
||||
const operation = payload?.operation;
|
||||
if (operation && ['success', 'failed', 'rolled-back'].includes(operation.status)) {
|
||||
const key = `${operation.id}:${operation.status}`;
|
||||
if (!auditedOperationStates.has(key)) {
|
||||
auditedOperationStates.add(key);
|
||||
notify(`Deployment ${operation.status}`, `${operation.repository || 'Repository'} · ${operation.shortSha || operation.sha?.slice(0, 7) || ''}`);
|
||||
audit.append('deployment.completed', { repository: operation.repository, profileId: operation.profileId, sha: operation.sha, result: operation.status, note: operation.releaseNote || '' }).catch((error) => diagnostics.warning('audit.write.failed', error));
|
||||
}
|
||||
}
|
||||
};
|
||||
const unraid = new UnraidDeploymentService({ store, ssh, git, diagnostics, sourcePath: app.getAppPath(), onOperationChange: reportOperationChange });
|
||||
const updates = new UpdateService({
|
||||
store,
|
||||
gitea,
|
||||
@@ -132,7 +192,8 @@ app.whenReady().then(async () => {
|
||||
onChange: (payload) => broadcast('repositories:changed', payload)
|
||||
});
|
||||
repositoryMonitor.restart();
|
||||
registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh, updates, preflight, diagnostics, monitor: repositoryMonitor });
|
||||
registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh, updates, preflight, diagnostics, audit, externalTools, monitor: repositoryMonitor, onPreferencesChanged: configureDesktopIntegration });
|
||||
configureDesktopIntegration();
|
||||
createWindow();
|
||||
|
||||
if (store.data.setupComplete && store.data.updates?.autoCheck && store.getToken()) {
|
||||
@@ -159,6 +220,7 @@ app.whenReady().then(async () => {
|
||||
if (operationTimer) clearTimeout(operationTimer);
|
||||
const intervalMs = Math.max(3, Number(store.data.preferences.operationPollSeconds) || 5) * 1000;
|
||||
operationTimer = setTimeout(async () => {
|
||||
try {
|
||||
if (store.data.setupComplete) {
|
||||
const active = store.data.operations.some((item) => item.type === 'deployment' && !['success', 'failed', 'cancelled', 'rolled-back'].includes(item.status));
|
||||
if (active) {
|
||||
@@ -167,10 +229,17 @@ app.whenReady().then(async () => {
|
||||
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 });
|
||||
if (updated.length) {
|
||||
broadcast('operations:changed', { operations: updated });
|
||||
for (const operation of updated.filter((item) => ['success', 'failed', 'rolled-back'].includes(item.status))) reportOperationChange({ operation });
|
||||
}
|
||||
}
|
||||
scheduleOperationPoll();
|
||||
}
|
||||
} catch (error) {
|
||||
await diagnostics.error('operation-monitor.tick.failed', error);
|
||||
} finally {
|
||||
if (!quitCleanupStarted) scheduleOperationPoll();
|
||||
}
|
||||
}, intervalMs);
|
||||
operationTimer.unref?.();
|
||||
};
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "forgeflow",
|
||||
"version": "0.6.1",
|
||||
"version": "0.8.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "forgeflow",
|
||||
"version": "0.6.1",
|
||||
"version": "0.8.1",
|
||||
"dependencies": {
|
||||
"ssh2": "1.17.0"
|
||||
},
|
||||
|
||||
+16
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "forgeflow",
|
||||
"version": "0.6.1",
|
||||
"version": "0.8.1",
|
||||
"private": true,
|
||||
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
|
||||
"main": "main.cjs",
|
||||
@@ -15,6 +15,9 @@
|
||||
"dist:linux": "electron-builder --linux AppImage",
|
||||
"dist:mac": "electron-builder --mac dmg",
|
||||
"doctor": "node scripts/doctor.mjs",
|
||||
"acceptance": "node scripts/acceptance.mjs",
|
||||
"connections:check": "electron scripts/validate-installed-connections.cjs",
|
||||
"manifest": "node scripts/generate-source-manifest.mjs",
|
||||
"check": "npm run verify && npm test"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -31,6 +34,7 @@
|
||||
"src/**/*",
|
||||
"package.json",
|
||||
"build/icon.png",
|
||||
"build/icon.ico",
|
||||
"docs/SETUP_GUIDE.md",
|
||||
"docs/DIAGNOSTICS.md",
|
||||
"docs/STATUS_ENDPOINT.md",
|
||||
@@ -63,7 +67,11 @@
|
||||
"START-FORGEFLOW-OVERLAY.ps1",
|
||||
"docs/RELEASE_NOTES_0.6.0.md",
|
||||
"docs/RELEASE_AUDIT_0.6.0.md",
|
||||
"docs/RELEASE_NOTES_0.6.1.md"
|
||||
"docs/RELEASE_NOTES_0.6.1.md",
|
||||
"docs/RELEASE_NOTES_0.7.0.md",
|
||||
"docs/RELEASE_NOTES_0.8.0.md",
|
||||
"docs/RELEASE_NOTES_0.8.1.md",
|
||||
"docs/ACCEPTANCE.md"
|
||||
],
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
@@ -75,6 +83,12 @@
|
||||
],
|
||||
"icon": "build/icon.ico"
|
||||
},
|
||||
"nsis": {
|
||||
"artifactName": "${productName}-Setup-${version}-${os}-${arch}.${ext}"
|
||||
},
|
||||
"portable": {
|
||||
"artifactName": "${productName}-Portable-${version}-${os}-${arch}.${ext}"
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage"
|
||||
|
||||
+22
-1
@@ -34,6 +34,10 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({
|
||||
setWorkspaceRoots: (roots) => invoke('settings:set-roots', { roots }),
|
||||
setAppearance: (appearance) => invoke('settings:set-appearance', { appearance }),
|
||||
setPreferences: (preferences) => invoke('settings:set-preferences', { preferences }),
|
||||
exportConfigurationBackup: (passphrase) => invoke('settings:export-backup', { passphrase }),
|
||||
importConfigurationBackup: (passphrase) => invoke('settings:import-backup', { passphrase }),
|
||||
listAuditEvents: (limit = 250) => invoke('audit:list', { limit }),
|
||||
exportAuditLog: (format = 'json') => invoke('audit:export', { format }),
|
||||
setUpdatePreferences: (updates) => invoke('updates:preferences', { updates }),
|
||||
checkForUpdates: () => invoke('updates:check'),
|
||||
downloadUpdate: () => invoke('updates:download'),
|
||||
@@ -42,6 +46,7 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({
|
||||
deleteServer: (serverId) => invoke('server:delete', { serverId }),
|
||||
testServer: (serverId) => invoke('server:test', { serverId }),
|
||||
inspectServerProject: (repository, profileId) => invoke('server:inspect-project', { repository, profileId }),
|
||||
discoverExistingDeployment: (repository, serverId, remoteFolder) => invoke('server:discover-existing', { repository, serverId, remoteFolder }),
|
||||
refreshRepositories: () => invoke('repositories:refresh'),
|
||||
discoverRepositories: (roots) => invoke('repositories:discover', { roots }),
|
||||
favoriteRepository: (fullName, favorite) => invoke('repository:favorite', { fullName, favorite }),
|
||||
@@ -49,14 +54,25 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({
|
||||
unlinkRepository: (fullName) => invoke('repository:unlink', { fullName }),
|
||||
repositoryStatus: (localPath) => invoke('repository:status', { localPath }),
|
||||
repositoryDiff: (localPath, filePath, staged = false) => invoke('repository:diff', { localPath, filePath, staged }),
|
||||
repositoryDiffHunks: (localPath, filePath) => invoke('repository:diff-hunks', { localPath, filePath }),
|
||||
stageHunks: (localPath, filePath, hunkIndexes) => invoke('repository:stage-hunks', { localPath, filePath, hunkIndexes }),
|
||||
conflictState: (localPath) => invoke('repository:conflicts', { localPath }),
|
||||
resolveConflict: (localPath, filePath, resolution) => invoke('repository:resolve-conflict', { localPath, filePath, resolution }),
|
||||
continueGitOperation: (localPath) => invoke('repository:continue-operation', { localPath }),
|
||||
abortGitOperation: (localPath) => invoke('repository:abort-operation', { localPath }),
|
||||
stageFiles: (localPath, files) => invoke('repository:stage', { localPath, files }),
|
||||
unstageFiles: (localPath, files) => invoke('repository:unstage', { localPath, files }),
|
||||
commit: (localPath, message, files) => invoke('repository:commit', { localPath, message, files }),
|
||||
commitStaged: (localPath, message) => invoke('repository:commit-staged', { localPath, message }),
|
||||
commitStagedAndPush: (localPath, message) => invoke('repository:commit-staged-push', { localPath, message }),
|
||||
commitAndPush: (localPath, message, files) => invoke('repository:commit-push', { localPath, message, files }),
|
||||
push: (localPath) => invoke('repository:push', { localPath }),
|
||||
fetch: (localPath) => invoke('repository:fetch', { localPath }),
|
||||
pull: (localPath) => invoke('repository:pull', { localPath }),
|
||||
history: (localPath, limit = 20) => invoke('repository:history', { localPath, limit }),
|
||||
branchProtection: (fullName, branch) => invoke('repository:branch-protection', { fullName, branch }),
|
||||
pullRequests: (fullName, state = 'open') => invoke('repository:pull-requests', { fullName, state }),
|
||||
createPullRequest: (fullName, title, body, base) => invoke('repository:create-pull-request', { fullName, title, body, base }),
|
||||
branches: (localPath) => invoke('repository:branches', { localPath }),
|
||||
checkoutBranch: (localPath, branch) => invoke('repository:checkout-branch', { localPath, branch }),
|
||||
createBranch: (localPath, branch) => invoke('repository:create-branch', { localPath, branch }),
|
||||
@@ -73,11 +89,13 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({
|
||||
normalizeOrigins: () => invoke('repositories:normalize-origins'),
|
||||
cloneRepository: (fullName, mode = 'default') => invoke('repository:clone', { fullName, mode }),
|
||||
openPath: (localPath) => invoke('repository:open-path', { localPath }),
|
||||
openEditor: (localPath, filePath = '', line = 1) => invoke('repository:open-editor', { localPath, filePath, line }),
|
||||
openTerminal: (localPath) => invoke('repository:open-terminal', { localPath }),
|
||||
openExternal: (url) => invoke('external:open', { url }),
|
||||
saveDeploymentProfile: (fullName, profile) => invoke('deployment:save-profile', { fullName, profile }),
|
||||
deploymentPreflight: (repository, profileId) => invoke('deployment:preflight', { repository, profileId }),
|
||||
deleteDeploymentProfile: (fullName, profileId) => invoke('deployment:delete-profile', { fullName, profileId }),
|
||||
deploy: (repository, profileId, sha) => invoke('deployment:dispatch', { repository, profileId, sha }),
|
||||
deploy: (repository, profileId, sha, options = {}) => invoke('deployment:dispatch', { repository, profileId, sha, note: options.note || '', override: options.override === true, overrideReason: options.overrideReason || '' }),
|
||||
rollback: (repository, profileId, targetSha) => invoke('deployment:rollback', { repository, profileId, targetSha }),
|
||||
healthcheck: (url) => invoke('deployment:health', { url }),
|
||||
refreshProfileState: (fullName, profileId) => invoke('deployment:profile-state', { fullName, profileId }),
|
||||
@@ -85,6 +103,9 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({
|
||||
reconcileDeployment: (fullName, profileId) => invoke('deployment:reconcile', { fullName, profileId }),
|
||||
refreshOperations: (operationId = null) => invoke('operations:refresh', { operationId }),
|
||||
getOperation: (operationId) => invoke('operations:get', { operationId }),
|
||||
troubleshooterScan: (fullName = null) => invoke('troubleshooter:scan', { fullName }),
|
||||
troubleshooterRepair: (issue) => invoke('troubleshooter:repair', { issue }),
|
||||
troubleshooterAutoRepair: (issues) => invoke('troubleshooter:auto-repair', { issues }),
|
||||
diagnosticsStatus: () => invoke('diagnostics:status'),
|
||||
clearDiagnostics: () => invoke('diagnostics:clear'),
|
||||
openDiagnosticsFolder: () => invoke('diagnostics:open-folder'),
|
||||
|
||||
@@ -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
@@ -11,8 +11,9 @@ const packageJson = JSON.parse(await readFile(new URL('../package.json', import.
|
||||
const checks = [];
|
||||
const jsonMode = process.argv.includes('--json');
|
||||
|
||||
function add(id, name, ok, detail, help = '') {
|
||||
checks.push({ id, name, status: ok ? 'pass' : 'fail', ok, detail, help });
|
||||
function add(id, name, ok, detail, help = '', severity = 'required') {
|
||||
const status = ok ? 'pass' : severity === 'warning' ? 'warning' : 'fail';
|
||||
checks.push({ id, name, status, ok: ok || severity === 'warning', detail, help, severity });
|
||||
}
|
||||
|
||||
const major = Number(process.versions.node.split('.')[0]);
|
||||
@@ -45,7 +46,7 @@ try {
|
||||
exec('git', ['config', '--global', '--get', 'user.name']).then((result) => result.stdout.trim()).catch(() => ''),
|
||||
exec('git', ['config', '--global', '--get', 'user.email']).then((result) => result.stdout.trim()).catch(() => '')
|
||||
]);
|
||||
add('git-identity', 'Git identity', Boolean(name && email), name && email ? `${name} <${email}>` : 'user.name or user.email is missing', 'Configure git config --global user.name and user.email.');
|
||||
add('git-identity', 'Git identity', Boolean(name && email), name && email ? `${name} <${email}>` : 'user.name or user.email is missing; commits will remain disabled until configured', 'Configure git config --global user.name and user.email.', 'warning');
|
||||
} catch (error) {
|
||||
add('git', 'Git', false, error.message, 'Install Git and ensure git is on PATH.');
|
||||
}
|
||||
@@ -68,20 +69,22 @@ try {
|
||||
if (markerDirectory) await rm(markerDirectory, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
const blockingChecks = checks.filter((check) => check.status === 'fail');
|
||||
|
||||
const report = {
|
||||
product: 'ForgeFlow',
|
||||
version: packageJson.version,
|
||||
generatedAt: new Date().toISOString(),
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
ready: checks.every((check) => check.ok),
|
||||
ready: blockingChecks.length === 0,
|
||||
checks
|
||||
};
|
||||
|
||||
if (jsonMode) console.log(JSON.stringify(report, null, 2));
|
||||
else {
|
||||
console.log('ForgeFlow doctor\n');
|
||||
for (const check of checks) console.log(`${check.ok ? 'PASS' : 'FAIL'} ${check.name.padEnd(26)} ${check.detail}`);
|
||||
for (const check of checks) console.log(`${check.status === 'pass' ? 'PASS' : check.status === 'warning' ? 'WARN' : 'FAIL'} ${check.name.padEnd(26)} ${check.detail}`);
|
||||
console.log(`\n${report.ready ? 'Environment is ready.' : 'Resolve failed checks before starting ForgeFlow.'}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -1,54 +1,140 @@
|
||||
import { access, readFile, readdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import shellVerification from '../src/shared/shell-verification.cjs';
|
||||
import { access, readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import shellVerification from "../src/shared/shell-verification.cjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const required = [
|
||||
'package.json', 'main.cjs', 'preload.cjs',
|
||||
'src/renderer/index.html', 'src/renderer/styles.css', 'src/renderer/app.js', 'src/renderer/mock-bridge.js',
|
||||
'src/renderer/assets/itworx-mark.png', 'src/renderer/assets/itworx-wordmark.png', 'src/renderer/assets/itworx-wordmark-light.png', 'src/renderer/assets/itworx-wordmark-dark.png',
|
||||
'src/main/config-store.cjs', 'src/main/git-service.cjs', 'src/main/gitea-service.cjs',
|
||||
'src/main/repository-service.cjs', 'src/main/repository-monitor.cjs', 'src/main/deployment-service.cjs',
|
||||
'src/main/unraid-deployment-service.cjs', 'src/main/ssh-service.cjs', 'src/main/update-service.cjs',
|
||||
'src/main/diagnostics-service.cjs', 'src/main/preflight-service.cjs', 'src/main/log-redaction.cjs', 'src/main/ipc.cjs',
|
||||
'src/shared/clone-target.cjs', 'src/shared/semver.cjs', 'src/shared/zip-writer.cjs',
|
||||
'src/shared/tool-invocation.cjs', 'src/shared/shell-verification.cjs', 'START_HERE.md', 'README.md', 'SOURCE_MANIFEST.txt',
|
||||
'setup-windows.ps1', 'START-FORGEFLOW-OVERLAY.ps1', 'update-windows.ps1', 'build-windows.ps1', 'UPDATE_FROM_0.3.2.md', 'scripts/apply-source-update.ps1',
|
||||
'docs/ARCHITECTURE.md', 'docs/SECURITY.md', 'docs/ROADMAP.md', 'docs/SETUP_GUIDE.md',
|
||||
'docs/UPDATING.md', 'docs/DIAGNOSTICS.md', 'docs/DEPLOYMENT_SETUP.md', 'docs/SSH_UNRAID_DEPLOYMENT.md',
|
||||
'docs/LUMAOPS_SERVER_AUDIT.md', 'docs/STATUS_ENDPOINT.md', 'docs/TEST_MATRIX.md', 'docs/RELEASE_NOTES_0.4.0.md', 'docs/RELEASE_NOTES_0.4.1.md', 'docs/RELEASE_NOTES_0.4.2.md', 'docs/RELEASE_NOTES_0.4.3.md',
|
||||
'docs/RELEASE_AUDIT_0.6.0.md', 'docs/RELEASE_NOTES_0.6.1.md', 'docs/RELEASE_NOTES_0.5.0.md', 'docs/RELEASE_NOTES_0.5.1.md', 'docs/RELEASE_NOTES_0.5.2.md', 'docs/RELEASE_NOTES_0.5.3.md', 'docs/RELEASE_NOTES_0.5.4.md', 'docs/RELEASE_NOTES_0.6.0.md',
|
||||
'Publish-ForgeFlow-Release.ps1', 'docs/RELEASE_NOTES_0.4.4.md', 'docs/RELEASE_NOTES_0.4.5.md',
|
||||
'examples/gitea-actions/deploy.yml', 'examples/gitea-actions/rollback.yml',
|
||||
'examples/server/forgeflow-deploy', 'examples/server/forgeflow-targets.conf',
|
||||
'examples/server/forgeflow-runner.sudoers', 'examples/server/status-example.json',
|
||||
'build/icon.png', 'build/icon.ico'
|
||||
"package.json",
|
||||
"main.cjs",
|
||||
"preload.cjs",
|
||||
"src/renderer/index.html",
|
||||
"src/renderer/styles.css",
|
||||
"src/renderer/app.js",
|
||||
"src/renderer/mock-bridge.js",
|
||||
"src/renderer/assets/itworx-mark.png",
|
||||
"src/renderer/assets/itworx-wordmark.png",
|
||||
"src/renderer/assets/itworx-wordmark-light.png",
|
||||
"src/renderer/assets/itworx-wordmark-dark.png",
|
||||
"src/main/config-store.cjs",
|
||||
"src/main/git-service.cjs",
|
||||
"src/main/gitea-service.cjs",
|
||||
"src/main/audit-service.cjs",
|
||||
"src/main/configuration-backup.cjs",
|
||||
"src/main/external-tools-service.cjs",
|
||||
"src/main/repository-service.cjs",
|
||||
"src/main/repository-monitor.cjs",
|
||||
"src/main/deployment-service.cjs",
|
||||
"src/main/unraid-deployment-service.cjs",
|
||||
"src/main/ssh-service.cjs",
|
||||
"src/main/update-service.cjs",
|
||||
"src/main/diagnostics-service.cjs",
|
||||
"src/main/preflight-service.cjs",
|
||||
"src/main/log-redaction.cjs",
|
||||
"src/main/ipc.cjs",
|
||||
"src/shared/clone-target.cjs",
|
||||
"src/shared/semver.cjs",
|
||||
"src/shared/zip-writer.cjs",
|
||||
"src/shared/tool-invocation.cjs",
|
||||
"src/shared/shell-verification.cjs",
|
||||
"START_HERE.md",
|
||||
"README.md",
|
||||
"SOURCE_MANIFEST.txt",
|
||||
"src/shared/deployment-policy.cjs",
|
||||
"scripts/acceptance.mjs",
|
||||
"scripts/validate-installed-connections.cjs",
|
||||
"scripts/generate-source-manifest.mjs",
|
||||
"setup-windows.ps1",
|
||||
"START-FORGEFLOW-OVERLAY.ps1",
|
||||
"update-windows.ps1",
|
||||
"build-windows.ps1",
|
||||
"UPDATE_FROM_0.3.2.md",
|
||||
"scripts/apply-source-update.ps1",
|
||||
"docs/ARCHITECTURE.md",
|
||||
"docs/SECURITY.md",
|
||||
"docs/ROADMAP.md",
|
||||
"docs/SETUP_GUIDE.md",
|
||||
"docs/ACCEPTANCE.md",
|
||||
"docs/RELEASE_NOTES_0.8.0.md",
|
||||
"docs/RELEASE_NOTES_0.8.1.md",
|
||||
"docs/UPDATING.md",
|
||||
"docs/DIAGNOSTICS.md",
|
||||
"docs/DEPLOYMENT_SETUP.md",
|
||||
"docs/SSH_UNRAID_DEPLOYMENT.md",
|
||||
"docs/LUMAOPS_SERVER_AUDIT.md",
|
||||
"docs/STATUS_ENDPOINT.md",
|
||||
"docs/TEST_MATRIX.md",
|
||||
"docs/RELEASE_NOTES_0.4.0.md",
|
||||
"docs/RELEASE_NOTES_0.4.1.md",
|
||||
"docs/RELEASE_NOTES_0.4.2.md",
|
||||
"docs/RELEASE_NOTES_0.4.3.md",
|
||||
"docs/RELEASE_AUDIT_0.6.0.md",
|
||||
"docs/RELEASE_NOTES_0.6.1.md",
|
||||
"docs/RELEASE_NOTES_0.7.0.md",
|
||||
"docs/RELEASE_NOTES_0.5.0.md",
|
||||
"docs/RELEASE_NOTES_0.5.1.md",
|
||||
"docs/RELEASE_NOTES_0.5.2.md",
|
||||
"docs/RELEASE_NOTES_0.5.3.md",
|
||||
"docs/RELEASE_NOTES_0.5.4.md",
|
||||
"docs/RELEASE_NOTES_0.6.0.md",
|
||||
"Publish-ForgeFlow-Release.ps1",
|
||||
"docs/RELEASE_NOTES_0.4.4.md",
|
||||
"docs/RELEASE_NOTES_0.4.5.md",
|
||||
"examples/gitea-actions/deploy.yml",
|
||||
"examples/gitea-actions/rollback.yml",
|
||||
"examples/server/forgeflow-deploy",
|
||||
"examples/server/forgeflow-targets.conf",
|
||||
"examples/server/forgeflow-runner.sudoers",
|
||||
"examples/server/status-example.json",
|
||||
"build/icon.png",
|
||||
"build/icon.ico",
|
||||
];
|
||||
|
||||
for (const file of required) await access(path.join(root, file));
|
||||
|
||||
const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
|
||||
if (packageJson.version !== '0.6.1') throw new Error(`Expected package version 0.6.1, got ${packageJson.version}.`);
|
||||
const sourceManifest = await readFile(path.join(root, 'SOURCE_MANIFEST.txt'), 'utf8');
|
||||
if (!sourceManifest.startsWith(`ForgeFlow ${packageJson.version} source manifest\n`)) throw new Error('SOURCE_MANIFEST.txt does not match the package version.');
|
||||
for (const group of ['dependencies', 'devDependencies']) {
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(path.join(root, "package.json"), "utf8"),
|
||||
);
|
||||
if (packageJson.version !== "0.8.1")
|
||||
throw new Error(
|
||||
`Expected package version 0.8.1, got ${packageJson.version}.`,
|
||||
);
|
||||
const sourceManifest = await readFile(
|
||||
path.join(root, "SOURCE_MANIFEST.txt"),
|
||||
"utf8",
|
||||
);
|
||||
if (
|
||||
!sourceManifest
|
||||
.replace(/\r\n/g, "\n")
|
||||
.startsWith(`ForgeFlow ${packageJson.version} source manifest\n`)
|
||||
)
|
||||
throw new Error("SOURCE_MANIFEST.txt does not match the package version.");
|
||||
for (const group of ["dependencies", "devDependencies"]) {
|
||||
for (const [name, version] of Object.entries(packageJson[group] || {})) {
|
||||
if (/^[~^*]/.test(version)) throw new Error(`${group} dependency ${name} must be pinned exactly, got ${version}.`);
|
||||
if (/^[~^*]/.test(version))
|
||||
throw new Error(
|
||||
`${group} dependency ${name} must be pinned exactly, got ${version}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (packageJson.dependencies?.ssh2 !== '1.17.0') throw new Error('ssh2 must remain pinned to 1.17.0.');
|
||||
for (const script of ['start', 'demo', 'test', 'verify', 'check']) {
|
||||
if (!packageJson.scripts?.[script]) throw new Error(`Required npm script is missing: ${script}`);
|
||||
if (packageJson.dependencies?.ssh2 !== "1.17.0")
|
||||
throw new Error("ssh2 must remain pinned to 1.17.0.");
|
||||
for (const script of ["start", "demo", "test", "verify", "check"]) {
|
||||
if (!packageJson.scripts?.[script])
|
||||
throw new Error(`Required npm script is missing: ${script}`);
|
||||
}
|
||||
if (!packageJson.build?.win?.icon || !packageJson.build?.linux?.icon || !packageJson.build?.mac?.icon) {
|
||||
throw new Error('Package icon configuration is incomplete.');
|
||||
if (
|
||||
!packageJson.build?.win?.icon ||
|
||||
!packageJson.build?.linux?.icon ||
|
||||
!packageJson.build?.mac?.icon
|
||||
) {
|
||||
throw new Error("Package icon configuration is incomplete.");
|
||||
}
|
||||
|
||||
async function collect(directory, extensions, output = []) {
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
if (['node_modules', 'dist'].includes(entry.name)) continue;
|
||||
if (["node_modules", "dist"].includes(entry.name)) continue;
|
||||
const absolute = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) await collect(absolute, extensions, output);
|
||||
else if (extensions.has(path.extname(entry.name))) output.push(absolute);
|
||||
@@ -56,13 +142,21 @@ async function collect(directory, extensions, output = []) {
|
||||
return output;
|
||||
}
|
||||
|
||||
const javascriptFiles = await collect(root, new Set(['.js', '.cjs', '.mjs']));
|
||||
const javascriptFiles = await collect(root, new Set([".js", ".cjs", ".mjs"]));
|
||||
for (const file of javascriptFiles) {
|
||||
const result = spawnSync(process.execPath, ['--check', file], { encoding: 'utf8' });
|
||||
if (result.status !== 0) throw new Error(`${path.relative(root, file)} failed syntax validation:\n${result.stderr}`);
|
||||
const result = spawnSync(process.execPath, ["--check", file], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (result.status !== 0)
|
||||
throw new Error(
|
||||
`${path.relative(root, file)} failed syntax validation:\n${result.stderr}`,
|
||||
);
|
||||
}
|
||||
|
||||
const deploymentScript = await readFile(path.join(root, 'examples/server/forgeflow-deploy'), 'utf8');
|
||||
const deploymentScript = await readFile(
|
||||
path.join(root, "examples/server/forgeflow-deploy"),
|
||||
"utf8",
|
||||
);
|
||||
shellVerification.validateShellScriptStructure(deploymentScript);
|
||||
|
||||
// The server deployment script targets Linux/Unraid. On Windows, different tools may
|
||||
@@ -71,66 +165,221 @@ shellVerification.validateShellScriptStructure(deploymentScript);
|
||||
// a desktop update therefore never depend on a Windows Bash shim. Portable structural
|
||||
// validation always runs; GNU Bash syntax validation additionally runs on non-Windows.
|
||||
if (shellVerification.shouldRunExternalBash(process.platform)) {
|
||||
const bashCheck = shellVerification.bashSyntaxCheckFromTextInvocation(deploymentScript);
|
||||
const bashCheck =
|
||||
shellVerification.bashSyntaxCheckFromTextInvocation(deploymentScript);
|
||||
const shell = spawnSync(bashCheck.command, bashCheck.args, bashCheck.options);
|
||||
if (shell.error) throw new Error(`Unable to start Bash for server deployment syntax validation: ${shell.error.message}`);
|
||||
if (shell.status !== 0) throw new Error(`Server deployment example failed bash syntax validation:
|
||||
${shell.stderr || shell.stdout || 'Bash returned a non-zero status.'}`);
|
||||
if (shell.error)
|
||||
throw new Error(
|
||||
`Unable to start Bash for server deployment syntax validation: ${shell.error.message}`,
|
||||
);
|
||||
if (shell.status !== 0)
|
||||
throw new Error(`Server deployment example failed bash syntax validation:
|
||||
${shell.stderr || shell.stdout || "Bash returned a non-zero status."}`);
|
||||
} else {
|
||||
console.log('Windows: external Bash syntax validation skipped; portable server-script validation passed.');
|
||||
console.log(
|
||||
"Windows: external Bash syntax validation skipped; portable server-script validation passed.",
|
||||
);
|
||||
}
|
||||
|
||||
JSON.parse(await readFile(path.join(root, 'examples/server/status-example.json'), 'utf8'));
|
||||
const setupGuide = await readFile(path.join(root, 'docs/SETUP_GUIDE.md'), 'utf8');
|
||||
const sshGuide = await readFile(path.join(root, 'docs/SSH_UNRAID_DEPLOYMENT.md'), 'utf8');
|
||||
const audit = await readFile(path.join(root, 'docs/LUMAOPS_SERVER_AUDIT.md'), 'utf8');
|
||||
const releaseNotes = await readFile(path.join(root, 'docs/RELEASE_NOTES_0.6.0.md'), 'utf8');
|
||||
const updaterReleaseNotes = await readFile(path.join(root, 'docs/RELEASE_NOTES_0.6.1.md'), 'utf8');
|
||||
if (!setupGuide.includes('Gitea access token') || !setupGuide.includes('diagnostic bundle')) {
|
||||
throw new Error('Setup guide is missing required connection or diagnostics instructions.');
|
||||
JSON.parse(
|
||||
await readFile(
|
||||
path.join(root, "examples/server/status-example.json"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const setupGuide = await readFile(
|
||||
path.join(root, "docs/SETUP_GUIDE.md"),
|
||||
"utf8",
|
||||
);
|
||||
const sshGuide = await readFile(
|
||||
path.join(root, "docs/SSH_UNRAID_DEPLOYMENT.md"),
|
||||
"utf8",
|
||||
);
|
||||
const audit = await readFile(
|
||||
path.join(root, "docs/LUMAOPS_SERVER_AUDIT.md"),
|
||||
"utf8",
|
||||
);
|
||||
const releaseNotes = await readFile(
|
||||
path.join(root, "docs/RELEASE_NOTES_0.6.0.md"),
|
||||
"utf8",
|
||||
);
|
||||
const updaterReleaseNotes = await readFile(
|
||||
path.join(root, "docs/RELEASE_NOTES_0.6.1.md"),
|
||||
"utf8",
|
||||
);
|
||||
if (
|
||||
!setupGuide.includes("Gitea access token") ||
|
||||
!setupGuide.includes("diagnostic bundle")
|
||||
) {
|
||||
throw new Error(
|
||||
"Setup guide is missing required connection or diagnostics instructions.",
|
||||
);
|
||||
}
|
||||
if (!sshGuide.includes('/mnt/user/appdata') || !sshGuide.includes('host-key fingerprint')) {
|
||||
throw new Error('SSH / Unraid guide is missing its base path or host identity policy.');
|
||||
if (
|
||||
!sshGuide.includes("/mnt/user/appdata") ||
|
||||
!sshGuide.includes("host-key fingerprint")
|
||||
) {
|
||||
throw new Error(
|
||||
"SSH / Unraid guide is missing its base path or host identity policy.",
|
||||
);
|
||||
}
|
||||
if (!audit.includes('d42d4a7f08240c478d07466e3fabec654dc71367') || !audit.includes('source/')) {
|
||||
throw new Error('LumaOps audit is missing the exact matching SHA or nested repository finding.');
|
||||
if (
|
||||
!audit.includes("d42d4a7f08240c478d07466e3fabec654dc71367") ||
|
||||
!audit.includes("source/")
|
||||
) {
|
||||
throw new Error(
|
||||
"LumaOps audit is missing the exact matching SHA or nested repository finding.",
|
||||
);
|
||||
}
|
||||
for (const phrase of ['DockerMan', 'HEAD.lock', 'deployment reconciliation', 'Portfolio', 'safety branch', 'high-contrast ITWorx']) {
|
||||
if (!releaseNotes.includes(phrase)) throw new Error(`Release notes are missing: ${phrase}`);
|
||||
for (const phrase of [
|
||||
"DockerMan",
|
||||
"HEAD.lock",
|
||||
"deployment reconciliation",
|
||||
"Portfolio",
|
||||
"safety branch",
|
||||
"high-contrast ITWorx",
|
||||
]) {
|
||||
if (!releaseNotes.includes(phrase))
|
||||
throw new Error(`Release notes are missing: ${phrase}`);
|
||||
}
|
||||
for (const phrase of ['Windows PowerShell 5.1', 'File.Replace', 'handshake-only', 'updateId']) {
|
||||
if (!updaterReleaseNotes.includes(phrase)) throw new Error(`Updater release notes are missing: ${phrase}`);
|
||||
for (const phrase of [
|
||||
"Windows PowerShell 5.1",
|
||||
"File.Replace",
|
||||
"handshake-only",
|
||||
"updateId",
|
||||
]) {
|
||||
if (!updaterReleaseNotes.includes(phrase))
|
||||
throw new Error(`Updater release notes are missing: ${phrase}`);
|
||||
}
|
||||
const updateHelperPath = path.join(root, 'scripts/apply-source-update.ps1');
|
||||
const setupScript = await readFile(
|
||||
path.join(root, "setup-windows.ps1"),
|
||||
"utf8",
|
||||
);
|
||||
const sourceUpdateScript = await readFile(
|
||||
path.join(root, "update-windows.ps1"),
|
||||
"utf8",
|
||||
);
|
||||
for (const [name, script] of [
|
||||
["setup-windows.ps1", setupScript],
|
||||
["update-windows.ps1", sourceUpdateScript],
|
||||
]) {
|
||||
if (
|
||||
!script.includes("$version = [string]$package.version") ||
|
||||
!script.includes("npm ci --no-audit --no-fund")
|
||||
)
|
||||
throw new Error(
|
||||
`${name} must use the package version dynamically and install from package-lock.json.`,
|
||||
);
|
||||
if (/v0\.4\.2|version -ne "0\.4\.2"/.test(script))
|
||||
throw new Error(
|
||||
`${name} still contains a stale hard-coded release version.`,
|
||||
);
|
||||
}
|
||||
|
||||
const updateHelperPath = path.join(root, "scripts/apply-source-update.ps1");
|
||||
const updateHelperBytes = await readFile(updateHelperPath);
|
||||
if (updateHelperBytes[0] === 0xef && updateHelperBytes[1] === 0xbb && updateHelperBytes[2] === 0xbf) throw new Error('PowerShell update helper must not contain a UTF-8 BOM.');
|
||||
const updateHelper = updateHelperBytes.toString('utf8');
|
||||
if (!updateHelper.trimStart().startsWith('param(') || updateHelper.trimStart().startsWith('\\')) throw new Error('PowerShell update helper must start directly with param(.');
|
||||
if (
|
||||
updateHelperBytes[0] === 0xef &&
|
||||
updateHelperBytes[1] === 0xbb &&
|
||||
updateHelperBytes[2] === 0xbf
|
||||
)
|
||||
throw new Error("PowerShell update helper must not contain a UTF-8 BOM.");
|
||||
const updateHelper = updateHelperBytes.toString("utf8");
|
||||
if (
|
||||
!updateHelper.trimStart().startsWith("param(") ||
|
||||
updateHelper.trimStart().startsWith("\\")
|
||||
)
|
||||
throw new Error("PowerShell update helper must start directly with param(.");
|
||||
|
||||
const renderer = await readFile(path.join(root, 'src/renderer/app.js'), 'utf8');
|
||||
const styles = await readFile(path.join(root, 'src/renderer/styles.css'), 'utf8');
|
||||
const preload = await readFile(path.join(root, 'preload.cjs'), 'utf8');
|
||||
const ipc = await readFile(path.join(root, 'src/main/ipc.cjs'), 'utf8');
|
||||
for (const phrase of ['Commit selected & push to Gitea', 'checkForUpdates', 'saveServer', 'profile-provider', 'profile-icon-mode', 'itworx-mark.png', 'Repair DockerMan integration', 'Repository troubleshooting', 'repair-repository-sync']) {
|
||||
if (!renderer.includes(phrase) && !preload.includes(phrase)) throw new Error(`Frontend integration is missing: ${phrase}`);
|
||||
const renderer = await readFile(path.join(root, "src/renderer/app.js"), "utf8");
|
||||
const styles = await readFile(
|
||||
path.join(root, "src/renderer/styles.css"),
|
||||
"utf8",
|
||||
);
|
||||
const preload = await readFile(path.join(root, "preload.cjs"), "utf8");
|
||||
const ipc = await readFile(path.join(root, "src/main/ipc.cjs"), "utf8");
|
||||
for (const phrase of [
|
||||
'data-action="commit-push"',
|
||||
"checkForUpdates",
|
||||
"saveServer",
|
||||
"profile-provider",
|
||||
"profile-icon-mode",
|
||||
"itworx-mark.png",
|
||||
"Repair DockerMan integration",
|
||||
"Repository troubleshooting",
|
||||
"repair-repository-sync",
|
||||
]) {
|
||||
if (!renderer.includes(phrase) && !preload.includes(phrase))
|
||||
throw new Error(`Frontend integration is missing: ${phrase}`);
|
||||
}
|
||||
if (!styles.includes('.file-list { flex: 1 1 auto;') || !styles.includes('.main-canvas.repository-canvas')) {
|
||||
throw new Error('Changed-file scrolling constraints are missing.');
|
||||
if (
|
||||
!/\.file-list\s*\{[^}]*flex:\s*1 1 auto;/s.test(styles) ||
|
||||
!styles.includes(".main-canvas.repository-canvas")
|
||||
) {
|
||||
throw new Error("Changed-file scrolling constraints are missing.");
|
||||
}
|
||||
for (const channel of ['updates:check', 'updates:download', 'updates:apply', 'server:save', 'server:test', 'server:inspect-project', 'repository:repair-git-locks', 'repository:repair-sync', 'deployment:apply-dockerman-metadata', 'deployment:reconcile']) {
|
||||
if (!ipc.includes(channel)) throw new Error(`IPC registration is missing: ${channel}`);
|
||||
for (const channel of [
|
||||
"server:discover-existing",
|
||||
"troubleshooter:scan",
|
||||
"troubleshooter:repair",
|
||||
"troubleshooter:auto-repair",
|
||||
"updates:check",
|
||||
"updates:download",
|
||||
"updates:apply",
|
||||
"server:save",
|
||||
"server:test",
|
||||
"server:inspect-project",
|
||||
"repository:repair-git-locks",
|
||||
"repository:repair-sync",
|
||||
"deployment:apply-dockerman-metadata",
|
||||
"deployment:reconcile",
|
||||
]) {
|
||||
if (!ipc.includes(channel))
|
||||
throw new Error(`IPC registration is missing: ${channel}`);
|
||||
}
|
||||
const gitSource = await readFile(path.join(root, 'src/main/git-service.cjs'), 'utf8');
|
||||
const unraidSource = await readFile(path.join(root, 'src/main/unraid-deployment-service.cjs'), 'utf8');
|
||||
const publisher = await readFile(path.join(root, 'Publish-ForgeFlow-Release.ps1'), 'utf8');
|
||||
for (const phrase of ['HEAD.lock', 'backup-reset', 'repairSync', "segments.includes('objects')"]) {
|
||||
if (!gitSource.includes(phrase)) throw new Error(`Git recovery implementation is missing: ${phrase}`);
|
||||
const gitSource = await readFile(
|
||||
path.join(root, "src/main/git-service.cjs"),
|
||||
"utf8",
|
||||
);
|
||||
const unraidSource = await readFile(
|
||||
path.join(root, "src/main/unraid-deployment-service.cjs"),
|
||||
"utf8",
|
||||
);
|
||||
const publisher = await readFile(
|
||||
path.join(root, "Publish-ForgeFlow-Release.ps1"),
|
||||
"utf8",
|
||||
);
|
||||
for (const phrase of [
|
||||
"HEAD.lock",
|
||||
"backup-reset",
|
||||
"repairSync",
|
||||
"segments.includes('objects')",
|
||||
]) {
|
||||
if (!gitSource.includes(phrase))
|
||||
throw new Error(`Git recovery implementation is missing: ${phrase}`);
|
||||
}
|
||||
for (const phrase of ['net.unraid.docker.managed', "'dockerman'", 'iconCacheRefresh', '[PORT:', 'Superseded by live commit']) {
|
||||
if (!unraidSource.includes(phrase)) throw new Error(`Unraid recovery implementation is missing: ${phrase}`);
|
||||
for (const phrase of [
|
||||
"discoverExisting",
|
||||
"deriveDetectedProfile",
|
||||
"docker inspect",
|
||||
"net.unraid.docker.managed",
|
||||
"'dockerman'",
|
||||
"iconCacheRefresh",
|
||||
"[PORT:",
|
||||
"Superseded by live commit",
|
||||
]) {
|
||||
if (!unraidSource.includes(phrase))
|
||||
throw new Error(`Unraid recovery implementation is missing: ${phrase}`);
|
||||
}
|
||||
for (const phrase of ['git ls-remote origin', 'apply-source-update.ps1', 'without changing its version']) {
|
||||
if (!publisher.includes(phrase)) throw new Error(`Publishing workflow is missing: ${phrase}`);
|
||||
for (const phrase of [
|
||||
"git ls-remote origin",
|
||||
"apply-source-update.ps1",
|
||||
"without changing its version",
|
||||
]) {
|
||||
if (!publisher.includes(phrase))
|
||||
throw new Error(`Publishing workflow is missing: ${phrase}`);
|
||||
}
|
||||
|
||||
console.log(`Verified ${required.length} required project files and ${javascriptFiles.length} JavaScript files for ForgeFlow ${packageJson.version}.`);
|
||||
console.log(
|
||||
`Verified ${required.length} required project files and ${javascriptFiles.length} JavaScript files for ForgeFlow ${packageJson.version}.`,
|
||||
);
|
||||
|
||||
+14
-29
@@ -10,18 +10,17 @@ function Assert-Command {
|
||||
}
|
||||
|
||||
function Invoke-Step {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Title,
|
||||
[Parameter(Mandatory = $true)][scriptblock]$Action
|
||||
)
|
||||
param([Parameter(Mandatory = $true)][string]$Title, [Parameter(Mandatory = $true)][scriptblock]$Action)
|
||||
Write-Host "`n$Title" -ForegroundColor Yellow
|
||||
& $Action
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$Title failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) { throw "$Title failed with exit code $LASTEXITCODE." }
|
||||
}
|
||||
|
||||
Write-Host "ForgeFlow v0.4.2 self-service setup" -ForegroundColor Cyan
|
||||
$package = Get-Content ".\package.json" -Raw | ConvertFrom-Json
|
||||
$version = [string]$package.version
|
||||
if ($package.name -ne "forgeflow" -or [string]::IsNullOrWhiteSpace($version)) { throw "This folder is not a valid ForgeFlow source release." }
|
||||
|
||||
Write-Host "ForgeFlow v$version self-service setup" -ForegroundColor Cyan
|
||||
Write-Host "No Gitea token, SSH key or server password is requested by this script." -ForegroundColor DarkGray
|
||||
|
||||
Assert-Command node
|
||||
@@ -30,33 +29,19 @@ Assert-Command git
|
||||
|
||||
$nodeVersionText = (node --version).Trim()
|
||||
$nodeMajor = [int]($nodeVersionText.TrimStart('v').Split('.')[0])
|
||||
if ($nodeMajor -lt 22) {
|
||||
throw "Node.js 22 or newer is required. Detected: $nodeVersionText"
|
||||
}
|
||||
if ($nodeMajor -lt 22) { throw "Node.js 22 or newer is required. Detected: $nodeVersionText" }
|
||||
|
||||
Write-Host "Node: $nodeVersionText" -ForegroundColor DarkGray
|
||||
Write-Host "npm: $((npm --version).Trim())" -ForegroundColor DarkGray
|
||||
Write-Host "Git: $((git --version).Trim())" -ForegroundColor DarkGray
|
||||
|
||||
Invoke-Step "Installing project dependencies..." {
|
||||
if (Test-Path ".\package-lock.json") {
|
||||
Invoke-Step "Installing exact project dependencies..." {
|
||||
if (-not (Test-Path ".\package-lock.json")) { throw "package-lock.json is required for a reproducible ForgeFlow installation." }
|
||||
npm ci --no-audit --no-fund
|
||||
} 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..." {
|
||||
npm run doctor
|
||||
}
|
||||
|
||||
Invoke-Step "Running source verification and automated tests..." {
|
||||
npm run check
|
||||
}
|
||||
|
||||
Write-Host "`nAll local checks passed." -ForegroundColor Green
|
||||
Write-Host "ForgeFlow will now open the five-step setup wizard." -ForegroundColor Green
|
||||
Write-Host "Enter credentials only inside the local ForgeFlow password field." -ForegroundColor DarkGray
|
||||
|
||||
Write-Host "`nForgeFlow v$version is ready." -ForegroundColor Green
|
||||
Write-Host "Starting ForgeFlow with your existing local configuration..." -ForegroundColor Green
|
||||
npm start
|
||||
|
||||
@@ -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 };
|
||||
@@ -7,7 +7,7 @@ const { safeStorage } = require('electron');
|
||||
const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
schemaVersion: 7,
|
||||
schemaVersion: 8,
|
||||
setupComplete: false,
|
||||
appearance: 'dark',
|
||||
gitea: { baseUrl: '', user: null, encryptedToken: null },
|
||||
@@ -33,7 +33,13 @@ const DEFAULT_CONFIG = {
|
||||
diagnosticsEnabled: true,
|
||||
diagnosticLevel: 'info',
|
||||
logRetentionDays: 14,
|
||||
maxLogFileMb: 8
|
||||
maxLogFileMb: 8,
|
||||
editor: { executable: 'code', args: ['--reuse-window', '--goto', '{file}:{line}'] },
|
||||
terminal: { executable: 'wt.exe', args: ['-d', '{path}'] },
|
||||
notificationsEnabled: true,
|
||||
trayEnabled: true,
|
||||
closeToTray: false,
|
||||
startAtLogin: false
|
||||
},
|
||||
operations: []
|
||||
};
|
||||
@@ -47,6 +53,7 @@ class ConfigStore {
|
||||
this.filePath = path.join(userDataPath, 'forgeflow-config.json');
|
||||
this.sessionToken = null;
|
||||
this.data = structuredClone(DEFAULT_CONFIG);
|
||||
this.saveQueue = Promise.resolve();
|
||||
}
|
||||
|
||||
migrate(parsed) {
|
||||
@@ -84,7 +91,15 @@ class ConfigStore {
|
||||
async load() {
|
||||
try {
|
||||
const raw = await fs.readFile(this.filePath, 'utf8');
|
||||
try {
|
||||
this.data = this.migrate(JSON.parse(raw));
|
||||
} catch (parseError) {
|
||||
const suffix = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const recoveryPath = `${this.filePath}.corrupt-${suffix}`;
|
||||
await fs.rename(this.filePath, recoveryPath).catch(async () => fs.writeFile(recoveryPath, raw, { mode: 0o600 }));
|
||||
this.data = structuredClone(DEFAULT_CONFIG);
|
||||
console.error(`ForgeFlow recovered a malformed configuration file to ${recoveryPath}.`, parseError);
|
||||
}
|
||||
await this.save();
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
@@ -94,11 +109,16 @@ class ConfigStore {
|
||||
}
|
||||
|
||||
async save() {
|
||||
const snapshot = JSON.stringify(this.data, null, 2);
|
||||
const operation = async () => {
|
||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||
const temporary = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
await fs.writeFile(temporary, JSON.stringify(this.data, null, 2), { mode: 0o600 });
|
||||
const temporary = `${this.filePath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`;
|
||||
await fs.writeFile(temporary, snapshot, { mode: 0o600 });
|
||||
await fs.rename(temporary, this.filePath);
|
||||
try { await fs.chmod(this.filePath, 0o600); } catch {}
|
||||
};
|
||||
this.saveQueue = this.saveQueue.then(operation, operation);
|
||||
return this.saveQueue;
|
||||
}
|
||||
|
||||
setToken(token, { preserveExisting = false } = {}) {
|
||||
@@ -250,6 +270,28 @@ class ConfigStore {
|
||||
return this.getPublicState();
|
||||
}
|
||||
|
||||
async restoreConfiguration(configuration) {
|
||||
const restored = this.migrate(configuration);
|
||||
restored.gitea.encryptedToken = String(restored.gitea.baseUrl || '').replace(/\/+$/, '').toLowerCase() === String(this.data.gitea.baseUrl || '').replace(/\/+$/, '').toLowerCase()
|
||||
? this.data.gitea.encryptedToken
|
||||
: null;
|
||||
const existingServers = new Map(this.data.servers.map((server) => [server.id, server]));
|
||||
restored.servers = restored.servers.map((server) => {
|
||||
const existing = existingServers.get(server.id);
|
||||
const sameCredentialTarget = existing
|
||||
&& ['host', 'port', 'username', 'authType', 'privateKeyPath'].every((key) => String(existing[key] || '') === String(server[key] || ''));
|
||||
return {
|
||||
...server,
|
||||
encryptedPassword: sameCredentialTarget ? existing.encryptedPassword || null : null,
|
||||
encryptedPassphrase: sameCredentialTarget ? existing.encryptedPassphrase || null : null
|
||||
};
|
||||
});
|
||||
restored.operations = this.data.operations;
|
||||
this.data = restored;
|
||||
await this.save();
|
||||
return this.getPublicState();
|
||||
}
|
||||
|
||||
async updateGitea({ baseUrl, token, user }) {
|
||||
const tokenState = this.setToken(token, { preserveExisting: true });
|
||||
this.data.gitea = {
|
||||
@@ -302,6 +344,16 @@ class ConfigStore {
|
||||
branch: assertBranchName(profile.branch || 'main'),
|
||||
healthcheckUrl,
|
||||
confirmationRequired: profile.confirmationRequired !== false,
|
||||
deploymentPolicy: {
|
||||
frozen: profile.deploymentPolicy?.frozen === true,
|
||||
freezeReason: String(profile.deploymentPolicy?.freezeReason || '').trim().slice(0, 500),
|
||||
requireNote: profile.deploymentPolicy?.requireNote === true,
|
||||
maintenanceWindows: (Array.isArray(profile.deploymentPolicy?.maintenanceWindows) ? profile.deploymentPolicy.maintenanceWindows : []).slice(0, 20).map((window) => ({
|
||||
days: [...new Set((Array.isArray(window?.days) ? window.days : []).map(Number).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6))],
|
||||
start: String(window?.start || '00:00'),
|
||||
end: String(window?.end || '23:59')
|
||||
}))
|
||||
},
|
||||
inputs: {}
|
||||
};
|
||||
if (provider === 'ssh-unraid') {
|
||||
@@ -327,7 +379,7 @@ class ConfigStore {
|
||||
alignRemote: profile.alignRemote === true,
|
||||
hostPort: profile.hostPort ? Math.min(Math.max(Number(profile.hostPort), 1), 65535) : null,
|
||||
containerPort: profile.containerPort ? Math.min(Math.max(Number(profile.containerPort), 1), 65535) : null,
|
||||
webUiUrl: assertHttpUrl(profile.webUiUrl, { optional: true, label: 'Web UI URL' }),
|
||||
webUiUrl: assertHttpUrl(profile.webUiUrl, { optional: true, label: 'Web UI URL', allowUnraidTemplate: true }),
|
||||
iconMode: ['builtin', 'upload', 'url', 'none'].includes(profile.iconMode)
|
||||
? profile.iconMode
|
||||
: profile.iconFilePath ? 'upload' : profile.iconUrl ? 'url' : 'builtin',
|
||||
@@ -335,7 +387,13 @@ class ConfigStore {
|
||||
iconFilePath: String(profile.iconFilePath || '').trim(),
|
||||
dockerShell: ['/bin/sh', '/bin/bash'].includes(profile.dockerShell) ? profile.dockerShell : '/bin/sh',
|
||||
preservePaths,
|
||||
generatedCompose: profile.generatedCompose === true
|
||||
generatedCompose: profile.generatedCompose === true,
|
||||
adoptedFromServer: profile.adoptedFromServer === true,
|
||||
serverSourceOfTruth: profile.serverSourceOfTruth === true,
|
||||
detectedAt: profile.detectedAt || null,
|
||||
provenance: profile.provenance && typeof profile.provenance === 'object' ? structuredClone(profile.provenance) : {},
|
||||
detectedMetadata: profile.detectedMetadata && typeof profile.detectedMetadata === 'object' ? structuredClone(profile.detectedMetadata) : {},
|
||||
serverIconReference: String(profile.serverIconReference || '').trim()
|
||||
};
|
||||
}
|
||||
const statusUrl = assertHttpUrl(profile.statusUrl, { label: 'Application status URL' });
|
||||
@@ -420,6 +478,16 @@ class ConfigStore {
|
||||
next.diagnosticLevel = ['debug', 'info', 'warning', 'error'].includes(next.diagnosticLevel) ? next.diagnosticLevel : 'info';
|
||||
next.logRetentionDays = Math.min(Math.max(Number(next.logRetentionDays) || 14, 1), 90);
|
||||
next.maxLogFileMb = Math.min(Math.max(Number(next.maxLogFileMb) || 8, 1), 50);
|
||||
const normalizeTool = (tool, fallback) => ({
|
||||
executable: String(tool?.executable || fallback.executable).trim().slice(0, 500),
|
||||
args: (Array.isArray(tool?.args) ? tool.args : fallback.args).map((item) => String(item).slice(0, 500)).slice(0, 20)
|
||||
});
|
||||
next.editor = normalizeTool(next.editor, DEFAULT_CONFIG.preferences.editor);
|
||||
next.terminal = normalizeTool(next.terminal, DEFAULT_CONFIG.preferences.terminal);
|
||||
next.notificationsEnabled = next.notificationsEnabled !== false;
|
||||
next.trayEnabled = next.trayEnabled !== false;
|
||||
next.closeToTray = next.closeToTray === true;
|
||||
next.startAtLogin = next.startAtLogin === true;
|
||||
this.data.preferences = next;
|
||||
await this.save();
|
||||
return this.getPublicState();
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -16,6 +16,18 @@ const {
|
||||
assertCloneRemote
|
||||
} = require('../shared/validation.cjs');
|
||||
|
||||
function parseUnifiedDiff(diffText) {
|
||||
const text = String(diffText || '').replace(/\r\n/g, '\n');
|
||||
const firstHunk = text.search(/^@@ /m);
|
||||
if (firstHunk < 0) return { header: text, hunks: [] };
|
||||
const header = text.slice(0, firstHunk);
|
||||
const hunks = text.slice(firstHunk).split(/(?=^@@ )/m).filter(Boolean).map((patch, index) => {
|
||||
const heading = patch.split('\n', 1)[0];
|
||||
return { index, heading, patch, additions: (patch.match(/^\+(?!\+\+)/gm) || []).length, deletions: (patch.match(/^-(?!---)/gm) || []).length };
|
||||
});
|
||||
return { header, hunks };
|
||||
}
|
||||
|
||||
class GitService {
|
||||
async isAvailable() {
|
||||
try {
|
||||
@@ -195,6 +207,40 @@ class GitService {
|
||||
};
|
||||
}
|
||||
|
||||
async abortInterruptedOperation(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const gitDirResult = await run('git', ['rev-parse', '--git-dir'], { cwd: root, timeout: 30_000 });
|
||||
const gitDir = path.resolve(root, gitDirResult.stdout.trim());
|
||||
const exists = async (name) => fs.access(path.join(gitDir, name)).then(() => true).catch(() => false);
|
||||
let aborted = null;
|
||||
if (await exists('rebase-merge') || await exists('rebase-apply')) {
|
||||
await run('git', ['rebase', '--abort'], { cwd: root, timeout: 120_000 });
|
||||
aborted = 'rebase';
|
||||
} else if (await exists('MERGE_HEAD')) {
|
||||
await run('git', ['merge', '--abort'], { cwd: root, timeout: 120_000 });
|
||||
aborted = 'merge';
|
||||
} else if (await exists('CHERRY_PICK_HEAD')) {
|
||||
await run('git', ['cherry-pick', '--abort'], { cwd: root, timeout: 120_000 });
|
||||
aborted = 'cherry-pick';
|
||||
} else if (await exists('REVERT_HEAD')) {
|
||||
await run('git', ['revert', '--abort'], { cwd: root, timeout: 120_000 });
|
||||
aborted = 'revert';
|
||||
}
|
||||
return { aborted, status: await this.status(root), lockReport: await this.listGitLocks(root) };
|
||||
}
|
||||
|
||||
async detectInterruptedOperation(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const gitDirResult = await run('git', ['rev-parse', '--git-dir'], { cwd: root, timeout: 30_000 });
|
||||
const gitDir = path.resolve(root, gitDirResult.stdout.trim());
|
||||
const exists = async (name) => fs.access(path.join(gitDir, name)).then(() => true).catch(() => false);
|
||||
if (await exists('rebase-merge') || await exists('rebase-apply')) return 'rebase';
|
||||
if (await exists('MERGE_HEAD')) return 'merge';
|
||||
if (await exists('CHERRY_PICK_HEAD')) return 'cherry-pick';
|
||||
if (await exists('REVERT_HEAD')) return 'revert';
|
||||
return null;
|
||||
}
|
||||
|
||||
async repairSync(repoPath, strategy) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const requested = String(strategy || '').trim();
|
||||
@@ -252,6 +298,54 @@ class GitService {
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
async diffHunks(repoPath, filePath) {
|
||||
const safeFile = assertRepositoryRelativePath(filePath);
|
||||
const diff = await this.diff(repoPath, safeFile, false);
|
||||
const parsed = parseUnifiedDiff(diff);
|
||||
return { filePath: safeFile, partialSupported: parsed.hunks.length > 0, hunks: parsed.hunks.map(({ patch, ...hunk }) => ({ ...hunk, lines: patch.split('\n') })) };
|
||||
}
|
||||
|
||||
async stageHunks(repoPath, filePath, hunkIndexes) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const safeFile = assertRepositoryRelativePath(filePath);
|
||||
const indexes = [...new Set((Array.isArray(hunkIndexes) ? hunkIndexes : []).map(Number))];
|
||||
if (!indexes.length || indexes.some((index) => !Number.isInteger(index) || index < 0)) throw new Error('Select at least one valid diff hunk.');
|
||||
const parsed = parseUnifiedDiff(await this.diff(root, safeFile, false));
|
||||
if (!parsed.hunks.length) throw new Error('Partial staging is unavailable for this file. Stage the complete file instead.');
|
||||
if (indexes.some((index) => index >= parsed.hunks.length)) throw new Error('The file changed after its diff was loaded. Refresh the diff and try again.');
|
||||
const patch = `${parsed.header}${indexes.map((index) => parsed.hunks[index].patch).join('')}`;
|
||||
await run('git', ['apply', '--cached', '--whitespace=nowarn', '-'], { cwd: root, input: patch, timeout: 60_000, maxBuffer: 16 * 1024 * 1024 });
|
||||
return this.status(root);
|
||||
}
|
||||
|
||||
async conflictState(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const operation = await this.detectInterruptedOperation(root);
|
||||
const result = await run('git', ['diff', '--name-only', '--diff-filter=U', '-z'], { cwd: root, timeout: 30_000 });
|
||||
const files = result.stdout.split('\0').filter(Boolean).map(assertRepositoryRelativePath);
|
||||
return { operation, files, canContinue: Boolean(operation) && files.length === 0, status: await this.status(root) };
|
||||
}
|
||||
|
||||
async resolveConflict(repoPath, filePath, resolution) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const safeFile = assertRepositoryRelativePath(filePath);
|
||||
const choice = String(resolution || 'resolved');
|
||||
if (!['ours', 'theirs', 'resolved'].includes(choice)) throw new Error('Unsupported conflict resolution choice.');
|
||||
if (choice !== 'resolved') await this.runWithPathspec(root, ['checkout', `--${choice}`], [safeFile], { timeout: 30_000 });
|
||||
await this.runWithPathspec(root, ['add'], [safeFile], { timeout: 30_000 });
|
||||
return this.conflictState(root);
|
||||
}
|
||||
|
||||
async continueInterruptedOperation(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const state = await this.conflictState(root);
|
||||
if (!state.operation) throw new Error('No interrupted Git operation is active.');
|
||||
if (state.files.length) throw new Error('Resolve every conflicted file before continuing.');
|
||||
const commands = { rebase: ['rebase', '--continue'], merge: ['merge', '--continue'], 'cherry-pick': ['cherry-pick', '--continue'], revert: ['revert', '--continue'] };
|
||||
await run('git', commands[state.operation], { cwd: root, env: { GIT_EDITOR: 'true' }, timeout: 120_000 });
|
||||
return this.conflictState(root);
|
||||
}
|
||||
|
||||
selectedStatusFiles(status, files) {
|
||||
const selected = assertRepositoryRelativePaths(files);
|
||||
if (!selected.length) return { selected, matches: status.files };
|
||||
@@ -334,6 +428,28 @@ class GitService {
|
||||
return { output: result.stdout.trim(), sha: status.head, shortSha: status.shortHead, status };
|
||||
}
|
||||
|
||||
async commitStaged(repoPath, message) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const commitMessage = assertCommitMessage(message);
|
||||
const stagedCheck = await run('git', ['diff', '--cached', '--quiet'], { cwd: root, allowExitCodes: [1] });
|
||||
if (stagedCheck.exitCode === 0) throw new Error('There are no staged changes to commit.');
|
||||
const result = await run('git', ['commit', '-m', commitMessage], { cwd: root, timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
|
||||
const status = await this.status(root);
|
||||
return { output: result.stdout.trim(), sha: status.head, shortSha: status.shortHead, status };
|
||||
}
|
||||
|
||||
async commitStagedAndPush(repoPath, message) {
|
||||
const committed = await this.commitStaged(repoPath, message);
|
||||
try {
|
||||
const pushed = await this.push(repoPath);
|
||||
return { commitOutput: committed.output, pushOutput: pushed.output, status: pushed.status, sha: committed.sha };
|
||||
} catch (error) {
|
||||
const wrapped = new Error(`Commit ${committed.shortSha} was created locally, but push failed: ${error.message}`);
|
||||
wrapped.code = 'PUSH_AFTER_COMMIT_FAILED'; wrapped.commitSha = committed.sha; wrapped.recoverable = true;
|
||||
throw wrapped;
|
||||
}
|
||||
}
|
||||
|
||||
async commitAndPush(repoPath, message, files = []) {
|
||||
const committed = await this.commit(repoPath, message, files);
|
||||
try {
|
||||
@@ -512,4 +628,4 @@ class GitService {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { GitService };
|
||||
module.exports = { GitService, parseUnifiedDiff };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
const { normalizeBaseUrl } = require('../shared/validation.cjs');
|
||||
const { normalizeBaseUrl, assertBranchName } = require('../shared/validation.cjs');
|
||||
const { redactSecrets } = require('./log-redaction.cjs');
|
||||
|
||||
class GiteaService {
|
||||
@@ -107,6 +107,44 @@ class GiteaService {
|
||||
return (await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches/${encodeURIComponent(branch)}`)).data;
|
||||
}
|
||||
|
||||
async getBranchProtection(owner, repo, branch) {
|
||||
const branchInfo = await this.getBranch(owner, repo, branch);
|
||||
let rule = null;
|
||||
try {
|
||||
const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branch_protections`);
|
||||
const rules = Array.isArray(result.data) ? result.data : [];
|
||||
rule = rules.find((item) => item.branch_name === branch || item.rule_name === branch) || null;
|
||||
} catch (error) {
|
||||
if (![403, 404].includes(error.status)) throw error;
|
||||
}
|
||||
return {
|
||||
branch,
|
||||
protected: Boolean(branchInfo?.protected || rule),
|
||||
enablePush: rule?.enable_push ?? null,
|
||||
enableForcePush: rule?.enable_force_push ?? false,
|
||||
requiredApprovals: Number(rule?.required_approvals || 0),
|
||||
requireSignedCommits: Boolean(rule?.require_signed_commits),
|
||||
rule
|
||||
};
|
||||
}
|
||||
|
||||
async listPullRequests({ owner, repo, state = 'open', limit = 30 } = {}) {
|
||||
const query = new URLSearchParams({ state, limit: String(Math.min(Math.max(Number(limit) || 30, 1), 50)) });
|
||||
const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls?${query}`);
|
||||
return Array.isArray(result.data) ? result.data : [];
|
||||
}
|
||||
|
||||
async createPullRequest({ owner, repo, head, base, title, body = '' }) {
|
||||
const cleanTitle = String(title || '').trim();
|
||||
if (!cleanTitle || cleanTitle.length > 255) throw new Error('Pull request title must contain 1-255 characters.');
|
||||
const cleanBody = String(body || '').trim().slice(0, 50_000);
|
||||
const source = assertBranchName(head);
|
||||
const target = assertBranchName(base);
|
||||
if (source === target) throw new Error('Pull request source and target branches must be different.');
|
||||
const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`, { method: 'POST', body: { head: source, base: target, title: cleanTitle, body: cleanBody }, timeout: 60_000 });
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async getRepositoryFile({ owner, repo, filePath, ref }) {
|
||||
const encodedPath = String(filePath || '').split('/').map(encodeURIComponent).join('/');
|
||||
const query = ref ? `?ref=${encodeURIComponent(ref)}` : '';
|
||||
|
||||
+145
-4
@@ -6,6 +6,8 @@ const { fileURLToPath } = require('node:url');
|
||||
const { ipcMain, dialog, shell, app } = require('electron');
|
||||
const { matchRemoteToRepository } = require('../shared/repository-match.cjs');
|
||||
const { cloneDirectoryName, resolveCloneTarget } = require('../shared/clone-target.cjs');
|
||||
const { createEncryptedBackup, readEncryptedBackup } = require('./configuration-backup.cjs');
|
||||
const { evaluateDeploymentPolicy } = require('../shared/deployment-policy.cjs');
|
||||
|
||||
let diagnosticsService = null;
|
||||
const TRUSTED_RENDERER_PATH = path.resolve(__dirname, '..', 'renderer', 'index.html');
|
||||
@@ -53,7 +55,7 @@ function register(channel, handler) {
|
||||
});
|
||||
}
|
||||
|
||||
function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh, updates, preflight, diagnostics, monitor }) {
|
||||
function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh, updates, preflight, diagnostics, audit, externalTools, monitor, onPreferencesChanged }) {
|
||||
diagnosticsService = diagnostics;
|
||||
const repositoryMutations = new Map();
|
||||
const withRepositoryPause = async (localPath, action) => {
|
||||
@@ -228,10 +230,46 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
|
||||
register('settings:set-preferences', async ({ preferences }) => {
|
||||
const state = await store.setPreferences(preferences);
|
||||
monitor?.restart();
|
||||
onPreferencesChanged?.();
|
||||
await diagnostics.info('settings.preferences.updated', { preferences: state.preferences });
|
||||
return state;
|
||||
});
|
||||
|
||||
register('settings:export-backup', async ({ passphrase }) => {
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: 'Export encrypted ForgeFlow configuration',
|
||||
defaultPath: path.join(app.getPath('documents'), `ForgeFlow-Configuration-${new Date().toISOString().slice(0, 10)}.ffbackup`),
|
||||
filters: [{ name: 'ForgeFlow encrypted backup', extensions: ['ffbackup'] }]
|
||||
});
|
||||
if (result.canceled || !result.filePath) return null;
|
||||
const destinationPath = result.filePath.toLowerCase().endsWith('.ffbackup') ? result.filePath : `${result.filePath}.ffbackup`;
|
||||
await fs.writeFile(destinationPath, createEncryptedBackup(store.data, passphrase), { mode: 0o600, flag: 'wx' }).catch(async (error) => {
|
||||
if (error.code !== 'EEXIST') throw error;
|
||||
await fs.writeFile(destinationPath, createEncryptedBackup(store.data, passphrase), { mode: 0o600 });
|
||||
});
|
||||
await audit.append('configuration.backup.exported', { fileName: path.basename(destinationPath) });
|
||||
return { filePath: destinationPath };
|
||||
});
|
||||
|
||||
register('settings:import-backup', async ({ passphrase }) => {
|
||||
const result = await dialog.showOpenDialog({ title: 'Import encrypted ForgeFlow configuration', properties: ['openFile'], filters: [{ name: 'ForgeFlow encrypted backup', extensions: ['ffbackup'] }] });
|
||||
if (result.canceled || !result.filePaths[0]) return null;
|
||||
const payload = readEncryptedBackup(await fs.readFile(result.filePaths[0], 'utf8'), passphrase);
|
||||
const state = await store.restoreConfiguration(payload.configuration);
|
||||
monitor?.restart();
|
||||
await audit.append('configuration.backup.imported', { fileName: path.basename(result.filePaths[0]), exportedAt: payload.exportedAt });
|
||||
return { state, exportedAt: payload.exportedAt };
|
||||
});
|
||||
|
||||
register('audit:list', ({ limit = 250 }) => audit.list(limit));
|
||||
register('audit:export', async ({ format = 'json' }) => {
|
||||
if (!['json', 'csv'].includes(format)) throw new Error('Unsupported audit export format.');
|
||||
const extension = format === 'csv' ? 'csv' : 'json';
|
||||
const result = await dialog.showSaveDialog({ title: 'Export ForgeFlow audit log', defaultPath: path.join(app.getPath('documents'), `ForgeFlow-Audit-${new Date().toISOString().slice(0, 10)}.${extension}`), filters: [{ name: `${extension.toUpperCase()} file`, extensions: [extension] }] });
|
||||
if (result.canceled || !result.filePath) return null;
|
||||
return audit.exportTo(result.filePath.toLowerCase().endsWith(`.${extension}`) ? result.filePath : `${result.filePath}.${extension}`, format);
|
||||
});
|
||||
|
||||
register('updates:preferences', ({ updates: next }) => store.setUpdatePreferences(next));
|
||||
register('updates:check', () => updates.check());
|
||||
register('updates:download', () => updates.download());
|
||||
@@ -271,6 +309,7 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
|
||||
return { ...result, state: store.getPublicState() };
|
||||
});
|
||||
register('server:inspect-project', async ({ repository, profileId }) => unraid.inspect({ repository: await resolveRepository(repository), profileId }));
|
||||
register('server:discover-existing', async ({ repository, serverId, remoteFolder }) => unraid.discoverExisting({ repository: await resolveRepository(repository), serverId, remoteFolder }));
|
||||
|
||||
register('repositories:refresh', async () => {
|
||||
const result = await repositories.refresh();
|
||||
@@ -308,14 +347,40 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
|
||||
|
||||
register('repository:status', async ({ localPath }) => git.status(await assertKnownRepositoryPath(localPath)));
|
||||
register('repository:diff', async ({ localPath, filePath, staged }) => git.diff(await assertKnownRepositoryPath(localPath), filePath, staged));
|
||||
register('repository:diff-hunks', async ({ localPath, filePath }) => git.diffHunks(await assertKnownRepositoryPath(localPath), filePath));
|
||||
register('repository:stage-hunks', async ({ localPath, filePath, hunkIndexes }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.stageHunks(safePath, filePath, hunkIndexes)); });
|
||||
register('repository:conflicts', async ({ localPath }) => git.conflictState(await assertKnownRepositoryPath(localPath)));
|
||||
register('repository:resolve-conflict', async ({ localPath, filePath, resolution }) => { const safePath = await assertKnownRepositoryPath(localPath); const result = await withRepositoryMutation(safePath, () => git.resolveConflict(safePath, filePath, resolution)); await audit.append('git.conflict.resolved', { localPath: safePath, filePath, resolution }); return result; });
|
||||
register('repository:continue-operation', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); const result = await withRepositoryMutation(safePath, () => git.continueInterruptedOperation(safePath)); await audit.append('git.operation.continued', { localPath: safePath }); return result; });
|
||||
register('repository:abort-operation', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); const result = await withRepositoryMutation(safePath, () => git.abortInterruptedOperation(safePath)); await audit.append('git.operation.aborted', { localPath: safePath, operation: result.aborted }); return result; });
|
||||
register('repository:stage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.stage(safePath, files)); });
|
||||
register('repository:unstage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.unstage(safePath, files)); });
|
||||
register('repository:commit', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.commit(safePath, message, files)); });
|
||||
register('repository:commit-staged', async ({ localPath, message }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.commitStaged(safePath, message)); });
|
||||
register('repository:commit-staged-push', async ({ localPath, message }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.commitStagedAndPush(safePath, message)); });
|
||||
register('repository:commit-push', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.commitAndPush(safePath, message, files)); });
|
||||
register('repository:push', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.push(safePath)); });
|
||||
register('repository:fetch', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.fetch(safePath)); });
|
||||
register('repository:pull', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.pullFastForward(safePath)); });
|
||||
register('repository:history', async ({ localPath, limit }) => git.history(await assertKnownRepositoryPath(localPath), limit));
|
||||
register('repository:branch-protection', async ({ fullName, branch }) => {
|
||||
const repository = await resolveRepository({ fullName });
|
||||
return gitea.getBranchProtection(repository.owner.login, repository.name, branch || repository.localStatus?.branch?.head || repository.defaultBranch);
|
||||
});
|
||||
register('repository:pull-requests', async ({ fullName, state = 'open' }) => {
|
||||
const repository = await resolveRepository({ fullName });
|
||||
return gitea.listPullRequests({ owner: repository.owner.login, repo: repository.name, state });
|
||||
});
|
||||
register('repository:create-pull-request', async ({ fullName, title, body, base }) => {
|
||||
const repository = await resolveRepository({ fullName });
|
||||
if (!repository.localPath || !repository.localStatus?.clean) throw new Error('A clean linked repository is required before creating a pull request.');
|
||||
const head = repository.localStatus.branch?.head;
|
||||
if (!head || !repository.localStatus.branch?.upstream) throw new Error('Publish the current branch before creating a pull request.');
|
||||
if (repository.localStatus.branch.ahead > 0) throw new Error('Push all local commits before creating a pull request.');
|
||||
const pullRequest = await gitea.createPullRequest({ owner: repository.owner.login, repo: repository.name, head, base: base || repository.defaultBranch, title, body });
|
||||
await audit.append('pull-request.created', { repository: repository.fullName, number: pullRequest.number, head, base: base || repository.defaultBranch, url: pullRequest.html_url });
|
||||
return pullRequest;
|
||||
});
|
||||
register('repository:branches', async ({ localPath }) => git.branches(await assertKnownRepositoryPath(localPath)));
|
||||
register('repository:checkout-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.checkoutBranch(safePath, branch)); });
|
||||
register('repository:create-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.createBranch(safePath, branch)); });
|
||||
@@ -370,6 +435,8 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
|
||||
if (error) throw new Error(error);
|
||||
return true;
|
||||
});
|
||||
register('repository:open-editor', async ({ localPath, filePath = '', line = 1 }) => externalTools.launch('editor', await assertKnownRepositoryPath(localPath), filePath, line));
|
||||
register('repository:open-terminal', async ({ localPath }) => externalTools.launch('terminal', await assertKnownRepositoryPath(localPath)));
|
||||
|
||||
register('external:open', async ({ url }) => {
|
||||
const parsed = new URL(url);
|
||||
@@ -378,6 +445,75 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
|
||||
return true;
|
||||
});
|
||||
|
||||
register('troubleshooter:scan', async ({ fullName = null }) => {
|
||||
const currentRepositories = await repositories.refresh();
|
||||
const candidates = fullName ? currentRepositories.filter((item) => item.fullName === fullName) : currentRepositories;
|
||||
const issues = [];
|
||||
for (const repository of candidates) {
|
||||
if (!repository.localPath) {
|
||||
issues.push({ id: `${repository.fullName}:not-linked`, repository: repository.fullName, severity: 'warning', title: 'Local repository is not linked', detail: 'Link or clone the repository before running local Git repairs.', repairable: false });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const interrupted = await git.detectInterruptedOperation(repository.localPath);
|
||||
if (interrupted) issues.push({ id: `${repository.fullName}:abort-operation`, repository: repository.fullName, localPath: repository.localPath, severity: 'error', title: `Interrupted Git ${interrupted}`, detail: `A ${interrupted} is still active and blocks normal Git operations. Aborting it can discard conflict-resolution work and therefore always requires separate confirmation.`, repairable: true, action: 'abort-operation', safe: false });
|
||||
const report = await git.reconcile(repository.localPath);
|
||||
for (const lock of report.lockReport?.locks || []) {
|
||||
const stale = lock.ageMs >= 10_000;
|
||||
const processProbeSafe = report.lockReport.processes?.available === true && !report.lockReport.processes.active?.length;
|
||||
issues.push({ id: `${repository.fullName}:locks:${lock.name}`, repository: repository.fullName, localPath: repository.localPath, severity: stale ? 'error' : 'warning', title: stale ? 'Stale Git lock detected' : 'Recent Git lock detected', detail: lock.name, repairable: stale, action: 'repair-locks', safe: stale && processProbeSafe });
|
||||
}
|
||||
const branch = report.status?.branch || {};
|
||||
if (branch.behind > 0 && branch.ahead === 0 && report.status.clean) issues.push({ id: `${repository.fullName}:fast-forward`, repository: repository.fullName, localPath: repository.localPath, severity: 'warning', title: 'Local branch is behind Gitea', detail: `${branch.behind} commit(s) can be fast-forwarded safely.`, repairable: true, action: 'fast-forward', safe: true });
|
||||
if (branch.ahead > 0 && branch.behind === 0) issues.push({ id: `${repository.fullName}:push`, repository: repository.fullName, localPath: repository.localPath, severity: 'warning', title: 'Local commits are not published', detail: `${branch.ahead} commit(s) can be pushed to Gitea after explicit confirmation.`, repairable: true, action: 'push', safe: false });
|
||||
if (branch.ahead > 0 && branch.behind > 0) issues.push({ id: `${repository.fullName}:diverged`, repository: repository.fullName, localPath: repository.localPath, severity: 'error', title: 'Local and Gitea branches have diverged', detail: `${branch.ahead} ahead and ${branch.behind} behind. ForgeFlow can preserve the local HEAD on a safety branch and use the upstream version.`, repairable: report.status.clean, action: 'backup-reset', safe: false });
|
||||
} catch (error) {
|
||||
issues.push({ id: `${repository.fullName}:git-error`, repository: repository.fullName, severity: 'error', title: 'Git health scan failed', detail: error.message, repairable: false });
|
||||
}
|
||||
for (const profile of repository.deploymentProfiles || []) {
|
||||
if (profile.provider !== 'ssh-unraid') continue;
|
||||
try {
|
||||
const inspection = await unraid.inspect({ repository, profileId: profile.id });
|
||||
if (!inspection.exists) issues.push({ id: `${profile.id}:server-folder`, repository: repository.fullName, profileId: profile.id, severity: 'error', title: 'Deployment folder is missing on the server', detail: inspection.remotePath, repairable: false });
|
||||
if (inspection.trackedChanges?.length) issues.push({ id: `${profile.id}:tracked-server-changes`, repository: repository.fullName, profileId: profile.id, severity: 'error', title: 'Tracked server-side changes detected', detail: `${inspection.trackedChanges.length} tracked change(s) must be reviewed before deployment.`, repairable: false });
|
||||
if (inspection.dockerContextExclusionsMissing?.length) issues.push({ id: `${profile.id}:dockerignore`, repository: repository.fullName, profileId: profile.id, severity: 'warning', title: 'Runtime paths are missing from .dockerignore', detail: inspection.dockerContextExclusionsMissing.join(', '), repairable: false });
|
||||
} catch (error) {
|
||||
issues.push({ id: `${profile.id}:server-error`, repository: repository.fullName, profileId: profile.id, severity: 'error', title: 'Server inspection failed', detail: error.message, repairable: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
const summary = { total: issues.length, errors: issues.filter((item) => item.severity === 'error').length, warnings: issues.filter((item) => item.severity === 'warning').length, repairable: issues.filter((item) => item.repairable).length };
|
||||
return { checkedAt: new Date().toISOString(), issues, summary };
|
||||
});
|
||||
|
||||
register('troubleshooter:repair', async ({ issue }) => {
|
||||
if (!issue || !issue.action) throw new Error('No repair action was supplied.');
|
||||
const localPath = issue.localPath ? await assertKnownRepositoryPath(issue.localPath) : null;
|
||||
let result;
|
||||
if (issue.action === 'abort-operation') result = await withRepositoryMutation(localPath, () => git.abortInterruptedOperation(localPath));
|
||||
else if (issue.action === 'repair-locks') result = await withRepositoryMutation(localPath, () => git.repairStaleGitLocks(localPath, { minimumAgeMs: 2_000 }));
|
||||
else if (['fast-forward', 'push', 'backup-reset', 'fetch'].includes(issue.action)) result = await withRepositoryMutation(localPath, () => git.repairSync(localPath, issue.action));
|
||||
else throw new Error('Unsupported troubleshooter repair action.');
|
||||
await diagnostics.info('troubleshooter.repair.completed', { repository: issue.repository, action: issue.action });
|
||||
return result;
|
||||
});
|
||||
|
||||
register('troubleshooter:auto-repair', async ({ issues }) => {
|
||||
const results = [];
|
||||
for (const issue of (issues || []).filter((item) => item.repairable && item.safe)) {
|
||||
try {
|
||||
const localPath = issue.localPath ? await assertKnownRepositoryPath(issue.localPath) : null;
|
||||
let result;
|
||||
if (issue.action === 'repair-locks') result = await withRepositoryMutation(localPath, () => git.repairStaleGitLocks(localPath, { minimumAgeMs: 10_000 }));
|
||||
else if (['fast-forward', 'fetch'].includes(issue.action)) result = await withRepositoryMutation(localPath, () => git.repairSync(localPath, issue.action));
|
||||
else continue;
|
||||
results.push({ id: issue.id, ok: true, result });
|
||||
} catch (error) { results.push({ id: issue.id, ok: false, error: error.message }); }
|
||||
}
|
||||
await diagnostics.info('troubleshooter.auto-repair.completed', { attempted: results.length, succeeded: results.filter((item) => item.ok).length });
|
||||
return results;
|
||||
});
|
||||
|
||||
register('deployment:save-profile', async ({ fullName, profile }) => {
|
||||
const saved = await store.saveDeploymentProfile(fullName, profile);
|
||||
await diagnostics.info('deployment.profile.saved', { repository: fullName, profile: saved });
|
||||
@@ -394,11 +530,16 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
|
||||
if (profile?.provider === 'ssh-unraid') return unraid.preflight({ repository: current, profileId });
|
||||
return preflight.runDeployment({ repository: current, profileId });
|
||||
});
|
||||
register('deployment:dispatch', async ({ repository, profileId, sha }) => {
|
||||
register('deployment:dispatch', async ({ repository, profileId, sha, note = '', override = false, overrideReason = '' }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const profile = store.getDeploymentProfile(current.fullName, profileId);
|
||||
if (profile?.provider === 'ssh-unraid') return unraid.deploy({ repository: current, profileId, sha });
|
||||
return deployments.deploy({ repository: current, profileId, sha });
|
||||
const policy = evaluateDeploymentPolicy(profile, { note, override, reason: overrideReason });
|
||||
await audit.append('deployment.requested', { repository: current.fullName, profileId, sha, note: policy.note, overridden: policy.overridden, overrideReason: policy.reason });
|
||||
const operation = profile?.provider === 'ssh-unraid'
|
||||
? await unraid.deploy({ repository: current, profileId, sha })
|
||||
: await deployments.deploy({ repository: current, profileId, sha });
|
||||
if (operation?.id) await store.addOperation({ ...operation, releaseNote: policy.note, policyOverride: policy.overridden ? { reason: policy.reason, violations: policy.violations } : null });
|
||||
return operation;
|
||||
});
|
||||
register('deployment:rollback', async ({ repository, profileId, targetSha }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
|
||||
@@ -97,6 +97,75 @@ function xmlEscape(value) {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
|
||||
|
||||
function decodeBase64Json(value, fallback) {
|
||||
try { return value ? JSON.parse(Buffer.from(value, 'base64').toString('utf8')) : fallback; }
|
||||
catch { return fallback; }
|
||||
}
|
||||
|
||||
function parseDockerManXml(xml) {
|
||||
const text = String(xml || '');
|
||||
const tag = (name) => {
|
||||
const match = text.match(new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`, 'i'));
|
||||
return match ? match[1].replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').trim() : '';
|
||||
};
|
||||
return { name: tag('Name'), webUiUrl: tag('WebUI'), iconUrl: tag('Icon'), shell: tag('Shell') };
|
||||
}
|
||||
|
||||
function deriveDetectedProfile({ repository, server, remoteFolder, remotePath, payload }) {
|
||||
const compose = payload.compose || {};
|
||||
const services = compose.services && typeof compose.services === 'object' ? compose.services : {};
|
||||
const inspections = Array.isArray(payload.containers) ? payload.containers : [];
|
||||
const primaryContainer = inspections.find((item) => item?.State?.Running) || inspections[0] || null;
|
||||
const labels = primaryContainer?.Config?.Labels || {};
|
||||
const serviceName = labels['com.docker.compose.service'] || Object.keys(services)[0] || remoteFolder;
|
||||
const service = services[serviceName] || {};
|
||||
const containerName = String(primaryContainer?.Name || service.container_name || serviceName).replace(/^\//, '');
|
||||
const ports = [];
|
||||
for (const [containerKey, bindings] of Object.entries(primaryContainer?.NetworkSettings?.Ports || {})) {
|
||||
const [containerPortText, protocol = 'tcp'] = containerKey.split('/');
|
||||
const containerPort = Number(containerPortText) || null;
|
||||
if (Array.isArray(bindings) && bindings.length) {
|
||||
for (const binding of bindings) ports.push({ hostIp: binding.HostIp || '', hostPort: Number(binding.HostPort) || null, containerPort, protocol });
|
||||
} else ports.push({ hostIp: '', hostPort: null, containerPort, protocol });
|
||||
}
|
||||
const primaryPort = ports.find((item) => item.hostPort) || ports[0] || {};
|
||||
const mounts = (primaryContainer?.Mounts || []).map((item) => ({ type: item.Type, source: item.Source, target: item.Destination, readOnly: item.RW === false }));
|
||||
const networks = Object.keys(primaryContainer?.NetworkSettings?.Networks || {});
|
||||
const envNames = (primaryContainer?.Config?.Env || []).map((item) => String(item).split('=')[0]).filter(Boolean);
|
||||
const dockerMan = parseDockerManXml(payload.dockerManXml || '');
|
||||
const webUiUrl = dockerMan.webUiUrl || labels['net.unraid.docker.webui'] || '';
|
||||
const iconUrl = dockerMan.iconUrl || labels['net.unraid.docker.icon'] || '';
|
||||
const shell = dockerMan.shell || labels['net.unraid.docker.shell'] || '/bin/sh';
|
||||
const preservePaths = [...new Set([
|
||||
'.env', 'appdata', 'data', 'logs', 'config', 'compose.override.yml',
|
||||
...mounts.filter((item) => String(item.source || '').startsWith(`${remotePath}/`)).map((item) => String(item.source).slice(remotePath.length + 1).split('/')[0]).filter(Boolean)
|
||||
])];
|
||||
const source = (value, origin, confidence = 'confirmed') => ({ value, origin, confidence, detectedAt: new Date().toISOString(), overridden: false });
|
||||
const composeFiles = payload.composeFiles || [];
|
||||
const composeFile = composeFiles[0] || labels['com.docker.compose.project.config_files']?.split(',')[0]?.replace(`${remotePath}/`, '') || 'docker-compose.yml';
|
||||
return {
|
||||
profile: {
|
||||
name: 'Production', environment: 'production', provider: 'ssh-unraid', branch: payload.branch || repository.defaultBranch || 'main',
|
||||
serverId: server.id, remoteFolder, cloneUrl: payload.remote || repository.sshUrl || '', alignRemote: false,
|
||||
generatedCompose: false, composeFile, composeService: serviceName, containerName,
|
||||
hostPort: primaryPort.hostPort || null, containerPort: primaryPort.containerPort || null,
|
||||
webUiUrl, iconMode: /^https?:\/\//i.test(iconUrl) ? 'url' : 'none', iconUrl: /^https?:\/\//i.test(iconUrl) ? iconUrl : '', serverIconReference: iconUrl, iconFilePath: '', dockerShell: ['/bin/bash','/bin/sh'].includes(shell) ? shell : '/bin/sh',
|
||||
healthcheckUrl: '', preservePaths, confirmationRequired: true,
|
||||
adoptedFromServer: true, serverSourceOfTruth: true, detectedAt: new Date().toISOString(),
|
||||
detectedMetadata: { head: payload.head || null, composeProject: labels['com.docker.compose.project'] || '', composeFiles, services: Object.keys(services), ports, mounts, networks, envNames, restartPolicy: primaryContainer?.HostConfig?.RestartPolicy?.Name || '', healthcheck: primaryContainer?.Config?.Healthcheck || null, image: primaryContainer?.Config?.Image || service.image || '', dockerMan }
|
||||
},
|
||||
provenance: {
|
||||
remoteFolder: source(remoteFolder, 'server-path'), cloneUrl: source(payload.remote || '', 'git-origin'), branch: source(payload.branch || '', 'git'),
|
||||
composeFile: source(composeFile, 'docker-compose'), composeService: source(serviceName, 'docker-labels'), containerName: source(containerName, 'docker-inspect'),
|
||||
hostPort: source(primaryPort.hostPort || null, 'docker-inspect'), containerPort: source(primaryPort.containerPort || null, 'docker-inspect'),
|
||||
webUiUrl: source(webUiUrl, dockerMan.webUiUrl ? 'unraid-dockerman' : 'docker-labels'), iconUrl: source(iconUrl, dockerMan.iconUrl ? 'unraid-dockerman' : 'docker-labels'), dockerShell: source(shell, dockerMan.shell ? 'unraid-dockerman' : 'docker-labels')
|
||||
},
|
||||
runtime: { remotePath, containerRunning: Boolean(primaryContainer?.State?.Running), containers: inspections.length, services: Object.keys(services).length, ports, mounts, networks, envNames }
|
||||
};
|
||||
}
|
||||
|
||||
function iconReferenceLocalPath(iconReference) {
|
||||
const value = String(iconReference || '').trim();
|
||||
if (value.startsWith('file:///')) return `/${value.slice('file:///'.length)}`;
|
||||
@@ -131,6 +200,64 @@ class UnraidDeploymentService {
|
||||
return { profile, server, remoteFolder, remotePath };
|
||||
}
|
||||
|
||||
|
||||
|
||||
async discoverExisting({ repository, serverId, remoteFolder = '' }) {
|
||||
const server = this.store.getServer(serverId);
|
||||
if (!server) throw new Error('The deployment server no longer exists.');
|
||||
const folder = safeRemoteFolder(remoteFolder || repository.name);
|
||||
const remotePath = path.join(server.basePath, folder);
|
||||
if (!remotePath.startsWith(`${server.basePath}/`)) throw new Error('Remote project path escapes the configured server base path.');
|
||||
const script = `
|
||||
root=${shellQuote(remotePath)}
|
||||
test -d "$root" || { echo "Existing server folder not found: $root" >&2; exit 44; }
|
||||
head=$(git -C "$root" rev-parse HEAD 2>/dev/null || true)
|
||||
branch=$(git -C "$root" branch --show-current 2>/dev/null || true)
|
||||
remote=$(git -C "$root" remote get-url origin 2>/dev/null || true)
|
||||
compose_files=$(find "$root" -maxdepth 2 -type f \\( -name 'docker-compose.yml' -o -name 'docker-compose.yaml' -o -name 'compose.yml' -o -name 'compose.yaml' \\) -printf '%P\\n' 2>/dev/null | sort)
|
||||
compose_file=$(printf '%s\\n' "$compose_files" | head -n1)
|
||||
compose_json='{}'
|
||||
container_json='[]'
|
||||
if [ -n "$compose_file" ] && command -v docker >/dev/null 2>&1; then
|
||||
compose_json=$(cd "$root" && docker compose -f "$compose_file" config --format json 2>/dev/null || printf '{}')
|
||||
ids=$(cd "$root" && docker compose -f "$compose_file" ps -aq 2>/dev/null || true)
|
||||
[ -n "$ids" ] && container_json=$(docker inspect $ids 2>/dev/null || printf '[]')
|
||||
fi
|
||||
container_name=$(printf '%s' "$container_json" | sed -n 's/.*"Name"[[:space:]]*:[[:space:]]*"\\/\\([^" ]*\\)".*/\\1/p' | head -n1)
|
||||
dockerman_xml=''
|
||||
if [ -n "$container_name" ] && [ -d /boot/config/plugins/dockerMan/templates-user ]; then
|
||||
template=$(grep -ril "<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 }) {
|
||||
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
||||
const preserveProbe = (profile.preservePaths || []).map((relativePath) =>
|
||||
@@ -906,5 +1033,8 @@ module.exports = {
|
||||
checksSummary,
|
||||
xmlEscape,
|
||||
iconReferenceLocalPath,
|
||||
decodeBase64Json,
|
||||
parseDockerManXml,
|
||||
deriveDetectedProfile,
|
||||
bash
|
||||
};
|
||||
|
||||
+2607
-659
File diff suppressed because it is too large
Load Diff
+1388
-203
File diff suppressed because it is too large
Load Diff
+2996
-473
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||
@@ -87,14 +87,15 @@ function assertDeploymentRequest(profile, sha) {
|
||||
assertHttpUrl(profile.statusUrl, { label: 'Application status URL' });
|
||||
}
|
||||
|
||||
function assertHttpUrl(value, { optional = false, label = 'URL' } = {}) {
|
||||
function assertHttpUrl(value, { optional = false, label = 'URL', allowUnraidTemplate = false } = {}) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw && optional) return '';
|
||||
if (!raw) throw new Error(`${label} is required.`);
|
||||
const url = new URL(raw);
|
||||
const validationValue = allowUnraidTemplate ? raw.replace(/\[IP\]/gi, '127.0.0.1').replace(/\[PORT(?::\d+)?\]/gi, '8080') : raw;
|
||||
const url = new URL(validationValue);
|
||||
if (!['http:', 'https:'].includes(url.protocol)) throw new Error(`${label} must use HTTP or HTTPS.`);
|
||||
if (url.username || url.password) throw new Error(`${label} may not contain credentials.`);
|
||||
return url.toString();
|
||||
return allowUnraidTemplate ? raw : url.toString();
|
||||
}
|
||||
|
||||
function assertCloneRemote(value) {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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"/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -243,3 +243,30 @@ test('repairs a diverged branch by creating a safety branch before resetting to
|
||||
const backupSha = (await git(['rev-parse', repaired.backupBranch], working)).stdout.trim();
|
||||
assert.equal(backupSha, localBefore);
|
||||
});
|
||||
|
||||
test('troubleshooter detects and aborts an interrupted merge without discarding committed history', async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-interrupted-merge-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
await git(['init'], root);
|
||||
await git(['config', 'user.name', 'ForgeFlow Test'], root);
|
||||
await git(['config', 'user.email', 'forgeflow@example.invalid'], root);
|
||||
await fs.writeFile(path.join(root, 'file.txt'), 'base\n');
|
||||
await git(['add', '.'], root);
|
||||
await git(['commit', '-m', 'Base'], root);
|
||||
await git(['checkout', '-b', 'other'], root);
|
||||
await fs.writeFile(path.join(root, 'file.txt'), 'other\n');
|
||||
await git(['commit', '-am', 'Other'], root);
|
||||
await git(['checkout', 'master'], root);
|
||||
await fs.writeFile(path.join(root, 'file.txt'), 'main\n');
|
||||
await git(['commit', '-am', 'Main'], root);
|
||||
await assert.rejects(git(['merge', 'other'], root));
|
||||
|
||||
const service = new GitService();
|
||||
assert.equal(await service.detectInterruptedOperation(root), 'merge');
|
||||
const result = await service.abortInterruptedOperation(root);
|
||||
assert.equal(result.aborted, 'merge');
|
||||
assert.equal(await service.detectInterruptedOperation(root), null);
|
||||
assert.equal(result.status.clean, true);
|
||||
const subject = await git(['log', '-1', '--pretty=%s'], root);
|
||||
assert.equal(subject.stdout.trim(), 'Main');
|
||||
});
|
||||
|
||||
@@ -88,3 +88,22 @@ test('checks repository workflow files through the contents API', async () => {
|
||||
service.request = async () => { const error = new Error('missing'); error.status = 404; throw error; };
|
||||
assert.equal(await service.repositoryFileExists({ owner: 'jens', repo: 'app', filePath: '.gitea/workflows/missing.yml', ref: 'main' }), false);
|
||||
});
|
||||
|
||||
test('creates controlled pull requests and reads branch protection', async () => {
|
||||
const service = new GiteaService(makeStore());
|
||||
const calls = [];
|
||||
service.request = async (pathname, options = {}) => {
|
||||
calls.push({ pathname, options });
|
||||
if (pathname.includes('/branches/main')) return { data: { name: 'main', protected: true } };
|
||||
if (pathname.endsWith('/branch_protections')) return { data: [{ branch_name: 'main', required_approvals: 2, require_signed_commits: true }] };
|
||||
return { data: { number: 12, html_url: 'https://gitea.test/owner/app/pulls/12' } };
|
||||
};
|
||||
const protection = await service.getBranchProtection('owner', 'app', 'main');
|
||||
assert.equal(protection.protected, true);
|
||||
assert.equal(protection.requiredApprovals, 2);
|
||||
const pull = await service.createPullRequest({ owner: 'owner', repo: 'app', head: 'feature', base: 'main', title: 'Release feature', body: 'Summary' });
|
||||
assert.equal(pull.number, 12);
|
||||
const create = calls.find((call) => call.options.method === 'POST');
|
||||
assert.deepEqual(create.options.body, { head: 'feature', base: 'main', title: 'Release feature', body: 'Summary' });
|
||||
await assert.rejects(() => service.createPullRequest({ owner: 'owner', repo: 'app', head: 'main', base: 'main', title: 'Invalid' }), /different/);
|
||||
});
|
||||
|
||||
@@ -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)),
|
||||
[],
|
||||
);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -1,46 +1,81 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
test('changed file list has an independently scrollable bounded layout', async () => {
|
||||
const css = await readFile(new URL('../src/renderer/styles.css', import.meta.url), 'utf8');
|
||||
assert.match(css, /\.main-canvas\.repository-canvas\s*\{[^}]*overflow:\s*hidden/);
|
||||
assert.match(css, /\.file-panel\s*\{[^}]*min-height:\s*0[^}]*overflow:\s*hidden/);
|
||||
assert.match(css, /\.file-list\s*\{[^}]*flex:\s*1 1 auto[^}]*overflow-y:\s*auto/);
|
||||
test("changed file list has an independently scrollable bounded layout", async () => {
|
||||
const css = await readFile(
|
||||
new URL("../src/renderer/styles.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.main-canvas\.repository-canvas\s*\{[^}]*overflow:\s*hidden/,
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.file-panel\s*\{[^}]*min-height:\s*0[^}]*overflow:\s*hidden/,
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.file-list\s*\{[^}]*flex:\s*1 1 auto[^}]*overflow-y:\s*auto/,
|
||||
);
|
||||
});
|
||||
|
||||
test('commit workflow explains every disabled prerequisite', async () => {
|
||||
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
|
||||
test("commit workflow explains every disabled prerequisite", async () => {
|
||||
const renderer = await readFile(
|
||||
new URL("../src/renderer/app.js", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(renderer, /Commit message <span class="required-mark">required/);
|
||||
assert.match(renderer, /Enter a commit message to enable commit and push/);
|
||||
assert.match(renderer, /ForgeFlow stages the selected files automatically/);
|
||||
assert.match(renderer, /Commit selected & push to Gitea/);
|
||||
assert.match(renderer, /data-action="commit-push"/);
|
||||
assert.match(renderer, /Commit staged hunks/);
|
||||
});
|
||||
|
||||
test('ITWorx branding is integrated into titlebar and setup', async () => {
|
||||
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
|
||||
test("ITWorx branding is integrated into titlebar and setup", async () => {
|
||||
const renderer = await readFile(
|
||||
new URL("../src/renderer/app.js", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(renderer, /itworx-mark\.png/);
|
||||
assert.match(renderer, /itworx-wordmark-(?:light|dark)\.png/);
|
||||
});
|
||||
|
||||
|
||||
test('all modal content stays inside the viewport with a persistent action footer', async () => {
|
||||
const css = await readFile(new URL('../src/renderer/styles.css', import.meta.url), 'utf8');
|
||||
assert.match(css, /\.modal\s*\{[^}]*max-height:\s*calc\(100dvh[^}]*display:\s*flex[^}]*flex-direction:\s*column/);
|
||||
assert.match(css, /\.modal-body\s*\{[^}]*min-height:\s*0[^}]*overflow-y:\s*auto/);
|
||||
test("all modal content stays inside the viewport with a persistent action footer", async () => {
|
||||
const css = await readFile(
|
||||
new URL("../src/renderer/styles.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.modal\s*\{[^}]*max-height:\s*calc\(100dvh[^}]*display:\s*flex[^}]*flex-direction:\s*column/,
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.modal-body\s*\{[^}]*min-height:\s*0[^}]*overflow-y:\s*auto/,
|
||||
);
|
||||
assert.match(css, /\.modal-footer\s*\{[^}]*flex:\s*0 0 auto/);
|
||||
});
|
||||
|
||||
test('settings provides one-click normalization for legacy Gitea origins', async () => {
|
||||
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
|
||||
test("settings provides one-click normalization for legacy Gitea origins", async () => {
|
||||
const renderer = await readFile(
|
||||
new URL("../src/renderer/app.js", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(renderer, /data-action="normalize-origins"/);
|
||||
assert.match(renderer, /Normalize all origins/);
|
||||
});
|
||||
|
||||
|
||||
test('Git mutations are serialized per repository and expose repair actions', async () => {
|
||||
const ipc = await readFile(new URL('../src/main/ipc.cjs', import.meta.url), 'utf8');
|
||||
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
|
||||
test("Git mutations are serialized per repository and expose repair actions", async () => {
|
||||
const ipc = await readFile(
|
||||
new URL("../src/main/ipc.cjs", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const renderer = await readFile(
|
||||
new URL("../src/renderer/app.js", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(ipc, /repositoryMutations = new Map/);
|
||||
assert.match(ipc, /withRepositoryMutation/);
|
||||
assert.match(ipc, /GIT_LOCKS_RECENT/);
|
||||
@@ -51,36 +86,122 @@ test('Git mutations are serialized per repository and expose repair actions', as
|
||||
assert.match(renderer, /Open guided repository repair/);
|
||||
});
|
||||
|
||||
|
||||
test('SSH secrets are captured before the loading render clears password inputs', async () => {
|
||||
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
|
||||
const passwordCapture = renderer.indexOf("const password = document.querySelector('#server-password')");
|
||||
const loading = renderer.indexOf("setLoading(true, 'Saving encrypted SSH configuration…')");
|
||||
test("SSH secrets are captured before the loading render clears password inputs", async () => {
|
||||
const renderer = await readFile(
|
||||
new URL("../src/renderer/app.js", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const passwordCapture = renderer.search(
|
||||
/const password = document\.querySelector\(["']#server-password["']\)/,
|
||||
);
|
||||
const loading = renderer.search(
|
||||
/setLoading\(true, ["']Saving encrypted SSH configuration/,
|
||||
);
|
||||
assert.ok(passwordCapture >= 0 && loading > passwordCapture);
|
||||
});
|
||||
|
||||
test('SSH deployments are polled in the background and Portfolio casing is preserved', async () => {
|
||||
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
|
||||
test("SSH deployments are polled in the background and Portfolio casing is preserved", async () => {
|
||||
const renderer = await readFile(
|
||||
new URL("../src/renderer/app.js", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(renderer, /function startOperationPolling\(\)/);
|
||||
assert.match(renderer, /startOperationPolling\(\);/);
|
||||
assert.match(renderer, /Visible container name/);
|
||||
assert.match(renderer, /Compose service \(internal\)/);
|
||||
});
|
||||
|
||||
|
||||
test('deployment profiles expose built-in/uploaded DockerMan icons and automatic metadata repair', async () => {
|
||||
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
|
||||
test("deployment profiles expose built-in/uploaded DockerMan icons and automatic metadata repair", async () => {
|
||||
const renderer = await readFile(
|
||||
new URL("../src/renderer/app.js", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(renderer, /Built-in high-contrast ITWorx mark/);
|
||||
assert.match(renderer, /profile-icon-mode/);
|
||||
assert.match(renderer, /Repair DockerMan integration/);
|
||||
assert.match(renderer, /reconcile-deployment/);
|
||||
});
|
||||
|
||||
test('repository troubleshooting offers personalized synchronization repair actions', async () => {
|
||||
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
|
||||
const ipc = await readFile(new URL('../src/main/ipc.cjs', import.meta.url), 'utf8');
|
||||
test("repository troubleshooting offers personalized synchronization repair actions", async () => {
|
||||
const renderer = await readFile(
|
||||
new URL("../src/renderer/app.js", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const ipc = await readFile(
|
||||
new URL("../src/main/ipc.cjs", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(renderer, /repair-repository-sync/);
|
||||
assert.match(renderer, /safety branch/);
|
||||
assert.match(ipc, /repository:repair-sync/);
|
||||
});
|
||||
|
||||
test("advanced Git, desktop, backup, policy and audit workflows are exposed in the renderer", async () => {
|
||||
const renderer = await readFile(
|
||||
new URL("../src/renderer/app.js", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const preload = await readFile(
|
||||
new URL("../preload.cjs", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
for (const phrase of [
|
||||
"Stage hunks",
|
||||
"Conflict guide",
|
||||
"Create pull request",
|
||||
"Open pull requests",
|
||||
"load-pull-requests",
|
||||
"Check branch protection",
|
||||
"Encrypted configuration backup",
|
||||
"Deployment policy",
|
||||
"Operational audit log",
|
||||
])
|
||||
assert.match(renderer, new RegExp(phrase, "i"));
|
||||
for (const method of [
|
||||
"stageHunks",
|
||||
"resolveConflict",
|
||||
"createPullRequest",
|
||||
"branchProtection",
|
||||
"openEditor",
|
||||
"openTerminal",
|
||||
"exportConfigurationBackup",
|
||||
"listAuditEvents",
|
||||
])
|
||||
assert.match(preload, new RegExp(`${method}:`));
|
||||
});
|
||||
|
||||
test("one-click troubleshooting excludes destructive or publishing Git actions", async () => {
|
||||
const renderer = await readFile(
|
||||
new URL("../src/renderer/app.js", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const ipc = await readFile(
|
||||
new URL("../src/main/ipc.cjs", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(ipc, /action: 'abort-operation', safe: false/);
|
||||
assert.match(ipc, /action: 'push', safe: false/);
|
||||
assert.match(ipc, /const stale = lock\.ageMs >= 10_000/);
|
||||
assert.match(ipc, /\['fast-forward', 'fetch'\]\.includes\(issue\.action\)/);
|
||||
assert.doesNotMatch(
|
||||
ipc,
|
||||
/\['fast-forward', 'push', 'fetch'\]\.includes\(issue\.action\)/,
|
||||
);
|
||||
assert.match(
|
||||
renderer,
|
||||
/trouble\?\.issues\?\.some\(\(item\) => item\.repairable && item\.safe\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("premium repository workspace reserves separate rows for actions and release status", async () => {
|
||||
const styles = await readFile(
|
||||
new URL("../src/renderer/styles.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.repo-workspace\s*\{[^}]*grid-template-rows:\s*auto auto auto 39px minmax\(0, 1fr\)/s,
|
||||
);
|
||||
assert.match(styles, /prefers-reduced-motion/);
|
||||
assert.match(styles, /ForgeFlow 0\.8 premium visual system/);
|
||||
});
|
||||
|
||||
@@ -265,3 +265,39 @@ test('stuck deployment is cleared as superseded when a different healthy commit
|
||||
assert.match(result.error, /Superseded/);
|
||||
assert.equal(saved.at(-1).status, 'cancelled');
|
||||
});
|
||||
|
||||
test('existing Unraid deployment discovery derives profile values from Docker, Compose and DockerMan truth', () => {
|
||||
const { deriveDetectedProfile } = require('../src/main/unraid-deployment-service.cjs');
|
||||
const result = deriveDetectedProfile({
|
||||
repository: { name: 'blockpilot-autonomous', defaultBranch: 'main', sshUrl: 'ssh://git@gitea/Jens/blockpilot-autonomous.git' },
|
||||
server: { id: 'unraid', host: '192.168.10.150' },
|
||||
remoteFolder: 'blockpilot-autonomous',
|
||||
remotePath: '/mnt/user/appdata/blockpilot-autonomous',
|
||||
payload: {
|
||||
head: 'a'.repeat(40),
|
||||
branch: 'main',
|
||||
remote: 'ssh://git@gitea/Jens/blockpilot-autonomous.git',
|
||||
composeFiles: ['compose.yml'],
|
||||
compose: { services: { app: { image: 'blockpilot:test' } } },
|
||||
containers: [{
|
||||
Name: '/blockpilot',
|
||||
State: { Running: true },
|
||||
Config: { Image: 'blockpilot:test', Env: ['TOKEN=secret', 'MODE=prod'], Labels: { 'com.docker.compose.service': 'app', 'com.docker.compose.project': 'blockpilot' } },
|
||||
HostConfig: { RestartPolicy: { Name: 'unless-stopped' } },
|
||||
NetworkSettings: { Ports: { '8080/tcp': [{ HostIp: '0.0.0.0', HostPort: '1223' }] }, Networks: { bridge: {} } },
|
||||
Mounts: [{ Type: 'bind', Source: '/mnt/user/appdata/blockpilot-autonomous/data', Destination: '/data', RW: true }]
|
||||
}],
|
||||
dockerManXml: '<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');
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import validation from '../src/shared/validation.cjs';
|
||||
|
||||
const { normalizeBaseUrl, assertCommitMessage, assertDeploymentRequest } = validation;
|
||||
const { normalizeBaseUrl, assertCommitMessage, assertDeploymentRequest, assertHttpUrl } = validation;
|
||||
|
||||
test('normalizes Gitea base URL', () => {
|
||||
assert.equal(normalizeBaseUrl('https://gitea.example.com/'), 'https://gitea.example.com');
|
||||
@@ -15,3 +15,8 @@ test('rejects blank commit messages', () => {
|
||||
test('requires exact SHA and workflow profile', () => {
|
||||
assert.throws(() => assertDeploymentRequest({ branch: 'main', workflowFile: 'deploy.yml' }, 'nope'), /commit SHA/i);
|
||||
});
|
||||
|
||||
test('accepts Unraid DockerMan WebUI placeholders only when explicitly enabled', () => {
|
||||
assert.equal(assertHttpUrl('http://[IP]:[PORT:1223]/', { allowUnraidTemplate: true }), 'http://[IP]:[PORT:1223]/');
|
||||
assert.throws(() => assertHttpUrl('http://[IP]:[PORT:1223]/'));
|
||||
});
|
||||
|
||||
+14
-29
@@ -4,47 +4,32 @@ Set-Location $PSScriptRoot
|
||||
|
||||
function Assert-Command {
|
||||
param([Parameter(Mandatory = $true)][string]$Name)
|
||||
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
|
||||
throw "Required command '$Name' was not found on PATH."
|
||||
}
|
||||
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { throw "Required command '$Name' was not found on PATH." }
|
||||
}
|
||||
|
||||
function Invoke-Step {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Title,
|
||||
[Parameter(Mandatory = $true)][scriptblock]$Action
|
||||
)
|
||||
param([Parameter(Mandatory = $true)][string]$Title, [Parameter(Mandatory = $true)][scriptblock]$Action)
|
||||
Write-Host "`n$Title" -ForegroundColor Yellow
|
||||
& $Action
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$Title failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) { throw "$Title failed with exit code $LASTEXITCODE." }
|
||||
}
|
||||
|
||||
Write-Host "ForgeFlow v0.4.2 source update" -ForegroundColor Cyan
|
||||
Write-Host "Your Gitea token, repository mappings and deployment profiles are stored outside this source folder and are not reset." -ForegroundColor DarkGray
|
||||
$package = Get-Content ".\package.json" -Raw | ConvertFrom-Json
|
||||
$version = [string]$package.version
|
||||
if ($package.name -ne "forgeflow" -or [string]::IsNullOrWhiteSpace($version)) { throw "This folder is not a valid ForgeFlow source release." }
|
||||
|
||||
Write-Host "ForgeFlow v$version source update" -ForegroundColor Cyan
|
||||
Write-Host "Your Gitea token, repository mappings and deployment profiles are stored outside this source folder and are not reset." -ForegroundColor DarkGray
|
||||
Assert-Command node
|
||||
Assert-Command npm
|
||||
Assert-Command git
|
||||
|
||||
$package = Get-Content ".\package.json" -Raw | ConvertFrom-Json
|
||||
if ($package.version -ne "0.4.2") {
|
||||
throw "This folder does not contain ForgeFlow v0.4.2. Detected version: $($package.version)"
|
||||
Invoke-Step "Installing exact project dependencies..." {
|
||||
if (-not (Test-Path ".\package-lock.json")) { throw "package-lock.json is required for a reproducible ForgeFlow update." }
|
||||
npm ci --no-audit --no-fund
|
||||
}
|
||||
Invoke-Step "Running the environment doctor..." { npm run doctor }
|
||||
Invoke-Step "Running source verification and automated tests..." { npm run check }
|
||||
|
||||
Invoke-Step "Updating project dependencies..." {
|
||||
npm install --no-audit --no-fund
|
||||
}
|
||||
|
||||
Invoke-Step "Running the environment doctor..." {
|
||||
npm run doctor
|
||||
}
|
||||
|
||||
Invoke-Step "Running source verification and all automated tests..." {
|
||||
npm run check
|
||||
}
|
||||
|
||||
Write-Host "`nForgeFlow v0.4.2 is ready." -ForegroundColor Green
|
||||
Write-Host "`nForgeFlow v$version is ready." -ForegroundColor Green
|
||||
Write-Host "Starting ForgeFlow with your existing local configuration..." -ForegroundColor Green
|
||||
npm start
|
||||
|
||||
Reference in New Issue
Block a user