Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24dde9a031 | ||
|
|
e888bd2c1c | ||
|
|
40d41f9a32 | ||
|
|
70840839f8 | ||
|
|
1310d2e5a7 | ||
|
|
095342b882 | ||
|
|
cb95c407d9 | ||
|
|
b7daef3cd9 |
+51
-4
@@ -1,5 +1,55 @@
|
||||
# Changelog
|
||||
|
||||
## 0.8.9 - 2026-07-26
|
||||
|
||||
- added a per-repository Git Validator with a weighted assurance score and evidence-backed checks;
|
||||
- validates Gitea branch governance, repository identity, upstream tracking, effective author identity, safe synchronization defaults, README and gitignore hygiene, tracked secret-shaped files and oversized files;
|
||||
- provides audited one-click repairs for origin alignment and repository-local safety configuration;
|
||||
- offers confirmed repairs for default-branch protection and a reviewable uncommitted `.gitignore`;
|
||||
- introduced a premium, theme-aware and container-responsive Validator workspace with safe-fix batching.
|
||||
|
||||
## 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 +85,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 +106,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 +118,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 +131,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 +202,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`.
|
||||
|
||||
+29
-19
@@ -1,4 +1,4 @@
|
||||
ForgeFlow 0.8.2 source manifest
|
||||
ForgeFlow 0.8.9 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
|
||||
2038d46210c3c2ac582889f9ed5e352a9ff4f2b0bbd80d25adcc20aba7c3e09b 11200 CHANGELOG.md
|
||||
21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md
|
||||
a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md
|
||||
30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md
|
||||
@@ -40,6 +40,13 @@ 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
|
||||
35dcfda990946480d6d55bd2d2e6360c336260bcd05cdb51d92e07a4e8d76945 1046 docs/RELEASE_NOTES_0.8.9.md
|
||||
2b631b9d6d973bdd70869d84886ff339da351e29e17598970b3b27915674661d 4175 docs/ROADMAP.md
|
||||
1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png
|
||||
b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png
|
||||
@@ -52,7 +59,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 +68,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
|
||||
3b16a087c73b600415394dff8b8e34e7f7519e48fde1cf443007b2e11ca77b27 13123 main.cjs
|
||||
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
|
||||
ebb0b154137c113205351216e79dc0aad79949ea465b1734f41dd524f913cb34 134141 package-lock.json
|
||||
4ad40664dd3ed8526685fc7ca75be8f8c21acbad515b375ba4f04f3e2c4dcfe1 3559 package.json
|
||||
3d2ac366a13e9418e3ec6d13ce95b611f30f0228eb3a80ef9e7a936ce9578e24 9080 preload.cjs
|
||||
ab7c04a2df33ebe2861adc68c74d8f7b0eeb6a2d7c72410c26bc66b883d985c7 130466 package-lock.json
|
||||
4256d59aa47ea13d9e305370c2602887930d6069f4db34dc91c1f738d4b79e97 3943 package.json
|
||||
b5dc2b79453afbcd74fa94ddacf078bf22c00807ebc4eb648d2451de49634329 9668 preload.cjs
|
||||
b31c43d9355c13b5ae4efc0f3649d8cb8d509b2bb7ebb042ff546b7820fb7de8 8411 Publish-ForgeFlow-Release.ps1
|
||||
a6d32a742412b7836606be00f17be0465f1b6f55d3911f6c73a14029787ba206 14037 README.md
|
||||
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
|
||||
@@ -73,10 +80,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
|
||||
c7942bac000c9850630dc086f260f2dbf4bf4b8b435dac3631e7fe5ca41d507f 12407 scripts/verify.mjs
|
||||
0079701b5acbfef07b71a9623613d1940805ccd20649d77e3f34c37e79df7655 735 scripts/write-release-checksums.mjs
|
||||
619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1
|
||||
dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs
|
||||
@@ -86,24 +94,25 @@ 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
|
||||
857f270a2204b743421eea619a6a88595f749e4c24c1794cb092ace7987d25dc 12553 src/main/git-validator-service.cjs
|
||||
1c0a1c1b7f20734c646c874e07c89550e601951351c04770874c1ed3496f0833 17837 src/main/gitea-service.cjs
|
||||
dadf2fd2e3d148456da0b735a1d9af142196b230a02346a9ca29c7605d7e0b87 47704 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
|
||||
9729e1124a95899f0803e2cd5147bd6d3ae58010d659c408ddd69a31f5d1cb0e 202489 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
|
||||
e56e81ec2f2c9743332de0e19aca75bf1aa585351a790d7b32ae1ccdb6a26b4c 54717 src/renderer/mock-bridge.js
|
||||
607b2592c6aee37de91126a80704c1f7d8575cb2e5ff889305cd86a37e6170c4 76937 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
|
||||
@@ -125,13 +134,14 @@ fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800
|
||||
dd121d96ca265a027cd415a52064500a4541b2f8a662f4f4b25f2f996d52b5da 762 tests/external-tools.test.mjs
|
||||
e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 tests/git-integration.test.mjs
|
||||
5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db 1239 tests/git-status.test.mjs
|
||||
c98cbe50a783e2a1cfecf9052f558aabe656add6a463899532dd59d743b720b2 3645 tests/git-validator.test.mjs
|
||||
681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.test.mjs
|
||||
52b96f0a6623778fbdc1e8dbfc892e1d77a3d6616058cd7b08d532b207aa5719 5531 tests/gitea-actions.test.mjs
|
||||
8df055080e7166a52d36a6fdc0bab40c09b054d579c6848fcc076245de1573c4 6797 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
|
||||
9b2534196f2f6f61fd1c8c5dc44634375bb65ad3000096904d6b10c07d221b13 9158 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 +149,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.
|
||||
@@ -0,0 +1,20 @@
|
||||
# ForgeFlow 0.8.9
|
||||
|
||||
ForgeFlow 0.8.9 introduces Git Validator, a dedicated repository assurance
|
||||
workspace that checks whether practical Git and Gitea best practices are being
|
||||
followed.
|
||||
|
||||
The validator produces a weighted score with evidence for repository identity,
|
||||
upstream tracking, working-tree state, effective commit identity, safe local
|
||||
synchronization defaults, default-branch and force-push protection, README and
|
||||
gitignore hygiene, tracked secret-shaped filenames and oversized tracked files.
|
||||
|
||||
Every repair is deliberately bounded. Origin alignment and repository-local
|
||||
fetch/pull/autostash safeguards can be applied as safe fixes. Creating default
|
||||
branch protection or a recommended `.gitignore` requires explicit confirmation.
|
||||
The generated `.gitignore` remains uncommitted for review, and secret/history
|
||||
findings are never modified automatically.
|
||||
|
||||
The new workspace includes grouped findings, an assurance score, safe-fix
|
||||
batching, audit events, interactive project illustration and responsive premium
|
||||
layouts for light and dark themes.
|
||||
@@ -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
|
||||
|
||||
@@ -25,6 +25,7 @@ const {
|
||||
UnraidDeploymentService,
|
||||
} = require("./src/main/unraid-deployment-service.cjs");
|
||||
const { AuditService } = require("./src/main/audit-service.cjs");
|
||||
const { GitValidatorService } = require("./src/main/git-validator-service.cjs");
|
||||
const {
|
||||
ExternalToolsService,
|
||||
} = require("./src/main/external-tools-service.cjs");
|
||||
@@ -141,9 +142,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 +259,7 @@ app
|
||||
store,
|
||||
ssh,
|
||||
git,
|
||||
gitea,
|
||||
diagnostics,
|
||||
sourcePath: app.getAppPath(),
|
||||
onOperationChange: reportOperationChange,
|
||||
@@ -286,6 +286,11 @@ app
|
||||
userDataPath,
|
||||
secureStorageAvailable: () => safeStorage.isEncryptionAvailable(),
|
||||
});
|
||||
const gitValidator = new GitValidatorService({
|
||||
git,
|
||||
gitea,
|
||||
diagnostics,
|
||||
});
|
||||
repositoryMonitor = new RepositoryMonitor({
|
||||
store,
|
||||
git,
|
||||
@@ -303,6 +308,7 @@ app
|
||||
ssh,
|
||||
updates,
|
||||
preflight,
|
||||
gitValidator,
|
||||
diagnostics,
|
||||
audit,
|
||||
externalTools,
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "forgeflow",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "forgeflow",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.9",
|
||||
"dependencies": {
|
||||
"ssh2": "1.17.0"
|
||||
},
|
||||
|
||||
+12
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "forgeflow",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.9",
|
||||
"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,13 @@
|
||||
"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/RELEASE_NOTES_0.8.9.md",
|
||||
"docs/ACCEPTANCE.md"
|
||||
],
|
||||
"asarUnpack": [
|
||||
|
||||
+30
-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 }),
|
||||
@@ -106,6 +127,8 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({
|
||||
troubleshooterScan: (fullName = null) => invoke('troubleshooter:scan', { fullName }),
|
||||
troubleshooterRepair: (issue) => invoke('troubleshooter:repair', { issue }),
|
||||
troubleshooterAutoRepair: (issues) => invoke('troubleshooter:auto-repair', { issues }),
|
||||
gitValidatorScan: (fullName) => invoke('git-validator:scan', { fullName }),
|
||||
gitValidatorRepair: (fullName, check) => invoke('git-validator:repair', { fullName, check }),
|
||||
diagnosticsStatus: () => invoke('diagnostics:status'),
|
||||
clearDiagnostics: () => invoke('diagnostics:clear'),
|
||||
openDiagnosticsFolder: () => invoke('diagnostics:open-folder'),
|
||||
@@ -115,5 +138,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.`,
|
||||
);
|
||||
}
|
||||
+11
-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,13 @@ 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/RELEASE_NOTES_0.8.9.md",
|
||||
"docs/UPDATING.md",
|
||||
"docs/DIAGNOSTICS.md",
|
||||
"docs/DEPLOYMENT_SETUP.md",
|
||||
@@ -100,9 +108,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.9")
|
||||
throw new Error(
|
||||
`Expected package version 0.8.2, got ${packageJson.version}.`,
|
||||
`Expected package version 0.8.9, got ${packageJson.version}.`,
|
||||
);
|
||||
const sourceManifest = await readFile(
|
||||
path.join(root, "SOURCE_MANIFEST.txt"),
|
||||
@@ -367,7 +375,7 @@ for (const phrase of [
|
||||
"deriveDetectedProfile",
|
||||
"docker inspect",
|
||||
"net.unraid.docker.managed",
|
||||
"'dockerman'",
|
||||
"dockerman",
|
||||
"iconCacheRefresh",
|
||||
"[PORT:",
|
||||
"Superseded by live commit",
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { run } = require("./process-runner.cjs");
|
||||
const { normalizeRemoteUrl } = require("../shared/repository-match.cjs");
|
||||
|
||||
const RECOMMENDED_GITIGNORE = `# Local configuration and secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.sample
|
||||
|
||||
# Dependencies and generated output
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
|
||||
# Editors and operating systems
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
`;
|
||||
|
||||
function sameRemote(left, right) {
|
||||
const a = normalizeRemoteUrl(left);
|
||||
const b = normalizeRemoteUrl(right);
|
||||
return Boolean(a && b && a.host === b.host && a.path === b.path);
|
||||
}
|
||||
|
||||
function result(id, category, title, status, detail, options = {}) {
|
||||
return {
|
||||
id,
|
||||
category,
|
||||
title,
|
||||
status,
|
||||
detail,
|
||||
weight: options.weight || 5,
|
||||
fixAction: options.fixAction || null,
|
||||
safe: options.safe === true,
|
||||
confirmation: options.confirmation || null,
|
||||
};
|
||||
}
|
||||
|
||||
function isSensitiveTrackedPath(filePath) {
|
||||
const value = String(filePath || "")
|
||||
.replace(/\\/g, "/")
|
||||
.toLowerCase();
|
||||
if (/\.env\.(example|sample|template)$/.test(value)) return false;
|
||||
return (
|
||||
/(^|\/)\.env($|\.)/.test(value) ||
|
||||
/(^|\/)(id_rsa|id_ed25519)$/.test(value) ||
|
||||
/\.(pem|p12|pfx|key)$/.test(value) ||
|
||||
/(^|\/)(credentials|secrets?)(\.[^/]+)?\.(json|ya?ml)$/.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
class GitValidatorService {
|
||||
constructor({ git, gitea, diagnostics }) {
|
||||
this.git = git;
|
||||
this.gitea = gitea;
|
||||
this.diagnostics = diagnostics;
|
||||
}
|
||||
|
||||
async config(root, key, { local = true } = {}) {
|
||||
const response = await run(
|
||||
"git",
|
||||
["config", ...(local ? ["--local"] : []), "--get", key],
|
||||
{
|
||||
cwd: root,
|
||||
timeout: 10_000,
|
||||
allowExitCodes: [1],
|
||||
},
|
||||
);
|
||||
return response.stdout.trim();
|
||||
}
|
||||
|
||||
async trackedFiles(root) {
|
||||
const response = await run("git", ["ls-files", "-z"], {
|
||||
cwd: root,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
return response.stdout.split("\0").filter(Boolean);
|
||||
}
|
||||
|
||||
async scan(repository) {
|
||||
const checks = [];
|
||||
const defaultBranch = repository.defaultBranch || "main";
|
||||
const owner = repository.owner?.login;
|
||||
try {
|
||||
const protection = await this.gitea.getBranchProtection(
|
||||
owner,
|
||||
repository.name,
|
||||
defaultBranch,
|
||||
);
|
||||
checks.push(
|
||||
result(
|
||||
"default-branch-protection",
|
||||
"Gitea governance",
|
||||
"Default branch protection",
|
||||
protection.protected ? "pass" : "warning",
|
||||
protection.protected
|
||||
? `${defaultBranch} is protected; force push is ${protection.enableForcePush ? "allowed" : "blocked"}.`
|
||||
: `${defaultBranch} accepts unprotected direct changes.`,
|
||||
{
|
||||
weight: 18,
|
||||
fixAction: protection.protected ? null : "protect-default-branch",
|
||||
safe: false,
|
||||
confirmation: `Protect ${defaultBranch} on Gitea and block direct and force pushes?`,
|
||||
},
|
||||
),
|
||||
);
|
||||
if (protection.protected)
|
||||
checks.push(
|
||||
result(
|
||||
"force-push",
|
||||
"Gitea governance",
|
||||
"Force-push protection",
|
||||
protection.enableForcePush ? "warning" : "pass",
|
||||
protection.enableForcePush
|
||||
? "Force pushes remain enabled on the protected branch."
|
||||
: "Force pushes are blocked on the protected branch.",
|
||||
{ weight: 8 },
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
checks.push(
|
||||
result(
|
||||
"branch-protection-unavailable",
|
||||
"Gitea governance",
|
||||
"Branch protection could not be verified",
|
||||
"warning",
|
||||
error.message,
|
||||
{ weight: 18 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!repository.localPath) {
|
||||
checks.push(
|
||||
result(
|
||||
"local-link",
|
||||
"Local repository",
|
||||
"Local working tree",
|
||||
"warning",
|
||||
"Link or clone this repository to validate files and local Git configuration.",
|
||||
{ weight: 35 },
|
||||
),
|
||||
);
|
||||
return this.summarize(repository, checks);
|
||||
}
|
||||
|
||||
const root = await this.git.ensureRepository(repository.localPath);
|
||||
const status = await this.git.status(root);
|
||||
const tracked = await this.trackedFiles(root);
|
||||
const lowerFiles = tracked.map((file) => file.toLowerCase());
|
||||
const desiredRemote =
|
||||
repository.preferredCloneUrl || repository.cloneUrl || repository.sshUrl;
|
||||
checks.push(
|
||||
result(
|
||||
"origin",
|
||||
"Repository identity",
|
||||
"Origin matches Gitea",
|
||||
sameRemote(status.remoteUrl, desiredRemote) ? "pass" : "error",
|
||||
sameRemote(status.remoteUrl, desiredRemote)
|
||||
? status.remoteUrl
|
||||
: `Current origin ${status.remoteUrl || "is missing"}; expected ${desiredRemote}.`,
|
||||
{
|
||||
weight: 15,
|
||||
fixAction: sameRemote(status.remoteUrl, desiredRemote)
|
||||
? null
|
||||
: "align-origin",
|
||||
safe: true,
|
||||
},
|
||||
),
|
||||
);
|
||||
checks.push(
|
||||
result(
|
||||
"upstream",
|
||||
"Branch hygiene",
|
||||
"Current branch has an upstream",
|
||||
status.branch?.upstream ? "pass" : "warning",
|
||||
status.branch?.upstream
|
||||
? `${status.branch.head} tracks ${status.branch.upstream}.`
|
||||
: `${status.branch?.head || "The current branch"} is not published or tracked.`,
|
||||
{ weight: 8 },
|
||||
),
|
||||
);
|
||||
checks.push(
|
||||
result(
|
||||
"working-tree",
|
||||
"Branch hygiene",
|
||||
"Working tree is intentional",
|
||||
status.clean ? "pass" : "warning",
|
||||
status.clean
|
||||
? "No uncommitted changes."
|
||||
: `${status.counts.changed} changed file(s) require review, commit or stash.`,
|
||||
{ weight: 5 },
|
||||
),
|
||||
);
|
||||
|
||||
const [userName, userEmail, fetchPrune, pullFf, autoStash] =
|
||||
await Promise.all([
|
||||
this.config(root, "user.name", { local: false }),
|
||||
this.config(root, "user.email", { local: false }),
|
||||
this.config(root, "fetch.prune"),
|
||||
this.config(root, "pull.ff"),
|
||||
this.config(root, "rebase.autoStash"),
|
||||
]);
|
||||
checks.push(
|
||||
result(
|
||||
"identity",
|
||||
"Commit integrity",
|
||||
"Repository author identity",
|
||||
userName && userEmail ? "pass" : "warning",
|
||||
userName && userEmail
|
||||
? `${userName} <${userEmail}>`
|
||||
: "The effective Git user.name or user.email is missing.",
|
||||
{ weight: 7 },
|
||||
),
|
||||
);
|
||||
const safetyReady =
|
||||
fetchPrune === "true" && pullFf === "only" && autoStash === "true";
|
||||
checks.push(
|
||||
result(
|
||||
"local-safety",
|
||||
"Local configuration",
|
||||
"Safe synchronization defaults",
|
||||
safetyReady ? "pass" : "warning",
|
||||
safetyReady
|
||||
? "Stale remotes are pruned, pulls are fast-forward-only and rebase autostash is enabled."
|
||||
: "Recommended repository-local fetch, pull and autostash safeguards are incomplete.",
|
||||
{
|
||||
weight: 10,
|
||||
fixAction: safetyReady ? null : "configure-local-safety",
|
||||
safe: true,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const hasReadme = lowerFiles.some((file) =>
|
||||
/(^|\/)readme(\.[^/]+)?$/.test(file),
|
||||
);
|
||||
checks.push(
|
||||
result(
|
||||
"readme",
|
||||
"Repository documentation",
|
||||
"README is versioned",
|
||||
hasReadme ? "pass" : "warning",
|
||||
hasReadme
|
||||
? "Repository purpose and usage can be documented at the source."
|
||||
: "No tracked README was found.",
|
||||
{ weight: 7 },
|
||||
),
|
||||
);
|
||||
const hasGitignore = lowerFiles.includes(".gitignore");
|
||||
checks.push(
|
||||
result(
|
||||
"gitignore",
|
||||
"Repository hygiene",
|
||||
".gitignore is versioned",
|
||||
hasGitignore ? "pass" : "warning",
|
||||
hasGitignore
|
||||
? "Generated and local-only files can be excluded centrally."
|
||||
: "No tracked .gitignore was found.",
|
||||
{
|
||||
weight: 8,
|
||||
fixAction: hasGitignore ? null : "add-gitignore",
|
||||
safe: false,
|
||||
confirmation:
|
||||
"Create a recommended .gitignore in the working tree? It will remain uncommitted for review.",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const sensitive = tracked.filter(isSensitiveTrackedPath);
|
||||
checks.push(
|
||||
result(
|
||||
"tracked-secrets",
|
||||
"Security",
|
||||
"No secret-shaped files are tracked",
|
||||
sensitive.length ? "error" : "pass",
|
||||
sensitive.length
|
||||
? `Review immediately: ${sensitive.slice(0, 8).join(", ")}${sensitive.length > 8 ? "…" : ""}. Removing a file does not erase Git history.`
|
||||
: "No tracked environment, private-key or credential filenames were detected.",
|
||||
{ weight: 22 },
|
||||
),
|
||||
);
|
||||
|
||||
const large = [];
|
||||
const candidates = tracked.slice(0, 5000);
|
||||
for (
|
||||
let index = 0;
|
||||
index < candidates.length && large.length < 12;
|
||||
index += 64
|
||||
) {
|
||||
const batch = candidates.slice(index, index + 64);
|
||||
const stats = await Promise.all(
|
||||
batch.map(async (file) => ({
|
||||
file,
|
||||
stat: await fs.stat(path.join(root, file)).catch(() => null),
|
||||
})),
|
||||
);
|
||||
for (const item of stats) {
|
||||
if (item.stat?.isFile() && item.stat.size > 10 * 1024 * 1024)
|
||||
large.push({ file: item.file, size: item.stat.size });
|
||||
if (large.length >= 12) break;
|
||||
}
|
||||
}
|
||||
checks.push(
|
||||
result(
|
||||
"large-files",
|
||||
"Repository performance",
|
||||
"No oversized tracked files",
|
||||
large.length ? "warning" : "pass",
|
||||
large.length
|
||||
? `${large.map((item) => `${item.file} (${Math.ceil(item.size / 1024 / 1024)} MB)`).join(", ")}. Consider Git LFS.`
|
||||
: "No tracked files above 10 MB were found.",
|
||||
{ weight: 7 },
|
||||
),
|
||||
);
|
||||
return this.summarize(repository, checks);
|
||||
}
|
||||
|
||||
summarize(repository, checks) {
|
||||
const totalWeight = checks.reduce((sum, check) => sum + check.weight, 0);
|
||||
const earned = checks.reduce(
|
||||
(sum, check) =>
|
||||
sum +
|
||||
(check.status === "pass"
|
||||
? check.weight
|
||||
: check.status === "warning"
|
||||
? check.weight * 0.45
|
||||
: 0),
|
||||
0,
|
||||
);
|
||||
const score = totalWeight ? Math.round((earned / totalWeight) * 100) : 0;
|
||||
return {
|
||||
repository: repository.fullName,
|
||||
checkedAt: new Date().toISOString(),
|
||||
score,
|
||||
grade:
|
||||
score >= 90
|
||||
? "Excellent"
|
||||
: score >= 75
|
||||
? "Good"
|
||||
: score >= 55
|
||||
? "Needs attention"
|
||||
: "High risk",
|
||||
checks,
|
||||
summary: {
|
||||
passed: checks.filter((check) => check.status === "pass").length,
|
||||
warnings: checks.filter((check) => check.status === "warning").length,
|
||||
errors: checks.filter((check) => check.status === "error").length,
|
||||
repairable: checks.filter((check) => check.fixAction).length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async repair(repository, check) {
|
||||
if (!check?.fixAction)
|
||||
throw new Error("This validator check has no repair action.");
|
||||
const root = repository.localPath
|
||||
? await this.git.ensureRepository(repository.localPath)
|
||||
: null;
|
||||
if (check.fixAction === "align-origin") {
|
||||
return this.git.setRemoteUrl(
|
||||
root,
|
||||
repository.preferredCloneUrl ||
|
||||
repository.cloneUrl ||
|
||||
repository.sshUrl,
|
||||
);
|
||||
}
|
||||
if (check.fixAction === "configure-local-safety") {
|
||||
for (const [key, value] of [
|
||||
["fetch.prune", "true"],
|
||||
["pull.ff", "only"],
|
||||
["rebase.autoStash", "true"],
|
||||
])
|
||||
await run("git", ["config", "--local", key, value], {
|
||||
cwd: root,
|
||||
timeout: 10_000,
|
||||
});
|
||||
return { configured: true };
|
||||
}
|
||||
if (check.fixAction === "add-gitignore") {
|
||||
const target = path.join(root, ".gitignore");
|
||||
const exists = await fs.stat(target).catch(() => null);
|
||||
if (exists)
|
||||
throw new Error(".gitignore already exists; rescan before repairing.");
|
||||
await fs.writeFile(target, RECOMMENDED_GITIGNORE, {
|
||||
encoding: "utf8",
|
||||
flag: "wx",
|
||||
});
|
||||
return { created: ".gitignore" };
|
||||
}
|
||||
if (check.fixAction === "protect-default-branch") {
|
||||
return this.gitea.createBranchProtection(
|
||||
repository.owner.login,
|
||||
repository.name,
|
||||
repository.defaultBranch || "main",
|
||||
);
|
||||
}
|
||||
throw new Error("Unsupported Git Validator repair action.");
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
GitValidatorService,
|
||||
RECOMMENDED_GITIGNORE,
|
||||
sameRemote,
|
||||
isSensitiveTrackedPath,
|
||||
};
|
||||
@@ -204,6 +204,28 @@ class GiteaService {
|
||||
};
|
||||
}
|
||||
|
||||
async createBranchProtection(owner, repo, branch) {
|
||||
const target = assertBranchName(branch);
|
||||
return (
|
||||
await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branch_protections`,
|
||||
{
|
||||
method: "POST",
|
||||
body: {
|
||||
rule_name: target,
|
||||
branch_name: target,
|
||||
enable_push: false,
|
||||
enable_force_push: false,
|
||||
required_approvals: 0,
|
||||
dismiss_stale_approvals: true,
|
||||
block_on_rejected_reviews: true,
|
||||
block_on_outdated_branch: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
).data;
|
||||
}
|
||||
|
||||
async listPullRequests({ owner, repo, state = "open", limit = 30 } = {}) {
|
||||
const query = new URLSearchParams({
|
||||
state,
|
||||
@@ -331,6 +353,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`,
|
||||
|
||||
+1011
-292
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);
|
||||
|
||||
+221
-8
@@ -159,6 +159,7 @@ const ui = {
|
||||
diagnosticsStatus: null,
|
||||
troubleshooter: null,
|
||||
deploymentDiscovery: null,
|
||||
serverDiscovery: [],
|
||||
lastDiagnosticBundle: null,
|
||||
setupDraft: {
|
||||
baseUrl: "https://",
|
||||
@@ -179,6 +180,7 @@ const ui = {
|
||||
servers: [],
|
||||
serverInspection: null,
|
||||
gitRecovery: null,
|
||||
gitValidation: null,
|
||||
diffHunks: null,
|
||||
conflictState: null,
|
||||
branchProtection: null,
|
||||
@@ -415,13 +417,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 +476,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) {
|
||||
@@ -466,6 +488,7 @@ function selectRepository(id, shouldRender = true) {
|
||||
ui.branches = [];
|
||||
ui.stashes = [];
|
||||
ui.gitRecovery = null;
|
||||
ui.gitValidation = null;
|
||||
ui.branchProtection = null;
|
||||
const repository = selectedRepository();
|
||||
ui.selectedProfileId = selectedProfile(repository)?.id || null;
|
||||
@@ -699,6 +722,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 +756,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 +796,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 +828,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 +904,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 +926,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 +934,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) {
|
||||
@@ -898,6 +970,34 @@ function renderRepositorySettings(repository) {
|
||||
return `<div class="tab-page"><section class="settings-group"><h2>Repository identity</h2><div class="form-grid"><div class="field full"><label>Gitea repository</label><input class="input" value="${attr(repository.fullName)}" readonly/></div><div class="field full"><label>Local working tree</label><input class="input mono" value="${attr(repository.localPath || automaticTarget || "Not linked")}" readonly/></div><div class="field full"><label>Current origin</label><input class="input mono" value="${attr(currentOrigin)}" readonly/></div>${desiredOrigin ? `<div class="field full"><label>Current Gitea SSH origin</label><input class="input mono" value="${attr(desiredOrigin)}" readonly/></div>` : ""}</div><div class="card-actions"><button class="button" data-action="${repository.localPath ? "open-path" : "link-repo"}">${icon("folder")}${repository.localPath ? "Open project folder" : "Link local folder"}</button>${originNeedsRepair ? `<button class="button primary" data-action="repair-origin">${icon("link")}Use current Gitea origin</button>` : ""}${repository.localPath ? `<button class="button" data-action="scan-git-recovery">${icon("pulse")}Scan Git health</button><button class="button danger" data-action="unlink-repo">${icon("link")}Remove link</button>` : `<button class="button primary" data-action="clone-repo">${icon("cloud")}${escapeHtml(clonePrimaryLabel(repository))}</button><button class="button ghost" data-action="clone-repo-custom">Choose another location</button>`}</div></section><section class="settings-group"><div class="section-heading"><div><h2>Open pull requests</h2><span class="meta">Live from Gitea</span></div><button class="button" data-action="load-pull-requests">${icon("refresh")}Refresh</button></div>${pullRequests.length ? `<div class="tool-list">${pullRequests.map((pull) => `<div class="tool-row"><div><strong>#${pull.number} · ${escapeHtml(pull.title)}</strong><span>${escapeHtml(pull.head?.ref || pull.head?.label || "source")} → ${escapeHtml(pull.base?.ref || pull.base?.label || "target")} · ${formatDate(pull.updated_at || pull.created_at)}</span></div><button class="button" data-action="open-pull-request-url" data-url="${attr(pull.html_url || "")}">Open</button></div>`).join("")}</div>` : '<div class="empty-state compact"><p>No open pull requests.</p></div>'}</section><section class="settings-group"><h2>Repository behavior</h2><div class="notice">${icon("shield")}Origin repair changes only the Git remote URL. Git health scans the actual Git directory, repairs only proven stale lock files and never changes source files or commits.</div></section></div>`;
|
||||
}
|
||||
|
||||
function renderGitValidator(repository) {
|
||||
const report = ui.gitValidation;
|
||||
if (!report)
|
||||
return `<div class="validator-empty panel">${projectIllustration("diagnostics")}<div><div class="eyebrow">Repository assurance</div><h2>Validate Git best practices</h2><p>Inspect repository identity, branch governance, tracked secrets, file hygiene and safe local synchronization settings.</p><button class="button primary" data-action="git-validator-scan">${icon("shield")}Run Git Validator</button></div></div>`;
|
||||
const tone =
|
||||
report.score >= 90 ? "success" : report.score >= 70 ? "warning" : "danger";
|
||||
const safeFixes = report.checks.filter(
|
||||
(check) => check.fixAction && check.safe,
|
||||
);
|
||||
const groups = report.checks.reduce((grouped, check) => {
|
||||
(grouped[check.category] ||= []).push(check);
|
||||
return grouped;
|
||||
}, {});
|
||||
return `<div class="validator-page"><section class="validator-hero panel ${tone}"><div class="validator-score"><strong>${report.score}</strong><span>/ 100</span></div><div><div class="eyebrow">Git assurance score</div><h2>${escapeHtml(report.grade)}</h2><p>${report.summary.passed} passed · ${report.summary.warnings} recommendations · ${report.summary.errors} critical</p></div>${projectIllustration("diagnostics")}<div class="validator-actions"><button class="button" data-action="git-validator-scan">${icon("refresh")}Scan again</button>${safeFixes.length ? `<button class="button primary" data-action="git-validator-repair-safe">${icon("wrench")}Apply ${safeFixes.length} safe fix${safeFixes.length === 1 ? "" : "es"}</button>` : ""}</div></section><div class="validator-groups">${Object.entries(
|
||||
groups,
|
||||
)
|
||||
.map(
|
||||
([category, checks]) =>
|
||||
`<section class="panel validator-group"><div class="panel-header"><h3>${escapeHtml(category)}</h3><span class="meta">${checks.filter((check) => check.status === "pass").length}/${checks.length} passed</span></div><div class="validator-checks">${checks
|
||||
.map((check) => {
|
||||
const checkIndex = report.checks.indexOf(check);
|
||||
return `<article class="validator-check ${check.status}"><span class="validator-check-icon">${icon(check.status === "pass" ? "check" : check.status === "error" ? "error" : "warning")}</span><div><strong>${escapeHtml(check.title)}</strong><p>${escapeHtml(check.detail)}</p></div>${check.fixAction ? `<button class="button ${check.safe ? "" : "primary"}" data-action="git-validator-repair" data-check-index="${checkIndex}">${icon("wrench")}${check.safe ? "Fix safely" : "Review & fix"}</button>` : `<span class="status-pill ${check.status === "pass" ? "success" : check.status === "error" ? "danger" : "warning"}">${check.status === "pass" ? "Best practice" : "Review"}</span>`}</article>`;
|
||||
})
|
||||
.join("")}</div></section>`,
|
||||
)
|
||||
.join("")}</div></div>`;
|
||||
}
|
||||
|
||||
function renderRepositoryWorkspace(repository) {
|
||||
const status = repository.localStatus;
|
||||
const profile = selectedProfile(repository);
|
||||
@@ -928,10 +1028,11 @@ function renderRepositoryWorkspace(repository) {
|
||||
history: renderHistory,
|
||||
deployments: renderRepositoryDeployments,
|
||||
gittools: renderGitTools,
|
||||
validator: renderGitValidator,
|
||||
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">${[
|
||||
@@ -939,6 +1040,7 @@ function renderRepositoryWorkspace(repository) {
|
||||
["history", "History"],
|
||||
["deployments", "Deployments"],
|
||||
["gittools", "Git tools"],
|
||||
["validator", "Git Validator"],
|
||||
["settings", "Project settings"],
|
||||
]
|
||||
.map(
|
||||
@@ -1599,7 +1701,19 @@ app.addEventListener("click", async (event) => {
|
||||
ui.repositoryTab = target.dataset.tab;
|
||||
if (ui.repositoryTab === "gittools" && !ui.branches.length)
|
||||
await loadGitTools(repository);
|
||||
else if (ui.repositoryTab === "settings") {
|
||||
else if (ui.repositoryTab === "validator" && !ui.gitValidation) {
|
||||
setLoading(true, "Validating Git and Gitea best practices…");
|
||||
try {
|
||||
ui.gitValidation = await window.forgeflow.gitValidatorScan(
|
||||
repository.fullName,
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Git Validator failed", error.message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
render();
|
||||
} else if (ui.repositoryTab === "settings") {
|
||||
try {
|
||||
ui.pullRequests = await window.forgeflow.pullRequests(
|
||||
repository.fullName,
|
||||
@@ -1611,6 +1725,61 @@ app.addEventListener("click", async (event) => {
|
||||
}
|
||||
render();
|
||||
} else render();
|
||||
} else if (action === "git-validator-scan") {
|
||||
setLoading(true, "Validating Git and Gitea best practices…");
|
||||
try {
|
||||
ui.gitValidation = await window.forgeflow.gitValidatorScan(
|
||||
repository.fullName,
|
||||
);
|
||||
showToast(
|
||||
"Git validation complete",
|
||||
`${ui.gitValidation.score}/100 · ${ui.gitValidation.grade}`,
|
||||
ui.gitValidation.summary.errors ? "error" : "success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Git Validator failed", error.message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} else if (action === "git-validator-repair") {
|
||||
const check = ui.gitValidation?.checks?.[Number(target.dataset.checkIndex)];
|
||||
if (!check?.fixAction) return;
|
||||
if (!check.safe && !confirm(check.confirmation || `Apply ${check.title}?`))
|
||||
return;
|
||||
setLoading(true, `Repairing ${check.title}…`);
|
||||
try {
|
||||
await window.forgeflow.gitValidatorRepair(repository.fullName, check);
|
||||
await refreshRepositories(false, true);
|
||||
ui.gitValidation = await window.forgeflow.gitValidatorScan(
|
||||
repository.fullName,
|
||||
);
|
||||
showToast("Git best practice repaired", check.title, "success");
|
||||
} catch (error) {
|
||||
showToast("Repair failed", error.message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} else if (action === "git-validator-repair-safe") {
|
||||
const checks = (ui.gitValidation?.checks || []).filter(
|
||||
(check) => check.fixAction && check.safe,
|
||||
);
|
||||
setLoading(true, `Applying ${checks.length} safe Git fixes…`);
|
||||
let repaired = 0;
|
||||
try {
|
||||
for (const check of checks) {
|
||||
await window.forgeflow.gitValidatorRepair(repository.fullName, check);
|
||||
repaired += 1;
|
||||
}
|
||||
await refreshRepositories(false, true);
|
||||
ui.gitValidation = await window.forgeflow.gitValidatorScan(
|
||||
repository.fullName,
|
||||
);
|
||||
showToast("Safe Git fixes applied", `${repaired} repaired.`, "success");
|
||||
} catch (error) {
|
||||
showToast("Safe repair stopped", error.message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} else if (action === "toggle-favorite") {
|
||||
ui.boot.state = await window.forgeflow.favoriteRepository(
|
||||
repository.fullName,
|
||||
@@ -3224,6 +3393,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", {
|
||||
|
||||
+124
-1
@@ -564,7 +564,7 @@
|
||||
await wait(80);
|
||||
snapshot();
|
||||
return {
|
||||
appVersion: "0.8.2-demo",
|
||||
appVersion: "0.8.9-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) {
|
||||
@@ -1449,6 +1461,117 @@
|
||||
state.operations.find((item) => item.id === operationId) || null,
|
||||
);
|
||||
},
|
||||
async gitValidatorScan(fullName) {
|
||||
await wait(260);
|
||||
return {
|
||||
repository: fullName,
|
||||
checkedAt: iso(),
|
||||
score: 78,
|
||||
grade: "Good",
|
||||
summary: { passed: 7, warnings: 3, errors: 0, repairable: 2 },
|
||||
checks: [
|
||||
{
|
||||
id: "origin",
|
||||
category: "Repository identity",
|
||||
title: "Origin matches Gitea",
|
||||
status: "pass",
|
||||
detail: "The local origin resolves to this Gitea repository.",
|
||||
weight: 15,
|
||||
},
|
||||
{
|
||||
id: "default-branch-protection",
|
||||
category: "Gitea governance",
|
||||
title: "Default branch protection",
|
||||
status: "warning",
|
||||
detail: "main accepts unprotected direct changes.",
|
||||
weight: 18,
|
||||
fixAction: "protect-default-branch",
|
||||
safe: false,
|
||||
confirmation:
|
||||
"Protect main on Gitea and block direct and force pushes?",
|
||||
},
|
||||
{
|
||||
id: "force-push",
|
||||
category: "Gitea governance",
|
||||
title: "Force-push protection",
|
||||
status: "pass",
|
||||
detail: "Force pushes are blocked.",
|
||||
weight: 8,
|
||||
},
|
||||
{
|
||||
id: "upstream",
|
||||
category: "Branch hygiene",
|
||||
title: "Current branch has an upstream",
|
||||
status: "pass",
|
||||
detail: "main tracks origin/main.",
|
||||
weight: 8,
|
||||
},
|
||||
{
|
||||
id: "working-tree",
|
||||
category: "Branch hygiene",
|
||||
title: "Working tree is intentional",
|
||||
status: "warning",
|
||||
detail: "3 changed files require review, commit or stash.",
|
||||
weight: 5,
|
||||
},
|
||||
{
|
||||
id: "identity",
|
||||
category: "Commit integrity",
|
||||
title: "Repository author identity",
|
||||
status: "pass",
|
||||
detail: "Jens <jens@example.test>",
|
||||
weight: 7,
|
||||
},
|
||||
{
|
||||
id: "local-safety",
|
||||
category: "Local configuration",
|
||||
title: "Safe synchronization defaults",
|
||||
status: "warning",
|
||||
detail: "Recommended repository-local safeguards are incomplete.",
|
||||
weight: 10,
|
||||
fixAction: "configure-local-safety",
|
||||
safe: true,
|
||||
},
|
||||
{
|
||||
id: "readme",
|
||||
category: "Repository documentation",
|
||||
title: "README is versioned",
|
||||
status: "pass",
|
||||
detail: "Repository documentation is tracked.",
|
||||
weight: 7,
|
||||
},
|
||||
{
|
||||
id: "gitignore",
|
||||
category: "Repository hygiene",
|
||||
title: ".gitignore is versioned",
|
||||
status: "pass",
|
||||
detail: "Generated files are excluded centrally.",
|
||||
weight: 8,
|
||||
},
|
||||
{
|
||||
id: "tracked-secrets",
|
||||
category: "Security",
|
||||
title: "No secret-shaped files are tracked",
|
||||
status: "pass",
|
||||
detail:
|
||||
"No tracked environment, key or credential filenames detected.",
|
||||
weight: 22,
|
||||
},
|
||||
{
|
||||
id: "large-files",
|
||||
category: "Repository performance",
|
||||
title: "No oversized tracked files",
|
||||
status: "pass",
|
||||
detail: "No tracked files above 10 MB were found.",
|
||||
weight: 7,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
async gitValidatorRepair() {
|
||||
await wait(180);
|
||||
return { repaired: true };
|
||||
},
|
||||
async diagnosticsStatus() {
|
||||
return {
|
||||
enabled: state.preferences.diagnosticsEnabled !== false,
|
||||
|
||||
+782
-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;
|
||||
@@ -1035,6 +1280,227 @@ html[data-theme="light"] .diff-line.remove {
|
||||
color: #caa7ff;
|
||||
background: rgba(148, 97, 214, 0.08);
|
||||
}
|
||||
|
||||
.validator-page {
|
||||
container-type: inline-size;
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
overflow: auto;
|
||||
}
|
||||
.validator-empty {
|
||||
min-height: 360px;
|
||||
margin: 18px;
|
||||
padding: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 36px;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
}
|
||||
.validator-empty > div:last-child {
|
||||
max-width: 520px;
|
||||
}
|
||||
.validator-empty h2 {
|
||||
margin: 5px 0 8px;
|
||||
font-size: 24px;
|
||||
}
|
||||
.validator-empty p {
|
||||
margin: 0 0 18px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.65;
|
||||
}
|
||||
.validator-hero {
|
||||
position: relative;
|
||||
min-height: 160px;
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(220px, 1fr) minmax(180px, 260px) auto;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 68% 16%,
|
||||
color-mix(in srgb, var(--primary) 16%, transparent),
|
||||
transparent 28%
|
||||
),
|
||||
linear-gradient(
|
||||
120deg,
|
||||
var(--surface-1),
|
||||
color-mix(in srgb, var(--surface-2) 84%, var(--primary-soft))
|
||||
);
|
||||
}
|
||||
.validator-hero.success {
|
||||
--validator-accent: var(--success);
|
||||
}
|
||||
.validator-hero.warning {
|
||||
--validator-accent: var(--warning);
|
||||
}
|
||||
.validator-hero.danger {
|
||||
--validator-accent: var(--danger);
|
||||
}
|
||||
.validator-score {
|
||||
width: 116px;
|
||||
height: 116px;
|
||||
border-radius: 32px;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
text-align: center;
|
||||
background: color-mix(in srgb, var(--validator-accent) 10%, var(--surface-2));
|
||||
border: 1px solid color-mix(in srgb, var(--validator-accent) 42%, var(--line));
|
||||
box-shadow:
|
||||
inset 0 0 34px color-mix(in srgb, var(--validator-accent) 10%, transparent),
|
||||
0 18px 38px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.validator-score strong {
|
||||
color: var(--validator-accent);
|
||||
font-size: 42px;
|
||||
line-height: 0.9;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
.validator-score span {
|
||||
margin-top: 7px;
|
||||
color: var(--text-muted);
|
||||
font: 700 10px/1 var(--font-mono);
|
||||
}
|
||||
.validator-hero h2 {
|
||||
margin: 4px 0 6px;
|
||||
font-size: 24px;
|
||||
}
|
||||
.validator-hero p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.validator-hero .project-illustration {
|
||||
width: 220px;
|
||||
opacity: 0.82;
|
||||
}
|
||||
.validator-actions {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 150px;
|
||||
}
|
||||
.validator-groups {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
.validator-group {
|
||||
overflow: hidden;
|
||||
}
|
||||
.validator-checks {
|
||||
display: grid;
|
||||
}
|
||||
.validator-check {
|
||||
min-height: 78px;
|
||||
padding: 13px 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr) auto;
|
||||
gap: 11px;
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
transition:
|
||||
background 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
.validator-check:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
.validator-check-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 9px;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.validator-check.pass .validator-check-icon {
|
||||
color: var(--success);
|
||||
background: color-mix(in srgb, var(--success) 12%, transparent);
|
||||
}
|
||||
.validator-check.warning .validator-check-icon {
|
||||
color: var(--warning);
|
||||
background: color-mix(in srgb, var(--warning) 12%, transparent);
|
||||
}
|
||||
.validator-check.error .validator-check-icon {
|
||||
color: var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 12%, transparent);
|
||||
}
|
||||
.validator-check strong {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
}
|
||||
.validator-check p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
html[data-theme="light"] .validator-hero {
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 68% 16%,
|
||||
rgba(66, 91, 220, 0.18),
|
||||
transparent 30%
|
||||
),
|
||||
linear-gradient(
|
||||
120deg,
|
||||
rgba(255, 255, 255, 0.98),
|
||||
rgba(236, 243, 255, 0.96)
|
||||
);
|
||||
}
|
||||
@media (max-width: 1180px) {
|
||||
.validator-hero {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
}
|
||||
.validator-hero .project-illustration {
|
||||
display: none;
|
||||
}
|
||||
.validator-groups {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@container (max-width: 900px) {
|
||||
.validator-hero {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
.validator-hero .project-illustration {
|
||||
display: none;
|
||||
}
|
||||
.validator-actions {
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.validator-groups {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@container (max-width: 520px) {
|
||||
.validator-hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.validator-score {
|
||||
width: 92px;
|
||||
height: 92px;
|
||||
border-radius: 25px;
|
||||
}
|
||||
.validator-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.validator-check {
|
||||
grid-template-columns: 30px minmax(0, 1fr);
|
||||
}
|
||||
.validator-check > .button,
|
||||
.validator-check > .status-pill {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
.empty-state {
|
||||
height: 100%;
|
||||
min-height: 260px;
|
||||
@@ -1309,10 +1775,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 +3631,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 +3831,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 +3850,11 @@ select:focus {
|
||||
.summary-grid {
|
||||
gap: 8px;
|
||||
}
|
||||
.project-illustration {
|
||||
width: 190px;
|
||||
flex-basis: 190px;
|
||||
}
|
||||
.project-illustration.repo {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const require = createRequire(import.meta.url);
|
||||
const { GitService } = require("../src/main/git-service.cjs");
|
||||
const {
|
||||
GitValidatorService,
|
||||
isSensitiveTrackedPath,
|
||||
sameRemote,
|
||||
} = require("../src/main/git-validator-service.cjs");
|
||||
|
||||
async function git(args, cwd) {
|
||||
return exec("git", args, { cwd, encoding: "utf8" });
|
||||
}
|
||||
|
||||
test("Git Validator scores repository hygiene and offers bounded safe repairs", async (t) => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "forgeflow-validator-"));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
await git(["init", "-b", "main"], root);
|
||||
await git(["config", "user.name", "ForgeFlow Test"], root);
|
||||
await git(["config", "user.email", "forgeflow@example.invalid"], root);
|
||||
await git(
|
||||
["remote", "add", "origin", "https://gitea.example.test/jens/app.git"],
|
||||
root,
|
||||
);
|
||||
await writeFile(path.join(root, "README.md"), "# App\n", "utf8");
|
||||
await writeFile(path.join(root, ".gitignore"), ".env\n", "utf8");
|
||||
await git(["add", "."], root);
|
||||
await git(["commit", "-m", "Initial"], root);
|
||||
|
||||
const validator = new GitValidatorService({
|
||||
git: new GitService(),
|
||||
gitea: {
|
||||
getBranchProtection: async () => ({
|
||||
protected: false,
|
||||
enableForcePush: false,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const repository = {
|
||||
fullName: "jens/app",
|
||||
name: "app",
|
||||
owner: { login: "jens" },
|
||||
defaultBranch: "main",
|
||||
localPath: root,
|
||||
cloneUrl: "https://gitea.example.test/jens/app.git",
|
||||
sshUrl: "git@gitea.example.test:jens/app.git",
|
||||
};
|
||||
const report = await validator.scan(repository);
|
||||
assert.ok(report.score > 60);
|
||||
assert.equal(
|
||||
report.checks.find((check) => check.id === "origin").status,
|
||||
"pass",
|
||||
);
|
||||
assert.equal(
|
||||
report.checks.find((check) => check.id === "default-branch-protection")
|
||||
.fixAction,
|
||||
"protect-default-branch",
|
||||
);
|
||||
const safety = report.checks.find((check) => check.id === "local-safety");
|
||||
assert.equal(safety.safe, true);
|
||||
await validator.repair(repository, safety);
|
||||
const rescanned = await validator.scan(repository);
|
||||
assert.equal(
|
||||
rescanned.checks.find((check) => check.id === "local-safety").status,
|
||||
"pass",
|
||||
);
|
||||
});
|
||||
|
||||
test("Git Validator creates a reviewable gitignore without committing it", async (t) => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "forgeflow-ignore-"));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
await git(["init", "-b", "main"], root);
|
||||
const validator = new GitValidatorService({ git: new GitService() });
|
||||
const repository = { localPath: root };
|
||||
await validator.repair(repository, { fixAction: "add-gitignore" });
|
||||
const content = await readFile(path.join(root, ".gitignore"), "utf8");
|
||||
assert.match(content, /\.env/);
|
||||
const status = await git(["status", "--short"], root);
|
||||
assert.match(status.stdout, /\?\? \.gitignore/);
|
||||
});
|
||||
|
||||
test("Git Validator recognizes remote aliases and secret-shaped tracked paths", () => {
|
||||
assert.equal(
|
||||
sameRemote(
|
||||
"git@gitea.example.test:jens/app.git",
|
||||
"https://gitea.example.test/jens/app",
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(isSensitiveTrackedPath(".env.production"), true);
|
||||
assert.equal(isSensitiveTrackedPath("config/private.pem"), true);
|
||||
assert.equal(isSensitiveTrackedPath(".env.example"), false);
|
||||
});
|
||||
@@ -107,3 +107,37 @@ 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/,
|
||||
);
|
||||
});
|
||||
|
||||
test('creates conservative default branch protection rules', async () => {
|
||||
const service = new GiteaService(makeStore());
|
||||
let request = null;
|
||||
service.request = async (pathname, options) => {
|
||||
request = { pathname, options };
|
||||
return { data: { rule_name: 'main' } };
|
||||
};
|
||||
const result = await service.createBranchProtection('jens', 'app', 'main');
|
||||
assert.equal(result.rule_name, 'main');
|
||||
assert.equal(request.options.method, 'POST');
|
||||
assert.equal(request.options.body.enable_push, false);
|
||||
assert.equal(request.options.body.enable_force_push, false);
|
||||
assert.equal(request.options.body.rule_name, 'main');
|
||||
});
|
||||
|
||||
@@ -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,60 @@ 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/);
|
||||
});
|
||||
|
||||
test("Git Validator exposes scored best-practice checks and bounded repairs", 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",
|
||||
);
|
||||
const preload = await readFile(
|
||||
new URL("../preload.cjs", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(renderer, /function renderGitValidator/);
|
||||
assert.match(renderer, /git-validator-repair-safe/);
|
||||
assert.match(renderer, /check\.safe/);
|
||||
assert.match(styles, /\.validator-score/);
|
||||
assert.match(styles, /@container \(max-width: 900px\)/);
|
||||
assert.match(preload, /gitValidatorScan/);
|
||||
assert.match(preload, /gitValidatorRepair/);
|
||||
});
|
||||
|
||||
+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