Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e888bd2c1c | ||
|
|
40d41f9a32 | ||
|
|
70840839f8 | ||
|
|
1310d2e5a7 | ||
|
|
095342b882 | ||
|
|
cb95c407d9 | ||
|
|
b7daef3cd9 |
+43
-4
@@ -1,5 +1,47 @@
|
||||
# Changelog
|
||||
|
||||
## 0.8.8 - 2026-07-26
|
||||
|
||||
- fixed binary update downloads on Gitea servers that require release-scoped attachment routes;
|
||||
- sends both the immutable release ID and attachment ID when downloading update assets;
|
||||
- preserves strict same-origin token handling and SHA-256 verification.
|
||||
|
||||
## 0.8.7 - 2026-07-26
|
||||
|
||||
- automatically inventories running workloads across configured Unraid servers;
|
||||
- links server Git checkouts to Gitea repositories through exact normalized origins and strong container evidence;
|
||||
- supports image-only discovery through OCI and ForgeFlow repository/commit labels;
|
||||
- adopts uniquely matched workloads into Deployments without requiring a prior ForgeFlow release operation;
|
||||
- verifies every SSH deployment refresh against the current full Gitea branch SHA;
|
||||
- treats matching commits as in order only while the container is running and healthy, and rejects ambiguous matches.
|
||||
|
||||
## 0.8.6 - 2026-07-26
|
||||
|
||||
- added contextual animated code maps to unused diff-canvas space;
|
||||
- made illustrations respond to file type, diff size and pointer depth without obscuring code;
|
||||
- enriched changed-file rows with clearer state chips, active hierarchy and premium interaction feedback;
|
||||
- added responsive and reduced-motion safeguards for focused, accessible workspaces.
|
||||
|
||||
## 0.8.5 - 2026-07-26
|
||||
|
||||
- fixed packaged update downloads when Gitea reports an asset URL with a different public origin;
|
||||
- downloads release assets by immutable Gitea asset ID on the configured trusted origin;
|
||||
- retains strict token isolation and never follows authenticated downloads to another host.
|
||||
|
||||
## 0.8.4 - 2026-07-26
|
||||
|
||||
- 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
|
||||
|
||||
- enabled checksum-verified binary auto-update for installed and portable Windows builds;
|
||||
@@ -35,7 +77,6 @@
|
||||
- Fixed Windows PowerShell 5.1 updater status replacement and STARTED handshake.
|
||||
- Added helper-log diagnostics and update-request identity validation.
|
||||
|
||||
|
||||
## 0.6.0
|
||||
|
||||
- Added complete repository troubleshooting for stale `HEAD.lock`, `index.lock`, ref locks and diverged branches.
|
||||
@@ -57,7 +98,6 @@
|
||||
- Preserved configured Compose casing such as Portfolio.
|
||||
- Guaranteed failed remote operations become terminal failed records.
|
||||
|
||||
|
||||
## 0.5.3
|
||||
|
||||
- Confirmed updater handoff before application exit.
|
||||
@@ -70,7 +110,6 @@
|
||||
- Added portable structural safety validation for the Unraid deployment script.
|
||||
- Kept GNU Bash syntax validation on Linux and other non-Windows systems.
|
||||
|
||||
|
||||
## 0.5.1
|
||||
|
||||
- Fixed Windows publication quality gate by validating Bash syntax through standard input.
|
||||
@@ -84,7 +123,6 @@
|
||||
- Bulk normalization of legacy Gitea origins.
|
||||
- Expanded regression coverage.
|
||||
|
||||
|
||||
- Correctly stage deleted and renamed paths.
|
||||
- Preserve and surface local commits when push fails.
|
||||
- Always refresh the actual Git state after operation errors.
|
||||
@@ -156,6 +194,7 @@
|
||||
- Added safe workflow and server deployment examples.
|
||||
|
||||
## 0.4.5
|
||||
|
||||
- Fixed commit/push after manually staging a deleted file.
|
||||
- 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`.
|
||||
|
||||
+26
-19
@@ -1,4 +1,4 @@
|
||||
ForgeFlow 0.8.2 source manifest
|
||||
ForgeFlow 0.8.8 source manifest
|
||||
SHA-256 BYTES PATH
|
||||
(The manifest excludes itself, dependencies and generated release artifacts.)
|
||||
755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 58 .gitignore
|
||||
@@ -12,7 +12,7 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263
|
||||
4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183 3364 build/icon-64.png
|
||||
25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico
|
||||
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png
|
||||
973e58a92fb2fdaff471dbe7a20549c9fc79e6014933cd56dce971ad7d474bd4 8234 CHANGELOG.md
|
||||
b5ebb381645c9a7e9de693ff30c563819b70182931057727dc6e48db551adf9f 10557 CHANGELOG.md
|
||||
21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md
|
||||
a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md
|
||||
30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md
|
||||
@@ -40,6 +40,12 @@ f3d04f2d3419a7a010d5399cdd9351ff85ab2b3fdf8023977e559b0a5f8bcdc3 2571
|
||||
d7bdc61d9b617ad5acf0b2d468eda547fd7509d4af08f33d2661393f25bdcb5a 576 docs/RELEASE_NOTES_0.8.0.md
|
||||
1e056bfcf2105843402f4b14c63480240cc55456a4a63e229b3fdbaf3156b803 754 docs/RELEASE_NOTES_0.8.1.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
|
||||
7eedb25e1aae3b06a04bb9b2f4843bd6af614418737e600b4bdc9161edabd76a 632 docs/RELEASE_NOTES_0.8.8.md
|
||||
2b631b9d6d973bdd70869d84886ff339da351e29e17598970b3b27915674661d 4175 docs/ROADMAP.md
|
||||
1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png
|
||||
b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png
|
||||
@@ -52,7 +58,7 @@ bcb1e4daf1eeedc5b3f61d2406f1a65312dba130082528007e1629d9df99570a 153240
|
||||
b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md
|
||||
0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md
|
||||
4625a10ebd3c749f60b2a7bef6b1716cd05dbc44ccceba0491a1b46bc293c195 4883 docs/TEST_MATRIX.md
|
||||
28f42ed6352a01e034c39c5a2a2f461f3f540aa37abc339e5b00afcc81e7ba7b 3273 docs/UPDATING.md
|
||||
b3f317faba63d574ee83924f01b5275c118b5b04f6d9109c7cf9ed18500835d8 3389 docs/UPDATING.md
|
||||
c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 examples/gitea-actions/deploy.yml
|
||||
4c792cc9fd57ed36da291300c252a6ef75b08a249cf6f2561e23c4c22522138a 1477 examples/gitea-actions/rollback.yml
|
||||
1d2cde1bef4882f56006823d2806f6105882fa098a665a303150fdf18ada2004 5705 examples/server/forgeflow-deploy
|
||||
@@ -61,11 +67,11 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532
|
||||
106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf
|
||||
2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json
|
||||
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE
|
||||
6765015bdf27b288a250192272750b243c3cb8d1326b752d056d1e43317b6344 12935 main.cjs
|
||||
0d9f0ac837b4d3b39476d9bcbb57fb3e12d6b7084bb1f7435401e416cff1854f 12920 main.cjs
|
||||
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
|
||||
ebb0b154137c113205351216e79dc0aad79949ea465b1734f41dd524f913cb34 134141 package-lock.json
|
||||
4ad40664dd3ed8526685fc7ca75be8f8c21acbad515b375ba4f04f3e2c4dcfe1 3559 package.json
|
||||
3d2ac366a13e9418e3ec6d13ce95b611f30f0228eb3a80ef9e7a936ce9578e24 9080 preload.cjs
|
||||
dc26585558f060c74a11bc7033cf1f22c626e91668a6349a06a928114ecf417d 130466 package-lock.json
|
||||
e3d60b3adde674e5aa80cc7776db3efbc0ced66d49e89490cb9fbb6786c0db70 3906 package.json
|
||||
45eea5fe4fd58cc0e96497295223e81a4f3acc3778bf546d4c7c96af03da4d26 9496 preload.cjs
|
||||
b31c43d9355c13b5ae4efc0f3649d8cb8d509b2bb7ebb042ff546b7820fb7de8 8411 Publish-ForgeFlow-Release.ps1
|
||||
a6d32a742412b7836606be00f17be0465f1b6f55d3911f6c73a14029787ba206 14037 README.md
|
||||
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
|
||||
@@ -73,10 +79,11 @@ a6d32a742412b7836606be00f17be0465f1b6f55d3911f6c73a14029787ba206 14037
|
||||
f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287 scripts/apply-source-update.ps1
|
||||
6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs
|
||||
5e9a2a819522f6a32bbd9d3303263d5e5eaec95898ea2cd5776b221168008d75 1727 scripts/generate-source-manifest.mjs
|
||||
842436680521311594e798848b050ae4e488d0595f0de57315f6ec081c049fb9 1266 scripts/prune-dist.mjs
|
||||
74433d8a6b24afe368197a469e2fe0c5050c239d7250b84c2f3f598c304778b0 4736 scripts/publish-binary-release.cjs
|
||||
444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs
|
||||
42203f9e0fd4aae517284d387f265cf1b0b180379bc253a092b5c3c5c4caef0a 2992 scripts/validate-installed-connections.cjs
|
||||
e6def656ba61e6ac705bc87e59abc607f4870702f0cbd609bc3e122bb01a9939 12150 scripts/verify.mjs
|
||||
1a0931346c89d2d49b736a25aa58c5d29fbdf4acd593ea3614dba904ec462843 12374 scripts/verify.mjs
|
||||
0079701b5acbfef07b71a9623613d1940805ccd20649d77e3f34c37e79df7655 735 scripts/write-release-checksums.mjs
|
||||
619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1
|
||||
dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs
|
||||
@@ -86,24 +93,24 @@ a381848a296c28f6d14093c96f722967acf9c994ffb867d54dd92bf5ada2729b 23648
|
||||
c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs
|
||||
a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs
|
||||
0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs
|
||||
f5b4e468c92eb0d96d02357290ac4bfe2d30eef9c7287267882bc146237a8693 16559 src/main/gitea-service.cjs
|
||||
b2d768a9dfd1e494edee6609a233469e60c31c362143d5c37c3c9f908b7bca79 40559 src/main/ipc.cjs
|
||||
6ee0c645521c20043708595d1a9dd0fe7642fc300be1306effd7e1142eebcfb7 17201 src/main/gitea-service.cjs
|
||||
13f6521bb82aa5a3399d6839ea7563459b19969c1e5af7525e4116cf771282e9 46526 src/main/ipc.cjs
|
||||
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs
|
||||
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs
|
||||
3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs
|
||||
e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508 src/main/repository-monitor.cjs
|
||||
17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs
|
||||
b31a63bf8cb1807b3e838e2bf8a0e742738f119d13de8ca9f42e471f072217d3 8328 src/main/ssh-service.cjs
|
||||
720103f14cbedd7fd2776e49fd970a634f14e03d548d90bf93bcd878b6b3c674 58758 src/main/unraid-deployment-service.cjs
|
||||
36cc05deda3395e5e9de92b880c8315f508cb88e9b080ae34705057ae696804f 20696 src/main/update-service.cjs
|
||||
2df4cd7b7d685871e40ce86ce4f75d8451cfdfca6184ce6cae5eaa6651ce97da 191018 src/renderer/app.js
|
||||
f4e221edace12e2120ec439e0f91274de110872c5bfbe968f2d7935eeb7193c4 78544 src/main/unraid-deployment-service.cjs
|
||||
45e65564e1e8b9db487dc6dda03a752c51130189f23ec3b1260dc62e3621a925 20806 src/main/update-service.cjs
|
||||
00b5c44b7d04452498288d42a2c345e0e3922c8ddfabfee9eb04654f238811d5 197015 src/renderer/app.js
|
||||
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png
|
||||
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png
|
||||
094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png
|
||||
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png
|
||||
e1c463d6cda9f2b9b78c468845c0a7e8688f0362be5642074a1a5f7122dfe811 762 src/renderer/index.html
|
||||
d72bca0e649392dbf5c0e9c676dd08b80d5c9f4493f59a32d9201763c139b690 50942 src/renderer/mock-bridge.js
|
||||
51f6777fd7d2dc73dc3ddd96c91a11882483099b0a62e0ce172e25e3dce474a6 59851 src/renderer/styles.css
|
||||
2b68194e9ca0caab1ea2d1e5f7222a42f962ea16f3dd9517a7a13af811963969 51166 src/renderer/mock-bridge.js
|
||||
e30ea37ee00f34110d0d252c79f9d8e0bb127c4db0bd1bf8b374609dea433361 72160 src/renderer/styles.css
|
||||
0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs
|
||||
5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs
|
||||
029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs
|
||||
@@ -126,12 +133,12 @@ dd121d96ca265a027cd415a52064500a4541b2f8a662f4f4b25f2f996d52b5da 762
|
||||
e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 tests/git-integration.test.mjs
|
||||
5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db 1239 tests/git-status.test.mjs
|
||||
681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.test.mjs
|
||||
52b96f0a6623778fbdc1e8dbfc892e1d77a3d6616058cd7b08d532b207aa5719 5531 tests/gitea-actions.test.mjs
|
||||
e9c45a70635adea853a0a420caabd5ba465070296d2e73ffbb947d4ade52ed46 6165 tests/gitea-actions.test.mjs
|
||||
267d76b868d8d06ea031c14acd09a7715fb44668a25ade51a9e62e0170888bc8 1522 tests/ipc-contract.test.mjs
|
||||
caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs
|
||||
96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.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
|
||||
f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 1068 tests/repository-monitor.test.mjs
|
||||
75b5b83836c75675bb9a48fe4363fcb8a24fc425e6af6f822d7955c6f3c79eac 2265 tests/repository-service.test.mjs
|
||||
@@ -139,8 +146,8 @@ d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463
|
||||
bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs
|
||||
020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs
|
||||
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs
|
||||
54f641103a91d98974c41a3c0a568c617b76fa9913a0e20710cd11adc39b0deb 18092 tests/unraid-deployment.test.mjs
|
||||
0c49d3222ea362dbef171f5ad556a335bad670b47adef660ede101d84814d05e 14849 tests/update-service.test.mjs
|
||||
8f44579de3af7d7d23d55572d1d97cb0690425d4d9dffc30c6bf2ec3ff9c54eb 24553 tests/unraid-deployment.test.mjs
|
||||
11d6e6329f775617a1ce3657d0454cc97d7bcf3d9759f9a5a623c9d18e13d03e 15118 tests/update-service.test.mjs
|
||||
9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs
|
||||
7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs
|
||||
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.
|
||||
@@ -0,0 +1,13 @@
|
||||
# ForgeFlow 0.8.8
|
||||
|
||||
ForgeFlow 0.8.8 fixes the HTTP 404 returned while downloading packaged updates
|
||||
from the configured Gitea server.
|
||||
|
||||
The server's API requires release attachments to be addressed using both the
|
||||
immutable release ID and attachment ID. ForgeFlow now uses that exact
|
||||
release-scoped endpoint for the executable and its checksum file.
|
||||
|
||||
Strict same-origin token protection and SHA-256 verification remain unchanged.
|
||||
Install 0.8.8 manually when upgrading from 0.8.7 because the affected download
|
||||
code runs before the corrected updater can be installed. Future packaged
|
||||
updates can again be completed from inside ForgeFlow.
|
||||
@@ -40,6 +40,8 @@ $env:FORGEFLOW_USER_DATA = "$env:APPDATA\forgeflow"
|
||||
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.
|
||||
|
||||
## Publishing a release from Downloads
|
||||
|
||||
@@ -141,9 +141,7 @@ function createWindow() {
|
||||
);
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (/^https?:\/\//i.test(url))
|
||||
shell
|
||||
.openExternal(url)
|
||||
.catch((error) =>
|
||||
shell.openExternal(url).catch((error) =>
|
||||
diagnostics?.warning("external-link.open.failed", {
|
||||
url,
|
||||
message: error.message,
|
||||
@@ -260,6 +258,7 @@ app
|
||||
store,
|
||||
ssh,
|
||||
git,
|
||||
gitea,
|
||||
diagnostics,
|
||||
sourcePath: app.getAppPath(),
|
||||
onOperationChange: reportOperationChange,
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "forgeflow",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "forgeflow",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.8",
|
||||
"dependencies": {
|
||||
"ssh2": "1.17.0"
|
||||
},
|
||||
|
||||
+11
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "forgeflow",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.8",
|
||||
"private": true,
|
||||
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
|
||||
"main": "main.cjs",
|
||||
@@ -11,9 +11,9 @@
|
||||
"demo": "node scripts/serve-demo.mjs",
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"verify": "node scripts/verify.mjs",
|
||||
"dist:win": "electron-builder --win nsis portable && node scripts/write-release-checksums.mjs",
|
||||
"dist:linux": "electron-builder --linux AppImage",
|
||||
"dist:mac": "electron-builder --mac dmg",
|
||||
"dist:win": "electron-builder --win nsis portable && node scripts/write-release-checksums.mjs && node scripts/prune-dist.mjs",
|
||||
"dist:linux": "electron-builder --linux AppImage && node scripts/prune-dist.mjs",
|
||||
"dist:mac": "electron-builder --mac dmg && node scripts/prune-dist.mjs",
|
||||
"doctor": "node scripts/doctor.mjs",
|
||||
"acceptance": "node scripts/acceptance.mjs",
|
||||
"connections:check": "electron scripts/validate-installed-connections.cjs",
|
||||
@@ -52,6 +52,7 @@
|
||||
"docs/UPDATING.md",
|
||||
"scripts/apply-source-update.ps1",
|
||||
"scripts/apply-binary-update.ps1",
|
||||
"scripts/prune-dist.mjs",
|
||||
"docs/RELEASE_NOTES_0.4.0.md",
|
||||
"docs/LUMAOPS_SERVER_AUDIT.md",
|
||||
"docs/SSH_UNRAID_DEPLOYMENT.md",
|
||||
@@ -74,6 +75,12 @@
|
||||
"docs/RELEASE_NOTES_0.8.0.md",
|
||||
"docs/RELEASE_NOTES_0.8.1.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/RELEASE_NOTES_0.8.8.md",
|
||||
"docs/ACCEPTANCE.md"
|
||||
],
|
||||
"asarUnpack": [
|
||||
|
||||
+28
-6
@@ -22,7 +22,9 @@ function subscribe(channel, listener) {
|
||||
return () => ipcRenderer.removeListener(channel, handler);
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('forgeflow', Object.freeze({
|
||||
contextBridge.exposeInMainWorld(
|
||||
'forgeflow',
|
||||
Object.freeze({
|
||||
bootstrap: () => invoke('app:bootstrap'),
|
||||
selectDirectory: (payload) => invoke('dialog:select-directory', payload),
|
||||
selectKeyFile: (payload) => invoke('dialog:select-key-file', payload),
|
||||
@@ -46,7 +48,12 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({
|
||||
deleteServer: (serverId) => invoke('server:delete', { serverId }),
|
||||
testServer: (serverId) => invoke('server:test', { serverId }),
|
||||
inspectServerProject: (repository, profileId) => invoke('server:inspect-project', { repository, profileId }),
|
||||
discoverExistingDeployment: (repository, serverId, remoteFolder) => invoke('server:discover-existing', { repository, serverId, remoteFolder }),
|
||||
discoverExistingDeployment: (repository, serverId, remoteFolder) =>
|
||||
invoke('server:discover-existing', {
|
||||
repository,
|
||||
serverId,
|
||||
remoteFolder,
|
||||
}),
|
||||
refreshRepositories: () => invoke('repositories:refresh'),
|
||||
discoverRepositories: (roots) => invoke('repositories:discover', { roots }),
|
||||
favoriteRepository: (fullName, favorite) => invoke('repository:favorite', { fullName, favorite }),
|
||||
@@ -57,7 +64,12 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({
|
||||
repositoryDiffHunks: (localPath, filePath) => invoke('repository:diff-hunks', { localPath, filePath }),
|
||||
stageHunks: (localPath, filePath, hunkIndexes) => invoke('repository:stage-hunks', { localPath, filePath, hunkIndexes }),
|
||||
conflictState: (localPath) => invoke('repository:conflicts', { localPath }),
|
||||
resolveConflict: (localPath, filePath, resolution) => invoke('repository:resolve-conflict', { localPath, filePath, resolution }),
|
||||
resolveConflict: (localPath, filePath, resolution) =>
|
||||
invoke('repository:resolve-conflict', {
|
||||
localPath,
|
||||
filePath,
|
||||
resolution,
|
||||
}),
|
||||
continueGitOperation: (localPath) => invoke('repository:continue-operation', { localPath }),
|
||||
abortGitOperation: (localPath) => invoke('repository:abort-operation', { localPath }),
|
||||
stageFiles: (localPath, files) => invoke('repository:stage', { localPath, files }),
|
||||
@@ -95,10 +107,19 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({
|
||||
saveDeploymentProfile: (fullName, profile) => invoke('deployment:save-profile', { fullName, profile }),
|
||||
deploymentPreflight: (repository, profileId) => invoke('deployment:preflight', { repository, profileId }),
|
||||
deleteDeploymentProfile: (fullName, profileId) => invoke('deployment:delete-profile', { fullName, profileId }),
|
||||
deploy: (repository, profileId, sha, options = {}) => invoke('deployment:dispatch', { repository, profileId, sha, note: options.note || '', override: options.override === true, overrideReason: options.overrideReason || '' }),
|
||||
deploy: (repository, profileId, sha, options = {}) =>
|
||||
invoke('deployment:dispatch', {
|
||||
repository,
|
||||
profileId,
|
||||
sha,
|
||||
note: options.note || '',
|
||||
override: options.override === true,
|
||||
overrideReason: options.overrideReason || '',
|
||||
}),
|
||||
rollback: (repository, profileId, targetSha) => invoke('deployment:rollback', { repository, profileId, targetSha }),
|
||||
healthcheck: (url) => invoke('deployment:health', { url }),
|
||||
refreshProfileState: (fullName, profileId) => invoke('deployment:profile-state', { fullName, profileId }),
|
||||
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 }),
|
||||
@@ -115,5 +136,6 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({
|
||||
onRepositoriesChanged: (listener) => subscribe('repositories:changed', listener),
|
||||
onOperationsChanged: (listener) => subscribe('operations:changed', listener),
|
||||
onUpdatesChanged: (listener) => subscribe('updates:changed', listener),
|
||||
reset: () => invoke('app:reset')
|
||||
}));
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
+10
-3
@@ -46,6 +46,7 @@ const required = [
|
||||
"scripts/validate-installed-connections.cjs",
|
||||
"scripts/publish-binary-release.cjs",
|
||||
"scripts/write-release-checksums.mjs",
|
||||
"scripts/prune-dist.mjs",
|
||||
"scripts/generate-source-manifest.mjs",
|
||||
"setup-windows.ps1",
|
||||
"START-FORGEFLOW-OVERLAY.ps1",
|
||||
@@ -62,6 +63,12 @@ const required = [
|
||||
"docs/RELEASE_NOTES_0.8.0.md",
|
||||
"docs/RELEASE_NOTES_0.8.1.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/RELEASE_NOTES_0.8.8.md",
|
||||
"docs/UPDATING.md",
|
||||
"docs/DIAGNOSTICS.md",
|
||||
"docs/DEPLOYMENT_SETUP.md",
|
||||
@@ -100,9 +107,9 @@ for (const file of required) await access(path.join(root, file));
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(path.join(root, "package.json"), "utf8"),
|
||||
);
|
||||
if (packageJson.version !== "0.8.2")
|
||||
if (packageJson.version !== "0.8.8")
|
||||
throw new Error(
|
||||
`Expected package version 0.8.2, got ${packageJson.version}.`,
|
||||
`Expected package version 0.8.8, got ${packageJson.version}.`,
|
||||
);
|
||||
const sourceManifest = await readFile(
|
||||
path.join(root, "SOURCE_MANIFEST.txt"),
|
||||
@@ -367,7 +374,7 @@ for (const phrase of [
|
||||
"deriveDetectedProfile",
|
||||
"docker inspect",
|
||||
"net.unraid.docker.managed",
|
||||
"'dockerman'",
|
||||
"dockerman",
|
||||
"iconCacheRefresh",
|
||||
"[PORT:",
|
||||
"Superseded by live commit",
|
||||
|
||||
@@ -331,6 +331,17 @@ class GiteaService {
|
||||
throw new Error("The update download exceeded the redirect limit.");
|
||||
}
|
||||
|
||||
async downloadReleaseAsset(owner, repo, releaseId, assetId, options = {}) {
|
||||
const numericReleaseId = Number(releaseId);
|
||||
const numericId = Number(assetId);
|
||||
if (!Number.isSafeInteger(numericReleaseId) || numericReleaseId <= 0)
|
||||
throw new Error("Gitea returned an invalid release ID.");
|
||||
if (!Number.isSafeInteger(numericId) || numericId <= 0)
|
||||
throw new Error("Gitea returned an invalid release asset ID.");
|
||||
const assetPath = `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${numericReleaseId}/assets/${numericId}`;
|
||||
return this.downloadAuthenticated(assetPath, options);
|
||||
}
|
||||
|
||||
async dispatchWorkflow({ owner, repo, workflowFile, ref, inputs = {} }) {
|
||||
const result = await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/workflows/${encodeURIComponent(workflowFile)}/dispatches`,
|
||||
|
||||
+952
-268
File diff suppressed because it is too large
Load Diff
+1515
-369
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 asset = assets.find((item) => item.name === assetName);
|
||||
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(
|
||||
`Release v${update.remoteVersion} is missing ${assetName} or its SHA-256 file.`,
|
||||
);
|
||||
@@ -300,8 +300,18 @@ class UpdateService {
|
||||
}
|
||||
|
||||
const [binary, checksumBytes] = await Promise.all([
|
||||
this.gitea.downloadAuthenticated(asset.browser_download_url),
|
||||
this.gitea.downloadAuthenticated(checksumAsset.browser_download_url),
|
||||
this.gitea.downloadReleaseAsset(
|
||||
update.owner,
|
||||
update.repo,
|
||||
release.id,
|
||||
asset.id,
|
||||
),
|
||||
this.gitea.downloadReleaseAsset(
|
||||
update.owner,
|
||||
update.repo,
|
||||
release.id,
|
||||
checksumAsset.id,
|
||||
),
|
||||
]);
|
||||
if (binary.length < 1_000_000 || binary[0] !== 0x4d || binary[1] !== 0x5a) {
|
||||
throw new Error(
|
||||
@@ -639,7 +649,10 @@ class UpdateService {
|
||||
.catch(() => []);
|
||||
const candidates = [];
|
||||
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;
|
||||
const filePath = path.join(this.updateDirectory, entry.name);
|
||||
const stat = await fs.stat(filePath).catch(() => null);
|
||||
|
||||
+121
-7
@@ -159,6 +159,7 @@ const ui = {
|
||||
diagnosticsStatus: null,
|
||||
troubleshooter: null,
|
||||
deploymentDiscovery: null,
|
||||
serverDiscovery: [],
|
||||
lastDiagnosticBundle: null,
|
||||
setupDraft: {
|
||||
baseUrl: "https://",
|
||||
@@ -415,13 +416,33 @@ async function refreshActiveOperations(showErrors = true) {
|
||||
}
|
||||
|
||||
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) =>
|
||||
(repository.deploymentProfiles || []).map((profile) => ({
|
||||
repository,
|
||||
profile,
|
||||
})),
|
||||
);
|
||||
if (!targets.length) return { checked: 0, failed: 0 };
|
||||
if (!targets.length) return { checked: 0, failed: 0, discovery };
|
||||
|
||||
const failures = [];
|
||||
const queue = [...targets];
|
||||
@@ -454,7 +475,7 @@ async function refreshDeploymentTruth(showErrors = false) {
|
||||
"error",
|
||||
);
|
||||
}
|
||||
return { checked: targets.length, failed: failures.length };
|
||||
return { checked: targets.length, failed: failures.length, discovery };
|
||||
}
|
||||
|
||||
function selectRepository(id, shouldRender = true) {
|
||||
@@ -699,6 +720,19 @@ function queueActionFor(repository) {
|
||||
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() {
|
||||
const changed = ui.repositories.filter(
|
||||
(repository) => repository.localStatus?.counts.changed,
|
||||
@@ -720,7 +754,7 @@ function renderOverview() {
|
||||
(operation) => !isTerminalOperation(operation.status),
|
||||
);
|
||||
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>` : ""}
|
||||
<div class="summary-grid">
|
||||
${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>`;
|
||||
}
|
||||
|
||||
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) {
|
||||
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 escapeHtml(diff)
|
||||
const rendered = escapeHtml(diff)
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const type =
|
||||
@@ -777,6 +826,7 @@ function renderDiff(diff) {
|
||||
return `<span class="diff-line ${type}">${line || " "}</span>`;
|
||||
})
|
||||
.join("");
|
||||
return `${rendered}${diffAtmosphere(diff)}`;
|
||||
}
|
||||
function fileStatusCode(file) {
|
||||
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) {
|
||||
const state = profile.state || {};
|
||||
const health = environmentState(profile);
|
||||
@@ -860,7 +924,7 @@ function renderProfileCard(repository, profile, compact = false) {
|
||||
repository.localStatus?.branch.head === profile.branch;
|
||||
const isSsh = profile.provider === "ssh-unraid";
|
||||
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}`;
|
||||
const rollbackConfigured = isSsh || Boolean(profile.rollbackWorkflowFile);
|
||||
const dockerMan = dockerManIntegration(profile);
|
||||
@@ -868,7 +932,13 @@ function renderProfileCard(repository, profile, compact = false) {
|
||||
const dockerManReady = dockerMan.ready;
|
||||
const 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) {
|
||||
@@ -931,7 +1001,7 @@ function renderRepositoryWorkspace(repository) {
|
||||
settings: renderRepositorySettings,
|
||||
}[ui.repositoryTab] || renderChanges
|
||||
)(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>` : ""}
|
||||
<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">${[
|
||||
@@ -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.forgeflow
|
||||
.reportRendererEvent?.("error", "uncaught-error", {
|
||||
|
||||
@@ -564,7 +564,7 @@
|
||||
await wait(80);
|
||||
snapshot();
|
||||
return {
|
||||
appVersion: "0.8.2-demo",
|
||||
appVersion: "0.8.8-demo",
|
||||
platform: "win32",
|
||||
state: clone(state),
|
||||
git: { available: true, version: "git version 2.47.3" },
|
||||
@@ -1421,6 +1421,18 @@
|
||||
syncState();
|
||||
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) {
|
||||
await wait(300);
|
||||
if (operationId) {
|
||||
|
||||
+561
-12
@@ -32,27 +32,67 @@
|
||||
|
||||
html[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #eef2f7;
|
||||
--surface-0: #f7f9fc;
|
||||
--bg: #e9eef7;
|
||||
--surface-0: #f2f6fc;
|
||||
--surface-1: #ffffff;
|
||||
--surface-2: #f3f6fa;
|
||||
--surface-3: #e8edf4;
|
||||
--surface-hover: #edf2f8;
|
||||
--line: #cdd5e1;
|
||||
--line-soft: #e1e6ee;
|
||||
--line: #c5d0df;
|
||||
--line-soft: #dce4ef;
|
||||
--text: #172033;
|
||||
--text-muted: #526078;
|
||||
--text-faint: #7b879a;
|
||||
--primary: #295fca;
|
||||
--primary-strong: #326ee0;
|
||||
--primary-soft: rgba(50, 110, 224, 0.1);
|
||||
--primary: #2857bf;
|
||||
--primary-strong: #346ee8;
|
||||
--primary-soft: rgba(52, 110, 232, 0.13);
|
||||
--success: #087a57;
|
||||
--success-soft: rgba(8, 122, 87, 0.1);
|
||||
--warning: #9b5b00;
|
||||
--warning-soft: rgba(155, 91, 0, 0.1);
|
||||
--danger: #c73737;
|
||||
--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;
|
||||
}
|
||||
.file-row {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
min-height: 38px;
|
||||
display: grid;
|
||||
grid-template-columns: 17px 17px minmax(0, 1fr) 16px;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
padding: 3px 6px;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
border-color 160ms ease,
|
||||
background 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
.file-row:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text);
|
||||
border-color: color-mix(in srgb, var(--primary) 18%, transparent);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.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 {
|
||||
margin: 0;
|
||||
accent-color: var(--primary-strong);
|
||||
@@ -963,6 +1032,12 @@ select:focus-visible {
|
||||
font-size: 11px;
|
||||
}
|
||||
.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-size: 10px;
|
||||
font-weight: 750;
|
||||
@@ -977,6 +1052,7 @@ select:focus-visible {
|
||||
color: var(--danger);
|
||||
}
|
||||
.diff-panel {
|
||||
container-type: inline-size;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
@@ -1001,6 +1077,8 @@ select:focus-visible {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.diff-view {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: auto;
|
||||
padding: 8px 0 36px;
|
||||
font-family: var(--font-mono);
|
||||
@@ -1008,12 +1086,179 @@ select:focus-visible {
|
||||
line-height: 19px;
|
||||
white-space: pre;
|
||||
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 {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: block;
|
||||
min-height: 19px;
|
||||
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 {
|
||||
background: rgba(38, 166, 115, 0.14);
|
||||
color: #8ef0c6;
|
||||
@@ -1309,10 +1554,110 @@ html[data-theme="light"] .diff-line.remove {
|
||||
gap: 12px;
|
||||
}
|
||||
.deploy-card {
|
||||
--card-accent: var(--primary-strong);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-1);
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -3065,6 +3410,197 @@ select:focus {
|
||||
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) {
|
||||
*,
|
||||
*::before,
|
||||
@@ -3074,6 +3610,12 @@ select:focus {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
.project-illustration svg {
|
||||
transform: none !important;
|
||||
}
|
||||
.diff-atmosphere {
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
@@ -3087,4 +3629,11 @@ select:focus {
|
||||
.summary-grid {
|
||||
gap: 8px;
|
||||
}
|
||||
.project-illustration {
|
||||
width: 190px;
|
||||
flex-basis: 190px;
|
||||
}
|
||||
.project-illustration.repo {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,3 +107,22 @@ test('creates controlled pull requests and reads branch protection', async () =>
|
||||
assert.deepEqual(create.options.body, { head: 'feature', base: 'main', title: 'Release feature', body: 'Summary' });
|
||||
await assert.rejects(() => service.createPullRequest({ owner: 'owner', repo: 'app', head: 'main', base: 'main', title: 'Invalid' }), /different/);
|
||||
});
|
||||
|
||||
test('downloads release assets through the release-scoped Gitea endpoint', async () => {
|
||||
const service = new GiteaService(makeStore());
|
||||
let requested = '';
|
||||
service.downloadAuthenticated = async (pathname) => {
|
||||
requested = pathname;
|
||||
return Buffer.from('asset');
|
||||
};
|
||||
const asset = await service.downloadReleaseAsset('Jens', 'ForgeFlow', 107, 412);
|
||||
assert.equal(asset.toString(), 'asset');
|
||||
assert.equal(
|
||||
requested,
|
||||
'/api/v1/repos/Jens/ForgeFlow/releases/107/assets/412',
|
||||
);
|
||||
await assert.rejects(
|
||||
() => service.downloadReleaseAsset('Jens', 'ForgeFlow', null, 412),
|
||||
/invalid release ID/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -179,13 +179,16 @@ test("one-click troubleshooting excludes destructive or publishing Git actions",
|
||||
new URL("../src/main/ipc.cjs", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(ipc, /action: 'abort-operation', safe: false/);
|
||||
assert.match(ipc, /action: 'push', safe: false/);
|
||||
assert.match(ipc, /action:\s*["']abort-operation["'],\s*safe:\s*false/);
|
||||
assert.match(ipc, /action:\s*["']push["'],\s*safe:\s*false/);
|
||||
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(
|
||||
ipc,
|
||||
/\['fast-forward', 'push', 'fetch'\]\.includes\(issue\.action\)/,
|
||||
/\[\s*["']fast-forward["'],\s*["']push["'],\s*["']fetch["']\s*\]\.includes\(issue\.action\)/,
|
||||
);
|
||||
assert.match(
|
||||
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, /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/);
|
||||
});
|
||||
|
||||
+632
-174
@@ -1,247 +1,578 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
const require = createRequire(import.meta.url);
|
||||
const { UnraidDeploymentService, safeRemoteFolder, safeRelativeRemoteFile, parseInspection, dockerIgnoreHasPath, checksSummary, xmlEscape, bash } = require('../src/main/unraid-deployment-service.cjs');
|
||||
const { fingerprintKey, shellQuote } = require('../src/main/ssh-service.cjs');
|
||||
const {
|
||||
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', () => {
|
||||
assert.equal(safeRemoteFolder('lumaops'), 'lumaops');
|
||||
assert.throws(() => safeRemoteFolder('../lumaops'));
|
||||
assert.equal(safeRelativeRemoteFile('deploy/docker-compose.yml'), 'deploy/docker-compose.yml');
|
||||
assert.throws(() => safeRelativeRemoteFile('../../etc/passwd'));
|
||||
test("Unraid remote paths cannot escape appdata project folder", () => {
|
||||
assert.equal(safeRemoteFolder("lumaops"), "lumaops");
|
||||
assert.throws(() => safeRemoteFolder("../lumaops"));
|
||||
assert.equal(
|
||||
safeRelativeRemoteFile("deploy/docker-compose.yml"),
|
||||
"deploy/docker-compose.yml",
|
||||
);
|
||||
assert.throws(() => safeRelativeRemoteFile("../../etc/passwd"));
|
||||
});
|
||||
|
||||
test('server inspection key-value payload is decoded safely', () => {
|
||||
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`);
|
||||
test("server inspection key-value payload is decoded safely", () => {
|
||||
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`,
|
||||
);
|
||||
assert.equal(parsed.rootGit, true);
|
||||
assert.deepEqual(parsed.composeFiles, ['docker-compose.yml']);
|
||||
assert.deepEqual(parsed.nestedGit, ['source']);
|
||||
assert.deepEqual(parsed.composeFiles, ["docker-compose.yml"]);
|
||||
assert.deepEqual(parsed.nestedGit, ["source"]);
|
||||
assert.equal(parsed.trackedChanges.length, 1);
|
||||
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', () => {
|
||||
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 workload inventory links running containers to exact Gitea checkouts", () => {
|
||||
const b64 = (value) => Buffer.from(value).toString("base64");
|
||||
const inspect = JSON.stringify([
|
||||
{
|
||||
Name: "/Portfolio",
|
||||
State: { Running: true, Health: { Status: "healthy" } },
|
||||
Config: {
|
||||
Labels: {
|
||||
"com.docker.compose.project.working_dir":
|
||||
"/mnt/user/appdata/Portfolio",
|
||||
},
|
||||
},
|
||||
Mounts: [],
|
||||
},
|
||||
]);
|
||||
const inventory = parseServerInventory(
|
||||
`noise\n__FORGEFLOW_INVENTORY__\nR\t${b64("/mnt/user/appdata/Portfolio")}\t${b64("git@gitea.itworx.tech:Jens/Portfolio.git")}\t${"a".repeat(40)}\t${b64("main")}\nC\t${b64(inspect)}\n`,
|
||||
);
|
||||
assert.equal(inventory.checkouts.length, 1);
|
||||
assert.equal(inventory.containers.length, 1);
|
||||
assert.equal(
|
||||
remoteIdentity("git@gitea.itworx.tech:Jens/Portfolio.git"),
|
||||
remoteIdentity("https://gitea.itworx.tech/Jens/Portfolio"),
|
||||
);
|
||||
assert.equal(
|
||||
inventoryContainerMatch(
|
||||
inventory.checkouts[0],
|
||||
{ name: "Portfolio" },
|
||||
inventory.containers[0],
|
||||
),
|
||||
100,
|
||||
);
|
||||
});
|
||||
|
||||
test('server inspection detects preserved runtime paths and missing Docker context exclusions', async () => {
|
||||
const b64 = (value) => Buffer.from(value).toString('base64');
|
||||
let receivedCommand = '';
|
||||
test("automatic server discovery adopts and verifies a running Gitea deployment", async () => {
|
||||
const b64 = (value) => Buffer.from(value).toString("base64");
|
||||
const sha = "b".repeat(40);
|
||||
const container = {
|
||||
Name: "/Portfolio",
|
||||
State: { Running: true, Health: { Status: "healthy" } },
|
||||
Config: {
|
||||
Labels: {
|
||||
"com.docker.compose.project.working_dir": "/mnt/user/appdata/Portfolio",
|
||||
"com.docker.compose.service": "portfolio",
|
||||
},
|
||||
},
|
||||
Mounts: [],
|
||||
NetworkSettings: { Ports: { "3000/tcp": [{ HostPort: "8080" }] } },
|
||||
};
|
||||
const profiles = [];
|
||||
const states = new Map();
|
||||
const service = new UnraidDeploymentService({
|
||||
store: {
|
||||
getServer: () => ({
|
||||
id: "unraid",
|
||||
name: "Unraid",
|
||||
basePath: "/mnt/user/appdata",
|
||||
}),
|
||||
getDeploymentProfiles: () => profiles,
|
||||
saveDeploymentProfile: async (_fullName, profile) => {
|
||||
profiles.push(profile);
|
||||
return profile;
|
||||
},
|
||||
saveDeploymentState: async (id, state) => {
|
||||
states.set(id, state);
|
||||
return state;
|
||||
},
|
||||
},
|
||||
ssh: {
|
||||
exec: async () => ({
|
||||
stdout: `__FORGEFLOW_INVENTORY__\nR\t${b64("/mnt/user/appdata/Portfolio")}\t${b64("git@gitea.itworx.tech:Jens/Portfolio.git")}\t${sha}\t${b64("main")}\nC\t${b64(JSON.stringify([container]))}\n`,
|
||||
}),
|
||||
},
|
||||
gitea: { getBranch: async () => ({ commit: { id: sha } }) },
|
||||
});
|
||||
const result = await service.discoverServerWorkloads("unraid", [
|
||||
{
|
||||
fullName: "Jens/Portfolio",
|
||||
name: "Portfolio",
|
||||
defaultBranch: "main",
|
||||
cloneUrl: "https://gitea.itworx.tech/Jens/Portfolio.git",
|
||||
sshUrl: "git@gitea.itworx.tech:Jens/Portfolio.git",
|
||||
},
|
||||
]);
|
||||
assert.equal(result.adopted, 1);
|
||||
assert.equal(result.verified, 1);
|
||||
assert.equal(profiles[0].containerName, "Portfolio");
|
||||
assert.equal(profiles[0].adoptedFromServer, true);
|
||||
assert.equal(states.get(profiles[0].id).matchesGitea, true);
|
||||
});
|
||||
|
||||
test("Docker ignore checks identify exact runtime and Git context exclusions", () => {
|
||||
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 = {
|
||||
getDeploymentProfile: () => ({
|
||||
id: 'production',
|
||||
provider: 'ssh-unraid',
|
||||
serverId: 'unraid',
|
||||
remoteFolder: 'lumaops',
|
||||
preservePaths: ['data', 'logs']
|
||||
id: "production",
|
||||
provider: "ssh-unraid",
|
||||
serverId: "unraid",
|
||||
remoteFolder: "lumaops",
|
||||
preservePaths: ["data", "logs"],
|
||||
}),
|
||||
getServer: () => ({
|
||||
id: 'unraid',
|
||||
name: 'Unraid',
|
||||
basePath: '/mnt/user/appdata'
|
||||
})
|
||||
id: "unraid",
|
||||
name: "Unraid",
|
||||
basePath: "/mnt/user/appdata",
|
||||
}),
|
||||
};
|
||||
const ssh = {
|
||||
exec: async (_serverId, command) => {
|
||||
receivedCommand = command;
|
||||
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`,
|
||||
stderr: '',
|
||||
exitCode: 0
|
||||
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: "",
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
const service = new UnraidDeploymentService({ store, ssh, git: {}, diagnostics: null });
|
||||
const inspection = await service.inspect({ repository: { fullName: 'Jens/LumaOps', name: 'LumaOps' }, profileId: 'production' });
|
||||
const service = new UnraidDeploymentService({
|
||||
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.equal(inspection.remotePath, '/mnt/user/appdata/lumaops');
|
||||
assert.equal(inspection.remotePath, "/mnt/user/appdata/lumaops");
|
||||
assert.equal(inspection.dockerignoreGitExcluded, true);
|
||||
assert.deepEqual(inspection.existingPreservePaths.sort(), ['data', 'logs']);
|
||||
assert.deepEqual(inspection.dockerContextExclusionsMissing.sort(), ['logs', 'source']);
|
||||
assert.deepEqual(inspection.existingPreservePaths.sort(), ["data", "logs"]);
|
||||
assert.deepEqual(inspection.dockerContextExclusionsMissing.sort(), [
|
||||
"logs",
|
||||
"source",
|
||||
]);
|
||||
});
|
||||
|
||||
test('preflight summary blocks only failed checks', () => {
|
||||
const result = checksSummary([{ id: 'a', status: 'pass' }, { id: 'b', status: 'warning' }, { id: 'c', status: 'fail' }]);
|
||||
test("preflight summary blocks only failed checks", () => {
|
||||
const result = checksSummary([
|
||||
{ id: "a", status: "pass" },
|
||||
{ id: "b", status: "warning" },
|
||||
{ id: "c", status: "fail" },
|
||||
]);
|
||||
assert.equal(result.ready, false);
|
||||
assert.deepEqual(result.blocking, ['c']);
|
||||
assert.deepEqual(result.blocking, ["c"]);
|
||||
});
|
||||
|
||||
test('SSH helpers produce pinned fingerprints and quoted commands', () => {
|
||||
assert.match(fingerprintKey(Buffer.from('host-key')), /^SHA256:/);
|
||||
test("SSH helpers produce pinned fingerprints and quoted commands", () => {
|
||||
assert.match(fingerprintKey(Buffer.from("host-key")), /^SHA256:/);
|
||||
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.equal(wrapped.includes('\n'), false);
|
||||
assert.equal(wrapped.includes("\n"), false);
|
||||
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, /BatchMode=yes/);
|
||||
assert.match(decoded, /git fetch origin main/);
|
||||
});
|
||||
|
||||
test('SSH rollback refuses any SHA other than the exact recorded previous deployment', async () => {
|
||||
const previousSha = 'a'.repeat(40);
|
||||
test("SSH rollback refuses any SHA other than the exact recorded previous deployment", async () => {
|
||||
const previousSha = "a".repeat(40);
|
||||
const store = {
|
||||
getDeploymentProfile: () => ({ id: 'production', 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 })
|
||||
getDeploymentProfile: () => ({
|
||||
id: "production",
|
||||
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(
|
||||
service.rollback({ repository: { fullName: 'Jens/LumaOps', name: 'LumaOps', localPath: '/tmp/lumaops' }, profileId: 'production', targetSha: 'c'.repeat(40) }),
|
||||
(error) => error.code === 'ROLLBACK_TARGET_NOT_PREVIOUS_SHA'
|
||||
service.rollback({
|
||||
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 () => {
|
||||
const previousSha = 'a'.repeat(40);
|
||||
const liveSha = 'b'.repeat(40);
|
||||
test("successful SSH rollback records the formerly live SHA as the new rollback target", async () => {
|
||||
const previousSha = "a".repeat(40);
|
||||
const liveSha = "b".repeat(40);
|
||||
const savedStates = [];
|
||||
const operations = [];
|
||||
const store = {
|
||||
getDeploymentProfile: () => ({ 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' }),
|
||||
getDeploymentProfile: () => ({
|
||||
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 }),
|
||||
addOperation: async (operation) => { operations.push(operation); return operation; },
|
||||
saveDeploymentState: async (_profileId, state) => { savedStates.push(state); return state; }
|
||||
addOperation: async (operation) => {
|
||||
operations.push(operation);
|
||||
return operation;
|
||||
},
|
||||
saveDeploymentState: async (_profileId, state) => {
|
||||
savedStates.push(state);
|
||||
return state;
|
||||
},
|
||||
};
|
||||
const git = { verifyCommitOnRemoteBranch: async () => true };
|
||||
const ssh = { exec: async () => ({ stdout: '', stderr: '', exitCode: 0 }) };
|
||||
const service = new UnraidDeploymentService({ store, ssh, git, 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');
|
||||
const ssh = { exec: async () => ({ stdout: "", stderr: "", exitCode: 0 }) };
|
||||
const service = new UnraidDeploymentService({
|
||||
store,
|
||||
ssh,
|
||||
git,
|
||||
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).previousSha, liveSha);
|
||||
assert.equal(operations.at(-1).previousSha, liveSha);
|
||||
});
|
||||
|
||||
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 compose = service.generatedCompose({ composeService: 'Portfolio', hostPort: 5150, containerPort: 80 }, { name: 'Portfolio' });
|
||||
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 compose = service.generatedCompose(
|
||||
{ composeService: "Portfolio", hostPort: 5150, containerPort: 80 },
|
||||
{ name: "Portfolio" },
|
||||
);
|
||||
assert.match(compose, / portfolio:/);
|
||||
assert.match(compose, /image: forgeflow\/portfolio:production/);
|
||||
assert.match(compose, /container_name: Portfolio/);
|
||||
});
|
||||
|
||||
test('SSH deployment dispatch returns a running operation while the remote build continues in background', async () => {
|
||||
const sha = 'd'.repeat(40);
|
||||
test("SSH deployment dispatch returns a running operation while the remote build continues in background", async () => {
|
||||
const sha = "d".repeat(40);
|
||||
const operations = [];
|
||||
let resolveRemote;
|
||||
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' }),
|
||||
getServer: () => ({ id: 'unraid', name: 'Unraid', basePath: '/mnt/user/appdata' }),
|
||||
addOperation: async (operation) => { operations.push(structuredClone(operation)); return structuredClone(operation); },
|
||||
saveDeploymentState: async () => ({})
|
||||
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",
|
||||
}),
|
||||
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 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 ssh = {
|
||||
exec: async () =>
|
||||
new Promise((resolve) => {
|
||||
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 });
|
||||
assert.equal(operation.status, 'running');
|
||||
assert.match(operation.logs.join('\n'), /background/i);
|
||||
const operation = await service.deploy({
|
||||
repository: { fullName: "Jens/Portfolio", name: "Portfolio" },
|
||||
profileId: "production",
|
||||
sha,
|
||||
});
|
||||
assert.equal(operation.status, "running");
|
||||
assert.match(operation.logs.join("\n"), /background/i);
|
||||
|
||||
resolveRemote({ stdout: 'Container Portfolio started\n', stderr: '', exitCode: 0 });
|
||||
resolveRemote({
|
||||
stdout: "Container Portfolio started\n",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
});
|
||||
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 () => {
|
||||
const source = await import('node:fs/promises').then(({ readFile }) => readFile(new URL('../src/main/unraid-deployment-service.cjs', import.meta.url), 'utf8'));
|
||||
test("Unraid preflight verifies repository access before a deployment can start", async () => {
|
||||
const source = await import("node:fs/promises").then(({ readFile }) =>
|
||||
readFile(
|
||||
new URL("../src/main/unraid-deployment-service.cjs", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
assert.match(source, /server-git-access/);
|
||||
assert.match(source, /git ls-remote --exit-code/);
|
||||
assert.match(source, /Unraid → Gitea access/);
|
||||
});
|
||||
|
||||
|
||||
test('DockerMan metadata uses dockerman labels, a template WebUI and lowercase-safe service/image names', () => {
|
||||
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {}, diagnostics: null });
|
||||
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');
|
||||
test("DockerMan metadata uses dockerman labels, a template WebUI and lowercase-safe service/image names", () => {
|
||||
const service = new UnraidDeploymentService({
|
||||
store: {},
|
||||
ssh: {},
|
||||
git: {},
|
||||
diagnostics: null,
|
||||
});
|
||||
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, /image: forgeflow\/portfolio:production/);
|
||||
assert.match(metadata, /container_name: Portfolio/);
|
||||
assert.match(metadata, /net\.unraid\.docker\.managed.*dockerman/);
|
||||
assert.match(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/);
|
||||
assert.match(
|
||||
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({ store: {}, ssh: {}, git: {}, diagnostics: null });
|
||||
test("DockerMan integration writes a persistent template fallback and invalidates cached metadata", () => {
|
||||
const service = new UnraidDeploymentService({
|
||||
store: {},
|
||||
ssh: {},
|
||||
git: {},
|
||||
diagnostics: null,
|
||||
});
|
||||
const profile = {
|
||||
composeService: 'portfolio', containerName: 'Portfolio', remoteFolder: 'Portfolio',
|
||||
environment: 'production', hostPort: 5150, webUiUrl: 'http://192.168.10.150:5150/', dockerShell: '/bin/sh'
|
||||
composeService: "portfolio",
|
||||
containerName: "Portfolio",
|
||||
remoteFolder: "Portfolio",
|
||||
environment: "production",
|
||||
hostPort: 5150,
|
||||
webUiUrl: "http://192.168.10.150:5150/",
|
||||
dockerShell: "/bin/sh",
|
||||
};
|
||||
const repository = { name: 'Portfolio' };
|
||||
const icon = 'file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png';
|
||||
const repository = { name: "Portfolio" };
|
||||
const icon =
|
||||
"file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png";
|
||||
const template = service.dockerManTemplate(profile, repository, icon);
|
||||
const refresh = service.dockerManRefreshScript(profile, repository, icon);
|
||||
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, /<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, /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.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) => {
|
||||
const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises');
|
||||
const os = await import('node:os');
|
||||
const path = await import('node:path');
|
||||
const sourcePath = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-icon-'));
|
||||
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 os = await import("node:os");
|
||||
const path = await import("node:path");
|
||||
const sourcePath = await mkdtemp(path.join(os.tmpdir(), "forgeflow-icon-"));
|
||||
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 writeFile(asset, Buffer.from([137, 80, 78, 71]));
|
||||
const uploads = [];
|
||||
const service = new UnraidDeploymentService({
|
||||
store: {}, git: {}, diagnostics: null, sourcePath,
|
||||
ssh: { uploadFile: async (...args) => { uploads.push(args); return {}; } }
|
||||
store: {},
|
||||
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' });
|
||||
assert.equal(icon, 'file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png');
|
||||
const icon = await service.prepareIcon(
|
||||
{ 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[0][0], 'unraid');
|
||||
assert.equal(uploads[0][0], "unraid");
|
||||
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 () => {
|
||||
const sha = 'f'.repeat(40);
|
||||
test("stuck SSH deployment is reconciled to success when exact SHA and container health are live", async () => {
|
||||
const sha = "f".repeat(40);
|
||||
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 = {
|
||||
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 });
|
||||
service.refreshProfileState = async () => ({ liveSha: sha, containerRunning: true, healthy: true });
|
||||
const result = await service.refreshOperation('op-1');
|
||||
assert.equal(result.status, 'success');
|
||||
const service = new UnraidDeploymentService({
|
||||
store,
|
||||
ssh: {},
|
||||
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.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 () => {
|
||||
const source = await import('node:fs/promises').then(({ readFile }) => readFile(new URL('../src/main/unraid-deployment-service.cjs', import.meta.url), 'utf8'));
|
||||
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",
|
||||
),
|
||||
);
|
||||
assert.match(source, /\/var\/lib\/docker\/unraid\/images/);
|
||||
assert.match(source, /dynamix\.docker\.manager\/images/);
|
||||
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/);
|
||||
});
|
||||
|
||||
|
||||
test('stuck deployment is cleared as superseded when a different healthy commit is already live', async () => {
|
||||
const requested = 'a'.repeat(40);
|
||||
const live = 'b'.repeat(40);
|
||||
const operation = { id: 'op-superseded', type: 'deployment', provider: 'ssh-unraid', action: 'deploy', repository: 'Jens/Portfolio', profileId: 'production', sha: requested, status: 'running', logs: [] };
|
||||
test("stuck deployment is cleared as superseded when a different healthy commit is already live", async () => {
|
||||
const requested = "a".repeat(40);
|
||||
const live = "b".repeat(40);
|
||||
const operation = {
|
||||
id: "op-superseded",
|
||||
type: "deployment",
|
||||
provider: "ssh-unraid",
|
||||
action: "deploy",
|
||||
repository: "Jens/Portfolio",
|
||||
profileId: "production",
|
||||
sha: requested,
|
||||
status: "running",
|
||||
logs: [],
|
||||
};
|
||||
const saved = [];
|
||||
const service = new UnraidDeploymentService({
|
||||
store: { getOperation: () => operation, addOperation: async (next) => { saved.push(next); return next; } },
|
||||
ssh: {}, git: {}, diagnostics: null
|
||||
store: {
|
||||
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);
|
||||
assert.equal(result.status, 'cancelled');
|
||||
assert.equal(result.status, "cancelled");
|
||||
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', () => {
|
||||
const { deriveDetectedProfile } = require('../src/main/unraid-deployment-service.cjs');
|
||||
test("existing Unraid deployment discovery derives profile values from Docker, Compose and DockerMan truth", () => {
|
||||
const {
|
||||
deriveDetectedProfile,
|
||||
} = require("../src/main/unraid-deployment-service.cjs");
|
||||
const result = deriveDetectedProfile({
|
||||
repository: { name: 'blockpilot-autonomous', defaultBranch: 'main', sshUrl: 'ssh://git@gitea/Jens/blockpilot-autonomous.git' },
|
||||
server: { id: 'unraid', host: '192.168.10.150' },
|
||||
remoteFolder: 'blockpilot-autonomous',
|
||||
remotePath: '/mnt/user/appdata/blockpilot-autonomous',
|
||||
repository: {
|
||||
name: "blockpilot-autonomous",
|
||||
defaultBranch: "main",
|
||||
sshUrl: "ssh://git@gitea/Jens/blockpilot-autonomous.git",
|
||||
},
|
||||
server: { id: "unraid", host: "192.168.10.150" },
|
||||
remoteFolder: "blockpilot-autonomous",
|
||||
remotePath: "/mnt/user/appdata/blockpilot-autonomous",
|
||||
payload: {
|
||||
head: 'a'.repeat(40),
|
||||
branch: 'main',
|
||||
remote: 'ssh://git@gitea/Jens/blockpilot-autonomous.git',
|
||||
composeFiles: ['compose.yml'],
|
||||
compose: { services: { app: { image: 'blockpilot:test' } } },
|
||||
containers: [{
|
||||
Name: '/blockpilot',
|
||||
head: "a".repeat(40),
|
||||
branch: "main",
|
||||
remote: "ssh://git@gitea/Jens/blockpilot-autonomous.git",
|
||||
composeFiles: ["compose.yml"],
|
||||
compose: { services: { app: { image: "blockpilot:test" } } },
|
||||
containers: [
|
||||
{
|
||||
Name: "/blockpilot",
|
||||
State: { Running: true },
|
||||
Config: { Image: 'blockpilot:test', Env: ['TOKEN=secret', 'MODE=prod'], Labels: { 'com.docker.compose.service': 'app', 'com.docker.compose.project': 'blockpilot' } },
|
||||
HostConfig: { RestartPolicy: { Name: 'unless-stopped' } },
|
||||
NetworkSettings: { Ports: { '8080/tcp': [{ HostIp: '0.0.0.0', HostPort: '1223' }] }, Networks: { bridge: {} } },
|
||||
Mounts: [{ Type: 'bind', Source: '/mnt/user/appdata/blockpilot-autonomous/data', Destination: '/data', RW: true }]
|
||||
}],
|
||||
dockerManXml: '<Container><Name>blockpilot</Name><WebUI>http://[IP]:[PORT:1223]/</WebUI><Icon>https://example.test/icon.png</Icon><Shell>/bin/bash</Shell></Container>'
|
||||
}
|
||||
Config: {
|
||||
Image: "blockpilot:test",
|
||||
Env: ["TOKEN=secret", "MODE=prod"],
|
||||
Labels: {
|
||||
"com.docker.compose.service": "app",
|
||||
"com.docker.compose.project": "blockpilot",
|
||||
},
|
||||
},
|
||||
HostConfig: { RestartPolicy: { Name: "unless-stopped" } },
|
||||
NetworkSettings: {
|
||||
Ports: { "8080/tcp": [{ HostIp: "0.0.0.0", HostPort: "1223" }] },
|
||||
Networks: { bridge: {} },
|
||||
},
|
||||
Mounts: [
|
||||
{
|
||||
Type: "bind",
|
||||
Source: "/mnt/user/appdata/blockpilot-autonomous/data",
|
||||
Destination: "/data",
|
||||
RW: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
dockerManXml:
|
||||
"<Container><Name>blockpilot</Name><WebUI>http://[IP]:[PORT:1223]/</WebUI><Icon>https://example.test/icon.png</Icon><Shell>/bin/bash</Shell></Container>",
|
||||
},
|
||||
});
|
||||
assert.equal(result.profile.hostPort, 1223);
|
||||
assert.equal(result.profile.containerPort, 8080);
|
||||
assert.equal(result.profile.containerName, 'blockpilot');
|
||||
assert.equal(result.profile.composeService, 'app');
|
||||
assert.equal(result.profile.webUiUrl, 'http://[IP]:[PORT:1223]/');
|
||||
assert.equal(result.profile.iconUrl, 'https://example.test/icon.png');
|
||||
assert.equal(result.profile.dockerShell, '/bin/bash');
|
||||
assert.deepEqual(result.profile.detectedMetadata.envNames, ['TOKEN', 'MODE']);
|
||||
assert.ok(result.profile.preservePaths.includes('data'));
|
||||
assert.equal(result.provenance.hostPort.origin, 'docker-inspect');
|
||||
assert.equal(result.profile.containerName, "blockpilot");
|
||||
assert.equal(result.profile.composeService, "app");
|
||||
assert.equal(result.profile.webUiUrl, "http://[IP]:[PORT:1223]/");
|
||||
assert.equal(result.profile.iconUrl, "https://example.test/icon.png");
|
||||
assert.equal(result.profile.dockerShell, "/bin/bash");
|
||||
assert.deepEqual(result.profile.detectedMetadata.envNames, ["TOKEN", "MODE"]);
|
||||
assert.ok(result.profile.preservePaths.includes("data"));
|
||||
assert.equal(result.provenance.hostPort.origin, "docker-inspect");
|
||||
});
|
||||
|
||||
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/);
|
||||
});
|
||||
|
||||
@@ -336,22 +336,27 @@ test("packaged updater downloads only a published checksum-matched Windows asset
|
||||
async getReleaseByTag(_owner, _repo, tag) {
|
||||
if (tag !== "v0.8.2") return null;
|
||||
return {
|
||||
id: 82,
|
||||
tag_name: tag,
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
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`,
|
||||
browser_download_url: "https://gitea.test/checksum",
|
||||
id: 42,
|
||||
browser_download_url: "http://wrong-origin.test/checksum",
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
async downloadAuthenticated(url) {
|
||||
return url.endsWith("/checksum")
|
||||
? Buffer.from(`${sha256} ${assetName}\n`)
|
||||
: binary;
|
||||
async downloadReleaseAsset(_owner, _repo, releaseId, assetId) {
|
||||
assert.equal(releaseId, 82);
|
||||
return assetId === 42 ? Buffer.from(`${sha256} ${assetName}\n`) : binary;
|
||||
},
|
||||
};
|
||||
const service = new UpdateService({
|
||||
@@ -395,21 +400,25 @@ test("packaged updater rejects a binary whose checksum does not match", async ()
|
||||
gitea: {
|
||||
async getReleaseByTag() {
|
||||
return {
|
||||
id: 83,
|
||||
tag_name: "v0.8.2",
|
||||
assets: [
|
||||
{
|
||||
id: 51,
|
||||
name: assetName,
|
||||
browser_download_url: "https://gitea.test/portable",
|
||||
browser_download_url: "http://wrong-origin.test/portable",
|
||||
},
|
||||
{
|
||||
id: 52,
|
||||
name: `${assetName}.sha256`,
|
||||
browser_download_url: "https://gitea.test/checksum",
|
||||
browser_download_url: "http://wrong-origin.test/checksum",
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
async downloadAuthenticated(url) {
|
||||
return url.endsWith("/checksum")
|
||||
async downloadReleaseAsset(_owner, _repo, releaseId, assetId) {
|
||||
assert.equal(releaseId, 83);
|
||||
return assetId === 52
|
||||
? Buffer.from(`${"0".repeat(64)} ${assetName}`)
|
||||
: binary;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user