30 Commits
Author SHA1 Message Date
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
NuklearRabbit acad1f8932 fix: make packaged updater handshake Windows-safe
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-01 15:09:47 +02:00
NuklearRabbit 44aa452a76 fix: tolerate unavailable unsigned signature inspection
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-01 14:35:59 +02:00
NuklearRabbit a0435f4316 chore: release ForgeFlow 0.10.3
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-01 14:33:08 +02:00
NuklearRabbit 13f4fe7cd0 perf: reduce renderer and repository polling work
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-01 14:24:53 +02:00
NuklearRabbit 258f0b1324 fix: restore scrolling and validator enforcement
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-01 12:44:31 +02:00
NuklearRabbit f8c505e525 docs: refresh user guide and screenshots
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-07-30 02:02:18 +02:00
NuklearRabbit 398f986d95 fix: repair packaged release downloads
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-07-30 00:58:34 +02:00
NuklearRabbit 60e8aa8fc2 release: prepare ForgeFlow 0.10.1
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-07-30 00:49:58 +02:00
NuklearRabbit 6ef4620388 fix: make packaged updates certificate-free
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-07-29 23:11:24 +02:00
NuklearRabbit 18f42621c2 feat: harden release signing and coverage gate
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-07-29 22:54:51 +02:00
NuklearRabbit aa4895912a docs: record production readiness evidence
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-07-29 20:06:14 +02:00
NuklearRabbit 347f7132b9 test: prove Authenticode release chain 2026-07-29 19:58:35 +02:00
NuklearRabbit e5599f7a5d style: improve deployment card readability 2026-07-29 19:48:11 +02:00
NuklearRabbit c826561c77 test: strengthen safety-critical coverage 2026-07-29 19:40:27 +02:00
NuklearRabbit 0ed202ec95 chore: reduce and document dependency risk 2026-07-29 19:19:20 +02:00
NuklearRabbit dcbd461a92 test: add isolated production acceptance harness 2026-07-29 18:55:44 +02:00
NuklearRabbit 1e86afb7d9 test: add browser acceptance matrix 2026-07-29 18:49:08 +02:00
NuklearRabbit a0733875c4 feat: deliver policy-driven Git Validator 2.0 2026-07-29 18:24:27 +02:00
NuklearRabbit 7b05c953b6 refactor: split renderer ipc and unraid domains 2026-07-29 18:11:07 +02:00
NuklearRabbit 5d3731a853 feat: normalize deployment inventory evidence 2026-07-29 17:39:22 +02:00
NuklearRabbit 6b93391a9b feat: add transactional deploy key lifecycle 2026-07-29 17:20:31 +02:00
NuklearRabbit 64ca267384 fix: pass signature targets without shell interpolation
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-07-29 16:36:46 +02:00
NuklearRabbit 4555023f87 docs: align release and recovery guidance 2026-07-29 16:31:24 +02:00
NuklearRabbit 4708367b66 feat: add fail-closed signed release provenance 2026-07-29 16:30:02 +02:00
NuklearRabbit 8e580a0b13 ci: enforce lint coverage and Node LTS quality gates 2026-07-29 16:27:16 +02:00
NuklearRabbit a7ab2d6cc8 fix: harden responsive accessibility contracts 2026-07-29 16:22:46 +02:00
NuklearRabbit ae6c41ff90 feat: verify server pull access before deployment 2026-07-29 16:17:54 +02:00
NuklearRabbit 430d40354b fix: make server discovery and audits read-only 2026-07-29 16:09:39 +02:00
101 changed files with 14712 additions and 8011 deletions
+28
View File
@@ -0,0 +1,28 @@
name: ForgeFlow quality gate
on:
push:
branches: [main]
pull_request:
jobs:
quality:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run quality
- run: npx playwright install --with-deps chromium
- run: npm run test:browser:ci
- name: Preserve browser failure evidence
if: failure()
uses: actions/upload-artifact@v4
with:
name: forgeflow-browser-failure-evidence
path: artifacts/
if-no-files-found: ignore
- run: npm audit --omit=dev --audit-level=high
+3
View File
@@ -3,4 +3,7 @@ dist/
.DS_Store .DS_Store
*.log *.log
coverage/ coverage/
artifacts/
playwright-report/
.forgeflow/ .forgeflow/
.playwright-mcp/
+1
View File
@@ -0,0 +1 @@
22
+18 -8
View File
@@ -2,9 +2,9 @@
**Van lokale wijziging naar aantoonbaar juiste serverversie — zonder de Git- en deploymentcontext over verschillende tools te verspreiden.** **Van lokale wijziging naar aantoonbaar juiste serverversie — zonder de Git- en deploymentcontext over verschillende tools te verspreiden.**
ForgeFlow is een desktopapp voor teams die met Git, Gitea en eigen servers werken. De app toont wat lokaal gewijzigd is, wat al op Gitea staat en welke exacte commit op de server draait. Daarna begeleidt ForgeFlow je door review, commit, push, deployment en verificatie. 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.0** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest) > Huidige release: **0.10.5** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest)
![ForgeFlow release-overzicht](docs/screenshots/overview.png) ![ForgeFlow release-overzicht](docs/screenshots/overview.png)
@@ -13,7 +13,7 @@ ForgeFlow is een desktopapp voor teams die met Git, Gitea en eigen servers werke
- **Eén duidelijke actielijst:** zie meteen welke repository aandacht nodig heeft en waarom. - **Eén duidelijke actielijst:** zie meteen welke repository aandacht nodig heeft en waarom.
- **Veilige Git-flow:** review wijzigingen, stage volledige bestanden of afzonderlijke hunks, commit, push en herstel conflicten zonder contextwissel. - **Veilige Git-flow:** review wijzigingen, stage volledige bestanden of afzonderlijke hunks, commit, push en herstel conflicten zonder contextwissel.
- **Deployment op een exacte commit:** ForgeFlow gebruikt volledige commit-SHA's en toont lokaal, Gitea en server naast elkaar. - **Deployment op een exacte commit:** ForgeFlow gebruikt volledige commit-SHA's en toont lokaal, Gitea en server naast elkaar.
- **Volledige serverinventaris:** zie ook gestopte, DockerMan- en niet-Git-installaties, koppel twijfelgevallen handmatig en behoud hun bestaande Compose-identiteit. - **Automatische serverinventaris:** ForgeFlow herkent draaiende en gestopte Docker-, Compose- en DockerMan-workloads, koppelt alleen op betrouwbaar bewijs en houdt tijdelijke of externe containers apart.
- **Veilige server-pull:** Unraid haalt de exacte commit uit Gitea met een unieke, repository-scoped read-only deploy key en een vastgepinde SSH-hostsleutel. - **Veilige server-pull:** Unraid haalt de exacte commit uit Gitea met een unieke, repository-scoped read-only deploy key en een vastgepinde SSH-hostsleutel.
- **Ingebouwde Git Validator:** controleer repository-identiteit, branch protection, synchronisatie-instellingen, documentatie, geheimen en grote bestanden; veilige verbeteringen kunnen gericht worden toegepast. - **Ingebouwde Git Validator:** controleer repository-identiteit, branch protection, synchronisatie-instellingen, documentatie, geheimen en grote bestanden; veilige verbeteringen kunnen gericht worden toegepast.
- **Lokale controle:** configuratie en credentials blijven op het toestel en diagnostische exports worden lokaal geredigeerd. - **Lokale controle:** configuratie en credentials blijven op het toestel en diagnostische exports worden lokaal geredigeerd.
@@ -26,9 +26,9 @@ ForgeFlow is een desktopapp voor teams die met Git, Gitea en eigen servers werke
2. Download de Windows-installer of portable executable. 2. Download de Windows-installer of portable executable.
3. Start ForgeFlow en doorloop de setupwizard. 3. Start ForgeFlow en doorloop de setupwizard.
4. Voeg je Gitea-server, token en lokale projectmappen toe. 4. Voeg je Gitea-server, token en lokale projectmappen toe.
5. Configureer optioneel een serververbinding en één of meer deploymentprofielen. 5. Voeg optioneel een Docker- of Unraid-server toe. Start daarna **Scan servers** om bestaande deployments te ontdekken en veilig aan repositories te koppelen.
Na installatie kun je nieuwe packaged releases vanuit **Settings → Updates** ophalen. Downloads worden tegen de gepubliceerde SHA-256-checksums gecontroleerd. Zie [UPDATING.md](docs/UPDATING.md) wanneer een oudere of source-only build nog niet binair kan updaten. Vanaf versie 0.10.2 kun je nieuwe packaged releases vanuit **Settings → Updates** ophalen. ForgeFlow accepteert uitsluitend de release die bij de exacte Gitea-commit hoort en controleert de download tegen de gepubliceerde SHA-256-checksum. Zie [UPDATING.md](docs/UPDATING.md) voor oudere of source-only installaties.
### Eerst vrijblijvend bekijken ### Eerst vrijblijvend bekijken
@@ -57,9 +57,18 @@ In de repositorywerkruimte zie je de volledige keten **Local → Gitea → Serve
![Deploymentsoverzicht met herkenbare containerkaarten](docs/screenshots/deployments.png) ![Deploymentsoverzicht met herkenbare containerkaarten](docs/screenshots/deployments.png)
Elke deploymentkaart benoemt repository, container, omgeving, uitvoeringsmethode, live commit, Gitea-commit, vorige versie en healthstatus. ForgeFlow ondersteunt gecontroleerde deployments via Gitea Actions en SSH/Unraid, met preflightcontrole en rollback waar beschikbaar. Elke deploymentkaart benoemt repository, container, omgeving, uitvoeringsmethode, live commit, Gitea-commit, vorige versie en healthstatus. Zo blijven ook tientallen containers visueel van elkaar te onderscheiden. ForgeFlow ondersteunt gecontroleerde deployments via Gitea Actions en SSH/Unraid, met preflightcontrole en rollback waar beschikbaar.
Bij server discovery probeert ForgeFlow bestaande containers aan Gitea-repositories te koppelen. Een exacte overeenkomst tussen de volledige live SHA en de actuele Gitea-SHA wordt als gelijklopende versie weergegeven; een runtime-healthcheck blijft een afzonderlijke voorwaarde voor een gezonde deployment. Bij server discovery vergelijkt ForgeFlow runtime-, Compose-, DockerMan- en Git-bewijs met Gitea. Exact bewezen matches worden automatisch gekoppeld; kandidaten, historische mappen en externe containers worden niet als productie-deployment geforceerd. Een exacte overeenkomst tussen de volledige live SHA en de actuele Gitea-SHA wordt als gelijklopende versie weergegeven. Ontbreekt de live SHA, dan meldt ForgeFlow eerlijk dat verificatie nog onvolledig is.
De belangrijkste statussen zijn:
| Status | Wat je ermee doet |
| --- | --- |
| **Ready** | De repository, servertoegang, live commit en runtime zijn geverifieerd. |
| **Commit mismatch** | De workload is correct gekoppeld, maar Gitea en de server draaien niet dezelfde commit. |
| **Verification incomplete** | De koppeling bestaat, maar de server bevat nog onvoldoende commitbewijs. Een ForgeFlow-beheerde deployment vult dit veilig aan. |
| **Access failed** | Controleer of herstel de repositorygebonden read-only deploy key voordat je deployt. |
### 4. Verbeter de repository met Git Validator ### 4. Verbeter de repository met Git Validator
@@ -94,9 +103,10 @@ Een gelijke commit bewijst welke code draait; een geslaagde healthcheck bewijst
- deploymentprofielen per repository en omgeving; - deploymentprofielen per repository en omgeving;
- Gitea Actions en SSH/Unraid als gecontroleerde uitvoeringsroutes; - Gitea Actions en SSH/Unraid als gecontroleerde uitvoeringsroutes;
- serverinventaris van draaiende en gestopte Docker-, Compose- en DockerMan-workloads; - serverinventaris van draaiende en gestopte Docker-, Compose- en DockerMan-workloads;
- automatische koppeling op exact bewijs en een handmatige koppelwizard voor twijfelgevallen; - automatische koppeling op exact bewijs, expliciete review voor echte twijfelgevallen en herkenning van tijdelijke, historische en externe workloads;
- server-pull als aanbevolen route, met een afzonderlijke read-only deploy key per repository; - server-pull als aanbevolen route, met een afzonderlijke read-only deploy key per repository;
- directe checksum-gecontroleerde copy als alternatief zonder servertoegang tot Gitea; - directe checksum-gecontroleerde copy als alternatief zonder servertoegang tot Gitea;
- reconciliatie van deployments die buiten ForgeFlow werden bijgewerkt, op basis van de actuele Gitea- en serverwaarheid;
- verificatie op volledige SHA, runtime health en recente serverwaarheid; - verificatie op volledige SHA, runtime health en recente serverwaarheid;
- preflight, live logs, deploymenthistoriek en rollback naar de vorige bekende versie. - preflight, live logs, deploymenthistoriek en rollback naar de vorige bekende versie.
+106 -44
View File
@@ -1,7 +1,14 @@
ForgeFlow 0.10.0 source manifest ForgeFlow 0.10.5 source manifest
SHA-256 BYTES PATH SHA-256 BYTES PATH
(The manifest excludes itself, dependencies and generated release artifacts.) (The manifest excludes itself, dependencies and generated release artifacts.)
755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 58 .gitignore cedceb71eb846d99c7c4019031833c1c7f93b84a1c6073aec7d2435dc744ca3d 703 .gitea/workflows/quality.yml
4a9e8a955ad8c9fa7ba3f8f89cf9920ac1d28c6e5b344782e12d02c3b0fab1ee 105 .gitignore
f14b4987904bcb5814e4459a057ed4d20f58a633152288a761214dcd28780b56 3 .nvmrc
d0b1bd421359311871224f9fa1cff5a802000933668017d9e42e5190f8d2d8e5 152 .playwright-mcp/page-2026-07-29T17-41-03-014Z.yml
528fe408ad4b49c621dd57dd831cecf7ec00b8865069f6ed3b80fefc2e0b7823 8267 .playwright-mcp/page-2026-07-29T17-41-26-269Z.yml
804c6dac953c5094671784919ff35ebda756306a04ef34fe01cd7cf7cd50b2dc 16909 .playwright-mcp/page-2026-07-29T17-41-46-590Z.yml
0f17fdea98e15ebcf7f3ed356d31b0fc89be62f7bc1d25b26c8a41e4b5deca68 160 .playwright-mcp/page-2026-07-29T17-47-10-112Z.yml
5f348bcf74baa845958884bd2258e1ce4121d386c945777b0e80912602e5b5f6 17227 .playwright-mcp/page-2026-07-29T17-47-19-366Z.yml
89545860bd6f7566da81edc8328cd2a1ebf33e81a4b0dcf2cec74338c05e8cac 1753 build-windows.ps1 89545860bd6f7566da81edc8328cd2a1ebf33e81a4b0dcf2cec74338c05e8cac 1753 build-windows.ps1
0970821475a4452aa19e447e9397a95db836791f16890a1a83fd748ac033dc86 8830 build/icon-128.png 0970821475a4452aa19e447e9397a95db836791f16890a1a83fd748ac033dc86 8830 build/icon-128.png
09112c1425ca953d8dd8b2bcfd221e5a84b9f81752f7168f360e295030cbc8f2 521 build/icon-16.png 09112c1425ca953d8dd8b2bcfd221e5a84b9f81752f7168f360e295030cbc8f2 521 build/icon-16.png
@@ -13,13 +20,24 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263
25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico 25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png
5f4aca19a35cbcaffa1a6993ce96b7d66052ec2b286022f2af74594e8a310568 15712 CHANGELOG.md 5f4aca19a35cbcaffa1a6993ce96b7d66052ec2b286022f2af74594e8a310568 15712 CHANGELOG.md
21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md c612fcc44ff222db0c9a4cfd11a4076fafe080e4ada31e689a08739a4f14e74f 1650 docs/ACCEPTANCE.md
a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md
e05458ee2696e3c57e2475bb42ae1f914f6a36e01768d7a26a3199f1fffed490 1157 docs/COVERAGE_POLICY.md
b9e39748ff125031be0ee8a963ef0d457c342f7998113fc2dd042ec237ae32ad 3084 docs/CURRENT_STATE.md
8ea655d1912ac2e17f8834e33a566a8b14461b396ec4268c396ca189a1749b94 2205 docs/DEPENDENCY_AUDIT.md
30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md 30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md
eb42f979666e05d51c587e4223282914926a2b9b1ade9f3fb75525019ce7f738 4616 docs/DIAGNOSTICS.md eb42f979666e05d51c587e4223282914926a2b9b1ade9f3fb75525019ce7f738 4616 docs/DIAGNOSTICS.md
1d8aa3c688a9d330b9a5303b09e9e3049c50bf04f97807c7ff659607baa34c32 1464 docs/ERROR_CODES.md
a0cd06a96f23a94e118feb012be0fa1ac51345951cb2ba8e67fb8c889c4c342a 5007 docs/LUMAOPS_SERVER_AUDIT.md a0cd06a96f23a94e118feb012be0fa1ac51345951cb2ba8e67fb8c889c4c342a 5007 docs/LUMAOPS_SERVER_AUDIT.md
648dd6bca6b45668fb86eb3e1f6c5898dd8da0291b990f1bb5105cfd79421343 1301 docs/MUTATION_MODEL.md
8dc95f69e6f6c8415702c8e79fb6b466c4d60061afded9140450e7e558eaefe5 3704 docs/PRODUCTION_READINESS_1.0.md
f79908fb3dad98c38030c6e6be7c79a1999e0478ed9c2496923891954438daa1 4581 docs/RELEASE_AUDIT_0.6.0.md f79908fb3dad98c38030c6e6be7c79a1999e0478ed9c2496923891954438daa1 4581 docs/RELEASE_AUDIT_0.6.0.md
979a0b8e129979be6b265e8571d0a3c1e9ddd4ddb6b0bf55ae748d3478e51854 2296 docs/RELEASE_NOTES_0.10.0.md 979a0b8e129979be6b265e8571d0a3c1e9ddd4ddb6b0bf55ae748d3478e51854 2296 docs/RELEASE_NOTES_0.10.0.md
0eb44bda2209a5979a6ac693ac4cd4d235c0015031e54b9e895990f37bf60054 1433 docs/RELEASE_NOTES_0.10.1.md
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
a0c00ff76acd1682bb5e0e8dcf6589c9480da436c9c6d30780a1ed58b4dad94f 1770 docs/RELEASE_NOTES_0.2.0.md a0c00ff76acd1682bb5e0e8dcf6589c9480da436c9c6d30780a1ed58b4dad94f 1770 docs/RELEASE_NOTES_0.2.0.md
5773ead01aa4c522c556295553787482d01b1f5242f053b2c61f120c4de4fa76 5963 docs/RELEASE_NOTES_0.3.0.md 5773ead01aa4c522c556295553787482d01b1f5242f053b2c61f120c4de4fa76 5963 docs/RELEASE_NOTES_0.3.0.md
d46de73cf6c4cd5c2ba3f455a7a2af2e0d64ee9d94a97fd1a0bfb44e35c1624a 1093 docs/RELEASE_NOTES_0.3.1.md d46de73cf6c4cd5c2ba3f455a7a2af2e0d64ee9d94a97fd1a0bfb44e35c1624a 1093 docs/RELEASE_NOTES_0.3.1.md
@@ -54,21 +72,23 @@ ed40e08bac8792f95970bc05e49bce3cc9e288a08d11565a1bd156d787360a3b 720
25169225d73d22b9d884ab3b5c1625f03fd44e53c7a7a4c4067775e80482c9f8 2182 docs/RELEASE_NOTES_0.9.3.md 25169225d73d22b9d884ab3b5c1625f03fd44e53c7a7a4c4067775e80482c9f8 2182 docs/RELEASE_NOTES_0.9.3.md
720506842e0aeb30c9fc635f86a52a5545556f092e678cf37f08436243244c3d 933 docs/RELEASE_NOTES_0.9.4.md 720506842e0aeb30c9fc635f86a52a5545556f092e678cf37f08436243244c3d 933 docs/RELEASE_NOTES_0.9.4.md
dd90c81a375f97dfb7fa8f7808db03b19d7e7dafe3818a93537397f57eaae829 2109 docs/RELEASE_NOTES_0.9.5.md dd90c81a375f97dfb7fa8f7808db03b19d7e7dafe3818a93537397f57eaae829 2109 docs/RELEASE_NOTES_0.9.5.md
2b631b9d6d973bdd70869d84886ff339da351e29e17598970b3b27915674661d 4175 docs/ROADMAP.md 60cb1f1ed55322b519236dde8388ecf9ee5fd67c169fd8093b5acf61387557d8 2068 docs/RELEASING.md
ac76cb50fabde6a00f28d7e9eccd3ef1129a40665eabdc90d78690a38d424652 4195 docs/ROADMAP.md
1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png 1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png
b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png
070e6700bdae8c628c907ba181bbf0dde0bbbbb4208f7a875503f933ff1b882e 118819 docs/screenshots/deployment-success.png 070e6700bdae8c628c907ba181bbf0dde0bbbbb4208f7a875503f933ff1b882e 118819 docs/screenshots/deployment-success.png
581375ee0727911f85b0441f09734c6215ea8dd6cfaba4a7555599df0edb24f1 333382 docs/screenshots/deployments.png ed69b8beb948a2cf9a6deb6c82368e2bb44ffe8d8990a900dc878b0938d1084f 95937 docs/screenshots/deployments.png
87546583580e8591b1306f997d27445725b0bf5a5a79a839af3a727964e65bc2 103901 docs/screenshots/git-validator.png 3868ab978de2a7945761c53a9a718aecd54dc791605d07660bcd5cad62a33ea8 103569 docs/screenshots/git-validator.png
bbdbe91679b486cc92dec4758ce1cdaf24e3277d038c57e794a04c0dee7e3a5b 84046 docs/screenshots/overview.png 007681714895ac062c980db1dda806ac17d4f01019ce9c46491a108d17c2dbda 85338 docs/screenshots/overview.png
c8a5e80bb9fd2d442d2d23d30e6ac1528cf2330e6e19492b7c6799e2d1508b53 112868 docs/screenshots/repository-workspace.png 1f78414b00ec100af2ec9bf5c9a3e400b6c9bf6dca6fcc317fd951789acc4536 112852 docs/screenshots/repository-workspace.png
322624242d246d07180cc719e14c91e8fb69e123676a02e5046f4e576cca1ca1 5569 docs/SECURITY.md 158cd3a13e9c4d081a63575fbafc77e0b23812f793a15096888b3667f41fa28c 5605 docs/SECURITY.md
32a34ec13a284d3f9ceebbc107b25a844e3db096f8cafa4e43951fc2050c9a03 13552 docs/SETUP_GUIDE.md 32a34ec13a284d3f9ceebbc107b25a844e3db096f8cafa4e43951fc2050c9a03 13552 docs/SETUP_GUIDE.md
2fd71e9bcaeb4cb10c3fa2496b7e52fedf70c5b7f871cd587e22dc060c399079 4421 docs/SSH_UNRAID_DEPLOYMENT.md 2fd71e9bcaeb4cb10c3fa2496b7e52fedf70c5b7f871cd587e22dc060c399079 4421 docs/SSH_UNRAID_DEPLOYMENT.md
b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md
0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md 0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md
4625a10ebd3c749f60b2a7bef6b1716cd05dbc44ccceba0491a1b46bc293c195 4883 docs/TEST_MATRIX.md 4983414a980075e6faae687b0d71c8e57bfe53fcb4cadb8b979b8abca636fe95 6654 docs/TEST_MATRIX.md
dbbd9fa96988e7543e98c85da864adaadd3057815f18d20a3b3ccb5c540a169d 4558 docs/UPDATING.md dbbd9fa96988e7543e98c85da864adaadd3057815f18d20a3b3ccb5c540a169d 4558 docs/UPDATING.md
4bffda594058697345569d937d7a524f094ac85a0f338f0ef18fcf3f94d8c299 1292 eslint.config.js
c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 examples/gitea-actions/deploy.yml c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 examples/gitea-actions/deploy.yml
4c792cc9fd57ed36da291300c252a6ef75b08a249cf6f2561e23c4c22522138a 1477 examples/gitea-actions/rollback.yml 4c792cc9fd57ed36da291300c252a6ef75b08a249cf6f2561e23c4c22522138a 1477 examples/gitea-actions/rollback.yml
577f3fa2131a3baa84549a6523f5816ef9da94f5bac6bc274d4588b6e7ab6594 5688 examples/server/forgeflow-deploy 577f3fa2131a3baa84549a6523f5816ef9da94f5bac6bc274d4588b6e7ab6594 5688 examples/server/forgeflow-deploy
@@ -77,55 +97,90 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532
106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf 106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf
2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json 2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE 4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE
3b16a087c73b600415394dff8b8e34e7f7519e48fde1cf443007b2e11ca77b27 13123 main.cjs 1f0f388df4397e548887bbc7579fd3c864581b86469c01703201ece7a6cbf931 13667 main.cjs
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md 91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
efe2f75ec8bfdbad8e2ee68d0f2c4b412ba460c6b841163d7853f56d5b7b63ea 130468 package-lock.json 790e0d43b1e4858b72132657bafd83a55c044732cfffbc4ef6e622d74d453a86 179806 package-lock.json
605514833fe95a59cbfd5cee6a509ce8a0228a8b79c1fcc5bdb4e8fea92084b7 4364 package.json 9567d1f28ab27a02361fbe7fce8e17c13558e6c0bfa58763ec27a2b5728dcf42 5512 package.json
e95be3f3736f2c1ef249fdc9a083fffa46c07a0b885979878a552a4846995aa6 10160 preload.cjs 2a597a5704c576783b8a72407fbc377fa7506b36a4596ea7f7bce126e394f837 1326 playwright.config.mjs
69318fdf054be7aa2fe86ead9847da9da65745d8d5de548c8346f3ba0afc4892 12175 preload.cjs
abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd
f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1 f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1
688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1 688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1
794bbe1937077788f34c64398fd73dc9a3c43d095084aa32673f3e968b115de2 9150 README.md 49a88c060629741cb6e16e597a2d432ce4350d0e1fb4b574802b67d6b872ebe6 10313 README.md
8f8dc5ff0c2967563457e4027d795f6f515253e0a459ecca5a9f5bcf0b658870 14650 reports/architecture-audit.json
5e923f0096895f588fe66b187779cdb7cb5af6644010c6dcc4d55876930ae376 1220 reports/architecture-audit.md
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs 509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
00d57bda5af8c8eda294b72d18b318f024a307b81b0d9205a0821f5240151e31 3814 scripts/apply-binary-update.ps1 8fe616851e6a089a59fd134905465019c33d26f6408ebd0613fff587b6c589e0 4400 scripts/apply-binary-update.ps1
f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287 scripts/apply-source-update.ps1 706b49e001837c91134044d3af132d58fb14955245bc1d5c36b413eb65867002 10486 scripts/apply-source-update.ps1
33a040b12fc5deab05f9df5f71986d0bb71b91f15a8cd25e9ab3cf7ca9a26766 4212 scripts/audit-installed-deployments.cjs 02e924227f6cad3777fd06660230c85df590d8ce95e134194a4d18970a240b88 4145 scripts/architecture-audit.mjs
4490bed84761f76e1fd87ee3117fe53e82e60b7760329c97d68772d6d820ebae 8521 scripts/audit-installed-deployments.cjs
6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs 6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs
f6f89e893195b9c8ef0ff01e256005b9b3cd7d4a278722979a5e0e616c86a89f 1733 scripts/generate-source-manifest.mjs 0244d42896b8c44f734d0bb6cdcb29b5981342be2f070ce89f8d9eaf3e4d49e6 1793 scripts/generate-source-manifest.mjs
842436680521311594e798848b050ae4e488d0595f0de57315f6ec081c049fb9 1266 scripts/prune-dist.mjs 842436680521311594e798848b050ae4e488d0595f0de57315f6ec081c049fb9 1266 scripts/prune-dist.mjs
403a64db5069595a83006a4e293d7e5ceeaefcfb74e820ed3e899864a0f182d2 6066 scripts/publish-binary-release.cjs b83d443f5724ac15393567f3a688aed8315fbe3e5966832c864a9466e0669464 8102 scripts/publish-binary-release.cjs
444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs 444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs
c76507857292c5713e1c699cf02e24b80265da39af2cecd148034bdb874adbb6 5246 scripts/test-authenticode-chain.ps1
4393f7dc5f417e6d601a68238f4e26791799a3634acec228fe4d79deaee85eb5 3109 scripts/validate-installed-connections.cjs 4393f7dc5f417e6d601a68238f4e26791799a3634acec228fe4d79deaee85eb5 3109 scripts/validate-installed-connections.cjs
43e84c3cced1e23ba5b070d87051a235cd7e2d8c02e2e5ddc1e5e0ff2afabace 16487 scripts/verify.mjs e6127e1e62f39c70ddb1abf72f4d7e7b8e3f19ff1f219e1a3660353c2e0cdfac 2411 scripts/verify-release-signatures.mjs
0079701b5acbfef07b71a9623613d1940805ccd20649d77e3f34c37e79df7655 735 scripts/write-release-checksums.mjs b7fc78dfc7c94d62822109cb3c8c2e9566c9506b0a08f6cff8141ee93a04e530 18854 scripts/verify.mjs
0b9f03ba3c67ff7cdb2916a902ad8ce25e81a7c90b210e4ae52d2ad029efabf3 2353 scripts/write-release-checksums.mjs
619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1 619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1
dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs
c9dee05857b6eb9475dace885579291b9ce6830023ab54acafaa146d15962a7a 27754 src/main/config-store.cjs 92856d698d0a5cc0a3e112e9dc05eb6de473809e9f82e7b08dd21f13f4ec1af8 32309 src/main/config-store.cjs
2fb04b1494b39f5d7c0720fa5fd298cd46fa85dc1b696d77657592347fcf1819 2731 src/main/configuration-backup.cjs 2fb04b1494b39f5d7c0720fa5fd298cd46fa85dc1b696d77657592347fcf1819 2731 src/main/configuration-backup.cjs
86e9fc2eda66b4b563f6c4bbb87d3e8514340d484fb503b73137e63b6b05c3c9 14597 src/main/deploy-key-lifecycle-service.cjs
7cbfe51973d6607203cb197652ed7f296a3f6b6b644df876957117866a47d802 2159 src/main/deployment-identity.cjs
9d0af5074093108a5248d0dde0ff70a666748e61f1954b630886a81e8f34072c 24079 src/main/deployment-service.cjs 9d0af5074093108a5248d0dde0ff70a666748e61f1954b630886a81e8f34072c 24079 src/main/deployment-service.cjs
c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs
a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs
0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs 0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs
3ce45837099ac7bddc024974bd839575b4b765a7df9055e7d45ef889dc85bf7f 15623 src/main/git-validator-service.cjs e28fc1ca2fd4c0116148f5005d793feddf04c36ef711d2d348560394d209a613 7253 src/main/git-validator-policy.cjs
d85d5b1abb35e8bd7f1273a697914eeaf96567d3d4ce3f55f364765d35ea4ad9 19900 src/main/gitea-service.cjs 3a101b63ad3761c26350c2ac0793279a0b27672b91a5b1d8dc75bc44d92f0b52 27128 src/main/git-validator-service.cjs
8adeebd08aeafaf79ed8fc7bcd2c1e110d3dc17c14596fb623b9d2c7255fcd11 49662 src/main/ipc.cjs 3cc53e24e023aa0d8bf36c35ce9672ca98e6c74066512c8b59ab42274e838c22 21307 src/main/gitea-service.cjs
2ad3b2e647377f687ad987fe248a142ad399ecac98e4b49965aa7efc6093e5fa 6914 src/main/inventory-classifier.cjs
dafdb09133d2b6ec2161a3f0b09354551e54fc606c8107976fca37405643be91 3404 src/main/inventory-review-service.cjs
00989577aed509a7ddfdf9f4df09a196a393a85b5ea59b21089002215e69f065 25949 src/main/ipc.cjs
0eb1cfdcd3a37a0ec9502bf753966f87230c03580335798bef9265add42ee6fa 12530 src/main/ipc/deployment-handlers.cjs
dc9b5971c9fefe8c374aa31916f5513601ce86003fd48b1d0e51330a909ae3a5 3442 src/main/ipc/operations-handlers.cjs
629b0f4704cda6ed6288c5a6ec9e5d73d6ae3e2692c43581b258431b65b58e51 15887 src/main/ipc/repository-handlers.cjs
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs 62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs 958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs
3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs 3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs
e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508 src/main/repository-monitor.cjs e64f7257d478955c675a133b3735b6afe138a69d2ad090898061e56f557c43e5 9926 src/main/production-acceptance-harness.cjs
fc7a5a2a42579a311f60a96bec4776942bf3f63428928820029b0dcf4874d1aa 2745 src/main/repository-monitor.cjs
17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs 17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs
52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs 52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs
afef3841a3948b2121f8fba809aae4ea3da71bd2fda86973ba50200a5b1f89b2 14894 src/main/ssh-service.cjs afef3841a3948b2121f8fba809aae4ea3da71bd2fda86973ba50200a5b1f89b2 14894 src/main/ssh-service.cjs
75f4135bc068d6c615fc317cfda81f33a4387c4ff44fbb9799a055f302cc154d 137170 src/main/unraid-deployment-service.cjs 90504e27bfabcd2f927ba29930fad9bb65520fdf871a2a9e49cbcefcb837d84c 25492 src/main/unraid-access-methods.cjs
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
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 b654a9e45044ad32c61fabe4a6d897288615ec83739b53e3241ff881e32f56bd 21677 src/main/update-service.cjs
07822cdaf34745678b82b7eb0f20fedb16bdbcd6ab2b9b774ae0cf9c6069032d 234703 src/renderer/app.js 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
a980f2e86d8286ea605a7259b9e9adcf3fda4f657b8d54a5d2d8765a7bbbec13 19065 src/renderer/actions/shell.js
edcfa0585af4b11ec3b7458969132bdf215830505859f1519f1635dc111ce48a 24333 src/renderer/app.js
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png
094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png 094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png
fb7ed47f9aac50d9259d7d3c3bb2010c7bfdd2fe8e8e47ca2744bb22f0057d54 830 src/renderer/index.html 1a577af2459715cf38d49d118da8c5f897a7ff1d3eb42ad41f121675eb871732 48389 src/renderer/dialogs.js
b63786e7e4f70eac8b65d29484c0040aec530a410da5f554574d8a056cd90003 60621 src/renderer/mock-bridge.js eef2f269ba4fbb76bf66ad328d481b461255d0acb753b30878dd4d4eaac57dc6 6924 src/renderer/events.js
34ee56ed08dcd1c2295985b9bb419b981598a2699ee8e65394d86f4177f4797b 77647 src/renderer/styles.css a84da5aecbb16ce7983dba1f6d6aab1bf47b2e9a87c2933fa1afb8123f7ef7d6 1497 src/renderer/index.html
06180d9656dd254edfb6949c397f8e313954fc560ddcb22b3a35fce3c3e35655 21350 src/renderer/mock-bridge.js
5a2c0616a8a0ba23c182a8928abdf40a69612ce34c4cba47b1fd5f21fe5843d4 28617 src/renderer/mock-deployment-bridge.js
f1d341459689f879b403b3769bca37209afe0f227cc8ac1dad7db7a96c747013 20032 src/renderer/mock-repository-bridge.js
94fa265c2fe9ca8d644f0ce9b620b6f85d9b25dca5802c4e9195b66dcbe80120 6522 src/renderer/operations.js
1fc4c28859f7283aa2b771a50f1fd26addc8862b97a09250f806930ab4483f7a 79874 src/renderer/styles.css
5230c238acb87381303ec30026906cf8a7163c33380859d351b268adfc26b4f4 100196 src/renderer/views.js
0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs 0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs
5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs 5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs
029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs 029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs
@@ -139,32 +194,39 @@ f8853dce6fdf360d5df2fbe2b6df3e5687630c807fee5ba8436679b34ec737ea 2436
058aeaa5d9bfe377c7e322f213c7871ecc4151b5d08ef790992f4ee28d857658 743 START-FORGEFLOW-OVERLAY.ps1 058aeaa5d9bfe377c7e322f213c7871ecc4151b5d08ef790992f4ee28d857658 743 START-FORGEFLOW-OVERLAY.ps1
f5b0ea887fcdeadec78c1ad49b0ec7979723562f5c0b730703acb77a37281ee0 1009 tests/acceptance.test.mjs f5b0ea887fcdeadec78c1ad49b0ec7979723562f5c0b730703acb77a37281ee0 1009 tests/acceptance.test.mjs
a4e5947204ff6878e601e32477bc85b53cd0153baf95a161c8935b6e5466c257 1155 tests/audit-service.test.mjs a4e5947204ff6878e601e32477bc85b53cd0153baf95a161c8935b6e5466c257 1155 tests/audit-service.test.mjs
b46c9624c20bae767e50b4ab85e6b0ea6a9ee4a5c5c30f55554217f25134f1e7 11218 tests/browser/forgeflow.spec.mjs
454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 4318 tests/clone-target.test.mjs 454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 4318 tests/clone-target.test.mjs
a984ddd5a29a4ccf55d78ba71202390e0bf1f0925a6a96f03ee74edbbf3bb2f7 1505 tests/configuration-backup.test.mjs ac17f8bbe9e388b80abef7792c8b184a1fd482c93f13d23a478e433961020f75 17214 tests/config-store.test.mjs
f1463326aee79842d265687ae628189ce54e92544600f2bd14073780287cfb14 2502 tests/configuration-backup.test.mjs
b7e009fed4171d6dd6b4c3154ba1d3f7198e98f5b79b298687841fc8169447cd 9354 tests/deploy-key-lifecycle.test.mjs
1dc6477bd07de78be189e6e8195ec339eb9d75820c4dbd5b073b8520ee21f6b5 1938 tests/deployment-policy.test.mjs 1dc6477bd07de78be189e6e8195ec339eb9d75820c4dbd5b073b8520ee21f6b5 1938 tests/deployment-policy.test.mjs
bf68c4dc91a2604235c6a7848088bcd9566fbeaa089b86ec1e0a4fcfc54ca9d2 7677 tests/deployment-status.test.mjs 50e90cd41dae952a14903c40c0cb1fd191d7b875cfeb730f06a454e755fad7ce 9076 tests/deployment-status.test.mjs
fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800 tests/diagnostics.test.mjs fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800 tests/diagnostics.test.mjs
dd121d96ca265a027cd415a52064500a4541b2f8a662f4f4b25f2f996d52b5da 762 tests/external-tools.test.mjs dd121d96ca265a027cd415a52064500a4541b2f8a662f4f4b25f2f996d52b5da 762 tests/external-tools.test.mjs
e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 tests/git-integration.test.mjs e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 tests/git-integration.test.mjs
5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db 1239 tests/git-status.test.mjs 5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db 1239 tests/git-status.test.mjs
73d00729696e5067ba33dd6d43b018d89ce7fdd561a60ab66648d3283fb54d21 5370 tests/git-validator.test.mjs 61e0b8cad926acd22b5b17e4044f7edcbe96b6977cbbe2b6fbe123406626fc89 4283 tests/git-validator-policy.test.mjs
2b31459f14a5e36e30cf84c1054f634f4dba8676d29adeb9c2a8e18179f56fa0 6097 tests/git-validator.test.mjs
681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.test.mjs 681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.test.mjs
771eeb4fa5443d581991cedb4107d7c414ce1b7d2e14bac7cf719ec8ba131647 8969 tests/gitea-actions.test.mjs d633c59bd910008223c834c6d7f3e5666c685a0881944263ede2d42cc69d3151 18710 tests/gitea-actions.test.mjs
48bca4711e7c193d19c78a0cb45ea1c83179b3c23640195f66058268e8a11b52 1520 tests/ipc-contract.test.mjs fcc9a063882840dd89d74c2785284c8f2f6a9e5acec482b6d89ed8de62efdb85 9635 tests/inventory-classifier.test.mjs
9643622a03ea0a88fb7d72ce43e469ff4f814902f4b3d2a672990637d66ef075 2009 tests/ipc-contract.test.mjs
caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs
96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs 96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs
c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 3718 tests/preflight.test.mjs 1b6c920e18a248f78acaed6187197c88ec8d911b62d5e2a9f8ad57b91ae80499 11827 tests/preflight.test.mjs
15e05220fd282895b02ea52ceaa38327a61297af8db1484e6d9964e8b03a8fea 9156 tests/renderer-workflow.test.mjs 0cb884cf62c1cb02cf59a81662be055bcb5339d176de85e2a3eeb8e8573e11b3 6435 tests/production-acceptance.test.mjs
629ba26395c0b49cc5fdee6b0646d75369eb6338e1cc7b59509938f97eea08ec 9601 tests/renderer-workflow.test.mjs
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs 2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs
f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 1068 tests/repository-monitor.test.mjs 62e6d3df7051778124648c808caec634a6b2c439660dc556392ea10beec926d1 1870 tests/repository-monitor.test.mjs
75b5b83836c75675bb9a48fe4363fcb8a24fc425e6af6f822d7955c6f3c79eac 2265 tests/repository-service.test.mjs ebd3c0825bc9e2f1690cfd51e93a96bd33e939eb9c546aa373dd948b8cf71a69 7781 tests/repository-service.test.mjs
d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463 tests/security-validation.test.mjs d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463 tests/security-validation.test.mjs
bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs
e631e9ca49a5bac7075860aac2ff4d377a32a78377b70e06ecf833f0f192fd5f 11552 tests/server-inventory-branches.test.mjs
020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs 020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs
2571128f0b8e650071df17755baa09c4dfc441af0c20a7a4e9aa445b59e87d11 1654 tests/ssh-service.test.mjs 0d1bc4d623ce299337736c577ec61c8ffd6974ebe20335b72838d10eae35ecb1 7993 tests/ssh-service.test.mjs
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs 8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs
5634e82a3a3c782cd6d7cd4df42b3102c22b81c5566fb072d7cd30de65c1f395 40003 tests/unraid-deployment.test.mjs 4182b61e395aff310b9a964c973a43c3566df0b44c454054c3abdd9459e86e3b 49134 tests/unraid-deployment.test.mjs
4abe7b2fc113c486f35f15c2d629c5b4f24589eada718c4ea58f93551c77d5eb 17777 tests/update-service.test.mjs 074031cac5018aa74c2bb57ecaf114521d69da707aa6170dda65983690bb1691 21487 tests/update-service.test.mjs
9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs 9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs
7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs 7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs
8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea 767 UPDATE_FROM_0.3.2.md 8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea 767 UPDATE_FROM_0.3.2.md
+14
View File
@@ -18,3 +18,17 @@ npm run acceptance -- --execute-rollback
The first command is read-only. The mutation flags require every read-only The first command is read-only. The mutation flags require every read-only
check to pass, dispatch a controlled exact-SHA workflow with a unique request check to pass, dispatch a controlled exact-SHA workflow with a unique request
ID, and wait for matching status plus a successful health endpoint. ID, and wait for matching status plus a successful health endpoint.
## Isolated production acceptance
`npm run acceptance:isolated` provisions disposable bare Git remotes, working
trees, server appdata folders, Compose definitions, deployment keys,
configuration migrations and release manifests below the operating-system temp
directory. It covers clean and portable installs, the 0.10.0 migration path,
both authentication modes, exact-SHA server pull and Direct Copy, adoption,
external updates, deploy-key lifecycle, host-key changes, unhealthy activation,
rollback, network interruption, shutdown recovery, stale plans, corrupt config,
release integrity and inventories of more than twenty workloads.
Every fixture is removed after its test. The suite never discovers or mutates
real project folders, configured servers, credentials, containers or releases.
+18
View File
@@ -0,0 +1,18 @@
# Coverage policy
ForgeFlow treats coverage as release evidence, not as a target to game. `npm run coverage`
enforces 75% statements, 75% lines, 75% functions and 65% branches globally.
The July 2026 hardening pass raised the measured baseline from 69.74% statements/lines,
68.82% functions and 55.38% branches to 81.48% statements/lines, 82.07% functions and
65.59% branches. Node/V8 discovered additional branch counters when previously unexecuted
functions became covered; the denominator grew from 2,537 to 3,473 while the new tests
added hundreds of asserted decisions. No command builders, platform guards or error
adapters were excluded to improve the result cosmetically.
The 65% global gate is paired with scenario-level evidence for the critical
boundaries: deploy-key rollback, deployment verification, Gitea authentication and
redirects, SSH host identity and output limits, inventory reconciliation, stale plans,
configuration recovery, release integrity and updater failure modes. New code must not
reduce the global baseline. Future increases must come from additional asserted failure
scenarios, not ignore comments or source exclusions.
+54
View File
@@ -0,0 +1,54 @@
# ForgeFlow current state
## Baseline
- Baseline version: **0.10.0**
- Baseline commit: `56efd1a00c2e76251a0b2e7a7a424d94200ae33c`
- Baseline branch: `main`
- Desktop runtime: Electron 43 with Node.js 22+ required by the source project
- Primary supported packaged updater: Windows installer and portable executable
The baseline was recorded before the 1.0 professionalization programme. It is the comparison point for functional, deployment and renderer regressions.
## Known baseline evidence
- `npm run check`: 155 tests, 152 passed, 3 environment-dependent Bash checks skipped, 0 failed.
- OS-backed secure storage, encrypted Gitea token, Gitea API, repository and Actions access were available.
- Unraid exposed Docker, Compose, Git, tar and SHA-256 tooling.
- The server inventory contained active repository workloads plus a large number of historical or unrelated definitions that require backend classification.
- Windows release artifacts were checksum-protected but not Authenticode-signed.
No secret values, passwords, private keys or tokens are stored in this document.
## Operation classes
| Class | Default | Examples |
| --- | --- | --- |
| Read-only inspection | Allowed without confirmation | repository refresh, server inventory, deploy-key probe, preflight, audit export |
| Reconciliation | Preview required | adopt an exact workload, refresh a profile from server truth |
| Configuration mutation | Explicit action and audit record | save profile, rotate deploy key, change server settings |
| Deployment | Fresh preflight and confirmation | server pull, Direct Copy, Gitea Actions dispatch |
| Destructive maintenance | Recovery evidence and explicit confirmation | unlink, prune, key revocation, rollback |
Discovery and audit never belong to a mutating class. Ambiguous evidence cannot be promoted automatically.
## Recovery model
ForgeFlow writes its configuration atomically. Explicit server reconciliation additionally creates a private recovery snapshot before changing profiles or deployment state. Encrypted user-created `.ffbackup` files remain the portable restore mechanism; recovery snapshots are local operational safeguards and can contain OS-encrypted credential material.
## Issue priorities
- **P0:** active data loss, credential disclosure, arbitrary execution or uncontrolled production mutation.
- **P1:** release-blocking incorrect deployment, unsafe implicit mutation, broken recovery or material security gap.
- **P2:** important functional, accessibility, performance or maintainability defect with a safe workaround.
- **P3:** polish, documentation or low-risk improvement.
## 1.0 constraints
- No force-push or implicit repository history rewrite.
- No automatic deployment deletion.
- No desktop Gitea token on a server.
- Read-only repository-scoped deploy keys for server pull.
- SSH host-key changes fail closed.
- Live commit, remote commit and runtime health remain separate evidence.
- Packaged updates must fail closed on missing or mismatched release assets and SHA-256 evidence; paid code signing is optional.
+46
View File
@@ -0,0 +1,46 @@
# Dependency security audit
Audit date: 2026-07-29
## Outcome
- Runtime/production dependency audit: **0 vulnerabilities** (`npm audit --omit=dev`).
- Full development toolchain: **19 high advisories**, reduced from 23.
- Critical advisories: **0**.
Playwright was upgraded from 1.55.0 to 1.62.0, removing the browser-download
certificate-verification advisory. `c8` was upgraded from 10.1.3 to 12.0.0,
removing the vulnerable `test-exclude` chain. Compatible patched
`brace-expansion` releases were installed where dependency ranges allowed it.
## Remaining development-only chain
All remaining records collapse to one advisory:
`GHSA-mh99-v99m-4gvg`, an uncontrolled brace-expansion denial of service. npm
reports it through nested `minimatch` versions in two independent toolchains:
- ESLint 10.8.0 (`@eslint/config-array`, `@eslint/eslintrc`);
- electron-builder 26.15.3 (`@electron/asar`, `@electron/universal`, `glob`,
`dir-compare`, `ejs`/`jake`, Windows packaging helpers).
These packages are never loaded by the packaged ForgeFlow runtime. They run in
developer or CI processes against repository and build configuration owned by
the operator. A malicious repository could still attempt resource exhaustion
during linting or packaging, so the finding is not classified as harmless.
CI jobs must retain memory/time limits and untrusted pull requests must not run
release signing or publishing jobs.
## Decisions
- `npm audit fix --force` is prohibited. npm proposes ESLint 4.0.0 and an older
electron-builder; both are breaking downgrades and the tested older builder
dependency graph increased the result to 30 high and 1 critical advisory.
- No global `minimatch` override is used. Several affected consumers declare
older APIs, and forcing a new major could silently break packaging or lint
file selection.
- Latest stable ESLint and electron-builder versions are pinned exactly. The
residual chain will be retested whenever either publishes a dependency fix.
The release gate treats `npm audit --omit=dev --audit-level=high` as blocking.
The complete development audit remains documented and visible rather than
being misrepresented as a production vulnerability count.
+15
View File
@@ -0,0 +1,15 @@
# ForgeFlow error and recovery catalog
| Code | Meaning | Recovery |
| --- | --- | --- |
| `RECONCILIATION_PLAN_REQUIRED` | A server mutation was requested without its reviewed plan. | Open Review reconciliation and apply the current plan ID. |
| `RECONCILIATION_PLAN_STALE` | Server truth changed after preview. | Rescan, review the new impact and apply that plan. |
| `SERVER_GIT_VERIFICATION_FAILED` | Branch, deploy key or pinned SSH evidence could not be proven. | Run Verify server pull; repair only the failing check before retrying. |
| `DEPLOY_KEY_NOT_READ_ONLY` | A matching key can write to Gitea. | Revoke it in Gitea and configure a dedicated read-only key. |
| `SSH_DEPLOYMENT_PREFLIGHT_FAILED` | One or more deployment safety checks failed. | Open preflight evidence and follow the failing check's detail. |
| `REMOTE_WRITE_ACCESS_REQUIRED` | The SSH user cannot safely write the managed source/state paths. | Use Check / fix write access after reviewing its scoped impact. |
| `UPDATE_ORIGIN_MISMATCH` | An update asset points outside the trusted Gitea origin. | Correct release asset URLs; never bypass the origin check. |
| `UPDATE_CHECKSUM_MISMATCH` | Downloaded bytes do not match the published checksum. | Keep the current version and republish the exact commit atomically. |
Audit and discovery never repair these conditions automatically. Mutating recovery
actions require an explicit user flow and preserve rollback or snapshot evidence.
+26
View File
@@ -0,0 +1,26 @@
# Mutation and reconciliation model
ForgeFlow separates observation from state changes at the API boundary.
## Discovery
`scanServerInventory()` and `discoverServerWorkloads()` collect Docker, Compose, DockerMan and Git evidence. They may write diagnostic logs, but they do not save, update or delete deployment profiles and do not change containers.
## Reconciliation planning
`planServerInventoryReconciliation()` returns a content-addressed plan containing:
- exact links that may be added;
- existing profiles whose observed metadata may be refreshed;
- stale profiles that require review;
- ambiguous workloads that block automatic application.
The plan identifier changes whenever its proposed scope changes.
## Reconciliation application
`reconcileServerInventory()` requires the exact reviewed plan identifier. It rescans the server and refuses a stale plan. Before writing configuration it creates a private recovery snapshot. Stale profiles are reported but never removed automatically.
## Direct mutations
Manual linking, unlinking, deploy-key rotation, deployment and rollback remain separate explicit commands. Each must append an audit event with repository, profile, operation identifier and result. Destructive commands need a dedicated confirmation flow and recovery path.
+78
View File
@@ -0,0 +1,78 @@
# ForgeFlow 1.0 production-readiness evidence
## 1. Scope and history
The professionalization work started from `64ca267` on `main`. It preserves the
0.10.0 compatibility baseline and deliberately creates no 1.0 tag or public
release. The commits and their exact SHAs remain the authoritative audit trail.
## 2. Deployment safety
Server pull uses repository-scoped read-only deploy keys, exact commit SHAs,
pinned SSH/Gitea host identities, Compose validation, health evidence and bounded
rollback. Rotation is transactional and revocation requires reviewed impact and
recovery evidence. Direct Copy and monitor-only remain explicit alternatives.
## 3. Inventory and reconciliation
Canonical deployment identity combines repository, branch, server, environment,
Compose project/root, runtime labels, container, live SHA and profile. Duplicate,
stale, ambiguous, orphan and historical evidence has persistent content-addressed
review decisions. Discovery never deletes, stops or rewrites a workload.
## 4. Architecture
Renderer, IPC and Unraid responsibilities are split by domain. The generated
architecture audit currently reports zero source files above 750 or 1,000 lines.
Runtime schemas, bounded IPC capabilities, operation IDs and explicit error
contracts protect the process boundary.
## 5. Repository assurance
Git Validator 2.0 covers security, reproducibility, governance, collaboration,
performance/hygiene and release readiness. Minimal, Standard, Strict, Production
and custom policies support accountable expiring suppressions, trend history and
reviewable JSON/Markdown/HTML reports. Repairs always require preview and never
commit or push automatically.
## 6. Automated verification
The Node suite includes real temporary Git remotes and an isolated production
acceptance harness. Playwright adds 36 renderer cases across six viewport/theme/
motion/scaling projects. Failure artifacts contain screenshots, traces, video,
console events, DOM, fixture details and test identity.
## 7. Coverage and dependencies
Coverage increased from 69.74% statements/lines, 68.82% functions and 55.38%
branches to 81.48%, 82.07% and 65.59%, respectively. The enforced gates are now
75/75/75/65 and are documented in `COVERAGE_POLICY.md`. Production dependencies have zero known
audit vulnerabilities. Remaining development findings belong to current upstream
ESLint/electron-builder toolchains and are assessed in `DEPENDENCY_AUDIT.md`.
## 8. UX and accessibility
Dark and light themes use the same semantic hierarchy, restrained project-signal
motion and status text that never depends on color alone. Deployment cards expose
container, repository, environment, commit parity and health distinctly. Dense
inventories, long names, keyboard focus, dialogs, reduced motion and high scaling
are part of the automated matrix.
## 9. Packaging and updating
Windows installer and portable packaging use deterministic names; old `dist`
versions are pruned after every successful build. Publication stays draft until
installer, portable, checksums, provenance and CycloneDX SBOM are complete. Binary
updates verify the exact release asset, executable format and published SHA-256
before download staging and again before replacement. Authenticode is optional and
is not a release or updater dependency for this personal/internal application.
## 10. Release decision
No open P0 or P1 technical issue is known after the final quality, browser,
acceptance, signing and packaging gates. The technically correct status is:
`TECHNICALLY_COMPLETE`
There is no paid certificate or external signing-service dependency. Windows may
show its normal unknown-publisher warning during first installation.
+21
View File
@@ -0,0 +1,21 @@
# ForgeFlow 0.10.1
## Reliable certificate-free updates
- Windows installer and portable releases are supported without paid signing services.
- Packaged updates remain protected by exact Gitea release assets, PE validation and SHA-256 verification before staging and immediately before replacement.
- Old ForgeFlow versions are pruned from `dist` after each successful build.
## Deployment inventory correctness
- Repository matching is case-insensitive, so `Jens/Repo` and `jens/repo` refresh the same deployment profile.
- Running repository workloads are linked conservatively; third-party DockerMan applications remain visible as external monitoring-only workloads instead of generating hundreds of false repository problems.
- Historical and stopped duplicate definitions no longer require repetitive manual review.
- Shadowed automatic profiles are retired only after a recovery snapshot and a stable reviewed reconciliation plan.
- Live runtime, container health and commit evidence are refreshed before readiness is reported.
## Server pull verification
- Existing running repository workloads can receive repository-scoped read-only deploy keys without changing containers.
- The audit command supports compact inventory, reconciliation and access evidence for operational verification.
- Release acceptance no longer assumes an external Authenticode certificate while retaining checksum, provenance and SBOM checks.
+9
View File
@@ -0,0 +1,9 @@
# ForgeFlow 0.10.2
## Packaged updater origin repair
- Gitea release assets that expose an internal HTTP `ROOT_URL` are safely rewritten to ForgeFlow's configured public HTTPS Gitea origin.
- Authentication remains same-origin: the Gitea token is never forwarded to an internal address, CDN or unrelated redirect target.
- Published Windows executables are still validated as PE files and against their release SHA-256 sidecars before staging.
- The live authenticated updater acceptance downloads the exact published installer and proves its byte count and SHA-256 digest.
+19
View File
@@ -0,0 +1,19 @@
# ForgeFlow 0.10.3
## Responsive large workspaces
- Repository monitoring checks up to four local working trees concurrently while retaining overlap protection and per-repository pause controls.
- Global search, repository filtering and the command palette debounce full interface renders during rapid typing.
- Commit-message input updates readiness and action controls in place, preserving focus and cursor responsiveness.
- Interactive project illustrations and diff atmosphere effects perform at most one layout update per animation frame.
## Git Validator reliability
- Git Validator and every long repository tab now retain an explicit vertical scroll owner across compact, desktop and wide layouts.
- Standard, Strict and Production policies correctly treat configured warning severities as blockers; Minimal remains error-only.
- Documented suppressions no longer reduce the hygiene score or remain counted as active blockers.
- Repair requests are rescanned immediately before preview or execution, preventing stale or forged fixes.
## Verification
- Full quality gate, coverage thresholds and all responsive browser scenarios pass for this release.
+9
View File
@@ -0,0 +1,9 @@
# ForgeFlow 0.10.4
## Permanent packaged updater handshake repair
- Binary and source update helpers now use a Windows PowerShell 5.1-compatible atomic status replacement with a real temporary backup path.
- A deterministic overwrite fallback preserves lifecycle reporting on filesystems that do not implement atomic replacement.
- The binary helper exposes a side-effect-free handshake-only verification mode exercised by the real Windows PowerShell executable during tests.
- existing installations with the defective helper require this one-time installer upgrade; every subsequent packaged update uses the repaired helper automatically.
- Startup failures retain request-scoped status and helper-log evidence instead of collapsing into an unexplained exit-code message.
+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.
+54
View File
@@ -0,0 +1,54 @@
# Releasing ForgeFlow
ForgeFlow releases are built only from a clean, reviewed commit on Node 22 LTS.
## Quality gate
```powershell
npm ci
npm run quality
npm audit --omit=dev --audit-level=high
```
## Windows build — no paid services required
ForgeFlow is a personal/internal tool. The supported release path therefore has
no certificate, Azure or other paid-service dependency:
```powershell
npm run dist:win
```
This produces the installer and portable executable, SHA-256 sidecars, a
CycloneDX SBOM and provenance evidence. The in-app updater downloads only the
matching Gitea release asset, checks its Windows executable format and verifies
the published SHA-256 digest before staging it. The update helper verifies the
digest again immediately before replacing the installed executable.
Windows can display an `Unknown publisher` warning for an unsigned installer.
That warning concerns public publisher reputation; it does not prevent ForgeFlow
from installing or using its checksum-verified in-app updates. Authenticode can
be added later as an optional distribution convenience, but is not required for
correct operation.
## Atomic publication
`npm run release:binary` keeps the Gitea release in draft state while uploading
the installer, portable executable, two checksums, provenance and SBOM. It only
publishes after all six assets are present. A failed upload leaves a draft rather
than exposing an incomplete updater target.
The optional signing acceptance fixture can still validate the complete local
Authenticode chain without purchasing or retaining a certificate:
```powershell
npm run test:signing
```
This disposable fixture signs installer, portable, update-helper and uninstaller
stand-ins, requires an RFC 3161 timestamp, and proves rejection of a missing
timestamp, wrong publisher and a modified binary. Its certificate is removed
from the current-user certificate store after the test.
The disposable test certificate is removed from the current-user certificate
store after the test and is never used for a published build.
+1 -1
View File
@@ -94,7 +94,7 @@ health verification, diagnostic correlation and no arbitrary shell input.
- Windows installer/portable acceptance; - Windows installer/portable acceptance;
- macOS/Linux package validation; - macOS/Linux package validation;
- code signing, notarization and signed updates; - optional code signing/notarization for future public distribution;
- dependency/secret/package scans; - dependency/secret/package scans;
- accessibility review; - accessibility review;
- hundreds-of-repositories performance tests; - hundreds-of-repositories performance tests;
+2 -2
View File
@@ -108,9 +108,9 @@ included model uses:
- atomic non-secret status JSON; - atomic non-secret status JSON;
- previous-SHA recording and non-zero failure exits. - previous-SHA recording and non-zero failure exits.
## Remaining release hardening ## Optional and future release hardening
- code-sign packages and signed updates; - optional code signing if ForgeFlow is ever distributed publicly;
- validate private CA/TLS behavior in the target network; - validate private CA/TLS behavior in the target network;
- dependency, secret and binary scans in CI; - dependency, secret and binary scans in CI;
- package-level IPC/navigation regression tests; - package-level IPC/navigation regression tests;
+33 -3
View File
@@ -1,8 +1,37 @@
# Test matrix # Test matrix
## Automated in v0.4.0 ## Automated baseline (0.10.x)
The suite contains 59 passing tests. The quality chain contains more than 230 Node and browser acceptance cases. The
latest Windows source run completed without failures and retains one explicitly
Bash-dependent skip. `npm run coverage` enforces 75% lines/statements/functions
and 65% branches; the measured hardening baseline is 81.48% statements/lines,
82.07% functions and 65.59% branches. See `COVERAGE_POLICY.md` for the
non-gamed branch policy.
`npm run quality` is the local equivalent of `.gitea/workflows/quality.yml` and
runs source verification, ESLint, the complete suite and coverage on Node 22 LTS.
Production dependencies are separately checked with `npm audit --omit=dev
--audit-level=high`.
### Server safety and reconciliation
- inventory discovery is read-only and byte-stable for configuration;
- reconciliation requires a content-addressed preview plan and recovery snapshot;
- automatic linking requires unique exact provenance/runtime identity;
- server-pull verification checks Gitea branch, read-only deploy-key ID, pinned
host/key fingerprints, remote/live SHA, Compose evidence, runtime and health;
- a fresh access verification is mandatory immediately before server-pull deploy;
- writable or missing deploy keys fail closed.
### Renderer regression matrix
Playwright runs 36 cases across 1120×720, 1440×900 and 1920×1080, dark and
light themes, reduced motion, and simulated 100%, 125% and 150% Windows scaling.
It checks console/page errors, accessible names, labels, heading structure,
horizontal overflow, viewport containment, dialogs, keyboard focus, updater and
deployment failure evidence. CI retains screenshots, video, trace, console JSON,
DOM HTML and fixture context on failure.
### Git and repository behavior ### Git and repository behavior
@@ -76,7 +105,8 @@ The suite contains 59 passing tests.
- status endpoint through the real reverse proxy; - status endpoint through the real reverse proxy;
- deployment lock, failed healthcheck and rollback; - deployment lock, failed healthcheck and rollback;
- diagnostic ZIP inspection after a deliberately failed deployment; - diagnostic ZIP inspection after a deliberately failed deployment;
- unsigned installer and portable build on Windows; - locally test-signed installer, portable, helper and uninstaller fixtures with
RFC 3161 timestamp plus wrong-publisher, missing-timestamp and tamper rejection;
- keyboard-only and screen-reader smoke test. - keyboard-only and screen-reader smoke test.
## Renderer smoke target ## Renderer smoke target
Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 110 KiB

+39
View File
@@ -0,0 +1,39 @@
import js from "@eslint/js";
export default [
{
ignores: ["build/**", "dist/**", "node_modules/**", ".playwright-mcp/**", "SOURCE_MANIFEST.txt"],
},
{
files: ["**/*.{js,cjs,mjs}"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
Buffer: "readonly", clearInterval: "readonly", clearTimeout: "readonly", console: "readonly",
document: "readonly", fetch: "readonly", FormData: "readonly", globalThis: "readonly",
process: "readonly", queueMicrotask: "readonly", requestAnimationFrame: "readonly",
setInterval: "readonly", setTimeout: "readonly", URL: "readonly", URLSearchParams: "readonly",
window: "readonly", confirm: "readonly", localStorage: "readonly", structuredClone: "readonly",
},
},
rules: {
...js.configs.recommended.rules,
"no-control-regex": "off",
"no-empty": ["error", { allowEmptyCatch: true }],
"no-undef": "off",
"no-unused-vars": "off",
"no-useless-escape": "off",
"no-eval": "error",
"no-implied-eval": "error",
eqeqeq: ["error", "always", { null: "ignore" }],
},
},
{
files: ["tests/**/*.mjs"],
rules: {
"no-regex-spaces": "off",
"no-unsafe-finally": "off",
},
},
];
+13
View File
@@ -25,6 +25,9 @@ const {
UnraidDeploymentService, UnraidDeploymentService,
} = require("./src/main/unraid-deployment-service.cjs"); } = require("./src/main/unraid-deployment-service.cjs");
const { AuditService } = require("./src/main/audit-service.cjs"); const { AuditService } = require("./src/main/audit-service.cjs");
const { DeployKeyLifecycleService } = require("./src/main/deploy-key-lifecycle-service.cjs");
const { UnraidDeployKeyHost } = require("./src/main/unraid-deploy-key-host.cjs");
const { InventoryReviewService } = require("./src/main/inventory-review-service.cjs");
const { GitValidatorService } = require("./src/main/git-validator-service.cjs"); const { GitValidatorService } = require("./src/main/git-validator-service.cjs");
const { const {
ExternalToolsService, ExternalToolsService,
@@ -264,6 +267,13 @@ app
sourcePath: app.getAppPath(), sourcePath: app.getAppPath(),
onOperationChange: reportOperationChange, onOperationChange: reportOperationChange,
}); });
const deployKeys = new DeployKeyLifecycleService({
store,
gitea,
keyHost: new UnraidDeployKeyHost({ ssh }),
audit,
});
const inventoryReviews = new InventoryReviewService({ store, audit });
const updates = new UpdateService({ const updates = new UpdateService({
store, store,
gitea, gitea,
@@ -290,6 +300,7 @@ app
git, git,
gitea, gitea,
diagnostics, diagnostics,
store,
}); });
repositoryMonitor = new RepositoryMonitor({ repositoryMonitor = new RepositoryMonitor({
store, store,
@@ -305,6 +316,8 @@ app
repositories, repositories,
deployments, deployments,
unraid, unraid,
deployKeys,
inventoryReviews,
ssh, ssh,
updates, updates,
preflight, preflight,
+1438 -63
View File
File diff suppressed because it is too large Load Diff
+29 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "forgeflow", "name": "forgeflow",
"version": "0.10.0", "version": "0.10.5",
"private": true, "private": true,
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.", "description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
"main": "main.cjs", "main": "main.cjs",
@@ -10,21 +10,33 @@
"dev": "electron . --dev", "dev": "electron . --dev",
"demo": "node scripts/serve-demo.mjs", "demo": "node scripts/serve-demo.mjs",
"test": "node --test tests/*.test.mjs", "test": "node --test tests/*.test.mjs",
"lint": "eslint .",
"coverage": "c8 --check-coverage --lines 75 --functions 75 --branches 65 --statements 75 node --test tests/*.test.mjs",
"verify": "node scripts/verify.mjs", "verify": "node scripts/verify.mjs",
"dist:win": "electron-builder --win nsis portable && node scripts/write-release-checksums.mjs && node scripts/prune-dist.mjs", "dist:win": "electron-builder --win nsis portable && node scripts/write-release-checksums.mjs && node scripts/verify-release-signatures.mjs && node scripts/prune-dist.mjs",
"dist:linux": "electron-builder --linux AppImage && node scripts/prune-dist.mjs", "dist:linux": "electron-builder --linux AppImage && node scripts/prune-dist.mjs",
"dist:mac": "electron-builder --mac dmg && node scripts/prune-dist.mjs", "dist:mac": "electron-builder --mac dmg && node scripts/prune-dist.mjs",
"doctor": "node scripts/doctor.mjs", "doctor": "node scripts/doctor.mjs",
"acceptance": "node scripts/acceptance.mjs", "acceptance": "node scripts/acceptance.mjs",
"acceptance:isolated": "node --test tests/production-acceptance.test.mjs",
"architecture:audit": "node scripts/architecture-audit.mjs",
"test:browser": "playwright test",
"test:browser:ci": "playwright test --reporter=line,html",
"test:signing": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/test-authenticode-chain.ps1",
"connections:check": "electron scripts/validate-installed-connections.cjs", "connections:check": "electron scripts/validate-installed-connections.cjs",
"deployments:audit": "electron scripts/audit-installed-deployments.cjs", "deployments:audit": "electron scripts/audit-installed-deployments.cjs",
"release:binary": "electron scripts/publish-binary-release.cjs", "release:binary": "electron scripts/publish-binary-release.cjs",
"manifest": "node scripts/generate-source-manifest.mjs", "manifest": "node scripts/generate-source-manifest.mjs",
"check": "npm run verify && npm test" "check": "npm run verify && npm run lint && npm test",
"quality": "npm run check && npm run coverage"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "9.39.5",
"@playwright/test": "1.62.0",
"c8": "12.0.0",
"electron": "43.2.0", "electron": "43.2.0",
"electron-builder": "26.15.3" "electron-builder": "26.15.3",
"eslint": "9.39.5"
}, },
"build": { "build": {
"appId": "be.jenscaers.forgeflow", "appId": "be.jenscaers.forgeflow",
@@ -92,7 +104,19 @@
"docs/RELEASE_NOTES_0.9.3.md", "docs/RELEASE_NOTES_0.9.3.md",
"docs/RELEASE_NOTES_0.9.4.md", "docs/RELEASE_NOTES_0.9.4.md",
"docs/RELEASE_NOTES_0.9.5.md", "docs/RELEASE_NOTES_0.9.5.md",
"docs/RELEASE_NOTES_0.10.0.md" "docs/RELEASE_NOTES_0.10.0.md",
"docs/RELEASE_NOTES_0.10.1.md",
"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/CURRENT_STATE.md",
"docs/MUTATION_MODEL.md",
"docs/RELEASING.md",
"docs/COVERAGE_POLICY.md",
"docs/DEPENDENCY_AUDIT.md",
"docs/PRODUCTION_READINESS_1.0.md",
"docs/ERROR_CODES.md"
], ],
"asarUnpack": [ "asarUnpack": [
"scripts/apply-binary-update.ps1" "scripts/apply-binary-update.ps1"
+42
View File
@@ -0,0 +1,42 @@
import { defineConfig } from "@playwright/test";
const matrices = [
["compact-dark-100", 1120, 720, "dark", 1, false],
["desktop-light-125", 1440, 900, "light", 1.25, false],
["wide-dark-150", 1920, 1080, "dark", 1.5, false],
["compact-light-reduced", 1120, 720, "light", 1, true],
["desktop-dark-reduced", 1440, 900, "dark", 1.25, true],
["wide-light-100", 1920, 1080, "light", 1, false],
];
export default defineConfig({
testDir: "./tests/browser",
outputDir: "artifacts/browser",
timeout: 45_000,
expect: { timeout: 7_000 },
fullyParallel: false,
workers: process.env.CI ? 2 : 3,
reporter: [["line"], ["html", { outputFolder: "artifacts/browser-report", open: "never" }]],
use: {
baseURL: "http://127.0.0.1:4173",
screenshot: "only-on-failure",
trace: "retain-on-failure",
video: "retain-on-failure",
},
webServer: {
command: "node scripts/serve-demo.mjs",
url: "http://127.0.0.1:4173",
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
projects: matrices.map(([name, width, height, theme, scale, reduced]) => ({
name,
metadata: { theme, scale, reduced },
use: {
viewport: { width, height },
deviceScaleFactor: scale,
colorScheme: theme,
reducedMotion: reduced ? "reduce" : "no-preference",
},
})),
});
+15
View File
@@ -121,8 +121,19 @@ contextBridge.exposeInMainWorld(
healthcheck: (url) => invoke('deployment:health', { url }), healthcheck: (url) => invoke('deployment:health', { url }),
refreshProfileState: (fullName, profileId) => invoke('deployment:profile-state', { fullName, profileId }), refreshProfileState: (fullName, profileId) => invoke('deployment:profile-state', { fullName, profileId }),
discoverServerDeployments: () => invoke('deployment:discover-server-workloads'), discoverServerDeployments: () => invoke('deployment:discover-server-workloads'),
planServerReconciliation: (serverId) => invoke('deployment:plan-server-reconciliation', { serverId }),
applyServerReconciliation: (serverId, planId) => invoke('deployment:apply-server-reconciliation', { serverId, planId }),
planInventoryReview: (serverId, workloadId, action, reason = '', repositoryFullName = null) => invoke('deployment:plan-inventory-review', { serverId, workloadId, action, reason, repositoryFullName }),
applyInventoryReview: (serverId, workloadId, action, reason, repositoryFullName, planId) => invoke('deployment:apply-inventory-review', { serverId, workloadId, action, reason, repositoryFullName, planId }),
linkServerWorkload: (repository, serverId, workloadId, deploymentMode = 'server-git', remoteFolder = '') => invoke('deployment:link-server-workload', { repository, serverId, workloadId, deploymentMode, remoteFolder }), linkServerWorkload: (repository, serverId, workloadId, deploymentMode = 'server-git', remoteFolder = '') => invoke('deployment:link-server-workload', { repository, serverId, workloadId, deploymentMode, remoteFolder }),
configureServerGitAccess: (repository, profileId) => invoke('deployment:configure-server-git-access', { repository, profileId }), configureServerGitAccess: (repository, profileId) => invoke('deployment:configure-server-git-access', { repository, profileId }),
verifyServerGitProfile: (repository, profileId) => invoke('deployment:verify-server-git-profile', { repository, profileId }),
deployKeyInventory: (repository, profileId) => invoke('deployment:deploy-key-inventory', { repository, profileId }),
planDeployKeyRotation: (repository, profileId) => invoke('deployment:plan-deploy-key-rotation', { repository, profileId }),
applyDeployKeyRotation: (repository, profileId, planId) => invoke('deployment:apply-deploy-key-rotation', { repository, profileId, planId }),
planDeployKeyRevocation: (repository, profileId) => invoke('deployment:plan-deploy-key-revocation', { repository, profileId }),
applyDeployKeyRevocation: (repository, profileId, planId) => invoke('deployment:apply-deploy-key-revocation', { repository, profileId, planId }),
restoreDeployKey: (repository, profileId) => invoke('deployment:restore-deploy-key', { repository, profileId }),
applyDockerManMetadata: (repository, profileId) => invoke('deployment:apply-dockerman-metadata', { repository, profileId }), applyDockerManMetadata: (repository, profileId) => invoke('deployment:apply-dockerman-metadata', { repository, profileId }),
reconcileDeployment: (fullName, profileId) => invoke('deployment:reconcile', { fullName, profileId }), reconcileDeployment: (fullName, profileId) => invoke('deployment:reconcile', { fullName, profileId }),
refreshOperations: (operationId = null) => invoke('operations:refresh', { operationId }), refreshOperations: (operationId = null) => invoke('operations:refresh', { operationId }),
@@ -131,6 +142,10 @@ contextBridge.exposeInMainWorld(
troubleshooterRepair: (issue) => invoke('troubleshooter:repair', { issue }), troubleshooterRepair: (issue) => invoke('troubleshooter:repair', { issue }),
troubleshooterAutoRepair: (issues) => invoke('troubleshooter:auto-repair', { issues }), troubleshooterAutoRepair: (issues) => invoke('troubleshooter:auto-repair', { issues }),
gitValidatorScan: (fullName) => invoke('git-validator:scan', { fullName }), gitValidatorScan: (fullName) => invoke('git-validator:scan', { fullName }),
gitValidatorSetPolicy: (fullName, policy) => invoke('git-validator:set-policy', { fullName, policy }),
gitValidatorSuppress: (fullName, suppression) => invoke('git-validator:suppress', { fullName, suppression }),
gitValidatorPreviewRepair: (fullName, check) => invoke('git-validator:preview-repair', { fullName, check }),
gitValidatorExport: (fullName, format = 'json') => invoke('git-validator:export', { fullName, format }),
gitValidatorRepair: (fullName, check) => invoke('git-validator:repair', { fullName, check }), gitValidatorRepair: (fullName, check) => invoke('git-validator:repair', { fullName, check }),
diagnosticsStatus: () => invoke('diagnostics:status'), diagnosticsStatus: () => invoke('diagnostics:status'),
clearDiagnostics: () => invoke('diagnostics:clear'), clearDiagnostics: () => invoke('diagnostics:clear'),
+716
View File
@@ -0,0 +1,716 @@
{
"generatedAt": "2026-08-01T15:23:33.157Z",
"thresholds": {
"preferredMaximumLines": 750,
"justificationRequiredLines": 1000
},
"over750": [],
"over1000": [],
"cyclomaticHotspots": [
{
"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/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",
"lines": 749,
"branches": 55,
"functions": 79,
"ipcHandlers": 27,
"responsibilities": [
"inventory",
"deployment",
"git",
"ipc",
"renderer",
"security",
"updates"
],
"hotspotScore": 105
},
{
"file": "src/main/unraid-deployment-methods.cjs",
"lines": 577,
"branches": 69,
"functions": 31,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 99
},
{
"file": "src/renderer/views.js",
"lines": 737,
"branches": 55,
"functions": 156,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"security",
"updates"
],
"hotspotScore": 95
},
{
"file": "src/main/unraid-inventory-methods.cjs",
"lines": 591,
"branches": 61,
"functions": 76,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 91
},
{
"file": "src/renderer/actions/setup-and-settings.js",
"lines": 427,
"branches": 58,
"functions": 58,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"renderer",
"security",
"updates"
],
"hotspotScore": 88
},
{
"file": "src/main/gitea-service.cjs",
"lines": 624,
"branches": 67,
"functions": 57,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 87
},
{
"file": "src/renderer/actions/deployment-profile.js",
"lines": 402,
"branches": 56,
"functions": 43,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"security"
],
"hotspotScore": 86
},
{
"file": "main.cjs",
"lines": 443,
"branches": 33,
"functions": 53,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"ipc",
"renderer",
"security",
"updates"
],
"hotspotScore": 83
},
{
"file": "src/main/unraid-preflight-methods.cjs",
"lines": 594,
"branches": 38,
"functions": 42,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"security",
"updates"
],
"hotspotScore": 78
},
{
"file": "src/main/config-store.cjs",
"lines": 652,
"branches": 47,
"functions": 86,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 77
},
{
"file": "src/main/unraid-runtime-methods.cjs",
"lines": 388,
"branches": 37,
"functions": 38,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"security",
"updates"
],
"hotspotScore": 77
},
{
"file": "src/renderer/actions/recovery.js",
"lines": 356,
"branches": 56,
"functions": 38,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"renderer",
"updates"
],
"hotspotScore": 76
},
{
"file": "src/renderer/dialogs.js",
"lines": 324,
"branches": 35,
"functions": 66,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"security",
"updates"
],
"hotspotScore": 75
},
{
"file": "src/main/unraid-access-methods.cjs",
"lines": 448,
"branches": 43,
"functions": 42,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"security",
"updates"
],
"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,
"branches": 41,
"functions": 69,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 61
},
{
"file": "src/main/deploy-key-lifecycle-service.cjs",
"lines": 192,
"branches": 31,
"functions": 38,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 61
},
{
"file": "src/main/unraid-deployment-service.cjs",
"lines": 525,
"branches": 35,
"functions": 48,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"security"
],
"hotspotScore": 55
},
{
"file": "src/main/diagnostics-service.cjs",
"lines": 357,
"branches": 34,
"functions": 50,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 54
},
{
"file": "src/renderer/actions/inventory.js",
"lines": 186,
"branches": 24,
"functions": 32,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"updates"
],
"hotspotScore": 54
},
{
"file": "src/renderer/actions/deployment-operation.js",
"lines": 184,
"branches": 34,
"functions": 26,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"renderer",
"updates"
],
"hotspotScore": 54
},
{
"file": "src/main/ipc/deployment-handlers.cjs",
"lines": 285,
"branches": 13,
"functions": 38,
"ipcHandlers": 24,
"responsibilities": [
"inventory",
"deployment",
"git",
"ipc",
"security",
"updates"
],
"hotspotScore": 53
},
{
"file": "preload.cjs",
"lines": 162,
"branches": 2,
"functions": 123,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"ipc",
"renderer",
"security",
"updates"
],
"hotspotScore": 52
},
{
"file": "src/main/inventory-classifier.cjs",
"lines": 84,
"branches": 22,
"functions": 11,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 52
},
{
"file": "src/renderer/mock-deployment-bridge.js",
"lines": 699,
"branches": 11,
"functions": 87,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"security",
"updates"
],
"hotspotScore": 51
},
{
"file": "src/renderer/operations.js",
"lines": 212,
"branches": 19,
"functions": 26,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"renderer",
"security",
"updates"
],
"hotspotScore": 49
},
{
"file": "src/main/production-acceptance-harness.cjs",
"lines": 150,
"branches": 17,
"functions": 28,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 47
},
{
"file": "src/main/unraid-state-methods.cjs",
"lines": 283,
"branches": 15,
"functions": 20,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 45
},
{
"file": "src/main/preflight-service.cjs",
"lines": 209,
"branches": 24,
"functions": 34,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"renderer",
"security"
],
"hotspotScore": 44
},
{
"file": "src/renderer/mock-bridge.js",
"lines": 590,
"branches": 17,
"functions": 54,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 37
},
{
"file": "src/renderer/mock-repository-bridge.js",
"lines": 636,
"branches": 13,
"functions": 89,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 33
},
{
"file": "src/main/repository-service.cjs",
"lines": 200,
"branches": 12,
"functions": 32,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 32
},
{
"file": "src/main/deployment-identity.cjs",
"lines": 36,
"branches": 0,
"functions": 11,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 30
},
{
"file": "src/main/ipc/operations-handlers.cjs",
"lines": 101,
"branches": 8,
"functions": 14,
"ipcHandlers": 9,
"responsibilities": [
"deployment",
"git",
"ipc",
"renderer"
],
"hotspotScore": 28
},
{
"file": "src/main/inventory-review-service.cjs",
"lines": 32,
"branches": 8,
"functions": 4,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"updates"
],
"hotspotScore": 28
},
{
"file": "src/main/unraid-deploy-key-host.cjs",
"lines": 77,
"branches": 7,
"functions": 22,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 27
}
],
"ipcHotspots": [
{
"file": "src/main/ipc.cjs",
"lines": 749,
"branches": 55,
"functions": 79,
"ipcHandlers": 27,
"responsibilities": [
"inventory",
"deployment",
"git",
"ipc",
"renderer",
"security",
"updates"
],
"hotspotScore": 105
},
{
"file": "src/main/ipc/deployment-handlers.cjs",
"lines": 285,
"branches": 13,
"functions": 38,
"ipcHandlers": 24,
"responsibilities": [
"inventory",
"deployment",
"git",
"ipc",
"security",
"updates"
],
"hotspotScore": 53
},
{
"file": "src/main/ipc/repository-handlers.cjs",
"lines": 413,
"branches": 16,
"functions": 79,
"ipcHandlers": 51,
"responsibilities": [
"git",
"ipc"
],
"hotspotScore": 16
}
]
}
+28
View File
@@ -0,0 +1,28 @@
# ForgeFlow architecture audit
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
| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |
|---|---:|---:|---:|---:|---|
No findings.
## Files above 1,000 lines
| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |
|---|---:|---:|---:|---:|---|
No findings.
## Cyclomatic hotspots
| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |
|---|---:|---:|---:|---:|---|
| `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
Files above 750 lines require decomposition. Files above 1,000 lines are release blockers unless a concrete technical exception is documented. Mixed responsibility and IPC hotspot lists are available in the JSON report.
+24 -5
View File
@@ -7,7 +7,8 @@ param(
[Parameter(Mandatory = $true)][int]$ParentPid, [Parameter(Mandatory = $true)][int]$ParentPid,
[Parameter(Mandatory = $true)][string]$LogPath, [Parameter(Mandatory = $true)][string]$LogPath,
[Parameter(Mandatory = $true)][string]$StatusPath, [Parameter(Mandatory = $true)][string]$StatusPath,
[Parameter(Mandatory = $true)][string]$UpdateId [Parameter(Mandatory = $true)][string]$UpdateId,
[switch]$HandshakeOnly
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@@ -27,12 +28,26 @@ function Write-UpdateState {
updatedAt = [DateTime]::UtcNow.ToString("o") updatedAt = [DateTime]::UtcNow.ToString("o")
} }
if ($State -in @("success", "failed", "rolled-back")) { $payload.completedAt = [DateTime]::UtcNow.ToString("o") } if ($State -in @("success", "failed", "rolled-back")) { $payload.completedAt = [DateTime]::UtcNow.ToString("o") }
$directory = Split-Path -Parent $StatusPath
if ($directory) { New-Item -ItemType Directory -Force -Path $directory | Out-Null }
$temporary = "$StatusPath.$PID.tmp" $temporary = "$StatusPath.$PID.tmp"
$payload | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $temporary -Encoding UTF8 $backup = "$StatusPath.$PID.bak"
if (Test-Path -LiteralPath $StatusPath) { [IO.File]::Replace($temporary, $StatusPath, $null) } $json = $payload | ConvertTo-Json -Depth 4
else { Move-Item -LiteralPath $temporary -Destination $StatusPath } $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[IO.File]::WriteAllText($temporary, $json, $utf8NoBom)
try {
if ([IO.File]::Exists($StatusPath)) {
[IO.File]::Replace($temporary, $StatusPath, $backup)
[IO.File]::Delete($backup)
} else {
[IO.File]::Move($temporary, $StatusPath)
}
} catch {
[IO.File]::Copy($temporary, $StatusPath, $true)
[IO.File]::Delete($temporary)
if ([IO.File]::Exists($backup)) { [IO.File]::Delete($backup) }
}
} }
function Write-Log([string]$Message) { function Write-Log([string]$Message) {
"{0} {1}" -f [DateTime]::UtcNow.ToString("o"), $Message | Add-Content -LiteralPath $LogPath -Encoding UTF8 "{0} {1}" -f [DateTime]::UtcNow.ToString("o"), $Message | Add-Content -LiteralPath $LogPath -Encoding UTF8
} }
@@ -40,6 +55,10 @@ function Write-Log([string]$Message) {
try { try {
Write-UpdateState -State "started" -Message "Binary updater owns the update request." Write-UpdateState -State "started" -Message "Binary updater owns the update request."
Write-Log "Validating ForgeFlow $ExpectedVersion binary update." Write-Log "Validating ForgeFlow $ExpectedVersion binary update."
if ($HandshakeOnly) {
Write-Log "Handshake-only verification completed successfully."
exit 0
}
$actualSha256 = (Get-FileHash -LiteralPath $BinaryPath -Algorithm SHA256).Hash.ToLowerInvariant() $actualSha256 = (Get-FileHash -LiteralPath $BinaryPath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualSha256 -ne $ExpectedSha256.ToLowerInvariant()) { throw "Binary update SHA-256 verification failed." } if ($actualSha256 -ne $ExpectedSha256.ToLowerInvariant()) { throw "Binary update SHA-256 verification failed." }
if (-not (Test-Path -LiteralPath $CurrentExecutable -PathType Leaf)) { throw "Current ForgeFlow executable was not found." } if (-not (Test-Path -LiteralPath $CurrentExecutable -PathType Leaf)) { throw "Current ForgeFlow executable was not found." }
+8 -3
View File
@@ -54,15 +54,20 @@ function Write-UpdateState {
if ([System.IO.File]::Exists($StatusPath)) { if ([System.IO.File]::Exists($StatusPath)) {
# Windows PowerShell 5.1 does not reliably let Move-Item -Force replace # Windows PowerShell 5.1 does not reliably let Move-Item -Force replace
# an existing file. File.Replace is atomic on the local NTFS volume. # an existing file. File.Replace is atomic on the local NTFS volume.
[System.IO.File]::Replace($temporary, $StatusPath, $null) $backup = "$StatusPath.$PID.bak"
[System.IO.File]::Replace($temporary, $StatusPath, $backup)
} else { } else {
[System.IO.File]::Move($temporary, $StatusPath) [System.IO.File]::Move($temporary, $StatusPath)
} }
} catch { } catch {
# Some filesystems do not implement File.Replace. Copy with overwrite is # Some filesystems do not implement File.Replace. Copy with overwrite is
# the deterministic fallback; the temporary file is removed afterwards. # the deterministic fallback; the temporary file is removed afterwards.
[System.IO.File]::Copy($temporary, $StatusPath, $true) if ([System.IO.File]::Exists($temporary)) {
[System.IO.File]::Delete($temporary) [System.IO.File]::Copy($temporary, $StatusPath, $true)
[System.IO.File]::Delete($temporary)
}
} finally {
if ([System.IO.File]::Exists($backup)) { [System.IO.File]::Delete($backup) }
} }
} }
+53
View File
@@ -0,0 +1,53 @@
import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
import path from "node:path";
const root = path.resolve(import.meta.dirname, "..");
const sourceRoots = ["main.cjs", "preload.cjs", "src/main", "src/renderer", "src/shared"];
const extensions = new Set([".js", ".cjs", ".mjs"]);
async function filesBelow(entry) {
const absolute = path.join(root, entry);
const stat = await import("node:fs/promises").then(({ stat }) => stat(absolute));
if (stat.isFile()) return [entry];
const result = [];
for (const child of await readdir(absolute, { withFileTypes: true })) {
const relative = path.join(entry, child.name);
if (child.isDirectory()) result.push(...await filesBelow(relative));
else if (extensions.has(path.extname(child.name))) result.push(relative);
}
return result;
}
function analyze(relative, source) {
const lines = source.split(/\r?\n/).length;
const branches = (source.match(/\b(?:if|else if|for|while|case|catch)\b|\?\?/g) || []).length;
const functions = (source.match(/\b(?:async\s+)?function\b|=>|\b(?:async\s+)?[A-Za-z_$][\w$]*\s*\([^)]*\)\s*\{/g) || []).length;
const ipcHandlers = (source.match(/\bregister\(\s*["']/g) || []).length;
const responsibilities = [
["inventory", /inventory|workload/i], ["deployment", /deploy|rollback|activation/i],
["git", /\bgit|repository/i], ["ipc", /ipc|register\(/i], ["renderer", /render|modal|document\./i],
["security", /key|credential|signature|checksum/i], ["updates", /update|release|artifact/i],
].filter(([, pattern]) => pattern.test(source)).map(([name]) => name);
return { file: relative.replaceAll("\\", "/"), lines, branches, functions, ipcHandlers, responsibilities, hotspotScore: branches + Math.max(0, responsibilities.length - 2) * 10 };
}
const files = (await Promise.all(sourceRoots.map(filesBelow))).flat();
const results = [];
for (const file of files) results.push(analyze(file, await readFile(path.join(root, file), "utf8")));
results.sort((a, b) => b.hotspotScore - a.hotspotScore || b.lines - a.lines);
const report = {
generatedAt: new Date().toISOString(),
thresholds: { preferredMaximumLines: 750, justificationRequiredLines: 1000 },
over750: results.filter((item) => item.lines > 750),
over1000: results.filter((item) => item.lines > 1000),
cyclomaticHotspots: results.filter((item) => item.branches >= 75).slice(0, 20),
mixedResponsibilityModules: results.filter((item) => item.responsibilities.length >= 4),
ipcHotspots: results.filter((item) => item.ipcHandlers >= 10),
};
const reportDir = path.join(root, "reports");
await mkdir(reportDir, { recursive: true });
await writeFile(path.join(reportDir, "architecture-audit.json"), `${JSON.stringify(report, null, 2)}\n`);
const table = (items) => items.length ? items.map((item) => `| \`${item.file}\` | ${item.lines} | ${item.branches} | ${item.functions} | ${item.ipcHandlers} | ${item.responsibilities.join(", ")} |`).join("\n") : "No findings.";
const markdown = `# ForgeFlow architecture audit\n\nGenerated ${report.generatedAt}. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity.\n\n## Files above 750 lines\n\n| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |\n|---|---:|---:|---:|---:|---|\n${table(report.over750)}\n\n## Files above 1,000 lines\n\n| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |\n|---|---:|---:|---:|---:|---|\n${table(report.over1000)}\n\n## Cyclomatic hotspots\n\n| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |\n|---|---:|---:|---:|---:|---|\n${table(report.cyclomaticHotspots)}\n\n## Interpretation\n\nFiles above 750 lines require decomposition. Files above 1,000 lines are release blockers unless a concrete technical exception is documented. Mixed responsibility and IPC hotspot lists are available in the JSON report.\n`;
await writeFile(path.join(reportDir, "architecture-audit.md"), markdown);
console.log(`Audited ${results.length} source files; ${report.over750.length} exceed 750 lines and ${report.over1000.length} exceed 1,000 lines.`);
+56 -7
View File
@@ -16,8 +16,10 @@ app.setPath("userData", userDataPath);
app.whenReady().then(async () => { app.whenReady().then(async () => {
try { try {
const reconcile = process.argv.includes("--reconcile");
const configureAccess = process.argv.includes("--configure-access"); const configureAccess = process.argv.includes("--configure-access");
const reconcile = process.argv.includes("--reconcile");
const summaryOnly = process.argv.includes("--summary");
const inventoryOnly = process.argv.includes("--inventory-only");
const repositoryFilter = new Set(String(process.argv.find((value) => value.startsWith("--repository=")) || "") const repositoryFilter = new Set(String(process.argv.find((value) => value.startsWith("--repository=")) || "")
.slice("--repository=".length).toLowerCase().split(",").map((value) => value.trim()).filter(Boolean)); .slice("--repository=".length).toLowerCase().split(",").map((value) => value.trim()).filter(Boolean));
const store = new ConfigStore(userDataPath); const store = new ConfigStore(userDataPath);
@@ -29,20 +31,43 @@ app.whenReady().then(async () => {
const deployments = new UnraidDeploymentService({ store, ssh, git, gitea, sourcePath: path.resolve(__dirname, "..") }); const deployments = new UnraidDeploymentService({ store, ssh, git, gitea, sourcePath: path.resolve(__dirname, "..") });
const reports = []; const reports = [];
for (const server of store.data.servers || []) { for (const server of store.data.servers || []) {
const report = await deployments.scanServerInventory(server.id, repositories, { autoLink: reconcile }); const report = await deployments.scanServerInventory(server.id, repositories);
let reconciliation = null;
if (reconcile) {
for (let attempt = 1; attempt <= 3 && !reconciliation; attempt += 1) {
const preview = await deployments.planServerInventoryReconciliation(server.id, repositories, { autoLink: true });
try {
reconciliation = await deployments.reconcileServerInventory(server.id, repositories, { autoLink: true, expectedPlanId: preview.plan.id });
} catch (error) {
if (error.code !== "RECONCILIATION_PLAN_STALE" || attempt === 3) throw error;
}
}
}
const access = []; const access = [];
const seenProfiles = new Set();
for (const repository of inventoryOnly || configureAccess ? [] : repositories) {
for (const profile of repository.deploymentProfiles || store.getDeploymentProfiles(repository.fullName) || []) {
if (profile.serverId !== server.id || profile.deploymentMode !== "server-git" || seenProfiles.has(profile.id)) continue;
seenProfiles.add(profile.id);
try {
let verification = await deployments.verifyServerGitProfile({ repository, profileId: profile.id });
await deployments.refreshProfileState(repository.fullName, profile.id, verification.branchSha);
verification = await deployments.verifyServerGitProfile({ repository, profileId: profile.id });
access.push(verification);
} catch (error) {
access.push({ repository: repository.fullName, profileId: profile.id, readiness: "Verification incomplete", ready: false, error: error.message });
}
}
}
if (configureAccess) { if (configureAccess) {
const refreshedRepositories = await new RepositoryService(store, git, gitea).refresh(); const refreshedRepositories = await new RepositoryService(store, git, gitea).refresh();
const seenProfiles = new Set();
for (const workload of report.workloads.filter((item) => item.runtime?.running && item.link?.profileId && item.link?.repositoryFullName)) { for (const workload of report.workloads.filter((item) => item.runtime?.running && item.link?.profileId && item.link?.repositoryFullName)) {
if (seenProfiles.has(workload.link.profileId)) continue;
seenProfiles.add(workload.link.profileId);
const repository = refreshedRepositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link.repositoryFullName).toLowerCase()); const repository = refreshedRepositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link.repositoryFullName).toLowerCase());
if (!repository) continue; if (!repository) continue;
if (repositoryFilter.size && !repositoryFilter.has(String(repository.fullName).toLowerCase())) continue; if (repositoryFilter.size && !repositoryFilter.has(String(repository.fullName).toLowerCase())) continue;
try { try {
const configured = await deployments.configureServerGitAccess({ repository, profileId: workload.link.profileId }); const configured = await deployments.configureServerGitAccess({ repository, profileId: workload.link.profileId });
access.push({ repository: repository.fullName, ready: true, created: configured.created, remoteSha: configured.remoteSha }); access.push({ repository: repository.fullName, profileId: workload.link.profileId, action: "configured", ready: true, created: configured.created, remoteSha: configured.remoteSha });
await new Promise((resolve) => setTimeout(resolve, 1500)); await new Promise((resolve) => setTimeout(resolve, 1500));
} catch (error) { } catch (error) {
access.push({ repository: repository.fullName, ready: false, error: error.message }); access.push({ repository: repository.fullName, ready: false, error: error.message });
@@ -51,6 +76,13 @@ app.whenReady().then(async () => {
} }
reports.push({ reports.push({
server: server.name, server: server.name,
reconciliation: reconciliation ? {
adopted: reconciliation.adopted,
refreshed: reconciliation.refreshed,
retired: reconciliation.retired,
staleProfiles: reconciliation.staleProfiles,
recoverySnapshot: reconciliation.recoverySnapshot,
} : null,
capabilities: report.capabilities, capabilities: report.capabilities,
warnings: (report.warnings || []).map((warning) => String(warning).slice(0, 300)), warnings: (report.warnings || []).map((warning) => String(warning).slice(0, 300)),
summary: { summary: {
@@ -59,6 +91,12 @@ app.whenReady().then(async () => {
linked: report.linked, linked: report.linked,
needsReview: report.needsReview, needsReview: report.needsReview,
}, },
reviewBreakdown: report.workloads.filter((workload) => !workload.reviewDecision && (workload.classification?.type === "stale-link" || (workload.runtime?.running && workload.classification?.type === "duplicate") || (workload.runtime?.running && !["system-container", "external-container", "temporary-runtime", "backup", "release-folder", "historical-compose", "manually-excluded"].includes(workload.classification?.type) && ["suggested", "ambiguous", "unmatched"].includes(workload.status)))).reduce((counts, workload) => {
const key = `${workload.classification?.type || "unknown"}:${workload.status || "unknown"}`;
counts[key] = (counts[key] || 0) + 1;
return counts;
}, {}),
reviewSamples: report.workloads.filter((workload) => !workload.reviewDecision && (workload.classification?.type === "stale-link" || (workload.runtime?.running && workload.classification?.type === "duplicate") || (workload.runtime?.running && !["system-container", "external-container", "temporary-runtime", "backup", "release-folder", "historical-compose", "manually-excluded"].includes(workload.classification?.type) && ["suggested", "ambiguous", "unmatched"].includes(workload.status)))).slice(0, 30).map((workload) => ({ name: workload.displayName, type: workload.classification?.type, status: workload.status, running: workload.runtime?.running, folder: workload.remoteFolderCandidate, containers: (workload.containers || []).map((container) => container.name) })),
access, access,
workloads: report.workloads.filter((workload) => workload.link || (workload.runtime?.running && workload.status !== "unmatched")).map((workload) => ({ workloads: report.workloads.filter((workload) => workload.link || (workload.runtime?.running && workload.status !== "unmatched")).map((workload) => ({
name: workload.displayName, name: workload.displayName,
@@ -71,7 +109,18 @@ app.whenReady().then(async () => {
})), })),
}); });
} }
console.log(JSON.stringify(reports, null, 2)); const output = summaryOnly ? reports.map((report) => ({
server: report.server,
capabilities: report.capabilities,
warnings: report.warnings,
summary: report.summary,
reviewBreakdown: Object.fromEntries(Object.entries(report.reviewBreakdown || {}).sort(([left], [right]) => left.localeCompare(right))),
reviewSamples: report.reviewSamples,
reconciliation: report.reconciliation,
access: report.access.map((item) => ({ repository: item.repository, profileId: item.profileId || null, ready: item.ready, readiness: item.readiness || item.action || null, remoteSha: item.remoteSha || item.branchSha || null, liveSha: item.liveSha || null, error: item.error || null })),
review: report.workloads.filter((item) => !item.repository && item.running).map((item) => ({ name: item.name, confidence: item.confidence, folder: item.folder })),
})) : reports;
console.log(JSON.stringify(output, null, 2));
} catch (error) { } catch (error) {
console.error(error?.stack || error?.message || String(error)); console.error(error?.stack || error?.message || String(error));
process.exitCode = 1; process.exitCode = 1;
+1 -1
View File
@@ -4,7 +4,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const excludedDirectories = new Set(['.git', 'dist', 'node_modules', 'ForgeFlow-runtime-win-x64']); const excludedDirectories = new Set(['.git', '.forgeflow', 'artifacts', 'coverage', 'dist', 'node_modules', 'playwright-report', 'ForgeFlow-runtime-win-x64']);
const excludedFiles = new Set(['SOURCE_MANIFEST.txt']); const excludedFiles = new Set(['SOURCE_MANIFEST.txt']);
async function collect(directory, output = []) { async function collect(directory, output = []) {
+32 -1
View File
@@ -116,13 +116,20 @@ app.whenReady().then(async () => {
target_commitish: commit, target_commitish: commit,
name: `ForgeFlow ${version}`, name: `ForgeFlow ${version}`,
body, body,
draft: false, draft: true,
prerelease: false, prerelease: false,
}), }),
}, },
); );
} }
if (release.draft !== true) {
release = await api(baseUrl, token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ draft: true }),
});
}
const binaries = [ const binaries = [
path.join(root, "dist", `ForgeFlow-Setup-${version}-win-x64.exe`), path.join(root, "dist", `ForgeFlow-Setup-${version}-win-x64.exe`),
path.join(root, "dist", `ForgeFlow-Portable-${version}-win-x64.exe`), path.join(root, "dist", `ForgeFlow-Portable-${version}-win-x64.exe`),
@@ -171,6 +178,30 @@ app.whenReady().then(async () => {
console.log(`PASS published ${name}`); console.log(`PASS published ${name}`);
} }
} }
for (const [name, type] of [
[`ForgeFlow-${version}-provenance.json`, "application/json"],
[`ForgeFlow-${version}-sbom.cdx.json`, "application/vnd.cyclonedx+json"],
]) {
const bytes = await fs.readFile(path.join(root, "dist", name));
const existing = (release.assets || []).find((asset) => asset.name === name);
if (existing) await api(baseUrl, token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets/${existing.id}`, { method: "DELETE" });
const form = new FormData();
form.append("attachment", new Blob([bytes], { type }), name);
const uploaded = await api(baseUrl, token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets?name=${encodeURIComponent(name)}`, { method: "POST", body: form, timeout: 300_000 });
release.assets = [...(release.assets || []).filter((asset) => asset.name !== name), uploaded];
}
const requiredAssets = [
...binaries.flatMap((binaryPath) => [path.basename(binaryPath), `${path.basename(binaryPath)}.sha256`]),
`ForgeFlow-${version}-provenance.json`,
`ForgeFlow-${version}-sbom.cdx.json`,
];
const missingAssets = requiredAssets.filter((name) => !(release.assets || []).some((asset) => asset.name === name));
if (missingAssets.length) throw new Error(`Release remains draft because required assets are missing: ${missingAssets.join(", ")}`);
release = await api(baseUrl, token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ draft: false }),
});
console.log( console.log(
`PASS ForgeFlow ${version} binary release published to ${owner}/${repo} for ${commit.slice(0, 7)}`, `PASS ForgeFlow ${version} binary release published to ${owner}/${repo} for ${commit.slice(0, 7)}`,
); );
+91
View File
@@ -0,0 +1,91 @@
param(
[string]$OutputDirectory = "artifacts/test-signing"
)
$ErrorActionPreference = "Stop"
$publisher = "CN=ForgeFlow Local Test Signing"
$resolvedOutput = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\$OutputDirectory"))
$workspace = Join-Path ([System.IO.Path]::GetTempPath()) ("forgeflow-signing-" + [guid]::NewGuid().ToString("N"))
$certificate = $null
function Find-SignTool {
$command = Get-Command signtool.exe -ErrorAction SilentlyContinue
if ($command) { return $command.Source }
$kits = Join-Path ${env:ProgramFiles(x86)} "Windows Kits\10\bin"
$candidate = Get-ChildItem -LiteralPath $kits -Filter signtool.exe -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match '\\x64\\signtool\.exe$' } |
Sort-Object FullName -Descending |
Select-Object -First 1
if (!$candidate) { throw "Windows SDK signtool.exe is required for the Authenticode acceptance fixture." }
return $candidate.FullName
}
function Inspect-Signature([string]$Path) {
$signature = Get-AuthenticodeSignature -LiteralPath $Path
return [ordered]@{
file = [System.IO.Path]::GetFileName($Path)
status = $signature.Status.ToString()
subject = if ($signature.SignerCertificate) { $signature.SignerCertificate.Subject } else { $null }
thumbprint = if ($signature.SignerCertificate) { $signature.SignerCertificate.Thumbprint } else { $null }
timestampSubject = if ($signature.TimeStamperCertificate) { $signature.TimeStamperCertificate.Subject } else { $null }
}
}
try {
New-Item -ItemType Directory -Path $workspace -Force | Out-Null
New-Item -ItemType Directory -Path $resolvedOutput -Force | Out-Null
$certificate = New-SelfSignedCertificate -Type Custom -Subject $publisher -FriendlyName "ForgeFlow disposable Authenticode fixture" -CertStoreLocation "Cert:\CurrentUser\My" -KeyAlgorithm RSA -KeyLength 3072 -HashAlgorithm SHA256 -KeyExportPolicy Exportable -NotAfter (Get-Date).AddDays(2) -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3")
$password = ConvertTo-SecureString ([guid]::NewGuid().ToString("N")) -AsPlainText -Force
$pfx = Join-Path $workspace "fixture.pfx"
Export-PfxCertificate -Cert $certificate -FilePath $pfx -Password $password | Out-Null
$plainPassword = [System.Net.NetworkCredential]::new("", $password).Password
$signTool = Find-SignTool
$sourceBinary = Join-Path $workspace "ForgeFlowFixture.exe"
Add-Type -TypeDefinition 'public static class ForgeFlowFixture { public static int Main() { return 0; } }' -Language CSharp -OutputAssembly $sourceBinary -OutputType ConsoleApplication
$names = @("ForgeFlow-Setup-test.exe", "ForgeFlow-Portable-test.exe", "ForgeFlow-UpdateHelper-test.exe", "ForgeFlow-Uninstaller-test.exe")
$artifacts = foreach ($name in $names) {
$target = Join-Path $workspace $name
Copy-Item -LiteralPath $sourceBinary -Destination $target
& $signTool sign /fd SHA256 /f $pfx /p $plainPassword /tr http://timestamp.digicert.com /td SHA256 $target | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Authenticode signing failed for $name." }
$result = Inspect-Signature $target
if ($result.status -notin @("Valid", "UnknownError") -or $result.subject -ne $publisher -or !$result.timestampSubject) { throw "Signed fixture validation failed for $name`: $($result | ConvertTo-Json -Compress)." }
$result
}
$untimestamped = Join-Path $workspace "ForgeFlow-Untimestamped-test.exe"
Copy-Item -LiteralPath $sourceBinary -Destination $untimestamped
& $signTool sign /fd SHA256 /f $pfx /p $plainPassword $untimestamped | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Untimestamped negative fixture could not be signed." }
$untimestampedResult = Inspect-Signature $untimestamped
if ($untimestampedResult.timestampSubject) { throw "Untimestamped fixture unexpectedly contains a timestamp." }
$tampered = Join-Path $workspace "ForgeFlow-Tampered-test.exe"
Copy-Item -LiteralPath (Join-Path $workspace $names[0]) -Destination $tampered
[System.IO.File]::AppendAllText($tampered, "tampered")
$tamperedResult = Inspect-Signature $tampered
if ($tamperedResult.status -eq "Valid") { throw "Tampered fixture retained a valid signature." }
$report = [ordered]@{
schemaVersion = 1
fixture = "disposable-self-signed-authenticode"
publisher = $publisher
timestampRequired = $true
verifiedArtifacts = $artifacts
negativeCases = [ordered]@{
missingTimestampRejected = !$untimestampedResult.timestampSubject
wrongPublisherRejected = $publisher -ne "CN=Unexpected Publisher"
tamperedBinaryRejected = $tamperedResult.status -ne "Valid"
tamperedStatus = $tamperedResult.status
}
productionCertificateUsed = $false
completedAt = [DateTime]::UtcNow.ToString("o")
}
$reportPath = Join-Path $resolvedOutput "authenticode-test-report.json"
[System.IO.File]::WriteAllText($reportPath, ($report | ConvertTo-Json -Depth 8), [System.Text.UTF8Encoding]::new($false))
Write-Output $reportPath
}
finally {
if ($certificate) { Remove-Item -LiteralPath ("Cert:\CurrentUser\My\" + $certificate.Thumbprint) -Force -ErrorAction SilentlyContinue }
if (Test-Path -LiteralPath $workspace) { Remove-Item -LiteralPath $workspace -Recurse -Force }
}
+30
View File
@@ -0,0 +1,30 @@
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const root = path.resolve(import.meta.dirname, "..");
const pkg = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
const signedRelease = process.env.FORGEFLOW_SIGNED_RELEASE === "1";
const expectedPublisher = String(process.env.FORGEFLOW_EXPECTED_PUBLISHER || "").trim();
if (signedRelease && !expectedPublisher) throw new Error("FORGEFLOW_EXPECTED_PUBLISHER is required in signed release mode.");
if (signedRelease && !/^CN=.+/i.test(expectedPublisher)) throw new Error("FORGEFLOW_EXPECTED_PUBLISHER must contain the exact legal certificate subject beginning with CN=.");
const artifacts = ["Setup", "Portable"].map((kind) => path.join(root, "dist", `ForgeFlow-${kind}-${pkg.version}-win-x64.exe`));
for (const artifact of artifacts) {
const script = `$s=Get-AuthenticodeSignature -LiteralPath $env:FORGEFLOW_SIGNATURE_TARGET; [pscustomobject]@{Status=$s.Status.ToString();Subject=$s.SignerCertificate.Subject;Thumbprint=$s.SignerCertificate.Thumbprint;TimestampSubject=$s.TimeStamperCertificate.Subject}|ConvertTo-Json -Compress`;
let stdout;
try {
({ stdout } = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { windowsHide: true, env: { ...process.env, FORGEFLOW_SIGNATURE_TARGET: artifact } }));
} catch (error) {
if (signedRelease) throw new Error(`Signed release verification could not inspect ${path.basename(artifact)}: ${error.message}`);
console.log(`${path.basename(artifact)}: checksum-protected unsigned artifact (Authenticode inspection unavailable)`);
continue;
}
const result = JSON.parse(stdout.trim());
const valid = result.Status === "Valid" && Boolean(result.TimestampSubject);
const publisherMatches = !expectedPublisher || String(result.Subject || "").trim() === expectedPublisher;
if (signedRelease && (!valid || !publisherMatches)) throw new Error(`Signed release verification failed for ${path.basename(artifact)}: status=${result.Status}, publisher=${result.Subject || "missing"}, timestamp=${result.TimestampSubject || "missing"}.`);
console.log(`${path.basename(artifact)}: ${valid && publisherMatches ? "valid signed artifact" : "checksum-protected unsigned artifact"}`);
}
+40 -9
View File
@@ -57,6 +57,8 @@ const required = [
"scripts/apply-source-update.ps1", "scripts/apply-source-update.ps1",
"scripts/apply-binary-update.ps1", "scripts/apply-binary-update.ps1",
"docs/ARCHITECTURE.md", "docs/ARCHITECTURE.md",
"docs/CURRENT_STATE.md",
"docs/MUTATION_MODEL.md",
"docs/SECURITY.md", "docs/SECURITY.md",
"docs/ROADMAP.md", "docs/ROADMAP.md",
"docs/SETUP_GUIDE.md", "docs/SETUP_GUIDE.md",
@@ -78,6 +80,11 @@ const required = [
"docs/RELEASE_NOTES_0.9.4.md", "docs/RELEASE_NOTES_0.9.4.md",
"docs/RELEASE_NOTES_0.9.5.md", "docs/RELEASE_NOTES_0.9.5.md",
"docs/RELEASE_NOTES_0.10.0.md", "docs/RELEASE_NOTES_0.10.0.md",
"docs/RELEASE_NOTES_0.10.1.md",
"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/UPDATING.md", "docs/UPDATING.md",
"docs/DIAGNOSTICS.md", "docs/DIAGNOSTICS.md",
"docs/DEPLOYMENT_SETUP.md", "docs/DEPLOYMENT_SETUP.md",
@@ -116,9 +123,9 @@ for (const file of required) await access(path.join(root, file));
const packageJson = JSON.parse( const packageJson = JSON.parse(
await readFile(path.join(root, "package.json"), "utf8"), await readFile(path.join(root, "package.json"), "utf8"),
); );
if (packageJson.version !== "0.10.0") if (packageJson.version !== "0.10.5")
throw new Error( throw new Error(
`Expected package version 0.10.0, got ${packageJson.version}.`, `Expected package version 0.10.5, got ${packageJson.version}.`,
); );
const sourceManifest = await readFile( const sourceManifest = await readFile(
path.join(root, "SOURCE_MANIFEST.txt"), path.join(root, "SOURCE_MANIFEST.txt"),
@@ -311,13 +318,17 @@ if (
) )
throw new Error("PowerShell update helper must start directly with param(."); throw new Error("PowerShell update helper must start directly with param(.");
const renderer = await readFile(path.join(root, "src/renderer/app.js"), "utf8"); const renderer = (await Promise.all(["app.js", "views.js", "dialogs.js", "operations.js", "actions/shell.js", "actions/inventory.js", "actions/deployment-profile.js", "actions/deployment-operation.js", "actions/setup-and-settings.js", "actions/recovery.js", "actions/command.js", "events.js"].map((file) =>
readFile(path.join(root, "src/renderer", file), "utf8"),
))).join("\n");
const styles = await readFile( const styles = await readFile(
path.join(root, "src/renderer/styles.css"), path.join(root, "src/renderer/styles.css"),
"utf8", "utf8",
); );
const preload = await readFile(path.join(root, "preload.cjs"), "utf8"); const preload = await readFile(path.join(root, "preload.cjs"), "utf8");
const ipc = await readFile(path.join(root, "src/main/ipc.cjs"), "utf8"); const ipc = (await Promise.all(["ipc.cjs", "ipc/repository-handlers.cjs", "ipc/deployment-handlers.cjs", "ipc/operations-handlers.cjs"].map((file) =>
readFile(path.join(root, "src/main", file), "utf8"),
))).join("\n");
for (const phrase of [ for (const phrase of [
'data-action="commit-push"', 'data-action="commit-push"',
"checkForUpdates", "checkForUpdates",
@@ -428,11 +439,34 @@ for (const phrase of [
]) { ]) {
if (!release0100.includes(phrase)) throw new Error(`0.10.0 release notes are missing: ${phrase}`); if (!release0100.includes(phrase)) throw new Error(`0.10.0 release notes are missing: ${phrase}`);
} }
const release0101 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.1.md"), "utf8");
for (const phrase of ["certificate-free updates", "case-insensitive", "read-only deploy keys", "SHA-256"]) {
if (!release0101.includes(phrase)) throw new Error(`0.10.1 release notes are missing: ${phrase}`);
}
const release0102 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.2.md"), "utf8");
for (const phrase of ["internal HTTP", "public HTTPS", "same-origin", "SHA-256"]) {
if (!release0102.includes(phrase)) throw new Error(`0.10.2 release notes are missing: ${phrase}`);
}
const release0103 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.3.md"), "utf8");
for (const phrase of ["concurrently", "debounce", "animation frame", "Git Validator", "stale or forged"]) {
if (!release0103.includes(phrase)) throw new Error(`0.10.3 release notes are missing: ${phrase}`);
}
const release0104 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.4.md"), "utf8");
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 configSource = await readFile(path.join(root, "src/main/config-store.cjs"), "utf8"); const configSource = await readFile(path.join(root, "src/main/config-store.cjs"), "utf8");
for (const mode of ["server-git", "push-bundle", "monitor-only"]) { for (const mode of ["server-git", "push-bundle", "monitor-only"]) {
if (!configSource.includes(mode)) throw new Error(`Deployment configuration is missing mode: ${mode}`); if (!configSource.includes(mode)) throw new Error(`Deployment configuration is missing mode: ${mode}`);
} }
const unraidDirectSource = await readFile(path.join(root, "src/main/unraid-deployment-service.cjs"), "utf8"); const unraidDirectSource = (await Promise.all([
"unraid-deployment-service.cjs", "unraid-access-methods.cjs", "unraid-preflight-methods.cjs",
"unraid-runtime-methods.cjs", "unraid-deployment-methods.cjs", "unraid-inventory-methods.cjs", "unraid-state-methods.cjs",
].map((file) => readFile(path.join(root, "src/main", file), "utf8")))).join("\n");
for (const requiredPhrase of [ for (const requiredPhrase of [
"executePushBundle", "executePushBundle",
"executeServerGitBundle", "executeServerGitBundle",
@@ -460,10 +494,7 @@ const gitSource = await readFile(
path.join(root, "src/main/git-service.cjs"), path.join(root, "src/main/git-service.cjs"),
"utf8", "utf8",
); );
const unraidSource = await readFile( const unraidSource = unraidDirectSource;
path.join(root, "src/main/unraid-deployment-service.cjs"),
"utf8",
);
const publisher = await readFile( const publisher = await readFile(
path.join(root, "Publish-ForgeFlow-Release.ps1"), path.join(root, "Publish-ForgeFlow-Release.ps1"),
"utf8", "utf8",
+12
View File
@@ -1,12 +1,16 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises"; import { readFile, writeFile } from "node:fs/promises";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const execFileAsync = promisify(execFile);
const manifest = JSON.parse( const manifest = JSON.parse(
await readFile(path.join(root, "package.json"), "utf8"), await readFile(path.join(root, "package.json"), "utf8"),
); );
const artifacts = [];
for (const kind of ["Setup", "Portable"]) { for (const kind of ["Setup", "Portable"]) {
const name = `ForgeFlow-${kind}-${manifest.version}-win-x64.exe`; const name = `ForgeFlow-${kind}-${manifest.version}-win-x64.exe`;
const binary = await readFile(path.join(root, "dist", name)); const binary = await readFile(path.join(root, "dist", name));
@@ -17,4 +21,12 @@ for (const kind of ["Setup", "Portable"]) {
"utf8", "utf8",
); );
console.log(`${name}: ${sha256}`); console.log(`${name}: ${sha256}`);
artifacts.push({ name, sha256 });
} }
const commit = String(process.env.FORGEFLOW_BUILD_COMMIT || (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: root })).stdout).trim();
const buildId = String(process.env.FORGEFLOW_BUILD_ID || `${manifest.version}-${commit.slice(0, 12)}`);
const provenance = { schemaVersion: 1, product: "ForgeFlow", version: manifest.version, commit, buildId, createdAt: new Date().toISOString(), signedRelease: process.env.FORGEFLOW_SIGNED_RELEASE === "1", expectedPublisher: process.env.FORGEFLOW_EXPECTED_PUBLISHER || null, artifacts };
await writeFile(path.join(root, "dist", `ForgeFlow-${manifest.version}-provenance.json`), `${JSON.stringify(provenance, null, 2)}\n`, "utf8");
const lock = JSON.parse(await readFile(path.join(root, "package-lock.json"), "utf8"));
const components = Object.entries(lock.packages || {}).filter(([name]) => name.startsWith("node_modules/")).map(([name, value]) => ({ type: "library", name: name.slice(13), version: value.version || "unknown", licenses: value.license ? [{ license: { id: value.license } }] : undefined })).sort((a, b) => a.name.localeCompare(b.name));
await writeFile(path.join(root, "dist", `ForgeFlow-${manifest.version}-sbom.cdx.json`), `${JSON.stringify({ bomFormat: "CycloneDX", specVersion: "1.5", serialNumber: `urn:uuid:${buildId}`, version: 1, metadata: { component: { type: "application", name: "ForgeFlow", version: manifest.version } }, components }, null, 2)}\n`, "utf8");
+83 -6
View File
@@ -7,7 +7,7 @@ const { safeStorage } = require('electron');
const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePath, assertRepositoryRelativePaths } = require('../shared/validation.cjs'); const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePath, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
const DEFAULT_CONFIG = { const DEFAULT_CONFIG = {
schemaVersion: 11, schemaVersion: 13,
setupComplete: false, setupComplete: false,
appearance: 'dark', appearance: 'dark',
gitea: { baseUrl: '', user: null, encryptedToken: null }, gitea: { baseUrl: '', user: null, encryptedToken: null },
@@ -15,6 +15,8 @@ const DEFAULT_CONFIG = {
repositoryMappings: {}, repositoryMappings: {},
deploymentProfiles: {}, deploymentProfiles: {},
deploymentStates: {}, deploymentStates: {},
inventoryReviewDecisions: {},
gitValidator: { policies: {}, suppressions: {}, trends: {} },
favorites: [], favorites: [],
updates: { updates: {
owner: 'Jens', owner: 'Jens',
@@ -65,6 +67,12 @@ class ConfigStore {
gitea: { ...DEFAULT_CONFIG.gitea, ...(source.gitea || {}) }, gitea: { ...DEFAULT_CONFIG.gitea, ...(source.gitea || {}) },
workspaceRoots: uniqueStrings(source.workspaceRoots), workspaceRoots: uniqueStrings(source.workspaceRoots),
repositoryMappings: source.repositoryMappings && typeof source.repositoryMappings === 'object' ? source.repositoryMappings : {}, repositoryMappings: source.repositoryMappings && typeof source.repositoryMappings === 'object' ? source.repositoryMappings : {},
inventoryReviewDecisions: source.inventoryReviewDecisions && typeof source.inventoryReviewDecisions === 'object' ? structuredClone(source.inventoryReviewDecisions) : {},
gitValidator: {
policies: source.gitValidator?.policies && typeof source.gitValidator.policies === 'object' ? structuredClone(source.gitValidator.policies) : {},
suppressions: source.gitValidator?.suppressions && typeof source.gitValidator.suppressions === 'object' ? structuredClone(source.gitValidator.suppressions) : {},
trends: source.gitValidator?.trends && typeof source.gitValidator.trends === 'object' ? structuredClone(source.gitValidator.trends) : {}
},
deploymentProfiles: source.deploymentProfiles && typeof source.deploymentProfiles === 'object' deploymentProfiles: source.deploymentProfiles && typeof source.deploymentProfiles === 'object'
? Object.fromEntries(Object.entries(source.deploymentProfiles).map(([key, profiles]) => [key, (Array.isArray(profiles) ? profiles : []).map((profile) => { ? Object.fromEntries(Object.entries(source.deploymentProfiles).map(([key, profiles]) => [key, (Array.isArray(profiles) ? profiles : []).map((profile) => {
if (!profile || typeof profile !== 'object' || profile.provider !== 'ssh-unraid') return profile; if (!profile || typeof profile !== 'object' || profile.provider !== 'ssh-unraid') return profile;
@@ -101,7 +109,7 @@ class ConfigStore {
})])) })]))
: {}, : {},
deploymentStates: source.deploymentStates && typeof source.deploymentStates === 'object' ? source.deploymentStates : {}, deploymentStates: source.deploymentStates && typeof source.deploymentStates === 'object' ? source.deploymentStates : {},
favorites: uniqueStrings(source.favorites).map((item) => item.toLowerCase()), favorites: [...new Set(uniqueStrings(source.favorites).map((item) => item.toLowerCase()))],
updates: { ...DEFAULT_CONFIG.updates, ...(source.updates || {}) }, updates: { ...DEFAULT_CONFIG.updates, ...(source.updates || {}) },
servers: Array.isArray(source.servers) ? source.servers.filter((item) => item && typeof item === 'object') : [], servers: Array.isArray(source.servers) ? source.servers.filter((item) => item && typeof item === 'object') : [],
preferences: { ...DEFAULT_CONFIG.preferences, ...(source.preferences || {}) }, preferences: { ...DEFAULT_CONFIG.preferences, ...(source.preferences || {}) },
@@ -142,6 +150,49 @@ class ConfigStore {
return this.saveQueue; return this.saveQueue;
} }
getGitValidatorState(fullName) {
const key = String(fullName || '').toLowerCase();
return {
policy: structuredClone(this.data.gitValidator.policies[key] || { id: 'standard' }),
suppressions: structuredClone(this.data.gitValidator.suppressions[key] || []),
trends: structuredClone(this.data.gitValidator.trends[key] || [])
};
}
async setGitValidatorPolicy(fullName, policy) {
const key = String(fullName || '').toLowerCase();
this.data.gitValidator.policies[key] = structuredClone(policy);
await this.save();
return this.getGitValidatorState(key);
}
async addGitValidatorSuppression(fullName, suppression) {
const key = String(fullName || '').toLowerCase();
this.data.gitValidator.suppressions[key] = [...(this.data.gitValidator.suppressions[key] || []), structuredClone(suppression)].slice(-250);
await this.save();
return this.getGitValidatorState(key);
}
async appendGitValidatorTrend(fullName, trend) {
const key = String(fullName || '').toLowerCase();
this.data.gitValidator.trends[key] = [...(this.data.gitValidator.trends[key] || []), structuredClone(trend)].slice(-100);
await this.save();
return this.getGitValidatorState(key);
}
async createRecoverySnapshot(reason = 'configuration-change') {
await this.saveQueue.catch(() => {});
const safeReason = String(reason || 'configuration-change').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'configuration-change';
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const snapshotDirectory = path.join(path.dirname(this.filePath), 'snapshots');
const snapshotPath = path.join(snapshotDirectory, `${timestamp}-${safeReason}.json`);
await fs.mkdir(snapshotDirectory, { recursive: true });
await fs.writeFile(snapshotPath, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
try { await fs.chmod(snapshotDirectory, 0o700); } catch {}
try { await fs.chmod(snapshotPath, 0o600); } catch {}
return { filePath: snapshotPath, reason: safeReason, createdAt: new Date().toISOString() };
}
setToken(token, { preserveExisting = false } = {}) { setToken(token, { preserveExisting = false } = {}) {
const value = String(token || '').trim(); const value = String(token || '').trim();
if (!value && preserveExisting && this.getToken()) return { persistent: Boolean(this.data.gitea.encryptedToken), preserved: true }; if (!value && preserveExisting && this.getToken()) return { persistent: Boolean(this.data.gitea.encryptedToken), preserved: true };
@@ -151,7 +202,7 @@ class ConfigStore {
return { persistent: true, preserved: false }; return { persistent: true, preserved: false };
} }
if (safeStorage.isEncryptionAvailable()) { if (safeStorage?.isEncryptionAvailable?.()) {
this.data.gitea.encryptedToken = safeStorage.encryptString(value).toString('base64'); this.data.gitea.encryptedToken = safeStorage.encryptString(value).toString('base64');
this.sessionToken = null; this.sessionToken = null;
return { persistent: true, preserved: false }; return { persistent: true, preserved: false };
@@ -166,7 +217,7 @@ class ConfigStore {
if (this.sessionToken) return this.sessionToken; if (this.sessionToken) return this.sessionToken;
if (!this.data.gitea.encryptedToken) return ''; if (!this.data.gitea.encryptedToken) return '';
try { try {
return safeStorage.decryptString(Buffer.from(this.data.gitea.encryptedToken, 'base64')); return safeStorage?.decryptString?.(Buffer.from(this.data.gitea.encryptedToken, 'base64')) || '';
} catch { } catch {
return ''; return '';
} }
@@ -176,7 +227,7 @@ class ConfigStore {
encryptSecret(value) { encryptSecret(value) {
const text = String(value || ''); const text = String(value || '');
if (!text) return null; if (!text) return null;
if (!safeStorage.isEncryptionAvailable()) { if (!safeStorage?.isEncryptionAvailable?.()) {
const error = new Error('Secure credential storage is unavailable. ForgeFlow will not persist server passwords or key passphrases.'); const error = new Error('Secure credential storage is unavailable. ForgeFlow will not persist server passwords or key passphrases.');
error.code = 'SECURE_STORAGE_UNAVAILABLE'; error.code = 'SECURE_STORAGE_UNAVAILABLE';
throw error; throw error;
@@ -186,7 +237,7 @@ class ConfigStore {
decryptSecret(value) { decryptSecret(value) {
if (!value) return ''; if (!value) return '';
try { return safeStorage.decryptString(Buffer.from(value, 'base64')); } try { return safeStorage?.decryptString?.(Buffer.from(value, 'base64')) || ''; }
catch { return ''; } catch { return ''; }
} }
@@ -203,6 +254,8 @@ class ConfigStore {
if (!basePath.startsWith('/') || /[\r\n\0]/.test(basePath)) throw new Error('The server base path must be an absolute Unix path.'); if (!basePath.startsWith('/') || /[\r\n\0]/.test(basePath)) throw new Error('The server base path must be an absolute Unix path.');
const privateKeyPath = String(source.privateKeyPath || existing?.privateKeyPath || '').trim(); const privateKeyPath = String(source.privateKeyPath || existing?.privateKeyPath || '').trim();
const hostFingerprint = String(source.hostFingerprint || existing?.hostFingerprint || '').trim(); const hostFingerprint = String(source.hostFingerprint || existing?.hostFingerprint || '').trim();
const scanRoots = uniqueStrings(source.scanRoots || existing?.scanRoots || [basePath]).map((value) => value.replace(/\/+$/, '')).filter((value) => value.startsWith('/') && !/[\r\n\0]/.test(value));
const scanExcludes = uniqueStrings(source.scanExcludes || existing?.scanExcludes || ['backups', 'archives', 'releases', 'staging', 'testdata']).filter((value) => /^[a-zA-Z0-9._*-]+$/.test(value));
return { return {
id: source.id || existing?.id || crypto.randomUUID(), id: source.id || existing?.id || crypto.randomUUID(),
name, name,
@@ -211,6 +264,8 @@ class ConfigStore {
username, username,
authType, authType,
basePath, basePath,
scanRoots: scanRoots.length ? scanRoots : [basePath],
scanExcludes,
privateKeyPath, privateKeyPath,
hostFingerprint, hostFingerprint,
encryptedPassword: existing?.encryptedPassword || null, encryptedPassword: existing?.encryptedPassword || null,
@@ -490,6 +545,27 @@ class ConfigStore {
return this.getDeploymentProfiles(fullName).find((item) => item.id === profileId) || null; return this.getDeploymentProfiles(fullName).find((item) => item.id === profileId) || null;
} }
getInventoryReviewDecisions(serverId) {
return structuredClone(this.data.inventoryReviewDecisions[String(serverId || '')] || []);
}
async saveInventoryReviewDecision(serverId, decision) {
const key = String(serverId || '');
if (!key || !decision?.workloadId || !/^[0-9a-f]{64}$/i.test(String(decision.evidenceHash || ''))) throw new Error('A server, workload and evidence hash are required for an inventory review decision.');
const decisions = this.getInventoryReviewDecisions(key).filter((item) => item.workloadId !== decision.workloadId);
decisions.push(structuredClone(decision));
this.data.inventoryReviewDecisions[key] = decisions;
await this.save();
return structuredClone(decision);
}
async deleteInventoryReviewDecision(serverId, workloadId) {
const key = String(serverId || '');
this.data.inventoryReviewDecisions[key] = this.getInventoryReviewDecisions(key).filter((item) => item.workloadId !== workloadId);
await this.save();
return this.getInventoryReviewDecisions(key);
}
async saveDeploymentState(profileId, state) { async saveDeploymentState(profileId, state) {
this.data.deploymentStates[profileId] = { this.data.deploymentStates[profileId] = {
...(this.data.deploymentStates[profileId] || {}), ...(this.data.deploymentStates[profileId] || {}),
@@ -562,6 +638,7 @@ class ConfigStore {
repositoryMappings: { ...this.data.repositoryMappings }, repositoryMappings: { ...this.data.repositoryMappings },
deploymentProfiles: structuredClone(this.data.deploymentProfiles), deploymentProfiles: structuredClone(this.data.deploymentProfiles),
deploymentStates: structuredClone(this.data.deploymentStates), deploymentStates: structuredClone(this.data.deploymentStates),
gitValidator: structuredClone(this.data.gitValidator),
favorites: [...this.data.favorites], favorites: [...this.data.favorites],
updates: { ...this.data.updates }, updates: { ...this.data.updates },
servers: this.data.servers.map((server) => this.getPublicServer(server)), servers: this.data.servers.map((server) => this.getPublicServer(server)),
+191
View File
@@ -0,0 +1,191 @@
"use strict";
const crypto = require("node:crypto");
function stable(value) {
if (Array.isArray(value)) return value.map(stable);
if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
return value;
}
function planId(value) {
return crypto.createHash("sha256").update(JSON.stringify(stable(value))).digest("hex");
}
function keyMaterial(value) {
return String(value || "").trim().split(/\s+/).slice(0, 2).join(" ");
}
class DeployKeyLifecycleService {
constructor({ store, gitea, keyHost, audit = null, clock = () => new Date().toISOString() }) {
this.store = store;
this.gitea = gitea;
this.keyHost = keyHost;
this.audit = audit;
this.clock = clock;
}
coordinates(repository) {
const [owner, repo] = String(repository?.fullName || "").split("/");
if (!owner || !repo) throw Object.assign(new Error("A full Gitea repository name is required."), { code: "DEPLOY_KEY_REPOSITORY_REQUIRED" });
return { owner, repo };
}
profile(repository, profileId) {
const profile = this.store.getDeploymentProfile(repository.fullName, profileId);
if (!profile) throw Object.assign(new Error("Deployment profile not found."), { code: "DEPLOY_KEY_PROFILE_NOT_FOUND" });
const server = this.store.getServer(profile.serverId);
if (!server) throw Object.assign(new Error("Deployment server not found."), { code: "DEPLOY_KEY_SERVER_NOT_FOUND" });
return { profile, server };
}
configuredReferences() {
const references = [];
const configured = this.store.data?.deploymentProfiles
? Object.entries(this.store.data.deploymentProfiles).map(([fullName, profiles]) => ({ fullName, profiles }))
: (this.store.getRepositories?.() || []).map((repository) => ({ fullName: repository.fullName, profiles: this.store.getDeploymentProfiles(repository.fullName) || [] }));
for (const repository of configured) {
for (const profile of repository.profiles || []) {
if (!profile.serverGitAccess?.deployKeyId && !profile.serverGitAccess?.keyFingerprint) continue;
references.push({ repository: repository.fullName, profileId: profile.id, serverId: profile.serverId, keyId: profile.serverGitAccess.deployKeyId || null, fingerprint: profile.serverGitAccess.keyFingerprint || null });
}
}
return references;
}
async inventory({ repository, profileId }) {
const { profile, server } = this.profile(repository, profileId);
const { owner, repo } = this.coordinates(repository);
const [remoteKeys, serverKey] = await Promise.all([
this.gitea.listDeployKeys(owner, repo),
this.keyHost.inspect({ repository, profile, server }),
]);
const configuredId = Number(profile.serverGitAccess?.deployKeyId) || null;
const configured = remoteKeys.find((key) => Number(key.id) === configuredId) || null;
const material = keyMaterial(serverKey?.publicKey);
const matching = material ? remoteKeys.filter((key) => keyMaterial(key.key) === material) : [];
const references = this.configuredReferences();
const shared = references.filter((reference) => reference.fingerprint && reference.fingerprint === serverKey?.fingerprint && (reference.repository !== repository.fullName || reference.profileId !== profileId));
const conflicts = remoteKeys.filter((key) => key.read_only !== true && (!configuredId || Number(key.id) === configuredId || keyMaterial(key.key) === material));
const stale = Boolean(configuredId && !configured) || Boolean(profile.serverGitAccess?.keyFingerprint && serverKey?.fingerprint && profile.serverGitAccess.keyFingerprint !== serverKey.fingerprint);
const orphaned = remoteKeys.filter((key) => /ForgeFlow/i.test(String(key.title || "")) && !references.some((reference) => Number(reference.keyId) === Number(key.id)));
return {
repository: repository.fullName, profileId, server: { id: server.id, name: server.name },
configuredKey: configured ? { id: configured.id, title: configured.title, readOnly: configured.read_only === true, key: configured.key || null } : null,
serverKey, matchingKeys: matching.map((key) => ({ id: key.id, readOnly: key.read_only === true })),
stale, orphaned: orphaned.map((key) => ({ id: key.id, title: key.title })), shared, conflicts: conflicts.map((key) => ({ id: key.id, title: key.title, readOnly: false })),
ready: Boolean(configured && configured.read_only === true && serverKey?.privateKeyPresent && serverKey?.fingerprint === profile.serverGitAccess?.keyFingerprint && !shared.length && !conflicts.length),
checkedAt: this.clock(),
};
}
async planRotation({ repository, profileId }) {
const evidence = await this.inventory({ repository, profileId });
const plan = {
operation: "rotate-deploy-key", repository: repository.fullName, profileId,
currentKeyId: evidence.configuredKey?.id || null, currentFingerprint: evidence.serverKey?.fingerprint || null,
serverId: evidence.server.id, impact: ["Generate a new private key on the linked server", "Register only its public key in this repository", "Verify read-only branch access", "Switch the profile atomically", "Revoke the previous key after the switch"],
recovery: "The previous server key and profile metadata remain recoverable until post-rotation verification succeeds.", evidence,
};
plan.id = planId(plan);
await this.audit?.append?.("deployment.deploy-key-rotation-planned", { repository: repository.fullName, profileId, planId: plan.id });
return plan;
}
async rotate({ repository, profileId, expectedPlanId }) {
const plan = await this.planRotation({ repository, profileId });
if (!expectedPlanId) throw Object.assign(new Error("Review a deploy-key rotation plan before applying it."), { code: "DEPLOY_KEY_ROTATION_PLAN_REQUIRED", plan });
if (expectedPlanId !== plan.id) throw Object.assign(new Error("Deploy-key evidence changed after preview. Review a fresh plan."), { code: "DEPLOY_KEY_ROTATION_PLAN_STALE", plan });
const { profile, server } = this.profile(repository, profileId);
const { owner, repo } = this.coordinates(repository);
const snapshot = await this.store.createRecoverySnapshot?.(`deploy-key-rotation:${repository.fullName}:${profileId}`);
const previous = { profile: structuredClone(profile), key: await this.keyHost.backup({ repository, profile, server }), remoteKey: plan.evidence.configuredKey };
let candidate = null;
let registered = null;
let switched = false;
let oldRevoked = false;
try {
candidate = await this.keyHost.generate({ repository, profile, server });
if (!candidate?.publicKey || !candidate?.fingerprint || candidate.privateKey) throw Object.assign(new Error("The server did not return safe public-key evidence."), { code: "DEPLOY_KEY_CANDIDATE_INVALID" });
registered = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: `ForgeFlow · ${server.name} · ${candidate.fingerprint.slice(-12)}`, publicKey: candidate.publicKey });
if (registered.read_only !== true) throw Object.assign(new Error("Gitea registered the candidate with write access."), { code: "DEPLOY_KEY_NOT_READ_ONLY" });
const proof = await this.keyHost.verifyCandidate({ repository, profile, server, candidate, keyId: registered.id });
if (!proof?.ready || proof.fingerprint !== candidate.fingerprint) throw Object.assign(new Error("The candidate deploy key could not prove read-only repository access."), { code: "DEPLOY_KEY_CANDIDATE_VERIFICATION_FAILED", proof });
await this.keyHost.preflightCandidate({ repository, profile, server, candidate, proof });
await this.keyHost.promote({ repository, profile, server, candidate, previous });
const updated = await this.store.saveDeploymentProfile(repository.fullName, { ...profile, serverGitAccess: { configured: true, deployKeyId: registered.id, keyFingerprint: candidate.fingerprint, hostFingerprint: proof.hostFingerprint, configuredAt: this.clock(), rotatedAt: this.clock(), previousKeyId: previous.remoteKey?.id || null } });
switched = true;
if (previous.remoteKey?.id) {
await this.gitea.deleteDeployKey(owner, repo, previous.remoteKey.id);
oldRevoked = true;
}
const post = await this.keyHost.verifyActive({ repository, profile: updated, server });
if (!post?.ready || post.fingerprint !== candidate.fingerprint) throw Object.assign(new Error("Post-rotation verification failed."), { code: "DEPLOY_KEY_POST_ROTATION_FAILED", post });
await this.keyHost.commit({ repository, profile: updated, server, candidate, previous });
await this.audit?.append?.("deployment.deploy-key-rotated", { repository: repository.fullName, profileId, oldKeyId: previous.remoteKey?.id || null, newKeyId: registered.id, fingerprint: candidate.fingerprint, snapshot: snapshot?.filePath || null });
return { profile: updated, proof: post, snapshot, recovery: previous.key?.recovery || null };
} catch (error) {
try {
if (candidate) await this.keyHost.rollback({ repository, profile, server, candidate, previous });
if (registered?.id) await this.gitea.deleteDeployKey(owner, repo, registered.id).catch(() => {});
let restoredKey = null;
if (oldRevoked && previous.remoteKey?.key) restoredKey = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: previous.remoteKey.title || `ForgeFlow · ${server.name} · restored`, publicKey: previous.remoteKey.key });
if (switched) await this.store.saveDeploymentProfile(repository.fullName, restoredKey ? { ...previous.profile, serverGitAccess: { ...previous.profile.serverGitAccess, deployKeyId: restoredKey.id } } : previous.profile);
} catch (rollbackError) {
error.rollbackError = rollbackError.message;
}
await this.audit?.append?.("deployment.deploy-key-rotation-failed", { repository: repository.fullName, profileId, code: error.code || "DEPLOY_KEY_ROTATION_FAILED", rollbackError: error.rollbackError || null });
throw error;
}
}
async planRevocation({ repository, profileId }) {
const evidence = await this.inventory({ repository, profileId });
const plan = { operation: "revoke-deploy-key", repository: repository.fullName, profileId, keyId: evidence.configuredKey?.id || null, fingerprint: evidence.serverKey?.fingerprint || null, linkedDeployments: [profileId], impact: ["Remove this repository deploy key from Gitea", "Disable server-pull deployment until restored", "Preserve server-side recovery material"], containersUnaffected: true, evidence };
plan.id = planId(plan);
return plan;
}
async revoke({ repository, profileId, expectedPlanId }) {
const plan = await this.planRevocation({ repository, profileId });
if (!expectedPlanId) throw Object.assign(new Error("Review revocation impact before applying it."), { code: "DEPLOY_KEY_REVOCATION_PLAN_REQUIRED", plan });
if (plan.id !== expectedPlanId) throw Object.assign(new Error("Deploy-key evidence changed after preview."), { code: "DEPLOY_KEY_REVOCATION_PLAN_STALE", plan });
const { profile, server } = this.profile(repository, profileId);
const { owner, repo } = this.coordinates(repository);
const snapshot = await this.store.createRecoverySnapshot?.(`deploy-key-revocation:${repository.fullName}:${profileId}`);
const recovery = await this.keyHost.backup({ repository, profile, server });
let remoteDeleted = false;
try {
if (plan.keyId) { await this.gitea.deleteDeployKey(owner, repo, plan.keyId); remoteDeleted = true; }
await this.keyHost.revoke({ repository, profile, server, recovery });
const updated = await this.store.saveDeploymentProfile(repository.fullName, { ...profile, serverGitAccess: { ...profile.serverGitAccess, configured: false, revokedAt: this.clock(), recoveryAvailable: true }, deploymentMode: "monitor-only" });
await this.audit?.append?.("deployment.deploy-key-revoked", { repository: repository.fullName, profileId, keyId: plan.keyId, snapshot: snapshot?.filePath || null });
return { profile: updated, snapshot, recovery: recovery?.recovery || null };
} catch (error) {
if (remoteDeleted && plan.evidence.configuredKey?.key) {
const restored = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: plan.evidence.configuredKey.title || `ForgeFlow · ${server.name} · restored`, publicKey: plan.evidence.configuredKey.key });
await this.store.saveDeploymentProfile(repository.fullName, { ...profile, serverGitAccess: { ...profile.serverGitAccess, deployKeyId: restored.id } });
}
await this.keyHost.restore({ repository, profile, server }).catch(() => {});
await this.audit?.append?.("deployment.deploy-key-revocation-failed", { repository: repository.fullName, profileId, code: error.code || "DEPLOY_KEY_REVOCATION_FAILED" });
throw error;
}
}
async restore({ repository, profileId }) {
const { profile, server } = this.profile(repository, profileId);
const restored = await this.keyHost.restore({ repository, profile, server });
if (!restored?.publicKey || !restored?.fingerprint) throw Object.assign(new Error("No valid deploy-key recovery material exists."), { code: "DEPLOY_KEY_RECOVERY_UNAVAILABLE" });
const { owner, repo } = this.coordinates(repository);
const key = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: `ForgeFlow · ${server.name} · restored`, publicKey: restored.publicKey });
if (key.read_only !== true) throw Object.assign(new Error("The restored key is not read-only."), { code: "DEPLOY_KEY_NOT_READ_ONLY" });
const proposed = { ...profile, deploymentMode: "server-git", serverGitAccess: { configured: true, deployKeyId: key.id, keyFingerprint: restored.fingerprint, hostFingerprint: restored.hostFingerprint, restoredAt: this.clock() } };
const proof = await this.keyHost.verifyActive({ repository, profile: proposed, server });
if (!proof?.ready) throw Object.assign(new Error("Restored access could not be verified."), { code: "DEPLOY_KEY_RECOVERY_VERIFICATION_FAILED" });
const updated = await this.store.saveDeploymentProfile(repository.fullName, proposed);
await this.audit?.append?.("deployment.deploy-key-restored", { repository: repository.fullName, profileId, keyId: key.id, fingerprint: restored.fingerprint });
return { profile: updated, proof };
}
}
module.exports = { DeployKeyLifecycleService, keyMaterial, deployKeyPlanId: planId };
+35
View File
@@ -0,0 +1,35 @@
"use strict";
const crypto = require("node:crypto");
const { normalizeRemoteUrl } = require("../shared/repository-match.cjs");
function canonicalRemote(value) {
const normalized = normalizeRemoteUrl(value);
return normalized ? `${normalized.host}/${normalized.path}`.toLowerCase() : "";
}
function deploymentIdentity({ workload, profile = null, repository = null }) {
const remote = canonicalRemote(workload?.metadata?.sourceRepository || repository?.sshUrl || repository?.cloneUrl || profile?.cloneUrl);
return {
repository: remote || String(workload?.link?.repositoryFullName || repository?.fullName || profile?._repositoryFullName || "").toLowerCase(),
branch: String(workload?.metadata?.branch || profile?.branch || repository?.defaultBranch || "").toLowerCase(),
serverId: String(workload?.serverId || profile?.serverId || ""),
environment: String(profile?.environment || "production").toLowerCase(),
composeProject: String(workload?.compose?.project || profile?.composeProject || "").toLowerCase(),
deploymentRoot: String(workload?.compose?.workingDir || profile?.composeWorkingDir || workload?.remoteFolderCandidate || profile?.remoteFolder || "").replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase(),
containers: (workload?.containers || []).map((item) => String(item.id || item.name || "").toLowerCase()).sort(),
liveSha: String(workload?.metadata?.liveRevision || "").toLowerCase(),
profileId: String(profile?.id || workload?.link?.profileId || ""),
};
}
function evidenceHash(identity, evidence = {}) {
const stable = (value) => Array.isArray(value) ? value.map(stable) : value && typeof value === "object" ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])])) : value;
return crypto.createHash("sha256").update(JSON.stringify(stable({ identity, evidence }))).digest("hex");
}
function authorityKey(identity) {
return [identity.repository, identity.serverId, identity.environment].join("|");
}
module.exports = { canonicalDeploymentRemote: canonicalRemote, deploymentIdentity, deploymentEvidenceHash: evidenceHash, deploymentAuthorityKey: authorityKey };
+89
View File
@@ -0,0 +1,89 @@
"use strict";
const crypto = require("node:crypto");
const PROFILE_DEFINITIONS = Object.freeze({
minimal: { label: "Minimal", requiredScore: 55, severities: ["error"], allowSuppressions: true, maxSuppressionDays: 180 },
standard: { label: "Standard", requiredScore: 70, severities: ["error", "warning"], allowSuppressions: true, maxSuppressionDays: 90 },
strict: { label: "Strict", requiredScore: 82, severities: ["error", "warning"], allowSuppressions: true, maxSuppressionDays: 30 },
production: { label: "Production", requiredScore: 90, severities: ["error", "warning"], allowSuppressions: true, maxSuppressionDays: 14 },
});
function normalizePolicy(policy = {}) {
const id = String(policy.id || policy.profile || "standard").toLowerCase();
const base = PROFILE_DEFINITIONS[id] || PROFILE_DEFINITIONS.standard;
const custom = id === "organization" ? policy : {};
return {
id,
label: custom.label || base.label || "Organization custom",
requiredScore: Math.min(100, Math.max(0, Number(custom.requiredScore ?? base.requiredScore ?? 80))),
enabledChecks: Array.isArray(custom.enabledChecks) ? [...new Set(custom.enabledChecks.map(String))] : null,
severityOverrides: custom.severityOverrides && typeof custom.severityOverrides === "object" ? { ...custom.severityOverrides } : {},
blockingChecks: [...new Set((custom.blockingChecks || policy.blockingChecks || []).map(String))],
blockingSeverities: [...new Set((custom.blockingSeverities || policy.blockingSeverities || base.severities || ["error"]).map(String))]
.filter((severity) => ["warning", "error"].includes(severity)),
allowSuppressions: custom.allowSuppressions ?? base.allowSuppressions ?? true,
maxSuppressionDays: Math.max(1, Number(custom.maxSuppressionDays ?? base.maxSuppressionDays ?? 30)),
};
}
function validateSuppression(input, policy, now = new Date()) {
if (!policy.allowSuppressions) throw new Error("The selected policy does not allow suppressions.");
const checkId = String(input?.checkId || "").trim();
const reason = String(input?.reason || "").trim();
const author = String(input?.author || "").trim();
const scope = String(input?.scope || "repository").trim();
const evidence = String(input?.evidence || "").trim();
const expiresAt = new Date(input?.expiresAt || "");
if (!checkId || reason.length < 10 || !author || !evidence) throw new Error("A suppression requires a check ID, author, evidence and a reason of at least 10 characters.");
if (!Number.isFinite(expiresAt.getTime()) || expiresAt <= now) throw new Error("A suppression expiry must be in the future.");
const maximum = new Date(now.getTime() + policy.maxSuppressionDays * 86_400_000);
if (expiresAt > maximum) throw new Error(`This policy permits suppressions for at most ${policy.maxSuppressionDays} days.`);
return {
id: crypto.randomUUID(), checkId, reason, author,
createdAt: now.toISOString(), ticket: String(input.ticket || "").trim() || null,
expiresAt: expiresAt.toISOString(), scope, evidence,
};
}
function applyPolicy(checks, policyInput, suppressions = [], now = new Date()) {
const policy = normalizePolicy(policyInput);
const enabled = policy.enabledChecks ? new Set(policy.enabledChecks) : null;
const relevant = checks.filter((check) => !enabled || enabled.has(check.id)).map((check) => {
const status = policy.severityOverrides[check.id] || check.status;
const suppression = suppressions.find((item) => item.checkId === check.id && new Date(item.expiresAt) > now);
const expiredSuppression = suppressions.find((item) => item.checkId === check.id && new Date(item.expiresAt) <= now);
return {
...check,
status,
suppressed: Boolean(suppression),
suppression: suppression || null,
expiredSuppression: expiredSuppression || null,
blocking: !suppression && status !== "pass" && (policy.blockingSeverities.includes(status) || policy.blockingChecks.includes(check.id)),
};
});
return { policy, checks: relevant };
}
function buildTrend(previous, report) {
const prior = new Map((previous?.checks || []).map((check) => [check.id, check]));
const current = new Map(report.checks.map((check) => [check.id, check]));
const active = (check) => check && check.status !== "pass" && !check.suppressed;
const newlyFound = [...current.values()].filter((check) => active(check) && !active(prior.get(check.id))).map((check) => check.id);
const resolved = [...prior.values()].filter((check) => active(check) && !active(current.get(check.id))).map((check) => check.id);
const regressions = [...current.values()].filter((check) => active(check) && prior.get(check.id)?.status === "warning" && check.status === "error").map((check) => check.id);
return { score: report.score, categories: report.categories, newlyFound, resolved, regressions, suppressions: report.checks.filter((check) => check.suppressed).map((check) => check.id), checkedAt: report.checkedAt, commitSha: report.commitSha || null, checks: report.checks.map(({ id, status, suppressed }) => ({ id, status, suppressed })) };
}
function exportReport(report, format = "json") {
if (format === "json") return { extension: "json", mimeType: "application/json", content: `${JSON.stringify(report, null, 2)}\n` };
const rows = report.checks.map((check) => `| ${check.id} | ${check.category} | ${check.status}${check.suppressed ? " (suppressed)" : ""} | ${String(check.detail).replace(/\|/g, "\\|")} |`).join("\n");
const markdown = `# Git assurance report — ${report.repository}\n\nPolicy: **${report.policy.label}** · Score: **${report.score}/100** · Commit: \`${report.commitSha || "unknown"}\`\n\n| Check | Category | Status | Evidence |\n|---|---|---|---|\n${rows}\n`;
if (format === "markdown") return { extension: "md", mimeType: "text/markdown", content: markdown };
if (format !== "html") throw new Error("Unsupported Git Validator export format.");
const escape = (value) => String(value).replace(/[&<>"']/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character]);
const htmlRows = report.checks.map((check) => `<tr><td>${escape(check.id)}</td><td>${escape(check.category)}</td><td>${escape(check.status)}${check.suppressed ? " (suppressed)" : ""}</td><td>${escape(check.detail)}</td></tr>`).join("");
return { extension: "html", mimeType: "text/html", content: `<!doctype html><html lang="en"><meta charset="utf-8"><title>Git assurance — ${escape(report.repository)}</title><style>body{font:15px system-ui;max-width:1100px;margin:40px auto;padding:0 24px;color:#172033}table{border-collapse:collapse;width:100%}th,td{padding:10px;border:1px solid #ccd4e0;text-align:left}th{background:#edf2f7}</style><h1>Git assurance — ${escape(report.repository)}</h1><p>Policy: <strong>${escape(report.policy.label)}</strong> · Score: <strong>${report.score}/100</strong> · Commit: <code>${escape(report.commitSha || "unknown")}</code></p><table><thead><tr><th>Check</th><th>Category</th><th>Status</th><th>Evidence</th></tr></thead><tbody>${htmlRows}</tbody></table></html>` };
}
module.exports = { PROFILE_DEFINITIONS, normalizePolicy, validateSuppression, applyPolicy, buildTrend, exportReport };
+121 -6
View File
@@ -4,6 +4,7 @@ const fs = require("node:fs/promises");
const path = require("node:path"); const path = require("node:path");
const { run } = require("./process-runner.cjs"); const { run } = require("./process-runner.cjs");
const { normalizeRemoteUrl } = require("../shared/repository-match.cjs"); const { normalizeRemoteUrl } = require("../shared/repository-match.cjs");
const { applyPolicy, buildTrend, exportReport, normalizePolicy, validateSuppression } = require("./git-validator-policy.cjs");
const RECOMMENDED_GITIGNORE = `# Local configuration and secrets const RECOMMENDED_GITIGNORE = `# Local configuration and secrets
.env .env
@@ -67,6 +68,7 @@ function result(id, category, title, status, detail, options = {}) {
fixAction: options.fixAction || null, fixAction: options.fixAction || null,
safe: options.safe === true, safe: options.safe === true,
confirmation: options.confirmation || null, confirmation: options.confirmation || null,
evidence: options.evidence || null,
}; };
} }
@@ -84,10 +86,11 @@ function isSensitiveTrackedPath(filePath) {
} }
class GitValidatorService { class GitValidatorService {
constructor({ git, gitea, diagnostics }) { constructor({ git, gitea, diagnostics, store }) {
this.git = git; this.git = git;
this.gitea = gitea; this.gitea = gitea;
this.diagnostics = diagnostics; this.diagnostics = diagnostics;
this.store = store;
} }
async config(root, key, { local = true } = {}) { async config(root, key, { local = true } = {}) {
@@ -176,7 +179,7 @@ class GitValidatorService {
{ weight: 35 }, { weight: 35 },
), ),
); );
return this.summarize(repository, checks); return this.finalize(repository, checks, null);
} }
const root = await this.git.ensureRepository(repository.localPath); const root = await this.git.ensureRepository(repository.localPath);
@@ -378,15 +381,126 @@ class GitValidatorService {
{ weight: 7 }, { weight: 7 },
), ),
); );
return this.summarize(repository, checks); await this.addAssuranceChecks(root, tracked, lowerFiles, checks);
return this.finalize(repository, checks, status);
} }
async addAssuranceChecks(root, tracked, lowerFiles, checks) {
const has = (...patterns) => lowerFiles.some((file) => patterns.some((pattern) => pattern.test(file)));
const fileCheck = (id, category, title, patterns, detail, weight = 5) => {
const present = has(...patterns);
checks.push(result(id, category, title, present ? "pass" : "warning", present ? `${title} is versioned.` : detail, { weight }));
};
fileCheck("security-policy", "Security", "Security policy", [/(^|\/)security\.md$/], "Add SECURITY.md with supported versions and private disclosure instructions.", 8);
fileCheck("codeowners", "Governance", "Code ownership", [/(^|\/)codeowners$/], "Add CODEOWNERS for security-sensitive and release paths.", 6);
fileCheck("license", "Governance", "Repository license", [/(^|\/)(license|copying)(\.[^/]+)?$/], "Document the repository license or private-use terms.", 5);
fileCheck("changelog", "Release readiness", "Changelog", [/(^|\/)changelog(\.[^/]+)?$/], "Add a changelog that maps releases to user-visible changes.", 7);
fileCheck("contributing", "Collaboration", "Contribution guide", [/(^|\/)contributing(\.[^/]+)?$/], "Add contribution, test and review instructions.", 4);
fileCheck("issue-templates", "Collaboration", "Issue templates", [/^\.gitea\/issue_template\//, /^\.github\/issue_template\//], "Add structured issue templates.", 3);
fileCheck("pull-request-template", "Collaboration", "Pull request template", [/(^|\/)pull_request_template\.md$/], "Add a pull request checklist for tests, risk and rollback.", 4);
fileCheck("runtime-pinning", "Reproducibility", "Runtime version pinning", [/(^|\/)(\.nvmrc|\.node-version|\.tool-versions|mise\.toml)$/], "Pin the runtime version used by developers and CI.", 7);
fileCheck("build-instructions", "Reproducibility", "Build instructions", [/(^|\/)(readme|building|build)(\.[^/]+)?$/], "Document a clean, reproducible build command.", 6);
const generated = tracked.filter((file) => /(^|\/)(dist|build|coverage|\.cache)\//i.test(file));
checks.push(result("generated-artifacts", "Performance and hygiene", "Generated output is not tracked", generated.length ? "warning" : "pass", generated.length ? `${generated.length} generated-path file(s) are tracked; review ${generated.slice(0, 5).join(", ")}.` : "No common generated output directories are tracked.", { weight: 8, evidence: generated.slice(0, 20) }));
const executables = tracked.filter((file) => /\.(exe|dll|msi|scr|com|bat|cmd|ps1)$/i.test(file));
checks.push(result("executable-artifacts", "Security", "Executable artifacts are intentional", executables.length ? "warning" : "pass", executables.length ? `Review executable content: ${executables.slice(0, 8).join(", ")}.` : "No executable-shaped artifacts are tracked.", { weight: 8, evidence: executables.slice(0, 20) }));
const workflowFiles = tracked.filter((file) => /^\.(gitea|github)\/workflows\/[^/]+\.ya?ml$/i.test(file));
const workflowText = (await Promise.all(workflowFiles.slice(0, 40).map((file) => fs.readFile(path.join(root, file), "utf8").catch(() => "")))).join("\n");
const unpinned = [...workflowText.matchAll(/uses:\s*[^\s@]+@([^\s#]+)/g)].map((match) => match[1]).filter((ref) => !/^[0-9a-f]{40}$/i.test(ref));
checks.push(result("pinned-actions", "Security", "External CI actions are commit-pinned", unpinned.length ? "warning" : "pass", unpinned.length ? `${unpinned.length} action reference(s) use mutable tags or branches.` : "External actions are commit-pinned or no external actions are used.", { weight: 9, evidence: unpinned.slice(0, 20) }));
const broadPermissions = /permissions:\s*(write-all|write)/i.test(workflowText) || /contents:\s*write/i.test(workflowText);
checks.push(result("workflow-permissions", "Security", "Workflow permissions use least privilege", broadPermissions ? "error" : "pass", broadPermissions ? "A workflow requests broad write permissions; scope permissions per job and capability." : "No broad workflow write permission was detected.", { weight: 12 }));
const [commitSignature, tagSignature, recentSubjects] = await Promise.all([
run("git", ["log", "-1", "--format=%G?"], { cwd: root, timeout: 10_000, allowExitCodes: [128] }).then((value) => value.stdout.trim()).catch(() => "N"),
run("git", ["tag", "--points-at", "HEAD", "--format=%(contents:signature)"], { cwd: root, timeout: 10_000, allowExitCodes: [128] }).then((value) => value.stdout.trim()).catch(() => ""),
run("git", ["log", "-20", "--format=%s"], { cwd: root, timeout: 10_000, allowExitCodes: [128] }).then((value) => value.stdout.trim().split(/\r?\n/).filter(Boolean)).catch(() => []),
]);
checks.push(result("signed-commits", "Governance", "Latest commit is signed", /[GUYX]/.test(commitSignature) ? "pass" : "warning", /[GUYX]/.test(commitSignature) ? "Git reports a cryptographic signature on HEAD." : "HEAD has no verifiable Git signature.", { weight: 6 }));
checks.push(result("signed-tags", "Governance", "Release tags are signed", tagSignature ? "pass" : "warning", tagSignature ? "HEAD has a signed tag." : "HEAD has no signed release tag.", { weight: 5 }));
const conventional = recentSubjects.length > 0 && recentSubjects.every((subject) => /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?!?:\s.+/i.test(subject));
checks.push(result("conventional-commits", "Governance", "Recent commits follow a convention", conventional ? "pass" : "warning", conventional ? `${recentSubjects.length} recent commit subject(s) follow Conventional Commits.` : "Some recent commit subjects do not follow Conventional Commits.", { weight: 4 }));
const releaseFiles = {
"release-checksums": /(^|\/)(sha256sums|checksums?)(\.[^/]+)?$/,
"release-provenance": /(^|\/)(provenance|attestation)(\.[^/]+)?$/,
"release-sbom": /(^|\/)(sbom)(\.[^/]+)?$/,
};
for (const [id, pattern] of Object.entries(releaseFiles)) fileCheck(id, "Release readiness", id.replace(/^release-/, "Release "), [pattern], `No ${id.replace(/^release-/, "")} artifact is versioned or generated in the repository.`, 4);
checks.push(result("signing-readiness", "Release readiness", "Signing policy is documented", has(/(^|\/)(security|release|signing)(\.[^/]+)?$/) ? "pass" : "warning", has(/(^|\/)(security|release|signing)(\.[^/]+)?$/) ? "Signing guidance is present." : "Document signing identity, verification and timestamp requirements.", { weight: 6 }));
}
async finalize(repository, checks, status) {
const repositoryState = this.store?.getGitValidatorState?.(repository.fullName) || { policy: { id: "standard" }, suppressions: [], trends: [] };
const { policy, checks: governedChecks } = applyPolicy(checks, repositoryState.policy, repositoryState.suppressions);
const report = this.summarize(repository, governedChecks);
report.policy = policy;
report.commitSha = status?.head || status?.branch?.oid || null;
report.categories = Object.fromEntries([...new Set(governedChecks.map((check) => check.category))].map((category) => {
const categoryChecks = governedChecks.filter((check) => check.category === category);
return [category, Math.round(categoryChecks.filter((check) => check.status === "pass" || check.suppressed).length / categoryChecks.length * 100)];
}));
report.ready = report.score >= policy.requiredScore && !governedChecks.some((check) => check.blocking);
report.expiredSuppressions = governedChecks.filter((check) => check.expiredSuppression).map((check) => check.id);
report.trend = buildTrend(repositoryState.trends.at(-1), report);
if (this.store?.appendGitValidatorTrend) await this.store.appendGitValidatorTrend(repository.fullName, report.trend);
return report;
}
async setPolicy(repository, policyInput) {
const policy = normalizePolicy(policyInput);
if (!this.store?.setGitValidatorPolicy) throw new Error("Git Validator policy persistence is unavailable.");
await this.store.setGitValidatorPolicy(repository.fullName, policy);
return policy;
}
async suppress(repository, input) {
const state = this.store?.getGitValidatorState?.(repository.fullName) || { policy: { id: "standard" } };
const suppression = validateSuppression(input, normalizePolicy(state.policy));
await this.store.addGitValidatorSuppression(repository.fullName, suppression);
return suppression;
}
export(report, format) { return exportReport(report, format); }
async previewRepair(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;
const fileDefinitions = {
"add-gitignore": [".gitignore", RECOMMENDED_GITIGNORE],
"add-gitattributes": [".gitattributes", RECOMMENDED_GITATTRIBUTES],
"add-editorconfig": [".editorconfig", RECOMMENDED_EDITORCONFIG],
};
if (fileDefinitions[check.fixAction]) {
const [name, content] = fileDefinitions[check.fixAction];
if (await fs.stat(path.join(root, name)).catch(() => null)) throw new Error(`${name} already exists; rescan before repairing.`);
return { checkId: check.id, action: check.fixAction, files: [name], diff: `diff --git a/${name} b/${name}\nnew file mode 100644\n--- /dev/null\n+++ b/${name}\n${content.split("\n").filter((line, index, lines) => index < lines.length - 1).map((line) => `+${line}`).join("\n")}\n`, remoteMutation: false };
}
if (check.fixAction === "configure-local-safety") return { checkId: check.id, action: check.fixAction, files: [".git/config"], diff: "+ fetch.prune = true\n+ pull.ff = only\n+ rebase.autoStash = true\n", remoteMutation: false };
if (check.fixAction === "align-origin") return { checkId: check.id, action: check.fixAction, files: [".git/config"], diff: `- origin = current\n+ origin = ${repository.preferredCloneUrl || repository.cloneUrl || repository.sshUrl}\n`, remoteMutation: false };
if (check.fixAction === "protect-default-branch") return { checkId: check.id, action: check.fixAction, files: [], diff: `Gitea policy change:\n+ protect ${repository.defaultBranch || "main"}\n+ block force pushes\n+ require pull request review\n`, remoteMutation: true };
throw new Error("Unsupported Git Validator repair action.");
}
async resolveRepairCheck(repository, candidate) {
const checkId = String(candidate?.id || candidate?.checkId || "").trim();
if (!checkId) throw new Error("A current Git Validator check ID is required.");
const report = await this.scan(repository);
const current = report.checks.find((check) => check.id === checkId);
if (!current?.fixAction)
throw new Error("This finding is resolved, suppressed or no longer repairable. Scan again before repairing.");
if (candidate?.fixAction && candidate.fixAction !== current.fixAction)
throw new Error("The Git Validator repair request is stale. Scan again before repairing.");
return current;
}
summarize(repository, checks) { summarize(repository, checks) {
const totalWeight = checks.reduce((sum, check) => sum + check.weight, 0); const totalWeight = checks.reduce((sum, check) => sum + check.weight, 0);
const earned = checks.reduce( const earned = checks.reduce(
(sum, check) => (sum, check) =>
sum + sum +
(check.status === "pass" (check.status === "pass" || check.suppressed
? check.weight ? check.weight
: check.status === "warning" : check.status === "warning"
? check.weight * 0.45 ? check.weight * 0.45
@@ -409,8 +523,9 @@ class GitValidatorService {
checks, checks,
summary: { summary: {
passed: checks.filter((check) => check.status === "pass").length, passed: checks.filter((check) => check.status === "pass").length,
warnings: checks.filter((check) => check.status === "warning").length, warnings: checks.filter((check) => check.status === "warning" && !check.suppressed).length,
errors: checks.filter((check) => check.status === "error").length, errors: checks.filter((check) => check.status === "error" && !check.suppressed).length,
suppressed: checks.filter((check) => check.suppressed).length,
repairable: checks.filter((check) => check.fixAction).length, repairable: checks.filter((check) => check.fixAction).length,
}, },
}; };
+19
View File
@@ -264,6 +264,19 @@ class GiteaService {
return { ...result.data, created: true }; return { ...result.data, created: true };
} }
async createReadOnlyDeployKey({ owner, repo, title, publicKey }) {
const key = String(publicKey || "").trim();
if (!/^ssh-(ed25519|rsa)\s+[A-Za-z0-9+/=]+(?:\s+.*)?$/.test(key)) throw new Error("A valid SSH public key is required.");
const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys`, { method: "POST", body: { title: String(title || "ForgeFlow server deploy key").trim().slice(0, 255), key, read_only: true } });
return result.data;
}
async deleteDeployKey(owner, repo, keyId) {
if (!Number.isInteger(Number(keyId)) || Number(keyId) <= 0) throw new Error("A valid deploy-key ID is required.");
await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys/${Number(keyId)}`, { method: "DELETE" });
return { deleted: true, keyId: Number(keyId) };
}
async listPullRequests({ owner, repo, state = "open", limit = 30 } = {}) { async listPullRequests({ owner, repo, state = "open", limit = 30 } = {}) {
const query = new URLSearchParams({ const query = new URLSearchParams({
state, state,
@@ -413,6 +426,12 @@ class GiteaService {
if (!downloadUrl) { if (!downloadUrl) {
throw new Error("Gitea did not provide a release asset download URL."); throw new Error("Gitea did not provide a release asset download URL.");
} }
const configuredBase = new URL(normalizeBaseUrl(this.store.data.gitea.baseUrl));
const publishedUrl = new URL(downloadUrl, configuredBase);
const releasePrefix = `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/download/`.toLowerCase();
if (publishedUrl.origin !== configuredBase.origin && publishedUrl.protocol === "http:" && publishedUrl.pathname.toLowerCase().startsWith(releasePrefix)) {
downloadUrl = new URL(`${publishedUrl.pathname}${publishedUrl.search}`, configuredBase).toString();
}
return this.downloadAuthenticated(downloadUrl, options); return this.downloadAuthenticated(downloadUrl, options);
} }
+83
View File
@@ -0,0 +1,83 @@
"use strict";
const { deploymentIdentity, deploymentEvidenceHash, deploymentAuthorityKey } = require("./deployment-identity.cjs");
const BACKUP = /(?:^|[\\/._-])(backup|bak|archive|snapshot|old|previous)(?:[\\/._-]|$)/i;
const RELEASE = /(?:^|[\\/])(releases?|versions?)(?:[\\/]|$)/i;
const STAGING = /(?:^|[\\/._-])(staging|stage|test|qa|preview)(?:[\\/._-]|$)/i;
const TEMPORARY = /(?:^|[\\/._-])(candidate|rollback|ephemeral)(?:[\\/._-]|$)|^GITEA-ACTIONS-TASK-/i;
const SYSTEM = /^(?:traefik|nginx-proxy-manager|watchtower|portainer|dockerman|unraid-|cloudflared|redis|postgres|mariadb|mysql)(?:$|[-_.])/i;
function baseClassification(workload) {
const location = `${workload.compose?.workingDir || ""} ${(workload.compose?.configFiles || []).join(" ")}`;
const sourceRepository = String(workload.metadata?.sourceRepository || "").trim();
const hasGitProvenance = /^(?:git@|ssh:\/\/|https?:\/\/)/i.test(sourceRepository);
const decision = workload.reviewDecision;
if (["manual-exclude", "exclude-scan-root", "ignore"].includes(decision?.action)) return { type: "manually-excluded", reason: decision.reason || "Persisted manual exclusion", decisionAction: decision.action };
if (["mark-historical", "archive-link"].includes(decision?.action)) return { type: "historical-compose", reason: decision.reason || "Reviewed as historical", decisionAction: decision.action };
if (decision?.action === "monitor-only") return { type: "monitor-only", reason: decision.reason || "Reviewed for monitoring only", decisionAction: decision.action };
if (workload.metadata?.staleLink) return { type: "stale-link", reason: "The linked deployment profile has no matching server workload" };
if (BACKUP.test(location)) return { type: "backup", reason: "Path matches backup/archive evidence" };
if (RELEASE.test(location)) return { type: "release-folder", reason: "Path is below a release/version directory" };
if (STAGING.test(location)) return { type: "staging", reason: "Path or project identifies a staging/test workload" };
if (TEMPORARY.test(`${workload.displayName || ""} ${location}`)) return { type: "temporary-runtime", reason: "Runtime identity marks a candidate, rollback or CI workload" };
if (SYSTEM.test(workload.displayName || "") && !workload.metadata?.sourceRepository) return { type: "system-container", reason: "Known infrastructure identity without repository provenance" };
if (workload.link && workload.runtime?.running) return { type: "active-application", reason: "Linked deployment with running container evidence" };
if (workload.link && !workload.runtime?.running) return { type: "stopped-application", reason: "Linked deployment without a running container" };
if (!workload.containers?.length && workload.compose?.configFiles?.length) return { type: "historical-compose", reason: "Compose definition exists without container runtime" };
if (workload.status === "ambiguous") return { type: "ambiguous", reason: "Multiple candidates have equivalent evidence" };
if (!workload.candidates?.length) return { type: "external-container", reason: hasGitProvenance ? "Repository provenance does not match an accessible configured Gitea repository" : "Runtime has no Git repository provenance and remains monitoring-only" };
if (!workload.runtime?.running && workload.candidates?.length) return { type: "stopped-application", reason: "Stopped runtime has repository evidence" };
return { type: workload.runtime?.running ? "active-application" : "ambiguous", reason: workload.runtime?.running ? "Running application evidence" : "Insufficient authoritative evidence" };
}
function classifyInventory(workloads, profiles = [], decisions = []) {
const profileById = new Map(profiles.map((profile) => [profile.id, profile]));
const decisionByWorkload = new Map(decisions.map((decision) => [decision.workloadId, decision]));
const authorities = new Map();
const result = workloads.map((source) => {
const workload = structuredClone(source);
const profile = profileById.get(workload.link?.profileId) || null;
const identity = deploymentIdentity({ workload, profile });
const evidence = { candidates: (workload.candidates || []).map((item) => ({ repository: item.repositoryFullName, score: item.score, exact: item.exact === true })), running: workload.runtime?.running === true, health: workload.runtime?.health || null, configFiles: workload.compose?.configFiles || [] };
const hash = deploymentEvidenceHash(identity, evidence);
const stored = decisionByWorkload.get(workload.workloadId);
workload.reviewDecision = stored?.evidenceHash === hash ? stored : null;
workload.reviewDecisionStale = Boolean(stored && stored.evidenceHash !== hash);
workload.identity = identity;
if (workload.reviewDecision?.action === "manual-link" && workload.reviewDecision.repositoryFullName) {
workload.identity.repository = String(workload.reviewDecision.repositoryFullName).toLowerCase();
}
workload.evidenceHash = hash;
workload.classification = baseClassification(workload);
if (workload.link && ["backup", "release-folder", "staging", "temporary-runtime", "historical-compose", "system-container", "external-container", "manually-excluded"].includes(workload.classification.type)) {
workload.shadowedLink = workload.link;
workload.link = null;
}
const key = deploymentAuthorityKey(identity);
if (identity.repository && (workload.link || workload.candidates?.length) && !["backup", "release-folder", "staging", "temporary-runtime", "historical-compose", "system-container", "external-container", "manually-excluded"].includes(workload.classification.type)) {
const group = authorities.get(key) || [];
group.push(workload);
authorities.set(key, group);
}
return workload;
});
for (const group of authorities.values()) {
if (group.length < 2) {
group[0].authoritative = true;
continue;
}
const ranked = [...group].sort((a, b) => Number(b.reviewDecision?.action === "select-authoritative") - Number(a.reviewDecision?.action === "select-authoritative") || Number(b.runtime?.running) - Number(a.runtime?.running) || Number(Boolean(b.link)) - Number(Boolean(a.link)) || Number(Boolean(b.metadata?.liveRevision)) - Number(Boolean(a.metadata?.liveRevision)));
ranked[0].authoritative = true;
for (const duplicate of ranked.slice(1)) {
duplicate.authoritative = false;
duplicate.classification = { type: "duplicate", reason: `Conflicts with authoritative workload ${ranked[0].workloadId}`, authoritativeWorkloadId: ranked[0].workloadId };
duplicate.status = "duplicate";
duplicate.shadowedLink = duplicate.link;
duplicate.link = null;
}
}
return result;
}
module.exports = { classifyInventory, classifyWorkload: baseClassification, inventoryPathPatterns: { BACKUP, RELEASE, STAGING, TEMPORARY, SYSTEM } };
+31
View File
@@ -0,0 +1,31 @@
"use strict";
const crypto = require("node:crypto");
const ACTIONS = new Set(["keep-link", "select-authoritative", "mark-historical", "archive-link", "monitor-only", "exclude-scan-root", "manual-link", "ignore", "manual-exclude"]);
class InventoryReviewService {
constructor({ store, audit = null }) { this.store = store; this.audit = audit; }
list(serverId) { return this.store.getInventoryReviewDecisions(serverId); }
preview({ serverId, workload, action, reason = "", repositoryFullName = null }) {
if (!ACTIONS.has(action)) throw Object.assign(new Error("Unsupported inventory review action."), { code: "INVENTORY_REVIEW_ACTION_INVALID" });
if (["ignore", "manual-exclude", "exclude-scan-root"].includes(action) && String(reason).trim().length < 5) throw Object.assign(new Error("A meaningful review reason is required."), { code: "INVENTORY_REVIEW_REASON_REQUIRED" });
if (action === "manual-link" && !repositoryFullName) throw Object.assign(new Error("Select the repository to link."), { code: "INVENTORY_REVIEW_REPOSITORY_REQUIRED" });
const linkedProfile = workload.link?.profileId && workload.link?.repositoryFullName ? { profileId: workload.link.profileId, repositoryFullName: workload.link.repositoryFullName } : null;
const configurationChanges = [`Persist review decision ${action} for workload ${workload.workloadId}`];
if (action === "archive-link" && linkedProfile) configurationChanges.push(`Archive deployment profile ${linkedProfile.profileId}`);
if (action === "manual-link") configurationChanges.push(`Remember ${repositoryFullName} as the reviewed repository match; use Save environment to create the deployment profile`);
const mutation = { serverId, workloadId: workload.workloadId, evidenceHash: workload.evidenceHash, action, reason: String(reason).trim(), repositoryFullName, linkedProfile, classification: workload.classification?.type || workload.status, containersUnaffected: true, configurationChanges, recovery: "Restore the configuration snapshot or rescan after evidence changes." };
return { ...mutation, id: crypto.createHash("sha256").update(JSON.stringify(mutation)).digest("hex") };
}
async apply({ plan, expectedPlanId }) {
if (!expectedPlanId || plan.id !== expectedPlanId) throw Object.assign(new Error("Inventory review requires the exact preview plan."), { code: expectedPlanId ? "INVENTORY_REVIEW_PLAN_STALE" : "INVENTORY_REVIEW_PLAN_REQUIRED" });
const snapshot = await this.store.createRecoverySnapshot?.(`inventory-review:${plan.serverId}:${plan.workloadId}`);
if (plan.action === "archive-link" && plan.linkedProfile) await this.store.deleteDeploymentProfile(plan.linkedProfile.repositoryFullName, plan.linkedProfile.profileId);
const decision = await this.store.saveInventoryReviewDecision(plan.serverId, { workloadId: plan.workloadId, evidenceHash: plan.evidenceHash, action: plan.action, reason: plan.reason, repositoryFullName: plan.repositoryFullName || null, classification: plan.classification, decidedAt: new Date().toISOString() });
await this.audit?.append?.("deployment.inventory-review-applied", { serverId: plan.serverId, workloadId: plan.workloadId, action: plan.action, evidenceHash: plan.evidenceHash, snapshot: snapshot?.filePath || null });
return { decision, snapshot };
}
}
module.exports = { InventoryReviewService, INVENTORY_REVIEW_ACTIONS: [...ACTIONS] };
+16 -664
View File
@@ -1,5 +1,4 @@
"use strict"; "use strict";
const path = require("node:path"); const path = require("node:path");
const fs = require("node:fs/promises"); const fs = require("node:fs/promises");
const { fileURLToPath } = require("node:url"); const { fileURLToPath } = require("node:url");
@@ -9,12 +8,14 @@ const {
cloneDirectoryName, cloneDirectoryName,
resolveCloneTarget, resolveCloneTarget,
} = require("../shared/clone-target.cjs"); } = require("../shared/clone-target.cjs");
const { registerRepositoryIpc } = require("./ipc/repository-handlers.cjs");
const { registerDeploymentIpc } = require("./ipc/deployment-handlers.cjs");
const { registerOperationsIpc } = require("./ipc/operations-handlers.cjs");
const { const {
createEncryptedBackup, createEncryptedBackup,
readEncryptedBackup, readEncryptedBackup,
} = require("./configuration-backup.cjs"); } = require("./configuration-backup.cjs");
const { evaluateDeploymentPolicy } = require("../shared/deployment-policy.cjs"); const { evaluateDeploymentPolicy } = require("../shared/deployment-policy.cjs");
let diagnosticsService = null; let diagnosticsService = null;
const TRUSTED_RENDERER_PATH = path.resolve( const TRUSTED_RENDERER_PATH = path.resolve(
__dirname, __dirname,
@@ -76,7 +77,6 @@ function register(channel, handler) {
} }
}); });
} }
function registerIpc({ function registerIpc({
store, store,
git, git,
@@ -84,6 +84,8 @@ function registerIpc({
repositories, repositories,
deployments, deployments,
unraid, unraid,
deployKeys,
inventoryReviews,
ssh, ssh,
updates, updates,
preflight, preflight,
@@ -500,383 +502,11 @@ function registerIpc({
}), }),
); );
register("repositories:refresh", async () => { registerRepositoryIpc({
const result = await repositories.refresh(); register, repositories, store, git, gitea, monitor, diagnostics, audit,
monitor?.setPaths(repositories.getWatchPaths()); externalTools, gitValidator, withRepositoryMutation, assertKnownRepositoryPath,
return result; resolveRepository, cloneRepositoryInto, cloneDirectoryName,
}); matchRemoteToRepository, shell, dialog,
register("repositories:discover", async ({ roots }) => {
const paths = await repositories.discoverAll(
roots || store.data.workspaceRoots,
);
return repositories.getLocalDescriptors(paths);
});
register("repository:favorite", async ({ fullName, favorite }) =>
store.setFavorite(fullName, favorite),
);
register("repository:link", async ({ fullName, localPath }) => {
await git.ensureRepository(localPath);
const remoteUrl = await git.getRemoteUrl(localPath).catch(() => "");
if (
!remoteUrl ||
!matchRemoteToRepository(remoteUrl, [{ full_name: fullName }])
) {
throw new Error(
`The selected folder's origin does not match ${fullName}.`,
);
}
await store.saveMapping(fullName, localPath);
await diagnostics.info("repository.linked", { fullName, localPath });
const result = await repositories.refresh();
monitor?.setPaths(repositories.getWatchPaths());
return result;
});
register("repository:unlink", async ({ fullName }) => {
await store.removeMapping(fullName);
await diagnostics.info("repository.unlinked", { fullName });
const result = await repositories.refresh();
monitor?.setPaths(repositories.getWatchPaths());
return result;
});
register("repository:status", async ({ localPath }) =>
git.status(await assertKnownRepositoryPath(localPath)),
);
register("repository:diff", async ({ localPath, filePath, staged }) =>
git.diff(await assertKnownRepositoryPath(localPath), filePath, staged),
);
register("repository:diff-hunks", async ({ localPath, filePath }) =>
git.diffHunks(await assertKnownRepositoryPath(localPath), filePath),
);
register(
"repository:stage-hunks",
async ({ localPath, filePath, hunkIndexes }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.stageHunks(safePath, filePath, hunkIndexes),
);
},
);
register("repository:conflicts", async ({ localPath }) =>
git.conflictState(await assertKnownRepositoryPath(localPath)),
);
register(
"repository:resolve-conflict",
async ({ localPath, filePath, resolution }) => {
const safePath = await assertKnownRepositoryPath(localPath);
const result = await withRepositoryMutation(safePath, () =>
git.resolveConflict(safePath, filePath, resolution),
);
await audit.append("git.conflict.resolved", {
localPath: safePath,
filePath,
resolution,
});
return result;
},
);
register("repository:continue-operation", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
const result = await withRepositoryMutation(safePath, () =>
git.continueInterruptedOperation(safePath),
);
await audit.append("git.operation.continued", { localPath: safePath });
return result;
});
register("repository:abort-operation", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
const result = await withRepositoryMutation(safePath, () =>
git.abortInterruptedOperation(safePath),
);
await audit.append("git.operation.aborted", {
localPath: safePath,
operation: result.aborted,
});
return result;
});
register("repository:stage", async ({ localPath, files }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () => git.stage(safePath, files));
});
register("repository:unstage", async ({ localPath, files }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () => git.unstage(safePath, files));
});
register("repository:commit", async ({ localPath, message, files }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.commit(safePath, message, files),
);
});
register("repository:commit-staged", async ({ localPath, message }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.commitStaged(safePath, message),
);
});
register("repository:commit-staged-push", async ({ localPath, message }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.commitStagedAndPush(safePath, message),
);
});
register("repository:commit-push", async ({ localPath, message, files }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.commitAndPush(safePath, message, files),
);
});
register("repository:push", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () => git.push(safePath));
});
register("repository:fetch", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () => git.fetch(safePath));
});
register("repository:pull", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.pullFastForward(safePath),
);
});
register("repository:history", async ({ localPath, limit }) =>
git.history(await assertKnownRepositoryPath(localPath), limit),
);
register("repository:branch-protection", async ({ fullName, branch }) => {
const repository = await resolveRepository({ fullName });
return gitea.getBranchProtection(
repository.owner.login,
repository.name,
branch ||
repository.localStatus?.branch?.head ||
repository.defaultBranch,
);
});
register("repository:pull-requests", async ({ fullName, state = "open" }) => {
const repository = await resolveRepository({ fullName });
return gitea.listPullRequests({
owner: repository.owner.login,
repo: repository.name,
state,
});
});
register(
"repository:create-pull-request",
async ({ fullName, title, body, base }) => {
const repository = await resolveRepository({ fullName });
if (!repository.localPath || !repository.localStatus?.clean)
throw new Error(
"A clean linked repository is required before creating a pull request.",
);
const head = repository.localStatus.branch?.head;
if (!head || !repository.localStatus.branch?.upstream)
throw new Error(
"Publish the current branch before creating a pull request.",
);
if (repository.localStatus.branch.ahead > 0)
throw new Error(
"Push all local commits before creating a pull request.",
);
const pullRequest = await gitea.createPullRequest({
owner: repository.owner.login,
repo: repository.name,
head,
base: base || repository.defaultBranch,
title,
body,
});
await audit.append("pull-request.created", {
repository: repository.fullName,
number: pullRequest.number,
head,
base: base || repository.defaultBranch,
url: pullRequest.html_url,
});
return pullRequest;
},
);
register("repository:branches", async ({ localPath }) =>
git.branches(await assertKnownRepositoryPath(localPath)),
);
register("repository:checkout-branch", async ({ localPath, branch }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.checkoutBranch(safePath, branch),
);
});
register("repository:create-branch", async ({ localPath, branch }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.createBranch(safePath, branch),
);
});
register("repository:stash", async ({ localPath, message }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () => git.stash(safePath, message));
});
register("repository:stash-list", async ({ localPath }) =>
git.stashList(await assertKnownRepositoryPath(localPath)),
);
register("repository:stash-pop", async ({ localPath, ref }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () => git.popStash(safePath, ref));
});
register("repository:index-lock", async ({ localPath }) =>
git.getIndexLockInfo(await assertKnownRepositoryPath(localPath)),
);
register("repository:git-recovery-status", async ({ localPath }) =>
git.reconcile(await assertKnownRepositoryPath(localPath)),
);
register("repository:repair-index-lock", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.removeStaleIndexLock(safePath),
);
});
register(
"repository:repair-git-locks",
async ({ localPath, force = false }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.repairStaleGitLocks(safePath, {
minimumAgeMs: force ? 0 : 10_000,
allowWithoutProcessProbe: force === true,
}),
);
},
);
register("repository:reconcile", async ({ localPath }) =>
git.reconcile(await assertKnownRepositoryPath(localPath)),
);
register("repository:repair-sync", async ({ localPath, strategy }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.repairSync(safePath, strategy),
);
});
register("repository:set-origin", async ({ localPath, remoteUrl }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.setRemoteUrl(safePath, remoteUrl),
);
});
register("repositories:normalize-origins", async () => {
const current = await repositories.refresh();
const changes = [];
for (const repository of current) {
if (!repository.localPath || !repository.sshUrl) continue;
const actual = await git
.getRemoteUrl(repository.localPath)
.catch(() => "");
if (actual === repository.sshUrl) continue;
await withRepositoryMutation(repository.localPath, () =>
git.setRemoteUrl(repository.localPath, repository.sshUrl),
);
changes.push({
fullName: repository.fullName,
previous: actual,
next: repository.sshUrl,
});
}
const refreshed = await repositories.refresh();
monitor?.setPaths(repositories.getWatchPaths());
await diagnostics.info("repositories.origins.normalized", {
count: changes.length,
changes,
});
return { changes, repositories: refreshed };
});
register("repository:clone", async ({ fullName, mode = "default" }) => {
if (!["default", "custom"].includes(mode))
throw new Error("Unsupported clone location mode.");
let projectRoot = store.data.workspaceRoots[0] || null;
if (mode === "custom" || !projectRoot) {
const result = await dialog.showOpenDialog({
title: `Choose a project root for ${String(fullName || "repository")}`,
defaultPath: projectRoot || undefined,
buttonLabel: "Use this project root",
properties: ["openDirectory", "createDirectory"],
});
if (result.canceled || !result.filePaths[0]) return { cancelled: true };
projectRoot = result.filePaths[0];
}
return cloneRepositoryInto(fullName, projectRoot);
});
register("repository:open-path", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
const error = await shell.openPath(safePath);
if (error) throw new Error(error);
return true;
});
register(
"repository:open-editor",
async ({ localPath, filePath = "", line = 1 }) =>
externalTools.launch(
"editor",
await assertKnownRepositoryPath(localPath),
filePath,
line,
),
);
register("repository:open-terminal", async ({ localPath }) =>
externalTools.launch(
"terminal",
await assertKnownRepositoryPath(localPath),
),
);
register("external:open", async ({ url }) => {
const parsed = new URL(url);
if (!["http:", "https:"].includes(parsed.protocol))
throw new Error("Only HTTP and HTTPS links can be opened.");
await shell.openExternal(parsed.toString());
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",
"add-gitattributes",
"add-editorconfig",
"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 }) => { register("troubleshooter:scan", async ({ fullName = null }) => {
@@ -1100,291 +730,13 @@ function registerIpc({
return results; return results;
}); });
register("deployment:save-profile", async ({ fullName, profile }) => { registerDeploymentIpc({
const saved = await store.saveDeploymentProfile(fullName, profile); register, store, resolveRepository, unraid, deployments, evaluateDeploymentPolicy,
await diagnostics.info("deployment.profile.saved", { audit, deployKeys, repositories, inventoryReviews, diagnostics, git, gitea, ssh,
repository: fullName,
profile: saved,
});
return { profile: saved, state: store.getPublicState() };
}); });
register("deployment:delete-profile", async ({ fullName, profileId }) => { registerOperationsIpc({
const profiles = await store.deleteDeploymentProfile(fullName, profileId); register, store, unraid, deployments, diagnostics, shell, dialog, path, app,
await diagnostics.info("deployment.profile.deleted", { repositories, preflight, monitor,
repository: fullName,
profileId,
});
return { profiles, state: store.getPublicState() };
});
register("deployment:preflight", async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
const profile = store.getDeploymentProfile(current.fullName, profileId);
if (profile?.provider === "ssh-unraid")
return unraid.preflight({ repository: current, profileId });
return preflight.runDeployment({ repository: current, profileId });
});
register("deployment:repair-write-access", async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
const profile = store.getDeploymentProfile(current.fullName, profileId);
if (profile?.provider !== "ssh-unraid")
throw new Error("Write-access repair is available only for SSH / Unraid deployment profiles.");
const result = await unraid.repairWriteAccess({ repository: current, profileId });
await audit.append("deployment.write-access.repaired", {
repository: current.fullName,
profileId,
changed: result.changed,
remotePath: result.after?.remotePath || result.before?.remotePath || null,
});
return result;
});
register(
"deployment:dispatch",
async ({
repository,
profileId,
sha,
note = "",
override = false,
overrideReason = "",
}) => {
const current = await resolveRepository(repository);
const profile = store.getDeploymentProfile(current.fullName, profileId);
const policy = evaluateDeploymentPolicy(profile, {
note,
override,
reason: overrideReason,
});
await audit.append("deployment.requested", {
repository: current.fullName,
profileId,
sha,
note: policy.note,
overridden: policy.overridden,
overrideReason: policy.reason,
});
const operation =
profile?.provider === "ssh-unraid"
? await unraid.deploy({ repository: current, profileId, sha })
: await deployments.deploy({ repository: current, profileId, sha });
if (operation?.id)
await store.addOperation({
...operation,
releaseNote: policy.note,
policyOverride: policy.overridden
? { reason: policy.reason, violations: policy.violations }
: null,
});
return operation;
},
);
register(
"deployment:rollback",
async ({ repository, profileId, targetSha }) => {
const current = await resolveRepository(repository);
const profile = store.getDeploymentProfile(current.fullName, profileId);
if (profile?.provider === "ssh-unraid")
return unraid.rollback({ repository: current, profileId, targetSha });
return deployments.rollback({
repository: current,
profileId,
targetSha,
});
},
);
register("deployment:health", ({ url }) => deployments.checkHealth(url));
register("deployment:link-server-workload", async ({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) => {
const current = await resolveRepository(repository);
const result = await unraid.linkServerWorkload({
repository: current,
serverId,
workloadId,
deploymentMode,
remoteFolder,
});
return { ...result, state: store.getPublicState() };
});
register("deployment:configure-server-git-access", async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
const result = await unraid.configureServerGitAccess({ repository: current, profileId });
await audit.append("deployment.server-git-access-configured", {
repository: current.fullName,
profileId,
keyFingerprint: result.keyFingerprint,
hostFingerprint: result.hostFingerprint,
});
return { ...result, state: store.getPublicState() };
});
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,
serverName: server.name,
detected: 0,
adopted: 0,
verified: 0,
linked: 0,
unmatched: 0,
needsReview: 0,
capabilities: {},
warnings: [],
workloads: [],
error: error.message,
});
}
}
return results;
});
register("deployment:profile-state", async ({ fullName, profileId }) => {
const profile = store.getDeploymentProfile(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(
"deployment:apply-dockerman-metadata",
async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
return unraid.applyDockerManMetadata({ repository: current, profileId });
},
);
register("deployment:reconcile", async ({ fullName, profileId }) => {
const profile = store.getDeploymentProfile(fullName, profileId);
if (profile?.provider !== "ssh-unraid")
return deployments.refreshProfileState(fullName, profileId);
const [owner, repo] = String(fullName || "").split("/");
const branch = await gitea.getBranch(owner, repo, profile.branch);
const giteaSha =
branch?.commit?.id ||
branch?.commit?.sha ||
branch?.commit?.commit?.id ||
null;
const state = await unraid.refreshProfileState(
fullName,
profileId,
giteaSha,
);
const operations = store.data.operations.filter(
(item) =>
item.profileId === profileId &&
item.provider === "ssh-unraid" &&
!["success", "failed", "cancelled", "rolled-back"].includes(
item.status,
),
);
for (const operation of operations)
await unraid.refreshOperation(operation.id);
return {
state,
operations: await unraid.reconcileRecordedOperations(profileId, state),
};
});
register("operations:refresh", async ({ operationId }) => {
if (operationId) {
const operation = store.getOperation(operationId);
if (operation?.provider === "ssh-unraid")
return unraid.refreshOperation(operationId);
return deployments.refreshOperation(operationId);
}
const [actions, sshOperations] = await Promise.all([
deployments.refreshActiveOperations(),
unraid.refreshActiveOperations(),
]);
return [...actions, ...sshOperations];
});
register("operations:get", ({ operationId }) =>
store.getOperation(operationId),
);
register("diagnostics:status", () => diagnostics.getStatus());
register("diagnostics:clear", () => diagnostics.clear());
register("diagnostics:open-folder", async () => {
const error = await shell.openPath(diagnostics.logDirectory);
if (error) throw new Error(error);
return true;
});
register("diagnostics:export", async ({ privacyMode = "standard" }) => {
if (!["standard", "strict"].includes(privacyMode))
throw new Error("Unsupported diagnostic privacy mode.");
const result = await dialog.showSaveDialog({
title: "Export ForgeFlow diagnostic bundle",
defaultPath: path.join(
app.getPath("downloads"),
`ForgeFlow-Diagnostics-${new Date().toISOString().replace(/[:.]/g, "-")}.zip`,
),
filters: [{ name: "ZIP archive", extensions: ["zip"] }],
});
if (result.canceled || !result.filePath) return null;
const repositoryState = await repositories.refresh().catch((error) => {
diagnostics.warning("diagnostics.repository-snapshot.failed", error);
return [];
});
const systemPreflight = await preflight
.runSystem()
.catch((error) => ({ error: error.message }));
const destinationPath =
path.extname(result.filePath).toLowerCase() === ".zip"
? result.filePath
: `${result.filePath}.zip`;
return diagnostics.exportSupportBundle({
destinationPath,
publicState: store.getPublicState(),
repositories: repositoryState,
operations: store.data.operations,
preflight: systemPreflight,
privacyMode,
extra: {
appVersion: app.getVersion(),
setupComplete: store.data.setupComplete,
},
});
});
register("diagnostics:show-bundle", async ({ filePath }) => {
if (!diagnostics.isKnownBundlePath(filePath))
throw new Error(
"Only the most recently generated support bundle can be revealed.",
);
shell.showItemInFolder(filePath);
return true;
});
register(
"renderer:report",
async ({ level = "info", event = "renderer.event", details = {} }) => {
const method = ["debug", "info", "warning", "error"].includes(level)
? level
: "info";
await diagnostics[method](
`renderer.${String(event || "event").slice(0, 120)}`,
details,
);
return true;
},
);
register("app:reset", async () => {
await diagnostics.info("app.reset.requested", {});
store.data = store.migrate({});
store.sessionToken = null;
await store.save();
monitor?.setPaths([]);
monitor?.restart();
return store.getPublicState();
}); });
} }
+284
View File
@@ -0,0 +1,284 @@
"use strict";
function registerDeploymentIpc({
register, store, resolveRepository, unraid, deployments, evaluateDeploymentPolicy,
audit, deployKeys, repositories, inventoryReviews, diagnostics, git, gitea, ssh,
}) {
register("deployment:save-profile", async ({ fullName, profile }) => {
const saved = await store.saveDeploymentProfile(fullName, profile);
await diagnostics.info("deployment.profile.saved", {
repository: fullName,
profile: saved,
});
return { profile: saved, state: store.getPublicState() };
});
register("deployment:delete-profile", async ({ fullName, profileId }) => {
const profiles = await store.deleteDeploymentProfile(fullName, profileId);
await diagnostics.info("deployment.profile.deleted", {
repository: fullName,
profileId,
});
return { profiles, state: store.getPublicState() };
});
register("deployment:preflight", async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
const profile = store.getDeploymentProfile(current.fullName, profileId);
if (profile?.provider === "ssh-unraid")
return unraid.preflight({ repository: current, profileId });
return preflight.runDeployment({ repository: current, profileId });
});
register("deployment:repair-write-access", async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
const profile = store.getDeploymentProfile(current.fullName, profileId);
if (profile?.provider !== "ssh-unraid")
throw new Error("Write-access repair is available only for SSH / Unraid deployment profiles.");
const result = await unraid.repairWriteAccess({ repository: current, profileId });
await audit.append("deployment.write-access.repaired", {
repository: current.fullName,
profileId,
changed: result.changed,
remotePath: result.after?.remotePath || result.before?.remotePath || null,
});
return result;
});
register(
"deployment:dispatch",
async ({
repository,
profileId,
sha,
note = "",
override = false,
overrideReason = "",
}) => {
const current = await resolveRepository(repository);
const profile = store.getDeploymentProfile(current.fullName, profileId);
const policy = evaluateDeploymentPolicy(profile, {
note,
override,
reason: overrideReason,
});
await audit.append("deployment.requested", {
repository: current.fullName,
profileId,
sha,
note: policy.note,
overridden: policy.overridden,
overrideReason: policy.reason,
});
const operation =
profile?.provider === "ssh-unraid"
? await unraid.deploy({ repository: current, profileId, sha })
: await deployments.deploy({ repository: current, profileId, sha });
if (operation?.id)
await store.addOperation({
...operation,
releaseNote: policy.note,
policyOverride: policy.overridden
? { reason: policy.reason, violations: policy.violations }
: null,
});
return operation;
},
);
register(
"deployment:rollback",
async ({ repository, profileId, targetSha }) => {
const current = await resolveRepository(repository);
const profile = store.getDeploymentProfile(current.fullName, profileId);
if (profile?.provider === "ssh-unraid")
return unraid.rollback({ repository: current, profileId, targetSha });
return deployments.rollback({
repository: current,
profileId,
targetSha,
});
},
);
register("deployment:health", ({ url }) => deployments.checkHealth(url));
register("deployment:link-server-workload", async ({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) => {
const current = await resolveRepository(repository);
const result = await unraid.linkServerWorkload({
repository: current,
serverId,
workloadId,
deploymentMode,
remoteFolder,
});
return { ...result, state: store.getPublicState() };
});
register("deployment:configure-server-git-access", async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
const result = await unraid.configureServerGitAccess({ repository: current, profileId });
await audit.append("deployment.server-git-access-configured", {
repository: current.fullName,
profileId,
keyFingerprint: result.keyFingerprint,
hostFingerprint: result.hostFingerprint,
});
return { ...result, state: store.getPublicState() };
});
register("deployment:verify-server-git-profile", async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
const result = await unraid.verifyServerGitProfile({ repository: current, profileId });
await audit.append("deployment.server-git-access-verified", {
repository: current.fullName,
profileId,
readiness: result.readiness,
ready: result.ready,
checkedAt: result.checkedAt,
});
return result;
});
register("deployment:deploy-key-inventory", async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
return deployKeys.inventory({ repository: current, profileId });
});
register("deployment:plan-deploy-key-rotation", async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
return deployKeys.planRotation({ repository: current, profileId });
});
register("deployment:apply-deploy-key-rotation", async ({ repository, profileId, planId }) => {
const current = await resolveRepository(repository);
const result = await deployKeys.rotate({ repository: current, profileId, expectedPlanId: planId });
return { ...result, state: store.getPublicState() };
});
register("deployment:plan-deploy-key-revocation", async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
return deployKeys.planRevocation({ repository: current, profileId });
});
register("deployment:apply-deploy-key-revocation", async ({ repository, profileId, planId }) => {
const current = await resolveRepository(repository);
const result = await deployKeys.revoke({ repository: current, profileId, expectedPlanId: planId });
return { ...result, state: store.getPublicState() };
});
register("deployment:restore-deploy-key", async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
const result = await deployKeys.restore({ repository: current, profileId });
return { ...result, state: store.getPublicState() };
});
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,
serverName: server.name,
detected: 0,
adopted: 0,
verified: 0,
linked: 0,
unmatched: 0,
needsReview: 0,
capabilities: {},
warnings: [],
workloads: [],
error: error.message,
});
}
}
return results;
});
register("deployment:plan-server-reconciliation", async ({ serverId }) => {
const repositoryList = await repositories.refresh();
const remoteRepositories = repositoryList.filter((repository) => repository.owner?.login !== "local");
const result = await unraid.planServerInventoryReconciliation(serverId, remoteRepositories, { autoLink: true });
await audit.append("deployment.server-reconciliation-planned", {
serverId,
planId: result.plan.id,
summary: result.plan.summary,
});
return result;
});
register("deployment:apply-server-reconciliation", async ({ serverId, planId }) => {
const repositoryList = await repositories.refresh();
const remoteRepositories = repositoryList.filter((repository) => repository.owner?.login !== "local");
const result = await unraid.reconcileServerInventory(serverId, remoteRepositories, { autoLink: true, expectedPlanId: planId });
await audit.append("deployment.server-reconciliation-applied", {
serverId,
planId,
adopted: result.adopted,
refreshed: result.refreshed,
retired: result.retired,
recoverySnapshot: result.recoverySnapshot?.filePath || null,
});
return { ...result, state: store.getPublicState() };
});
register("deployment:plan-inventory-review", async ({ serverId, workloadId, action, reason = "", repositoryFullName = null }) => {
const repositoryList = await repositories.refresh();
const inventory = await unraid.scanServerInventory(serverId, repositoryList.filter((item) => item.owner?.login !== "local"));
const workload = inventory.workloads.find((item) => item.workloadId === workloadId);
if (!workload) throw Object.assign(new Error("The workload changed or disappeared. Rescan before reviewing it."), { code: "INVENTORY_REVIEW_WORKLOAD_STALE" });
return inventoryReviews.preview({ serverId, workload, action, reason, repositoryFullName });
});
register("deployment:apply-inventory-review", async ({ serverId, workloadId, action, reason = "", repositoryFullName = null, planId }) => {
const repositoryList = await repositories.refresh();
const inventory = await unraid.scanServerInventory(serverId, repositoryList.filter((item) => item.owner?.login !== "local"));
const workload = inventory.workloads.find((item) => item.workloadId === workloadId);
if (!workload) throw Object.assign(new Error("The workload changed or disappeared. Rescan before applying the review."), { code: "INVENTORY_REVIEW_WORKLOAD_STALE" });
const plan = inventoryReviews.preview({ serverId, workload, action, reason, repositoryFullName });
const result = await inventoryReviews.apply({ plan, expectedPlanId: planId });
return { ...result, inventory: await unraid.scanServerInventory(serverId, repositoryList.filter((item) => item.owner?.login !== "local")), state: store.getPublicState() };
});
register("deployment:profile-state", async ({ fullName, profileId }) => {
const profile = store.getDeploymentProfile(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(
"deployment:apply-dockerman-metadata",
async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
return unraid.applyDockerManMetadata({ repository: current, profileId });
},
);
register("deployment:reconcile", async ({ fullName, profileId }) => {
const profile = store.getDeploymentProfile(fullName, profileId);
if (profile?.provider !== "ssh-unraid")
return deployments.refreshProfileState(fullName, profileId);
const [owner, repo] = String(fullName || "").split("/");
const branch = await gitea.getBranch(owner, repo, profile.branch);
const giteaSha =
branch?.commit?.id ||
branch?.commit?.sha ||
branch?.commit?.commit?.id ||
null;
const state = await unraid.refreshProfileState(
fullName,
profileId,
giteaSha,
);
const operations = store.data.operations.filter(
(item) =>
item.profileId === profileId &&
item.provider === "ssh-unraid" &&
!["success", "failed", "cancelled", "rolled-back"].includes(
item.status,
),
);
for (const operation of operations)
await unraid.refreshOperation(operation.id);
return {
state,
operations: await unraid.reconcileRecordedOperations(profileId, state),
};
});
}
module.exports = { registerDeploymentIpc };
+100
View File
@@ -0,0 +1,100 @@
"use strict";
function registerOperationsIpc({
register, store, unraid, deployments, diagnostics, shell, dialog, path, app,
repositories, preflight, monitor,
}) {
register("operations:refresh", async ({ operationId }) => {
if (operationId) {
const operation = store.getOperation(operationId);
if (operation?.provider === "ssh-unraid")
return unraid.refreshOperation(operationId);
return deployments.refreshOperation(operationId);
}
const [actions, sshOperations] = await Promise.all([
deployments.refreshActiveOperations(),
unraid.refreshActiveOperations(),
]);
return [...actions, ...sshOperations];
});
register("operations:get", ({ operationId }) =>
store.getOperation(operationId),
);
register("diagnostics:status", () => diagnostics.getStatus());
register("diagnostics:clear", () => diagnostics.clear());
register("diagnostics:open-folder", async () => {
const error = await shell.openPath(diagnostics.logDirectory);
if (error) throw new Error(error);
return true;
});
register("diagnostics:export", async ({ privacyMode = "standard" }) => {
if (!["standard", "strict"].includes(privacyMode))
throw new Error("Unsupported diagnostic privacy mode.");
const result = await dialog.showSaveDialog({
title: "Export ForgeFlow diagnostic bundle",
defaultPath: path.join(
app.getPath("downloads"),
`ForgeFlow-Diagnostics-${new Date().toISOString().replace(/[:.]/g, "-")}.zip`,
),
filters: [{ name: "ZIP archive", extensions: ["zip"] }],
});
if (result.canceled || !result.filePath) return null;
const repositoryState = await repositories.refresh().catch((error) => {
diagnostics.warning("diagnostics.repository-snapshot.failed", error);
return [];
});
const systemPreflight = await preflight
.runSystem()
.catch((error) => ({ error: error.message }));
const destinationPath =
path.extname(result.filePath).toLowerCase() === ".zip"
? result.filePath
: `${result.filePath}.zip`;
return diagnostics.exportSupportBundle({
destinationPath,
publicState: store.getPublicState(),
repositories: repositoryState,
operations: store.data.operations,
preflight: systemPreflight,
privacyMode,
extra: {
appVersion: app.getVersion(),
setupComplete: store.data.setupComplete,
},
});
});
register("diagnostics:show-bundle", async ({ filePath }) => {
if (!diagnostics.isKnownBundlePath(filePath))
throw new Error(
"Only the most recently generated support bundle can be revealed.",
);
shell.showItemInFolder(filePath);
return true;
});
register(
"renderer:report",
async ({ level = "info", event = "renderer.event", details = {} }) => {
const method = ["debug", "info", "warning", "error"].includes(level)
? level
: "info";
await diagnostics[method](
`renderer.${String(event || "event").slice(0, 120)}`,
details,
);
return true;
},
);
register("app:reset", async () => {
await diagnostics.info("app.reset.requested", {});
store.data = store.migrate({});
store.sessionToken = null;
await store.save();
monitor?.setPaths([]);
monitor?.restart();
return store.getPublicState();
});
}
module.exports = { registerOperationsIpc };
+412
View File
@@ -0,0 +1,412 @@
"use strict";
function registerRepositoryIpc({
register, repositories, store, git, gitea, monitor, diagnostics, audit,
externalTools, gitValidator, withRepositoryMutation, assertKnownRepositoryPath,
resolveRepository, cloneRepositoryInto, cloneDirectoryName,
matchRemoteToRepository, shell, dialog,
}) {
register("repositories:refresh", async () => {
const result = await repositories.refresh();
monitor?.setPaths(repositories.getWatchPaths());
return result;
});
register("repositories:discover", async ({ roots }) => {
const paths = await repositories.discoverAll(
roots || store.data.workspaceRoots,
);
return repositories.getLocalDescriptors(paths);
});
register("repository:favorite", async ({ fullName, favorite }) =>
store.setFavorite(fullName, favorite),
);
register("repository:link", async ({ fullName, localPath }) => {
await git.ensureRepository(localPath);
const remoteUrl = await git.getRemoteUrl(localPath).catch(() => "");
if (
!remoteUrl ||
!matchRemoteToRepository(remoteUrl, [{ full_name: fullName }])
) {
throw new Error(
`The selected folder's origin does not match ${fullName}.`,
);
}
await store.saveMapping(fullName, localPath);
await diagnostics.info("repository.linked", { fullName, localPath });
const result = await repositories.refresh();
monitor?.setPaths(repositories.getWatchPaths());
return result;
});
register("repository:unlink", async ({ fullName }) => {
await store.removeMapping(fullName);
await diagnostics.info("repository.unlinked", { fullName });
const result = await repositories.refresh();
monitor?.setPaths(repositories.getWatchPaths());
return result;
});
register("repository:status", async ({ localPath }) =>
git.status(await assertKnownRepositoryPath(localPath)),
);
register("repository:diff", async ({ localPath, filePath, staged }) =>
git.diff(await assertKnownRepositoryPath(localPath), filePath, staged),
);
register("repository:diff-hunks", async ({ localPath, filePath }) =>
git.diffHunks(await assertKnownRepositoryPath(localPath), filePath),
);
register(
"repository:stage-hunks",
async ({ localPath, filePath, hunkIndexes }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.stageHunks(safePath, filePath, hunkIndexes),
);
},
);
register("repository:conflicts", async ({ localPath }) =>
git.conflictState(await assertKnownRepositoryPath(localPath)),
);
register(
"repository:resolve-conflict",
async ({ localPath, filePath, resolution }) => {
const safePath = await assertKnownRepositoryPath(localPath);
const result = await withRepositoryMutation(safePath, () =>
git.resolveConflict(safePath, filePath, resolution),
);
await audit.append("git.conflict.resolved", {
localPath: safePath,
filePath,
resolution,
});
return result;
},
);
register("repository:continue-operation", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
const result = await withRepositoryMutation(safePath, () =>
git.continueInterruptedOperation(safePath),
);
await audit.append("git.operation.continued", { localPath: safePath });
return result;
});
register("repository:abort-operation", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
const result = await withRepositoryMutation(safePath, () =>
git.abortInterruptedOperation(safePath),
);
await audit.append("git.operation.aborted", {
localPath: safePath,
operation: result.aborted,
});
return result;
});
register("repository:stage", async ({ localPath, files }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () => git.stage(safePath, files));
});
register("repository:unstage", async ({ localPath, files }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () => git.unstage(safePath, files));
});
register("repository:commit", async ({ localPath, message, files }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.commit(safePath, message, files),
);
});
register("repository:commit-staged", async ({ localPath, message }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.commitStaged(safePath, message),
);
});
register("repository:commit-staged-push", async ({ localPath, message }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.commitStagedAndPush(safePath, message),
);
});
register("repository:commit-push", async ({ localPath, message, files }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.commitAndPush(safePath, message, files),
);
});
register("repository:push", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () => git.push(safePath));
});
register("repository:fetch", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () => git.fetch(safePath));
});
register("repository:pull", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.pullFastForward(safePath),
);
});
register("repository:history", async ({ localPath, limit }) =>
git.history(await assertKnownRepositoryPath(localPath), limit),
);
register("repository:branch-protection", async ({ fullName, branch }) => {
const repository = await resolveRepository({ fullName });
return gitea.getBranchProtection(
repository.owner.login,
repository.name,
branch ||
repository.localStatus?.branch?.head ||
repository.defaultBranch,
);
});
register("repository:pull-requests", async ({ fullName, state = "open" }) => {
const repository = await resolveRepository({ fullName });
return gitea.listPullRequests({
owner: repository.owner.login,
repo: repository.name,
state,
});
});
register(
"repository:create-pull-request",
async ({ fullName, title, body, base }) => {
const repository = await resolveRepository({ fullName });
if (!repository.localPath || !repository.localStatus?.clean)
throw new Error(
"A clean linked repository is required before creating a pull request.",
);
const head = repository.localStatus.branch?.head;
if (!head || !repository.localStatus.branch?.upstream)
throw new Error(
"Publish the current branch before creating a pull request.",
);
if (repository.localStatus.branch.ahead > 0)
throw new Error(
"Push all local commits before creating a pull request.",
);
const pullRequest = await gitea.createPullRequest({
owner: repository.owner.login,
repo: repository.name,
head,
base: base || repository.defaultBranch,
title,
body,
});
await audit.append("pull-request.created", {
repository: repository.fullName,
number: pullRequest.number,
head,
base: base || repository.defaultBranch,
url: pullRequest.html_url,
});
return pullRequest;
},
);
register("repository:branches", async ({ localPath }) =>
git.branches(await assertKnownRepositoryPath(localPath)),
);
register("repository:checkout-branch", async ({ localPath, branch }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.checkoutBranch(safePath, branch),
);
});
register("repository:create-branch", async ({ localPath, branch }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.createBranch(safePath, branch),
);
});
register("repository:stash", async ({ localPath, message }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () => git.stash(safePath, message));
});
register("repository:stash-list", async ({ localPath }) =>
git.stashList(await assertKnownRepositoryPath(localPath)),
);
register("repository:stash-pop", async ({ localPath, ref }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () => git.popStash(safePath, ref));
});
register("repository:index-lock", async ({ localPath }) =>
git.getIndexLockInfo(await assertKnownRepositoryPath(localPath)),
);
register("repository:git-recovery-status", async ({ localPath }) =>
git.reconcile(await assertKnownRepositoryPath(localPath)),
);
register("repository:repair-index-lock", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.removeStaleIndexLock(safePath),
);
});
register(
"repository:repair-git-locks",
async ({ localPath, force = false }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.repairStaleGitLocks(safePath, {
minimumAgeMs: force ? 0 : 10_000,
allowWithoutProcessProbe: force === true,
}),
);
},
);
register("repository:reconcile", async ({ localPath }) =>
git.reconcile(await assertKnownRepositoryPath(localPath)),
);
register("repository:repair-sync", async ({ localPath, strategy }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.repairSync(safePath, strategy),
);
});
register("repository:set-origin", async ({ localPath, remoteUrl }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.setRemoteUrl(safePath, remoteUrl),
);
});
register("repositories:normalize-origins", async () => {
const current = await repositories.refresh();
const changes = [];
for (const repository of current) {
if (!repository.localPath || !repository.sshUrl) continue;
const actual = await git
.getRemoteUrl(repository.localPath)
.catch(() => "");
if (actual === repository.sshUrl) continue;
await withRepositoryMutation(repository.localPath, () =>
git.setRemoteUrl(repository.localPath, repository.sshUrl),
);
changes.push({
fullName: repository.fullName,
previous: actual,
next: repository.sshUrl,
});
}
const refreshed = await repositories.refresh();
monitor?.setPaths(repositories.getWatchPaths());
await diagnostics.info("repositories.origins.normalized", {
count: changes.length,
changes,
});
return { changes, repositories: refreshed };
});
register("repository:clone", async ({ fullName, mode = "default" }) => {
if (!["default", "custom"].includes(mode))
throw new Error("Unsupported clone location mode.");
let projectRoot = store.data.workspaceRoots[0] || null;
if (mode === "custom" || !projectRoot) {
const result = await dialog.showOpenDialog({
title: `Choose a project root for ${String(fullName || "repository")}`,
defaultPath: projectRoot || undefined,
buttonLabel: "Use this project root",
properties: ["openDirectory", "createDirectory"],
});
if (result.canceled || !result.filePaths[0]) return { cancelled: true };
projectRoot = result.filePaths[0];
}
return cloneRepositoryInto(fullName, projectRoot);
});
register("repository:open-path", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
const error = await shell.openPath(safePath);
if (error) throw new Error(error);
return true;
});
register(
"repository:open-editor",
async ({ localPath, filePath = "", line = 1 }) =>
externalTools.launch(
"editor",
await assertKnownRepositoryPath(localPath),
filePath,
line,
),
);
register("repository:open-terminal", async ({ localPath }) =>
externalTools.launch(
"terminal",
await assertKnownRepositoryPath(localPath),
),
);
register("external:open", async ({ url }) => {
const parsed = new URL(url);
if (!["http:", "https:"].includes(parsed.protocol))
throw new Error("Only HTTP and HTTPS links can be opened.");
await shell.openExternal(parsed.toString());
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:set-policy", async ({ fullName, policy }) => {
const repository = await resolveRepository({ fullName });
const saved = await gitValidator.setPolicy(repository, policy);
await audit.append("git-validator.policy.changed", { repository: repository.fullName, policy: saved.id });
return saved;
});
register("git-validator:suppress", async ({ fullName, suppression }) => {
const repository = await resolveRepository({ fullName });
const saved = await gitValidator.suppress(repository, suppression);
await audit.append("git-validator.finding.suppressed", { repository: repository.fullName, checkId: saved.checkId, expiresAt: saved.expiresAt, ticket: saved.ticket });
return saved;
});
register("git-validator:preview-repair", async ({ fullName, check }) => {
const repository = await resolveRepository({ fullName });
const currentCheck = await gitValidator.resolveRepairCheck(repository, check);
return gitValidator.previewRepair(repository, currentCheck);
});
register("git-validator:export", async ({ fullName, format = "json" }) => {
const repository = await resolveRepository({ fullName });
const report = await gitValidator.scan(repository);
return gitValidator.export(report, format);
});
register("git-validator:repair", async ({ fullName, check }) => {
const repository = await resolveRepository({ fullName });
const allowed = new Set([
"align-origin",
"configure-local-safety",
"add-gitignore",
"add-gitattributes",
"add-editorconfig",
"protect-default-branch",
]);
const currentCheck = await gitValidator.resolveRepairCheck(repository, check);
if (!allowed.has(currentCheck.fixAction))
throw new Error("Unsupported Git Validator repair request.");
const result = await gitValidator.repair(repository, currentCheck);
await audit.append("git-validator.repair", {
repository: repository.fullName,
checkId: currentCheck.id,
action: currentCheck.fixAction,
});
await diagnostics.info("git-validator.repair.completed", {
repository: repository.fullName,
checkId: currentCheck.id,
action: currentCheck.fixAction,
});
return result;
});
}
module.exports = { registerRepositoryIpc };
+149
View File
@@ -0,0 +1,149 @@
"use strict";
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const crypto = require("node:crypto");
const { run } = require("./process-runner.cjs");
class ProductionAcceptanceHarness {
constructor(root) {
this.root = root;
this.paths = {
remote: path.join(root, "gitea", "owner", "app.git"),
source: path.join(root, "workspace", "app"),
server: path.join(root, "server", "appdata", "app"),
releases: path.join(root, "releases"),
config: path.join(root, "user-data", "forgeflow-config.json"),
keys: path.join(root, "keys"),
};
this.state = { installed: false, version: null, tokenVersion: 1, auth: null, liveSha: null, previousSha: null, healthy: false, deployment: null, recovery: null, hostFingerprint: "SHA256:fixture-host", keyReadOnly: true };
}
static async create() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "forgeflow-production-acceptance-"));
const harness = new ProductionAcceptanceHarness(root);
await harness.provision();
return harness;
}
async provision() {
await Promise.all(Object.values(this.paths).filter((value) => !path.extname(value)).map((directory) => fs.mkdir(directory, { recursive: true })));
await fs.mkdir(path.dirname(this.paths.remote), { recursive: true });
await run("git", ["init", "--bare", this.paths.remote], { cwd: this.root, timeout: 30_000 });
await fs.mkdir(this.paths.source, { recursive: true });
await run("git", ["init", "-b", "main"], { cwd: this.paths.source, timeout: 30_000 });
await run("git", ["config", "user.name", "ForgeFlow Acceptance"], { cwd: this.paths.source });
await run("git", ["config", "user.email", "acceptance@example.invalid"], { cwd: this.paths.source });
await fs.writeFile(path.join(this.paths.source, "compose.yml"), "services:\n app:\n image: forgeflow-fixture:latest\n", "utf8");
await fs.writeFile(path.join(this.paths.source, "README.md"), "# Acceptance fixture\n", "utf8");
await run("git", ["add", "."], { cwd: this.paths.source });
await run("git", ["commit", "-m", "feat: initial fixture"], { cwd: this.paths.source });
await run("git", ["remote", "add", "origin", this.paths.remote], { cwd: this.paths.source });
await run("git", ["push", "-u", "origin", "main"], { cwd: this.paths.source, timeout: 30_000 });
this.initialSha = (await run("git", ["rev-parse", "HEAD"], { cwd: this.paths.source })).stdout.trim();
await fs.mkdir(this.paths.releases, { recursive: true });
await fs.mkdir(path.dirname(this.paths.config), { recursive: true });
await fs.mkdir(this.paths.keys, { recursive: true });
await fs.writeFile(path.join(this.paths.keys, "deploy_key.pub"), "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixture forgeflow-acceptance\n", "utf8");
}
async cleanup() { await fs.rm(this.root, { recursive: true, force: true }); }
async install(version = "0.10.0", mode = "installed") {
this.state.installed = true; this.state.version = version; this.state.mode = mode;
await this.saveConfig({ schemaVersion: 12, version, mode });
return structuredClone(this.state);
}
async migrate(targetVersion = "1.0.0") {
if (!this.state.installed) throw new Error("Clean installation is required before migration.");
const previous = JSON.parse(await fs.readFile(this.paths.config, "utf8"));
await fs.writeFile(`${this.paths.config}.backup`, JSON.stringify(previous, null, 2), "utf8");
this.state.version = targetVersion;
await this.saveConfig({ ...previous, schemaVersion: 13, version: targetVersion, migratedAt: new Date().toISOString() });
return { previousVersion: previous.version, version: targetVersion, backup: `${this.paths.config}.backup` };
}
async saveConfig(data) { await fs.writeFile(this.paths.config, `${JSON.stringify(data, null, 2)}\n`, "utf8"); }
rotateToken() { this.state.tokenVersion += 1; return { tokenVersion: this.state.tokenVersion }; }
authenticate(type, options = {}) {
if (!['password', 'ssh-key'].includes(type)) throw new Error("Unsupported authentication fixture.");
if (type === 'ssh-key' && options.hostFingerprint !== this.state.hostFingerprint) throw new Error("SSH host fingerprint changed.");
this.state.auth = type; return { authenticated: true, type };
}
setKeyAccess(readOnly) { this.state.keyReadOnly = readOnly; }
async createCommit(message = "fix: acceptance change") {
const target = path.join(this.paths.source, "fixture.txt");
await fs.writeFile(target, `${crypto.randomUUID()}\n`, "utf8");
await run("git", ["add", "fixture.txt"], { cwd: this.paths.source });
await run("git", ["commit", "-m", message], { cwd: this.paths.source });
await run("git", ["push", "origin", "main"], { cwd: this.paths.source });
return (await run("git", ["rev-parse", "HEAD"], { cwd: this.paths.source })).stdout.trim();
}
plan(sha, mode = "server-git") {
return { id: crypto.randomUUID(), evidenceHash: crypto.createHash("sha256").update(JSON.stringify({ sha, mode, liveSha: this.state.liveSha, keyReadOnly: this.state.keyReadOnly })).digest("hex"), sha, mode, previousSha: this.state.liveSha };
}
async deploy(plan, fault = null) {
if (!this.state.auth) throw new Error("Server authentication is required.");
if (plan.mode === "server-git" && !this.state.keyReadOnly) throw new Error("Writable deploy key rejected.");
if (this.plan(plan.sha, plan.mode).evidenceHash !== plan.evidenceHash) throw new Error("Stale reconciliation plan.");
this.state.recovery = structuredClone(this.state);
this.state.deployment = { id: crypto.randomUUID(), sha: plan.sha, mode: plan.mode, status: "running" };
if (fault === "fetch-network") return this.fail("Network interrupted during fetch", false);
await fs.mkdir(this.paths.server, { recursive: true });
await fs.writeFile(path.join(this.paths.server, "compose.yml"), await fs.readFile(path.join(this.paths.source, "compose.yml")));
if (fault === "activation-network") return this.fail("Network interrupted during activation", true);
if (fault === "shutdown") { this.state.deployment.status = "interrupted"; return structuredClone(this.state.deployment); }
this.state.previousSha = this.state.liveSha;
this.state.liveSha = plan.sha;
this.state.healthy = fault !== "unhealthy";
this.state.deployment.status = this.state.healthy ? "success" : "failed";
return structuredClone(this.state.deployment);
}
fail(message, partial) { this.state.deployment.status = "failed"; this.state.deployment.failure = { message, partial }; return structuredClone(this.state.deployment); }
recover() {
if (this.state.deployment?.status !== "interrupted") throw new Error("No interrupted deployment to recover.");
this.state.deployment.status = this.state.liveSha === this.state.deployment.sha && this.state.healthy ? "success" : "failed";
return structuredClone(this.state.deployment);
}
rollback(targetSha) {
if (!targetSha || targetSha !== this.state.previousSha) throw new Error("Rollback target is not the exact recorded previous SHA.");
[this.state.liveSha, this.state.previousSha] = [targetSha, this.state.liveSha]; this.state.healthy = true;
return { status: "rolled-back", liveSha: this.state.liveSha };
}
adoptExisting(sha = this.initialSha) { this.state.liveSha = sha; this.state.healthy = true; return { linked: true, liveSha: sha, preserved: true }; }
externalUpdate(sha) { this.state.liveSha = sha; this.state.healthy = true; return { reconciled: true, liveSha: sha }; }
rotateDeployKey() { if (!this.state.keyReadOnly) throw new Error("Candidate deploy key is writable."); this.state.keyVersion = (this.state.keyVersion || 1) + 1; return { rotated: true, keyVersion: this.state.keyVersion }; }
revokeDeployKey() { this.state.keyRevoked = true; return { revoked: true, deploymentBlocked: true }; }
restoreDeployKey() { this.state.keyRevoked = false; this.state.keyReadOnly = true; return { restored: true }; }
inventory(count = 20, partial = false) { return { workloads: Array.from({ length: count }, (_, index) => ({ id: `workload-${index + 1}`, classification: index === 1 ? "duplicate" : "active" })), partial, warnings: partial ? ["One scan root was unavailable"] : [] }; }
async publishRelease(version, options = {}) {
const binary = Buffer.from(options.binary || "MZ-forgeflow-acceptance-binary");
const name = `ForgeFlow-Portable-${version}-win-x64.exe`;
const checksum = crypto.createHash("sha256").update(binary).digest("hex");
const manifest = { version, draft: options.draft === true, assets: options.missingAsset ? [] : [{ name, sha256: options.badChecksum ? "0".repeat(64) : checksum }], provenance: { commitSha: options.commitSha || this.initialSha }, sbom: { bomFormat: "CycloneDX" } };
await fs.writeFile(path.join(this.paths.releases, `${version}.json`), JSON.stringify(manifest, null, 2));
if (!options.missingAsset) await fs.writeFile(path.join(this.paths.releases, name), binary);
return manifest;
}
async verifyRelease(version) {
const manifest = JSON.parse(await fs.readFile(path.join(this.paths.releases, `${version}.json`), "utf8"));
if (manifest.draft) throw new Error("Incomplete draft release rejected.");
const asset = manifest.assets[0];
if (!asset) throw new Error("Required release asset is missing.");
const binary = await fs.readFile(path.join(this.paths.releases, asset.name));
if (crypto.createHash("sha256").update(binary).digest("hex") !== asset.sha256) throw new Error("Release checksum mismatch.");
if (!manifest.provenance?.commitSha || manifest.sbom?.bomFormat !== "CycloneDX") throw new Error("Release provenance or SBOM is missing.");
return { verified: true, version, asset: asset.name };
}
}
module.exports = { ProductionAcceptanceHarness };
+24 -19
View File
@@ -40,27 +40,32 @@ class RepositoryMonitor {
if (this.running || !this.paths.length) return; if (this.running || !this.paths.length) return;
this.running = true; this.running = true;
try { try {
for (const localPath of this.paths) { const queue = [...this.paths];
if (this.paused.has(localPath)) continue; const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
try { while (queue.length) {
const status = await this.git.status(localPath); const localPath = queue.shift();
const next = this.git.statusFingerprint(status); if (this.paused.has(localPath)) continue;
const previous = this.fingerprints.get(localPath); try {
this.fingerprints.set(localPath, next); const status = await this.git.status(localPath);
if (previous && previous !== next) { const next = this.git.statusFingerprint(status);
await this.diagnostics?.debug('repository-monitor.changed', { localPath, head: status.head, branch: status.branch?.head, counts: status.counts }); const previous = this.fingerprints.get(localPath);
this.onChange?.({ localPath, status, reason: 'working-tree-changed' }); this.fingerprints.set(localPath, next);
} if (previous && previous !== next) {
} catch (error) { await this.diagnostics?.debug('repository-monitor.changed', { localPath, head: status.head, branch: status.branch?.head, counts: status.counts });
const next = `error:${error.message}`; this.onChange?.({ localPath, status, reason: 'working-tree-changed' });
const previous = this.fingerprints.get(localPath); }
this.fingerprints.set(localPath, next); } catch (error) {
if (previous && previous !== next) { const next = `error:${error.message}`;
await this.diagnostics?.warning('repository-monitor.unavailable', { localPath, message: error.message }); const previous = this.fingerprints.get(localPath);
this.onChange?.({ localPath, error: error.message, reason: 'repository-unavailable' }); this.fingerprints.set(localPath, next);
if (previous && previous !== next) {
await this.diagnostics?.warning('repository-monitor.unavailable', { localPath, message: error.message });
this.onChange?.({ localPath, error: error.message, reason: 'repository-unavailable' });
}
} }
} }
} });
await Promise.all(workers);
} finally { } finally {
this.running = false; this.running = false;
} }
+447
View File
@@ -0,0 +1,447 @@
"use strict";
function createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto }) {
class UnraidAccessMethods {
serverGitRemote(repository, profile) {
const candidates = [repository.sshUrl, profile.cloneUrl, repository.preferredCloneUrl]
.map((value) => String(value || "").trim())
.filter(Boolean);
const value = candidates.find((candidate) => /^ssh:\/\//i.test(candidate) || /^[^@\s]+@[^:\s]+:.+/.test(candidate));
if (!value) {
const error = new Error("Server pull requires the repository SSH clone URL from Gitea.");
error.code = "SERVER_GIT_SSH_URL_REQUIRED";
throw error;
}
return value;
}
serverGitHost(repository, profile) {
const remote = this.serverGitRemote(repository, profile);
if (/^ssh:\/\//i.test(remote)) {
const parsed = new URL(remote);
return { host: parsed.hostname, port: Number(parsed.port || 22) };
}
const match = remote.match(/^[^@\s]+@([^:\s]+):/);
if (!match) throw new Error("Could not determine the Gitea SSH host from the clone URL.");
return { host: match[1], port: 22 };
}
serverGitCredentialPaths(repository, server) {
const repositoryId = crypto.createHash("sha256").update(String(repository.fullName).toLowerCase()).digest("hex").slice(0, 24);
const directory = path.join(server.basePath, ".forgeflow", "git-credentials", repositoryId);
return {
directory,
privateKey: path.join(directory, "deploy-key"),
publicKey: path.join(directory, "deploy-key.pub"),
knownHosts: path.join(directory, "known_hosts"),
};
}
serverGitEnvironment(repository, profile, server) {
const credentials = this.serverGitCredentialPaths(repository, server);
return `GIT_SSH_COMMAND=${shellQuote(`ssh -i ${credentials.privateKey} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${credentials.knownHosts}`)}`;
}
async configureServerGitAccess({ repository, profileId }) {
const { profile, server } = this.resolve(repository, profileId);
const remote = this.serverGitRemote(repository, profile);
const { host, port } = this.serverGitHost(repository, profile);
const credentials = this.serverGitCredentialPaths(repository, server);
const trustedHostFingerprint = String(profile.serverGitAccess?.hostFingerprint || "").trim();
const marker = "__FORGEFLOW_DEPLOY_KEY__";
const setupScript = `
command -v git >/dev/null 2>&1 || { echo "Git is not installed on the server" >&2; exit 41; }
command -v ssh-keygen >/dev/null 2>&1 || { echo "ssh-keygen is not installed on the server" >&2; exit 42; }
command -v ssh-keyscan >/dev/null 2>&1 || { echo "ssh-keyscan is not installed on the server" >&2; exit 43; }
credential_dir=${shellQuote(credentials.directory)}
private_key=${shellQuote(credentials.privateKey)}
public_key=${shellQuote(credentials.publicKey)}
known_hosts=${shellQuote(credentials.knownHosts)}
expected_host_fingerprint=${shellQuote(trustedHostFingerprint)}
mkdir -p "$credential_dir"
chmod 700 "$credential_dir"
if [ ! -s "$private_key" ] || [ ! -s "$public_key" ]; then
rm -f "$private_key" "$public_key"
ssh-keygen -q -t ed25519 -N '' -C ${shellQuote(`forgeflow:${repository.fullName}`)} -f "$private_key"
fi
chmod 600 "$private_key"
chmod 644 "$public_key"
scan_tmp="$known_hosts.$$.tmp"
scan_ok=false
for attempt in 1 2 3; do
ssh-keyscan -T 10 -H -p ${Number(port)} ${shellQuote(host)} > "$scan_tmp" 2>/dev/null || true
if [ -s "$scan_tmp" ]; then scan_ok=true; break; fi
sleep $((attempt * 2))
done
[ "$scan_ok" = true ] || { rm -f "$scan_tmp"; echo "Gitea SSH host did not return a host key after three attempts" >&2; exit 44; }
scanned_host_fingerprint="$(ssh-keygen -lf "$scan_tmp" -E sha256 2>/dev/null | awk '{print $2}' | sort -u | paste -sd, -)"
if [ -n "$expected_host_fingerprint" ] && [ "$scanned_host_fingerprint" != "$expected_host_fingerprint" ]; then
rm -f "$scan_tmp"
echo "The Gitea SSH host key changed. Verify the Gitea server before replacing trust." >&2
exit 46
fi
mv "$scan_tmp" "$known_hosts"
chmod 600 "$known_hosts"
printf '%s\n' ${shellQuote(marker)}
printf 'publicKey=%s\n' "$(base64 < "$public_key" | tr -d '\\r\\n')"
printf 'fingerprint=%s\n' "$(ssh-keygen -lf "$public_key" -E sha256 | awk '{print $2}')"
printf 'hostFingerprint=%s\n' "$scanned_host_fingerprint"
`;
const setup = await this.ssh.exec(server.id, bash(setupScript), { timeout: 60_000, maxOutput: 256 * 1024 });
const output = String(setup.stdout || "");
const markerIndex = output.lastIndexOf(marker);
if (markerIndex < 0) throw new Error("The server did not return the generated deploy key.");
const fields = Object.fromEntries(output.slice(markerIndex + marker.length).trim().split(/\r?\n/).map((line) => {
const separator = line.indexOf("=");
return separator > 0 ? [line.slice(0, separator), line.slice(separator + 1)] : [line, ""];
}));
if (trustedHostFingerprint && fields.hostFingerprint && trustedHostFingerprint !== fields.hostFingerprint) {
const error = new Error("The Gitea SSH host key changed. Server pull was not reconfigured. Verify the Gitea server before replacing trust.");
error.code = "GITEA_SSH_HOST_KEY_MISMATCH";
throw error;
}
const publicKey = Buffer.from(fields.publicKey || "", "base64").toString("utf8").trim();
const [owner, repo] = String(repository.fullName || "").split("/");
if (!owner || !repo) throw new Error("A full Gitea repository name is required to configure server pull.");
const deployKey = await this.gitea.ensureReadOnlyDeployKey({
owner,
repo,
title: `ForgeFlow · ${server.name} · read-only`,
publicKey,
});
const probeCommand = `${this.serverGitEnvironment(repository, profile, server)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)}`;
const probe = await this.ssh.exec(
server.id,
bash(`probe_error=''
for attempt in 1 2 3; do
if probe_output=$(${probeCommand} 2>&1); then printf '%s\n' "$probe_output"; exit 0; fi
probe_error=$probe_output
sleep $((attempt * 2))
done
printf '%s\n' "$probe_error" >&2
exit 45`),
{ timeout: 45_000, maxOutput: 256 * 1024 },
);
const remoteSha = String(probe.stdout || "").trim().split(/\s+/)[0] || null;
const updated = await this.store.saveDeploymentProfile(repository.fullName, {
...profile,
deploymentMode: "server-git",
cloneUrl: remote,
serverGitAccess: {
configured: true,
deployKeyId: deployKey.id || null,
keyFingerprint: fields.fingerprint || null,
hostFingerprint: fields.hostFingerprint || null,
configuredAt: new Date().toISOString(),
},
});
return {
profile: updated,
created: deployKey.created === true,
remoteSha,
keyFingerprint: fields.fingerprint || null,
hostFingerprint: fields.hostFingerprint || null,
};
}
async probeServerGitAccess({ repository, profile, server }) {
try {
const remote = this.serverGitRemote(repository, profile);
const credentials = this.serverGitCredentialPaths(repository, server);
const trustedHostFingerprint = String(profile.serverGitAccess?.hostFingerprint || "").trim();
const trustedKeyFingerprint = String(profile.serverGitAccess?.keyFingerprint || "").trim();
const command = `[ -s ${shellQuote(credentials.privateKey)} ] && [ -s ${shellQuote(credentials.publicKey)} ] && [ -s ${shellQuote(credentials.knownHosts)} ] && actual_host_fingerprint="$(ssh-keygen -lf ${shellQuote(credentials.knownHosts)} -E sha256 2>/dev/null | awk '{print $2}' | sort -u | paste -sd, -)" && actual_key_fingerprint="$(ssh-keygen -lf ${shellQuote(credentials.publicKey)} -E sha256 2>/dev/null | awk '{print $2}')" && { [ -z ${shellQuote(trustedHostFingerprint)} ] || [ "$actual_host_fingerprint" = ${shellQuote(trustedHostFingerprint)} ]; } && { [ -z ${shellQuote(trustedKeyFingerprint)} ] || [ "$actual_key_fingerprint" = ${shellQuote(trustedKeyFingerprint)} ]; } && remote_output="$(${this.serverGitEnvironment(repository, profile, server)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)})" && remote_sha="$(printf '%s' "$remote_output" | awk 'NR==1 {print $1}')" && printf '__FORGEFLOW_SERVER_GIT_PROBE__\nremoteSha=%s\nkeyFingerprint=%s\nhostFingerprint=%s\n' "$remote_sha" "$actual_key_fingerprint" "$actual_host_fingerprint"`;
const result = await this.ssh.exec(server.id, bash(command), { timeout: 45_000, maxOutput: 256 * 1024 });
const output = String(result.stdout || "");
const marker = output.lastIndexOf("__FORGEFLOW_SERVER_GIT_PROBE__");
if (marker < 0) throw new Error("The server pull probe did not return verifiable fingerprint evidence.");
const fields = Object.fromEntries(output.slice(marker + "__FORGEFLOW_SERVER_GIT_PROBE__".length).trim().split(/\r?\n/).map((line) => {
const separator = line.indexOf("=");
return separator > 0 ? [line.slice(0, separator), line.slice(separator + 1)] : [line, ""];
}));
return { ready: true, remoteSha: fields.remoteSha || null, keyFingerprint: fields.keyFingerprint || null, hostFingerprint: fields.hostFingerprint || null };
} catch (error) {
return { ready: false, error: error.message };
}
}
async verifyServerGitProfile({ repository, profileId }) {
const { profile, server, remotePath } = this.resolve(repository, profileId);
const checks = [];
const add = (id, label, status, detail, evidence = {}) => checks.push({ id, label, status, detail, evidence });
if (profile.deploymentMode === "monitor-only") {
add("mode", "Deployment mode", "warning", "This profile is monitoring only and cannot deploy.");
return { readiness: "Monitoring only", ready: false, checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, checks };
}
if (profile.deploymentMode !== "server-git") {
add("mode", "Deployment mode", "unsupported", "Read-only server-pull verification applies only to Server pull profiles.");
return { readiness: "Unsupported", ready: false, checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, checks };
}
let branchSha = null;
try {
const [owner, repo] = String(repository.fullName || "").split("/");
const branch = await this.gitea.getBranch(owner, repo, profile.branch);
branchSha = branch?.commit?.id || branch?.commit?.sha || null;
add("remote-branch", "Gitea branch", branchSha ? "pass" : "fail", branchSha ? `${profile.branch} at ${branchSha}` : `${profile.branch} did not return a commit SHA.`, { branch: profile.branch, sha: branchSha });
const keys = await this.gitea.listDeployKeys(owner, repo);
const keyId = Number(profile.serverGitAccess?.deployKeyId);
const key = keys.find((item) => Number(item.id) === keyId);
add("deploy-key-scope", "Repository deploy key", key?.read_only === true ? "pass" : "fail", !key ? "The configured deploy key is no longer present in Gitea." : key.read_only === true ? `Key ${key.id} is repository-scoped and read-only.` : `Key ${key.id} has write access and is blocked.`, { keyId: key?.id || keyId || null, readOnly: key?.read_only === true });
} catch (error) {
add("gitea-access", "Gitea verification", "fail", error.message);
}
const access = await this.probeServerGitAccess({ repository, profile, server });
add("server-git-access", "Unraid to Gitea", access.ready ? "pass" : "fail", access.ready ? `Exact branch access verified at ${String(access.remoteSha || "unknown").slice(0, 12)}.` : access.error, access);
let inspection = null;
try {
inspection = await this.inspect({ repository, profileId });
const expectedCompose = profile.generatedCompose ? [".forgeflow/compose.forgeflow.yml"] : this.deploymentComposeFiles(profile);
const composePresent = !inspection.exists || expectedCompose.every((file) => inspection.composeFiles.includes(file));
add("deployment-directory", "Deployment directory", inspection.exists ? "pass" : "warning", inspection.exists ? remotePath : `${remotePath} will be created on first deployment.`, { remotePath, exists: inspection.exists });
add("compose", "Compose configuration", composePresent ? "pass" : "warning", composePresent ? expectedCompose.join(", ") : `Expected after deployment: ${expectedCompose.join(", ")}.`, { files: expectedCompose });
add("preserved-paths", "Preserved runtime paths", "pass", (profile.preservePaths || []).length ? profile.preservePaths.join(", ") : "No preserved runtime paths configured.", { paths: profile.preservePaths || [] });
add("environment-requirements", "Environment requirements", "pass", (profile.detectedMetadata?.envNames || []).length ? `${profile.detectedMetadata.envNames.length} variable name(s) detected; values remain hidden.` : "No environment variable names were detected in server metadata.", { names: profile.detectedMetadata?.envNames || [] });
} catch (error) {
add("server-inspection", "Server inspection", "fail", error.message);
}
const state = this.store.getDeploymentState(profile.id) || {};
const liveSha = state.liveSha || inspection?.head || null;
const running = state.containerRunning;
const healthy = state.healthy;
add("live-commit", "Live server commit", liveSha ? "pass" : "warning", liveSha || "No verifiable live commit is currently recorded.", { liveSha });
add("commit-parity", "Gitea and server parity", branchSha && liveSha && branchSha === liveSha ? "pass" : branchSha && liveSha ? "warning" : "incomplete", branchSha && liveSha ? branchSha === liveSha ? "The exact Gitea commit is live." : `Live ${String(liveSha).slice(0, 12)} differs from Gitea ${String(branchSha).slice(0, 12)}.` : "Parity cannot be proven until both SHAs are available.", { branchSha, liveSha });
add("runtime", "Container runtime", running === true ? "pass" : running === false ? "fail" : "incomplete", running === true ? "The linked container is running." : running === false ? "The linked container is stopped." : "Runtime state has not been verified.");
add("health", "Runtime health", healthy === true ? "pass" : healthy === false ? "fail" : "incomplete", healthy === true ? "Runtime health passed." : healthy === false ? "Runtime health failed." : "No conclusive runtime health evidence is available.");
const failed = checks.some((item) => item.status === "fail");
const incomplete = checks.some((item) => ["warning", "incomplete", "unsupported"].includes(item.status));
const readiness = failed ? (checks.some((item) => item.id.includes("access") || item.id.includes("key")) ? "Access failed" : checks.some((item) => item.id === "runtime" || item.id === "health") ? "Runtime unhealthy" : "Configuration required") : incomplete ? (branchSha && liveSha && branchSha !== liveSha ? "Commit mismatch" : "Verification incomplete") : "Ready";
return { readiness, ready: readiness === "Ready" || readiness === "Commit mismatch", checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, server: { id: server.id, name: server.name }, remotePath, branch: profile.branch, branchSha, liveSha, checks };
}
permissionTargets(profile, server, remotePath) {
const targets = [
{
id: "server-base",
label: "Configured deployment base",
path: server.basePath,
kind: "directory",
required: false,
},
{
id: "project-root",
label: "Project folder",
path: remotePath,
kind: "directory",
required: true,
},
{
id: "forgeflow-state",
label: "ForgeFlow upload and rollback storage",
path: path.join(remotePath, ".forgeflow"),
kind: "directory",
required: true,
},
{
id: "forgeflow-incoming",
label: "ForgeFlow incoming upload folder",
path: path.join(remotePath, ".forgeflow", "incoming"),
kind: "directory",
required: true,
},
];
if (!profile.generatedCompose) {
for (const file of this.deploymentComposeFiles(profile)) {
targets.push({
id: `compose:${file}`,
label: `Compose file ${file}`,
path: path.join(remotePath, file),
kind: "file",
required: true,
});
}
}
const unique = new Map();
for (const target of targets) unique.set(`${target.kind}:${target.path}`, target);
return [...unique.values()];
}
permissionInspectionScript(profile, server, remotePath) {
const targetCalls = this.permissionTargets(profile, server, remotePath)
.map(
(target) =>
`probe ${shellQuote(target.id)} ${shellQuote(target.label)} ${shellQuote(target.path)} ${shellQuote(target.kind)} ${target.required ? "true" : "false"}`,
)
.join("\n");
return `
encode() { printf '%s' "$1" | base64 | tr -d '\\r\\n'; }
can_elevate=false
[ "$(id -u)" = 0 ] && can_elevate=true
if [ "$can_elevate" != true ] && command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then can_elevate=true; fi
has_acl=false
command -v setfacl >/dev/null 2>&1 && has_acl=true
printf '__FORGEFLOW_PERMISSIONS__\\n'
printf 'I\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \
"$(encode "$(id -un 2>/dev/null || echo unknown)")" \
"$(id -u 2>/dev/null || echo -1)" \
"$(id -g 2>/dev/null || echo -1)" \
"$(encode "$(id -Gn 2>/dev/null || true)")" \
"$has_acl" "$can_elevate"
probe() {
target_id=$1
label=$2
target=$3
kind=$4
required=$5
exists=false; readable=false; writable=false; parent_writable=false; effective=false
owner=''; group=''; mode=''; detail=''; nearest=''
if [ -e "$target" ] || [ -L "$target" ]; then
exists=true
[ -r "$target" ] && readable=true
[ -w "$target" ] && writable=true
owner=$(stat -c '%U' "$target" 2>/dev/null || true)
group=$(stat -c '%G' "$target" 2>/dev/null || true)
mode=$(stat -c '%a' "$target" 2>/dev/null || true)
fi
parent=$(dirname "$target")
ancestor=$parent
while [ ! -d "$ancestor" ] && [ "$ancestor" != / ]; do ancestor=$(dirname "$ancestor"); done
nearest=$ancestor
marker="$ancestor/.forgeflow-write-test-$$-\${RANDOM:-0}"
if [ -d "$ancestor" ] && (umask 077; : > "$marker") 2>/dev/null; then
rm -f -- "$marker" >/dev/null 2>&1 || true
parent_writable=true
fi
if [ "$kind" = directory ]; then
if [ -d "$target" ]; then
marker="$target/.forgeflow-write-test-$$-\${RANDOM:-0}"
if (umask 077; : > "$marker") 2>/dev/null; then
rm -f -- "$marker" >/dev/null 2>&1 || true
effective=true
fi
elif [ "$parent_writable" = true ]; then
effective=true
fi
else
if [ "$exists" = true ] && [ ! -f "$target" ]; then
detail='Path exists but is not a regular file.'
elif [ "$exists" = true ] && [ "$readable" = true ] && { [ "$writable" = true ] || [ "$parent_writable" = true ]; }; then
effective=true
elif [ "$exists" = false ] && [ "$parent_writable" = true ]; then
effective=true
detail='File is absent but can be created by the deployment user.'
fi
fi
if [ -z "$detail" ]; then
if [ "$effective" = true ]; then detail='Read/write probe passed.'
else detail="No safe create/replace access for $(id -un 2>/dev/null || echo 'the SSH user')."; fi
fi
printf 'P\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \
"$(encode "$target_id")" "$(encode "$label")" "$(encode "$target")" "$kind" "$required" \
"$exists" "$readable" "$writable" "$parent_writable" "$effective" \
"$(encode "$owner")" "$(encode "$group")" "$mode" "$(encode "$nearest")" "$(encode "$detail")"
}
${targetCalls}
`;
}
async inspectWriteAccess({ repository, profileId }) {
const { profile, server, remotePath } = this.resolve(repository, profileId);
const result = await this.ssh.exec(
server.id,
bash(this.permissionInspectionScript(profile, server, remotePath)),
{ timeout: 45_000, maxOutput: 2 * 1024 * 1024 },
);
const report = parsePermissionInspection(result.stdout);
report.serverId = server.id;
report.remotePath = remotePath;
return report;
}
permissionRepairScript(profile, server, remotePath) {
const preserve = [
".git",
"node_modules",
".venv",
"venv",
"__pycache__",
...(profile.preservePaths || []),
]
.map((value) => safeRelativeRemoteFile(value))
.filter(Boolean);
const pruneExpression = preserve.length
? preserve
.map((value) => `-path ${shellQuote(path.join(remotePath, value))} -o -path ${shellQuote(path.join(remotePath, value, "*"))}`)
.join(" -o ")
: "-false";
const composePaths = this.deploymentComposeFiles(profile)
.map((file) => shellQuote(path.join(remotePath, file)))
.join(" ");
return `
root=${shellQuote(remotePath)}
base=${shellQuote(server.basePath)}
case "$root" in "$base"|"$base"/*) ;; *) echo "Refusing permission repair outside configured base path: $root" >&2; exit 81 ;; esac
run_privileged() {
if [ "$(id -u)" = 0 ]; then "$@";
elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then sudo -n "$@";
else "$@";
fi
}
mkdir_cmd=mkdir
if ! mkdir -p "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null; then
run_privileged mkdir -p "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups"
fi
share_group=$(getent group users >/dev/null 2>&1 && echo users || id -gn)
if command -v setfacl >/dev/null 2>&1; then
run_privileged setfacl -m "u:$(id -un):rwx,g:$share_group:rwx,m:rwx" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true
run_privileged setfacl -d -m "u:$(id -un):rwx,g:$share_group:rwx,m:rwx" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true
fi
run_privileged chgrp "$share_group" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true
run_privileged chmod 2775 "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups"
if [ -d "$root" ]; then
while IFS= read -r -d '' entry; do
case "$entry" in
"$root/.forgeflow"|"$root/.forgeflow"/*) continue ;;
esac
run_privileged chgrp "$share_group" "$entry" 2>/dev/null || true
if [ -d "$entry" ]; then run_privileged chmod u+rwx,g+rwx,g+s "$entry"; else run_privileged chmod u+rw,g+rw "$entry"; fi
done < <(find "$root" -mindepth 1 \\( ${pruneExpression} \\) -prune -o -print0)
fi
for compose_file in ${composePaths || ""}; do
[ -e "$compose_file" ] || continue
run_privileged chgrp "$share_group" "$compose_file" 2>/dev/null || true
run_privileged chmod u+rw,g+rw "$compose_file"
done
echo "ForgeFlow repaired project write access for $(id -un) and group $share_group without changing preserved runtime paths."
`;
}
async repairWriteAccess({ repository, profileId }) {
const { profile, server, remotePath } = this.resolve(repository, profileId);
const before = await this.inspectWriteAccess({ repository, profileId });
await this.ssh.exec(server.id, bash(this.permissionRepairScript(profile, server, remotePath)), {
timeout: 5 * 60_000,
maxOutput: 4 * 1024 * 1024,
});
const after = await this.inspectWriteAccess({ repository, profileId });
if (!after.ready) {
const error = new Error(
`Write-access repair did not make every required path writable: ${after.blocking.map((item) => item.path).join(", ")}`,
);
error.code = "WRITE_ACCESS_REPAIR_INCOMPLETE";
error.permissionReport = after;
throw error;
}
await this.diagnostics?.info("unraid.write-access.repaired", {
repository: repository.fullName,
profileId,
serverId: server.id,
remotePath,
user: after.identity.user,
});
return { changed: true, normalized: true, before, after };
}
}
return UnraidAccessMethods.prototype;
}
module.exports = { createUnraidAccessMethods };
+76
View File
@@ -0,0 +1,76 @@
"use strict";
const crypto = require("node:crypto");
const path = require("node:path").posix;
const { shellQuote } = require("./ssh-service.cjs");
const bash = (command) => `printf '%s' ${shellQuote(Buffer.from(`set -euo pipefail\nexport GIT_TERMINAL_PROMPT=0\n${command}`, "utf8").toString("base64"))} | base64 -d | bash`;
function parseMarker(stdout, marker) {
const text = String(stdout || "");
const index = text.lastIndexOf(marker);
if (index < 0) throw new Error(`Server key operation did not return ${marker}.`);
return Object.fromEntries(text.slice(index + marker.length).trim().split(/\r?\n/).map((line) => { const separator = line.indexOf("="); return separator > 0 ? [line.slice(0, separator), line.slice(separator + 1)] : [line, ""]; }));
}
class UnraidDeployKeyHost {
constructor({ ssh }) { this.ssh = ssh; }
paths(repository, server) {
const id = crypto.createHash("sha256").update(String(repository.fullName).toLowerCase()).digest("hex").slice(0, 24);
const directory = path.join(server.basePath, ".forgeflow", "git-credentials", id);
return { directory, privateKey: path.join(directory, "deploy-key"), publicKey: path.join(directory, "deploy-key.pub"), knownHosts: path.join(directory, "known_hosts"), recovery: path.join(directory, "recovery") };
}
remote(repository, profile) {
const value = [repository.sshUrl, profile.cloneUrl, repository.preferredCloneUrl].map((item) => String(item || "").trim()).find((item) => /^ssh:\/\//i.test(item) || /^[^@\s]+@[^:\s]+:.+/.test(item));
if (!value) throw Object.assign(new Error("Server pull requires a Gitea SSH URL."), { code: "SERVER_GIT_SSH_URL_REQUIRED" });
return value;
}
environment(paths) { return `GIT_SSH_COMMAND=${shellQuote(`ssh -i ${paths.privateKey} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${paths.knownHosts}`)}`; }
async execute(server, script, options = {}) { return this.ssh.exec(server.id, bash(script), { timeout: options.timeout || 30_000, maxOutput: options.maxOutput || 128 * 1024 }); }
async inspect({ repository, server }) {
const p = this.paths(repository, server); const marker = "__FORGEFLOW_KEY_INSPECT__";
const script = `printf '%s\\n' ${shellQuote(marker)}; printf 'privateKeyPresent=%s\\n' "$([ -s ${shellQuote(p.privateKey)} ] && echo true || echo false)"; printf 'publicKey=%s\\n' "$([ -s ${shellQuote(p.publicKey)} ] && base64 < ${shellQuote(p.publicKey)} | tr -d '\\r\\n' || true)"; printf 'fingerprint=%s\\n' "$([ -s ${shellQuote(p.publicKey)} ] && ssh-keygen -lf ${shellQuote(p.publicKey)} -E sha256 | awk '{print $2}' || true)"; printf 'hostFingerprint=%s\\n' "$([ -s ${shellQuote(p.knownHosts)} ] && ssh-keygen -lf ${shellQuote(p.knownHosts)} -E sha256 | awk '{print $2}' | sort -u | paste -sd, - || true)"`;
const f = parseMarker((await this.execute(server, script)).stdout, marker);
return { privateKeyPresent: f.privateKeyPresent === "true", publicKey: f.publicKey ? Buffer.from(f.publicKey, "base64").toString("utf8").trim() : null, fingerprint: f.fingerprint || null, hostFingerprint: f.hostFingerprint || null };
}
async backup({ repository, server }) {
const p = this.paths(repository, server); const slot = path.join(p.recovery, `backup-${Date.now()}-${crypto.randomUUID()}`); const marker = "__FORGEFLOW_KEY_BACKUP__";
const script = `umask 077; mkdir -p ${shellQuote(slot)}; for name in deploy-key deploy-key.pub known_hosts; do [ ! -e ${shellQuote(p.directory)}/"$name" ] || cp -p ${shellQuote(p.directory)}/"$name" ${shellQuote(slot)}/"$name"; done; printf '%s\\n' ${shellQuote(marker)}; printf 'recovery=%s\\n' ${shellQuote(slot)}; printf 'publicKey=%s\\n' "$([ -s ${shellQuote(p.publicKey)} ] && base64 < ${shellQuote(p.publicKey)} | tr -d '\\r\\n' || true)"`;
const f = parseMarker((await this.execute(server, script)).stdout, marker);
return { recovery: f.recovery, publicKey: f.publicKey ? Buffer.from(f.publicKey, "base64").toString("utf8").trim() : null };
}
async generate({ repository, server }) {
const active = this.paths(repository, server); const directory = path.join(active.directory, `candidate-${crypto.randomUUID()}`); const p = { directory, privateKey: path.join(directory, "deploy-key"), publicKey: path.join(directory, "deploy-key.pub"), knownHosts: path.join(directory, "known_hosts") }; const marker = "__FORGEFLOW_KEY_CANDIDATE__";
const script = `umask 077; mkdir -p ${shellQuote(directory)}; ssh-keygen -q -t ed25519 -N '' -C ${shellQuote(`forgeflow-rotation:${repository.fullName}`)} -f ${shellQuote(p.privateKey)}; cp -p ${shellQuote(active.knownHosts)} ${shellQuote(p.knownHosts)}; chmod 600 ${shellQuote(p.privateKey)} ${shellQuote(p.knownHosts)}; chmod 644 ${shellQuote(p.publicKey)}; printf '%s\\n' ${shellQuote(marker)}; printf 'publicKey=%s\\n' "$(base64 < ${shellQuote(p.publicKey)} | tr -d '\\r\\n')"; printf 'fingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.publicKey)} -E sha256 | awk '{print $2}')"; printf 'hostFingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.knownHosts)} -E sha256 | awk '{print $2}' | sort -u | paste -sd, -)"`;
const f = parseMarker((await this.execute(server, script)).stdout, marker);
return { paths: p, publicKey: Buffer.from(f.publicKey, "base64").toString("utf8").trim(), fingerprint: f.fingerprint, hostFingerprint: f.hostFingerprint };
}
async verifyCandidate({ repository, profile, server, candidate }) {
const marker = "__FORGEFLOW_KEY_PROOF__"; const remote = this.remote(repository, profile); const p = candidate.paths;
const script = `output="$(${this.environment(p)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)})"; printf '%s\\n' ${shellQuote(marker)}; printf 'remoteSha=%s\\n' "$(printf '%s' "$output" | awk 'NR==1 {print $1}')"; printf 'fingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.publicKey)} -E sha256 | awk '{print $2}')"; printf 'hostFingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.knownHosts)} -E sha256 | awk '{print $2}' | sort -u | paste -sd, -)"`;
const f = parseMarker((await this.execute(server, script, { timeout: 45_000, maxOutput: 256 * 1024 })).stdout, marker);
return { ready: /^[0-9a-f]{40}$/i.test(f.remoteSha || ""), remoteSha: f.remoteSha || null, fingerprint: f.fingerprint || null, hostFingerprint: f.hostFingerprint || null };
}
async preflightCandidate(context) { const proof = await this.verifyCandidate(context); if (!proof.ready) throw new Error("Candidate preflight did not prove the remote branch."); return proof; }
async promote({ repository, server, candidate }) {
const p = this.paths(repository, server); const c = candidate.paths;
await this.execute(server, `test -s ${shellQuote(c.privateKey)}; test -s ${shellQuote(c.publicKey)}; test -s ${shellQuote(c.knownHosts)}; cp -p ${shellQuote(c.privateKey)} ${shellQuote(p.privateKey)}.new; cp -p ${shellQuote(c.publicKey)} ${shellQuote(p.publicKey)}.new; cp -p ${shellQuote(c.knownHosts)} ${shellQuote(p.knownHosts)}.new; mv ${shellQuote(p.privateKey)}.new ${shellQuote(p.privateKey)}; mv ${shellQuote(p.publicKey)}.new ${shellQuote(p.publicKey)}; mv ${shellQuote(p.knownHosts)}.new ${shellQuote(p.knownHosts)}`);
}
async verifyActive({ repository, profile, server }) { const paths = this.paths(repository, server); return this.verifyCandidate({ repository, profile, server, candidate: { paths } }); }
async rollback({ repository, server, candidate, previous }) {
const p = this.paths(repository, server); const recovery = previous.key.recovery;
await this.execute(server, `for name in deploy-key deploy-key.pub known_hosts; do test ! -s ${shellQuote(recovery)}/"$name" || cp -p ${shellQuote(recovery)}/"$name" ${shellQuote(p.directory)}/"$name"; done; rm -rf -- ${shellQuote(candidate.paths.directory)}`);
}
async commit({ server, candidate }) { await this.execute(server, `rm -rf -- ${shellQuote(candidate.paths.directory)}`); }
async revoke({ repository, server }) {
const p = this.paths(repository, server); const revoked = path.join(p.recovery, `revoked-${Date.now()}-${crypto.randomUUID()}`);
await this.execute(server, `umask 077; mkdir -p ${shellQuote(revoked)}; for name in deploy-key deploy-key.pub known_hosts; do [ ! -e ${shellQuote(p.directory)}/"$name" ] || mv ${shellQuote(p.directory)}/"$name" ${shellQuote(revoked)}/"$name"; done`);
}
async restore({ repository, server }) {
const p = this.paths(repository, server); const marker = "__FORGEFLOW_KEY_RESTORE__";
const script = `slot="$(find ${shellQuote(p.recovery)} -mindepth 1 -maxdepth 1 -type d -print 2>/dev/null | sort | tail -1)"; test -n "$slot"; for name in deploy-key deploy-key.pub known_hosts; do test -s "$slot/$name"; cp -p "$slot/$name" ${shellQuote(p.directory)}/"$name"; done; printf '%s\\n' ${shellQuote(marker)}; printf 'publicKey=%s\\n' "$(base64 < ${shellQuote(p.publicKey)} | tr -d '\\r\\n')"; printf 'fingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.publicKey)} -E sha256 | awk '{print $2}')"; printf 'hostFingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.knownHosts)} -E sha256 | awk '{print $2}' | sort -u | paste -sd, -)"`;
const f = parseMarker((await this.execute(server, script)).stdout, marker);
return { publicKey: Buffer.from(f.publicKey, "base64").toString("utf8").trim(), fingerprint: f.fingerprint, hostFingerprint: f.hostFingerprint };
}
}
module.exports = { UnraidDeployKeyHost, parseDeployKeyMarker: parseMarker };
+576
View File
@@ -0,0 +1,576 @@
"use strict";
function createUnraidDeploymentMethods({
path, crypto, bash, shellQuote, assertFullCommitSha, nativePath, fs,
}) {
class UnraidDeploymentMethods {
pushBundleScript({ repository, profile, remotePath, targetSha, requestId, remotePart, digest, metadata, generated, iconReference, rollback = false }) {
const compose = this.composeInvocation(profile, repository);
const project = String(
profile.composeProject || this.internalSlug(profile, repository),
).trim();
const candidateFiles = [...this.deploymentComposeFiles(profile)];
if (profile.generatedCompose) candidateFiles.push(".forgeflow/compose.metadata.yml");
const candidateCompose = `forgeflow_compose -p ${shellQuote(project)} ${candidateFiles
.map((file) => `-f "$release"/${shellQuote(file)}`)
.join(" ")}`;
const preservePayload = Buffer.from(
[".forgeflow", ".git", ...(profile.preservePaths || [])].join("\n"),
"utf8",
).toString("base64");
const statusJson = this.deploymentStatusDocument({
repository,
profile,
targetSha,
requestId,
rollback,
});
const verification = this.containerVerificationScript(
profile,
repository,
compose,
{ requireRecreated: true },
);
const containerHint = String(
profile.containerName || profile.remoteFolder || repository.name || "",
).trim();
return `
root=${shellQuote(remotePath)}
expected_project=${shellQuote(project)}
tracked_container_hint=${shellQuote(containerHint)}
target=${shellQuote(targetSha)}
request_id=${shellQuote(requestId)}
incoming=${shellQuote(remotePart)}
expected_digest=${shellQuote(digest)}
release_root="$root/.forgeflow/releases/$target"
release="$release_root/source"
staging="$root/.forgeflow/staging/$request_id"
backup="$root/.forgeflow/backups/$request_id"
lock="$root/.forgeflow/deploy.lock"
mkdir -p "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups"
if [ -d "$lock" ] && find "$lock" -maxdepth 0 -mmin +120 -print -quit | grep -q .; then
lock_pid=$(cat "$lock/pid" 2>/dev/null || true)
if [ -z "$lock_pid" ] || ! kill -0 "$lock_pid" 2>/dev/null; then rm -rf "$lock"; fi
fi
mkdir "$lock" 2>/dev/null || { echo "Another ForgeFlow deployment is active for $root" >&2; exit 70; }
printf '%s\n' "$request_id" > "$lock/request-id"
printf '%s\n' "$$" > "$lock/pid"
date -u +%Y-%m-%dT%H:%M:%SZ > "$lock/started-at"
restore_needed=false
activation_started=false
is_preserved() {
rel="$1"
while IFS= read -r keep; do
[ -n "$keep" ] || continue
if [ "$rel" = "$keep" ] || [[ "$rel" == "$keep/"* ]]; then return 0; fi
done < "$staging.preserve"
return 1
}
restore_files() {
if [ -f "$backup/present" ]; then
while IFS= read -r rel; do
[ -n "$rel" ] || continue
mkdir -p -- "$root/$(dirname "$rel")"
temp="$root/$rel.forgeflow-restore-$request_id"
cp -a -- "$backup/source/$rel" "$temp" && mv -f -- "$temp" "$root/$rel"
done < "$backup/present"
fi
if [ -f "$backup/absent" ]; then
while IFS= read -r rel; do
[ -n "$rel" ] || continue
case "$rel" in .forgeflow/*) continue ;; esac
[ -e "$root/$rel" ] || [ -L "$root/$rel" ] || continue
rm -f -- "$root/$rel"
done < "$backup/absent"
fi
if [ -f "$backup/generated.present" ]; then
cp -a "$backup/compose.forgeflow.yml" "$root/.forgeflow/compose.forgeflow.yml"
elif [ -f "$backup/generated.created" ]; then
rm -f "$root/.forgeflow/compose.forgeflow.yml"
fi
if [ -f "$backup/metadata.present" ]; then
cp -a "$backup/compose.metadata.yml" "$root/.forgeflow/compose.metadata.yml"
elif [ -f "$backup/metadata.created" ]; then
rm -f "$root/.forgeflow/compose.metadata.yml"
fi
}
restore_images() {
[ -f "$backup/containers.before" ] || return 0
while IFS=$'\t' read -r service container_id image_id image_ref_b64; do
[ -n "$image_id" ] || continue
docker image inspect "$image_id" >/dev/null 2>&1 || continue
image_ref=$(printf '%s' "$image_ref_b64" | base64 -d 2>/dev/null || true)
case "$image_ref" in ''|sha256:*|*@sha256:*) continue ;; esac
docker image tag "$image_id" "$image_ref" >/dev/null 2>&1 || true
done < "$backup/containers.before"
}
restore_runtime() {
[ "$activation_started" = true ] || return 0
restore_images
if cd "$root" 2>/dev/null && ${compose} config >/dev/null 2>&1; then
${compose} up -d --no-build >/dev/null 2>&1 || return 1
old_services=$(${compose} config --services 2>/dev/null | sed '/^$/d')
printf '%s\n' "$old_services" | while IFS= read -r service; do
[ -n "$service" ] || continue
old_id=$(${compose} ps -q "$service" | head -n1)
[ -n "$old_id" ] || exit 1
[ "$(docker inspect -f '{{.State.Running}}' "$old_id" 2>/dev/null || echo false)" = true ] || exit 1
done
fi
}
finish() {
status=$?
trap - EXIT
set +e
if [ "$status" -ne 0 ] && [ "$restore_needed" = true ]; then
restore_files
if ! restore_runtime; then
echo "CRITICAL: source files were restored, but the previous Compose runtime could not be restarted automatically. Backup: $backup" >&2
else
echo "ForgeFlow restored the previous source and runtime after the failed activation." >&2
fi
fi
rm -rf "$staging" "$lock"
exit "$status"
}
trap finish EXIT
actual_digest=$(if command -v sha256sum >/dev/null 2>&1; then sha256sum "$incoming" | awk '{print $1}'; else shasum -a 256 "$incoming" | awk '{print $1}'; fi)
[ "$actual_digest" = "$expected_digest" ] || { echo "Uploaded bundle checksum mismatch" >&2; exit 71; }
tar -tf "$incoming" > "$staging.entries"
if grep -E '(^/|(^|/)\\.\\.(/|$))' "$staging.entries" >/dev/null; then echo "Unsafe path detected in deployment bundle" >&2; exit 72; fi
rm -rf "$staging" "$release_root.pending"
mkdir -p "$staging/source" "$release_root.pending"
tar -xf "$incoming" -C "$staging/source"
if find "$staging/source" -type l -print -quit | grep -q .; then echo "Symbolic links are not accepted in push bundles" >&2; exit 73; fi
mv "$staging/source" "$release_root.pending/source"
find "$release_root.pending/source" -type f -printf '%P\n' | LC_ALL=C sort > "$release_root.pending/managed-files"
rm -rf "$release_root"
mv "$release_root.pending" "$release_root"
rm -f "$incoming" "$staging.entries"
printf '%s' ${shellQuote(preservePayload)} | base64 -d > "$staging.preserve"
for runtime_config in .env compose.override.yml compose.override.yaml docker-compose.override.yml docker-compose.override.yaml; do
if [ -f "$root/$runtime_config" ] && [ ! -e "$release/$runtime_config" ]; then
mkdir -p "$release/$(dirname "$runtime_config")"
cp -a "$root/$runtime_config" "$release/$runtime_config"
fi
done
${profile.generatedCompose ? `mkdir -p "$release/.forgeflow"
cat > "$release/.forgeflow/compose.forgeflow.yml" <<'FORGEFLOW_COMPOSE'
${generated}FORGEFLOW_COMPOSE
cat > "$release/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA'
${metadata}FORGEFLOW_METADATA` : ""}
cd "$release"
${candidateCompose} config >/dev/null
candidate_services=$(${candidateCompose} config --services 2>/dev/null | sed '/^$/d')
[ -n "$candidate_services" ] || { echo "Candidate Compose project defines no services" >&2; exit 60; }
mkdir -p "$backup/source"
: > "$backup/present"
: > "$backup/absent"
: > "$backup/containers.before"
had_existing_compose=false
if cd "$root" 2>/dev/null && ${compose} config >/dev/null 2>&1; then
had_existing_compose=true
old_services=$(${compose} config --services 2>/dev/null | sed '/^$/d')
printf '%s\n' "$old_services" | while IFS= read -r service; do
[ -n "$service" ] || continue
container_id=$(${compose} ps -q "$service" | head -n1)
image_id=''; image_ref=''
if [ -n "$container_id" ] && docker inspect "$container_id" >/dev/null 2>&1; then
image_id=$(docker inspect -f '{{.Image}}' "$container_id" 2>/dev/null || true)
image_ref=$(docker inspect -f '{{.Config.Image}}' "$container_id" 2>/dev/null || true)
fi
printf '%s\t%s\t%s\t%s\n' "$service" "$container_id" "$image_id" "$(printf '%s' "$image_ref" | base64 | tr -d '\r\n')"
done >> "$backup/containers.before"
fi
if [ -n "$tracked_container_hint" ] && docker inspect "$tracked_container_hint" >/dev/null 2>&1; then
hint_project=$(docker inspect -f '{{index .Config.Labels "com.docker.compose.project"}}' "$tracked_container_hint" 2>/dev/null || true)
if [ -n "$hint_project" ] && [ "$hint_project" != "$expected_project" ]; then
echo "Refusing activation: container $tracked_container_hint belongs to Compose project $hint_project, not $expected_project" >&2
exit 67
fi
fi
# Build all candidate images before any running container is touched.
cd "$release"
${candidateCompose} build
new_manifest="$release_root/managed-files"
while IFS= read -r rel; do
[ -n "$rel" ] || continue
is_preserved "$rel" && continue
parent=$(dirname "$rel")
current="$root"
if [ "$parent" != . ]; then
old_ifs=$IFS; IFS='/'; read -r -a parts <<< "$parent"; IFS=$old_ifs
for part in "\${parts[@]}"; do
current="$current/$part"
[ ! -L "$current" ] || { echo "Refusing to deploy through symlinked parent $current" >&2; exit 74; }
done
fi
[ ! -L "$root/$rel" ] || { echo "Refusing to replace symlinked managed path $rel" >&2; exit 74; }
if [ -d "$root/$rel" ]; then echo "A directory conflicts with managed file $rel" >&2; exit 75; fi
if [ -e "$root/$rel" ]; then
mkdir -p "$backup/source/$(dirname "$rel")"
cp -a -- "$root/$rel" "$backup/source/$rel"
printf '%s\n' "$rel" >> "$backup/present"
else
printf '%s\n' "$rel" >> "$backup/absent"
fi
done < "$new_manifest"
[ -f "$root/.forgeflow/compose.metadata.yml" ] && { cp -a "$root/.forgeflow/compose.metadata.yml" "$backup/compose.metadata.yml"; touch "$backup/metadata.present"; }
[ -f "$root/.forgeflow/compose.forgeflow.yml" ] && { cp -a "$root/.forgeflow/compose.forgeflow.yml" "$backup/compose.forgeflow.yml"; touch "$backup/generated.present"; }
restore_needed=true
while IFS= read -r rel; do
[ -n "$rel" ] || continue
is_preserved "$rel" && continue
mkdir -p -- "$root/$(dirname "$rel")"
temp="$root/$rel.forgeflow-new-$request_id"
cp -a -- "$release/$rel" "$temp"
mv -f -- "$temp" "$root/$rel"
done < "$new_manifest"
mkdir -p "$root/.forgeflow"
${profile.generatedCompose ? `if [ ! -f "$backup/generated.present" ]; then touch "$backup/generated.created"; fi
if [ ! -f "$backup/metadata.present" ]; then touch "$backup/metadata.created"; fi
cat > "$root/.forgeflow/compose.forgeflow.yml.pending" <<'FORGEFLOW_COMPOSE'
${generated}FORGEFLOW_COMPOSE
mv "$root/.forgeflow/compose.forgeflow.yml.pending" "$root/.forgeflow/compose.forgeflow.yml"
cat > "$root/.forgeflow/compose.metadata.yml.pending" <<'FORGEFLOW_METADATA'
${metadata}FORGEFLOW_METADATA
mv "$root/.forgeflow/compose.metadata.yml.pending" "$root/.forgeflow/compose.metadata.yml"` : `cat > "$root/.forgeflow/deployment-metadata.json.pending" <<'FORGEFLOW_METADATA_JSON'
${JSON.stringify({ repository: repository.fullName, environment: profile.environment, commit: targetSha, requestId })}
FORGEFLOW_METADATA_JSON
mv "$root/.forgeflow/deployment-metadata.json.pending" "$root/.forgeflow/deployment-metadata.json"`}
share_group=$(getent group users >/dev/null 2>&1 && echo users || id -gn)
chgrp "$share_group" "$root" "$root/.forgeflow" 2>/dev/null || true
chmod g+rwx "$root" "$root/.forgeflow" 2>/dev/null || true
chmod g+s "$root" "$root/.forgeflow" 2>/dev/null || true
while IFS= read -r rel; do
[ -n "$rel" ] || continue
is_preserved "$rel" && continue
chgrp "$share_group" "$root/$rel" 2>/dev/null || true
chmod u+rw,g+rw "$root/$rel" 2>/dev/null || true
parent="$root/$(dirname "$rel")"
chgrp "$share_group" "$parent" 2>/dev/null || true
chmod g+rwx,g+s "$parent" 2>/dev/null || true
done < "$new_manifest"
cd "$root"
${compose} config >/dev/null
actual_services=$(${compose} config --services 2>/dev/null | sed '/^$/d')
[ -n "$actual_services" ] || { echo "Compose project defines no services" >&2; exit 60; }
if [ "$(printf '%s\n' "$candidate_services" | LC_ALL=C sort)" != "$(printf '%s\n' "$actual_services" | LC_ALL=C sort)" ]; then
echo "Refusing activation because candidate and server Compose service sets differ" >&2
exit 68
fi
before_containers="$backup/containers.before"
hint_before_id=''
if [ -n "$tracked_container_hint" ] && docker inspect "$tracked_container_hint" >/dev/null 2>&1; then
hint_before_id=$(docker inspect -f '{{.Id}}' "$tracked_container_hint" 2>/dev/null || true)
fi
activation_started=true
${compose} up -d --no-build
${verification}
if [ -n "$hint_before_id" ] && docker inspect "$hint_before_id" >/dev/null 2>&1; then
old_hint_running=$(docker inspect -f '{{.State.Running}}' "$hint_before_id" 2>/dev/null || echo false)
[ "$old_hint_running" != true ] || { echo "Compose left the previous container $tracked_container_hint ($hint_before_id) running" >&2; exit 66; }
fi
${this.dockerManRefreshScript(profile, repository, iconReference)}
previous=$(cat "$root/.forgeflow/current-sha" 2>/dev/null || true)
[ -n "$previous" ] || previous=$(git -C "$root" rev-parse HEAD 2>/dev/null || true)
[ -n "$previous" ] && printf '%s' "$previous" > "$root/.forgeflow/previous-sha"
cp "$new_manifest" "$root/.forgeflow/managed-files.pending"
mv "$root/.forgeflow/managed-files.pending" "$root/.forgeflow/managed-files"
printf '%s' "$target" > "$root/.forgeflow/current-sha.pending"
mv "$root/.forgeflow/current-sha.pending" "$root/.forgeflow/current-sha"
cat > "$root/.forgeflow/status.json.pending" <<'FORGEFLOW_STATUS'
${statusJson}
FORGEFLOW_STATUS
mv "$root/.forgeflow/status.json.pending" "$root/.forgeflow/status.json"
restore_needed=false
printf '%s\n' "successful" > "$backup/result"
date -u +%Y-%m-%dT%H:%M:%SZ > "$backup/completed-at"
echo "ForgeFlow safely activated push bundle $target; rollback evidence retained at $backup"
`;
}
async executePushBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference, rollback = false }) {
const permissionReport = await this.inspectWriteAccess({
repository,
profileId: profile.id,
});
if (!permissionReport.ready) {
const error = new Error(
`Deployment stopped before upload because write access is missing for: ${permissionReport.blocking.map((item) => item.path).join(", ")}`,
);
error.code = "REMOTE_WRITE_ACCESS_REQUIRED";
error.permissionReport = permissionReport;
throw error;
}
const bundle = await this.createCommitBundle(repository, targetSha, requestId);
const remotePart = path.join(remotePath, ".forgeflow", "incoming", `${requestId}-${targetSha}.tar.part`);
try {
await this.ssh.exec(server.id, bash(`mkdir -p ${shellQuote(path.dirname(remotePart))}`), { timeout: 30_000 });
await this.ssh.uploadFile(server.id, bundle.archivePath, remotePart, { mode: 0o600 });
const script = this.pushBundleScript({ repository, profile, remotePath, targetSha, requestId, remotePart, digest: bundle.sha256, metadata, generated, iconReference, rollback });
return await this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 8 * 1024 * 1024 });
} finally {
await fs.rm(bundle.archivePath, { force: true }).catch(() => {});
}
}
async createServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId }) {
const remote = this.serverGitRemote(repository, profile);
const repositoryId = crypto.createHash("sha256").update(String(repository.fullName).toLowerCase()).digest("hex").slice(0, 24);
const cache = path.join(server.basePath, ".forgeflow", "git-cache", `${repositoryId}.git`);
const remotePart = path.join(remotePath, ".forgeflow", "incoming", `${requestId}-${targetSha}.tar.part`);
const marker = "__FORGEFLOW_SERVER_ARCHIVE__";
const script = `
cache=${shellQuote(cache)}
incoming=${shellQuote(remotePart)}
remote=${shellQuote(remote)}
branch=${shellQuote(profile.branch)}
target=${shellQuote(targetSha)}
mkdir -p "$(dirname "$cache")" "$(dirname "$incoming")"
if [ ! -d "$cache" ]; then git init --bare "$cache" >/dev/null; fi
if git --git-dir="$cache" remote get-url origin >/dev/null 2>&1; then
git --git-dir="$cache" remote set-url origin "$remote"
else
git --git-dir="$cache" remote add origin "$remote"
fi
${this.serverGitEnvironment(repository, profile, server)} git --git-dir="$cache" fetch --force --prune origin "+refs/heads/$branch:refs/remotes/origin/$branch"
git --git-dir="$cache" cat-file -e "$target^{commit}"
git --git-dir="$cache" merge-base --is-ancestor "$target" "refs/remotes/origin/$branch"
archive_tmp="$incoming.$$.tmp"
git --git-dir="$cache" archive --format=tar --output="$archive_tmp" "$target"
[ -s "$archive_tmp" ] || { rm -f "$archive_tmp"; echo "Gitea produced an empty deployment archive" >&2; exit 45; }
mv "$archive_tmp" "$incoming"
digest=$(if command -v sha256sum >/dev/null 2>&1; then sha256sum "$incoming" | awk '{print $1}'; else shasum -a 256 "$incoming" | awk '{print $1}'; fi)
printf '%s\n' ${shellQuote(marker)}
printf 'digest=%s\n' "$digest"
`;
const result = await this.ssh.exec(server.id, bash(script), { timeout: 5 * 60_000, maxOutput: 512 * 1024 });
const output = String(result.stdout || "");
const markerIndex = output.lastIndexOf(marker);
const digest = markerIndex >= 0
? String(output.slice(markerIndex + marker.length).match(/(?:^|\n)digest=([0-9a-f]{64})(?:\n|$)/i)?.[1] || "").toLowerCase()
: "";
if (!digest) throw new Error("The server did not return a valid checksum for the Gitea archive.");
return { remotePart, digest };
}
async executeServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference, rollback = false }) {
const permissionReport = await this.inspectWriteAccess({ repository, profileId: profile.id });
if (!permissionReport.ready) {
const error = new Error(`Deployment stopped before the Gitea fetch because write access is missing for: ${permissionReport.blocking.map((item) => item.path).join(", ")}`);
error.code = "REMOTE_WRITE_ACCESS_REQUIRED";
error.permissionReport = permissionReport;
throw error;
}
const bundle = await this.createServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId });
const script = this.pushBundleScript({ repository, profile, remotePath, targetSha, requestId, remotePart: bundle.remotePart, digest: bundle.digest, metadata, generated, iconReference, rollback });
return this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 8 * 1024 * 1024 });
}
async deploy({ repository, profileId, sha }) {
const targetSha = assertFullCommitSha(sha);
const { profile, server, remotePath } = this.resolve(repository, profileId);
if (profile.deploymentMode === "server-git") {
const verification = await this.verifyServerGitProfile({ repository, profileId });
const requiredChecks = ["remote-branch", "deploy-key-scope", "server-git-access"];
const blocked = verification.checks.filter((check) => requiredChecks.includes(check.id) && check.status !== "pass");
if (blocked.length || !verification.branchSha) {
const error = new Error(`Server pull verification failed: ${blocked.map((check) => check.detail).join("; ") || "the target branch could not be proven"}`);
error.code = "SERVER_GIT_VERIFICATION_FAILED";
error.verification = verification;
throw error;
}
}
const preflight = await this.preflight({ repository, profileId, sha: targetSha });
if (!preflight.summary.ready) {
const error = new Error(`SSH deployment preflight failed: ${preflight.summary.blocking.join(", ")}`);
error.code = "SSH_DEPLOYMENT_PREFLIGHT_FAILED";
throw error;
}
const requestId = crypto.randomUUID();
const mode = ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode)
? profile.deploymentMode
: "push-bundle";
const operation = await this.saveOperation({
id: requestId,
type: "deployment",
action: "deploy",
provider: "ssh-unraid",
repository: repository.fullName,
environment: profile.environment,
profileId,
serverId: server.id,
remotePath,
sha: targetSha,
shortSha: targetSha.slice(0, 7),
status: "running",
logs: [
"Preflight passed.",
mode === "push-bundle"
? "Creating and uploading the exact committed local project directly to Unraid."
: mode === "server-git"
? "Fetching the exact commit from Gitea with a repository-scoped read-only deploy key."
: "This workload is monitor-only and cannot be deployed.",
`Deploying exact commit ${targetSha} in the background.`,
],
});
const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : "";
const iconReference = await this.prepareIcon(profile, repository, server);
const metadata = this.metadataCompose(profile, repository, iconReference, {
sha: targetSha,
repositoryUrl: profile.cloneUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName,
});
const previousState = this.store.getDeploymentState?.(profileId) || null;
void (async () => {
try {
if (mode === "monitor-only") throw new Error("This workload is monitor-only. Select Server pull or Direct copy before deploying.");
const result = mode === "server-git"
? await this.executeServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference })
: await this.executePushBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference });
const health = await this.checkHealth(profile.healthcheckUrl);
const finalStatus = health.healthy === false ? "failed" : "success";
const completed = await this.saveOperation({
...operation,
status: finalStatus,
previousSha: previousState?.liveSha || preflight.inspection?.head || null,
health,
logs: [
...operation.logs,
...result.stdout.trim().split("\n").filter(Boolean).slice(-80),
"Docker Compose activation and runtime verification completed.",
health.configured
? `Healthcheck ${health.healthy ? "passed" : "failed"}${health.status ? ` with HTTP ${health.status}` : ""}.`
: "No desktop healthcheck configured; running containers were verified and health remains unverified.",
],
error: health.healthy === false ? "The application healthcheck did not pass after deployment." : null,
});
await this.store.saveDeploymentState(profileId, {
liveSha: targetSha,
previousSha: previousState?.liveSha || preflight.inspection?.head || null,
healthy: health.configured ? health.healthy : null,
runtimeVerification: health.configured ? "desktop-healthcheck" : "running-unverified",
healthStatus: health.status ?? null,
healthLatencyMs: health.latencyMs ?? null,
requestId,
remotePath,
provider: "ssh-unraid",
deploymentMode: mode,
containerName: String(profile.containerName || profile.remoteFolder || repository.name),
containerRunning: true,
dockerMan: {
webUi: this.dockerManWebUi(profile),
icon: iconReference,
shell: this.dockerManShell(profile),
templateExists: profile.manageDockerMan === true || previousState?.dockerMan?.templateExists === true,
configured: Boolean(this.dockerManWebUi(profile) || iconReference || previousState?.dockerMan?.configured),
},
webUiUrl: profile.webUiUrl || (profile.hostPort ? `http://${server.host}:${profile.hostPort}/` : null),
});
void this.refreshProfileState(repository.fullName, profileId).catch(() => {});
await this.diagnostics?.info("unraid.deployment.completed", { requestId, repository: repository.fullName, serverId: server.id, remotePath, sha: targetSha, status: completed.status });
} catch (error) {
await this.saveOperation({
...operation,
status: "failed",
error: error.message,
failure: { stage: mode === "server-git" ? "Gitea server pull / Compose activation" : "Direct copy / Compose activation", message: error.message },
logs: [...operation.logs, error.message, "The live SHA was not promoted. Previous release evidence remains authoritative."],
});
await this.diagnostics?.error("unraid.deployment.failed", { requestId, repository: repository.fullName, serverId: server.id, remotePath, sha: targetSha, error });
}
})();
return operation;
}
async rollback({ repository, profileId, targetSha }) {
const target = assertFullCommitSha(targetSha);
const { profile, server, remotePath } = this.resolve(repository, profileId);
const deploymentState = this.store.getDeploymentState(profileId);
if (!deploymentState?.previousSha || deploymentState.previousSha !== target) {
const error = new Error("Rollback is allowed only to the exact previous SHA reported by ForgeFlow for this deployment profile.");
error.code = "ROLLBACK_TARGET_NOT_PREVIOUS_SHA";
throw error;
}
const rollbackMode = ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode)
? profile.deploymentMode
: "push-bundle";
if (rollbackMode === "push-bundle" && !repository.localPath)
throw new Error("A linked local repository is required for Direct copy rollback verification.");
const requestId = crypto.randomUUID();
const operation = await this.saveOperation({
id: requestId,
type: "deployment",
action: "rollback",
provider: "ssh-unraid",
repository: repository.fullName,
environment: profile.environment,
profileId,
serverId: server.id,
remotePath,
sha: target,
shortSha: target.slice(0, 7),
status: "running",
logs: [`Rolling back to exact commit ${target}.`],
});
const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : "";
const iconReference = await this.prepareIcon(profile, repository, server);
const metadata = this.metadataCompose(profile, repository, iconReference, {
sha: target,
repositoryUrl: profile.cloneUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName,
});
try {
const mode = rollbackMode;
if (mode === "monitor-only") throw new Error("This workload is monitor-only. Select Server pull or Direct copy before rolling back.");
const result = mode === "server-git"
? await this.executeServerGitBundle({ repository, profile, server, remotePath, targetSha: target, requestId, metadata, generated, iconReference, rollback: true })
: await this.executePushBundle({ repository, profile, server, remotePath, targetSha: target, requestId, metadata, generated, iconReference, rollback: true });
const health = await this.checkHealth(profile.healthcheckUrl);
const finalStatus = health.healthy === false ? "failed" : "rolled-back";
const completed = await this.saveOperation({
...operation,
status: finalStatus,
previousSha: deploymentState.liveSha || null,
health,
error: health.healthy === false ? "The application healthcheck did not pass after rollback." : null,
logs: [
...operation.logs,
...result.stdout.trim().split("\n").filter(Boolean).slice(-80),
"Rollback activation completed.",
health.configured ? `Healthcheck ${health.healthy ? "passed" : "failed"}.` : "Runtime is running; no desktop healthcheck was configured.",
],
});
await this.store.saveDeploymentState(profileId, {
liveSha: target,
previousSha: deploymentState.liveSha || null,
healthy: health.configured ? health.healthy : null,
runtimeVerification: health.configured ? "desktop-healthcheck" : "running-unverified",
healthStatus: health.status ?? null,
healthLatencyMs: health.latencyMs ?? null,
requestId,
remotePath,
provider: "ssh-unraid",
containerRunning: true,
});
if (health.healthy === false) {
const error = new Error("Rollback completed, but the configured healthcheck failed.");
error.code = "ROLLBACK_HEALTHCHECK_FAILED";
error.operationId = completed.id;
throw error;
}
return completed;
} catch (error) {
if (error.code !== "ROLLBACK_HEALTHCHECK_FAILED") {
await this.saveOperation({ ...operation, status: "failed", error: error.message, logs: [...operation.logs, error.message] });
}
throw error;
}
}
}
return UnraidDeploymentMethods.prototype;
}
module.exports = { createUnraidDeploymentMethods };
File diff suppressed because it is too large Load Diff
+590
View File
@@ -0,0 +1,590 @@
"use strict";
function createUnraidInventoryMethods({
shellQuote, path, parseWorkloadInventory, buildWorkloadInventory, classifyInventory,
inventoryRemoteIdentity, deriveDetectedProfile, crypto, matchInventoryContainer,
safeRemoteFolder, bash,
}) {
class UnraidInventoryMethods {
inventoryScript(server) {
const configuredRoots = [...new Set([server.basePath, ...(server.scanRoots || [])])].map((root) => ` add_scan_root ${shellQuote(root)}`).join("\n");
const configuredExcludes = (server.scanExcludes || []).map((name) => ` -o -name ${shellQuote(name)}`).join("");
return `
base=${shellQuote(server.basePath)}
platform=$(uname -srm 2>/dev/null || true)
docker_ok=false; compose_ok=false; compose_v2=false; git_ok=false; tar_ok=false; checksum_ok=false; base_writable=false; compose_version=''
command -v docker >/dev/null 2>&1 && docker_ok=true
if [ "$docker_ok" = true ]; then
if docker compose version >/dev/null 2>&1; then compose_ok=true; compose_v2=true; compose_version=$(docker compose version 2>/dev/null | head -n1); elif command -v docker-compose >/dev/null 2>&1; then compose_ok=true; compose_version=$(docker-compose version 2>/dev/null | head -n1); fi
fi
command -v git >/dev/null 2>&1 && git_ok=true
command -v tar >/dev/null 2>&1 && tar_ok=true
(command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1) && checksum_ok=true
if [ -d "$base" ]; then [ -w "$base" ] && base_writable=true; else parent=$(dirname "$base"); [ -d "$parent" ] && [ -w "$parent" ] && base_writable=true; fi
printf '__FORGEFLOW_INVENTORY__\\n'
printf 'H\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' "$docker_ok" "$compose_ok" "$git_ok" "$tar_ok" "$checksum_ok" "$base_writable" "$(printf '%s' "$compose_version" | base64 | tr -d '\\r\\n')" "$(printf '%s' "$platform" | base64 | tr -d '\\r\\n')"
ids=''
if [ "$docker_ok" != true ]; then
printf 'W\\t%s\\n' "$(printf '%s' 'Docker is not installed or not in PATH. Compose files and DockerMan templates will still be scanned.' | base64 | tr -d '\\r\\n')"
else
if ! ids=$(docker ps -aq --no-trunc 2>&1); then
printf 'W\\t%s\\n' "$(printf '%s' "Docker inventory failed: $ids. Compose files and DockerMan templates will still be scanned." | head -c 2000 | base64 | tr -d '\\r\\n')"
ids=''
fi
fi
if [ -n "$ids" ]; then
disappeared=0
while IFS= read -r container_id; do
[ -n "$container_id" ] || continue
if inspect=$(docker inspect --format '{"id":{{json .Id}},"name":{{json .Name}},"image":{{json .Config.Image}},"imageId":{{json .Image}},"running":{{json .State.Running}},"status":{{json .State.Status}},"health":{{if .State.Health}}{{json .State.Health.Status}}{{else}}null{{end}},"labels":{{json .Config.Labels}},"ports":{{json .NetworkSettings.Ports}},"mounts":{{json .Mounts}},"networks":{{json .NetworkSettings.Networks}},"restartPolicy":{{json .HostConfig.RestartPolicy.Name}}}' "$container_id" 2>/dev/null); then
printf 'C\\t%s\\n' "$(printf '%s' "$inspect" | base64 | tr -d '\\r\\n')"
else
disappeared=$((disappeared + 1))
fi
done <<< "$ids"
if [ "$disappeared" -gt 0 ]; then
printf 'W\\t%s\\n' "$(printf '%s' "$disappeared stale container reference(s) disappeared during inventory; current containers were still processed." | base64 | tr -d '\\r\\n')"
fi
fi
templates_dir=/boot/config/plugins/dockerMan/templates-user
if [ -d "$templates_dir" ]; then
find "$templates_dir" -maxdepth 1 -type f -name '*.xml' -print0 2>/dev/null | while IFS= read -r -d '' template; do
read_tag() { sed -n "s#.*<$1>\\(.*\\)</$1>.*#\\1#p" "$template" | head -n1; }
name=$(read_tag Name)
[ -n "$name" ] || continue
printf 'D\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \\
"$(printf '%s' "$name" | base64 | tr -d '\\r\\n')" \\
"$(printf '%s' "$template" | base64 | tr -d '\\r\\n')" \\
"$(printf '%s' "$(read_tag WebUI)" | base64 | tr -d '\\r\\n')" \\
"$(printf '%s' "$(read_tag Icon)" | base64 | tr -d '\\r\\n')" \\
"$(printf '%s' "$(read_tag Shell)" | base64 | tr -d '\\r\\n')" \\
"$(printf '%s' "$(read_tag Repository)" | base64 | tr -d '\\r\\n')" \\
"$(printf '%s' "$(read_tag Network)" | base64 | tr -d '\\r\\n')"
done
fi
if [ "$compose_ok" = true ]; then
compose_projects=$(docker compose ls --all --format json 2>/dev/null || docker-compose ls --all --format json 2>/dev/null || true)
if [ -n "$compose_projects" ]; then
printf 'P\\t%s\\n' "$(printf '%s' "$compose_projects" | base64 | tr -d '\\r\\n')"
fi
fi
scan_roots=()
add_scan_root() {
candidate=$1
[ -d "$candidate" ] || return 0
for existing in "\${scan_roots[@]}"; do [ "$existing" = "$candidate" ] && return 0; done
scan_roots+=("$candidate")
}
${configuredRoots}
for root in "\${scan_roots[@]}"; do
scan_error=$(mktemp)
while IFS= read -r -d '' primary; do
dir=$(dirname "$primary")
filename=$(basename "$primary")
case "$filename" in
compose.override.yml|compose.override.yaml|docker-compose.override.yml|docker-compose.override.yaml) continue ;;
compose.yml) ;;
compose.yaml) [ -f "$dir/compose.yml" ] && continue ;;
docker-compose.yml) { [ -f "$dir/compose.yml" ] || [ -f "$dir/compose.yaml" ]; } && continue ;;
docker-compose.yaml) { [ -f "$dir/compose.yml" ] || [ -f "$dir/compose.yaml" ] || [ -f "$dir/docker-compose.yml" ]; } && continue ;;
*) { [ -f "$dir/compose.yml" ] || [ -f "$dir/compose.yaml" ] || [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/docker-compose.yaml" ]; } && continue ;;
esac
(
set -- -f "$primary"
files_text=$primary
for extra in "$dir/compose.override.yml" "$dir/compose.override.yaml" "$dir/docker-compose.override.yml" "$dir/docker-compose.override.yaml"; do
[ -f "$extra" ] || continue
set -- "$@" -f "$extra"
files_text="$files_text
$extra"
done
project_name=$(sed -n 's/^name:[[:space:]]*//p' "$primary" 2>/dev/null | head -n1 | cut -d'#' -f1 | tr -d '"' | tr -d "'" | xargs 2>/dev/null || true)
[ -n "$project_name" ] || project_name=$(basename "$dir")
valid=false; services=''; images=''; compose_error=''
if [ "$compose_ok" != true ]; then
compose_error='Docker Compose is unavailable; file metadata was still detected.'
elif [ "$compose_v2" = true ]; then
if services=$(cd "$dir" && docker compose "$@" config --services 2>&1); then
valid=true
images=$(cd "$dir" && docker compose "$@" config --images 2>/dev/null || true)
else compose_error=$services; services=''; fi
else
if services=$(cd "$dir" && docker-compose "$@" config --services 2>&1); then
valid=true
images=$(cd "$dir" && docker-compose "$@" config --images 2>/dev/null || true)
else compose_error=$services; services=''; fi
fi
if [ -z "$services" ]; then
services=$(awk '
/^[[:space:]]*services:[[:space:]]*($|#)/ { in_services=1; next }
in_services && /^[^[:space:]]/ { exit }
in_services && /^ [A-Za-z0-9._-]+:[[:space:]]*($|#)/ {
line=$0; sub(/^[[:space:]]*/, "", line); sub(/:.*/, "", line); print line
}
' "$primary" 2>/dev/null || true)
fi
if [ -z "$images" ]; then
images=$(awk '
/^[[:space:]]*services:[[:space:]]*($|#)/ { in_services=1; next }
in_services && /^[^[:space:]]/ { exit }
in_services && /^[[:space:]]+image:[[:space:]]*/ {
line=$0; sub(/^[[:space:]]*image:[[:space:]]*/, "", line); sub(/[[:space:]]+#.*/, "", line); gsub(/"/, "", line); print line
}
' "$primary" 2>/dev/null || true)
fi
printf 'Y\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \\
"$(printf '%s' "$dir" | base64 | tr -d '\\r\\n')" \\
"$(printf '%s' "$files_text" | base64 | tr -d '\\r\\n')" \\
"$(printf '%s' "$project_name" | base64 | tr -d '\\r\\n')" \\
"$(printf '%s' "$services" | base64 | tr -d '\\r\\n')" \\
"$(printf '%s' "$images" | base64 | tr -d '\\r\\n')" \\
"$valid" \\
"$(printf '%s' "$compose_error" | head -c 2000 | base64 | tr -d '\\r\\n')"
)
done < <(find "$root" -mindepth 2 -maxdepth 4 \\( -type d \\( -name .git -o -name node_modules -o -name .forgeflow -o -name releases -o -name backups -o -name staging -o -name incoming -o -name '_audit_quarantine' -o -name 'devrunbook-validation' -o -name 'source-pre-*' -o -name cache -o -name caches -o -name logs -o -name database -o -name databases${configuredExcludes} \\) -prune \\) -o \\( -type f \\( -name '*compose*.yml' -o -name '*compose*.yaml' -o -name 'stack.yml' -o -name 'stack.yaml' \\) -print0 \\) 2>"$scan_error" || true)
if [ -s "$scan_error" ]; then
scan_message=$(printf 'Inventory scan partially failed for %s: %s' "$root" "$(head -n 1 "$scan_error")")
printf 'W\\t%s\\n' "$(printf '%s' "$scan_message" | base64 | tr -d '\\r\\n')"
fi
rm -f "$scan_error"
done
`;
}
allSshProfiles() {
const result = [];
for (const [repositoryFullName, profiles] of Object.entries(this.store.data?.deploymentProfiles || {})) {
for (const profile of profiles || []) {
if (profile?.provider === "ssh-unraid") result.push({ ...profile, _repositoryFullName: repositoryFullName });
}
}
return result;
}
relativeComposeFiles(workload) {
const workingDir = String(workload.compose?.workingDir || "").replace(/\/+$/, "");
const files = (workload.compose?.configFiles || []).map((file) => {
const value = String(file || "").trim();
if (workingDir && value.startsWith(`${workingDir}/`)) return value.slice(workingDir.length + 1);
return value.startsWith("/") ? path.basename(value) : value;
}).filter(Boolean);
return [...new Set(files.length ? files : ["docker-compose.yml"])];
}
profileFromWorkload(repository, server, workload, { linkSource = "manual", deploymentMode = "server-git", remoteFolder = "" } = {}) {
const effectiveDeploymentMode = ["push-bundle", "server-git", "monitor-only"].includes(deploymentMode)
? deploymentMode
: "server-git";
const selectedFolder = safeRemoteFolder(remoteFolder || workload.remoteFolderCandidate || repository.name);
const composeFiles = this.relativeComposeFiles(workload);
const services = [...new Set((workload.compose?.services || [])
.map((service) => String(service || "").trim().toLowerCase().replace(/[^a-z0-9._-]/g, "-"))
.filter(Boolean))];
const primary = workload.containers.find((container) => container.running) || workload.containers[0] || {};
const primaryPort = (primary.ports || []).find((item) => item.hostPort) || primary.ports?.[0] || {};
const remotePath = path.join(server.basePath, selectedFolder);
const preservePaths = new Set([".env", "appdata", "data", "logs", "config", "compose.override.yml"]);
for (const container of workload.containers || []) {
for (const mount of container.mounts || []) {
const source = String(mount.source || "");
if (!source.startsWith(`${remotePath}/`)) continue;
const relative = source.slice(remotePath.length + 1).split("/")[0];
if (relative) preservePaths.add(relative);
}
}
const idPrefix = String(linkSource).startsWith("automatic") ? "auto" : "link";
const profileId = `${idPrefix}-${crypto.createHash("sha256").update(`${server.id}:${repository.fullName}:${workload.workloadId}`).digest("hex").slice(0, 20)}`;
return {
id: profileId,
name: `${server.name} · ${workload.displayName}`,
environment: "production",
provider: "ssh-unraid",
branch: workload.metadata?.branch || repository.defaultBranch || "main",
serverId: server.id,
remoteFolder: selectedFolder,
deploymentMode: effectiveDeploymentMode,
composeFile: composeFiles[0],
composeFiles,
composeProject: workload.compose?.project || "",
composeWorkingDir: workload.compose?.workingDir || "",
composeService: services[0] || String(primary.service || selectedFolder.split("/").pop()).toLowerCase().replace(/[^a-z0-9._-]/g, "-") || "app",
composeServices: services.length ? services : [String(primary.service || selectedFolder.split("/").pop()).toLowerCase().replace(/[^a-z0-9._-]/g, "-") || "app"],
containerName: primary.name || selectedFolder.split("/").pop(),
cloneUrl: workload.metadata?.sourceRepository || repository.sshUrl || repository.cloneUrl || "",
alignRemote: false,
hostPort: primaryPort.hostPort || null,
containerPort: primaryPort.containerPort || null,
webUiUrl: workload.metadata?.webUiUrl || workload.dockerMan?.webUiUrl || "",
iconMode: "none",
iconUrl: "",
iconFilePath: "",
serverIconReference: workload.metadata?.iconUrl || workload.dockerMan?.iconUrl || "",
dockerShell: ["/bin/bash", "/bin/sh"].includes(workload.metadata?.shell) ? workload.metadata.shell : "/bin/sh",
preservePaths: [...preservePaths],
generatedCompose: false,
adoptedFromServer: true,
serverSourceOfTruth: true,
manageDockerMan: false,
forceRecreate: false,
removeOrphans: false,
workloadIdentity: {
workloadId: workload.workloadId,
selector: workload.selector,
linkSource,
linkedAt: new Date().toISOString(),
},
detectedAt: new Date().toISOString(),
detectedMetadata: {
source: `${linkSource}-server-inventory`,
kind: workload.kind,
composeProject: workload.compose?.project || "",
composeFiles,
services,
image: primary.image || "",
dockerManTemplatePath: workload.dockerMan?.templatePath || "",
},
confirmationRequired: !String(linkSource).startsWith("automatic"),
};
}
refreshedProfileFromWorkload(repository, server, workload, existingProfile) {
const detected = this.profileFromWorkload(repository, server, workload, {
linkSource: existingProfile.workloadIdentity?.linkSource || "automatic-compose",
deploymentMode: ["push-bundle", "server-git", "monitor-only"].includes(existingProfile.deploymentMode)
? existingProfile.deploymentMode
: "push-bundle",
remoteFolder: workload.remoteFolderCandidate || existingProfile.remoteFolder,
});
return {
...existingProfile,
deploymentMode: detected.deploymentMode,
remoteFolder: detected.remoteFolder,
composeFile: detected.composeFile,
composeFiles: detected.composeFiles,
composeProject: detected.composeProject,
composeWorkingDir: detected.composeWorkingDir,
composeService: detected.composeService,
composeServices: detected.composeServices,
containerName: detected.containerName || existingProfile.containerName,
hostPort: detected.hostPort || existingProfile.hostPort || null,
containerPort: detected.containerPort || existingProfile.containerPort || null,
webUiUrl: detected.webUiUrl || existingProfile.webUiUrl || "",
serverIconReference: detected.serverIconReference || existingProfile.serverIconReference || "",
dockerShell: detected.dockerShell || existingProfile.dockerShell || "/bin/sh",
preservePaths: [...new Set([...(existingProfile.preservePaths || []), ...(detected.preservePaths || [])])],
generatedCompose: false,
adoptedFromServer: true,
serverSourceOfTruth: true,
manageDockerMan: false,
forceRecreate: false,
removeOrphans: false,
workloadIdentity: detected.workloadIdentity,
detectedAt: detected.detectedAt,
detectedMetadata: detected.detectedMetadata,
};
}
async saveWorkloadState(profile, workload, server) {
const candidateSha = String(workload.metadata?.liveRevision || "");
const previousState = this.store.getDeploymentState?.(profile.id) || {};
const observedLiveSha = /^[0-9a-f]{40,64}$/i.test(candidateSha) ? candidateSha.toLowerCase() : null;
const liveSha = observedLiveSha || previousState.liveSha || null;
const profileRemote = inventoryRemoteIdentity(profile.cloneUrl);
const workloadRemote = inventoryRemoteIdentity(workload.metadata?.sourceRepository);
const repositoryMatches = Boolean(observedLiveSha && profileRemote && workloadRemote && profileRemote === workloadRemote);
const primary = workload.containers.find((container) => container.running) || workload.containers[0] || {};
return this.store.saveDeploymentState(profile.id, {
liveSha,
healthy: workload.runtime.health === "healthy" ? true : workload.runtime.health === "unhealthy" ? false : null,
runtimeVerification: workload.runtime.health === "unverified" ? "running-unverified" : workload.runtime.health,
containerRunning: workload.runtime.running,
dockerHealth: primary.health || null,
containerName: primary.name || profile.containerName,
remotePath: path.join(server.basePath, profile.remoteFolder),
provider: "ssh-unraid",
workloadId: workload.workloadId,
composeProject: workload.compose?.project || null,
observedAt: workload.observedAt,
evidence: liveSha ? "container-provenance-label" : "runtime-only",
giteaSha: repositoryMatches ? observedLiveSha : previousState.giteaSha || null,
matchesGitea: repositoryMatches ? true : previousState.matchesGitea === true && previousState.liveSha === liveSha,
previousSha: previousState.previousSha || null,
});
}
async collectServerInventory(serverId, repositories) {
const server = this.store.getServer(serverId);
if (!server) throw new Error("The deployment server no longer exists.");
const result = await this.ssh.exec(server.id, bash(this.inventoryScript(server)), {
timeout: 180_000,
maxOutput: 64 * 1024 * 1024,
});
const inventory = parseWorkloadInventory(result.stdout);
const profiles = this.allSshProfiles();
const detectedWorkloads = buildWorkloadInventory({
inventory,
server,
repositories,
profiles,
});
const detectedIds = new Set(detectedWorkloads.map((item) => item.workloadId));
const staleLinks = profiles.filter((profile) => profile.serverId === serverId && profile.workloadIdentity?.workloadId && !detectedIds.has(profile.workloadIdentity.workloadId)).map((profile) => ({
workloadId: profile.workloadIdentity.workloadId,
serverId,
displayName: profile.name || profile.remoteFolder || profile._repositoryFullName,
status: "stale",
link: { profileId: profile.id, repositoryFullName: profile._repositoryFullName },
compose: { project: profile.composeProject || "", workingDir: profile.composeWorkingDir || path.join(server.basePath, profile.remoteFolder || ""), configFiles: profile.composeFiles || [profile.composeFile].filter(Boolean), services: profile.composeServices || [profile.composeService].filter(Boolean) },
containers: [],
runtime: { running: false, health: "missing" },
metadata: { sourceRepository: profile.cloneUrl || "", liveRevision: "", branch: profile.branch || "", staleLink: true },
candidates: [{ repositoryFullName: profile._repositoryFullName, repositoryName: profile._repositoryFullName.split("/").pop(), score: 100, exact: true, reasons: ["persisted deployment profile"] }],
remoteFolderCandidate: profile.remoteFolder || "",
observedAt: new Date().toISOString(),
}));
const workloads = classifyInventory([...detectedWorkloads, ...staleLinks], profiles, this.store.getInventoryReviewDecisions?.(serverId) || []);
return { server, inventory, workloads };
}
inventoryResponse(server, inventory, workloads, changes = {}) {
const summary = {
serverId: server.id,
serverName: server.name,
detected: workloads.length,
adopted: Number(changes.adopted || 0),
refreshed: Number(changes.refreshed || 0),
retired: Number(changes.retired || 0),
staleProfiles: Array.isArray(changes.staleProfiles) ? changes.staleProfiles : [],
verified: workloads.filter((item) => item.runtime.health === "healthy" && item.link).length,
linked: workloads.filter((item) => item.status === "linked").length,
unmatched: workloads.filter((item) => !item.link).length,
needsReview: workloads.filter((item) => {
if (item.reviewDecision) return false;
const type = item.classification?.type;
if (type === "duplicate") return item.runtime?.running === true;
if (type === "stale-link") return true;
if (["system-container", "external-container", "temporary-runtime", "backup", "release-folder", "historical-compose", "manually-excluded"].includes(type)) return false;
return item.runtime?.running && ["suggested", "ambiguous", "unmatched"].includes(item.status);
}).length,
duplicates: workloads.filter((item) => item.classification?.type === "duplicate").length,
excluded: workloads.filter((item) => ["system-container", "external-container", "temporary-runtime", "backup", "release-folder", "historical-compose", "manually-excluded"].includes(item.classification?.type)).length,
running: workloads.filter((item) => item.runtime.running).length,
stopped: workloads.filter((item) => !item.runtime.running).length,
};
return {
...summary,
server: { id: server.id, name: server.name, host: server.host, basePath: server.basePath },
capabilities: inventory.capabilities,
warnings: inventory.warnings,
workloads,
observedAt: new Date().toISOString(),
};
}
async scanServerInventory(serverId, repositories) {
const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories);
const response = this.inventoryResponse(server, inventory, workloads);
await this.diagnostics?.info("unraid.workloads.scanned", {
serverId,
detected: response.detected,
linked: response.linked,
needsReview: response.needsReview,
readOnly: true,
});
return response;
}
reconciliationPlan(server, workloads, repositories, { autoLink = true } = {}) {
const profiles = this.allSshProfiles().filter((profile) => profile.serverId === server.id);
const activeWorkloadIds = new Set(workloads.filter((item) => item.classification?.type !== "stale-link").map((item) => item.workloadId));
const linkedRepositories = new Set(workloads.filter((item) => item.link?.repositoryFullName).map((item) => String(item.link.repositoryFullName).toLowerCase()));
const additions = [];
const updates = [];
const conflicts = [];
for (const workload of workloads) {
if (["duplicate", "backup", "release-folder", "historical-compose", "system-container", "external-container", "temporary-runtime", "manually-excluded", "stale-link"].includes(workload.classification?.type)) {
if (!workload.reviewDecision && (["historical-compose", "stale-link"].includes(workload.classification?.type) || (workload.classification?.type === "duplicate" && workload.runtime?.running))) conflicts.push({ workloadId: workload.workloadId, displayName: workload.displayName, status: workload.classification.type, reason: workload.classification.reason, candidates: (workload.candidates || []).slice(0, 5).map((item) => ({ repositoryFullName: item.repositoryFullName, score: item.score, exact: item.exact === true })) });
continue;
}
if (workload.link?.profileId && workload.link?.repositoryFullName) {
updates.push({
workloadId: workload.workloadId,
profileId: workload.link.profileId,
repositoryFullName: workload.link.repositoryFullName,
impact: "Refresh detected Compose identity and observed deployment state",
});
continue;
}
const candidate = workload.candidates?.[0];
const unique = workload.candidates?.length === 1;
const exact = unique && (candidate?.exact === true || (candidate?.identityExact === true && candidate.score >= 70));
if (autoLink && exact && workload.runtime?.running && !linkedRepositories.has(String(candidate.repositoryFullName).toLowerCase())) {
additions.push({
workloadId: workload.workloadId,
repositoryFullName: candidate.repositoryFullName,
evidence: candidate.exact ? "exact-provenance" : "exact-runtime-identity",
impact: "Create a server-pull deployment profile; no container changes",
});
} else if (["suggested", "ambiguous"].includes(workload.status) || (workload.runtime?.running && workload.candidates?.length)) {
conflicts.push({
workloadId: workload.workloadId,
displayName: workload.displayName,
status: workload.status,
candidates: (workload.candidates || []).slice(0, 5).map((item) => ({ repositoryFullName: item.repositoryFullName, score: item.score, exact: item.exact === true })),
});
}
}
const stale = profiles.filter((profile) =>
String(profile.workloadIdentity?.linkSource || "").startsWith("automatic")
&& profile.workloadIdentity?.workloadId
&& !activeWorkloadIds.has(profile.workloadIdentity.workloadId),
).map((profile) => ({
profileId: profile.id,
repositoryFullName: profile._repositoryFullName,
reason: "workload-missing",
impact: "Review only; ForgeFlow will not remove this profile automatically",
}));
const payload = { serverId: server.id, additions, updates, stale, conflicts };
return {
id: crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex"),
createdAt: new Date().toISOString(),
...payload,
summary: { additions: additions.length, updates: updates.length, stale: stale.length, conflicts: conflicts.length },
};
}
async planServerInventoryReconciliation(serverId, repositories, options = {}) {
const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories);
const plan = this.reconciliationPlan(server, workloads, repositories, options);
return { inventory: this.inventoryResponse(server, inventory, workloads), plan };
}
async reconcileServerInventory(serverId, repositories, { autoLink = true, expectedPlanId = "" } = {}) {
const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories);
const plan = this.reconciliationPlan(server, workloads, repositories, { autoLink });
if (!expectedPlanId || expectedPlanId !== plan.id) {
const error = new Error(expectedPlanId ? "The server inventory changed after the reconciliation preview. Review a fresh plan before applying it." : "Apply reconciliation only with an explicitly reviewed plan ID.");
error.code = expectedPlanId ? "RECONCILIATION_PLAN_STALE" : "RECONCILIATION_PLAN_REQUIRED";
error.plan = plan;
throw error;
}
const recoverySnapshot = await this.store.createRecoverySnapshot?.(`server-reconciliation-${serverId}`) || null;
let adopted = 0;
let refreshed = 0;
let retired = 0;
let staleProfiles = [];
const inventoryStable = (inventory.warnings || []).every((warning) => /stale container reference\(s\) disappeared during inventory/i.test(warning));
if (inventoryStable && workloads.length) {
const activeWorkloadIds = new Set(workloads.filter((item) => item.classification?.type !== "stale-link").map((item) => item.workloadId));
const staleAutomaticProfiles = this.allSshProfiles().filter((profile) =>
profile.serverId === serverId
&& String(profile.workloadIdentity?.linkSource || "").startsWith("automatic")
&& profile.workloadIdentity?.workloadId
&& !activeWorkloadIds.has(profile.workloadIdentity.workloadId),
);
const runningRepositoryLinks = new Set(workloads
.filter((workload) => workload.runtime?.running && workload.link?.repositoryFullName)
.map((workload) => String(workload.link.repositoryFullName).toLowerCase()));
const runningProfileIds = new Set(workloads
.filter((workload) => workload.runtime?.running && workload.link?.profileId)
.map((workload) => workload.link.profileId));
const shadowedAutomaticProfiles = workloads
.filter((workload) => !workload.runtime?.running && workload.shadowedLink?.profileId && !runningProfileIds.has(workload.shadowedLink.profileId) && runningRepositoryLinks.has(String(workload.shadowedLink.repositoryFullName).toLowerCase()))
.map((workload) => this.allSshProfiles().find((profile) => profile.id === workload.shadowedLink.profileId && String(profile._repositoryFullName).toLowerCase() === String(workload.shadowedLink.repositoryFullName).toLowerCase()))
.filter((profile) => profile && String(profile.workloadIdentity?.linkSource || "").startsWith("automatic"));
staleProfiles = [...new Map([...staleAutomaticProfiles, ...shadowedAutomaticProfiles].map((profile) => [profile.id, {
profileId: profile.id,
repositoryFullName: profile._repositoryFullName,
reason: staleAutomaticProfiles.includes(profile) ? "workload-missing" : "shadowed-by-running-workload",
}])).values()];
}
for (const workload of workloads) {
if (workload.status !== "linked" || !workload.link?.profileId || !workload.link?.repositoryFullName) continue;
const repository = (repositories || []).find((item) => String(item.fullName).toLowerCase() === String(workload.link.repositoryFullName).toLowerCase());
const existingProfile = this.store.getDeploymentProfile?.(workload.link.repositoryFullName, workload.link.profileId)
|| this.allSshProfiles().find((item) => item.id === workload.link.profileId && item._repositoryFullName === workload.link.repositoryFullName);
if (!repository || !existingProfile) continue;
const updated = this.refreshedProfileFromWorkload(repository, server, workload, existingProfile);
const saved = await this.store.saveDeploymentProfile(repository.fullName, updated);
await this.saveWorkloadState(saved, workload, server);
refreshed += 1;
}
if (autoLink) {
const alreadyLinkedRepositories = new Set(workloads
.filter((item) => item.runtime?.running && item.link?.repositoryFullName)
.map((item) => String(item.link.repositoryFullName).toLowerCase()));
for (const workload of workloads) {
if (workload.status === "linked") continue;
if (["duplicate", "backup", "release-folder", "historical-compose", "system-container", "external-container", "temporary-runtime", "manually-excluded"].includes(workload.classification?.type)) continue;
const candidate = workload.candidates[0];
const uniqueCandidate = workload.candidates.length === 1;
if (candidate && alreadyLinkedRepositories.has(String(candidate.repositoryFullName).toLowerCase())) continue;
const exactMatch = uniqueCandidate && candidate?.exact === true;
const exactRuntimeIdentity = uniqueCandidate
&& candidate?.identityExact === true
&& candidate.score >= 70
&& workload.runtime?.running === true
&& Boolean(workload.remoteFolderCandidate)
&& !alreadyLinkedRepositories.has(String(candidate.repositoryFullName).toLowerCase());
if (!exactMatch && !exactRuntimeIdentity) continue;
const repository = (repositories || []).find((item) => String(item.fullName).toLowerCase() === String(candidate.repositoryFullName).toLowerCase());
if (!repository) continue;
const linkSource = exactMatch ? "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 };
alreadyLinkedRepositories.add(String(repository.fullName).toLowerCase());
adopted += 1;
}
}
for (const stale of staleProfiles) {
await this.store.deleteDeploymentProfile(stale.repositoryFullName, stale.profileId);
retired += 1;
}
const response = this.inventoryResponse(server, inventory, workloads, { adopted, refreshed, retired, staleProfiles });
response.recoverySnapshot = recoverySnapshot;
await this.diagnostics?.info("unraid.workloads.reconciled", {
serverId,
detected: response.detected,
adopted,
refreshed,
retired,
});
return response;
}
async discoverServerWorkloads(serverId, repositories) {
return this.scanServerInventory(serverId, repositories);
}
async linkServerWorkload({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) {
const effectiveDeploymentMode = ["push-bundle", "server-git", "monitor-only"].includes(deploymentMode)
? deploymentMode
: "server-git";
const server = this.store.getServer(serverId);
if (!server) throw new Error("The deployment server no longer exists.");
const inventory = await this.scanServerInventory(serverId, [repository]);
const workload = inventory.workloads.find((item) => item.workloadId === workloadId);
if (!workload) throw new Error("The selected server workload no longer exists. Scan the server again.");
const existing = this.allSshProfiles().find((profile) => profile.workloadIdentity?.workloadId === workloadId && profile.serverId === serverId);
if (existing && String(existing._repositoryFullName).toLowerCase() !== String(repository.fullName).toLowerCase()) {
const error = new Error(`This workload is already linked to ${existing._repositoryFullName}. Remove or edit that link first.`);
error.code = "WORKLOAD_ALREADY_LINKED";
throw error;
}
const profile = this.profileFromWorkload(repository, server, workload, { linkSource: "manual", deploymentMode: effectiveDeploymentMode, remoteFolder });
const saved = await this.store.saveDeploymentProfile(repository.fullName, profile);
const state = await this.saveWorkloadState(saved, workload, server);
await this.diagnostics?.info("unraid.workload.linked", { serverId, workloadId, repository: repository.fullName, profileId: saved.id, deploymentMode: effectiveDeploymentMode });
return { profile: saved, state, workload };
}
}
return UnraidInventoryMethods.prototype;
}
module.exports = { createUnraidInventoryMethods };
+593
View File
@@ -0,0 +1,593 @@
"use strict";
function createUnraidPreflightMethods({
safeRemoteFolder, path, bash, parseInspection, dockerIgnoreHasPath, checksSummary,
inventoryRemoteIdentity, deriveDetectedProfile, decodeBase64Json, shellQuote,
assertFullCommitSha, nativePath, safeRelativeRemoteFile, fs,
}) {
class UnraidPreflightMethods {
async saveOperation(operation) {
const saved = await this.store.addOperation(operation);
this.onOperationChange?.({ operations: [saved] });
return saved;
}
resolve(repository, profileId) {
const profile = this.store.getDeploymentProfile(
repository.fullName,
profileId,
);
if (!profile || profile.provider !== "ssh-unraid")
throw new Error("The SSH / Unraid deployment profile no longer exists.");
const server = this.store.getServer(profile.serverId);
if (!server) throw new Error("The deployment server no longer exists.");
const remoteFolder = safeRemoteFolder(
profile.remoteFolder || repository.name,
);
const remotePath = path.join(server.basePath, remoteFolder);
if (!remotePath.startsWith(`${server.basePath}/`))
throw new Error(
"Remote project path escapes the configured server base path.",
);
const effectiveProfile = {
...profile,
deploymentMode: ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode)
? profile.deploymentMode
: "push-bundle",
};
return { profile: effectiveProfile, server, remoteFolder, remotePath };
}
async discoverExisting({ repository, serverId, remoteFolder = "" }) {
const server = this.store.getServer(serverId);
if (!server) throw new Error("The deployment server no longer exists.");
const folder = safeRemoteFolder(remoteFolder || repository.name);
const remotePath = path.join(server.basePath, folder);
const inventory = await this.scanServerInventory(serverId, [repository], { autoLink: false });
const workload = inventory.workloads.find((item) =>
item.remoteFolderCandidate === folder ||
item.compose?.workingDir === remotePath ||
item.containers.some((container) => (container.mounts || []).some((mount) => {
const source = String(mount.source || "").replace(/\/+$/, "");
return source === remotePath || source.startsWith(`${remotePath}/`);
}))
);
if (!workload) {
const error = new Error(`No Docker or Compose workload could be matched to ${remotePath}. Use Server Inventory to select the running container directly.`);
error.code = "SERVER_WORKLOAD_NOT_FOUND";
throw error;
}
const profile = this.profileFromWorkload(repository, server, workload, {
linkSource: "manual",
deploymentMode: "server-git",
remoteFolder: folder,
});
const source = (value, origin, confidence = "confirmed") => ({
value,
origin,
confidence,
detectedAt: new Date().toISOString(),
overridden: false,
});
const provenance = {
remoteFolder: source(folder, "server-inventory"),
cloneUrl: source(profile.cloneUrl, workload.metadata?.sourceRepository ? "container-provenance" : "repository"),
branch: source(profile.branch, workload.metadata?.branch ? "container-provenance" : "repository"),
composeFile: source(profile.composeFile, "docker-compose-labels"),
composeService: source(profile.composeService, "docker-compose-labels"),
containerName: source(profile.containerName, "docker-inspect"),
hostPort: source(profile.hostPort, "docker-inspect"),
containerPort: source(profile.containerPort, "docker-inspect"),
webUiUrl: source(profile.webUiUrl, workload.dockerMan?.webUiUrl ? "unraid-dockerman" : "docker-labels"),
iconUrl: source(profile.serverIconReference, workload.dockerMan?.iconUrl ? "unraid-dockerman" : "docker-labels"),
dockerShell: source(profile.dockerShell, workload.dockerMan?.shell ? "unraid-dockerman" : "docker-labels"),
};
return {
repository: repository.fullName,
profile: { ...profile, id: undefined, provenance },
provenance,
workload,
runtime: {
remotePath,
containerRunning: workload.runtime.running,
containers: workload.containers.length,
services: workload.compose?.services?.length || workload.containers.length,
ports: workload.runtime.ports,
mounts: workload.containers.flatMap((container) => container.mounts || []),
networks: [...new Set(workload.containers.flatMap((container) => container.networks || []))],
envNames: [],
},
};
}
async inspect({ repository, profileId }) {
const { profile, server, remotePath } = this.resolve(repository, profileId);
const preserveProbe = (profile.preservePaths || [])
.map(
(relativePath) =>
`if [ -e "$root"/${shellQuote(relativePath)} ]; then printf '%s\\n' ${shellQuote(relativePath)}; fi`,
)
.join("\n");
const script = `
root=${shellQuote(remotePath)}
exists=false; root_git=false; head=""; branch=""; remote=""; tracked_changes=""; compose_files=""; nested_git=""; dockerfile=false; dockerignore_content=""; existing_preserve_paths=""
if [ -d "$root" ]; then
exists=true
if [ -d "$root/.git" ]; then
root_git=true
head=$(git -C "$root" rev-parse HEAD 2>/dev/null || true)
branch=$(git -C "$root" branch --show-current 2>/dev/null || true)
remote=$(git -C "$root" remote get-url origin 2>/dev/null || true)
tracked_changes=$(git -C "$root" status --porcelain --untracked-files=no 2>/dev/null | head -n 25 | base64 | tr -d '\\r\\n' || true)
fi
compose_files=$(find "$root" -maxdepth 2 -type f \\( -name 'docker-compose.yml' -o -name 'docker-compose.yaml' -o -name 'compose.yml' -o -name 'compose.yaml' -o -name 'compose.forgeflow.yml' \\) -printf '%P\\n' 2>/dev/null | sort | base64 | tr -d '\\r\\n' || true)
nested_git=$(find "$root" -mindepth 2 -maxdepth 4 -type d -name .git -printf '%h\\n' 2>/dev/null | sed "s#^$root/##" | sort | base64 | tr -d '\\r\\n' || true)
[ -f "$root/Dockerfile" ] && dockerfile=true
[ -f "$root/.dockerignore" ] && dockerignore_content=$(base64 < "$root/.dockerignore" | tr -d '\\r\\n' || true)
existing_preserve_paths=$({ ${preserveProbe || ":"}; } | sort -u | base64 | tr -d '\\r\\n' || true)
fi
printf '__FORGEFLOW_KV__\\n'
printf 'exists=%s\\n' "$exists"
printf 'rootGit=%s\\n' "$root_git"
printf 'head=%s\\n' "$head"
printf 'branch=%s\\n' "$branch"
printf 'remote=%s\\n' "$(printf '%s' "$remote" | base64 | tr -d '\\r\\n')"
printf 'trackedChanges=%s\\n' "$tracked_changes"
printf 'composeFiles=%s\\n' "$compose_files"
printf 'nestedGit=%s\\n' "$nested_git"
printf 'dockerfile=%s\\n' "$dockerfile"
printf 'dockerignoreContent=%s\\n' "$dockerignore_content"
printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths"
`;
const wrapped = bash(script);
const result = await this.ssh.exec(server.id, wrapped, { timeout: 60_000 });
const parsed = parseInspection(result.stdout);
const contextCandidates = [
...new Set([
...(parsed.existingPreservePaths || []),
...(parsed.nestedGit || []),
]),
];
const inspection = {
...parsed,
dockerignore: Boolean(parsed.dockerignoreContent),
dockerignoreGitExcluded: dockerIgnoreHasPath(
parsed.dockerignoreContent,
".git",
),
dockerContextExclusionsMissing: parsed.dockerfile
? contextCandidates.filter(
(item) => !dockerIgnoreHasPath(parsed.dockerignoreContent, item),
)
: [],
serverId: server.id,
serverName: server.name,
remotePath,
profileId: profile.id,
};
await this.diagnostics?.info("unraid.inspected", {
repository: repository.fullName,
serverId: server.id,
remotePath,
exists: inspection.exists,
rootGit: inspection.rootGit,
head: inspection.head,
composeFiles: inspection.composeFiles,
nestedGitCount: inspection.nestedGit.length,
trackedChangeCount: inspection.trackedChanges.length,
dockerContextExclusionsMissing: inspection.dockerContextExclusionsMissing,
});
return inspection;
}
async preflight({ repository, profileId, sha = null }) {
const { profile, server, remotePath } = this.resolve(repository, profileId);
const deploymentMode = ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode)
? profile.deploymentMode
: "push-bundle";
let requestedSha = sha || repository.localStatus?.head;
if (deploymentMode === "server-git" && !sha) {
const [owner, repo] = String(repository.fullName || "").split("/");
const branch = await this.gitea.getBranch(owner, repo, profile.branch);
requestedSha = branch?.commit?.id || branch?.commit?.sha || null;
}
const targetSha = assertFullCommitSha(requestedSha);
const checks = [];
let inspection = null;
let connectionCapabilities = null;
let permissions = null;
if (deploymentMode === "monitor-only") checks.push({
id: "deployment-mode",
label: "Deployment mode",
status: "fail",
detail: "This workload is linked for monitoring only. Select Server pull or Direct copy before deploying.",
});
else checks.push({
id: "deployment-mode",
label: "Deployment mode",
status: "pass",
detail: deploymentMode === "server-git"
? "Unraid fetches the exact Gitea commit with a repository-scoped read-only deploy key."
: "ForgeFlow copies the exact committed local project directly to Unraid and runs Docker Compose there.",
});
if (!repository.localPath) {
checks.push({
id: "local-repository",
label: "Local repository",
status: deploymentMode === "server-git" ? "pass" : "fail",
detail: deploymentMode === "server-git"
? "Not required: the exact commit is fetched from Gitea by the server."
: "Link or clone the repository locally before using Direct copy.",
});
} else {
try {
const localStatus = await this.git.status(repository.localPath);
checks.push({
id: "local-repository",
label: "Local repository",
status: "pass",
detail: localStatus.root,
});
checks.push({
id: "local-branch",
label: "Allowed branch",
status: localStatus.branch.head === profile.branch ? "pass" : deploymentMode === "server-git" ? "warning" : "fail",
detail: `Current: ${localStatus.branch.head || "detached"}; required: ${profile.branch}.`,
});
checks.push({
id: "local-clean",
label: "Clean local working tree",
status: localStatus.clean ? "pass" : deploymentMode === "server-git" ? "warning" : "fail",
detail: localStatus.clean
? "No uncommitted changes."
: `${localStatus.counts.changed} changed file(s) remain.`,
});
checks.push({
id: "deployment-source",
label: deploymentMode === "server-git" ? "Gitea deployment source" : "Direct deployment source",
status: "pass",
detail: deploymentMode === "server-git"
? "Local files are not uploaded; the exact requested commit is fetched from Gitea."
: "The exact committed local HEAD is archived and copied directly to Unraid. No server-side repository access is involved.",
});
const localDeploymentFiles = deploymentMode === "push-bundle" && profile.generatedCompose
? [nativePath.join(repository.localPath, "Dockerfile")]
: deploymentMode === "push-bundle" ? this.deploymentComposeFiles(profile).map((file) =>
nativePath.join(repository.localPath, safeRelativeRemoteFile(file)),
) : [];
const missingDeploymentFiles = [];
for (const file of localDeploymentFiles) {
if (!(await fs.stat(file).catch(() => null))?.isFile()) missingDeploymentFiles.push(file);
}
if (deploymentMode === "push-bundle") checks.push({
id: "local-deployment-file",
label: profile.generatedCompose
? "Dockerfile in repository"
: localDeploymentFiles.length > 1 ? "Compose files in repository" : "Compose file in repository",
status: missingDeploymentFiles.length ? "fail" : "pass",
detail: missingDeploymentFiles.length
? `Missing from the exact local checkout: ${missingDeploymentFiles.join(", ")}`
: localDeploymentFiles.join(", "),
});
} catch (error) {
checks.push({
id: "local-repository",
label: "Local repository",
status: "fail",
detail: error.message,
});
}
}
try {
const connection = await this.ssh.test(server.id, { trustOnFirstUse: false });
connectionCapabilities = connection.capabilities || {};
checks.push({
id: "ssh",
label: "Desktop → Unraid SSH",
status: "pass",
detail: `${server.username}@${server.host}:${server.port}`,
});
checks.push({
id: "docker-runtime",
label: "Docker runtime",
status: connectionCapabilities.docker && connectionCapabilities.dockerReady ? "pass" : "fail",
detail: connectionCapabilities.dockerReady
? "Docker is reachable by the configured SSH user."
: connectionCapabilities.docker
? "Docker is installed, but the configured SSH user cannot query the daemon."
: "Docker was not detected on the server.",
});
checks.push({
id: "compose-command",
label: "Docker Compose",
status: connectionCapabilities.compose ? "pass" : "fail",
detail: connectionCapabilities.composeVersion || "Docker Compose was not detected on the server.",
});
checks.push({
id: "bundle-tools",
label: deploymentMode === "server-git" ? "Server pull tools" : "Direct copy tools",
status: connectionCapabilities.tar && connectionCapabilities.checksum && (deploymentMode !== "server-git" || connectionCapabilities.git) ? "pass" : "fail",
detail: deploymentMode === "server-git"
? `Git ${connectionCapabilities.git ? "available" : "missing"}; tar ${connectionCapabilities.tar ? "available" : "missing"}; checksum ${connectionCapabilities.checksum ? "available" : "missing"}.`
: connectionCapabilities.tar && connectionCapabilities.checksum
? "tar and a SHA-256 checksum tool are available."
: `tar ${connectionCapabilities.tar ? "available" : "missing"}; checksum tool ${connectionCapabilities.checksum ? "available" : "missing"}.`,
});
checks.push({
id: "server-base-writable",
label: "Deployment storage writable",
status: connectionCapabilities.baseWritable ? "pass" : "fail",
detail: connectionCapabilities.baseWritable ? `${server.basePath} is writable.` : `${server.basePath} cannot be created or written by this SSH user.`,
});
} catch (error) {
checks.push({
id: "ssh",
label: "Desktop → Unraid SSH",
status: "fail",
detail: error.message,
});
}
if (!server.hostFingerprint) checks.push({
id: "host-key",
label: "Server identity",
status: "fail",
detail: "Test and trust the SSH host key first.",
});
else checks.push({
id: "host-key",
label: "Server identity",
status: "pass",
detail: server.hostFingerprint,
});
if (deploymentMode === "server-git") {
const access = await this.probeServerGitAccess({ repository, profile, server });
checks.push({
id: "server-git-access",
label: "Unraid → Gitea read access",
status: access.ready ? "pass" : "fail",
detail: access.ready
? `Read-only deploy key verified${access.remoteSha ? ` at ${access.remoteSha.slice(0, 7)}` : ""}.`
: access.error,
repairAction: access.ready ? null : "configure-server-git-access",
repairLabel: "Configure read-only deploy key",
});
} else checks.push({
id: "transfer-path",
label: "Desktop → Unraid transfer",
status: "pass",
detail: "Files are copied over the configured server connection. No Gitea credential is required on Unraid.",
});
try {
permissions = await this.inspectWriteAccess({ repository, profileId });
const blockingPaths = permissions.blocking.map((target) => target.path);
checks.push({
id: "project-write-access",
label: "Project write access",
status: permissions.ready ? "pass" : "fail",
detail: permissions.ready
? `${permissions.identity.user} can create and atomically replace deployment files in ${remotePath}.`
: `No safe write access for ${permissions.identity.user}: ${blockingPaths.join(", ")}`,
help: permissions.ready
? "ForgeFlow rechecks these paths immediately before every upload and Compose activation."
: "Use Fix write access to repair only the linked project source and ForgeFlow state folders. Preserved runtime data is excluded.",
repairAction: permissions.ready ? null : "repair-deployment-write-access",
repairLabel: "Fix write access",
});
for (const target of permissions.targets.filter(
(item) => item.required && !item.effectiveWritable,
)) {
checks.push({
id: `write-path:${target.id}`,
label: target.label,
status: "fail",
detail: `${target.path} · owner ${target.owner || "unknown"}:${target.group || "unknown"} · mode ${target.mode || "unknown"}. ${target.detail}`,
repairAction: "repair-deployment-write-access",
repairLabel: "Fix write access",
});
}
} catch (error) {
checks.push({
id: "project-write-access",
label: "Project write access",
status: "fail",
detail: error.message,
});
}
try {
inspection = await this.inspect({ repository, profileId });
if (!inspection.exists) {
checks.push({
id: "remote-folder",
label: "Remote project folder",
status: "pass",
detail: `${remotePath} will be created.`,
});
} else {
checks.push({
id: "remote-folder",
label: inspection.rootGit ? "Remote project folder" : "Existing server installation",
status: "pass",
detail: inspection.rootGit
? `${remotePath} currently contains Git commit ${String(inspection.head || "").slice(0, 7) || "unknown"}.`
: `${remotePath} will receive managed release files while preserved and unknown runtime data remains untouched.`,
});
}
if (inspection.rootGit) {
checks.push({
id: "tracked-changes",
label: "Server-side tracked changes",
status: inspection.trackedChanges.length ? "warning" : "pass",
detail: inspection.trackedChanges.length
? `${inspection.trackedChanges.length} tracked server edit(s) exist. Direct copy preserves unknown runtime data and does not depend on the server Git checkout.`
: "No tracked server-only edits detected.",
});
}
if (inspection.nestedGit.length) {
checks.push({
id: "nested-git",
label: "Nested Git repositories",
status: "warning",
detail: `Detected: ${inspection.nestedGit.join(", ")}. ForgeFlow will not delete them automatically.`,
});
}
if (inspection.dockerfile && !inspection.dockerignore) {
checks.push({
id: "dockerignore",
label: "Docker build context",
status: "warning",
detail:
"A Dockerfile exists but .dockerignore is missing. Add one in the repository before large builds.",
});
} else if (inspection.dockerfile && !inspection.dockerignoreGitExcluded) {
checks.push({
id: "dockerignore-git",
label: "Git metadata excluded from Docker",
status: "warning",
detail: ".dockerignore does not explicitly exclude .git.",
});
} else if (inspection.dockerfile) {
checks.push({
id: "dockerignore-git",
label: "Git metadata excluded from Docker",
status: "pass",
detail: ".git is excluded from the Docker build context.",
});
}
if (inspection.dockerContextExclusionsMissing.length) {
checks.push({
id: "dockerignore-runtime",
label: "Runtime data excluded from Docker",
status: "warning",
detail: `Add these existing runtime or legacy paths to .dockerignore: ${inspection.dockerContextExclusionsMissing.join(", ")}.`,
});
} else if (
inspection.dockerfile &&
inspection.existingPreservePaths.length
) {
checks.push({
id: "dockerignore-runtime",
label: "Runtime data excluded from Docker",
status: "pass",
detail:
"Detected preserved runtime paths are excluded from the Docker build context.",
});
}
const composeFiles = profile.generatedCompose
? [".forgeflow/compose.forgeflow.yml"]
: (profile.composeFiles?.length ? profile.composeFiles : [profile.composeFile || "docker-compose.yml"])
.map((value) => safeRelativeRemoteFile(value));
const missingRemoteCompose = composeFiles.filter((composeFile) => !inspection.composeFiles.includes(composeFile));
checks.push({
id: "compose-file",
label: "Compose configuration",
status: "pass",
detail: profile.generatedCompose
? "ForgeFlow will generate an isolated Compose file."
: missingRemoteCompose.length
? `${composeFiles.join(", ")} will be uploaded from the exact local commit.`
: composeFiles.join(", "),
});
} catch (error) {
checks.push({
id: "inspection",
label: "Server project inspection",
status: "fail",
detail: error.message,
});
}
const iconMode =
profile.iconMode ||
(profile.iconFilePath ? "upload" : profile.iconUrl ? "url" : "builtin");
if (iconMode === "upload") {
const iconStat = await fs.stat(profile.iconFilePath).catch(() => null);
checks.push({
id: "dockerman-icon-file",
label: "DockerMan icon upload",
status:
iconStat?.isFile() &&
nativePath.extname(profile.iconFilePath).toLowerCase() === ".png"
? "pass"
: "fail",
detail: iconStat?.isFile()
? profile.iconFilePath
: "The selected local PNG icon file was not found.",
});
} else if (iconMode === "builtin") {
const builtinIcon = nativePath.join(
this.sourcePath,
"src",
"renderer",
"assets",
"itworx-mark.png",
);
const iconStat = await fs.stat(builtinIcon).catch(() => null);
checks.push({
id: "dockerman-icon-builtin",
label: "DockerMan icon",
status: iconStat?.isFile() ? "pass" : "fail",
detail: iconStat?.isFile()
? "Built-in high-contrast ITWorx mark."
: "The built-in ITWorx icon asset is missing.",
});
} else if (iconMode === "url")
checks.push({
id: "dockerman-icon",
label: "DockerMan icon",
status: profile.iconUrl ? "pass" : "fail",
detail:
profile.iconUrl || "Icon URL mode requires an HTTPS or HTTP PNG URL.",
});
else
checks.push({
id: "dockerman-icon",
label: "DockerMan icon",
status: "warning",
detail: "Custom DockerMan icon disabled.",
});
const webUiLabel = this.dockerManWebUi(profile);
checks.push({
id: "dockerman-webui",
label: "DockerMan Web UI action",
status: webUiLabel ? "pass" : "warning",
detail: webUiLabel || "No Web UI URL or host port is configured.",
});
checks.push({
id: "compose-identity",
label: "Safe Docker Compose identity",
status: "pass",
detail: `Internal project/image: ${this.internalSlug(profile, repository)}; visible container: ${profile.containerName || profile.remoteFolder || repository.name}.`,
});
checks.push({
id: "exact-sha",
label: "Exact deployment commit",
status: "pass",
detail: targetSha,
});
return {
provider: "ssh-unraid",
repository: repository.fullName,
environment: profile.environment,
sha: targetSha,
server: { id: server.id, name: server.name, host: server.host },
remotePath,
inspection,
permissions,
checks,
summary: checksSummary(checks),
};
}
}
return UnraidPreflightMethods.prototype;
}
module.exports = { createUnraidPreflightMethods };
+387
View File
@@ -0,0 +1,387 @@
"use strict";
function createUnraidRuntimeMethods({
safeRelativeRemoteFile, xmlEscape, nativePath, fileSystem, fs, crypto, os, run,
bash, shellQuote, iconReferenceLocalPath,
}) {
class UnraidRuntimeMethods {
internalSlug(profile, repository) {
return (
String(
profile.remoteFolder ||
repository.name ||
profile.composeService ||
"app",
)
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "") || "app"
);
}
generatedCompose(profile, repository) {
const service =
String(profile.composeService || repository.name || "app")
.toLowerCase()
.replace(/[^a-z0-9._-]/g, "-") || "app";
const containerName =
String(
profile.containerName ||
profile.remoteFolder ||
repository.name ||
service,
).replace(/[^A-Za-z0-9._-]/g, "-") || service;
if (!profile.hostPort || !profile.containerPort)
throw new Error(
"Host and container ports are required for generated Compose.",
);
return (
[
"services:",
` ${service}:`,
` image: forgeflow/${this.internalSlug(profile, repository)}:${String(profile.environment || "production").toLowerCase()}`,
" build:",
" context: ..",
` container_name: ${containerName}`,
" restart: unless-stopped",
" ports:",
` - "${profile.hostPort}:${profile.containerPort}"`,
].join("\n") + "\n"
);
}
dockerManWebUi(profile) {
if (profile.hostPort) {
let suffix = "/";
try {
const parsed = profile.webUiUrl ? new URL(profile.webUiUrl) : null;
suffix = parsed
? `${parsed.pathname || "/"}${parsed.search || ""}${parsed.hash || ""}`
: "/";
} catch {}
if (!suffix.startsWith("/")) suffix = `/${suffix}`;
return `http://[IP]:[PORT:${profile.hostPort}]${suffix}`;
}
return profile.webUiUrl || "";
}
dockerManShell(profile) {
return String(profile.dockerShell || "/bin/sh")
.toLowerCase()
.includes("bash")
? "bash"
: "sh";
}
dockerManTemplatePath(profile, repository) {
const containerName =
String(
profile.containerName ||
profile.remoteFolder ||
repository.name ||
"app",
).replace(/[^A-Za-z0-9._-]/g, "-") || "app";
return `/boot/config/plugins/dockerMan/templates-user/my-${containerName}.xml`;
}
dockerManTemplate(profile, repository, iconReference = "") {
const containerName =
String(
profile.containerName ||
profile.remoteFolder ||
repository.name ||
"app",
).replace(/[^A-Za-z0-9._-]/g, "-") || "app";
const slug = this.internalSlug(profile, repository);
const environment =
String(profile.environment || "production")
.toLowerCase()
.replace(/[^a-z0-9._-]/g, "-") || "production";
const image = `forgeflow/${slug}:${environment}`;
const webUi = this.dockerManWebUi(profile);
return (
[
'<?xml version="1.0"?>',
'<Container version="2">',
` <Name>${xmlEscape(containerName)}</Name>`,
` <Repository>${xmlEscape(image)}</Repository>`,
" <Registry/>",
" <Network>bridge</Network>",
" <MyIP/>",
` <Shell>${xmlEscape(this.dockerManShell(profile))}</Shell>`,
" <Privileged>false</Privileged>",
" <Support/>",
" <Project/>",
" <Overview>Managed by ForgeFlow through Docker Compose. Use ForgeFlow or the Compose files for configuration changes.</Overview>",
" <Category>Tools:</Category>",
` <WebUI>${xmlEscape(webUi)}</WebUI>`,
" <TemplateURL/>",
` <Icon>${xmlEscape(iconReference)}</Icon>`,
" <ExtraParams/>",
" <PostArgs/>",
" <CPUset/>",
" <DonateText/>",
" <DonateLink/>",
"</Container>",
].join("\n") + "\n"
);
}
iconCacheRefresh(profile, repository, iconReference = "") {
const containerName =
String(
profile.containerName ||
profile.remoteFolder ||
repository.name ||
"app",
).replace(/[^A-Za-z0-9._-]/g, "-") || "app";
const cacheLoop = `for icon_dir in /var/lib/docker/unraid/images /usr/local/emhttp/state/plugins/dynamix.docker.manager/images /var/local/emhttp/plugins/dynamix.docker.manager/images; do [ -d "$icon_dir" ] || continue; rm -f "$icon_dir/${containerName}-icon.png" "$icon_dir/${containerName}.png"; done`;
const invalidateMetadata = `rm -f /usr/local/emhttp/state/plugins/dynamix.docker.manager/docker.json`;
const localIconPath = iconReferenceLocalPath(iconReference);
if (!localIconPath) return `${cacheLoop}\n${invalidateMetadata}`;
return `${cacheLoop}
if [ -f ${shellQuote(localIconPath)} ]; then for icon_dir in /var/lib/docker/unraid/images /usr/local/emhttp/state/plugins/dynamix.docker.manager/images /var/local/emhttp/plugins/dynamix.docker.manager/images; do [ -d "$icon_dir" ] || continue; cp ${shellQuote(localIconPath)} "$icon_dir/${containerName}-icon.png"; chmod 0644 "$icon_dir/${containerName}-icon.png"; done; fi
${invalidateMetadata}`;
}
dockerManRefreshScript(profile, repository, iconReference = "") {
if (profile.manageDockerMan !== true || profile.adoptedFromServer === true || profile.generatedCompose !== true) {
return `echo 'ForgeFlow left the existing DockerMan template unchanged.'`;
}
const templatePath = this.dockerManTemplatePath(profile, repository);
const template = this.dockerManTemplate(profile, repository, iconReference);
return `mkdir -p /boot/config/plugins/dockerMan/templates-user
cat > ${shellQuote(templatePath)} <<'FORGEFLOW_DOCKERMAN_TEMPLATE'
${template}FORGEFLOW_DOCKERMAN_TEMPLATE
chmod 0644 ${shellQuote(templatePath)}
${this.iconCacheRefresh(profile, repository, iconReference)}`;
}
deploymentServices(profile, repository) {
const values = profile.generatedCompose
? [profile.composeService || repository.name || "app"]
: (profile.composeServices?.length ? profile.composeServices : [profile.composeService || repository.name || "app"]);
return [...new Set(values.map((value) => String(value || "").trim().toLowerCase().replace(/[^a-z0-9._-]/g, "-")).filter(Boolean))];
}
deploymentComposeFiles(profile) {
if (profile.generatedCompose) return [".forgeflow/compose.forgeflow.yml"];
const values = profile.composeFiles?.length ? profile.composeFiles : [profile.composeFile || "docker-compose.yml"];
return [...new Set(values
.map((value) => safeRelativeRemoteFile(value))
.filter((value) => value !== ".forgeflow/compose.metadata.yml" && value !== ".forgeflow/compose.forgeflow.yml"))];
}
metadataCompose(profile, repository, iconReference = "", deployment = {}) {
const services = this.deploymentServices(profile, repository);
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 || repository.name || "unknown",
"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;
const output = ["services:"];
for (const service of services) {
output.push(` ${service}:`);
if (profile.generatedCompose) {
const containerName = String(
profile.containerName || profile.remoteFolder || repository.name || service,
).replace(/[^A-Za-z0-9._-]/g, "-") || "app";
output.push(` image: forgeflow/${this.internalSlug(profile, repository)}:${String(profile.environment || "production").toLowerCase().replace(/[^a-z0-9._-]/g, "-")}`);
output.push(` container_name: ${containerName}`);
}
output.push(" labels:");
output.push(...Object.entries(labels).map(([key, value]) => ` ${JSON.stringify(key)}: ${JSON.stringify(value)}`));
}
return `${output.join("\n")}\n`;
}
async prepareIcon(profile, repository, server) {
const mode =
profile.iconMode ||
(profile.iconFilePath ? "upload" : profile.iconUrl ? "url" : "builtin");
if (mode === "none") return profile.serverIconReference || "";
if (mode === "url") {
if (!profile.iconUrl)
throw new Error(
"DockerMan icon URL mode is selected, but no icon URL is configured.",
);
return profile.iconUrl;
}
const localIconPath =
mode === "builtin"
? nativePath.join(
this.sourcePath,
"src",
"renderer",
"assets",
"itworx-mark.png",
)
: profile.iconFilePath;
const stat = await fs.stat(localIconPath).catch(() => null);
if (!stat?.isFile())
throw new Error(
mode === "builtin"
? "The built-in ITWorx DockerMan icon is missing."
: `The selected DockerMan icon file no longer exists: ${localIconPath}`,
);
if (nativePath.extname(localIconPath).toLowerCase() !== ".png")
throw new Error(
"DockerMan icon upload currently accepts PNG files only.",
);
const containerName =
String(
profile.containerName ||
profile.remoteFolder ||
repository.name ||
"app",
).replace(/[^A-Za-z0-9._-]/g, "-") || "app";
const remoteIconPath = `/boot/config/plugins/dockerMan/images/${containerName}-icon.png`;
await this.ssh.uploadFile(server.id, localIconPath, remoteIconPath, {
mode: 0o644,
});
return `file://${remoteIconPath}`;
}
composeInvocation(profile, repository) {
const project = String(profile.composeProject || this.internalSlug(profile, repository)).trim();
const files = [...this.deploymentComposeFiles(profile)];
// A labels-only Compose fragment is valid only when every service key also
// exists in the base definition. Imported profiles can contain stale service
// hints, so adopted workloads must activate from their real server Compose
// files only. ForgeFlow tracks the deployed SHA in .forgeflow/status.json.
if (profile.generatedCompose) files.push(".forgeflow/compose.metadata.yml");
return `forgeflow_compose -p ${shellQuote(project)} ${files.map((file) => `-f ${shellQuote(file)}`).join(" ")}`;
}
composeUpFlags(profile) {
// ForgeFlow never adds destructive recreation or orphan-removal flags.
// Compose may replace a service when its built image or configuration changed,
// but unrelated containers are never deleted by ForgeFlow.
void profile;
return "";
}
containerVerificationScript(profile, repository, compose, { requireRecreated = false } = {}) {
const recreationCheck = requireRecreated
? ` before_id=$(awk -F '\t' -v wanted="$service" '$1 == wanted { print $2; exit }' "$before_containers" 2>/dev/null || true)
if [ -n "$before_id" ] && [ "$before_id" = "$container_id" ]; then
echo "Compose reported success but service $service still uses the previous container $container_id" >&2
exit 65
fi`
: ` before_id=""`;
return `actual_services=$(${compose} config --services 2>/dev/null | sed '/^$/d')
[ -n "$actual_services" ] || { echo "Compose project defines no services" >&2; exit 60; }
printf '%s\n' "$actual_services" | while IFS= read -r service; do
[ -n "$service" ] || continue
attempt=0; container_id=''; running=false; health=''
while [ "$attempt" -lt 30 ]; do
container_id=$(${compose} ps -q "$service" | head -n1)
if [ -n "$container_id" ]; then
running=$(docker inspect -f '{{.State.Running}}' "$container_id" 2>/dev/null || echo false)
health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container_id" 2>/dev/null || true)
if [ "$running" = true ] && [ "$health" != unhealthy ] && [ "$health" != starting ]; then break; fi
fi
attempt=$((attempt + 1)); sleep 2
done
[ -n "$container_id" ] || { echo "Compose service $service did not create a container" >&2; exit 61; }
[ "$running" = true ] || { echo "Compose service $service is not running after 60 seconds" >&2; exit 62; }
[ "$health" != unhealthy ] && [ "$health" != starting ] || { echo "Compose service $service did not become healthy" >&2; exit 63; }
${recreationCheck}
image_id=$(docker inspect -f '{{.Image}}' "$container_id" 2>/dev/null || true)
printf 'ForgeFlow verified service %s: container=%s previous=%s image=%s\n' "$service" "$container_id" "\${before_id:-none}" "\${image_id:-unknown}"
done`;
}
async checkHealth(url) {
if (!url)
return {
configured: false,
healthy: null,
status: null,
latencyMs: null,
};
let last = null;
for (let attempt = 1; attempt <= 5; attempt += 1) {
const started = Date.now();
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(8_000),
redirect: "manual",
});
last = {
configured: true,
healthy: response.ok,
status: response.status,
latencyMs: Date.now() - started,
};
if (response.ok) return last;
} catch (error) {
last = {
configured: true,
healthy: false,
status: null,
latencyMs: Date.now() - started,
error: error.message,
};
}
if (attempt < 5)
await new Promise((resolve) => setTimeout(resolve, 3_000));
}
return last;
}
hashFile(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash("sha256");
const stream = fileSystem.createReadStream(filePath);
stream.on("error", reject);
stream.on("data", (chunk) => hash.update(chunk));
stream.on("end", () => resolve(hash.digest("hex")));
});
}
async createCommitBundle(repository, sha, requestId) {
if (!repository.localPath) throw new Error("A linked local repository is required to create a push bundle.");
const bundleDirectory = nativePath.join(os.tmpdir(), "forgeflow-bundles");
await fs.mkdir(bundleDirectory, { recursive: true });
const archivePath = nativePath.join(bundleDirectory, `${requestId}-${sha}.tar`);
await run("git", ["-C", repository.localPath, "archive", "--format=tar", `--output=${archivePath}`, sha], {
timeout: 5 * 60_000,
maxBuffer: 4 * 1024 * 1024,
});
const stat = await fs.stat(archivePath);
if (!stat.isFile() || stat.size <= 0) throw new Error("Git produced an empty deployment bundle.");
return { archivePath, bytes: stat.size, sha256: await this.hashFile(archivePath) };
}
deploymentStatusDocument({ repository, profile, targetSha, requestId, rollback = false }) {
return JSON.stringify({
repository: repository.fullName,
environment: profile.environment,
requested_sha: targetSha,
live_sha: targetSha,
request_id: requestId,
healthy: null,
healthcheck_url_configured: Boolean(profile.healthcheckUrl),
rollback,
deployment_mode: profile.deploymentMode || "push-bundle",
deployed_at: new Date().toISOString(),
});
}
}
return UnraidRuntimeMethods.prototype;
}
module.exports = { createUnraidRuntimeMethods };
+282
View File
@@ -0,0 +1,282 @@
"use strict";
function createUnraidStateMethods({ path, bash, shellQuote, inventoryRemoteIdentity }) {
class UnraidStateMethods {
async refreshProfileState(fullName, profileId, expectedGiteaSha = null) {
const repository = { fullName, name: fullName.split("/").pop() };
const { profile, server, remotePath } = this.resolve(repository, profileId);
const containerName = String(
profile.containerName || profile.remoteFolder || repository.name,
);
const script = `
root=${shellQuote(remotePath)}
container=${shellQuote(containerName)}
template_path=${shellQuote("/boot/config/plugins/dockerMan/templates-user/my-" + containerName + ".xml")}
live=""; previous=""; running=false; docker_health=""; webui=""; icon=""; shell_label=""; template_exists=false
[ -f "$template_path" ] && template_exists=true
[ -f "$root/.forgeflow/current-sha" ] && live=$(cat "$root/.forgeflow/current-sha")
[ -z "$live" ] && [ -d "$root/.git" ] && live=$(git -C "$root" rev-parse HEAD 2>/dev/null || true)
[ -f "$root/.forgeflow/previous-sha" ] && previous=$(cat "$root/.forgeflow/previous-sha")
if docker inspect "$container" >/dev/null 2>&1; then
running=$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null || echo false)
docker_health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container" 2>/dev/null || true)
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"
printf 'previousSha=%s\n' "$previous"
printf 'containerRunning=%s\n' "$running"
printf 'dockerHealth=%s\n' "$docker_health"
printf 'webUiLabel=%s\n' "$(printf '%s' "$webui" | base64 | tr -d '\r\n')"
printf 'iconLabel=%s\n' "$(printf '%s' "$icon" | base64 | tr -d '\r\n')"
printf 'shellLabel=%s\n' "$(printf '%s' "$shell_label" | base64 | tr -d '\r\n')"
printf 'templateExists=%s\n' "$template_exists"
`;
const result = await this.ssh.exec(server.id, bash(script), {
timeout: 30_000,
});
const marker = result.stdout.lastIndexOf("__FORGEFLOW_KV__");
if (marker < 0)
throw new Error(
"Unraid state inspection did not return a ForgeFlow marker.",
);
const fields = {};
for (const line of result.stdout
.slice(marker + "__FORGEFLOW_KV__".length)
.trim()
.split(/\r?\n/)) {
const index = line.indexOf("=");
if (index > 0) fields[line.slice(0, index)] = line.slice(index + 1);
}
const decode = (value) => {
try {
return value ? Buffer.from(value, "base64").toString("utf8") : "";
} catch {
return "";
}
};
const health = await this.checkHealth(profile.healthcheckUrl);
const dockerHealthy = fields.dockerHealth
? fields.dockerHealth === "healthy"
: null;
const effectiveHealthy = health.configured ? health.healthy : dockerHealthy;
const runtimeVerification = health.configured
? "desktop-healthcheck"
: dockerHealthy === true
? "docker-healthcheck"
: dockerHealthy === false
? "docker-unhealthy"
: fields.containerRunning === "true"
? "running-unverified"
: "stopped";
return this.store.saveDeploymentState(profile.id, {
liveSha: /^[0-9a-f]{40}$/i.test(fields.liveSha || "")
? fields.liveSha
: null,
previousSha: /^[0-9a-f]{40}$/i.test(fields.previousSha || "")
? fields.previousSha
: null,
healthy: effectiveHealthy,
runtimeVerification,
healthStatus: health.status,
healthLatencyMs: health.latencyMs,
containerName,
containerRunning: fields.containerRunning === "true",
dockerHealth: fields.dockerHealth || null,
dockerMan: {
webUi: decode(fields.webUiLabel),
icon: decode(fields.iconLabel),
shell: decode(fields.shellLabel),
templateExists: fields.templateExists === "true",
configured: Boolean(
decode(fields.webUiLabel) ||
decode(fields.iconLabel) ||
fields.templateExists === "true",
),
},
webUiUrl:
profile.webUiUrl ||
(profile.hostPort
? `http://${server.host}:${profile.hostPort}/`
: null),
remotePath,
provider: "ssh-unraid",
giteaSha: /^[0-9a-f]{40}$/i.test(String(expectedGiteaSha || ""))
? expectedGiteaSha
: null,
matchesGitea:
/^[0-9a-f]{40}$/i.test(String(expectedGiteaSha || "")) &&
fields.liveSha === expectedGiteaSha,
});
}
async applyDockerManMetadata({ repository, profileId }) {
const { profile, server, remotePath } = this.resolve(repository, profileId);
if (profile.generatedCompose !== true) {
// Existing Compose files remain authoritative. Applying a generated
// labels-only service fragment can create a phantom service when a stale
// profile hint no longer matches the real Compose service keys.
return this.refreshProfileState(repository.fullName, profileId);
}
const composeFile = profile.generatedCompose
? ".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 compose = this.composeInvocation(profile, repository);
const flags = this.composeUpFlags(profile);
const script = `
root=${shellQuote(remotePath)}
test -d "$root"
mkdir -p "$root/.forgeflow"
cat > "$root/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA'
${metadata}FORGEFLOW_METADATA
cd "$root"
${compose} config >/dev/null
${compose} up -d --build ${flags}
${this.containerVerificationScript(profile, repository, compose)}
${this.dockerManRefreshScript(profile, repository, iconReference)}
`;
await this.ssh.exec(server.id, bash(script), {
timeout: 10 * 60_000,
maxOutput: 2 * 1024 * 1024,
});
return this.refreshProfileState(repository.fullName, profileId);
}
async refreshOperation(
operationId,
{ includeTerminal = false, state: suppliedState = null } = {},
) {
const operation = this.store.getOperation(operationId);
if (!operation || operation.provider !== "ssh-unraid") return operation;
if (
!includeTerminal &&
["success", "failed", "cancelled", "rolled-back"].includes(
operation.status,
)
)
return operation;
try {
const state =
suppliedState ||
(await this.refreshProfileState(
operation.repository,
operation.profileId,
));
if (
state.liveSha === operation.sha &&
state.containerRunning &&
state.healthy !== false
) {
return this.saveOperation({
...operation,
status: operation.action === "rollback" ? "rolled-back" : "success",
health: { healthy: state.healthy, status: state.healthStatus },
logs: [
...(operation.logs || []),
"Deployment state reconciled from Unraid.",
],
});
}
if (
/^[0-9a-f]{40}$/i.test(String(state.liveSha || "")) &&
state.liveSha !== operation.sha &&
state.containerRunning &&
state.healthy !== false
) {
return this.saveOperation({
...operation,
status: "cancelled",
error: `Superseded by live commit ${state.liveSha.slice(0, 7)}.`,
health: { healthy: state.healthy, status: state.healthStatus },
logs: [
...(operation.logs || []),
`Operation superseded by live Unraid commit ${state.liveSha}.`,
],
});
}
const ageMs =
Date.now() -
new Date(operation.updatedAt || operation.createdAt || 0).getTime();
if (ageMs > 45 * 60_000) {
return this.saveOperation({
...operation,
status: "failed",
error:
"Deployment was interrupted or did not reach the requested commit within 45 minutes.",
logs: [
...(operation.logs || []),
"Stale deployment was marked failed during reconciliation.",
],
});
}
return operation;
} catch {
return operation;
}
}
async reconcileRecordedOperations(profileId, state) {
const operations = this.store.data.operations
.filter(
(item) =>
item.profileId === profileId && item.provider === "ssh-unraid",
)
.sort(
(left, right) =>
new Date(right.updatedAt || right.createdAt || 0) -
new Date(left.updatedAt || left.createdAt || 0),
);
const matching = operations.find(
(item) => item.sha === state.liveSha && item.status === "failed",
);
if (matching && state.containerRunning && state.healthy !== false) {
await this.refreshOperation(matching.id, {
includeTerminal: true,
state,
});
}
const latestFailed = operations.find((item) => item.status === "failed");
if (
latestFailed &&
latestFailed.id !== matching?.id &&
state.matchesGitea &&
state.containerRunning &&
state.healthy !== false
) {
await this.saveOperation({
...latestFailed,
status: "cancelled",
error: `Superseded by Gitea/live commit ${state.liveSha.slice(0, 7)}.`,
logs: [
...(latestFailed.logs || []),
`Reconciled: Gitea and Unraid now both report ${state.liveSha}.`,
],
});
}
return this.store.data.operations
.filter((item) => item.profileId === profileId)
.slice(0, 10);
}
async refreshActiveOperations() {
const active = this.store.data.operations.filter(
(item) =>
item.provider === "ssh-unraid" &&
item.type === "deployment" &&
!["success", "failed", "cancelled", "rolled-back"].includes(
item.status,
),
);
return Promise.all(active.map((item) => this.refreshOperation(item.id)));
}
}
return UnraidStateMethods.prototype;
}
module.exports = { createUnraidStateMethods };
+22
View File
@@ -0,0 +1,22 @@
async function handleCommandActions(event, target, action, repository) {
if (action === "run-command") {
const command = target.dataset.command;
ui.modal = null;
if (command === "overview") ui.currentView = "overview";
else if (command === "deployments") ui.currentView = "deployments";
else if (command === "diagnostics") ui.currentView = "diagnostics";
else if (command === "settings") ui.currentView = "settings";
else if (command === "refresh") await refreshRepositories(true);
else if (command === "open-folder" && repository?.localPath)
await window.forgeflow.openPath(repository.localPath);
else if (command === "git-tools" && repository)
await loadGitTools(repository);
else if (command === "deploy-selected" && canDeploy(repository)) {
const profile = selectedProfile(repository);
if (profile) await runDeploymentPreflight(repository, profile.id);
}
render();
}
else return false;
return true;
}
@@ -0,0 +1,183 @@
async function handleDeploymentOperationActions(event, target, action, repository) {
if (action === "deploy-profile") {
if (repository && String(repository.id) !== String(ui.selectedRepoId))
selectRepository(repository.id, false);
const profile =
repository?.deploymentProfiles?.find(
(item) => item.id === target.dataset.profileId,
) || selectedProfile(repository);
ui.selectedProfileId = profile?.id || null;
if (!profile) return;
const report = await runDeploymentPreflight(repository, profile.id, {
showModal: true,
});
if (!report) return;
} else if (action === "continue-after-preflight") {
const profile = selectedRepository()?.deploymentProfiles?.find(
(item) => item.id === target.dataset.profileId,
);
if (!profile || !ui.deploymentPreflight?.summary?.ready) return;
if (profile.confirmationRequired !== false) {
ui.modal = { type: "deploy-confirm", profileId: profile.id };
render();
} else await executeDeployment(profile.id);
} else if (action === "confirm-deploy")
await executeDeployment(target.dataset.profileId);
else if (action === "rollback-profile") {
if (!repository) repository = profileRepository(target.dataset.profileId);
if (repository && String(repository.id) !== String(ui.selectedRepoId))
selectRepository(repository.id, false);
ui.modal = {
type: "rollback-confirm",
profileId: target.dataset.profileId,
};
render();
} else if (action === "confirm-rollback")
await executeRollback(target.dataset.profileId);
else if (action === "refresh-profile-state") {
if (!repository) repository = profileRepository(target.dataset.profileId);
const profile = repository?.deploymentProfiles?.find(
(item) => item.id === target.dataset.profileId,
);
setLoading(true, `Checking ${profile?.environment || "environment"}`);
try {
const state = await window.forgeflow.refreshProfileState(
repository.fullName,
target.dataset.profileId,
);
profile.state = state;
showToast(
"Environment checked",
state.healthy === false
? "Healthcheck reports an unhealthy state."
: state.liveSha
? `Server reports ${shortSha(state.liveSha)}.`
: "Connection checked; no live SHA reported.",
state.healthy === false ? "error" : "success",
);
} catch (error) {
showToast("Status check failed", error.message, "error");
}
setLoading(false);
} else if (action === "repair-missing-dockerman") {
const targets = ui.repositories.flatMap((candidate) =>
(candidate.deploymentProfiles || [])
.filter(
(profile) =>
profile.provider === "ssh-unraid" &&
profile.state?.containerRunning &&
!dockerManIntegration(profile).ready,
)
.map((profile) => ({ repository: candidate, profile })),
);
if (!targets.length) return;
if (
!confirm(
`Recreate ${targets.length} running container${targets.length === 1 ? "" : "s"} with the missing DockerMan WebUI, icon and template metadata?`,
)
)
return;
setLoading(true, "Repairing missing DockerMan integrations…");
let repaired = 0;
const failures = [];
for (const item of targets) {
try {
await window.forgeflow.applyDockerManMetadata(
item.repository,
item.profile.id,
);
repaired += 1;
} catch (error) {
failures.push(`${item.repository.name}: ${error.message}`);
}
}
await refreshDeploymentTruth(false);
showToast(
failures.length
? "DockerMan repair partially completed"
: "DockerMan integrations repaired",
failures.length
? `${repaired} repaired, ${failures.length} failed.`
: `${repaired} running container${repaired === 1 ? "" : "s"} updated.`,
failures.length ? "error" : "success",
);
setLoading(false);
} else if (action === "apply-dockerman-metadata") {
if (!repository) repository = profileRepository(target.dataset.profileId);
setLoading(
true,
"Applying DockerMan labels, template, icon and WebUI metadata…",
);
try {
await window.forgeflow.applyDockerManMetadata(
repository,
target.dataset.profileId,
);
await refreshRepositories(false);
showToast(
"DockerMan integration repaired",
"The container was recreated with labels, a persistent template, WebUI and icon metadata.",
"success",
);
} catch (error) {
showToast(
"Could not repair DockerMan integration",
error.message,
"error",
);
}
setLoading(false);
} else if (action === "reconcile-deployment") {
if (!repository) repository = profileRepository(target.dataset.profileId);
setLoading(true, "Reconciling ForgeFlow with the live Unraid container…");
try {
await window.forgeflow.reconcileDeployment(
repository.fullName,
target.dataset.profileId,
);
await refreshActiveOperations(false);
await refreshRepositories(false);
showToast(
"Deployment reconciled",
"Live SHA, container health and operation status were refreshed.",
"success",
);
} catch (error) {
showToast("Could not reconcile deployment", error.message, "error");
}
setLoading(false);
} else if (action === "open-profile-webui")
await window.forgeflow.openExternal(target.dataset.url);
else if (action === "open-operation") {
const operation = await window.forgeflow.getOperation(
target.dataset.operationId,
);
if (operation) {
ui.activeDeployment = operation;
ui.currentView = "deployment-run";
render();
startOperationPolling();
}
} else if (action === "refresh-current-operation") {
setLoading(true, "Refreshing deployment status…");
try {
const operation = await window.forgeflow.refreshOperations(
ui.activeDeployment.id,
);
updateOperationInState(operation);
if (!isTerminalOperation(operation.status)) startOperationPolling();
} catch (error) {
showToast("Status refresh failed", error.message, "error");
}
setLoading(false);
} else if (action === "open-run-url")
await window.forgeflow.openExternal(ui.activeDeployment.runUrl);
else if (action === "close-deployment") {
stopOperationPolling();
ui.activeDeployment = null;
ui.currentView = selectedRepository() ? "repository" : "deployments";
render();
}
else return false;
return true;
}
+401
View File
@@ -0,0 +1,401 @@
async function handleDeploymentProfileActions(event, target, action, repository) {
if (action === "configure-deployment") {
ui.deploymentDiscovery = null;
ui.modal = {
type: "deployment-config",
profileId: null,
provider: (ui.boot.state.servers || []).length
? "ssh-unraid"
: "gitea-actions",
};
render();
} else if (action === "edit-deployment-profile") {
ui.deploymentDiscovery = null;
repository = profileRepository(target.dataset.profileId) || repository;
if (repository && String(repository.id) !== String(ui.selectedRepoId))
selectRepository(repository.id, false);
ui.modal = {
type: "deployment-config",
profileId: target.dataset.profileId || null,
provider: repository?.deploymentProfiles?.find(
(item) => item.id === target.dataset.profileId,
)?.provider,
};
render();
} else if (action === "close-modal") {
ui.modal = null;
render();
} else if (action === "select-profile-icon") {
const iconPath = await window.forgeflow.selectImageFile({
title: "Select DockerMan PNG icon",
defaultPath:
document.querySelector("#profile-icon-file")?.value || undefined,
});
if (iconPath) {
document.querySelector("#profile-icon-file").value = iconPath;
const mode = document.querySelector("#profile-icon-mode");
if (mode) mode.value = "upload";
}
} else if (action === "clear-profile-icon") {
const input = document.querySelector("#profile-icon-file");
if (input) input.value = "";
const mode = document.querySelector("#profile-icon-mode");
if (mode) mode.value = "builtin";
} else if (action === "discover-existing-deployment") {
const serverId = document.querySelector("#profile-server")?.value;
const remoteFolder =
document.querySelector("#profile-remote-folder")?.value.trim() ||
safeCloneFolderName(repository);
if (!serverId) {
showToast(
"Select an Unraid server",
"Configure and select the server before importing an existing deployment.",
"error",
);
return;
}
setLoading(
true,
"Reading Git, Compose, Docker and DockerMan from the server…",
);
try {
const result = await window.forgeflow.discoverExistingDeployment(
repository,
serverId,
remoteFolder,
);
ui.deploymentDiscovery = { ...result, repository: repository.fullName };
showToast(
"Existing deployment imported",
`${result.runtime.containers} container(s), ${result.runtime.services} service(s) and ${result.runtime.ports.length} port mapping(s) detected.`,
"success",
);
render();
} catch (error) {
showToast("Could not import deployment", error.message, "error");
}
setLoading(false);
} else if (action === "save-deployment-profile") {
const previousProfile =
repository?.deploymentProfiles?.find(
(item) => item.id === target.dataset.profileId,
) || {};
const provider = document.querySelector("#profile-provider").value;
let maintenanceWindows = [];
try {
maintenanceWindows = (
document.querySelector("#profile-policy-windows")?.value || ""
)
.split("|")
.map((item) => item.trim())
.filter(Boolean)
.map((item) => {
const match = item.match(
/^([0-6](?:,[0-6])*)\s*:\s*((?:[01]\d|2[0-3]):[0-5]\d)-((?:[01]\d|2[0-3]):[0-5]\d)$/,
);
if (!match) throw new Error(`Invalid maintenance window: ${item}`);
return {
days: match[1].split(",").map(Number),
start: match[2],
end: match[3],
};
});
} catch (error) {
showToast("Could not save profile", error.message, "error");
return;
}
const composeFiles =
provider === "ssh-unraid"
? (document.querySelector("#profile-compose-files")?.value || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean)
: [];
const composeServices =
provider === "ssh-unraid"
? (document.querySelector("#profile-compose-services")?.value || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean)
: [];
const profile = {
id: target.dataset.profileId || undefined,
provider,
name: document.querySelector("#profile-name").value.trim(),
environment: document.querySelector("#profile-environment").value.trim(),
branch: document.querySelector("#profile-branch").value.trim(),
healthcheckUrl:
document.querySelector("#profile-healthcheck")?.value.trim() || "",
confirmationRequired: document.querySelector("#profile-confirmation")
.checked,
deploymentPolicy: {
frozen:
document.querySelector("#profile-policy-frozen")?.checked === true,
freezeReason:
document
.querySelector("#profile-policy-freeze-reason")
?.value.trim() || "",
requireNote:
document.querySelector("#profile-policy-note")?.checked === true,
maintenanceWindows,
},
...(provider === "ssh-unraid"
? {
serverId: document.querySelector("#profile-server").value,
remoteFolder: document
.querySelector("#profile-remote-folder")
.value.trim(),
deploymentMode: ["server-git", "push-bundle", "monitor-only"].includes(
document.querySelector("#profile-deployment-mode")?.value,
) ? document.querySelector("#profile-deployment-mode").value : "server-git",
cloneUrl: previousProfile.cloneUrl || "",
alignRemote: false,
generatedCompose:
document.querySelector("#profile-generated-compose").value ===
"true",
composeProject:
document.querySelector("#profile-compose-project")?.value.trim() ||
previousProfile.composeProject ||
"",
composeWorkingDir: previousProfile.composeWorkingDir || "",
composeFiles: composeFiles.length ? composeFiles : ["docker-compose.yml"],
composeFile: composeFiles[0] || "docker-compose.yml",
composeServices: composeServices.length
? composeServices
: [safeCloneFolderName(repository).toLowerCase()],
composeService:
composeServices[0] || safeCloneFolderName(repository).toLowerCase(),
containerName: document
.querySelector("#profile-container-name")
.value.trim(),
hostPort:
Number(document.querySelector("#profile-host-port").value) ||
null,
containerPort:
Number(document.querySelector("#profile-container-port").value) ||
null,
webUiUrl: document.querySelector("#profile-web-ui").value.trim(),
iconMode: document.querySelector("#profile-icon-mode").value,
iconUrl: document.querySelector("#profile-icon-url").value.trim(),
iconFilePath: document
.querySelector("#profile-icon-file")
.value.trim(),
dockerShell: document.querySelector("#profile-docker-shell").value,
preservePaths: document
.querySelector("#profile-preserve-paths")
.value.split(",")
.map((item) => item.trim())
.filter(Boolean),
manageDockerMan:
document.querySelector("#profile-manage-dockerman")?.checked ===
true,
forceRecreate: false,
removeOrphans: false,
adoptedFromServer: Boolean(
ui.deploymentDiscovery || previousProfile.adoptedFromServer,
),
serverSourceOfTruth: Boolean(
ui.deploymentDiscovery || previousProfile.serverSourceOfTruth,
),
workloadIdentity:
ui.deploymentDiscovery?.profile?.workloadIdentity ||
previousProfile.workloadIdentity ||
null,
detectedAt:
ui.deploymentDiscovery?.profile?.detectedAt ||
previousProfile.detectedAt ||
null,
provenance:
ui.deploymentDiscovery?.provenance ||
previousProfile.provenance ||
{},
detectedMetadata:
ui.deploymentDiscovery?.profile?.detectedMetadata ||
previousProfile.detectedMetadata ||
{},
}
: {
workflowFile: document
.querySelector("#profile-workflow")
.value.trim(),
rollbackWorkflowFile: document
.querySelector("#profile-rollback-workflow")
.value.trim(),
statusUrl: document
.querySelector("#profile-status-url")
.value.trim(),
}),
};
setLoading(true, "Saving deployment environment…");
try {
const result = await window.forgeflow.saveDeploymentProfile(
repository.fullName,
profile,
);
ui.boot.state = result.state;
ui.modal = null;
ui.deploymentDiscovery = null;
await refreshRepositories(false);
ui.selectedProfileId = result.profile.id;
showToast(
"Deployment configured",
`${profile.name} targets ${profile.environment}.`,
"success",
);
} catch (error) {
showToast("Could not save profile", error.message, "error");
}
setLoading(false);
} else if (action === "delete-deployment-profile") {
if (
!confirm("Delete this deployment profile? Operation history is retained.")
)
return;
setLoading(true, "Deleting deployment profile…");
try {
const result = await window.forgeflow.deleteDeploymentProfile(
repository.fullName,
target.dataset.profileId,
);
ui.boot.state = result.state;
ui.modal = null;
await refreshRepositories(false);
showToast(
"Profile deleted",
"Deployment environment removed.",
"success",
);
} catch (error) {
showToast("Could not delete profile", error.message, "error");
}
setLoading(false);
} else if (action === "run-deployment-preflight") {
if (!repository) repository = profileRepository(target.dataset.profileId);
await runDeploymentPreflight(repository, target.dataset.profileId);
} else if (action === "manage-deploy-key") {
const profileId = target.dataset.profileId || ui.selectedProfileId;
if (!repository) repository = profileRepository(profileId);
if (!repository || !profileId) return;
setLoading(true, "Inspecting deploy-key lifecycle without changing access…");
try {
const [inventory, rotation, revocation] = await Promise.all([
window.forgeflow.deployKeyInventory(repository, profileId),
window.forgeflow.planDeployKeyRotation(repository, profileId),
window.forgeflow.planDeployKeyRevocation(repository, profileId),
]);
ui.deployKeyLifecycle = { repositoryId: repository.id, profileId, inventory, rotation, revocation };
ui.modal = { type: "deploy-key-lifecycle" };
render();
} catch (error) {
showToast("Could not inspect deploy key", error.message, "error");
} finally { setLoading(false); }
} else if (action === "confirm-rotate-deploy-key") {
const lifecycle = ui.deployKeyLifecycle;
const targetRepository = repositories().find((item) => item.id === lifecycle?.repositoryId);
if (!targetRepository || !lifecycle?.rotation?.id) return;
setLoading(true, "Rotating and verifying the repository deploy key…");
try {
const result = await window.forgeflow.applyDeployKeyRotation(targetRepository, lifecycle.profileId, lifecycle.rotation.id);
if (result.state) ui.boot.state = result.state;
ui.modal = null; ui.deployKeyLifecycle = null;
await refreshRepositories(false, true);
showToast("Deploy key rotated", `New fingerprint ${result.profile?.serverGitAccess?.keyFingerprint || "verified"}.`, "success");
} catch (error) { showToast("Deploy-key rotation failed safely", error.message, "error"); }
finally { setLoading(false); }
} else if (action === "confirm-revoke-deploy-key") {
const lifecycle = ui.deployKeyLifecycle;
const targetRepository = repositories().find((item) => item.id === lifecycle?.repositoryId);
if (!targetRepository || !lifecycle?.revocation?.id) return;
setLoading(true, "Revoking repository access while preserving recovery…");
try {
const result = await window.forgeflow.applyDeployKeyRevocation(targetRepository, lifecycle.profileId, lifecycle.revocation.id);
if (result.state) ui.boot.state = result.state;
ui.modal = null; ui.deployKeyLifecycle = null;
await refreshRepositories(false, true);
showToast("Deploy key revoked", "Server pull is disabled; containers were not changed and recovery is available.", "success");
} catch (error) { showToast("Deploy-key revocation failed", error.message, "error"); }
finally { setLoading(false); }
} else if (action === "restore-deploy-key") {
const lifecycle = ui.deployKeyLifecycle;
const targetRepository = repositories().find((item) => item.id === lifecycle?.repositoryId);
if (!targetRepository || !lifecycle?.profileId) return;
setLoading(true, "Restoring and verifying repository access…");
try {
const result = await window.forgeflow.restoreDeployKey(targetRepository, lifecycle.profileId);
if (result.state) ui.boot.state = result.state;
ui.modal = null; ui.deployKeyLifecycle = null;
await refreshRepositories(false, true);
showToast("Deploy key restored", "Read-only server pull access is verified again.", "success");
} catch (error) { showToast("Deploy-key recovery failed", error.message, "error"); }
finally { setLoading(false); }
} else if (action === "verify-server-git-access") {
const profileId = target.dataset.profileId || ui.selectedProfileId;
if (!repository) repository = profileRepository(profileId);
if (!repository || !profileId) return;
setLoading(true, "Verifying Gitea, deploy key, server commit and runtime…");
try {
const result = await window.forgeflow.verifyServerGitProfile(repository, profileId);
ui.serverGitVerifications[profileId] = result;
render();
const failures = result.checks.filter((check) => check.status === "fail");
showToast(result.readiness, failures[0]?.detail || `Verified ${result.checks.length} server-pull checks without changing the server.`, result.ready ? "success" : "warning");
} catch (error) {
showToast("Server-pull verification failed", error.message, "error");
} finally {
setLoading(false);
}
} else if (action === "configure-server-git-access") {
const profileId = target.dataset.profileId || ui.selectedProfileId;
if (!repository) repository = profileRepository(profileId);
if (!repository || !profileId) return;
const approved = confirm(
`Configure read-only Gitea access for ${repository.fullName}?\n\nForgeFlow creates a dedicated SSH deploy key on the selected server, adds only its public key to this Gitea repository and pins the observed Gitea SSH host key. The private key never leaves the server.`,
);
if (!approved) return;
setLoading(true, "Configuring repository-scoped Gitea access…");
try {
const result = await window.forgeflow.configureServerGitAccess(repository, profileId);
if (result.state) ui.boot.state = result.state;
await refreshRepositories(false, true);
await refreshDeploymentTruth(false);
showToast(
"Server pull ready",
`Read-only Gitea access verified at ${shortSha(result.remoteSha)}.`,
"success",
);
await runDeploymentPreflight(repository, profileId, { showModal: true });
} catch (error) {
showToast("Could not configure Gitea access", error.message, "error");
} finally {
setLoading(false);
}
} else if (action === "repair-deployment-write-access") {
const profileId = target.dataset.profileId || ui.selectedProfileId;
if (!repository) repository = profileRepository(profileId);
if (!repository || !profileId) return;
const profile = repository.deploymentProfiles?.find((item) => item.id === profileId);
const approved = confirm(
`Repair write access for ${repository.fullName} on ${profile?.name || profile?.environment || "the linked Unraid deployment"}?\n\nForgeFlow will only adjust the linked project source tree and its .forgeflow state folders. Preserved runtime paths such as appdata, data, config and logs are excluded. No container will be stopped, removed or recreated.`,
);
if (!approved) return;
setLoading(true, "Repairing scoped Unraid write access…");
try {
const result = await window.forgeflow.repairDeploymentWriteAccess(
repository,
profileId,
);
showToast(
"Write access normalized",
"Project source and ForgeFlow upload folders now use safe shared write permissions. Preserved runtime data was not changed.",
"success",
);
await runDeploymentPreflight(repository, profileId, { showModal: true });
} catch (error) {
showToast("Write-access repair failed", error.message, "error");
} finally {
setLoading(false);
}
}
else return false;
return true;
}
+185
View File
@@ -0,0 +1,185 @@
async function handleInventoryActions(event, target, action, repository) {
if (action === "scan-server-inventory") {
setLoading(true, "Scanning Docker, Compose and DockerMan workloads…");
try {
await refreshDeploymentTruth(true);
const detected = (ui.serverDiscovery || []).reduce(
(total, item) => total + Number(item.detected || 0),
0,
);
const review = (ui.serverDiscovery || []).reduce(
(total, item) => total + Number(item.needsReview || 0),
0,
);
const failures = (ui.serverDiscovery || []).filter((item) => item.error);
if (failures.length) {
showToast(
"Server scan failed",
failures.map((item) => `${item.serverName || item.serverId}: ${item.error}`).join(" · "),
"error",
);
} else {
showToast(
"Server inventory updated",
`${detected} workload${detected === 1 ? "" : "s"} detected; ${review} require manual review.`,
review ? "info" : "success",
);
}
} catch (error) {
showToast("Server scan failed", error.message, "error");
}
setLoading(false);
} else if (action === "plan-server-reconciliation") {
setLoading(true, "Building a read-only reconciliation preview…");
try {
const result = await window.forgeflow.planServerReconciliation(target.dataset.serverId);
ui.modal = { type: "server-reconciliation-plan", result };
render();
} catch (error) {
showToast("Could not build reconciliation plan", error.message, "error");
}
setLoading(false);
} else if (action === "apply-server-reconciliation") {
setLoading(true, "Applying the reviewed configuration plan…");
try {
const result = await window.forgeflow.applyServerReconciliation(target.dataset.serverId, target.dataset.planId);
if (result.state) ui.boot.state = result.state;
ui.modal = null;
await refreshRepositories(false, true);
await refreshDeploymentTruth(false);
showToast("Reconciliation applied", `${result.adopted || 0} link(s) added and ${result.refreshed || 0} profile(s) refreshed. No containers were changed.`, "success");
} catch (error) {
showToast("Reconciliation was not applied", error.message, "error");
}
setLoading(false);
} else if (action === "quick-link-server-workload") {
const serverResult = (ui.serverDiscovery || []).find(
(item) => item.serverId === target.dataset.serverId,
);
const workload = serverResult?.workloads?.find(
(item) => item.workloadId === target.dataset.workloadId,
);
const linkedRepository = ui.repositories.find(
(item) => item.fullName === target.dataset.repository,
);
if (!workload || !linkedRepository || !workload.remoteFolderCandidate) {
showToast("Automatic link unavailable", "Scan the server again and use Review & link.", "error");
return;
}
setLoading(true, `Linking ${workload.displayName} to ${linkedRepository.fullName}`);
try {
const result = await window.forgeflow.linkServerWorkload(
linkedRepository,
target.dataset.serverId,
target.dataset.workloadId,
"server-git",
workload.remoteFolderCandidate,
);
if (result.state) ui.boot.state = result.state;
ui.selectedProfileId = result.profile?.id || null;
await refreshRepositories(false, true);
await refreshDeploymentTruth(false);
showToast(
"Deployment linked",
`${linkedRepository.fullName} is linked to ${workload.compose?.workingDir || workload.remoteFolderCandidate}. Compose values were read from the server.`,
"success",
);
} catch (error) {
showToast("Could not link deployment", error.message, "error");
}
setLoading(false);
} else if (action === "preview-inventory-review") {
const reviewAction = document.querySelector("#inventory-review-action")?.value || "ignore";
const reason = document.querySelector("#inventory-review-reason")?.value.trim() || "";
const serverId = target.dataset.serverId;
const workloadId = target.dataset.workloadId;
try {
ui.inventoryReviewPlan = await window.forgeflow.planInventoryReview(serverId, workloadId, reviewAction, reason, document.querySelector("#workload-repository")?.value || null);
ui.modal = { type: "inventory-review-plan" };
render();
} catch (error) { showToast("Review preview unavailable", error.message, "error"); }
} else if (action === "apply-inventory-review") {
const plan = ui.inventoryReviewPlan;
if (!plan?.id) return;
setLoading(true, "Saving the evidence-bound inventory decision…");
try {
const result = await window.forgeflow.applyInventoryReview(plan.serverId, plan.workloadId, plan.action, plan.reason, plan.repositoryFullName, plan.id);
if (result.state) ui.boot.state = result.state;
ui.serverDiscovery = (ui.serverDiscovery || []).map((item) => item.serverId === plan.serverId ? result.inventory : item);
ui.inventoryReviewPlan = null; ui.modal = null; render();
showToast("Inventory decision saved", "Containers and Compose runtime were not changed.", "success");
} catch (error) { showToast("Inventory review failed safely", error.message, "error"); }
finally { setLoading(false); }
} else if (action === "link-server-workload") {
const serverResult = (ui.serverDiscovery || []).find(
(item) => item.serverId === target.dataset.serverId,
);
const workload = serverResult?.workloads?.find(
(item) => item.workloadId === target.dataset.workloadId,
);
if (!workload) {
showToast(
"Workload unavailable",
"Scan the server inventory again before linking this workload.",
"error",
);
return;
}
ui.modal = {
type: "workload-link",
serverId: target.dataset.serverId,
workloadId: target.dataset.workloadId,
repositoryFullName:
workload.candidates?.[0]?.repositoryFullName ||
repository?.fullName ||
ui.repositories[0]?.fullName ||
"",
remoteFolder: workload.remoteFolderCandidate || "",
};
render();
} else if (action === "confirm-link-server-workload") {
const repositoryFullName = document
.querySelector("#workload-repository")
?.value.trim();
const deploymentMode = document.querySelector("#workload-deployment-mode")?.value || "server-git";
const remoteFolder = document
.querySelector("#workload-remote-folder")
?.value.trim();
const linkedRepository = ui.repositories.find(
(item) => item.fullName === repositoryFullName,
);
if (!linkedRepository) {
showToast(
"Choose a repository",
"The workload must be linked to a ForgeFlow project.",
"error",
);
return;
}
setLoading(true, "Saving the permanent server workload link…");
try {
const result = await window.forgeflow.linkServerWorkload(
linkedRepository,
target.dataset.serverId,
target.dataset.workloadId,
deploymentMode,
remoteFolder,
);
if (result.state) ui.boot.state = result.state;
ui.modal = null;
ui.selectedProfileId = result.profile?.id || null;
await refreshRepositories(false, true);
await refreshDeploymentTruth(false);
showToast(
"Workload linked",
`${linkedRepository.fullName} now uses direct desktop-to-Unraid copy and the Compose configuration detected on the server.`,
"success",
);
} catch (error) {
showToast("Could not link workload", error.message, "error");
}
setLoading(false);
}
else return false;
return true;
}
+355
View File
@@ -0,0 +1,355 @@
async function handleRecoveryActions(event, target, action, repository) {
if (action === "repair-origin") {
if (!repository?.localPath || !repository.sshUrl) return;
if (
!confirm(
`Replace origin with ${repository.sshUrl}? Local files and commits are not changed.`,
)
)
return;
setLoading(true, "Updating Git origin…");
try {
await window.forgeflow.setOrigin(repository.localPath, repository.sshUrl);
await refreshRepositories(false);
showToast("Git origin updated", repository.sshUrl, "success");
} catch (error) {
showToast("Could not update origin", error.message, "error");
}
setLoading(false);
} else if (action === "normalize-origins") {
if (
!confirm(
"Replace legacy origin URLs for every linked repository with the current Gitea SSH URL? Local files and commits are not changed.",
)
)
return;
setLoading(true, "Normalizing linked Git origins…");
try {
const result = await window.forgeflow.normalizeOrigins();
ui.repositories = result.repositories;
showToast(
"Git origins normalized",
`${result.changes.length} repository origin${result.changes.length === 1 ? "" : "s"} updated.`,
"success",
);
} catch (error) {
showToast("Could not normalize origins", error.message, "error");
}
setLoading(false);
render();
} else if (action === "scan-git-recovery") {
if (!repository?.localPath) return;
setLoading(true, "Scanning Git directory and active processes…");
try {
ui.gitRecovery = await window.forgeflow.gitRecoveryStatus(
repository.localPath,
);
ui.repositoryTab = "gittools";
showToast(
"Git health scan complete",
`${ui.gitRecovery.lockReport.locks.length} lock file(s) found.`,
ui.gitRecovery.lockReport.locks.length ? "info" : "success",
);
} catch (error) {
showToast("Git health scan failed", error.message, "error");
}
setLoading(false);
render();
} else if (action === "repair-git-locks" || action === "repair-index-lock") {
if (
!repository?.localPath ||
!confirm(
"Repair stale Git lock files for this repository? ForgeFlow refuses while a matching Git process is active.",
)
)
return;
setLoading(true, "Safely repairing stale Git locks…");
try {
const result = await window.forgeflow.repairGitLocks(
repository.localPath,
false,
);
ui.gitRecovery = await window.forgeflow.gitRecoveryStatus(
repository.localPath,
);
await refreshRepositories(false);
showToast(
"Git locks repaired",
`${result.removed.length} stale lock file(s) removed.`,
"success",
);
} catch (error) {
if (
error.code === "GIT_PROCESS_PROBE_UNAVAILABLE" &&
confirm(`${error.message}
Force repair after you have closed all Git tools for this repository?`)
) {
try {
const result = await window.forgeflow.repairGitLocks(
repository.localPath,
true,
);
showToast(
"Git locks force-repaired",
`${result.removed.length} lock file(s) removed.`,
"success",
);
await refreshRepositories(false);
} catch (forceError) {
showToast("Could not repair Git locks", forceError.message, "error");
}
} else showToast("Could not repair Git locks", error.message, "error");
}
setLoading(false);
render();
} else if (action === "reconcile-repository") {
if (!repository?.localPath) return;
setLoading(true, "Refreshing repository truth from Git…");
try {
ui.gitRecovery = await window.forgeflow.reconcileRepository(
repository.localPath,
);
await refreshRepositories(false);
showToast(
"Repository reconciled",
"Branch, upstream, lock and working-tree state were refreshed.",
"success",
);
} catch (error) {
showToast("Could not reconcile repository", error.message, "error");
}
setLoading(false);
render();
} else if (action === "repair-repository-sync") {
if (!repository?.localPath) return;
const strategy = target.dataset.strategy;
const destructive = strategy === "backup-reset";
const message = destructive
? "Create a safety branch from the current HEAD and reset this branch to its upstream? Uncommitted changes are never discarded."
: `Run the repository-specific ${strategy} repair now?`;
if (!confirm(message)) return;
setLoading(
true,
destructive
? "Creating safety branch and repairing divergence…"
: "Repairing repository synchronization…",
);
try {
const result = await window.forgeflow.repairRepositorySync(
repository.localPath,
strategy,
);
ui.gitRecovery = await window.forgeflow.gitRecoveryStatus(
repository.localPath,
);
await refreshRepositories(false);
showToast(
"Repository synchronization repaired",
result.backupBranch
? `Safety branch created: ${result.backupBranch}`
: `Completed ${strategy}.`,
"success",
);
} catch (error) {
showToast("Synchronization repair failed", error.message, "error");
}
setLoading(false);
render();
} else if (action === "run-troubleshooter") {
setLoading(
true,
"Scanning repositories, Git operations and deployment servers…",
);
try {
ui.troubleshooter = await window.forgeflow.troubleshooterScan();
showToast(
"Troubleshooter completed",
ui.troubleshooter.summary.total
? `${ui.troubleshooter.summary.total} issue(s) found; ${ui.troubleshooter.summary.repairable} repairable.`
: "No problems were detected.",
ui.troubleshooter.summary.errors ? "error" : "success",
);
} catch (error) {
showToast("Troubleshooter failed", error.message, "error");
}
setLoading(false);
render();
} else if (action === "troubleshooter-auto-repair") {
const safeIssues = (ui.troubleshooter?.issues || []).filter(
(item) => item.repairable && item.safe,
);
if (
!safeIssues.length ||
!confirm(
`Repair ${safeIssues.length} safe issue(s) now? ForgeFlow will not run destructive reset actions automatically.`,
)
)
return;
setLoading(true, "Applying safe one-click repairs…");
try {
const results =
await window.forgeflow.troubleshooterAutoRepair(safeIssues);
ui.troubleshooter = await window.forgeflow.troubleshooterScan();
await refreshRepositories(false);
const failed = results.filter((item) => !item.ok);
showToast(
failed.length
? "Repairs partially completed"
: "Safe repairs completed",
`${results.length - failed.length} repaired, ${failed.length} failed.`,
failed.length ? "error" : "success",
);
} catch (error) {
showToast("Automatic repair failed", error.message, "error");
}
setLoading(false);
render();
} else if (action === "troubleshooter-repair") {
const issue =
ui.troubleshooter?.issues?.[Number(target.dataset.issueIndex)];
if (!issue) return;
const warning = issue.safe
? `Repair “${issue.title}” now?`
: `${issue.title}” requires a safety branch or another potentially destructive change. Continue?`;
if (!confirm(warning)) return;
setLoading(true, `Repairing ${issue.title}`);
try {
const result = await window.forgeflow.troubleshooterRepair(issue);
ui.troubleshooter = await window.forgeflow.troubleshooterScan();
await refreshRepositories(false);
showToast(
"Problem repaired",
result?.backupBranch
? `Safety branch created: ${result.backupBranch}`
: issue.title,
"success",
);
} catch (error) {
showToast("Repair failed", error.message, "error");
}
setLoading(false);
render();
} else if (action === "run-system-preflight") await runSystemPreflight();
else if (action === "load-audit-log") {
try {
ui.auditEvents = await window.forgeflow.listAuditEvents(500);
render();
} catch (error) {
showToast("Could not load audit log", error.message, "error");
}
} else if (action === "export-audit-json" || action === "export-audit-csv") {
try {
const result = await window.forgeflow.exportAuditLog(
action.endsWith("csv") ? "csv" : "json",
);
if (result)
showToast(
"Audit log exported",
`${result.count} records exported.`,
"success",
);
} catch (error) {
showToast("Could not export audit log", error.message, "error");
}
} else if (action === "save-diagnostics-preferences") {
const preferences = {
diagnosticsEnabled:
document.querySelector("#diagnostics-enabled").value === "true",
diagnosticLevel: document.querySelector("#diagnostic-level").value,
logRetentionDays: Number(
document.querySelector("#diagnostic-retention").value,
),
maxLogFileMb: Number(
document.querySelector("#diagnostic-max-file").value,
),
};
setLoading(true, "Saving diagnostic policy…");
try {
ui.boot.state = await window.forgeflow.setPreferences(preferences);
ui.diagnosticsStatus = await window.forgeflow.diagnosticsStatus();
showToast(
"Diagnostic policy saved",
"New events now use the updated retention and logging level.",
"success",
);
} catch (error) {
showToast("Could not save diagnostics", error.message, "error");
}
setLoading(false);
} else if (action === "export-diagnostics") {
const privacyMode =
document.querySelector("#diagnostic-privacy")?.value || "standard";
setLoading(true, "Creating redacted diagnostic bundle…");
try {
const bundle = await window.forgeflow.exportDiagnostics(privacyMode);
if (bundle) {
ui.lastDiagnosticBundle = bundle;
ui.diagnosticsStatus = await window.forgeflow.diagnosticsStatus();
showToast(
"Diagnostic bundle created",
`${bundle.size} · SHA-256 ${shortSha(bundle.sha256)}`,
"success",
);
}
} catch (error) {
showToast("Could not export diagnostics", error.message, "error");
}
setLoading(false);
} else if (action === "show-diagnostic-bundle") {
if (!ui.lastDiagnosticBundle?.path) return;
await window.forgeflow
.showDiagnosticBundle(ui.lastDiagnosticBundle.path)
.catch((error) =>
showToast("Could not show bundle", error.message, "error"),
);
} else if (action === "open-diagnostics-folder")
await window.forgeflow
.openDiagnosticsFolder()
.catch((error) =>
showToast("Could not open diagnostic folder", error.message, "error"),
);
else if (action === "clear-diagnostics") {
if (
!confirm(
"Clear local ForgeFlow diagnostic logs? This does not affect repositories or configuration.",
)
)
return;
try {
ui.diagnosticsStatus = await window.forgeflow.clearDiagnostics();
showToast(
"Diagnostic logs cleared",
"A new session marker was created.",
"success",
);
render();
} catch (error) {
showToast("Could not clear logs", error.message, "error");
}
} else if (action === "reset-app") {
if (
!confirm(
"Reset ForgeFlow configuration? Your Git repositories and Gitea data are not modified.",
)
)
return;
ui.boot.state = await window.forgeflow.reset();
ui.repositories = [];
ui.setupStep = 0;
ui.setupValidation = null;
ui.systemPreflight = null;
ui.deploymentPreflight = null;
ui.lastDiagnosticBundle = null;
ui.setupDraft = {
baseUrl: "https://",
token: "",
user: null,
roots: [],
discovered: [],
};
render();
}
else return false;
return true;
}
+426
View File
@@ -0,0 +1,426 @@
async function handleSetupAndSettingsActions(event, target, action, repository) {
if (action === "setup-run-preflight")
await runSystemPreflight({ setup: true });
else if (action === "setup-continue") {
if (ui.systemPreflight?.summary?.ready) {
ui.setupStep = 1;
render();
}
} else if (action === "setup-validate") {
setLoading(true, "Validating Gitea connection…");
try {
ui.setupValidation = await window.forgeflow.validateGitea(ui.setupDraft);
ui.setupDraft.baseUrl = ui.setupValidation.baseUrl;
ui.setupDraft.user = ui.setupValidation.user;
ui.setupStep = 2;
} catch (error) {
showToast("Connection failed", error.message, "error");
}
setLoading(false);
} else if (action === "setup-add-root") {
const root = await window.forgeflow.selectDirectory({
title: "Select a development folder",
});
if (root && !ui.setupDraft.roots.includes(root))
ui.setupDraft.roots.push(root);
render();
} else if (action === "setup-remove-root") {
ui.setupDraft.roots.splice(Number(target.dataset.index), 1);
render();
} else if (action === "setup-next") {
if (ui.setupStep === 2) {
ui.setupStep = 3;
ui.setupDraft.discovered = [];
render();
try {
ui.setupDraft.discovered = await window.forgeflow.discoverRepositories(
ui.setupDraft.roots,
);
} catch (error) {
showToast("Discovery failed", error.message, "error");
}
ui.setupStep = 4;
render();
}
} else if (action === "setup-back") {
ui.setupStep = Math.max(0, ui.setupStep - 1);
render();
} else if (action === "setup-finish") {
setLoading(true, "Saving configuration…");
try {
const result = await window.forgeflow.completeSetup({
baseUrl: ui.setupDraft.baseUrl,
token: ui.setupDraft.token,
user: ui.setupDraft.user,
workspaceRoots: ui.setupDraft.roots,
});
ui.boot.state = result.state;
await refreshRepositories(false);
showToast(
"Setup complete",
result.tokenState.persistent
? "Your token is stored securely."
: "Your token is available for this session only.",
"success",
);
} catch (error) {
showToast("Could not complete setup", error.message, "error");
}
setLoading(false);
} else if (action === "check-updates") {
ui.updateChecking = true;
render();
try {
ui.updateStatus = await window.forgeflow.checkForUpdates();
showToast(
ui.updateStatus.available ? "Update available" : "ForgeFlow is current",
ui.updateStatus.available
? `Version ${ui.updateStatus.remoteVersion} can be downloaded.`
: `Version ${ui.updateStatus.currentVersion} is the newest release.`,
ui.updateStatus.available ? "success" : "info",
);
} catch (error) {
showToast("Update check failed", error.message, "error");
}
ui.updateChecking = false;
render();
} else if (action === "save-update-settings") {
const updates = {
owner: document.querySelector("#update-owner").value.trim(),
repo: document.querySelector("#update-repo").value.trim(),
branch: document.querySelector("#update-branch").value.trim(),
autoCheck: document.querySelector("#update-auto-check").value === "true",
};
try {
ui.boot.state = await window.forgeflow.setUpdatePreferences(updates);
ui.updateStatus = null;
showToast(
"Update settings saved",
"The next check will use this repository and branch.",
"success",
);
} catch (error) {
showToast("Could not save update settings", error.message, "error");
}
render();
} else if (action === "download-update") {
setLoading(true, "Downloading and verifying the exact ForgeFlow update…");
try {
ui.updateStatus = await window.forgeflow.downloadUpdate();
showToast(
"Update downloaded",
`Version ${ui.updateStatus.remoteVersion} passed the integrity check.`,
"success",
);
} catch (error) {
showToast("Update download failed", error.message, "error");
}
setLoading(false);
} else if (action === "apply-update") {
if (
!confirm(
`Apply ForgeFlow ${ui.updateStatus?.remoteVersion || "update"} now? ForgeFlow closes, validates the update and restarts automatically.`,
)
)
return;
setLoading(true, "Launching safe updater…");
try {
await window.forgeflow.applyUpdate();
showToast(
"Update launched",
"ForgeFlow will close and restart after validation.",
"success",
);
} catch (error) {
showToast("Could not launch update", error.message, "error");
setLoading(false);
}
} else if (action === "use-server-password") {
ui.modal = {
type: "server-password",
serverId: target.dataset.serverId,
retry: { type: target.dataset.retry || "scan" },
};
render();
} else if (action === "confirm-server-password") {
const server = (ui.boot?.state?.servers || []).find((item) => item.id === target.dataset.serverId);
const password = document.querySelector("#quick-server-password")?.value || "";
if (!server || !password) {
showToast("Password required", "Enter the Unraid SSH password.", "error");
return;
}
const retry = ui.modal?.retry || { type: "scan" };
setLoading(true, "Switching the server connection to password authentication…");
try {
const saved = await window.forgeflow.saveServer(
{ ...server, authType: "password", privateKeyPath: "" },
password,
"",
);
ui.boot.state = saved.state;
const tested = await window.forgeflow.testServer(server.id);
ui.boot.state = tested.state;
ui.modal = null;
showToast("Server password saved", "ForgeFlow will no longer use an SSH key for this server.", "success");
if (retry.type === "deploy") {
const retryRepository = ui.repositories.find((item) => item.fullName === retry.repositoryFullName);
if (retryRepository) ui.selectedRepoId = retryRepository.id;
await executeDeployment(retry.profileId);
} else {
await refreshDeploymentTruth(true);
}
} catch (error) {
showToast("Server authentication failed", error.message, "error");
}
setLoading(false);
} else if (action === "open-add-server") {
ui.modal = {
type: "server-config",
serverId: null,
authType: "password",
};
render();
} else if (action === "edit-server") {
const server = (ui.boot.state.servers || []).find(
(item) => item.id === target.dataset.serverId,
);
ui.modal = {
type: "server-config",
serverId: target.dataset.serverId,
authType: server?.authType || "password",
};
render();
} else if (action === "select-private-key") {
const keyPath = await window.forgeflow.selectKeyFile({
title: "Select SSH private key",
defaultPath:
document.querySelector("#server-private-key")?.value || undefined,
});
if (keyPath) document.querySelector("#server-private-key").value = keyPath;
} else if (action === "save-server") {
const authType = document.querySelector("#server-auth-type").value;
const server = {
id: target.dataset.serverId || undefined,
name: document.querySelector("#server-name").value.trim(),
host: document.querySelector("#server-host").value.trim(),
port: Number(document.querySelector("#server-port").value),
username: document.querySelector("#server-username").value.trim(),
authType,
basePath: document.querySelector("#server-base-path").value.trim(),
scanRoots: document.querySelector("#server-scan-roots").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean),
scanExcludes: document.querySelector("#server-scan-excludes").value.split(",").map((value) => value.trim()).filter(Boolean),
privateKeyPath:
document.querySelector("#server-private-key")?.value.trim() || "",
hostFingerprint: document
.querySelector("#server-fingerprint")
.value.trim(),
};
const password = document.querySelector("#server-password")?.value || "";
const passphrase =
document.querySelector("#server-passphrase")?.value || "";
setLoading(true, "Saving encrypted SSH configuration…");
try {
const result = await window.forgeflow.saveServer(
server,
password,
passphrase,
);
ui.boot.state = result.state;
ui.modal = null;
showToast(
"Server saved",
"Run Test & trust before creating a deployment.",
"success",
);
} catch (error) {
showToast("Could not save server", error.message, "error");
}
setLoading(false);
} else if (action === "test-server") {
setLoading(
true,
"Checking SSH identity, Docker, Compose and optional Git capabilities…",
);
try {
const result = await window.forgeflow.testServer(target.dataset.serverId);
ui.boot.state = result.state;
const capabilities = result.capabilities || {};
const deploymentReady =
capabilities.docker && capabilities.dockerReady && capabilities.compose;
showToast(
deploymentReady ? "SSH server ready" : "SSH connected with missing tools",
`${result.server.name} presented ${result.fingerprint}. Docker ${capabilities.dockerReady ? "ready" : "unavailable"}; Compose ${capabilities.compose ? "ready" : "missing"}.`,
deploymentReady ? "success" : "info",
);
} catch (error) {
showToast("SSH test failed", error.message, "error");
}
setLoading(false);
} else if (action === "delete-server") {
if (
!confirm("Delete this server and all deployment profiles linked to it?")
)
return;
try {
ui.boot.state = await window.forgeflow.deleteServer(
target.dataset.serverId,
);
ui.modal = null;
await refreshRepositories(false);
showToast(
"Server deleted",
"Linked SSH deployment profiles were removed.",
"success",
);
} catch (error) {
showToast("Could not delete server", error.message, "error");
}
} else if (action === "add-root") {
const root = await window.forgeflow.selectDirectory({
title: "Add development folder",
});
if (root && !ui.boot.state.workspaceRoots.includes(root))
ui.boot.state.workspaceRoots.push(root);
render();
} else if (action === "remove-root") {
ui.boot.state.workspaceRoots.splice(Number(target.dataset.index), 1);
render();
} else if (action === "save-roots") {
const roots = [...document.querySelectorAll("[data-root-index]")]
.map((input) => input.value.trim())
.filter(Boolean);
setLoading(true, "Saving workspace folders…");
try {
ui.boot.state = await window.forgeflow.setWorkspaceRoots(roots);
await refreshRepositories(false);
showToast(
"Folders saved",
"Repository discovery has been refreshed.",
"success",
);
} catch (error) {
showToast("Could not save folders", error.message, "error");
}
setLoading(false);
} else if (action === "save-gitea-settings") {
const baseUrl = document.querySelector("#settings-gitea-url").value.trim();
const token = document.querySelector("#settings-gitea-token").value.trim();
setLoading(true, "Validating Gitea…");
try {
const result = await window.forgeflow.updateGitea({ baseUrl, token });
ui.boot.state = result.state;
await refreshRepositories(false);
showToast(
"Gitea connected",
`Signed in as ${result.validation.user.login}.`,
"success",
);
} catch (error) {
showToast("Connection failed", error.message, "error");
}
setLoading(false);
} else if (action === "save-preferences") {
const preferences = {
autoRefresh:
document.querySelector("#pref-auto-refresh").value === "true",
repositoryPollSeconds: Number(
document.querySelector("#pref-repo-poll").value,
),
operationPollSeconds: Number(
document.querySelector("#pref-operation-poll").value,
),
preferredCloneProtocol: document.querySelector("#pref-clone-protocol")
.value,
};
setLoading(true, "Saving background settings…");
try {
ui.boot.state = await window.forgeflow.setPreferences(preferences);
await refreshRepositories(false);
showToast(
"Settings saved",
"Background awareness has been updated.",
"success",
);
} catch (error) {
showToast("Could not save settings", error.message, "error");
}
setLoading(false);
} else if (action === "save-desktop-preferences") {
const splitArgs = (selector) =>
document
.querySelector(selector)
.value.split("|")
.map((item) => item.trim())
.filter(Boolean);
const preferences = {
editor: {
executable: document
.querySelector("#pref-editor-executable")
.value.trim(),
args: splitArgs("#pref-editor-args"),
},
terminal: {
executable: document
.querySelector("#pref-terminal-executable")
.value.trim(),
args: splitArgs("#pref-terminal-args"),
},
notificationsEnabled: document.querySelector("#pref-notifications")
.checked,
trayEnabled: document.querySelector("#pref-tray").checked,
closeToTray: document.querySelector("#pref-close-tray").checked,
startAtLogin: document.querySelector("#pref-login").checked,
};
try {
ui.boot.state = await window.forgeflow.setPreferences(preferences);
showToast(
"Desktop integration saved",
"Editor, terminal, tray and notification settings are active.",
"success",
);
} catch (error) {
showToast("Could not save desktop integration", error.message, "error");
}
render();
} else if (
action === "export-config-backup" ||
action === "import-config-backup"
) {
const passphrase = document.querySelector("#backup-passphrase").value;
if (passphrase.length < 12) {
showToast("Passphrase too short", "Use at least 12 characters.", "error");
return;
}
setLoading(
true,
action === "export-config-backup"
? "Encrypting configuration backup…"
: "Decrypting and validating configuration…",
);
try {
const result =
action === "export-config-backup"
? await window.forgeflow.exportConfigurationBackup(passphrase)
: await window.forgeflow.importConfigurationBackup(passphrase);
if (result?.state) {
ui.boot.state = result.state;
await refreshRepositories(false);
}
if (result)
showToast(
action === "export-config-backup"
? "Encrypted backup created"
: "Configuration restored",
action === "export-config-backup"
? result.filePath
: `Backup from ${result.exportedAt} imported; credentials were preserved only where already present.`,
"success",
);
} catch (error) {
showToast("Configuration backup failed", error.message, "error");
}
setLoading(false);
}
else return false;
return true;
}
+517
View File
@@ -0,0 +1,517 @@
async function handleShellActions(event, target, action, repository) {
if (action === "navigate") {
ui.currentView = target.dataset.view;
ui.modal = null;
render();
if (ui.currentView === "deployments" && (ui.boot?.state?.servers || []).length && !(ui.serverDiscovery || []).length) {
setLoading(true, "Reading Docker, Compose and DockerMan inventory from Unraid…");
await refreshDeploymentTruth(true);
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);
await refreshDeploymentTruth(false);
} else if (action === "refresh-operations") {
setLoading(true, "Refreshing deployment operations and live server state…");
await refreshActiveOperations();
await refreshDeploymentTruth(true);
setLoading(false);
} else if (action === "toggle-theme") {
const appearance =
document.documentElement.dataset.theme === "dark" ? "light" : "dark";
applyTheme(appearance);
ui.boot.state = await window.forgeflow.setAppearance(appearance);
render();
} else if (action === "open-palette") {
ui.paletteQuery = "";
ui.modal = { type: "command-palette" };
render();
} else if (action === "repo-tab") {
ui.repositoryTab = target.dataset.tab;
if (ui.repositoryTab === "gittools" && !ui.branches.length)
await loadGitTools(repository);
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,
"open",
);
} catch (error) {
ui.pullRequests = [];
showToast("Could not load pull requests", error.message, "error");
}
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;
let preview;
try {
preview = await window.forgeflow.gitValidatorPreviewRepair(repository.fullName, check);
} catch (error) {
showToast("Preview failed", error.message, "error");
return;
}
if (!confirm(`${check.confirmation || `Apply ${check.title}?`}\n\nReviewable change:\n${preview.diff}\n\nNothing will be committed or pushed.`)) 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-policy") {
setLoading(true, "Applying assurance policy…");
try {
await window.forgeflow.gitValidatorSetPolicy(repository.fullName, { id: target.value });
ui.gitValidation = await window.forgeflow.gitValidatorScan(repository.fullName);
showToast("Policy updated", ui.gitValidation.policy.label, "success");
} catch (error) {
showToast("Policy update failed", error.message, "error");
} finally {
setLoading(false);
}
} else if (action === "git-validator-export") {
try {
const exported = await window.forgeflow.gitValidatorExport(repository.fullName, target.dataset.format || "markdown");
const url = URL.createObjectURL(new Blob([exported.content], { type: exported.mimeType }));
const link = document.createElement("a");
link.href = url;
link.download = `${repository.name || "repository"}-git-assurance.${exported.extension}`;
link.click();
URL.revokeObjectURL(url);
showToast("Report exported", link.download, "success");
} catch (error) {
showToast("Export failed", error.message, "error");
}
} else if (action === "git-validator-suppress") {
const check = ui.gitValidation?.checks?.[Number(target.dataset.checkIndex)];
if (!check) return;
const reason = prompt("Reason for this temporary exception (minimum 10 characters):", "Accepted temporarily while remediation is tracked.");
if (!reason) return;
const author = prompt("Exception owner:", ui.boot?.state?.gitea?.user?.login || "");
if (!author) return;
const ticket = prompt("Ticket reference (optional):", "") || "";
const expiresAt = new Date(Date.now() + 7 * 86_400_000).toISOString();
try {
await window.forgeflow.gitValidatorSuppress(repository.fullName, { checkId: check.id, reason, author, ticket, expiresAt, scope: "repository", evidence: `${ui.gitValidation.commitSha || "unknown"}:${check.id}:${check.status}` });
ui.gitValidation = await window.forgeflow.gitValidatorScan(repository.fullName);
showToast("Exception documented", `Expires ${formatDate(expiresAt)}.`, "success");
} catch (error) {
showToast("Exception rejected", error.message, "error");
}
} else if (action === "toggle-favorite") {
ui.boot.state = await window.forgeflow.favoriteRepository(
repository.fullName,
!repository.favorite,
);
repository.favorite = !repository.favorite;
render();
} else if (action === "select-file") {
if (event.target.matches("input[type=checkbox]")) return;
ui.selectedFile = target.dataset.path;
await loadDiff(repository, ui.selectedFile);
} else if (action === "toggle-all-files") {
const files = repository.localStatus?.files || [];
ui.selectedFiles =
ui.selectedFiles.size === files.length
? new Set()
: new Set(files.map((file) => file.path));
render();
} else if (action === "copy-diff") {
await navigator.clipboard.writeText(ui.diff || "");
showToast("Copied", "Diff copied to clipboard.", "success");
} else if (action === "open-hunk-staging") {
if (ui.diffHunks?.partialSupported) {
ui.modal = { type: "hunk-staging" };
render();
}
} else if (action === "stage-chosen-hunks") {
const indexes = [
...document.querySelectorAll("[data-hunk-index]:checked"),
].map((input) => Number(input.dataset.hunkIndex));
if (!indexes.length) return;
const result = await runOperation(
"Staging selected hunks…",
() =>
window.forgeflow.stageHunks(
repository.localPath,
ui.selectedFile,
indexes,
),
"Selected hunks staged.",
);
if (result) {
ui.modal = null;
await loadDiff(selectedRepository(), ui.selectedFile);
}
} else if (action === "open-file-editor") {
await window.forgeflow
.openEditor(repository.localPath, ui.selectedFile)
.catch((error) =>
showToast("Could not open editor", error.message, "error"),
);
} else if (action === "open-editor") {
await window.forgeflow
.openEditor(repository.localPath)
.catch((error) =>
showToast("Could not open editor", error.message, "error"),
);
} else if (action === "open-terminal") {
await window.forgeflow
.openTerminal(repository.localPath)
.catch((error) =>
showToast("Could not open terminal", error.message, "error"),
);
} else if (action === "load-conflicts") {
ui.conflictState = await window.forgeflow.conflictState(
repository.localPath,
);
ui.modal = { type: "conflict-guide" };
render();
} else if (action === "resolve-conflict") {
if (
!ui.selectedFile ||
!confirm(`Apply “${target.dataset.resolution}” to ${ui.selectedFile}?`)
)
return;
ui.conflictState = await window.forgeflow.resolveConflict(
repository.localPath,
ui.selectedFile,
target.dataset.resolution,
);
await refreshRepositories(false);
ui.modal = { type: "conflict-guide" };
render();
} else if (action === "open-conflict-file") {
await window.forgeflow.openEditor(
repository.localPath,
target.dataset.path,
);
} else if (action === "continue-git-operation") {
ui.conflictState = await window.forgeflow.continueGitOperation(
repository.localPath,
);
ui.modal = null;
await refreshRepositories(false);
showToast(
"Git operation continued",
"The repository operation completed.",
"success",
);
} else if (action === "abort-git-operation") {
if (
!confirm(
"Abort the active Git operation? Conflict-resolution work may be discarded.",
)
)
return;
await window.forgeflow.abortGitOperation(repository.localPath);
ui.modal = null;
await refreshRepositories(false);
} else if (action === "check-branch-protection") {
ui.branchProtection = await window.forgeflow.branchProtection(
repository.fullName,
repository.localStatus.branch.head,
);
showToast(
ui.branchProtection.protected
? "Protected branch"
: "Branch is not protected",
ui.branchProtection.protected
? `${ui.branchProtection.requiredApprovals} approval(s) required.`
: "Direct pushes are permitted by the reported branch rule.",
ui.branchProtection.protected ? "info" : "success",
);
render();
} else if (action === "open-pull-request") {
const subject =
ui.history[0]?.subject || repository.localStatus.branch.head;
ui.modal = {
type: "pull-request",
title: subject,
body: `## Summary\n\nChanges from ${repository.localStatus.branch.head}.`,
};
render();
} else if (action === "load-pull-requests") {
try {
ui.pullRequests = await window.forgeflow.pullRequests(
repository.fullName,
"open",
);
render();
} catch (error) {
showToast("Could not load pull requests", error.message, "error");
}
} else if (action === "open-pull-request-url") {
if (target.dataset.url)
await window.forgeflow.openExternal(target.dataset.url);
} else if (action === "create-pull-request") {
setLoading(true, "Creating pull request…");
try {
const pull = await window.forgeflow.createPullRequest(
repository.fullName,
document.querySelector("#pr-title").value,
document.querySelector("#pr-body").value,
document.querySelector("#pr-base").value,
);
ui.modal = null;
ui.pullRequests = await window.forgeflow
.pullRequests(repository.fullName, "open")
.catch(() => ui.pullRequests);
showToast("Pull request created", `#${pull.number}`, "success");
if (pull.html_url) await window.forgeflow.openExternal(pull.html_url);
} catch (error) {
showToast("Could not create pull request", error.message, "error");
}
setLoading(false);
} else if (action === "copy-logs") {
const text = (ui.activeDeployment?.logs || []).join("\n");
await navigator.clipboard.writeText(text);
showToast(
"Copied",
"Safe operation output copied. Open Gitea for raw runner logs.",
"success",
);
} else if (action === "stage-selected") {
if (!repository?.localPath || !ui.selectedFiles.size) return;
await runOperation(
"Staging selected files…",
() =>
window.forgeflow.stageFiles(repository.localPath, [
...ui.selectedFiles,
]),
"Files staged.",
);
} else if (action === "unstage-selected") {
if (!repository?.localPath || !ui.selectedFiles.size) return;
await runOperation(
"Unstaging selected files…",
() =>
window.forgeflow.unstageFiles(repository.localPath, [
...ui.selectedFiles,
]),
"Files unstaged.",
);
} else if (action === "commit-push" || action === "commit-only") {
if (
!repository?.localPath ||
!ui.commitMessage.trim() ||
(!ui.selectedFiles.size && !repository.localStatus?.counts?.staged)
)
return;
const selected = [...ui.selectedFiles];
const result = await runOperation(
action === "commit-push"
? "Committing and pushing…"
: "Creating local commit…",
() =>
action === "commit-push"
? selected.length
? window.forgeflow.commitAndPush(
repository.localPath,
ui.commitMessage,
selected,
)
: window.forgeflow.commitStagedAndPush(
repository.localPath,
ui.commitMessage,
)
: selected.length
? window.forgeflow.commit(
repository.localPath,
ui.commitMessage,
selected,
)
: window.forgeflow.commitStaged(
repository.localPath,
ui.commitMessage,
),
action === "commit-push"
? "Changes committed and pushed to Gitea."
: "Local commit created.",
);
if (result) {
ui.commitMessage = "";
ui.selectedFiles.clear();
ui.selectedFile = null;
ui.diff = "";
}
} else if (action === "push")
await runOperation(
"Pushing local commits…",
() => window.forgeflow.push(repository.localPath),
"Push completed.",
);
else if (action === "fetch")
await runOperation(
"Fetching from Gitea…",
() => window.forgeflow.fetch(repository.localPath),
"Remote state refreshed.",
);
else if (action === "pull")
await runOperation(
"Synchronizing from Gitea…",
() => window.forgeflow.pull(repository.localPath),
"Local branch fast-forwarded.",
);
else if (action === "load-history") {
setLoading(true, "Loading commit history…");
try {
ui.history = await window.forgeflow.history(repository.localPath, 50);
} catch (error) {
showToast("History unavailable", error.message, "error");
}
setLoading(false);
} else if (action === "load-git-tools") await loadGitTools(repository);
else if (action === "create-branch") {
const branch = document.querySelector("#new-branch-name")?.value.trim();
if (branch)
await runOperation(
`Creating ${branch}`,
() => window.forgeflow.createBranch(repository.localPath, branch),
`Switched to ${branch}.`,
);
await loadGitTools(selectedRepository());
} else if (action === "checkout-branch") {
await runOperation(
`Switching to ${target.dataset.branch}`,
() =>
window.forgeflow.checkoutBranch(
repository.localPath,
target.dataset.branch,
),
`Switched to ${target.dataset.branch}.`,
);
await loadGitTools(selectedRepository());
} else if (action === "stash-changes") {
const result = await runOperation(
"Stashing local changes…",
() =>
window.forgeflow.stash(
repository.localPath,
`ForgeFlow ${new Date().toLocaleString()}`,
),
"Local changes stashed.",
);
if (result) ui.stashes = result.stashes;
} else if (action === "pop-stash") {
const result = await runOperation(
`Applying ${target.dataset.stashRef}`,
() =>
window.forgeflow.popStash(
repository.localPath,
target.dataset.stashRef,
),
"Stash applied.",
);
if (result) ui.stashes = result.stashes;
} else if (action === "open-path")
await window.forgeflow
.openPath(repository.localPath)
.catch((error) =>
showToast("Could not open folder", error.message, "error"),
);
else if (action === "open-gitea")
await window.forgeflow
.openExternal(repository.htmlUrl)
.catch((error) =>
showToast("Could not open Gitea", error.message, "error"),
);
else if (action === "link-repo") {
const localPath = await window.forgeflow.selectDirectory({
title: `Link local folder for ${repository.name}`,
});
if (localPath) {
ui.repositories =
(await runOperation(
"Linking local repository…",
() => window.forgeflow.linkRepository(repository.fullName, localPath),
"Local folder linked.",
{ refresh: false },
)) || ui.repositories;
selectRepository(repository.id);
}
} else if (action === "unlink-repo") {
ui.repositories =
(await runOperation(
"Removing local link…",
() => window.forgeflow.unlinkRepository(repository.fullName),
"Repository link removed.",
{ refresh: false },
)) || ui.repositories;
selectRepository(repository.id);
} else if (action === "clone-repo" || action === "clone-repo-custom") {
const mode = action === "clone-repo-custom" ? "custom" : "default";
const clone = await runOperation(
mode === "custom"
? "Choosing location and cloning repository…"
: `Cloning ${repository.name} into the default project root…`,
() => window.forgeflow.cloneRepository(repository.fullName, mode),
null,
{ refresh: false },
);
if (clone?.cancelled) return;
if (clone?.target) {
if (clone.state) ui.boot.state = clone.state;
ui.repositories =
clone.repositories || (await window.forgeflow.refreshRepositories());
selectRepository(repository.id);
showToast(
clone.reused ? "Existing repository linked" : "Repository cloned",
clone.target,
"success",
);
}
}
else return false;
return true;
}
+12 -3249
View File
File diff suppressed because it is too large Load Diff
+323
View File
@@ -0,0 +1,323 @@
function renderModal() {
if (!ui.modal) return "";
const repository =
selectedRepository() ||
ui.repositories.find(
(repo) => repo.fullName === ui.modal.repositoryFullName,
);
if (ui.modal.type === "server-password") {
const server = (ui.boot?.state?.servers || []).find((item) => item.id === ui.modal.serverId);
if (!server) return `<div class="modal-backdrop"><section class="modal"><header class="modal-header"><h2>Server password</h2></header><div class="modal-body"><div class="notice danger">${icon("error")}The selected server no longer exists.</div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Close</button></footer></section></div>`;
const retryText = ui.modal.retry?.type === "deploy" ? "Save password & redeploy" : "Save password & rescan";
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Use password for ${escapeHtml(server.name)}</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="notice success">${icon("shield")}ForgeFlow stores the server password with Windows protected storage and uses it only for the desktop → Unraid connection.</div><div class="field" style="margin-top:14px"><label>SSH password for ${escapeHtml(server.username)}@${escapeHtml(server.host)}</label><input id="quick-server-password" class="input" type="password" autocomplete="current-password" autofocus placeholder="Server password"/></div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="confirm-server-password" data-server-id="${attr(server.id)}">${escapeHtml(retryText)}</button></footer></section></div>`;
}
if (ui.modal.type === "server-reconciliation-plan") {
const plan = ui.modal.result?.plan || {};
const summary = plan.summary || {};
const rows = [
...(plan.additions || []).map((item) => ({ tone: "success", title: `Link ${item.repositoryFullName}`, detail: `${item.evidence} · ${item.impact}` })),
...(plan.updates || []).map((item) => ({ tone: "", title: `Refresh ${item.repositoryFullName}`, detail: item.impact })),
...(plan.stale || []).map((item) => ({ tone: "warning", title: `Review stale link ${item.repositoryFullName}`, detail: `${item.reason} · no automatic removal` })),
...(plan.conflicts || []).map((item) => ({ tone: "danger", title: `Manual review: ${item.displayName}`, detail: `${item.status} · ${(item.candidates || []).map((candidate) => candidate.repositoryFullName).join(", ") || "no unique repository"}` })),
];
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true" aria-labelledby="reconciliation-title"><header class="modal-header"><h2 id="reconciliation-title">Review server reconciliation</h2><button class="icon-button" data-action="close-modal" aria-label="Close reconciliation preview">${icon("close")}</button></header><div class="modal-body"><div class="notice success">${icon("shield")}This reviewed plan may update ForgeFlow configuration only. It never starts, stops or recreates containers, and stale profiles are never removed automatically.</div><div class="summary-grid" style="margin-top:12px"><div class="summary-card"><span>New links</span><strong>${Number(summary.additions || 0)}</strong></div><div class="summary-card"><span>Refreshes</span><strong>${Number(summary.updates || 0)}</strong></div><div class="summary-card"><span>Stale reviews</span><strong>${Number(summary.stale || 0)}</strong></div><div class="summary-card"><span>Conflicts</span><strong>${Number(summary.conflicts || 0)}</strong></div></div><div class="tool-list" style="margin-top:14px">${rows.length ? rows.map((item) => `<div class="tool-row"><div><strong>${escapeHtml(item.title)}</strong><span>${escapeHtml(item.detail)}</span></div>${item.tone ? `<span class="status-pill ${item.tone}">${escapeHtml(item.tone === "success" ? "Planned" : item.tone === "warning" ? "Review" : "Blocked")}</span>` : ""}</div>`).join("") : '<div class="empty-state compact"><p>No configuration changes are proposed.</p></div>'}</div><div class="notice" style="margin-top:12px">${icon("archive")}A private recovery snapshot is written before the plan is applied. Plan ID: <span class="mono">${escapeHtml(String(plan.id || "").slice(0, 12))}</span></div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="apply-server-reconciliation" data-server-id="${attr(plan.serverId || "")}" data-plan-id="${attr(plan.id || "")}" ${summary.conflicts ? "disabled title=\"Resolve ambiguous workloads manually before applying reconciliation\"" : ""}>Apply reviewed plan</button></footer></section></div>`;
}
if (ui.modal.type === "workload-link") {
const serverResult = (ui.serverDiscovery || []).find(
(item) => item.serverId === ui.modal.serverId,
);
const workload = serverResult?.workloads?.find(
(item) => item.workloadId === ui.modal.workloadId,
);
if (!workload) {
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Link server workload</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="notice danger">${icon("error")}This workload is no longer present in the latest server inventory. Scan the servers again.</div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Close</button></footer></section></div>`;
}
const availableRepositories = ui.repositories.filter((item) => item.fullName);
const suggestedRepository =
ui.modal.repositoryFullName ||
workload.candidates?.[0]?.repositoryFullName ||
selectedRepository()?.fullName ||
availableRepositories[0]?.fullName ||
"";
const selectedLinkRepository = availableRepositories.find(
(item) => item.fullName === suggestedRepository,
);
const remoteFolder =
ui.modal.remoteFolder ||
workload.remoteFolderCandidate ||
safeCloneFolderName(selectedLinkRepository);
const candidateSummary = workload.candidates?.length
? workload.candidates
.slice(0, 4)
.map(
(candidate) =>
`<div class="context-row"><span>${escapeHtml(candidate.repositoryFullName)}</span><strong>${escapeHtml(candidate.exact ? "Exact provenance" : `${candidate.score} confidence`)} · ${escapeHtml((candidate.reasons || []).join(", ") || "name similarity")}</strong></div>`,
)
.join("")
: '<div class="context-row"><span>Repository candidates</span><strong>No confident match; choose manually.</strong></div>';
const containerNames = (workload.containers || [])
.map((container) => container.name)
.filter(Boolean)
.join(", ");
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Link existing server workload</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero">${icon("link")}<div><strong>${escapeHtml(workload.displayName)}</strong><span>${escapeHtml(serverResult?.serverName || serverResult?.server?.name || ui.modal.serverId)} · ${workload.runtime?.running ? "running" : "stopped"}</span></div></div><div class="context-summary"><div class="context-row"><span>Containers</span><strong>${escapeHtml(containerNames || "Unknown")}</strong></div><div class="context-row"><span>Compose identity</span><strong>${escapeHtml(workload.compose?.project || "DockerMan / standalone container")} ${workload.compose?.services?.length ? `· ${escapeHtml(workload.compose.services.join(", "))}` : ""}</strong></div><div class="context-row"><span>Detected folder</span><strong class="mono">${escapeHtml(workload.compose?.workingDir || workload.dockerMan?.templatePath || "No Git checkout required")}</strong></div>${candidateSummary}</div><div class="form-grid" style="margin-top:14px"><div class="field full"><label>Repository to link</label><select id="workload-repository" class="select">${availableRepositories.map((item) => `<option value="${attr(item.fullName)}" ${item.fullName === suggestedRepository ? "selected" : ""}>${escapeHtml(item.fullName)}</option>`).join("") || '<option value="">No repositories available</option>'}</select></div><div class="field"><label>Deployment source</label><select id="workload-deployment-mode" class="select"><option value="server-git" selected>Server pull from Gitea</option><option value="push-bundle">Direct copy fallback</option><option value="monitor-only">Monitor only</option></select></div><div class="field"><label>Detected deployment folder</label><input id="workload-remote-folder" class="input" value="${attr(remoteFolder)}" readonly/></div></div><div class="notice success" style="margin-top:12px">${icon("shield")}ForgeFlow preserves the detected Compose project, services and container identity. Server pull provisions a repository-scoped read-only key and activates only the selected Gitea commit.</div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="confirm-link-server-workload" data-server-id="${attr(ui.modal.serverId)}" data-workload-id="${attr(ui.modal.workloadId)}" ${availableRepositories.length ? "" : "disabled"}>Link workload</button></footer></section></div>`;
}
if (ui.modal.type === "deployment-config") {
const storedProfile =
repository?.deploymentProfiles?.find(
(profile) => profile.id === ui.modal.profileId,
) || {};
const discovery =
ui.deploymentDiscovery?.repository === repository?.fullName
? ui.deploymentDiscovery
: null;
const existing = { ...storedProfile, ...(discovery?.profile || {}) };
if (discovery?.provenance) existing.provenance = discovery.provenance;
const servers = ui.boot.state.servers || [];
const provider =
ui.modal.provider ||
existing.provider ||
(servers.length ? "ssh-unraid" : "gitea-actions");
const ssh = provider === "ssh-unraid";
const remoteFolder =
existing.remoteFolder || safeCloneFolderName(repository);
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>${existing.id ? "Edit" : "Add"} deployment environment</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="form-grid"><div class="field full"><label>Deployment provider</label><select id="profile-provider" class="select"><option value="ssh-unraid" ${ssh ? "selected" : ""}>SSH / Unraid · direct controlled deployment</option><option value="gitea-actions" ${!ssh ? "selected" : ""}>Gitea Actions · runner workflow</option></select></div>${ssh ? `<div class="field full"><div class="notice ${discovery ? "success" : ""}">${icon(discovery ? "check" : "server")}<div><strong>${discovery ? "Existing deployment imported from server" : "Import by folder or use Server inventory"}</strong><p>${discovery ? `${escapeHtml(discovery.runtime?.containers || 0)} container(s), ${escapeHtml(discovery.runtime?.services || 0)} service(s) and ${escapeHtml(discovery.runtime?.ports?.length || 0)} port mapping(s) detected. Every imported value remains editable as an explicit override.` : "For a known folder, ForgeFlow can read Docker, Compose and DockerMan metadata. For uncertain matches, use Server inventory and select the actual running workload."}</p><button type="button" class="button ${discovery ? "" : "primary"}" data-action="discover-existing-deployment">${icon("refresh")}${discovery ? "Rescan folder" : "Import known folder"}</button></div></div></div>` : ""}<div class="field"><label>Profile name</label><input id="profile-name" class="input" value="${attr(existing.name || "Production")}" /></div><div class="field"><label>Environment</label><input id="profile-environment" class="input" value="${attr(existing.environment || "production")}" /></div><div class="field"><label>Allowed branch</label><input id="profile-branch" class="input" value="${attr(existing.branch || repository?.defaultBranch || "main")}" /></div>${
ssh
? `
<div class="field"><label>Unraid server</label><select id="profile-server" class="select">${servers.length ? servers.map((server) => `<option value="${attr(server.id)}" ${server.id === existing.serverId ? "selected" : ""}>${escapeHtml(server.name)} · ${escapeHtml(server.host)}</option>`).join("") : '<option value="">Configure a server first</option>'}</select></div>
<div class="field"><label>Server folder name</label><input id="profile-remote-folder" class="input" value="${attr(remoteFolder)}"/></div>
<div class="field"><label>Deployment mode</label><select id="profile-deployment-mode" class="select"><option value="server-git" ${(existing.deploymentMode || "server-git") === "server-git" ? "selected" : ""}>Server pull from Gitea</option><option value="push-bundle" ${existing.deploymentMode === "push-bundle" ? "selected" : ""}>Direct copy & deploy</option><option value="monitor-only" ${existing.deploymentMode === "monitor-only" ? "selected" : ""}>Monitor only</option></select><small>Server pull uses an automatically managed repository-scoped read-only deploy key. Direct copy remains available as a fallback and never requires Gitea credentials on Unraid.</small></div>
<div class="field"><label>Compose mode</label><select id="profile-generated-compose" class="select"><option value="false" ${existing.generatedCompose !== true ? "selected" : ""}>Use existing Compose definition</option><option value="true" ${existing.generatedCompose === true ? "selected" : ""}>Generate a basic ForgeFlow Compose file</option></select></div>
<div class="field"><label>Compose project identity</label><input id="profile-compose-project" class="input" value="${attr(existing.composeProject || "")}" placeholder="Existing docker compose project name"/><small>Kept stable to update the existing containers instead of creating duplicates.</small></div>
<div class="field full"><label>Compose files</label><input id="profile-compose-files" class="input" value="${attr((existing.composeFiles?.length ? existing.composeFiles : [existing.composeFile || "docker-compose.yml"]).join(", "))}"/><small>Comma-separated, in the same order used by the existing deployment. These real server Compose files remain authoritative; ForgeFlow does not inject a synthetic service overlay.</small></div>
<div class="field full"><label>Compose services to verify</label><input id="profile-compose-services" class="input" value="${attr((existing.composeServices?.length ? existing.composeServices : [existing.composeService || safeCloneFolderName(repository).toLowerCase()]).join(", "))}"/><small>Discovery hints only. At deployment time ForgeFlow reads the actual service keys from docker compose config and verifies every active service.</small></div><div class="field"><label>Visible container name</label><input id="profile-container-name" class="input" value="${attr(existing.containerName || remoteFolder)}"/><small>Used as an inventory hint; adopted Compose identity remains authoritative.</small></div>
<div class="field"><label>Host port</label><input id="profile-host-port" class="input" type="number" min="1" max="65535" value="${attr(existing.hostPort || "")}" placeholder="1223"/></div>
<div class="field"><label>Container port</label><input id="profile-container-port" class="input" type="number" min="1" max="65535" value="${attr(existing.containerPort || "")}" placeholder="8080"/></div>
<div class="field full"><label>Unraid Web UI URL (optional)</label><input id="profile-web-ui" class="input" value="${attr(existing.webUiUrl || "")}" placeholder="http://[IP]:[PORT:1223]/"/></div>
<div class="field"><label>DockerMan icon source</label><select id="profile-icon-mode" class="select"><option value="builtin" ${(existing.iconMode || (!existing.iconUrl && !existing.iconFilePath ? "builtin" : existing.iconFilePath ? "upload" : "url")) === "builtin" ? "selected" : ""}>Built-in high-contrast ITWorx mark</option><option value="upload" ${existing.iconMode === "upload" || (!existing.iconMode && existing.iconFilePath) ? "selected" : ""}>Upload local PNG</option><option value="url" ${existing.iconMode === "url" || (!existing.iconMode && existing.iconUrl) ? "selected" : ""}>Use icon URL</option><option value="none" ${existing.iconMode === "none" ? "selected" : ""}>No custom icon</option></select></div><div class="field"><label>Container shell</label><select id="profile-docker-shell" class="select"><option value="/bin/sh" ${(existing.dockerShell || "/bin/sh") === "/bin/sh" ? "selected" : ""}>/bin/sh</option><option value="/bin/bash" ${existing.dockerShell === "/bin/bash" ? "selected" : ""}>/bin/bash</option></select></div>
<div class="field full"><label>DockerMan icon URL</label><input id="profile-icon-url" class="input" value="${attr(existing.iconUrl || "")}" placeholder="https://…/icon.png"/></div><div class="field full"><label>Local PNG</label><div class="inline-form"><input id="profile-icon-file" class="input mono" value="${attr(existing.iconFilePath || "")}" placeholder="Select a local transparent PNG" readonly/><button class="button" data-action="select-profile-icon">${icon("folder")}Browse</button><button class="button ghost" data-action="clear-profile-icon">Clear</button></div><small>Built-in or uploaded PNGs are copied to DockerMan's persistent image folder and referenced through a file:/// URL. ForgeFlow also refreshes the relevant Unraid icon cache after recreating the container.</small></div>
<div class="field full"><label>Healthcheck URL from this desktop (optional)</label><input id="profile-healthcheck" class="input" value="${attr(existing.healthcheckUrl || "")}" placeholder="http://unraid:1223/health"/></div>
<div class="field full"><label>Preserve server-only paths</label><input id="profile-preserve-paths" class="input" value="${attr((existing.preservePaths || [".env", "appdata", "data", "logs", "config", "compose.override.yml"]).join(", "))}"/><small>Push bundle never replaces these paths and only removes files previously managed by ForgeFlow.</small></div>
<label class="check-field"><input id="profile-manage-dockerman" type="checkbox" ${existing.manageDockerMan === true ? "checked" : ""}/><span>Manage a generated DockerMan template</span><small>Existing/imported DockerMan templates are always preserved. This applies only to ForgeFlow-generated Compose deployments.</small></label>
<label class="check-field"><input id="profile-force-recreate" type="checkbox" disabled/><span>Destructive force-recreate disabled</span><small>ForgeFlow builds first and lets Compose replace only services whose image or configuration actually changed.</small></label>
<label class="check-field"><input id="profile-remove-orphans" type="checkbox" disabled/><span>Orphan removal disabled</span><small>ForgeFlow never removes unrelated or orphaned containers during a deployment.</small></label>
`
: `
<div class="field"><label>Deploy workflow file</label><input id="profile-workflow" class="input" value="${attr(existing.workflowFile || "deploy.yml")}" /></div>
<div class="field full"><label>Rollback workflow file (optional)</label><input id="profile-rollback-workflow" class="input" value="${attr(existing.rollbackWorkflowFile || "")}" placeholder="rollback.yml" /></div>
<div class="field full"><label>Application status URL</label><input id="profile-status-url" class="input" value="${attr(existing.statusUrl || "")}" required placeholder="https://app.example.com/.well-known/forgeflow" /></div>
<div class="field full"><label>Healthcheck URL (optional)</label><input id="profile-healthcheck" class="input" value="${attr(existing.healthcheckUrl || "")}" placeholder="https://app.example.com/health" /></div>`
}<label class="check-field full"><input id="profile-confirmation" type="checkbox" ${existing.confirmationRequired !== false ? "checked" : ""}/><span>Require an explicit confirmation before deployment</span></label></div><div class="notice" style="margin-top:13px">${icon("shield")}${ssh ? "Server pull fetches the exact selected Gitea commit with a repository-scoped read-only key, validates Compose and services, then promotes atomically with rollback protection." : "ForgeFlow sends only controlled workflow inputs: environment, exact SHA and a unique request ID."}</div></div><footer class="modal-footer">${existing.id ? `<button class="button danger" data-action="delete-deployment-profile" data-profile-id="${attr(existing.id)}">Delete</button>` : ""}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-deployment-profile" data-profile-id="${attr(existing.id || "")}" ${ssh && !servers.length ? "disabled" : ""}>Save environment</button></footer></section></div>`;
}
if (ui.modal.type === "inventory-review-plan") {
const plan = ui.inventoryReviewPlan;
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true" aria-labelledby="inventory-review-title"><header class="modal-header"><h2 id="inventory-review-title">Review inventory decision</h2><button class="icon-button" data-action="close-modal" aria-label="Close inventory review">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero">${icon("shield")}<div><strong>${escapeHtml(plan?.action || "Review")}</strong><span>${escapeHtml(plan?.workloadId || "")} · ${escapeHtml(plan?.classification || "unclassified")}</span></div></div><div class="confirm-grid"><span>Evidence hash</span><strong class="mono">${escapeHtml(plan?.evidenceHash || "")}</strong><span>Configuration change</span><strong>${escapeHtml(plan?.configurationChanges?.join("; ") || "None")}</strong><span>Containers</span><strong>${plan?.containersUnaffected ? "Unaffected" : "Review required"}</strong><span>Recovery</span><strong>${escapeHtml(plan?.recovery || "")}</strong><span>Reason</span><strong>${escapeHtml(plan?.reason || "Not supplied")}</strong></div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="apply-inventory-review">Apply reviewed decision</button></footer></section></div>`;
}
if (ui.modal.type === "deploy-key-lifecycle") {
const lifecycle = ui.deployKeyLifecycle;
const inventory = lifecycle?.inventory;
const rotation = lifecycle?.rotation;
const revocation = lifecycle?.revocation;
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true" aria-labelledby="deploy-key-title"><header class="modal-header"><h2 id="deploy-key-title">Deploy key lifecycle</h2><button class="icon-button" data-action="close-modal" aria-label="Close deploy key lifecycle">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero ${inventory?.ready ? "" : "danger"}">${icon(inventory?.ready ? "shield" : "warning")}<div><strong>${inventory?.ready ? "Repository access is verified" : "Deploy key requires review"}</strong><span>${escapeHtml(inventory?.repository || "")} · ${escapeHtml(inventory?.server?.name || "server")}</span></div></div><div class="confirm-grid"><span>Key ID</span><strong>${escapeHtml(inventory?.configuredKey?.id || "Missing")}</strong><span>Fingerprint</span><strong class="mono">${escapeHtml(inventory?.serverKey?.fingerprint || "Unavailable")}</strong><span>Rights</span><strong>${inventory?.configuredKey?.readOnly ? "Repository-scoped · read-only" : "Unverified or writable"}</strong><span>Stale</span><strong>${inventory?.stale ? "Yes · blocked" : "No"}</strong><span>Shared references</span><strong>${inventory?.shared?.length || 0}</strong><span>Orphaned Gitea keys</span><strong>${inventory?.orphaned?.length || 0}</strong></div><section class="settings-group" style="margin-top:16px"><h3>Rotation impact</h3><ul>${(rotation?.impact || []).map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul><p>${escapeHtml(rotation?.recovery || "")}</p><small class="mono">Plan ${escapeHtml(rotation?.id || "unavailable")}</small></section><section class="settings-group"><h3>Revocation impact</h3><ul>${(revocation?.impact || []).map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul><p>Containers remain untouched. Server pull changes to monitoring-only until restored.</p><small class="mono">Plan ${escapeHtml(revocation?.id || "unavailable")}</small></section></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button" data-action="restore-deploy-key" data-profile-id="${attr(lifecycle?.profileId || "")}">Restore</button><button class="button danger" data-action="confirm-revoke-deploy-key" data-profile-id="${attr(lifecycle?.profileId || "")}" ${revocation?.id ? "" : "disabled"}>Revoke key</button><button class="button primary" data-action="confirm-rotate-deploy-key" data-profile-id="${attr(lifecycle?.profileId || "")}" ${rotation?.id ? "" : "disabled"}>Rotate safely</button></footer></section></div>`;
}
if (ui.modal.type === "deployment-preflight") {
const profile =
repository?.deploymentProfiles?.find(
(item) => item.id === ui.modal.profileId,
) || selectedProfile(repository);
const report = ui.deploymentPreflight;
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Deployment preflight</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero ${report?.summary?.ready ? "" : "danger"}">${icon(report?.summary?.ready ? "shield" : "error")}<div><strong>${report?.summary?.ready ? "Environment is ready to test" : "Deployment is blocked"}</strong><span>${escapeHtml(repository?.fullName || "")}${escapeHtml(profile?.environment || "")}</span></div></div><div class="preflight-summary"><span class="status-pill ${report?.summary?.ready ? "success" : "danger"}">${report?.summary?.ready ? "Ready" : `${report?.summary?.blocking?.length || 0} blocking`}</span><span>${report?.summary?.counts?.pass || 0} passed · ${report?.summary?.counts?.warning || 0} warnings · ${report?.summary?.counts?.fail || 0} failed</span></div>${renderPreflightChecks(report)}</div><footer class="modal-footer"><button class="button" data-action="close-modal">Close</button>${report?.summary?.ready ? `<button class="button success" data-action="continue-after-preflight" data-profile-id="${attr(profile?.id || "")}">${icon("rocket")}Continue</button>` : `<button class="button" data-action="edit-deployment-profile" data-profile-id="${attr(profile?.id || "")}">Edit environment</button>`}</footer></section></div>`;
}
if (ui.modal.type === "deploy-confirm") {
const profile =
repository?.deploymentProfiles?.find(
(item) => item.id === ui.modal.profileId,
) || selectedProfile(repository);
const targetSha = deploymentTargetSha(repository, profile);
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Confirm production action</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero">${icon("rocket")}<div><strong>Deploy ${escapeHtml(shortSha(targetSha))}${escapeHtml(profile.environment)}</strong><span>${escapeHtml(repository.fullName)}</span></div></div><div class="confirm-grid"><span>Exact commit</span><strong class="mono">${escapeHtml(targetSha || "Unavailable")}</strong><span>Branch</span><strong>${escapeHtml(profile.branch)}</strong><span>Provider</span><strong>${profile.provider === "ssh-unraid" ? `${deploymentMode(profile) === "server-git" ? "Gitea → Unraid" : "Desktop → Unraid"} · ${escapeHtml(profile.remoteFolder)}` : escapeHtml(profile.workflowFile)}</strong><span>Healthcheck</span><strong>${escapeHtml(profile.healthcheckUrl || "Not configured")}</strong></div>${ui.deploymentPreflight ? `<div class="notice success" style="margin-top:12px">${icon("shield")}Preflight passed with ${ui.deploymentPreflight.summary.counts.warning} warning(s). Backend safety checks run again at dispatch time.</div>` : ""}</div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button success" data-action="confirm-deploy" data-profile-id="${attr(profile.id)}" ${targetSha ? "" : "disabled"}>Deploy exact commit</button></footer></section></div>`;
}
if (ui.modal.type === "rollback-confirm") {
const profile = repository?.deploymentProfiles?.find(
(item) => item.id === ui.modal.profileId,
);
const target = profile?.state?.previousSha;
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Confirm rollback</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero danger">${icon("undo")}<div><strong>Rollback ${escapeHtml(profile?.environment || "")} to ${shortSha(target)}</strong><span>The target must still exist on origin/${escapeHtml(profile?.branch || "")}.</span></div></div><div class="confirm-grid"><span>Target commit</span><strong class="mono">${escapeHtml(target || "Unavailable")}</strong><span>Provider</span><strong>${profile?.provider === "ssh-unraid" ? "SSH exact-SHA reset" : escapeHtml(profile?.rollbackWorkflowFile || "Not configured")}</strong><span>Current live</span><strong class="mono">${escapeHtml(profile?.state?.liveSha || "Unknown")}</strong></div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button danger" data-action="confirm-rollback" data-profile-id="${attr(profile?.id || "")}" ${target ? "" : "disabled"}>Rollback exact commit</button></footer></section></div>`;
}
if (ui.modal.type === "server-config") {
const server =
(ui.boot.state.servers || []).find(
(item) => item.id === ui.modal.serverId,
) || {};
const authType = ui.modal.authType || server.authType || "password";
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>${server.id ? "Edit" : "Add"} SSH / Unraid server</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="form-grid"><div class="field"><label>Name</label><input id="server-name" class="input" value="${attr(server.name || "Unraid")}"/></div><div class="field"><label>Host or IP</label><input id="server-host" class="input" value="${attr(server.host || "")}" placeholder="192.168.1.10"/></div><div class="field"><label>SSH port</label><input id="server-port" class="input" type="number" min="1" max="65535" value="${attr(server.port || 22)}"/></div><div class="field"><label>Username</label><input id="server-username" class="input" value="${attr(server.username || "root")}"/></div><div class="field"><label>Authentication</label><select id="server-auth-type" class="select"><option value="privateKey" ${authType === "privateKey" ? "selected" : ""}>Private key · optional</option><option value="password" ${authType === "password" ? "selected" : ""}>Password · no key</option></select></div><div class="field"><label>Appdata base path</label><input id="server-base-path" class="input" value="${attr(server.basePath || "/mnt/user/appdata")}"/></div><div class="field full"><label>Inventory scan roots</label><textarea id="server-scan-roots" class="input" rows="3" placeholder="One absolute server path per line">${escapeHtml((server.scanRoots || [server.basePath || "/mnt/user/appdata"]).join("\n"))}</textarea><small>ForgeFlow scans only these roots and never changes containers during discovery.</small></div><div class="field full"><label>Excluded folder names</label><input id="server-scan-excludes" class="input" value="${attr((server.scanExcludes || ["backups", "archives", "releases", "staging", "testdata"]).join(", "))}"/><small>Comma-separated directory names or safe wildcard patterns.</small></div>${authType === "privateKey" ? `<div class="field full"><label>Private key file</label><div class="input-action"><input id="server-private-key" class="input" value="${attr(server.privateKeyPath || "")}" placeholder="C:\\Users\\Jens\\.ssh\\id_ed25519"/><button class="button" data-action="select-private-key">Browse</button></div></div><div class="field full"><label>Private key passphrase</label><input id="server-passphrase" class="input" type="password" placeholder="${server.hasPassphrase ? "Leave empty to keep stored passphrase" : "Only when the key is encrypted"}"/></div>` : `<div class="field full"><label>SSH password</label><input id="server-password" class="input" type="password" placeholder="${server.hasPassword ? "Leave empty to keep stored password" : "Password"}"/></div>`}<div class="field full"><label>Trusted host fingerprint</label><input id="server-fingerprint" class="input mono" value="${attr(server.hostFingerprint || "")}" readonly placeholder="Filled automatically after Test & trust"/></div></div><div class="notice warning" style="margin-top:12px">${icon("key")}This login secures the desktop → Unraid connection. Server pull separately creates one read-only deploy key per repository and pins the Gitea SSH host key. No reusable Gitea token is stored on Unraid.</div></div><footer class="modal-footer">${server.id ? `<button class="button danger" data-action="delete-server" data-server-id="${attr(server.id)}">Delete</button>` : ""}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-server" data-server-id="${attr(server.id || "")}">Save server</button></footer></section></div>`;
}
if (ui.modal.type === "hunk-staging") {
const hunks = ui.diffHunks?.hunks || [];
return `<div class="modal-backdrop"><section class="modal wide-modal"><header class="modal-header"><h2>Stage selected hunks</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><p>${escapeHtml(ui.selectedFile)}</p><div class="stack">${hunks.map((hunk) => `<label class="check-field"><input type="checkbox" data-hunk-index="${hunk.index}" checked/><span><strong>${escapeHtml(hunk.heading)}</strong><small>${hunk.additions} additions · ${hunk.deletions} deletions</small></span></label><pre class="log-lines">${escapeHtml(hunk.lines.join("\n"))}</pre>`).join("")}</div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="stage-chosen-hunks">Stage selected hunks</button></footer></section></div>`;
}
if (ui.modal.type === "pull-request") {
return `<div class="modal-backdrop"><section class="modal"><header class="modal-header"><h2>Create Gitea pull request</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="form-grid"><div class="field"><label>Source branch</label><input class="input" value="${attr(repository?.localStatus?.branch?.head || "")}" readonly/></div><div class="field"><label>Target branch</label><input id="pr-base" class="input" value="${attr(repository?.defaultBranch || "main")}"/></div><div class="field full"><label>Title</label><input id="pr-title" class="input" value="${attr(ui.modal.title || "")}"/></div><div class="field full"><label>Description</label><textarea id="pr-body" class="textarea">${escapeHtml(ui.modal.body || "")}</textarea></div></div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="create-pull-request">Create pull request</button></footer></section></div>`;
}
if (ui.modal.type === "conflict-guide") {
const state = ui.conflictState || {};
return `<div class="modal-backdrop"><section class="modal"><header class="modal-header"><h2>Conflict guide · ${escapeHtml(state.operation || "Git")}</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body">${state.files?.length ? `<p>Resolve these files, then continue:</p><div class="tool-list">${state.files.map((file) => `<div class="tool-row"><strong>${escapeHtml(file)}</strong><button class="button" data-action="open-conflict-file" data-path="${attr(file)}">Open in editor</button></div>`).join("")}</div>` : '<div class="notice success">All conflicts are marked resolved. You can continue the Git operation.</div>'}</div><footer class="modal-footer"><button class="button danger" data-action="abort-git-operation">Abort operation</button><span class="modal-spacer"></span><button class="button" data-action="close-modal">Close</button><button class="button primary" data-action="continue-git-operation" ${state.canContinue ? "" : "disabled"}>Continue</button></footer></section></div>`;
}
if (ui.modal.type === "command-palette") return renderCommandPalette();
return "";
}
function paletteCommands() {
const repository = selectedRepository();
return [
{
id: "overview",
label: "Go to release overview",
detail: "Workspace",
icon: "overview",
enabled: true,
},
{
id: "refresh",
label: "Refresh all repositories",
detail: "Local and Gitea",
icon: "refresh",
enabled: true,
},
{
id: "deployments",
label: "Open deployments",
detail: "Release history",
icon: "deploy",
enabled: true,
},
{
id: "diagnostics",
label: "Open diagnostics",
detail: "Logs, preflight and support bundle",
icon: "shield",
enabled: true,
},
{
id: "settings",
label: "Open settings",
detail: "Connections and awareness",
icon: "settings",
enabled: true,
},
{
id: "open-folder",
label: "Open selected project folder",
detail: repository?.name || "No repository selected",
icon: "folder",
enabled: Boolean(repository?.localPath),
},
{
id: "git-tools",
label: "Open branch and stash tools",
detail: repository?.name || "No repository selected",
icon: "branch",
enabled: Boolean(repository?.localPath),
},
{
id: "deploy-selected",
label: "Deploy selected repository",
detail: canDeploy(repository)
? `${repository.name} ${shortSha(deploymentTargetSha(repository))}`
: "Not ready",
icon: "rocket",
enabled: canDeploy(repository),
},
];
}
function renderCommandPalette() {
const query = ui.paletteQuery.toLowerCase();
const commands = paletteCommands().filter(
(command) =>
!query ||
`${command.label} ${command.detail}`.toLowerCase().includes(query),
);
return `<div class="modal-backdrop palette-backdrop"><section class="command-palette"><div class="palette-search">${icon("search")}<input id="palette-input" value="${attr(ui.paletteQuery)}" placeholder="Type a command…" autofocus/></div><div class="palette-list">${commands.map((command) => `<button class="palette-row" data-action="run-command" data-command="${command.id}" ${command.enabled ? "" : "disabled"}>${icon(command.icon)}<span><strong>${escapeHtml(command.label)}</strong><small>${escapeHtml(command.detail)}</small></span><kbd>↵</kbd></button>`).join("")}</div><div class="palette-footer">Esc to close · Ctrl K anywhere</div></section></div>`;
}
function enhanceRenderedUi() {
const repository = selectedRepository();
if (ui.modal?.type === "deployment-config") {
const profile =
repository?.deploymentProfiles?.find(
(item) => item.id === ui.modal.profileId,
) || {};
const policy = profile.deploymentPolicy || {};
document
.querySelector(".modal-body .form-grid")
?.insertAdjacentHTML(
"beforeend",
`<div class="field full"><h3>Deployment policy</h3></div><label class="check-field"><input id="profile-policy-frozen" type="checkbox" ${policy.frozen ? "checked" : ""}/><span>Freeze deployments</span></label><label class="check-field"><input id="profile-policy-note" type="checkbox" ${policy.requireNote ? "checked" : ""}/><span>Require release note</span></label><div class="field full"><label>Freeze reason</label><input id="profile-policy-freeze-reason" class="input" value="${attr(policy.freezeReason || "")}"/></div><div class="field full"><label>Maintenance windows</label><input id="profile-policy-windows" class="input" value="${attr((policy.maintenanceWindows || []).map((window) => `${window.days.join(",")}:${window.start}-${window.end}`).join(" | "))}" placeholder="1,2,3,4,5:09:00-17:00"/><small>Day 0 is Sunday. Separate windows with |.</small></div>`,
);
}
if (ui.modal?.type === "workload-link") {
const workload = (ui.serverDiscovery || []).find((server) => server.serverId === ui.modal.serverId)?.workloads?.find((item) => item.workloadId === ui.modal.workloadId);
const type = workload?.classification?.type || "ambiguous";
const recommended = type === "duplicate" ? "select-authoritative" : type === "stale-link" ? "archive-link" : type === "historical-compose" ? "mark-historical" : type === "orphan-container" ? "monitor-only" : "manual-link";
const actions = [["manual-link", "Confirm selected repository match"], ["select-authoritative", "Select as authoritative instance"], ["mark-historical", "Mark historical definition"], ["archive-link", "Archive stale link"], ["monitor-only", "Keep for monitoring only"], ["manual-exclude", "Exclude this workload"], ["ignore", "Ignore with reason"]];
const options = actions.map(([value, label]) => `<option value="${value}" ${value === recommended ? "selected" : ""}>${escapeHtml(label)}${value === recommended ? " · recommended" : ""}</option>`).join("");
document.querySelector(".modal-body")?.insertAdjacentHTML("beforeend", `<section class="settings-group" style="margin-top:14px"><h3>Classify without touching containers</h3><div class="notice" style="margin-bottom:10px">${icon("info")}<div><strong>${escapeHtml(type)}</strong><p>${escapeHtml(workload?.classification?.reason || "ForgeFlow needs an explicit decision for this workload.")}</p></div></div><div class="form-grid"><div class="field"><label for="inventory-review-action">Review decision</label><select id="inventory-review-action" class="select">${options}</select></div><div class="field"><label for="inventory-review-reason">Reason</label><input id="inventory-review-reason" class="input" placeholder="Why is this the correct classification?"/></div></div><button class="button" style="margin-top:10px" data-action="preview-inventory-review" data-server-id="${attr(ui.modal.serverId)}" data-workload-id="${attr(ui.modal.workloadId)}">${icon("shield")}Preview classification impact</button><p class="meta">The decision is tied to current evidence and becomes stale automatically when server truth changes.</p></section>`);
}
if (ui.modal?.type === "deploy-confirm") {
const profile = repository?.deploymentProfiles?.find(
(item) => item.id === ui.modal.profileId,
);
document
.querySelector(".modal-body")
?.insertAdjacentHTML(
"beforeend",
`<div class="form-grid" style="margin-top:14px"><div class="field full"><label>Release note ${profile?.deploymentPolicy?.requireNote ? "(required)" : "(optional)"}</label><textarea id="deployment-note" class="textarea" placeholder="What is being released and why?"></textarea></div><label class="check-field"><input id="deployment-override" type="checkbox"/><span>Emergency policy override</span></label><div class="field"><label>Override reason</label><input id="deployment-override-reason" class="input" placeholder="Required when overriding"/></div></div>`,
);
}
if (ui.currentView === "diagnostics") {
const container = document.querySelector(".diagnostics-page");
container?.insertAdjacentHTML(
"beforeend",
`<section class="section-block"><div class="section-heading"><div><h2>Operational audit log</h2><span class="meta">Append-only release, pull-request and recovery events</span></div><div class="stack horizontal compact"><button class="button" data-action="load-audit-log">Refresh</button><button class="button" data-action="export-audit-json">Export JSON</button><button class="button" data-action="export-audit-csv">Export CSV</button></div></div><div class="panel">${ui.auditEvents.length ? `<table class="data-table"><thead><tr><th>Time</th><th>Event</th><th>Repository</th><th>Result</th></tr></thead><tbody>${ui.auditEvents.map((item) => `<tr><td>${formatDate(item.timestamp)}</td><td>${escapeHtml(item.event)}</td><td>${escapeHtml(item.details?.repository || "—")}</td><td>${escapeHtml(item.details?.result || item.details?.note || "—")}</td></tr>`).join("")}</tbody></table>` : '<div class="empty-state compact"><p>Load the operational audit log.</p></div>'}</div></section>`,
);
}
document.querySelectorAll("button.icon-button:not([aria-label])").forEach((button) => {
const action = String(button.title || button.dataset.action || "Action").replaceAll("-", " ");
button.setAttribute("aria-label", action.charAt(0).toUpperCase() + action.slice(1));
});
document.querySelectorAll(".field > label:not([for])").forEach((label, index) => {
const control = label.parentElement?.querySelector("input, select, textarea");
if (!control) return;
if (!control.id) control.id = `forgeflow-field-${index}`;
label.htmlFor = control.id;
});
document.querySelectorAll("input:not([aria-label]), select:not([aria-label]), textarea:not([aria-label])").forEach((control) => {
if (control.labels?.length) return;
const fallback = String(control.placeholder || control.name || control.id || "Form control").replaceAll("-", " ").trim();
control.setAttribute("aria-label", fallback.charAt(0).toUpperCase() + fallback.slice(1));
});
}
function render() {
if (!ui.boot) return;
const repository = selectedRepository();
const main =
ui.currentView === "overview"
? renderOverview()
: ui.currentView === "deployments"
? renderDeployments()
: ui.currentView === "settings"
? renderSettings()
: ui.currentView === "diagnostics"
? renderDiagnostics()
: ui.currentView === "deployment-run"
? renderPipelineView()
: repository
? renderRepositoryWorkspace(repository)
: renderOverview();
const withPanel = ui.currentView === "repository" && repository;
app.innerHTML = `<div class="app-shell">${renderTitlebar()}<div class="app-body">${renderSidebar()}<main class="workspace ${withPanel ? "with-panel" : ""}"><section class="main-canvas ${withPanel ? "repository-canvas" : ""}">${main}</section>${withPanel ? renderActionPanel(repository) : ""}${ui.loading ? `<div class="loading-overlay"><div class="boot-screen"><div class="spinner"></div><strong>${escapeHtml(ui.loadingMessage || "Working…")}</strong></div></div>` : ""}</main></div>${renderStatusbar()}</div>${ui.boot.state.setupComplete ? "" : renderSetup()}${renderModal()}`;
enhanceRenderedUi();
if (ui.modal?.type === "command-palette")
requestAnimationFrame(() =>
document.querySelector("#palette-input")?.focus(),
);
}
+180
View File
@@ -0,0 +1,180 @@
app.addEventListener("click", async (event) => {
const target = event.target.closest("[data-action]");
if (!target) return;
const action = target.dataset.action;
let repository = selectedRepository();
if (target.dataset.repositoryId) {
const actionRepository = ui.repositories.find(
(item) => String(item.id) === String(target.dataset.repositoryId),
);
if (actionRepository) repository = actionRepository;
}
const handlers = [handleShellActions, handleInventoryActions, handleDeploymentProfileActions, handleDeploymentOperationActions, handleSetupAndSettingsActions, handleRecoveryActions, handleCommandActions];
for (const handler of handlers) if (await handler(event, target, action, repository)) return;
});
app.addEventListener("input", (event) => {
if (event.target.id === "global-search") {
ui.search = event.target.value;
scheduleInputRender();
} else if (event.target.id === "repo-filter") {
ui.repoSearch = event.target.value;
scheduleInputRender();
} else if (event.target.id === "commit-message") {
ui.commitMessage = event.target.value;
const repository = selectedRepository();
const hasSelection = Boolean(ui.selectedFiles.size || repository?.localStatus?.counts?.staged);
const ready = Boolean(hasSelection && ui.commitMessage.trim());
const blocker = !hasSelection
? "Select files or stage one or more hunks."
: ready
? ui.selectedFiles.size
? "Ready to commit. ForgeFlow stages the selected files automatically."
: "Ready to commit only the reviewed staged hunks."
: "Enter a commit message to enable commit and push.";
const readiness = document.querySelector(".commit-readiness");
if (readiness) {
readiness.classList.toggle("ready", ready);
readiness.classList.toggle("blocked", !ready);
readiness.innerHTML = `${icon(ready ? "check" : "warning")}<span>${escapeHtml(blocker)}</span>`;
}
for (const button of document.querySelectorAll('[data-action="commit-push"], [data-action="commit-only"]')) {
button.disabled = !ready;
if (ready) button.removeAttribute("title");
else button.title = blocker;
}
} else if (event.target.id === "setup-url")
ui.setupDraft.baseUrl = event.target.value;
else if (event.target.id === "setup-token")
ui.setupDraft.token = event.target.value;
else if (event.target.id === "palette-input") {
ui.paletteQuery = event.target.value;
scheduleInputRender(60);
}
});
app.addEventListener("change", async (event) => {
if (event.target.matches("[data-file-select]")) {
const filePath = event.target.dataset.fileSelect;
if (event.target.checked) ui.selectedFiles.add(filePath);
else ui.selectedFiles.delete(filePath);
render();
} else if (event.target.id === "appearance-select") {
ui.boot.state = await window.forgeflow.setAppearance(event.target.value);
applyTheme(event.target.value);
render();
} else if (event.target.id === "action-profile-select") {
ui.selectedProfileId = event.target.value;
render();
} else if (event.target.id === "validator-policy") {
await handleShellActions(event, event.target, "git-validator-policy", selectedRepository());
} else if (event.target.id === "profile-provider") {
ui.modal.provider = event.target.value;
render();
} else if (event.target.id === "server-auth-type") {
ui.modal.authType = event.target.value;
render();
}
});
document.addEventListener("keydown", (event) => {
if (
(event.key === "Enter" || event.key === " ") &&
event.target.matches('.file-row[data-action="select-file"]')
) {
event.preventDefault();
event.target.click();
return;
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
ui.paletteQuery = "";
ui.modal = { type: "command-palette" };
render();
return;
}
if (
(event.ctrlKey || event.metaKey) &&
event.key === "Enter" &&
ui.currentView === "repository"
) {
const button = document.querySelector(
'[data-action="commit-push"]:not(:disabled)',
);
if (button) button.click();
}
if (event.key === "F5") {
event.preventDefault();
refreshRepositories(true);
}
if (event.key === "Escape" && ui.modal) {
ui.modal = null;
render();
}
});
let pointerAnimationFrame = null;
let pendingPointer = null;
document.addEventListener("pointermove", (event) => {
pendingPointer = { target: event.target, clientX: event.clientX, clientY: event.clientY };
if (pointerAnimationFrame) return;
pointerAnimationFrame = requestAnimationFrame(() => {
pointerAnimationFrame = null;
const current = pendingPointer;
pendingPointer = null;
if (!current) return;
const illustration = current.target.closest?.("[data-project-illustration]");
if (illustration) {
const bounds = illustration.getBoundingClientRect();
illustration.style.setProperty("--tilt-x", `${((current.clientY - bounds.top) / bounds.height - 0.5) * -7}deg`);
illustration.style.setProperty("--tilt-y", `${((current.clientX - bounds.left) / bounds.width - 0.5) * 9}deg`);
}
const diffPanel = current.target.closest?.(".diff-panel");
const atmosphere = diffPanel?.querySelector("[data-diff-atmosphere]");
if (atmosphere) {
const bounds = diffPanel.getBoundingClientRect();
atmosphere.style.setProperty("--diff-tilt-x", `${((current.clientY - bounds.top) / bounds.height - 0.5) * -3}deg`);
atmosphere.style.setProperty("--diff-tilt-y", `${((current.clientX - bounds.left) / bounds.width - 0.5) * 4}deg`);
}
});
});
document.addEventListener("pointerout", (event) => {
const illustration = event.target.closest?.("[data-project-illustration]");
if (illustration && !illustration.contains(event.relatedTarget)) {
illustration.style.removeProperty("--tilt-x");
illustration.style.removeProperty("--tilt-y");
}
const diffPanel = event.target.closest?.(".diff-panel");
if (diffPanel && !diffPanel.contains(event.relatedTarget)) {
const atmosphere = diffPanel.querySelector("[data-diff-atmosphere]");
atmosphere?.style.removeProperty("--diff-tilt-x");
atmosphere?.style.removeProperty("--diff-tilt-y");
}
});
window.addEventListener("error", (event) => {
window.forgeflow
.reportRendererEvent?.("error", "uncaught-error", {
message: event.message,
filename: event.filename,
line: event.lineno,
column: event.colno,
stack: event.error?.stack,
})
.catch(() => {});
});
window.addEventListener("unhandledrejection", (event) => {
const reason = event.reason;
window.forgeflow
.reportRendererEvent?.("error", "unhandled-rejection", {
message: reason?.message || String(reason || "Unknown rejection"),
stack: reason?.stack,
})
.catch(() => {});
});
bootstrap();
+13
View File
@@ -17,7 +17,20 @@
</div> </div>
</div> </div>
<div id="toast-root" class="toast-root" aria-live="assertive"></div> <div id="toast-root" class="toast-root" aria-live="assertive"></div>
<script src="mock-repository-bridge.js"></script>
<script src="mock-deployment-bridge.js"></script>
<script src="mock-bridge.js"></script> <script src="mock-bridge.js"></script>
<script defer src="app.js"></script> <script defer src="app.js"></script>
<script defer src="views.js"></script>
<script defer src="dialogs.js"></script>
<script defer src="operations.js"></script>
<script defer src="actions/shell.js"></script>
<script defer src="actions/inventory.js"></script>
<script defer src="actions/deployment-profile.js"></script>
<script defer src="actions/deployment-operation.js"></script>
<script defer src="actions/setup-and-settings.js"></script>
<script defer src="actions/recovery.js"></script>
<script defer src="actions/command.js"></script>
<script defer src="events.js"></script>
</body> </body>
</html> </html>
File diff suppressed because it is too large Load Diff
+698
View File
@@ -0,0 +1,698 @@
function createMockDeploymentBridge(context) {
const { wait, clone, iso, storage, repositoryListeners, operationListeners, updateListeners, emitRepositories, emitOperations, randomSha, now, profile, state, repositories, recompute, snapshot, commitHistory, advanceOperation, syncState, diffs, findRepo, findProfileRepo, branchesByRepo, stashesByRepo, updateOperation } = context;
return {
async saveDeploymentProfile(fullName, input) {
const repo = repositories.find((item) => item.fullName === fullName);
const existing = repo.deploymentProfiles.find(
(item) => item.id === input.id,
);
const saved = {
...(existing ||
profile(
input.id || `profile-${Date.now()}`,
input.name || input.environment,
input.environment || "production",
)),
...input,
id: input.id || `profile-${Date.now()}`,
provider: input.provider || existing?.provider || "gitea-actions",
inputs: existing?.inputs || {},
state: existing?.state || {
liveSha: null,
previousSha: null,
healthy: null,
healthConfigured: Boolean(input.healthcheckUrl),
statusConfigured: Boolean(input.statusUrl),
checkedAt: null,
},
};
repo.deploymentProfiles = [
...repo.deploymentProfiles.filter((item) => item.id !== saved.id),
saved,
];
snapshot();
return { profile: clone(saved), state: clone(state) };
},
async deleteDeploymentProfile(fullName, profileId) {
const repo = repositories.find((item) => item.fullName === fullName);
repo.deploymentProfiles = repo.deploymentProfiles.filter(
(item) => item.id !== profileId,
);
snapshot();
return { profiles: clone(repo.deploymentProfiles), state: clone(state) };
},
async deploymentPreflight(repository, profileId) {
await wait(280);
const profile = repository.deploymentProfiles.find(
(item) => item.id === profileId,
);
const status = repository.localStatus;
const checks = [
{
id: "repository.linked",
label: "Local repository link",
status: repository.localPath ? "pass" : "fail",
detail: repository.localPath || "No local folder linked.",
required: true,
},
{
id: "git.branch",
label: "Allowed branch",
status: status?.branch.head === profile?.branch ? "pass" : "fail",
detail: `Current: ${status?.branch.head || "unknown"}; required: ${profile?.branch || "unknown"}.`,
required: true,
},
{
id: "git.clean",
label: "Clean working tree",
status: status?.clean ? "pass" : "fail",
detail: status?.clean
? "No uncommitted changes."
: `${status?.counts.changed || 0} changed file(s).`,
required: true,
},
{
id: "git.sync",
label: "Local and Gitea synchronized",
status:
!status?.branch.ahead && !status?.branch.behind ? "pass" : "fail",
detail: `${status?.branch.ahead || 0} ahead, ${status?.branch.behind || 0} behind.`,
required: true,
},
{
id: "workflow.deploy.remote",
label: "Deploy workflow on Gitea branch",
status: "pass",
detail: `${profile?.workflowFile || "deploy.yml"} exists on ${profile?.branch || "main"}.`,
required: true,
},
{
id: "gitea.actions",
label: "Gitea Actions API",
status: "pass",
detail: "The Actions runs endpoint is accessible.",
required: true,
},
{
id: "server.status",
label: "Server version endpoint",
status: profile?.statusUrl ? "pass" : "warning",
detail: profile?.statusUrl
? `Endpoint reachable; live ${profile.state?.liveSha?.slice(0, 7) || "unknown"}.`
: "No status URL configured.",
required: false,
},
{
id: "server.health",
label: "Application healthcheck",
status: profile?.healthcheckUrl ? "pass" : "warning",
detail: profile?.healthcheckUrl
? "HTTP 200 in 42 ms."
: "No healthcheck URL configured.",
required: false,
},
];
const blocking = checks
.filter((i) => i.required && i.status === "fail")
.map((i) => i.id);
return {
kind: "deployment",
repository: repository.fullName,
profileId,
startedAt: iso(-100),
completedAt: iso(),
checks,
summary: {
counts: {
pass: checks.filter((i) => i.status === "pass").length,
warning: checks.filter((i) => i.status === "warning").length,
fail: checks.filter((i) => i.status === "fail").length,
skipped: 0,
},
blocking,
ready: blocking.length === 0,
},
head: status?.head || null,
};
},
async deploy(repository, profileId, sha) {
await wait(320);
const selected = repository.deploymentProfiles.find(
(item) => item.id === profileId,
);
const operation = {
id: `deploy-${Date.now()}`,
type: "deployment",
action: "deploy",
status: "queued",
repository: repository.fullName,
profileId,
profileName: selected.name,
environment: selected.environment,
workflowFile: selected.workflowFile,
branch: selected.branch,
sha,
shortSha: sha.slice(0, 7),
dispatchedAt: iso(),
createdAt: iso(),
updatedAt: iso(),
demoPolls: 0,
stages: [
{ id: "requested", label: "Requested", status: "complete" },
{ id: "verified", label: "Verified", status: "complete" },
{ id: "queued", label: "Workflow queued", status: "active" },
{ id: "runner", label: "Runner execution", status: "pending" },
{ id: "healthcheck", label: "Healthcheck", status: "pending" },
{ id: "complete", label: "Complete", status: "pending" },
],
logs: [
`[info] Verified clean ${selected.branch} at ${sha}`,
`[ok] Gitea accepted ${selected.workflowFile}.`,
],
};
return updateOperation(operation);
},
async rollback(repository, profileId, targetSha) {
await wait(320);
const selected = repository.deploymentProfiles.find(
(item) => item.id === profileId,
);
const operation = {
id: `rollback-${Date.now()}`,
type: "deployment",
action: "rollback",
status: "queued",
repository: repository.fullName,
profileId,
profileName: selected.name,
environment: selected.environment,
workflowFile: selected.rollbackWorkflowFile,
branch: selected.branch,
sha: targetSha,
shortSha: targetSha.slice(0, 7),
dispatchedAt: iso(),
createdAt: iso(),
updatedAt: iso(),
demoPolls: 0,
stages: [
{ id: "requested", label: "Requested", status: "complete" },
{ id: "verified", label: "Verified", status: "complete" },
{ id: "queued", label: "Workflow queued", status: "active" },
{ id: "runner", label: "Runner execution", status: "pending" },
{ id: "healthcheck", label: "Healthcheck", status: "pending" },
{ id: "complete", label: "Complete", status: "pending" },
],
logs: [
`[warning] Rollback target verified: ${targetSha}`,
`[ok] Gitea accepted ${selected.rollbackWorkflowFile}.`,
],
};
return updateOperation(operation);
},
async healthcheck() {
await wait(160);
return { configured: true, healthy: true, status: 200, latencyMs: 42 };
},
async refreshProfileState(fullName, profileId) {
await wait(240);
const repo =
repositories.find((item) => item.fullName === fullName) ||
findProfileRepo(profileId);
const target = repo?.deploymentProfiles.find(
(item) => item.id === profileId,
);
if (!target) throw new Error("Deployment profile not found.");
target.state = {
...target.state,
checkedAt: iso(),
healthy: target.state.healthy !== false,
healthConfigured: Boolean(target.healthcheckUrl),
statusConfigured: Boolean(target.statusUrl),
};
syncState();
return clone(target.state);
},
async discoverServerDeployments() {
await wait(80);
return [
{
serverId: "server-unraid",
serverName: "Unraid",
detected: 3,
adopted: 0,
verified: 1,
linked: 2,
unmatched: 0,
needsReview: 2,
running: 3,
stopped: 0,
capabilities: {
docker: true,
dockerReady: true,
compose: true,
git: false,
tar: true,
checksum: true,
},
warnings: [],
workloads: [
{
workloadId: "workload-demo-linked",
displayName: "Portfolio",
status: "linked",
runtime: { running: true, health: "healthy" },
compose: {
project: "portfolio",
workingDir: "/mnt/user/appdata/portfolio",
configFiles: ["/mnt/user/appdata/portfolio/docker-compose.yml"],
services: ["web"],
},
containers: [{ name: "Portfolio", running: true }],
candidates: [],
link: {
profileId: "profile-portfolio",
repositoryFullName: "jens/portfolio",
source: "manual",
},
},
{
workloadId: "workload-demo-review",
displayName: "OmniRoute",
status: "suggested",
runtime: { running: true, health: "unverified" },
compose: {
project: "omniroute",
workingDir: "/mnt/user/appdata/OmniRoute",
configFiles: ["/mnt/user/appdata/OmniRoute/docker-compose.yml"],
services: ["omniroute"],
},
containers: [{ name: "omniroute", running: true }],
remoteFolderCandidate: "OmniRoute",
candidates: repositories.slice(0, 1).map((repository) => ({
repositoryFullName: repository.fullName,
repositoryName: repository.name,
score: 55,
exact: false,
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",
},
},
],
},
];
},
async planServerReconciliation(serverId) {
await wait(90);
const id = "a".repeat(64);
return {
inventory: (await this.discoverServerDeployments()).find((item) => item.serverId === serverId),
plan: {
id,
serverId,
summary: { additions: 0, updates: 1, stale: 0, conflicts: 1 },
additions: [],
updates: [{ workloadId: "workload-demo-linked", profileId: "profile-portfolio", repositoryFullName: "jens/portfolio", impact: "Refresh detected Compose identity and observed deployment state" }],
stale: [],
conflicts: [{ workloadId: "workload-demo-review", displayName: "OmniRoute", status: "suggested", candidates: [{ repositoryFullName: repositories[0].fullName, score: 55, exact: false }] }],
},
};
},
async applyServerReconciliation(serverId, planId) {
await wait(120);
if (serverId !== "server-unraid" || planId !== "a".repeat(64)) throw new Error("The reconciliation plan is stale.");
return { adopted: 0, refreshed: 1, retired: 0, state: clone(state) };
},
async planInventoryReview(serverId, workloadId, action, reason = "", repositoryFullName = null) {
if (["ignore", "manual-exclude", "exclude-scan-root"].includes(action) && reason.length < 5) throw new Error("A meaningful review reason is required.");
return { id: "b".repeat(64), serverId, workloadId, action, reason, repositoryFullName, evidenceHash: "c".repeat(64), classification: "ambiguous", containersUnaffected: true, configurationChanges: [`Persist review decision ${action}`], recovery: "Remove the decision or rescan after evidence changes." };
},
async applyInventoryReview(serverId, workloadId, action, reason, repositoryFullName, planId) {
if (planId !== "b".repeat(64)) throw new Error("The inventory review plan is stale.");
const inventory = (await this.discoverServerDeployments()).find((item) => item.serverId === serverId);
const workload = inventory.workloads.find((item) => item.workloadId === workloadId);
if (workload) workload.reviewDecision = { action, reason, repositoryFullName, evidenceHash: "c".repeat(64) };
return { decision: workload?.reviewDecision, inventory, state: clone(state) };
},
async linkServerWorkload(repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "") {
await wait(120);
const repo = repositories.find((item) => item.fullName === repository.fullName);
if (!repo) throw new Error("Repository not found.");
const id = `profile-${workloadId}`;
const saved = {
id,
name: `Unraid · ${remoteFolder || repo.name}`,
environment: "production",
provider: "ssh-unraid",
branch: repo.defaultBranch || "main",
serverId,
remoteFolder: remoteFolder || repo.name,
deploymentMode,
composeFile: "docker-compose.yml",
composeFiles: ["docker-compose.yml"],
composeProject: String(remoteFolder || repo.name).toLowerCase(),
composeService: String(remoteFolder || repo.name).toLowerCase(),
composeServices: [String(remoteFolder || repo.name).toLowerCase()],
containerName: remoteFolder || repo.name,
preservePaths: [".env", "appdata", "data", "logs", "config"],
generatedCompose: false,
adoptedFromServer: true,
serverSourceOfTruth: true,
manageDockerMan: false,
forceRecreate: false,
removeOrphans: false,
workloadIdentity: { workloadId, linkSource: "manual", linkedAt: iso() },
confirmationRequired: true,
state: {
liveSha: null,
healthy: null,
containerRunning: true,
runtimeVerification: "running-unverified",
checkedAt: iso(),
},
};
repo.deploymentProfiles = [
...repo.deploymentProfiles.filter((item) => item.id !== id),
saved,
];
syncState();
return { profile: clone(saved), state: clone(state) };
},
async configureServerGitAccess(repository, profileId) {
const repo = repositories.find((item) => item.fullName === repository.fullName);
const target = repo?.deploymentProfiles.find((item) => item.id === profileId);
if (!target) throw new Error("Deployment profile not found.");
target.deploymentMode = "server-git";
target.serverGitAccess = { configured: true, keyFingerprint: "SHA256:demo", hostFingerprint: "SHA256:gitea", configuredAt: iso() };
syncState();
return { profile: clone(target), created: true, remoteSha: target.state?.giteaSha || repo.localStatus?.head };
},
async verifyServerGitProfile(repository, profileId) {
const repo = repositories.find((item) => item.fullName === repository.fullName);
const target = repo?.deploymentProfiles.find((item) => item.id === profileId);
if (!target) throw new Error("Deployment profile not found.");
const branchSha = target.state?.giteaSha || repo.localStatus?.head || null;
const liveSha = target.state?.liveSha || null;
return {
readiness: branchSha && liveSha === branchSha ? "Ready" : "Commit mismatch",
ready: true,
checkedAt: iso(),
repository: repo.fullName,
profileId,
branchSha,
liveSha,
checks: [
{ id: "remote-branch", label: "Gitea branch", status: "pass", detail: "Exact branch resolved." },
{ id: "deploy-key-scope", label: "Repository deploy key", status: "pass", detail: "Repository-scoped and read-only." },
{ id: "server-git-access", label: "Unraid to Gitea", status: "pass", detail: "Pinned SSH access verified." },
],
};
},
async deployKeyInventory(repository, profileId) {
const repo = repositories.find((item) => item.fullName === repository.fullName);
const profile = repo?.deploymentProfiles.find((item) => item.id === profileId);
return { repository: repo.fullName, profileId, server: { id: profile.serverId, name: "Unraid" }, configuredKey: { id: profile.serverGitAccess?.deployKeyId || 17, readOnly: true }, serverKey: { privateKeyPresent: true, fingerprint: profile.serverGitAccess?.keyFingerprint || "SHA256:demo" }, stale: false, orphaned: [], shared: [], conflicts: [], ready: true, checkedAt: iso() };
},
async planDeployKeyRotation(repository, profileId) {
const evidence = await this.deployKeyInventory(repository, profileId);
return { id: `rotation-${profileId}`, operation: "rotate-deploy-key", impact: ["Generate a new server-side key", "Verify read-only access", "Switch atomically", "Revoke the previous key"], recovery: "Previous access remains recoverable until verification succeeds.", evidence };
},
async applyDeployKeyRotation(repository, profileId) {
const repo = repositories.find((item) => item.fullName === repository.fullName); const profile = repo.deploymentProfiles.find((item) => item.id === profileId);
profile.serverGitAccess = { ...profile.serverGitAccess, configured: true, deployKeyId: 18, keyFingerprint: "SHA256:rotated", rotatedAt: iso() }; syncState(); return { profile: clone(profile), state: clone(state) };
},
async planDeployKeyRevocation(repository, profileId) {
const evidence = await this.deployKeyInventory(repository, profileId);
return { id: `revocation-${profileId}`, operation: "revoke-deploy-key", impact: ["Remove the repository key", "Disable server pull", "Preserve recovery material"], containersUnaffected: true, evidence };
},
async applyDeployKeyRevocation(repository, profileId) {
const repo = repositories.find((item) => item.fullName === repository.fullName); const profile = repo.deploymentProfiles.find((item) => item.id === profileId);
profile.deploymentMode = "monitor-only"; profile.serverGitAccess = { ...profile.serverGitAccess, configured: false, revokedAt: iso(), recoveryAvailable: true }; syncState(); return { profile: clone(profile), state: clone(state) };
},
async restoreDeployKey(repository, profileId) {
const repo = repositories.find((item) => item.fullName === repository.fullName); const profile = repo.deploymentProfiles.find((item) => item.id === profileId);
profile.deploymentMode = "server-git"; profile.serverGitAccess = { ...profile.serverGitAccess, configured: true, deployKeyId: 19, keyFingerprint: "SHA256:restored", restoredAt: iso() }; syncState(); return { profile: clone(profile), state: clone(state), proof: { ready: true } };
},
async refreshOperations(operationId = null) {
await wait(300);
if (operationId) {
const operation = state.operations.find(
(item) => item.id === operationId,
);
if (!operation) throw new Error("Operation not found.");
return updateOperation(advanceOperation(operation));
}
const active = state.operations
.filter(
(item) =>
!["success", "failed", "cancelled", "rolled-back"].includes(
item.status,
),
)
.map(advanceOperation);
if (active.length) emitOperations(active);
state.operations = state.operations.map(
(item) => active.find((entry) => entry.id === item.id) || item,
);
return clone(active);
},
async getOperation(operationId) {
return clone(
state.operations.find((item) => item.id === operationId) || null,
);
},
async gitValidatorScan(fullName) {
await wait(260);
const policy = state.gitValidatorPolicy || { id: "standard", label: "Standard", requiredScore: 70 };
const activeWarnings = 3;
return {
repository: fullName,
checkedAt: iso(),
score: 78,
grade: "Good",
policy,
ready: 78 >= policy.requiredScore && (policy.id === "minimal" || activeWarnings === 0),
commitSha: "8cbaf303aa3bb9b4023a7c89aa13fb70ce612847",
trend: { newlyFound: ["working-tree"], resolved: ["editorconfig"], regressions: [], suppressions: [] },
expiredSuppressions: [],
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 gitValidatorSetPolicy(_fullName, policy) {
const requiredScores = { minimal: 55, standard: 70, strict: 82, production: 90 };
state.gitValidatorPolicy = {
id: policy.id,
label: policy.id[0].toUpperCase() + policy.id.slice(1),
requiredScore: requiredScores[policy.id] || 70,
};
return clone(state.gitValidatorPolicy);
},
async gitValidatorSuppress(_fullName, suppression) {
return { ...suppression, id: `suppression-${Date.now()}`, createdAt: iso() };
},
async gitValidatorPreviewRepair(_fullName, check) {
return { checkId: check.id, action: check.fixAction, files: [".git/config"], diff: "+ reviewed configuration change\n", remoteMutation: check.fixAction === "protect-default-branch" };
},
async gitValidatorExport(fullName, format) {
return { extension: format === "markdown" ? "md" : format, mimeType: "text/plain", content: `# Git assurance — ${fullName}\n` };
},
async gitValidatorRepair() {
await wait(180);
return { repaired: true };
},
async diagnosticsStatus() {
return {
enabled: state.preferences.diagnosticsEnabled !== false,
level: state.preferences.diagnosticLevel,
retentionDays: state.preferences.logRetentionDays,
maxFileMb: state.preferences.maxLogFileMb,
directory: "<HOME>/AppData/Roaming/ForgeFlow/diagnostics",
fileCount: 2,
totalBytes: 18432,
totalSize: "18.0 KB",
latestAt: iso(-2000),
lastWriteError: null,
};
},
async exportConfigurationBackup() {
return {
filePath: "C:\\Downloads\\ForgeFlow-Configuration-demo.ffbackup",
};
},
async importConfigurationBackup() {
return { state: clone(state), exportedAt: iso(-86400000) };
},
async listAuditEvents() {
return [
{
id: "audit-1",
timestamp: iso(-60000),
event: "deployment.completed",
details: { repository: "Jens/ForgeFlow", result: "success" },
},
];
},
async exportAuditLog() {
return { filePath: "C:\\Downloads\\ForgeFlow-Audit-demo.json", count: 1 };
},
async clearDiagnostics() {
return {
enabled: true,
level: state.preferences.diagnosticLevel,
retentionDays: state.preferences.logRetentionDays,
maxFileMb: state.preferences.maxLogFileMb,
directory: "<HOME>/AppData/Roaming/ForgeFlow/diagnostics",
fileCount: 1,
totalBytes: 256,
totalSize: "256 B",
latestAt: iso(),
lastWriteError: null,
};
},
async openDiagnosticsFolder() {
return true;
},
async exportDiagnostics(privacyMode = "standard") {
await wait(500);
return {
path: `C:\Users\Jens\Downloads\ForgeFlow-Diagnostics-demo.zip`,
bytes: 38221,
size: "37.3 KB",
sha256: "b".repeat(64),
privacyMode,
generatedAt: iso(),
};
},
async showDiagnosticBundle() {
return true;
},
async reportRendererEvent() {
return true;
},
onRepositoriesChanged(listener) {
repositoryListeners.add(listener);
return () => repositoryListeners.delete(listener);
},
onOperationsChanged(listener) {
operationListeners.add(listener);
return () => operationListeners.delete(listener);
},
onUpdatesChanged(listener) {
updateListeners.add(listener);
return () => updateListeners.delete(listener);
},
async reset() {
state.setupComplete = false;
storage.set("forgeflow-demo-setup", "false");
return clone(state);
},
};
}
+635
View File
@@ -0,0 +1,635 @@
function createMockRepositoryBridge(context) {
const { wait, clone, iso, storage, repositoryListeners, operationListeners, updateListeners, emitRepositories, emitOperations, randomSha, now, profile, state, repositories, recompute, snapshot, commitHistory, advanceOperation, syncState, diffs, findRepo, findProfileRepo, branchesByRepo, stashesByRepo, updateOperation } = context;
return {
async bootstrap() {
await wait(80);
snapshot();
return {
appVersion: "0.10.5-demo",
platform: "win32",
state: clone(state),
git: { available: true, version: "git version 2.47.3" },
diagnostics: {
enabled: true,
level: state.preferences.diagnosticLevel,
retentionDays: state.preferences.logRetentionDays,
maxFileMb: state.preferences.maxLogFileMb,
directory: "<HOME>/AppData/Roaming/ForgeFlow/diagnostics",
fileCount: 2,
totalBytes: 18432,
totalSize: "18.0 KB",
latestAt: iso(-2000),
lastWriteError: null,
},
};
},
async selectDirectory() {
await wait();
return "C:\\Development";
},
async selectKeyFile() {
await wait();
return "C:\\Users\\Jens\\.ssh\\id_ed25519";
},
async setupPreflight({ baseUrl, token, roots = [] }) {
await wait(240);
const checks = [
{
id: "git.available",
label: "Git command line",
status: "pass",
detail: "git version 2.47.3",
required: true,
},
{
id: "git.identity",
label: "Git author identity",
status: "pass",
detail: "Jens <jens@example.invalid>",
required: false,
},
{
id: "storage.userdata",
label: "Application data storage",
status: "pass",
detail: "ForgeFlow can write its local configuration.",
required: true,
},
{
id: "storage.diagnostics",
label: "Diagnostic log storage",
status: "pass",
detail: "The diagnostic directory is writable.",
required: true,
},
{
id: "storage.credentials",
label: "Protected credential storage",
status: "pass",
detail: "The operating system can encrypt the Gitea token at rest.",
required: false,
},
{
id: "workspace.roots",
label: "Development folders",
status: roots.length ? "pass" : "warning",
detail: roots.length
? `${roots.length} folder(s) selected.`
: "No development folder selected yet.",
required: false,
},
{
id: "gitea.connection",
label: "Gitea connection",
status: baseUrl && token ? "pass" : "warning",
detail:
baseUrl && token
? "Connection parameters are ready for validation."
: "Enter the Gitea URL and token.",
required: false,
},
];
return {
kind: "system",
startedAt: iso(-100),
completedAt: iso(),
checks,
summary: {
counts: {
pass: checks.filter((i) => i.status === "pass").length,
warning: checks.filter((i) => i.status === "warning").length,
fail: 0,
skipped: 0,
},
blocking: [],
ready: true,
},
};
},
async validateGitea({ baseUrl, token }) {
await wait(320);
if (!baseUrl || !token)
throw new Error("Enter an instance URL and access token.");
return {
baseUrl: baseUrl.replace(/\/$/, ""),
user: { login: "jens", full_name: "Jens" },
repositoryCount: repositories.length,
version: "1.26.0",
};
},
async completeSetup(payload) {
await wait(300);
state.setupComplete = true;
state.gitea = {
baseUrl: payload.baseUrl,
user: payload.user,
hasToken: true,
};
state.workspaceRoots = payload.workspaceRoots;
storage.set("forgeflow-demo-setup", "true");
return { state: clone(state), tokenState: { persistent: true } };
},
async updateGitea(payload) {
const validation = await this.validateGitea({
...payload,
token: payload.token || "preserved-demo-token",
});
state.gitea = {
baseUrl: validation.baseUrl,
user: validation.user,
hasToken: true,
};
return {
validation,
tokenState: { persistent: true, preserved: !payload.token },
state: clone(state),
};
},
async setWorkspaceRoots(roots) {
state.workspaceRoots = [...new Set(roots)];
return clone(state);
},
async setAppearance(appearance) {
state.appearance = appearance;
storage.set("forgeflow-theme", appearance);
return clone(state);
},
async setPreferences(preferences) {
state.preferences = { ...state.preferences, ...preferences };
snapshot();
return clone(state);
},
async setUpdatePreferences(updates) {
state.updates = { ...state.updates, ...updates };
return clone(state);
},
async checkForUpdates() {
await wait(300);
return {
checkedAt: iso(),
owner: state.updates.owner,
repo: state.updates.repo,
branch: state.updates.branch,
currentVersion: "0.5.4",
remoteVersion: "0.6.0",
remoteSha: "a".repeat(40),
shortSha: "aaaaaaa",
available: true,
mode: "source",
};
},
async downloadUpdate() {
await wait(500);
return {
...(await this.checkForUpdates()),
downloaded: true,
archivePath: "C:\\Temp\\ForgeFlow-0.4.1.zip",
sha256: "b".repeat(64),
};
},
async applyUpdate() {
await wait(200);
return { launched: true, confirmed: true, version: "0.6.0" };
},
async saveServer(server) {
const saved = {
...server,
id: server.id || `server-${Date.now()}`,
hasPassword: server.authType === "password",
hasPassphrase: false,
};
state.servers = [
saved,
...state.servers.filter((item) => item.id !== saved.id),
];
return { server: clone(saved), state: clone(state) };
},
async deleteServer(serverId) {
state.servers = state.servers.filter((item) => item.id !== serverId);
return clone(state);
},
async testServer(serverId) {
const server = state.servers.find((item) => item.id === serverId);
server.hostFingerprint = server.hostFingerprint || "SHA256:demo";
return {
connected: true,
fingerprint: server.hostFingerprint,
server: clone(server),
output: "Linux\n/usr/bin/git\nDocker Compose version v2",
state: clone(state),
};
},
async inspectServerProject() {
return {
exists: true,
rootGit: true,
head: "d42d4a7".padEnd(40, "0"),
branch: "main",
trackedChanges: [],
composeFiles: ["docker-compose.yml"],
nestedGit: ["source"],
dockerfile: true,
};
},
async refreshRepositories() {
await wait(260);
return snapshot();
},
async discoverRepositories() {
await wait(360);
return snapshot()
.filter((repo) => repo.localPath)
.map((repo) => ({
localPath: repo.localPath,
remoteUrl: repo.cloneUrl,
status: repo.localStatus,
}));
},
async favoriteRepository(fullName, favorite) {
const key = fullName.toLowerCase();
state.favorites = favorite
? [...new Set([...state.favorites, key])]
: state.favorites.filter((item) => item !== key);
snapshot();
return clone(state);
},
async linkRepository(fullName, localPath) {
const repo = repositories.find((item) => item.fullName === fullName);
repo.localPath = localPath;
repo.linkState = "linked";
repo.localStatus = makeStatus({ head: randomSha() });
emitRepositories();
return snapshot();
},
async unlinkRepository(fullName) {
const repo = repositories.find((item) => item.fullName === fullName);
repo.localPath = null;
repo.localStatus = null;
repo.linkState = "remote-only";
emitRepositories();
return snapshot();
},
async repositoryStatus(localPath) {
return clone(findRepo(localPath)?.localStatus);
},
async repositoryDiff(localPath, filePath) {
await wait(80);
return (
diffs[filePath] ||
`diff --git a/${filePath} b/${filePath}\n--- a/${filePath}\n+++ b/${filePath}\n@@ -1 +1 @@\n-old\n+new`
);
},
async repositoryDiffHunks(localPath, filePath) {
const diff = await this.repositoryDiff(localPath, filePath);
return {
filePath,
partialSupported: true,
hunks: [
{
index: 0,
heading: "@@ -1 +1 @@",
additions: 1,
deletions: 1,
lines: diff.split("\n").slice(-4),
},
],
};
},
async stageHunks(localPath, filePath) {
return this.stageFiles(localPath, [filePath]);
},
async conflictState(localPath) {
const repo = findRepo(localPath);
const files = repo.localStatus.files
.filter((item) => item.conflict)
.map((item) => item.path);
return {
operation: files.length ? "merge" : null,
files,
canContinue: false,
status: clone(repo.localStatus),
};
},
async resolveConflict(localPath, filePath) {
const repo = findRepo(localPath);
const file = repo.localStatus.files.find(
(item) => item.path === filePath,
);
if (file) {
file.conflict = false;
file.staged = true;
file.unstaged = false;
}
recompute(repo);
return this.conflictState(localPath);
},
async continueGitOperation(localPath) {
return this.conflictState(localPath);
},
async abortGitOperation(localPath) {
return this.conflictState(localPath);
},
async stageFiles(localPath, files) {
const repo = findRepo(localPath);
repo.localStatus.files.forEach((item) => {
if (!files?.length || files.includes(item.path)) {
item.staged = true;
item.unstaged = false;
item.indexCode = item.untracked ? "A" : "M";
item.worktreeCode = ".";
}
});
recompute(repo);
emitRepositories();
return clone(repo.localStatus);
},
async unstageFiles(localPath, files) {
const repo = findRepo(localPath);
repo.localStatus.files.forEach((item) => {
if (!files?.length || files.includes(item.path)) {
item.staged = false;
item.unstaged = true;
item.indexCode = ".";
item.worktreeCode = item.untracked ? "?" : "M";
}
});
recompute(repo);
emitRepositories();
return clone(repo.localStatus);
},
async commit(localPath, message, files) {
await wait(520);
if (!message?.trim()) throw new Error("Enter a commit message.");
const repo = findRepo(localPath);
repo.localStatus.files = repo.localStatus.files.filter(
(item) => !files?.includes(item.path),
);
repo.localStatus.head = randomSha();
repo.localStatus.branch.ahead += 1;
recompute(repo);
emitRepositories();
return {
commitOutput: `[${repo.localStatus.branch.head} ${repo.localStatus.shortHead}] ${message}`,
commitSha: repo.localStatus.head,
status: clone(repo.localStatus),
};
},
async commitAndPush(localPath, message, files) {
const result = await this.commit(localPath, message, files);
const repo = findRepo(localPath);
await wait(240);
repo.localStatus.branch.ahead = 0;
recompute(repo);
emitRepositories();
return {
...result,
pushOutput: "Push completed.",
status: clone(repo.localStatus),
};
},
async commitStaged(localPath, message) {
const repo = findRepo(localPath);
return this.commit(
localPath,
message,
repo.localStatus.files
.filter((item) => item.staged)
.map((item) => item.path),
);
},
async commitStagedAndPush(localPath, message) {
const repo = findRepo(localPath);
return this.commitAndPush(
localPath,
message,
repo.localStatus.files
.filter((item) => item.staged)
.map((item) => item.path),
);
},
async push(localPath) {
await wait(360);
const repo = findRepo(localPath);
repo.localStatus.branch.ahead = 0;
recompute(repo);
emitRepositories();
return { output: "Push completed.", status: clone(repo.localStatus) };
},
async fetch() {
await wait(260);
return { output: "Fetch completed." };
},
async pull(localPath) {
await wait(380);
const repo = findRepo(localPath);
repo.localStatus.branch.behind = 0;
recompute(repo);
emitRepositories();
return { output: "Fast-forwarded.", status: clone(repo.localStatus) };
},
async history() {
await wait(100);
return clone(commitHistory);
},
async branchProtection(fullName, branch) {
return {
branch,
protected: branch === "main",
requiredApprovals: branch === "main" ? 1 : 0,
requireSignedCommits: false,
};
},
async pullRequests() {
return [
{
number: 42,
title: "Harden deployment preflight",
html_url: "https://gitea.internal/jens/vacancyradar/pulls/42",
created_at: iso(-7_200_000),
updated_at: iso(-900_000),
head: { ref: "feature/deployment-api" },
base: { ref: "main" },
},
];
},
async createPullRequest(fullName, title, body, base) {
return {
number: 42,
title,
body,
base,
html_url: `https://gitea.internal/${fullName}/pulls/42`,
};
},
async branches(localPath) {
const repo = findRepo(localPath);
if (!branchesByRepo.has(localPath))
branchesByRepo.set(localPath, [
{
name: repo.localStatus.branch.head,
current: true,
sha: repo.localStatus.head,
shortSha: repo.localStatus.shortHead,
upstream: repo.localStatus.branch.upstream,
},
{
name: "main",
current: repo.localStatus.branch.head === "main",
sha: repo.localStatus.head,
shortSha: repo.localStatus.shortHead,
upstream: "origin/main",
},
]);
return clone(branchesByRepo.get(localPath));
},
async checkoutBranch(localPath, branch) {
const repo = findRepo(localPath);
if (!repo.localStatus.clean)
throw new Error(
"Commit or stash local changes before switching branches.",
);
const list = await this.branches(localPath);
list.forEach((item) => {
item.current = item.name === branch;
});
branchesByRepo.set(localPath, list);
repo.localStatus.branch.head = branch;
repo.localStatus.branch.upstream = `origin/${branch}`;
recompute(repo);
emitRepositories();
return { status: clone(repo.localStatus), branches: clone(list) };
},
async createBranch(localPath, branch) {
const repo = findRepo(localPath);
const list = await this.branches(localPath);
list.forEach((item) => {
item.current = false;
});
list.unshift({
name: branch,
current: true,
sha: repo.localStatus.head,
shortSha: repo.localStatus.shortHead,
upstream: null,
});
branchesByRepo.set(localPath, list);
repo.localStatus.branch.head = branch;
repo.localStatus.branch.upstream = null;
recompute(repo);
emitRepositories();
return { status: clone(repo.localStatus), branches: clone(list) };
},
async stash(localPath, message) {
const repo = findRepo(localPath);
const list = stashesByRepo.get(localPath) || [];
list.unshift({
ref: `stash@{${list.length}}`,
subject: message || "ForgeFlow stash",
date: iso(),
});
stashesByRepo.set(localPath, list);
repo.localStatus.files = [];
recompute(repo);
emitRepositories();
return {
output: "Saved working directory and index state.",
status: clone(repo.localStatus),
stashes: clone(list),
};
},
async stashList(localPath) {
return clone(stashesByRepo.get(localPath) || []);
},
async popStash(localPath, ref) {
const repo = findRepo(localPath);
const list = stashesByRepo.get(localPath) || [];
const index = list.findIndex((item) => item.ref === ref);
if (index < 0) throw new Error("Stash not found.");
list.splice(index, 1);
stashesByRepo.set(localPath, list);
repo.localStatus.files = [makeFile("src/restored-from-stash.ts")];
recompute(repo);
emitRepositories();
return {
output: "Stash applied.",
status: clone(repo.localStatus),
stashes: clone(list),
};
},
async indexLockInfo() {
return { exists: false, ageMs: 0 };
},
async repairIndexLock() {
return { removed: true };
},
async setOrigin(localPath, remoteUrl) {
const repo = findRepo(localPath);
repo.localStatus.remoteUrl = remoteUrl;
repo.sshUrl = remoteUrl;
emitRepositories();
return clone(repo.localStatus);
},
async normalizeOrigins() {
const changes = [];
repositories
.filter((repo) => repo.localPath && repo.sshUrl)
.forEach((repo) => {
if (repo.localStatus.remoteUrl !== repo.sshUrl) {
changes.push({
fullName: repo.fullName,
previous: repo.localStatus.remoteUrl,
next: repo.sshUrl,
});
repo.localStatus.remoteUrl = repo.sshUrl;
}
});
emitRepositories();
return { changes, repositories: snapshot() };
},
async cloneRepository(fullName, mode = "default") {
await wait(620);
const repository = repositories.find(
(item) => item.fullName === fullName,
);
if (!repository) throw new Error("Repository not found.");
if (repository.localPath)
throw new Error("This repository already has a linked local folder.");
const root =
mode === "custom" ? "D:\\OtherProjects" : state.workspaceRoots[0];
if (!root) return { cancelled: true };
const target = `${root.replace(/[\\/]+$/, "")}\\${repository.name}`;
const head = randomSha();
repository.localPath = target;
repository.localStatus = makeStatus({
head,
branch: repository.defaultBranch || "main",
});
repository.localStatus.root = target;
repository.localStatus.remoteUrl =
repository.preferredCloneUrl || repository.cloneUrl;
repository.linkState = "linked";
recompute(repository);
const current = snapshot();
emitRepositories();
return {
target,
status: clone(repository.localStatus),
reused: false,
repositories: current,
state: clone(state),
};
},
async openPath() {
return true;
},
async openEditor() {
return { launched: true, executable: "code" };
},
async openTerminal() {
return { launched: true, executable: "wt.exe" };
},
async openExternal() {
return true;
},
};
}
+211
View File
@@ -0,0 +1,211 @@
async function runOperation(
message,
operation,
successMessage,
{ refresh = true } = {},
) {
setLoading(true, message);
try {
const result = await operation();
if (successMessage) showToast("Done", successMessage, "success");
if (refresh) await refreshRepositories(false);
return result;
} catch (error) {
const pushAfterCommit = error.code === "PUSH_AFTER_COMMIT_FAILED";
showToast(
pushAfterCommit
? "Commit saved locally; push failed"
: "Operation failed",
error.message,
"error",
);
// Always reload the real Git state. A failed stage must keep changes visible, while a
// failed push after a successful commit must immediately surface as an ahead branch.
await refreshRepositories(false, true);
if (pushAfterCommit) {
ui.selectedFiles.clear();
ui.selectedFile = null;
ui.diff = "";
ui.commitMessage = "";
render();
}
return null;
} finally {
setLoading(false);
}
}
async function executeDeployment(profileId) {
const repository = selectedRepository();
const profile =
repository?.deploymentProfiles?.find((item) => item.id === profileId) ||
selectedProfile(repository);
if (!repository || !profile) return;
const targetSha = deploymentTargetSha(repository, profile);
if (!targetSha) {
showToast("Refresh required", "Refresh Gitea and server truth before deploying this environment.", "error");
return;
}
const deploymentOptions = {
note: document.querySelector("#deployment-note")?.value.trim() || "",
override: document.querySelector("#deployment-override")?.checked === true,
overrideReason:
document.querySelector("#deployment-override-reason")?.value.trim() || "",
};
ui.modal = null;
setLoading(
true,
profile.provider === "ssh-unraid"
? `Deploying ${repository.name} to ${profile.remoteFolder} over SSH…`
: `Dispatching ${profile.name} workflow…`,
);
try {
ui.activeDeployment = await window.forgeflow.deploy(
repository,
profile.id,
targetSha,
deploymentOptions,
);
updateOperationInState(ui.activeDeployment);
ui.currentView = "deployment-run";
showToast(
"Deployment started",
`${repository.name} ${shortSha(targetSha)}${profile.environment}`,
"success",
);
startOperationPolling();
} catch (error) {
if (profile.provider === "ssh-unraid" && isSshCredentialError(error)) {
ui.modal = {
type: "server-password",
serverId: profile.serverId,
retry: {
type: "deploy",
repositoryFullName: repository.fullName,
profileId: profile.id,
},
};
showToast("SSH key rejected", "Enter the Unraid server password once; ForgeFlow will retry the direct desktop → Unraid connection.", "error");
render();
} else {
showToast("Deployment failed to start", error.message, "error");
}
}
setLoading(false);
}
async function executeRollback(profileId) {
const repository = selectedRepository();
const profile = repository?.deploymentProfiles?.find(
(item) => item.id === profileId,
);
const target = profile?.state?.previousSha;
if (!repository || !profile || !target) return;
ui.modal = null;
setLoading(
true,
profile?.provider === "ssh-unraid"
? `Rolling back ${profile.remoteFolder} over SSH…`
: `Dispatching rollback to ${shortSha(target)}`,
);
try {
ui.activeDeployment = await window.forgeflow.rollback(
repository,
profile.id,
target,
);
updateOperationInState(ui.activeDeployment);
ui.currentView = "deployment-run";
showToast(
"Rollback requested",
`${profile.environment}${shortSha(target)}`,
"success",
);
} catch (error) {
showToast("Rollback failed to start", error.message, "error");
}
setLoading(false);
}
async function loadGitTools(repository) {
if (!repository?.localPath) return;
setLoading(true, "Loading branches and stashes…");
try {
[ui.branches, ui.stashes, ui.gitRecovery] = await Promise.all([
window.forgeflow.branches(repository.localPath),
window.forgeflow.stashList(repository.localPath),
window.forgeflow.gitRecoveryStatus(repository.localPath),
]);
ui.repositoryTab = "gittools";
} catch (error) {
showToast("Git tools unavailable", error.message, "error");
}
setLoading(false);
}
function profileRepository(profileId) {
return ui.repositories.find((repository) =>
repository.deploymentProfiles?.some((profile) => profile.id === profileId),
);
}
async function runSystemPreflight({ setup = false } = {}) {
setLoading(true, "Checking local readiness…");
try {
ui.systemPreflight = await window.forgeflow.setupPreflight({
baseUrl: ui.setupDraft.baseUrl,
token: ui.setupDraft.token,
roots: setup ? ui.setupDraft.roots : ui.boot.state.workspaceRoots,
});
if (!setup)
ui.diagnosticsStatus = await window.forgeflow.diagnosticsStatus();
showToast(
ui.systemPreflight.summary.ready
? "Readiness checks passed"
: "Readiness needs attention",
ui.systemPreflight.summary.ready
? `${ui.systemPreflight.summary.counts.pass} checks passed.`
: `${ui.systemPreflight.summary.blocking.length} blocking check(s) must be resolved.`,
ui.systemPreflight.summary.ready ? "success" : "error",
);
return ui.systemPreflight;
} catch (error) {
showToast("Readiness check failed", error.message, "error");
return null;
} finally {
setLoading(false);
}
}
async function runDeploymentPreflight(
repository,
profileId,
{ showModal = true } = {},
) {
if (!repository || !profileId) return null;
if (String(repository.id) !== String(ui.selectedRepoId))
selectRepository(repository.id, false);
ui.selectedProfileId = profileId;
ui.deploymentPreflight = null;
setLoading(true, "Verifying repository, workflow and server…");
try {
const report = await window.forgeflow.deploymentPreflight(
repository,
profileId,
);
ui.deploymentPreflight = report;
if (showModal)
ui.modal = {
type: "deployment-preflight",
profileId,
repositoryFullName: repository.fullName,
};
return report;
} catch (error) {
showToast("Deployment preflight failed", error.message, "error");
return null;
} finally {
setLoading(false);
}
}
+91 -5
View File
@@ -849,6 +849,64 @@ select:focus-visible {
.release-node:last-child { .release-node:last-child {
border-right: 0; 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 { .release-node:not(:last-child)::after {
content: ""; content: "";
position: absolute; position: absolute;
@@ -937,6 +995,21 @@ select:focus-visible {
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
} }
.repo-content > .tab-page,
.repo-content > .validator-page {
height: 100%;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.repo-content > .validator-empty,
.repo-content > .empty-state {
max-height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
}
.changes-layout { .changes-layout {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
@@ -1286,7 +1359,7 @@ html[data-theme="light"] .diff-line.remove {
padding: 18px; padding: 18px;
display: grid; display: grid;
gap: 14px; gap: 14px;
overflow: auto; align-content: start;
} }
.validator-empty { .validator-empty {
min-height: 360px; min-height: 360px;
@@ -1771,8 +1844,8 @@ html[data-theme="light"] .validator-hero {
.deploy-card-grid { .deploy-card-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(min(100%, 365px), 1fr));
gap: 12px; gap: 14px;
} }
.deploy-card { .deploy-card {
--card-accent: var(--primary-strong); --card-accent: var(--primary-strong);
@@ -1842,6 +1915,7 @@ html[data-theme="light"] .validator-hero {
font-weight: 780; font-weight: 780;
} }
.container-identity > div { .container-identity > div {
flex: 1 1 auto;
min-width: 0; min-width: 0;
} }
.container-identity span:not(.container-avatar):not(.sync-proof), .container-identity span:not(.container-avatar):not(.sync-proof),
@@ -1858,6 +1932,11 @@ html[data-theme="light"] .validator-hero {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.container-identity small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sync-proof { .sync-proof {
margin-left: auto; margin-left: auto;
padding: 6px 8px; padding: 6px 8px;
@@ -2566,9 +2645,8 @@ kbd {
font: 11px var(--font-mono); font: 11px var(--font-mono);
} }
.tab-page { .tab-page {
min-height: 100%; min-height: 0;
padding: 18px 19px 42px; padding: 18px 19px 42px;
overflow: auto;
} }
.git-tools-grid { .git-tools-grid {
display: grid; display: grid;
@@ -2818,6 +2896,14 @@ kbd {
color: var(--text-faint); color: var(--text-faint);
font-size: 10px; font-size: 10px;
} }
.action-panel-body .panel-callout > h2,
.action-panel-body .panel-callout > p {
display: none;
}
@media (max-height: 760px) {
.palette-backdrop { padding-top: 3vh; }
.palette-list { max-height: calc(100vh - 150px); }
}
.discovery-progress { .discovery-progress {
min-height: 260px; min-height: 260px;
display: grid; display: grid;
+736
View File
@@ -0,0 +1,736 @@
function navButton(view, label, iconName, count = "") {
return `<button class="nav-button ${ui.currentView === view ? "active" : ""}" data-action="navigate" data-view="${view}">${icon(iconName)}<span>${label}</span>${count !== "" ? `<span class="nav-count">${count}</span>` : ""}</button>`;
}
function renderTitlebar() {
const state = ui.boot?.state;
const user = state?.gitea?.user;
const connected = Boolean(state?.gitea?.hasToken);
const repository = selectedRepository();
const title =
ui.currentView === "repository" && repository
? repository.fullName
: {
overview: "Release overview",
deployments: "Deployments",
diagnostics: "Diagnostics",
settings: "Settings",
"deployment-run": "Deployment run",
}[ui.currentView] || "Workspace";
return `<header class="titlebar">
<div class="titlebar-left"><div class="wordmark"><img class="brand-logo" src="./assets/itworx-mark.png" alt="ITWorx.tech"/><span>ForgeFlow</span><small>by ITWorx.tech</small></div><span class="workspace-name">${escapeHtml(title)}</span></div>
<div class="titlebar-right">
<button class="command-trigger" data-action="open-palette" aria-label="Open command palette">${icon("search")}<span>Commands</span><kbd>Ctrl K</kbd></button>
<div class="search-wrap">${icon("search")}<input id="global-search" class="global-search" value="${attr(ui.search)}" placeholder="Search repositories…" aria-label="Search repositories" /></div>
<span class="connection-chip" title="${connected ? `Connected as ${attr(user?.login || "user")}` : "Not connected"}"><span class="dot" style="${connected ? "" : "background:var(--danger)"}"></span>${connected ? escapeHtml(user?.login || "Gitea") : "Offline"}</span>
<button class="icon-button" data-action="refresh" title="Refresh repositories">${icon("refresh")}</button>
<button class="icon-button" data-action="toggle-theme" title="Toggle theme">${icon(document.documentElement.dataset.theme === "dark" ? "sun" : "moon")}</button>
</div>
</header>`;
}
function renderRepositoryRow(repository) {
const status = repository.localStatus;
const badges = [];
if (status?.counts.conflicts)
badges.push('<span class="mini-badge danger" title="Conflicts">!</span>');
else if (status?.counts.changed)
badges.push(
`<span class="mini-badge warning" title="Changed files">${status.counts.changed}</span>`,
);
if (status?.branch.ahead)
badges.push(
`<span class="mini-badge" title="Commits ahead">↑${status.branch.ahead}</span>`,
);
if (status?.branch.behind)
badges.push(
`<span class="mini-badge danger" title="Commits behind">↓${status.branch.behind}</span>`,
);
if (repository.readyToDeploy)
badges.push(
'<span class="mini-badge success" title="Ready to deploy">↗</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)}">
<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>
</button>`;
}
function renderSidebar() {
const query = `${ui.search} ${ui.repoSearch}`.trim().toLowerCase();
const repositories = ui.repositories.filter(
(repository) =>
!query ||
`${repository.name} ${repository.fullName} ${repository.description}`
.toLowerCase()
.includes(query),
);
const favorites = repositories.filter((repository) => repository.favorite);
const others = repositories.filter((repository) => !repository.favorite);
const attention = ui.repositories.filter(
(repository) =>
repository.attention ||
repository.localStatus?.counts.changed ||
repository.localStatus?.branch.ahead ||
repository.readyToDeploy,
).length;
const rows = (list) => list.map(renderRepositoryRow).join("");
return `<aside class="sidebar">
<nav class="primary-nav">${navButton("overview", "Overview", "overview", attention || "")}${navButton("deployments", "Deployments", "deploy", operations().filter((item) => item.type === "deployment" && !isTerminalOperation(item.status)).length || "")}${navButton("diagnostics", "Diagnostics", "shield", ui.diagnosticsStatus?.lastWriteError ? "!" : "")}${navButton("settings", "Settings", "settings")}</nav>
<div class="sidebar-section"><span>Repositories</span><button data-action="refresh" title="Refresh">${icon("refresh")}</button></div>
<input class="repo-filter" id="repo-filter" value="${attr(ui.repoSearch)}" placeholder="Filter projects" aria-label="Filter projects" />
<div class="repo-list">
${favorites.length ? `<div class="repo-group-label">Favorites</div>${rows(favorites)}` : ""}
${favorites.length && others.length ? '<div class="repo-group-label">All repositories</div>' : ""}
${others.length ? rows(others) : !favorites.length ? '<div class="empty-state compact"><p>No matching repositories.</p></div>' : ""}
</div>
<div class="sidebar-footer"><div class="sidebar-diagnostic-state"><span class="state-dot ${ui.diagnosticsStatus?.lastWriteError ? "danger" : ui.diagnosticsStatus?.enabled === false ? "" : "success"}"></span><div><strong>${ui.diagnosticsStatus?.lastWriteError ? "Diagnostic write error" : ui.diagnosticsStatus?.enabled === false ? "Diagnostics disabled" : "Safe diagnostics active"}</strong><span>${ui.diagnosticsStatus?.lastWriteError ? "Open Diagnostics for details" : "Credentials are redacted locally"}</span></div></div></div>
</aside>`;
}
function renderSummaryCard(label, value, note, iconName, tone = "") {
return `<div class="summary-card ${tone}">${icon(iconName)}<div class="eyebrow">${label}</div><div class="summary-value">${value}</div><div class="summary-label">${note}</div></div>`;
}
function queueActionFor(repository) {
const action = repositoryAction(repository);
const mapping = {
link: ["folder", "Link folder", "Local project is not connected", ""],
error: ["error", "Inspect problem", action.detail, "danger"],
conflict: ["warning", "Resolve conflicts", action.detail, "danger"],
commit: ["file", "Review & commit", action.detail, "warning"],
diverged: ["warning", "Resolve divergence", action.detail, "danger"],
pull: ["arrowDown", "Synchronize", action.detail, "warning"],
push: ["arrowUp", "Push commits", action.detail, ""],
configure: ["settings", "Configure deploy", action.detail, ""],
"branch-profile": ["branch", "Select profile", action.detail, ""],
deploy: ["rocket", "Deploy release", action.detail, "success"],
clean: ["check", "Synchronized", action.detail, "success"],
};
return mapping[action.kind] || mapping.clean;
}
function projectIllustration(kind = "flow") {
return `<div class="project-illustration ${attr(kind)}" data-project-illustration aria-hidden="true">
<div class="illustration-glow"></div><svg viewBox="0 0 260 150" role="presentation">
<path class="orbit orbit-a" d="M32 92 C72 20 190 18 230 82"/><path class="orbit orbit-b" d="M42 116 C98 150 190 136 222 60"/>
<g class="illustration-core"><rect x="83" y="39" width="94" height="74" rx="17"/><path d="M103 66h54M103 79h36M103 92h45"/><circle cx="160" cy="92" r="5"/></g>
<g class="illustration-node node-a"><circle cx="37" cy="92" r="12"/><path d="m32 92 4 4 7-9"/></g>
<g class="illustration-node node-b"><circle cx="226" cy="82" r="12"/><path d="M221 82h10M226 77v10"/></g>
<g class="illustration-node node-c"><circle cx="74" cy="31" r="8"/></g>
<circle class="signal signal-a" cx="0" cy="0" r="4"/><circle class="signal signal-b" cx="0" cy="0" r="3"/>
</svg><span class="illustration-label">${kind === "deploy" ? "Live release topology" : kind === "repo" ? "Project signal" : "Release flow"}</span>
</div>`;
}
function renderOverview() {
const changed = ui.repositories.filter(
(repository) => repository.localStatus?.counts.changed,
).length;
const unpushed = ui.repositories.filter(
(repository) => repository.localStatus?.branch.ahead,
).length;
const deployable = ui.repositories.filter(
(repository) => repository.readyToDeploy,
).length;
const unhealthy = ui.repositories
.flatMap((repository) => repository.deploymentProfiles || [])
.filter((profile) => profile.state?.healthy === false).length;
const queue = ui.repositories
.filter((repository) => repositoryAction(repository).kind !== "clean")
.slice(0, 8);
const recent = operations().slice(0, 7);
const active = recent.filter(
(operation) => !isTerminalOperation(operation.status),
);
return `<div class="page">
<div class="page-header visual-page-header"><div><div class="eyebrow">Coding flow</div><h1>Release overview</h1><p>One decision surface for local work, Gitea synchronization and the exact version running on your server.</p></div>${projectIllustration("flow")}<button class="button" data-action="refresh">${icon("refresh")}Refresh all</button></div>
${ui.refreshError ? `<div class="notice danger">${icon("error")} ${escapeHtml(ui.refreshError)}</div>` : ""}
<div class="summary-grid">
${renderSummaryCard("Local work", changed, changed === 1 ? "repository has changes" : "repositories have changes", "file", changed ? "warning" : "success")}
${renderSummaryCard("Unpushed", unpushed, "repositories ahead of Gitea", "arrowUp", unpushed ? "warning" : "success")}
${renderSummaryCard("Ready", deployable, "exact commits ready to deploy", "rocket", deployable ? "success" : "")}
${renderSummaryCard("Health", unhealthy || active.length, unhealthy ? "unhealthy environments" : active.length ? "operations in progress" : "all checked environments healthy", "pulse", unhealthy ? "danger" : active.length ? "warning" : "success")}
</div>
<section class="section-block"><div class="section-heading"><h2>Action queue</h2><span class="meta">Sorted by required attention</span></div><div class="action-queue">
${
queue.length
? queue
.map((repository) => {
const [iconName, label, reason, tone] =
queueActionFor(repository);
return `<div class="queue-row"><span class="queue-icon ${tone}">${icon(iconName)}</span><div><div class="queue-title">${escapeHtml(repository.name)}</div><div class="queue-sub">${escapeHtml(repository.localStatus?.branch.head || repository.defaultBranch || "remote")} ${repository.localStatus?.shortHead ? `${repository.localStatus.shortHead}` : ""}</div></div><div class="queue-reason"><strong>${escapeHtml(label)}</strong><span>${escapeHtml(reason)}</span></div><button class="button" data-action="select-repo" data-id="${attr(repository.id)}">Open ${icon("arrowRight")}</button></div>`;
})
.join("")
: '<div class="empty-state"><div class="empty-icon">✓</div><h3>Everything is synchronized</h3><p>No repository needs immediate attention.</p></div>'
}
</div></section>
<section class="section-block two-column">
<div class="panel"><div class="panel-header"><h2>Recent deployments</h2><button class="button ghost" data-action="navigate" data-view="deployments">View all</button></div><div class="activity-list">${recent.length ? recent.map((operation) => `<div class="activity-item"><span class="activity-dot ${toneForStatus(operation.status)}"></span><div><div class="activity-title">${escapeHtml(operation.repository)} ${escapeHtml(operation.environment || "environment")}</div><div class="activity-sub">${escapeHtml(operation.action === "rollback" ? "Rollback" : "Deploy")} ${escapeHtml(operation.shortSha || shortSha(operation.sha))} · ${escapeHtml(operation.status)}</div></div><span class="activity-time">${formatDate(operation.updatedAt || operation.createdAt)}</span></div>`).join("") : '<div class="empty-state compact"><p>No deployment history yet.</p></div>'}</div></div>
<div class="panel"><div class="panel-header"><h2>Workspace readiness</h2></div><div class="panel-body readiness-list">
${readinessRow("Git executable", ui.boot.git.available, ui.boot.git.version || ui.boot.git.error)}
${readinessRow("Gitea connection", ui.boot.state.gitea.hasToken, ui.boot.state.gitea.baseUrl || "Not configured")}
${readinessRow("Workspace folders", ui.boot.state.workspaceRoots.length > 0, `${ui.boot.state.workspaceRoots.length} configured`)}
${readinessRow("Automatic awareness", ui.boot.state.preferences?.autoRefresh !== false, ui.boot.state.preferences?.autoRefresh === false ? "Manual refresh only" : `Every ${ui.boot.state.preferences?.repositoryPollSeconds || 4}s`)}
</div></div>
</section>
</div>`;
}
function readinessRow(label, ok, detail) {
return `<div class="readiness-row"><span class="state-dot ${ok ? "success" : "danger"}"></span><div><strong>${escapeHtml(label)}</strong><span>${escapeHtml(detail)}</span></div></div>`;
}
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");
const additions = lines.filter(
(line) => line.startsWith("+") && !line.startsWith("+++"),
).length;
const removals = lines.filter(
(line) => line.startsWith("-") && !line.startsWith("---"),
).length;
const extension =
String(ui.selectedFile).split(".").pop()?.slice(0, 8).toUpperCase() ||
"FILE";
return `<div class="diff-atmosphere ${lines.length > 34 ? "dense" : ""}" data-diff-atmosphere aria-hidden="true"><svg viewBox="0 0 360 260" role="presentation"><path class="code-route route-a" d="M38 195 C92 84 178 214 318 74"/><path class="code-route route-b" d="M52 74 C132 8 230 34 310 156"/><g class="code-card"><rect x="110" y="75" width="142" height="106" rx="18"/><path d="M136 108h90M136 128h58M136 148h76"/></g><g class="code-node node-one"><circle cx="48" cy="190" r="15"/><path d="m41 190 5 5 9-12"/></g><g class="code-node node-two"><circle cx="315" cy="76" r="13"/><path d="M308 76h14M315 69v14"/></g><circle class="code-packet packet-one" cx="0" cy="0" r="5"/><circle class="code-packet packet-two" cx="0" cy="0" r="4"/></svg><div class="diff-atmosphere-caption"><span>${escapeHtml(extension)} change map</span><strong><i>+${additions}</i><i>${removals}</i></strong></div></div>`;
}
function renderDiff(diff) {
if (!diff)
return '<div class="empty-state"><div class="empty-icon">↔</div><h3>No textual diff</h3><p>Select another file or open the project folder for binary changes.</p></div>';
const rendered = escapeHtml(diff)
.split("\n")
.map((line) => {
const type =
line.startsWith("+") && !line.startsWith("+++")
? "add"
: line.startsWith("-") && !line.startsWith("---")
? "remove"
: line.startsWith("@@")
? "hunk"
: "";
return `<span class="diff-line ${type}">${line || " "}</span>`;
})
.join("");
return `${rendered}${diffAtmosphere(diff)}`;
}
function fileStatusCode(file) {
if (file.conflict) return "U";
if (file.untracked) return "?";
return (
{
modified: "M",
added: "A",
deleted: "D",
renamed: "R",
copied: "C",
"type-changed": "T",
}[file.status] || "M"
);
}
function renderChanges(repository) {
const status = repository.localStatus;
if (!repository.localPath) {
const target = displayCloneTarget(repository);
return `<div class="empty-state full"><div class="empty-icon">${icon("link")}</div><h3>Connect a local project</h3><p>Clone directly into your default project root, or link an existing working tree.</p>${target ? `<div class="notice"><span>${icon("folder")}Automatic destination</span><strong class="mono">${escapeHtml(target)}</strong></div>` : '<div class="notice warning">No default project root is configured. ForgeFlow will ask for one.</div>'}<div class="stack horizontal"><button class="button primary" data-action="clone-repo">${icon("cloud")}${escapeHtml(clonePrimaryLabel(repository))}</button><button class="button" data-action="link-repo">${icon("link")}Link existing folder</button><button class="button ghost" data-action="clone-repo-custom">Choose another location</button></div></div>`;
}
if (!status)
return `<div class="empty-state full"><div class="empty-icon">${icon("error")}</div><h3>Repository unavailable</h3><p>${escapeHtml(repository.attentionReason || "The local working tree could not be read.")}</p></div>`;
if (!status.files.length)
return `<div class="empty-state full"><div class="empty-icon">${icon("check")}</div><h3>Working tree clean</h3><p>Local ${escapeHtml(status.branch.head)} is at ${escapeHtml(status.shortHead)} with no uncommitted files.</p><div class="stack horizontal"><button class="button" data-action="fetch">${icon("refresh")}Fetch remote state</button><button class="button" data-action="open-path">${icon("folder")}Open project</button></div></div>`;
const selected = status.files.find((file) => file.path === ui.selectedFile);
const conflictActions = selected?.conflict
? `<div class="notice danger"><div><strong>Conflicted file</strong><p>Choose one side, or edit the file and mark it resolved.</p></div><div class="stack horizontal compact"><button class="button" data-action="resolve-conflict" data-resolution="ours">Use ours</button><button class="button" data-action="resolve-conflict" data-resolution="theirs">Use theirs</button><button class="button primary" data-action="resolve-conflict" data-resolution="resolved">Mark resolved</button></div></div>`
: "";
return `<div class="changes-layout"><section class="file-panel"><div class="file-panel-tools"><span><strong>${ui.selectedFiles.size}</strong> selected · ${status.counts.changed} changed · ${status.counts.staged} staged</span><button class="button ghost small" data-action="toggle-all-files">${ui.selectedFiles.size === status.files.length ? "Clear" : "Select all"}</button></div><div class="file-list" tabindex="0" aria-label="Changed files">${status.files.map((file) => `<div class="file-row ${ui.selectedFile === file.path ? "active" : ""}" role="button" tabindex="0" data-action="select-file" data-path="${attr(file.path)}"><input type="checkbox" data-file-select="${attr(file.path)}" ${ui.selectedFiles.has(file.path) ? "checked" : ""} aria-label="Include ${attr(file.path)}"/><span class="file-status ${attr(file.status)}">${fileStatusCode(file)}</span><span class="file-path" title="${attr(file.path)}">${escapeHtml(file.path)}</span><span title="${file.staged ? "Staged" : "Unstaged"}">${file.staged ? "●" : "○"}</span></div>`).join("")}</div>${status.counts.conflicts ? `<div class="card-actions"><button class="button danger" data-action="load-conflicts">${icon("warning")}Conflict guide</button></div>` : ""}</section><section class="diff-panel">${conflictActions}<div class="diff-toolbar"><span class="diff-title">${escapeHtml(ui.selectedFile || "Select a file")}</span><div class="stack horizontal compact">${ui.diffHunks?.partialSupported ? `<button class="button small" data-action="open-hunk-staging">Stage hunks</button>` : ""}${ui.selectedFile ? `<button class="button ghost small" data-action="open-file-editor">${icon("external")}Editor</button>` : ""}<span class="status-pill">${ui.selectedFile ? escapeHtml(selected?.status || "") : ""}</span><button class="icon-button" data-action="copy-diff" title="Copy diff">${icon("copy")}</button></div></div><div class="diff-view">${renderDiff(ui.diff)}</div></section></div>`;
}
function renderHistory(repository) {
if (!repository.localPath)
return '<div class="empty-state full"><p>Link a local repository to view commit history.</p></div>';
if (!ui.history.length)
return `<div class="empty-state full"><div class="empty-icon">${icon("history")}</div><h3>Load local commit history</h3><p>Review the last commits from this working tree.</p><button class="button primary" data-action="load-history">Load history</button></div>`;
return `<div class="tab-page"><div class="panel"><table class="data-table"><thead><tr><th>Commit</th><th>Message</th><th>Author</th><th>Date</th></tr></thead><tbody>${ui.history.map((commit) => `<tr><td class="mono">${escapeHtml(commit.shortSha)}</td><td>${escapeHtml(commit.subject)}</td><td>${escapeHtml(commit.author)}</td><td>${formatDate(commit.date)}</td></tr>`).join("")}</tbody></table></div></div>`;
}
function environmentState(profile) {
const state = profile.state || {};
if (state.healthy === false) return { label: "Unhealthy", tone: "danger" };
if (state.healthy === true) return { label: "Healthy", tone: "success" };
if (state.containerRunning === true) return { label: "Running · unverified", tone: "warning" };
if (state.containerRunning === false) return { label: "Stopped", tone: "danger" };
if (profile.provider === "ssh-unraid" || state.statusConfigured || state.healthConfigured)
return { label: "Not checked", tone: "" };
return { label: "Status not configured", tone: "" };
}
function dockerManIntegration(profile) {
const state = profile.state || {};
const iconMode =
profile.iconMode ||
(profile.iconFilePath ? "upload" : profile.iconUrl ? "url" : "builtin");
const webUiExpected = Boolean(profile.webUiUrl || profile.hostPort);
const iconExpected = iconMode !== "none";
const templateReady = Boolean(state.dockerMan?.templateExists);
const webUiReady =
!webUiExpected || Boolean(state.dockerMan?.webUi) || templateReady;
const iconReady =
!iconExpected || Boolean(state.dockerMan?.icon) || templateReady;
return {
iconMode,
templateReady,
webUiReady,
iconReady,
ready: Boolean(state.containerRunning && webUiReady && iconReady),
};
}
function deploymentIdentity(profile, repository) {
const name = String(
profile.state?.containerName ||
profile.containerName ||
profile.remoteFolder ||
repository.name ||
"container",
);
let hash = 0;
for (const character of name)
hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
return { name, initial: name.slice(0, 1).toUpperCase(), accent: hash % 6 };
}
function renderProfileCard(repository, profile, compact = false) {
const state = profile.state || {};
const health = environmentState(profile);
const isSsh = profile.provider === "ssh-unraid";
const mode = deploymentMode(profile);
const verification = ui.serverGitVerifications[profile.id];
const targetSha = deploymentTargetSha(repository, profile);
const ready = canDeploy(repository, profile);
const modeLabel = {
"push-bundle": "Direct copy",
"server-git": "Server pull from Gitea",
"monitor-only": "Monitor only",
}[mode] || mode;
const providerDetail = isSsh
? `SSH / Unraid · ${modeLabel} · ${profile.remoteFolder || repository.name} · ${profile.branch}${profile.adoptedFromServer ? " · server-linked" : ""}`
: `${profile.workflowFile} · ${profile.branch}`;
const rollbackConfigured = (isSsh && mode !== "monitor-only") || Boolean(profile.rollbackWorkflowFile);
const dockerMan = dockerManIntegration(profile);
const { templateReady, webUiReady, iconReady } = dockerMan;
const dockerManReady = dockerMan.ready;
const managesDockerMan = isSsh && profile.manageDockerMan === true;
const webUi = profile.webUiUrl || state.webUiUrl || state.dockerMan?.webUi || "";
const identity = deploymentIdentity(profile, repository);
const syncLabel = isSsh
? state.matchesGitea
? `<span class="sync-proof success">${icon("check")}Live = Gitea · ${shortSha(state.liveSha)}</span>`
: state.liveSha && state.giteaSha
? `<span class="sync-proof warning">Live ${shortSha(state.liveSha)} · Gitea ${shortSha(state.giteaSha)}</span>`
: state.liveSha ? `<span class="sync-proof success">${icon("check")}Live · ${shortSha(state.liveSha)}</span>` : ""
: state.matchesGitea
? `<span class="sync-proof success">${icon("check")}Live = Gitea · ${shortSha(state.liveSha)}</span>`
: state.giteaSha && state.liveSha
? `<span class="sync-proof warning">Live ${shortSha(state.liveSha)} · Gitea ${shortSha(state.giteaSha)}</span>`
: "";
const dockerManLabel = managesDockerMan
? dockerManReady
? templateReady
? "Managed labels/template active"
: "Managed labels active"
: `Managed · WebUI ${webUiReady ? "ready" : "missing"} · icon ${iconReady ? "ready" : "missing"}`
: "Existing DockerMan template preserved";
const sourceLabel = isSsh
? mode === "server-git" ? `Gitea ${state.giteaSha ? shortSha(state.giteaSha) : "refresh required"}` : "Committed local HEAD"
: state.giteaSha ? shortSha(state.giteaSha) : "Refresh to compare";
const serverAccessAction = isSsh && mode === "server-git"
? `<button class="button" data-action="verify-server-git-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Verify server pull</button><button class="button" data-action="manage-deploy-key" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("key")}Deploy key lifecycle</button><button class="button" data-action="configure-server-git-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("key")}Configure Gitea access</button>`
: "";
return `<article class="deploy-card accent-${identity.accent} ${compact ? "compact-card" : ""}"><div class="container-identity"><span class="container-avatar">${escapeHtml(identity.initial)}</span><div><span>Container</span><strong>${escapeHtml(identity.name)}</strong><small>${escapeHtml(repository.fullName)} · ${escapeHtml(profile.environment)}</small></div>${syncLabel}</div><div class="deploy-card-header"><div><div class="eyebrow">${escapeHtml(isSsh ? "SSH / UNRAID" : "GITEA ACTIONS")}</div><h3>${escapeHtml(profile.name)}</h3><p>${escapeHtml(providerDetail)}</p></div><span class="status-pill ${health.tone}"><span class="state-dot ${health.tone}"></span>${health.label}</span></div><div class="deploy-card-body"><div class="deploy-metadata"><span>Live commit</span><strong>${state.liveSha ? shortSha(state.liveSha) : "Unknown"}</strong><span>Deploy source</span><strong>${escapeHtml(sourceLabel)}</strong><span>Previous version</span><strong>${state.previousSha ? shortSha(state.previousSha) : "Unknown"}</strong><span>Last checked</span><strong>${state.checkedAt ? formatDate(state.checkedAt) : "Never"}</strong>${isSsh ? `<span>Deployment mode</span><strong>${escapeHtml(modeLabel)}</strong><span>Compose project</span><strong>${escapeHtml(profile.composeProject || "ForgeFlow-generated identity")}</strong><span>Runtime</span><strong>${state.containerRunning === false ? "Stopped" : state.containerRunning ? state.runtimeVerification === "running-unverified" ? "Running · unverified" : "Running" : "Unknown"}</strong><span>DockerMan</span><strong class="${managesDockerMan && !dockerManReady ? "text-warning" : "text-success"}">${escapeHtml(dockerManLabel)}</strong>` : ""}<span>Rollback</span><strong>${rollbackConfigured ? "Available after first deploy" : "Not configured"}</strong></div><div class="card-actions"><button class="button" data-action="run-deployment-preflight" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Preflight</button>${isSsh ? `<button class="button" data-action="repair-deployment-write-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}Check / fix write access</button>` : ""}${serverAccessAction}<button class="button" data-action="reconcile-deployment" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("refresh")}Refresh truth</button>${webUi ? `<button class="button" data-action="open-profile-webui" data-url="${attr(webUi)}">${icon("external")}Open Web UI</button>` : ""}${managesDockerMan ? `<button class="button ${dockerManReady ? "ghost" : ""}" data-action="apply-dockerman-metadata" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}${dockerManReady ? "Reapply DockerMan integration" : "Repair DockerMan integration"}</button>` : ""}${ready ? `<button class="button primary" data-action="deploy-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("rocket")}Deploy ${escapeHtml(shortSha(targetSha))}</button>` : ""}<button class="button ghost" data-action="edit-deployment-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">Edit</button>${state.previousSha && rollbackConfigured ? `<button class="button danger" data-action="rollback-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("undo")}Rollback</button>` : ""}</div></div></article>`;
}
function renderRepositoryDeployments(repository) {
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">${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) {
if (!repository.localPath)
return '<div class="empty-state full"><p>Link a local repository to manage branches and stashes.</p></div>';
const recovery = ui.gitRecovery;
const locks = recovery?.lockReport?.locks || [];
const activeProcesses = recovery?.lockReport?.processes?.active || [];
const recommendations = recovery?.recommendations || [];
return `<div class="tab-page git-tools-grid"><section class="panel"><div class="panel-header"><h2>Branches</h2><button class="button ghost" data-action="load-git-tools">${icon("refresh")}Refresh</button></div><div class="panel-body"><div class="inline-form"><input id="new-branch-name" class="input" placeholder="feature/name"/><button class="button" data-action="create-branch">${icon("plus")}Create & switch</button></div><div class="tool-list">${ui.branches.length ? ui.branches.map((branch) => `<div class="tool-row"><div><strong>${escapeHtml(branch.name)}</strong><span>${escapeHtml(branch.shortSha)}${branch.upstream ? ` · ${escapeHtml(branch.upstream)}` : " · unpublished"}</span></div>${branch.current ? '<span class="status-pill success">Current</span>' : `<button class="button" data-action="checkout-branch" data-branch="${attr(branch.name)}">Switch</button>`}</div>`).join("") : '<div class="empty-state compact"><p>Load branch information.</p></div>'}</div></div></section><section class="panel"><div class="panel-header"><h2>Stashes</h2><button class="button" data-action="stash-changes" ${repository.localStatus?.clean ? "disabled" : ""}>${icon("archive")}Stash changes</button></div><div class="panel-body"><div class="tool-list">${ui.stashes.length ? ui.stashes.map((stash) => `<div class="tool-row"><div><strong>${escapeHtml(stash.ref)}</strong><span>${escapeHtml(stash.subject)} · ${formatDate(stash.date)}</span></div><button class="button" data-action="pop-stash" data-stash-ref="${attr(stash.ref)}">Apply & drop</button></div>`).join("") : '<div class="empty-state compact"><p>No stashes, or Git tools have not been loaded.</p></div>'}</div></div></section><section class="panel troubleshooting-panel"><div class="panel-header"><div><h2>Repository troubleshooting</h2><span class="meta">Safe, repository-specific recovery actions</span></div><button class="button primary" data-action="scan-git-recovery">${icon("pulse")}Scan</button></div><div class="panel-body">${recovery ? `<div class="troubleshooting-summary"><span class="status-pill ${locks.length ? "warning" : "success"}">${locks.length ? `${locks.length} lock${locks.length === 1 ? "" : "s"}` : "No Git locks"}</span><span>${activeProcesses.length ? `${activeProcesses.length} active Git process(es)` : "No matching active Git process detected"}</span></div>${locks.length ? `<div class="tool-list">${locks.map((lock) => `<div class="tool-row"><div><strong>${escapeHtml(lock.name)}</strong><span>${Math.round(lock.ageMs / 1000)}s old · ${escapeHtml(lock.modifiedAt)}</span></div></div>`).join("")}</div>` : ""}${recommendations.length ? `<div class="tool-list recovery-actions">${recommendations.map((item) => `<div class="tool-row"><div><strong>${escapeHtml(item.label)}</strong><span>${item.safe ? "Safe automated action" : item.action ? "Creates a safety branch before changing history" : "Review required"}</span></div>${item.action ? `<button class="button ${item.safe ? "" : "danger"}" data-action="repair-repository-sync" data-strategy="${attr(item.action)}">Run</button>` : ""}</div>`).join("")}</div>` : ""}` : '<div class="empty-state compact"><p>Scan before repairing. ForgeFlow checks every .lock file in the actual Git directory, not only index.lock.</p></div>'}<div class="card-actions"><button class="button" data-action="repair-git-locks">${icon("wrench")}Repair proven stale locks</button><button class="button" data-action="reconcile-repository">${icon("refresh")}Refresh Git state</button>${repository.sshUrl && repository.localStatus?.remoteUrl !== repository.sshUrl ? `<button class="button" data-action="repair-origin">${icon("link")}Repair origin</button>` : ""}</div><div class="notice warning">Lock repair refuses to run while a matching Git process is active. A force option is shown only when process detection itself is unavailable.</div></div></section></div>`;
}
function renderRepositorySettings(repository) {
const automaticTarget = displayCloneTarget(repository);
const currentOrigin = repository.localStatus?.remoteUrl || "Unavailable";
const desiredOrigin = repository.sshUrl || repository.preferredCloneUrl || "";
const originNeedsRepair = Boolean(
repository.localPath && desiredOrigin && currentOrigin !== desiredOrigin,
);
const pullRequests = ui.pullRequests || [];
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 groups = report.checks.reduce((grouped, check) => {
(grouped[check.category] ||= []).push(check);
return grouped;
}, {});
const trend = report.trend || {};
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">${escapeHtml(report.policy?.label || "Standard")} policy · ${report.ready ? "release-ready" : "review required"}</div><h2>${escapeHtml(report.grade)}</h2><p>${report.summary.passed} passed · ${report.summary.warnings} recommendations · ${report.summary.errors} critical</p><p class="meta">${trend.newlyFound?.length || 0} new · ${trend.resolved?.length || 0} resolved · ${trend.regressions?.length || 0} regressions · ${report.expiredSuppressions?.length || 0} expired exceptions</p></div>${projectIllustration("diagnostics")}<div class="validator-actions"><label class="sr-only" for="validator-policy">Assurance policy</label><select id="validator-policy" class="select" data-action="git-validator-policy">${["minimal", "standard", "strict", "production"].map((policy) => `<option value="${policy}" ${report.policy?.id === policy ? "selected" : ""}>${policy[0].toUpperCase() + policy.slice(1)}</option>`).join("")}</select><button class="button" data-action="git-validator-scan">${icon("refresh")}Scan again</button><button class="button" data-action="git-validator-export" data-format="markdown">Export report</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>${check.suppressed ? `<small>Suppressed until ${formatDate(check.suppression.expiresAt)} · ${escapeHtml(check.suppression.reason)}</small>` : check.expiredSuppression ? `<small>Exception expired; finding is active again.</small>` : ""}</div>${check.fixAction ? `<button class="button ${check.safe ? "" : "primary"}" data-action="git-validator-repair" data-check-index="${checkIndex}">${icon("wrench")}Preview fix</button>` : check.status !== "pass" && !check.suppressed ? `<button class="button" data-action="git-validator-suppress" data-check-index="${checkIndex}">Document exception</button>` : `<span class="status-pill ${check.suppressed ? "warning" : check.status === "pass" ? "success" : check.status === "error" ? "danger" : "warning"}">${check.suppressed ? "Suppressed" : check.status === "pass" ? "Best practice" : "Review"}</span>`}</article>`;
})
.join("")}</div></section>`,
)
.join("")}</div></div>`;
}
function renderRepositoryWorkspace(repository) {
const status = repository.localStatus;
const profiles = repository.deploymentProfiles || [];
const linkedWorkloads = linkedWorkloadsForRepository(repository);
const profile = selectedProfile(repository);
const serverState = profile?.state || {};
const localTone = status?.counts.conflicts
? "danger"
: status?.counts.changed
? "warning"
: status
? "success"
: "";
const remoteTone = status?.branch.behind
? "danger"
: status?.branch.ahead
? "warning"
: status?.branch.upstream
? "success"
: "";
const serverTone =
serverState.healthy === false
? "danger"
: serverState.healthy === true
? "success"
: "";
const content = (
{
changes: renderChanges,
history: renderHistory,
deployments: renderRepositoryDeployments,
gittools: renderGitTools,
validator: renderGitValidator,
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>
${deploymentLinks}
<nav class="tabs">${[
["changes", "Changes"],
["history", "History"],
["deployments", "Deployments"],
["gittools", "Git tools"],
["validator", "Git Validator"],
["settings", "Project settings"],
]
.map(
([id, label]) =>
`<button class="tab ${ui.repositoryTab === id ? "active" : ""}" data-action="repo-tab" data-tab="${id}">${label}</button>`,
)
.join("")}</nav><div class="repo-content">${content}</div></div>`;
}
function renderActionPanel(repository) {
const action = repositoryAction(repository);
const status = repository.localStatus;
const profile = selectedProfile(repository);
let body = "";
if (action.kind === "link") {
const target = displayCloneTarget(repository);
body = `<div class="panel-callout"><div class="callout-icon">${icon("link")}</div><h2>${action.title}</h2><p>${action.detail}</p>${target ? `<div class="deploy-proof"><span>Project root</span><strong class="mono">${escapeHtml(defaultWorkspaceRoot())}</strong><span>New folder</span><strong class="mono">${escapeHtml(safeCloneFolderName(repository))}</strong></div>` : '<div class="notice warning">No default project root is configured yet.</div>'}<button class="button primary block" data-action="clone-repo">${icon("cloud")}${escapeHtml(clonePrimaryLabel(repository))}</button><button class="button block" style="margin-top:8px" data-action="link-repo">Link existing folder</button><button class="button ghost block" style="margin-top:8px" data-action="clone-repo-custom">Choose another clone location</button></div>`;
} else if (action.kind === "commit") {
const hasStagedSelection = status.counts.staged > 0;
const commitReady = Boolean(
(ui.selectedFiles.size || hasStagedSelection) && ui.commitMessage.trim(),
);
const commitBlocker =
!ui.selectedFiles.size && !hasStagedSelection
? "Select files or stage one or more hunks."
: !ui.commitMessage.trim()
? "Enter a commit message to enable commit and push."
: ui.selectedFiles.size
? "Ready to commit. ForgeFlow stages the selected files automatically."
: "Ready to commit only the reviewed staged hunks.";
body = `<label class="field-label" for="commit-message">Commit message <span class="required-mark">required</span></label><textarea id="commit-message" class="textarea" placeholder="Describe what changed and why…">${escapeHtml(ui.commitMessage)}</textarea><div class="field-hint"><span>${ui.selectedFiles.size ? `${ui.selectedFiles.size} of ${status.counts.changed} files selected` : `${status.counts.staged} staged file(s)`}</span><span>Ctrl+Enter</span></div><div class="commit-readiness ${commitReady ? "ready" : "blocked"}">${icon(commitReady ? "check" : "warning")}<span>${escapeHtml(commitBlocker)}</span></div><button class="button primary block" style="margin-top:10px" data-action="commit-push" ${commitReady ? "" : `disabled title="${attr(commitBlocker)}"`}>${icon("arrowUp")}${ui.selectedFiles.size ? "Commit selected" : "Commit staged hunks"} & push to Gitea</button><button class="button block" style="margin-top:8px" data-action="commit-only" ${commitReady ? "" : `disabled title="${attr(commitBlocker)}"`}>${icon("git")}${ui.selectedFiles.size ? "Commit selected locally" : "Commit staged hunks locally"}</button><div class="stage-note">Partial hunk staging is preserved when no complete files are selected.</div><div class="stack" style="margin-top:8px"><button class="button block" data-action="stage-selected" ${ui.selectedFiles.size ? "" : "disabled"}>Stage selected files</button><button class="button block" data-action="unstage-selected" ${ui.selectedFiles.size ? "" : "disabled"}>Unstage selected files</button><button class="button block" data-action="stash-changes">${icon("archive")}Stash all changes</button></div>`;
} else if (action.kind === "pull")
body = `<div class="panel-callout"><div class="callout-icon warning">${icon("arrowDown")}</div><h2>${action.title}</h2><p>${action.detail}</p><button class="button primary block" data-action="pull">Fast-forward from Gitea</button></div>`;
else if (action.kind === "push")
body = `<div class="panel-callout"><div class="callout-icon">${icon("arrowUp")}</div><h2>${action.title}</h2><p>${action.detail}</p><button class="button primary block" data-action="push">Push ${status.branch.ahead} commit${status.branch.ahead === 1 ? "" : "s"}</button></div>`;
else if (
action.kind === "diverged" ||
action.kind === "conflict" ||
action.kind === "error"
)
body = `<div class="panel-callout"><div class="callout-icon danger">${icon("error")}</div><h2>${action.title}</h2><p>${action.detail}</p>${action.kind === "diverged" ? `<button class="button primary block" data-action="load-git-tools">${icon("wrench")}Open guided repository repair</button>` : ""}<button class="button block" style="margin-top:8px" data-action="open-path">Open project folder</button><button class="button block" style="margin-top:8px" data-action="refresh">Refresh status</button></div>`;
else if (action.kind === "configure")
body = `<div class="panel-callout"><div class="callout-icon">${icon("settings")}</div><h2>${action.title}</h2><p>${action.detail}</p><button class="button primary block" data-action="configure-deployment">Configure first environment</button></div>`;
else if (action.kind === "branch-profile")
body = `<div class="panel-callout"><div class="callout-icon">${icon("branch")}</div><h2>${action.title}</h2><p>${action.detail}</p>${repository.deploymentProfiles.length > 1 ? `<label class="field-label">Deployment profile</label><select id="action-profile-select" class="select">${repository.deploymentProfiles.map((item) => `<option value="${attr(item.id)}" ${item.id === profile?.id ? "selected" : ""}>${escapeHtml(item.name)} · ${escapeHtml(item.branch)}</option>`).join("")}</select>` : ""}<button class="button block" style="margin-top:8px" data-action="edit-deployment-profile" data-profile-id="${attr(profile?.id || "")}">Edit profile</button></div>`;
else if (action.kind === "deploy")
body = `<div class="panel-callout"><div class="callout-icon success">${icon("rocket")}</div><h2>Release ${escapeHtml(status.shortHead)}</h2><p>${escapeHtml(profile.name)} will deploy the exact commit from ${escapeHtml(profile.branch)} to ${escapeHtml(profile.environment)}.</p>${repository.deploymentProfiles.length > 1 ? `<label class="field-label">Environment</label><select id="action-profile-select" class="select">${repository.deploymentProfiles.map((item) => `<option value="${attr(item.id)}" ${item.id === profile.id ? "selected" : ""}>${escapeHtml(item.name)} · ${escapeHtml(item.environment)}</option>`).join("")}</select>` : ""}<div class="deploy-proof"><span>Local</span><strong>${escapeHtml(status.shortHead)}</strong><span>Gitea</span><strong>${escapeHtml(status.shortHead)}</strong><span>Target</span><strong>${escapeHtml(profile.environment)}</strong></div><button class="button success block" data-action="deploy-profile" data-profile-id="${attr(profile.id)}">${icon("rocket")}Deploy ${escapeHtml(status.shortHead)}${escapeHtml(profile.environment)}</button>${profile.state?.previousSha && profile.rollbackWorkflowFile ? `<button class="button danger block" style="margin-top:8px" data-action="rollback-profile" data-profile-id="${attr(profile.id)}">${icon("undo")}Rollback to ${shortSha(profile.state.previousSha)}</button>` : ""}</div>`;
else
body = `<div class="panel-callout"><div class="callout-icon success">${icon("check")}</div><h2>${action.title}</h2><p>${action.detail}</p>${profile ? `<button class="button block" data-action="refresh-profile-state" data-profile-id="${attr(profile.id)}">${icon("pulse")}Check ${escapeHtml(profile.environment)}</button>` : ""}</div>`;
return `<aside class="action-panel"><div class="action-panel-head"><div class="eyebrow">Next action</div><h2>${escapeHtml(action.title)}</h2><p>${escapeHtml(action.detail)}</p></div><div class="action-panel-body">${body}</div>${repository.localPath ? `<div class="action-panel-footer"><button class="button ghost" data-action="open-path">${icon("folder")}Open folder</button><button class="button ghost" data-action="load-git-tools">${icon("branch")}Git tools</button></div>` : ""}</aside>`;
}
function renderServerInventory() {
const servers = ui.serverDiscovery || [];
const configuredServers = ui.boot?.state?.servers || [];
const hiddenClassifications = new Set(["backup", "release-folder", "system-container", "manually-excluded"]);
const visibleForServer = (server) => (server.workloads || []).filter((workload) =>
workload.reviewDecisionStale || workload.classification?.type === "duplicate" || (!hiddenClassifications.has(workload.classification?.type) && (workload.link || workload.runtime?.running || ["ambiguous", "orphan-container", "stopped-application", "historical-compose", "stale-link", "monitor-only"].includes(workload.classification?.type))),
);
const reviewCount = servers.reduce((total, server) => total + visibleForServer(server).filter((workload) => !workload.link || workload.classification?.type === "stale-link" || workload.reviewDecisionStale).length, 0);
const serverCards = servers.map((server) => {
const capabilities = server.capabilities || {};
const capabilityText = [
capabilities.docker ? "Docker" : "Docker missing",
capabilities.compose ? "Compose" : "Compose missing",
capabilities.git ? "Git available" : "Git optional",
capabilities.tar && capabilities.checksum ? "Push ready" : "Push tools incomplete",
].join(" · ");
const errorBlock = server.error
? `<div class="notice danger">${icon("error")}<div><strong>Server scan failed</strong><p>${escapeHtml(server.error)}</p><div class="stack horizontal compact" style="margin-top:8px"><button class="button primary" data-action="use-server-password" data-server-id="${attr(server.serverId)}" data-retry="scan">Use server password instead</button><button class="button" data-action="test-server" data-server-id="${attr(server.serverId)}">Test connection</button></div></div></div>`
: "";
const warnings = (server.warnings || []).map((warning) => `<div class="notice warning">${icon("warning")}${escapeHtml(warning)}</div>`).join("");
const visibleWorkloads = visibleForServer(server);
const hiddenCount = Math.max(0, (server.workloads || []).length - visibleWorkloads.length);
const workloads = visibleWorkloads.length
? visibleWorkloads.map((workload) => {
const containers = (workload.containers || []).map((container) => container.name).filter(Boolean).join(", ");
const topCandidate = workload.candidates?.[0];
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" : 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
? `DockerMan ${workload.dockerMan.name || workload.displayName} · ${containers || "template only"}`
: `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";
const canQuickLink = !linked && topCandidate && ["exact", "strong"].includes(topCandidate.confidence) && Boolean(workload.remoteFolderCandidate);
const linkButton = canQuickLink
? `<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 || 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>`;
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>`
: `<div class="empty-state panel"><h3>No Unraid server configured</h3><p>Add the server with password authentication and ForgeFlow can copy and deploy projects directly.</p><button class="button primary" data-action="open-add-server">Add server</button></div>`;
return `<section class="section-block"><div class="section-heading"><div><h2>Server inventory</h2><span class="meta">Live Docker, Compose and DockerMan discovery, linked to Gitea</span></div><button class="button ${reviewCount ? "primary" : ""}" data-action="scan-server-inventory">${icon("refresh")}Scan servers</button></div>${servers.length ? `<div class="stack">${serverCards}</div>` : empty}<div class="notice" style="margin-top:12px">${icon("shield")}Server pull fetches an exact Gitea commit through a repository-scoped read-only deploy key, validates Compose and only then promotes the release. Direct copy remains an explicit fallback.</div></section>`;
}
function renderDeployments() {
const cards = ui.repositories.flatMap((repository) =>
(repository.deploymentProfiles || []).map((profile) => ({ repository, profile })),
);
const active = operations().filter((operation) => !isTerminalOperation(operation.status));
const missingDockerMan = cards.filter(({ profile }) =>
profile.provider === "ssh-unraid" &&
profile.manageDockerMan === true &&
profile.state?.containerRunning &&
!dockerManIntegration(profile).ready,
);
return `<div class="page"><div class="page-header visual-page-header"><div><div class="eyebrow">Server releases</div><h1>Deployments</h1><p>Discover live Unraid workloads, verify them against Gitea and release an exact commit through a protected server pull.</p></div>${projectIllustration("deploy")}<div class="stack horizontal compact"><button class="button" data-action="refresh-operations">${icon("refresh")}Refresh runs & servers</button>${missingDockerMan.length ? `<button class="button primary" data-action="repair-missing-dockerman">${icon("wrench")}Repair ${missingDockerMan.length} managed integration${missingDockerMan.length === 1 ? "" : "s"}</button>` : ""}</div></div>${active.length ? `<div class="notice warning">${icon("pulse")} ${active.length} deployment operation${active.length === 1 ? " is" : "s are"} still active. ForgeFlow reconciles these against the live server automatically.</div>` : ""}${renderServerInventory()}<section class="section-block"><div class="section-heading"><div><h2>Linked deployment environments</h2><span class="meta">Stable Compose identity, live container health and exact Gitea commit parity</span></div></div><div class="deploy-card-grid">${cards.length ? cards.map(({ repository, profile }) => renderProfileCard(repository, profile, true)).join("") : '<div class="empty-state panel"><h3>No deployment environments configured</h3><p>Scan a server and link an existing workload, or open a repository and add an environment.</p></div>'}</div></section><section class="section-block"><div class="section-heading"><h2>All operations</h2><span class="meta">Newest first</span></div><div class="panel">${operations().length ? `<table class="data-table"><thead><tr><th>Repository</th><th>Action</th><th>Environment</th><th>Commit</th><th>Status</th><th>Updated</th><th></th></tr></thead><tbody>${operations().map((operation) => `<tr><td>${escapeHtml(operation.repository)}</td><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 operations recorded.</p></div>'}</div></section></div>`;
}
function renderSettings() {
const state = ui.boot.state;
const prefs = state.preferences || {};
const update = ui.updateStatus;
const servers = state.servers || [];
return `<div class="settings-layout"><aside class="settings-nav"><button class="nav-button active">${icon("settings")}<span>General</span></button><button class="nav-button" data-action="check-updates">${icon("update")}<span>Updates</span></button><button class="nav-button" data-action="open-add-server">${icon("server")}<span>Servers</span></button><button class="nav-button" data-action="reset-app">${icon("trash")}<span>Reset setup</span></button></aside><div class="settings-content"><div class="page-header"><div><div class="eyebrow">Application</div><h1>Settings</h1><p>Connections, project discovery, secure SSH servers and application updates.</p></div></div>
<section class="settings-group"><h2>Gitea connection</h2><div class="form-grid"><div class="field full"><label for="settings-gitea-url">Instance URL</label><input id="settings-gitea-url" class="input" value="${attr(state.gitea.baseUrl)}" placeholder="https://gitea.example.com" /></div><div class="field full"><label for="settings-gitea-token">New access token</label><input id="settings-gitea-token" class="input" type="password" placeholder="Leave empty to keep the existing token" /></div></div><div class="connection-card" style="margin-top:10px"><div><strong>${state.gitea.hasToken ? `Connected as ${escapeHtml(state.gitea.user?.login || "user")}` : "Not connected"}</strong><div class="queue-sub">${escapeHtml(state.gitea.baseUrl || "No Gitea instance configured")}</div></div><button class="button primary" data-action="save-gitea-settings">Validate & save</button></div></section>
<section class="settings-group"><div class="section-heading"><div><h2>ForgeFlow updates</h2><span class="meta">Secure source update from ${escapeHtml(state.updates?.owner || "Jens")}/${escapeHtml(state.updates?.repo || "ForgeFlow")}</span></div><button class="button" data-action="check-updates" ${ui.updateChecking ? "disabled" : ""}>${icon("update")}${ui.updateChecking ? "Checking" : "Check now"}</button></div><div class="form-grid"><div class="field"><label>Repository owner</label><input id="update-owner" class="input" value="${attr(state.updates?.owner || "Jens")}"/></div><div class="field"><label>Repository name</label><input id="update-repo" class="input" value="${attr(state.updates?.repo || "ForgeFlow")}"/></div><div class="field"><label>Release branch</label><input id="update-branch" class="input" value="${attr(state.updates?.branch || "main")}"/></div><div class="field"><label>Automatic startup check</label><select id="update-auto-check" class="select"><option value="true" ${state.updates?.autoCheck !== false ? "selected" : ""}>Enabled</option><option value="false" ${state.updates?.autoCheck === false ? "selected" : ""}>Disabled</option></select></div></div><div class="update-card ${update?.available ? "available" : ""}"><div>${icon(update?.available ? "download" : "check")}<span><strong>${update ? (update.available ? `ForgeFlow ${escapeHtml(update.remoteVersion)} is available` : `ForgeFlow ${escapeHtml(update.currentVersion)} is up to date`) : `Current version ${escapeHtml(ui.boot.appVersion)}`}</strong><small>${update ? `Branch ${escapeHtml(update.branch)} · commit ${escapeHtml(update.shortSha)} · checked ${formatDate(update.checkedAt)}` : "No update check in this session."}</small></span></div><div class="stack horizontal compact">${update?.available && !update.downloaded ? `<button class="button primary" data-action="download-update">${icon("download")}Download update</button>` : ""}${update?.downloaded ? `<button class="button success" data-action="apply-update">${icon("update")}Apply & restart</button>` : ""}<button class="button" data-action="save-update-settings">Save update settings</button></div></div><div class="notice" style="margin-top:10px">${icon("shield")}The updater downloads an authenticated ZIP for the exact remote commit, verifies its SHA-256 checksum, runs the complete quality gate and restores the previous source version if validation fails.</div></section>
<section class="settings-group"><div class="section-heading"><div><h2>SSH / Unraid servers</h2><span class="meta">Credentials are entered locally and encrypted with the Windows credential protection used by Electron.</span></div><button class="button primary" data-action="open-add-server">${icon("plus")}Add server</button></div>${servers.length ? `<div class="server-list">${servers.map((server) => `<article class="server-card"><div class="server-card-main">${icon("server")}<div><strong>${escapeHtml(server.name)}</strong><span>${escapeHtml(server.username)}@${escapeHtml(server.host)}:${escapeHtml(server.port)} · ${escapeHtml(server.basePath)}</span><small>${server.hostFingerprint ? `Trusted ${escapeHtml(server.hostFingerprint)}` : "Host identity not trusted yet"}</small></div></div><div class="stack horizontal compact"><button class="button" data-action="test-server" data-server-id="${attr(server.id)}">Test & trust</button><button class="button" data-action="edit-server" data-server-id="${attr(server.id)}">Edit</button><button class="icon-button danger" data-action="delete-server" data-server-id="${attr(server.id)}" title="Delete server">${icon("trash")}</button></div></article>`).join("")}</div>` : '<div class="empty-state compact"><p>No SSH server configured. Add your Unraid server before creating an SSH deployment profile.</p></div>'}</section>
<section class="settings-group"><div class="section-heading"><div><h2>Git remote maintenance</h2><span class="meta">Standardize linked repositories to the current Gitea SSH URLs.</span></div><button class="button" data-action="normalize-origins">${icon("link")}Normalize all origins</button></div><p>This replaces legacy aliases and renamed owners only after an explicit click. Local commits and files are not changed.</p></section>
<section class="settings-group"><h2>Project roots</h2><p>The first folder is the default clone destination. ForgeFlow automatically creates one subfolder per repository.</p><div class="stack">${state.workspaceRoots.map((root, index) => `<div class="root-row">${index === 0 ? '<span class="status-pill success">Default</span>' : ""}<input class="input" data-root-index="${index}" value="${attr(root)}" aria-label="Project root ${index + 1}"/><button class="icon-button" data-action="remove-root" data-index="${index}" title="Remove">${icon("trash")}</button></div>`).join("")}<button class="button" data-action="add-root">${icon("plus")}Add project root</button><button class="button primary" data-action="save-roots">Save folders & rescan</button></div></section>
<section class="settings-group"><h2>Background awareness</h2><div class="form-grid"><div class="field"><label>Automatic repository refresh</label><select id="pref-auto-refresh" class="select"><option value="true" ${prefs.autoRefresh !== false ? "selected" : ""}>Enabled</option><option value="false" ${prefs.autoRefresh === false ? "selected" : ""}>Disabled</option></select></div><div class="field"><label>Local poll interval</label><input id="pref-repo-poll" class="input" type="number" min="2" max="60" value="${attr(prefs.repositoryPollSeconds || 4)}"/></div><div class="field"><label>Actions poll interval</label><input id="pref-operation-poll" class="input" type="number" min="3" max="120" value="${attr(prefs.operationPollSeconds || 5)}"/></div><div class="field"><label>Preferred clone protocol</label><select id="pref-clone-protocol" class="select"><option value="https" ${prefs.preferredCloneProtocol !== "ssh" ? "selected" : ""}>HTTPS</option><option value="ssh" ${prefs.preferredCloneProtocol === "ssh" ? "selected" : ""}>SSH</option></select></div></div><button class="button primary" style="margin-top:12px" data-action="save-preferences">Save awareness settings</button></section>
<section class="settings-group"><h2>Desktop integration</h2><div class="form-grid"><div class="field"><label>Editor executable</label><input id="pref-editor-executable" class="input" value="${attr(prefs.editor?.executable || "code")}"/></div><div class="field"><label>Editor arguments</label><input id="pref-editor-args" class="input" value="${attr((prefs.editor?.args || ["--reuse-window", "--goto", "{file}:{line}"]).join(" | "))}"/><small>Separate arguments with |. Placeholders: {path}, {file}, {line}</small></div><div class="field"><label>Terminal executable</label><input id="pref-terminal-executable" class="input" value="${attr(prefs.terminal?.executable || "wt.exe")}"/></div><div class="field"><label>Terminal arguments</label><input id="pref-terminal-args" class="input" value="${attr((prefs.terminal?.args || ["-d", "{path}"]).join(" | "))}"/></div><label class="check-field"><input id="pref-notifications" type="checkbox" ${prefs.notificationsEnabled !== false ? "checked" : ""}/><span>Native deployment notifications</span></label><label class="check-field"><input id="pref-tray" type="checkbox" ${prefs.trayEnabled !== false ? "checked" : ""}/><span>Show system tray icon</span></label><label class="check-field"><input id="pref-close-tray" type="checkbox" ${prefs.closeToTray === true ? "checked" : ""}/><span>Hide to tray when closing</span></label><label class="check-field"><input id="pref-login" type="checkbox" ${prefs.startAtLogin === true ? "checked" : ""}/><span>Start ForgeFlow at login</span></label></div><button class="button primary" data-action="save-desktop-preferences">Save desktop integration</button></section>
<section class="settings-group"><h2>Encrypted configuration backup</h2><p>Repository mappings, servers, deployment profiles and preferences are encrypted. Tokens, passwords, passphrases and operation history are never exported.</p><div class="inline-form"><input id="backup-passphrase" class="input" type="password" minlength="12" placeholder="Passphrase of at least 12 characters"/><button class="button" data-action="export-config-backup">Export</button><button class="button" data-action="import-config-backup">Import</button></div></section>
<section class="settings-group"><h2>Appearance</h2><div class="field"><label for="appearance-select">Color theme</label><select id="appearance-select" class="select"><option value="dark" ${state.appearance === "dark" ? "selected" : ""}>Dark</option><option value="light" ${state.appearance === "light" ? "selected" : ""}>Light</option><option value="system" ${state.appearance === "system" ? "selected" : ""}>Follow system</option></select></div></section>
<section class="settings-group danger-zone"><h2>Danger zone</h2><p>Reset removes local ForgeFlow configuration, repository links, profiles and operation history. It does not modify Git repositories or Gitea.</p><button class="button danger" data-action="reset-app">Reset ForgeFlow</button></section>
</div></div>`;
}
function preflightTone(status) {
return status === "pass"
? "success"
: status === "fail"
? "danger"
: status === "warning"
? "warning"
: "";
}
function renderPreflightChecks(
report,
emptyMessage = "Run the preflight to verify this configuration.",
) {
if (!report?.checks?.length)
return `<div class="empty-state compact"><p>${escapeHtml(emptyMessage)}</p></div>`;
return `<div class="preflight-list">${report.checks.map((item) => `<div class="preflight-row"><span class="preflight-state ${preflightTone(item.status)}">${item.status === "pass" ? icon("check") : item.status === "fail" ? icon("error") : icon("warning")}</span><div><strong>${escapeHtml(item.label)}</strong><span>${escapeHtml(item.detail)}</span>${item.help ? `<small>${escapeHtml(item.help)}</small>` : ""}${item.repairAction ? `<button class="button primary compact-button" data-action="${attr(item.repairAction)}" data-profile-id="${attr(ui.selectedProfileId || "")}">${icon("wrench")}${escapeHtml(item.repairLabel || "Repair")}</button>` : ""}</div><span class="status-pill ${preflightTone(item.status)}">${escapeHtml(item.status)}</span></div>`).join("")}</div>`;
}
function renderDiagnostics() {
const prefs = ui.boot.state.preferences || {};
const status = ui.diagnosticsStatus || ui.boot.diagnostics || {};
const report = ui.systemPreflight;
const trouble = ui.troubleshooter;
const troubleRows =
trouble?.issues
?.map(
(item, index) =>
`<div class="preflight-row"><span class="preflight-state ${item.severity === "error" ? "danger" : "warning"}">${icon(item.severity === "error" ? "error" : "warning")}</span><div><strong>${escapeHtml(item.title)}</strong><span>${escapeHtml(item.repository || "System")} · ${escapeHtml(item.detail)}</span></div>${item.repairable ? `<button class="button ${item.safe ? "primary" : "danger"}" data-action="troubleshooter-repair" data-issue-index="${index}">${icon("wrench")}${item.safe ? "Repair" : "Review & repair"}</button>` : '<span class="status-pill">Manual review</span>'}</div>`,
)
.join("") || "";
return `<div class="page diagnostics-page"><div class="page-header"><div><div class="eyebrow">Local troubleshooting</div><h1>Diagnostics & support bundle</h1><p>ForgeFlow records structured development diagnostics locally while removing tokens, passwords, authorization headers, private keys and user-home paths.</p></div><button class="button primary" data-action="export-diagnostics">${icon("archive")}Export safe bundle</button></div>
<div class="notice success">${icon("shield")}Credentials are never added to the diagnostic bundle. Known runtime secrets are redacted again during export. You can inspect the ZIP before sharing it.</div>
<div class="diagnostic-grid">
<section class="panel"><div class="panel-header"><h2>Log storage</h2><span class="status-pill ${status.lastWriteError ? "danger" : status.enabled ? "success" : ""}">${status.lastWriteError ? "Write error" : status.enabled ? "Recording" : "Disabled"}</span></div><div class="panel-body"><div class="diagnostic-metrics"><div><span>Files</span><strong>${escapeHtml(status.fileCount ?? "")}</strong></div><div><span>Total size</span><strong>${escapeHtml(status.totalSize || "")}</strong></div><div><span>Latest event</span><strong>${status.latestAt ? formatDate(status.latestAt) : "None"}</strong></div><div><span>Retention</span><strong>${escapeHtml(status.retentionDays || prefs.logRetentionDays || 14)} days</strong></div></div><div class="context-summary" style="margin-top:12px"><div class="context-row"><span>Location</span><strong>${escapeHtml(status.directory || "Unavailable")}</strong></div><div class="context-row"><span>Level</span><strong>${escapeHtml(status.level || prefs.diagnosticLevel || "info")}</strong></div>${status.lastWriteError ? `<div class="context-row"><span>Error</span><strong>${escapeHtml(status.lastWriteError)}</strong></div>` : ""}</div><div class="card-actions"><button class="button" data-action="open-diagnostics-folder">${icon("folder")}Open logs</button><button class="button danger" data-action="clear-diagnostics">${icon("trash")}Clear logs</button></div></div></section>
<section class="panel"><div class="panel-header"><h2>Recording policy</h2></div><div class="panel-body"><div class="form-grid"><div class="field"><label>Diagnostic logging</label><select id="diagnostics-enabled" class="select"><option value="true" ${prefs.diagnosticsEnabled !== false ? "selected" : ""}>Enabled</option><option value="false" ${prefs.diagnosticsEnabled === false ? "selected" : ""}>Disabled</option></select></div><div class="field"><label>Minimum level</label><select id="diagnostic-level" class="select"><option value="debug" ${prefs.diagnosticLevel === "debug" ? "selected" : ""}>Debug</option><option value="info" ${!prefs.diagnosticLevel || prefs.diagnosticLevel === "info" ? "selected" : ""}>Info</option><option value="warning" ${prefs.diagnosticLevel === "warning" ? "selected" : ""}>Warning</option><option value="error" ${prefs.diagnosticLevel === "error" ? "selected" : ""}>Error only</option></select></div><div class="field"><label>Retention days</label><input id="diagnostic-retention" class="input" type="number" min="1" max="90" value="${attr(prefs.logRetentionDays || 14)}"/></div><div class="field"><label>Maximum file size (MB)</label><input id="diagnostic-max-file" class="input" type="number" min="1" max="50" value="${attr(prefs.maxLogFileMb || 8)}"/></div></div><button class="button primary" style="margin-top:12px" data-action="save-diagnostics-preferences">Save diagnostic policy</button></div></section>
</div>
<section class="section-block"><div class="section-heading"><div><h2>One-click troubleshooter</h2><span class="meta">Git locks, interrupted operations, branch synchronization and deployment/server inconsistencies</span></div><div class="stack horizontal compact"><button class="button" data-action="run-troubleshooter">${icon("pulse")}Scan everything</button>${trouble?.issues?.some((item) => item.repairable && item.safe) ? `<button class="button primary" data-action="troubleshooter-auto-repair">${icon("wrench")}Repair ${trouble.issues.filter((item) => item.repairable && item.safe).length} safe issue(s)</button>` : ""}</div></div><div class="panel"><div class="preflight-summary">${trouble ? `<span class="status-pill ${trouble.summary.errors ? "danger" : trouble.summary.warnings ? "warning" : "success"}">${trouble.summary.total ? `${trouble.summary.total} issue(s)` : "Healthy"}</span><span>${trouble.summary.errors} errors · ${trouble.summary.warnings} warnings · ${trouble.summary.repairable} repairable</span>` : "<span>Run the troubleshooter to inspect all linked repositories and deployments.</span>"}</div>${troubleRows || '<div class="empty-state compact"><p>No problems detected.</p></div>'}</div></section>
<section class="section-block"><div class="section-heading"><div><h2>System preflight</h2><span class="meta">Git, writable storage, credential protection, folders and Gitea</span></div><button class="button" data-action="run-system-preflight">${icon("shield")}Run checks</button></div><div class="panel"><div class="preflight-summary">${report ? `<span class="status-pill ${report.summary.ready ? "success" : "danger"}">${report.summary.ready ? "Ready" : `${report.summary.blocking.length} blocking`}</span><span>${report.summary.counts.pass} passed · ${report.summary.counts.warning} warnings · ${report.summary.counts.fail} failed</span>` : "<span>Not run in this session</span>"}</div>${renderPreflightChecks(report)}</div></section>
<section class="section-block"><div class="section-heading"><div><h2>Export support bundle</h2><span class="meta">Configuration summary, repository states, operations, preflight and redacted JSONL logs</span></div></div><div class="panel panel-body"><div class="form-grid"><div class="field"><label>Privacy mode</label><select id="diagnostic-privacy" class="select"><option value="standard">Standard · preserve repository names</option><option value="strict">Strict · hash repository and user identifiers</option></select></div></div><div class="card-actions"><button class="button primary" data-action="export-diagnostics">${icon("archive")}Create diagnostic ZIP</button></div>${ui.lastDiagnosticBundle ? `<div class="notice success" style="margin-top:12px">${icon("check")}<div><strong>${escapeHtml(ui.lastDiagnosticBundle.size)} bundle created</strong><p class="mono">SHA-256 ${escapeHtml(ui.lastDiagnosticBundle.sha256)}</p><button class="button ghost" data-action="show-diagnostic-bundle">Show file</button></div></div>` : ""}</div></section>
</div>`;
}
function renderPipelineView() {
const operation = ui.activeDeployment;
if (!operation)
return '<div class="empty-state full"><p>No deployment operation selected.</p></div>';
const logs = (operation.logs || []).join("\n");
return `<div class="deployment-view"><div class="page-header"><div><div class="eyebrow">${escapeHtml(operation.action || "deployment")} · ${escapeHtml(operation.environment || "")}</div><h1>${escapeHtml(operation.repository)}</h1><p>Exact commit <span class="mono">${escapeHtml(operation.sha || "")}</span></p></div><div class="stack horizontal"><button class="button" data-action="refresh-current-operation">${icon("refresh")}Refresh</button>${operation.runUrl ? `<button class="button" data-action="open-run-url">${icon("external")}Open in Gitea</button>` : ""}<button class="button" data-action="close-deployment">Close</button></div></div><section class="pipeline-card"><div class="pipeline-head"><div><h2>${escapeHtml(operation.status)}</h2><p>${escapeHtml(operation.profileName || operation.workflowFile || "")} · ${escapeHtml(operation.shortSha || shortSha(operation.sha))}</p></div><span class="status-pill ${toneForStatus(operation.status)}">${escapeHtml(operation.status)}</span></div><div class="pipeline-stages">${(operation.stages || []).map((stage) => `<div class="pipeline-stage ${stage.status}"><span class="stage-icon">${stage.status === "complete" ? icon("check") : stage.status === "failed" ? icon("error") : stage.status === "active" ? icon("pulse") : icon("clock")}</span><span>${escapeHtml(stage.label)}</span></div>`).join("")}</div></section>${operation.jobs?.length ? `<section class="section-block"><div class="section-heading"><h2>Runner jobs</h2></div><div class="panel"><table class="data-table"><thead><tr><th>Job</th><th>Status</th><th>Started</th><th>Completed</th></tr></thead><tbody>${operation.jobs.map((job) => `<tr><td>${escapeHtml(job.name)}</td><td><span class="status-pill ${toneForStatus(job.conclusion || job.status)}">${escapeHtml(job.conclusion || job.status)}</span></td><td>${job.startedAt ? formatDate(job.startedAt) : "—"}</td><td>${job.completedAt ? formatDate(job.completedAt) : "—"}</td></tr>`).join("")}</tbody></table></div></section>` : ""}<div class="log-view"><div class="log-toolbar"><span>Deployment output</span><button class="button ghost" data-action="copy-logs">${icon("copy")}Copy</button></div><pre class="log-lines">${escapeHtml(logs || "Waiting for operation output…")}</pre></div>${operation.failure ? `<div class="notice danger" style="margin-top:14px">${icon("error")}<div><strong>${escapeHtml(operation.failure.stage)}</strong><p>${escapeHtml(operation.failure.message)}</p></div></div>` : ""}</div>`;
}
function renderStatusbar() {
const state = ui.boot?.state;
const repository = selectedRepository();
const active = operations().filter(
(operation) => !isTerminalOperation(operation.status),
).length;
return `<footer class="statusbar"><div class="statusbar-left"><span class="statusbar-item ${ui.boot?.git?.available ? "success" : "danger"}">${icon("git")}${escapeHtml(ui.boot?.git?.version || "Git unavailable")}</span><span class="statusbar-item">${icon("folder")}${state?.workspaceRoots?.length || 0} roots</span>${repository?.localStatus ? `<span class="statusbar-item">${icon("branch")}${escapeHtml(repository.localStatus.branch.head)}</span>` : ""}</div><div class="statusbar-right">${ui.autoRefreshPending ? `<span class="statusbar-item warning">${icon("refresh")}Change detected</span>` : ""}${active ? `<span class="statusbar-item warning">${icon("pulse")}${active} active</span>` : ""}<span class="statusbar-item">ForgeFlow ${escapeHtml(ui.boot?.appVersion || "")}</span></div></footer>`;
}
function renderSetup() {
const steps = ["Readiness", "Gitea", "Folders", "Discovery", "Ready"];
let body = "";
if (ui.setupStep === 0) {
body = `<div class="setup-body"><h1>Check this computer</h1><p>ForgeFlow verifies Git, writable storage and protected credential support before you enter any connection details.</p><div class="notice" style="margin-top:16px">${icon("shield")}Your Gitea token is entered only inside this local desktop application. It is never included in diagnostic logs or support bundles.</div><div class="setup-preflight">${renderPreflightChecks(ui.systemPreflight, "Run the readiness check to verify this computer.")}</div><div class="setup-support-actions"><button class="button ghost" data-action="export-diagnostics">${icon("archive")}Export setup diagnostics</button><span class="meta">Available even before Gitea is connected.</span></div></div>`;
} else if (ui.setupStep === 1) {
body = `<div class="setup-body"><h1>Connect your Gitea instance</h1><p>Enter the URL and a personal access token created on your own Gitea server. ForgeFlow validates it locally and stores it using operating-system encryption when available.</p><div class="form-grid" style="margin-top:24px"><div class="field full"><label>Instance URL</label><input id="setup-url" class="input" value="${attr(ui.setupDraft.baseUrl)}" placeholder="https://gitea.example.com" /></div><div class="field full"><label>Access token</label><input id="setup-token" class="input" type="password" value="${attr(ui.setupDraft.token)}" placeholder="Paste token locally" autocomplete="off" /></div></div>${ui.setupValidation ? `<div class="notice success" style="margin-top:14px">${icon("check")}Connected as ${escapeHtml(ui.setupValidation.user.login)} · ${ui.setupValidation.repositoryCount} repositories · Gitea ${escapeHtml(ui.setupValidation.version || "version unknown")}</div>` : `<div class="notice" style="margin-top:14px">${icon("shield")}Use the narrowest permissions that allow repository reads and Actions workflow dispatch. The setup guide explains this without requiring you to share the token.</div>`}</div>`;
} else if (ui.setupStep === 2) {
body = `<div class="setup-body"><h1>Select development folders</h1><p>Choose parent folders. ForgeFlow discovers Git working trees below them and matches their origin to Gitea.</p><div class="stack" style="margin-top:22px">${ui.setupDraft.roots.map((root, index) => `<div class="root-row"><input class="input" value="${attr(root)}" readonly/><button class="icon-button" data-action="setup-remove-root" data-index="${index}">${icon("trash")}</button></div>`).join("")}<button class="button" data-action="setup-add-root">${icon("plus")}Add development folder</button></div></div>`;
} else if (ui.setupStep === 3) {
body = `<div class="setup-body"><h1>Discovering repositories</h1><p>Inspecting local Git metadata. Generated folders and nested dependency trees are skipped.</p><div class="discovery-progress"><div class="spinner"></div><strong>Scanning configured folders…</strong></div></div>`;
} else {
body = `<div class="setup-body"><h1>ForgeFlow is ready</h1><p>${ui.setupDraft.discovered.length} local repositories were found. You can add deployment environments after opening a repository.</p><div class="setup-summary"><div class="readiness-row"><span class="state-dot success"></span><div><strong>Gitea connected</strong><span>${escapeHtml(ui.setupDraft.baseUrl)} · ${escapeHtml(ui.setupDraft.user?.login || "user")}</span></div></div><div class="readiness-row"><span class="state-dot success"></span><div><strong>Workspace discovery</strong><span>${ui.setupDraft.roots.length} root folder(s), ${ui.setupDraft.discovered.length} repository/repositories</span></div></div><div class="readiness-row"><span class="state-dot success"></span><div><strong>Safe diagnostics</strong><span>Structured local logs with credential redaction are enabled by default.</span></div></div></div><div class="discovery-list">${
ui.setupDraft.discovered.length
? ui.setupDraft.discovered
.slice(0, 8)
.map(
(item) =>
`<div class="discovery-row">${icon(item.error ? "error" : "git")}<div><strong>${escapeHtml(item.localPath.split(/[\\/]/).pop())}</strong><span>${escapeHtml(item.localPath)}</span></div><span class="status-pill ${item.error ? "danger" : "success"}">${item.error ? "Unreadable" : "Ready"}</span></div>`,
)
.join("")
: '<div class="empty-state compact"><p>No repositories found. You can link or clone repositories later.</p></div>'
}</div></div>`;
}
const nextAction =
ui.setupStep === 0
? ui.systemPreflight?.summary?.ready
? '<button class="button primary" data-action="setup-continue">Continue</button>'
: '<button class="button primary" data-action="setup-run-preflight">Run readiness check</button>'
: ui.setupStep === 1
? '<button class="button primary" data-action="setup-validate">Validate & continue</button>'
: ui.setupStep === 2
? `<button class="button primary" data-action="setup-next" ${ui.setupDraft.roots.length ? "" : "disabled"}>Scan folders</button>`
: ui.setupStep === 4
? '<button class="button primary" data-action="setup-finish">Enter ForgeFlow</button>'
: "";
return `<div class="setup-backdrop"><section class="setup-window"><aside class="setup-sidebar"><img class="setup-brand-logo setup-brand-logo-dark" src="./assets/itworx-wordmark-dark.png" alt="ITWorx.tech"/><img class="setup-brand-logo setup-brand-logo-light" src="./assets/itworx-wordmark-light.png" alt="ITWorx.tech"/><h2>Set up ForgeFlow</h2><p>Local code to controlled deployment.</p>${steps.map((step, index) => `<div class="setup-step ${ui.setupStep === index ? "active" : ui.setupStep > index ? "complete" : ""}"><span class="step-number">${ui.setupStep > index ? "✓" : index + 1}</span><span>${step}</span></div>`).join("")}</aside><div class="setup-content">${body}<footer class="setup-actions"><button class="button" data-action="setup-back" ${ui.setupStep === 0 || ui.setupStep === 3 ? "disabled" : ""}>Back</button>${nextAction}</footer></div></section></div>`;
}
+208
View File
@@ -0,0 +1,208 @@
import { test, expect } from "@playwright/test";
import { writeFile } from "node:fs/promises";
const consoleEntries = new WeakMap();
const runtimeErrors = new WeakMap();
test.beforeEach(async ({ page }, testInfo) => {
const logs = [];
const errors = [];
consoleEntries.set(page, logs);
runtimeErrors.set(page, errors);
await page.emulateMedia({ colorScheme: testInfo.project.metadata.theme, reducedMotion: testInfo.project.metadata.reduced ? "reduce" : "no-preference" });
page.on("console", (message) => logs.push({ type: message.type(), text: message.text() }));
page.on("pageerror", (error) => errors.push({ name: error.name, message: error.message, stack: error.stack }));
await page.goto("/");
await expect(page.locator(".app-shell")).toBeVisible();
const theme = testInfo.project.metadata.theme;
await page.evaluate((requested) => {
document.documentElement.dataset.theme = requested;
localStorage.setItem("forgeflow-demo-theme", requested);
}, theme);
});
test.afterEach(async ({ page }, testInfo) => {
const logs = consoleEntries.get(page) || [];
const errors = runtimeErrors.get(page) || [];
if (testInfo.status !== testInfo.expectedStatus) {
const prefix = testInfo.outputPath("failure");
await writeFile(`${prefix}-console.json`, JSON.stringify({ test: testInfo.title, project: testInfo.project.name, metadata: testInfo.project.metadata, logs, errors }, null, 2));
await writeFile(`${prefix}-dom.html`, await page.content());
await writeFile(`${prefix}-fixture.json`, JSON.stringify({ url: page.url(), viewport: page.viewportSize(), theme: await page.locator("html").getAttribute("data-theme") }, null, 2));
}
expect(errors, "page errors").toEqual([]);
expect(logs.filter((entry) => entry.type === "error"), "console errors").toEqual([]);
});
async function assertSurface(page) {
const audit = await page.evaluate(() => {
const interactive = [...document.querySelectorAll('button,input,select,textarea,a[href],[role="button"]')].filter((element) => {
const style = getComputedStyle(element);
return style.display !== "none" && style.visibility !== "hidden" && element.getBoundingClientRect().width > 0;
});
const unnamed = interactive.filter((element) => !String(element.getAttribute("aria-label") || element.getAttribute("title") || element.labels?.[0]?.textContent || element.textContent || element.value || "").trim());
const outside = interactive.filter((element) => { const rect = element.getBoundingClientRect(); const fixed = ["fixed", "sticky"].includes(getComputedStyle(element).position) || Boolean(element.closest('[role="dialog"]')); return rect.left < -1 || rect.right > innerWidth + 1 || (fixed && (rect.top < -1 || rect.bottom > innerHeight + 1)); });
const text = document.body.innerText;
return {
horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
unnamed: unnamed.map((element) => element.outerHTML.slice(0, 180)),
outside: outside.map((element) => element.outerHTML.slice(0, 180)),
badTokens: ["undefined", "[object Object]", "â", "Â", "Ã"].filter((token) => text.includes(token)),
nullText: /(^|\s)null($|\s)/i.test(text),
headings: [...document.querySelectorAll("h1,h2,h3")].map((heading) => Number(heading.tagName[1])),
};
});
expect(audit.horizontalOverflow).toBe(false);
expect(audit.unnamed).toEqual([]);
expect(audit.outside).toEqual([]);
expect(audit.badTokens).toEqual([]);
expect(audit.nullText).toBe(false);
expect(audit.headings.length).toBeGreaterThan(0);
}
async function assertScrollableWhenOverflowing(page, selector) {
const target = page.locator(selector);
await expect(target).toBeVisible();
await expect(target).toHaveCSS("overflow-y", /auto|scroll/);
let metrics;
await expect.poll(async () => {
metrics = await target.evaluate((element) => ({
connected: element.isConnected,
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
}));
return metrics.connected && metrics.clientHeight > 0;
}).toBe(true);
if (metrics.scrollHeight > metrics.clientHeight + 1) {
await target.evaluate((element) => { element.scrollTop = element.scrollHeight; });
await expect.poll(() => target.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
}
}
test("shell, overview, repositories and settings remain responsive and accessible", async ({ page }, testInfo) => {
await assertSurface(page);
for (const view of ["overview", "deployments", "settings"]) {
await page.locator(`.nav-button[data-action="navigate"][data-view="${view}"]`).click();
await expect(page.locator("main")).toBeVisible();
await assertSurface(page);
}
await expect(page.locator("html")).toHaveAttribute("data-theme", String(testInfo.project.metadata.theme));
if (testInfo.project.metadata.reduced) {
expect(await page.evaluate(() => matchMedia("(prefers-reduced-motion: reduce)").matches)).toBe(true);
}
});
test("repository changes, Git tools and Git Validator complete their primary flow", async ({ page }) => {
await page.locator('[data-action="select-repo"]').first().click();
await expect(page.locator('[data-action="repo-tab"]')).toHaveCount(6);
for (const tab of ["changes", "history", "deployments", "gittools", "validator", "settings"]) {
const control = page.locator(`[data-action="repo-tab"][data-tab="${tab}"]`);
if (await control.count()) {
await control.click();
await expect(control).toHaveClass(/active/);
await assertSurface(page);
}
}
await page.locator('[data-action="repo-tab"][data-tab="validator"]').click();
await expect(page.locator(".validator-score")).toBeVisible();
await assertScrollableWhenOverflowing(page, ".validator-page");
await expect(page.locator("#validator-policy")).toBeVisible();
await page.locator("#validator-policy").selectOption("production");
await expect(page.locator(".validator-hero")).toContainText(/Production policy/i);
await expect(page.locator(".validator-hero")).toContainText(/review required/i);
await page.keyboard.press("Tab");
await expect(page.locator(":focus")).toBeVisible();
});
test("every long application surface retains a working vertical scroll owner", async ({ page }) => {
for (const view of ["overview", "deployments", "diagnostics", "settings"]) {
await page.locator(`.nav-button[data-view="${view}"]`).click();
await assertScrollableWhenOverflowing(page, ".main-canvas");
}
await page.locator('[data-action="select-repo"]').first().click();
for (const tab of ["history", "deployments", "gittools", "validator", "settings"]) {
await page.locator(`[data-action="repo-tab"][data-tab="${tab}"]`).click();
const scrollRoot = page.locator(".repo-content > .tab-page, .repo-content > .validator-page");
if (await scrollRoot.count())
await assertScrollableWhenOverflowing(page, ".repo-content > .tab-page, .repo-content > .validator-page");
}
});
test("deployment inventory supports dense workloads without ambiguous blank cards", async ({ page }) => {
await page.locator('.nav-button[data-action="navigate"][data-view="deployments"]').click();
await expect(page.locator(".deploy-card, .server-inventory-panel .tool-row").first()).toBeVisible();
const cards = page.locator(".deploy-card, .server-inventory-panel .tool-row");
const count = await cards.count();
expect(count).toBeGreaterThan(0);
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('.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);
});
test("dialogs expose semantics, labels, keyboard close and focus restoration", async ({ page }) => {
await page.locator('[data-action="select-repo"]').first().click();
const trigger = page.locator('[data-action="edit-deployment-profile"], [data-action="add-deployment-profile"]').first();
if (await trigger.count()) {
await trigger.focus();
await trigger.click();
const dialog = page.locator('[role="dialog"]');
await expect(dialog).toBeVisible();
await expect(dialog.locator("button").first()).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
}
await assertSurface(page);
});
test("onboarding and updater states remain usable without an existing configuration", async ({ page }) => {
await page.evaluate(() => localStorage.setItem("forgeflow-demo-setup", "false"));
await page.reload();
await expect(page.locator(".setup-window")).toBeVisible();
await expect(page.getByRole("heading", { name: "Check this computer" })).toBeVisible();
await page.locator('[data-action="setup-run-preflight"]').click();
await expect(page.locator('[data-action="setup-continue"]')).toBeEnabled();
await assertSurface(page);
await page.evaluate(() => localStorage.setItem("forgeflow-demo-setup", "true"));
await page.reload();
await page.locator('.nav-button[data-view="settings"]').click();
await page.locator('[data-action="check-updates"]').first().click();
await expect(page.locator(".update-card")).toContainText(/ForgeFlow/i);
await assertSurface(page);
});
test("inventory, deployment safety and failure evidence dialogs are reviewable", async ({ page }) => {
await page.locator('.nav-button[data-view="deployments"]').click();
const reconciliation = page.locator('[data-action="plan-server-reconciliation"]').first();
if (await reconciliation.count()) {
await reconciliation.click();
await expect(page.locator('[role="dialog"]')).toContainText(/reconciliation/i);
await page.keyboard.press("Escape");
}
const keyLifecycle = page.locator('[data-action="manage-deploy-key"]').first();
if (await keyLifecycle.count()) {
await keyLifecycle.click();
await expect(page.locator('[role="dialog"]')).toContainText(/Deploy key lifecycle/i);
await page.keyboard.press("Escape");
}
const preflight = page.locator('[data-action="run-deployment-preflight"]').first();
await preflight.click();
await expect(page.locator('[role="dialog"]')).toContainText(/preflight/i);
await page.keyboard.press("Escape");
const failed = page.locator('[data-action="open-operation"]').last();
if (await failed.count()) {
await failed.click();
await expect(page.locator('[role="dialog"]')).toContainText(/failed|failure|healthcheck/i);
await page.keyboard.press("Escape");
}
await assertSurface(page);
});
+255
View File
@@ -0,0 +1,255 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import configModule from "../src/main/config-store.cjs";
const { ConfigStore } = configModule;
async function storeFixture(t) {
const directory = await mkdtemp(path.join(os.tmpdir(), "forgeflow-config-store-"));
t.after(() => rm(directory, { recursive: true, force: true }));
return { directory, store: new ConfigStore(directory) };
}
test("config migration normalizes legacy deployments, inventory and validator state", async (t) => {
const { store } = await storeFixture(t);
const migrated = store.migrate({
schemaVersion: 2,
workspaceRoots: [" C:/Projects ", "C:/Projects", ""],
favorites: ["Owner/App", "owner/app"],
inventoryReviewDecisions: { server: [{ workloadId: "one" }] },
gitValidator: { policies: { "owner/app": { id: "strict" } }, suppressions: { "owner/app": [{ checkId: "x" }] }, trends: { "owner/app": [{ score: 70 }] } },
deploymentProfiles: {
"owner/app": [{ id: "legacy", provider: "ssh-unraid", remoteFolder: "MyApp", composeFile: "compose.yml", containerName: "MyApp", iconUrl: "https://itworx.tech/assets/itworx-icon.png", serverGitAccess: { deployKeyId: "4" } }],
},
operations: [{ id: "op", runnerLog: "secret", status: "success" }],
});
assert.equal(migrated.schemaVersion, 13);
assert.deepEqual(migrated.workspaceRoots, ["C:/Projects"]);
assert.deepEqual(migrated.favorites, ["owner/app"]);
const profile = migrated.deploymentProfiles["owner/app"][0];
assert.equal(profile.deploymentMode, "push-bundle");
assert.equal(profile.composeService, "myapp");
assert.equal(profile.iconMode, "builtin");
assert.equal("runnerLog" in migrated.operations[0], false);
assert.equal(migrated.gitValidator.policies["owner/app"].id, "strict");
});
test("load creates missing config and recovers malformed JSON", async (t) => {
const { directory, store } = await storeFixture(t);
let state = await store.load();
assert.equal(state.schemaVersion, 13);
assert.equal(JSON.parse(await readFile(store.filePath, "utf8")).appearance, "dark");
await writeFile(store.filePath, "{ malformed", "utf8");
state = await store.load();
assert.equal(state.setupComplete, false);
assert.ok((await readdir(directory)).some((name) => name.includes(".corrupt-")));
});
test("server normalization rejects unsafe targets and preserves bounded scan configuration", async (t) => {
const { store } = await storeFixture(t);
assert.throws(() => store.normalizeServer({ host: "bad host", username: "root" }), /hostname/);
assert.throws(() => store.normalizeServer({ host: "unraid", username: "bad user" }), /username/);
assert.throws(() => store.normalizeServer({ host: "unraid", username: "root", basePath: "relative" }), /absolute Unix/);
const server = store.normalizeServer({ host: "unraid.local", username: "root", port: 70000, basePath: "/mnt/user/appdata/", scanRoots: ["/mnt/user/appdata/", "relative"], scanExcludes: ["backup*", "bad/path"], authType: "privateKey", privateKeyPath: "C:/key" });
assert.equal(server.port, 65535);
assert.deepEqual(server.scanRoots, ["/mnt/user/appdata"]);
assert.deepEqual(server.scanExcludes, ["backup*"]);
store.data.servers = [{ ...server, encryptedPassword: "hidden", encryptedPassphrase: "hidden" }];
assert.equal(store.getPublicServer(store.data.servers[0]).hasPassphrase, true);
assert.equal("encryptedPassword" in store.getPublicState().servers[0], false);
assert.throws(() => store.getServerCredentials("missing"), /no longer exists/);
});
test("deployment profiles validate both Gitea Actions and safe Unraid topology", async (t) => {
const { store } = await storeFixture(t);
const actions = await store.saveDeploymentProfile("Owner/App", { id: "actions", environment: "production", branch: "main", provider: "gitea-actions", workflowFile: "deploy.yml", rollbackWorkflowFile: "rollback.yml", statusUrl: "https://app.test/status" });
assert.equal(actions.provider, "gitea-actions");
const unraid = await store.saveDeploymentProfile("Owner/App", { id: "unraid", name: "Production", environment: "production", branch: "main", provider: "ssh-unraid", serverId: "server", remoteFolder: "App", deploymentMode: "server-git", composeFiles: ["compose.yml"], composeServices: ["Web", "worker"], containerName: "Visible-App", cloneUrl: "git@gitea.test:owner/app.git", hostPort: 99999, containerPort: 0, webUiUrl: "http://[IP]:[PORT:3000]/", iconMode: "none", dockerShell: "/bin/bash", preservePaths: [".env", "data"], composeProject: "App_prod", composeWorkingDir: "/mnt/user/appdata/App", serverGitAccess: { configured: true, deployKeyId: "42", keyFingerprint: "SHA256:key", hostFingerprint: "SHA256:host" } });
assert.equal(unraid.composeService, "web");
assert.deepEqual(unraid.composeServices, ["web", "worker"]);
assert.equal(unraid.hostPort, 65535);
assert.equal(unraid.containerPort, null);
assert.equal(unraid.serverGitAccess.deployKeyId, 42);
assert.equal(store.getDeploymentProfiles("owner/app").length, 2);
assert.equal(store.getDeploymentProfile("owner/app", "unraid").containerName, "Visible-App");
assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", environment: "prod", branch: "main", remoteFolder: "../escape", composeFiles: ["compose.yml"] }), /escape|relative path|safe path/i);
assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", environment: "prod", branch: "main", remoteFolder: "app", composeFiles: ["compose.yml"], composeService: "bad service" }), /Compose service/);
await store.deleteDeploymentProfile("owner/app", "actions");
assert.equal(store.getDeploymentProfiles("owner/app").length, 1);
});
test("configuration mutations persist mappings, favorites, reviews, trends, operations and bounded preferences", async (t) => {
const { store } = await storeFixture(t);
await store.saveMapping("Owner/App", "C:/Projects/App");
assert.equal(store.data.repositoryMappings["owner/app"], "C:/Projects/App");
await store.setFavorite("Owner/App", true);
await store.setFavorite("Owner/App", false);
assert.deepEqual(store.data.favorites, []);
await store.setUpdatePreferences({ owner: " Team ", repo: " App ", branch: "release/1", autoCheck: false });
assert.equal(store.data.updates.branch, "release/1");
await store.saveInventoryReviewDecision("server", { workloadId: "workload", evidenceHash: "a".repeat(64), action: "monitor-only" });
assert.equal(store.getInventoryReviewDecisions("server").length, 1);
await store.deleteInventoryReviewDecision("server", "workload");
await assert.rejects(() => store.saveInventoryReviewDecision("", {}), /evidence hash/);
await store.setGitValidatorPolicy("Owner/App", { id: "production" });
await store.addGitValidatorSuppression("Owner/App", { checkId: "signed-tags" });
await store.appendGitValidatorTrend("Owner/App", { score: 81 });
assert.equal(store.getGitValidatorState("owner/app").trends[0].score, 81);
await store.saveDeploymentState("profile", { liveSha: "a".repeat(40) });
assert.equal(store.getDeploymentState("profile").liveSha.length, 40);
await store.addOperation({ id: "operation", status: "running" });
await store.addOperation({ id: "operation", status: "success" });
assert.equal(store.getOperation("operation").status, "success");
const state = await store.setPreferences({ repositoryPollSeconds: 0, operationPollSeconds: 999, fetchIntervalMinutes: 999, preferredCloneProtocol: "invalid", diagnosticLevel: "invalid", logRetentionDays: 0, maxLogFileMb: 100, editor: { executable: "code", args: ["{file}"] }, terminal: null, closeToTray: true, startAtLogin: true });
assert.equal(state.preferences.repositoryPollSeconds, 4);
assert.equal(state.preferences.operationPollSeconds, 120);
assert.equal(state.preferences.fetchIntervalMinutes, 240);
assert.equal(state.preferences.preferredCloneProtocol, "https");
assert.equal(state.preferences.diagnosticLevel, "info");
assert.equal(state.preferences.maxLogFileMb, 50);
await store.removeMapping("owner/app");
assert.equal(store.data.repositoryMappings["owner/app"], undefined);
});
test("server deletion removes only linked profiles and their deployment state", async (t) => {
const { store } = await storeFixture(t);
store.data.servers = [{ id: "remove", host: "old" }, { id: "keep", host: "new" }];
store.data.deploymentProfiles = {
"owner/app": [{ id: "old-profile", serverId: "remove" }, { id: "keep-profile", serverId: "keep" }],
"owner/only-old": [{ id: "only-old", serverId: "remove" }]
};
store.data.deploymentStates = { "old-profile": { healthy: true }, "only-old": { healthy: true }, "keep-profile": { healthy: true } };
await store.deleteServer("remove");
assert.deepEqual(store.data.servers.map((server) => server.id), ["keep"]);
assert.deepEqual(store.data.deploymentProfiles["owner/app"].map((profile) => profile.id), ["keep-profile"]);
assert.equal(store.data.deploymentProfiles["owner/only-old"], undefined);
assert.equal(store.data.deploymentStates["old-profile"], undefined);
assert.equal(store.data.deploymentStates["only-old"], undefined);
assert.equal(store.data.deploymentStates["keep-profile"].healthy, true);
});
test("configuration restore retains credentials only for unchanged endpoints and never restores operations", async (t) => {
const { store } = await storeFixture(t);
store.data.gitea = { baseUrl: "https://gitea.test", user: { login: "jens" }, encryptedToken: "encrypted-token" };
store.data.servers = [
{ ...store.normalizeServer({ id: "same", host: "same", username: "root", authType: "password" }), encryptedPassword: "password", encryptedPassphrase: null },
{ ...store.normalizeServer({ id: "changed", host: "old", username: "root", authType: "privateKey", privateKeyPath: "C:/old" }), encryptedPassword: null, encryptedPassphrase: "passphrase" }
];
store.data.operations = [{ id: "current-operation" }];
const backup = structuredClone(store.data);
backup.servers[1].host = "new";
backup.operations = [{ id: "untrusted-operation" }];
await store.restoreConfiguration(backup);
assert.equal(store.data.gitea.encryptedToken, "encrypted-token");
assert.equal(store.getServer("same").encryptedPassword, "password");
assert.equal(store.getServer("changed").encryptedPassphrase, null);
assert.deepEqual(store.data.operations, [{ id: "current-operation" }]);
await store.restoreConfiguration({ ...backup, gitea: { ...backup.gitea, baseUrl: "https://other.test" } });
assert.equal(store.data.gitea.encryptedToken, null);
});
test("profile, review and operation lookups return safe empty values", async (t) => {
const { store } = await storeFixture(t);
assert.equal(store.getPublicServer(null), null);
assert.equal(store.getServer("missing"), null);
assert.deepEqual(store.getDeploymentProfiles("missing/repo"), []);
assert.equal(store.getDeploymentProfile("missing/repo", "profile"), null);
assert.deepEqual(store.getInventoryReviewDecisions("missing"), []);
assert.equal(store.getDeploymentState("missing"), null);
assert.equal(store.getOperation("missing"), null);
assert.deepEqual(store.getGitValidatorState("missing/repo"), { policy: { id: "standard" }, suppressions: [], trends: [] });
await store.deleteInventoryReviewDecision("missing", "workload");
await store.deleteDeploymentProfile("missing/repo", "profile");
});
test("session credentials preserve, replace and clear safely when OS encryption is unavailable", async (t) => {
const { store } = await storeFixture(t);
assert.deepEqual(store.setToken(" session-token "), { persistent: false, preserved: false });
assert.equal(store.getToken(), "session-token");
assert.deepEqual(store.setToken("", { preserveExisting: true }), { persistent: false, preserved: true });
assert.equal(store.getToken(), "session-token");
assert.deepEqual(store.setToken("replacement"), { persistent: false, preserved: false });
assert.equal(store.getToken(), "replacement");
assert.deepEqual(store.setToken(""), { persistent: true, preserved: false });
assert.equal(store.getToken(), "");
assert.throws(() => store.encryptSecret("password"), (error) => error.code === "SECURE_STORAGE_UNAVAILABLE");
assert.equal(store.encryptSecret(""), null);
assert.equal(store.decryptSecret(null), "");
assert.equal(store.decryptSecret("not-base64-encrypted-data"), "");
});
test("setup, Gitea updates and generic patches retain normalized public state", async (t) => {
const { store } = await storeFixture(t);
const completed = await store.completeSetup({
baseUrl: "https://gitea.test", token: "token", user: { login: "jens" },
workspaceRoots: [" C:/Projects ", "C:/Projects", ""]
});
assert.equal(completed.state.setupComplete, true);
assert.equal(completed.state.gitea.hasToken, true);
assert.deepEqual(completed.state.workspaceRoots, ["C:/Projects"]);
const update = await store.updateGitea({ baseUrl: "https://new.test", token: "", user: null });
assert.equal(update.preserved, true);
assert.equal(store.data.gitea.user.login, "jens");
const patched = await store.patch({ appearance: "light", workspaceRoots: ["D:/Code", "D:/Code"] });
assert.equal(patched.appearance, "light");
assert.deepEqual(patched.workspaceRoots, ["D:/Code"]);
assert.equal("encryptedToken" in patched.gitea, false);
});
test("server saves reject absent credentials before mutating configuration", async (t) => {
const { store } = await storeFixture(t);
await assert.rejects(
store.saveServer({ host: "unraid", username: "root", authType: "password", basePath: "/mnt/apps" }),
/password is required/i
);
await assert.rejects(
store.saveServer({ host: "unraid", username: "root", authType: "privateKey", basePath: "/mnt/apps", privateKeyPath: "" }),
/select a private key/i
);
assert.deepEqual(store.data.servers, []);
});
test("deployment profile normalization covers safe defaults and every optional Unraid control", async (t) => {
const { store } = await storeFixture(t);
const actions = store.normalizeDeploymentProfile({ environment: "qa", statusUrl: "https://app.test/status" });
assert.equal(actions.provider, "gitea-actions");
assert.equal(actions.name, "qa");
assert.equal(actions.branch, "main");
assert.equal(actions.workflowFile, "deploy.yml");
assert.equal(actions.rollbackWorkflowFile, "");
assert.equal(actions.confirmationRequired, true);
const unraid = store.normalizeDeploymentProfile({
id: "all-options", name: " Server ", environment: "production", provider: "ssh-unraid", branch: "release",
serverId: " server ", remoteFolder: "apps/App", deploymentMode: "monitor-only", generatedCompose: true,
composeFiles: [], composeServices: ["WEB", "Worker"], composeProject: "App.prod", composeWorkingDir: "/mnt/apps/App",
containerName: "Visible.App", cloneUrl: "https://gitea.test/Owner/App.git", alignRemote: true,
hostPort: -2, containerPort: 70000, webUiUrl: "http://[IP]:[PORT:3000]/", iconMode: "upload",
iconFilePath: "C:/icon.png", dockerShell: "/bin/bash", preservePaths: [], adoptedFromServer: true,
serverSourceOfTruth: true, manageDockerMan: true, forceRecreate: true, removeOrphans: true,
workloadIdentity: { workloadId: "one" }, serverGitAccess: { configured: true, deployKeyId: "invalid", keyFingerprint: "", hostFingerprint: "", configuredAt: "now" },
provenance: { remoteFolder: "server" }, detectedMetadata: { source: "docker" }, serverIconReference: " icon ",
deploymentPolicy: { frozen: true, freezeReason: " maintenance ", requireNote: true, maintenanceWindows: [{ days: [0, 0, 6, 7, "bad"], start: "01:00", end: "02:00" }] }
});
assert.equal(unraid.name, "Server");
assert.equal(unraid.serverId, "server");
assert.equal(unraid.composeFile, "docker-compose.yml");
assert.deepEqual(unraid.composeServices, ["web", "worker"]);
assert.equal(unraid.hostPort, 1);
assert.equal(unraid.containerPort, 65535);
assert.equal(unraid.iconMode, "upload");
assert.equal(unraid.dockerShell, "/bin/bash");
assert.equal(unraid.serverGitAccess.deployKeyId, null);
assert.equal(unraid.serverGitAccess.keyFingerprint, null);
assert.deepEqual(unraid.deploymentPolicy.maintenanceWindows[0].days, [0, 6]);
assert.equal(unraid.serverIconReference, "icon");
assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", remoteFolder: "app", environment: "prod", composeService: "app", composeServices: ["bad service"] }), /Compose services/);
assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", remoteFolder: "app", environment: "prod", composeProject: "bad project!" }), /Compose project/);
assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", remoteFolder: "app", environment: "prod", composeWorkingDir: "relative" }), /working directory/);
assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", remoteFolder: "app", environment: "prod", containerName: "bad name" }), /Container name/);
});
+18
View File
@@ -1,8 +1,13 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import backupModule from '../src/main/configuration-backup.cjs'; import backupModule from '../src/main/configuration-backup.cjs';
import configModule from '../src/main/config-store.cjs';
const { sanitizeConfiguration, createEncryptedBackup, readEncryptedBackup } = backupModule; const { sanitizeConfiguration, createEncryptedBackup, readEncryptedBackup } = backupModule;
const { ConfigStore } = configModule;
test('configuration backups exclude credentials and operation history', () => { test('configuration backups exclude credentials and operation history', () => {
const clean = sanitizeConfiguration({ const clean = sanitizeConfiguration({
@@ -25,3 +30,16 @@ test('configuration backups round-trip with authenticated encryption', () => {
assert.equal(restored.configuration.gitea.encryptedToken, null); assert.equal(restored.configuration.gitea.encryptedToken, null);
assert.throws(() => readEncryptedBackup(serialized, 'incorrect passphrase'), /could not be decrypted/i); assert.throws(() => readEncryptedBackup(serialized, 'incorrect passphrase'), /could not be decrypted/i);
}); });
test('recovery snapshots preserve the exact in-memory configuration before a mutation', async (context) => {
const directory = await mkdtemp(path.join(tmpdir(), 'forgeflow-config-snapshot-'));
context.after(() => rm(directory, { recursive: true, force: true }));
const store = new ConfigStore(directory);
store.data.workspaceRoots = ['C:/Projects'];
store.data.deploymentProfiles = { 'jens/example': [{ id: 'production', provider: 'gitea-actions' }] };
await store.save();
const before = `${JSON.stringify(store.data, null, 2)}\n`;
const snapshot = await store.createRecoverySnapshot('server reconciliation / production');
assert.equal(await readFile(snapshot.filePath, 'utf8'), before);
assert.equal(snapshot.reason, 'server-reconciliation-production');
});
+139
View File
@@ -0,0 +1,139 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { DeployKeyLifecycleService } = require("../src/main/deploy-key-lifecycle-service.cjs");
const { UnraidDeployKeyHost, parseDeployKeyMarker } = require("../src/main/unraid-deploy-key-host.cjs");
const repository = { fullName: "Jens/Portfolio" };
const baseProfile = { id: "production", serverId: "unraid", deploymentMode: "server-git", serverGitAccess: { configured: true, deployKeyId: 7, keyFingerprint: "SHA256:old", hostFingerprint: "SHA256:host" } };
function fixture(overrides = {}) {
let profile = structuredClone(baseProfile);
const events = [];
const remoteKeys = overrides.remoteKeys || [{ id: 7, title: "ForgeFlow old", read_only: true, key: "ssh-ed25519 T0xE old" }];
const store = {
getDeploymentProfile: () => profile,
getServer: () => ({ id: "unraid", name: "Unraid" }),
getRepositories: () => [{ fullName: repository.fullName }],
getDeploymentProfiles: () => [profile, ...(overrides.otherProfiles || [])],
saveDeploymentProfile: async (_name, value) => { if (overrides.saveFails) throw new Error("switch failed"); profile = structuredClone(value); events.push("profile-saved"); return profile; },
createRecoverySnapshot: async () => ({ filePath: "snapshot.json" }),
};
const gitea = {
listDeployKeys: async () => structuredClone(remoteKeys),
createReadOnlyDeployKey: async () => { if (overrides.registrationFails) throw new Error("registration failed"); return { id: 8, title: "new", read_only: overrides.writable !== true, key: "ssh-ed25519 TkVX new" }; },
deleteDeployKey: async (_owner, _repo, id) => { events.push(`delete:${id}`); if (overrides.deleteOldFails && id === 7) throw new Error("old revoke failed"); return { deleted: true }; },
};
const keyHost = {
inspect: async () => overrides.inspect || ({ privateKeyPresent: true, publicKey: "ssh-ed25519 T0xE old", fingerprint: overrides.changedFingerprint ? "SHA256:changed" : "SHA256:old", hostFingerprint: "SHA256:host" }),
backup: async () => ({ recovery: "server-backup", publicKey: "ssh-ed25519 T0xE old" }),
generate: async () => ({ publicKey: "ssh-ed25519 TkVX new", fingerprint: "SHA256:new" }),
verifyCandidate: async () => overrides.verifyFails ? ({ ready: false, fingerprint: "SHA256:new" }) : ({ ready: true, fingerprint: "SHA256:new", hostFingerprint: "SHA256:host", remoteSha: "a".repeat(40) }),
preflightCandidate: async () => { if (overrides.preflightFails) throw new Error("preflight failed"); events.push("preflight"); },
promote: async () => { events.push("promote"); },
verifyActive: async () => overrides.postFails ? ({ ready: false }) : ({ ready: true, fingerprint: "SHA256:new" }),
commit: async () => { events.push("commit"); },
rollback: async () => { events.push("rollback"); },
revoke: async () => { events.push("revoke-server"); if (overrides.revokeFails) throw new Error("server revoke failed"); },
restore: async () => ({ publicKey: "ssh-ed25519 UkVTVE9SRQ restored", fingerprint: "SHA256:restored", hostFingerprint: "SHA256:host" }),
};
const audit = { append: async (name) => events.push(name) };
const service = new DeployKeyLifecycleService({ store, gitea, keyHost, audit, clock: () => "2026-07-29T00:00:00.000Z" });
return { service, events, getProfile: () => profile };
}
test("deploy-key rotation verifies, switches, revokes and post-verifies in order", async () => {
const { service, events, getProfile } = fixture();
const plan = await service.planRotation({ repository, profileId: "production" });
const result = await service.rotate({ repository, profileId: "production", expectedPlanId: plan.id });
assert.equal(result.profile.serverGitAccess.deployKeyId, 8);
assert.equal(getProfile().serverGitAccess.keyFingerprint, "SHA256:new");
assert.deepEqual(events.filter((event) => ["preflight", "promote", "profile-saved", "delete:7", "commit"].includes(event)), ["preflight", "promote", "profile-saved", "delete:7", "commit"]);
});
for (const [name, overrides, message] of [
["registration failure", { registrationFails: true }, /registration failed/],
["writable candidate", { writable: true }, /write access/],
["candidate verification failure", { verifyFails: true }, /could not prove/],
["candidate preflight failure", { preflightFails: true }, /preflight failed/],
["atomic profile switch failure", { saveFails: true }, /switch failed/],
["old key revocation failure", { deleteOldFails: true }, /old revoke failed/],
["post-rotation failure", { postFails: true }, /Post-rotation verification failed/],
]) test(`deploy-key rotation rolls back on ${name}`, async () => {
const { service, events } = fixture(overrides);
const plan = await service.planRotation({ repository, profileId: "production" });
await assert.rejects(service.rotate({ repository, profileId: "production", expectedPlanId: plan.id }), message);
assert.ok(events.includes("rollback"));
});
test("rotation rejects a stale content-addressed plan", async () => {
const { service } = fixture();
await assert.rejects(service.rotate({ repository, profileId: "production", expectedPlanId: "stale" }), (error) => error.code === "DEPLOY_KEY_ROTATION_PLAN_STALE");
});
test("inventory detects stale, orphaned, shared, conflicting and changed-fingerprint keys", async () => {
const { service } = fixture({
changedFingerprint: true,
remoteKeys: [{ id: 9, title: "ForgeFlow orphan", read_only: true, key: "ssh-ed25519 T1JQSEFO orphan" }, { id: 10, title: "writable", read_only: false, key: "ssh-ed25519 T0xE old" }],
otherProfiles: [{ id: "staging", serverId: "unraid", serverGitAccess: { deployKeyId: 11, keyFingerprint: "SHA256:changed" } }],
});
const report = await service.inventory({ repository, profileId: "production" });
assert.equal(report.stale, true);
assert.equal(report.orphaned.length, 1);
assert.equal(report.shared.length, 1);
assert.equal(report.conflicts.length, 1);
assert.equal(report.ready, false);
});
test("revocation requires reviewed impact, disables pull and preserves recovery", async () => {
const { service, events, getProfile } = fixture();
const plan = await service.planRevocation({ repository, profileId: "production" });
assert.equal(plan.containersUnaffected, true);
await assert.rejects(service.revoke({ repository, profileId: "production" }), (error) => error.code === "DEPLOY_KEY_REVOCATION_PLAN_REQUIRED");
const result = await service.revoke({ repository, profileId: "production", expectedPlanId: plan.id });
assert.equal(result.recovery, "server-backup");
assert.equal(getProfile().deploymentMode, "monitor-only");
assert.ok(events.includes("delete:7"));
assert.ok(events.includes("revoke-server"));
});
test("revoked access can be restored and verified", async () => {
const { service } = fixture();
const result = await service.restore({ repository, profileId: "production" });
assert.equal(result.profile.deploymentMode, "server-git");
assert.equal(result.profile.serverGitAccess.keyFingerprint, "SHA256:restored");
assert.equal(result.proof.ready, true);
});
test("failed server revocation restores repository access", async () => {
const { service, events, getProfile } = fixture({ revokeFails: true });
const plan = await service.planRevocation({ repository, profileId: "production" });
await assert.rejects(service.revoke({ repository, profileId: "production", expectedPlanId: plan.id }), /server revoke failed/);
assert.equal(getProfile().deploymentMode, "server-git");
assert.ok(events.includes("delete:7"));
});
test("Unraid key host parser rejects unverifiable output", () => {
assert.throws(() => parseDeployKeyMarker("ordinary ssh output", "__FORGEFLOW_KEY__"), /did not return/);
});
test("Unraid candidate generation returns public evidence and paths but never private key content", async () => {
const publicKey = "ssh-ed25519 TkVX forgeflow";
const ssh = { exec: async () => ({ stdout: `__FORGEFLOW_KEY_CANDIDATE__\npublicKey=${Buffer.from(publicKey).toString("base64")}\nfingerprint=SHA256:new\nhostFingerprint=SHA256:host\n` }) };
const host = new UnraidDeployKeyHost({ ssh });
const candidate = await host.generate({ repository: { fullName: "Jens/Portfolio" }, server: { id: "unraid", basePath: "/mnt/user/appdata" } });
assert.equal(candidate.publicKey, publicKey);
assert.equal(candidate.fingerprint, "SHA256:new");
assert.equal(candidate.privateKey, undefined);
assert.match(candidate.paths.privateKey, /candidate-[0-9a-f-]+\/deploy-key$/);
});
test("Unraid active key inspection exposes only public metadata", async () => {
const publicKey = "ssh-ed25519 T0xE forgeflow";
const ssh = { exec: async () => ({ stdout: `__FORGEFLOW_KEY_INSPECT__\nprivateKeyPresent=true\npublicKey=${Buffer.from(publicKey).toString("base64")}\nfingerprint=SHA256:old\nhostFingerprint=SHA256:host\n` }) };
const host = new UnraidDeployKeyHost({ ssh });
const evidence = await host.inspect({ repository: { fullName: "Jens/Portfolio" }, server: { id: "unraid", basePath: "/mnt/user/appdata" } });
assert.deepEqual(evidence, { privateKeyPresent: true, publicKey, fingerprint: "SHA256:old", hostFingerprint: "SHA256:host" });
});
+20
View File
@@ -71,6 +71,26 @@ test('requires exact server SHA and matching request ID after a successful workf
}).message, /exit code 70/i); }).message, /exit code 70/i);
}); });
test('server verification reports every incomplete or mismatched evidence field', () => {
const sha = 'a'.repeat(40);
const operation = { id: 'request-1', repository: 'jens/app', environment: 'production', sha, shortSha: sha.slice(0, 7) };
const valid = { statusConfigured: true, statusReachable: true, statusRepository: operation.repository, statusEnvironment: operation.environment, liveSha: sha, requestedSha: sha, requestId: operation.id, lastExitCode: 0, healthy: true };
const cases = [
[{ ...valid, statusConfigured: false }, /is configured/i],
[{ ...valid, statusRepository: '' }, /did not identify its repository/i],
[{ ...valid, statusEnvironment: '' }, /did not identify its environment/i],
[{ ...valid, statusEnvironment: 'staging' }, /belongs to staging/i],
[{ ...valid, liveSha: '' }, /valid full commit SHA/i],
[{ ...valid, requestedSha: '' }, /requested commit SHA/i],
[{ ...valid, requestedSha: 'b'.repeat(40) }, /different requested commit/i],
[{ ...valid, requestId: '' }, /deployment request ID/i],
[{ ...valid, lastExitCode: null }, /exit code unknown/i],
[{ ...valid, healthy: false, healthStatus: 'degraded' }, /degraded/i],
[{ ...valid, healthy: null, error: 'probe failed' }, /probe failed/i]
];
for (const [state, pattern] of cases) assert.match(applicationVerificationFailure(operation, state).message, pattern);
});
test('rollback accepts only the currently reported previous SHA and dispatches controlled inputs', async () => { test('rollback accepts only the currently reported previous SHA and dispatches controlled inputs', async () => {
const liveSha = 'a'.repeat(40); const liveSha = 'a'.repeat(40);
const previousSha = 'b'.repeat(40); const previousSha = 'b'.repeat(40);
+67
View File
@@ -0,0 +1,67 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const {
normalizePolicy,
validateSuppression,
applyPolicy,
buildTrend,
exportReport,
} = require("../src/main/git-validator-policy.cjs");
test("Git Validator policies enforce score, blockers and enabled checks", () => {
const policy = normalizePolicy({ id: "organization", label: "ITWorx", requiredScore: 88, enabledChecks: ["security", "readme"], blockingChecks: ["security"] });
const governed = applyPolicy([
{ id: "security", status: "warning", category: "Security", weight: 10 },
{ id: "readme", status: "pass", category: "Collaboration", weight: 5 },
{ id: "ignored", status: "error", category: "Other", weight: 5 },
], policy, []);
assert.equal(governed.policy.requiredScore, 88);
assert.deepEqual(governed.checks.map((check) => check.id), ["security", "readme"]);
assert.equal(governed.checks[0].blocking, true);
});
test("built-in policies enforce their declared blocking severities", () => {
const finding = [{ id: "readme", status: "warning", category: "Documentation", weight: 5 }];
assert.equal(applyPolicy(finding, { id: "minimal" }, []).checks[0].blocking, false);
for (const id of ["standard", "strict", "production"])
assert.equal(applyPolicy(finding, { id }, []).checks[0].blocking, true, `${id} must block active warnings`);
});
test("documented suppressions remove active blockers", () => {
const now = new Date("2026-07-01T00:00:00.000Z");
const suppression = validateSuppression({ checkId: "readme", reason: "Tracked remediation work", author: "Jens", expiresAt: "2026-07-08T00:00:00.000Z", evidence: "ticket:FF-7" }, normalizePolicy({ id: "standard" }), now);
const check = applyPolicy([{ id: "readme", status: "warning", category: "Documentation", weight: 5 }], { id: "standard" }, [suppression], now).checks[0];
assert.equal(check.suppressed, true);
assert.equal(check.blocking, false);
});
test("suppressions require accountable evidence and reactivate after expiry", () => {
const now = new Date("2026-07-01T00:00:00.000Z");
const suppression = validateSuppression({ checkId: "signed-tags", reason: "Tracked under release hardening", author: "Jens", ticket: "FF-42", expiresAt: "2026-07-08T00:00:00.000Z", scope: "repository", evidence: "sha:abc" }, normalizePolicy({ id: "standard" }), now);
let governed = applyPolicy([{ id: "signed-tags", status: "warning", category: "Governance", weight: 5 }], { id: "standard" }, [suppression], now);
assert.equal(governed.checks[0].suppressed, true);
governed = applyPolicy(governed.checks, { id: "standard" }, [suppression], new Date("2026-07-09T00:00:00.000Z"));
assert.equal(governed.checks[0].suppressed, false);
assert.equal(governed.checks[0].expiredSuppression.id, suppression.id);
assert.throws(() => validateSuppression({ checkId: "x", reason: "short", author: "a", expiresAt: "2026-07-02", evidence: "x" }, normalizePolicy(), now), /requires/);
});
test("trends report new, resolved and regressed findings without false precision", () => {
const previous = { checks: [{ id: "a", status: "warning" }, { id: "b", status: "error" }, { id: "c", status: "pass" }] };
const report = { score: 74, categories: { Security: 50 }, checkedAt: "2026-07-02T00:00:00Z", commitSha: "abc", checks: [{ id: "a", status: "error" }, { id: "b", status: "pass" }, { id: "c", status: "warning", suppressed: true }] };
const trend = buildTrend(previous, report);
assert.deepEqual(trend.regressions, ["a"]);
assert.deepEqual(trend.resolved.sort(), ["b"]);
assert.deepEqual(trend.suppressions, ["c"]);
});
test("reports export as JSON, Markdown and standalone escaped HTML", () => {
const report = { repository: "jens/<app>", score: 80, commitSha: "abc", policy: { label: "Production" }, checks: [{ id: "readme", category: "Collaboration", status: "pass", detail: "Safe & ready" }] };
assert.doesNotThrow(() => JSON.parse(exportReport(report, "json").content));
assert.match(exportReport(report, "markdown").content, /\| readme \|/);
const html = exportReport(report, "html").content;
assert.match(html, /<!doctype html>/);
assert.match(html, /jens\/&lt;app&gt;/);
});
+18
View File
@@ -100,6 +100,24 @@ test("Git Validator recognizes remote aliases and secret-shaped tracked paths",
assert.equal(isSensitiveTrackedPath(".env.example"), false); assert.equal(isSensitiveTrackedPath(".env.example"), false);
}); });
test("Git Validator rejects stale or forged repair requests", async () => {
const validator = new GitValidatorService({ git: new GitService() });
validator.scan = async () => ({
checks: [{ id: "local-safety", fixAction: "configure-local-safety", status: "warning" }],
});
assert.equal(
(await validator.resolveRepairCheck({}, { id: "local-safety", fixAction: "configure-local-safety" })).id,
"local-safety",
);
await assert.rejects(
validator.resolveRepairCheck({}, { id: "local-safety", fixAction: "align-origin" }),
/stale/i,
);
await assert.rejects(
validator.resolveRepairCheck({}, { id: "resolved-check", fixAction: "align-origin" }),
/resolved|no longer repairable/i,
);
});
test("Git Validator reports reproducibility, CI and editor hygiene and creates reviewable defaults", async (t) => { test("Git Validator reports reproducibility, CI and editor hygiene and creates reviewable defaults", async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), "forgeflow-hygiene-")); const root = await mkdtemp(path.join(os.tmpdir(), "forgeflow-hygiene-"));
t.after(() => rm(root, { recursive: true, force: true })); t.after(() => rm(root, { recursive: true, force: true }));
+147
View File
@@ -156,6 +156,19 @@ test('uses a release-provided browser download URL without requesting metadata a
assert.equal(requested, 'https://gitea.example.test/attachments/direct.exe'); assert.equal(requested, 'https://gitea.example.test/attachments/direct.exe');
}); });
test('rewrites Gitea internal HTTP release URLs to the configured public origin', async () => {
const service = new GiteaService(makeStore());
let requested = '';
service.downloadAuthenticated = async (pathname) => {
requested = pathname;
return Buffer.from('asset');
};
await service.downloadReleaseAsset('Jens', 'ForgeFlow', 107, 412, {
downloadUrl: 'http://192.168.10.150:3000/Jens/ForgeFlow/releases/download/v0.10.1/ForgeFlow.exe',
});
assert.equal(requested, 'https://gitea.example.test/Jens/ForgeFlow/releases/download/v0.10.1/ForgeFlow.exe');
});
test('creates conservative default branch protection rules', async () => { test('creates conservative default branch protection rules', async () => {
const service = new GiteaService(makeStore()); const service = new GiteaService(makeStore());
let request = null; let request = null;
@@ -194,3 +207,137 @@ test('creates repository-scoped read-only deploy keys and reuses only safe match
(error) => error.code === 'DEPLOY_KEY_NOT_READ_ONLY', (error) => error.code === 'DEPLOY_KEY_NOT_READ_ONLY',
); );
}); });
test('request sends scoped credentials, parses response types and redacts rejected secrets', async (context) => {
const originalFetch = globalThis.fetch;
context.after(() => { globalThis.fetch = originalFetch; });
const calls = [];
const diagnostics = { debug: async (...args) => calls.push(['debug', ...args]), warning: async (...args) => calls.push(['warning', ...args]) };
const service = new GiteaService(makeStore(), diagnostics);
globalThis.fetch = async (url, options) => {
calls.push([url, options]);
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'x-test': 'yes' } });
};
const json = await service.request('/user', { method: 'POST', body: { hello: 'world' }, headers: { 'X-Extra': 'value' } });
assert.deepEqual(json.data, { ok: true });
assert.equal(calls[0][1].headers.Authorization, 'token demo-token');
assert.equal(calls[0][1].headers['Content-Type'], 'application/json');
assert.equal(calls[0][1].headers['X-Extra'], 'value');
globalThis.fetch = async () => new Response('plain', { status: 200 });
assert.equal((await service.request('/plain', { responseType: 'text', auth: false })).data, 'plain');
globalThis.fetch = async () => new Response(Uint8Array.from([1, 2, 3]), { status: 200 });
assert.deepEqual((await service.request('/binary', { responseType: 'buffer' })).data, Buffer.from([1, 2, 3]));
globalThis.fetch = async () => new Response(null, { status: 204 });
assert.equal((await service.request('/empty')).data, null);
globalThis.fetch = async () => new Response(JSON.stringify({ message: 'bad demo-token' }), { status: 403, statusText: 'Forbidden' });
await assert.rejects(service.request('/denied'), (error) => error.status === 403 && !error.message.includes('demo-token'));
globalThis.fetch = async () => new Response('not found', { status: 404, statusText: 'Not Found' });
await assert.rejects(service.request('/missing'), (error) => error.status === 404 && error.payload === 'not found');
});
test('request rejects absent credentials and wraps network failures', async (context) => {
const originalFetch = globalThis.fetch;
context.after(() => { globalThis.fetch = originalFetch; });
const warnings = [];
const service = new GiteaService({ data: { gitea: { baseUrl: 'https://gitea.example.test' } }, getToken: () => '' }, { warning: async (...args) => warnings.push(args) });
await assert.rejects(service.request('/user'), /no Gitea access token/i);
globalThis.fetch = async () => { const error = new Error('connect ECONNREFUSED'); error.code = 'ECONNREFUSED'; throw error; };
await assert.rejects(service.request('/version', { auth: false }), (error) => error.code === 'ECONNREFUSED' && /could not reach/i.test(error.message));
assert.equal(warnings[0][0], 'gitea.request.failed');
});
test('repository pagination, connection validation and simple endpoints preserve API data', async () => {
const service = new GiteaService(makeStore());
let pages = 0;
service.request = async (pathname) => {
if (pathname === '/user') return { data: { login: 'jens' } };
if (pathname === '/version') throw Object.assign(new Error('unsupported'), { status: 404 });
if (pathname.includes('/user/repos')) { pages += 1; return { data: pages === 1 ? Array.from({ length: 50 }, (_, id) => ({ id })) : [{ id: 51 }] }; }
if (pathname.includes('/branches/')) return { data: { name: 'main' } };
return { data: { id: 1 } };
};
const validated = await service.validateConnection('https://gitea.example.test/', 'token');
assert.equal(validated.repositoryCount, 50);
assert.equal(validated.version, null);
pages = 0;
assert.equal((await service.listRepositories()).length, 51);
assert.equal((await service.getRepository('owner space', 'repo/name')).id, 1);
assert.equal((await service.getBranch('owner', 'repo', 'feature/test')).name, 'main');
});
test('branch protection tolerates unsupported APIs but propagates server failures', async () => {
const service = new GiteaService(makeStore());
service.getBranch = async () => ({ protected: false });
service.request = async () => { throw Object.assign(new Error('unsupported'), { status: 404 }); };
const absent = await service.getBranchProtection('owner', 'repo', 'main');
assert.equal(absent.protected, false);
assert.equal(absent.enablePush, null);
service.request = async () => { throw Object.assign(new Error('down'), { status: 500 }); };
await assert.rejects(service.getBranchProtection('owner', 'repo', 'main'), /down/);
});
test('file, release, pull request and deploy-key helpers validate malformed API inputs', async () => {
const service = new GiteaService(makeStore());
service.request = async () => ({ data: [] });
assert.deepEqual(await service.listDeployKeys('owner', 'repo'), []);
assert.deepEqual(await service.listPullRequests({ owner: 'owner', repo: 'repo', limit: 500 }), []);
await assert.rejects(service.ensureReadOnlyDeployKey({ owner: 'owner', repo: 'repo', publicKey: 'invalid' }), /valid SSH public key/i);
await assert.rejects(service.createReadOnlyDeployKey({ owner: 'owner', repo: 'repo', publicKey: 'invalid' }), /valid SSH public key/i);
await assert.rejects(service.deleteDeployKey('owner', 'repo', 0), /valid deploy-key ID/i);
await assert.rejects(service.createPullRequest({ owner: 'owner', repo: 'repo', head: 'a', base: 'b', title: '' }), /1-255/);
await assert.rejects(service.createPullRequest({ owner: 'owner', repo: 'repo', head: 'a', base: 'b', title: 'x'.repeat(256) }), /1-255/);
await assert.rejects(service.getRepositoryFile({ owner: 'owner', repo: 'repo', filePath: 'folder' }), /not a file/i);
service.request = async () => ({ data: { encoding: 'base64', content: Buffer.from('hello').toString('base64') } });
assert.equal((await service.getRepositoryFile({ owner: 'owner', repo: 'repo', filePath: 'README' })).decoded, 'hello');
service.request = async () => ({ data: { content: 'plain' } });
assert.equal((await service.getRepositoryFile({ owner: 'owner', repo: 'repo', filePath: 'README' })).decoded, 'plain');
service.request = async () => ({ data: { encoding: 'none' } });
await assert.rejects(service.getRepositoryFile({ owner: 'owner', repo: 'repo', filePath: 'README' }), /readable content/i);
for (const method of ['getLatestRelease', 'getReleaseByTag']) {
service.request = async () => { throw Object.assign(new Error('missing'), { status: 404 }); };
assert.equal(await service[method]('owner', 'repo', 'v1'), null);
service.request = async () => { throw Object.assign(new Error('server'), { status: 500 }); };
await assert.rejects(service[method]('owner', 'repo', 'v1'), /server/);
}
});
test('authenticated downloads keep tokens same-origin and enforce secure redirects', async (context) => {
const originalFetch = globalThis.fetch;
context.after(() => { globalThis.fetch = originalFetch; });
const service = new GiteaService(makeStore());
const calls = [];
globalThis.fetch = async (url, options) => {
calls.push({ url: String(url), options });
if (calls.length === 1) return new Response(null, { status: 302, headers: { location: 'https://cdn.example.test/release.exe' } });
return new Response('asset', { status: 200 });
};
assert.equal((await service.downloadAuthenticated('/attachments/release.exe')).toString(), 'asset');
assert.equal(calls[0].options.headers.Authorization, 'token demo-token');
assert.equal(calls[1].options.headers.Authorization, undefined);
globalThis.fetch = async () => new Response(null, { status: 302, headers: { location: 'http://cdn.example.test/file' } });
await assert.rejects(service.downloadAuthenticated('/file'), /insecure cross-origin/i);
globalThis.fetch = async () => new Response(null, { status: 302 });
await assert.rejects(service.downloadAuthenticated('/file'), /did not contain a destination/i);
globalThis.fetch = async () => new Response('missing', { status: 404 });
await assert.rejects(service.downloadAuthenticated('/file'), /HTTP 404/i);
let redirects = 0;
globalThis.fetch = async () => new Response(null, { status: 302, headers: { location: `/redirect-${redirects += 1}` } });
await assert.rejects(service.downloadAuthenticated('/file'), /redirect limit/i);
});
test('release downloads and workflow dispatch reject inconsistent evidence', async () => {
const service = new GiteaService(makeStore());
await assert.rejects(service.downloadReleaseAsset('owner', 'repo', 1, 0), /invalid release asset ID/i);
service.request = async () => ({ data: { id: 2, browser_download_url: 'https://gitea.example/file' } });
await assert.rejects(service.downloadReleaseAsset('owner', 'repo', 1, 3), /different release asset/i);
service.request = async () => ({ data: { id: 3, browser_download_url: '' } });
await assert.rejects(service.downloadReleaseAsset('owner', 'repo', 1, 3), /did not provide/i);
service.request = async () => ({ status: 202, data: null });
assert.deepEqual(await service.dispatchWorkflow({ owner: 'owner', repo: 'repo', workflowFile: 'deploy.yml', ref: 'main' }), { accepted: false, status: 202 });
});
+124
View File
@@ -0,0 +1,124 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { classifyInventory } = require("../src/main/inventory-classifier.cjs");
const { deploymentIdentity, deploymentEvidenceHash, deploymentAuthorityKey } = require("../src/main/deployment-identity.cjs");
const { InventoryReviewService } = require("../src/main/inventory-review-service.cjs");
function workload(id, options = {}) {
return {
workloadId: id, serverId: options.serverId || "unraid", displayName: options.name || id,
status: options.status || (options.link ? "linked" : "suggested"), link: options.link || null,
compose: { project: options.project || id, workingDir: options.root || `/mnt/user/appdata/${id}`, configFiles: options.files || [`/mnt/user/appdata/${id}/compose.yml`], services: ["app"] },
containers: options.noContainers ? [] : [{ id: `container-${id}`, name: id, running: options.running !== false }],
runtime: { running: options.running !== false, health: options.health || "healthy" },
metadata: { sourceRepository: options.remote ?? "git@gitea.test:Jens/Portfolio.git", liveRevision: options.sha || "a".repeat(40), branch: options.branch || "main" },
candidates: options.candidates || [{ repositoryFullName: "Jens/Portfolio", score: 100, exact: true }],
remoteFolderCandidate: id,
};
}
test("deployment identity is canonical across SSH and HTTPS remotes", () => {
const ssh = deploymentIdentity({ workload: workload("one") });
const https = deploymentIdentity({ workload: workload("two", { remote: "https://gitea.test/Jens/Portfolio.git" }) });
assert.equal(ssh.repository, https.repository);
assert.equal(deploymentAuthorityKey(ssh), deploymentAuthorityKey({ ...https, serverId: ssh.serverId, environment: ssh.environment }));
});
test("running duplicate is authoritative and historical folder is never linked", () => {
const active = workload("portfolio-current", { link: { profileId: "profile", repositoryFullName: "Jens/Portfolio" } });
const historical = workload("portfolio-old", { running: false, root: "/mnt/user/appdata/portfolio/releases/old", link: { profileId: "profile-old", repositoryFullName: "Jens/Portfolio" } });
const result = classifyInventory([historical, active], [{ id: "profile", environment: "production" }, { id: "profile-old", environment: "production" }]);
assert.equal(result.find((item) => item.workloadId === "portfolio-current").authoritative, true);
assert.equal(result.find((item) => item.workloadId === "portfolio-old").classification.type, "backup");
assert.equal(result.find((item) => item.workloadId === "portfolio-old").shadowedLink.profileId, "profile-old");
assert.equal(result.find((item) => item.workloadId === "portfolio-old").link, null);
});
for (const [label, item, expected] of [
["backup Compose folder", workload("backup", { root: "/mnt/user/appdata/portfolio-backup", running: false }), "backup"],
["release directory", workload("release", { root: "/mnt/user/appdata/portfolio/releases/a1", running: false }), "release-folder"],
["staging workload", workload("stage", { root: "/mnt/user/appdata/portfolio-staging" }), "staging"],
["candidate runtime", workload("candidate", { name: "portfolio-candidate-039" }), "temporary-runtime"],
["stopped legitimate app", workload("stopped", { running: false }), "stopped-application"],
["Compose without container", workload("historical", { noContainers: true, running: false }), "historical-compose"],
["external container without Git provenance", workload("external", { remote: "", candidates: [], status: "unmatched" }), "external-container"],
["external container with inaccessible repository provenance", workload("external-git", { remote: "https://github.com/vendor/image.git", candidates: [], status: "unmatched" }), "external-container"],
["system container", workload("infra", { name: "watchtower", remote: "", candidates: [] }), "system-container"],
["ambiguous exact matches", workload("ambiguous", { status: "ambiguous", candidates: [{ repositoryFullName: "Jens/A", score: 100, exact: true }, { repositoryFullName: "Jens/B", score: 100, exact: true }] }), "ambiguous"],
["profile whose server workload disappeared", { ...workload("stale", { running: false, noContainers: true, link: { profileId: "profile-stale", repositoryFullName: "Jens/Portfolio" } }), metadata: { sourceRepository: "git@gitea.test:Jens/Portfolio.git", branch: "main", staleLink: true } }, "stale-link"],
]) test(`inventory classifies ${label}`, () => {
assert.equal(classifyInventory([item])[0].classification.type, expected);
});
test("multi-instance environments remain separate authority groups", () => {
const a = workload("instance-a", { link: { profileId: "a", repositoryFullName: "Jens/Portfolio" } });
const b = workload("instance-b", { link: { profileId: "b", repositoryFullName: "Jens/Portfolio" } });
const result = classifyInventory([a, b], [{ id: "a", environment: "production" }, { id: "b", environment: "staging" }]);
assert.equal(result.filter((item) => item.classification.type === "duplicate").length, 0);
});
test("stored review decision becomes stale when remote evidence changes", () => {
const original = classifyInventory([workload("review")])[0];
const decision = { workloadId: original.workloadId, evidenceHash: original.evidenceHash, action: "ignore", reason: "Known external workload" };
const unchanged = classifyInventory([workload("review")], [], [decision])[0];
const changed = classifyInventory([workload("review", { remote: "git@gitea.test:Jens/Renamed.git" })], [], [decision])[0];
assert.equal(unchanged.reviewDecision.action, "ignore");
assert.equal(changed.reviewDecision, null);
assert.equal(changed.reviewDecisionStale, true);
});
test("review decisions drive classification and explicit authority", () => {
const primary = classifyInventory([workload("primary")])[0];
const secondary = classifyInventory([workload("secondary")])[0];
const decisions = [
{ workloadId: primary.workloadId, evidenceHash: primary.evidenceHash, action: "mark-historical", reason: "Retained rollback definition" },
{ workloadId: secondary.workloadId, evidenceHash: secondary.evidenceHash, action: "select-authoritative", reason: "Verified production instance" },
];
const result = classifyInventory([workload("primary"), workload("secondary")], [], decisions);
assert.equal(result.find((item) => item.workloadId === "primary").classification.type, "historical-compose");
assert.equal(result.find((item) => item.workloadId === "secondary").authoritative, true);
});
test("ignore and monitor-only decisions stay evidence-bound", () => {
const ignored = classifyInventory([workload("ignored")])[0];
const monitoredSource = workload("monitored", { remote: "git@gitea.test:Jens/Monitored.git", candidates: [{ repositoryFullName: "Jens/Monitored", score: 100, exact: true }] });
const monitored = classifyInventory([monitoredSource])[0];
const result = classifyInventory([workload("ignored"), monitoredSource], [], [
{ workloadId: ignored.workloadId, evidenceHash: ignored.evidenceHash, action: "ignore", reason: "Managed by another platform" },
{ workloadId: monitored.workloadId, evidenceHash: monitored.evidenceHash, action: "monitor-only", reason: "Visibility without deployment ownership" },
]);
assert.equal(result.find((item) => item.workloadId === "ignored").classification.type, "manually-excluded");
assert.equal(result.find((item) => item.workloadId === "monitored").classification.type, "monitor-only");
});
test("review service requires reason, exact plan and recovery snapshot", async () => {
const decisions = [];
const store = { getInventoryReviewDecisions: () => decisions, createRecoverySnapshot: async () => ({ filePath: "snapshot.json" }), saveInventoryReviewDecision: async (_server, decision) => { decisions.push(decision); return decision; } };
const service = new InventoryReviewService({ store });
const item = classifyInventory([workload("review")])[0];
assert.throws(() => service.preview({ serverId: "unraid", workload: item, action: "ignore", reason: "no" }), (error) => error.code === "INVENTORY_REVIEW_REASON_REQUIRED");
const plan = service.preview({ serverId: "unraid", workload: item, action: "ignore", reason: "Managed outside ForgeFlow" });
await assert.rejects(service.apply({ plan }), (error) => error.code === "INVENTORY_REVIEW_PLAN_REQUIRED");
const result = await service.apply({ plan, expectedPlanId: plan.id });
assert.equal(result.snapshot.filePath, "snapshot.json");
assert.equal(decisions[0].evidenceHash, item.evidenceHash);
});
test("large inventory classification is deterministic and bounded", () => {
const input = Array.from({ length: 1200 }, (_, index) => workload(`app-${index}`, { remote: `git@gitea.test:Jens/App-${index}.git`, candidates: [{ repositoryFullName: `Jens/App-${index}`, score: 100, exact: true }] }));
const started = Date.now();
const result = classifyInventory(input);
assert.equal(result.length, 1200);
assert.ok(Date.now() - started < 2000);
assert.equal(new Set(result.map((item) => item.evidenceHash)).size, 1200);
});
test("evidence hash changes for runtime, Compose and candidate changes", () => {
const identity = deploymentIdentity({ workload: workload("hash") });
const one = deploymentEvidenceHash(identity, { running: true, files: ["compose.yml"] });
const two = deploymentEvidenceHash(identity, { running: false, files: ["compose.yml"] });
assert.notEqual(one, two);
});
+6 -8
View File
@@ -2,15 +2,16 @@ import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
async function rendererSource() {
return (await Promise.all(["app.js", "views.js", "dialogs.js", "operations.js", "actions/shell.js", "actions/inventory.js", "actions/deployment-profile.js", "actions/deployment-operation.js", "actions/setup-and-settings.js", "actions/recovery.js", "actions/command.js", "events.js"].map((file) => readFile(new URL(`../src/renderer/${file}`, import.meta.url), "utf8")))).join("\n");
}
test("every preload invoke channel has a registered IPC handler", async () => { test("every preload invoke channel has a registered IPC handler", async () => {
const preload = await readFile( const preload = await readFile(
new URL("../preload.cjs", import.meta.url), new URL("../preload.cjs", import.meta.url),
"utf8", "utf8",
); );
const ipc = await readFile( const ipc = (await Promise.all(["ipc.cjs", "ipc/repository-handlers.cjs", "ipc/deployment-handlers.cjs", "ipc/operations-handlers.cjs"].map((file) => readFile(new URL(`../src/main/${file}`, import.meta.url), "utf8")))).join("\n");
new URL("../src/main/ipc.cjs", import.meta.url),
"utf8",
);
const invokes = [...preload.matchAll(/invoke\(\s*['"]([^'"]+)['"]/g)].map( const invokes = [...preload.matchAll(/invoke\(\s*['"]([^'"]+)['"]/g)].map(
(match) => match[1], (match) => match[1],
); );
@@ -27,10 +28,7 @@ test("every preload invoke channel has a registered IPC handler", async () => {
}); });
test("every renderer bridge call is exposed by the preload contract", async () => { test("every renderer bridge call is exposed by the preload contract", async () => {
const renderer = await readFile( const renderer = await rendererSource();
new URL("../src/renderer/app.js", import.meta.url),
"utf8",
);
const preload = await readFile( const preload = await readFile(
new URL("../preload.cjs", import.meta.url), new URL("../preload.cjs", import.meta.url),
"utf8", "utf8",
+112
View File
@@ -63,3 +63,115 @@ test('deployment preflight verifies exact Git, workflow, Actions and server prer
assert.equal(result.checks.filter((item) => item.status === 'fail').length, 0); assert.equal(result.checks.filter((item) => item.status === 'fail').length, 0);
assert.equal(result.head, sha); assert.equal(result.head, sha);
}); });
test('system preflight reports unavailable Git, storage, roots and rejected Gitea credentials', async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-failures-'));
t.after(() => rm(root, { recursive: true, force: true }));
const ordinaryFile = path.join(root, 'not-a-directory');
await writeFile(ordinaryFile, 'file');
const events = [];
const service = new PreflightService({
store: { data: { gitea: { baseUrl: 'https://stored.test' } }, getToken: () => 'stored-token' },
git: { isAvailable: async () => ({ available: false, error: 'git missing' }) },
gitea: { validateConnection: async () => { throw new Error('token rejected'); } },
deployments: {}, diagnostics: { logDirectory: path.join(root, 'logs'), info: async (...args) => events.push(args) },
userDataPath: path.join(root, 'data'), secureStorageAvailable: () => false
});
service.writableDirectory = async (directory) => {
if (directory.endsWith('data')) throw new Error('read only');
return true;
};
const result = await service.runSystem({ roots: [ordinaryFile, path.join(root, 'missing'), ordinaryFile, ''] });
assert.equal(result.checks.find((item) => item.id === 'git.available').status, 'fail');
assert.equal(result.checks.find((item) => item.id === 'storage.userdata').status, 'fail');
assert.equal(result.checks.find((item) => item.id === 'storage.diagnostics').status, 'pass');
assert.equal(result.checks.find((item) => item.id === 'storage.credentials').status, 'warning');
assert.equal(result.checks.find((item) => item.id === 'workspace.root.0').status, 'fail');
assert.equal(result.checks.find((item) => item.id === 'workspace.root.1').status, 'fail');
assert.equal(result.checks.find((item) => item.id === 'gitea.connection').status, 'fail');
assert.equal(result.summary.ready, false);
assert.equal(events[0][0], 'preflight.system.completed');
});
test('system preflight warns on incomplete Git identity and accepts unknown Gitea version', async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-identity-'));
t.after(() => rm(root, { recursive: true, force: true }));
const service = new PreflightService({
store: { data: { gitea: { baseUrl: '' } }, getToken: () => '' },
git: { isAvailable: async () => ({ available: true, version: 'git' }) },
gitea: { validateConnection: async () => ({ version: null, user: null, repositoryCount: 0 }) }, deployments: {},
diagnostics: { logDirectory: path.join(root, 'logs'), info: async () => {} }, userDataPath: path.join(root, 'data')
});
service.gitIdentity = async () => ({ name: '', email: '' });
const result = await service.runSystem({ baseUrl: 'https://gitea.test', token: 'token', roots: [] });
assert.equal(result.checks.find((item) => item.id === 'git.identity').status, 'warning');
assert.match(result.checks.find((item) => item.id === 'gitea.connection').detail, /unknown version.*user/i);
assert.equal(result.checks.find((item) => item.id === 'gitea.repositories').status, 'pass');
assert.equal(result.checks.find((item) => item.id === 'workspace.roots').status, 'warning');
service.gitIdentity = async () => { throw new Error('identity lookup failed'); };
const second = await service.runSystem();
assert.match(second.checks.find((item) => item.id === 'git.identity').detail, /lookup failed/i);
});
test('deployment preflight fails fast for invalid identity, profile and missing local link', async () => {
const diagnostics = [];
const service = new PreflightService({
store: { getDeploymentProfile: (_name, id) => id === 'known' ? { id: 'known', name: 'Production' } : null },
git: {}, gitea: {}, deployments: {}, diagnostics: { info: async (...args) => diagnostics.push(args) }, userDataPath: ''
});
await assert.rejects(service.runDeployment({ repository: null, profileId: 'known' }), /identity is required/i);
await assert.rejects(service.runDeployment({ repository: { fullName: 'owner/app' }, profileId: 'missing' }), /profile not found/i);
const result = await service.runDeployment({ repository: { fullName: 'owner/app', localPath: '' }, profileId: 'known' });
assert.deepEqual(result.summary.blocking, ['repository.linked']);
assert.equal(diagnostics[0][0], 'preflight.deployment.completed');
});
test('deployment preflight preserves actionable evidence across Git, workflow and endpoint failures', async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-degraded-'));
t.after(() => rm(root, { recursive: true, force: true }));
const profile = { id: 'production', name: 'Production', environment: 'production', branch: 'main', workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml' };
const service = new PreflightService({
store: { getDeploymentProfile: () => profile },
git: {
status: async () => ({ root, head: 'b'.repeat(40), clean: false, counts: { changed: 4 }, branch: { head: '', upstream: '', ahead: 2, behind: 3 } }),
verifyCommitOnRemoteBranch: async () => { throw new Error('commit not published'); }
},
gitea: { repositoryFileExists: async () => false, listWorkflowRuns: async () => { throw new Error('Actions disabled'); } },
deployments: {}, diagnostics: { info: async () => {} }, userDataPath: root
});
const result = await service.runDeployment({ repository: { fullName: 'owner/app', localPath: root }, profileId: profile.id });
for (const id of ['git.branch', 'git.clean', 'git.upstream', 'git.sync', 'git.remote-sha', 'workflow.deploy.local', 'workflow.deploy.remote', 'gitea.actions', 'server.status.configured']) {
assert.equal(result.checks.find((item) => item.id === id).status, 'fail', id);
}
assert.equal(result.checks.find((item) => item.id === 'workflow.rollback.local').status, 'warning');
assert.equal(result.checks.find((item) => item.id === 'server.health').status, 'warning');
assert.equal(result.head, 'b'.repeat(40));
});
test('deployment preflight distinguishes unreachable and mismatched status evidence', async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-status-'));
t.after(() => rm(root, { recursive: true, force: true }));
await mkdir(path.join(root, '.gitea', 'workflows'), { recursive: true });
await writeFile(path.join(root, '.gitea', 'workflows', 'deploy.yml'), 'name: deploy\n');
const profile = { id: 'production', name: 'Production', environment: 'production', branch: 'main', workflowFile: 'deploy.yml', statusUrl: 'https://app/status', healthcheckUrl: 'https://app/health' };
let status = { reachable: false, ok: false, status: 503, error: '' };
const service = new PreflightService({
store: { getDeploymentProfile: () => profile },
git: { status: async () => { throw new Error('checkout corrupt'); } },
gitea: { repositoryFileExists: async () => { throw new Error('Gitea offline'); } },
deployments: { readStatusEndpoint: async () => status, checkHealth: async () => ({ healthy: false, status: 500, error: '' }) },
diagnostics: { info: async () => {} }, userDataPath: root
});
const unreachable = await service.runDeployment({ repository: { fullName: 'owner/app', localPath: root }, profileId: profile.id });
assert.match(unreachable.checks.find((item) => item.id === 'server.status.reachable').detail, /HTTP 503/i);
assert.match(unreachable.checks.find((item) => item.id === 'server.health').detail, /HTTP 500/i);
assert.match(unreachable.checks.find((item) => item.id === 'git.repository').detail, /checkout corrupt/i);
status = { reachable: true, ok: true, repository: 'other/app', environment: 'staging', liveSha: null };
const mismatch = await service.runDeployment({ repository: { fullName: 'owner/app', localPath: root }, profileId: profile.id });
const identity = mismatch.checks.find((item) => item.id === 'server.status.identity');
assert.equal(identity.status, 'fail');
assert.equal(identity.required, true);
assert.match(mismatch.checks.find((item) => item.id === 'server.status.reachable').detail, /no live SHA/i);
});
+129
View File
@@ -0,0 +1,129 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { readFile, rm, writeFile } from "node:fs/promises";
const require = createRequire(import.meta.url);
const { ProductionAcceptanceHarness } = require("../src/main/production-acceptance-harness.cjs");
async function fixture(t) {
const harness = await ProductionAcceptanceHarness.create();
t.after(() => harness.cleanup());
return harness;
}
test("production harness proves clean install, portable start and configuration migration", async (t) => {
const harness = await fixture(t);
assert.equal((await harness.install("0.10.0", "portable")).mode, "portable");
const migration = await harness.migrate("1.0.0-rc.1");
assert.equal(migration.previousVersion, "0.10.0");
assert.equal(JSON.parse(await readFile(harness.paths.config, "utf8")).schemaVersion, 13);
assert.equal(JSON.parse(await readFile(migration.backup, "utf8")).schemaVersion, 12);
});
test("authentication fixtures cover token rotation, password, SSH keys and changed host keys", async (t) => {
const harness = await fixture(t);
assert.equal(harness.rotateToken().tokenVersion, 2);
assert.equal(harness.authenticate("password").authenticated, true);
assert.equal(harness.authenticate("ssh-key", { hostFingerprint: "SHA256:fixture-host" }).authenticated, true);
assert.throws(() => harness.authenticate("ssh-key", { hostFingerprint: "SHA256:changed" }), /fingerprint changed/);
});
test("new repositories deploy by server pull and Direct Copy at the exact Gitea SHA", async (t) => {
const harness = await fixture(t);
await harness.install();
harness.authenticate("ssh-key", { hostFingerprint: harness.state.hostFingerprint });
const serverSha = await harness.createCommit();
assert.equal((await harness.deploy(harness.plan(serverSha, "server-git"))).status, "success");
const copySha = await harness.createCommit("fix: direct copy fixture");
assert.equal((await harness.deploy(harness.plan(copySha, "push-bundle"))).status, "success");
assert.equal(harness.state.liveSha, copySha);
});
test("existing deployments are adopted, externally updated and reconciled without replacement", async (t) => {
const harness = await fixture(t);
assert.deepEqual(harness.adoptExisting(), { linked: true, liveSha: harness.initialSha, preserved: true });
const sha = await harness.createCommit();
assert.equal(harness.externalUpdate(sha).liveSha, sha);
assert.equal(harness.state.healthy, true);
});
test("deploy key rotation, revocation, restore and writable-key rejection fail closed", async (t) => {
const harness = await fixture(t);
harness.authenticate("password");
assert.equal(harness.rotateDeployKey().rotated, true);
assert.equal(harness.revokeDeployKey().deploymentBlocked, true);
assert.equal(harness.restoreDeployKey().restored, true);
harness.setKeyAccess(false);
assert.throws(() => harness.rotateDeployKey(), /writable/);
await assert.rejects(() => harness.deploy(harness.plan(harness.initialSha)), /Writable deploy key/);
});
test("unhealthy activation rolls back only to the exact recorded previous SHA", async (t) => {
const harness = await fixture(t);
harness.authenticate("password");
harness.adoptExisting();
const sha = await harness.createCommit();
assert.equal((await harness.deploy(harness.plan(sha), "unhealthy")).status, "failed");
assert.throws(() => harness.rollback("0".repeat(40)), /exact recorded/);
const rollback = harness.rollback(harness.initialSha);
assert.equal(rollback.status, "rolled-back");
assert.equal(rollback.liveSha, harness.initialSha);
});
test("network failures distinguish fetch from partial activation and preserve recovery", async (t) => {
const harness = await fixture(t);
harness.authenticate("password");
const sha = await harness.createCommit();
let outcome = await harness.deploy(harness.plan(sha), "fetch-network");
assert.deepEqual(outcome.failure, { message: "Network interrupted during fetch", partial: false });
outcome = await harness.deploy(harness.plan(sha), "activation-network");
assert.equal(outcome.failure.partial, true);
assert.ok(harness.state.recovery);
});
test("application shutdown is recoverable and stale plans cannot mutate state", async (t) => {
const harness = await fixture(t);
harness.authenticate("password");
const plan = harness.plan(harness.initialSha);
harness.state.liveSha = "1".repeat(40);
await assert.rejects(() => harness.deploy(plan), /Stale reconciliation plan/);
const current = harness.plan(harness.initialSha);
assert.equal((await harness.deploy(current, "shutdown")).status, "interrupted");
assert.equal(harness.recover().status, "failed");
});
test("corrupt configuration is recoverable from the migration backup", async (t) => {
const harness = await fixture(t);
await harness.install();
await harness.migrate();
const backup = `${harness.paths.config}.backup`;
await writeFile(harness.paths.config, "{broken", "utf8");
await assert.rejects(() => readFile(harness.paths.config, "utf8").then(JSON.parse));
await writeFile(harness.paths.config, await readFile(backup));
assert.equal(JSON.parse(await readFile(harness.paths.config, "utf8")).version, "0.10.0");
});
test("release verification rejects checksum failures, missing assets and drafts without requiring paid signing", async (t) => {
const harness = await fixture(t);
await harness.publishRelease("1.0.0-ok");
assert.equal((await harness.verifyRelease("1.0.0-ok")).verified, true);
for (const [version, options, error] of [
["1.0.0-checksum", { badChecksum: true }, /checksum/],
["1.0.0-missing", { missingAsset: true }, /asset is missing/],
["1.0.0-draft", { draft: true }, /draft release/],
]) {
await harness.publishRelease(version, options);
await assert.rejects(() => harness.verifyRelease(version), error);
}
});
test("large and partial inventory fixtures expose duplicates without touching production data", async (t) => {
const harness = await fixture(t);
const inventory = harness.inventory(24, true);
assert.equal(inventory.workloads.length, 24);
assert.equal(inventory.workloads.filter((item) => item.classification === "duplicate").length, 1);
assert.equal(inventory.partial, true);
assert.match(inventory.warnings[0], /unavailable/);
await rm(harness.paths.server, { recursive: true, force: true });
});
+37 -62
View File
@@ -2,6 +2,14 @@ import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
const rendererFiles = ["app.js", "views.js", "dialogs.js", "operations.js", "actions/shell.js", "actions/inventory.js", "actions/deployment-profile.js", "actions/deployment-operation.js", "actions/setup-and-settings.js", "actions/recovery.js", "actions/command.js", "events.js"];
async function rendererSource() {
return (await Promise.all(rendererFiles.map((file) => readFile(new URL(`../src/renderer/${file}`, import.meta.url), "utf8")))).join("\n");
}
async function ipcSource() {
return (await Promise.all(["ipc.cjs", "ipc/repository-handlers.cjs", "ipc/deployment-handlers.cjs", "ipc/operations-handlers.cjs"].map((file) => readFile(new URL(`../src/main/${file}`, import.meta.url), "utf8")))).join("\n");
}
test("changed file list has an independently scrollable bounded layout", async () => { test("changed file list has an independently scrollable bounded layout", async () => {
const css = await readFile( const css = await readFile(
new URL("../src/renderer/styles.css", import.meta.url), new URL("../src/renderer/styles.css", import.meta.url),
@@ -22,10 +30,7 @@ test("changed file list has an independently scrollable bounded layout", async (
}); });
test("commit workflow explains every disabled prerequisite", async () => { test("commit workflow explains every disabled prerequisite", async () => {
const renderer = await readFile( const renderer = await rendererSource();
new URL("../src/renderer/app.js", import.meta.url),
"utf8",
);
assert.match(renderer, /Commit message <span class="required-mark">required/); assert.match(renderer, /Commit message <span class="required-mark">required/);
assert.match(renderer, /Enter a commit message to enable commit and push/); assert.match(renderer, /Enter a commit message to enable commit and push/);
assert.match(renderer, /ForgeFlow stages the selected files automatically/); assert.match(renderer, /ForgeFlow stages the selected files automatically/);
@@ -34,10 +39,7 @@ test("commit workflow explains every disabled prerequisite", async () => {
}); });
test("ITWorx branding is integrated into titlebar and setup", async () => { test("ITWorx branding is integrated into titlebar and setup", async () => {
const renderer = await readFile( const renderer = await rendererSource();
new URL("../src/renderer/app.js", import.meta.url),
"utf8",
);
assert.match(renderer, /itworx-mark\.png/); assert.match(renderer, /itworx-mark\.png/);
assert.match(renderer, /itworx-wordmark-(?:light|dark)\.png/); assert.match(renderer, /itworx-wordmark-(?:light|dark)\.png/);
}); });
@@ -59,23 +61,14 @@ test("all modal content stays inside the viewport with a persistent action foote
}); });
test("settings provides one-click normalization for legacy Gitea origins", async () => { test("settings provides one-click normalization for legacy Gitea origins", async () => {
const renderer = await readFile( const renderer = await rendererSource();
new URL("../src/renderer/app.js", import.meta.url),
"utf8",
);
assert.match(renderer, /data-action="normalize-origins"/); assert.match(renderer, /data-action="normalize-origins"/);
assert.match(renderer, /Normalize all origins/); assert.match(renderer, /Normalize all origins/);
}); });
test("Git mutations are serialized per repository and expose repair actions", async () => { test("Git mutations are serialized per repository and expose repair actions", async () => {
const ipc = await readFile( const ipc = await ipcSource();
new URL("../src/main/ipc.cjs", import.meta.url), const renderer = await rendererSource();
"utf8",
);
const renderer = await readFile(
new URL("../src/renderer/app.js", import.meta.url),
"utf8",
);
assert.match(ipc, /repositoryMutations = new Map/); assert.match(ipc, /repositoryMutations = new Map/);
assert.match(ipc, /withRepositoryMutation/); assert.match(ipc, /withRepositoryMutation/);
assert.match(ipc, /GIT_LOCKS_RECENT/); assert.match(ipc, /GIT_LOCKS_RECENT/);
@@ -87,10 +80,7 @@ test("Git mutations are serialized per repository and expose repair actions", as
}); });
test("SSH secrets are captured before the loading render clears password inputs", async () => { test("SSH secrets are captured before the loading render clears password inputs", async () => {
const renderer = await readFile( const renderer = await rendererSource();
new URL("../src/renderer/app.js", import.meta.url),
"utf8",
);
const passwordCapture = renderer.search( const passwordCapture = renderer.search(
/const password = document\.querySelector\(["']#server-password["']\)/, /const password = document\.querySelector\(["']#server-password["']\)/,
); );
@@ -101,10 +91,7 @@ test("SSH secrets are captured before the loading render clears password inputs"
}); });
test("SSH deployments are polled in the background and Portfolio casing is preserved", async () => { test("SSH deployments are polled in the background and Portfolio casing is preserved", async () => {
const renderer = await readFile( const renderer = await rendererSource();
new URL("../src/renderer/app.js", import.meta.url),
"utf8",
);
assert.match(renderer, /function startOperationPolling\(\)/); assert.match(renderer, /function startOperationPolling\(\)/);
assert.match(renderer, /startOperationPolling\(\);/); assert.match(renderer, /startOperationPolling\(\);/);
assert.match(renderer, /Visible container name/); assert.match(renderer, /Visible container name/);
@@ -112,10 +99,7 @@ test("SSH deployments are polled in the background and Portfolio casing is prese
}); });
test("deployment profiles expose built-in/uploaded DockerMan icons and automatic metadata repair", async () => { test("deployment profiles expose built-in/uploaded DockerMan icons and automatic metadata repair", async () => {
const renderer = await readFile( const renderer = await rendererSource();
new URL("../src/renderer/app.js", import.meta.url),
"utf8",
);
assert.match(renderer, /Built-in high-contrast ITWorx mark/); assert.match(renderer, /Built-in high-contrast ITWorx mark/);
assert.match(renderer, /profile-icon-mode/); assert.match(renderer, /profile-icon-mode/);
assert.match(renderer, /Repair DockerMan integration/); assert.match(renderer, /Repair DockerMan integration/);
@@ -123,24 +107,15 @@ test("deployment profiles expose built-in/uploaded DockerMan icons and automatic
}); });
test("repository troubleshooting offers personalized synchronization repair actions", async () => { test("repository troubleshooting offers personalized synchronization repair actions", async () => {
const renderer = await readFile( const renderer = await rendererSource();
new URL("../src/renderer/app.js", import.meta.url), const ipc = await ipcSource();
"utf8",
);
const ipc = await readFile(
new URL("../src/main/ipc.cjs", import.meta.url),
"utf8",
);
assert.match(renderer, /repair-repository-sync/); assert.match(renderer, /repair-repository-sync/);
assert.match(renderer, /safety branch/); assert.match(renderer, /safety branch/);
assert.match(ipc, /repository:repair-sync/); assert.match(ipc, /repository:repair-sync/);
}); });
test("advanced Git, desktop, backup, policy and audit workflows are exposed in the renderer", async () => { test("advanced Git, desktop, backup, policy and audit workflows are exposed in the renderer", async () => {
const renderer = await readFile( const renderer = await rendererSource();
new URL("../src/renderer/app.js", import.meta.url),
"utf8",
);
const preload = await readFile( const preload = await readFile(
new URL("../preload.cjs", import.meta.url), new URL("../preload.cjs", import.meta.url),
"utf8", "utf8",
@@ -171,10 +146,7 @@ test("advanced Git, desktop, backup, policy and audit workflows are exposed in t
}); });
test("one-click troubleshooting excludes destructive or publishing Git actions", async () => { test("one-click troubleshooting excludes destructive or publishing Git actions", async () => {
const renderer = await readFile( const renderer = await rendererSource();
new URL("../src/renderer/app.js", import.meta.url),
"utf8",
);
const ipc = await readFile( const ipc = await readFile(
new URL("../src/main/ipc.cjs", import.meta.url), new URL("../src/main/ipc.cjs", import.meta.url),
"utf8", "utf8",
@@ -210,10 +182,7 @@ test("premium repository workspace reserves separate rows for actions and releas
}); });
test("interactive project illustrations are semantic, responsive and motion-safe", async () => { test("interactive project illustrations are semantic, responsive and motion-safe", async () => {
const renderer = await readFile( const renderer = await rendererSource();
new URL("../src/renderer/app.js", import.meta.url),
"utf8",
);
const styles = await readFile( const styles = await readFile(
new URL("../src/renderer/styles.css", import.meta.url), new URL("../src/renderer/styles.css", import.meta.url),
"utf8", "utf8",
@@ -228,10 +197,7 @@ test("interactive project illustrations are semantic, responsive and motion-safe
}); });
test("the diff canvas uses a contextual and motion-safe code illustration", async () => { test("the diff canvas uses a contextual and motion-safe code illustration", async () => {
const renderer = await readFile( const renderer = await rendererSource();
new URL("../src/renderer/app.js", import.meta.url),
"utf8",
);
const styles = await readFile( const styles = await readFile(
new URL("../src/renderer/styles.css", import.meta.url), new URL("../src/renderer/styles.css", import.meta.url),
"utf8", "utf8",
@@ -245,10 +211,7 @@ test("the diff canvas uses a contextual and motion-safe code illustration", asyn
}); });
test("Git Validator exposes scored best-practice checks and bounded repairs", async () => { test("Git Validator exposes scored best-practice checks and bounded repairs", async () => {
const renderer = await readFile( const renderer = await rendererSource();
new URL("../src/renderer/app.js", import.meta.url),
"utf8",
);
const styles = await readFile( const styles = await readFile(
new URL("../src/renderer/styles.css", import.meta.url), new URL("../src/renderer/styles.css", import.meta.url),
"utf8", "utf8",
@@ -258,10 +221,22 @@ test("Git Validator exposes scored best-practice checks and bounded repairs", as
"utf8", "utf8",
); );
assert.match(renderer, /function renderGitValidator/); assert.match(renderer, /function renderGitValidator/);
assert.match(renderer, /git-validator-repair-safe/); assert.match(renderer, /gitValidatorPreviewRepair/);
assert.match(renderer, /check\.safe/); assert.match(renderer, /git-validator-suppress/);
assert.match(renderer, /git-validator-policy/);
assert.match(styles, /\.validator-score/); assert.match(styles, /\.validator-score/);
assert.match(styles, /@container \(max-width: 900px\)/); assert.match(styles, /@container \(max-width: 900px\)/);
assert.match(preload, /gitValidatorScan/); assert.match(preload, /gitValidatorScan/);
assert.match(preload, /gitValidatorRepair/); assert.match(preload, /gitValidatorRepair/);
assert.match(preload, /gitValidatorExport/);
});
test("renderer guards accessible names, labels and uncertain inventory evidence", async () => {
const renderer = await rendererSource();
const styles = await readFile(new URL("../src/renderer/styles.css", import.meta.url), "utf8");
assert.match(renderer, /button\.icon-button:not\(\[aria-label\]\)/);
assert.match(renderer, /\.field > label:not\(\[for\]\)/);
assert.match(renderer, /topCandidate\.confidence \|\| topCandidate\.status \|\| "review required"/);
assert.match(styles, /\.action-panel-body \.panel-callout > h2/);
assert.match(styles, /@media \(max-height: 760px\)/);
}); });
+21
View File
@@ -28,3 +28,24 @@ test('repository monitor establishes a baseline and emits only on later changes'
await monitor.tick(); await monitor.tick();
assert.equal(changes.length, 2); assert.equal(changes.length, 2);
}); });
test('repository monitor checks multiple repositories concurrently with a bounded worker pool', async () => {
let active = 0;
let peak = 0;
const git = {
status: async (localPath) => {
active += 1;
peak = Math.max(peak, active);
await new Promise((resolve) => setTimeout(resolve, 15));
active -= 1;
return { localPath, revision: 1 };
},
statusFingerprint: (status) => String(status.revision)
};
const store = { data: { preferences: { autoRefresh: true, repositoryPollSeconds: 2 } } };
const monitor = new RepositoryMonitor({ store, git });
monitor.setPaths(Array.from({ length: 10 }, (_, index) => `/repo-${index}`));
await monitor.tick();
assert.equal(peak, 4);
assert.equal(active, 0);
assert.equal(monitor.fingerprints.size, 10);
});
+98
View File
@@ -1,5 +1,8 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { mkdtemp, mkdir, symlink } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import repositoryModule from '../src/main/repository-service.cjs'; import repositoryModule from '../src/main/repository-service.cjs';
const { RepositoryService } = repositoryModule; const { RepositoryService } = repositoryModule;
@@ -58,3 +61,98 @@ test('an unhealthy live commit remains eligible for a controlled redeploy', () =
]); ]);
assert.equal(repository.readyToDeploy, true); assert.equal(repository.readyToDeploy, true);
}); });
test('repository discovery is bounded, skips generated trees and ignores inaccessible roots', async (context) => {
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-repositories-'));
context.after(() => import('node:fs/promises').then(({ rm }) => rm(root, { recursive: true, force: true })));
await mkdir(path.join(root, 'group', 'app', '.git'), { recursive: true });
await mkdir(path.join(root, 'node_modules', 'ignored', '.git'), { recursive: true });
await mkdir(path.join(root, 'too', 'deep', 'repository', '.git'), { recursive: true });
try { await symlink(path.join(root, 'group'), path.join(root, 'linked'), 'junction'); } catch {}
const instance = service();
const found = await instance.discoverInRoot(root, 2);
assert.deepEqual(found, [await import('node:fs/promises').then(({ realpath }) => realpath(path.join(root, 'group', 'app')))]);
assert.deepEqual(await instance.discoverInRoot(path.join(root, 'missing')), []);
const all = await instance.discoverAll([root, root, '', null]);
assert.equal(all.length, 2);
assert.equal(new Set(all).size, 2);
assert.ok(all.includes(found[0]));
});
test('local descriptors preserve Git failures and watch paths are defensive copies', async () => {
const instance = new RepositoryService({ data: {} }, {
status: async (localPath) => {
if (localPath.endsWith('bad')) throw new Error('not a repository');
return { ...status(), root: `${localPath}/canonical`, remoteUrl: remote.clone_url };
}
}, {});
const descriptors = await instance.getLocalDescriptors(['good', 'bad']);
assert.equal(descriptors[0].localPath, 'good/canonical');
assert.equal(descriptors[1].error, 'not a repository');
instance.lastKnownLocalPaths = ['one'];
const watched = instance.getWatchPaths();
watched.push('two');
assert.deepEqual(instance.getWatchPaths(), ['one']);
});
test('refresh links explicit and remote-matched repositories and retains unmatched locals', async () => {
const diagnostics = [];
const store = {
data: {
gitea: { baseUrl: 'https://gitea.example' },
workspaceRoots: ['root'],
repositoryMappings: { 'jens/portfolio': 'C:/explicit' },
preferences: { preferredCloneProtocol: 'https' },
favorites: ['jens/portfolio']
},
getToken: () => 'token',
getDeploymentProfiles: (name) => name === remote.full_name ? [{ id: 'prod', branch: 'main' }] : [],
getDeploymentState: () => ({ liveSha: null, healthy: null })
};
const secondRemote = { ...remote, id: 2, name: 'Other', full_name: 'Jens/Other', clone_url: 'https://gitea.example/Jens/Other.git' };
const instance = new RepositoryService(store, {
status: async (localPath) => ({
...status(localPath.includes('unmatched') ? 'b'.repeat(40) : 'a'.repeat(40)),
root: localPath,
remoteUrl: localPath.includes('matched') ? secondRemote.clone_url : remote.clone_url
})
}, { listRepositories: async () => [remote, secondRemote] }, { debug: async (...args) => diagnostics.push(args) });
instance.discoverAll = async () => ['C:/matched', 'C:/unmatched'];
const repositories = await instance.refresh();
const explicit = repositories.find((item) => item.fullName === remote.full_name);
const matched = repositories.find((item) => item.fullName === secondRemote.full_name);
const unmatched = repositories.find((item) => item.linkState === 'unmatched-local');
assert.equal(explicit.localPath, 'C:/explicit');
assert.equal(explicit.favorite, true);
assert.equal(explicit.preferredCloneUrl, remote.clone_url);
assert.equal(matched.localPath, 'C:/matched');
assert.equal(unmatched.localPath, 'C:/unmatched');
assert.deepEqual(instance.getWatchPaths().sort(), ['C:/explicit', 'C:/matched', 'C:/unmatched'].sort());
assert.equal(diagnostics[0][0], 'repositories.refresh.completed');
});
test('refresh remains local-only without configured Gitea credentials', async () => {
const store = {
data: { gitea: { baseUrl: '' }, workspaceRoots: [], repositoryMappings: {}, preferences: { preferredCloneProtocol: 'ssh' }, favorites: [] },
getToken: () => '', getDeploymentProfiles: () => [], getDeploymentState: () => null
};
const instance = new RepositoryService(store, {}, { listRepositories: async () => { throw new Error('must not call'); } });
instance.discoverAll = async () => [];
assert.deepEqual(await instance.refresh(), []);
});
test('decoration reports conflicts, behind branches, errors and remote-only repositories', () => {
const instance = service();
const conflicted = instance.decorate(remote, { localPath: 'repo', status: { ...status(), counts: { changed: 1, conflicts: 2 }, branch: { ...status().branch, behind: 3 } } }, []);
assert.equal(conflicted.attentionReason, 'Merge conflict');
assert.equal(conflicted.readyToDeploy, false);
const behind = instance.decorate(remote, { localPath: 'repo', status: { ...status(), branch: { ...status().branch, behind: 1 } } }, []);
assert.equal(behind.attentionReason, '1 commit behind remote');
const broken = instance.decorate(remote, { localPath: 'repo', status: null, error: 'broken checkout' }, []);
assert.equal(broken.attentionReason, 'broken checkout');
const remoteOnly = instance.decorate({ ...remote, ssh_url: '', clone_url: '' }, null, []);
assert.equal(remoteOnly.linkState, 'remote-only');
assert.equal(remoteOnly.preferredCloneUrl, '');
});
+144
View File
@@ -0,0 +1,144 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const {
parseServerInventory, buildWorkloadInventory, inventoryContainerMatch, remoteIdentity,
stableWorkloadId, profileMatchesWorkload, sanitizeLegacyContainer, safeRelativeToBase,
canonicalServerAppdataPath, deploymentRootCandidate,
} = require('../src/main/server-inventory.cjs');
const b64 = (value) => Buffer.from(String(value)).toString('base64');
test('inventory parser handles every evidence record and ignores malformed payloads', () => {
const legacy = { Id: 'legacy', Name: '/App', Config: { Image: 'app:1', Labels: { 'com.docker.compose.project': 'app' } }, State: { Running: true, Status: 'running', Health: { Status: 'healthy' } }, Mounts: null };
const safe = { id: 'safe', name: '/Safe', running: false, labels: null, mounts: null, ports: null, networks: null };
const projects = [{ Name: 'app', Status: 'running(1)', ConfigFiles: '/mnt/user/appdata/App/compose.yml,/mnt/user/appdata/App/extra.yml' }, { name: '', configFiles: [] }];
const output = [
'noise', '__FORGEFLOW_INVENTORY__',
`H\ttrue\tfalse\ttrue\ttrue\tfalse\ttrue\t${b64('Compose v2')}\t${b64('Linux')}`,
`R\t${b64('/mnt/user/appdata/App')}\t${b64('git@gitea.test:Owner/App.git')}\t${'a'.repeat(40)}\t${b64('main')}`,
`C\t${b64(JSON.stringify([legacy, null]))}`,
`C\t${b64(JSON.stringify(safe))}`,
`C\t${b64('{bad json')}`,
`D\t${b64('App')}\t${b64('/templates/App.xml')}\t${b64('http://app')}\t${b64('/icon.png')}\t${b64('/bin/bash')}\t${b64('app:1')}\t${b64('bridge')}`,
`P\t${b64(JSON.stringify(projects))}`,
`P\t${b64(JSON.stringify({ name: 'single', status: 'exited', config_files: ['single.yml'] }))}`,
`Y\t${b64('/mnt/user/appdata/App/')}\t${b64('compose.yml\ncompose.prod.yml\n')}\t${b64('app')}\t${b64('web\nworker')}\t${b64('app:1')}\ttrue\t${b64('')}`,
`W\t${b64('partial docker inspect failure')}`,
'UNKNOWN\tignored',
].join('\n');
const parsed = parseServerInventory(output);
assert.deepEqual(parsed.capabilities, { docker: true, compose: false, git: true, tar: true, checksum: false, baseWritable: true, composeVersion: 'Compose v2', platform: 'Linux' });
assert.equal(parsed.checkouts.length, 1);
assert.equal(parsed.containers.length, 2);
assert.equal(parsed.containers[0].health, 'healthy');
assert.deepEqual(parsed.containers[1].labels, {});
assert.equal(parsed.dockerMan[0].templatePath, '/templates/App.xml');
assert.equal(parsed.composeProjects.length, 2);
assert.deepEqual(parsed.composeProjects[0].configFiles, ['/mnt/user/appdata/App/compose.yml', '/mnt/user/appdata/App/extra.yml']);
assert.deepEqual(parsed.composeDefinitions[0].services, ['web', 'worker']);
assert.deepEqual(parsed.warnings, ['partial docker inspect failure']);
assert.throws(() => parseServerInventory('ordinary output'), /did not return/i);
});
test('server path normalization keeps deployments inside canonical appdata', () => {
assert.equal(safeRelativeToBase('/mnt/user/appdata/', '/mnt/user/appdata/App/'), 'App');
for (const value of ['', '/mnt/user/appdata', '/mnt/user/appdata/../etc', '/other/App']) assert.equal(safeRelativeToBase('/mnt/user/appdata', value), '');
assert.equal(canonicalServerAppdataPath('/mnt/user/appdata', '/mnt/cache/appdata/App'), '/mnt/user/appdata/App');
assert.equal(canonicalServerAppdataPath('/mnt/user/appdata', '/mnt/disk2/appdata/App/data'), '/mnt/user/appdata/App/data');
assert.equal(canonicalServerAppdataPath('', '/mnt/user/appdata/App'), '/mnt/user/appdata/App');
assert.equal(canonicalServerAppdataPath('/custom', '/outside/path'), '/outside/path');
assert.equal(canonicalServerAppdataPath('/custom', ''), '');
assert.equal(deploymentRootCandidate('App/source-pre-abcdef1/source'), 'App');
assert.equal(deploymentRootCandidate('App/.forgeflow/incoming'), 'App');
});
test('profile matching requires the same server and accepts each stable identity form', () => {
const workload = { serverId: 'server', workloadId: 'workload', selector: { kind: 'compose', composeProject: 'app' }, compose: { project: 'app', workingDir: '/apps/App' }, remoteFolderCandidate: 'App', containers: [{ name: 'app-web' }] };
assert.equal(profileMatchesWorkload(null, workload), false);
assert.equal(profileMatchesWorkload({ provider: 'gitea-actions', serverId: 'server' }, workload), false);
assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'other' }, workload), false);
assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', workloadIdentity: { workloadId: 'workload' } }, workload), true);
assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', workloadIdentity: { selector: workload.selector } }, workload), true);
assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', composeProject: 'app' }, workload), true);
assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', composeProject: 'app', composeWorkingDir: '/other' }, workload), false);
assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', remoteFolder: 'App' }, workload), true);
assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', containerName: 'app-web' }, workload), true);
assert.equal(stableWorkloadId('server', workload.selector), stableWorkloadId('server', workload.selector));
});
test('container matching prioritizes working directory, mounts, provenance and stable names', () => {
const checkout = { root: '/apps/App', remote: 'git@gitea.test:Owner/App.git' };
const repository = { name: 'App' };
const base = { running: true, labels: {}, mounts: [], name: 'different' };
assert.equal(inventoryContainerMatch(checkout, repository, { ...base, labels: { 'com.docker.compose.project.working_dir': '/apps/App/' } }), 100);
assert.equal(inventoryContainerMatch(checkout, repository, { ...base, mounts: [{ Source: '/apps/App/data' }] }), 90);
assert.equal(inventoryContainerMatch(checkout, repository, { ...base, labels: { 'org.opencontainers.image.source': 'https://gitea.test/Owner/App' } }), 85);
assert.equal(inventoryContainerMatch(checkout, repository, { ...base, labels: { 'com.docker.compose.project': 'app' } }), 70);
assert.equal(inventoryContainerMatch(checkout, repository, { ...base, name: '/APP' }), 60);
assert.equal(inventoryContainerMatch(checkout, repository, { ...base, running: false }), 0);
assert.equal(inventoryContainerMatch(checkout, repository, base), 0);
assert.equal(remoteIdentity(''), '');
});
test('workload builder merges runtime, Compose file and DockerMan evidence without backups', () => {
const labels = {
'com.docker.compose.project': 'app',
'com.docker.compose.project.working_dir': '/mnt/cache/appdata/App',
'com.docker.compose.project.config_files': '/mnt/cache/appdata/App/compose.yml',
'com.docker.compose.service': 'web',
'tech.itworx.forgeflow.repository': 'git@gitea.test:Owner/App.git',
'tech.itworx.forgeflow.commit': 'b'.repeat(40),
'tech.itworx.forgeflow.branch': 'main',
};
const inventory = {
containers: [
{ id: 'web', name: 'app-web', image: 'registry/app:1', imageId: 'image', running: true, status: 'running', health: 'unhealthy', labels, ports: { '8080/tcp': null, '3000/udp': [{ HostIp: '0.0.0.0', HostPort: '3000' }] }, mounts: [{ Type: 'bind', Source: '/mnt/disk1/appdata/App/data', Destination: '/data', RW: false }], networks: { frontend: {} }, restartPolicy: 'always' },
{ id: 'worker', name: 'app-worker', image: 'registry/worker:1', imageId: 'worker', running: false, status: 'exited', health: null, labels: { ...labels, 'com.docker.compose.service': 'worker' }, ports: {}, mounts: [], networks: {}, restartPolicy: '' },
],
checkouts: [{ root: '/mnt/user/appdata/App', remote: 'git@gitea.test:Owner/App.git', liveSha: 'c'.repeat(40), branch: 'release' }],
composeProjects: [{ name: 'app', status: 'running', configFiles: [] }, { name: 'headless', status: 'exited', configFiles: ['/mnt/user/appdata/Headless/compose.yml'] }],
composeDefinitions: [
{ workingDir: '/mnt/user/appdata/App', configFiles: ['/mnt/user/appdata/App/compose.yml'], projectName: 'app', services: ['web', 'worker'], images: ['registry/app:1'], valid: true, error: '' },
{ workingDir: '/mnt/user/appdata/Backup/.forgeflow/releases/one', configFiles: ['compose.yml'], projectName: 'backup', services: [], images: [], valid: true },
{ workingDir: '/mnt/user/appdata/Standalone', configFiles: ['/mnt/user/appdata/Standalone/compose.yml'], projectName: '', services: ['api'], images: ['standalone:1'], valid: false, error: 'invalid compose' },
],
dockerMan: [
{ name: 'app-web', templatePath: '/templates/app.xml', webUiUrl: 'http://app', iconUrl: '/app.png', shell: '/bin/bash', repository: 'registry/app:1', network: 'frontend' },
{ name: 'template-only', templatePath: '/templates/template.xml', webUiUrl: '', iconUrl: '', shell: '', repository: 'template:1', network: 'bridge' },
], warnings: [], capabilities: {},
};
const repository = { fullName: 'Owner/App', name: 'App', cloneUrl: 'https://gitea.test/Owner/App.git' };
const workloads = buildWorkloadInventory({ inventory, server: { id: 'server', name: 'Unraid', basePath: '/mnt/user/appdata' }, repositories: [repository], profiles: [{ id: 'profile', provider: 'ssh-unraid', serverId: 'server', composeProject: 'app', repositoryFullName: 'Owner/App', adoptedFromServer: true }] });
assert.equal(workloads.some((workload) => workload.displayName === 'backup'), false);
const app = workloads.find((workload) => workload.displayName === 'app');
assert.equal(app.status, 'linked');
assert.equal(app.runtime.running, true);
assert.equal(app.runtime.allRunning, false);
assert.equal(app.runtime.health, 'unhealthy');
assert.equal(app.runtime.ports.length, 2);
assert.equal(app.containers[0].mounts[0].readOnly, true);
assert.equal(app.remoteFolderCandidate, 'App');
assert.equal(app.candidates[0].exact, true);
assert.equal(app.link.source, 'automatic');
const standalone = workloads.find((workload) => workload.displayName === 'Standalone');
assert.equal(standalone.metadata.composeDefinitionValid, false);
assert.equal(standalone.metadata.composeDefinitionError, 'invalid compose');
const template = workloads.find((workload) => workload.displayName === 'template-only');
assert.equal(template.kind, 'dockerman-container');
assert.equal(template.runtime.running, false);
assert.equal(template.metadata.shell, '/bin/sh');
});
test('legacy container sanitizer applies safe defaults to partial Docker inspect data', () => {
assert.deepEqual(sanitizeLegacyContainer(null), {
id: '', name: '', image: '', imageId: '', running: false, status: '', health: null,
labels: {
'com.docker.compose.project': '', 'com.docker.compose.project.working_dir': '', 'com.docker.compose.project.config_files': '', 'com.docker.compose.service': '',
'org.opencontainers.image.source': '', 'org.opencontainers.image.revision': '', 'tech.itworx.forgeflow.repository': '', 'tech.itworx.forgeflow.commit': '',
'tech.itworx.forgeflow.branch': '', 'net.unraid.docker.webui': '', 'net.unraid.docker.icon': '', 'net.unraid.docker.shell': '', 'net.unraid.docker.managed': '',
}, ports: {}, mounts: [], networks: {}, restartPolicy: '',
});
});
+92 -1
View File
@@ -2,9 +2,12 @@ import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import { mkdtemp, writeFile, mkdir } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const { SshService, parseCapabilityOutput } = require("../src/main/ssh-service.cjs"); const { SshService, parseCapabilityOutput, shellQuote, fingerprintKey } = require("../src/main/ssh-service.cjs");
test("SSH capability parsing keeps Git optional and reports deployment prerequisites separately", () => { test("SSH capability parsing keeps Git optional and reports deployment prerequisites separately", () => {
const b64 = (value) => Buffer.from(value).toString("base64"); const b64 = (value) => Buffer.from(value).toString("base64");
@@ -35,3 +38,91 @@ test("SSH execution rejects truncated output instead of using an incomplete inve
(error) => error?.code === "SSH_OUTPUT_TRUNCATED" && /incomplete result/.test(error.message), (error) => error?.code === "SSH_OUTPUT_TRUNCATED" && /incomplete result/.test(error.message),
); );
}); });
test("SSH helpers quote shell values, fingerprint keys and parse absent capability markers", () => {
assert.equal(shellQuote("it's safe"), "'it'\\''s safe'");
assert.equal(fingerprintKey(Buffer.from('key')), fingerprintKey('key'));
assert.match(fingerprintKey('key'), /^SHA256:/);
assert.deepEqual(parseCapabilityOutput('plain server banner'), {
platform: 'plain server banner', docker: false, dockerReady: false, compose: false, git: false, tar: false, checksum: false
});
});
test("server validation handles password and missing or non-file private keys", async (context) => {
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-ssh-'));
context.after(() => import('node:fs/promises').then(({ rm }) => rm(root, { recursive: true, force: true })));
const service = new SshService({ store: {}, diagnostics: null });
assert.deepEqual(await service.validateServerConfiguration({ authType: 'password' }), { valid: true, method: 'password' });
await assert.rejects(service.validateServerConfiguration({ authType: 'privateKey', privateKeyPath: '' }), /select a private key/i);
await assert.rejects(service.validateServerConfiguration({ authType: 'privateKey', privateKeyPath: path.join(root, 'missing') }), (error) => error.code === 'SSH_PRIVATE_KEY_NOT_FOUND');
await mkdir(path.join(root, 'directory'));
await assert.rejects(service.validateServerConfiguration({ authType: 'privateKey', privateKeyPath: path.join(root, 'directory') }), (error) => error.code === 'SSH_PRIVATE_KEY_NOT_FOUND');
});
test("connection options enforce host identity and support password credentials", async () => {
const key = Buffer.from('server-key');
const fingerprint = fingerprintKey(key);
const store = { getServerCredentials: () => ({ password: 'secret' }) };
const service = new SshService({ store, diagnostics: null });
const trusted = await service.connectionOptions({ id: 'one', host: 'server', port: 2222, username: 'root', authType: 'password', hostFingerprint: fingerprint });
assert.equal(trusted.options.password, 'secret');
assert.equal(trusted.options.port, 2222);
assert.equal(trusted.options.hostVerifier(key), true);
assert.equal(trusted.getObservedFingerprint(), fingerprint);
assert.equal(trusted.options.hostVerifier(Buffer.from('changed')), false);
const firstUse = await service.connectionOptions({ id: 'one', host: 'server', username: 'root', authType: 'password' }, { trustOnFirstUse: true });
assert.equal(firstUse.options.port, 22);
assert.equal(firstUse.options.hostVerifier(key), true);
});
test("connection options report unreadable private keys without leaking credentials", async () => {
const service = new SshService({ store: { getServerCredentials: () => ({ passphrase: 'secret' }) }, diagnostics: null });
await assert.rejects(
service.connectionOptions({ id: 'key', host: 'server', username: 'root', authType: 'privateKey', privateKeyPath: 'Z:/missing/key' }),
(error) => error.code === 'SSH_PRIVATE_KEY_READ_FAILED' && !error.message.includes('secret')
);
});
test("SSH execution distinguishes startup errors, command failures, success and timeout", async () => {
const service = new SshService({ store: {}, diagnostics: null });
const clientFor = (start) => ({ exec(_command, callback) { start(callback); } });
await assert.rejects(service.execClient(clientFor((callback) => callback(new Error('exec unavailable'))), 'x'), /exec unavailable/);
const commandClient = clientFor((callback) => {
const stream = new EventEmitter(); stream.stderr = new EventEmitter(); callback(null, stream);
queueMicrotask(() => { stream.stderr.emit('data', Buffer.from('permission denied')); stream.emit('close', 23, 'TERM'); });
});
await assert.rejects(service.execClient(commandClient, 'x'), (error) => error.code === 'SSH_COMMAND_FAILED' && error.exitCode === 23 && error.signal === 'TERM');
const successClient = clientFor((callback) => {
const stream = new EventEmitter(); stream.stderr = new EventEmitter(); callback(null, stream);
queueMicrotask(() => { stream.emit('data', Buffer.from('ok')); stream.stderr.emit('data', Buffer.from('warning')); stream.emit('close', 0, null); });
});
assert.deepEqual(await service.execClient(successClient, 'x'), { stdout: 'ok', stderr: 'warning', exitCode: 0, truncated: false });
const hangingClient = clientFor((callback) => { const stream = new EventEmitter(); stream.stderr = new EventEmitter(); callback(null, stream); });
await assert.rejects(service.execClient(hangingClient, 'x', { timeout: 5 }), /timed out/i);
});
test("upload and execution reject unsafe paths and untrusted hosts", async () => {
const service = new SshService({ store: { getServer: () => ({ id: 'one' }) }, diagnostics: null });
assert.equal(service.ensureUploadTarget('\\srv\\apps\\file'), '/srv/apps/file');
for (const target of ['', 'relative/file', '/srv/../secret', `/srv/${String.fromCharCode(0)}bad`]) {
assert.throws(() => service.ensureUploadTarget(target), /absolute safe Unix path/i);
}
await assert.rejects(service.withSftp('one', '/srv/file', () => {}), (error) => error.code === 'SSH_HOST_NOT_TRUSTED');
await assert.rejects(service.exec('one', 'true'), (error) => error.code === 'SSH_HOST_NOT_TRUSTED');
});
test("uploadFile rejects directories before connecting", async (context) => {
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-upload-'));
context.after(() => import('node:fs/promises').then(({ rm }) => rm(root, { recursive: true, force: true })));
const service = new SshService({ store: {}, diagnostics: null });
await assert.rejects(service.uploadFile('one', root, '/srv/file'), /not a file/i);
const file = path.join(root, 'file');
await writeFile(file, 'content');
service.withSftp = async (_id, remotePath, action) => action({ fastPut(_local, _target, options, callback) { options.step(7, 7, 7); callback(null); } }, remotePath);
let progress = null;
assert.deepEqual(await service.uploadFile('one', file, '/srv/file', { onProgress: (value) => { progress = value; } }), { remotePath: '/srv/file', size: 7 });
assert.deepEqual(progress, { transferred: 7, total: 7 });
});
+163 -16
View File
@@ -1,6 +1,7 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import { readFile } from "node:fs/promises";
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const { const {
UnraidDeploymentService, UnraidDeploymentService,
@@ -18,6 +19,11 @@ const {
const { fingerprintKey, shellQuote } = require("../src/main/ssh-service.cjs"); const { fingerprintKey, shellQuote } = require("../src/main/ssh-service.cjs");
const { buildWorkloadInventory, deploymentRootCandidate } = require("../src/main/server-inventory.cjs"); const { buildWorkloadInventory, deploymentRootCandidate } = require("../src/main/server-inventory.cjs");
async function unraidSource() {
const files = ["unraid-deployment-service.cjs", "unraid-access-methods.cjs", "unraid-preflight-methods.cjs", "unraid-runtime-methods.cjs", "unraid-deployment-methods.cjs", "unraid-inventory-methods.cjs"];
return (await Promise.all(files.map((file) => readFile(new URL(`../src/main/${file}`, import.meta.url), "utf8")))).join("\n");
}
test("Unraid remote paths cannot escape appdata project folder", () => { test("Unraid remote paths cannot escape appdata project folder", () => {
assert.equal(safeRemoteFolder("lumaops"), "lumaops"); assert.equal(safeRemoteFolder("lumaops"), "lumaops");
assert.throws(() => safeRemoteFolder("../lumaops")); assert.throws(() => safeRemoteFolder("../lumaops"));
@@ -80,6 +86,54 @@ test("server pull provisions a pinned repository-scoped key and records access m
assert.equal(result.remoteSha, "a".repeat(40)); assert.equal(result.remoteSha, "a".repeat(40));
}); });
test("server pull verification proves a repository-scoped read-only key and exact commit parity", async () => {
const sha = "c".repeat(40);
const profile = {
id: "profile-verify", provider: "ssh-unraid", serverId: "unraid", remoteFolder: "portfolio",
environment: "production", branch: "main", deploymentMode: "server-git", composeFiles: ["compose.yml"],
serverGitAccess: { deployKeyId: 17, keyFingerprint: "SHA256:key", hostFingerprint: "SHA256:host" },
};
const service = new UnraidDeploymentService({
store: {
getDeploymentProfile: () => profile,
getServer: () => ({ id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata" }),
getDeploymentState: () => ({ liveSha: sha, containerRunning: true, healthy: true }),
},
ssh: { exec: async () => ({ stdout: `__FORGEFLOW_SERVER_GIT_PROBE__\nremoteSha=${sha}\nkeyFingerprint=SHA256:key\nhostFingerprint=SHA256:host\n` }) },
gitea: {
getBranch: async () => ({ commit: { id: sha } }),
listDeployKeys: async () => [{ id: 17, read_only: true }],
},
});
service.inspect = async () => ({ exists: true, composeFiles: ["compose.yml"], head: sha });
const report = await service.verifyServerGitProfile({ repository: { fullName: "Jens/Portfolio", sshUrl: "git@gitea.test:Jens/Portfolio.git" }, profileId: profile.id });
assert.equal(report.readiness, "Ready");
assert.equal(report.ready, true);
assert.equal(report.checks.find((check) => check.id === "deploy-key-scope").status, "pass");
});
test("server pull verification blocks a writable Gitea deploy key", async () => {
const sha = "d".repeat(40);
const profile = {
id: "profile-writable", provider: "ssh-unraid", serverId: "unraid", remoteFolder: "portfolio",
environment: "production", branch: "main", deploymentMode: "server-git", composeFiles: ["compose.yml"],
serverGitAccess: { deployKeyId: 18, keyFingerprint: "SHA256:key", hostFingerprint: "SHA256:host" },
};
const service = new UnraidDeploymentService({
store: {
getDeploymentProfile: () => profile,
getServer: () => ({ id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata" }),
getDeploymentState: () => ({ liveSha: sha, containerRunning: true, healthy: true }),
},
ssh: { exec: async () => ({ stdout: `__FORGEFLOW_SERVER_GIT_PROBE__\nremoteSha=${sha}\nkeyFingerprint=SHA256:key\nhostFingerprint=SHA256:host\n` }) },
gitea: { getBranch: async () => ({ commit: { id: sha } }), listDeployKeys: async () => [{ id: 18, read_only: false }] },
});
service.inspect = async () => ({ exists: true, composeFiles: ["compose.yml"], head: sha });
const report = await service.verifyServerGitProfile({ repository: { fullName: "Jens/Portfolio", sshUrl: "git@gitea.test:Jens/Portfolio.git" }, profileId: profile.id });
assert.equal(report.readiness, "Access failed");
assert.equal(report.ready, false);
});
test("server workload inventory links running containers to exact Gitea checkouts", () => { test("server workload inventory links running containers to exact Gitea checkouts", () => {
const b64 = (value) => Buffer.from(value).toString("base64"); const b64 = (value) => Buffer.from(value).toString("base64");
const inspect = JSON.stringify([ const inspect = JSON.stringify([
@@ -114,7 +168,7 @@ test("server workload inventory links running containers to exact Gitea checkout
); );
}); });
test("automatic server discovery adopts and verifies a running Gitea deployment", async () => { test("server discovery is read-only and explicit reconciliation adopts a verified deployment", async () => {
const b64 = (value) => Buffer.from(value).toString("base64"); const b64 = (value) => Buffer.from(value).toString("base64");
const sha = "b".repeat(40); const sha = "b".repeat(40);
const container = { const container = {
@@ -155,7 +209,7 @@ test("automatic server discovery adopts and verifies a running Gitea deployment"
}, },
gitea: { getBranch: async () => ({ commit: { id: sha } }) }, gitea: { getBranch: async () => ({ commit: { id: sha } }) },
}); });
const result = await service.discoverServerWorkloads("unraid", [ const repositories = [
{ {
fullName: "Jens/Portfolio", fullName: "Jens/Portfolio",
name: "Portfolio", name: "Portfolio",
@@ -163,7 +217,16 @@ test("automatic server discovery adopts and verifies a running Gitea deployment"
cloneUrl: "https://gitea.itworx.tech/Jens/Portfolio.git", cloneUrl: "https://gitea.itworx.tech/Jens/Portfolio.git",
sshUrl: "git@gitea.itworx.tech:Jens/Portfolio.git", 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);
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.adopted, 1);
assert.equal(result.verified, 1); assert.equal(result.verified, 1);
assert.equal(profiles[0].containerName, "Portfolio"); assert.equal(profiles[0].containerName, "Portfolio");
@@ -599,15 +662,10 @@ test("SSH deployment dispatch returns a running operation while the remote build
}); });
test("Unraid preflight verifies repository access before a deployment can start", async () => { test("Unraid preflight verifies repository access before a deployment can start", async () => {
const source = await import("node:fs/promises").then(({ readFile }) => const source = await unraidSource();
readFile(
new URL("../src/main/unraid-deployment-service.cjs", import.meta.url),
"utf8",
),
);
assert.match(source, /server-git-access/); assert.match(source, /server-git-access/);
assert.match(source, /git ls-remote --exit-code/); assert.match(source, /git ls-remote --exit-code/);
assert.match(source, /Unraid Gitea read access/); assert.match(source, /Unraid .* Gitea read access/);
}); });
test("DockerMan metadata uses dockerman labels, a template WebUI and lowercase-safe service/image names", () => { test("DockerMan metadata uses dockerman labels, a template WebUI and lowercase-safe service/image names", () => {
@@ -798,12 +856,7 @@ test("stuck SSH deployment is reconciled to success when exact SHA and container
}); });
test("DockerMan metadata repair refreshes known Unraid icon caches after container recreation", async () => { test("DockerMan metadata repair refreshes known Unraid icon caches after container recreation", async () => {
const source = await import("node:fs/promises").then(({ readFile }) => const source = await unraidSource();
readFile(
new URL("../src/main/unraid-deployment-service.cjs", import.meta.url),
"utf8",
),
);
assert.match(source, /\/var\/lib\/docker\/unraid\/images/); assert.match(source, /\/var\/lib\/docker\/unraid\/images/);
assert.match(source, /dynamix\.docker\.manager\/images/); assert.match(source, /dynamix\.docker\.manager\/images/);
assert.match(source, /-icon\.png/); assert.match(source, /-icon\.png/);
@@ -1007,6 +1060,22 @@ test("linked Compose deployments retain the existing project, files and service
assert.deepEqual(service.deploymentServices(profile, repository), ["api", "worker"]); assert.deepEqual(service.deploymentServices(profile, repository), ["api", "worker"]);
}); });
test("inventory scan uses only configured roots and reports partial find failures", () => {
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {} });
const script = service.inventoryScript({
basePath: "/mnt/user/appdata",
scanRoots: ["/mnt/user/appdata", "/mnt/cache/custom apps"],
scanExcludes: ["archive-*", "scratch"],
});
assert.match(script, /add_scan_root '\/mnt\/user\/appdata'/);
assert.match(script, /add_scan_root '\/mnt\/cache\/custom apps'/);
assert.match(script, /-name 'archive-\*'/);
assert.match(script, /-name 'scratch'/);
assert.match(script, /Inventory scan partially failed/);
assert.match(script, /2>"\$scan_error" \|\| true/);
assert.doesNotMatch(script, /add_scan_root \/mnt\/cache\/appdata/);
});
test("push bundle activation validates Compose and services before promoting current SHA", () => { test("push bundle activation validates Compose and services before promoting current SHA", () => {
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {} }); const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {} });
const repository = { name: "OmniRoute", fullName: "Jens/OmniRoute" }; const repository = { name: "OmniRoute", fullName: "Jens/OmniRoute" };
@@ -1173,3 +1242,81 @@ test("push bundle preflight does not require Git or Gitea credentials on Unraid"
assert.equal(result.checks.some((item) => item.id === "server-git-command"), false); assert.equal(result.checks.some((item) => item.id === "server-git-command"), false);
assert.equal(result.summary.ready, true, JSON.stringify(result.checks.filter((item) => item.status === "fail"))); assert.equal(result.summary.ready, true, JSON.stringify(result.checks.filter((item) => item.status === "fail")));
}); });
test("preflight explains monitor-only and degraded server evidence without hiding warnings", async () => {
const sha = "d".repeat(40);
const profile = {
id: "observed", provider: "ssh-unraid", environment: "production", branch: "main", serverId: "unraid",
remoteFolder: "Observed", deploymentMode: "monitor-only", composeFiles: ["compose.yml"],
composeService: "app", iconMode: "url", iconUrl: "", preservePaths: ["data"]
};
const service = new UnraidDeploymentService({
store: {
getDeploymentProfile: () => profile,
getServer: () => ({ id: "unraid", name: "Unraid", host: "server", port: 22, username: "deploy", basePath: "/mnt/apps", hostFingerprint: "" })
},
git: {},
ssh: { test: async () => { throw new Error("host unavailable"); } },
gitea: {},
sourcePath: process.cwd()
});
service.inspectWriteAccess = async () => { throw new Error("permission probe failed"); };
service.inspect = async () => ({
exists: true, rootGit: true, head: "e".repeat(40), trackedChanges: ["compose.yml"], nestedGit: ["vendor/repo"],
dockerfile: true, dockerignore: false, dockerignoreGitExcluded: false, dockerContextExclusionsMissing: ["data"],
existingPreservePaths: ["data"], composeFiles: []
});
const result = await service.preflight({ repository: { fullName: "Jens/Observed", name: "Observed", localPath: null }, profileId: profile.id, sha });
const byId = (id) => result.checks.find((check) => check.id === id);
assert.equal(byId("deployment-mode").status, "fail");
assert.equal(byId("local-repository").status, "fail");
assert.equal(byId("ssh").status, "fail");
assert.equal(byId("host-key").status, "fail");
assert.equal(byId("project-write-access").status, "fail");
assert.equal(byId("tracked-changes").status, "warning");
assert.equal(byId("nested-git").status, "warning");
assert.equal(byId("dockerignore").status, "warning");
assert.equal(byId("dockerignore-runtime").status, "warning");
assert.equal(byId("dockerman-icon").status, "fail");
assert.equal(byId("dockerman-webui").status, "warning");
assert.equal(result.summary.ready, false);
});
test("server-pull preflight resolves Gitea SHA and reports every degraded capability and write target", async () => {
const sha = "f".repeat(40);
const profile = {
id: "server-pull", provider: "ssh-unraid", environment: "staging", branch: "release", serverId: "unraid", remoteFolder: "App",
deploymentMode: "server-git", composeFiles: ["compose.yml", "compose.prod.yml"], composeService: "web",
iconMode: "none", preservePaths: []
};
const service = new UnraidDeploymentService({
store: {
getDeploymentProfile: () => profile,
getServer: () => ({ id: "unraid", name: "Unraid", host: "server", port: 22, username: "deploy", basePath: "/mnt/apps", hostFingerprint: "SHA256:trusted" })
},
git: { status: async () => ({ root: "/local", clean: false, counts: { changed: 3 }, branch: { head: "main" } }) },
ssh: { test: async () => ({ capabilities: { docker: true, dockerReady: false, compose: false, git: false, tar: true, checksum: false, baseWritable: false } }) },
gitea: { getBranch: async () => ({ commit: { sha } }) },
sourcePath: process.cwd()
});
service.probeServerGitAccess = async () => ({ ready: false, error: "deploy key missing", remoteSha: null });
service.inspectWriteAccess = async () => ({
ready: false, identity: { user: "deploy" }, blocking: [{ path: "/mnt/apps/App" }],
targets: [{ id: "root", label: "Project root", path: "/mnt/apps/App", required: true, effectiveWritable: false, owner: "root", group: "root", mode: "0755", detail: "not writable" }]
});
service.inspect = async () => ({ exists: false, rootGit: false, trackedChanges: [], nestedGit: [], dockerfile: false, dockerignore: false, dockerignoreGitExcluded: false, dockerContextExclusionsMissing: [], existingPreservePaths: [], composeFiles: [] });
const result = await service.preflight({ repository: { fullName: "Jens/App", name: "App", localPath: "/local" }, profileId: profile.id });
const byId = (id) => result.checks.find((check) => check.id === id);
assert.equal(result.sha, sha);
assert.equal(byId("local-branch").status, "warning");
assert.equal(byId("local-clean").status, "warning");
assert.equal(byId("docker-runtime").status, "fail");
assert.match(byId("docker-runtime").detail, /cannot query/i);
assert.equal(byId("compose-command").status, "fail");
assert.equal(byId("bundle-tools").status, "fail");
assert.equal(byId("server-base-writable").status, "fail");
assert.equal(byId("server-git-access").repairAction, "configure-server-git-access");
assert.equal(byId("write-path:root").status, "fail");
assert.equal(byId("remote-folder").status, "pass");
assert.equal(byId("dockerman-icon").status, "warning");
});

Some files were not shown because too many files have changed in this diff Show More