Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24dde9a031 | ||
|
|
e888bd2c1c | ||
|
|
40d41f9a32 |
@@ -1,5 +1,28 @@
|
||||
# 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;
|
||||
|
||||
+23
-18
@@ -1,4 +1,4 @@
|
||||
ForgeFlow 0.8.6 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
|
||||
d73523f71016ac34bcf227d0222c9008fbd9c6489e2711dd3ad1da37de796369 9665 CHANGELOG.md
|
||||
2038d46210c3c2ac582889f9ed5e352a9ff4f2b0bbd80d25adcc20aba7c3e09b 11200 CHANGELOG.md
|
||||
21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md
|
||||
a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md
|
||||
30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md
|
||||
@@ -44,6 +44,9 @@ c2802fa5dbff392c846b82b55e84a8bfb8e1625546fd1eba39f7129318bece96 654
|
||||
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
|
||||
@@ -65,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
|
||||
59ab2691188bfde953a30c0ad2709f529c86ea9de7351aa98f53238ba50d7771 130466 package-lock.json
|
||||
8c137e3654c508000fa313429457e314c42a4b73abf6d14848e07445289515cf 3832 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
|
||||
@@ -81,7 +84,7 @@ f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287
|
||||
74433d8a6b24afe368197a469e2fe0c5050c239d7250b84c2f3f598c304778b0 4736 scripts/publish-binary-release.cjs
|
||||
444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs
|
||||
42203f9e0fd4aae517284d387f265cf1b0b180379bc253a092b5c3c5c4caef0a 2992 scripts/validate-installed-connections.cjs
|
||||
cd7650a307ce862c9dfc6c756efda5b10c75d3d1f63ce54191baaf7f008123bb 12308 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
|
||||
@@ -91,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
|
||||
75f25fa8ee520b03b0bbe4c4cea439853202d318547343cda49a19c6ab868901 16984 src/main/gitea-service.cjs
|
||||
79593a28b8f40f48a73028be34464f94be2e4c67a027a3f5da78a33bcadc76eb 45539 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
|
||||
8cf5013de91737dd9345b121586ceec38a6fc8518648975d60593d81fc225c4b 67376 src/main/unraid-deployment-service.cjs
|
||||
27b9e15dd6530bc7bfab51414430eaef169e0cc4acd027257b1c63f891af8c85 20733 src/main/update-service.cjs
|
||||
ac13e1222c7adcb664ff18c1de42c1f7bd25e6d90d2f02a18e4a4a68f5683bad 196260 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
|
||||
96e62ef31fd20954942dbe9dd4d44ecefdf869e9fb3144fe23d88c38fac0d9bf 50922 src/renderer/mock-bridge.js
|
||||
e30ea37ee00f34110d0d252c79f9d8e0bb127c4db0bd1bf8b374609dea433361 72160 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
|
||||
@@ -130,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
|
||||
185e96ff8fd029f06b77155117f492bc16684e08818a160148e00737dd8c220b 8395 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
|
||||
@@ -144,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
|
||||
05c791ea262aef85a7c79874ed900c5d792a78778c577cb803b88a433cd6e2b6 21413 tests/unraid-deployment.test.mjs
|
||||
f1f0f13ac41f47c9df8ef778d7b98c09a589f61e6f76e14bc0e8943c5ede7560 14990 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,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.
|
||||
@@ -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,14 +142,12 @@ function createWindow() {
|
||||
);
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (/^https?:\/\//i.test(url))
|
||||
shell
|
||||
.openExternal(url)
|
||||
.catch((error) =>
|
||||
diagnostics?.warning("external-link.open.failed", {
|
||||
url,
|
||||
message: error.message,
|
||||
}),
|
||||
);
|
||||
shell.openExternal(url).catch((error) =>
|
||||
diagnostics?.warning("external-link.open.failed", {
|
||||
url,
|
||||
message: error.message,
|
||||
}),
|
||||
);
|
||||
return { action: "deny" };
|
||||
});
|
||||
mainWindow.webContents.on("will-navigate", (event, url) => {
|
||||
@@ -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.6",
|
||||
"version": "0.8.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "forgeflow",
|
||||
"version": "0.8.6",
|
||||
"version": "0.8.9",
|
||||
"dependencies": {
|
||||
"ssh2": "1.17.0"
|
||||
},
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "forgeflow",
|
||||
"version": "0.8.6",
|
||||
"version": "0.8.9",
|
||||
"private": true,
|
||||
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
|
||||
"main": "main.cjs",
|
||||
@@ -79,6 +79,9 @@
|
||||
"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": [
|
||||
|
||||
+118
-94
@@ -22,98 +22,122 @@ function subscribe(channel, listener) {
|
||||
return () => ipcRenderer.removeListener(channel, handler);
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('forgeflow', Object.freeze({
|
||||
bootstrap: () => invoke('app:bootstrap'),
|
||||
selectDirectory: (payload) => invoke('dialog:select-directory', payload),
|
||||
selectKeyFile: (payload) => invoke('dialog:select-key-file', payload),
|
||||
selectImageFile: (payload) => invoke('dialog:select-image-file', payload),
|
||||
setupPreflight: (payload) => invoke('setup:preflight', payload),
|
||||
validateGitea: (payload) => invoke('setup:validate-gitea', payload),
|
||||
completeSetup: (payload) => invoke('setup:complete', payload),
|
||||
updateGitea: (payload) => invoke('settings:update-gitea', payload),
|
||||
setWorkspaceRoots: (roots) => invoke('settings:set-roots', { roots }),
|
||||
setAppearance: (appearance) => invoke('settings:set-appearance', { appearance }),
|
||||
setPreferences: (preferences) => invoke('settings:set-preferences', { preferences }),
|
||||
exportConfigurationBackup: (passphrase) => invoke('settings:export-backup', { passphrase }),
|
||||
importConfigurationBackup: (passphrase) => invoke('settings:import-backup', { passphrase }),
|
||||
listAuditEvents: (limit = 250) => invoke('audit:list', { limit }),
|
||||
exportAuditLog: (format = 'json') => invoke('audit:export', { format }),
|
||||
setUpdatePreferences: (updates) => invoke('updates:preferences', { updates }),
|
||||
checkForUpdates: () => invoke('updates:check'),
|
||||
downloadUpdate: () => invoke('updates:download'),
|
||||
applyUpdate: () => invoke('updates:apply'),
|
||||
saveServer: (server, password = '', passphrase = '') => invoke('server:save', { server, password, passphrase }),
|
||||
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 }),
|
||||
refreshRepositories: () => invoke('repositories:refresh'),
|
||||
discoverRepositories: (roots) => invoke('repositories:discover', { roots }),
|
||||
favoriteRepository: (fullName, favorite) => invoke('repository:favorite', { fullName, favorite }),
|
||||
linkRepository: (fullName, localPath) => invoke('repository:link', { fullName, localPath }),
|
||||
unlinkRepository: (fullName) => invoke('repository:unlink', { fullName }),
|
||||
repositoryStatus: (localPath) => invoke('repository:status', { localPath }),
|
||||
repositoryDiff: (localPath, filePath, staged = false) => invoke('repository:diff', { localPath, filePath, staged }),
|
||||
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 }),
|
||||
continueGitOperation: (localPath) => invoke('repository:continue-operation', { localPath }),
|
||||
abortGitOperation: (localPath) => invoke('repository:abort-operation', { localPath }),
|
||||
stageFiles: (localPath, files) => invoke('repository:stage', { localPath, files }),
|
||||
unstageFiles: (localPath, files) => invoke('repository:unstage', { localPath, files }),
|
||||
commit: (localPath, message, files) => invoke('repository:commit', { localPath, message, files }),
|
||||
commitStaged: (localPath, message) => invoke('repository:commit-staged', { localPath, message }),
|
||||
commitStagedAndPush: (localPath, message) => invoke('repository:commit-staged-push', { localPath, message }),
|
||||
commitAndPush: (localPath, message, files) => invoke('repository:commit-push', { localPath, message, files }),
|
||||
push: (localPath) => invoke('repository:push', { localPath }),
|
||||
fetch: (localPath) => invoke('repository:fetch', { localPath }),
|
||||
pull: (localPath) => invoke('repository:pull', { localPath }),
|
||||
history: (localPath, limit = 20) => invoke('repository:history', { localPath, limit }),
|
||||
branchProtection: (fullName, branch) => invoke('repository:branch-protection', { fullName, branch }),
|
||||
pullRequests: (fullName, state = 'open') => invoke('repository:pull-requests', { fullName, state }),
|
||||
createPullRequest: (fullName, title, body, base) => invoke('repository:create-pull-request', { fullName, title, body, base }),
|
||||
branches: (localPath) => invoke('repository:branches', { localPath }),
|
||||
checkoutBranch: (localPath, branch) => invoke('repository:checkout-branch', { localPath, branch }),
|
||||
createBranch: (localPath, branch) => invoke('repository:create-branch', { localPath, branch }),
|
||||
stash: (localPath, message) => invoke('repository:stash', { localPath, message }),
|
||||
stashList: (localPath) => invoke('repository:stash-list', { localPath }),
|
||||
popStash: (localPath, ref) => invoke('repository:stash-pop', { localPath, ref }),
|
||||
indexLockInfo: (localPath) => invoke('repository:index-lock', { localPath }),
|
||||
repairIndexLock: (localPath) => invoke('repository:repair-index-lock', { localPath }),
|
||||
gitRecoveryStatus: (localPath) => invoke('repository:git-recovery-status', { localPath }),
|
||||
repairGitLocks: (localPath, force = false) => invoke('repository:repair-git-locks', { localPath, force }),
|
||||
reconcileRepository: (localPath) => invoke('repository:reconcile', { localPath }),
|
||||
repairRepositorySync: (localPath, strategy) => invoke('repository:repair-sync', { localPath, strategy }),
|
||||
setOrigin: (localPath, remoteUrl) => invoke('repository:set-origin', { localPath, remoteUrl }),
|
||||
normalizeOrigins: () => invoke('repositories:normalize-origins'),
|
||||
cloneRepository: (fullName, mode = 'default') => invoke('repository:clone', { fullName, mode }),
|
||||
openPath: (localPath) => invoke('repository:open-path', { localPath }),
|
||||
openEditor: (localPath, filePath = '', line = 1) => invoke('repository:open-editor', { localPath, filePath, line }),
|
||||
openTerminal: (localPath) => invoke('repository:open-terminal', { localPath }),
|
||||
openExternal: (url) => invoke('external:open', { url }),
|
||||
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 || '' }),
|
||||
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 }),
|
||||
applyDockerManMetadata: (repository, profileId) => invoke('deployment:apply-dockerman-metadata', { repository, profileId }),
|
||||
reconcileDeployment: (fullName, profileId) => invoke('deployment:reconcile', { fullName, profileId }),
|
||||
refreshOperations: (operationId = null) => invoke('operations:refresh', { operationId }),
|
||||
getOperation: (operationId) => invoke('operations:get', { operationId }),
|
||||
troubleshooterScan: (fullName = null) => invoke('troubleshooter:scan', { fullName }),
|
||||
troubleshooterRepair: (issue) => invoke('troubleshooter:repair', { issue }),
|
||||
contextBridge.exposeInMainWorld(
|
||||
'forgeflow',
|
||||
Object.freeze({
|
||||
bootstrap: () => invoke('app:bootstrap'),
|
||||
selectDirectory: (payload) => invoke('dialog:select-directory', payload),
|
||||
selectKeyFile: (payload) => invoke('dialog:select-key-file', payload),
|
||||
selectImageFile: (payload) => invoke('dialog:select-image-file', payload),
|
||||
setupPreflight: (payload) => invoke('setup:preflight', payload),
|
||||
validateGitea: (payload) => invoke('setup:validate-gitea', payload),
|
||||
completeSetup: (payload) => invoke('setup:complete', payload),
|
||||
updateGitea: (payload) => invoke('settings:update-gitea', payload),
|
||||
setWorkspaceRoots: (roots) => invoke('settings:set-roots', { roots }),
|
||||
setAppearance: (appearance) => invoke('settings:set-appearance', { appearance }),
|
||||
setPreferences: (preferences) => invoke('settings:set-preferences', { preferences }),
|
||||
exportConfigurationBackup: (passphrase) => invoke('settings:export-backup', { passphrase }),
|
||||
importConfigurationBackup: (passphrase) => invoke('settings:import-backup', { passphrase }),
|
||||
listAuditEvents: (limit = 250) => invoke('audit:list', { limit }),
|
||||
exportAuditLog: (format = 'json') => invoke('audit:export', { format }),
|
||||
setUpdatePreferences: (updates) => invoke('updates:preferences', { updates }),
|
||||
checkForUpdates: () => invoke('updates:check'),
|
||||
downloadUpdate: () => invoke('updates:download'),
|
||||
applyUpdate: () => invoke('updates:apply'),
|
||||
saveServer: (server, password = '', passphrase = '') => invoke('server:save', { server, password, passphrase }),
|
||||
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,
|
||||
}),
|
||||
refreshRepositories: () => invoke('repositories:refresh'),
|
||||
discoverRepositories: (roots) => invoke('repositories:discover', { roots }),
|
||||
favoriteRepository: (fullName, favorite) => invoke('repository:favorite', { fullName, favorite }),
|
||||
linkRepository: (fullName, localPath) => invoke('repository:link', { fullName, localPath }),
|
||||
unlinkRepository: (fullName) => invoke('repository:unlink', { fullName }),
|
||||
repositoryStatus: (localPath) => invoke('repository:status', { localPath }),
|
||||
repositoryDiff: (localPath, filePath, staged = false) => invoke('repository:diff', { localPath, filePath, staged }),
|
||||
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,
|
||||
}),
|
||||
continueGitOperation: (localPath) => invoke('repository:continue-operation', { localPath }),
|
||||
abortGitOperation: (localPath) => invoke('repository:abort-operation', { localPath }),
|
||||
stageFiles: (localPath, files) => invoke('repository:stage', { localPath, files }),
|
||||
unstageFiles: (localPath, files) => invoke('repository:unstage', { localPath, files }),
|
||||
commit: (localPath, message, files) => invoke('repository:commit', { localPath, message, files }),
|
||||
commitStaged: (localPath, message) => invoke('repository:commit-staged', { localPath, message }),
|
||||
commitStagedAndPush: (localPath, message) => invoke('repository:commit-staged-push', { localPath, message }),
|
||||
commitAndPush: (localPath, message, files) => invoke('repository:commit-push', { localPath, message, files }),
|
||||
push: (localPath) => invoke('repository:push', { localPath }),
|
||||
fetch: (localPath) => invoke('repository:fetch', { localPath }),
|
||||
pull: (localPath) => invoke('repository:pull', { localPath }),
|
||||
history: (localPath, limit = 20) => invoke('repository:history', { localPath, limit }),
|
||||
branchProtection: (fullName, branch) => invoke('repository:branch-protection', { fullName, branch }),
|
||||
pullRequests: (fullName, state = 'open') => invoke('repository:pull-requests', { fullName, state }),
|
||||
createPullRequest: (fullName, title, body, base) => invoke('repository:create-pull-request', { fullName, title, body, base }),
|
||||
branches: (localPath) => invoke('repository:branches', { localPath }),
|
||||
checkoutBranch: (localPath, branch) => invoke('repository:checkout-branch', { localPath, branch }),
|
||||
createBranch: (localPath, branch) => invoke('repository:create-branch', { localPath, branch }),
|
||||
stash: (localPath, message) => invoke('repository:stash', { localPath, message }),
|
||||
stashList: (localPath) => invoke('repository:stash-list', { localPath }),
|
||||
popStash: (localPath, ref) => invoke('repository:stash-pop', { localPath, ref }),
|
||||
indexLockInfo: (localPath) => invoke('repository:index-lock', { localPath }),
|
||||
repairIndexLock: (localPath) => invoke('repository:repair-index-lock', { localPath }),
|
||||
gitRecoveryStatus: (localPath) => invoke('repository:git-recovery-status', { localPath }),
|
||||
repairGitLocks: (localPath, force = false) => invoke('repository:repair-git-locks', { localPath, force }),
|
||||
reconcileRepository: (localPath) => invoke('repository:reconcile', { localPath }),
|
||||
repairRepositorySync: (localPath, strategy) => invoke('repository:repair-sync', { localPath, strategy }),
|
||||
setOrigin: (localPath, remoteUrl) => invoke('repository:set-origin', { localPath, remoteUrl }),
|
||||
normalizeOrigins: () => invoke('repositories:normalize-origins'),
|
||||
cloneRepository: (fullName, mode = 'default') => invoke('repository:clone', { fullName, mode }),
|
||||
openPath: (localPath) => invoke('repository:open-path', { localPath }),
|
||||
openEditor: (localPath, filePath = '', line = 1) => invoke('repository:open-editor', { localPath, filePath, line }),
|
||||
openTerminal: (localPath) => invoke('repository:open-terminal', { localPath }),
|
||||
openExternal: (url) => invoke('external:open', { url }),
|
||||
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 || '',
|
||||
}),
|
||||
rollback: (repository, profileId, targetSha) => invoke('deployment:rollback', { repository, profileId, targetSha }),
|
||||
healthcheck: (url) => invoke('deployment:health', { url }),
|
||||
refreshProfileState: (fullName, profileId) => invoke('deployment:profile-state', { fullName, profileId }),
|
||||
discoverServerDeployments: () => invoke('deployment:discover-server-workloads'),
|
||||
applyDockerManMetadata: (repository, profileId) => invoke('deployment:apply-dockerman-metadata', { repository, profileId }),
|
||||
reconcileDeployment: (fullName, profileId) => invoke('deployment:reconcile', { fullName, profileId }),
|
||||
refreshOperations: (operationId = null) => invoke('operations:refresh', { operationId }),
|
||||
getOperation: (operationId) => invoke('operations:get', { operationId }),
|
||||
troubleshooterScan: (fullName = null) => invoke('troubleshooter:scan', { fullName }),
|
||||
troubleshooterRepair: (issue) => invoke('troubleshooter:repair', { issue }),
|
||||
troubleshooterAutoRepair: (issues) => invoke('troubleshooter:auto-repair', { issues }),
|
||||
diagnosticsStatus: () => invoke('diagnostics:status'),
|
||||
clearDiagnostics: () => invoke('diagnostics:clear'),
|
||||
openDiagnosticsFolder: () => invoke('diagnostics:open-folder'),
|
||||
exportDiagnostics: (privacyMode = 'standard') => invoke('diagnostics:export', { privacyMode }),
|
||||
showDiagnosticBundle: (filePath) => invoke('diagnostics:show-bundle', { filePath }),
|
||||
reportRendererEvent: (level, event, details = {}) => invoke('renderer:report', { level, event, details }),
|
||||
onRepositoriesChanged: (listener) => subscribe('repositories:changed', listener),
|
||||
onOperationsChanged: (listener) => subscribe('operations:changed', listener),
|
||||
onUpdatesChanged: (listener) => subscribe('updates:changed', listener),
|
||||
reset: () => invoke('app:reset')
|
||||
}));
|
||||
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'),
|
||||
exportDiagnostics: (privacyMode = 'standard') => invoke('diagnostics:export', { privacyMode }),
|
||||
showDiagnosticBundle: (filePath) => invoke('diagnostics:show-bundle', { filePath }),
|
||||
reportRendererEvent: (level, event, details = {}) => invoke('renderer:report', { level, event, details }),
|
||||
onRepositoriesChanged: (listener) => subscribe('repositories:changed', listener),
|
||||
onOperationsChanged: (listener) => subscribe('operations:changed', listener),
|
||||
onUpdatesChanged: (listener) => subscribe('updates:changed', listener),
|
||||
reset: () => invoke('app:reset'),
|
||||
}),
|
||||
);
|
||||
|
||||
+5
-2
@@ -67,6 +67,9 @@ const required = [
|
||||
"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",
|
||||
@@ -105,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.6")
|
||||
if (packageJson.version !== "0.8.9")
|
||||
throw new Error(
|
||||
`Expected package version 0.8.6, got ${packageJson.version}.`,
|
||||
`Expected package version 0.8.9, got ${packageJson.version}.`,
|
||||
);
|
||||
const sourceManifest = await readFile(
|
||||
path.join(root, "SOURCE_MANIFEST.txt"),
|
||||
|
||||
@@ -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,11 +353,14 @@ class GiteaService {
|
||||
throw new Error("The update download exceeded the redirect limit.");
|
||||
}
|
||||
|
||||
async downloadReleaseAsset(owner, repo, assetId, options = {}) {
|
||||
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/assets/${numericId}`;
|
||||
const assetPath = `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${numericReleaseId}/assets/${numericId}`;
|
||||
return this.downloadAuthenticated(assetPath, options);
|
||||
}
|
||||
|
||||
|
||||
+69
-3
@@ -87,6 +87,7 @@ function registerIpc({
|
||||
ssh,
|
||||
updates,
|
||||
preflight,
|
||||
gitValidator,
|
||||
diagnostics,
|
||||
audit,
|
||||
externalTools,
|
||||
@@ -841,6 +842,40 @@ function registerIpc({
|
||||
return true;
|
||||
});
|
||||
|
||||
register("git-validator:scan", async ({ fullName }) => {
|
||||
const repository = await resolveRepository({ fullName });
|
||||
const report = await gitValidator.scan(repository);
|
||||
await diagnostics.info("git-validator.scan.completed", {
|
||||
repository: repository.fullName,
|
||||
score: report.score,
|
||||
summary: report.summary,
|
||||
});
|
||||
return report;
|
||||
});
|
||||
register("git-validator:repair", async ({ fullName, check }) => {
|
||||
const repository = await resolveRepository({ fullName });
|
||||
const allowed = new Set([
|
||||
"align-origin",
|
||||
"configure-local-safety",
|
||||
"add-gitignore",
|
||||
"protect-default-branch",
|
||||
]);
|
||||
if (!allowed.has(check?.fixAction))
|
||||
throw new Error("Unsupported Git Validator repair request.");
|
||||
const result = await gitValidator.repair(repository, check);
|
||||
await audit.append("git-validator.repair", {
|
||||
repository: repository.fullName,
|
||||
checkId: check.id,
|
||||
action: check.fixAction,
|
||||
});
|
||||
await diagnostics.info("git-validator.repair.completed", {
|
||||
repository: repository.fullName,
|
||||
checkId: check.id,
|
||||
action: check.fixAction,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
register("troubleshooter:scan", async ({ fullName = null }) => {
|
||||
const currentRepositories = await repositories.refresh();
|
||||
const candidates = fullName
|
||||
@@ -1140,10 +1175,41 @@ function registerIpc({
|
||||
},
|
||||
);
|
||||
register("deployment:health", ({ url }) => deployments.checkHealth(url));
|
||||
register("deployment:profile-state", ({ fullName, profileId }) => {
|
||||
register("deployment:discover-server-workloads", async () => {
|
||||
const repositoryList = await repositories.refresh();
|
||||
const remoteRepositories = repositoryList.filter(
|
||||
(repository) => repository.owner?.login !== "local",
|
||||
);
|
||||
const results = [];
|
||||
for (const server of store.data.servers || []) {
|
||||
try {
|
||||
results.push(
|
||||
await unraid.discoverServerWorkloads(server.id, remoteRepositories),
|
||||
);
|
||||
} catch (error) {
|
||||
results.push({
|
||||
serverId: server.id,
|
||||
detected: 0,
|
||||
adopted: 0,
|
||||
verified: 0,
|
||||
unmatched: 0,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
});
|
||||
register("deployment:profile-state", async ({ fullName, profileId }) => {
|
||||
const profile = store.getDeploymentProfile(fullName, profileId);
|
||||
if (profile?.provider === "ssh-unraid")
|
||||
return unraid.refreshProfileState(fullName, profileId);
|
||||
if (profile?.provider === "ssh-unraid") {
|
||||
let giteaSha = null;
|
||||
try {
|
||||
const [owner, repo] = String(fullName || "").split("/");
|
||||
const branch = await gitea.getBranch(owner, repo, profile.branch);
|
||||
giteaSha = branch?.commit?.id || branch?.commit?.sha || null;
|
||||
} catch {}
|
||||
return unraid.refreshProfileState(fullName, profileId, giteaSha);
|
||||
}
|
||||
return deployments.refreshProfileState(fullName, profileId);
|
||||
});
|
||||
register(
|
||||
|
||||
@@ -349,11 +349,92 @@ function iconReferenceLocalPath(iconReference) {
|
||||
return "";
|
||||
}
|
||||
|
||||
function decodeInventoryValue(value) {
|
||||
try {
|
||||
return Buffer.from(String(value || ""), "base64").toString("utf8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function remoteIdentity(value) {
|
||||
const normalized = normalizeRemoteUrl(value);
|
||||
return normalized ? `${normalized.host}/${normalized.path}` : "";
|
||||
}
|
||||
|
||||
function parseServerInventory(output) {
|
||||
const marker = "__FORGEFLOW_INVENTORY__";
|
||||
const index = String(output || "").lastIndexOf(marker);
|
||||
if (index < 0)
|
||||
throw new Error(
|
||||
"The server did not return a ForgeFlow workload inventory.",
|
||||
);
|
||||
const checkouts = [];
|
||||
const containers = [];
|
||||
for (const line of String(output)
|
||||
.slice(index + marker.length)
|
||||
.trim()
|
||||
.split(/\r?\n/)) {
|
||||
const [kind, ...parts] = line.split("\t");
|
||||
if (kind === "R" && parts.length >= 4) {
|
||||
checkouts.push({
|
||||
root: decodeInventoryValue(parts[0]),
|
||||
remote: decodeInventoryValue(parts[1]),
|
||||
liveSha: parts[2],
|
||||
branch: decodeInventoryValue(parts[3]),
|
||||
});
|
||||
} else if (kind === "C" && parts[0]) {
|
||||
try {
|
||||
const parsed = JSON.parse(decodeInventoryValue(parts[0]));
|
||||
if (Array.isArray(parsed) && parsed[0]) containers.push(parsed[0]);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return { checkouts, containers };
|
||||
}
|
||||
|
||||
function inventoryContainerMatch(checkout, repository, container) {
|
||||
if (!container?.State?.Running) return 0;
|
||||
const labels = container.Config?.Labels || {};
|
||||
const workingDir = String(
|
||||
labels["com.docker.compose.project.working_dir"] || "",
|
||||
).replace(/\/$/, "");
|
||||
const source = remoteIdentity(
|
||||
labels["org.opencontainers.image.source"] || "",
|
||||
);
|
||||
const mounts = Array.isArray(container.Mounts) ? container.Mounts : [];
|
||||
const root = String(checkout.root || "").replace(/\/$/, "");
|
||||
const name = String(container.Name || "").replace(/^\//, "");
|
||||
const project = String(labels["com.docker.compose.project"] || "");
|
||||
const expectedNames = new Set(
|
||||
[repository.name, root.split("/").pop()].filter(Boolean).map((value) =>
|
||||
String(value)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, ""),
|
||||
),
|
||||
);
|
||||
const normalizedName = name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
const normalizedProject = project.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
if (workingDir && workingDir === root) return 100;
|
||||
if (
|
||||
mounts.some((mount) => {
|
||||
const mountSource = String(mount.Source || "").replace(/\/$/, "");
|
||||
return mountSource === root || mountSource.startsWith(`${root}/`);
|
||||
})
|
||||
)
|
||||
return 90;
|
||||
if (source && source === remoteIdentity(checkout.remote)) return 85;
|
||||
if (expectedNames.has(normalizedProject)) return 70;
|
||||
if (expectedNames.has(normalizedName)) return 60;
|
||||
return 0;
|
||||
}
|
||||
|
||||
class UnraidDeploymentService {
|
||||
constructor({
|
||||
store,
|
||||
ssh,
|
||||
git,
|
||||
gitea,
|
||||
diagnostics,
|
||||
sourcePath = process.cwd(),
|
||||
onOperationChange = null,
|
||||
@@ -361,11 +442,204 @@ class UnraidDeploymentService {
|
||||
this.store = store;
|
||||
this.ssh = ssh;
|
||||
this.git = git;
|
||||
this.gitea = gitea;
|
||||
this.diagnostics = diagnostics;
|
||||
this.sourcePath = sourcePath;
|
||||
this.onOperationChange = onOperationChange;
|
||||
}
|
||||
|
||||
async discoverServerWorkloads(serverId, repositories) {
|
||||
const server = this.store.getServer(serverId);
|
||||
if (!server) throw new Error("The deployment server no longer exists.");
|
||||
const script = `
|
||||
base=${shellQuote(server.basePath)}
|
||||
printf '__FORGEFLOW_INVENTORY__\\n'
|
||||
if [ -d "$base" ]; then
|
||||
find "$base" -mindepth 2 -maxdepth 2 -type d -name .git -print0 2>/dev/null | while IFS= read -r -d '' gitdir; do
|
||||
root=\${gitdir%/.git}
|
||||
remote=$(git -C "$root" remote get-url origin 2>/dev/null || true)
|
||||
live=$(git -C "$root" rev-parse HEAD 2>/dev/null || true)
|
||||
branch=$(git -C "$root" symbolic-ref --short HEAD 2>/dev/null || true)
|
||||
[ -n "$remote" ] || continue
|
||||
printf 'R\\t%s\\t%s\\t%s\\t%s\\n' "$(printf '%s' "$root" | base64 | tr -d '\\r\\n')" "$(printf '%s' "$remote" | base64 | tr -d '\\r\\n')" "$live" "$(printf '%s' "$branch" | base64 | tr -d '\\r\\n')"
|
||||
done
|
||||
fi
|
||||
for id in $(docker ps -aq 2>/dev/null || true); do
|
||||
printf 'C\\t%s\\n' "$(docker inspect "$id" 2>/dev/null | base64 | tr -d '\\r\\n')"
|
||||
done
|
||||
`;
|
||||
const result = await this.ssh.exec(server.id, bash(script), {
|
||||
timeout: 45_000,
|
||||
maxOutput: 8 * 1024 * 1024,
|
||||
});
|
||||
const inventory = parseServerInventory(result.stdout);
|
||||
const workloads = [...inventory.checkouts];
|
||||
const knownRemotes = new Set(
|
||||
workloads.map((checkout) => remoteIdentity(checkout.remote)),
|
||||
);
|
||||
for (const container of inventory.containers) {
|
||||
const labels = container.Config?.Labels || {};
|
||||
const remote =
|
||||
labels["org.opencontainers.image.source"] ||
|
||||
labels["tech.itworx.forgeflow.repository"] ||
|
||||
"";
|
||||
const revision =
|
||||
labels["org.opencontainers.image.revision"] ||
|
||||
labels["tech.itworx.forgeflow.commit"] ||
|
||||
"";
|
||||
if (
|
||||
!remoteIdentity(remote) ||
|
||||
knownRemotes.has(remoteIdentity(remote)) ||
|
||||
!/^[0-9a-f]{40}$/i.test(revision)
|
||||
)
|
||||
continue;
|
||||
const workingDir = String(
|
||||
labels["com.docker.compose.project.working_dir"] || "",
|
||||
);
|
||||
const mountedRoot = (container.Mounts || [])
|
||||
.map((mount) => String(mount.Source || ""))
|
||||
.find((source) => source.startsWith(`${server.basePath}/`));
|
||||
workloads.push({
|
||||
root: workingDir || mountedRoot || "",
|
||||
remote,
|
||||
liveSha: revision,
|
||||
branch: labels["tech.itworx.forgeflow.branch"] || "",
|
||||
imageMetadata: true,
|
||||
});
|
||||
knownRemotes.add(remoteIdentity(remote));
|
||||
}
|
||||
const remoteMap = new Map();
|
||||
for (const repository of repositories || []) {
|
||||
for (const remote of [
|
||||
repository.cloneUrl,
|
||||
repository.sshUrl,
|
||||
repository.htmlUrl,
|
||||
]) {
|
||||
const normalized = remoteIdentity(remote);
|
||||
if (normalized) remoteMap.set(normalized, repository);
|
||||
}
|
||||
}
|
||||
const summary = {
|
||||
serverId,
|
||||
detected: 0,
|
||||
adopted: 0,
|
||||
verified: 0,
|
||||
unmatched: 0,
|
||||
};
|
||||
for (const checkout of workloads) {
|
||||
const repository = remoteMap.get(remoteIdentity(checkout.remote));
|
||||
if (!repository) {
|
||||
summary.unmatched += 1;
|
||||
continue;
|
||||
}
|
||||
if (!checkout.root)
|
||||
checkout.root = path.join(server.basePath, repository.name);
|
||||
const candidates = inventory.containers
|
||||
.map((container) => ({
|
||||
container,
|
||||
score: inventoryContainerMatch(checkout, repository, container),
|
||||
}))
|
||||
.filter((candidate) => candidate.score >= 60)
|
||||
.sort((left, right) => right.score - left.score);
|
||||
if (!candidates.length || candidates[1]?.score === candidates[0].score) {
|
||||
summary.unmatched += 1;
|
||||
continue;
|
||||
}
|
||||
summary.detected += 1;
|
||||
const container = candidates[0].container;
|
||||
const containerName = String(container.Name || "").replace(/^\//, "");
|
||||
const remoteFolder = checkout.root
|
||||
.slice(server.basePath.length)
|
||||
.replace(/^\/+/, "");
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(remoteFolder)) {
|
||||
summary.unmatched += 1;
|
||||
continue;
|
||||
}
|
||||
const existing = this.store
|
||||
.getDeploymentProfiles(repository.fullName)
|
||||
.find(
|
||||
(profile) =>
|
||||
profile.provider === "ssh-unraid" &&
|
||||
profile.serverId === server.id &&
|
||||
(profile.containerName === containerName ||
|
||||
profile.remoteFolder === remoteFolder),
|
||||
);
|
||||
const labels = container.Config?.Labels || {};
|
||||
const portEntry = Object.entries(
|
||||
container.NetworkSettings?.Ports || {},
|
||||
).find(
|
||||
([, bindings]) => Array.isArray(bindings) && bindings[0]?.HostPort,
|
||||
);
|
||||
const containerPort = portEntry
|
||||
? Number(String(portEntry[0]).split("/")[0]) || null
|
||||
: null;
|
||||
const hostPort = portEntry
|
||||
? Number(portEntry[1][0].HostPort) || null
|
||||
: null;
|
||||
const profile =
|
||||
existing ||
|
||||
(await this.store.saveDeploymentProfile(repository.fullName, {
|
||||
id: `auto-${crypto.createHash("sha256").update(`${server.id}:${repository.fullName}:${containerName}`).digest("hex").slice(0, 20)}`,
|
||||
name: `${server.name} · ${containerName}`,
|
||||
environment: "production",
|
||||
provider: "ssh-unraid",
|
||||
branch: checkout.branch || repository.defaultBranch || "main",
|
||||
serverId: server.id,
|
||||
remoteFolder,
|
||||
composeFile:
|
||||
String(labels["com.docker.compose.project.config_files"] || "")
|
||||
.split(",")[0]
|
||||
.split("/")
|
||||
.pop() || "docker-compose.yml",
|
||||
composeService:
|
||||
String(labels["com.docker.compose.service"] || remoteFolder)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]/g, "-") || remoteFolder.toLowerCase(),
|
||||
containerName,
|
||||
cloneUrl: checkout.remote,
|
||||
hostPort,
|
||||
containerPort,
|
||||
webUiUrl: labels["net.unraid.docker.webui"] || "",
|
||||
adoptedFromServer: true,
|
||||
serverSourceOfTruth: true,
|
||||
detectedAt: new Date().toISOString(),
|
||||
detectedMetadata: {
|
||||
confidence: candidates[0].score,
|
||||
source: "automatic-server-inventory",
|
||||
},
|
||||
}));
|
||||
if (!existing) summary.adopted += 1;
|
||||
let giteaSha = null;
|
||||
try {
|
||||
const [owner, repo] = repository.fullName.split("/");
|
||||
const branch = await this.gitea.getBranch(owner, repo, profile.branch);
|
||||
giteaSha = branch?.commit?.id || branch?.commit?.sha || null;
|
||||
} catch {}
|
||||
const dockerHealth = String(container.State?.Health?.Status || "");
|
||||
const healthy = dockerHealth
|
||||
? dockerHealth === "healthy"
|
||||
: container.State?.Running === true;
|
||||
await this.store.saveDeploymentState(profile.id, {
|
||||
liveSha: /^[0-9a-f]{40}$/i.test(checkout.liveSha)
|
||||
? checkout.liveSha
|
||||
: null,
|
||||
giteaSha: /^[0-9a-f]{40}$/i.test(giteaSha || "") ? giteaSha : null,
|
||||
matchesGitea: Boolean(giteaSha && checkout.liveSha === giteaSha),
|
||||
healthy,
|
||||
containerRunning: container.State?.Running === true,
|
||||
dockerHealth: dockerHealth || null,
|
||||
containerName,
|
||||
remotePath: checkout.root,
|
||||
provider: "ssh-unraid",
|
||||
discovery: { automatic: true, confidence: candidates[0].score },
|
||||
});
|
||||
if (healthy && giteaSha && checkout.liveSha === giteaSha)
|
||||
summary.verified += 1;
|
||||
}
|
||||
await this.diagnostics?.info("unraid.workloads.discovered", summary);
|
||||
return summary;
|
||||
}
|
||||
|
||||
async saveOperation(operation) {
|
||||
const saved = await this.store.addOperation(operation);
|
||||
this.onOperationChange?.({ operations: [saved] });
|
||||
@@ -1148,7 +1422,7 @@ chmod 0644 ${shellQuote(templatePath)}
|
||||
${this.iconCacheRefresh(profile, repository, iconReference)}`;
|
||||
}
|
||||
|
||||
metadataCompose(profile, repository, iconReference = "") {
|
||||
metadataCompose(profile, repository, iconReference = "", deployment = {}) {
|
||||
const service =
|
||||
String(profile.composeService || repository.name || "app")
|
||||
.trim()
|
||||
@@ -1165,7 +1439,16 @@ ${this.iconCacheRefresh(profile, repository, iconReference)}`;
|
||||
const labels = {
|
||||
"net.unraid.docker.managed": "dockerman",
|
||||
"net.unraid.docker.shell": this.dockerManShell(profile),
|
||||
"tech.itworx.forgeflow.repository":
|
||||
deployment.repositoryUrl ||
|
||||
profile.cloneUrl ||
|
||||
repository.sshUrl ||
|
||||
repository.cloneUrl ||
|
||||
repository.htmlUrl ||
|
||||
repository.fullName,
|
||||
"tech.itworx.forgeflow.branch": profile.branch || "main",
|
||||
};
|
||||
if (deployment.sha) labels["tech.itworx.forgeflow.commit"] = deployment.sha;
|
||||
const webUiLabel = this.dockerManWebUi(profile);
|
||||
if (webUiLabel) labels["net.unraid.docker.webui"] = webUiLabel;
|
||||
if (iconReference) labels["net.unraid.docker.icon"] = iconReference;
|
||||
@@ -1327,7 +1610,10 @@ ${this.iconCacheRefresh(profile, repository, iconReference)}`;
|
||||
? this.generatedCompose(profile, repository)
|
||||
: "";
|
||||
const iconReference = await this.prepareIcon(profile, repository, server);
|
||||
const metadata = this.metadataCompose(profile, repository, iconReference);
|
||||
const metadata = this.metadataCompose(profile, repository, iconReference, {
|
||||
sha: targetSha,
|
||||
repositoryUrl: cloneUrl,
|
||||
});
|
||||
const compose = this.composeInvocation(profile, repository, composeFile);
|
||||
const branch = String(profile.branch || "main");
|
||||
const statusJson = JSON.stringify({
|
||||
@@ -1513,7 +1799,11 @@ FORGEFLOW_STATUS
|
||||
? ".forgeflow/compose.forgeflow.yml"
|
||||
: safeRelativeRemoteFile(profile.composeFile || "docker-compose.yml");
|
||||
const iconReference = await this.prepareIcon(profile, repository, server);
|
||||
const metadata = this.metadataCompose(profile, repository, iconReference);
|
||||
const metadata = this.metadataCompose(profile, repository, iconReference, {
|
||||
sha: target,
|
||||
repositoryUrl:
|
||||
profile.cloneUrl || repository.sshUrl || repository.cloneUrl || "",
|
||||
});
|
||||
const compose = this.composeInvocation(profile, repository, composeFile);
|
||||
const requestId = crypto.randomUUID();
|
||||
const operation = await this.saveOperation({
|
||||
@@ -1639,6 +1929,8 @@ if docker inspect "$container" >/dev/null 2>&1; then
|
||||
webui=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.webui"}}' "$container" 2>/dev/null || true)
|
||||
icon=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.icon"}}' "$container" 2>/dev/null || true)
|
||||
shell_label=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.shell"}}' "$container" 2>/dev/null || true)
|
||||
[ -z "$live" ] && live=$(docker inspect -f '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$container" 2>/dev/null || true)
|
||||
[ -z "$live" ] && live=$(docker inspect -f '{{index .Config.Labels "tech.itworx.forgeflow.commit"}}' "$container" 2>/dev/null || true)
|
||||
fi
|
||||
printf '__FORGEFLOW_KV__\n'
|
||||
printf 'liveSha=%s\n' "$live"
|
||||
@@ -1886,6 +2178,9 @@ module.exports = {
|
||||
iconReferenceLocalPath,
|
||||
decodeBase64Json,
|
||||
parseDockerManXml,
|
||||
parseServerInventory,
|
||||
inventoryContainerMatch,
|
||||
remoteIdentity,
|
||||
deriveDetectedProfile,
|
||||
bash,
|
||||
};
|
||||
|
||||
@@ -300,10 +300,16 @@ class UpdateService {
|
||||
}
|
||||
|
||||
const [binary, checksumBytes] = await Promise.all([
|
||||
this.gitea.downloadReleaseAsset(update.owner, update.repo, asset.id),
|
||||
this.gitea.downloadReleaseAsset(
|
||||
update.owner,
|
||||
update.repo,
|
||||
release.id,
|
||||
asset.id,
|
||||
),
|
||||
this.gitea.downloadReleaseAsset(
|
||||
update.owner,
|
||||
update.repo,
|
||||
release.id,
|
||||
checksumAsset.id,
|
||||
),
|
||||
]);
|
||||
|
||||
+124
-4
@@ -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;
|
||||
@@ -903,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);
|
||||
@@ -947,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);
|
||||
@@ -977,6 +1028,7 @@ function renderRepositoryWorkspace(repository) {
|
||||
history: renderHistory,
|
||||
deployments: renderRepositoryDeployments,
|
||||
gittools: renderGitTools,
|
||||
validator: renderGitValidator,
|
||||
settings: renderRepositorySettings,
|
||||
}[ui.repositoryTab] || renderChanges
|
||||
)(repository);
|
||||
@@ -988,6 +1040,7 @@ function renderRepositoryWorkspace(repository) {
|
||||
["history", "History"],
|
||||
["deployments", "Deployments"],
|
||||
["gittools", "Git tools"],
|
||||
["validator", "Git Validator"],
|
||||
["settings", "Project settings"],
|
||||
]
|
||||
.map(
|
||||
@@ -1648,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,
|
||||
@@ -1660,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,
|
||||
|
||||
+124
-1
@@ -564,7 +564,7 @@
|
||||
await wait(80);
|
||||
snapshot();
|
||||
return {
|
||||
appVersion: "0.8.6-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,
|
||||
|
||||
@@ -1280,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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -243,3 +243,25 @@ test("the diff canvas uses a contextual and motion-safe code illustration", asyn
|
||||
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/);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,9 @@ const {
|
||||
parseInspection,
|
||||
dockerIgnoreHasPath,
|
||||
checksSummary,
|
||||
parseServerInventory,
|
||||
inventoryContainerMatch,
|
||||
remoteIdentity,
|
||||
xmlEscape,
|
||||
bash,
|
||||
} = require("../src/main/unraid-deployment-service.cjs");
|
||||
@@ -37,6 +40,97 @@ test("server inspection key-value payload is decoded safely", () => {
|
||||
assert.deepEqual(parsed.existingPreservePaths, ["data", "logs"]);
|
||||
});
|
||||
|
||||
test("server workload inventory links running containers to exact Gitea checkouts", () => {
|
||||
const b64 = (value) => Buffer.from(value).toString("base64");
|
||||
const inspect = JSON.stringify([
|
||||
{
|
||||
Name: "/Portfolio",
|
||||
State: { Running: true, Health: { Status: "healthy" } },
|
||||
Config: {
|
||||
Labels: {
|
||||
"com.docker.compose.project.working_dir":
|
||||
"/mnt/user/appdata/Portfolio",
|
||||
},
|
||||
},
|
||||
Mounts: [],
|
||||
},
|
||||
]);
|
||||
const inventory = parseServerInventory(
|
||||
`noise\n__FORGEFLOW_INVENTORY__\nR\t${b64("/mnt/user/appdata/Portfolio")}\t${b64("git@gitea.itworx.tech:Jens/Portfolio.git")}\t${"a".repeat(40)}\t${b64("main")}\nC\t${b64(inspect)}\n`,
|
||||
);
|
||||
assert.equal(inventory.checkouts.length, 1);
|
||||
assert.equal(inventory.containers.length, 1);
|
||||
assert.equal(
|
||||
remoteIdentity("git@gitea.itworx.tech:Jens/Portfolio.git"),
|
||||
remoteIdentity("https://gitea.itworx.tech/Jens/Portfolio"),
|
||||
);
|
||||
assert.equal(
|
||||
inventoryContainerMatch(
|
||||
inventory.checkouts[0],
|
||||
{ name: "Portfolio" },
|
||||
inventory.containers[0],
|
||||
),
|
||||
100,
|
||||
);
|
||||
});
|
||||
|
||||
test("automatic server discovery adopts and verifies a running Gitea deployment", async () => {
|
||||
const b64 = (value) => Buffer.from(value).toString("base64");
|
||||
const sha = "b".repeat(40);
|
||||
const container = {
|
||||
Name: "/Portfolio",
|
||||
State: { Running: true, Health: { Status: "healthy" } },
|
||||
Config: {
|
||||
Labels: {
|
||||
"com.docker.compose.project.working_dir": "/mnt/user/appdata/Portfolio",
|
||||
"com.docker.compose.service": "portfolio",
|
||||
},
|
||||
},
|
||||
Mounts: [],
|
||||
NetworkSettings: { Ports: { "3000/tcp": [{ HostPort: "8080" }] } },
|
||||
};
|
||||
const profiles = [];
|
||||
const states = new Map();
|
||||
const service = new UnraidDeploymentService({
|
||||
store: {
|
||||
getServer: () => ({
|
||||
id: "unraid",
|
||||
name: "Unraid",
|
||||
basePath: "/mnt/user/appdata",
|
||||
}),
|
||||
getDeploymentProfiles: () => profiles,
|
||||
saveDeploymentProfile: async (_fullName, profile) => {
|
||||
profiles.push(profile);
|
||||
return profile;
|
||||
},
|
||||
saveDeploymentState: async (id, state) => {
|
||||
states.set(id, state);
|
||||
return state;
|
||||
},
|
||||
},
|
||||
ssh: {
|
||||
exec: async () => ({
|
||||
stdout: `__FORGEFLOW_INVENTORY__\nR\t${b64("/mnt/user/appdata/Portfolio")}\t${b64("git@gitea.itworx.tech:Jens/Portfolio.git")}\t${sha}\t${b64("main")}\nC\t${b64(JSON.stringify([container]))}\n`,
|
||||
}),
|
||||
},
|
||||
gitea: { getBranch: async () => ({ commit: { id: sha } }) },
|
||||
});
|
||||
const result = await service.discoverServerWorkloads("unraid", [
|
||||
{
|
||||
fullName: "Jens/Portfolio",
|
||||
name: "Portfolio",
|
||||
defaultBranch: "main",
|
||||
cloneUrl: "https://gitea.itworx.tech/Jens/Portfolio.git",
|
||||
sshUrl: "git@gitea.itworx.tech:Jens/Portfolio.git",
|
||||
},
|
||||
]);
|
||||
assert.equal(result.adopted, 1);
|
||||
assert.equal(result.verified, 1);
|
||||
assert.equal(profiles[0].containerName, "Portfolio");
|
||||
assert.equal(profiles[0].adoptedFromServer, true);
|
||||
assert.equal(states.get(profiles[0].id).matchesGitea, true);
|
||||
});
|
||||
|
||||
test("Docker ignore checks identify exact runtime and Git context exclusions", () => {
|
||||
const rules = "# build context\n.git\ndata/\nlogs/**\n!logs/keep.txt\n";
|
||||
assert.equal(dockerIgnoreHasPath(rules, ".git"), true);
|
||||
|
||||
@@ -336,6 +336,7 @@ 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,
|
||||
@@ -353,7 +354,8 @@ test("packaged updater downloads only a published checksum-matched Windows asset
|
||||
],
|
||||
};
|
||||
},
|
||||
async downloadReleaseAsset(_owner, _repo, assetId) {
|
||||
async downloadReleaseAsset(_owner, _repo, releaseId, assetId) {
|
||||
assert.equal(releaseId, 82);
|
||||
return assetId === 42 ? Buffer.from(`${sha256} ${assetName}\n`) : binary;
|
||||
},
|
||||
};
|
||||
@@ -398,6 +400,7 @@ test("packaged updater rejects a binary whose checksum does not match", async ()
|
||||
gitea: {
|
||||
async getReleaseByTag() {
|
||||
return {
|
||||
id: 83,
|
||||
tag_name: "v0.8.2",
|
||||
assets: [
|
||||
{
|
||||
@@ -413,7 +416,8 @@ test("packaged updater rejects a binary whose checksum does not match", async ()
|
||||
],
|
||||
};
|
||||
},
|
||||
async downloadReleaseAsset(_owner, _repo, assetId) {
|
||||
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