Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb95c407d9 | ||
|
|
b7daef3cd9 |
+8
-4
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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": [
|
||||||
|
|||||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+1218
-367
File diff suppressed because it is too large
Load Diff
+21
-1
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+541
-177
@@ -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 operation = await service.deploy({ repository: { fullName: 'Jens/Portfolio', name: 'Portfolio' }, profileId: 'production', sha });
|
};
|
||||||
assert.equal(operation.status, 'running');
|
const service = new UnraidDeploymentService({
|
||||||
assert.match(operation.logs.join('\n'), /background/i);
|
store,
|
||||||
|
ssh,
|
||||||
resolveRemote({ stdout: 'Container Portfolio started\n', stderr: '', exitCode: 0 });
|
git: {},
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
diagnostics: null,
|
||||||
assert.equal(operations.at(-1).status, 'success');
|
});
|
||||||
|
service.preflight = async () => ({
|
||||||
|
summary: { ready: true, blocking: [] },
|
||||||
|
inspection: { head: null },
|
||||||
|
});
|
||||||
|
service.checkHealth = async () => ({
|
||||||
|
configured: false,
|
||||||
|
healthy: null,
|
||||||
|
status: null,
|
||||||
|
latencyMs: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Unraid preflight verifies repository access before a deployment can start', async () => {
|
const operation = await service.deploy({
|
||||||
const source = await import('node:fs/promises').then(({ readFile }) => readFile(new URL('../src/main/unraid-deployment-service.cjs', import.meta.url), 'utf8'));
|
repository: { fullName: "Jens/Portfolio", name: "Portfolio" },
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
assert.equal(operations.at(-1).status, "success");
|
||||||
|
});
|
||||||
|
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
);
|
||||||
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&B<"x">');
|
assert.equal(xmlEscape('A&B<"x">'), "A&B<"x">");
|
||||||
});
|
});
|
||||||
|
|
||||||
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/);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user