4 Commits
Author SHA1 Message Date
NuklearRabbit f7d6bc374f fix: reconcile server deployments across repository views
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-01 18:57:55 +02:00
NuklearRabbit 958d5b84d3 fix: make Windows updater helper launch reliable
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-01 17:59:55 +02:00
NuklearRabbit 8fa4891075 chore: release ForgeFlow 0.10.5
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-01 17:32:40 +02:00
NuklearRabbit 58d361bbab fix: align deployment links across repository views
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-01 17:05:03 +02:00
20 changed files with 458 additions and 153 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
ForgeFlow is een Windows-desktopapp voor wie Git, Gitea en eigen Docker- of Unraid-servers gebruikt. Je ziet in één werkruimte wat lokaal gewijzigd is, wat op Gitea staat en welke exacte commit op de server draait. ForgeFlow begeleidt je daarna veilig door review, commit, push, deployment en verificatie.
> Huidige release: **0.10.4** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest)
> Huidige release: **0.10.7** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest)
![ForgeFlow release-overzicht](docs/screenshots/overview.png)
+20 -17
View File
@@ -1,4 +1,4 @@
ForgeFlow 0.10.4 source manifest
ForgeFlow 0.10.7 source manifest
SHA-256 BYTES PATH
(The manifest excludes itself, dependencies and generated release artifacts.)
cedceb71eb846d99c7c4019031833c1c7f93b84a1c6073aec7d2435dc744ca3d 703 .gitea/workflows/quality.yml
@@ -37,6 +37,9 @@ f79908fb3dad98c38030c6e6be7c79a1999e0478ed9c2496923891954438daa1 4581
8d713471a437a8a55b00d7e1dd95290680862107bc4e586cf27d727f6274e46c 577 docs/RELEASE_NOTES_0.10.2.md
0942fb2c4a4f972296423b5232687f7389e2c6417a9d48a3244beef9dec907b9 1164 docs/RELEASE_NOTES_0.10.3.md
8f4a0fe6dc250ae210cc2fc1c57c46091822ae6c2a58caa76e0091f255f9f30d 775 docs/RELEASE_NOTES_0.10.4.md
05ed618f5a74a854363930128ca98939808517eedd28b9a508660a0c46e91d97 884 docs/RELEASE_NOTES_0.10.5.md
94bfb2783c1befad1197e1c5e32fc002222c94a28d70d48360a8d53ecd260d5c 772 docs/RELEASE_NOTES_0.10.6.md
226a3b2d4bc7f54841749a283fcdd71b643cd585ba74d673084bee829fef6ea2 903 docs/RELEASE_NOTES_0.10.7.md
a0c00ff76acd1682bb5e0e8dcf6589c9480da436c9c6d30780a1ed58b4dad94f 1770 docs/RELEASE_NOTES_0.2.0.md
5773ead01aa4c522c556295553787482d01b1f5242f053b2c61f120c4de4fa76 5963 docs/RELEASE_NOTES_0.3.0.md
d46de73cf6c4cd5c2ba3f455a7a2af2e0d64ee9d94a97fd1a0bfb44e35c1624a 1093 docs/RELEASE_NOTES_0.3.1.md
@@ -98,16 +101,16 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE
1f0f388df4397e548887bbc7579fd3c864581b86469c01703201ece7a6cbf931 13667 main.cjs
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
58e26cc15df56efc6413b7c45a3dda0997a86758616da9c2479ef859091d475f 179806 package-lock.json
5b51588b7ef64f3a94ecad5fb5e5df4b26640614fdf5d69851875741a17e0c72 5474 package.json
df9841fba56ae74ff14800075d69b92ed24e0aca89085f47b0b3283a9321afc8 179806 package-lock.json
4be2b648fda413f2f188309691d5a68eeabcad4fa6cca52be0c9b448b425c618 5588 package.json
2a597a5704c576783b8a72407fbc377fa7506b36a4596ea7f7bce126e394f837 1326 playwright.config.mjs
69318fdf054be7aa2fe86ead9847da9da65745d8d5de548c8346f3ba0afc4892 12175 preload.cjs
abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd
f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1
688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1
ae6ee934ace29cf16106b40ea669578b4d4f754dae0c06e162820a1d66d9ff0d 10313 README.md
1fa7bf646321e07e40d98f4a7529d5f748c600edd5283f62ee13574b4e97280f 14329 reports/architecture-audit.json
c1ff18f1367691332b189bb7589843a5e0bbde4817e3dd29df4ade7ea71dbd52 1114 reports/architecture-audit.md
e4468143ec620cc5369065f1e51ef378fdcf3de4927f3280119ac01c050c3fc4 10313 README.md
8f8dc5ff0c2967563457e4027d795f6f515253e0a459ecca5a9f5bcf0b658870 14650 reports/architecture-audit.json
5e923f0096895f588fe66b187779cdb7cb5af6644010c6dcc4d55876930ae376 1220 reports/architecture-audit.md
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
8fe616851e6a089a59fd134905465019c33d26f6408ebd0613fff587b6c589e0 4400 scripts/apply-binary-update.ps1
706b49e001837c91134044d3af132d58fb14955245bc1d5c36b413eb65867002 10486 scripts/apply-source-update.ps1
@@ -121,7 +124,7 @@ b83d443f5724ac15393567f3a688aed8315fbe3e5966832c864a9466e0669464 8102
c76507857292c5713e1c699cf02e24b80265da39af2cecd148034bdb874adbb6 5246 scripts/test-authenticode-chain.ps1
4393f7dc5f417e6d601a68238f4e26791799a3634acec228fe4d79deaee85eb5 3109 scripts/validate-installed-connections.cjs
e6127e1e62f39c70ddb1abf72f4d7e7b8e3f19ff1f219e1a3660353c2e0cdfac 2411 scripts/verify-release-signatures.mjs
f32f00dc6740019578e811d9fa1b8c38592394475005a5a98360f94fdac7b81d 18500 scripts/verify.mjs
bc88d892ea1bb43721787fafa04d0fa2684ee8faa03e3a33d7cfc8a7984c56fe 19558 scripts/verify.mjs
0b9f03ba3c67ff7cdb2916a902ad8ce25e81a7c90b210e4ae52d2ad029efabf3 2353 scripts/write-release-checksums.mjs
619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1
dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs
@@ -154,18 +157,18 @@ afef3841a3948b2121f8fba809aae4ea3da71bd2fda86973ba50200a5b1f89b2 14894
2ede80cd1565a7f2c282cc58d35dc0889d58d7465346bc723026b9c8be4df0ac 9501 src/main/unraid-deploy-key-host.cjs
803a079499f7b8148495209dea43b505f6eb9bb587de19e82054e47183186c6e 30461 src/main/unraid-deployment-methods.cjs
2e63fdc0be0bf4d8e5c3d7d45ff5811d786b0821a08b82f511f1301696e12c9d 17157 src/main/unraid-deployment-service.cjs
f0861356c1ff4ca361c7004c1f7d935858f51cc74335a7ad2136021f2e612220 36016 src/main/unraid-inventory-methods.cjs
e998db9bc9a51b5ef182ef23f7e286af8b92c34903fbc7d6211ef1218701cc79 37937 src/main/unraid-inventory-methods.cjs
9e682411f73450f595b5cc4dfb28939c6c58b9547d0a91e287c3efb4955e8840 26299 src/main/unraid-preflight-methods.cjs
d45220176aed72d692f9ae5534f9d40bcc359a2d08e025e74a3b3b505b8b9ed4 16559 src/main/unraid-runtime-methods.cjs
d4b3a07eeca687a49544a94ea574f7c6f7e0bc3aa9311b614d5244a468ca4a1b 11307 src/main/unraid-state-methods.cjs
b654a9e45044ad32c61fabe4a6d897288615ec83739b53e3241ff881e32f56bd 21677 src/main/update-service.cjs
a387ac9735624ef89d964ea59e035dae256b9915ba2cee22ae5cbc2894f5808a 22000 src/main/update-service.cjs
b5c304531bec358d059189a27cd9db8fa20cefb7f817e5eb0287001f7353f6a7 985 src/renderer/actions/command.js
d0bf607dd1de9d55f2947d0adf0997cd3ca5c269d10a5362cc1d8bc4d1a2a8ae 6706 src/renderer/actions/deployment-operation.js
0f2070aa3b5c404aedf643837dfd7c5d547e8f45b2e9c97e4cfcf11951555474 17705 src/renderer/actions/deployment-profile.js
48bed91dd2a85bb51ee7307f7acc3b79c881ce8cf63b22ba79d5d079b265eb4b 7785 src/renderer/actions/inventory.js
4227a05a20580a31127d2c929640defc3d36e8e3e89d6be830aab1940da81082 12267 src/renderer/actions/recovery.js
cdfaacdcd5ae04b0e5c79fefa21f5e09d5c810bcea504c5b6e1d6b744182ff84 15567 src/renderer/actions/setup-and-settings.js
5d8110918b2957889047e38eb4ab2953b2b4476394d9328bd40ae6e4246e65ef 18584 src/renderer/actions/shell.js
a980f2e86d8286ea605a7259b9e9adcf3fda4f657b8d54a5d2d8765a7bbbec13 19065 src/renderer/actions/shell.js
edcfa0585af4b11ec3b7458969132bdf215830505859f1519f1635dc111ce48a 24333 src/renderer/app.js
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png
@@ -175,11 +178,11 @@ edcfa0585af4b11ec3b7458969132bdf215830505859f1519f1635dc111ce48a 24333
eef2f269ba4fbb76bf66ad328d481b461255d0acb753b30878dd4d4eaac57dc6 6924 src/renderer/events.js
a84da5aecbb16ce7983dba1f6d6aab1bf47b2e9a87c2933fa1afb8123f7ef7d6 1497 src/renderer/index.html
06180d9656dd254edfb6949c397f8e313954fc560ddcb22b3a35fce3c3e35655 21350 src/renderer/mock-bridge.js
9fa522dad0088e2981c4ca5c392e93d6c92e04ff23fd1083d9040a3aa52d2b55 28101 src/renderer/mock-deployment-bridge.js
123518a4cfd47b7e510ffab9102a2fd82edc93754aaabd7bc0259d51dfce5d63 20032 src/renderer/mock-repository-bridge.js
5a2c0616a8a0ba23c182a8928abdf40a69612ce34c4cba47b1fd5f21fe5843d4 28617 src/renderer/mock-deployment-bridge.js
87859b1a04d39d1f5a47ce1f3f2e280367d569453f50b546d60e02a1604f216b 20032 src/renderer/mock-repository-bridge.js
94fa265c2fe9ca8d644f0ce9b620b6f85d9b25dca5802c4e9195b66dcbe80120 6522 src/renderer/operations.js
6776e0adb690b8f36274bfc5b31e3140054893c46ba64621c4f56a0d91b52fc2 78323 src/renderer/styles.css
1703e64533b7e2717b27c5776296c7dd76331e6f97e8005aea9fd688f1aee3ae 94834 src/renderer/views.js
1fc4c28859f7283aa2b771a50f1fd26addc8862b97a09250f806930ab4483f7a 79874 src/renderer/styles.css
8d366214f5ce9a87911439632de1c68fe5724e564412f651190c4066f9818fac 101196 src/renderer/views.js
0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs
5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs
029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs
@@ -193,7 +196,7 @@ f8853dce6fdf360d5df2fbe2b6df3e5687630c807fee5ba8436679b34ec737ea 2436
058aeaa5d9bfe377c7e322f213c7871ecc4151b5d08ef790992f4ee28d857658 743 START-FORGEFLOW-OVERLAY.ps1
f5b0ea887fcdeadec78c1ad49b0ec7979723562f5c0b730703acb77a37281ee0 1009 tests/acceptance.test.mjs
a4e5947204ff6878e601e32477bc85b53cd0153baf95a161c8935b6e5466c257 1155 tests/audit-service.test.mjs
2d57a66bb2a6461e4c3266f14998d505ba0582135b0bbcf8fce9f1542a70007a 10479 tests/browser/forgeflow.spec.mjs
809bb7addda0762098289451b4df66bde87f75f730cacc7aa7c56ecae3ff29da 11405 tests/browser/forgeflow.spec.mjs
454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 4318 tests/clone-target.test.mjs
ac17f8bbe9e388b80abef7792c8b184a1fd482c93f13d23a478e433961020f75 17214 tests/config-store.test.mjs
f1463326aee79842d265687ae628189ce54e92544600f2bd14073780287cfb14 2502 tests/configuration-backup.test.mjs
@@ -224,8 +227,8 @@ e631e9ca49a5bac7075860aac2ff4d377a32a78377b70e06ecf833f0f192fd5f 11552
020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs
0d1bc4d623ce299337736c577ec61c8ffd6974ebe20335b72838d10eae35ecb1 7993 tests/ssh-service.test.mjs
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs
4182b61e395aff310b9a964c973a43c3566df0b44c454054c3abdd9459e86e3b 49134 tests/unraid-deployment.test.mjs
074031cac5018aa74c2bb57ecaf114521d69da707aa6170dda65983690bb1691 21487 tests/update-service.test.mjs
eff3cc63d2622a4924e43516b3b00bef61fa62a489995bebaee5c48b11cdf8c9 48945 tests/unraid-deployment.test.mjs
9c58c600d925db6a3ad526e661010c05619e24d61c9d19ec0e207a09ae4cb93a 23981 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
+10
View File
@@ -0,0 +1,10 @@
# ForgeFlow 0.10.5
## Consistent repository and deployment links
- Every repository workspace now shows all configured deployment environments in a compact, directly actionable strip.
- The repository deployment tab includes every detected server workload linked to that repository, including its container, Compose identity, server and runtime state.
- A workload is only labelled linked when its repository and resolved profile both exist in the current ForgeFlow configuration.
- Stale or incomplete metadata is shown as **Link unresolved** and routed through explicit reconciliation instead of being presented as a healthy deployment.
- The global deployment inventory links directly to the correct repository deployment profile.
- Responsive browser coverage now verifies valid links, unresolved links, repository navigation and scrolling across dark/light and scaled layouts.
+9
View File
@@ -0,0 +1,9 @@
# ForgeFlow 0.10.6
## Permanent Windows updater launch repair
- ForgeFlow no longer launches hidden PowerShell update helpers with Node's defective Windows `detached` process mode.
- Binary and source updater processes remain hidden, are explicitly unreferenced after their verified handshake, and continue independently when ForgeFlow closes.
- A real Windows regression test now exercises the exact production Node spawn options instead of using a different process API.
- Startup is still fail-closed: ForgeFlow remains open unless the request-scoped helper status reaches `started`.
- Versions 0.10.4 and 0.10.5 need a one-time direct installation of 0.10.6 because their installed launcher cannot execute its own helper; updates after 0.10.6 use the repaired path.
+10
View File
@@ -0,0 +1,10 @@
# ForgeFlow 0.10.7
## Reliable server-to-repository recognition
- Live, running workloads with one unique exact provenance or runtime-identity match are now linked automatically during normal server discovery.
- Automatic adoption creates only ForgeFlow configuration and observed state; it performs no container changes and never automatically removes stale profiles.
- Ambiguous, duplicate, external and monitoring-only workloads remain behind explicit **Review & link** confirmation.
- Every linked repository now displays an `S` deployment badge with its profile count in the repository sidebar.
- The repository release rail reports **Linked** with container and server identity even when a legacy workload has no verifiable live commit yet.
- DevRunbook-style DockerMan deployments therefore show the same linked relationship in Deployments, the repository sidebar and the repository workspace.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "forgeflow",
"version": "0.10.4",
"version": "0.10.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "forgeflow",
"version": "0.10.4",
"version": "0.10.7",
"dependencies": {
"ssh2": "1.17.0"
},
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "forgeflow",
"version": "0.10.4",
"version": "0.10.7",
"private": true,
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
"main": "main.cjs",
@@ -109,6 +109,9 @@
"docs/RELEASE_NOTES_0.10.2.md",
"docs/RELEASE_NOTES_0.10.3.md",
"docs/RELEASE_NOTES_0.10.4.md",
"docs/RELEASE_NOTES_0.10.5.md",
"docs/RELEASE_NOTES_0.10.6.md",
"docs/RELEASE_NOTES_0.10.7.md",
"docs/CURRENT_STATE.md",
"docs/MUTATION_MODEL.md",
"docs/RELEASING.md",
+106 -90
View File
@@ -1,5 +1,5 @@
{
"generatedAt": "2026-07-29T22:49:48.766Z",
"generatedAt": "2026-08-01T15:23:33.157Z",
"thresholds": {
"preferredMaximumLines": 750,
"justificationRequiredLines": 1000
@@ -9,9 +9,9 @@
"cyclomaticHotspots": [
{
"file": "src/renderer/actions/shell.js",
"lines": 506,
"branches": 98,
"functions": 84,
"lines": 518,
"branches": 101,
"functions": 86,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
@@ -20,50 +20,7 @@
"renderer",
"updates"
],
"hotspotScore": 128
},
{
"file": "src/main/server-inventory.cjs",
"lines": 578,
"branches": 89,
"functions": 104,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 119
},
{
"file": "src/main/git-service.cjs",
"lines": 632,
"branches": 98,
"functions": 109,
"ipcHandlers": 0,
"responsibilities": [
"git"
],
"hotspotScore": 98
}
],
"mixedResponsibilityModules": [
{
"file": "src/renderer/actions/shell.js",
"lines": 506,
"branches": 98,
"functions": 84,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"updates"
],
"hotspotScore": 128
"hotspotScore": 131
},
{
"file": "src/main/server-inventory.cjs",
@@ -82,9 +39,9 @@
},
{
"file": "src/renderer/app.js",
"lines": 667,
"branches": 74,
"functions": 110,
"lines": 676,
"branches": 75,
"functions": 113,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
@@ -94,7 +51,66 @@
"security",
"updates"
],
"hotspotScore": 114
"hotspotScore": 115
},
{
"file": "src/main/git-service.cjs",
"lines": 632,
"branches": 98,
"functions": 109,
"ipcHandlers": 0,
"responsibilities": [
"git"
],
"hotspotScore": 98
}
],
"mixedResponsibilityModules": [
{
"file": "src/renderer/actions/shell.js",
"lines": 518,
"branches": 101,
"functions": 86,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"updates"
],
"hotspotScore": 131
},
{
"file": "src/main/server-inventory.cjs",
"lines": 578,
"branches": 89,
"functions": 104,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 119
},
{
"file": "src/renderer/app.js",
"lines": 676,
"branches": 75,
"functions": 113,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"security",
"updates"
],
"hotspotScore": 115
},
{
"file": "src/main/ipc.cjs",
@@ -130,9 +146,9 @@
},
{
"file": "src/renderer/views.js",
"lines": 693,
"branches": 53,
"functions": 138,
"lines": 737,
"branches": 55,
"functions": 156,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
@@ -142,7 +158,7 @@
"security",
"updates"
],
"hotspotScore": 93
"hotspotScore": 95
},
{
"file": "src/main/unraid-inventory-methods.cjs",
@@ -176,8 +192,8 @@
},
{
"file": "src/main/gitea-service.cjs",
"lines": 618,
"branches": 66,
"lines": 624,
"branches": 67,
"functions": 57,
"ipcHandlers": 0,
"responsibilities": [
@@ -186,7 +202,7 @@
"security",
"updates"
],
"hotspotScore": 86
"hotspotScore": 87
},
{
"file": "src/renderer/actions/deployment-profile.js",
@@ -312,6 +328,35 @@
],
"hotspotScore": 73
},
{
"file": "src/renderer/events.js",
"lines": 181,
"branches": 35,
"functions": 27,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"security"
],
"hotspotScore": 65
},
{
"file": "src/main/git-validator-service.cjs",
"lines": 600,
"branches": 43,
"functions": 77,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 63
},
{
"file": "src/main/ssh-service.cjs",
"lines": 333,
@@ -341,35 +386,6 @@
],
"hotspotScore": 61
},
{
"file": "src/renderer/events.js",
"lines": 172,
"branches": 31,
"functions": 26,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"security"
],
"hotspotScore": 61
},
{
"file": "src/main/git-validator-service.cjs",
"lines": 588,
"branches": 40,
"functions": 74,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 60
},
{
"file": "src/main/unraid-deployment-service.cjs",
"lines": 525,
@@ -477,7 +493,7 @@
},
{
"file": "src/renderer/mock-deployment-bridge.js",
"lines": 679,
"lines": 699,
"branches": 11,
"functions": 87,
"ipcHandlers": 0,
@@ -686,7 +702,7 @@
},
{
"file": "src/main/ipc/repository-handlers.cjs",
"lines": 411,
"lines": 413,
"branches": 16,
"functions": 79,
"ipcHandlers": 51,
+3 -2
View File
@@ -1,6 +1,6 @@
# ForgeFlow architecture audit
Generated 2026-07-29T22:49:48.766Z. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity.
Generated 2026-08-01T15:23:33.157Z. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity.
## Files above 750 lines
@@ -18,8 +18,9 @@ No findings.
| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |
|---|---:|---:|---:|---:|---|
| `src/renderer/actions/shell.js` | 506 | 98 | 84 | 0 | inventory, deployment, git, renderer, updates |
| `src/renderer/actions/shell.js` | 518 | 101 | 86 | 0 | inventory, deployment, git, renderer, updates |
| `src/main/server-inventory.cjs` | 578 | 89 | 104 | 0 | inventory, deployment, git, security, updates |
| `src/renderer/app.js` | 676 | 75 | 113 | 0 | inventory, deployment, git, renderer, security, updates |
| `src/main/git-service.cjs` | 632 | 98 | 109 | 0 | git |
## Interpretation
+17 -2
View File
@@ -84,6 +84,9 @@ const required = [
"docs/RELEASE_NOTES_0.10.2.md",
"docs/RELEASE_NOTES_0.10.3.md",
"docs/RELEASE_NOTES_0.10.4.md",
"docs/RELEASE_NOTES_0.10.5.md",
"docs/RELEASE_NOTES_0.10.6.md",
"docs/RELEASE_NOTES_0.10.7.md",
"docs/UPDATING.md",
"docs/DIAGNOSTICS.md",
"docs/DEPLOYMENT_SETUP.md",
@@ -122,9 +125,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.10.4")
if (packageJson.version !== "0.10.7")
throw new Error(
`Expected package version 0.10.4, got ${packageJson.version}.`,
`Expected package version 0.10.7, got ${packageJson.version}.`,
);
const sourceManifest = await readFile(
path.join(root, "SOURCE_MANIFEST.txt"),
@@ -454,6 +457,18 @@ const release0104 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.4.md
for (const phrase of ["Windows PowerShell 5.1", "atomic status", "handshake-only", "existing installations"]) {
if (!release0104.includes(phrase)) throw new Error(`0.10.4 release notes are missing: ${phrase}`);
}
const release0105 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.5.md"), "utf8");
for (const phrase of ["repository workspace", "resolved profile", "Link unresolved", "reconciliation", "server workload"]) {
if (!release0105.includes(phrase)) throw new Error(`0.10.5 release notes are missing: ${phrase}`);
}
const release0106 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.6.md"), "utf8");
for (const phrase of ["detached", "PowerShell", "production Node spawn", "source updater", "one-time direct installation"]) {
if (!release0106.includes(phrase)) throw new Error(`0.10.6 release notes are missing: ${phrase}`);
}
const release0107 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.7.md"), "utf8");
for (const phrase of ["exact provenance", "automatic", "repository sidebar", "DevRunbook", "no container changes"]) {
if (!release0107.includes(phrase)) throw new Error(`0.10.7 release notes are missing: ${phrase}`);
}
const configSource = await readFile(path.join(root, "src/main/config-store.cjs"), "utf8");
for (const mode of ["server-git", "push-bundle", "monitor-only"]) {
if (!configSource.includes(mode)) throw new Error(`Deployment configuration is missing mode: ${mode}`);
+31 -4
View File
@@ -384,15 +384,41 @@ function createUnraidInventoryMethods({
};
}
async scanServerInventory(serverId, repositories) {
async scanServerInventory(serverId, repositories, { autoLink = false } = {}) {
const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories);
const response = this.inventoryResponse(server, inventory, workloads);
let adopted = 0;
const adoptedLinks = [];
if (autoLink) {
const plan = this.reconciliationPlan(server, workloads, repositories, { autoLink: true });
if (plan.additions.length) await this.store.createRecoverySnapshot?.(`automatic-server-links-${serverId}`);
const linkedRepositories = new Set(workloads
.filter((workload) => workload.link?.repositoryFullName)
.map((workload) => String(workload.link.repositoryFullName).toLowerCase()));
for (const addition of plan.additions) {
const workload = workloads.find((item) => item.workloadId === addition.workloadId);
const repository = (repositories || []).find((item) => String(item.fullName).toLowerCase() === String(addition.repositoryFullName).toLowerCase());
const key = String(repository?.fullName || "").toLowerCase();
if (!workload || !repository || linkedRepositories.has(key)) continue;
const linkSource = addition.evidence === "exact-provenance" ? "automatic" : "automatic-runtime-identity";
const profile = this.profileFromWorkload(repository, server, workload, { linkSource, deploymentMode: "server-git" });
const saved = await this.store.saveDeploymentProfile(repository.fullName, profile);
await this.saveWorkloadState(saved, workload, server);
workload.status = "linked";
workload.link = { status: "linked", profileId: saved.id, repositoryFullName: repository.fullName, source: linkSource };
linkedRepositories.add(key);
adopted += 1;
adoptedLinks.push({ repositoryFullName: repository.fullName, profileId: saved.id, workloadId: workload.workloadId });
}
}
const response = this.inventoryResponse(server, inventory, workloads, { adopted });
await this.diagnostics?.info("unraid.workloads.scanned", {
serverId,
detected: response.detected,
linked: response.linked,
needsReview: response.needsReview,
readOnly: true,
adopted,
adoptedLinks,
readOnly: !autoLink,
});
return response;
}
@@ -428,6 +454,7 @@ function createUnraidInventoryMethods({
evidence: candidate.exact ? "exact-provenance" : "exact-runtime-identity",
impact: "Create a server-pull deployment profile; no container changes",
});
linkedRepositories.add(String(candidate.repositoryFullName).toLowerCase());
} else if (["suggested", "ambiguous"].includes(workload.status) || (workload.runtime?.running && workload.candidates?.length)) {
conflicts.push({
workloadId: workload.workloadId,
@@ -559,7 +586,7 @@ function createUnraidInventoryMethods({
}
async discoverServerWorkloads(serverId, repositories) {
return this.scanServerInventory(serverId, repositories);
return this.scanServerInventory(serverId, repositories, { autoLink: true });
}
async linkServerWorkload({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) {
+23 -12
View File
@@ -33,6 +33,18 @@ function resolveWindowsPowerShellPath(environment = process.env) {
return "powershell.exe";
}
function windowsUpdaterSpawnOptions(cwd) {
return {
// A detached hidden PowerShell child can exit successfully on Windows
// without ever executing its -File script. Normal Windows children survive
// their parent; unref() below releases the event-loop reference instead.
detached: false,
stdio: "ignore",
windowsHide: true,
cwd,
};
}
async function readJsonFile(filePath) {
try {
return JSON.parse(await fs.readFile(filePath, "utf8"));
@@ -450,12 +462,11 @@ class UpdateService {
const childState = { exited: false, code: null, error: null };
let child;
try {
child = this.spawnProcess(executable, args, {
detached: true,
stdio: "ignore",
windowsHide: true,
cwd: this.sourcePath,
});
child = this.spawnProcess(
executable,
args,
windowsUpdaterSpawnOptions(this.sourcePath),
);
} catch (error) {
error.code ||= "UPDATE_HELPER_SPAWN_FAILED";
throw error;
@@ -591,12 +602,11 @@ class UpdateService {
"-UpdateId",
updateId,
];
const child = this.spawnProcess(executable, args, {
detached: true,
stdio: "ignore",
windowsHide: true,
cwd: this.updateDirectory,
});
const child = this.spawnProcess(
executable,
args,
windowsUpdaterSpawnOptions(this.updateDirectory),
);
const childState = { exited: false, code: null, error: null };
child.once?.("error", (error) => {
childState.error = error;
@@ -700,6 +710,7 @@ module.exports = {
UpdateService,
safeRepositoryPart,
resolveWindowsPowerShellPath,
windowsUpdaterSpawnOptions,
waitForUpdaterStarted,
readJsonFile,
readLogTail,
+12
View File
@@ -9,6 +9,18 @@ async function handleShellActions(event, target, action, repository) {
setLoading(false);
}
} else if (action === "select-repo") selectRepository(target.dataset.id);
else if (action === "open-deployment-link") {
if (!repository) return true;
selectRepository(repository.id, false);
ui.selectedProfileId = target.dataset.profileId || selectedProfile(repository)?.id || null;
ui.repositoryTab = "deployments";
ui.currentView = "repository";
render();
} else if (action === "select-deployment-profile") {
ui.selectedProfileId = target.dataset.profileId || null;
ui.repositoryTab = "deployments";
render();
}
else if (action === "refresh") {
await refreshRepositories(true);
await refreshActiveOperations(false);
+17 -4
View File
@@ -238,13 +238,13 @@ function createMockDeploymentBridge(context) {
{
serverId: "server-unraid",
serverName: "Unraid",
detected: 2,
detected: 3,
adopted: 0,
verified: 1,
linked: 1,
linked: 2,
unmatched: 0,
needsReview: 1,
running: 2,
needsReview: 2,
running: 3,
stopped: 0,
capabilities: {
docker: true,
@@ -296,6 +296,19 @@ function createMockDeploymentBridge(context) {
reasons: ["container and repository names are similar"],
})),
},
{
workloadId: "workload-demo-unresolved",
displayName: "Legacy Worker",
status: "linked",
runtime: { running: true, health: "healthy" },
containers: [{ name: "legacy-worker", running: true }],
candidates: [],
link: {
profileId: "profile-that-no-longer-exists",
repositoryFullName: "jens/removed-repository",
source: "manual",
},
},
],
},
];
+1 -1
View File
@@ -5,7 +5,7 @@ function createMockRepositoryBridge(context) {
await wait(80);
snapshot();
return {
appVersion: "0.10.4-demo",
appVersion: "0.10.7-demo",
platform: "win32",
state: clone(state),
git: { available: true, version: "git version 2.47.3" },
+58
View File
@@ -849,6 +849,64 @@ select:focus-visible {
.release-node:last-child {
border-right: 0;
}
.repository-deployment-summary {
display: flex;
align-items: center;
gap: 10px;
min-height: 48px;
padding: 7px 12px;
border-bottom: 1px solid var(--line);
background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 7%, var(--surface-1)), var(--surface-1) 42%);
}
.repository-deployment-summary-label {
display: inline-flex;
align-items: center;
gap: 6px;
flex: 0 0 auto;
color: var(--text-muted);
font-size: 10px;
font-weight: 760;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.repository-deployment-summary-label svg {
width: 14px;
height: 14px;
color: var(--accent);
}
.repository-deployment-chips {
display: flex;
gap: 7px;
min-width: 0;
flex: 1;
overflow-x: auto;
scrollbar-width: thin;
}
.repository-deployment-chip {
display: inline-flex;
align-items: center;
gap: 6px;
flex: 0 0 auto;
max-width: 280px;
padding: 6px 9px;
border: 1px solid var(--line);
border-radius: 8px;
background: color-mix(in srgb, var(--surface-2) 88%, transparent);
color: var(--text);
cursor: pointer;
}
.repository-deployment-chip:hover {
border-color: color-mix(in srgb, var(--accent) 48%, var(--line));
background: color-mix(in srgb, var(--accent) 10%, var(--surface-2));
}
.repository-deployment-chip strong,
.repository-deployment-chip span:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.repository-deployment-chip strong { font-size: 11px; }
.repository-deployment-chip span:last-child { color: var(--text-muted); font-size: 10px; }
.release-node:not(:last-child)::after {
content: "";
position: absolute;
+61 -7
View File
@@ -31,6 +31,9 @@ function renderTitlebar() {
function renderRepositoryRow(repository) {
const status = repository.localStatus;
const profiles = repository.deploymentProfiles || [];
const workloads = linkedWorkloadsForRepository(repository);
const runningWorkloads = workloads.filter((workload) => workload.runtime?.running);
const badges = [];
if (status?.counts.conflicts)
badges.push('<span class="mini-badge danger" title="Conflicts">!</span>');
@@ -50,10 +53,14 @@ function renderRepositoryRow(repository) {
badges.push(
'<span class="mini-badge success" title="Ready to deploy">↗</span>',
);
if (profiles.length)
badges.push(
`<span class="mini-badge ${runningWorkloads.length ? "success" : "warning"} deployment-badge" title="${attr(`${profiles.length} server deployment${profiles.length === 1 ? "" : "s"} linked${runningWorkloads.length ? ` · ${runningWorkloads.length} running` : ""}`)}">S${profiles.length}</span>`,
);
if (!repository.localPath)
badges.push('<span class="mini-badge" title="No local folder">—</span>');
const branch = status?.branch.head || repository.defaultBranch || "remote";
return `<button class="repo-row ${String(repository.id) === String(ui.selectedRepoId) ? "active" : ""} ${repository.attention ? "attention" : ""}" data-action="select-repo" data-id="${attr(repository.id)}">
return `<button class="repo-row ${String(repository.id) === String(ui.selectedRepoId) ? "active" : ""} ${repository.attention ? "attention" : ""}" data-action="select-repo" data-id="${attr(repository.id)}" data-deployment-count="${profiles.length}">
<span class="repo-icon">${repository.favorite ? icon("star") : icon(repository.localPath ? "git" : "cloud")}</span>
<span class="repo-main"><span class="repo-name">${escapeHtml(repository.name)}</span><span class="repo-sub"><span>${escapeHtml(branch)}</span>${status?.shortHead ? `<span> ${escapeHtml(status.shortHead)}</span>` : ""}</span></span>
<span class="repo-badges">${badges.join("")}</span>
@@ -188,6 +195,16 @@ function releaseNode(label, value, description, tone = "") {
return `<div class="release-node"><div class="release-label">${label}</div><div class="release-value"><span class="state-dot ${tone}"></span><strong>${escapeHtml(value)}</strong><span>${escapeHtml(description)}</span></div></div>`;
}
function linkedWorkloadsForRepository(repository) {
const fullName = String(repository?.fullName || "").toLowerCase();
if (!fullName) return [];
return (ui.serverDiscovery || []).flatMap((server) =>
(server.workloads || [])
.filter((workload) => String(workload.link?.repositoryFullName || "").toLowerCase() === fullName)
.map((workload) => ({ ...workload, serverId: server.serverId, serverName: server.serverName || server.server?.name || "Server" })),
);
}
function diffAtmosphere(diff) {
if (!ui.selectedFile) return "";
const lines = String(diff || "").split("\n");
@@ -359,8 +376,15 @@ function renderProfileCard(repository, profile, compact = false) {
}
function renderRepositoryDeployments(repository) {
const profiles = repository.deploymentProfiles || [];
const workloads = linkedWorkloadsForRepository(repository);
const profileIds = new Set(profiles.map((profile) => profile.id));
const workloadRows = workloads.map((workload) => {
const containers = (workload.containers || []).map((container) => container.name).filter(Boolean);
const profileResolved = Boolean(workload.link?.profileId && profileIds.has(workload.link.profileId));
return `<div class="tool-row repository-workload-row"><div><strong>${escapeHtml(workload.displayName || containers[0] || "Server workload")}</strong><span>${escapeHtml(workload.serverName)} · ${containers.length ? escapeHtml(containers.join(", ")) : "container identity unavailable"} · ${workload.runtime?.running ? "running" : "stopped"}</span><span>${escapeHtml(workload.compose?.project ? `Compose ${workload.compose.project}` : workload.remoteFolderCandidate || "Docker workload")}</span></div><div class="stack horizontal compact"><span class="status-pill ${profileResolved ? "success" : "danger"}">${profileResolved ? "Repository linked" : "Link needs reconciliation"}</span>${profileResolved ? `<button class="button ghost" data-action="select-deployment-profile" data-profile-id="${attr(workload.link.profileId)}">Open profile</button>` : `<button class="button" data-action="navigate" data-view="deployments">Review inventory</button>`}</div></div>`;
}).join("");
const repoOps = repositoryOperations(repository).slice(0, 10);
return `<div class="tab-page"><div class="section-heading"><div><h2>Deployment environments</h2><span class="meta">Exact-commit Gitea Actions or pinned SSH / Unraid deployments</span></div><button class="button primary" data-action="configure-deployment">${icon("plus")}Add environment</button></div>${profiles.length ? `<div class="deploy-card-grid">${profiles.map((profile) => renderProfileCard(repository, profile)).join("")}</div>` : '<div class="empty-state panel"><div class="empty-icon">↗</div><h3>No deployment profile</h3><p>Connect a Gitea Actions workflow or a trusted SSH / Unraid server.</p><button class="button primary" data-action="configure-deployment">Configure deployment</button></div>'}<section class="section-block"><div class="section-heading"><h2>Release history</h2></div><div class="panel">${repoOps.length ? `<table class="data-table"><thead><tr><th>Action</th><th>Environment</th><th>Commit</th><th>Status</th><th>Updated</th><th></th></tr></thead><tbody>${repoOps.map((operation) => `<tr><td>${escapeHtml(operation.action || "deploy")}</td><td>${escapeHtml(operation.environment)}</td><td class="mono">${escapeHtml(operation.shortSha || shortSha(operation.sha))}</td><td><span class="status-pill ${toneForStatus(operation.status)}">${escapeHtml(operation.status)}</span></td><td>${formatDate(operation.updatedAt || operation.createdAt)}</td><td><button class="button ghost" data-action="open-operation" data-operation-id="${attr(operation.id)}">Open</button></td></tr>`).join("")}</tbody></table>` : '<div class="empty-state compact"><p>No releases for this repository yet.</p></div>'}</div></section></div>`;
return `<div class="tab-page"><div class="section-heading"><div><h2>Deployment environments</h2><span class="meta">${profiles.length} configured profile${profiles.length === 1 ? "" : "s"} · ${workloads.length} server workload${workloads.length === 1 ? "" : "s"} linked to this repository</span></div><button class="button primary" data-action="configure-deployment">${icon("plus")}Add environment</button></div>${workloads.length ? `<section class="panel repository-workloads"><div class="panel-header"><div><h3>Detected on server</h3><span class="meta">Live Docker / Compose identities resolved back to this repository</span></div></div><div class="panel-body"><div class="tool-list">${workloadRows}</div></div></section>` : ""}${profiles.length ? `<div class="deploy-card-grid">${profiles.map((profile) => renderProfileCard(repository, profile)).join("")}</div>` : '<div class="empty-state panel"><div class="empty-icon">↗</div><h3>No deployment profile</h3><p>Connect a Gitea Actions workflow or a trusted SSH / Unraid server.</p><button class="button primary" data-action="configure-deployment">Configure deployment</button></div>'}<section class="section-block"><div class="section-heading"><h2>Release history</h2></div><div class="panel">${repoOps.length ? `<table class="data-table"><thead><tr><th>Action</th><th>Environment</th><th>Commit</th><th>Status</th><th>Updated</th><th></th></tr></thead><tbody>${repoOps.map((operation) => `<tr><td>${escapeHtml(operation.action || "deploy")}</td><td>${escapeHtml(operation.environment)}</td><td class="mono">${escapeHtml(operation.shortSha || shortSha(operation.sha))}</td><td><span class="status-pill ${toneForStatus(operation.status)}">${escapeHtml(operation.status)}</span></td><td>${formatDate(operation.updatedAt || operation.createdAt)}</td><td><button class="button ghost" data-action="open-operation" data-operation-id="${attr(operation.id)}">Open</button></td></tr>`).join("")}</tbody></table>` : '<div class="empty-state compact"><p>No releases for this repository yet.</p></div>'}</div></section></div>`;
}
function renderGitTools(repository) {
@@ -412,7 +436,10 @@ function renderGitValidator(repository) {
function renderRepositoryWorkspace(repository) {
const status = repository.localStatus;
const profiles = repository.deploymentProfiles || [];
const linkedWorkloads = linkedWorkloadsForRepository(repository);
const profile = selectedProfile(repository);
const profileWorkload = linkedWorkloads.find((workload) => workload.link?.profileId === profile?.id);
const serverState = profile?.state || {};
const localTone = status?.counts.conflicts
? "danger"
@@ -433,6 +460,8 @@ function renderRepositoryWorkspace(repository) {
? "danger"
: serverState.healthy === true
? "success"
: profileWorkload?.runtime?.running
? "success"
: "";
const content = (
{
@@ -444,9 +473,19 @@ function renderRepositoryWorkspace(repository) {
settings: renderRepositorySettings,
}[ui.repositoryTab] || renderChanges
)(repository);
const deploymentLinks = profiles.length
? `<div class="repository-deployment-summary"><span class="repository-deployment-summary-label">${icon("server")}Linked deployments</span><div class="repository-deployment-chips">${profiles.map((item) => {
const workload = linkedWorkloads.find((candidate) => candidate.link?.profileId === item.id);
const itemState = item.state || {};
const tone = itemState.healthy === false ? "danger" : itemState.healthy === true ? "success" : workload?.runtime?.running ? "success" : "warning";
const identity = workload?.displayName || item.containerName || item.remoteFolder || item.environment;
return `<button class="repository-deployment-chip" data-action="select-deployment-profile" data-profile-id="${attr(item.id)}"><span class="state-dot ${tone}"></span><strong>${escapeHtml(identity)}</strong><span>${escapeHtml(item.environment)}${workload?.serverName ? ` · ${escapeHtml(workload.serverName)}` : ""}</span></button>`;
}).join("")}</div><button class="button ghost" data-action="select-deployment-profile" data-profile-id="${attr(profile?.id || profiles[0].id)}">View all</button></div>`
: "";
return `<div class="repo-workspace"><header class="repo-header illustrated-repo-header"><div class="repo-heading"><h1><button class="favorite-button ${repository.favorite ? "active" : ""}" data-action="toggle-favorite" title="Toggle favorite">${icon("star")}</button>${escapeHtml(repository.fullName)}</h1><p>${escapeHtml(repository.localPath || "No local working tree linked")}</p></div>${projectIllustration("repo")}<div class="repo-header-actions"><button class="button" data-action="fetch" ${!repository.localPath ? "disabled" : ""}>${icon("refresh")}Fetch</button><button class="button" data-action="open-path" ${!repository.localPath ? "disabled" : ""}>${icon("folder")}Folder</button><button class="button" data-action="open-gitea" ${!repository.htmlUrl ? "disabled" : ""}>${icon("external")}Gitea</button></div></header>
${repository.localPath ? `<div class="repo-quick-actions"><button class="button" data-action="open-editor">${icon("external")}Open in editor</button><button class="button" data-action="open-terminal">${icon("terminal")}Open terminal</button><button class="button" data-action="check-branch-protection">${icon("shield")}Check branch protection</button><button class="button primary" data-action="open-pull-request">${icon("git")}Create pull request</button>${ui.branchProtection ? `<span class="status-pill ${ui.branchProtection.protected ? "warning" : "success"}">${ui.branchProtection.protected ? `Protected · ${ui.branchProtection.requiredApprovals || 0} approval(s)` : "Direct pushes allowed"}</span>` : ""}</div>` : ""}
<div class="release-rail">${releaseNode("Local", status?.shortHead || "Not linked", status ? `${status.counts.changed} changes · ${status.branch.head}` : "No working tree", localTone)}${releaseNode("Gitea", status?.shortHead || "Unknown", status?.branch.upstream ? `${status.branch.ahead} ahead · ${status.branch.behind} behind` : "Branch not published", remoteTone)}${releaseNode(`Server${profile ? ` · ${profile.environment}` : ""}`, serverState.liveSha ? shortSha(serverState.liveSha) : "Unknown", profile ? (serverState.checkedAt ? `checked ${formatDate(serverState.checkedAt)}` : "not checked") : "No deployment profile", serverTone)}</div>
<div class="release-rail">${releaseNode("Local", status?.shortHead || "Not linked", status ? `${status.counts.changed} changes · ${status.branch.head}` : "No working tree", localTone)}${releaseNode("Gitea", status?.shortHead || "Unknown", status?.branch.upstream ? `${status.branch.ahead} ahead · ${status.branch.behind} behind` : "Branch not published", remoteTone)}${releaseNode(`Server${profile ? ` · ${profile.environment}` : ""}`, serverState.liveSha ? shortSha(serverState.liveSha) : profile ? "Linked" : "Unknown", profileWorkload ? `${profileWorkload.displayName || profile.containerName || "Container"} · ${profileWorkload.runtime?.running ? "running" : "stopped"} on ${profileWorkload.serverName}` : profile ? (serverState.checkedAt ? `checked ${formatDate(serverState.checkedAt)}` : "profile linked · awaiting live scan") : "No deployment profile", serverTone)}</div>
${deploymentLinks}
<nav class="tabs">${[
["changes", "Changes"],
["history", "History"],
@@ -531,9 +570,13 @@ function renderServerInventory() {
? visibleWorkloads.map((workload) => {
const containers = (workload.containers || []).map((container) => container.name).filter(Boolean).join(", ");
const topCandidate = workload.candidates?.[0];
const linked = (workload.status === "linked" || Boolean(workload.link)) && workload.classification?.type !== "stale-link";
const linkedRepository = ui.repositories.find((repository) => String(repository.fullName).toLowerCase() === String(workload.link?.repositoryFullName || "").toLowerCase());
const linkedProfile = linkedRepository?.deploymentProfiles?.find((profile) => profile.id === workload.link?.profileId);
const claimsLink = workload.status === "linked" || Boolean(workload.link);
const linked = Boolean(claimsLink && linkedRepository && linkedProfile) && workload.classification?.type !== "stale-link";
const inconsistentLink = claimsLink && !linked;
const classification = workload.classification?.type || workload.status || "review";
const statusTone = linked && !workload.reviewDecisionStale ? "success" : ["ambiguous", "duplicate", "orphan-container"].includes(classification) || workload.reviewDecisionStale ? "danger" : "warning";
const statusTone = linked && !workload.reviewDecisionStale ? "success" : inconsistentLink || ["ambiguous", "duplicate", "orphan-container"].includes(classification) || workload.reviewDecisionStale ? "danger" : "warning";
const detail = workload.compose?.project
? `Compose ${workload.compose.project} · ${(workload.compose.services || []).join(", ") || "services unknown"}`
: workload.dockerMan?.templatePath
@@ -541,6 +584,8 @@ function renderServerInventory() {
: `Container installation · ${containers || "unnamed"}`;
const candidate = linked
? `Linked to ${workload.link?.repositoryFullName || "repository"}`
: inconsistentLink
? `Stored link cannot be resolved to a loaded repository profile`
: topCandidate
? `${topCandidate.repositoryFullName} suggested · ${topCandidate.confidence || topCandidate.status || "review required"}`
: "No repository candidate; select one manually";
@@ -549,10 +594,19 @@ function renderServerInventory() {
? `<button class="button primary" data-action="quick-link-server-workload" data-server-id="${attr(server.serverId)}" data-workload-id="${attr(workload.workloadId)}" data-repository="${attr(topCandidate.repositoryFullName)}">${icon("link")}Link to ${escapeHtml(topCandidate.repositoryName || topCandidate.repositoryFullName)}</button>`
: `<button class="button primary" data-action="link-server-workload" data-server-id="${attr(server.serverId)}" data-workload-id="${attr(workload.workloadId)}">${icon("link")}Review & link</button>`;
const evidenceNote = workload.reviewDecisionStale ? "Saved decision is stale because server evidence changed" : workload.classification?.reason || "Awaiting review";
return `<div class="tool-row"><div><strong>${escapeHtml(workload.displayName)}</strong><span>${escapeHtml(detail)} · ${workload.runtime?.running ? "running" : "stopped"}</span><span>${escapeHtml(candidate)}</span><span class="${workload.reviewDecisionStale ? "text-warning" : "meta"}">${escapeHtml(evidenceNote)}</span>${workload.metadata?.composeDefinitionError ? `<span class="text-warning">Compose file found; validation warning: ${escapeHtml(workload.metadata.composeDefinitionError)}</span>` : ""}</div><div class="stack horizontal compact"><span class="status-pill ${statusTone}">${escapeHtml(workload.reviewDecisionStale ? "Decision stale" : linked ? "Linked" : classification)}</span>${linked ? `<button class="button ghost" data-action="edit-deployment-profile" data-profile-id="${attr(workload.link?.profileId || "")}">Open link</button>` : linkButton}</div></div>`;
return `<div class="tool-row"><div><strong>${escapeHtml(workload.displayName)}</strong><span>${escapeHtml(detail)} · ${workload.runtime?.running ? "running" : "stopped"}</span><span>${escapeHtml(candidate)}</span><span class="${workload.reviewDecisionStale || inconsistentLink ? "text-warning" : "meta"}">${escapeHtml(inconsistentLink ? "Reconcile this inventory link before deployment" : evidenceNote)}</span>${workload.metadata?.composeDefinitionError ? `<span class="text-warning">Compose file found; validation warning: ${escapeHtml(workload.metadata.composeDefinitionError)}</span>` : ""}</div><div class="stack horizontal compact"><span class="status-pill ${statusTone}">${escapeHtml(workload.reviewDecisionStale ? "Decision stale" : linked ? "Linked" : inconsistentLink ? "Link unresolved" : classification)}</span>${linked ? `<button class="button ghost" data-action="open-deployment-link" data-repository-id="${attr(linkedRepository.id)}" data-profile-id="${attr(linkedProfile.id)}">Open in repository</button>` : inconsistentLink ? `<button class="button" data-action="plan-server-reconciliation" data-server-id="${attr(server.serverId)}">Reconcile</button>` : linkButton}</div></div>`;
}).join("")
: `<div class="empty-state compact"><p>${server.error ? "No inventory could be read until the SSH connection works." : "Docker returned no containers, Compose projects or DockerMan templates."}</p></div>`;
return `<section class="panel server-inventory-panel"><div class="panel-header"><div><h3>${escapeHtml(server.serverName || server.server?.name || server.serverId)}</h3><span class="meta">${server.running || 0} running · ${server.linked || 0} repository links · ${visibleWorkloads.filter((workload) => !workload.link).length} to review${hiddenCount ? ` · ${hiddenCount} unrelated/system workloads hidden` : ""}</span></div><div class="stack horizontal compact"><span class="status-pill ${server.error ? "danger" : capabilities.docker && capabilities.compose ? "success" : "warning"}">${server.error ? "Scan failed" : escapeHtml(capabilityText)}</span>${server.error ? "" : `<button class="button" data-action="plan-server-reconciliation" data-server-id="${attr(server.serverId)}">${icon("shield")}Review reconciliation</button>`}</div></div><div class="panel-body">${errorBlock}${warnings}<div class="tool-list">${workloads}</div></div></section>`;
const resolvedLinks = visibleWorkloads.filter((workload) => {
const repository = ui.repositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link?.repositoryFullName || "").toLowerCase());
return repository?.deploymentProfiles?.some((profile) => profile.id === workload.link?.profileId);
}).length;
const unresolvedLinks = visibleWorkloads.filter((workload) => {
if (!(workload.status === "linked" || workload.link)) return false;
const repository = ui.repositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link?.repositoryFullName || "").toLowerCase());
return !repository?.deploymentProfiles?.some((profile) => profile.id === workload.link?.profileId);
}).length;
return `<section class="panel server-inventory-panel"><div class="panel-header"><div><h3>${escapeHtml(server.serverName || server.server?.name || server.serverId)}</h3><span class="meta">${server.running || 0} running · ${resolvedLinks} visible repository links${unresolvedLinks ? ` · ${unresolvedLinks} unresolved` : ""} · ${visibleWorkloads.filter((workload) => !workload.link).length} to review${hiddenCount ? ` · ${hiddenCount} unrelated/system workloads hidden` : ""}</span></div><div class="stack horizontal compact"><span class="status-pill ${server.error ? "danger" : capabilities.docker && capabilities.compose ? "success" : "warning"}">${server.error ? "Scan failed" : escapeHtml(capabilityText)}</span>${server.error ? "" : `<button class="button" data-action="plan-server-reconciliation" data-server-id="${attr(server.serverId)}">${icon("shield")}Review reconciliation</button>`}</div></div><div class="panel-body">${errorBlock}${warnings}<div class="tool-list">${workloads}</div></div></section>`;
}).join("");
const empty = configuredServers.length
? `<div class="empty-state panel"><h3>Server inventory has not completed</h3><p>ForgeFlow will query Docker directly. A failed connection is shown explicitly instead of being reported as zero deployments.</p><button class="button primary" data-action="scan-server-inventory">Scan servers now</button></div>`
+13
View File
@@ -135,6 +135,19 @@ test("deployment inventory supports dense workloads without ambiguous blank card
for (let index = 0; index < Math.min(count, 25); index += 1) {
await expect(cards.nth(index)).not.toHaveText(/^\s*$/);
}
const unresolved = page.locator(".tool-row", { hasText: "Legacy Worker" });
await expect(unresolved).toContainText("Link unresolved");
await expect(unresolved).not.toContainText(/^Linked$/);
await expect(page.locator(".server-inventory-panel").first()).toContainText("1 unresolved");
const repositoryLink = page.locator('[data-action="open-deployment-link"]');
if (await repositoryLink.count()) {
await repositoryLink.first().click();
await expect(page.locator('.repo-row.active')).toHaveAttribute("data-deployment-count", /^[1-9]/);
await expect(page.locator('.repo-row.active .deployment-badge')).toBeVisible();
await expect(page.locator('.tab[data-action="repo-tab"][data-tab="deployments"]')).toHaveClass(/active/);
await expect(page.locator(".repository-workloads")).toBeVisible();
await expect(page.locator(".repository-workload-row").first()).toContainText("Repository linked");
}
await assertSurface(page);
});
+7 -9
View File
@@ -168,7 +168,7 @@ test("server workload inventory links running containers to exact Gitea checkout
);
});
test("server discovery is read-only and explicit reconciliation adopts a verified deployment", async () => {
test("low-level inventory scan is read-only and user discovery auto-links exact provenance", async () => {
const b64 = (value) => Buffer.from(value).toString("base64");
const sha = "b".repeat(40);
const container = {
@@ -218,17 +218,15 @@ test("server discovery is read-only and explicit reconciliation adopts a verifie
sshUrl: "git@gitea.itworx.tech:Jens/Portfolio.git",
},
];
const discovery = await service.discoverServerWorkloads("unraid", repositories);
assert.equal(discovery.adopted, 0);
assert.equal(discovery.verified, 0);
const readOnlyDiscovery = await service.scanServerInventory("unraid", repositories);
assert.equal(readOnlyDiscovery.adopted, 0);
assert.equal(readOnlyDiscovery.verified, 0);
assert.equal(profiles.length, 0);
assert.equal(states.size, 0);
const preview = await service.planServerInventoryReconciliation("unraid", repositories, { autoLink: true });
assert.equal(preview.plan.summary.additions, 1);
const result = await service.reconcileServerInventory("unraid", repositories, { autoLink: true, expectedPlanId: preview.plan.id });
assert.equal(result.adopted, 1);
assert.equal(result.verified, 1);
const discovery = await service.discoverServerWorkloads("unraid", repositories);
assert.equal(discovery.adopted, 1);
assert.equal(discovery.verified, 1);
assert.equal(profiles[0].containerName, "Portfolio");
assert.equal(profiles[0].adoptedFromServer, true);
assert.equal(states.get(profiles[0].id).matchesGitea, true);
+53 -1
View File
@@ -6,16 +6,27 @@ import path from "node:path";
import { createRequire } from "node:module";
import { EventEmitter } from "node:events";
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import { execFile, spawn } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { setTimeout as delay } from "node:timers/promises";
const require = createRequire(import.meta.url);
const execFileAsync = promisify(execFile);
const {
UpdateService,
waitForUpdaterStarted,
windowsUpdaterSpawnOptions,
} = require("../src/main/update-service.cjs");
test("Windows updater uses a hidden non-detached PowerShell child", () => {
assert.deepEqual(windowsUpdaterSpawnOptions("C:\\updates"), {
detached: false,
stdio: "ignore",
windowsHide: true,
cwd: "C:\\updates",
});
});
test("update check pins version to an exact branch commit", async () => {
const temp = await mkdtemp(path.join(os.tmpdir(), "forgeflow-update-test-"));
const saved = [];
@@ -358,6 +369,47 @@ test("binary helper confirms startup through real Windows PowerShell", { skip: p
assert.match(await readFile(logPath, "utf8"), /Handshake-only verification completed successfully/);
await rm(temp, { recursive: true, force: true });
});
test("binary helper confirms startup through the production Node spawn options", { skip: process.platform !== "win32" }, async () => {
const temp = await mkdtemp(path.join(os.tmpdir(), "forgeflow-binary-node-spawn-"));
const statusPath = path.join(temp, "status.json");
const logPath = path.join(temp, "helper.log");
const powershell = path.join(process.env.SystemRoot || process.env.WINDIR, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
const scriptPath = fileURLToPath(new URL("../scripts/apply-binary-update.ps1", import.meta.url));
const updateId = "binary-node-spawn";
await writeFile(statusPath, JSON.stringify({ state: "launching", updateId }));
const child = spawn(powershell, [
"-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", scriptPath,
"-BinaryPath", path.join(temp, "unused.exe"), "-ExpectedSha256", "0".repeat(64),
"-ExpectedVersion", "9.9.9", "-CurrentExecutable", path.join(temp, "unused-current.exe"),
"-Portable", "False", "-ParentPid", String(process.pid), "-LogPath", logPath,
"-StatusPath", statusPath, "-UpdateId", updateId, "-HandshakeOnly",
], windowsUpdaterSpawnOptions(temp));
const childState = { exited: false, code: null, error: null };
child.once("error", (error) => { childState.error = error; });
child.once("exit", (code) => { childState.exited = true; childState.code = code; });
const status = await waitForUpdaterStarted(statusPath, {
timeoutMs: 5000,
pollMs: 25,
childState,
expectedUpdateId: updateId,
logPath,
});
assert.equal(status.state, "started");
let log = "";
for (let attempt = 0; attempt < 40 && !log.includes("Handshake-only verification completed successfully"); attempt += 1) {
await delay(25);
log = await readFile(logPath, "utf8").catch(() => "");
}
assert.match(log, /Handshake-only verification completed successfully/);
if (child.exitCode === null) {
await new Promise((resolve, reject) => {
child.once("exit", resolve);
child.once("error", reject);
});
}
await rm(temp, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
test("early helper exit reports the helper log instead of only an exit code", async () => {
const temp = await mkdtemp(
path.join(os.tmpdir(), "forgeflow-update-log-tail-"),