Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40d41f9a32 | ||
|
|
70840839f8 | ||
|
|
1310d2e5a7 | ||
|
|
095342b882 | ||
|
|
cb95c407d9 | ||
|
|
b7daef3cd9 |
+37
-4
@@ -1,5 +1,41 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- added interactive animated release-flow illustrations to high-value project surfaces;
|
||||||
|
- introduced cursor-responsive depth, travelling deployment signals and living status nodes;
|
||||||
|
- added compact repository illustration watermarks without reducing usable header space;
|
||||||
|
- made every illustration theme-aware, responsive, semantic and reduced-motion safe.
|
||||||
|
|
||||||
|
## 0.8.3 - 2026-07-26
|
||||||
|
|
||||||
|
- enriched light mode with layered color, depth and stronger navigation hierarchy;
|
||||||
|
- made every deployment visually identifiable by container, repository, environment and stable accent color;
|
||||||
|
- added live-versus-Gitea commit proof directly to deployment cards;
|
||||||
|
- reconciled stale failed operations against healthy live Unraid and current Gitea truth.
|
||||||
|
|
||||||
## 0.8.2 - 2026-07-26
|
## 0.8.2 - 2026-07-26
|
||||||
|
|
||||||
- enabled checksum-verified binary auto-update for installed and portable Windows builds;
|
- enabled checksum-verified binary auto-update for installed and portable Windows builds;
|
||||||
@@ -35,7 +71,6 @@
|
|||||||
- Fixed Windows PowerShell 5.1 updater status replacement and STARTED handshake.
|
- Fixed Windows PowerShell 5.1 updater status replacement and STARTED handshake.
|
||||||
- Added helper-log diagnostics and update-request identity validation.
|
- Added helper-log diagnostics and update-request identity validation.
|
||||||
|
|
||||||
|
|
||||||
## 0.6.0
|
## 0.6.0
|
||||||
|
|
||||||
- Added complete repository troubleshooting for stale `HEAD.lock`, `index.lock`, ref locks and diverged branches.
|
- Added complete repository troubleshooting for stale `HEAD.lock`, `index.lock`, ref locks and diverged branches.
|
||||||
@@ -57,7 +92,6 @@
|
|||||||
- Preserved configured Compose casing such as Portfolio.
|
- Preserved configured Compose casing such as Portfolio.
|
||||||
- Guaranteed failed remote operations become terminal failed records.
|
- Guaranteed failed remote operations become terminal failed records.
|
||||||
|
|
||||||
|
|
||||||
## 0.5.3
|
## 0.5.3
|
||||||
|
|
||||||
- Confirmed updater handoff before application exit.
|
- Confirmed updater handoff before application exit.
|
||||||
@@ -70,7 +104,6 @@
|
|||||||
- Added portable structural safety validation for the Unraid deployment script.
|
- Added portable structural safety validation for the Unraid deployment script.
|
||||||
- Kept GNU Bash syntax validation on Linux and other non-Windows systems.
|
- Kept GNU Bash syntax validation on Linux and other non-Windows systems.
|
||||||
|
|
||||||
|
|
||||||
## 0.5.1
|
## 0.5.1
|
||||||
|
|
||||||
- Fixed Windows publication quality gate by validating Bash syntax through standard input.
|
- Fixed Windows publication quality gate by validating Bash syntax through standard input.
|
||||||
@@ -84,7 +117,6 @@
|
|||||||
- Bulk normalization of legacy Gitea origins.
|
- Bulk normalization of legacy Gitea origins.
|
||||||
- Expanded regression coverage.
|
- Expanded regression coverage.
|
||||||
|
|
||||||
|
|
||||||
- Correctly stage deleted and renamed paths.
|
- Correctly stage deleted and renamed paths.
|
||||||
- Preserve and surface local commits when push fails.
|
- Preserve and surface local commits when push fails.
|
||||||
- Always refresh the actual Git state after operation errors.
|
- Always refresh the actual Git state after operation errors.
|
||||||
@@ -156,6 +188,7 @@
|
|||||||
- Added safe workflow and server deployment examples.
|
- Added safe workflow and server deployment examples.
|
||||||
|
|
||||||
## 0.4.5
|
## 0.4.5
|
||||||
|
|
||||||
- Fixed commit/push after manually staging a deleted file.
|
- Fixed commit/push after manually staging a deleted file.
|
||||||
- Already staged deletions and renames are no longer re-added as missing pathspecs.
|
- Already staged deletions and renames are no longer re-added as missing pathspecs.
|
||||||
- Added a real bare-remote regression test for `silent-zebra-glow.zip`.
|
- Added a real bare-remote regression test for `silent-zebra-glow.zip`.
|
||||||
|
|||||||
+24
-18
@@ -1,4 +1,4 @@
|
|||||||
ForgeFlow 0.8.2 source manifest
|
ForgeFlow 0.8.7 source manifest
|
||||||
SHA-256 BYTES PATH
|
SHA-256 BYTES PATH
|
||||||
(The manifest excludes itself, dependencies and generated release artifacts.)
|
(The manifest excludes itself, dependencies and generated release artifacts.)
|
||||||
755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 58 .gitignore
|
755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 58 .gitignore
|
||||||
@@ -12,7 +12,7 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263
|
|||||||
4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183 3364 build/icon-64.png
|
4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183 3364 build/icon-64.png
|
||||||
25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico
|
25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico
|
||||||
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png
|
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png
|
||||||
973e58a92fb2fdaff471dbe7a20549c9fc79e6014933cd56dce971ad7d474bd4 8234 CHANGELOG.md
|
feac8256bec2762533c6dd76a49fceaeafb4935528e406158598e8ba55500a6e 10277 CHANGELOG.md
|
||||||
21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md
|
21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md
|
||||||
a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md
|
a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md
|
||||||
30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md
|
30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md
|
||||||
@@ -40,6 +40,11 @@ f3d04f2d3419a7a010d5399cdd9351ff85ab2b3fdf8023977e559b0a5f8bcdc3 2571
|
|||||||
d7bdc61d9b617ad5acf0b2d468eda547fd7509d4af08f33d2661393f25bdcb5a 576 docs/RELEASE_NOTES_0.8.0.md
|
d7bdc61d9b617ad5acf0b2d468eda547fd7509d4af08f33d2661393f25bdcb5a 576 docs/RELEASE_NOTES_0.8.0.md
|
||||||
1e056bfcf2105843402f4b14c63480240cc55456a4a63e229b3fdbaf3156b803 754 docs/RELEASE_NOTES_0.8.1.md
|
1e056bfcf2105843402f4b14c63480240cc55456a4a63e229b3fdbaf3156b803 754 docs/RELEASE_NOTES_0.8.1.md
|
||||||
7f1d7c8bc895d309dad2f8ab444d6d2ba3e68c8daa240fecd2abdd9d8a56ba20 729 docs/RELEASE_NOTES_0.8.2.md
|
7f1d7c8bc895d309dad2f8ab444d6d2ba3e68c8daa240fecd2abdd9d8a56ba20 729 docs/RELEASE_NOTES_0.8.2.md
|
||||||
|
c2802fa5dbff392c846b82b55e84a8bfb8e1625546fd1eba39f7129318bece96 654 docs/RELEASE_NOTES_0.8.3.md
|
||||||
|
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
|
||||||
2b631b9d6d973bdd70869d84886ff339da351e29e17598970b3b27915674661d 4175 docs/ROADMAP.md
|
2b631b9d6d973bdd70869d84886ff339da351e29e17598970b3b27915674661d 4175 docs/ROADMAP.md
|
||||||
1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png
|
1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png
|
||||||
b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png
|
b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png
|
||||||
@@ -52,7 +57,7 @@ bcb1e4daf1eeedc5b3f61d2406f1a65312dba130082528007e1629d9df99570a 153240
|
|||||||
b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md
|
b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md
|
||||||
0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md
|
0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md
|
||||||
4625a10ebd3c749f60b2a7bef6b1716cd05dbc44ccceba0491a1b46bc293c195 4883 docs/TEST_MATRIX.md
|
4625a10ebd3c749f60b2a7bef6b1716cd05dbc44ccceba0491a1b46bc293c195 4883 docs/TEST_MATRIX.md
|
||||||
28f42ed6352a01e034c39c5a2a2f461f3f540aa37abc339e5b00afcc81e7ba7b 3273 docs/UPDATING.md
|
b3f317faba63d574ee83924f01b5275c118b5b04f6d9109c7cf9ed18500835d8 3389 docs/UPDATING.md
|
||||||
c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 examples/gitea-actions/deploy.yml
|
c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 examples/gitea-actions/deploy.yml
|
||||||
4c792cc9fd57ed36da291300c252a6ef75b08a249cf6f2561e23c4c22522138a 1477 examples/gitea-actions/rollback.yml
|
4c792cc9fd57ed36da291300c252a6ef75b08a249cf6f2561e23c4c22522138a 1477 examples/gitea-actions/rollback.yml
|
||||||
1d2cde1bef4882f56006823d2806f6105882fa098a665a303150fdf18ada2004 5705 examples/server/forgeflow-deploy
|
1d2cde1bef4882f56006823d2806f6105882fa098a665a303150fdf18ada2004 5705 examples/server/forgeflow-deploy
|
||||||
@@ -61,11 +66,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
|
||||||
ebb0b154137c113205351216e79dc0aad79949ea465b1734f41dd524f913cb34 134141 package-lock.json
|
5610ef8f25cc47b5296c57edb29d5e1b605de4113b4b0128fa451b9ec16bba2c 130466 package-lock.json
|
||||||
4ad40664dd3ed8526685fc7ca75be8f8c21acbad515b375ba4f04f3e2c4dcfe1 3559 package.json
|
faae3af578a40306472494f5816855bf9422425b6daf39732fe04cbad55675ae 3869 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
|
||||||
@@ -73,10 +78,11 @@ a6d32a742412b7836606be00f17be0465f1b6f55d3911f6c73a14029787ba206 14037
|
|||||||
f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287 scripts/apply-source-update.ps1
|
f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287 scripts/apply-source-update.ps1
|
||||||
6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs
|
6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs
|
||||||
5e9a2a819522f6a32bbd9d3303263d5e5eaec95898ea2cd5776b221168008d75 1727 scripts/generate-source-manifest.mjs
|
5e9a2a819522f6a32bbd9d3303263d5e5eaec95898ea2cd5776b221168008d75 1727 scripts/generate-source-manifest.mjs
|
||||||
|
842436680521311594e798848b050ae4e488d0595f0de57315f6ec081c049fb9 1266 scripts/prune-dist.mjs
|
||||||
74433d8a6b24afe368197a469e2fe0c5050c239d7250b84c2f3f598c304778b0 4736 scripts/publish-binary-release.cjs
|
74433d8a6b24afe368197a469e2fe0c5050c239d7250b84c2f3f598c304778b0 4736 scripts/publish-binary-release.cjs
|
||||||
444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs
|
444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs
|
||||||
42203f9e0fd4aae517284d387f265cf1b0b180379bc253a092b5c3c5c4caef0a 2992 scripts/validate-installed-connections.cjs
|
42203f9e0fd4aae517284d387f265cf1b0b180379bc253a092b5c3c5c4caef0a 2992 scripts/validate-installed-connections.cjs
|
||||||
e6def656ba61e6ac705bc87e59abc607f4870702f0cbd609bc3e122bb01a9939 12150 scripts/verify.mjs
|
e77edf02fc9b9afb398ae46767e634f47259e58288ce9a18bc696b028234b581 12341 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
|
||||||
@@ -86,24 +92,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
|
75f25fa8ee520b03b0bbe4c4cea439853202d318547343cda49a19c6ab868901 16984 src/main/gitea-service.cjs
|
||||||
b2d768a9dfd1e494edee6609a233469e60c31c362143d5c37c3c9f908b7bca79 40559 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
|
||||||
720103f14cbedd7fd2776e49fd970a634f14e03d548d90bf93bcd878b6b3c674 58758 src/main/unraid-deployment-service.cjs
|
f4e221edace12e2120ec439e0f91274de110872c5bfbe968f2d7935eeb7193c4 78544 src/main/unraid-deployment-service.cjs
|
||||||
36cc05deda3395e5e9de92b880c8315f508cb88e9b080ae34705057ae696804f 20696 src/main/update-service.cjs
|
27b9e15dd6530bc7bfab51414430eaef169e0cc4acd027257b1c63f891af8c85 20733 src/main/update-service.cjs
|
||||||
2df4cd7b7d685871e40ce86ce4f75d8451cfdfca6184ce6cae5eaa6651ce97da 191018 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
|
||||||
d72bca0e649392dbf5c0e9c676dd08b80d5c9f4493f59a32d9201763c139b690 50942 src/renderer/mock-bridge.js
|
3faf63826ee693b1afcfcc632238dd71e7969641e2502ef6884b40f3855e9d6b 51166 src/renderer/mock-bridge.js
|
||||||
51f6777fd7d2dc73dc3ddd96c91a11882483099b0a62e0ce172e25e3dce474a6 59851 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
|
||||||
@@ -131,7 +137,7 @@ e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690
|
|||||||
caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs
|
caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs
|
||||||
96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs
|
96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs
|
||||||
c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 3718 tests/preflight.test.mjs
|
c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 3718 tests/preflight.test.mjs
|
||||||
9b56c259cbf6c45c671267343c0871d56ac336082c531050b44fbcfca7476f62 6989 tests/renderer-workflow.test.mjs
|
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
|
||||||
@@ -139,8 +145,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
|
||||||
54f641103a91d98974c41a3c0a568c617b76fa9913a0e20710cd11adc39b0deb 18092 tests/unraid-deployment.test.mjs
|
8f44579de3af7d7d23d55572d1d97cb0690425d4d9dffc30c6bf2ec3ff9c54eb 24553 tests/unraid-deployment.test.mjs
|
||||||
0c49d3222ea362dbef171f5ad556a335bad670b47adef660ede101d84814d05e 14849 tests/update-service.test.mjs
|
f1f0f13ac41f47c9df8ef778d7b98c09a589f61e6f76e14bc0e8943c5ede7560 14990 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,13 @@
|
|||||||
|
# ForgeFlow 0.8.3
|
||||||
|
|
||||||
|
ForgeFlow 0.8.3 refreshes the complete light appearance with richer surfaces,
|
||||||
|
subtle color, clearer depth and stronger active states.
|
||||||
|
|
||||||
|
Deployment cards now lead with the exact container identity, repository,
|
||||||
|
environment and a stable visual accent. Live and Gitea commits are shown side by
|
||||||
|
side, with an explicit confirmation when both sources agree.
|
||||||
|
|
||||||
|
Deployment reconciliation now revisits stale failed records. When the requested
|
||||||
|
commit is healthy on Unraid it is corrected to success. When a newer commit is
|
||||||
|
both live and current on Gitea, the old failure is marked as superseded instead
|
||||||
|
of remaining an apparently current incident.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# ForgeFlow 0.8.4
|
||||||
|
|
||||||
|
ForgeFlow 0.8.4 adds interactive project illustrations where they reinforce the
|
||||||
|
release story. The overview now visualizes commits travelling through a release
|
||||||
|
topology, with animated status nodes and cursor-responsive depth.
|
||||||
|
|
||||||
|
Repository headers reuse the same visual language as a subtle animated
|
||||||
|
watermark. Illustrations adapt to light and dark themes, collapse gracefully at
|
||||||
|
compact widths and disable non-essential movement when reduced motion is
|
||||||
|
requested by the operating system.
|
||||||
@@ -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.
|
||||||
@@ -40,6 +40,8 @@ $env:FORGEFLOW_USER_DATA = "$env:APPDATA\forgeflow"
|
|||||||
npm run release:binary
|
npm run release:binary
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Every platform build finishes by removing ForgeFlow artifacts for older versions from `dist`. The unpacked application directory and builder diagnostics are kept.
|
||||||
|
|
||||||
The publisher refuses to upload when local `HEAD` differs from `origin/main`. Users on 0.8.1 or older need one manual 0.8.2 installation because those versions deliberately disabled packaged updates.
|
The publisher refuses to upload when local `HEAD` differs from `origin/main`. Users on 0.8.1 or older need one manual 0.8.2 installation because those versions deliberately disabled packaged updates.
|
||||||
|
|
||||||
## Publishing a release from Downloads
|
## Publishing a release from Downloads
|
||||||
|
|||||||
@@ -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.2",
|
"version": "0.8.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "forgeflow",
|
"name": "forgeflow",
|
||||||
"version": "0.8.2",
|
"version": "0.8.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ssh2": "1.17.0"
|
"ssh2": "1.17.0"
|
||||||
},
|
},
|
||||||
|
|||||||
+10
-4
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "forgeflow",
|
"name": "forgeflow",
|
||||||
"version": "0.8.2",
|
"version": "0.8.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
|
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
|
||||||
"main": "main.cjs",
|
"main": "main.cjs",
|
||||||
@@ -11,9 +11,9 @@
|
|||||||
"demo": "node scripts/serve-demo.mjs",
|
"demo": "node scripts/serve-demo.mjs",
|
||||||
"test": "node --test tests/*.test.mjs",
|
"test": "node --test tests/*.test.mjs",
|
||||||
"verify": "node scripts/verify.mjs",
|
"verify": "node scripts/verify.mjs",
|
||||||
"dist:win": "electron-builder --win nsis portable && node scripts/write-release-checksums.mjs",
|
"dist:win": "electron-builder --win nsis portable && node scripts/write-release-checksums.mjs && node scripts/prune-dist.mjs",
|
||||||
"dist:linux": "electron-builder --linux AppImage",
|
"dist:linux": "electron-builder --linux AppImage && node scripts/prune-dist.mjs",
|
||||||
"dist:mac": "electron-builder --mac dmg",
|
"dist:mac": "electron-builder --mac dmg && node scripts/prune-dist.mjs",
|
||||||
"doctor": "node scripts/doctor.mjs",
|
"doctor": "node scripts/doctor.mjs",
|
||||||
"acceptance": "node scripts/acceptance.mjs",
|
"acceptance": "node scripts/acceptance.mjs",
|
||||||
"connections:check": "electron scripts/validate-installed-connections.cjs",
|
"connections:check": "electron scripts/validate-installed-connections.cjs",
|
||||||
@@ -52,6 +52,7 @@
|
|||||||
"docs/UPDATING.md",
|
"docs/UPDATING.md",
|
||||||
"scripts/apply-source-update.ps1",
|
"scripts/apply-source-update.ps1",
|
||||||
"scripts/apply-binary-update.ps1",
|
"scripts/apply-binary-update.ps1",
|
||||||
|
"scripts/prune-dist.mjs",
|
||||||
"docs/RELEASE_NOTES_0.4.0.md",
|
"docs/RELEASE_NOTES_0.4.0.md",
|
||||||
"docs/LUMAOPS_SERVER_AUDIT.md",
|
"docs/LUMAOPS_SERVER_AUDIT.md",
|
||||||
"docs/SSH_UNRAID_DEPLOYMENT.md",
|
"docs/SSH_UNRAID_DEPLOYMENT.md",
|
||||||
@@ -74,6 +75,11 @@
|
|||||||
"docs/RELEASE_NOTES_0.8.0.md",
|
"docs/RELEASE_NOTES_0.8.0.md",
|
||||||
"docs/RELEASE_NOTES_0.8.1.md",
|
"docs/RELEASE_NOTES_0.8.1.md",
|
||||||
"docs/RELEASE_NOTES_0.8.2.md",
|
"docs/RELEASE_NOTES_0.8.2.md",
|
||||||
|
"docs/RELEASE_NOTES_0.8.3.md",
|
||||||
|
"docs/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/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'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const projectRoot = path.resolve(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"..",
|
||||||
|
);
|
||||||
|
const distDirectory = path.join(projectRoot, "dist");
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
await fs.readFile(path.join(projectRoot, "package.json"), "utf8"),
|
||||||
|
);
|
||||||
|
const currentVersion = String(manifest.version || "").trim();
|
||||||
|
|
||||||
|
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(currentVersion)) {
|
||||||
|
throw new Error("package.json contains an invalid release version.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await fs
|
||||||
|
.readdir(distDirectory, { withFileTypes: true })
|
||||||
|
.catch((error) => {
|
||||||
|
if (error.code === "ENOENT") return [];
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
const removed = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile() || !entry.name.startsWith("ForgeFlow-")) continue;
|
||||||
|
if (entry.name.includes(`-${currentVersion}-`)) continue;
|
||||||
|
await fs.rm(path.join(distDirectory, entry.name), { force: true });
|
||||||
|
removed.push(entry.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removed.length) {
|
||||||
|
console.log(`Removed ${removed.length} obsolete dist artifact(s):`);
|
||||||
|
for (const name of removed) console.log(`- ${name}`);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`No ForgeFlow dist artifacts older than ${currentVersion} found.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
+9
-3
@@ -46,6 +46,7 @@ const required = [
|
|||||||
"scripts/validate-installed-connections.cjs",
|
"scripts/validate-installed-connections.cjs",
|
||||||
"scripts/publish-binary-release.cjs",
|
"scripts/publish-binary-release.cjs",
|
||||||
"scripts/write-release-checksums.mjs",
|
"scripts/write-release-checksums.mjs",
|
||||||
|
"scripts/prune-dist.mjs",
|
||||||
"scripts/generate-source-manifest.mjs",
|
"scripts/generate-source-manifest.mjs",
|
||||||
"setup-windows.ps1",
|
"setup-windows.ps1",
|
||||||
"START-FORGEFLOW-OVERLAY.ps1",
|
"START-FORGEFLOW-OVERLAY.ps1",
|
||||||
@@ -62,6 +63,11 @@ const required = [
|
|||||||
"docs/RELEASE_NOTES_0.8.0.md",
|
"docs/RELEASE_NOTES_0.8.0.md",
|
||||||
"docs/RELEASE_NOTES_0.8.1.md",
|
"docs/RELEASE_NOTES_0.8.1.md",
|
||||||
"docs/RELEASE_NOTES_0.8.2.md",
|
"docs/RELEASE_NOTES_0.8.2.md",
|
||||||
|
"docs/RELEASE_NOTES_0.8.3.md",
|
||||||
|
"docs/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/UPDATING.md",
|
"docs/UPDATING.md",
|
||||||
"docs/DIAGNOSTICS.md",
|
"docs/DIAGNOSTICS.md",
|
||||||
"docs/DEPLOYMENT_SETUP.md",
|
"docs/DEPLOYMENT_SETUP.md",
|
||||||
@@ -100,9 +106,9 @@ for (const file of required) await access(path.join(root, file));
|
|||||||
const packageJson = JSON.parse(
|
const packageJson = JSON.parse(
|
||||||
await readFile(path.join(root, "package.json"), "utf8"),
|
await readFile(path.join(root, "package.json"), "utf8"),
|
||||||
);
|
);
|
||||||
if (packageJson.version !== "0.8.2")
|
if (packageJson.version !== "0.8.7")
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Expected package version 0.8.2, got ${packageJson.version}.`,
|
`Expected package version 0.8.7, got ${packageJson.version}.`,
|
||||||
);
|
);
|
||||||
const sourceManifest = await readFile(
|
const sourceManifest = await readFile(
|
||||||
path.join(root, "SOURCE_MANIFEST.txt"),
|
path.join(root, "SOURCE_MANIFEST.txt"),
|
||||||
@@ -367,7 +373,7 @@ for (const phrase of [
|
|||||||
"deriveDetectedProfile",
|
"deriveDetectedProfile",
|
||||||
"docker inspect",
|
"docker inspect",
|
||||||
"net.unraid.docker.managed",
|
"net.unraid.docker.managed",
|
||||||
"'dockerman'",
|
"dockerman",
|
||||||
"iconCacheRefresh",
|
"iconCacheRefresh",
|
||||||
"[PORT:",
|
"[PORT:",
|
||||||
"Superseded by live commit",
|
"Superseded by live commit",
|
||||||
|
|||||||
@@ -331,6 +331,14 @@ 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, assetId, options = {}) {
|
||||||
|
const numericId = Number(assetId);
|
||||||
|
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/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`,
|
||||||
|
|||||||
+987
-303
File diff suppressed because it is too large
Load Diff
+1532
-386
File diff suppressed because it is too large
Load Diff
@@ -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,12 @@ class UpdateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [binary, checksumBytes] = await Promise.all([
|
const [binary, checksumBytes] = await Promise.all([
|
||||||
this.gitea.downloadAuthenticated(asset.browser_download_url),
|
this.gitea.downloadReleaseAsset(update.owner, update.repo, asset.id),
|
||||||
this.gitea.downloadAuthenticated(checksumAsset.browser_download_url),
|
this.gitea.downloadReleaseAsset(
|
||||||
|
update.owner,
|
||||||
|
update.repo,
|
||||||
|
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 +643,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);
|
||||||
|
|||||||
+121
-7
@@ -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) {
|
||||||
@@ -699,6 +720,19 @@ function queueActionFor(repository) {
|
|||||||
return mapping[action.kind] || mapping.clean;
|
return mapping[action.kind] || mapping.clean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function projectIllustration(kind = "flow") {
|
||||||
|
return `<div class="project-illustration ${attr(kind)}" data-project-illustration aria-hidden="true">
|
||||||
|
<div class="illustration-glow"></div><svg viewBox="0 0 260 150" role="presentation">
|
||||||
|
<path class="orbit orbit-a" d="M32 92 C72 20 190 18 230 82"/><path class="orbit orbit-b" d="M42 116 C98 150 190 136 222 60"/>
|
||||||
|
<g class="illustration-core"><rect x="83" y="39" width="94" height="74" rx="17"/><path d="M103 66h54M103 79h36M103 92h45"/><circle cx="160" cy="92" r="5"/></g>
|
||||||
|
<g class="illustration-node node-a"><circle cx="37" cy="92" r="12"/><path d="m32 92 4 4 7-9"/></g>
|
||||||
|
<g class="illustration-node node-b"><circle cx="226" cy="82" r="12"/><path d="M221 82h10M226 77v10"/></g>
|
||||||
|
<g class="illustration-node node-c"><circle cx="74" cy="31" r="8"/></g>
|
||||||
|
<circle class="signal signal-a" cx="0" cy="0" r="4"/><circle class="signal signal-b" cx="0" cy="0" r="3"/>
|
||||||
|
</svg><span class="illustration-label">${kind === "deploy" ? "Live release topology" : kind === "repo" ? "Project signal" : "Release flow"}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderOverview() {
|
function renderOverview() {
|
||||||
const changed = ui.repositories.filter(
|
const changed = ui.repositories.filter(
|
||||||
(repository) => repository.localStatus?.counts.changed,
|
(repository) => repository.localStatus?.counts.changed,
|
||||||
@@ -720,7 +754,7 @@ function renderOverview() {
|
|||||||
(operation) => !isTerminalOperation(operation.status),
|
(operation) => !isTerminalOperation(operation.status),
|
||||||
);
|
);
|
||||||
return `<div class="page">
|
return `<div class="page">
|
||||||
<div class="page-header"><div><div class="eyebrow">Coding flow</div><h1>Release overview</h1><p>One decision surface for local work, Gitea synchronization and the exact version running on your server.</p></div><button class="button" data-action="refresh">${icon("refresh")}Refresh all</button></div>
|
<div class="page-header visual-page-header"><div><div class="eyebrow">Coding flow</div><h1>Release overview</h1><p>One decision surface for local work, Gitea synchronization and the exact version running on your server.</p></div>${projectIllustration("flow")}<button class="button" data-action="refresh">${icon("refresh")}Refresh all</button></div>
|
||||||
${ui.refreshError ? `<div class="notice danger">${icon("error")} ${escapeHtml(ui.refreshError)}</div>` : ""}
|
${ui.refreshError ? `<div class="notice danger">${icon("error")} ${escapeHtml(ui.refreshError)}</div>` : ""}
|
||||||
<div class="summary-grid">
|
<div class="summary-grid">
|
||||||
${renderSummaryCard("Local work", changed, changed === 1 ? "repository has changes" : "repositories have changes", "file", changed ? "warning" : "success")}
|
${renderSummaryCard("Local work", changed, changed === 1 ? "repository has changes" : "repositories have changes", "file", changed ? "warning" : "success")}
|
||||||
@@ -760,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 =
|
||||||
@@ -777,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";
|
||||||
@@ -852,6 +902,20 @@ function dockerManIntegration(profile) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deploymentIdentity(profile, repository) {
|
||||||
|
const name = String(
|
||||||
|
profile.state?.containerName ||
|
||||||
|
profile.containerName ||
|
||||||
|
profile.remoteFolder ||
|
||||||
|
repository.name ||
|
||||||
|
"container",
|
||||||
|
);
|
||||||
|
let hash = 0;
|
||||||
|
for (const character of name)
|
||||||
|
hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
|
||||||
|
return { name, initial: name.slice(0, 1).toUpperCase(), accent: hash % 6 };
|
||||||
|
}
|
||||||
|
|
||||||
function renderProfileCard(repository, profile, compact = false) {
|
function renderProfileCard(repository, profile, compact = false) {
|
||||||
const state = profile.state || {};
|
const state = profile.state || {};
|
||||||
const health = environmentState(profile);
|
const health = environmentState(profile);
|
||||||
@@ -860,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);
|
||||||
@@ -868,7 +932,13 @@ function renderProfileCard(repository, profile, compact = false) {
|
|||||||
const dockerManReady = dockerMan.ready;
|
const dockerManReady = dockerMan.ready;
|
||||||
const webUi =
|
const webUi =
|
||||||
profile.webUiUrl || state.webUiUrl || state.dockerMan?.webUi || "";
|
profile.webUiUrl || state.webUiUrl || state.dockerMan?.webUi || "";
|
||||||
return `<article class="deploy-card ${compact ? "compact-card" : ""}"><div class="deploy-card-header"><div><div class="eyebrow">${escapeHtml(profile.environment)}</div><h3>${escapeHtml(profile.name)}</h3><p>${escapeHtml(providerDetail)}</p></div><span class="status-pill ${health.tone}"><span class="state-dot ${health.tone}"></span>${health.label}</span></div><div class="deploy-card-body"><div class="deploy-metadata"><span>Provider</span><strong>${isSsh ? "SSH / Unraid" : "Gitea Actions"}</strong><span>Live version</span><strong>${state.liveSha ? shortSha(state.liveSha) : "Unknown"}</strong><span>Previous version</span><strong>${state.previousSha ? shortSha(state.previousSha) : "Unknown"}</strong><span>Last checked</span><strong>${state.checkedAt ? formatDate(state.checkedAt) : "Never"}</strong>${isSsh ? `<span>Container</span><strong>${escapeHtml(state.containerName || profile.containerName || profile.remoteFolder || repository.name)}${state.containerRunning === false ? " · stopped" : state.containerRunning ? " · running" : ""}</strong><span>DockerMan</span><strong class="${dockerManReady ? "text-success" : "text-warning"}">${dockerManReady ? (templateReady ? "Labels/template active" : "WebUI/icon labels active") : `WebUI ${webUiReady ? "ready" : "missing"} · icon ${iconReady ? "ready" : "missing"}`}</strong>` : ""}<span>Rollback</span><strong>${rollbackConfigured ? "Available after first deploy" : "Not configured"}</strong></div><div class="card-actions"><button class="button" data-action="run-deployment-preflight" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Preflight</button><button class="button" data-action="reconcile-deployment" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("refresh")}Reconcile</button>${webUi ? `<button class="button" data-action="open-profile-webui" data-url="${attr(webUi)}">${icon("external")}Open Web UI</button>` : ""}${isSsh ? `<button class="button ${dockerManReady ? "ghost" : ""}" data-action="apply-dockerman-metadata" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}${dockerManReady ? "Reapply DockerMan metadata" : "Repair DockerMan integration"}</button>` : ""}${ready ? `<button class="button primary" data-action="deploy-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("rocket")}Deploy ${escapeHtml(repository.localStatus.shortHead)}</button>` : ""}<button class="button ghost" data-action="edit-deployment-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">Edit</button>${state.previousSha && rollbackConfigured ? `<button class="button danger" data-action="rollback-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("undo")}Rollback</button>` : ""}</div></div></article>`;
|
const identity = deploymentIdentity(profile, repository);
|
||||||
|
const syncLabel = state.matchesGitea
|
||||||
|
? `<span class="sync-proof success">${icon("check")}Live = Gitea · ${shortSha(state.liveSha)}</span>`
|
||||||
|
: state.giteaSha && state.liveSha
|
||||||
|
? `<span class="sync-proof warning">Live ${shortSha(state.liveSha)} · Gitea ${shortSha(state.giteaSha)}</span>`
|
||||||
|
: "";
|
||||||
|
return `<article class="deploy-card accent-${identity.accent} ${compact ? "compact-card" : ""}"><div class="container-identity"><span class="container-avatar">${escapeHtml(identity.initial)}</span><div><span>Container</span><strong>${escapeHtml(identity.name)}</strong><small>${escapeHtml(repository.fullName)} · ${escapeHtml(profile.environment)}</small></div>${syncLabel}</div><div class="deploy-card-header"><div><div class="eyebrow">${escapeHtml(isSsh ? "SSH / UNRAID" : "GITEA ACTIONS")}</div><h3>${escapeHtml(profile.name)}</h3><p>${escapeHtml(providerDetail)}</p></div><span class="status-pill ${health.tone}"><span class="state-dot ${health.tone}"></span>${health.label}</span></div><div class="deploy-card-body"><div class="deploy-metadata"><span>Live commit</span><strong>${state.liveSha ? shortSha(state.liveSha) : "Unknown"}</strong><span>Gitea commit</span><strong>${state.giteaSha ? shortSha(state.giteaSha) : "Refresh to compare"}</strong><span>Previous version</span><strong>${state.previousSha ? shortSha(state.previousSha) : "Unknown"}</strong><span>Last checked</span><strong>${state.checkedAt ? formatDate(state.checkedAt) : "Never"}</strong>${isSsh ? `<span>Runtime</span><strong>${state.containerRunning === false ? "Stopped" : state.containerRunning ? "Running" : "Unknown"}</strong><span>DockerMan</span><strong class="${dockerManReady ? "text-success" : "text-warning"}">${dockerManReady ? (templateReady ? "Labels/template active" : "WebUI/icon labels active") : `WebUI ${webUiReady ? "ready" : "missing"} · icon ${iconReady ? "ready" : "missing"}`}</strong>` : ""}<span>Rollback</span><strong>${rollbackConfigured ? "Available after first deploy" : "Not configured"}</strong></div><div class="card-actions"><button class="button" data-action="run-deployment-preflight" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Preflight</button><button class="button" data-action="reconcile-deployment" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("refresh")}Refresh truth</button>${webUi ? `<button class="button" data-action="open-profile-webui" data-url="${attr(webUi)}">${icon("external")}Open Web UI</button>` : ""}${isSsh ? `<button class="button ${dockerManReady ? "ghost" : ""}" data-action="apply-dockerman-metadata" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}${dockerManReady ? "Reapply DockerMan metadata" : "Repair DockerMan integration"}</button>` : ""}${ready ? `<button class="button primary" data-action="deploy-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("rocket")}Deploy ${escapeHtml(repository.localStatus.shortHead)}</button>` : ""}<button class="button ghost" data-action="edit-deployment-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">Edit</button>${state.previousSha && rollbackConfigured ? `<button class="button danger" data-action="rollback-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("undo")}Rollback</button>` : ""}</div></div></article>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRepositoryDeployments(repository) {
|
function renderRepositoryDeployments(repository) {
|
||||||
@@ -931,7 +1001,7 @@ function renderRepositoryWorkspace(repository) {
|
|||||||
settings: renderRepositorySettings,
|
settings: renderRepositorySettings,
|
||||||
}[ui.repositoryTab] || renderChanges
|
}[ui.repositoryTab] || renderChanges
|
||||||
)(repository);
|
)(repository);
|
||||||
return `<div class="repo-workspace"><header class="repo-header"><div class="repo-heading"><h1><button class="favorite-button ${repository.favorite ? "active" : ""}" data-action="toggle-favorite" title="Toggle favorite">${icon("star")}</button>${escapeHtml(repository.fullName)}</h1><p>${escapeHtml(repository.localPath || "No local working tree linked")}</p></div><div class="repo-header-actions"><button class="button" data-action="fetch" ${!repository.localPath ? "disabled" : ""}>${icon("refresh")}Fetch</button><button class="button" data-action="open-path" ${!repository.localPath ? "disabled" : ""}>${icon("folder")}Folder</button><button class="button" data-action="open-gitea" ${!repository.htmlUrl ? "disabled" : ""}>${icon("external")}Gitea</button></div></header>
|
return `<div class="repo-workspace"><header class="repo-header illustrated-repo-header"><div class="repo-heading"><h1><button class="favorite-button ${repository.favorite ? "active" : ""}" data-action="toggle-favorite" title="Toggle favorite">${icon("star")}</button>${escapeHtml(repository.fullName)}</h1><p>${escapeHtml(repository.localPath || "No local working tree linked")}</p></div>${projectIllustration("repo")}<div class="repo-header-actions"><button class="button" data-action="fetch" ${!repository.localPath ? "disabled" : ""}>${icon("refresh")}Fetch</button><button class="button" data-action="open-path" ${!repository.localPath ? "disabled" : ""}>${icon("folder")}Folder</button><button class="button" data-action="open-gitea" ${!repository.htmlUrl ? "disabled" : ""}>${icon("external")}Gitea</button></div></header>
|
||||||
${repository.localPath ? `<div class="repo-quick-actions"><button class="button" data-action="open-editor">${icon("external")}Open in editor</button><button class="button" data-action="open-terminal">${icon("terminal")}Open terminal</button><button class="button" data-action="check-branch-protection">${icon("shield")}Check branch protection</button><button class="button primary" data-action="open-pull-request">${icon("git")}Create pull request</button>${ui.branchProtection ? `<span class="status-pill ${ui.branchProtection.protected ? "warning" : "success"}">${ui.branchProtection.protected ? `Protected · ${ui.branchProtection.requiredApprovals || 0} approval(s)` : "Direct pushes allowed"}</span>` : ""}</div>` : ""}
|
${repository.localPath ? `<div class="repo-quick-actions"><button class="button" data-action="open-editor">${icon("external")}Open in editor</button><button class="button" data-action="open-terminal">${icon("terminal")}Open terminal</button><button class="button" data-action="check-branch-protection">${icon("shield")}Check branch protection</button><button class="button primary" data-action="open-pull-request">${icon("git")}Create pull request</button>${ui.branchProtection ? `<span class="status-pill ${ui.branchProtection.protected ? "warning" : "success"}">${ui.branchProtection.protected ? `Protected · ${ui.branchProtection.requiredApprovals || 0} approval(s)` : "Direct pushes allowed"}</span>` : ""}</div>` : ""}
|
||||||
<div class="release-rail">${releaseNode("Local", status?.shortHead || "Not linked", status ? `${status.counts.changed} changes · ${status.branch.head}` : "No working tree", localTone)}${releaseNode("Gitea", status?.shortHead || "Unknown", status?.branch.upstream ? `${status.branch.ahead} ahead · ${status.branch.behind} behind` : "Branch not published", remoteTone)}${releaseNode(`Server${profile ? ` · ${profile.environment}` : ""}`, serverState.liveSha ? shortSha(serverState.liveSha) : "Unknown", profile ? (serverState.checkedAt ? `checked ${formatDate(serverState.checkedAt)}` : "not checked") : "No deployment profile", serverTone)}</div>
|
<div class="release-rail">${releaseNode("Local", status?.shortHead || "Not linked", status ? `${status.counts.changed} changes · ${status.branch.head}` : "No working tree", localTone)}${releaseNode("Gitea", status?.shortHead || "Unknown", status?.branch.upstream ? `${status.branch.ahead} ahead · ${status.branch.behind} behind` : "Branch not published", remoteTone)}${releaseNode(`Server${profile ? ` · ${profile.environment}` : ""}`, serverState.liveSha ? shortSha(serverState.liveSha) : "Unknown", profile ? (serverState.checkedAt ? `checked ${formatDate(serverState.checkedAt)}` : "not checked") : "No deployment profile", serverTone)}</div>
|
||||||
<nav class="tabs">${[
|
<nav class="tabs">${[
|
||||||
@@ -3224,6 +3294,50 @@ document.addEventListener("keydown", (event) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener("pointermove", (event) => {
|
||||||
|
const illustration = event.target.closest?.("[data-project-illustration]");
|
||||||
|
if (illustration) {
|
||||||
|
const bounds = illustration.getBoundingClientRect();
|
||||||
|
illustration.style.setProperty(
|
||||||
|
"--tilt-x",
|
||||||
|
`${((event.clientY - bounds.top) / bounds.height - 0.5) * -7}deg`,
|
||||||
|
);
|
||||||
|
illustration.style.setProperty(
|
||||||
|
"--tilt-y",
|
||||||
|
`${((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) => {
|
||||||
|
const illustration = event.target.closest?.("[data-project-illustration]");
|
||||||
|
if (illustration && !illustration.contains(event.relatedTarget)) {
|
||||||
|
illustration.style.removeProperty("--tilt-x");
|
||||||
|
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) => {
|
||||||
window.forgeflow
|
window.forgeflow
|
||||||
.reportRendererEvent?.("error", "uncaught-error", {
|
.reportRendererEvent?.("error", "uncaught-error", {
|
||||||
|
|||||||
+23
-11
@@ -416,21 +416,21 @@
|
|||||||
);
|
);
|
||||||
repository.readyToDeploy = Boolean(
|
repository.readyToDeploy = Boolean(
|
||||||
repository.localPath &&
|
repository.localPath &&
|
||||||
status?.clean &&
|
status?.clean &&
|
||||||
status.branch.upstream &&
|
status.branch.upstream &&
|
||||||
status.branch.ahead === 0 &&
|
status.branch.ahead === 0 &&
|
||||||
status.branch.behind === 0 &&
|
status.branch.behind === 0 &&
|
||||||
repository.deploymentProfiles.some(
|
repository.deploymentProfiles.some(
|
||||||
(entry) => entry.branch === status.branch.head,
|
(entry) => entry.branch === status.branch.head,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
repository.attention =
|
repository.attention =
|
||||||
!repository.localPath ||
|
!repository.localPath ||
|
||||||
Boolean(
|
Boolean(
|
||||||
status?.counts.conflicts ||
|
status?.counts.conflicts ||
|
||||||
status?.branch.behind ||
|
status?.branch.behind ||
|
||||||
status?.branch.ahead ||
|
status?.branch.ahead ||
|
||||||
status?.counts.changed,
|
status?.counts.changed,
|
||||||
);
|
);
|
||||||
repository.attentionReason = !repository.localPath
|
repository.attentionReason = !repository.localPath
|
||||||
? "No local folder linked"
|
? "No local folder linked"
|
||||||
@@ -564,7 +564,7 @@
|
|||||||
await wait(80);
|
await wait(80);
|
||||||
snapshot();
|
snapshot();
|
||||||
return {
|
return {
|
||||||
appVersion: "0.8.2-demo",
|
appVersion: "0.8.7-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) {
|
||||||
|
|||||||
+561
-12
@@ -32,27 +32,67 @@
|
|||||||
|
|
||||||
html[data-theme="light"] {
|
html[data-theme="light"] {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
--bg: #eef2f7;
|
--bg: #e9eef7;
|
||||||
--surface-0: #f7f9fc;
|
--surface-0: #f2f6fc;
|
||||||
--surface-1: #ffffff;
|
--surface-1: #ffffff;
|
||||||
--surface-2: #f3f6fa;
|
--surface-2: #f3f6fa;
|
||||||
--surface-3: #e8edf4;
|
--surface-3: #e8edf4;
|
||||||
--surface-hover: #edf2f8;
|
--surface-hover: #edf2f8;
|
||||||
--line: #cdd5e1;
|
--line: #c5d0df;
|
||||||
--line-soft: #e1e6ee;
|
--line-soft: #dce4ef;
|
||||||
--text: #172033;
|
--text: #172033;
|
||||||
--text-muted: #526078;
|
--text-muted: #526078;
|
||||||
--text-faint: #7b879a;
|
--text-faint: #7b879a;
|
||||||
--primary: #295fca;
|
--primary: #2857bf;
|
||||||
--primary-strong: #326ee0;
|
--primary-strong: #346ee8;
|
||||||
--primary-soft: rgba(50, 110, 224, 0.1);
|
--primary-soft: rgba(52, 110, 232, 0.13);
|
||||||
--success: #087a57;
|
--success: #087a57;
|
||||||
--success-soft: rgba(8, 122, 87, 0.1);
|
--success-soft: rgba(8, 122, 87, 0.1);
|
||||||
--warning: #9b5b00;
|
--warning: #9b5b00;
|
||||||
--warning-soft: rgba(155, 91, 0, 0.1);
|
--warning-soft: rgba(155, 91, 0, 0.1);
|
||||||
--danger: #c73737;
|
--danger: #c73737;
|
||||||
--danger-soft: rgba(199, 55, 55, 0.1);
|
--danger-soft: rgba(199, 55, 55, 0.1);
|
||||||
--shadow: 0 18px 70px rgba(43, 55, 77, 0.15);
|
--shadow: 0 18px 54px rgba(31, 55, 94, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] body,
|
||||||
|
html[data-theme="light"] .app-shell {
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
circle at 82% 2%,
|
||||||
|
rgba(67, 123, 235, 0.13),
|
||||||
|
transparent 31%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
circle at 20% 100%,
|
||||||
|
rgba(31, 170, 141, 0.08),
|
||||||
|
transparent 33%
|
||||||
|
),
|
||||||
|
var(--bg);
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .titlebar,
|
||||||
|
html[data-theme="light"] .sidebar {
|
||||||
|
background: rgba(250, 252, 255, 0.92);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .panel,
|
||||||
|
html[data-theme="light"] .summary-card,
|
||||||
|
html[data-theme="light"] .deploy-card,
|
||||||
|
html[data-theme="light"] .settings-group,
|
||||||
|
html[data-theme="light"] .pipeline-card {
|
||||||
|
border-color: rgba(151, 169, 197, 0.48);
|
||||||
|
box-shadow: 0 8px 28px rgba(49, 75, 116, 0.08);
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .nav-button.active {
|
||||||
|
background: linear-gradient(
|
||||||
|
105deg,
|
||||||
|
rgba(52, 110, 232, 0.16),
|
||||||
|
rgba(48, 181, 151, 0.08)
|
||||||
|
);
|
||||||
|
box-shadow: inset 3px 0 var(--primary-strong);
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .button.primary {
|
||||||
|
box-shadow: 0 7px 18px rgba(52, 110, 232, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -930,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);
|
||||||
@@ -963,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;
|
||||||
@@ -977,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;
|
||||||
@@ -1001,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);
|
||||||
@@ -1008,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;
|
||||||
@@ -1309,10 +1554,110 @@ html[data-theme="light"] .diff-line.remove {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.deploy-card {
|
.deploy-card {
|
||||||
|
--card-accent: var(--primary-strong);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: var(--surface-1);
|
background: var(--surface-1);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.1);
|
||||||
|
transition:
|
||||||
|
transform 160ms ease,
|
||||||
|
border-color 160ms ease,
|
||||||
|
box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
.deploy-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: color-mix(in srgb, var(--card-accent) 42%, var(--line));
|
||||||
|
box-shadow: 0 16px 42px
|
||||||
|
color-mix(in srgb, var(--card-accent) 12%, transparent);
|
||||||
|
}
|
||||||
|
.deploy-card.accent-0 {
|
||||||
|
--card-accent: #4d7df3;
|
||||||
|
}
|
||||||
|
.deploy-card.accent-1 {
|
||||||
|
--card-accent: #8b5cf6;
|
||||||
|
}
|
||||||
|
.deploy-card.accent-2 {
|
||||||
|
--card-accent: #0ea5a0;
|
||||||
|
}
|
||||||
|
.deploy-card.accent-3 {
|
||||||
|
--card-accent: #e2783f;
|
||||||
|
}
|
||||||
|
.deploy-card.accent-4 {
|
||||||
|
--card-accent: #d24e83;
|
||||||
|
}
|
||||||
|
.deploy-card.accent-5 {
|
||||||
|
--card-accent: #4b9b55;
|
||||||
|
}
|
||||||
|
.container-identity {
|
||||||
|
min-height: 78px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 13px 14px;
|
||||||
|
border-top: 4px solid var(--card-accent);
|
||||||
|
border-bottom: 1px solid var(--line-soft);
|
||||||
|
background: linear-gradient(
|
||||||
|
110deg,
|
||||||
|
color-mix(in srgb, var(--card-accent) 15%, var(--surface-1)),
|
||||||
|
var(--surface-1) 68%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.container-avatar {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
flex: 0 0 44px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(
|
||||||
|
145deg,
|
||||||
|
color-mix(in srgb, var(--card-accent) 72%, #fff),
|
||||||
|
var(--card-accent)
|
||||||
|
);
|
||||||
|
box-shadow: 0 8px 20px color-mix(in srgb, var(--card-accent) 28%, transparent);
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 780;
|
||||||
|
}
|
||||||
|
.container-identity > div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.container-identity span:not(.container-avatar):not(.sync-proof),
|
||||||
|
.container-identity small {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.container-identity strong {
|
||||||
|
display: block;
|
||||||
|
margin: 2px 0 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 16px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sync-proof {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sync-proof.success {
|
||||||
|
color: var(--success);
|
||||||
|
background: var(--success-soft);
|
||||||
|
}
|
||||||
|
.sync-proof.warning {
|
||||||
|
color: var(--warning);
|
||||||
|
background: var(--warning-soft);
|
||||||
|
}
|
||||||
|
.sync-proof .icon {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
vertical-align: -2px;
|
||||||
}
|
}
|
||||||
.deploy-card-header {
|
.deploy-card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -3065,6 +3410,197 @@ select:focus {
|
|||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.visual-page-header {
|
||||||
|
min-height: 142px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 20px 22px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--primary) 22%, var(--line));
|
||||||
|
border-radius: 16px;
|
||||||
|
background:
|
||||||
|
linear-gradient(
|
||||||
|
105deg,
|
||||||
|
var(--surface-1) 0 52%,
|
||||||
|
color-mix(in srgb, var(--primary-soft) 62%, var(--surface-1))
|
||||||
|
),
|
||||||
|
var(--surface-1);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-illustration {
|
||||||
|
--tilt-x: 0deg;
|
||||||
|
--tilt-y: 0deg;
|
||||||
|
width: 238px;
|
||||||
|
height: 128px;
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 238px;
|
||||||
|
perspective: 700px;
|
||||||
|
cursor: crosshair;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
.project-illustration svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
overflow: visible;
|
||||||
|
transform: rotateX(var(--tilt-x)) rotateY(var(--tilt-y));
|
||||||
|
filter: drop-shadow(0 16px 24px rgba(10, 20, 48, 0.2));
|
||||||
|
transition: transform 140ms ease-out;
|
||||||
|
}
|
||||||
|
.illustration-glow {
|
||||||
|
width: 150px;
|
||||||
|
height: 85px;
|
||||||
|
position: absolute;
|
||||||
|
inset: 24px auto auto 49px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: color-mix(in srgb, var(--primary) 24%, transparent);
|
||||||
|
filter: blur(24px);
|
||||||
|
animation: illustration-breathe 4.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.project-illustration .orbit {
|
||||||
|
fill: none;
|
||||||
|
stroke: color-mix(in srgb, var(--primary) 48%, var(--line));
|
||||||
|
stroke-width: 1.5;
|
||||||
|
stroke-dasharray: 4 7;
|
||||||
|
}
|
||||||
|
.project-illustration .orbit-b {
|
||||||
|
stroke: color-mix(in srgb, var(--success) 48%, var(--line));
|
||||||
|
}
|
||||||
|
.illustration-core rect {
|
||||||
|
fill: color-mix(in srgb, var(--surface-1) 86%, var(--primary));
|
||||||
|
stroke: color-mix(in srgb, var(--primary) 58%, var(--line));
|
||||||
|
stroke-width: 1.5;
|
||||||
|
}
|
||||||
|
.illustration-core path {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--text-muted);
|
||||||
|
stroke-width: 3;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
.illustration-core circle {
|
||||||
|
fill: var(--success);
|
||||||
|
animation: illustration-pulse 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.illustration-node {
|
||||||
|
animation: illustration-float 4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.illustration-node circle {
|
||||||
|
fill: var(--surface-1);
|
||||||
|
stroke: var(--primary);
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
.illustration-node path {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--primary);
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
.illustration-node.node-b {
|
||||||
|
animation-delay: -1.4s;
|
||||||
|
}
|
||||||
|
.illustration-node.node-c {
|
||||||
|
animation-delay: -2.7s;
|
||||||
|
}
|
||||||
|
.illustration-node.node-c circle {
|
||||||
|
fill: var(--warning);
|
||||||
|
stroke: color-mix(in srgb, var(--warning) 55%, var(--surface-1));
|
||||||
|
}
|
||||||
|
.project-illustration .signal {
|
||||||
|
fill: var(--primary);
|
||||||
|
}
|
||||||
|
.project-illustration .signal-a {
|
||||||
|
offset-path: path("M32 92 C72 20 190 18 230 82");
|
||||||
|
animation: signal-travel 3.6s linear infinite;
|
||||||
|
}
|
||||||
|
.project-illustration .signal-b {
|
||||||
|
fill: var(--success);
|
||||||
|
offset-path: path("M42 116 C98 150 190 136 222 60");
|
||||||
|
animation: signal-travel 4.4s -2s linear infinite;
|
||||||
|
}
|
||||||
|
.illustration-label {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
bottom: 3px;
|
||||||
|
z-index: 2;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font: 650 9px var(--font-mono);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.project-illustration.repo {
|
||||||
|
width: 150px;
|
||||||
|
height: 82px;
|
||||||
|
position: absolute;
|
||||||
|
top: -9px;
|
||||||
|
left: 43%;
|
||||||
|
z-index: 0;
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.28;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.project-illustration.repo .illustration-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.illustrated-repo-header {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.illustrated-repo-header > :not(.project-illustration) {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes signal-travel {
|
||||||
|
to {
|
||||||
|
offset-distance: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes illustration-float {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes illustration-breathe {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.55;
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.95;
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes illustration-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .visual-page-header {
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
circle at 74% 20%,
|
||||||
|
rgba(102, 82, 235, 0.12),
|
||||||
|
transparent 28%
|
||||||
|
),
|
||||||
|
linear-gradient(
|
||||||
|
105deg,
|
||||||
|
rgba(255, 255, 255, 0.96) 0 52%,
|
||||||
|
rgba(229, 239, 255, 0.94)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
@@ -3074,6 +3610,12 @@ select:focus {
|
|||||||
animation-duration: 0.01ms !important;
|
animation-duration: 0.01ms !important;
|
||||||
animation-iteration-count: 1 !important;
|
animation-iteration-count: 1 !important;
|
||||||
}
|
}
|
||||||
|
.project-illustration svg {
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
.diff-atmosphere {
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
@@ -3087,4 +3629,11 @@ select:focus {
|
|||||||
.summary-grid {
|
.summary-grid {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
.project-illustration {
|
||||||
|
width: 190px;
|
||||||
|
flex-basis: 190px;
|
||||||
|
}
|
||||||
|
.project-illustration.repo {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,13 +179,16 @@ test("one-click troubleshooting excludes destructive or publishing Git actions",
|
|||||||
new URL("../src/main/ipc.cjs", import.meta.url),
|
new URL("../src/main/ipc.cjs", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
assert.match(ipc, /action: 'abort-operation', safe: false/);
|
assert.match(ipc, /action:\s*["']abort-operation["'],\s*safe:\s*false/);
|
||||||
assert.match(ipc, /action: 'push', safe: false/);
|
assert.match(ipc, /action:\s*["']push["'],\s*safe:\s*false/);
|
||||||
assert.match(ipc, /const stale = lock\.ageMs >= 10_000/);
|
assert.match(ipc, /const stale = lock\.ageMs >= 10_000/);
|
||||||
assert.match(ipc, /\['fast-forward', 'fetch'\]\.includes\(issue\.action\)/);
|
assert.match(
|
||||||
|
ipc,
|
||||||
|
/\[\s*["']fast-forward["'],\s*["']fetch["']\s*\]\.includes\(issue\.action\)/,
|
||||||
|
);
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
ipc,
|
ipc,
|
||||||
/\['fast-forward', 'push', 'fetch'\]\.includes\(issue\.action\)/,
|
/\[\s*["']fast-forward["'],\s*["']push["'],\s*["']fetch["']\s*\]\.includes\(issue\.action\)/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
renderer,
|
renderer,
|
||||||
@@ -205,3 +208,38 @@ test("premium repository workspace reserves separate rows for actions and releas
|
|||||||
assert.match(styles, /prefers-reduced-motion/);
|
assert.match(styles, /prefers-reduced-motion/);
|
||||||
assert.match(styles, /ForgeFlow 0\.8 premium visual system/);
|
assert.match(styles, /ForgeFlow 0\.8 premium visual system/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("interactive project illustrations are semantic, responsive and motion-safe", 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 projectIllustration/);
|
||||||
|
assert.match(renderer, /data-project-illustration/);
|
||||||
|
assert.match(renderer, /document\.addEventListener\("pointermove"/);
|
||||||
|
assert.match(styles, /\.project-illustration/);
|
||||||
|
assert.match(styles, /@keyframes signal-travel/);
|
||||||
|
assert.match(styles, /prefers-reduced-motion/);
|
||||||
|
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/);
|
||||||
|
});
|
||||||
|
|||||||
+633
-175
@@ -1,247 +1,578 @@
|
|||||||
import test from 'node:test';
|
import test from "node:test";
|
||||||
import assert from 'node:assert/strict';
|
import assert from "node:assert/strict";
|
||||||
import { createRequire } from 'node:module';
|
import { createRequire } from "node:module";
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const { UnraidDeploymentService, safeRemoteFolder, safeRelativeRemoteFile, parseInspection, dockerIgnoreHasPath, checksSummary, xmlEscape, bash } = require('../src/main/unraid-deployment-service.cjs');
|
const {
|
||||||
const { fingerprintKey, shellQuote } = require('../src/main/ssh-service.cjs');
|
UnraidDeploymentService,
|
||||||
|
safeRemoteFolder,
|
||||||
|
safeRelativeRemoteFile,
|
||||||
|
parseInspection,
|
||||||
|
dockerIgnoreHasPath,
|
||||||
|
checksSummary,
|
||||||
|
parseServerInventory,
|
||||||
|
inventoryContainerMatch,
|
||||||
|
remoteIdentity,
|
||||||
|
xmlEscape,
|
||||||
|
bash,
|
||||||
|
} = require("../src/main/unraid-deployment-service.cjs");
|
||||||
|
const { fingerprintKey, shellQuote } = require("../src/main/ssh-service.cjs");
|
||||||
|
|
||||||
test('Unraid remote paths cannot escape appdata project folder', () => {
|
test("Unraid remote paths cannot escape appdata project folder", () => {
|
||||||
assert.equal(safeRemoteFolder('lumaops'), 'lumaops');
|
assert.equal(safeRemoteFolder("lumaops"), "lumaops");
|
||||||
assert.throws(() => safeRemoteFolder('../lumaops'));
|
assert.throws(() => safeRemoteFolder("../lumaops"));
|
||||||
assert.equal(safeRelativeRemoteFile('deploy/docker-compose.yml'), 'deploy/docker-compose.yml');
|
assert.equal(
|
||||||
assert.throws(() => safeRelativeRemoteFile('../../etc/passwd'));
|
safeRelativeRemoteFile("deploy/docker-compose.yml"),
|
||||||
|
"deploy/docker-compose.yml",
|
||||||
|
);
|
||||||
|
assert.throws(() => safeRelativeRemoteFile("../../etc/passwd"));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('server inspection key-value payload is decoded safely', () => {
|
test("server inspection key-value payload is decoded safely", () => {
|
||||||
const b64 = (value) => Buffer.from(value).toString('base64');
|
const b64 = (value) => Buffer.from(value).toString("base64");
|
||||||
const parsed = parseInspection(`noise\n__FORGEFLOW_KV__\nexists=true\nrootGit=true\nhead=${'a'.repeat(40)}\nbranch=main\nremote=${b64('ssh://git@gitea/Jens/LumaOps.git')}\ntrackedChanges=${b64(' M docker-compose.yml\n')}\ncomposeFiles=${b64('docker-compose.yml\n')}\nnestedGit=${b64('source\n')}\ndockerfile=true\ndockerignoreContent=${b64('.git\ndata/\n')}\nexistingPreservePaths=${b64('data\nlogs\n')}\n`);
|
const parsed = parseInspection(
|
||||||
|
`noise\n__FORGEFLOW_KV__\nexists=true\nrootGit=true\nhead=${"a".repeat(40)}\nbranch=main\nremote=${b64("ssh://git@gitea/Jens/LumaOps.git")}\ntrackedChanges=${b64(" M docker-compose.yml\n")}\ncomposeFiles=${b64("docker-compose.yml\n")}\nnestedGit=${b64("source\n")}\ndockerfile=true\ndockerignoreContent=${b64(".git\ndata/\n")}\nexistingPreservePaths=${b64("data\nlogs\n")}\n`,
|
||||||
|
);
|
||||||
assert.equal(parsed.rootGit, true);
|
assert.equal(parsed.rootGit, true);
|
||||||
assert.deepEqual(parsed.composeFiles, ['docker-compose.yml']);
|
assert.deepEqual(parsed.composeFiles, ["docker-compose.yml"]);
|
||||||
assert.deepEqual(parsed.nestedGit, ['source']);
|
assert.deepEqual(parsed.nestedGit, ["source"]);
|
||||||
assert.equal(parsed.trackedChanges.length, 1);
|
assert.equal(parsed.trackedChanges.length, 1);
|
||||||
assert.match(parsed.dockerignoreContent, /\.git/);
|
assert.match(parsed.dockerignoreContent, /\.git/);
|
||||||
assert.deepEqual(parsed.existingPreservePaths, ['data', 'logs']);
|
assert.deepEqual(parsed.existingPreservePaths, ["data", "logs"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Docker ignore checks identify exact runtime and Git context exclusions', () => {
|
test("server workload inventory links running containers to exact Gitea checkouts", () => {
|
||||||
const rules = '# build context\n.git\ndata/\nlogs/**\n!logs/keep.txt\n';
|
const b64 = (value) => Buffer.from(value).toString("base64");
|
||||||
assert.equal(dockerIgnoreHasPath(rules, '.git'), true);
|
const inspect = JSON.stringify([
|
||||||
assert.equal(dockerIgnoreHasPath(rules, 'data'), true);
|
{
|
||||||
assert.equal(dockerIgnoreHasPath(rules, 'logs'), true);
|
Name: "/Portfolio",
|
||||||
assert.equal(dockerIgnoreHasPath(rules, 'source'), false);
|
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('server inspection detects preserved runtime paths and missing Docker context exclusions', async () => {
|
test("automatic server discovery adopts and verifies a running Gitea deployment", async () => {
|
||||||
const b64 = (value) => Buffer.from(value).toString('base64');
|
const b64 = (value) => Buffer.from(value).toString("base64");
|
||||||
let receivedCommand = '';
|
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", () => {
|
||||||
|
const rules = "# build context\n.git\ndata/\nlogs/**\n!logs/keep.txt\n";
|
||||||
|
assert.equal(dockerIgnoreHasPath(rules, ".git"), true);
|
||||||
|
assert.equal(dockerIgnoreHasPath(rules, "data"), true);
|
||||||
|
assert.equal(dockerIgnoreHasPath(rules, "logs"), true);
|
||||||
|
assert.equal(dockerIgnoreHasPath(rules, "source"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("server inspection detects preserved runtime paths and missing Docker context exclusions", async () => {
|
||||||
|
const b64 = (value) => Buffer.from(value).toString("base64");
|
||||||
|
let receivedCommand = "";
|
||||||
const store = {
|
const store = {
|
||||||
getDeploymentProfile: () => ({
|
getDeploymentProfile: () => ({
|
||||||
id: 'production',
|
id: "production",
|
||||||
provider: 'ssh-unraid',
|
provider: "ssh-unraid",
|
||||||
serverId: 'unraid',
|
serverId: "unraid",
|
||||||
remoteFolder: 'lumaops',
|
remoteFolder: "lumaops",
|
||||||
preservePaths: ['data', 'logs']
|
preservePaths: ["data", "logs"],
|
||||||
}),
|
}),
|
||||||
getServer: () => ({
|
getServer: () => ({
|
||||||
id: 'unraid',
|
id: "unraid",
|
||||||
name: 'Unraid',
|
name: "Unraid",
|
||||||
basePath: '/mnt/user/appdata'
|
basePath: "/mnt/user/appdata",
|
||||||
})
|
}),
|
||||||
};
|
};
|
||||||
const ssh = {
|
const ssh = {
|
||||||
exec: async (_serverId, command) => {
|
exec: async (_serverId, command) => {
|
||||||
receivedCommand = command;
|
receivedCommand = command;
|
||||||
return {
|
return {
|
||||||
stdout: `__FORGEFLOW_KV__\nexists=true\nrootGit=true\nhead=${'a'.repeat(40)}\nbranch=main\nremote=${b64('ssh://git@gitea/Jens/LumaOps.git')}\ntrackedChanges=\ncomposeFiles=${b64('docker-compose.yml\n')}\nnestedGit=${b64('source\n')}\ndockerfile=true\ndockerignoreContent=${b64('.git\ndata/\n')}\nexistingPreservePaths=${b64('data\nlogs\n')}\n`,
|
stdout: `__FORGEFLOW_KV__\nexists=true\nrootGit=true\nhead=${"a".repeat(40)}\nbranch=main\nremote=${b64("ssh://git@gitea/Jens/LumaOps.git")}\ntrackedChanges=\ncomposeFiles=${b64("docker-compose.yml\n")}\nnestedGit=${b64("source\n")}\ndockerfile=true\ndockerignoreContent=${b64(".git\ndata/\n")}\nexistingPreservePaths=${b64("data\nlogs\n")}\n`,
|
||||||
stderr: '',
|
stderr: "",
|
||||||
exitCode: 0
|
exitCode: 0,
|
||||||
};
|
};
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
const service = new UnraidDeploymentService({ store, ssh, git: {}, diagnostics: null });
|
const service = new UnraidDeploymentService({
|
||||||
const inspection = await service.inspect({ repository: { fullName: 'Jens/LumaOps', name: 'LumaOps' }, profileId: 'production' });
|
store,
|
||||||
|
ssh,
|
||||||
|
git: {},
|
||||||
|
diagnostics: null,
|
||||||
|
});
|
||||||
|
const inspection = await service.inspect({
|
||||||
|
repository: { fullName: "Jens/LumaOps", name: "LumaOps" },
|
||||||
|
profileId: "production",
|
||||||
|
});
|
||||||
|
|
||||||
assert.match(receivedCommand, /base64 -d \| bash$/);
|
assert.match(receivedCommand, /base64 -d \| bash$/);
|
||||||
assert.equal(inspection.remotePath, '/mnt/user/appdata/lumaops');
|
assert.equal(inspection.remotePath, "/mnt/user/appdata/lumaops");
|
||||||
assert.equal(inspection.dockerignoreGitExcluded, true);
|
assert.equal(inspection.dockerignoreGitExcluded, true);
|
||||||
assert.deepEqual(inspection.existingPreservePaths.sort(), ['data', 'logs']);
|
assert.deepEqual(inspection.existingPreservePaths.sort(), ["data", "logs"]);
|
||||||
assert.deepEqual(inspection.dockerContextExclusionsMissing.sort(), ['logs', 'source']);
|
assert.deepEqual(inspection.dockerContextExclusionsMissing.sort(), [
|
||||||
|
"logs",
|
||||||
|
"source",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('preflight summary blocks only failed checks', () => {
|
test("preflight summary blocks only failed checks", () => {
|
||||||
const result = checksSummary([{ id: 'a', status: 'pass' }, { id: 'b', status: 'warning' }, { id: 'c', status: 'fail' }]);
|
const result = checksSummary([
|
||||||
|
{ id: "a", status: "pass" },
|
||||||
|
{ id: "b", status: "warning" },
|
||||||
|
{ id: "c", status: "fail" },
|
||||||
|
]);
|
||||||
assert.equal(result.ready, false);
|
assert.equal(result.ready, false);
|
||||||
assert.deepEqual(result.blocking, ['c']);
|
assert.deepEqual(result.blocking, ["c"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('SSH helpers produce pinned fingerprints and quoted commands', () => {
|
test("SSH helpers produce pinned fingerprints and quoted commands", () => {
|
||||||
assert.match(fingerprintKey(Buffer.from('host-key')), /^SHA256:/);
|
assert.match(fingerprintKey(Buffer.from("host-key")), /^SHA256:/);
|
||||||
assert.equal(shellQuote("a'b"), "'a'\\''b'");
|
assert.equal(shellQuote("a'b"), "'a'\\''b'");
|
||||||
const wrapped = bash('git fetch origin main');
|
const wrapped = bash("git fetch origin main");
|
||||||
assert.match(wrapped, /base64 -d \| bash$/);
|
assert.match(wrapped, /base64 -d \| bash$/);
|
||||||
assert.equal(wrapped.includes('\n'), false);
|
assert.equal(wrapped.includes("\n"), false);
|
||||||
const encoded = wrapped.match(/printf '%s' '([A-Za-z0-9+/=]+)'/)[1];
|
const encoded = wrapped.match(/printf '%s' '([A-Za-z0-9+/=]+)'/)[1];
|
||||||
const decoded = Buffer.from(encoded, 'base64').toString('utf8');
|
const decoded = Buffer.from(encoded, "base64").toString("utf8");
|
||||||
assert.match(decoded, /GIT_TERMINAL_PROMPT=0/);
|
assert.match(decoded, /GIT_TERMINAL_PROMPT=0/);
|
||||||
assert.match(decoded, /BatchMode=yes/);
|
assert.match(decoded, /BatchMode=yes/);
|
||||||
assert.match(decoded, /git fetch origin main/);
|
assert.match(decoded, /git fetch origin main/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('SSH rollback refuses any SHA other than the exact recorded previous deployment', async () => {
|
test("SSH rollback refuses any SHA other than the exact recorded previous deployment", async () => {
|
||||||
const previousSha = 'a'.repeat(40);
|
const previousSha = "a".repeat(40);
|
||||||
const store = {
|
const store = {
|
||||||
getDeploymentProfile: () => ({ id: 'production', provider: 'ssh-unraid', serverId: 'unraid', remoteFolder: 'lumaops', composeFile: 'docker-compose.yml', branch: 'main', environment: 'production' }),
|
getDeploymentProfile: () => ({
|
||||||
getServer: () => ({ id: 'unraid', basePath: '/mnt/user/appdata' }),
|
id: "production",
|
||||||
getDeploymentState: () => ({ liveSha: 'b'.repeat(40), previousSha })
|
provider: "ssh-unraid",
|
||||||
|
serverId: "unraid",
|
||||||
|
remoteFolder: "lumaops",
|
||||||
|
composeFile: "docker-compose.yml",
|
||||||
|
branch: "main",
|
||||||
|
environment: "production",
|
||||||
|
}),
|
||||||
|
getServer: () => ({ id: "unraid", basePath: "/mnt/user/appdata" }),
|
||||||
|
getDeploymentState: () => ({ liveSha: "b".repeat(40), previousSha }),
|
||||||
};
|
};
|
||||||
const service = new UnraidDeploymentService({ store, ssh: {}, git: {}, diagnostics: null });
|
const service = new UnraidDeploymentService({
|
||||||
|
store,
|
||||||
|
ssh: {},
|
||||||
|
git: {},
|
||||||
|
diagnostics: null,
|
||||||
|
});
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
service.rollback({ repository: { fullName: 'Jens/LumaOps', name: 'LumaOps', localPath: '/tmp/lumaops' }, profileId: 'production', targetSha: 'c'.repeat(40) }),
|
service.rollback({
|
||||||
(error) => error.code === 'ROLLBACK_TARGET_NOT_PREVIOUS_SHA'
|
repository: {
|
||||||
|
fullName: "Jens/LumaOps",
|
||||||
|
name: "LumaOps",
|
||||||
|
localPath: "/tmp/lumaops",
|
||||||
|
},
|
||||||
|
profileId: "production",
|
||||||
|
targetSha: "c".repeat(40),
|
||||||
|
}),
|
||||||
|
(error) => error.code === "ROLLBACK_TARGET_NOT_PREVIOUS_SHA",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('successful SSH rollback records the formerly live SHA as the new rollback target', async () => {
|
test("successful SSH rollback records the formerly live SHA as the new rollback target", async () => {
|
||||||
const previousSha = 'a'.repeat(40);
|
const previousSha = "a".repeat(40);
|
||||||
const liveSha = 'b'.repeat(40);
|
const liveSha = "b".repeat(40);
|
||||||
const savedStates = [];
|
const savedStates = [];
|
||||||
const operations = [];
|
const operations = [];
|
||||||
const store = {
|
const store = {
|
||||||
getDeploymentProfile: () => ({ id: 'production', provider: 'ssh-unraid', serverId: 'unraid', remoteFolder: 'lumaops', composeFile: 'docker-compose.yml', branch: 'main', environment: 'production', healthcheckUrl: '', iconMode: 'none' }),
|
getDeploymentProfile: () => ({
|
||||||
getServer: () => ({ id: 'unraid', name: 'Unraid', basePath: '/mnt/user/appdata' }),
|
id: "production",
|
||||||
|
provider: "ssh-unraid",
|
||||||
|
serverId: "unraid",
|
||||||
|
remoteFolder: "lumaops",
|
||||||
|
composeFile: "docker-compose.yml",
|
||||||
|
branch: "main",
|
||||||
|
environment: "production",
|
||||||
|
healthcheckUrl: "",
|
||||||
|
iconMode: "none",
|
||||||
|
}),
|
||||||
|
getServer: () => ({
|
||||||
|
id: "unraid",
|
||||||
|
name: "Unraid",
|
||||||
|
basePath: "/mnt/user/appdata",
|
||||||
|
}),
|
||||||
getDeploymentState: () => ({ liveSha, previousSha }),
|
getDeploymentState: () => ({ liveSha, previousSha }),
|
||||||
addOperation: async (operation) => { operations.push(operation); return operation; },
|
addOperation: async (operation) => {
|
||||||
saveDeploymentState: async (_profileId, state) => { savedStates.push(state); return state; }
|
operations.push(operation);
|
||||||
|
return operation;
|
||||||
|
},
|
||||||
|
saveDeploymentState: async (_profileId, state) => {
|
||||||
|
savedStates.push(state);
|
||||||
|
return state;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const git = { verifyCommitOnRemoteBranch: async () => true };
|
const git = { verifyCommitOnRemoteBranch: async () => true };
|
||||||
const ssh = { exec: async () => ({ stdout: '', stderr: '', exitCode: 0 }) };
|
const ssh = { exec: async () => ({ stdout: "", stderr: "", exitCode: 0 }) };
|
||||||
const service = new UnraidDeploymentService({ store, ssh, git, diagnostics: null });
|
const service = new UnraidDeploymentService({
|
||||||
service.inspect = async () => ({ rootGit: true, trackedChanges: [], head: liveSha });
|
store,
|
||||||
service.checkHealth = async () => ({ configured: false, healthy: null, status: null, latencyMs: null });
|
ssh,
|
||||||
const result = await service.rollback({ repository: { fullName: 'Jens/LumaOps', name: 'LumaOps', localPath: '/tmp/lumaops' }, profileId: 'production', targetSha: previousSha });
|
git,
|
||||||
assert.equal(result.status, 'rolled-back');
|
diagnostics: null,
|
||||||
|
});
|
||||||
|
service.inspect = async () => ({
|
||||||
|
rootGit: true,
|
||||||
|
trackedChanges: [],
|
||||||
|
head: liveSha,
|
||||||
|
});
|
||||||
|
service.checkHealth = async () => ({
|
||||||
|
configured: false,
|
||||||
|
healthy: null,
|
||||||
|
status: null,
|
||||||
|
latencyMs: null,
|
||||||
|
});
|
||||||
|
const result = await service.rollback({
|
||||||
|
repository: {
|
||||||
|
fullName: "Jens/LumaOps",
|
||||||
|
name: "LumaOps",
|
||||||
|
localPath: "/tmp/lumaops",
|
||||||
|
},
|
||||||
|
profileId: "production",
|
||||||
|
targetSha: previousSha,
|
||||||
|
});
|
||||||
|
assert.equal(result.status, "rolled-back");
|
||||||
assert.equal(savedStates.at(-1).liveSha, previousSha);
|
assert.equal(savedStates.at(-1).liveSha, previousSha);
|
||||||
assert.equal(savedStates.at(-1).previousSha, liveSha);
|
assert.equal(savedStates.at(-1).previousSha, liveSha);
|
||||||
assert.equal(operations.at(-1).previousSha, liveSha);
|
assert.equal(operations.at(-1).previousSha, liveSha);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('generated Compose uses a lowercase-safe service while preserving the visible Portfolio container name', () => {
|
test("generated Compose uses a lowercase-safe service while preserving the visible Portfolio container name", () => {
|
||||||
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {}, diagnostics: null });
|
const service = new UnraidDeploymentService({
|
||||||
const compose = service.generatedCompose({ composeService: 'Portfolio', hostPort: 5150, containerPort: 80 }, { name: 'Portfolio' });
|
store: {},
|
||||||
|
ssh: {},
|
||||||
|
git: {},
|
||||||
|
diagnostics: null,
|
||||||
|
});
|
||||||
|
const compose = service.generatedCompose(
|
||||||
|
{ composeService: "Portfolio", hostPort: 5150, containerPort: 80 },
|
||||||
|
{ name: "Portfolio" },
|
||||||
|
);
|
||||||
assert.match(compose, / portfolio:/);
|
assert.match(compose, / portfolio:/);
|
||||||
assert.match(compose, /image: forgeflow\/portfolio:production/);
|
assert.match(compose, /image: forgeflow\/portfolio:production/);
|
||||||
assert.match(compose, /container_name: Portfolio/);
|
assert.match(compose, /container_name: Portfolio/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('SSH deployment dispatch returns a running operation while the remote build continues in background', async () => {
|
test("SSH deployment dispatch returns a running operation while the remote build continues in background", async () => {
|
||||||
const sha = 'd'.repeat(40);
|
const sha = "d".repeat(40);
|
||||||
const operations = [];
|
const operations = [];
|
||||||
let resolveRemote;
|
let resolveRemote;
|
||||||
const store = {
|
const store = {
|
||||||
getDeploymentProfile: () => ({ id: 'production', provider: 'ssh-unraid', serverId: 'unraid', remoteFolder: 'Portfolio', cloneUrl: 'forgeflow-gitea:Jens/Portfolio.git', composeFile: 'docker-compose.yml', branch: 'main', environment: 'production', healthcheckUrl: '', iconMode: 'none' }),
|
getDeploymentProfile: () => ({
|
||||||
getServer: () => ({ id: 'unraid', name: 'Unraid', basePath: '/mnt/user/appdata' }),
|
id: "production",
|
||||||
addOperation: async (operation) => { operations.push(structuredClone(operation)); return structuredClone(operation); },
|
provider: "ssh-unraid",
|
||||||
saveDeploymentState: async () => ({})
|
serverId: "unraid",
|
||||||
|
remoteFolder: "Portfolio",
|
||||||
|
cloneUrl: "forgeflow-gitea:Jens/Portfolio.git",
|
||||||
|
composeFile: "docker-compose.yml",
|
||||||
|
branch: "main",
|
||||||
|
environment: "production",
|
||||||
|
healthcheckUrl: "",
|
||||||
|
iconMode: "none",
|
||||||
|
}),
|
||||||
|
getServer: () => ({
|
||||||
|
id: "unraid",
|
||||||
|
name: "Unraid",
|
||||||
|
basePath: "/mnt/user/appdata",
|
||||||
|
}),
|
||||||
|
addOperation: async (operation) => {
|
||||||
|
operations.push(structuredClone(operation));
|
||||||
|
return structuredClone(operation);
|
||||||
|
},
|
||||||
|
saveDeploymentState: async () => ({}),
|
||||||
};
|
};
|
||||||
const ssh = { exec: async () => new Promise((resolve) => { resolveRemote = resolve; }) };
|
const ssh = {
|
||||||
const service = new UnraidDeploymentService({ store, ssh, git: {}, diagnostics: null });
|
exec: async () =>
|
||||||
service.preflight = async () => ({ summary: { ready: true, blocking: [] }, inspection: { head: null } });
|
new Promise((resolve) => {
|
||||||
service.checkHealth = async () => ({ configured: false, healthy: null, status: null, latencyMs: null });
|
resolveRemote = resolve;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const service = new UnraidDeploymentService({
|
||||||
|
store,
|
||||||
|
ssh,
|
||||||
|
git: {},
|
||||||
|
diagnostics: null,
|
||||||
|
});
|
||||||
|
service.preflight = async () => ({
|
||||||
|
summary: { ready: true, blocking: [] },
|
||||||
|
inspection: { head: null },
|
||||||
|
});
|
||||||
|
service.checkHealth = async () => ({
|
||||||
|
configured: false,
|
||||||
|
healthy: null,
|
||||||
|
status: null,
|
||||||
|
latencyMs: null,
|
||||||
|
});
|
||||||
|
|
||||||
const operation = await service.deploy({ repository: { fullName: 'Jens/Portfolio', name: 'Portfolio' }, profileId: 'production', sha });
|
const operation = await service.deploy({
|
||||||
assert.equal(operation.status, 'running');
|
repository: { fullName: "Jens/Portfolio", name: "Portfolio" },
|
||||||
assert.match(operation.logs.join('\n'), /background/i);
|
profileId: "production",
|
||||||
|
sha,
|
||||||
|
});
|
||||||
|
assert.equal(operation.status, "running");
|
||||||
|
assert.match(operation.logs.join("\n"), /background/i);
|
||||||
|
|
||||||
resolveRemote({ stdout: 'Container Portfolio started\n', stderr: '', exitCode: 0 });
|
resolveRemote({
|
||||||
|
stdout: "Container Portfolio started\n",
|
||||||
|
stderr: "",
|
||||||
|
exitCode: 0,
|
||||||
|
});
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
assert.equal(operations.at(-1).status, 'success');
|
assert.equal(operations.at(-1).status, "success");
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Unraid preflight verifies repository access before a deployment can start', async () => {
|
test("Unraid preflight verifies repository access before a deployment can start", async () => {
|
||||||
const source = await import('node:fs/promises').then(({ readFile }) => readFile(new URL('../src/main/unraid-deployment-service.cjs', import.meta.url), 'utf8'));
|
const source = await import("node:fs/promises").then(({ readFile }) =>
|
||||||
|
readFile(
|
||||||
|
new URL("../src/main/unraid-deployment-service.cjs", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
);
|
||||||
assert.match(source, /server-git-access/);
|
assert.match(source, /server-git-access/);
|
||||||
assert.match(source, /git ls-remote --exit-code/);
|
assert.match(source, /git ls-remote --exit-code/);
|
||||||
assert.match(source, /Unraid → Gitea access/);
|
assert.match(source, /Unraid → Gitea access/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("DockerMan metadata uses dockerman labels, a template WebUI and lowercase-safe service/image names", () => {
|
||||||
test('DockerMan metadata uses dockerman labels, a template WebUI and lowercase-safe service/image names', () => {
|
const service = new UnraidDeploymentService({
|
||||||
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {}, diagnostics: null });
|
store: {},
|
||||||
const metadata = service.metadataCompose({
|
ssh: {},
|
||||||
composeService: 'portfolio', containerName: 'Portfolio', remoteFolder: 'Portfolio',
|
git: {},
|
||||||
environment: 'production', hostPort: 5150, webUiUrl: 'http://192.168.10.150:5150/admin', dockerShell: '/bin/sh'
|
diagnostics: null,
|
||||||
}, { name: 'Portfolio' }, 'file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png');
|
});
|
||||||
|
const metadata = service.metadataCompose(
|
||||||
|
{
|
||||||
|
composeService: "portfolio",
|
||||||
|
containerName: "Portfolio",
|
||||||
|
remoteFolder: "Portfolio",
|
||||||
|
environment: "production",
|
||||||
|
hostPort: 5150,
|
||||||
|
webUiUrl: "http://192.168.10.150:5150/admin",
|
||||||
|
dockerShell: "/bin/sh",
|
||||||
|
},
|
||||||
|
{ name: "Portfolio" },
|
||||||
|
"file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png",
|
||||||
|
);
|
||||||
assert.match(metadata, / portfolio:/);
|
assert.match(metadata, / portfolio:/);
|
||||||
assert.match(metadata, /image: forgeflow\/portfolio:production/);
|
assert.match(metadata, /image: forgeflow\/portfolio:production/);
|
||||||
assert.match(metadata, /container_name: Portfolio/);
|
assert.match(metadata, /container_name: Portfolio/);
|
||||||
assert.match(metadata, /net\.unraid\.docker\.managed.*dockerman/);
|
assert.match(metadata, /net\.unraid\.docker\.managed.*dockerman/);
|
||||||
assert.match(metadata, /net\.unraid\.docker\.webui.*http:\/\/\[IP\]:\[PORT:5150\]\/admin/);
|
assert.match(
|
||||||
assert.match(metadata, /net\.unraid\.docker\.icon.*file:\/\/\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png/);
|
metadata,
|
||||||
|
/net\.unraid\.docker\.webui.*http:\/\/\[IP\]:\[PORT:5150\]\/admin/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
metadata,
|
||||||
|
/net\.unraid\.docker\.icon.*file:\/\/\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("DockerMan integration writes a persistent template fallback and invalidates cached metadata", () => {
|
||||||
|
const service = new UnraidDeploymentService({
|
||||||
test('DockerMan integration writes a persistent template fallback and invalidates cached metadata', () => {
|
store: {},
|
||||||
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {}, diagnostics: null });
|
ssh: {},
|
||||||
|
git: {},
|
||||||
|
diagnostics: null,
|
||||||
|
});
|
||||||
const profile = {
|
const profile = {
|
||||||
composeService: 'portfolio', containerName: 'Portfolio', remoteFolder: 'Portfolio',
|
composeService: "portfolio",
|
||||||
environment: 'production', hostPort: 5150, webUiUrl: 'http://192.168.10.150:5150/', dockerShell: '/bin/sh'
|
containerName: "Portfolio",
|
||||||
|
remoteFolder: "Portfolio",
|
||||||
|
environment: "production",
|
||||||
|
hostPort: 5150,
|
||||||
|
webUiUrl: "http://192.168.10.150:5150/",
|
||||||
|
dockerShell: "/bin/sh",
|
||||||
};
|
};
|
||||||
const repository = { name: 'Portfolio' };
|
const repository = { name: "Portfolio" };
|
||||||
const icon = 'file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png';
|
const icon =
|
||||||
|
"file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png";
|
||||||
const template = service.dockerManTemplate(profile, repository, icon);
|
const template = service.dockerManTemplate(profile, repository, icon);
|
||||||
const refresh = service.dockerManRefreshScript(profile, repository, icon);
|
const refresh = service.dockerManRefreshScript(profile, repository, icon);
|
||||||
assert.match(template, /<Name>Portfolio<\/Name>/);
|
assert.match(template, /<Name>Portfolio<\/Name>/);
|
||||||
assert.match(template, /<Repository>forgeflow\/portfolio:production<\/Repository>/);
|
assert.match(
|
||||||
|
template,
|
||||||
|
/<Repository>forgeflow\/portfolio:production<\/Repository>/,
|
||||||
|
);
|
||||||
assert.match(template, /<WebUI>http:\/\/\[IP\]:\[PORT:5150\]\/<\/WebUI>/);
|
assert.match(template, /<WebUI>http:\/\/\[IP\]:\[PORT:5150\]\/<\/WebUI>/);
|
||||||
assert.match(template, /<Icon>file:\/\/\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png<\/Icon>/);
|
assert.match(
|
||||||
|
template,
|
||||||
|
/<Icon>file:\/\/\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png<\/Icon>/,
|
||||||
|
);
|
||||||
assert.match(refresh, /templates-user\/my-Portfolio\.xml/);
|
assert.match(refresh, /templates-user\/my-Portfolio\.xml/);
|
||||||
assert.match(refresh, /dynamix\.docker\.manager\/docker\.json/);
|
assert.match(refresh, /dynamix\.docker\.manager\/docker\.json/);
|
||||||
assert.match(refresh, /cp '\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png'/);
|
assert.match(
|
||||||
|
refresh,
|
||||||
|
/cp '\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png'/,
|
||||||
|
);
|
||||||
assert.doesNotMatch(refresh, /dockerManRefreshScript/);
|
assert.doesNotMatch(refresh, /dockerManRefreshScript/);
|
||||||
assert.equal(xmlEscape('A&B<"x">'), 'A&B<"x">');
|
assert.equal(xmlEscape('A&B<"x">'), "A&B<"x">");
|
||||||
});
|
});
|
||||||
|
|
||||||
test('built-in ITWorx DockerMan icon is uploaded to persistent Unraid storage', async (t) => {
|
test("built-in ITWorx DockerMan icon is uploaded to persistent Unraid storage", async (t) => {
|
||||||
const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises');
|
const { mkdtemp, mkdir, writeFile, rm } = await import("node:fs/promises");
|
||||||
const os = await import('node:os');
|
const os = await import("node:os");
|
||||||
const path = await import('node:path');
|
const path = await import("node:path");
|
||||||
const sourcePath = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-icon-'));
|
const sourcePath = await mkdtemp(path.join(os.tmpdir(), "forgeflow-icon-"));
|
||||||
t.after(() => rm(sourcePath, { recursive: true, force: true }));
|
t.after(() => rm(sourcePath, { recursive: true, force: true }));
|
||||||
const asset = path.join(sourcePath, 'src', 'renderer', 'assets', 'itworx-mark.png');
|
const asset = path.join(
|
||||||
|
sourcePath,
|
||||||
|
"src",
|
||||||
|
"renderer",
|
||||||
|
"assets",
|
||||||
|
"itworx-mark.png",
|
||||||
|
);
|
||||||
await mkdir(path.dirname(asset), { recursive: true });
|
await mkdir(path.dirname(asset), { recursive: true });
|
||||||
await writeFile(asset, Buffer.from([137, 80, 78, 71]));
|
await writeFile(asset, Buffer.from([137, 80, 78, 71]));
|
||||||
const uploads = [];
|
const uploads = [];
|
||||||
const service = new UnraidDeploymentService({
|
const service = new UnraidDeploymentService({
|
||||||
store: {}, git: {}, diagnostics: null, sourcePath,
|
store: {},
|
||||||
ssh: { uploadFile: async (...args) => { uploads.push(args); return {}; } }
|
git: {},
|
||||||
|
diagnostics: null,
|
||||||
|
sourcePath,
|
||||||
|
ssh: {
|
||||||
|
uploadFile: async (...args) => {
|
||||||
|
uploads.push(args);
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const icon = await service.prepareIcon({ iconMode: 'builtin', remoteFolder: 'Portfolio' }, { name: 'Portfolio' }, { id: 'unraid' });
|
const icon = await service.prepareIcon(
|
||||||
assert.equal(icon, 'file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png');
|
{ iconMode: "builtin", remoteFolder: "Portfolio" },
|
||||||
|
{ name: "Portfolio" },
|
||||||
|
{ id: "unraid" },
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
icon,
|
||||||
|
"file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png",
|
||||||
|
);
|
||||||
assert.equal(uploads.length, 1);
|
assert.equal(uploads.length, 1);
|
||||||
assert.equal(uploads[0][0], 'unraid');
|
assert.equal(uploads[0][0], "unraid");
|
||||||
assert.equal(uploads[0][1], asset);
|
assert.equal(uploads[0][1], asset);
|
||||||
assert.equal(uploads[0][2], '/boot/config/plugins/dockerMan/images/Portfolio-icon.png');
|
assert.equal(
|
||||||
|
uploads[0][2],
|
||||||
|
"/boot/config/plugins/dockerMan/images/Portfolio-icon.png",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stuck SSH deployment is reconciled to success when exact SHA and container health are live', async () => {
|
test("stuck SSH deployment is reconciled to success when exact SHA and container health are live", async () => {
|
||||||
const sha = 'f'.repeat(40);
|
const sha = "f".repeat(40);
|
||||||
const saved = [];
|
const saved = [];
|
||||||
const operation = { id: 'op-1', type: 'deployment', provider: 'ssh-unraid', action: 'deploy', repository: 'Jens/Portfolio', profileId: 'production', sha, status: 'running', logs: [] };
|
const operation = {
|
||||||
|
id: "op-1",
|
||||||
|
type: "deployment",
|
||||||
|
provider: "ssh-unraid",
|
||||||
|
action: "deploy",
|
||||||
|
repository: "Jens/Portfolio",
|
||||||
|
profileId: "production",
|
||||||
|
sha,
|
||||||
|
status: "running",
|
||||||
|
logs: [],
|
||||||
|
};
|
||||||
const store = {
|
const store = {
|
||||||
getOperation: () => operation,
|
getOperation: () => operation,
|
||||||
addOperation: async (next) => { saved.push(next); return next; }
|
addOperation: async (next) => {
|
||||||
|
saved.push(next);
|
||||||
|
return next;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const service = new UnraidDeploymentService({ store, ssh: {}, git: {}, diagnostics: null });
|
const service = new UnraidDeploymentService({
|
||||||
service.refreshProfileState = async () => ({ liveSha: sha, containerRunning: true, healthy: true });
|
store,
|
||||||
const result = await service.refreshOperation('op-1');
|
ssh: {},
|
||||||
assert.equal(result.status, 'success');
|
git: {},
|
||||||
|
diagnostics: null,
|
||||||
|
});
|
||||||
|
service.refreshProfileState = async () => ({
|
||||||
|
liveSha: sha,
|
||||||
|
containerRunning: true,
|
||||||
|
healthy: true,
|
||||||
|
});
|
||||||
|
const result = await service.refreshOperation("op-1");
|
||||||
|
assert.equal(result.status, "success");
|
||||||
assert.match(result.logs.at(-1), /reconciled/i);
|
assert.match(result.logs.at(-1), /reconciled/i);
|
||||||
assert.equal(saved.at(-1).status, 'success');
|
assert.equal(saved.at(-1).status, "success");
|
||||||
});
|
});
|
||||||
|
|
||||||
test('DockerMan metadata repair refreshes known Unraid icon caches after container recreation', async () => {
|
test("DockerMan metadata repair refreshes known Unraid icon caches after container recreation", async () => {
|
||||||
const source = await import('node:fs/promises').then(({ readFile }) => readFile(new URL('../src/main/unraid-deployment-service.cjs', import.meta.url), 'utf8'));
|
const source = await import("node:fs/promises").then(({ readFile }) =>
|
||||||
|
readFile(
|
||||||
|
new URL("../src/main/unraid-deployment-service.cjs", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
);
|
||||||
assert.match(source, /\/var\/lib\/docker\/unraid\/images/);
|
assert.match(source, /\/var\/lib\/docker\/unraid\/images/);
|
||||||
assert.match(source, /dynamix\.docker\.manager\/images/);
|
assert.match(source, /dynamix\.docker\.manager\/images/);
|
||||||
assert.match(source, /-icon\.png/);
|
assert.match(source, /-icon\.png/);
|
||||||
@@ -249,55 +580,182 @@ test('DockerMan metadata repair refreshes known Unraid icon caches after contain
|
|||||||
assert.match(source, /--force-recreate/);
|
assert.match(source, /--force-recreate/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("stuck deployment is cleared as superseded when a different healthy commit is already live", async () => {
|
||||||
test('stuck deployment is cleared as superseded when a different healthy commit is already live', async () => {
|
const requested = "a".repeat(40);
|
||||||
const requested = 'a'.repeat(40);
|
const live = "b".repeat(40);
|
||||||
const live = 'b'.repeat(40);
|
const operation = {
|
||||||
const operation = { id: 'op-superseded', type: 'deployment', provider: 'ssh-unraid', action: 'deploy', repository: 'Jens/Portfolio', profileId: 'production', sha: requested, status: 'running', logs: [] };
|
id: "op-superseded",
|
||||||
|
type: "deployment",
|
||||||
|
provider: "ssh-unraid",
|
||||||
|
action: "deploy",
|
||||||
|
repository: "Jens/Portfolio",
|
||||||
|
profileId: "production",
|
||||||
|
sha: requested,
|
||||||
|
status: "running",
|
||||||
|
logs: [],
|
||||||
|
};
|
||||||
const saved = [];
|
const saved = [];
|
||||||
const service = new UnraidDeploymentService({
|
const service = new UnraidDeploymentService({
|
||||||
store: { getOperation: () => operation, addOperation: async (next) => { saved.push(next); return next; } },
|
store: {
|
||||||
ssh: {}, git: {}, diagnostics: null
|
getOperation: () => operation,
|
||||||
|
addOperation: async (next) => {
|
||||||
|
saved.push(next);
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ssh: {},
|
||||||
|
git: {},
|
||||||
|
diagnostics: null,
|
||||||
|
});
|
||||||
|
service.refreshProfileState = async () => ({
|
||||||
|
liveSha: live,
|
||||||
|
containerRunning: true,
|
||||||
|
healthy: true,
|
||||||
});
|
});
|
||||||
service.refreshProfileState = async () => ({ liveSha: live, containerRunning: true, healthy: true });
|
|
||||||
const result = await service.refreshOperation(operation.id);
|
const result = await service.refreshOperation(operation.id);
|
||||||
assert.equal(result.status, 'cancelled');
|
assert.equal(result.status, "cancelled");
|
||||||
assert.match(result.error, /Superseded/);
|
assert.match(result.error, /Superseded/);
|
||||||
assert.equal(saved.at(-1).status, 'cancelled');
|
assert.equal(saved.at(-1).status, "cancelled");
|
||||||
});
|
});
|
||||||
|
|
||||||
test('existing Unraid deployment discovery derives profile values from Docker, Compose and DockerMan truth', () => {
|
test("existing Unraid deployment discovery derives profile values from Docker, Compose and DockerMan truth", () => {
|
||||||
const { deriveDetectedProfile } = require('../src/main/unraid-deployment-service.cjs');
|
const {
|
||||||
|
deriveDetectedProfile,
|
||||||
|
} = require("../src/main/unraid-deployment-service.cjs");
|
||||||
const result = deriveDetectedProfile({
|
const result = deriveDetectedProfile({
|
||||||
repository: { name: 'blockpilot-autonomous', defaultBranch: 'main', sshUrl: 'ssh://git@gitea/Jens/blockpilot-autonomous.git' },
|
repository: {
|
||||||
server: { id: 'unraid', host: '192.168.10.150' },
|
name: "blockpilot-autonomous",
|
||||||
remoteFolder: 'blockpilot-autonomous',
|
defaultBranch: "main",
|
||||||
remotePath: '/mnt/user/appdata/blockpilot-autonomous',
|
sshUrl: "ssh://git@gitea/Jens/blockpilot-autonomous.git",
|
||||||
|
},
|
||||||
|
server: { id: "unraid", host: "192.168.10.150" },
|
||||||
|
remoteFolder: "blockpilot-autonomous",
|
||||||
|
remotePath: "/mnt/user/appdata/blockpilot-autonomous",
|
||||||
payload: {
|
payload: {
|
||||||
head: 'a'.repeat(40),
|
head: "a".repeat(40),
|
||||||
branch: 'main',
|
branch: "main",
|
||||||
remote: 'ssh://git@gitea/Jens/blockpilot-autonomous.git',
|
remote: "ssh://git@gitea/Jens/blockpilot-autonomous.git",
|
||||||
composeFiles: ['compose.yml'],
|
composeFiles: ["compose.yml"],
|
||||||
compose: { services: { app: { image: 'blockpilot:test' } } },
|
compose: { services: { app: { image: "blockpilot:test" } } },
|
||||||
containers: [{
|
containers: [
|
||||||
Name: '/blockpilot',
|
{
|
||||||
State: { Running: true },
|
Name: "/blockpilot",
|
||||||
Config: { Image: 'blockpilot:test', Env: ['TOKEN=secret', 'MODE=prod'], Labels: { 'com.docker.compose.service': 'app', 'com.docker.compose.project': 'blockpilot' } },
|
State: { Running: true },
|
||||||
HostConfig: { RestartPolicy: { Name: 'unless-stopped' } },
|
Config: {
|
||||||
NetworkSettings: { Ports: { '8080/tcp': [{ HostIp: '0.0.0.0', HostPort: '1223' }] }, Networks: { bridge: {} } },
|
Image: "blockpilot:test",
|
||||||
Mounts: [{ Type: 'bind', Source: '/mnt/user/appdata/blockpilot-autonomous/data', Destination: '/data', RW: true }]
|
Env: ["TOKEN=secret", "MODE=prod"],
|
||||||
}],
|
Labels: {
|
||||||
dockerManXml: '<Container><Name>blockpilot</Name><WebUI>http://[IP]:[PORT:1223]/</WebUI><Icon>https://example.test/icon.png</Icon><Shell>/bin/bash</Shell></Container>'
|
"com.docker.compose.service": "app",
|
||||||
}
|
"com.docker.compose.project": "blockpilot",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
HostConfig: { RestartPolicy: { Name: "unless-stopped" } },
|
||||||
|
NetworkSettings: {
|
||||||
|
Ports: { "8080/tcp": [{ HostIp: "0.0.0.0", HostPort: "1223" }] },
|
||||||
|
Networks: { bridge: {} },
|
||||||
|
},
|
||||||
|
Mounts: [
|
||||||
|
{
|
||||||
|
Type: "bind",
|
||||||
|
Source: "/mnt/user/appdata/blockpilot-autonomous/data",
|
||||||
|
Destination: "/data",
|
||||||
|
RW: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dockerManXml:
|
||||||
|
"<Container><Name>blockpilot</Name><WebUI>http://[IP]:[PORT:1223]/</WebUI><Icon>https://example.test/icon.png</Icon><Shell>/bin/bash</Shell></Container>",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
assert.equal(result.profile.hostPort, 1223);
|
assert.equal(result.profile.hostPort, 1223);
|
||||||
assert.equal(result.profile.containerPort, 8080);
|
assert.equal(result.profile.containerPort, 8080);
|
||||||
assert.equal(result.profile.containerName, 'blockpilot');
|
assert.equal(result.profile.containerName, "blockpilot");
|
||||||
assert.equal(result.profile.composeService, 'app');
|
assert.equal(result.profile.composeService, "app");
|
||||||
assert.equal(result.profile.webUiUrl, 'http://[IP]:[PORT:1223]/');
|
assert.equal(result.profile.webUiUrl, "http://[IP]:[PORT:1223]/");
|
||||||
assert.equal(result.profile.iconUrl, 'https://example.test/icon.png');
|
assert.equal(result.profile.iconUrl, "https://example.test/icon.png");
|
||||||
assert.equal(result.profile.dockerShell, '/bin/bash');
|
assert.equal(result.profile.dockerShell, "/bin/bash");
|
||||||
assert.deepEqual(result.profile.detectedMetadata.envNames, ['TOKEN', 'MODE']);
|
assert.deepEqual(result.profile.detectedMetadata.envNames, ["TOKEN", "MODE"]);
|
||||||
assert.ok(result.profile.preservePaths.includes('data'));
|
assert.ok(result.profile.preservePaths.includes("data"));
|
||||||
assert.equal(result.provenance.hostPort.origin, 'docker-inspect');
|
assert.equal(result.provenance.hostPort.origin, "docker-inspect");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a previously failed deployment is corrected when its exact commit is healthy on Unraid", async () => {
|
||||||
|
const sha = "e".repeat(40);
|
||||||
|
const failed = {
|
||||||
|
id: "failed-1",
|
||||||
|
type: "deployment",
|
||||||
|
provider: "ssh-unraid",
|
||||||
|
action: "deploy",
|
||||||
|
profileId: "production",
|
||||||
|
sha,
|
||||||
|
status: "failed",
|
||||||
|
logs: [],
|
||||||
|
};
|
||||||
|
const operations = [failed];
|
||||||
|
const service = new UnraidDeploymentService({
|
||||||
|
store: {
|
||||||
|
data: { operations },
|
||||||
|
getOperation: (id) => operations.find((item) => item.id === id),
|
||||||
|
addOperation: async (next) => {
|
||||||
|
operations.splice(
|
||||||
|
operations.findIndex((item) => item.id === next.id),
|
||||||
|
1,
|
||||||
|
next,
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ssh: {},
|
||||||
|
git: {},
|
||||||
|
diagnostics: null,
|
||||||
|
});
|
||||||
|
await service.reconcileRecordedOperations("production", {
|
||||||
|
liveSha: sha,
|
||||||
|
matchesGitea: true,
|
||||||
|
containerRunning: true,
|
||||||
|
healthy: true,
|
||||||
|
});
|
||||||
|
assert.equal(operations[0].status, "success");
|
||||||
|
assert.match(operations[0].logs.at(-1), /reconciled/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed deployment is marked superseded when Gitea and Unraid agree on a newer commit", async () => {
|
||||||
|
const liveSha = "d".repeat(40);
|
||||||
|
const operations = [
|
||||||
|
{
|
||||||
|
id: "failed-2",
|
||||||
|
type: "deployment",
|
||||||
|
provider: "ssh-unraid",
|
||||||
|
profileId: "production",
|
||||||
|
sha: "c".repeat(40),
|
||||||
|
status: "failed",
|
||||||
|
logs: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const service = new UnraidDeploymentService({
|
||||||
|
store: {
|
||||||
|
data: { operations },
|
||||||
|
getOperation: (id) => operations.find((item) => item.id === id),
|
||||||
|
addOperation: async (next) => {
|
||||||
|
operations.splice(
|
||||||
|
operations.findIndex((item) => item.id === next.id),
|
||||||
|
1,
|
||||||
|
next,
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ssh: {},
|
||||||
|
git: {},
|
||||||
|
diagnostics: null,
|
||||||
|
});
|
||||||
|
await service.reconcileRecordedOperations("production", {
|
||||||
|
liveSha,
|
||||||
|
matchesGitea: true,
|
||||||
|
containerRunning: true,
|
||||||
|
healthy: true,
|
||||||
|
});
|
||||||
|
assert.equal(operations[0].status, "cancelled");
|
||||||
|
assert.match(operations[0].error, /Superseded/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -340,18 +340,21 @@ test("packaged updater downloads only a published checksum-matched Windows asset
|
|||||||
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, assetId) {
|
||||||
return url.endsWith("/checksum")
|
return assetId === 42 ? Buffer.from(`${sha256} ${assetName}\n`) : binary;
|
||||||
? Buffer.from(`${sha256} ${assetName}\n`)
|
|
||||||
: binary;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const service = new UpdateService({
|
const service = new UpdateService({
|
||||||
@@ -398,18 +401,20 @@ test("packaged updater rejects a binary whose checksum does not match", async ()
|
|||||||
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, assetId) {
|
||||||
return url.endsWith("/checksum")
|
return assetId === 52
|
||||||
? Buffer.from(`${"0".repeat(64)} ${assetName}`)
|
? Buffer.from(`${"0".repeat(64)} ${assetName}`)
|
||||||
: binary;
|
: binary;
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user