2 Commits
15 changed files with 3007 additions and 913 deletions
+8 -4
View File
@@ -1,5 +1,12 @@
# Changelog # Changelog
## 0.8.3 - 2026-07-26
- enriched light mode with layered color, depth and stronger navigation hierarchy;
- made every deployment visually identifiable by container, repository, environment and stable accent color;
- added live-versus-Gitea commit proof directly to deployment cards;
- reconciled stale failed operations against healthy live Unraid and current Gitea truth.
## 0.8.2 - 2026-07-26 ## 0.8.2 - 2026-07-26
- enabled checksum-verified binary auto-update for installed and portable Windows builds; - enabled checksum-verified binary auto-update for installed and portable Windows builds;
@@ -35,7 +42,6 @@
- Fixed Windows PowerShell 5.1 updater status replacement and STARTED handshake. - Fixed Windows PowerShell 5.1 updater status replacement and STARTED handshake.
- Added helper-log diagnostics and update-request identity validation. - Added helper-log diagnostics and update-request identity validation.
## 0.6.0 ## 0.6.0
- Added complete repository troubleshooting for stale `HEAD.lock`, `index.lock`, ref locks and diverged branches. - Added complete repository troubleshooting for stale `HEAD.lock`, `index.lock`, ref locks and diverged branches.
@@ -57,7 +63,6 @@
- Preserved configured Compose casing such as Portfolio. - Preserved configured Compose casing such as Portfolio.
- Guaranteed failed remote operations become terminal failed records. - Guaranteed failed remote operations become terminal failed records.
## 0.5.3 ## 0.5.3
- Confirmed updater handoff before application exit. - Confirmed updater handoff before application exit.
@@ -70,7 +75,6 @@
- Added portable structural safety validation for the Unraid deployment script. - Added portable structural safety validation for the Unraid deployment script.
- Kept GNU Bash syntax validation on Linux and other non-Windows systems. - Kept GNU Bash syntax validation on Linux and other non-Windows systems.
## 0.5.1 ## 0.5.1
- Fixed Windows publication quality gate by validating Bash syntax through standard input. - Fixed Windows publication quality gate by validating Bash syntax through standard input.
@@ -84,7 +88,6 @@
- Bulk normalization of legacy Gitea origins. - Bulk normalization of legacy Gitea origins.
- Expanded regression coverage. - Expanded regression coverage.
- Correctly stage deleted and renamed paths. - Correctly stage deleted and renamed paths.
- Preserve and surface local commits when push fails. - Preserve and surface local commits when push fails.
- Always refresh the actual Git state after operation errors. - Always refresh the actual Git state after operation errors.
@@ -156,6 +159,7 @@
- Added safe workflow and server deployment examples. - Added safe workflow and server deployment examples.
## 0.4.5 ## 0.4.5
- Fixed commit/push after manually staging a deleted file. - Fixed commit/push after manually staging a deleted file.
- Already staged deletions and renames are no longer re-added as missing pathspecs. - Already staged deletions and renames are no longer re-added as missing pathspecs.
- Added a real bare-remote regression test for `silent-zebra-glow.zip`. - Added a real bare-remote regression test for `silent-zebra-glow.zip`.
+15 -13
View File
@@ -1,4 +1,4 @@
ForgeFlow 0.8.2 source manifest ForgeFlow 0.8.3 source manifest
SHA-256 BYTES PATH SHA-256 BYTES PATH
(The manifest excludes itself, dependencies and generated release artifacts.) (The manifest excludes itself, dependencies and generated release artifacts.)
755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 58 .gitignore 755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 58 .gitignore
@@ -12,7 +12,7 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263
4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183 3364 build/icon-64.png 4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183 3364 build/icon-64.png
25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico 25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png
973e58a92fb2fdaff471dbe7a20549c9fc79e6014933cd56dce971ad7d474bd4 8234 CHANGELOG.md 02c39f04f01394f687eac1d8bae0cdd85018d8e95ff941b6ce6d9f9d7b19d697 8606 CHANGELOG.md
21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md 21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md
a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md
30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md 30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md
@@ -40,6 +40,7 @@ f3d04f2d3419a7a010d5399cdd9351ff85ab2b3fdf8023977e559b0a5f8bcdc3 2571
d7bdc61d9b617ad5acf0b2d468eda547fd7509d4af08f33d2661393f25bdcb5a 576 docs/RELEASE_NOTES_0.8.0.md d7bdc61d9b617ad5acf0b2d468eda547fd7509d4af08f33d2661393f25bdcb5a 576 docs/RELEASE_NOTES_0.8.0.md
1e056bfcf2105843402f4b14c63480240cc55456a4a63e229b3fdbaf3156b803 754 docs/RELEASE_NOTES_0.8.1.md 1e056bfcf2105843402f4b14c63480240cc55456a4a63e229b3fdbaf3156b803 754 docs/RELEASE_NOTES_0.8.1.md
7f1d7c8bc895d309dad2f8ab444d6d2ba3e68c8daa240fecd2abdd9d8a56ba20 729 docs/RELEASE_NOTES_0.8.2.md 7f1d7c8bc895d309dad2f8ab444d6d2ba3e68c8daa240fecd2abdd9d8a56ba20 729 docs/RELEASE_NOTES_0.8.2.md
c2802fa5dbff392c846b82b55e84a8bfb8e1625546fd1eba39f7129318bece96 654 docs/RELEASE_NOTES_0.8.3.md
2b631b9d6d973bdd70869d84886ff339da351e29e17598970b3b27915674661d 4175 docs/ROADMAP.md 2b631b9d6d973bdd70869d84886ff339da351e29e17598970b3b27915674661d 4175 docs/ROADMAP.md
1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png 1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png
b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png
@@ -52,7 +53,7 @@ bcb1e4daf1eeedc5b3f61d2406f1a65312dba130082528007e1629d9df99570a 153240
b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md
0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md 0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md
4625a10ebd3c749f60b2a7bef6b1716cd05dbc44ccceba0491a1b46bc293c195 4883 docs/TEST_MATRIX.md 4625a10ebd3c749f60b2a7bef6b1716cd05dbc44ccceba0491a1b46bc293c195 4883 docs/TEST_MATRIX.md
28f42ed6352a01e034c39c5a2a2f461f3f540aa37abc339e5b00afcc81e7ba7b 3273 docs/UPDATING.md b3f317faba63d574ee83924f01b5275c118b5b04f6d9109c7cf9ed18500835d8 3389 docs/UPDATING.md
c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 examples/gitea-actions/deploy.yml c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 examples/gitea-actions/deploy.yml
4c792cc9fd57ed36da291300c252a6ef75b08a249cf6f2561e23c4c22522138a 1477 examples/gitea-actions/rollback.yml 4c792cc9fd57ed36da291300c252a6ef75b08a249cf6f2561e23c4c22522138a 1477 examples/gitea-actions/rollback.yml
1d2cde1bef4882f56006823d2806f6105882fa098a665a303150fdf18ada2004 5705 examples/server/forgeflow-deploy 1d2cde1bef4882f56006823d2806f6105882fa098a665a303150fdf18ada2004 5705 examples/server/forgeflow-deploy
@@ -63,8 +64,8 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE 4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE
6765015bdf27b288a250192272750b243c3cb8d1326b752d056d1e43317b6344 12935 main.cjs 6765015bdf27b288a250192272750b243c3cb8d1326b752d056d1e43317b6344 12935 main.cjs
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md 91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
ebb0b154137c113205351216e79dc0aad79949ea465b1734f41dd524f913cb34 134141 package-lock.json 8f1b45693760790cca87c0643a0e43adb38b1b7182a592b0ac5e1f3883d47566 130466 package-lock.json
4ad40664dd3ed8526685fc7ca75be8f8c21acbad515b375ba4f04f3e2c4dcfe1 3559 package.json 17ec2a34f0182f0752212765674afd79df9aa47c6b833065b943743737ce6bc8 3721 package.json
3d2ac366a13e9418e3ec6d13ce95b611f30f0228eb3a80ef9e7a936ce9578e24 9080 preload.cjs 3d2ac366a13e9418e3ec6d13ce95b611f30f0228eb3a80ef9e7a936ce9578e24 9080 preload.cjs
b31c43d9355c13b5ae4efc0f3649d8cb8d509b2bb7ebb042ff546b7820fb7de8 8411 Publish-ForgeFlow-Release.ps1 b31c43d9355c13b5ae4efc0f3649d8cb8d509b2bb7ebb042ff546b7820fb7de8 8411 Publish-ForgeFlow-Release.ps1
a6d32a742412b7836606be00f17be0465f1b6f55d3911f6c73a14029787ba206 14037 README.md a6d32a742412b7836606be00f17be0465f1b6f55d3911f6c73a14029787ba206 14037 README.md
@@ -73,10 +74,11 @@ a6d32a742412b7836606be00f17be0465f1b6f55d3911f6c73a14029787ba206 14037
f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287 scripts/apply-source-update.ps1 f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287 scripts/apply-source-update.ps1
6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs 6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs
5e9a2a819522f6a32bbd9d3303263d5e5eaec95898ea2cd5776b221168008d75 1727 scripts/generate-source-manifest.mjs 5e9a2a819522f6a32bbd9d3303263d5e5eaec95898ea2cd5776b221168008d75 1727 scripts/generate-source-manifest.mjs
842436680521311594e798848b050ae4e488d0595f0de57315f6ec081c049fb9 1266 scripts/prune-dist.mjs
74433d8a6b24afe368197a469e2fe0c5050c239d7250b84c2f3f598c304778b0 4736 scripts/publish-binary-release.cjs 74433d8a6b24afe368197a469e2fe0c5050c239d7250b84c2f3f598c304778b0 4736 scripts/publish-binary-release.cjs
444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs 444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs
42203f9e0fd4aae517284d387f265cf1b0b180379bc253a092b5c3c5c4caef0a 2992 scripts/validate-installed-connections.cjs 42203f9e0fd4aae517284d387f265cf1b0b180379bc253a092b5c3c5c4caef0a 2992 scripts/validate-installed-connections.cjs
e6def656ba61e6ac705bc87e59abc607f4870702f0cbd609bc3e122bb01a9939 12150 scripts/verify.mjs a2f19d7c45132973b3db3a56b57bd4a8e34de98e7c446b19cfaf3c4904fe5715 12209 scripts/verify.mjs
0079701b5acbfef07b71a9623613d1940805ccd20649d77e3f34c37e79df7655 735 scripts/write-release-checksums.mjs 0079701b5acbfef07b71a9623613d1940805ccd20649d77e3f34c37e79df7655 735 scripts/write-release-checksums.mjs
619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1 619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1
dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs
@@ -87,23 +89,23 @@ c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095
a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs
0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs 0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs
f5b4e468c92eb0d96d02357290ac4bfe2d30eef9c7287267882bc146237a8693 16559 src/main/gitea-service.cjs f5b4e468c92eb0d96d02357290ac4bfe2d30eef9c7287267882bc146237a8693 16559 src/main/gitea-service.cjs
b2d768a9dfd1e494edee6609a233469e60c31c362143d5c37c3c9f908b7bca79 40559 src/main/ipc.cjs 79593a28b8f40f48a73028be34464f94be2e4c67a027a3f5da78a33bcadc76eb 45539 src/main/ipc.cjs
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs 62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs 958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs
3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs 3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs
e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508 src/main/repository-monitor.cjs e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508 src/main/repository-monitor.cjs
17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs 17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs
b31a63bf8cb1807b3e838e2bf8a0e742738f119d13de8ca9f42e471f072217d3 8328 src/main/ssh-service.cjs b31a63bf8cb1807b3e838e2bf8a0e742738f119d13de8ca9f42e471f072217d3 8328 src/main/ssh-service.cjs
720103f14cbedd7fd2776e49fd970a634f14e03d548d90bf93bcd878b6b3c674 58758 src/main/unraid-deployment-service.cjs 8cf5013de91737dd9345b121586ceec38a6fc8518648975d60593d81fc225c4b 67376 src/main/unraid-deployment-service.cjs
36cc05deda3395e5e9de92b880c8315f508cb88e9b080ae34705057ae696804f 20696 src/main/update-service.cjs 36cc05deda3395e5e9de92b880c8315f508cb88e9b080ae34705057ae696804f 20696 src/main/update-service.cjs
2df4cd7b7d685871e40ce86ce4f75d8451cfdfca6184ce6cae5eaa6651ce97da 191018 src/renderer/app.js 982039f1243c3e48b589b8ad6f8fe712b5b8dde3d23f6f171ad31b09ff1a70c2 192055 src/renderer/app.js
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png
094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png 094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png
e1c463d6cda9f2b9b78c468845c0a7e8688f0362be5642074a1a5f7122dfe811 762 src/renderer/index.html e1c463d6cda9f2b9b78c468845c0a7e8688f0362be5642074a1a5f7122dfe811 762 src/renderer/index.html
d72bca0e649392dbf5c0e9c676dd08b80d5c9f4493f59a32d9201763c139b690 50942 src/renderer/mock-bridge.js 68e565f0dc3d8a7fbce0d57fc55535c0fe022fd96ebd716d1adf279f995b0256 50922 src/renderer/mock-bridge.js
51f6777fd7d2dc73dc3ddd96c91a11882483099b0a62e0ce172e25e3dce474a6 59851 src/renderer/styles.css 09c832aaea5994558eddcd17b20e7edd6840df5fc80e50354c627c41989711d9 63138 src/renderer/styles.css
0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs 0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs
5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs 5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs
029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs 029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs
@@ -131,7 +133,7 @@ e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690
caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs
96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs 96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs
c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 3718 tests/preflight.test.mjs c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 3718 tests/preflight.test.mjs
9b56c259cbf6c45c671267343c0871d56ac336082c531050b44fbcfca7476f62 6989 tests/renderer-workflow.test.mjs 128a3b87c26db9edd4413d6ab8c06bdfada311d2135d342ea298ebea89e40712 7061 tests/renderer-workflow.test.mjs
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs 2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs
f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 1068 tests/repository-monitor.test.mjs f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 1068 tests/repository-monitor.test.mjs
75b5b83836c75675bb9a48fe4363fcb8a24fc425e6af6f822d7955c6f3c79eac 2265 tests/repository-service.test.mjs 75b5b83836c75675bb9a48fe4363fcb8a24fc425e6af6f822d7955c6f3c79eac 2265 tests/repository-service.test.mjs
@@ -139,7 +141,7 @@ d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463
bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs
020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs 020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs 8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs
54f641103a91d98974c41a3c0a568c617b76fa9913a0e20710cd11adc39b0deb 18092 tests/unraid-deployment.test.mjs 05c791ea262aef85a7c79874ed900c5d792a78778c577cb803b88a433cd6e2b6 21413 tests/unraid-deployment.test.mjs
0c49d3222ea362dbef171f5ad556a335bad670b47adef660ede101d84814d05e 14849 tests/update-service.test.mjs 0c49d3222ea362dbef171f5ad556a335bad670b47adef660ede101d84814d05e 14849 tests/update-service.test.mjs
9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs 9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs
7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs 7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs
+13
View File
@@ -0,0 +1,13 @@
# ForgeFlow 0.8.3
ForgeFlow 0.8.3 refreshes the complete light appearance with richer surfaces,
subtle color, clearer depth and stronger active states.
Deployment cards now lead with the exact container identity, repository,
environment and a stable visual accent. Live and Gitea commits are shown side by
side, with an explicit confirmation when both sources agree.
Deployment reconciliation now revisits stale failed records. When the requested
commit is healthy on Unraid it is corrected to success. When a newer commit is
both live and current on Gitea, the old failure is marked as superseded instead
of remaining an apparently current incident.
+2
View File
@@ -40,6 +40,8 @@ $env:FORGEFLOW_USER_DATA = "$env:APPDATA\forgeflow"
npm run release:binary npm run release:binary
``` ```
Every platform build finishes by removing ForgeFlow artifacts for older versions from `dist`. The unpacked application directory and builder diagnostics are kept.
The publisher refuses to upload when local `HEAD` differs from `origin/main`. Users on 0.8.1 or older need one manual 0.8.2 installation because those versions deliberately disabled packaged updates. The publisher refuses to upload when local `HEAD` differs from `origin/main`. Users on 0.8.1 or older need one manual 0.8.2 installation because those versions deliberately disabled packaged updates.
## Publishing a release from Downloads ## Publishing a release from Downloads
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "forgeflow", "name": "forgeflow",
"version": "0.8.2", "version": "0.8.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "forgeflow", "name": "forgeflow",
"version": "0.8.2", "version": "0.8.3",
"dependencies": { "dependencies": {
"ssh2": "1.17.0" "ssh2": "1.17.0"
}, },
+6 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "forgeflow", "name": "forgeflow",
"version": "0.8.2", "version": "0.8.3",
"private": true, "private": true,
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.", "description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
"main": "main.cjs", "main": "main.cjs",
@@ -11,9 +11,9 @@
"demo": "node scripts/serve-demo.mjs", "demo": "node scripts/serve-demo.mjs",
"test": "node --test tests/*.test.mjs", "test": "node --test tests/*.test.mjs",
"verify": "node scripts/verify.mjs", "verify": "node scripts/verify.mjs",
"dist:win": "electron-builder --win nsis portable && node scripts/write-release-checksums.mjs", "dist:win": "electron-builder --win nsis portable && node scripts/write-release-checksums.mjs && node scripts/prune-dist.mjs",
"dist:linux": "electron-builder --linux AppImage", "dist:linux": "electron-builder --linux AppImage && node scripts/prune-dist.mjs",
"dist:mac": "electron-builder --mac dmg", "dist:mac": "electron-builder --mac dmg && node scripts/prune-dist.mjs",
"doctor": "node scripts/doctor.mjs", "doctor": "node scripts/doctor.mjs",
"acceptance": "node scripts/acceptance.mjs", "acceptance": "node scripts/acceptance.mjs",
"connections:check": "electron scripts/validate-installed-connections.cjs", "connections:check": "electron scripts/validate-installed-connections.cjs",
@@ -52,6 +52,7 @@
"docs/UPDATING.md", "docs/UPDATING.md",
"scripts/apply-source-update.ps1", "scripts/apply-source-update.ps1",
"scripts/apply-binary-update.ps1", "scripts/apply-binary-update.ps1",
"scripts/prune-dist.mjs",
"docs/RELEASE_NOTES_0.4.0.md", "docs/RELEASE_NOTES_0.4.0.md",
"docs/LUMAOPS_SERVER_AUDIT.md", "docs/LUMAOPS_SERVER_AUDIT.md",
"docs/SSH_UNRAID_DEPLOYMENT.md", "docs/SSH_UNRAID_DEPLOYMENT.md",
@@ -74,6 +75,7 @@
"docs/RELEASE_NOTES_0.8.0.md", "docs/RELEASE_NOTES_0.8.0.md",
"docs/RELEASE_NOTES_0.8.1.md", "docs/RELEASE_NOTES_0.8.1.md",
"docs/RELEASE_NOTES_0.8.2.md", "docs/RELEASE_NOTES_0.8.2.md",
"docs/RELEASE_NOTES_0.8.3.md",
"docs/ACCEPTANCE.md" "docs/ACCEPTANCE.md"
], ],
"asarUnpack": [ "asarUnpack": [
+41
View File
@@ -0,0 +1,41 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const projectRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
);
const distDirectory = path.join(projectRoot, "dist");
const manifest = JSON.parse(
await fs.readFile(path.join(projectRoot, "package.json"), "utf8"),
);
const currentVersion = String(manifest.version || "").trim();
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(currentVersion)) {
throw new Error("package.json contains an invalid release version.");
}
const entries = await fs
.readdir(distDirectory, { withFileTypes: true })
.catch((error) => {
if (error.code === "ENOENT") return [];
throw error;
});
const removed = [];
for (const entry of entries) {
if (!entry.isFile() || !entry.name.startsWith("ForgeFlow-")) continue;
if (entry.name.includes(`-${currentVersion}-`)) continue;
await fs.rm(path.join(distDirectory, entry.name), { force: true });
removed.push(entry.name);
}
if (removed.length) {
console.log(`Removed ${removed.length} obsolete dist artifact(s):`);
for (const name of removed) console.log(`- ${name}`);
} else {
console.log(
`No ForgeFlow dist artifacts older than ${currentVersion} found.`,
);
}
+5 -3
View File
@@ -46,6 +46,7 @@ const required = [
"scripts/validate-installed-connections.cjs", "scripts/validate-installed-connections.cjs",
"scripts/publish-binary-release.cjs", "scripts/publish-binary-release.cjs",
"scripts/write-release-checksums.mjs", "scripts/write-release-checksums.mjs",
"scripts/prune-dist.mjs",
"scripts/generate-source-manifest.mjs", "scripts/generate-source-manifest.mjs",
"setup-windows.ps1", "setup-windows.ps1",
"START-FORGEFLOW-OVERLAY.ps1", "START-FORGEFLOW-OVERLAY.ps1",
@@ -62,6 +63,7 @@ const required = [
"docs/RELEASE_NOTES_0.8.0.md", "docs/RELEASE_NOTES_0.8.0.md",
"docs/RELEASE_NOTES_0.8.1.md", "docs/RELEASE_NOTES_0.8.1.md",
"docs/RELEASE_NOTES_0.8.2.md", "docs/RELEASE_NOTES_0.8.2.md",
"docs/RELEASE_NOTES_0.8.3.md",
"docs/UPDATING.md", "docs/UPDATING.md",
"docs/DIAGNOSTICS.md", "docs/DIAGNOSTICS.md",
"docs/DEPLOYMENT_SETUP.md", "docs/DEPLOYMENT_SETUP.md",
@@ -100,9 +102,9 @@ for (const file of required) await access(path.join(root, file));
const packageJson = JSON.parse( const packageJson = JSON.parse(
await readFile(path.join(root, "package.json"), "utf8"), await readFile(path.join(root, "package.json"), "utf8"),
); );
if (packageJson.version !== "0.8.2") if (packageJson.version !== "0.8.3")
throw new Error( throw new Error(
`Expected package version 0.8.2, got ${packageJson.version}.`, `Expected package version 0.8.3, got ${packageJson.version}.`,
); );
const sourceManifest = await readFile( const sourceManifest = await readFile(
path.join(root, "SOURCE_MANIFEST.txt"), path.join(root, "SOURCE_MANIFEST.txt"),
@@ -367,7 +369,7 @@ for (const phrase of [
"deriveDetectedProfile", "deriveDetectedProfile",
"docker inspect", "docker inspect",
"net.unraid.docker.managed", "net.unraid.docker.managed",
"'dockerman'", "dockerman",
"iconCacheRefresh", "iconCacheRefresh",
"[PORT:", "[PORT:",
"Superseded by live commit", "Superseded by live commit",
+921 -268
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21 -1
View File
@@ -852,6 +852,20 @@ function dockerManIntegration(profile) {
}; };
} }
function deploymentIdentity(profile, repository) {
const name = String(
profile.state?.containerName ||
profile.containerName ||
profile.remoteFolder ||
repository.name ||
"container",
);
let hash = 0;
for (const character of name)
hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
return { name, initial: name.slice(0, 1).toUpperCase(), accent: hash % 6 };
}
function renderProfileCard(repository, profile, compact = false) { function renderProfileCard(repository, profile, compact = false) {
const state = profile.state || {}; const state = profile.state || {};
const health = environmentState(profile); const health = environmentState(profile);
@@ -868,7 +882,13 @@ function renderProfileCard(repository, profile, compact = false) {
const dockerManReady = dockerMan.ready; const dockerManReady = dockerMan.ready;
const webUi = const webUi =
profile.webUiUrl || state.webUiUrl || state.dockerMan?.webUi || ""; profile.webUiUrl || state.webUiUrl || state.dockerMan?.webUi || "";
return `<article class="deploy-card ${compact ? "compact-card" : ""}"><div class="deploy-card-header"><div><div class="eyebrow">${escapeHtml(profile.environment)}</div><h3>${escapeHtml(profile.name)}</h3><p>${escapeHtml(providerDetail)}</p></div><span class="status-pill ${health.tone}"><span class="state-dot ${health.tone}"></span>${health.label}</span></div><div class="deploy-card-body"><div class="deploy-metadata"><span>Provider</span><strong>${isSsh ? "SSH / Unraid" : "Gitea Actions"}</strong><span>Live version</span><strong>${state.liveSha ? shortSha(state.liveSha) : "Unknown"}</strong><span>Previous version</span><strong>${state.previousSha ? shortSha(state.previousSha) : "Unknown"}</strong><span>Last checked</span><strong>${state.checkedAt ? formatDate(state.checkedAt) : "Never"}</strong>${isSsh ? `<span>Container</span><strong>${escapeHtml(state.containerName || profile.containerName || profile.remoteFolder || repository.name)}${state.containerRunning === false ? " · stopped" : state.containerRunning ? " · running" : ""}</strong><span>DockerMan</span><strong class="${dockerManReady ? "text-success" : "text-warning"}">${dockerManReady ? (templateReady ? "Labels/template active" : "WebUI/icon labels active") : `WebUI ${webUiReady ? "ready" : "missing"} · icon ${iconReady ? "ready" : "missing"}`}</strong>` : ""}<span>Rollback</span><strong>${rollbackConfigured ? "Available after first deploy" : "Not configured"}</strong></div><div class="card-actions"><button class="button" data-action="run-deployment-preflight" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Preflight</button><button class="button" data-action="reconcile-deployment" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("refresh")}Reconcile</button>${webUi ? `<button class="button" data-action="open-profile-webui" data-url="${attr(webUi)}">${icon("external")}Open Web UI</button>` : ""}${isSsh ? `<button class="button ${dockerManReady ? "ghost" : ""}" data-action="apply-dockerman-metadata" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}${dockerManReady ? "Reapply DockerMan metadata" : "Repair DockerMan integration"}</button>` : ""}${ready ? `<button class="button primary" data-action="deploy-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("rocket")}Deploy ${escapeHtml(repository.localStatus.shortHead)}</button>` : ""}<button class="button ghost" data-action="edit-deployment-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">Edit</button>${state.previousSha && rollbackConfigured ? `<button class="button danger" data-action="rollback-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("undo")}Rollback</button>` : ""}</div></div></article>`; const identity = deploymentIdentity(profile, repository);
const syncLabel = state.matchesGitea
? `<span class="sync-proof success">${icon("check")}Live = Gitea · ${shortSha(state.liveSha)}</span>`
: state.giteaSha && state.liveSha
? `<span class="sync-proof warning">Live ${shortSha(state.liveSha)} · Gitea ${shortSha(state.giteaSha)}</span>`
: "";
return `<article class="deploy-card accent-${identity.accent} ${compact ? "compact-card" : ""}"><div class="container-identity"><span class="container-avatar">${escapeHtml(identity.initial)}</span><div><span>Container</span><strong>${escapeHtml(identity.name)}</strong><small>${escapeHtml(repository.fullName)} · ${escapeHtml(profile.environment)}</small></div>${syncLabel}</div><div class="deploy-card-header"><div><div class="eyebrow">${escapeHtml(isSsh ? "SSH / UNRAID" : "GITEA ACTIONS")}</div><h3>${escapeHtml(profile.name)}</h3><p>${escapeHtml(providerDetail)}</p></div><span class="status-pill ${health.tone}"><span class="state-dot ${health.tone}"></span>${health.label}</span></div><div class="deploy-card-body"><div class="deploy-metadata"><span>Live commit</span><strong>${state.liveSha ? shortSha(state.liveSha) : "Unknown"}</strong><span>Gitea commit</span><strong>${state.giteaSha ? shortSha(state.giteaSha) : "Refresh to compare"}</strong><span>Previous version</span><strong>${state.previousSha ? shortSha(state.previousSha) : "Unknown"}</strong><span>Last checked</span><strong>${state.checkedAt ? formatDate(state.checkedAt) : "Never"}</strong>${isSsh ? `<span>Runtime</span><strong>${state.containerRunning === false ? "Stopped" : state.containerRunning ? "Running" : "Unknown"}</strong><span>DockerMan</span><strong class="${dockerManReady ? "text-success" : "text-warning"}">${dockerManReady ? (templateReady ? "Labels/template active" : "WebUI/icon labels active") : `WebUI ${webUiReady ? "ready" : "missing"} · icon ${iconReady ? "ready" : "missing"}`}</strong>` : ""}<span>Rollback</span><strong>${rollbackConfigured ? "Available after first deploy" : "Not configured"}</strong></div><div class="card-actions"><button class="button" data-action="run-deployment-preflight" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Preflight</button><button class="button" data-action="reconcile-deployment" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("refresh")}Refresh truth</button>${webUi ? `<button class="button" data-action="open-profile-webui" data-url="${attr(webUi)}">${icon("external")}Open Web UI</button>` : ""}${isSsh ? `<button class="button ${dockerManReady ? "ghost" : ""}" data-action="apply-dockerman-metadata" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}${dockerManReady ? "Reapply DockerMan metadata" : "Repair DockerMan integration"}</button>` : ""}${ready ? `<button class="button primary" data-action="deploy-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("rocket")}Deploy ${escapeHtml(repository.localStatus.shortHead)}</button>` : ""}<button class="button ghost" data-action="edit-deployment-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">Edit</button>${state.previousSha && rollbackConfigured ? `<button class="button danger" data-action="rollback-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("undo")}Rollback</button>` : ""}</div></div></article>`;
} }
function renderRepositoryDeployments(repository) { function renderRepositoryDeployments(repository) {
+1 -1
View File
@@ -564,7 +564,7 @@
await wait(80); await wait(80);
snapshot(); snapshot();
return { return {
appVersion: "0.8.2-demo", appVersion: "0.8.3-demo",
platform: "win32", platform: "win32",
state: clone(state), state: clone(state),
git: { available: true, version: "git version 2.47.3" }, git: { available: true, version: "git version 2.47.3" },
+148 -8
View File
@@ -32,27 +32,67 @@
html[data-theme="light"] { html[data-theme="light"] {
color-scheme: light; color-scheme: light;
--bg: #eef2f7; --bg: #e9eef7;
--surface-0: #f7f9fc; --surface-0: #f2f6fc;
--surface-1: #ffffff; --surface-1: #ffffff;
--surface-2: #f3f6fa; --surface-2: #f3f6fa;
--surface-3: #e8edf4; --surface-3: #e8edf4;
--surface-hover: #edf2f8; --surface-hover: #edf2f8;
--line: #cdd5e1; --line: #c5d0df;
--line-soft: #e1e6ee; --line-soft: #dce4ef;
--text: #172033; --text: #172033;
--text-muted: #526078; --text-muted: #526078;
--text-faint: #7b879a; --text-faint: #7b879a;
--primary: #295fca; --primary: #2857bf;
--primary-strong: #326ee0; --primary-strong: #346ee8;
--primary-soft: rgba(50, 110, 224, 0.1); --primary-soft: rgba(52, 110, 232, 0.13);
--success: #087a57; --success: #087a57;
--success-soft: rgba(8, 122, 87, 0.1); --success-soft: rgba(8, 122, 87, 0.1);
--warning: #9b5b00; --warning: #9b5b00;
--warning-soft: rgba(155, 91, 0, 0.1); --warning-soft: rgba(155, 91, 0, 0.1);
--danger: #c73737; --danger: #c73737;
--danger-soft: rgba(199, 55, 55, 0.1); --danger-soft: rgba(199, 55, 55, 0.1);
--shadow: 0 18px 70px rgba(43, 55, 77, 0.15); --shadow: 0 18px 54px rgba(31, 55, 94, 0.14);
}
html[data-theme="light"] body,
html[data-theme="light"] .app-shell {
background:
radial-gradient(
circle at 82% 2%,
rgba(67, 123, 235, 0.13),
transparent 31%
),
radial-gradient(
circle at 20% 100%,
rgba(31, 170, 141, 0.08),
transparent 33%
),
var(--bg);
}
html[data-theme="light"] .titlebar,
html[data-theme="light"] .sidebar {
background: rgba(250, 252, 255, 0.92);
backdrop-filter: blur(18px);
}
html[data-theme="light"] .panel,
html[data-theme="light"] .summary-card,
html[data-theme="light"] .deploy-card,
html[data-theme="light"] .settings-group,
html[data-theme="light"] .pipeline-card {
border-color: rgba(151, 169, 197, 0.48);
box-shadow: 0 8px 28px rgba(49, 75, 116, 0.08);
}
html[data-theme="light"] .nav-button.active {
background: linear-gradient(
105deg,
rgba(52, 110, 232, 0.16),
rgba(48, 181, 151, 0.08)
);
box-shadow: inset 3px 0 var(--primary-strong);
}
html[data-theme="light"] .button.primary {
box-shadow: 0 7px 18px rgba(52, 110, 232, 0.22);
} }
* { * {
@@ -1309,10 +1349,110 @@ html[data-theme="light"] .diff-line.remove {
gap: 12px; gap: 12px;
} }
.deploy-card { .deploy-card {
--card-accent: var(--primary-strong);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--surface-1); background: var(--surface-1);
overflow: hidden; overflow: hidden;
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.1);
transition:
transform 160ms ease,
border-color 160ms ease,
box-shadow 160ms ease;
}
.deploy-card:hover {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--card-accent) 42%, var(--line));
box-shadow: 0 16px 42px
color-mix(in srgb, var(--card-accent) 12%, transparent);
}
.deploy-card.accent-0 {
--card-accent: #4d7df3;
}
.deploy-card.accent-1 {
--card-accent: #8b5cf6;
}
.deploy-card.accent-2 {
--card-accent: #0ea5a0;
}
.deploy-card.accent-3 {
--card-accent: #e2783f;
}
.deploy-card.accent-4 {
--card-accent: #d24e83;
}
.deploy-card.accent-5 {
--card-accent: #4b9b55;
}
.container-identity {
min-height: 78px;
display: flex;
align-items: center;
gap: 12px;
padding: 13px 14px;
border-top: 4px solid var(--card-accent);
border-bottom: 1px solid var(--line-soft);
background: linear-gradient(
110deg,
color-mix(in srgb, var(--card-accent) 15%, var(--surface-1)),
var(--surface-1) 68%
);
}
.container-avatar {
width: 44px;
height: 44px;
flex: 0 0 44px;
display: grid;
place-items: center;
border-radius: 12px;
color: #fff;
background: linear-gradient(
145deg,
color-mix(in srgb, var(--card-accent) 72%, #fff),
var(--card-accent)
);
box-shadow: 0 8px 20px color-mix(in srgb, var(--card-accent) 28%, transparent);
font-size: 19px;
font-weight: 780;
}
.container-identity > div {
min-width: 0;
}
.container-identity span:not(.container-avatar):not(.sync-proof),
.container-identity small {
display: block;
color: var(--text-faint);
font-size: 10px;
}
.container-identity strong {
display: block;
margin: 2px 0 3px;
overflow: hidden;
font-size: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.sync-proof {
margin-left: auto;
padding: 6px 8px;
border-radius: 6px;
background: var(--surface-2);
font-size: 10px;
font-weight: 700;
white-space: nowrap;
}
.sync-proof.success {
color: var(--success);
background: var(--success-soft);
}
.sync-proof.warning {
color: var(--warning);
background: var(--warning-soft);
}
.sync-proof .icon {
width: 12px;
height: 12px;
vertical-align: -2px;
} }
.deploy-card-header { .deploy-card-header {
display: flex; display: flex;
+4 -4
View File
@@ -179,13 +179,13 @@ test("one-click troubleshooting excludes destructive or publishing Git actions",
new URL("../src/main/ipc.cjs", import.meta.url), new URL("../src/main/ipc.cjs", import.meta.url),
"utf8", "utf8",
); );
assert.match(ipc, /action: 'abort-operation', safe: false/); assert.match(ipc, /action:\s*["']abort-operation["'],\s*safe:\s*false/);
assert.match(ipc, /action: 'push', safe: false/); assert.match(ipc, /action:\s*["']push["'],\s*safe:\s*false/);
assert.match(ipc, /const stale = lock\.ageMs >= 10_000/); assert.match(ipc, /const stale = lock\.ageMs >= 10_000/);
assert.match(ipc, /\['fast-forward', 'fetch'\]\.includes\(issue\.action\)/); assert.match(ipc, /\[\s*["']fast-forward["'],\s*["']fetch["']\s*\]\.includes\(issue\.action\)/);
assert.doesNotMatch( assert.doesNotMatch(
ipc, ipc,
/\['fast-forward', 'push', 'fetch'\]\.includes\(issue\.action\)/, /\[\s*["']fast-forward["'],\s*["']push["'],\s*["']fetch["']\s*\]\.includes\(issue\.action\)/,
); );
assert.match( assert.match(
renderer, renderer,
+538 -174
View File
@@ -1,247 +1,484 @@
import test from 'node:test'; import test from "node:test";
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import { createRequire } from 'node:module'; import { createRequire } from "node:module";
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const { UnraidDeploymentService, safeRemoteFolder, safeRelativeRemoteFile, parseInspection, dockerIgnoreHasPath, checksSummary, xmlEscape, bash } = require('../src/main/unraid-deployment-service.cjs'); const {
const { fingerprintKey, shellQuote } = require('../src/main/ssh-service.cjs'); UnraidDeploymentService,
safeRemoteFolder,
safeRelativeRemoteFile,
parseInspection,
dockerIgnoreHasPath,
checksSummary,
xmlEscape,
bash,
} = require("../src/main/unraid-deployment-service.cjs");
const { fingerprintKey, shellQuote } = require("../src/main/ssh-service.cjs");
test('Unraid remote paths cannot escape appdata project folder', () => { test("Unraid remote paths cannot escape appdata project folder", () => {
assert.equal(safeRemoteFolder('lumaops'), 'lumaops'); assert.equal(safeRemoteFolder("lumaops"), "lumaops");
assert.throws(() => safeRemoteFolder('../lumaops')); assert.throws(() => safeRemoteFolder("../lumaops"));
assert.equal(safeRelativeRemoteFile('deploy/docker-compose.yml'), 'deploy/docker-compose.yml'); assert.equal(
assert.throws(() => safeRelativeRemoteFile('../../etc/passwd')); safeRelativeRemoteFile("deploy/docker-compose.yml"),
"deploy/docker-compose.yml",
);
assert.throws(() => safeRelativeRemoteFile("../../etc/passwd"));
}); });
test('server inspection key-value payload is decoded safely', () => { test("server inspection key-value payload is decoded safely", () => {
const b64 = (value) => Buffer.from(value).toString('base64'); const b64 = (value) => Buffer.from(value).toString("base64");
const parsed = parseInspection(`noise\n__FORGEFLOW_KV__\nexists=true\nrootGit=true\nhead=${'a'.repeat(40)}\nbranch=main\nremote=${b64('ssh://git@gitea/Jens/LumaOps.git')}\ntrackedChanges=${b64(' M docker-compose.yml\n')}\ncomposeFiles=${b64('docker-compose.yml\n')}\nnestedGit=${b64('source\n')}\ndockerfile=true\ndockerignoreContent=${b64('.git\ndata/\n')}\nexistingPreservePaths=${b64('data\nlogs\n')}\n`); const parsed = parseInspection(
`noise\n__FORGEFLOW_KV__\nexists=true\nrootGit=true\nhead=${"a".repeat(40)}\nbranch=main\nremote=${b64("ssh://git@gitea/Jens/LumaOps.git")}\ntrackedChanges=${b64(" M docker-compose.yml\n")}\ncomposeFiles=${b64("docker-compose.yml\n")}\nnestedGit=${b64("source\n")}\ndockerfile=true\ndockerignoreContent=${b64(".git\ndata/\n")}\nexistingPreservePaths=${b64("data\nlogs\n")}\n`,
);
assert.equal(parsed.rootGit, true); assert.equal(parsed.rootGit, true);
assert.deepEqual(parsed.composeFiles, ['docker-compose.yml']); assert.deepEqual(parsed.composeFiles, ["docker-compose.yml"]);
assert.deepEqual(parsed.nestedGit, ['source']); assert.deepEqual(parsed.nestedGit, ["source"]);
assert.equal(parsed.trackedChanges.length, 1); assert.equal(parsed.trackedChanges.length, 1);
assert.match(parsed.dockerignoreContent, /\.git/); assert.match(parsed.dockerignoreContent, /\.git/);
assert.deepEqual(parsed.existingPreservePaths, ['data', 'logs']); assert.deepEqual(parsed.existingPreservePaths, ["data", "logs"]);
}); });
test('Docker ignore checks identify exact runtime and Git context exclusions', () => { test("Docker ignore checks identify exact runtime and Git context exclusions", () => {
const rules = '# build context\n.git\ndata/\nlogs/**\n!logs/keep.txt\n'; const rules = "# build context\n.git\ndata/\nlogs/**\n!logs/keep.txt\n";
assert.equal(dockerIgnoreHasPath(rules, '.git'), true); assert.equal(dockerIgnoreHasPath(rules, ".git"), true);
assert.equal(dockerIgnoreHasPath(rules, 'data'), true); assert.equal(dockerIgnoreHasPath(rules, "data"), true);
assert.equal(dockerIgnoreHasPath(rules, 'logs'), true); assert.equal(dockerIgnoreHasPath(rules, "logs"), true);
assert.equal(dockerIgnoreHasPath(rules, 'source'), false); assert.equal(dockerIgnoreHasPath(rules, "source"), false);
}); });
test('server inspection detects preserved runtime paths and missing Docker context exclusions', async () => { test("server inspection detects preserved runtime paths and missing Docker context exclusions", async () => {
const b64 = (value) => Buffer.from(value).toString('base64'); const b64 = (value) => Buffer.from(value).toString("base64");
let receivedCommand = ''; let receivedCommand = "";
const store = { const store = {
getDeploymentProfile: () => ({ getDeploymentProfile: () => ({
id: 'production', id: "production",
provider: 'ssh-unraid', provider: "ssh-unraid",
serverId: 'unraid', serverId: "unraid",
remoteFolder: 'lumaops', remoteFolder: "lumaops",
preservePaths: ['data', 'logs'] preservePaths: ["data", "logs"],
}), }),
getServer: () => ({ getServer: () => ({
id: 'unraid', id: "unraid",
name: 'Unraid', name: "Unraid",
basePath: '/mnt/user/appdata' basePath: "/mnt/user/appdata",
}) }),
}; };
const ssh = { const ssh = {
exec: async (_serverId, command) => { exec: async (_serverId, command) => {
receivedCommand = command; receivedCommand = command;
return { return {
stdout: `__FORGEFLOW_KV__\nexists=true\nrootGit=true\nhead=${'a'.repeat(40)}\nbranch=main\nremote=${b64('ssh://git@gitea/Jens/LumaOps.git')}\ntrackedChanges=\ncomposeFiles=${b64('docker-compose.yml\n')}\nnestedGit=${b64('source\n')}\ndockerfile=true\ndockerignoreContent=${b64('.git\ndata/\n')}\nexistingPreservePaths=${b64('data\nlogs\n')}\n`, stdout: `__FORGEFLOW_KV__\nexists=true\nrootGit=true\nhead=${"a".repeat(40)}\nbranch=main\nremote=${b64("ssh://git@gitea/Jens/LumaOps.git")}\ntrackedChanges=\ncomposeFiles=${b64("docker-compose.yml\n")}\nnestedGit=${b64("source\n")}\ndockerfile=true\ndockerignoreContent=${b64(".git\ndata/\n")}\nexistingPreservePaths=${b64("data\nlogs\n")}\n`,
stderr: '', stderr: "",
exitCode: 0 exitCode: 0,
}; };
} },
}; };
const service = new UnraidDeploymentService({ store, ssh, git: {}, diagnostics: null }); const service = new UnraidDeploymentService({
const inspection = await service.inspect({ repository: { fullName: 'Jens/LumaOps', name: 'LumaOps' }, profileId: 'production' }); store,
ssh,
git: {},
diagnostics: null,
});
const inspection = await service.inspect({
repository: { fullName: "Jens/LumaOps", name: "LumaOps" },
profileId: "production",
});
assert.match(receivedCommand, /base64 -d \| bash$/); assert.match(receivedCommand, /base64 -d \| bash$/);
assert.equal(inspection.remotePath, '/mnt/user/appdata/lumaops'); assert.equal(inspection.remotePath, "/mnt/user/appdata/lumaops");
assert.equal(inspection.dockerignoreGitExcluded, true); assert.equal(inspection.dockerignoreGitExcluded, true);
assert.deepEqual(inspection.existingPreservePaths.sort(), ['data', 'logs']); assert.deepEqual(inspection.existingPreservePaths.sort(), ["data", "logs"]);
assert.deepEqual(inspection.dockerContextExclusionsMissing.sort(), ['logs', 'source']); assert.deepEqual(inspection.dockerContextExclusionsMissing.sort(), [
"logs",
"source",
]);
}); });
test('preflight summary blocks only failed checks', () => { test("preflight summary blocks only failed checks", () => {
const result = checksSummary([{ id: 'a', status: 'pass' }, { id: 'b', status: 'warning' }, { id: 'c', status: 'fail' }]); const result = checksSummary([
{ id: "a", status: "pass" },
{ id: "b", status: "warning" },
{ id: "c", status: "fail" },
]);
assert.equal(result.ready, false); assert.equal(result.ready, false);
assert.deepEqual(result.blocking, ['c']); assert.deepEqual(result.blocking, ["c"]);
}); });
test('SSH helpers produce pinned fingerprints and quoted commands', () => { test("SSH helpers produce pinned fingerprints and quoted commands", () => {
assert.match(fingerprintKey(Buffer.from('host-key')), /^SHA256:/); assert.match(fingerprintKey(Buffer.from("host-key")), /^SHA256:/);
assert.equal(shellQuote("a'b"), "'a'\\''b'"); assert.equal(shellQuote("a'b"), "'a'\\''b'");
const wrapped = bash('git fetch origin main'); const wrapped = bash("git fetch origin main");
assert.match(wrapped, /base64 -d \| bash$/); assert.match(wrapped, /base64 -d \| bash$/);
assert.equal(wrapped.includes('\n'), false); assert.equal(wrapped.includes("\n"), false);
const encoded = wrapped.match(/printf '%s' '([A-Za-z0-9+/=]+)'/)[1]; const encoded = wrapped.match(/printf '%s' '([A-Za-z0-9+/=]+)'/)[1];
const decoded = Buffer.from(encoded, 'base64').toString('utf8'); const decoded = Buffer.from(encoded, "base64").toString("utf8");
assert.match(decoded, /GIT_TERMINAL_PROMPT=0/); assert.match(decoded, /GIT_TERMINAL_PROMPT=0/);
assert.match(decoded, /BatchMode=yes/); assert.match(decoded, /BatchMode=yes/);
assert.match(decoded, /git fetch origin main/); assert.match(decoded, /git fetch origin main/);
}); });
test('SSH rollback refuses any SHA other than the exact recorded previous deployment', async () => { test("SSH rollback refuses any SHA other than the exact recorded previous deployment", async () => {
const previousSha = 'a'.repeat(40); const previousSha = "a".repeat(40);
const store = { const store = {
getDeploymentProfile: () => ({ id: 'production', provider: 'ssh-unraid', serverId: 'unraid', remoteFolder: 'lumaops', composeFile: 'docker-compose.yml', branch: 'main', environment: 'production' }), getDeploymentProfile: () => ({
getServer: () => ({ id: 'unraid', basePath: '/mnt/user/appdata' }), id: "production",
getDeploymentState: () => ({ liveSha: 'b'.repeat(40), previousSha }) provider: "ssh-unraid",
serverId: "unraid",
remoteFolder: "lumaops",
composeFile: "docker-compose.yml",
branch: "main",
environment: "production",
}),
getServer: () => ({ id: "unraid", basePath: "/mnt/user/appdata" }),
getDeploymentState: () => ({ liveSha: "b".repeat(40), previousSha }),
}; };
const service = new UnraidDeploymentService({ store, ssh: {}, git: {}, diagnostics: null }); const service = new UnraidDeploymentService({
store,
ssh: {},
git: {},
diagnostics: null,
});
await assert.rejects( await assert.rejects(
service.rollback({ repository: { fullName: 'Jens/LumaOps', name: 'LumaOps', localPath: '/tmp/lumaops' }, profileId: 'production', targetSha: 'c'.repeat(40) }), service.rollback({
(error) => error.code === 'ROLLBACK_TARGET_NOT_PREVIOUS_SHA' repository: {
fullName: "Jens/LumaOps",
name: "LumaOps",
localPath: "/tmp/lumaops",
},
profileId: "production",
targetSha: "c".repeat(40),
}),
(error) => error.code === "ROLLBACK_TARGET_NOT_PREVIOUS_SHA",
); );
}); });
test('successful SSH rollback records the formerly live SHA as the new rollback target', async () => { test("successful SSH rollback records the formerly live SHA as the new rollback target", async () => {
const previousSha = 'a'.repeat(40); const previousSha = "a".repeat(40);
const liveSha = 'b'.repeat(40); const liveSha = "b".repeat(40);
const savedStates = []; const savedStates = [];
const operations = []; const operations = [];
const store = { const store = {
getDeploymentProfile: () => ({ id: 'production', provider: 'ssh-unraid', serverId: 'unraid', remoteFolder: 'lumaops', composeFile: 'docker-compose.yml', branch: 'main', environment: 'production', healthcheckUrl: '', iconMode: 'none' }), getDeploymentProfile: () => ({
getServer: () => ({ id: 'unraid', name: 'Unraid', basePath: '/mnt/user/appdata' }), id: "production",
provider: "ssh-unraid",
serverId: "unraid",
remoteFolder: "lumaops",
composeFile: "docker-compose.yml",
branch: "main",
environment: "production",
healthcheckUrl: "",
iconMode: "none",
}),
getServer: () => ({
id: "unraid",
name: "Unraid",
basePath: "/mnt/user/appdata",
}),
getDeploymentState: () => ({ liveSha, previousSha }), getDeploymentState: () => ({ liveSha, previousSha }),
addOperation: async (operation) => { operations.push(operation); return operation; }, addOperation: async (operation) => {
saveDeploymentState: async (_profileId, state) => { savedStates.push(state); return state; } operations.push(operation);
return operation;
},
saveDeploymentState: async (_profileId, state) => {
savedStates.push(state);
return state;
},
}; };
const git = { verifyCommitOnRemoteBranch: async () => true }; const git = { verifyCommitOnRemoteBranch: async () => true };
const ssh = { exec: async () => ({ stdout: '', stderr: '', exitCode: 0 }) }; const ssh = { exec: async () => ({ stdout: "", stderr: "", exitCode: 0 }) };
const service = new UnraidDeploymentService({ store, ssh, git, diagnostics: null }); const service = new UnraidDeploymentService({
service.inspect = async () => ({ rootGit: true, trackedChanges: [], head: liveSha }); store,
service.checkHealth = async () => ({ configured: false, healthy: null, status: null, latencyMs: null }); ssh,
const result = await service.rollback({ repository: { fullName: 'Jens/LumaOps', name: 'LumaOps', localPath: '/tmp/lumaops' }, profileId: 'production', targetSha: previousSha }); git,
assert.equal(result.status, 'rolled-back'); diagnostics: null,
});
service.inspect = async () => ({
rootGit: true,
trackedChanges: [],
head: liveSha,
});
service.checkHealth = async () => ({
configured: false,
healthy: null,
status: null,
latencyMs: null,
});
const result = await service.rollback({
repository: {
fullName: "Jens/LumaOps",
name: "LumaOps",
localPath: "/tmp/lumaops",
},
profileId: "production",
targetSha: previousSha,
});
assert.equal(result.status, "rolled-back");
assert.equal(savedStates.at(-1).liveSha, previousSha); assert.equal(savedStates.at(-1).liveSha, previousSha);
assert.equal(savedStates.at(-1).previousSha, liveSha); assert.equal(savedStates.at(-1).previousSha, liveSha);
assert.equal(operations.at(-1).previousSha, liveSha); assert.equal(operations.at(-1).previousSha, liveSha);
}); });
test('generated Compose uses a lowercase-safe service while preserving the visible Portfolio container name', () => { test("generated Compose uses a lowercase-safe service while preserving the visible Portfolio container name", () => {
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {}, diagnostics: null }); const service = new UnraidDeploymentService({
const compose = service.generatedCompose({ composeService: 'Portfolio', hostPort: 5150, containerPort: 80 }, { name: 'Portfolio' }); store: {},
ssh: {},
git: {},
diagnostics: null,
});
const compose = service.generatedCompose(
{ composeService: "Portfolio", hostPort: 5150, containerPort: 80 },
{ name: "Portfolio" },
);
assert.match(compose, / portfolio:/); assert.match(compose, / portfolio:/);
assert.match(compose, /image: forgeflow\/portfolio:production/); assert.match(compose, /image: forgeflow\/portfolio:production/);
assert.match(compose, /container_name: Portfolio/); assert.match(compose, /container_name: Portfolio/);
}); });
test('SSH deployment dispatch returns a running operation while the remote build continues in background', async () => { test("SSH deployment dispatch returns a running operation while the remote build continues in background", async () => {
const sha = 'd'.repeat(40); const sha = "d".repeat(40);
const operations = []; const operations = [];
let resolveRemote; let resolveRemote;
const store = { const store = {
getDeploymentProfile: () => ({ id: 'production', provider: 'ssh-unraid', serverId: 'unraid', remoteFolder: 'Portfolio', cloneUrl: 'forgeflow-gitea:Jens/Portfolio.git', composeFile: 'docker-compose.yml', branch: 'main', environment: 'production', healthcheckUrl: '', iconMode: 'none' }), getDeploymentProfile: () => ({
getServer: () => ({ id: 'unraid', name: 'Unraid', basePath: '/mnt/user/appdata' }), id: "production",
addOperation: async (operation) => { operations.push(structuredClone(operation)); return structuredClone(operation); }, provider: "ssh-unraid",
saveDeploymentState: async () => ({}) serverId: "unraid",
remoteFolder: "Portfolio",
cloneUrl: "forgeflow-gitea:Jens/Portfolio.git",
composeFile: "docker-compose.yml",
branch: "main",
environment: "production",
healthcheckUrl: "",
iconMode: "none",
}),
getServer: () => ({
id: "unraid",
name: "Unraid",
basePath: "/mnt/user/appdata",
}),
addOperation: async (operation) => {
operations.push(structuredClone(operation));
return structuredClone(operation);
},
saveDeploymentState: async () => ({}),
}; };
const ssh = { exec: async () => new Promise((resolve) => { resolveRemote = resolve; }) }; const ssh = {
const service = new UnraidDeploymentService({ store, ssh, git: {}, diagnostics: null }); exec: async () =>
service.preflight = async () => ({ summary: { ready: true, blocking: [] }, inspection: { head: null } }); new Promise((resolve) => {
service.checkHealth = async () => ({ configured: false, healthy: null, status: null, latencyMs: null }); resolveRemote = resolve;
}),
};
const service = new UnraidDeploymentService({
store,
ssh,
git: {},
diagnostics: null,
});
service.preflight = async () => ({
summary: { ready: true, blocking: [] },
inspection: { head: null },
});
service.checkHealth = async () => ({
configured: false,
healthy: null,
status: null,
latencyMs: null,
});
const operation = await service.deploy({ repository: { fullName: 'Jens/Portfolio', name: 'Portfolio' }, profileId: 'production', sha }); const operation = await service.deploy({
assert.equal(operation.status, 'running'); repository: { fullName: "Jens/Portfolio", name: "Portfolio" },
assert.match(operation.logs.join('\n'), /background/i); profileId: "production",
sha,
});
assert.equal(operation.status, "running");
assert.match(operation.logs.join("\n"), /background/i);
resolveRemote({ stdout: 'Container Portfolio started\n', stderr: '', exitCode: 0 }); resolveRemote({
stdout: "Container Portfolio started\n",
stderr: "",
exitCode: 0,
});
await new Promise((resolve) => setTimeout(resolve, 10)); await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(operations.at(-1).status, 'success'); assert.equal(operations.at(-1).status, "success");
}); });
test('Unraid preflight verifies repository access before a deployment can start', async () => { test("Unraid preflight verifies repository access before a deployment can start", async () => {
const source = await import('node:fs/promises').then(({ readFile }) => readFile(new URL('../src/main/unraid-deployment-service.cjs', import.meta.url), 'utf8')); const source = await import("node:fs/promises").then(({ readFile }) =>
readFile(
new URL("../src/main/unraid-deployment-service.cjs", import.meta.url),
"utf8",
),
);
assert.match(source, /server-git-access/); assert.match(source, /server-git-access/);
assert.match(source, /git ls-remote --exit-code/); assert.match(source, /git ls-remote --exit-code/);
assert.match(source, /Unraid → Gitea access/); assert.match(source, /Unraid → Gitea access/);
}); });
test("DockerMan metadata uses dockerman labels, a template WebUI and lowercase-safe service/image names", () => {
test('DockerMan metadata uses dockerman labels, a template WebUI and lowercase-safe service/image names', () => { const service = new UnraidDeploymentService({
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {}, diagnostics: null }); store: {},
const metadata = service.metadataCompose({ ssh: {},
composeService: 'portfolio', containerName: 'Portfolio', remoteFolder: 'Portfolio', git: {},
environment: 'production', hostPort: 5150, webUiUrl: 'http://192.168.10.150:5150/admin', dockerShell: '/bin/sh' diagnostics: null,
}, { name: 'Portfolio' }, 'file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png'); });
const metadata = service.metadataCompose(
{
composeService: "portfolio",
containerName: "Portfolio",
remoteFolder: "Portfolio",
environment: "production",
hostPort: 5150,
webUiUrl: "http://192.168.10.150:5150/admin",
dockerShell: "/bin/sh",
},
{ name: "Portfolio" },
"file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png",
);
assert.match(metadata, / portfolio:/); assert.match(metadata, / portfolio:/);
assert.match(metadata, /image: forgeflow\/portfolio:production/); assert.match(metadata, /image: forgeflow\/portfolio:production/);
assert.match(metadata, /container_name: Portfolio/); assert.match(metadata, /container_name: Portfolio/);
assert.match(metadata, /net\.unraid\.docker\.managed.*dockerman/); assert.match(metadata, /net\.unraid\.docker\.managed.*dockerman/);
assert.match(metadata, /net\.unraid\.docker\.webui.*http:\/\/\[IP\]:\[PORT:5150\]\/admin/); assert.match(
assert.match(metadata, /net\.unraid\.docker\.icon.*file:\/\/\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png/); metadata,
/net\.unraid\.docker\.webui.*http:\/\/\[IP\]:\[PORT:5150\]\/admin/,
);
assert.match(
metadata,
/net\.unraid\.docker\.icon.*file:\/\/\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png/,
);
}); });
test("DockerMan integration writes a persistent template fallback and invalidates cached metadata", () => {
const service = new UnraidDeploymentService({
test('DockerMan integration writes a persistent template fallback and invalidates cached metadata', () => { store: {},
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {}, diagnostics: null }); ssh: {},
git: {},
diagnostics: null,
});
const profile = { const profile = {
composeService: 'portfolio', containerName: 'Portfolio', remoteFolder: 'Portfolio', composeService: "portfolio",
environment: 'production', hostPort: 5150, webUiUrl: 'http://192.168.10.150:5150/', dockerShell: '/bin/sh' containerName: "Portfolio",
remoteFolder: "Portfolio",
environment: "production",
hostPort: 5150,
webUiUrl: "http://192.168.10.150:5150/",
dockerShell: "/bin/sh",
}; };
const repository = { name: 'Portfolio' }; const repository = { name: "Portfolio" };
const icon = 'file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png'; const icon =
"file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png";
const template = service.dockerManTemplate(profile, repository, icon); const template = service.dockerManTemplate(profile, repository, icon);
const refresh = service.dockerManRefreshScript(profile, repository, icon); const refresh = service.dockerManRefreshScript(profile, repository, icon);
assert.match(template, /<Name>Portfolio<\/Name>/); assert.match(template, /<Name>Portfolio<\/Name>/);
assert.match(template, /<Repository>forgeflow\/portfolio:production<\/Repository>/); assert.match(
template,
/<Repository>forgeflow\/portfolio:production<\/Repository>/,
);
assert.match(template, /<WebUI>http:\/\/\[IP\]:\[PORT:5150\]\/<\/WebUI>/); assert.match(template, /<WebUI>http:\/\/\[IP\]:\[PORT:5150\]\/<\/WebUI>/);
assert.match(template, /<Icon>file:\/\/\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png<\/Icon>/); assert.match(
template,
/<Icon>file:\/\/\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png<\/Icon>/,
);
assert.match(refresh, /templates-user\/my-Portfolio\.xml/); assert.match(refresh, /templates-user\/my-Portfolio\.xml/);
assert.match(refresh, /dynamix\.docker\.manager\/docker\.json/); assert.match(refresh, /dynamix\.docker\.manager\/docker\.json/);
assert.match(refresh, /cp '\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png'/); assert.match(
refresh,
/cp '\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png'/,
);
assert.doesNotMatch(refresh, /dockerManRefreshScript/); assert.doesNotMatch(refresh, /dockerManRefreshScript/);
assert.equal(xmlEscape('A&B<"x">'), 'A&amp;B&lt;&quot;x&quot;&gt;'); assert.equal(xmlEscape('A&B<"x">'), "A&amp;B&lt;&quot;x&quot;&gt;");
}); });
test('built-in ITWorx DockerMan icon is uploaded to persistent Unraid storage', async (t) => { test("built-in ITWorx DockerMan icon is uploaded to persistent Unraid storage", async (t) => {
const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises'); const { mkdtemp, mkdir, writeFile, rm } = await import("node:fs/promises");
const os = await import('node:os'); const os = await import("node:os");
const path = await import('node:path'); const path = await import("node:path");
const sourcePath = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-icon-')); const sourcePath = await mkdtemp(path.join(os.tmpdir(), "forgeflow-icon-"));
t.after(() => rm(sourcePath, { recursive: true, force: true })); t.after(() => rm(sourcePath, { recursive: true, force: true }));
const asset = path.join(sourcePath, 'src', 'renderer', 'assets', 'itworx-mark.png'); const asset = path.join(
sourcePath,
"src",
"renderer",
"assets",
"itworx-mark.png",
);
await mkdir(path.dirname(asset), { recursive: true }); await mkdir(path.dirname(asset), { recursive: true });
await writeFile(asset, Buffer.from([137, 80, 78, 71])); await writeFile(asset, Buffer.from([137, 80, 78, 71]));
const uploads = []; const uploads = [];
const service = new UnraidDeploymentService({ const service = new UnraidDeploymentService({
store: {}, git: {}, diagnostics: null, sourcePath, store: {},
ssh: { uploadFile: async (...args) => { uploads.push(args); return {}; } } git: {},
diagnostics: null,
sourcePath,
ssh: {
uploadFile: async (...args) => {
uploads.push(args);
return {};
},
},
}); });
const icon = await service.prepareIcon({ iconMode: 'builtin', remoteFolder: 'Portfolio' }, { name: 'Portfolio' }, { id: 'unraid' }); const icon = await service.prepareIcon(
assert.equal(icon, 'file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png'); { iconMode: "builtin", remoteFolder: "Portfolio" },
{ name: "Portfolio" },
{ id: "unraid" },
);
assert.equal(
icon,
"file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png",
);
assert.equal(uploads.length, 1); assert.equal(uploads.length, 1);
assert.equal(uploads[0][0], 'unraid'); assert.equal(uploads[0][0], "unraid");
assert.equal(uploads[0][1], asset); assert.equal(uploads[0][1], asset);
assert.equal(uploads[0][2], '/boot/config/plugins/dockerMan/images/Portfolio-icon.png'); assert.equal(
uploads[0][2],
"/boot/config/plugins/dockerMan/images/Portfolio-icon.png",
);
}); });
test('stuck SSH deployment is reconciled to success when exact SHA and container health are live', async () => { test("stuck SSH deployment is reconciled to success when exact SHA and container health are live", async () => {
const sha = 'f'.repeat(40); const sha = "f".repeat(40);
const saved = []; const saved = [];
const operation = { id: 'op-1', type: 'deployment', provider: 'ssh-unraid', action: 'deploy', repository: 'Jens/Portfolio', profileId: 'production', sha, status: 'running', logs: [] }; const operation = {
id: "op-1",
type: "deployment",
provider: "ssh-unraid",
action: "deploy",
repository: "Jens/Portfolio",
profileId: "production",
sha,
status: "running",
logs: [],
};
const store = { const store = {
getOperation: () => operation, getOperation: () => operation,
addOperation: async (next) => { saved.push(next); return next; } addOperation: async (next) => {
saved.push(next);
return next;
},
}; };
const service = new UnraidDeploymentService({ store, ssh: {}, git: {}, diagnostics: null }); const service = new UnraidDeploymentService({
service.refreshProfileState = async () => ({ liveSha: sha, containerRunning: true, healthy: true }); store,
const result = await service.refreshOperation('op-1'); ssh: {},
assert.equal(result.status, 'success'); git: {},
diagnostics: null,
});
service.refreshProfileState = async () => ({
liveSha: sha,
containerRunning: true,
healthy: true,
});
const result = await service.refreshOperation("op-1");
assert.equal(result.status, "success");
assert.match(result.logs.at(-1), /reconciled/i); assert.match(result.logs.at(-1), /reconciled/i);
assert.equal(saved.at(-1).status, 'success'); assert.equal(saved.at(-1).status, "success");
}); });
test('DockerMan metadata repair refreshes known Unraid icon caches after container recreation', async () => { test("DockerMan metadata repair refreshes known Unraid icon caches after container recreation", async () => {
const source = await import('node:fs/promises').then(({ readFile }) => readFile(new URL('../src/main/unraid-deployment-service.cjs', import.meta.url), 'utf8')); const source = await import("node:fs/promises").then(({ readFile }) =>
readFile(
new URL("../src/main/unraid-deployment-service.cjs", import.meta.url),
"utf8",
),
);
assert.match(source, /\/var\/lib\/docker\/unraid\/images/); assert.match(source, /\/var\/lib\/docker\/unraid\/images/);
assert.match(source, /dynamix\.docker\.manager\/images/); assert.match(source, /dynamix\.docker\.manager\/images/);
assert.match(source, /-icon\.png/); assert.match(source, /-icon\.png/);
@@ -249,55 +486,182 @@ test('DockerMan metadata repair refreshes known Unraid icon caches after contain
assert.match(source, /--force-recreate/); assert.match(source, /--force-recreate/);
}); });
test("stuck deployment is cleared as superseded when a different healthy commit is already live", async () => {
test('stuck deployment is cleared as superseded when a different healthy commit is already live', async () => { const requested = "a".repeat(40);
const requested = 'a'.repeat(40); const live = "b".repeat(40);
const live = 'b'.repeat(40); const operation = {
const operation = { id: 'op-superseded', type: 'deployment', provider: 'ssh-unraid', action: 'deploy', repository: 'Jens/Portfolio', profileId: 'production', sha: requested, status: 'running', logs: [] }; id: "op-superseded",
type: "deployment",
provider: "ssh-unraid",
action: "deploy",
repository: "Jens/Portfolio",
profileId: "production",
sha: requested,
status: "running",
logs: [],
};
const saved = []; const saved = [];
const service = new UnraidDeploymentService({ const service = new UnraidDeploymentService({
store: { getOperation: () => operation, addOperation: async (next) => { saved.push(next); return next; } }, store: {
ssh: {}, git: {}, diagnostics: null getOperation: () => operation,
addOperation: async (next) => {
saved.push(next);
return next;
},
},
ssh: {},
git: {},
diagnostics: null,
});
service.refreshProfileState = async () => ({
liveSha: live,
containerRunning: true,
healthy: true,
}); });
service.refreshProfileState = async () => ({ liveSha: live, containerRunning: true, healthy: true });
const result = await service.refreshOperation(operation.id); const result = await service.refreshOperation(operation.id);
assert.equal(result.status, 'cancelled'); assert.equal(result.status, "cancelled");
assert.match(result.error, /Superseded/); assert.match(result.error, /Superseded/);
assert.equal(saved.at(-1).status, 'cancelled'); assert.equal(saved.at(-1).status, "cancelled");
}); });
test('existing Unraid deployment discovery derives profile values from Docker, Compose and DockerMan truth', () => { test("existing Unraid deployment discovery derives profile values from Docker, Compose and DockerMan truth", () => {
const { deriveDetectedProfile } = require('../src/main/unraid-deployment-service.cjs'); const {
deriveDetectedProfile,
} = require("../src/main/unraid-deployment-service.cjs");
const result = deriveDetectedProfile({ const result = deriveDetectedProfile({
repository: { name: 'blockpilot-autonomous', defaultBranch: 'main', sshUrl: 'ssh://git@gitea/Jens/blockpilot-autonomous.git' }, repository: {
server: { id: 'unraid', host: '192.168.10.150' }, name: "blockpilot-autonomous",
remoteFolder: 'blockpilot-autonomous', defaultBranch: "main",
remotePath: '/mnt/user/appdata/blockpilot-autonomous', 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: { payload: {
head: 'a'.repeat(40), head: "a".repeat(40),
branch: 'main', branch: "main",
remote: 'ssh://git@gitea/Jens/blockpilot-autonomous.git', remote: "ssh://git@gitea/Jens/blockpilot-autonomous.git",
composeFiles: ['compose.yml'], composeFiles: ["compose.yml"],
compose: { services: { app: { image: 'blockpilot:test' } } }, compose: { services: { app: { image: "blockpilot:test" } } },
containers: [{ containers: [
Name: '/blockpilot', {
Name: "/blockpilot",
State: { Running: true }, State: { Running: true },
Config: { Image: 'blockpilot:test', Env: ['TOKEN=secret', 'MODE=prod'], Labels: { 'com.docker.compose.service': 'app', 'com.docker.compose.project': 'blockpilot' } }, Config: {
HostConfig: { RestartPolicy: { Name: 'unless-stopped' } }, Image: "blockpilot:test",
NetworkSettings: { Ports: { '8080/tcp': [{ HostIp: '0.0.0.0', HostPort: '1223' }] }, Networks: { bridge: {} } }, Env: ["TOKEN=secret", "MODE=prod"],
Mounts: [{ Type: 'bind', Source: '/mnt/user/appdata/blockpilot-autonomous/data', Destination: '/data', RW: true }] Labels: {
}], "com.docker.compose.service": "app",
dockerManXml: '<Container><Name>blockpilot</Name><WebUI>http://[IP]:[PORT:1223]/</WebUI><Icon>https://example.test/icon.png</Icon><Shell>/bin/bash</Shell></Container>' "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.hostPort, 1223);
assert.equal(result.profile.containerPort, 8080); assert.equal(result.profile.containerPort, 8080);
assert.equal(result.profile.containerName, 'blockpilot'); assert.equal(result.profile.containerName, "blockpilot");
assert.equal(result.profile.composeService, 'app'); assert.equal(result.profile.composeService, "app");
assert.equal(result.profile.webUiUrl, 'http://[IP]:[PORT:1223]/'); assert.equal(result.profile.webUiUrl, "http://[IP]:[PORT:1223]/");
assert.equal(result.profile.iconUrl, 'https://example.test/icon.png'); assert.equal(result.profile.iconUrl, "https://example.test/icon.png");
assert.equal(result.profile.dockerShell, '/bin/bash'); assert.equal(result.profile.dockerShell, "/bin/bash");
assert.deepEqual(result.profile.detectedMetadata.envNames, ['TOKEN', 'MODE']); assert.deepEqual(result.profile.detectedMetadata.envNames, ["TOKEN", "MODE"]);
assert.ok(result.profile.preservePaths.includes('data')); assert.ok(result.profile.preservePaths.includes("data"));
assert.equal(result.provenance.hostPort.origin, 'docker-inspect'); assert.equal(result.provenance.hostPort.origin, "docker-inspect");
});
test("a previously failed deployment is corrected when its exact commit is healthy on Unraid", async () => {
const sha = "e".repeat(40);
const failed = {
id: "failed-1",
type: "deployment",
provider: "ssh-unraid",
action: "deploy",
profileId: "production",
sha,
status: "failed",
logs: [],
};
const operations = [failed];
const service = new UnraidDeploymentService({
store: {
data: { operations },
getOperation: (id) => operations.find((item) => item.id === id),
addOperation: async (next) => {
operations.splice(
operations.findIndex((item) => item.id === next.id),
1,
next,
);
return next;
},
},
ssh: {},
git: {},
diagnostics: null,
});
await service.reconcileRecordedOperations("production", {
liveSha: sha,
matchesGitea: true,
containerRunning: true,
healthy: true,
});
assert.equal(operations[0].status, "success");
assert.match(operations[0].logs.at(-1), /reconciled/i);
});
test("a failed deployment is marked superseded when Gitea and Unraid agree on a newer commit", async () => {
const liveSha = "d".repeat(40);
const operations = [
{
id: "failed-2",
type: "deployment",
provider: "ssh-unraid",
profileId: "production",
sha: "c".repeat(40),
status: "failed",
logs: [],
},
];
const service = new UnraidDeploymentService({
store: {
data: { operations },
getOperation: (id) => operations.find((item) => item.id === id),
addOperation: async (next) => {
operations.splice(
operations.findIndex((item) => item.id === next.id),
1,
next,
);
return next;
},
},
ssh: {},
git: {},
diagnostics: null,
});
await service.reconcileRecordedOperations("production", {
liveSha,
matchesGitea: true,
containerRunning: true,
healthy: true,
});
assert.equal(operations[0].status, "cancelled");
assert.match(operations[0].error, /Superseded/);
}); });