Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e888bd2c1c | ||
|
|
40d41f9a32 | ||
|
|
70840839f8 | ||
|
|
1310d2e5a7 |
@@ -1,5 +1,33 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.8.8 - 2026-07-26
|
||||||
|
|
||||||
|
- fixed binary update downloads on Gitea servers that require release-scoped attachment routes;
|
||||||
|
- sends both the immutable release ID and attachment ID when downloading update assets;
|
||||||
|
- preserves strict same-origin token handling and SHA-256 verification.
|
||||||
|
|
||||||
|
## 0.8.7 - 2026-07-26
|
||||||
|
|
||||||
|
- automatically inventories running workloads across configured Unraid servers;
|
||||||
|
- links server Git checkouts to Gitea repositories through exact normalized origins and strong container evidence;
|
||||||
|
- supports image-only discovery through OCI and ForgeFlow repository/commit labels;
|
||||||
|
- adopts uniquely matched workloads into Deployments without requiring a prior ForgeFlow release operation;
|
||||||
|
- verifies every SSH deployment refresh against the current full Gitea branch SHA;
|
||||||
|
- treats matching commits as in order only while the container is running and healthy, and rejects ambiguous matches.
|
||||||
|
|
||||||
|
## 0.8.6 - 2026-07-26
|
||||||
|
|
||||||
|
- added contextual animated code maps to unused diff-canvas space;
|
||||||
|
- made illustrations respond to file type, diff size and pointer depth without obscuring code;
|
||||||
|
- enriched changed-file rows with clearer state chips, active hierarchy and premium interaction feedback;
|
||||||
|
- added responsive and reduced-motion safeguards for focused, accessible workspaces.
|
||||||
|
|
||||||
|
## 0.8.5 - 2026-07-26
|
||||||
|
|
||||||
|
- fixed packaged update downloads when Gitea reports an asset URL with a different public origin;
|
||||||
|
- downloads release assets by immutable Gitea asset ID on the configured trusted origin;
|
||||||
|
- retains strict token isolation and never follows authenticated downloads to another host.
|
||||||
|
|
||||||
## 0.8.4 - 2026-07-26
|
## 0.8.4 - 2026-07-26
|
||||||
|
|
||||||
- added interactive animated release-flow illustrations to high-value project surfaces;
|
- added interactive animated release-flow illustrations to high-value project surfaces;
|
||||||
|
|||||||
+22
-18
@@ -1,4 +1,4 @@
|
|||||||
ForgeFlow 0.8.4 source manifest
|
ForgeFlow 0.8.8 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
|
||||||
30857d5b5ab786f2f7a2b7c15f329b209a5759ea6f34e851898c19c70a01a95d 8985 CHANGELOG.md
|
b5ebb381645c9a7e9de693ff30c563819b70182931057727dc6e48db551adf9f 10557 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
|
||||||
@@ -42,6 +42,10 @@ d7bdc61d9b617ad5acf0b2d468eda547fd7509d4af08f33d2661393f25bdcb5a 576
|
|||||||
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
|
c2802fa5dbff392c846b82b55e84a8bfb8e1625546fd1eba39f7129318bece96 654 docs/RELEASE_NOTES_0.8.3.md
|
||||||
8c13279987672314332f648889f52338bcdcb243249f9e1c20fb09d85d7808f5 505 docs/RELEASE_NOTES_0.8.4.md
|
8c13279987672314332f648889f52338bcdcb243249f9e1c20fb09d85d7808f5 505 docs/RELEASE_NOTES_0.8.4.md
|
||||||
|
b516db97a0353babc810c24a87a971d30d72a8021d809e6b833ff7ae0458f442 538 docs/RELEASE_NOTES_0.8.5.md
|
||||||
|
838d196f3fbbfeee8df375a0502107f56b6df28babca30aa09ef1c7aa5196d09 738 docs/RELEASE_NOTES_0.8.6.md
|
||||||
|
ef049adcfa204908e6dc3a059124b39ba0e2739cc54e38945ce73a57049df0d8 1185 docs/RELEASE_NOTES_0.8.7.md
|
||||||
|
7eedb25e1aae3b06a04bb9b2f4843bd6af614418737e600b4bdc9161edabd76a 632 docs/RELEASE_NOTES_0.8.8.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
|
||||||
@@ -63,11 +67,11 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532
|
|||||||
106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf
|
106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf
|
||||||
2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json
|
2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json
|
||||||
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE
|
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE
|
||||||
6765015bdf27b288a250192272750b243c3cb8d1326b752d056d1e43317b6344 12935 main.cjs
|
0d9f0ac837b4d3b39476d9bcbb57fb3e12d6b7084bb1f7435401e416cff1854f 12920 main.cjs
|
||||||
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
|
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
|
||||||
6c74e7d16379b92028a17b9d8b5b843734710ce8c2813da75e5cda2e822fa913 130466 package-lock.json
|
dc26585558f060c74a11bc7033cf1f22c626e91668a6349a06a928114ecf417d 130466 package-lock.json
|
||||||
05bead9ffc27397b7854f7bda8150806ca433342613834448b28e72dcdd6ccea 3758 package.json
|
e3d60b3adde674e5aa80cc7776db3efbc0ced66d49e89490cb9fbb6786c0db70 3906 package.json
|
||||||
3d2ac366a13e9418e3ec6d13ce95b611f30f0228eb3a80ef9e7a936ce9578e24 9080 preload.cjs
|
45eea5fe4fd58cc0e96497295223e81a4f3acc3778bf546d4c7c96af03da4d26 9496 preload.cjs
|
||||||
b31c43d9355c13b5ae4efc0f3649d8cb8d509b2bb7ebb042ff546b7820fb7de8 8411 Publish-ForgeFlow-Release.ps1
|
b31c43d9355c13b5ae4efc0f3649d8cb8d509b2bb7ebb042ff546b7820fb7de8 8411 Publish-ForgeFlow-Release.ps1
|
||||||
a6d32a742412b7836606be00f17be0465f1b6f55d3911f6c73a14029787ba206 14037 README.md
|
a6d32a742412b7836606be00f17be0465f1b6f55d3911f6c73a14029787ba206 14037 README.md
|
||||||
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
|
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
|
||||||
@@ -79,7 +83,7 @@ f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287
|
|||||||
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
|
||||||
1b03d3144b911863a688ec4be2ae471afd09214ee901bd76c42429c0f5285062 12242 scripts/verify.mjs
|
1a0931346c89d2d49b736a25aa58c5d29fbdf4acd593ea3614dba904ec462843 12374 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
|
||||||
@@ -89,24 +93,24 @@ a381848a296c28f6d14093c96f722967acf9c994ffb867d54dd92bf5ada2729b 23648
|
|||||||
c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs
|
c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs
|
||||||
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
|
6ee0c645521c20043708595d1a9dd0fe7642fc300be1306effd7e1142eebcfb7 17201 src/main/gitea-service.cjs
|
||||||
79593a28b8f40f48a73028be34464f94be2e4c67a027a3f5da78a33bcadc76eb 45539 src/main/ipc.cjs
|
13f6521bb82aa5a3399d6839ea7563459b19969c1e5af7525e4116cf771282e9 46526 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
|
||||||
8cf5013de91737dd9345b121586ceec38a6fc8518648975d60593d81fc225c4b 67376 src/main/unraid-deployment-service.cjs
|
f4e221edace12e2120ec439e0f91274de110872c5bfbe968f2d7935eeb7193c4 78544 src/main/unraid-deployment-service.cjs
|
||||||
36cc05deda3395e5e9de92b880c8315f508cb88e9b080ae34705057ae696804f 20696 src/main/update-service.cjs
|
45e65564e1e8b9db487dc6dda03a752c51130189f23ec3b1260dc62e3621a925 20806 src/main/update-service.cjs
|
||||||
ef029189791024421d78b3ac92981f03b20f2086fc4846eaa95d509f65e096a9 194049 src/renderer/app.js
|
00b5c44b7d04452498288d42a2c345e0e3922c8ddfabfee9eb04654f238811d5 197015 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
|
||||||
342faf4acf1464c871114ca1c82ef926a64cb612a93d022dba401873925f3177 50922 src/renderer/mock-bridge.js
|
2b68194e9ca0caab1ea2d1e5f7222a42f962ea16f3dd9517a7a13af811963969 51166 src/renderer/mock-bridge.js
|
||||||
ebdd804b55c0b13cbf57e51c6d51a60dd838d67bb4c4eaf17ca34c86938a6865 67440 src/renderer/styles.css
|
e30ea37ee00f34110d0d252c79f9d8e0bb127c4db0bd1bf8b374609dea433361 72160 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
|
||||||
@@ -129,12 +133,12 @@ dd121d96ca265a027cd415a52064500a4541b2f8a662f4f4b25f2f996d52b5da 762
|
|||||||
e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 tests/git-integration.test.mjs
|
e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 tests/git-integration.test.mjs
|
||||||
5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db 1239 tests/git-status.test.mjs
|
5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db 1239 tests/git-status.test.mjs
|
||||||
681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.test.mjs
|
681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.test.mjs
|
||||||
52b96f0a6623778fbdc1e8dbfc892e1d77a3d6616058cd7b08d532b207aa5719 5531 tests/gitea-actions.test.mjs
|
e9c45a70635adea853a0a420caabd5ba465070296d2e73ffbb947d4ade52ed46 6165 tests/gitea-actions.test.mjs
|
||||||
267d76b868d8d06ea031c14acd09a7715fb44668a25ade51a9e62e0170888bc8 1522 tests/ipc-contract.test.mjs
|
267d76b868d8d06ea031c14acd09a7715fb44668a25ade51a9e62e0170888bc8 1522 tests/ipc-contract.test.mjs
|
||||||
caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs
|
caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs
|
||||||
96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs
|
96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs
|
||||||
c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 3718 tests/preflight.test.mjs
|
c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 3718 tests/preflight.test.mjs
|
||||||
bc29c4e0c08305b2d00bf59ae9d486cad323c94879cf2a35d596a6b58fc17a0b 7784 tests/renderer-workflow.test.mjs
|
185e96ff8fd029f06b77155117f492bc16684e08818a160148e00737dd8c220b 8395 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
|
||||||
@@ -142,8 +146,8 @@ 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
|
||||||
05c791ea262aef85a7c79874ed900c5d792a78778c577cb803b88a433cd6e2b6 21413 tests/unraid-deployment.test.mjs
|
8f44579de3af7d7d23d55572d1d97cb0690425d4d9dffc30c6bf2ec3ff9c54eb 24553 tests/unraid-deployment.test.mjs
|
||||||
0c49d3222ea362dbef171f5ad556a335bad670b47adef660ede101d84814d05e 14849 tests/update-service.test.mjs
|
11d6e6329f775617a1ce3657d0454cc97d7bcf3d9759f9a5a623c9d18e13d03e 15118 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
|
||||||
8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea 767 UPDATE_FROM_0.3.2.md
|
8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea 767 UPDATE_FROM_0.3.2.md
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# ForgeFlow 0.8.5
|
||||||
|
|
||||||
|
ForgeFlow 0.8.5 fixes packaged update downloads for Gitea instances whose
|
||||||
|
release metadata reports a public asset URL with a different scheme or origin.
|
||||||
|
|
||||||
|
The updater now ignores that mutable browser URL and downloads each release
|
||||||
|
asset through its immutable asset ID on the configured, trusted Gitea origin.
|
||||||
|
Authentication tokens remain protected and are never sent to another host.
|
||||||
|
|
||||||
|
Install 0.8.5 manually when upgrading from 0.8.4 because the affected download
|
||||||
|
path runs before the new updater code can be installed.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# ForgeFlow 0.8.6
|
||||||
|
|
||||||
|
ForgeFlow 0.8.6 gives the changes workspace a richer, more purposeful visual
|
||||||
|
identity. Unused diff-canvas space now presents a contextual animated code map
|
||||||
|
that reflects the selected file type and its additions and removals.
|
||||||
|
|
||||||
|
Subtle travelling signals, floating status nodes and pointer-responsive depth
|
||||||
|
bring the canvas to life while keeping every diff line fully readable. Dense
|
||||||
|
diffs automatically reduce the illustration's presence, and narrow panes hide
|
||||||
|
it entirely when there is no useful room.
|
||||||
|
|
||||||
|
The changed-file list also gains clearer state chips, stronger active-file
|
||||||
|
hierarchy and refined hover feedback. All effects support light and dark themes
|
||||||
|
and respect the operating system's reduced-motion preference.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# ForgeFlow 0.8.7
|
||||||
|
|
||||||
|
ForgeFlow 0.8.7 automatically discovers applications already running on every
|
||||||
|
configured and trusted Unraid server. It inventories server-side Git checkouts,
|
||||||
|
Docker Compose metadata, bind mounts, container identity and image provenance,
|
||||||
|
then links each workload to a Gitea repository only when the evidence produces
|
||||||
|
one unambiguous match.
|
||||||
|
|
||||||
|
Uniquely matched workloads are added to Deployments automatically, even when
|
||||||
|
they were originally deployed outside ForgeFlow. Every refresh resolves the
|
||||||
|
configured branch directly on Gitea and compares its full commit SHA with the
|
||||||
|
live server version. A deployment is reported as in order when the SHAs match,
|
||||||
|
the container is running and Docker health is not failing.
|
||||||
|
|
||||||
|
Containers without a server-side Git checkout can also be discovered when the
|
||||||
|
image exposes standard OCI source/revision labels or ForgeFlow provenance
|
||||||
|
labels. New ForgeFlow deployments now write repository, branch and exact commit
|
||||||
|
labels so future discovery remains deterministic.
|
||||||
|
|
||||||
|
Ambiguous or weak matches are intentionally left unlinked for manual review.
|
||||||
|
Server inventory is read-only; automatic adoption changes only ForgeFlow's local
|
||||||
|
configuration.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# ForgeFlow 0.8.8
|
||||||
|
|
||||||
|
ForgeFlow 0.8.8 fixes the HTTP 404 returned while downloading packaged updates
|
||||||
|
from the configured Gitea server.
|
||||||
|
|
||||||
|
The server's API requires release attachments to be addressed using both the
|
||||||
|
immutable release ID and attachment ID. ForgeFlow now uses that exact
|
||||||
|
release-scoped endpoint for the executable and its checksum file.
|
||||||
|
|
||||||
|
Strict same-origin token protection and SHA-256 verification remain unchanged.
|
||||||
|
Install 0.8.8 manually when upgrading from 0.8.7 because the affected download
|
||||||
|
code runs before the corrected updater can be installed. Future packaged
|
||||||
|
updates can again be completed from inside ForgeFlow.
|
||||||
@@ -141,14 +141,12 @@ function createWindow() {
|
|||||||
);
|
);
|
||||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
if (/^https?:\/\//i.test(url))
|
if (/^https?:\/\//i.test(url))
|
||||||
shell
|
shell.openExternal(url).catch((error) =>
|
||||||
.openExternal(url)
|
diagnostics?.warning("external-link.open.failed", {
|
||||||
.catch((error) =>
|
url,
|
||||||
diagnostics?.warning("external-link.open.failed", {
|
message: error.message,
|
||||||
url,
|
}),
|
||||||
message: error.message,
|
);
|
||||||
}),
|
|
||||||
);
|
|
||||||
return { action: "deny" };
|
return { action: "deny" };
|
||||||
});
|
});
|
||||||
mainWindow.webContents.on("will-navigate", (event, url) => {
|
mainWindow.webContents.on("will-navigate", (event, url) => {
|
||||||
@@ -260,6 +258,7 @@ app
|
|||||||
store,
|
store,
|
||||||
ssh,
|
ssh,
|
||||||
git,
|
git,
|
||||||
|
gitea,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
sourcePath: app.getAppPath(),
|
sourcePath: app.getAppPath(),
|
||||||
onOperationChange: reportOperationChange,
|
onOperationChange: reportOperationChange,
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "forgeflow",
|
"name": "forgeflow",
|
||||||
"version": "0.8.4",
|
"version": "0.8.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "forgeflow",
|
"name": "forgeflow",
|
||||||
"version": "0.8.4",
|
"version": "0.8.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ssh2": "1.17.0"
|
"ssh2": "1.17.0"
|
||||||
},
|
},
|
||||||
|
|||||||
+5
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "forgeflow",
|
"name": "forgeflow",
|
||||||
"version": "0.8.4",
|
"version": "0.8.8",
|
||||||
"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",
|
||||||
@@ -77,6 +77,10 @@
|
|||||||
"docs/RELEASE_NOTES_0.8.2.md",
|
"docs/RELEASE_NOTES_0.8.2.md",
|
||||||
"docs/RELEASE_NOTES_0.8.3.md",
|
"docs/RELEASE_NOTES_0.8.3.md",
|
||||||
"docs/RELEASE_NOTES_0.8.4.md",
|
"docs/RELEASE_NOTES_0.8.4.md",
|
||||||
|
"docs/RELEASE_NOTES_0.8.5.md",
|
||||||
|
"docs/RELEASE_NOTES_0.8.6.md",
|
||||||
|
"docs/RELEASE_NOTES_0.8.7.md",
|
||||||
|
"docs/RELEASE_NOTES_0.8.8.md",
|
||||||
"docs/ACCEPTANCE.md"
|
"docs/ACCEPTANCE.md"
|
||||||
],
|
],
|
||||||
"asarUnpack": [
|
"asarUnpack": [
|
||||||
|
|||||||
+117
-95
@@ -22,98 +22,120 @@ function subscribe(channel, listener) {
|
|||||||
return () => ipcRenderer.removeListener(channel, handler);
|
return () => ipcRenderer.removeListener(channel, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('forgeflow', Object.freeze({
|
contextBridge.exposeInMainWorld(
|
||||||
bootstrap: () => invoke('app:bootstrap'),
|
'forgeflow',
|
||||||
selectDirectory: (payload) => invoke('dialog:select-directory', payload),
|
Object.freeze({
|
||||||
selectKeyFile: (payload) => invoke('dialog:select-key-file', payload),
|
bootstrap: () => invoke('app:bootstrap'),
|
||||||
selectImageFile: (payload) => invoke('dialog:select-image-file', payload),
|
selectDirectory: (payload) => invoke('dialog:select-directory', payload),
|
||||||
setupPreflight: (payload) => invoke('setup:preflight', payload),
|
selectKeyFile: (payload) => invoke('dialog:select-key-file', payload),
|
||||||
validateGitea: (payload) => invoke('setup:validate-gitea', payload),
|
selectImageFile: (payload) => invoke('dialog:select-image-file', payload),
|
||||||
completeSetup: (payload) => invoke('setup:complete', payload),
|
setupPreflight: (payload) => invoke('setup:preflight', payload),
|
||||||
updateGitea: (payload) => invoke('settings:update-gitea', payload),
|
validateGitea: (payload) => invoke('setup:validate-gitea', payload),
|
||||||
setWorkspaceRoots: (roots) => invoke('settings:set-roots', { roots }),
|
completeSetup: (payload) => invoke('setup:complete', payload),
|
||||||
setAppearance: (appearance) => invoke('settings:set-appearance', { appearance }),
|
updateGitea: (payload) => invoke('settings:update-gitea', payload),
|
||||||
setPreferences: (preferences) => invoke('settings:set-preferences', { preferences }),
|
setWorkspaceRoots: (roots) => invoke('settings:set-roots', { roots }),
|
||||||
exportConfigurationBackup: (passphrase) => invoke('settings:export-backup', { passphrase }),
|
setAppearance: (appearance) => invoke('settings:set-appearance', { appearance }),
|
||||||
importConfigurationBackup: (passphrase) => invoke('settings:import-backup', { passphrase }),
|
setPreferences: (preferences) => invoke('settings:set-preferences', { preferences }),
|
||||||
listAuditEvents: (limit = 250) => invoke('audit:list', { limit }),
|
exportConfigurationBackup: (passphrase) => invoke('settings:export-backup', { passphrase }),
|
||||||
exportAuditLog: (format = 'json') => invoke('audit:export', { format }),
|
importConfigurationBackup: (passphrase) => invoke('settings:import-backup', { passphrase }),
|
||||||
setUpdatePreferences: (updates) => invoke('updates:preferences', { updates }),
|
listAuditEvents: (limit = 250) => invoke('audit:list', { limit }),
|
||||||
checkForUpdates: () => invoke('updates:check'),
|
exportAuditLog: (format = 'json') => invoke('audit:export', { format }),
|
||||||
downloadUpdate: () => invoke('updates:download'),
|
setUpdatePreferences: (updates) => invoke('updates:preferences', { updates }),
|
||||||
applyUpdate: () => invoke('updates:apply'),
|
checkForUpdates: () => invoke('updates:check'),
|
||||||
saveServer: (server, password = '', passphrase = '') => invoke('server:save', { server, password, passphrase }),
|
downloadUpdate: () => invoke('updates:download'),
|
||||||
deleteServer: (serverId) => invoke('server:delete', { serverId }),
|
applyUpdate: () => invoke('updates:apply'),
|
||||||
testServer: (serverId) => invoke('server:test', { serverId }),
|
saveServer: (server, password = '', passphrase = '') => invoke('server:save', { server, password, passphrase }),
|
||||||
inspectServerProject: (repository, profileId) => invoke('server:inspect-project', { repository, profileId }),
|
deleteServer: (serverId) => invoke('server:delete', { serverId }),
|
||||||
discoverExistingDeployment: (repository, serverId, remoteFolder) => invoke('server:discover-existing', { repository, serverId, remoteFolder }),
|
testServer: (serverId) => invoke('server:test', { serverId }),
|
||||||
refreshRepositories: () => invoke('repositories:refresh'),
|
inspectServerProject: (repository, profileId) => invoke('server:inspect-project', { repository, profileId }),
|
||||||
discoverRepositories: (roots) => invoke('repositories:discover', { roots }),
|
discoverExistingDeployment: (repository, serverId, remoteFolder) =>
|
||||||
favoriteRepository: (fullName, favorite) => invoke('repository:favorite', { fullName, favorite }),
|
invoke('server:discover-existing', {
|
||||||
linkRepository: (fullName, localPath) => invoke('repository:link', { fullName, localPath }),
|
repository,
|
||||||
unlinkRepository: (fullName) => invoke('repository:unlink', { fullName }),
|
serverId,
|
||||||
repositoryStatus: (localPath) => invoke('repository:status', { localPath }),
|
remoteFolder,
|
||||||
repositoryDiff: (localPath, filePath, staged = false) => invoke('repository:diff', { localPath, filePath, staged }),
|
}),
|
||||||
repositoryDiffHunks: (localPath, filePath) => invoke('repository:diff-hunks', { localPath, filePath }),
|
refreshRepositories: () => invoke('repositories:refresh'),
|
||||||
stageHunks: (localPath, filePath, hunkIndexes) => invoke('repository:stage-hunks', { localPath, filePath, hunkIndexes }),
|
discoverRepositories: (roots) => invoke('repositories:discover', { roots }),
|
||||||
conflictState: (localPath) => invoke('repository:conflicts', { localPath }),
|
favoriteRepository: (fullName, favorite) => invoke('repository:favorite', { fullName, favorite }),
|
||||||
resolveConflict: (localPath, filePath, resolution) => invoke('repository:resolve-conflict', { localPath, filePath, resolution }),
|
linkRepository: (fullName, localPath) => invoke('repository:link', { fullName, localPath }),
|
||||||
continueGitOperation: (localPath) => invoke('repository:continue-operation', { localPath }),
|
unlinkRepository: (fullName) => invoke('repository:unlink', { fullName }),
|
||||||
abortGitOperation: (localPath) => invoke('repository:abort-operation', { localPath }),
|
repositoryStatus: (localPath) => invoke('repository:status', { localPath }),
|
||||||
stageFiles: (localPath, files) => invoke('repository:stage', { localPath, files }),
|
repositoryDiff: (localPath, filePath, staged = false) => invoke('repository:diff', { localPath, filePath, staged }),
|
||||||
unstageFiles: (localPath, files) => invoke('repository:unstage', { localPath, files }),
|
repositoryDiffHunks: (localPath, filePath) => invoke('repository:diff-hunks', { localPath, filePath }),
|
||||||
commit: (localPath, message, files) => invoke('repository:commit', { localPath, message, files }),
|
stageHunks: (localPath, filePath, hunkIndexes) => invoke('repository:stage-hunks', { localPath, filePath, hunkIndexes }),
|
||||||
commitStaged: (localPath, message) => invoke('repository:commit-staged', { localPath, message }),
|
conflictState: (localPath) => invoke('repository:conflicts', { localPath }),
|
||||||
commitStagedAndPush: (localPath, message) => invoke('repository:commit-staged-push', { localPath, message }),
|
resolveConflict: (localPath, filePath, resolution) =>
|
||||||
commitAndPush: (localPath, message, files) => invoke('repository:commit-push', { localPath, message, files }),
|
invoke('repository:resolve-conflict', {
|
||||||
push: (localPath) => invoke('repository:push', { localPath }),
|
localPath,
|
||||||
fetch: (localPath) => invoke('repository:fetch', { localPath }),
|
filePath,
|
||||||
pull: (localPath) => invoke('repository:pull', { localPath }),
|
resolution,
|
||||||
history: (localPath, limit = 20) => invoke('repository:history', { localPath, limit }),
|
}),
|
||||||
branchProtection: (fullName, branch) => invoke('repository:branch-protection', { fullName, branch }),
|
continueGitOperation: (localPath) => invoke('repository:continue-operation', { localPath }),
|
||||||
pullRequests: (fullName, state = 'open') => invoke('repository:pull-requests', { fullName, state }),
|
abortGitOperation: (localPath) => invoke('repository:abort-operation', { localPath }),
|
||||||
createPullRequest: (fullName, title, body, base) => invoke('repository:create-pull-request', { fullName, title, body, base }),
|
stageFiles: (localPath, files) => invoke('repository:stage', { localPath, files }),
|
||||||
branches: (localPath) => invoke('repository:branches', { localPath }),
|
unstageFiles: (localPath, files) => invoke('repository:unstage', { localPath, files }),
|
||||||
checkoutBranch: (localPath, branch) => invoke('repository:checkout-branch', { localPath, branch }),
|
commit: (localPath, message, files) => invoke('repository:commit', { localPath, message, files }),
|
||||||
createBranch: (localPath, branch) => invoke('repository:create-branch', { localPath, branch }),
|
commitStaged: (localPath, message) => invoke('repository:commit-staged', { localPath, message }),
|
||||||
stash: (localPath, message) => invoke('repository:stash', { localPath, message }),
|
commitStagedAndPush: (localPath, message) => invoke('repository:commit-staged-push', { localPath, message }),
|
||||||
stashList: (localPath) => invoke('repository:stash-list', { localPath }),
|
commitAndPush: (localPath, message, files) => invoke('repository:commit-push', { localPath, message, files }),
|
||||||
popStash: (localPath, ref) => invoke('repository:stash-pop', { localPath, ref }),
|
push: (localPath) => invoke('repository:push', { localPath }),
|
||||||
indexLockInfo: (localPath) => invoke('repository:index-lock', { localPath }),
|
fetch: (localPath) => invoke('repository:fetch', { localPath }),
|
||||||
repairIndexLock: (localPath) => invoke('repository:repair-index-lock', { localPath }),
|
pull: (localPath) => invoke('repository:pull', { localPath }),
|
||||||
gitRecoveryStatus: (localPath) => invoke('repository:git-recovery-status', { localPath }),
|
history: (localPath, limit = 20) => invoke('repository:history', { localPath, limit }),
|
||||||
repairGitLocks: (localPath, force = false) => invoke('repository:repair-git-locks', { localPath, force }),
|
branchProtection: (fullName, branch) => invoke('repository:branch-protection', { fullName, branch }),
|
||||||
reconcileRepository: (localPath) => invoke('repository:reconcile', { localPath }),
|
pullRequests: (fullName, state = 'open') => invoke('repository:pull-requests', { fullName, state }),
|
||||||
repairRepositorySync: (localPath, strategy) => invoke('repository:repair-sync', { localPath, strategy }),
|
createPullRequest: (fullName, title, body, base) => invoke('repository:create-pull-request', { fullName, title, body, base }),
|
||||||
setOrigin: (localPath, remoteUrl) => invoke('repository:set-origin', { localPath, remoteUrl }),
|
branches: (localPath) => invoke('repository:branches', { localPath }),
|
||||||
normalizeOrigins: () => invoke('repositories:normalize-origins'),
|
checkoutBranch: (localPath, branch) => invoke('repository:checkout-branch', { localPath, branch }),
|
||||||
cloneRepository: (fullName, mode = 'default') => invoke('repository:clone', { fullName, mode }),
|
createBranch: (localPath, branch) => invoke('repository:create-branch', { localPath, branch }),
|
||||||
openPath: (localPath) => invoke('repository:open-path', { localPath }),
|
stash: (localPath, message) => invoke('repository:stash', { localPath, message }),
|
||||||
openEditor: (localPath, filePath = '', line = 1) => invoke('repository:open-editor', { localPath, filePath, line }),
|
stashList: (localPath) => invoke('repository:stash-list', { localPath }),
|
||||||
openTerminal: (localPath) => invoke('repository:open-terminal', { localPath }),
|
popStash: (localPath, ref) => invoke('repository:stash-pop', { localPath, ref }),
|
||||||
openExternal: (url) => invoke('external:open', { url }),
|
indexLockInfo: (localPath) => invoke('repository:index-lock', { localPath }),
|
||||||
saveDeploymentProfile: (fullName, profile) => invoke('deployment:save-profile', { fullName, profile }),
|
repairIndexLock: (localPath) => invoke('repository:repair-index-lock', { localPath }),
|
||||||
deploymentPreflight: (repository, profileId) => invoke('deployment:preflight', { repository, profileId }),
|
gitRecoveryStatus: (localPath) => invoke('repository:git-recovery-status', { localPath }),
|
||||||
deleteDeploymentProfile: (fullName, profileId) => invoke('deployment:delete-profile', { fullName, profileId }),
|
repairGitLocks: (localPath, force = false) => invoke('repository:repair-git-locks', { localPath, force }),
|
||||||
deploy: (repository, profileId, sha, options = {}) => invoke('deployment:dispatch', { repository, profileId, sha, note: options.note || '', override: options.override === true, overrideReason: options.overrideReason || '' }),
|
reconcileRepository: (localPath) => invoke('repository:reconcile', { localPath }),
|
||||||
rollback: (repository, profileId, targetSha) => invoke('deployment:rollback', { repository, profileId, targetSha }),
|
repairRepositorySync: (localPath, strategy) => invoke('repository:repair-sync', { localPath, strategy }),
|
||||||
healthcheck: (url) => invoke('deployment:health', { url }),
|
setOrigin: (localPath, remoteUrl) => invoke('repository:set-origin', { localPath, remoteUrl }),
|
||||||
refreshProfileState: (fullName, profileId) => invoke('deployment:profile-state', { fullName, profileId }),
|
normalizeOrigins: () => invoke('repositories:normalize-origins'),
|
||||||
applyDockerManMetadata: (repository, profileId) => invoke('deployment:apply-dockerman-metadata', { repository, profileId }),
|
cloneRepository: (fullName, mode = 'default') => invoke('repository:clone', { fullName, mode }),
|
||||||
reconcileDeployment: (fullName, profileId) => invoke('deployment:reconcile', { fullName, profileId }),
|
openPath: (localPath) => invoke('repository:open-path', { localPath }),
|
||||||
refreshOperations: (operationId = null) => invoke('operations:refresh', { operationId }),
|
openEditor: (localPath, filePath = '', line = 1) => invoke('repository:open-editor', { localPath, filePath, line }),
|
||||||
getOperation: (operationId) => invoke('operations:get', { operationId }),
|
openTerminal: (localPath) => invoke('repository:open-terminal', { localPath }),
|
||||||
troubleshooterScan: (fullName = null) => invoke('troubleshooter:scan', { fullName }),
|
openExternal: (url) => invoke('external:open', { url }),
|
||||||
troubleshooterRepair: (issue) => invoke('troubleshooter:repair', { issue }),
|
saveDeploymentProfile: (fullName, profile) => invoke('deployment:save-profile', { fullName, profile }),
|
||||||
troubleshooterAutoRepair: (issues) => invoke('troubleshooter:auto-repair', { issues }),
|
deploymentPreflight: (repository, profileId) => invoke('deployment:preflight', { repository, profileId }),
|
||||||
diagnosticsStatus: () => invoke('diagnostics:status'),
|
deleteDeploymentProfile: (fullName, profileId) => invoke('deployment:delete-profile', { fullName, profileId }),
|
||||||
clearDiagnostics: () => invoke('diagnostics:clear'),
|
deploy: (repository, profileId, sha, options = {}) =>
|
||||||
openDiagnosticsFolder: () => invoke('diagnostics:open-folder'),
|
invoke('deployment:dispatch', {
|
||||||
exportDiagnostics: (privacyMode = 'standard') => invoke('diagnostics:export', { privacyMode }),
|
repository,
|
||||||
showDiagnosticBundle: (filePath) => invoke('diagnostics:show-bundle', { filePath }),
|
profileId,
|
||||||
reportRendererEvent: (level, event, details = {}) => invoke('renderer:report', { level, event, details }),
|
sha,
|
||||||
onRepositoriesChanged: (listener) => subscribe('repositories:changed', listener),
|
note: options.note || '',
|
||||||
onOperationsChanged: (listener) => subscribe('operations:changed', listener),
|
override: options.override === true,
|
||||||
onUpdatesChanged: (listener) => subscribe('updates:changed', listener),
|
overrideReason: options.overrideReason || '',
|
||||||
reset: () => invoke('app:reset')
|
}),
|
||||||
}));
|
rollback: (repository, profileId, targetSha) => invoke('deployment:rollback', { repository, profileId, targetSha }),
|
||||||
|
healthcheck: (url) => invoke('deployment:health', { url }),
|
||||||
|
refreshProfileState: (fullName, profileId) => invoke('deployment:profile-state', { fullName, profileId }),
|
||||||
|
discoverServerDeployments: () => invoke('deployment:discover-server-workloads'),
|
||||||
|
applyDockerManMetadata: (repository, profileId) => invoke('deployment:apply-dockerman-metadata', { repository, profileId }),
|
||||||
|
reconcileDeployment: (fullName, profileId) => invoke('deployment:reconcile', { fullName, profileId }),
|
||||||
|
refreshOperations: (operationId = null) => invoke('operations:refresh', { operationId }),
|
||||||
|
getOperation: (operationId) => invoke('operations:get', { operationId }),
|
||||||
|
troubleshooterScan: (fullName = null) => invoke('troubleshooter:scan', { fullName }),
|
||||||
|
troubleshooterRepair: (issue) => invoke('troubleshooter:repair', { issue }),
|
||||||
|
troubleshooterAutoRepair: (issues) => invoke('troubleshooter:auto-repair', { issues }),
|
||||||
|
diagnosticsStatus: () => invoke('diagnostics:status'),
|
||||||
|
clearDiagnostics: () => invoke('diagnostics:clear'),
|
||||||
|
openDiagnosticsFolder: () => invoke('diagnostics:open-folder'),
|
||||||
|
exportDiagnostics: (privacyMode = 'standard') => invoke('diagnostics:export', { privacyMode }),
|
||||||
|
showDiagnosticBundle: (filePath) => invoke('diagnostics:show-bundle', { filePath }),
|
||||||
|
reportRendererEvent: (level, event, details = {}) => invoke('renderer:report', { level, event, details }),
|
||||||
|
onRepositoriesChanged: (listener) => subscribe('repositories:changed', listener),
|
||||||
|
onOperationsChanged: (listener) => subscribe('operations:changed', listener),
|
||||||
|
onUpdatesChanged: (listener) => subscribe('updates:changed', listener),
|
||||||
|
reset: () => invoke('app:reset'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|||||||
+6
-2
@@ -65,6 +65,10 @@ const required = [
|
|||||||
"docs/RELEASE_NOTES_0.8.2.md",
|
"docs/RELEASE_NOTES_0.8.2.md",
|
||||||
"docs/RELEASE_NOTES_0.8.3.md",
|
"docs/RELEASE_NOTES_0.8.3.md",
|
||||||
"docs/RELEASE_NOTES_0.8.4.md",
|
"docs/RELEASE_NOTES_0.8.4.md",
|
||||||
|
"docs/RELEASE_NOTES_0.8.5.md",
|
||||||
|
"docs/RELEASE_NOTES_0.8.6.md",
|
||||||
|
"docs/RELEASE_NOTES_0.8.7.md",
|
||||||
|
"docs/RELEASE_NOTES_0.8.8.md",
|
||||||
"docs/UPDATING.md",
|
"docs/UPDATING.md",
|
||||||
"docs/DIAGNOSTICS.md",
|
"docs/DIAGNOSTICS.md",
|
||||||
"docs/DEPLOYMENT_SETUP.md",
|
"docs/DEPLOYMENT_SETUP.md",
|
||||||
@@ -103,9 +107,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.4")
|
if (packageJson.version !== "0.8.8")
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Expected package version 0.8.4, got ${packageJson.version}.`,
|
`Expected package version 0.8.8, got ${packageJson.version}.`,
|
||||||
);
|
);
|
||||||
const sourceManifest = await readFile(
|
const sourceManifest = await readFile(
|
||||||
path.join(root, "SOURCE_MANIFEST.txt"),
|
path.join(root, "SOURCE_MANIFEST.txt"),
|
||||||
|
|||||||
@@ -331,6 +331,17 @@ class GiteaService {
|
|||||||
throw new Error("The update download exceeded the redirect limit.");
|
throw new Error("The update download exceeded the redirect limit.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async downloadReleaseAsset(owner, repo, releaseId, assetId, options = {}) {
|
||||||
|
const numericReleaseId = Number(releaseId);
|
||||||
|
const numericId = Number(assetId);
|
||||||
|
if (!Number.isSafeInteger(numericReleaseId) || numericReleaseId <= 0)
|
||||||
|
throw new Error("Gitea returned an invalid release ID.");
|
||||||
|
if (!Number.isSafeInteger(numericId) || numericId <= 0)
|
||||||
|
throw new Error("Gitea returned an invalid release asset ID.");
|
||||||
|
const assetPath = `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${numericReleaseId}/assets/${numericId}`;
|
||||||
|
return this.downloadAuthenticated(assetPath, options);
|
||||||
|
}
|
||||||
|
|
||||||
async dispatchWorkflow({ owner, repo, workflowFile, ref, inputs = {} }) {
|
async dispatchWorkflow({ owner, repo, workflowFile, ref, inputs = {} }) {
|
||||||
const result = await this.request(
|
const result = await this.request(
|
||||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/workflows/${encodeURIComponent(workflowFile)}/dispatches`,
|
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/workflows/${encodeURIComponent(workflowFile)}/dispatches`,
|
||||||
|
|||||||
+34
-3
@@ -1140,10 +1140,41 @@ function registerIpc({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
register("deployment:health", ({ url }) => deployments.checkHealth(url));
|
register("deployment:health", ({ url }) => deployments.checkHealth(url));
|
||||||
register("deployment:profile-state", ({ fullName, profileId }) => {
|
register("deployment:discover-server-workloads", async () => {
|
||||||
|
const repositoryList = await repositories.refresh();
|
||||||
|
const remoteRepositories = repositoryList.filter(
|
||||||
|
(repository) => repository.owner?.login !== "local",
|
||||||
|
);
|
||||||
|
const results = [];
|
||||||
|
for (const server of store.data.servers || []) {
|
||||||
|
try {
|
||||||
|
results.push(
|
||||||
|
await unraid.discoverServerWorkloads(server.id, remoteRepositories),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
results.push({
|
||||||
|
serverId: server.id,
|
||||||
|
detected: 0,
|
||||||
|
adopted: 0,
|
||||||
|
verified: 0,
|
||||||
|
unmatched: 0,
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
});
|
||||||
|
register("deployment:profile-state", async ({ fullName, profileId }) => {
|
||||||
const profile = store.getDeploymentProfile(fullName, profileId);
|
const profile = store.getDeploymentProfile(fullName, profileId);
|
||||||
if (profile?.provider === "ssh-unraid")
|
if (profile?.provider === "ssh-unraid") {
|
||||||
return unraid.refreshProfileState(fullName, profileId);
|
let giteaSha = null;
|
||||||
|
try {
|
||||||
|
const [owner, repo] = String(fullName || "").split("/");
|
||||||
|
const branch = await gitea.getBranch(owner, repo, profile.branch);
|
||||||
|
giteaSha = branch?.commit?.id || branch?.commit?.sha || null;
|
||||||
|
} catch {}
|
||||||
|
return unraid.refreshProfileState(fullName, profileId, giteaSha);
|
||||||
|
}
|
||||||
return deployments.refreshProfileState(fullName, profileId);
|
return deployments.refreshProfileState(fullName, profileId);
|
||||||
});
|
});
|
||||||
register(
|
register(
|
||||||
|
|||||||
@@ -349,11 +349,92 @@ function iconReferenceLocalPath(iconReference) {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decodeInventoryValue(value) {
|
||||||
|
try {
|
||||||
|
return Buffer.from(String(value || ""), "base64").toString("utf8");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function remoteIdentity(value) {
|
||||||
|
const normalized = normalizeRemoteUrl(value);
|
||||||
|
return normalized ? `${normalized.host}/${normalized.path}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseServerInventory(output) {
|
||||||
|
const marker = "__FORGEFLOW_INVENTORY__";
|
||||||
|
const index = String(output || "").lastIndexOf(marker);
|
||||||
|
if (index < 0)
|
||||||
|
throw new Error(
|
||||||
|
"The server did not return a ForgeFlow workload inventory.",
|
||||||
|
);
|
||||||
|
const checkouts = [];
|
||||||
|
const containers = [];
|
||||||
|
for (const line of String(output)
|
||||||
|
.slice(index + marker.length)
|
||||||
|
.trim()
|
||||||
|
.split(/\r?\n/)) {
|
||||||
|
const [kind, ...parts] = line.split("\t");
|
||||||
|
if (kind === "R" && parts.length >= 4) {
|
||||||
|
checkouts.push({
|
||||||
|
root: decodeInventoryValue(parts[0]),
|
||||||
|
remote: decodeInventoryValue(parts[1]),
|
||||||
|
liveSha: parts[2],
|
||||||
|
branch: decodeInventoryValue(parts[3]),
|
||||||
|
});
|
||||||
|
} else if (kind === "C" && parts[0]) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(decodeInventoryValue(parts[0]));
|
||||||
|
if (Array.isArray(parsed) && parsed[0]) containers.push(parsed[0]);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { checkouts, containers };
|
||||||
|
}
|
||||||
|
|
||||||
|
function inventoryContainerMatch(checkout, repository, container) {
|
||||||
|
if (!container?.State?.Running) return 0;
|
||||||
|
const labels = container.Config?.Labels || {};
|
||||||
|
const workingDir = String(
|
||||||
|
labels["com.docker.compose.project.working_dir"] || "",
|
||||||
|
).replace(/\/$/, "");
|
||||||
|
const source = remoteIdentity(
|
||||||
|
labels["org.opencontainers.image.source"] || "",
|
||||||
|
);
|
||||||
|
const mounts = Array.isArray(container.Mounts) ? container.Mounts : [];
|
||||||
|
const root = String(checkout.root || "").replace(/\/$/, "");
|
||||||
|
const name = String(container.Name || "").replace(/^\//, "");
|
||||||
|
const project = String(labels["com.docker.compose.project"] || "");
|
||||||
|
const expectedNames = new Set(
|
||||||
|
[repository.name, root.split("/").pop()].filter(Boolean).map((value) =>
|
||||||
|
String(value)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]/g, ""),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const normalizedName = name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||||
|
const normalizedProject = project.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||||
|
if (workingDir && workingDir === root) return 100;
|
||||||
|
if (
|
||||||
|
mounts.some((mount) => {
|
||||||
|
const mountSource = String(mount.Source || "").replace(/\/$/, "");
|
||||||
|
return mountSource === root || mountSource.startsWith(`${root}/`);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return 90;
|
||||||
|
if (source && source === remoteIdentity(checkout.remote)) return 85;
|
||||||
|
if (expectedNames.has(normalizedProject)) return 70;
|
||||||
|
if (expectedNames.has(normalizedName)) return 60;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
class UnraidDeploymentService {
|
class UnraidDeploymentService {
|
||||||
constructor({
|
constructor({
|
||||||
store,
|
store,
|
||||||
ssh,
|
ssh,
|
||||||
git,
|
git,
|
||||||
|
gitea,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
sourcePath = process.cwd(),
|
sourcePath = process.cwd(),
|
||||||
onOperationChange = null,
|
onOperationChange = null,
|
||||||
@@ -361,11 +442,204 @@ class UnraidDeploymentService {
|
|||||||
this.store = store;
|
this.store = store;
|
||||||
this.ssh = ssh;
|
this.ssh = ssh;
|
||||||
this.git = git;
|
this.git = git;
|
||||||
|
this.gitea = gitea;
|
||||||
this.diagnostics = diagnostics;
|
this.diagnostics = diagnostics;
|
||||||
this.sourcePath = sourcePath;
|
this.sourcePath = sourcePath;
|
||||||
this.onOperationChange = onOperationChange;
|
this.onOperationChange = onOperationChange;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async discoverServerWorkloads(serverId, repositories) {
|
||||||
|
const server = this.store.getServer(serverId);
|
||||||
|
if (!server) throw new Error("The deployment server no longer exists.");
|
||||||
|
const script = `
|
||||||
|
base=${shellQuote(server.basePath)}
|
||||||
|
printf '__FORGEFLOW_INVENTORY__\\n'
|
||||||
|
if [ -d "$base" ]; then
|
||||||
|
find "$base" -mindepth 2 -maxdepth 2 -type d -name .git -print0 2>/dev/null | while IFS= read -r -d '' gitdir; do
|
||||||
|
root=\${gitdir%/.git}
|
||||||
|
remote=$(git -C "$root" remote get-url origin 2>/dev/null || true)
|
||||||
|
live=$(git -C "$root" rev-parse HEAD 2>/dev/null || true)
|
||||||
|
branch=$(git -C "$root" symbolic-ref --short HEAD 2>/dev/null || true)
|
||||||
|
[ -n "$remote" ] || continue
|
||||||
|
printf 'R\\t%s\\t%s\\t%s\\t%s\\n' "$(printf '%s' "$root" | base64 | tr -d '\\r\\n')" "$(printf '%s' "$remote" | base64 | tr -d '\\r\\n')" "$live" "$(printf '%s' "$branch" | base64 | tr -d '\\r\\n')"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
for id in $(docker ps -aq 2>/dev/null || true); do
|
||||||
|
printf 'C\\t%s\\n' "$(docker inspect "$id" 2>/dev/null | base64 | tr -d '\\r\\n')"
|
||||||
|
done
|
||||||
|
`;
|
||||||
|
const result = await this.ssh.exec(server.id, bash(script), {
|
||||||
|
timeout: 45_000,
|
||||||
|
maxOutput: 8 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
const inventory = parseServerInventory(result.stdout);
|
||||||
|
const workloads = [...inventory.checkouts];
|
||||||
|
const knownRemotes = new Set(
|
||||||
|
workloads.map((checkout) => remoteIdentity(checkout.remote)),
|
||||||
|
);
|
||||||
|
for (const container of inventory.containers) {
|
||||||
|
const labels = container.Config?.Labels || {};
|
||||||
|
const remote =
|
||||||
|
labels["org.opencontainers.image.source"] ||
|
||||||
|
labels["tech.itworx.forgeflow.repository"] ||
|
||||||
|
"";
|
||||||
|
const revision =
|
||||||
|
labels["org.opencontainers.image.revision"] ||
|
||||||
|
labels["tech.itworx.forgeflow.commit"] ||
|
||||||
|
"";
|
||||||
|
if (
|
||||||
|
!remoteIdentity(remote) ||
|
||||||
|
knownRemotes.has(remoteIdentity(remote)) ||
|
||||||
|
!/^[0-9a-f]{40}$/i.test(revision)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
const workingDir = String(
|
||||||
|
labels["com.docker.compose.project.working_dir"] || "",
|
||||||
|
);
|
||||||
|
const mountedRoot = (container.Mounts || [])
|
||||||
|
.map((mount) => String(mount.Source || ""))
|
||||||
|
.find((source) => source.startsWith(`${server.basePath}/`));
|
||||||
|
workloads.push({
|
||||||
|
root: workingDir || mountedRoot || "",
|
||||||
|
remote,
|
||||||
|
liveSha: revision,
|
||||||
|
branch: labels["tech.itworx.forgeflow.branch"] || "",
|
||||||
|
imageMetadata: true,
|
||||||
|
});
|
||||||
|
knownRemotes.add(remoteIdentity(remote));
|
||||||
|
}
|
||||||
|
const remoteMap = new Map();
|
||||||
|
for (const repository of repositories || []) {
|
||||||
|
for (const remote of [
|
||||||
|
repository.cloneUrl,
|
||||||
|
repository.sshUrl,
|
||||||
|
repository.htmlUrl,
|
||||||
|
]) {
|
||||||
|
const normalized = remoteIdentity(remote);
|
||||||
|
if (normalized) remoteMap.set(normalized, repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const summary = {
|
||||||
|
serverId,
|
||||||
|
detected: 0,
|
||||||
|
adopted: 0,
|
||||||
|
verified: 0,
|
||||||
|
unmatched: 0,
|
||||||
|
};
|
||||||
|
for (const checkout of workloads) {
|
||||||
|
const repository = remoteMap.get(remoteIdentity(checkout.remote));
|
||||||
|
if (!repository) {
|
||||||
|
summary.unmatched += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!checkout.root)
|
||||||
|
checkout.root = path.join(server.basePath, repository.name);
|
||||||
|
const candidates = inventory.containers
|
||||||
|
.map((container) => ({
|
||||||
|
container,
|
||||||
|
score: inventoryContainerMatch(checkout, repository, container),
|
||||||
|
}))
|
||||||
|
.filter((candidate) => candidate.score >= 60)
|
||||||
|
.sort((left, right) => right.score - left.score);
|
||||||
|
if (!candidates.length || candidates[1]?.score === candidates[0].score) {
|
||||||
|
summary.unmatched += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
summary.detected += 1;
|
||||||
|
const container = candidates[0].container;
|
||||||
|
const containerName = String(container.Name || "").replace(/^\//, "");
|
||||||
|
const remoteFolder = checkout.root
|
||||||
|
.slice(server.basePath.length)
|
||||||
|
.replace(/^\/+/, "");
|
||||||
|
if (!/^[A-Za-z0-9._-]+$/.test(remoteFolder)) {
|
||||||
|
summary.unmatched += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existing = this.store
|
||||||
|
.getDeploymentProfiles(repository.fullName)
|
||||||
|
.find(
|
||||||
|
(profile) =>
|
||||||
|
profile.provider === "ssh-unraid" &&
|
||||||
|
profile.serverId === server.id &&
|
||||||
|
(profile.containerName === containerName ||
|
||||||
|
profile.remoteFolder === remoteFolder),
|
||||||
|
);
|
||||||
|
const labels = container.Config?.Labels || {};
|
||||||
|
const portEntry = Object.entries(
|
||||||
|
container.NetworkSettings?.Ports || {},
|
||||||
|
).find(
|
||||||
|
([, bindings]) => Array.isArray(bindings) && bindings[0]?.HostPort,
|
||||||
|
);
|
||||||
|
const containerPort = portEntry
|
||||||
|
? Number(String(portEntry[0]).split("/")[0]) || null
|
||||||
|
: null;
|
||||||
|
const hostPort = portEntry
|
||||||
|
? Number(portEntry[1][0].HostPort) || null
|
||||||
|
: null;
|
||||||
|
const profile =
|
||||||
|
existing ||
|
||||||
|
(await this.store.saveDeploymentProfile(repository.fullName, {
|
||||||
|
id: `auto-${crypto.createHash("sha256").update(`${server.id}:${repository.fullName}:${containerName}`).digest("hex").slice(0, 20)}`,
|
||||||
|
name: `${server.name} · ${containerName}`,
|
||||||
|
environment: "production",
|
||||||
|
provider: "ssh-unraid",
|
||||||
|
branch: checkout.branch || repository.defaultBranch || "main",
|
||||||
|
serverId: server.id,
|
||||||
|
remoteFolder,
|
||||||
|
composeFile:
|
||||||
|
String(labels["com.docker.compose.project.config_files"] || "")
|
||||||
|
.split(",")[0]
|
||||||
|
.split("/")
|
||||||
|
.pop() || "docker-compose.yml",
|
||||||
|
composeService:
|
||||||
|
String(labels["com.docker.compose.service"] || remoteFolder)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9._-]/g, "-") || remoteFolder.toLowerCase(),
|
||||||
|
containerName,
|
||||||
|
cloneUrl: checkout.remote,
|
||||||
|
hostPort,
|
||||||
|
containerPort,
|
||||||
|
webUiUrl: labels["net.unraid.docker.webui"] || "",
|
||||||
|
adoptedFromServer: true,
|
||||||
|
serverSourceOfTruth: true,
|
||||||
|
detectedAt: new Date().toISOString(),
|
||||||
|
detectedMetadata: {
|
||||||
|
confidence: candidates[0].score,
|
||||||
|
source: "automatic-server-inventory",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
if (!existing) summary.adopted += 1;
|
||||||
|
let giteaSha = null;
|
||||||
|
try {
|
||||||
|
const [owner, repo] = repository.fullName.split("/");
|
||||||
|
const branch = await this.gitea.getBranch(owner, repo, profile.branch);
|
||||||
|
giteaSha = branch?.commit?.id || branch?.commit?.sha || null;
|
||||||
|
} catch {}
|
||||||
|
const dockerHealth = String(container.State?.Health?.Status || "");
|
||||||
|
const healthy = dockerHealth
|
||||||
|
? dockerHealth === "healthy"
|
||||||
|
: container.State?.Running === true;
|
||||||
|
await this.store.saveDeploymentState(profile.id, {
|
||||||
|
liveSha: /^[0-9a-f]{40}$/i.test(checkout.liveSha)
|
||||||
|
? checkout.liveSha
|
||||||
|
: null,
|
||||||
|
giteaSha: /^[0-9a-f]{40}$/i.test(giteaSha || "") ? giteaSha : null,
|
||||||
|
matchesGitea: Boolean(giteaSha && checkout.liveSha === giteaSha),
|
||||||
|
healthy,
|
||||||
|
containerRunning: container.State?.Running === true,
|
||||||
|
dockerHealth: dockerHealth || null,
|
||||||
|
containerName,
|
||||||
|
remotePath: checkout.root,
|
||||||
|
provider: "ssh-unraid",
|
||||||
|
discovery: { automatic: true, confidence: candidates[0].score },
|
||||||
|
});
|
||||||
|
if (healthy && giteaSha && checkout.liveSha === giteaSha)
|
||||||
|
summary.verified += 1;
|
||||||
|
}
|
||||||
|
await this.diagnostics?.info("unraid.workloads.discovered", summary);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
async saveOperation(operation) {
|
async saveOperation(operation) {
|
||||||
const saved = await this.store.addOperation(operation);
|
const saved = await this.store.addOperation(operation);
|
||||||
this.onOperationChange?.({ operations: [saved] });
|
this.onOperationChange?.({ operations: [saved] });
|
||||||
@@ -1148,7 +1422,7 @@ chmod 0644 ${shellQuote(templatePath)}
|
|||||||
${this.iconCacheRefresh(profile, repository, iconReference)}`;
|
${this.iconCacheRefresh(profile, repository, iconReference)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
metadataCompose(profile, repository, iconReference = "") {
|
metadataCompose(profile, repository, iconReference = "", deployment = {}) {
|
||||||
const service =
|
const service =
|
||||||
String(profile.composeService || repository.name || "app")
|
String(profile.composeService || repository.name || "app")
|
||||||
.trim()
|
.trim()
|
||||||
@@ -1165,7 +1439,16 @@ ${this.iconCacheRefresh(profile, repository, iconReference)}`;
|
|||||||
const labels = {
|
const labels = {
|
||||||
"net.unraid.docker.managed": "dockerman",
|
"net.unraid.docker.managed": "dockerman",
|
||||||
"net.unraid.docker.shell": this.dockerManShell(profile),
|
"net.unraid.docker.shell": this.dockerManShell(profile),
|
||||||
|
"tech.itworx.forgeflow.repository":
|
||||||
|
deployment.repositoryUrl ||
|
||||||
|
profile.cloneUrl ||
|
||||||
|
repository.sshUrl ||
|
||||||
|
repository.cloneUrl ||
|
||||||
|
repository.htmlUrl ||
|
||||||
|
repository.fullName,
|
||||||
|
"tech.itworx.forgeflow.branch": profile.branch || "main",
|
||||||
};
|
};
|
||||||
|
if (deployment.sha) labels["tech.itworx.forgeflow.commit"] = deployment.sha;
|
||||||
const webUiLabel = this.dockerManWebUi(profile);
|
const webUiLabel = this.dockerManWebUi(profile);
|
||||||
if (webUiLabel) labels["net.unraid.docker.webui"] = webUiLabel;
|
if (webUiLabel) labels["net.unraid.docker.webui"] = webUiLabel;
|
||||||
if (iconReference) labels["net.unraid.docker.icon"] = iconReference;
|
if (iconReference) labels["net.unraid.docker.icon"] = iconReference;
|
||||||
@@ -1327,7 +1610,10 @@ ${this.iconCacheRefresh(profile, repository, iconReference)}`;
|
|||||||
? this.generatedCompose(profile, repository)
|
? this.generatedCompose(profile, repository)
|
||||||
: "";
|
: "";
|
||||||
const iconReference = await this.prepareIcon(profile, repository, server);
|
const iconReference = await this.prepareIcon(profile, repository, server);
|
||||||
const metadata = this.metadataCompose(profile, repository, iconReference);
|
const metadata = this.metadataCompose(profile, repository, iconReference, {
|
||||||
|
sha: targetSha,
|
||||||
|
repositoryUrl: cloneUrl,
|
||||||
|
});
|
||||||
const compose = this.composeInvocation(profile, repository, composeFile);
|
const compose = this.composeInvocation(profile, repository, composeFile);
|
||||||
const branch = String(profile.branch || "main");
|
const branch = String(profile.branch || "main");
|
||||||
const statusJson = JSON.stringify({
|
const statusJson = JSON.stringify({
|
||||||
@@ -1513,7 +1799,11 @@ FORGEFLOW_STATUS
|
|||||||
? ".forgeflow/compose.forgeflow.yml"
|
? ".forgeflow/compose.forgeflow.yml"
|
||||||
: safeRelativeRemoteFile(profile.composeFile || "docker-compose.yml");
|
: safeRelativeRemoteFile(profile.composeFile || "docker-compose.yml");
|
||||||
const iconReference = await this.prepareIcon(profile, repository, server);
|
const iconReference = await this.prepareIcon(profile, repository, server);
|
||||||
const metadata = this.metadataCompose(profile, repository, iconReference);
|
const metadata = this.metadataCompose(profile, repository, iconReference, {
|
||||||
|
sha: target,
|
||||||
|
repositoryUrl:
|
||||||
|
profile.cloneUrl || repository.sshUrl || repository.cloneUrl || "",
|
||||||
|
});
|
||||||
const compose = this.composeInvocation(profile, repository, composeFile);
|
const compose = this.composeInvocation(profile, repository, composeFile);
|
||||||
const requestId = crypto.randomUUID();
|
const requestId = crypto.randomUUID();
|
||||||
const operation = await this.saveOperation({
|
const operation = await this.saveOperation({
|
||||||
@@ -1639,6 +1929,8 @@ if docker inspect "$container" >/dev/null 2>&1; then
|
|||||||
webui=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.webui"}}' "$container" 2>/dev/null || true)
|
webui=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.webui"}}' "$container" 2>/dev/null || true)
|
||||||
icon=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.icon"}}' "$container" 2>/dev/null || true)
|
icon=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.icon"}}' "$container" 2>/dev/null || true)
|
||||||
shell_label=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.shell"}}' "$container" 2>/dev/null || true)
|
shell_label=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.shell"}}' "$container" 2>/dev/null || true)
|
||||||
|
[ -z "$live" ] && live=$(docker inspect -f '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$container" 2>/dev/null || true)
|
||||||
|
[ -z "$live" ] && live=$(docker inspect -f '{{index .Config.Labels "tech.itworx.forgeflow.commit"}}' "$container" 2>/dev/null || true)
|
||||||
fi
|
fi
|
||||||
printf '__FORGEFLOW_KV__\n'
|
printf '__FORGEFLOW_KV__\n'
|
||||||
printf 'liveSha=%s\n' "$live"
|
printf 'liveSha=%s\n' "$live"
|
||||||
@@ -1886,6 +2178,9 @@ module.exports = {
|
|||||||
iconReferenceLocalPath,
|
iconReferenceLocalPath,
|
||||||
decodeBase64Json,
|
decodeBase64Json,
|
||||||
parseDockerManXml,
|
parseDockerManXml,
|
||||||
|
parseServerInventory,
|
||||||
|
inventoryContainerMatch,
|
||||||
|
remoteIdentity,
|
||||||
deriveDetectedProfile,
|
deriveDetectedProfile,
|
||||||
bash,
|
bash,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ class UpdateService {
|
|||||||
const assets = Array.isArray(release.assets) ? release.assets : [];
|
const assets = Array.isArray(release.assets) ? release.assets : [];
|
||||||
const asset = assets.find((item) => item.name === assetName);
|
const asset = assets.find((item) => item.name === assetName);
|
||||||
const checksumAsset = assets.find((item) => item.name === checksumName);
|
const checksumAsset = assets.find((item) => item.name === checksumName);
|
||||||
if (!asset?.browser_download_url || !checksumAsset?.browser_download_url) {
|
if (!asset?.id || !checksumAsset?.id) {
|
||||||
const error = new Error(
|
const error = new Error(
|
||||||
`Release v${update.remoteVersion} is missing ${assetName} or its SHA-256 file.`,
|
`Release v${update.remoteVersion} is missing ${assetName} or its SHA-256 file.`,
|
||||||
);
|
);
|
||||||
@@ -300,8 +300,18 @@ class UpdateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [binary, checksumBytes] = await Promise.all([
|
const [binary, checksumBytes] = await Promise.all([
|
||||||
this.gitea.downloadAuthenticated(asset.browser_download_url),
|
this.gitea.downloadReleaseAsset(
|
||||||
this.gitea.downloadAuthenticated(checksumAsset.browser_download_url),
|
update.owner,
|
||||||
|
update.repo,
|
||||||
|
release.id,
|
||||||
|
asset.id,
|
||||||
|
),
|
||||||
|
this.gitea.downloadReleaseAsset(
|
||||||
|
update.owner,
|
||||||
|
update.repo,
|
||||||
|
release.id,
|
||||||
|
checksumAsset.id,
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
if (binary.length < 1_000_000 || binary[0] !== 0x4d || binary[1] !== 0x5a) {
|
if (binary.length < 1_000_000 || binary[0] !== 0x4d || binary[1] !== 0x5a) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -639,7 +649,10 @@ class UpdateService {
|
|||||||
.catch(() => []);
|
.catch(() => []);
|
||||||
const candidates = [];
|
const candidates = [];
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isFile() || !/^(?:apply|binary)-.*\.status\.json$/i.test(entry.name))
|
if (
|
||||||
|
!entry.isFile() ||
|
||||||
|
!/^(?:apply|binary)-.*\.status\.json$/i.test(entry.name)
|
||||||
|
)
|
||||||
continue;
|
continue;
|
||||||
const filePath = path.join(this.updateDirectory, entry.name);
|
const filePath = path.join(this.updateDirectory, entry.name);
|
||||||
const stat = await fs.stat(filePath).catch(() => null);
|
const stat = await fs.stat(filePath).catch(() => null);
|
||||||
|
|||||||
+77
-17
@@ -159,6 +159,7 @@ const ui = {
|
|||||||
diagnosticsStatus: null,
|
diagnosticsStatus: null,
|
||||||
troubleshooter: null,
|
troubleshooter: null,
|
||||||
deploymentDiscovery: null,
|
deploymentDiscovery: null,
|
||||||
|
serverDiscovery: [],
|
||||||
lastDiagnosticBundle: null,
|
lastDiagnosticBundle: null,
|
||||||
setupDraft: {
|
setupDraft: {
|
||||||
baseUrl: "https://",
|
baseUrl: "https://",
|
||||||
@@ -415,13 +416,33 @@ async function refreshActiveOperations(showErrors = true) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshDeploymentTruth(showErrors = false) {
|
async function refreshDeploymentTruth(showErrors = false) {
|
||||||
|
let discovery = [];
|
||||||
|
try {
|
||||||
|
discovery = (await window.forgeflow.discoverServerDeployments?.()) || [];
|
||||||
|
ui.serverDiscovery = discovery;
|
||||||
|
const adopted = discovery.reduce(
|
||||||
|
(total, server) => total + Number(server.adopted || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
if (adopted > 0) {
|
||||||
|
await refreshRepositories(false, true);
|
||||||
|
showToast(
|
||||||
|
"Server workloads discovered",
|
||||||
|
`${adopted} running deployment${adopted === 1 ? " was" : "s were"} linked to Gitea automatically.`,
|
||||||
|
"success",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (showErrors)
|
||||||
|
showToast("Server discovery unavailable", error.message, "error");
|
||||||
|
}
|
||||||
const targets = ui.repositories.flatMap((repository) =>
|
const targets = ui.repositories.flatMap((repository) =>
|
||||||
(repository.deploymentProfiles || []).map((profile) => ({
|
(repository.deploymentProfiles || []).map((profile) => ({
|
||||||
repository,
|
repository,
|
||||||
profile,
|
profile,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
if (!targets.length) return { checked: 0, failed: 0 };
|
if (!targets.length) return { checked: 0, failed: 0, discovery };
|
||||||
|
|
||||||
const failures = [];
|
const failures = [];
|
||||||
const queue = [...targets];
|
const queue = [...targets];
|
||||||
@@ -454,7 +475,7 @@ async function refreshDeploymentTruth(showErrors = false) {
|
|||||||
"error",
|
"error",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { checked: targets.length, failed: failures.length };
|
return { checked: targets.length, failed: failures.length, discovery };
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectRepository(id, shouldRender = true) {
|
function selectRepository(id, shouldRender = true) {
|
||||||
@@ -773,10 +794,25 @@ function releaseNode(label, value, description, tone = "") {
|
|||||||
return `<div class="release-node"><div class="release-label">${label}</div><div class="release-value"><span class="state-dot ${tone}"></span><strong>${escapeHtml(value)}</strong><span>${escapeHtml(description)}</span></div></div>`;
|
return `<div class="release-node"><div class="release-label">${label}</div><div class="release-value"><span class="state-dot ${tone}"></span><strong>${escapeHtml(value)}</strong><span>${escapeHtml(description)}</span></div></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function diffAtmosphere(diff) {
|
||||||
|
if (!ui.selectedFile) return "";
|
||||||
|
const lines = String(diff || "").split("\n");
|
||||||
|
const additions = lines.filter(
|
||||||
|
(line) => line.startsWith("+") && !line.startsWith("+++"),
|
||||||
|
).length;
|
||||||
|
const removals = lines.filter(
|
||||||
|
(line) => line.startsWith("-") && !line.startsWith("---"),
|
||||||
|
).length;
|
||||||
|
const extension =
|
||||||
|
String(ui.selectedFile).split(".").pop()?.slice(0, 8).toUpperCase() ||
|
||||||
|
"FILE";
|
||||||
|
return `<div class="diff-atmosphere ${lines.length > 34 ? "dense" : ""}" data-diff-atmosphere aria-hidden="true"><svg viewBox="0 0 360 260" role="presentation"><path class="code-route route-a" d="M38 195 C92 84 178 214 318 74"/><path class="code-route route-b" d="M52 74 C132 8 230 34 310 156"/><g class="code-card"><rect x="110" y="75" width="142" height="106" rx="18"/><path d="M136 108h90M136 128h58M136 148h76"/></g><g class="code-node node-one"><circle cx="48" cy="190" r="15"/><path d="m41 190 5 5 9-12"/></g><g class="code-node node-two"><circle cx="315" cy="76" r="13"/><path d="M308 76h14M315 69v14"/></g><circle class="code-packet packet-one" cx="0" cy="0" r="5"/><circle class="code-packet packet-two" cx="0" cy="0" r="4"/></svg><div class="diff-atmosphere-caption"><span>${escapeHtml(extension)} change map</span><strong><i>+${additions}</i><i>−${removals}</i></strong></div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderDiff(diff) {
|
function renderDiff(diff) {
|
||||||
if (!diff)
|
if (!diff)
|
||||||
return '<div class="empty-state"><div class="empty-icon">↔</div><h3>No textual diff</h3><p>Select another file or open the project folder for binary changes.</p></div>';
|
return '<div class="empty-state"><div class="empty-icon">↔</div><h3>No textual diff</h3><p>Select another file or open the project folder for binary changes.</p></div>';
|
||||||
return escapeHtml(diff)
|
const rendered = escapeHtml(diff)
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((line) => {
|
.map((line) => {
|
||||||
const type =
|
const type =
|
||||||
@@ -790,6 +826,7 @@ function renderDiff(diff) {
|
|||||||
return `<span class="diff-line ${type}">${line || " "}</span>`;
|
return `<span class="diff-line ${type}">${line || " "}</span>`;
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
|
return `${rendered}${diffAtmosphere(diff)}`;
|
||||||
}
|
}
|
||||||
function fileStatusCode(file) {
|
function fileStatusCode(file) {
|
||||||
if (file.conflict) return "U";
|
if (file.conflict) return "U";
|
||||||
@@ -887,7 +924,7 @@ function renderProfileCard(repository, profile, compact = false) {
|
|||||||
repository.localStatus?.branch.head === profile.branch;
|
repository.localStatus?.branch.head === profile.branch;
|
||||||
const isSsh = profile.provider === "ssh-unraid";
|
const isSsh = profile.provider === "ssh-unraid";
|
||||||
const providerDetail = isSsh
|
const providerDetail = isSsh
|
||||||
? `SSH / Unraid · ${profile.remoteFolder || repository.name} · ${profile.branch}`
|
? `SSH / Unraid · ${profile.remoteFolder || repository.name} · ${profile.branch}${profile.adoptedFromServer ? " · automatically discovered" : ""}`
|
||||||
: `${profile.workflowFile} · ${profile.branch}`;
|
: `${profile.workflowFile} · ${profile.branch}`;
|
||||||
const rollbackConfigured = isSsh || Boolean(profile.rollbackWorkflowFile);
|
const rollbackConfigured = isSsh || Boolean(profile.rollbackWorkflowFile);
|
||||||
const dockerMan = dockerManIntegration(profile);
|
const dockerMan = dockerManIntegration(profile);
|
||||||
@@ -3259,23 +3296,46 @@ document.addEventListener("keydown", (event) => {
|
|||||||
|
|
||||||
document.addEventListener("pointermove", (event) => {
|
document.addEventListener("pointermove", (event) => {
|
||||||
const illustration = event.target.closest?.("[data-project-illustration]");
|
const illustration = event.target.closest?.("[data-project-illustration]");
|
||||||
if (!illustration) return;
|
if (illustration) {
|
||||||
const bounds = illustration.getBoundingClientRect();
|
const bounds = illustration.getBoundingClientRect();
|
||||||
illustration.style.setProperty(
|
illustration.style.setProperty(
|
||||||
"--tilt-x",
|
"--tilt-x",
|
||||||
`${((event.clientY - bounds.top) / bounds.height - 0.5) * -7}deg`,
|
`${((event.clientY - bounds.top) / bounds.height - 0.5) * -7}deg`,
|
||||||
);
|
);
|
||||||
illustration.style.setProperty(
|
illustration.style.setProperty(
|
||||||
"--tilt-y",
|
"--tilt-y",
|
||||||
`${((event.clientX - bounds.left) / bounds.width - 0.5) * 9}deg`,
|
`${((event.clientX - bounds.left) / bounds.width - 0.5) * 9}deg`,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffPanel = event.target.closest?.(".diff-panel");
|
||||||
|
const atmosphere = diffPanel?.querySelector("[data-diff-atmosphere]");
|
||||||
|
if (atmosphere) {
|
||||||
|
const bounds = diffPanel.getBoundingClientRect();
|
||||||
|
atmosphere.style.setProperty(
|
||||||
|
"--diff-tilt-x",
|
||||||
|
`${((event.clientY - bounds.top) / bounds.height - 0.5) * -3}deg`,
|
||||||
|
);
|
||||||
|
atmosphere.style.setProperty(
|
||||||
|
"--diff-tilt-y",
|
||||||
|
`${((event.clientX - bounds.left) / bounds.width - 0.5) * 4}deg`,
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener("pointerout", (event) => {
|
document.addEventListener("pointerout", (event) => {
|
||||||
const illustration = event.target.closest?.("[data-project-illustration]");
|
const illustration = event.target.closest?.("[data-project-illustration]");
|
||||||
if (!illustration || illustration.contains(event.relatedTarget)) return;
|
if (illustration && !illustration.contains(event.relatedTarget)) {
|
||||||
illustration.style.removeProperty("--tilt-x");
|
illustration.style.removeProperty("--tilt-x");
|
||||||
illustration.style.removeProperty("--tilt-y");
|
illustration.style.removeProperty("--tilt-y");
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffPanel = event.target.closest?.(".diff-panel");
|
||||||
|
if (diffPanel && !diffPanel.contains(event.relatedTarget)) {
|
||||||
|
const atmosphere = diffPanel.querySelector("[data-diff-atmosphere]");
|
||||||
|
atmosphere?.style.removeProperty("--diff-tilt-x");
|
||||||
|
atmosphere?.style.removeProperty("--diff-tilt-y");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("error", (event) => {
|
window.addEventListener("error", (event) => {
|
||||||
|
|||||||
@@ -564,7 +564,7 @@
|
|||||||
await wait(80);
|
await wait(80);
|
||||||
snapshot();
|
snapshot();
|
||||||
return {
|
return {
|
||||||
appVersion: "0.8.4-demo",
|
appVersion: "0.8.8-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" },
|
||||||
@@ -1421,6 +1421,18 @@
|
|||||||
syncState();
|
syncState();
|
||||||
return clone(target.state);
|
return clone(target.state);
|
||||||
},
|
},
|
||||||
|
async discoverServerDeployments() {
|
||||||
|
await wait(80);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
serverId: "unraid-primary",
|
||||||
|
detected: 2,
|
||||||
|
adopted: 0,
|
||||||
|
verified: 1,
|
||||||
|
unmatched: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
async refreshOperations(operationId = null) {
|
async refreshOperations(operationId = null) {
|
||||||
await wait(300);
|
await wait(300);
|
||||||
if (operationId) {
|
if (operationId) {
|
||||||
|
|||||||
+212
-4
@@ -970,27 +970,56 @@ select:focus-visible {
|
|||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
.file-row {
|
.file-row {
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 34px;
|
min-height: 38px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 17px 17px minmax(0, 1fr) 16px;
|
grid-template-columns: 17px 17px minmax(0, 1fr) 16px;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 3px 6px;
|
padding: 4px 8px;
|
||||||
border-radius: 4px;
|
border: 1px solid transparent;
|
||||||
|
border-radius: 7px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
transform 160ms ease,
|
||||||
|
border-color 160ms ease,
|
||||||
|
background 160ms ease,
|
||||||
|
box-shadow 160ms ease;
|
||||||
}
|
}
|
||||||
.file-row:hover {
|
.file-row:hover {
|
||||||
background: var(--surface-hover);
|
background: var(--surface-hover);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 18%, transparent);
|
||||||
|
transform: translateX(2px);
|
||||||
}
|
}
|
||||||
.file-row.active {
|
.file-row.active {
|
||||||
background: var(--primary-soft);
|
background:
|
||||||
|
linear-gradient(90deg, var(--primary-soft), transparent 110%),
|
||||||
|
var(--surface-1);
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 36%, var(--line));
|
||||||
|
box-shadow:
|
||||||
|
inset 3px 0 0 var(--primary),
|
||||||
|
0 7px 22px rgba(0, 0, 0, 0.12);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
.file-row > span:last-child {
|
||||||
|
color: var(--text-faint);
|
||||||
|
filter: drop-shadow(0 0 5px transparent);
|
||||||
|
transition:
|
||||||
|
color 160ms ease,
|
||||||
|
filter 160ms ease;
|
||||||
|
}
|
||||||
|
.file-row:hover > span:last-child,
|
||||||
|
.file-row.active > span:last-child {
|
||||||
|
color: var(--primary);
|
||||||
|
filter: drop-shadow(
|
||||||
|
0 0 5px color-mix(in srgb, var(--primary) 45%, transparent)
|
||||||
|
);
|
||||||
|
}
|
||||||
.file-row input {
|
.file-row input {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
accent-color: var(--primary-strong);
|
accent-color: var(--primary-strong);
|
||||||
@@ -1003,6 +1032,12 @@ select:focus-visible {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
.file-status {
|
.file-status {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: color-mix(in srgb, currentColor 11%, transparent);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
@@ -1017,6 +1052,7 @@ select:focus-visible {
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
.diff-panel {
|
.diff-panel {
|
||||||
|
container-type: inline-size;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -1041,6 +1077,8 @@ select:focus-visible {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.diff-view {
|
.diff-view {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 8px 0 36px;
|
padding: 8px 0 36px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@@ -1048,12 +1086,179 @@ select:focus-visible {
|
|||||||
line-height: 19px;
|
line-height: 19px;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
tab-size: 2;
|
tab-size: 2;
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
circle at 84% 72%,
|
||||||
|
color-mix(in srgb, var(--primary) 8%, transparent),
|
||||||
|
transparent 28%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
circle at 72% 88%,
|
||||||
|
color-mix(in srgb, var(--success) 5%, transparent),
|
||||||
|
transparent 24%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
.diff-line {
|
.diff-line {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
display: block;
|
display: block;
|
||||||
min-height: 19px;
|
min-height: 19px;
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
}
|
}
|
||||||
|
.diff-atmosphere {
|
||||||
|
--diff-tilt-x: 0deg;
|
||||||
|
--diff-tilt-y: 0deg;
|
||||||
|
position: absolute;
|
||||||
|
right: clamp(24px, 7vw, 110px);
|
||||||
|
bottom: clamp(28px, 8vh, 90px);
|
||||||
|
z-index: 0;
|
||||||
|
width: min(360px, 34vw);
|
||||||
|
color: var(--primary);
|
||||||
|
opacity: 0.38;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: perspective(850px) rotateX(var(--diff-tilt-x))
|
||||||
|
rotateY(var(--diff-tilt-y));
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
transition:
|
||||||
|
transform 220ms ease-out,
|
||||||
|
opacity 180ms ease;
|
||||||
|
}
|
||||||
|
.diff-atmosphere.dense {
|
||||||
|
opacity: 0.14;
|
||||||
|
}
|
||||||
|
.diff-atmosphere svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.code-route {
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.2;
|
||||||
|
stroke-dasharray: 4 8;
|
||||||
|
opacity: 0.42;
|
||||||
|
}
|
||||||
|
.code-route.route-b {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
.code-card rect {
|
||||||
|
fill: color-mix(in srgb, var(--surface-2) 72%, transparent);
|
||||||
|
stroke: color-mix(in srgb, var(--primary) 60%, var(--line));
|
||||||
|
stroke-width: 1.2;
|
||||||
|
filter: drop-shadow(0 18px 28px rgba(0, 0, 0, 0.24));
|
||||||
|
}
|
||||||
|
.code-card path {
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-width: 4;
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
.code-node circle {
|
||||||
|
fill: var(--surface-2);
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
}
|
||||||
|
.code-node path {
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
.node-one {
|
||||||
|
color: var(--success);
|
||||||
|
animation: code-node-float 5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.node-two {
|
||||||
|
animation: code-node-float 5s ease-in-out -2.5s infinite;
|
||||||
|
}
|
||||||
|
.code-packet {
|
||||||
|
fill: var(--primary);
|
||||||
|
filter: drop-shadow(0 0 8px currentColor);
|
||||||
|
offset-path: path("M38 195 C92 84 178 214 318 74");
|
||||||
|
animation: code-packet-travel 5.4s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||||
|
}
|
||||||
|
.code-packet.packet-two {
|
||||||
|
fill: var(--success);
|
||||||
|
offset-path: path("M52 74 C132 8 230 34 310 156");
|
||||||
|
animation-delay: -2.7s;
|
||||||
|
}
|
||||||
|
.diff-atmosphere-caption {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin: -10px 42px 0;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--primary) 32%, transparent);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font: 700 9px/1 var(--font-mono);
|
||||||
|
letter-spacing: 0.11em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.diff-atmosphere-caption strong {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.diff-atmosphere-caption i {
|
||||||
|
color: var(--success);
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
.diff-atmosphere-caption i + i {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
@keyframes code-packet-travel {
|
||||||
|
0% {
|
||||||
|
offset-distance: 0%;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
12%,
|
||||||
|
82% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
offset-distance: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes code-node-float {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-6px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .diff-view {
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
circle at 84% 72%,
|
||||||
|
rgba(72, 92, 220, 0.12),
|
||||||
|
transparent 30%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
circle at 72% 88%,
|
||||||
|
rgba(15, 148, 108, 0.08),
|
||||||
|
transparent 25%
|
||||||
|
),
|
||||||
|
linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(248, 251, 255, 0.88),
|
||||||
|
rgba(239, 245, 255, 0.62)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .diff-atmosphere {
|
||||||
|
opacity: 0.46;
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .diff-atmosphere.dense {
|
||||||
|
opacity: 0.18;
|
||||||
|
}
|
||||||
|
@container (max-width: 560px) {
|
||||||
|
.diff-atmosphere {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
.diff-line.add {
|
.diff-line.add {
|
||||||
background: rgba(38, 166, 115, 0.14);
|
background: rgba(38, 166, 115, 0.14);
|
||||||
color: #8ef0c6;
|
color: #8ef0c6;
|
||||||
@@ -3408,6 +3613,9 @@ html[data-theme="light"] .visual-page-header {
|
|||||||
.project-illustration svg {
|
.project-illustration svg {
|
||||||
transform: none !important;
|
transform: none !important;
|
||||||
}
|
}
|
||||||
|
.diff-atmosphere {
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
|
|||||||
@@ -107,3 +107,22 @@ test('creates controlled pull requests and reads branch protection', async () =>
|
|||||||
assert.deepEqual(create.options.body, { head: 'feature', base: 'main', title: 'Release feature', body: 'Summary' });
|
assert.deepEqual(create.options.body, { head: 'feature', base: 'main', title: 'Release feature', body: 'Summary' });
|
||||||
await assert.rejects(() => service.createPullRequest({ owner: 'owner', repo: 'app', head: 'main', base: 'main', title: 'Invalid' }), /different/);
|
await assert.rejects(() => service.createPullRequest({ owner: 'owner', repo: 'app', head: 'main', base: 'main', title: 'Invalid' }), /different/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('downloads release assets through the release-scoped Gitea endpoint', async () => {
|
||||||
|
const service = new GiteaService(makeStore());
|
||||||
|
let requested = '';
|
||||||
|
service.downloadAuthenticated = async (pathname) => {
|
||||||
|
requested = pathname;
|
||||||
|
return Buffer.from('asset');
|
||||||
|
};
|
||||||
|
const asset = await service.downloadReleaseAsset('Jens', 'ForgeFlow', 107, 412);
|
||||||
|
assert.equal(asset.toString(), 'asset');
|
||||||
|
assert.equal(
|
||||||
|
requested,
|
||||||
|
'/api/v1/repos/Jens/ForgeFlow/releases/107/assets/412',
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
() => service.downloadReleaseAsset('Jens', 'ForgeFlow', null, 412),
|
||||||
|
/invalid release ID/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -226,3 +226,20 @@ test("interactive project illustrations are semantic, responsive and motion-safe
|
|||||||
assert.match(styles, /prefers-reduced-motion/);
|
assert.match(styles, /prefers-reduced-motion/);
|
||||||
assert.match(styles, /transform: none !important/);
|
assert.match(styles, /transform: none !important/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the diff canvas uses a contextual and motion-safe code illustration", async () => {
|
||||||
|
const renderer = await readFile(
|
||||||
|
new URL("../src/renderer/app.js", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const styles = await readFile(
|
||||||
|
new URL("../src/renderer/styles.css", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(renderer, /function diffAtmosphere/);
|
||||||
|
assert.match(renderer, /data-diff-atmosphere/);
|
||||||
|
assert.match(renderer, /--diff-tilt-x/);
|
||||||
|
assert.match(styles, /\.diff-atmosphere/);
|
||||||
|
assert.match(styles, /@keyframes code-packet-travel/);
|
||||||
|
assert.match(styles, /prefers-reduced-motion/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ const {
|
|||||||
parseInspection,
|
parseInspection,
|
||||||
dockerIgnoreHasPath,
|
dockerIgnoreHasPath,
|
||||||
checksSummary,
|
checksSummary,
|
||||||
|
parseServerInventory,
|
||||||
|
inventoryContainerMatch,
|
||||||
|
remoteIdentity,
|
||||||
xmlEscape,
|
xmlEscape,
|
||||||
bash,
|
bash,
|
||||||
} = require("../src/main/unraid-deployment-service.cjs");
|
} = require("../src/main/unraid-deployment-service.cjs");
|
||||||
@@ -37,6 +40,97 @@ test("server inspection key-value payload is decoded safely", () => {
|
|||||||
assert.deepEqual(parsed.existingPreservePaths, ["data", "logs"]);
|
assert.deepEqual(parsed.existingPreservePaths, ["data", "logs"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("server workload inventory links running containers to exact Gitea checkouts", () => {
|
||||||
|
const b64 = (value) => Buffer.from(value).toString("base64");
|
||||||
|
const inspect = JSON.stringify([
|
||||||
|
{
|
||||||
|
Name: "/Portfolio",
|
||||||
|
State: { Running: true, Health: { Status: "healthy" } },
|
||||||
|
Config: {
|
||||||
|
Labels: {
|
||||||
|
"com.docker.compose.project.working_dir":
|
||||||
|
"/mnt/user/appdata/Portfolio",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Mounts: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const inventory = parseServerInventory(
|
||||||
|
`noise\n__FORGEFLOW_INVENTORY__\nR\t${b64("/mnt/user/appdata/Portfolio")}\t${b64("git@gitea.itworx.tech:Jens/Portfolio.git")}\t${"a".repeat(40)}\t${b64("main")}\nC\t${b64(inspect)}\n`,
|
||||||
|
);
|
||||||
|
assert.equal(inventory.checkouts.length, 1);
|
||||||
|
assert.equal(inventory.containers.length, 1);
|
||||||
|
assert.equal(
|
||||||
|
remoteIdentity("git@gitea.itworx.tech:Jens/Portfolio.git"),
|
||||||
|
remoteIdentity("https://gitea.itworx.tech/Jens/Portfolio"),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
inventoryContainerMatch(
|
||||||
|
inventory.checkouts[0],
|
||||||
|
{ name: "Portfolio" },
|
||||||
|
inventory.containers[0],
|
||||||
|
),
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("automatic server discovery adopts and verifies a running Gitea deployment", async () => {
|
||||||
|
const b64 = (value) => Buffer.from(value).toString("base64");
|
||||||
|
const sha = "b".repeat(40);
|
||||||
|
const container = {
|
||||||
|
Name: "/Portfolio",
|
||||||
|
State: { Running: true, Health: { Status: "healthy" } },
|
||||||
|
Config: {
|
||||||
|
Labels: {
|
||||||
|
"com.docker.compose.project.working_dir": "/mnt/user/appdata/Portfolio",
|
||||||
|
"com.docker.compose.service": "portfolio",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Mounts: [],
|
||||||
|
NetworkSettings: { Ports: { "3000/tcp": [{ HostPort: "8080" }] } },
|
||||||
|
};
|
||||||
|
const profiles = [];
|
||||||
|
const states = new Map();
|
||||||
|
const service = new UnraidDeploymentService({
|
||||||
|
store: {
|
||||||
|
getServer: () => ({
|
||||||
|
id: "unraid",
|
||||||
|
name: "Unraid",
|
||||||
|
basePath: "/mnt/user/appdata",
|
||||||
|
}),
|
||||||
|
getDeploymentProfiles: () => profiles,
|
||||||
|
saveDeploymentProfile: async (_fullName, profile) => {
|
||||||
|
profiles.push(profile);
|
||||||
|
return profile;
|
||||||
|
},
|
||||||
|
saveDeploymentState: async (id, state) => {
|
||||||
|
states.set(id, state);
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ssh: {
|
||||||
|
exec: async () => ({
|
||||||
|
stdout: `__FORGEFLOW_INVENTORY__\nR\t${b64("/mnt/user/appdata/Portfolio")}\t${b64("git@gitea.itworx.tech:Jens/Portfolio.git")}\t${sha}\t${b64("main")}\nC\t${b64(JSON.stringify([container]))}\n`,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
gitea: { getBranch: async () => ({ commit: { id: sha } }) },
|
||||||
|
});
|
||||||
|
const result = await service.discoverServerWorkloads("unraid", [
|
||||||
|
{
|
||||||
|
fullName: "Jens/Portfolio",
|
||||||
|
name: "Portfolio",
|
||||||
|
defaultBranch: "main",
|
||||||
|
cloneUrl: "https://gitea.itworx.tech/Jens/Portfolio.git",
|
||||||
|
sshUrl: "git@gitea.itworx.tech:Jens/Portfolio.git",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.equal(result.adopted, 1);
|
||||||
|
assert.equal(result.verified, 1);
|
||||||
|
assert.equal(profiles[0].containerName, "Portfolio");
|
||||||
|
assert.equal(profiles[0].adoptedFromServer, true);
|
||||||
|
assert.equal(states.get(profiles[0].id).matchesGitea, true);
|
||||||
|
});
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
@@ -336,22 +336,27 @@ test("packaged updater downloads only a published checksum-matched Windows asset
|
|||||||
async getReleaseByTag(_owner, _repo, tag) {
|
async getReleaseByTag(_owner, _repo, tag) {
|
||||||
if (tag !== "v0.8.2") return null;
|
if (tag !== "v0.8.2") return null;
|
||||||
return {
|
return {
|
||||||
|
id: 82,
|
||||||
tag_name: tag,
|
tag_name: tag,
|
||||||
draft: false,
|
draft: false,
|
||||||
prerelease: false,
|
prerelease: false,
|
||||||
assets: [
|
assets: [
|
||||||
{ name: assetName, browser_download_url: "https://gitea.test/setup" },
|
{
|
||||||
|
id: 41,
|
||||||
|
name: assetName,
|
||||||
|
browser_download_url: "http://wrong-origin.test/setup",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: `${assetName}.sha256`,
|
name: `${assetName}.sha256`,
|
||||||
browser_download_url: "https://gitea.test/checksum",
|
id: 42,
|
||||||
|
browser_download_url: "http://wrong-origin.test/checksum",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
async downloadAuthenticated(url) {
|
async downloadReleaseAsset(_owner, _repo, releaseId, assetId) {
|
||||||
return url.endsWith("/checksum")
|
assert.equal(releaseId, 82);
|
||||||
? Buffer.from(`${sha256} ${assetName}\n`)
|
return assetId === 42 ? Buffer.from(`${sha256} ${assetName}\n`) : binary;
|
||||||
: binary;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const service = new UpdateService({
|
const service = new UpdateService({
|
||||||
@@ -395,21 +400,25 @@ test("packaged updater rejects a binary whose checksum does not match", async ()
|
|||||||
gitea: {
|
gitea: {
|
||||||
async getReleaseByTag() {
|
async getReleaseByTag() {
|
||||||
return {
|
return {
|
||||||
|
id: 83,
|
||||||
tag_name: "v0.8.2",
|
tag_name: "v0.8.2",
|
||||||
assets: [
|
assets: [
|
||||||
{
|
{
|
||||||
|
id: 51,
|
||||||
name: assetName,
|
name: assetName,
|
||||||
browser_download_url: "https://gitea.test/portable",
|
browser_download_url: "http://wrong-origin.test/portable",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 52,
|
||||||
name: `${assetName}.sha256`,
|
name: `${assetName}.sha256`,
|
||||||
browser_download_url: "https://gitea.test/checksum",
|
browser_download_url: "http://wrong-origin.test/checksum",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
async downloadAuthenticated(url) {
|
async downloadReleaseAsset(_owner, _repo, releaseId, assetId) {
|
||||||
return url.endsWith("/checksum")
|
assert.equal(releaseId, 83);
|
||||||
|
return assetId === 52
|
||||||
? Buffer.from(`${"0".repeat(64)} ${assetName}`)
|
? Buffer.from(`${"0".repeat(64)} ${assetName}`)
|
||||||
: binary;
|
: binary;
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user