Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d47c7b5e41 | ||
|
|
cb9bdcd713 | ||
|
|
beeafdcba7 | ||
|
|
d77643c058 | ||
|
|
5cecaa080d | ||
|
|
a5666e95f2 | ||
|
|
9260d35957 | ||
|
|
cf1da8a2fa | ||
|
|
32ed4fcb5e | ||
|
|
38e221cbd1 | ||
|
|
bffad670ef | ||
|
|
4c21616e72 | ||
|
|
f866b12fbf | ||
|
|
f7d6bc374f | ||
|
|
958d5b84d3 | ||
|
|
8fa4891075 | ||
|
|
58d361bbab | ||
|
|
acad1f8932 | ||
|
|
44aa452a76 | ||
|
|
a0435f4316 | ||
|
|
13f4fe7cd0 | ||
|
|
258f0b1324 | ||
|
|
f8c505e525 | ||
|
|
398f986d95 | ||
|
|
60e8aa8fc2 | ||
|
|
6ef4620388 | ||
|
|
18f42621c2 | ||
|
|
aa4895912a | ||
|
|
347f7132b9 | ||
|
|
e5599f7a5d | ||
|
|
c826561c77 | ||
|
|
0ed202ec95 | ||
|
|
dcbd461a92 | ||
|
|
1e86afb7d9 | ||
|
|
a0733875c4 | ||
|
|
7b05c953b6 | ||
|
|
5d3731a853 | ||
|
|
6b93391a9b | ||
|
|
64ca267384 | ||
|
|
4555023f87 | ||
|
|
4708367b66 | ||
|
|
8e580a0b13 | ||
|
|
a7ab2d6cc8 | ||
|
|
ae6c41ff90 | ||
|
|
430d40354b |
@@ -0,0 +1,38 @@
|
||||
name: ForgeFlow quality gate
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
secret-scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Secret scan
|
||||
uses: trufflesecurity/trufflehog@v3.79.0
|
||||
with:
|
||||
path: ./
|
||||
extra_args: --only-verified
|
||||
|
||||
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,4 +3,7 @@ dist/
|
||||
.DS_Store
|
||||
*.log
|
||||
coverage/
|
||||
artifacts/
|
||||
playwright-report/
|
||||
.forgeflow/
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -75,7 +75,11 @@ try {
|
||||
"ForgeFlow-Setup-$version-win-x64.exe",
|
||||
"ForgeFlow-Setup-$version-win-x64.exe.sha256",
|
||||
"ForgeFlow-Portable-$version-win-x64.exe",
|
||||
"ForgeFlow-Portable-$version-win-x64.exe.sha256"
|
||||
"ForgeFlow-Portable-$version-win-x64.exe.sha256",
|
||||
"ForgeFlow-$version-provenance.json",
|
||||
"ForgeFlow-$version-sbom.cdx.json",
|
||||
"ForgeFlow-$version-release-manifest.json",
|
||||
"ForgeFlow-$version-release-manifest.json.sig"
|
||||
)
|
||||
foreach ($assetName in $expectedAssets) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $clone "dist\$assetName"))) {
|
||||
|
||||
@@ -73,7 +73,11 @@ try {
|
||||
"ForgeFlow-Setup-$version-win-x64.exe",
|
||||
"ForgeFlow-Setup-$version-win-x64.exe.sha256",
|
||||
"ForgeFlow-Portable-$version-win-x64.exe",
|
||||
"ForgeFlow-Portable-$version-win-x64.exe.sha256"
|
||||
"ForgeFlow-Portable-$version-win-x64.exe.sha256",
|
||||
"ForgeFlow-$version-provenance.json",
|
||||
"ForgeFlow-$version-sbom.cdx.json",
|
||||
"ForgeFlow-$version-release-manifest.json",
|
||||
"ForgeFlow-$version-release-manifest.json.sig"
|
||||
)
|
||||
foreach ($assetName in $expectedAssets) {
|
||||
$assetPath = Join-Path $clone "dist\$assetName"
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
**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.13** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest)
|
||||
|
||||

|
||||
|
||||
@@ -12,8 +12,9 @@ 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.
|
||||
- **Veilige Git-flow:** review wijzigingen, stage volledige bestanden of afzonderlijke hunks, commit, push en herstel conflicten zonder contextwissel.
|
||||
- **Veilige Gitea-sync:** bekijk vooraf welke bestanden wijzigen of verdwijnen, bewaar lokale commits in een recovery branch en zet gewijzigde of untracked bestanden in een stash voordat de werkmap exact gelijk wordt gemaakt aan Gitea.
|
||||
- **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.
|
||||
- **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.
|
||||
@@ -26,9 +27,9 @@ ForgeFlow is een desktopapp voor teams die met Git, Gitea en eigen servers werke
|
||||
2. Download de Windows-installer of portable executable.
|
||||
3. Start ForgeFlow en doorloop de setupwizard.
|
||||
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.
|
||||
Vanuit **Settings → Updates** kun je nieuwe packaged releases ophalen. ForgeFlow accepteert uitsluitend de release die bij de exacte Gitea-commit hoort, controleert de SHA-256-checksum én verifieert vanaf 0.10.13 een onafhankelijk Ed25519-releasemanifest met de ingebouwde publieke sleutel. Zie [UPDATING.md](docs/UPDATING.md) voor oudere of source-only installaties.
|
||||
|
||||
### Eerst vrijblijvend bekijken
|
||||
|
||||
@@ -39,7 +40,7 @@ npm install
|
||||
npm run demo
|
||||
```
|
||||
|
||||
Open daarna `http://127.0.0.1:4173`.
|
||||
Open daarna `http://127.0.0.1:41737`.
|
||||
|
||||
## De dagelijkse workflow
|
||||
|
||||
@@ -57,9 +58,18 @@ In de repositorywerkruimte zie je de volledige keten **Local → Gitea → Serve
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||
@@ -85,6 +95,7 @@ Een gelijke commit bewijst welke code draait; een geslaagde healthcheck bewijst
|
||||
|
||||
- repositories ontdekken, favorieten beheren en ontbrekende lokale clones koppelen;
|
||||
- status, diff, staging, partial hunks, commit, push, fetch, pull, stash en conflict recovery;
|
||||
- read-only achtergrondfetch en een expliciete preview om een lokale projectmap veilig exact met de upstream Gitea-branch te synchroniseren;
|
||||
- branches maken, wisselen, vergelijken en opruimen;
|
||||
- branch protection controleren en pull requests openen;
|
||||
- Git Validator met assurance score, bewijs per controle en gerichte veilige fixes.
|
||||
@@ -94,9 +105,10 @@ Een gelijke commit bewijst welke code draait; een geslaagde healthcheck bewijst
|
||||
- deploymentprofielen per repository en omgeving;
|
||||
- Gitea Actions en SSH/Unraid als gecontroleerde uitvoeringsroutes;
|
||||
- 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;
|
||||
- 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;
|
||||
- preflight, live logs, deploymenthistoriek en rollback naar de vorige bekende versie.
|
||||
|
||||
@@ -104,7 +116,7 @@ Een gelijke commit bewijst welke code draait; een geslaagde healthcheck bewijst
|
||||
|
||||
- credentials versleuteld via de beveiligde opslag van het besturingssysteem;
|
||||
- origin-checks voorkomen dat een Gitea-token naar een andere host wordt gestuurd;
|
||||
- updatepakketten worden alleen vanaf de geconfigureerde Gitea-origin gedownload en met checksums geverifieerd;
|
||||
- updatepakketten worden alleen vanaf de geconfigureerde Gitea-origin gedownload en met checksums plus een vastgepinde Ed25519-publisherhandtekening geverifieerd;
|
||||
- lokale redactie van tokens, wachtwoorden en gevoelige diagnostische data;
|
||||
- versleutelde configuratieback-up, herstelvoorbeeld en lokale audittrail;
|
||||
- packaged builds als Windows-installer en portable executable.
|
||||
@@ -146,7 +158,8 @@ Handige opdrachten:
|
||||
| `npm run check` | Voert bronverificatie en de volledige testset uit. |
|
||||
| `npm run doctor` | Controleert de lokale ontwikkelomgeving. |
|
||||
| `npm run acceptance` | Voert de release-acceptatiecontroles uit. |
|
||||
| `npm run dist:win` | Bouwt Windows installer + portable package, schrijft checksums en ruimt oude dist-artifacts op. |
|
||||
| `npm run signing:setup` | Maakt eenmalig de lokale Ed25519-releasesleutel en schrijft alleen de publieke sleutel naar het project. |
|
||||
| `npm run dist:win` | Bouwt Windows installer + portable package, schrijft checksums en een ondertekend releasemanifest en ruimt oude dist-artifacts op. |
|
||||
| `.\Publish-ForgeFlow-Release.ps1` | Publiceert broncode én de bijbehorende Windows-release-assets als één gecontroleerde release. |
|
||||
| `.\Publish-Missing-Binary-Release.ps1` | Herstelt een reeds gepushte versie waarvoor de Gitea binary release ontbreekt. |
|
||||
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
ForgeFlow 0.10.0 source manifest
|
||||
ForgeFlow 0.10.13 source manifest
|
||||
SHA-256 BYTES PATH
|
||||
(The manifest excludes itself, dependencies and generated release artifacts.)
|
||||
755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 58 .gitignore
|
||||
61f37822ae5502219a38b2eaf23fdcb611875f0e675efb4abe6157c9f072c0cc 937 .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
|
||||
0970821475a4452aa19e447e9397a95db836791f16890a1a83fd748ac033dc86 8830 build/icon-128.png
|
||||
09112c1425ca953d8dd8b2bcfd221e5a84b9f81752f7168f360e295030cbc8f2 521 build/icon-16.png
|
||||
@@ -12,14 +19,34 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263
|
||||
4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183 3364 build/icon-64.png
|
||||
25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico
|
||||
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png
|
||||
164c059453a5737110b4e5e98b6211650c757f0aff710f8f7523ffe0ff1815d7 113 build/update-signing-public.pem
|
||||
5f4aca19a35cbcaffa1a6993ce96b7d66052ec2b286022f2af74594e8a310568 15712 CHANGELOG.md
|
||||
21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md
|
||||
c612fcc44ff222db0c9a4cfd11a4076fafe080e4ada31e689a08739a4f14e74f 1650 docs/ACCEPTANCE.md
|
||||
a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md
|
||||
e05458ee2696e3c57e2475bb42ae1f914f6a36e01768d7a26a3199f1fffed490 1157 docs/COVERAGE_POLICY.md
|
||||
9eb9eec82518c0bfc7686f5faaf690a93ed63c71d35f1dc5a2c5ec5199da652a 3124 docs/CURRENT_STATE.md
|
||||
8ea655d1912ac2e17f8834e33a566a8b14461b396ec4268c396ca189a1749b94 2205 docs/DEPENDENCY_AUDIT.md
|
||||
30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md
|
||||
eb42f979666e05d51c587e4223282914926a2b9b1ade9f3fb75525019ce7f738 4616 docs/DIAGNOSTICS.md
|
||||
1d8aa3c688a9d330b9a5303b09e9e3049c50bf04f97807c7ff659607baa34c32 1464 docs/ERROR_CODES.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
|
||||
979a0b8e129979be6b265e8571d0a3c1e9ddd4ddb6b0bf55ae748d3478e51854 2296 docs/RELEASE_NOTES_0.10.0.md
|
||||
0eb44bda2209a5979a6ac693ac4cd4d235c0015031e54b9e895990f37bf60054 1433 docs/RELEASE_NOTES_0.10.1.md
|
||||
5d3240169765e3fb1d3cd391d09547101227e76dd4670ee46be8ca3a21553a03 894 docs/RELEASE_NOTES_0.10.10.md
|
||||
36edb4f096a248fb8679bd13e5766befb478cf628c6ccfb21e3eda71bbec7633 992 docs/RELEASE_NOTES_0.10.11.md
|
||||
a355d3f577c2ec85dde5dfd7b6995f4f1615e2f6bac597529f3ff102acd93c35 1292 docs/RELEASE_NOTES_0.10.12.md
|
||||
609c55a1c0b06c307ebe16f2daaf1e48601edd57137586e4f2be1febd6a7060a 1931 docs/RELEASE_NOTES_0.10.13.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
|
||||
94bfb2783c1befad1197e1c5e32fc002222c94a28d70d48360a8d53ecd260d5c 772 docs/RELEASE_NOTES_0.10.6.md
|
||||
226a3b2d4bc7f54841749a283fcdd71b643cd585ba74d673084bee829fef6ea2 903 docs/RELEASE_NOTES_0.10.7.md
|
||||
4be29ad0cb7ebcf5625172b8d2bd7a67cdc6d64d3a94e2c3f0656cdfd42dcb7a 642 docs/RELEASE_NOTES_0.10.8.md
|
||||
fb64517aa64d3ecfe8b51b09e198c2c9fbba96d0cd24a87301c7f6dea3076095 961 docs/RELEASE_NOTES_0.10.9.md
|
||||
a0c00ff76acd1682bb5e0e8dcf6589c9480da436c9c6d30780a1ed58b4dad94f 1770 docs/RELEASE_NOTES_0.2.0.md
|
||||
5773ead01aa4c522c556295553787482d01b1f5242f053b2c61f120c4de4fa76 5963 docs/RELEASE_NOTES_0.3.0.md
|
||||
d46de73cf6c4cd5c2ba3f455a7a2af2e0d64ee9d94a97fd1a0bfb44e35c1624a 1093 docs/RELEASE_NOTES_0.3.1.md
|
||||
@@ -54,21 +81,23 @@ ed40e08bac8792f95970bc05e49bce3cc9e288a08d11565a1bd156d787360a3b 720
|
||||
25169225d73d22b9d884ab3b5c1625f03fd44e53c7a7a4c4067775e80482c9f8 2182 docs/RELEASE_NOTES_0.9.3.md
|
||||
720506842e0aeb30c9fc635f86a52a5545556f092e678cf37f08436243244c3d 933 docs/RELEASE_NOTES_0.9.4.md
|
||||
dd90c81a375f97dfb7fa8f7808db03b19d7e7dafe3818a93537397f57eaae829 2109 docs/RELEASE_NOTES_0.9.5.md
|
||||
2b631b9d6d973bdd70869d84886ff339da351e29e17598970b3b27915674661d 4175 docs/ROADMAP.md
|
||||
1bf75f25d704dab0c6bc56c639d259f34523f0fb46718dd5a8419a59911ad2c3 2242 docs/RELEASING.md
|
||||
ac76cb50fabde6a00f28d7e9eccd3ef1129a40665eabdc90d78690a38d424652 4195 docs/ROADMAP.md
|
||||
1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png
|
||||
b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png
|
||||
070e6700bdae8c628c907ba181bbf0dde0bbbbb4208f7a875503f933ff1b882e 118819 docs/screenshots/deployment-success.png
|
||||
581375ee0727911f85b0441f09734c6215ea8dd6cfaba4a7555599df0edb24f1 333382 docs/screenshots/deployments.png
|
||||
87546583580e8591b1306f997d27445725b0bf5a5a79a839af3a727964e65bc2 103901 docs/screenshots/git-validator.png
|
||||
bbdbe91679b486cc92dec4758ce1cdaf24e3277d038c57e794a04c0dee7e3a5b 84046 docs/screenshots/overview.png
|
||||
c8a5e80bb9fd2d442d2d23d30e6ac1528cf2330e6e19492b7c6799e2d1508b53 112868 docs/screenshots/repository-workspace.png
|
||||
322624242d246d07180cc719e14c91e8fb69e123676a02e5046f4e576cca1ca1 5569 docs/SECURITY.md
|
||||
ed69b8beb948a2cf9a6deb6c82368e2bb44ffe8d8990a900dc878b0938d1084f 95937 docs/screenshots/deployments.png
|
||||
3868ab978de2a7945761c53a9a718aecd54dc791605d07660bcd5cad62a33ea8 103569 docs/screenshots/git-validator.png
|
||||
007681714895ac062c980db1dda806ac17d4f01019ce9c46491a108d17c2dbda 85338 docs/screenshots/overview.png
|
||||
1f78414b00ec100af2ec9bf5c9a3e400b6c9bf6dca6fcc317fd951789acc4536 112852 docs/screenshots/repository-workspace.png
|
||||
735950c1e77bd4a1cf5ee986a7a307600741e5fdb90918adb0687bd29a89aaec 5877 docs/SECURITY.md
|
||||
32a34ec13a284d3f9ceebbc107b25a844e3db096f8cafa4e43951fc2050c9a03 13552 docs/SETUP_GUIDE.md
|
||||
2fd71e9bcaeb4cb10c3fa2496b7e52fedf70c5b7f871cd587e22dc060c399079 4421 docs/SSH_UNRAID_DEPLOYMENT.md
|
||||
b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md
|
||||
0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md
|
||||
4625a10ebd3c749f60b2a7bef6b1716cd05dbc44ccceba0491a1b46bc293c195 4883 docs/TEST_MATRIX.md
|
||||
dbbd9fa96988e7543e98c85da864adaadd3057815f18d20a3b3ccb5c540a169d 4558 docs/UPDATING.md
|
||||
4983414a980075e6faae687b0d71c8e57bfe53fcb4cadb8b979b8abca636fe95 6654 docs/TEST_MATRIX.md
|
||||
03fb2fe52a863b9d3d536f3c8abe23e47b9851be7fd7ccbfb106554b9c595385 5013 docs/UPDATING.md
|
||||
73f094a2f0db3de053e515feb2771cd5a4f3aa4178f2c5f37be01ca65ff1c938 2705 eslint.config.js
|
||||
c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 examples/gitea-actions/deploy.yml
|
||||
4c792cc9fd57ed36da291300c252a6ef75b08a249cf6f2561e23c4c22522138a 1477 examples/gitea-actions/rollback.yml
|
||||
577f3fa2131a3baa84549a6523f5816ef9da94f5bac6bc274d4588b6e7ab6594 5688 examples/server/forgeflow-deploy
|
||||
@@ -77,94 +106,147 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532
|
||||
106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf
|
||||
2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json
|
||||
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE
|
||||
3b16a087c73b600415394dff8b8e34e7f7519e48fde1cf443007b2e11ca77b27 13123 main.cjs
|
||||
e2daa28bbc01c68c3702add6ea8259dff5920b22f6fdc3c9193ed78a153f2e9e 14708 main.cjs
|
||||
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
|
||||
efe2f75ec8bfdbad8e2ee68d0f2c4b412ba460c6b841163d7853f56d5b7b63ea 130468 package-lock.json
|
||||
605514833fe95a59cbfd5cee6a509ce8a0228a8b79c1fcc5bdb4e8fea92084b7 4364 package.json
|
||||
e95be3f3736f2c1ef249fdc9a083fffa46c07a0b885979878a552a4846995aa6 10160 preload.cjs
|
||||
46e77e759b75c39737fc9812d105b31c20b68f0c46c7994bebf01a487ec9f207 179808 package-lock.json
|
||||
ba81105b16f3f3605a9e578a9e7a40d6fbc29f0001ad96f7f296441e563ceeb4 6367 package.json
|
||||
1237df9ddcbb5ac7dc4316f18c34ff4a7030e3e0d56216ade6dd07369e5e2a04 1353 playwright.config.mjs
|
||||
e8f678b26a1b651ee0e06e499b0538d8a193d0565687e9f750b58f801e4fabd8 12473 preload.cjs
|
||||
abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd
|
||||
f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1
|
||||
688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1
|
||||
794bbe1937077788f34c64398fd73dc9a3c43d095084aa32673f3e968b115de2 9150 README.md
|
||||
6d0858d6654c3c3dc7083ebbd234c88324afcebaecd7b772719440a8afbc2e4e 10736 Publish-ForgeFlow-Release.ps1
|
||||
33f3c4795705ab77c6e6603c88a32c123b3a286bc77e8e472b76970485699338 4386 Publish-Missing-Binary-Release.ps1
|
||||
423447da25b001d869f9e9d7c860710f470945e72344332fc16b6c42a70d3ff6 10965 README.md
|
||||
79d93000a8b9dec65448f866f48d83b62d6bf37af26480becd73654544d454e1 15831 reports/architecture-audit.json
|
||||
dd54fd4a24120a466603acedab329bd3ac6f482ddb1e65c305d4cc755be5ec0f 1516 reports/architecture-audit.md
|
||||
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
|
||||
00d57bda5af8c8eda294b72d18b318f024a307b81b0d9205a0821f5240151e31 3814 scripts/apply-binary-update.ps1
|
||||
f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287 scripts/apply-source-update.ps1
|
||||
33a040b12fc5deab05f9df5f71986d0bb71b91f15a8cd25e9ab3cf7ca9a26766 4212 scripts/audit-installed-deployments.cjs
|
||||
d0745072321aca2c80f44460974a7926715a9f429164aaf7660dced40b52c736 4790 scripts/apply-binary-update.ps1
|
||||
404863bcbe7292355662e3a326455df864d7279badc29f90866a3b837420df54 10745 scripts/apply-source-update.ps1
|
||||
02e924227f6cad3777fd06660230c85df590d8ce95e134194a4d18970a240b88 4145 scripts/architecture-audit.mjs
|
||||
47a5b16e95934bfe510c18bf94547ae65acb980c0f0506ae156d1a486dfbdfc9 8985 scripts/audit-installed-deployments.cjs
|
||||
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
|
||||
403a64db5069595a83006a4e293d7e5ceeaefcfb74e820ed3e899864a0f182d2 6066 scripts/publish-binary-release.cjs
|
||||
444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs
|
||||
d0e6fd6ce67b553a3654acd4393e5b9c3be825c45d03d957fee36fb2a3c56a85 8308 scripts/publish-binary-release.cjs
|
||||
558ff442988f1396c174c7161ff5bd3ef0b2f43cfc31459ec7c3967faa146bc3 1694 scripts/serve-demo.mjs
|
||||
288c4b93f6006c0b32cdf90555bdc0d1d3b61d24a8763fcc30f1e6425ce1684d 1713 scripts/setup-update-signing-key.mjs
|
||||
431d3d7eabf7e2ea2d5cbb96fb0ddc13f26d85afebcb9692f3e30242197cbd8d 2607 scripts/sign-release-manifest.mjs
|
||||
c76507857292c5713e1c699cf02e24b80265da39af2cecd148034bdb874adbb6 5246 scripts/test-authenticode-chain.ps1
|
||||
4393f7dc5f417e6d601a68238f4e26791799a3634acec228fe4d79deaee85eb5 3109 scripts/validate-installed-connections.cjs
|
||||
43e84c3cced1e23ba5b070d87051a235cd7e2d8c02e2e5ddc1e5e0ff2afabace 16487 scripts/verify.mjs
|
||||
0079701b5acbfef07b71a9623613d1940805ccd20649d77e3f34c37e79df7655 735 scripts/write-release-checksums.mjs
|
||||
e6127e1e62f39c70ddb1abf72f4d7e7b8e3f19ff1f219e1a3660353c2e0cdfac 2411 scripts/verify-release-signatures.mjs
|
||||
587efcb60a363614fb61f722fa84a81e4da09c5ea35e6897009f82c5d244d740 21919 scripts/verify.mjs
|
||||
c2c9e4ba251d93a530a52b2d0079787680261c314083bb99d2356fc177719613 2434 scripts/write-release-checksums.mjs
|
||||
619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1
|
||||
dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs
|
||||
c9dee05857b6eb9475dace885579291b9ce6830023ab54acafaa146d15962a7a 27754 src/main/config-store.cjs
|
||||
5506ac1e5e49006ffd028a29c89bd0b95485ea3f2abc22db4f2b9fedd959852f 33194 src/main/config-store.cjs
|
||||
2fb04b1494b39f5d7c0720fa5fd298cd46fa85dc1b696d77657592347fcf1819 2731 src/main/configuration-backup.cjs
|
||||
9d0af5074093108a5248d0dde0ff70a666748e61f1954b630886a81e8f34072c 24079 src/main/deployment-service.cjs
|
||||
c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs
|
||||
86e9fc2eda66b4b563f6c4bbb87d3e8514340d484fb503b73137e63b6b05c3c9 14597 src/main/deploy-key-lifecycle-service.cjs
|
||||
7cbfe51973d6607203cb197652ed7f296a3f6b6b644df876957117866a47d802 2159 src/main/deployment-identity.cjs
|
||||
ce30ddac403d1adf21176e5df21b0cc3db435305d2628f51f1486eacf20df6f2 23708 src/main/deployment-service.cjs
|
||||
f22348297291199e858656248cec70f94f002144ea7ea0807bd844cc5016baaf 16277 src/main/diagnostics-service.cjs
|
||||
a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs
|
||||
0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs
|
||||
3ce45837099ac7bddc024974bd839575b4b765a7df9055e7d45ef889dc85bf7f 15623 src/main/git-validator-service.cjs
|
||||
d85d5b1abb35e8bd7f1273a697914eeaf96567d3d4ce3f55f364765d35ea4ad9 19900 src/main/gitea-service.cjs
|
||||
8adeebd08aeafaf79ed8fc7bcd2c1e110d3dc17c14596fb623b9d2c7255fcd11 49662 src/main/ipc.cjs
|
||||
3d19a328eec427329fd9123b24fe79b5ab6670c0b33ac68397948e8df636a99d 43961 src/main/git-service.cjs
|
||||
e28fc1ca2fd4c0116148f5005d793feddf04c36ef711d2d348560394d209a613 7253 src/main/git-validator-policy.cjs
|
||||
3a101b63ad3761c26350c2ac0793279a0b27672b91a5b1d8dc75bc44d92f0b52 27128 src/main/git-validator-service.cjs
|
||||
3cc53e24e023aa0d8bf36c35ce9672ca98e6c74066512c8b59ab42274e838c22 21307 src/main/gitea-service.cjs
|
||||
2ad3b2e647377f687ad987fe248a142ad399ecac98e4b49965aa7efc6093e5fa 6914 src/main/inventory-classifier.cjs
|
||||
dafdb09133d2b6ec2161a3f0b09354551e54fc606c8107976fca37405643be91 3404 src/main/inventory-review-service.cjs
|
||||
5e10cf3759bbf9294909866ed16f206d394789f0564549fc0fe23cdd89118ca6 25110 src/main/ipc.cjs
|
||||
26efebb4c147ed560966e7e60e64a013b3476327b3bbdb4e4439949142fa7846 2250 src/main/ipc/channel.cjs
|
||||
b80357dd1f0aa18022d92db85b6cc8f29bc691ef11f9a0e90b4886ae5e19c763 12543 src/main/ipc/deployment-handlers.cjs
|
||||
dc9b5971c9fefe8c374aa31916f5513601ce86003fd48b1d0e51330a909ae3a5 3442 src/main/ipc/operations-handlers.cjs
|
||||
8072252821b1245d121eac534a18eeb64f0d7d18429e272e21a6e90b010005b9 17272 src/main/ipc/repository-handlers.cjs
|
||||
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs
|
||||
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs
|
||||
720c4a0c554f46386d87c3ab6607d1fbcae66e50b69483c7dbba169d5128c851 680 src/main/process-error-policy.cjs
|
||||
3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs
|
||||
e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508 src/main/repository-monitor.cjs
|
||||
17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs
|
||||
e64f7257d478955c675a133b3735b6afe138a69d2ad090898061e56f557c43e5 9926 src/main/production-acceptance-harness.cjs
|
||||
27bd6621c731545ec46d8914e9408c89928a8ce563b40eb4bcd8a516662a54d1 8716 src/main/repository-monitor.cjs
|
||||
6393583911263575c6e2a19d9baab6e638cce90252c386b0a5144f2fb6f81f15 12154 src/main/repository-service.cjs
|
||||
52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs
|
||||
afef3841a3948b2121f8fba809aae4ea3da71bd2fda86973ba50200a5b1f89b2 14894 src/main/ssh-service.cjs
|
||||
75f4135bc068d6c615fc317cfda81f33a4387c4ff44fbb9799a055f302cc154d 137170 src/main/unraid-deployment-service.cjs
|
||||
b654a9e45044ad32c61fabe4a6d897288615ec83739b53e3241ff881e32f56bd 21677 src/main/update-service.cjs
|
||||
07822cdaf34745678b82b7eb0f20fedb16bdbcd6ab2b9b774ae0cf9c6069032d 234703 src/renderer/app.js
|
||||
793003566823e1d5c02283f583888ecc07e44477525620579b3d858f488b3c08 22347 src/main/ssh-service.cjs
|
||||
19538a3c40ea3489bbaee9a23af36a5e99962af6bb3d04259f05ece6588cbeb2 25901 src/main/unraid-access-methods.cjs
|
||||
5621e35323e4f81fb14a05670f81579ec1e66bea3a55fa6457ece0f807421424 9801 src/main/unraid-deploy-key-host.cjs
|
||||
6d9910dace52625f88e066a8485af2663c3735ff15e9ce9031441ce742710a21 30793 src/main/unraid-deployment-methods.cjs
|
||||
673b1692e7c2b5197545df98750b5d048bddf44206263e25be4f17d9bf900e2c 17208 src/main/unraid-deployment-service.cjs
|
||||
bb4a99c3526fcf4db4fbae88a058e8598fd10a87990e7d502bfdc765328bdaa1 42766 src/main/unraid-inventory-methods.cjs
|
||||
2c0cf07921ca7ee5a9085ced44498c2e6798e5cc1e8a5ecf704c3cecabe39a25 27607 src/main/unraid-preflight-methods.cjs
|
||||
d45220176aed72d692f9ae5534f9d40bcc359a2d08e025e74a3b3b505b8b9ed4 16559 src/main/unraid-runtime-methods.cjs
|
||||
4c5cf01922e1feb36a31b50af22e973d8aee3fecccd406e449690604111898ac 11608 src/main/unraid-state-methods.cjs
|
||||
0b25c3729c5fffe3cd412c2325616bb86a6c916ae248eeb39d837378bb78c144 26420 src/main/update-service.cjs
|
||||
b5c304531bec358d059189a27cd9db8fa20cefb7f817e5eb0287001f7353f6a7 985 src/renderer/actions/command.js
|
||||
d0bf607dd1de9d55f2947d0adf0997cd3ca5c269d10a5362cc1d8bc4d1a2a8ae 6706 src/renderer/actions/deployment-operation.js
|
||||
0db283b1a458ae0b31538940b1ddc931ffdb53bd04ceb7fd8903813f9200d071 17978 src/renderer/actions/deployment-profile.js
|
||||
48bed91dd2a85bb51ee7307f7acc3b79c881ce8cf63b22ba79d5d079b265eb4b 7785 src/renderer/actions/inventory.js
|
||||
13b8611b5389625deeec59ff2a6cfebcfc93bd7972902439715be371d1f9f573 14936 src/renderer/actions/recovery.js
|
||||
2414a0d29a0380d343b9b0e58ba1909e7a7eeb46357fd45ddbb3ad411d119f78 16280 src/renderer/actions/setup-and-settings.js
|
||||
a980f2e86d8286ea605a7259b9e9adcf3fda4f657b8d54a5d2d8765a7bbbec13 19065 src/renderer/actions/shell.js
|
||||
65e305965d6d00d45b516f271c0f905854797af07c38982768199bb30d988c2b 26659 src/renderer/app.js
|
||||
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png
|
||||
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png
|
||||
094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png
|
||||
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png
|
||||
fb7ed47f9aac50d9259d7d3c3bb2010c7bfdd2fe8e8e47ca2744bb22f0057d54 830 src/renderer/index.html
|
||||
b63786e7e4f70eac8b65d29484c0040aec530a410da5f554574d8a056cd90003 60621 src/renderer/mock-bridge.js
|
||||
34ee56ed08dcd1c2295985b9bb419b981598a2699ee8e65394d86f4177f4797b 77647 src/renderer/styles.css
|
||||
0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs
|
||||
fd47efd296409c19b80fd7719429e3eb33963ef78f30bc748d1770308aa472a3 55138 src/renderer/dialogs.js
|
||||
dede1f21a06c73a2c2a462a869d27530d85f99baff202a2eb509c57436ad6aec 2732 src/renderer/diff-view.js
|
||||
eef2f269ba4fbb76bf66ad328d481b461255d0acb753b30878dd4d4eaac57dc6 6924 src/renderer/events.js
|
||||
c4a71213d412166093f7bd8254b847de4d8beb58c1aaa356a0cdc8d728080326 1524 src/renderer/index.html
|
||||
06180d9656dd254edfb6949c397f8e313954fc560ddcb22b3a35fce3c3e35655 21350 src/renderer/mock-bridge.js
|
||||
870024aff376826a92c9cf7452689cc1ecc5d9034f055bea56734f3f7fcea5e5 28703 src/renderer/mock-deployment-bridge.js
|
||||
92cacf58a3576044800ee5f9823d4fd2a1c768571b7f5d271950f5002a80d212 25031 src/renderer/mock-repository-bridge.js
|
||||
94fa265c2fe9ca8d644f0ce9b620b6f85d9b25dca5802c4e9195b66dcbe80120 6522 src/renderer/operations.js
|
||||
431ed7bbadecbcc99572aea9a5ae5550dbb5bacbd2d8028f8a05bf77f97b251e 81161 src/renderer/styles.css
|
||||
8ef063feee96c0a3692da21fa797bf44325388b94e051d53f20a5198036c9252 102998 src/renderer/views.js
|
||||
e9e72c072a5c5d04f59cd6763de0cfbf736c2a5ffa2f722143f3bad2bdbc630b 1411 src/shared/clone-target.cjs
|
||||
5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs
|
||||
029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs
|
||||
2778ebcbdf60fdc1cb0749f15565e0e1bd66f3a0d31eb70ae7942a7511a3de75 1295 src/shared/repository-match.cjs
|
||||
c7e120ea53c5ef3c01b8cce71afe913f34bb461bb73aa3ade24656e09f99f338 1152 src/shared/semver.cjs
|
||||
8791d3813e6cf285ee6aa49f76e75fc1f3af76fd98c76bcb3c92ee18e9cb699f 2889 src/shared/shell-verification.cjs
|
||||
2daa98fd421598bfe5fc9757c9b6f4d82c31d1bfece15829928473581d5d2639 1210 src/shared/tool-invocation.cjs
|
||||
114f01be8bd54c91b90af82d8e1604e24cc0c5f8e64e63c40cf3f4042623a98e 5402 src/shared/validation.cjs
|
||||
ee73fdf9c591c029243385cb2d2085c3005c7b08c5b9e1b89102201f0ab30759 5702 src/shared/validation.cjs
|
||||
13b731c38863b1007b0312fd9d89562401b7cce875c952f52429bde74f77a8af 3096 src/shared/zip-writer.cjs
|
||||
f8853dce6fdf360d5df2fbe2b6df3e5687630c807fee5ba8436679b34ec737ea 2436 START_HERE.md
|
||||
058aeaa5d9bfe377c7e322f213c7871ecc4151b5d08ef790992f4ee28d857658 743 START-FORGEFLOW-OVERLAY.ps1
|
||||
f5b0ea887fcdeadec78c1ad49b0ec7979723562f5c0b730703acb77a37281ee0 1009 tests/acceptance.test.mjs
|
||||
a4e5947204ff6878e601e32477bc85b53cd0153baf95a161c8935b6e5466c257 1155 tests/audit-service.test.mjs
|
||||
454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 4318 tests/clone-target.test.mjs
|
||||
a984ddd5a29a4ccf55d78ba71202390e0bf1f0925a6a96f03ee74edbbf3bb2f7 1505 tests/configuration-backup.test.mjs
|
||||
0841f6a2515508f8d28562d5c565c1519d357b8579515f890883dc2aec7d7737 16881 tests/browser/forgeflow.spec.mjs
|
||||
1728c0a7abd92f4d7d9e68df32e4a6b00730555f23795e9b36416795d9d127af 5978 tests/clone-target.test.mjs
|
||||
aa2ae0e5a12bc47f8024e0d7408148e3af797e3f3f0ecb2525fb1cc54cf4e1f0 17378 tests/config-store.test.mjs
|
||||
f1463326aee79842d265687ae628189ce54e92544600f2bd14073780287cfb14 2502 tests/configuration-backup.test.mjs
|
||||
144c8e217a334cd69f502938d944e0f2dac61703d5df47e287b9ed542918c779 8129 tests/dependency-wiring.test.mjs
|
||||
aad5948ea374d1e56e777005c73639654c96a90364dd398c949052cf5ae343a2 11130 tests/deploy-key-host.test.mjs
|
||||
b7e009fed4171d6dd6b4c3154ba1d3f7198e98f5b79b298687841fc8169447cd 9354 tests/deploy-key-lifecycle.test.mjs
|
||||
49bf9cf9842e7899015013675208f83a95402065a082320927a677ee4bab0766 24875 tests/deployment-operations.test.mjs
|
||||
1dc6477bd07de78be189e6e8195ec339eb9d75820c4dbd5b073b8520ee21f6b5 1938 tests/deployment-policy.test.mjs
|
||||
bf68c4dc91a2604235c6a7848088bcd9566fbeaa089b86ec1e0a4fcfc54ca9d2 7677 tests/deployment-status.test.mjs
|
||||
bf4576901e32662d832687a2761852aa1b2cffe256de5044f18c6637c189463b 9780 tests/deployment-status.test.mjs
|
||||
fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800 tests/diagnostics.test.mjs
|
||||
dd121d96ca265a027cd415a52064500a4541b2f8a662f4f4b25f2f996d52b5da 762 tests/external-tools.test.mjs
|
||||
e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 tests/git-integration.test.mjs
|
||||
1da4abd9355183ee04410b3d89403cc36094eb5df622ecfe6736e8807135bd28 19532 tests/git-integration.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
|
||||
771eeb4fa5443d581991cedb4107d7c414ce1b7d2e14bac7cf719ec8ba131647 8969 tests/gitea-actions.test.mjs
|
||||
48bca4711e7c193d19c78a0cb45ea1c83179b3c23640195f66058268e8a11b52 1520 tests/ipc-contract.test.mjs
|
||||
d633c59bd910008223c834c6d7f3e5666c685a0881944263ede2d42cc69d3151 18710 tests/gitea-actions.test.mjs
|
||||
fcc9a063882840dd89d74c2785284c8f2f6a9e5acec482b6d89ed8de62efdb85 9635 tests/inventory-classifier.test.mjs
|
||||
62b90c21c15b841af30d26ccb0b9e88d25674fa7dbff9dc231dd8a1dddc3d657 2025 tests/ipc-contract.test.mjs
|
||||
caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs
|
||||
96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs
|
||||
c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 3718 tests/preflight.test.mjs
|
||||
15e05220fd282895b02ea52ceaa38327a61297af8db1484e6d9964e8b03a8fea 9156 tests/renderer-workflow.test.mjs
|
||||
1b6c920e18a248f78acaed6187197c88ec8d911b62d5e2a9f8ad57b91ae80499 11827 tests/preflight.test.mjs
|
||||
7f2751ea2621f76b5427f442e931344d13e97faa7b6ef3151949bbd6a03097cf 1205 tests/process-error-policy.test.mjs
|
||||
0cb884cf62c1cb02cf59a81662be055bcb5339d176de85e2a3eeb8e8573e11b3 6435 tests/production-acceptance.test.mjs
|
||||
252e0345fcd3cd3899467f2a9ed363df0675d529e59d4d6e1204f4464fd8a5f7 11485 tests/renderer-workflow.test.mjs
|
||||
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs
|
||||
f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 1068 tests/repository-monitor.test.mjs
|
||||
75b5b83836c75675bb9a48fe4363fcb8a24fc425e6af6f822d7955c6f3c79eac 2265 tests/repository-service.test.mjs
|
||||
d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463 tests/security-validation.test.mjs
|
||||
76712a5d26f2598b00b83b925c9c84a90ab81c0eb1760895e9d6a2bd2f6eb425 5828 tests/repository-monitor.test.mjs
|
||||
5476f3ba90bc096d4172900d9b54ada7c12da521f8627913d87794eade3cee23 13494 tests/repository-service.test.mjs
|
||||
5fea04e668344508fb4e16da9bb6fe8733e2b83d1c227acb3421e51da26b2ffa 3636 tests/security-validation.test.mjs
|
||||
bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs
|
||||
12cb3b240bdd0922566323c0014838ca067ad10d9d4009943165ae2c4e93bc6f 11786 tests/server-inventory-branches.test.mjs
|
||||
020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs
|
||||
2571128f0b8e650071df17755baa09c4dfc441af0c20a7a4e9aa445b59e87d11 1654 tests/ssh-service.test.mjs
|
||||
a39d30f47813dfb98c998811f3d76ebbb1544ecfd017a165d44f9afb80d7daf9 9090 tests/ssh-connection-pool.test.mjs
|
||||
7ee9166327ed227d2b7c6929692dea5c5d7a41c3e566596fa92d9ec4f42e8677 4085 tests/ssh-connection.test.mjs
|
||||
c9354e4bf3720c28cff21c15ff8b9474ba4a23b7f55389de4326f4dffde54d78 9510 tests/ssh-service.test.mjs
|
||||
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs
|
||||
5634e82a3a3c782cd6d7cd4df42b3102c22b81c5566fb072d7cd30de65c1f395 40003 tests/unraid-deployment.test.mjs
|
||||
4abe7b2fc113c486f35f15c2d629c5b4f24589eada718c4ea58f93551c77d5eb 17777 tests/update-service.test.mjs
|
||||
3e4a1a6d6a744df9badcfece2cf8d09f8c34efb3c437cb08a6f2e6c9d428c0d4 59353 tests/unraid-deployment.test.mjs
|
||||
3bd3247ed821ba261ad7c02d649c26979e3591df456afd1bda04e351b2296fa1 29168 tests/update-service.test.mjs
|
||||
9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs
|
||||
7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs
|
||||
8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea 767 UPDATE_FROM_0.3.2.md
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEApGKe81NzC5mU3jfMNAQUnAOfQnCnMFry8cNpmjQsdtE=
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -18,3 +18,17 @@ npm run acceptance -- --execute-rollback
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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 fail closed on missing or mismatched release assets, SHA-256 evidence and the pinned Ed25519 publisher signature; paid Authenticode remains optional.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,10 @@
|
||||
# ForgeFlow 0.10.10
|
||||
|
||||
## Complete server-pull deployment repair
|
||||
|
||||
- Missing repository-scoped read-only deploy keys can be provisioned and verified from Unraid against the exact Gitea branch.
|
||||
- A repository deployment root can now remain above its Compose working directory without breaking workload recognition or being overwritten by inventory refresh.
|
||||
- Nested Compose files are preserved as repository-relative deployment paths, including Ludarium, Launchpad and ITWorx MCP Hub layouts.
|
||||
- The **Fix write access** action now receives its permission-report parser correctly instead of reporting a false write-access failure.
|
||||
- Server-pull preflight proves every required deployment file at the exact Gitea commit before any container activation starts.
|
||||
- Runtime secrets remain in server-side `.env` files and preserved appdata paths; no secret values are written to Git or diagnostic output.
|
||||
@@ -0,0 +1,10 @@
|
||||
# ForgeFlow 0.10.11
|
||||
|
||||
## Resilient repository refresh and consistent server pull
|
||||
|
||||
- Temporary Gitea list failures now use the in-session **last-known-good** repository inventory while local and server state continue to refresh. The UI clearly reports that remote data is stale.
|
||||
- A **closed output pipe** from a detached parent process is no longer treated as a fatal desktop-app exception.
|
||||
- Server pull, deploy-key verification, deployment, rollback and metadata now consistently prefer the verified **linked checkout origin** over a stale URL detected earlier on the server.
|
||||
- Repository-scoped **read-only deploy key** checks remain fail-closed; a changed SSH host still requires explicit trust and access reconfiguration.
|
||||
- The local **browser test server** now has a dedicated port and identity endpoint, preventing another localhost application from being mistaken for ForgeFlow.
|
||||
- All 42 responsive browser flows pass across dark/light, compact/wide and reduced-motion configurations.
|
||||
@@ -0,0 +1,12 @@
|
||||
# ForgeFlow 0.10.12
|
||||
|
||||
## Faster awareness with stricter deployment truth
|
||||
|
||||
- Repository refreshes are **coalesced** and briefly cache Gitea inventory and workspace discovery; a manual refresh remains fully forced and file changes arriving mid-refresh receive one trailing refresh.
|
||||
- Server discovery reuses its Docker and Compose evidence for existing deployment profiles instead of opening a separate SSH session for every linked workload.
|
||||
- Deployment status only claims **exact Gitea commit parity** after comparing a concrete branch SHA with the live server SHA; matching repository provenance alone is no longer sufficient.
|
||||
- Container discovery uses **batched Docker inspect** with a safe per-container fallback when a container disappears during the scan.
|
||||
- Active Gitea and SSH deployment polling uses **bounded worker pools**, improving multi-deployment latency without flooding external services.
|
||||
- A **stopped container** can no longer be marked healthy because another process answers on its previous healthcheck port.
|
||||
- Large repository and server-inventory lists use offscreen rendering containment to reduce layout and paint work.
|
||||
- Inventory diagnostics now include scan and state-refresh durations, and Gitea bulk verification fails fast after a confirmed connectivity outage.
|
||||
@@ -0,0 +1,18 @@
|
||||
# ForgeFlow 0.10.13
|
||||
|
||||
## Veilige synchronisatie en aantoonbare release-integriteit
|
||||
|
||||
- **Gitea workspace sync** toont eerst de exacte additions, wijzigingen en deletions ten opzichte van de actuele upstream-SHA. Lokale commits worden beschermd in een recovery branch; staged, unstaged en untracked werk gaat naar een stash. Genegeerde runtimebestanden blijven onaangeroerd.
|
||||
- Read-only achtergrondfetch houdt `ahead` en `behind` actueel zonder projectbestanden automatisch te wijzigen. Interval `0` schakelt netwerkfetch volledig uit.
|
||||
- Stale deployment links blokkeren niet langer de automatische, bewijsgebaseerde koppeling van de werkelijk draaiende vervangende workload.
|
||||
- SSH-hostidentiteit wordt vóór het verzenden van credentials getoond en bij bevestiging exact vastgepind. Gitea-tokens vereisen HTTPS, behalve bij expliciete loopbackontwikkeling.
|
||||
- Packaged updates vereisen een **Ed25519-signed release manifest** dat versie, tag, broncommit, artifactnaam, bytegrootte en SHA-256 bindt aan de ingebouwde publisher key. Hiervoor is geen betaald certificaat of Azure-dienst nodig.
|
||||
- Diagnostische bundels exporteren geen ruwe remote output meer. Untracked diffs kunnen geen junction of symlink buiten de repository volgen en zijn begrensd op bestandsgrootte.
|
||||
- De Git-toolsgrid behoudt nu de volledige inhoudshoogte binnen zijn eigen scrollvlak; workspace sync en troubleshooting overlappen niet meer. De demo bridge ondersteunt dezelfde recoveryflow als de desktopapp.
|
||||
- Repositorymonitoring, deploymentpolling, Docker-inspect en SSH-verbindingen gebruiken begrensde paralleliteit en hergebruik waar dat veilig is.
|
||||
|
||||
## Verificatie
|
||||
|
||||
- Volledige Node-testset, coveragepoort, architectuuraudit en dependency-audit.
|
||||
- 72 browserflows over dark/light, compact/desktop/wide, 100–150% schaal en reduced motion.
|
||||
- Windows installer en portable build, SHA-256-sidecars, provenance, CycloneDX-SBOM en ondertekend releasemanifest.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,9 @@
|
||||
# ForgeFlow 0.10.6
|
||||
|
||||
## Permanent Windows updater launch repair
|
||||
|
||||
- ForgeFlow no longer launches hidden PowerShell update helpers with Node's defective Windows `detached` process mode.
|
||||
- Binary and source updater processes remain hidden, are explicitly unreferenced after their verified handshake, and continue independently when ForgeFlow closes.
|
||||
- A real Windows regression test now exercises the exact production Node spawn options instead of using a different process API.
|
||||
- Startup is still fail-closed: ForgeFlow remains open unless the request-scoped helper status reaches `started`.
|
||||
- Versions 0.10.4 and 0.10.5 need a one-time direct installation of 0.10.6 because their installed launcher cannot execute its own helper; updates after 0.10.6 use the repaired path.
|
||||
@@ -0,0 +1,10 @@
|
||||
# ForgeFlow 0.10.7
|
||||
|
||||
## Reliable server-to-repository recognition
|
||||
|
||||
- Live, running workloads with one unique exact provenance or runtime-identity match are now linked automatically during normal server discovery.
|
||||
- Automatic adoption creates only ForgeFlow configuration and observed state; it performs no container changes and never automatically removes stale profiles.
|
||||
- Ambiguous, duplicate, external and monitoring-only workloads remain behind explicit **Review & link** confirmation.
|
||||
- Every linked repository now displays an `S` deployment badge with its profile count in the repository sidebar.
|
||||
- The repository release rail reports **Linked** with container and server identity even when a legacy workload has no verifiable live commit yet.
|
||||
- DevRunbook-style DockerMan deployments therefore show the same linked relationship in Deployments, the repository sidebar and the repository workspace.
|
||||
@@ -0,0 +1,9 @@
|
||||
# ForgeFlow 0.10.8
|
||||
|
||||
## Self-contained checksum verification
|
||||
|
||||
- Binary and source update helpers no longer depend on the optional PowerShell `Get-FileHash` cmdlet.
|
||||
- Both helpers calculate checksums directly with the built-in .NET SHA-256 implementation.
|
||||
- A real Windows regression test clears `PSModulePath` and verifies the downloaded binary successfully in that minimal environment.
|
||||
- The helper still validates the exact published checksum before waiting for ForgeFlow to exit or changing installed files.
|
||||
- This release retains the reliable non-detached launcher and server-to-repository recognition improvements from 0.10.6 and 0.10.7.
|
||||
@@ -0,0 +1,10 @@
|
||||
# ForgeFlow 0.10.9
|
||||
|
||||
## Reliable deployment inventory and preflight
|
||||
|
||||
- Unraid inventory now includes containers without healthchecks. Docker's complete JSON state is parsed safely instead of using a failing Go-template lookup.
|
||||
- ForgeFlow is single-instance: opening it again focuses the existing window, preventing concurrent inventory scans and configuration writes.
|
||||
- Server pull verifies required Compose files or the Dockerfile at the exact Gitea commit before any deployment operation starts.
|
||||
- Server-pull verification now separates deploy-ready access from optional live-SHA and runtime-health evidence. A recoverable workload is no longer shown as blocked merely because parity is not yet provable.
|
||||
- Deployment cards and audit output show concrete access blockers and non-blocking warnings instead of a generic incomplete result.
|
||||
- All discovery, verification and preflight checks remain non-destructive; no containers are changed during these checks.
|
||||
@@ -0,0 +1,56 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
Run `npm run signing:setup` once on the release workstation. It stores the
|
||||
private Ed25519 key outside the repository and writes only its public key into
|
||||
the packaged app. `npm run dist:win` then produces the installer and portable
|
||||
executable, SHA-256 sidecars, CycloneDX SBOM, provenance and an Ed25519-signed
|
||||
manifest bound to the exact source commit. The updater verifies the pinned
|
||||
publisher key before trusting the artifact digest and verifies that 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, SBOM, signed
|
||||
manifest and signature. It only publishes after all eight 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.
|
||||
@@ -94,7 +94,7 @@ health verification, diagnostic correlation and no arbitrary shell input.
|
||||
|
||||
- Windows installer/portable acceptance;
|
||||
- macOS/Linux package validation;
|
||||
- code signing, notarization and signed updates;
|
||||
- optional code signing/notarization for future public distribution;
|
||||
- dependency/secret/package scans;
|
||||
- accessibility review;
|
||||
- hundreds-of-repositories performance tests;
|
||||
|
||||
@@ -108,9 +108,9 @@ included model uses:
|
||||
- atomic non-secret status JSON;
|
||||
- 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;
|
||||
- dependency, secret and binary scans in CI;
|
||||
- package-level IPC/navigation regression tests;
|
||||
@@ -124,9 +124,13 @@ included model uses:
|
||||
- SSH passwords and private-key passphrases use Electron `safeStorage`;
|
||||
- diagnostics receive those runtime secrets only for redaction and never export
|
||||
encrypted credential fields;
|
||||
- SSH deployment requires a pinned host-key fingerprint;
|
||||
- SSH host identity is previewed without credentials and authenticated sessions
|
||||
require the exact user-confirmed pinned fingerprint;
|
||||
- remote folders and Compose paths are validated against traversal;
|
||||
- tracked server-side changes block exact-SHA reset;
|
||||
- updater tokens are sent only to the configured Gitea origin;
|
||||
- non-loopback Gitea connections require HTTPS;
|
||||
- packaged updates require a publisher-signed Ed25519 manifest that binds the
|
||||
source commit, artifact identity, byte length and SHA-256 digest;
|
||||
- update archives are checksummed and validated by the full local quality gate;
|
||||
- source backup is restored when an update fails.
|
||||
|
||||
@@ -1,8 +1,37 @@
|
||||
# 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
|
||||
|
||||
@@ -76,7 +105,8 @@ The suite contains 59 passing tests.
|
||||
- status endpoint through the real reverse proxy;
|
||||
- deployment lock, failed healthcheck and rollback;
|
||||
- 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.
|
||||
|
||||
## Renderer smoke target
|
||||
|
||||
@@ -30,14 +30,20 @@ Update logs and status files are stored beneath ForgeFlow's local user-data `upd
|
||||
|
||||
## Packaged Windows updates
|
||||
|
||||
ForgeFlow 0.9.1 and newer use authenticated Gitea release assets when running from the installer or portable executable. The updater selects the installer or portable artifact that matches the current installation mode, requires its `.sha256` sidecar, validates the Windows executable header and SHA-256 digest, then verifies the digest again immediately before applying it. An external PowerShell helper waits for ForgeFlow to exit, installs or replaces the executable and restarts it.
|
||||
ForgeFlow uses authenticated Gitea release assets when running from the installer or portable executable. The updater selects the artifact that matches the current installation mode and requires its `.sha256` sidecar. From version 0.10.13 onward it also requires an Ed25519-signed release manifest. The embedded public key verifies that manifest before ForgeFlow trusts the artifact name, byte length, exact source commit or SHA-256 digest. The digest is checked again immediately before applying the update.
|
||||
|
||||
`Publish-ForgeFlow-Release.ps1` now treats source and binaries as one release transaction. By default it pushes the validated source, builds the exact published commit and uploads all four required assets:
|
||||
`Publish-ForgeFlow-Release.ps1` treats source and binaries as one release transaction. It pushes the validated source, builds the exact published commit and uploads eight required assets:
|
||||
|
||||
- `ForgeFlow-Setup-<version>-win-x64.exe`
|
||||
- `ForgeFlow-Setup-<version>-win-x64.exe.sha256`
|
||||
- `ForgeFlow-Portable-<version>-win-x64.exe`
|
||||
- `ForgeFlow-Portable-<version>-win-x64.exe.sha256`
|
||||
- `ForgeFlow-<version>-provenance.json`
|
||||
- `ForgeFlow-<version>-sbom.cdx.json`
|
||||
- `ForgeFlow-<version>-release-manifest.json`
|
||||
- `ForgeFlow-<version>-release-manifest.json.sig`
|
||||
|
||||
Run `npm run signing:setup` once on the release workstation. The private Ed25519 key stays outside the repository in ForgeFlow's user-data folder. This independent publisher signature is free; optional Authenticode can still be added later for Windows reputation.
|
||||
|
||||
Use `-SkipBinaryRelease` only when intentionally publishing source without enabling packaged auto-update.
|
||||
|
||||
@@ -70,6 +76,6 @@ Set-ExecutionPolicy -Scope Process Bypass
|
||||
.\Publish-ForgeFlow-Release.ps1
|
||||
```
|
||||
|
||||
The script installs dependencies, runs the complete quality gate, clones `git@gitea.itworx.tech:Jens/ForgeFlow.git` into a temporary folder, mirrors the validated source without `.git`, `node_modules`, `dist` or release archives, commits it and pushes `main`. It then compares local `HEAD` with `git ls-remote`, builds the exact published checkout and uploads the installer, portable executable and both checksums to the matching Gitea release. Publication fails when either the source commit or any required binary asset cannot be verified.
|
||||
The script installs dependencies, runs the complete quality gate, clones `git@gitea.itworx.tech:Jens/ForgeFlow.git` into a temporary folder, mirrors the validated source without `.git`, `node_modules`, `dist` or release archives, commits it and pushes `main`. It then compares local `HEAD` with `git ls-remote`, builds the exact published checkout and uploads all binaries, checksums and signed release evidence to the matching Gitea release. Publication fails when either the source commit, publisher signature or any required asset cannot be verified.
|
||||
|
||||
Keep the currently installed older ForgeFlow source folder untouched until the built-in updater test is complete.
|
||||
|
||||
|
Before Width: | Height: | Size: 326 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 110 KiB |
@@ -0,0 +1,67 @@
|
||||
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" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
// The main process and shared modules are plain CommonJS with an explicit
|
||||
// dependency graph, so undefined identifiers there are always real bugs
|
||||
// (missing require, missing injected dependency) rather than a global that
|
||||
// another script tag happens to define.
|
||||
files: ["src/main/**/*.cjs", "src/shared/**/*.cjs", "main.cjs", "preload.cjs"],
|
||||
languageOptions: {
|
||||
sourceType: "commonjs",
|
||||
globals: {
|
||||
require: "readonly", module: "writable", exports: "writable",
|
||||
__dirname: "readonly", __filename: "readonly",
|
||||
Buffer: "readonly", process: "readonly", console: "readonly",
|
||||
setTimeout: "readonly", clearTimeout: "readonly",
|
||||
setInterval: "readonly", clearInterval: "readonly", setImmediate: "readonly",
|
||||
queueMicrotask: "readonly", structuredClone: "readonly", globalThis: "readonly",
|
||||
URL: "readonly", URLSearchParams: "readonly", fetch: "readonly",
|
||||
FormData: "readonly", Blob: "readonly",
|
||||
AbortController: "readonly", AbortSignal: "readonly",
|
||||
TextEncoder: "readonly", TextDecoder: "readonly",
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"no-undef": "error",
|
||||
// Also catches code that a refactor left behind, such as a value computed
|
||||
// from a dependency that is no longer injected.
|
||||
"no-unused-vars": ["error", { args: "none", caughtErrors: "none", ignoreRestSiblings: true }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["tests/**/*.mjs"],
|
||||
rules: {
|
||||
"no-regex-spaces": "off",
|
||||
"no-unsafe-finally": "off",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -25,19 +25,39 @@ const {
|
||||
UnraidDeploymentService,
|
||||
} = require("./src/main/unraid-deployment-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 {
|
||||
ExternalToolsService,
|
||||
} = require("./src/main/external-tools-service.cjs");
|
||||
const { registerIpc } = require("./src/main/ipc.cjs");
|
||||
const {
|
||||
installOutputPipeGuards,
|
||||
isBrokenPipeError,
|
||||
} = require("./src/main/process-error-policy.cjs");
|
||||
|
||||
let mainWindow;
|
||||
let repositoryMonitor;
|
||||
let sshService;
|
||||
let operationTimer;
|
||||
let diagnostics;
|
||||
let configStore;
|
||||
let tray;
|
||||
let quitCleanupStarted = false;
|
||||
const reportBrokenOutputPipe = (error) => {
|
||||
const report = diagnostics?.warning("process.output-pipe.closed", {
|
||||
code: error?.code || null,
|
||||
message: error?.message || "The parent output pipe was closed.",
|
||||
});
|
||||
report?.catch(() => {});
|
||||
};
|
||||
installOutputPipeGuards({ onBrokenPipe: reportBrokenOutputPipe });
|
||||
const ownsSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
|
||||
if (!ownsSingleInstanceLock) app.quit();
|
||||
else app.on("second-instance", () => showMainWindow());
|
||||
|
||||
function broadcast(channel, payload) {
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
@@ -168,6 +188,7 @@ function createWindow() {
|
||||
app
|
||||
.whenReady()
|
||||
.then(async () => {
|
||||
if (!ownsSingleInstanceLock) return;
|
||||
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
||||
callback({
|
||||
responseHeaders: {
|
||||
@@ -211,6 +232,10 @@ app
|
||||
await audit.initialize();
|
||||
|
||||
process.on("uncaughtException", (error) => {
|
||||
if (isBrokenPipeError(error)) {
|
||||
reportBrokenOutputPipe(error);
|
||||
return;
|
||||
}
|
||||
diagnostics
|
||||
?.error("process.uncaught-exception", error)
|
||||
.finally(() => app.exit(1));
|
||||
@@ -228,6 +253,7 @@ app
|
||||
const repositories = new RepositoryService(store, git, gitea, diagnostics);
|
||||
const deployments = new DeploymentService(store, gitea, git, diagnostics);
|
||||
const ssh = new SshService({ store, diagnostics });
|
||||
sshService = ssh;
|
||||
const auditedOperationStates = new Set();
|
||||
const reportOperationChange = (payload) => {
|
||||
broadcast("operations:changed", payload);
|
||||
@@ -238,6 +264,11 @@ app
|
||||
) {
|
||||
const key = `${operation.id}:${operation.status}`;
|
||||
if (!auditedOperationStates.has(key)) {
|
||||
// One entry per completed deployment, so the set is trimmed rather
|
||||
// than kept for the lifetime of the process.
|
||||
if (auditedOperationStates.size >= 500) {
|
||||
auditedOperationStates.delete(auditedOperationStates.values().next().value);
|
||||
}
|
||||
auditedOperationStates.add(key);
|
||||
notify(
|
||||
`Deployment ${operation.status}`,
|
||||
@@ -264,6 +295,13 @@ app
|
||||
sourcePath: app.getAppPath(),
|
||||
onOperationChange: reportOperationChange,
|
||||
});
|
||||
const deployKeys = new DeployKeyLifecycleService({
|
||||
store,
|
||||
gitea,
|
||||
keyHost: new UnraidDeployKeyHost({ ssh }),
|
||||
audit,
|
||||
});
|
||||
const inventoryReviews = new InventoryReviewService({ store, audit });
|
||||
const updates = new UpdateService({
|
||||
store,
|
||||
gitea,
|
||||
@@ -290,6 +328,7 @@ app
|
||||
git,
|
||||
gitea,
|
||||
diagnostics,
|
||||
store,
|
||||
});
|
||||
repositoryMonitor = new RepositoryMonitor({
|
||||
store,
|
||||
@@ -305,6 +344,8 @@ app
|
||||
repositories,
|
||||
deployments,
|
||||
unraid,
|
||||
deployKeys,
|
||||
inventoryReviews,
|
||||
ssh,
|
||||
updates,
|
||||
preflight,
|
||||
@@ -406,7 +447,6 @@ app
|
||||
});
|
||||
})
|
||||
.catch(async (error) => {
|
||||
console.error("[startup]", error);
|
||||
await diagnostics?.error("app.startup.failed", error);
|
||||
await diagnostics?.flush();
|
||||
app.exit(1);
|
||||
@@ -417,6 +457,7 @@ app.on("before-quit", (event) => {
|
||||
event.preventDefault();
|
||||
quitCleanupStarted = true;
|
||||
repositoryMonitor?.stop();
|
||||
sshService?.closeAll();
|
||||
if (operationTimer) clearTimeout(operationTimer);
|
||||
Promise.resolve()
|
||||
.then(() => diagnostics?.info("app.quitting", {}))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "forgeflow",
|
||||
"version": "0.10.0",
|
||||
"version": "0.10.13",
|
||||
"private": true,
|
||||
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
|
||||
"main": "main.cjs",
|
||||
@@ -10,21 +10,35 @@
|
||||
"dev": "electron . --dev",
|
||||
"demo": "node scripts/serve-demo.mjs",
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"lint": "eslint .",
|
||||
"coverage": "c8 --check-coverage --lines 85 --functions 85 --branches 68 --statements 85 node --test tests/*.test.mjs && npm run coverage:modules",
|
||||
"coverage:modules": "c8 report --check-coverage --per-file --include src/** --statements 60 --lines 60 --functions 50 --branches 36 --reporter=text-summary",
|
||||
"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/sign-release-manifest.mjs && node scripts/verify-release-signatures.mjs && node scripts/prune-dist.mjs",
|
||||
"dist:linux": "electron-builder --linux AppImage && node scripts/prune-dist.mjs",
|
||||
"dist:mac": "electron-builder --mac dmg && node scripts/prune-dist.mjs",
|
||||
"doctor": "node scripts/doctor.mjs",
|
||||
"acceptance": "node scripts/acceptance.mjs",
|
||||
"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",
|
||||
"signing:setup": "node scripts/setup-update-signing-key.mjs",
|
||||
"connections:check": "electron scripts/validate-installed-connections.cjs",
|
||||
"deployments:audit": "electron scripts/audit-installed-deployments.cjs",
|
||||
"release:binary": "electron scripts/publish-binary-release.cjs",
|
||||
"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": {
|
||||
"@eslint/js": "9.39.5",
|
||||
"@playwright/test": "1.62.0",
|
||||
"c8": "12.0.0",
|
||||
"electron": "43.2.0",
|
||||
"electron-builder": "26.15.3"
|
||||
"electron-builder": "26.15.3",
|
||||
"eslint": "9.39.5"
|
||||
},
|
||||
"build": {
|
||||
"appId": "be.jenscaers.forgeflow",
|
||||
@@ -37,6 +51,7 @@
|
||||
"package.json",
|
||||
"build/icon.png",
|
||||
"build/icon.ico",
|
||||
"build/update-signing-public.pem",
|
||||
"docs/SETUP_GUIDE.md",
|
||||
"docs/DIAGNOSTICS.md",
|
||||
"docs/STATUS_ENDPOINT.md",
|
||||
@@ -54,6 +69,7 @@
|
||||
"scripts/apply-source-update.ps1",
|
||||
"scripts/apply-binary-update.ps1",
|
||||
"scripts/prune-dist.mjs",
|
||||
"scripts/sign-release-manifest.mjs",
|
||||
"docs/RELEASE_NOTES_0.4.0.md",
|
||||
"docs/LUMAOPS_SERVER_AUDIT.md",
|
||||
"docs/SSH_UNRAID_DEPLOYMENT.md",
|
||||
@@ -92,7 +108,27 @@
|
||||
"docs/RELEASE_NOTES_0.9.3.md",
|
||||
"docs/RELEASE_NOTES_0.9.4.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/RELEASE_NOTES_0.10.6.md",
|
||||
"docs/RELEASE_NOTES_0.10.7.md",
|
||||
"docs/RELEASE_NOTES_0.10.8.md",
|
||||
"docs/RELEASE_NOTES_0.10.9.md",
|
||||
"docs/RELEASE_NOTES_0.10.10.md",
|
||||
"docs/RELEASE_NOTES_0.10.11.md",
|
||||
"docs/RELEASE_NOTES_0.10.12.md",
|
||||
"docs/RELEASE_NOTES_0.10.13.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": [
|
||||
"scripts/apply-binary-update.ps1"
|
||||
|
||||
@@ -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:41737",
|
||||
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:41737/__forgeflow_test_ready__",
|
||||
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",
|
||||
},
|
||||
})),
|
||||
});
|
||||
@@ -46,7 +46,7 @@ contextBridge.exposeInMainWorld(
|
||||
applyUpdate: () => invoke('updates:apply'),
|
||||
saveServer: (server, password = '', passphrase = '') => invoke('server:save', { server, password, passphrase }),
|
||||
deleteServer: (serverId) => invoke('server:delete', { serverId }),
|
||||
testServer: (serverId) => invoke('server:test', { serverId }),
|
||||
testServer: (serverId, expectedFingerprint = '') => invoke('server:test', { serverId, expectedFingerprint }),
|
||||
inspectServerProject: (repository, profileId) => invoke('server:inspect-project', { repository, profileId }),
|
||||
discoverExistingDeployment: (repository, serverId, remoteFolder) =>
|
||||
invoke('server:discover-existing', {
|
||||
@@ -54,7 +54,7 @@ contextBridge.exposeInMainWorld(
|
||||
serverId,
|
||||
remoteFolder,
|
||||
}),
|
||||
refreshRepositories: () => invoke('repositories:refresh'),
|
||||
refreshRepositories: (options = {}) => invoke('repositories:refresh', options),
|
||||
discoverRepositories: (roots) => invoke('repositories:discover', { roots }),
|
||||
favoriteRepository: (fullName, favorite) => invoke('repository:favorite', { fullName, favorite }),
|
||||
linkRepository: (fullName, localPath) => invoke('repository:link', { fullName, localPath }),
|
||||
@@ -97,6 +97,8 @@ contextBridge.exposeInMainWorld(
|
||||
repairGitLocks: (localPath, force = false) => invoke('repository:repair-git-locks', { localPath, force }),
|
||||
reconcileRepository: (localPath) => invoke('repository:reconcile', { localPath }),
|
||||
repairRepositorySync: (localPath, strategy) => invoke('repository:repair-sync', { localPath, strategy }),
|
||||
previewWorkspaceSync: (localPath) => invoke('repository:workspace-sync-preview', { localPath }),
|
||||
applyWorkspaceSync: (localPath, expectedPlanId) => invoke('repository:workspace-sync-apply', { localPath, expectedPlanId }),
|
||||
setOrigin: (localPath, remoteUrl) => invoke('repository:set-origin', { localPath, remoteUrl }),
|
||||
normalizeOrigins: () => invoke('repositories:normalize-origins'),
|
||||
cloneRepository: (fullName, mode = 'default') => invoke('repository:clone', { fullName, mode }),
|
||||
@@ -121,8 +123,19 @@ contextBridge.exposeInMainWorld(
|
||||
healthcheck: (url) => invoke('deployment:health', { url }),
|
||||
refreshProfileState: (fullName, profileId) => invoke('deployment:profile-state', { fullName, profileId }),
|
||||
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 }),
|
||||
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 }),
|
||||
reconcileDeployment: (fullName, profileId) => invoke('deployment:reconcile', { fullName, profileId }),
|
||||
refreshOperations: (operationId = null) => invoke('operations:refresh', { operationId }),
|
||||
@@ -131,6 +144,10 @@ contextBridge.exposeInMainWorld(
|
||||
troubleshooterRepair: (issue) => invoke('troubleshooter:repair', { issue }),
|
||||
troubleshooterAutoRepair: (issues) => invoke('troubleshooter:auto-repair', { issues }),
|
||||
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 }),
|
||||
diagnosticsStatus: () => invoke('diagnostics:status'),
|
||||
clearDiagnostics: () => invoke('diagnostics:clear'),
|
||||
|
||||
@@ -0,0 +1,775 @@
|
||||
{
|
||||
"generatedAt": "2026-08-26T22:18:32.525Z",
|
||||
"thresholds": {
|
||||
"preferredMaximumLines": 750,
|
||||
"justificationRequiredLines": 1000
|
||||
},
|
||||
"over750": [
|
||||
{
|
||||
"file": "src/main/git-service.cjs",
|
||||
"lines": 881,
|
||||
"branches": 134,
|
||||
"functions": 147,
|
||||
"ipcHandlers": 0,
|
||||
"responsibilities": [
|
||||
"git",
|
||||
"renderer",
|
||||
"security",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 154
|
||||
},
|
||||
{
|
||||
"file": "src/main/update-service.cjs",
|
||||
"lines": 854,
|
||||
"branches": 64,
|
||||
"functions": 65,
|
||||
"ipcHandlers": 0,
|
||||
"responsibilities": [
|
||||
"git",
|
||||
"security",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 74
|
||||
}
|
||||
],
|
||||
"over1000": [],
|
||||
"cyclomaticHotspots": [
|
||||
{
|
||||
"file": "src/main/git-service.cjs",
|
||||
"lines": 881,
|
||||
"branches": 134,
|
||||
"functions": 147,
|
||||
"ipcHandlers": 0,
|
||||
"responsibilities": [
|
||||
"git",
|
||||
"renderer",
|
||||
"security",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 154
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/actions/shell.js",
|
||||
"lines": 518,
|
||||
"branches": 101,
|
||||
"functions": 86,
|
||||
"ipcHandlers": 0,
|
||||
"responsibilities": [
|
||||
"inventory",
|
||||
"deployment",
|
||||
"git",
|
||||
"renderer",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 131
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/app.js",
|
||||
"lines": 734,
|
||||
"branches": 80,
|
||||
"functions": 124,
|
||||
"ipcHandlers": 0,
|
||||
"responsibilities": [
|
||||
"inventory",
|
||||
"deployment",
|
||||
"git",
|
||||
"renderer",
|
||||
"security",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 120
|
||||
},
|
||||
{
|
||||
"file": "src/main/server-inventory.cjs",
|
||||
"lines": 578,
|
||||
"branches": 89,
|
||||
"functions": 104,
|
||||
"ipcHandlers": 0,
|
||||
"responsibilities": [
|
||||
"inventory",
|
||||
"deployment",
|
||||
"git",
|
||||
"security",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 119
|
||||
},
|
||||
{
|
||||
"file": "src/main/unraid-inventory-methods.cjs",
|
||||
"lines": 710,
|
||||
"branches": 76,
|
||||
"functions": 93,
|
||||
"ipcHandlers": 0,
|
||||
"responsibilities": [
|
||||
"inventory",
|
||||
"deployment",
|
||||
"git",
|
||||
"security",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 106
|
||||
}
|
||||
],
|
||||
"mixedResponsibilityModules": [
|
||||
{
|
||||
"file": "src/main/git-service.cjs",
|
||||
"lines": 881,
|
||||
"branches": 134,
|
||||
"functions": 147,
|
||||
"ipcHandlers": 0,
|
||||
"responsibilities": [
|
||||
"git",
|
||||
"renderer",
|
||||
"security",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 154
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/actions/shell.js",
|
||||
"lines": 518,
|
||||
"branches": 101,
|
||||
"functions": 86,
|
||||
"ipcHandlers": 0,
|
||||
"responsibilities": [
|
||||
"inventory",
|
||||
"deployment",
|
||||
"git",
|
||||
"renderer",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 131
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/app.js",
|
||||
"lines": 734,
|
||||
"branches": 80,
|
||||
"functions": 124,
|
||||
"ipcHandlers": 0,
|
||||
"responsibilities": [
|
||||
"inventory",
|
||||
"deployment",
|
||||
"git",
|
||||
"renderer",
|
||||
"security",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 120
|
||||
},
|
||||
{
|
||||
"file": "src/main/server-inventory.cjs",
|
||||
"lines": 578,
|
||||
"branches": 89,
|
||||
"functions": 104,
|
||||
"ipcHandlers": 0,
|
||||
"responsibilities": [
|
||||
"inventory",
|
||||
"deployment",
|
||||
"git",
|
||||
"security",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 119
|
||||
},
|
||||
{
|
||||
"file": "src/main/unraid-inventory-methods.cjs",
|
||||
"lines": 710,
|
||||
"branches": 76,
|
||||
"functions": 93,
|
||||
"ipcHandlers": 0,
|
||||
"responsibilities": [
|
||||
"inventory",
|
||||
"deployment",
|
||||
"git",
|
||||
"security",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 106
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/dialogs.js",
|
||||
"lines": 433,
|
||||
"branches": 60,
|
||||
"functions": 83,
|
||||
"ipcHandlers": 0,
|
||||
"responsibilities": [
|
||||
"inventory",
|
||||
"deployment",
|
||||
"git",
|
||||
"renderer",
|
||||
"security",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 100
|
||||
},
|
||||
{
|
||||
"file": "src/main/unraid-deployment-methods.cjs",
|
||||
"lines": 583,
|
||||
"branches": 69,
|
||||
"functions": 31,
|
||||
"ipcHandlers": 0,
|
||||
"responsibilities": [
|
||||
"inventory",
|
||||
"deployment",
|
||||
"git",
|
||||
"security",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 99
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc.cjs",
|
||||
"lines": 706,
|
||||
"branches": 54,
|
||||
"functions": 72,
|
||||
"ipcHandlers": 27,
|
||||
"responsibilities": [
|
||||
"inventory",
|
||||
"deployment",
|
||||
"git",
|
||||
"ipc",
|
||||
"security",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 94
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/views.js",
|
||||
"lines": 736,
|
||||
"branches": 53,
|
||||
"functions": 152,
|
||||
"ipcHandlers": 0,
|
||||
"responsibilities": [
|
||||
"inventory",
|
||||
"deployment",
|
||||
"git",
|
||||
"renderer",
|
||||
"security",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 93
|
||||
},
|
||||
{
|
||||
"file": "src/main/ssh-service.cjs",
|
||||
"lines": 513,
|
||||
"branches": 70,
|
||||
"functions": 101,
|
||||
"ipcHandlers": 0,
|
||||
"responsibilities": [
|
||||
"deployment",
|
||||
"git",
|
||||
"security",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 90
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/actions/setup-and-settings.js",
|
||||
"lines": 441,
|
||||
"branches": 60,
|
||||
"functions": 60,
|
||||
"ipcHandlers": 0,
|
||||
"responsibilities": [
|
||||
"deployment",
|
||||
"git",
|
||||
"renderer",
|
||||
"security",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 90
|
||||
},
|
||||
{
|
||||
"file": "main.cjs",
|
||||
"lines": 471,
|
||||
"branches": 39,
|
||||
"functions": 57,
|
||||
"ipcHandlers": 0,
|
||||
"responsibilities": [
|
||||
"inventory",
|
||||
"deployment",
|
||||
"git",
|
||||
"ipc",
|
||||
"renderer",
|
||||
"security",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 89
|
||||
},
|
||||
{
|
||||
"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": 408,
|
||||
"branches": 57,
|
||||
"functions": 44,
|
||||
"ipcHandlers": 0,
|
||||
"responsibilities": [
|
||||
"inventory",
|
||||
"deployment",
|
||||
"git",
|
||||
"renderer",
|
||||
"security"
|
||||
],
|
||||
"hotspotScore": 87
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/actions/recovery.js",
|
||||
"lines": 421,
|
||||
"branches": 64,
|
||||
"functions": 43,
|
||||
"ipcHandlers": 0,
|
||||
"responsibilities": [
|
||||
"deployment",
|
||||
"git",
|
||||
"renderer",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 84
|
||||
},
|
||||
{
|
||||
"file": "src/main/config-store.cjs",
|
||||
"lines": 668,
|
||||
"branches": 52,
|
||||
"functions": 89,
|
||||
"ipcHandlers": 0,
|
||||
"responsibilities": [
|
||||
"inventory",
|
||||
"deployment",
|
||||
"git",
|
||||
"security",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 82
|
||||
},
|
||||
{
|
||||
"file": "src/main/unraid-preflight-methods.cjs",
|
||||
"lines": 623,
|
||||
"branches": 40,
|
||||
"functions": 47,
|
||||
"ipcHandlers": 0,
|
||||
"responsibilities": [
|
||||
"inventory",
|
||||
"deployment",
|
||||
"git",
|
||||
"renderer",
|
||||
"security",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 80
|
||||
},
|
||||
{
|
||||
"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/main/unraid-access-methods.cjs",
|
||||
"lines": 462,
|
||||
"branches": 43,
|
||||
"functions": 41,
|
||||
"ipcHandlers": 0,
|
||||
"responsibilities": [
|
||||
"inventory",
|
||||
"deployment",
|
||||
"git",
|
||||
"security",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 73
|
||||
},
|
||||
{
|
||||
"file": "src/main/diagnostics-service.cjs",
|
||||
"lines": 377,
|
||||
"branches": 39,
|
||||
"functions": 51,
|
||||
"ipcHandlers": 0,
|
||||
"responsibilities": [
|
||||
"deployment",
|
||||
"git",
|
||||
"ipc",
|
||||
"security",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 69
|
||||
},
|
||||
{
|
||||
"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/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/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": 286,
|
||||
"branches": 13,
|
||||
"functions": 38,
|
||||
"ipcHandlers": 24,
|
||||
"responsibilities": [
|
||||
"inventory",
|
||||
"deployment",
|
||||
"git",
|
||||
"ipc",
|
||||
"security",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 53
|
||||
},
|
||||
{
|
||||
"file": "preload.cjs",
|
||||
"lines": 164,
|
||||
"branches": 2,
|
||||
"functions": 125,
|
||||
"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": 701,
|
||||
"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": 294,
|
||||
"branches": 16,
|
||||
"functions": 21,
|
||||
"ipcHandlers": 0,
|
||||
"responsibilities": [
|
||||
"inventory",
|
||||
"deployment",
|
||||
"git",
|
||||
"security",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 46
|
||||
},
|
||||
{
|
||||
"file": "src/main/preflight-service.cjs",
|
||||
"lines": 209,
|
||||
"branches": 24,
|
||||
"functions": 34,
|
||||
"ipcHandlers": 0,
|
||||
"responsibilities": [
|
||||
"deployment",
|
||||
"git",
|
||||
"renderer",
|
||||
"security"
|
||||
],
|
||||
"hotspotScore": 44
|
||||
},
|
||||
{
|
||||
"file": "src/main/repository-service.cjs",
|
||||
"lines": 304,
|
||||
"branches": 22,
|
||||
"functions": 46,
|
||||
"ipcHandlers": 0,
|
||||
"responsibilities": [
|
||||
"deployment",
|
||||
"git",
|
||||
"security",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 42
|
||||
},
|
||||
{
|
||||
"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": 701,
|
||||
"branches": 14,
|
||||
"functions": 91,
|
||||
"ipcHandlers": 0,
|
||||
"responsibilities": [
|
||||
"deployment",
|
||||
"git",
|
||||
"security",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 34
|
||||
},
|
||||
{
|
||||
"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": 80,
|
||||
"branches": 7,
|
||||
"functions": 22,
|
||||
"ipcHandlers": 0,
|
||||
"responsibilities": [
|
||||
"deployment",
|
||||
"git",
|
||||
"security",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 27
|
||||
}
|
||||
],
|
||||
"ipcHotspots": [
|
||||
{
|
||||
"file": "src/main/ipc.cjs",
|
||||
"lines": 706,
|
||||
"branches": 54,
|
||||
"functions": 72,
|
||||
"ipcHandlers": 27,
|
||||
"responsibilities": [
|
||||
"inventory",
|
||||
"deployment",
|
||||
"git",
|
||||
"ipc",
|
||||
"security",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 94
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/deployment-handlers.cjs",
|
||||
"lines": 286,
|
||||
"branches": 13,
|
||||
"functions": 38,
|
||||
"ipcHandlers": 24,
|
||||
"responsibilities": [
|
||||
"inventory",
|
||||
"deployment",
|
||||
"git",
|
||||
"ipc",
|
||||
"security",
|
||||
"updates"
|
||||
],
|
||||
"hotspotScore": 53
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/repository-handlers.cjs",
|
||||
"lines": 451,
|
||||
"branches": 16,
|
||||
"functions": 83,
|
||||
"ipcHandlers": 53,
|
||||
"responsibilities": [
|
||||
"git",
|
||||
"ipc"
|
||||
],
|
||||
"hotspotScore": 16
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# ForgeFlow architecture audit
|
||||
|
||||
Generated 2026-08-26T22:18:32.525Z. 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 |
|
||||
|---|---:|---:|---:|---:|---|
|
||||
| `src/main/git-service.cjs` | 881 | 134 | 147 | 0 | git, renderer, security, updates |
|
||||
| `src/main/update-service.cjs` | 854 | 64 | 65 | 0 | git, security, updates |
|
||||
|
||||
## Files above 1,000 lines
|
||||
|
||||
| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |
|
||||
|---|---:|---:|---:|---:|---|
|
||||
No findings.
|
||||
|
||||
## Cyclomatic hotspots
|
||||
|
||||
| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |
|
||||
|---|---:|---:|---:|---:|---|
|
||||
| `src/main/git-service.cjs` | 881 | 134 | 147 | 0 | git, renderer, security, updates |
|
||||
| `src/renderer/actions/shell.js` | 518 | 101 | 86 | 0 | inventory, deployment, git, renderer, updates |
|
||||
| `src/renderer/app.js` | 734 | 80 | 124 | 0 | inventory, deployment, git, renderer, security, updates |
|
||||
| `src/main/server-inventory.cjs` | 578 | 89 | 104 | 0 | inventory, deployment, git, security, updates |
|
||||
| `src/main/unraid-inventory-methods.cjs` | 710 | 76 | 93 | 0 | inventory, deployment, git, security, updates |
|
||||
|
||||
## 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.
|
||||
@@ -7,7 +7,9 @@ param(
|
||||
[Parameter(Mandatory = $true)][int]$ParentPid,
|
||||
[Parameter(Mandatory = $true)][string]$LogPath,
|
||||
[Parameter(Mandatory = $true)][string]$StatusPath,
|
||||
[Parameter(Mandatory = $true)][string]$UpdateId
|
||||
[Parameter(Mandatory = $true)][string]$UpdateId,
|
||||
[switch]$HandshakeOnly,
|
||||
[switch]$VerifyOnly
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -27,22 +29,54 @@ function Write-UpdateState {
|
||||
updatedAt = [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"
|
||||
$payload | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $temporary -Encoding UTF8
|
||||
if (Test-Path -LiteralPath $StatusPath) { [IO.File]::Replace($temporary, $StatusPath, $null) }
|
||||
else { Move-Item -LiteralPath $temporary -Destination $StatusPath }
|
||||
$backup = "$StatusPath.$PID.bak"
|
||||
$json = $payload | ConvertTo-Json -Depth 4
|
||||
$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) {
|
||||
"{0} {1}" -f [DateTime]::UtcNow.ToString("o"), $Message | Add-Content -LiteralPath $LogPath -Encoding UTF8
|
||||
}
|
||||
function Get-Sha256([string]$Path) {
|
||||
$stream = [IO.File]::OpenRead($Path)
|
||||
$algorithm = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
return ([BitConverter]::ToString($algorithm.ComputeHash($stream))).Replace("-", "").ToLowerInvariant()
|
||||
} finally {
|
||||
$algorithm.Dispose()
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Write-UpdateState -State "started" -Message "Binary updater owns the update request."
|
||||
Write-Log "Validating ForgeFlow $ExpectedVersion binary update."
|
||||
$actualSha256 = (Get-FileHash -LiteralPath $BinaryPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($HandshakeOnly) {
|
||||
Write-Log "Handshake-only verification completed successfully."
|
||||
exit 0
|
||||
}
|
||||
$actualSha256 = Get-Sha256 -Path $BinaryPath
|
||||
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 ($VerifyOnly) {
|
||||
Write-Log "Verification-only SHA-256 check completed successfully."
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-UpdateState -State "waiting-for-exit" -Message "Waiting for ForgeFlow to close."
|
||||
try { Wait-Process -Id $ParentPid -Timeout 60 -ErrorAction Stop } catch {
|
||||
|
||||
@@ -54,15 +54,31 @@ function Write-UpdateState {
|
||||
if ([System.IO.File]::Exists($StatusPath)) {
|
||||
# Windows PowerShell 5.1 does not reliably let Move-Item -Force replace
|
||||
# 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 {
|
||||
[System.IO.File]::Move($temporary, $StatusPath)
|
||||
}
|
||||
} catch {
|
||||
# Some filesystems do not implement File.Replace. Copy with overwrite is
|
||||
# the deterministic fallback; the temporary file is removed afterwards.
|
||||
[System.IO.File]::Copy($temporary, $StatusPath, $true)
|
||||
[System.IO.File]::Delete($temporary)
|
||||
if ([System.IO.File]::Exists($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) }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-Sha256([string]$Path) {
|
||||
$stream = [IO.File]::OpenRead($Path)
|
||||
$algorithm = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
return ([BitConverter]::ToString($algorithm.ComputeHash($stream))).Replace("-", "").ToLowerInvariant()
|
||||
} finally {
|
||||
$algorithm.Dispose()
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +131,7 @@ try {
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
|
||||
$actualHash = (Get-FileHash -LiteralPath $ArchivePath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$actualHash = Get-Sha256 -Path $ArchivePath
|
||||
if ($actualHash -ne $ExpectedSha256.ToLowerInvariant()) { throw "Update archive checksum mismatch." }
|
||||
|
||||
$working = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-update-" + [guid]::NewGuid().ToString("N"))
|
||||
|
||||
@@ -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.`);
|
||||
@@ -16,8 +16,10 @@ app.setPath("userData", userDataPath);
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
try {
|
||||
const reconcile = process.argv.includes("--reconcile");
|
||||
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=")) || "")
|
||||
.slice("--repository=".length).toLowerCase().split(",").map((value) => value.trim()).filter(Boolean));
|
||||
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 reports = [];
|
||||
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 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) {
|
||||
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)) {
|
||||
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());
|
||||
if (!repository) continue;
|
||||
if (repositoryFilter.size && !repositoryFilter.has(String(repository.fullName).toLowerCase())) continue;
|
||||
try {
|
||||
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));
|
||||
} catch (error) {
|
||||
access.push({ repository: repository.fullName, ready: false, error: error.message });
|
||||
@@ -51,6 +76,13 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
reports.push({
|
||||
server: server.name,
|
||||
reconciliation: reconciliation ? {
|
||||
adopted: reconciliation.adopted,
|
||||
refreshed: reconciliation.refreshed,
|
||||
retired: reconciliation.retired,
|
||||
staleProfiles: reconciliation.staleProfiles,
|
||||
recoverySnapshot: reconciliation.recoverySnapshot,
|
||||
} : null,
|
||||
capabilities: report.capabilities,
|
||||
warnings: (report.warnings || []).map((warning) => String(warning).slice(0, 300)),
|
||||
summary: {
|
||||
@@ -59,6 +91,12 @@ app.whenReady().then(async () => {
|
||||
linked: report.linked,
|
||||
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,
|
||||
workloads: report.workloads.filter((workload) => workload.link || (workload.runtime?.running && workload.status !== "unmatched")).map((workload) => ({
|
||||
name: workload.displayName,
|
||||
@@ -71,7 +109,29 @@ 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,
|
||||
deployReady: item.deployReady ?? item.ready,
|
||||
readiness: item.readiness || item.action || null,
|
||||
remoteSha: item.remoteSha || item.branchSha || null,
|
||||
liveSha: item.liveSha || null,
|
||||
blockers: (item.deploymentBlockers || []).map((check) => ({ id: check.id, detail: check.detail })),
|
||||
warnings: (item.checks || []).filter((check) => check.status !== "pass" && !(item.deploymentBlockers || []).some((blocker) => blocker.id === check.id)).map((check) => ({ id: check.id, status: check.status, detail: check.detail })),
|
||||
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) {
|
||||
console.error(error?.stack || error?.message || String(error));
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node: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']);
|
||||
|
||||
async function collect(directory, output = []) {
|
||||
|
||||
@@ -4,6 +4,7 @@ const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { app, safeStorage } = require("electron");
|
||||
const { normalizeBaseUrl } = require("../src/shared/validation.cjs");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const configuredUserData =
|
||||
@@ -59,10 +60,7 @@ app.whenReady().then(async () => {
|
||||
const token = safeStorage.decryptString(
|
||||
Buffer.from(config.gitea.encryptedToken, "base64"),
|
||||
);
|
||||
const baseUrl = String(config.gitea.baseUrl || "").replace(/\/+$/, "");
|
||||
if (!/^https?:\/\//i.test(baseUrl)) {
|
||||
throw new Error("The configured Gitea base URL is invalid.");
|
||||
}
|
||||
const baseUrl = normalizeBaseUrl(config.gitea.baseUrl);
|
||||
const owner = safeRepositoryPart(
|
||||
process.env.FORGEFLOW_RELEASE_OWNER || config.updates?.owner || "Jens",
|
||||
"Release repository owner",
|
||||
@@ -116,13 +114,20 @@ app.whenReady().then(async () => {
|
||||
target_commitish: commit,
|
||||
name: `ForgeFlow ${version}`,
|
||||
body,
|
||||
draft: false,
|
||||
draft: true,
|
||||
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 = [
|
||||
path.join(root, "dist", `ForgeFlow-Setup-${version}-win-x64.exe`),
|
||||
path.join(root, "dist", `ForgeFlow-Portable-${version}-win-x64.exe`),
|
||||
@@ -171,6 +176,34 @@ app.whenReady().then(async () => {
|
||||
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"],
|
||||
[`ForgeFlow-${version}-release-manifest.json`, "application/json"],
|
||||
[`ForgeFlow-${version}-release-manifest.json.sig`, "application/octet-stream"],
|
||||
]) {
|
||||
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`,
|
||||
`ForgeFlow-${version}-release-manifest.json`,
|
||||
`ForgeFlow-${version}-release-manifest.json.sig`,
|
||||
];
|
||||
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(
|
||||
`PASS ForgeFlow ${version} binary release published to ${owner}/${repo} for ${commit.slice(0, 7)}`,
|
||||
);
|
||||
|
||||
@@ -4,12 +4,17 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'renderer');
|
||||
const port = Number(process.env.PORT || 4173);
|
||||
const port = Number(process.env.PORT || 41737);
|
||||
const mime = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml' };
|
||||
|
||||
const server = http.createServer(async (request, response) => {
|
||||
try {
|
||||
const pathname = decodeURIComponent(new URL(request.url, `http://${request.headers.host}`).pathname);
|
||||
if (pathname === '/__forgeflow_test_ready__') {
|
||||
response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||
response.end('forgeflow-demo-ready');
|
||||
return;
|
||||
}
|
||||
const relative = pathname === '/' ? 'index.html' : pathname.replace(/^\//, '');
|
||||
const target = path.resolve(root, relative);
|
||||
if (!target.startsWith(root)) throw Object.assign(new Error('Forbidden'), { code: 'EACCES' });
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const defaultPrivatePath = path.join(
|
||||
process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"),
|
||||
"forgeflow",
|
||||
"release-signing-private.pem",
|
||||
);
|
||||
const privatePath = path.resolve(process.env.FORGEFLOW_UPDATE_SIGNING_PRIVATE_KEY || defaultPrivatePath);
|
||||
const publicPath = path.join(root, "build", "update-signing-public.pem");
|
||||
|
||||
let privateKey;
|
||||
try {
|
||||
privateKey = createPrivateKey(await readFile(privatePath));
|
||||
if (privateKey.asymmetricKeyType !== "ed25519") throw new Error("The existing key is not Ed25519.");
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
privateKey = generateKeyPairSync("ed25519").privateKey;
|
||||
await mkdir(path.dirname(privatePath), { recursive: true, mode: 0o700 });
|
||||
await writeFile(privatePath, privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o600, flag: "wx" });
|
||||
}
|
||||
|
||||
const publicKey = createPublicKey(privateKey);
|
||||
const publicPem = publicKey.export({ type: "spki", format: "pem" });
|
||||
await mkdir(path.dirname(publicPath), { recursive: true });
|
||||
await writeFile(publicPath, publicPem, { mode: 0o644 });
|
||||
const fingerprint = createHash("sha256").update(publicKey.export({ type: "spki", format: "der" })).digest("hex");
|
||||
console.log(`ForgeFlow Ed25519 update key ready. Public key fingerprint: SHA256:${fingerprint}`);
|
||||
console.log(`Private key: ${privatePath}`);
|
||||
console.log(`Public key: ${publicPath}`);
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createHash, createPrivateKey, createPublicKey, sign, verify } from "node:crypto";
|
||||
import { readFile, stat, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const pkg = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
|
||||
const privatePath = path.resolve(
|
||||
process.env.FORGEFLOW_UPDATE_SIGNING_PRIVATE_KEY ||
|
||||
path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "forgeflow", "release-signing-private.pem"),
|
||||
);
|
||||
const publicPath = path.join(root, "build", "update-signing-public.pem");
|
||||
const privateKey = createPrivateKey(await readFile(privatePath).catch((error) => {
|
||||
if (error.code === "ENOENT") throw new Error(`ForgeFlow update signing key is missing. Run npm run signing:setup once. Expected: ${privatePath}`);
|
||||
throw error;
|
||||
}));
|
||||
const publicKey = createPublicKey(await readFile(publicPath));
|
||||
if (!publicKey.equals(createPublicKey(privateKey))) throw new Error("The release private key does not match the public key embedded in ForgeFlow.");
|
||||
|
||||
const provenance = JSON.parse(await readFile(path.join(root, "dist", `ForgeFlow-${pkg.version}-provenance.json`), "utf8"));
|
||||
const artifacts = [];
|
||||
for (const kind of ["Setup", "Portable"]) {
|
||||
const name = `ForgeFlow-${kind}-${pkg.version}-win-x64.exe`;
|
||||
const filePath = path.join(root, "dist", name);
|
||||
const bytes = await readFile(filePath);
|
||||
artifacts.push({ name, bytes: (await stat(filePath)).size, sha256: createHash("sha256").update(bytes).digest("hex") });
|
||||
}
|
||||
const keyId = createHash("sha256").update(publicKey.export({ type: "spki", format: "der" })).digest("hex");
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
product: "ForgeFlow",
|
||||
version: pkg.version,
|
||||
tag: `v${pkg.version}`,
|
||||
commit: provenance.commit,
|
||||
buildId: provenance.buildId,
|
||||
signature: { algorithm: "Ed25519", keyId: `SHA256:${keyId}` },
|
||||
artifacts,
|
||||
};
|
||||
const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
||||
const signature = sign(null, manifestBytes, privateKey);
|
||||
if (!verify(null, manifestBytes, publicKey, signature)) throw new Error("The generated release signature did not verify.");
|
||||
const manifestName = `ForgeFlow-${pkg.version}-release-manifest.json`;
|
||||
await writeFile(path.join(root, "dist", manifestName), manifestBytes, { mode: 0o644 });
|
||||
await writeFile(path.join(root, "dist", `${manifestName}.sig`), `${signature.toString("base64")}\n`, { mode: 0o644 });
|
||||
console.log(`${manifestName}: signed with SHA256:${keyId}`);
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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"}`);
|
||||
}
|
||||
@@ -47,6 +47,8 @@ const required = [
|
||||
"scripts/validate-installed-connections.cjs",
|
||||
"scripts/publish-binary-release.cjs",
|
||||
"scripts/write-release-checksums.mjs",
|
||||
"scripts/setup-update-signing-key.mjs",
|
||||
"scripts/sign-release-manifest.mjs",
|
||||
"scripts/prune-dist.mjs",
|
||||
"scripts/generate-source-manifest.mjs",
|
||||
"setup-windows.ps1",
|
||||
@@ -57,6 +59,8 @@ const required = [
|
||||
"scripts/apply-source-update.ps1",
|
||||
"scripts/apply-binary-update.ps1",
|
||||
"docs/ARCHITECTURE.md",
|
||||
"docs/CURRENT_STATE.md",
|
||||
"docs/MUTATION_MODEL.md",
|
||||
"docs/SECURITY.md",
|
||||
"docs/ROADMAP.md",
|
||||
"docs/SETUP_GUIDE.md",
|
||||
@@ -78,6 +82,19 @@ const required = [
|
||||
"docs/RELEASE_NOTES_0.9.4.md",
|
||||
"docs/RELEASE_NOTES_0.9.5.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/RELEASE_NOTES_0.10.6.md",
|
||||
"docs/RELEASE_NOTES_0.10.7.md",
|
||||
"docs/RELEASE_NOTES_0.10.8.md",
|
||||
"docs/RELEASE_NOTES_0.10.9.md",
|
||||
"docs/RELEASE_NOTES_0.10.10.md",
|
||||
"docs/RELEASE_NOTES_0.10.11.md",
|
||||
"docs/RELEASE_NOTES_0.10.12.md",
|
||||
"docs/RELEASE_NOTES_0.10.13.md",
|
||||
"docs/UPDATING.md",
|
||||
"docs/DIAGNOSTICS.md",
|
||||
"docs/DEPLOYMENT_SETUP.md",
|
||||
@@ -109,6 +126,7 @@ const required = [
|
||||
"examples/server/status-example.json",
|
||||
"build/icon.png",
|
||||
"build/icon.ico",
|
||||
"build/update-signing-public.pem",
|
||||
];
|
||||
|
||||
for (const file of required) await access(path.join(root, file));
|
||||
@@ -116,9 +134,9 @@ for (const file of required) await access(path.join(root, file));
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(path.join(root, "package.json"), "utf8"),
|
||||
);
|
||||
if (packageJson.version !== "0.10.0")
|
||||
if (packageJson.version !== "0.10.13")
|
||||
throw new Error(
|
||||
`Expected package version 0.10.0, got ${packageJson.version}.`,
|
||||
`Expected package version 0.10.13, got ${packageJson.version}.`,
|
||||
);
|
||||
const sourceManifest = await readFile(
|
||||
path.join(root, "SOURCE_MANIFEST.txt"),
|
||||
@@ -311,13 +329,17 @@ if (
|
||||
)
|
||||
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", "diff-view.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(
|
||||
path.join(root, "src/renderer/styles.css"),
|
||||
"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 [
|
||||
'data-action="commit-push"',
|
||||
"checkForUpdates",
|
||||
@@ -428,11 +450,66 @@ for (const phrase of [
|
||||
]) {
|
||||
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 release0106 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.6.md"), "utf8");
|
||||
for (const phrase of ["detached", "PowerShell", "production Node spawn", "source updater", "one-time direct installation"]) {
|
||||
if (!release0106.includes(phrase)) throw new Error(`0.10.6 release notes are missing: ${phrase}`);
|
||||
}
|
||||
const release0107 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.7.md"), "utf8");
|
||||
for (const phrase of ["exact provenance", "automatic", "repository sidebar", "DevRunbook", "no container changes"]) {
|
||||
if (!release0107.includes(phrase)) throw new Error(`0.10.7 release notes are missing: ${phrase}`);
|
||||
}
|
||||
const release0108 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.8.md"), "utf8");
|
||||
for (const phrase of ["Get-FileHash", ".NET SHA-256", "PSModulePath", "binary", "source update helpers"]) {
|
||||
if (!release0108.includes(phrase)) throw new Error(`0.10.8 release notes are missing: ${phrase}`);
|
||||
}
|
||||
const release0109 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.9.md"), "utf8");
|
||||
for (const phrase of ["containers without healthchecks", "single-instance", "exact Gitea commit", "deploy-ready", "no containers are changed"]) {
|
||||
if (!release0109.includes(phrase)) throw new Error(`0.10.9 release notes are missing: ${phrase}`);
|
||||
}
|
||||
const release01010 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.10.md"), "utf8");
|
||||
for (const phrase of ["read-only deploy keys", "repository deployment root", "Compose working directory", "Fix write access", "exact Gitea commit"]) {
|
||||
if (!release01010.includes(phrase)) throw new Error(`0.10.10 release notes are missing: ${phrase}`);
|
||||
}
|
||||
const release01011 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.11.md"), "utf8");
|
||||
for (const phrase of ["last-known-good", "closed output pipe", "linked checkout origin", "read-only deploy key", "browser test server"]) {
|
||||
if (!release01011.includes(phrase)) throw new Error(`0.10.11 release notes are missing: ${phrase}`);
|
||||
}
|
||||
const release01012 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.12.md"), "utf8");
|
||||
for (const phrase of ["coalesced", "exact Gitea commit parity", "batched Docker inspect", "bounded worker pools", "stopped container"]) {
|
||||
if (!release01012.includes(phrase)) throw new Error(`0.10.12 release notes are missing: ${phrase}`);
|
||||
}
|
||||
const release01013 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.13.md"), "utf8");
|
||||
for (const phrase of ["Gitea workspace sync", "recovery branch", "Stale deployment links", "Ed25519-signed release manifest", "Git-toolsgrid"]) {
|
||||
if (!release01013.includes(phrase)) throw new Error(`0.10.13 release notes are missing: ${phrase}`);
|
||||
}
|
||||
const configSource = await readFile(path.join(root, "src/main/config-store.cjs"), "utf8");
|
||||
for (const mode of ["server-git", "push-bundle", "monitor-only"]) {
|
||||
if (!configSource.includes(mode)) throw new Error(`Deployment configuration is missing mode: ${mode}`);
|
||||
}
|
||||
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 [
|
||||
"executePushBundle",
|
||||
"executeServerGitBundle",
|
||||
@@ -460,10 +537,7 @@ const gitSource = await readFile(
|
||||
path.join(root, "src/main/git-service.cjs"),
|
||||
"utf8",
|
||||
);
|
||||
const unraidSource = await readFile(
|
||||
path.join(root, "src/main/unraid-deployment-service.cjs"),
|
||||
"utf8",
|
||||
);
|
||||
const unraidSource = unraidDirectSource;
|
||||
const publisher = await readFile(
|
||||
path.join(root, "Publish-ForgeFlow-Release.ps1"),
|
||||
"utf8",
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const execFileAsync = promisify(execFile);
|
||||
const manifest = JSON.parse(
|
||||
await readFile(path.join(root, "package.json"), "utf8"),
|
||||
);
|
||||
const artifacts = [];
|
||||
for (const kind of ["Setup", "Portable"]) {
|
||||
const name = `ForgeFlow-${kind}-${manifest.version}-win-x64.exe`;
|
||||
const binary = await readFile(path.join(root, "dist", name));
|
||||
@@ -17,4 +21,24 @@ for (const kind of ["Setup", "Portable"]) {
|
||||
"utf8",
|
||||
);
|
||||
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(),
|
||||
publisherManifestSignature: "Ed25519",
|
||||
authenticodeSigned: process.env.FORGEFLOW_SIGNED_RELEASE === "1",
|
||||
expectedAuthenticodePublisher:
|
||||
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");
|
||||
|
||||
@@ -7,7 +7,7 @@ const { safeStorage } = require('electron');
|
||||
const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePath, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
schemaVersion: 11,
|
||||
schemaVersion: 13,
|
||||
setupComplete: false,
|
||||
appearance: 'dark',
|
||||
gitea: { baseUrl: '', user: null, encryptedToken: null },
|
||||
@@ -15,6 +15,8 @@ const DEFAULT_CONFIG = {
|
||||
repositoryMappings: {},
|
||||
deploymentProfiles: {},
|
||||
deploymentStates: {},
|
||||
inventoryReviewDecisions: {},
|
||||
gitValidator: { policies: {}, suppressions: {}, trends: {} },
|
||||
favorites: [],
|
||||
updates: {
|
||||
owner: 'Jens',
|
||||
@@ -54,6 +56,8 @@ class ConfigStore {
|
||||
this.sessionToken = null;
|
||||
this.data = structuredClone(DEFAULT_CONFIG);
|
||||
this.saveQueue = Promise.resolve();
|
||||
this.pendingSave = null;
|
||||
this.lastWrittenSnapshot = null;
|
||||
}
|
||||
|
||||
migrate(parsed) {
|
||||
@@ -65,6 +69,12 @@ class ConfigStore {
|
||||
gitea: { ...DEFAULT_CONFIG.gitea, ...(source.gitea || {}) },
|
||||
workspaceRoots: uniqueStrings(source.workspaceRoots),
|
||||
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'
|
||||
? 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;
|
||||
@@ -101,7 +111,7 @@ class ConfigStore {
|
||||
})]))
|
||||
: {},
|
||||
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 || {}) },
|
||||
servers: Array.isArray(source.servers) ? source.servers.filter((item) => item && typeof item === 'object') : [],
|
||||
preferences: { ...DEFAULT_CONFIG.preferences, ...(source.preferences || {}) },
|
||||
@@ -130,16 +140,70 @@ class ConfigStore {
|
||||
}
|
||||
|
||||
async save() {
|
||||
const snapshot = JSON.stringify(this.data, null, 2);
|
||||
// Several callers persist in quick succession (a server scan writes deployment
|
||||
// state per workload). Serializing the configuration once per call is the
|
||||
// expensive part, so saves that are still queued share a single write of the
|
||||
// latest data. That is equivalent because every caller asks for "persist the
|
||||
// current configuration", not "persist the snapshot I saw".
|
||||
if (this.pendingSave) return this.pendingSave;
|
||||
const operation = async () => {
|
||||
this.pendingSave = null;
|
||||
const snapshot = JSON.stringify(this.data, null, 2);
|
||||
if (snapshot === this.lastWrittenSnapshot
|
||||
&& await fs.access(this.filePath).then(() => true).catch(() => false)) return;
|
||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||
const temporary = `${this.filePath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`;
|
||||
await fs.writeFile(temporary, snapshot, { mode: 0o600 });
|
||||
await fs.rename(temporary, this.filePath);
|
||||
try { await fs.chmod(this.filePath, 0o600); } catch {}
|
||||
this.lastWrittenSnapshot = snapshot;
|
||||
};
|
||||
this.saveQueue = this.saveQueue.then(operation, operation);
|
||||
return this.saveQueue;
|
||||
this.pendingSave = this.saveQueue.then(operation, operation);
|
||||
this.saveQueue = this.pendingSave.catch(() => {});
|
||||
return this.pendingSave;
|
||||
}
|
||||
|
||||
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 } = {}) {
|
||||
@@ -151,7 +215,7 @@ class ConfigStore {
|
||||
return { persistent: true, preserved: false };
|
||||
}
|
||||
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
if (safeStorage?.isEncryptionAvailable?.()) {
|
||||
this.data.gitea.encryptedToken = safeStorage.encryptString(value).toString('base64');
|
||||
this.sessionToken = null;
|
||||
return { persistent: true, preserved: false };
|
||||
@@ -166,7 +230,7 @@ class ConfigStore {
|
||||
if (this.sessionToken) return this.sessionToken;
|
||||
if (!this.data.gitea.encryptedToken) return '';
|
||||
try {
|
||||
return safeStorage.decryptString(Buffer.from(this.data.gitea.encryptedToken, 'base64'));
|
||||
return safeStorage?.decryptString?.(Buffer.from(this.data.gitea.encryptedToken, 'base64')) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
@@ -176,7 +240,7 @@ class ConfigStore {
|
||||
encryptSecret(value) {
|
||||
const text = String(value || '');
|
||||
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.');
|
||||
error.code = 'SECURE_STORAGE_UNAVAILABLE';
|
||||
throw error;
|
||||
@@ -186,7 +250,7 @@ class ConfigStore {
|
||||
|
||||
decryptSecret(value) {
|
||||
if (!value) return '';
|
||||
try { return safeStorage.decryptString(Buffer.from(value, 'base64')); }
|
||||
try { return safeStorage?.decryptString?.(Buffer.from(value, 'base64')) || ''; }
|
||||
catch { return ''; }
|
||||
}
|
||||
|
||||
@@ -203,6 +267,8 @@ class ConfigStore {
|
||||
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 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 {
|
||||
id: source.id || existing?.id || crypto.randomUUID(),
|
||||
name,
|
||||
@@ -211,6 +277,8 @@ class ConfigStore {
|
||||
username,
|
||||
authType,
|
||||
basePath,
|
||||
scanRoots: scanRoots.length ? scanRoots : [basePath],
|
||||
scanExcludes,
|
||||
privateKeyPath,
|
||||
hostFingerprint,
|
||||
encryptedPassword: existing?.encryptedPassword || null,
|
||||
@@ -490,6 +558,27 @@ class ConfigStore {
|
||||
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) {
|
||||
this.data.deploymentStates[profileId] = {
|
||||
...(this.data.deploymentStates[profileId] || {}),
|
||||
@@ -526,7 +615,10 @@ class ConfigStore {
|
||||
const next = { ...this.data.preferences, ...(preferences || {}) };
|
||||
next.repositoryPollSeconds = Math.min(Math.max(Number(next.repositoryPollSeconds) || 4, 2), 60);
|
||||
next.operationPollSeconds = Math.min(Math.max(Number(next.operationPollSeconds) || 5, 3), 120);
|
||||
next.fetchIntervalMinutes = Math.min(Math.max(Number(next.fetchIntervalMinutes) || 10, 0), 240);
|
||||
const fetchIntervalMinutes = Number(next.fetchIntervalMinutes);
|
||||
next.fetchIntervalMinutes = Number.isFinite(fetchIntervalMinutes)
|
||||
? Math.min(Math.max(fetchIntervalMinutes, 0), 240)
|
||||
: 10;
|
||||
next.autoRefresh = next.autoRefresh !== false;
|
||||
next.preferredCloneProtocol = ['https', 'ssh'].includes(next.preferredCloneProtocol) ? next.preferredCloneProtocol : 'https';
|
||||
next.diagnosticsEnabled = next.diagnosticsEnabled !== false;
|
||||
@@ -562,6 +654,7 @@ class ConfigStore {
|
||||
repositoryMappings: { ...this.data.repositoryMappings },
|
||||
deploymentProfiles: structuredClone(this.data.deploymentProfiles),
|
||||
deploymentStates: structuredClone(this.data.deploymentStates),
|
||||
gitValidator: structuredClone(this.data.gitValidator),
|
||||
favorites: [...this.data.favorites],
|
||||
updates: { ...this.data.updates },
|
||||
servers: this.data.servers.map((server) => this.getPublicServer(server)),
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -36,10 +36,6 @@ function isRunningStatus(value) {
|
||||
return ['running', 'in_progress', 'processing'].includes(String(value || '').toLowerCase());
|
||||
}
|
||||
|
||||
function isQueuedStatus(value) {
|
||||
return ['pending', 'queued', 'waiting', 'blocked', 'requested'].includes(String(value || '').toLowerCase());
|
||||
}
|
||||
|
||||
class DeploymentService {
|
||||
constructor(store, giteaService, gitService, diagnostics = null) {
|
||||
this.store = store;
|
||||
@@ -328,8 +324,15 @@ class DeploymentService {
|
||||
|
||||
async refreshActiveOperations() {
|
||||
const active = this.store.data.operations.filter((item) => item.type === 'deployment' && !TERMINAL_STATUSES.has(item.status));
|
||||
const queue = active.slice(0, 20);
|
||||
const results = [];
|
||||
for (const operation of active.slice(0, 20)) results.push(await this.refreshOperation(operation.id));
|
||||
const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
|
||||
while (queue.length) {
|
||||
const operation = queue.shift();
|
||||
results.push(await this.refreshOperation(operation.id));
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,9 @@ class DiagnosticsService {
|
||||
this.preferencesProvider = preferencesProvider;
|
||||
this.sessionId = crypto.randomUUID();
|
||||
this.writeChain = Promise.resolve();
|
||||
this.pendingLines = [];
|
||||
this.pendingFlush = null;
|
||||
this.securedFiles = new Set();
|
||||
this.initialized = false;
|
||||
this.lastWriteError = null;
|
||||
this.lastBundlePath = null;
|
||||
@@ -115,13 +118,25 @@ class DiagnosticsService {
|
||||
sessionId: this.sessionId,
|
||||
details
|
||||
});
|
||||
const line = `${JSON.stringify(record)}\n`;
|
||||
this.writeChain = this.writeChain.then(async () => {
|
||||
this.pendingLines.push(`${JSON.stringify(record)}\n`);
|
||||
// At the debug level every IPC call and every Gitea request writes a line.
|
||||
// Records that queue up while a write is in flight are appended together, so
|
||||
// a burst costs one open/write/close instead of one per record.
|
||||
if (this.pendingFlush) return this.pendingFlush;
|
||||
this.pendingFlush = this.writeChain.then(async () => {
|
||||
this.pendingFlush = null;
|
||||
const lines = this.pendingLines.splice(0).join('');
|
||||
if (!lines) return true;
|
||||
try {
|
||||
if (!this.initialized) await fs.mkdir(this.logDirectory, { recursive: true, mode: 0o700 });
|
||||
const target = await this.rotateIfNeeded(this.filePathForToday());
|
||||
await fs.appendFile(target, line, { encoding: 'utf8', mode: 0o600 });
|
||||
try { await fs.chmod(target, 0o600); } catch {}
|
||||
await fs.appendFile(target, lines, { encoding: 'utf8', mode: 0o600 });
|
||||
// The mode above only applies when appendFile creates the file, so the
|
||||
// explicit chmod is needed once per file rather than once per record.
|
||||
if (!this.securedFiles.has(target)) {
|
||||
try { await fs.chmod(target, 0o600); } catch { /* best effort */ }
|
||||
this.securedFiles.add(target);
|
||||
}
|
||||
this.lastWriteError = null;
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -129,7 +144,8 @@ class DiagnosticsService {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return this.writeChain;
|
||||
this.writeChain = this.pendingFlush.catch(() => {});
|
||||
return this.pendingFlush;
|
||||
}
|
||||
|
||||
debug(event, details) { return this.log('debug', event, details); }
|
||||
@@ -275,9 +291,13 @@ class DiagnosticsService {
|
||||
dispatchedAt: operation.dispatchedAt,
|
||||
stages: operation.stages,
|
||||
jobs: operation.jobs,
|
||||
logs: operation.logs,
|
||||
failure: operation.failure,
|
||||
pollError: operation.pollError,
|
||||
remoteOutput: operation.logs || operation.failure || operation.pollError ? {
|
||||
included: false,
|
||||
reason: 'Remote build and command output is intentionally omitted because it may contain application secrets unknown to ForgeFlow.',
|
||||
logCharacters: String(operation.logs || '').length,
|
||||
failureRecorded: Boolean(operation.failure),
|
||||
pollErrorRecorded: Boolean(operation.pollError)
|
||||
} : null,
|
||||
applicationState: operation.applicationState,
|
||||
run: operation.run ? {
|
||||
id: operation.run.id,
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs/promises');
|
||||
const crypto = require('node:crypto');
|
||||
const { run } = require('./process-runner.cjs');
|
||||
const { parsePorcelainV2 } = require('../shared/git-status.cjs');
|
||||
const { normalizeRemoteUrl } = require('../shared/repository-match.cjs');
|
||||
|
||||
const COMMON_GIT_LOCK_FILES = ['HEAD.lock', 'index.lock'];
|
||||
const MAX_UNTRACKED_DIFF_BYTES = 16 * 1024 * 1024;
|
||||
const {
|
||||
assertSafeRepositoryPath,
|
||||
assertRepositoryRelativePath,
|
||||
@@ -28,7 +30,42 @@ function parseUnifiedDiff(diffText) {
|
||||
return { header, hunks };
|
||||
}
|
||||
|
||||
function parseNameStatus(output) {
|
||||
const entries = String(output || '').split('\0');
|
||||
const changes = [];
|
||||
for (let index = 0; index < entries.length;) {
|
||||
const rawStatus = entries[index++];
|
||||
if (!rawStatus) continue;
|
||||
const code = rawStatus[0];
|
||||
if (code === 'R' || code === 'C') {
|
||||
const originalPath = entries[index++] || '';
|
||||
const filePath = entries[index++] || '';
|
||||
if (filePath) changes.push({ code, status: code === 'R' ? 'renamed' : 'copied', path: filePath, originalPath });
|
||||
continue;
|
||||
}
|
||||
const filePath = entries[index++] || '';
|
||||
if (!filePath) continue;
|
||||
const labels = { A: 'added', D: 'deleted', M: 'modified', T: 'type-changed', U: 'conflict' };
|
||||
changes.push({ code, status: labels[code] || 'changed', path: filePath, originalPath: null });
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
function parseCompactLog(output) {
|
||||
return String(output || '').split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
|
||||
const [sha, shortSha, date, subject] = record.split('\x1f');
|
||||
return { sha, shortSha, date, subject };
|
||||
});
|
||||
}
|
||||
|
||||
class GitService {
|
||||
constructor() {
|
||||
// `git remote get-url` is only re-run when the repository configuration file
|
||||
// itself changed. Status polling asks for the remote URL of every repository
|
||||
// every few seconds, and on Windows the child process dominates that cost.
|
||||
this.remoteUrlCache = new Map();
|
||||
}
|
||||
|
||||
async isAvailable() {
|
||||
try {
|
||||
const result = await run('git', ['--version'], { timeout: 10_000 });
|
||||
@@ -42,13 +79,24 @@ class GitService {
|
||||
const resolved = assertSafeRepositoryPath(repoPath);
|
||||
const stat = await fs.stat(resolved).catch(() => null);
|
||||
if (!stat?.isDirectory()) throw new Error('The linked local folder no longer exists.');
|
||||
// A directory that carries its own `.git` entry is by definition the top level
|
||||
// of that working tree, for plain repositories as well as for submodules and
|
||||
// linked worktrees where `.git` is a file. Spawning `git rev-parse` to learn
|
||||
// that again is pure overhead, and every status poll passes an already
|
||||
// resolved repository root back in.
|
||||
const marker = await fs.stat(path.join(resolved, '.git')).catch(() => null);
|
||||
if (marker) return resolved;
|
||||
const result = await run('git', ['rev-parse', '--show-toplevel'], { cwd: resolved, timeout: 15_000 });
|
||||
return path.resolve(result.stdout.trim());
|
||||
}
|
||||
|
||||
async status(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const result = await run('git', ['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'], {
|
||||
// `--no-optional-locks` keeps a status read from refreshing and rewriting the
|
||||
// index. Without it every read writes inside .git, which both fights a
|
||||
// concurrent Git command for the index lock and retriggers the filesystem
|
||||
// watcher that asked for this read in the first place.
|
||||
const result = await run('git', ['--no-optional-locks', 'status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'], {
|
||||
cwd: root,
|
||||
timeout: 30_000
|
||||
});
|
||||
@@ -66,9 +114,34 @@ class GitService {
|
||||
});
|
||||
}
|
||||
|
||||
remoteUrlCacheKey(repoPath, remote) {
|
||||
return JSON.stringify([path.resolve(repoPath), remote]);
|
||||
}
|
||||
|
||||
async getRemoteUrl(repoPath, remote = 'origin') {
|
||||
const result = await run('git', ['remote', 'get-url', remote], { cwd: repoPath, timeout: 15_000 });
|
||||
return result.stdout.trim();
|
||||
const cacheKey = this.remoteUrlCacheKey(repoPath, remote);
|
||||
const config = await fs.stat(path.join(repoPath, '.git', 'config')).catch(() => null);
|
||||
const cached = this.remoteUrlCache.get(cacheKey);
|
||||
if (config && cached && cached.mtimeMs === config.mtimeMs && cached.size === config.size) {
|
||||
if (cached.error) throw cached.error;
|
||||
return cached.url;
|
||||
}
|
||||
const remember = (entry) => {
|
||||
if (config) this.remoteUrlCache.set(cacheKey, { ...entry, mtimeMs: config.mtimeMs, size: config.size });
|
||||
else this.remoteUrlCache.delete(cacheKey);
|
||||
};
|
||||
try {
|
||||
const result = await run('git', ['remote', 'get-url', remote], { cwd: repoPath, timeout: 15_000 });
|
||||
const url = result.stdout.trim();
|
||||
remember({ url, error: null });
|
||||
return url;
|
||||
} catch (error) {
|
||||
// A repository that has no such remote keeps failing until its configuration
|
||||
// changes, so the failure is remembered too. Without this, every status poll
|
||||
// of an unmatched local repository spawns a child process that cannot succeed.
|
||||
remember({ url: '', error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -273,12 +346,158 @@ class GitService {
|
||||
return { strategy: requested, backupBranch: null, status, lockReport: await this.listGitLocks(root) };
|
||||
}
|
||||
|
||||
async previewWorkspaceSync(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const { status } = await this.fetch(root);
|
||||
const branch = status.branch?.head;
|
||||
const upstream = status.branch?.upstream;
|
||||
if (!status.head || !branch || branch === '(detached)') {
|
||||
const error = new Error('Workspace synchronization requires a named branch with at least one commit.');
|
||||
error.code = 'WORKSPACE_SYNC_BRANCH_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
if (!upstream) {
|
||||
const error = new Error('The current branch has no Gitea upstream. Publish it or switch to a tracked branch first.');
|
||||
error.code = 'WORKSPACE_SYNC_UPSTREAM_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const targetSha = (await run('git', ['rev-parse', '--verify', upstream], { cwd: root, timeout: 30_000 })).stdout.trim();
|
||||
const changes = parseNameStatus((await run('git', [
|
||||
'diff', '--name-status', '-z', '--find-renames', 'HEAD', upstream, '--'
|
||||
], { cwd: root, timeout: 60_000, maxBuffer: 16 * 1024 * 1024 })).stdout);
|
||||
const logFormat = '%H%x1f%h%x1f%aI%x1f%s%x1e';
|
||||
const [incomingResult, localResult, interruptedOperation] = await Promise.all([
|
||||
run('git', ['log', `--format=${logFormat}`, `HEAD..${upstream}`, '-20'], { cwd: root, timeout: 30_000 }),
|
||||
run('git', ['log', `--format=${logFormat}`, `${upstream}..HEAD`, '-20'], { cwd: root, timeout: 30_000 }),
|
||||
this.detectInterruptedOperation(root)
|
||||
]);
|
||||
const blockers = [];
|
||||
if (interruptedOperation) blockers.push(`Finish or abort the active Git ${interruptedOperation} before synchronizing.`);
|
||||
if (status.counts.conflicts) blockers.push(`Resolve ${status.counts.conflicts} conflicted file${status.counts.conflicts === 1 ? '' : 's'} before synchronizing.`);
|
||||
const summary = {
|
||||
resultingTrackedChanges: changes.length,
|
||||
added: changes.filter((item) => item.code === 'A').length,
|
||||
modified: changes.filter((item) => ['M', 'T'].includes(item.code)).length,
|
||||
deleted: changes.filter((item) => item.code === 'D').length,
|
||||
renamed: changes.filter((item) => item.code === 'R').length,
|
||||
localFilesToStash: status.counts.changed,
|
||||
untrackedFilesToStash: status.counts.untracked,
|
||||
localCommitsToProtect: status.branch.ahead,
|
||||
incomingCommits: status.branch.behind
|
||||
};
|
||||
const planId = crypto.createHash('sha256').update(JSON.stringify({
|
||||
head: status.head,
|
||||
targetSha,
|
||||
branch,
|
||||
upstream,
|
||||
fingerprint: this.statusFingerprint(status)
|
||||
})).digest('hex');
|
||||
return {
|
||||
id: planId,
|
||||
repositoryRoot: root,
|
||||
branch,
|
||||
upstream,
|
||||
currentSha: status.head,
|
||||
targetSha,
|
||||
needsSync: status.head !== targetSha || !status.clean,
|
||||
cleanBeforeSync: status.clean,
|
||||
blockers,
|
||||
summary,
|
||||
changes: changes.slice(0, 250),
|
||||
changesTruncated: changes.length > 250,
|
||||
localFiles: status.files.slice(0, 250),
|
||||
localFilesTruncated: status.files.length > 250,
|
||||
incomingCommits: parseCompactLog(incomingResult.stdout),
|
||||
localCommits: parseCompactLog(localResult.stdout),
|
||||
recovery: {
|
||||
safetyBranch: status.branch.ahead > 0,
|
||||
stash: status.counts.changed > 0,
|
||||
untrackedCleanup: status.counts.untracked > 0,
|
||||
ignoredFilesPreserved: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async synchronizeWorkspace(repoPath, expectedPlanId) {
|
||||
const expected = String(expectedPlanId || '').trim();
|
||||
if (!/^[0-9a-f]{64}$/i.test(expected)) {
|
||||
const error = new Error('Apply workspace synchronization only from a reviewed preview.');
|
||||
error.code = 'WORKSPACE_SYNC_PLAN_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
const plan = await this.previewWorkspaceSync(repoPath);
|
||||
if (plan.id !== expected) {
|
||||
const error = new Error('The local workspace or Gitea branch changed after the preview. Review a fresh synchronization plan.');
|
||||
error.code = 'WORKSPACE_SYNC_PLAN_STALE';
|
||||
error.recoverable = true;
|
||||
throw error;
|
||||
}
|
||||
if (plan.blockers.length) {
|
||||
const error = new Error(plan.blockers.join(' '));
|
||||
error.code = 'WORKSPACE_SYNC_BLOCKED';
|
||||
error.recoverable = true;
|
||||
throw error;
|
||||
}
|
||||
if (!plan.needsSync) {
|
||||
return { applied: false, unchanged: true, plan, status: await this.status(plan.repositoryRoot), backupBranch: null, stash: null, cleaned: [] };
|
||||
}
|
||||
|
||||
const root = plan.repositoryRoot;
|
||||
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
|
||||
let backupBranch = null;
|
||||
let stash = null;
|
||||
if (plan.summary.localCommitsToProtect > 0) {
|
||||
const safeBranch = plan.branch.replace(/[^A-Za-z0-9._-]/g, '-');
|
||||
backupBranch = `forgeflow/recovery-${safeBranch}-${stamp}-${plan.currentSha.slice(0, 7)}`;
|
||||
await run('git', ['check-ref-format', '--branch', backupBranch], { cwd: root, timeout: 30_000 });
|
||||
await run('git', ['branch', backupBranch, 'HEAD'], { cwd: root, timeout: 30_000 });
|
||||
}
|
||||
if (plan.summary.localFilesToStash > 0) {
|
||||
const label = `ForgeFlow workspace sync ${plan.branch} ${stamp}`;
|
||||
await run('git', ['stash', 'push', '--include-untracked', '-m', label], { cwd: root, timeout: 120_000 });
|
||||
stash = (await this.stashList(root))[0] || null;
|
||||
}
|
||||
|
||||
const protectedStatus = await this.status(root);
|
||||
if (!protectedStatus.clean || protectedStatus.head !== plan.currentSha) {
|
||||
const error = new Error('The workspace changed while ForgeFlow was protecting local work. Nothing was reset; review a fresh synchronization plan.');
|
||||
error.code = 'WORKSPACE_SYNC_CONCURRENT_CHANGE';
|
||||
error.recoverable = true;
|
||||
error.backupBranch = backupBranch;
|
||||
error.stash = stash;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await run('git', ['reset', '--hard', plan.targetSha], { cwd: root, timeout: 2 * 60_000 });
|
||||
const status = await this.status(root);
|
||||
if (status.head !== plan.targetSha || !status.clean) {
|
||||
const error = new Error('Git did not verify an exact clean match with the reviewed Gitea commit. Local recovery references were preserved.');
|
||||
error.code = 'WORKSPACE_SYNC_VERIFICATION_FAILED';
|
||||
error.recoverable = true;
|
||||
error.backupBranch = backupBranch;
|
||||
error.stash = stash;
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
applied: true,
|
||||
unchanged: false,
|
||||
plan,
|
||||
status,
|
||||
backupBranch,
|
||||
stash,
|
||||
cleaned: plan.localFiles.filter((file) => file.untracked).map((file) => file.path),
|
||||
ignoredFilesPreserved: true
|
||||
};
|
||||
}
|
||||
|
||||
async setRemoteUrl(repoPath, remoteUrl, remote = 'origin') {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const safeRemote = assertCloneRemote(remoteUrl);
|
||||
const name = String(remote || 'origin').trim();
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(name)) throw new Error('Invalid Git remote name.');
|
||||
await run('git', ['remote', 'set-url', name, safeRemote], { cwd: root, timeout: 30_000 });
|
||||
this.remoteUrlCache.delete(this.remoteUrlCacheKey(root, name));
|
||||
return this.status(root);
|
||||
}
|
||||
|
||||
@@ -292,7 +511,26 @@ class GitService {
|
||||
if (!result.stdout && safeFile && !staged) {
|
||||
const candidate = path.resolve(root, safeFile);
|
||||
if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) throw new Error('File path escapes repository root.');
|
||||
const content = await fs.readFile(candidate, 'utf8').catch(() => '');
|
||||
const [realRoot, realCandidate, candidateStat] = await Promise.all([
|
||||
fs.realpath(root).catch(() => root),
|
||||
fs.realpath(candidate).catch(() => candidate),
|
||||
fs.stat(candidate).catch(() => null)
|
||||
]);
|
||||
const normalize = (value) => process.platform === 'win32' ? value.toLowerCase() : value;
|
||||
const normalizedRoot = normalize(realRoot);
|
||||
const normalizedCandidate = normalize(realCandidate);
|
||||
if (normalizedCandidate !== normalizedRoot && !normalizedCandidate.startsWith(`${normalizedRoot}${path.sep}`)) {
|
||||
const error = new Error('ForgeFlow refuses to read a diff target that resolves outside the repository.');
|
||||
error.code = 'DIFF_TARGET_OUTSIDE_REPOSITORY';
|
||||
throw error;
|
||||
}
|
||||
if (candidateStat?.size > MAX_UNTRACKED_DIFF_BYTES) {
|
||||
const error = new Error('The untracked file is too large to render safely as a diff.');
|
||||
error.code = 'DIFF_FILE_TOO_LARGE';
|
||||
error.recoverable = true;
|
||||
throw error;
|
||||
}
|
||||
const content = candidateStat?.isFile() ? await fs.readFile(candidate, 'utf8').catch(() => '') : '';
|
||||
if (content) return `diff --git a/${safeFile} b/${safeFile}\nnew file mode 100644\n--- /dev/null\n+++ b/${safeFile}\n${content.split('\n').map((line) => `+${line}`).join('\n')}`;
|
||||
}
|
||||
return result.stdout;
|
||||
@@ -354,8 +592,7 @@ class GitService {
|
||||
return { selected, matches };
|
||||
}
|
||||
|
||||
async expandSelectedPaths(root, files, { unstagedOnly = false } = {}) {
|
||||
const status = await this.status(root);
|
||||
expandStatusPaths(status, files, { unstagedOnly = false } = {}) {
|
||||
const { selected, matches } = this.selectedStatusFiles(status, files);
|
||||
if (!selected.length) return [];
|
||||
const expanded = new Set();
|
||||
@@ -367,12 +604,17 @@ class GitService {
|
||||
return [...expanded];
|
||||
}
|
||||
|
||||
async stage(repoPath, files) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
async expandSelectedPaths(root, files, options = {}) {
|
||||
return this.expandStatusPaths(await this.status(root), files, options);
|
||||
}
|
||||
|
||||
// Callers that already read the status pass it in. Reading it again costs a
|
||||
// child process, and a commit used to pay for four of them.
|
||||
async applyStage(root, files, knownStatus = null) {
|
||||
const requested = assertRepositoryRelativePaths(files);
|
||||
if (!requested.length) {
|
||||
await run('git', ['add', '--all'], { cwd: root, timeout: 60_000 });
|
||||
return this.status(root);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only stage records that still have a worktree-side change. Re-running
|
||||
@@ -380,10 +622,16 @@ class GitService {
|
||||
// Git fail with "pathspec did not match any files" because the file no
|
||||
// longer exists in either the worktree or HEAD. Staged-only deletions and
|
||||
// renames are already ready for commit and must therefore be left alone.
|
||||
const selected = await this.expandSelectedPaths(root, requested, { unstagedOnly: true });
|
||||
const status = knownStatus || await this.status(root);
|
||||
const selected = this.expandStatusPaths(status, requested, { unstagedOnly: true });
|
||||
if (selected.length) {
|
||||
await this.runWithPathspec(root, ['add', '-A'], selected, { timeout: 120_000 });
|
||||
}
|
||||
}
|
||||
|
||||
async stage(repoPath, files) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
await this.applyStage(root, files);
|
||||
return this.status(root);
|
||||
}
|
||||
|
||||
@@ -403,8 +651,9 @@ class GitService {
|
||||
|
||||
async prepareSelectedStage(root, files) {
|
||||
const selected = assertRepositoryRelativePaths(files);
|
||||
let current = null;
|
||||
if (selected.length) {
|
||||
const current = await this.status(root);
|
||||
current = await this.status(root);
|
||||
const excludedStaged = current.files
|
||||
.filter((file) => file.staged)
|
||||
.filter((file) => !selected.includes(file.path) && !(file.originalPath && selected.includes(file.originalPath)))
|
||||
@@ -413,7 +662,7 @@ class GitService {
|
||||
throw new Error(`Some staged files are not selected (${excludedStaged.slice(0, 3).join(', ')}${excludedStaged.length > 3 ? ', …' : ''}). Select them or unstage them first.`);
|
||||
}
|
||||
}
|
||||
await this.stage(root, selected);
|
||||
await this.applyStage(root, selected, current);
|
||||
const stagedCheck = await run('git', ['diff', '--cached', '--quiet'], { cwd: root, allowExitCodes: [1] });
|
||||
if (stagedCheck.exitCode === 0) throw new Error('There are no staged changes to commit.');
|
||||
return selected;
|
||||
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 };
|
||||
@@ -4,6 +4,7 @@ const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { run } = require("./process-runner.cjs");
|
||||
const { normalizeRemoteUrl } = require("../shared/repository-match.cjs");
|
||||
const { applyPolicy, buildTrend, exportReport, normalizePolicy, validateSuppression } = require("./git-validator-policy.cjs");
|
||||
|
||||
const RECOMMENDED_GITIGNORE = `# Local configuration and secrets
|
||||
.env
|
||||
@@ -67,6 +68,7 @@ function result(id, category, title, status, detail, options = {}) {
|
||||
fixAction: options.fixAction || null,
|
||||
safe: options.safe === true,
|
||||
confirmation: options.confirmation || null,
|
||||
evidence: options.evidence || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,10 +86,11 @@ function isSensitiveTrackedPath(filePath) {
|
||||
}
|
||||
|
||||
class GitValidatorService {
|
||||
constructor({ git, gitea, diagnostics }) {
|
||||
constructor({ git, gitea, diagnostics, store }) {
|
||||
this.git = git;
|
||||
this.gitea = gitea;
|
||||
this.diagnostics = diagnostics;
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
async config(root, key, { local = true } = {}) {
|
||||
@@ -176,7 +179,7 @@ class GitValidatorService {
|
||||
{ weight: 35 },
|
||||
),
|
||||
);
|
||||
return this.summarize(repository, checks);
|
||||
return this.finalize(repository, checks, null);
|
||||
}
|
||||
|
||||
const root = await this.git.ensureRepository(repository.localPath);
|
||||
@@ -378,15 +381,126 @@ class GitValidatorService {
|
||||
{ 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) {
|
||||
const totalWeight = checks.reduce((sum, check) => sum + check.weight, 0);
|
||||
const earned = checks.reduce(
|
||||
(sum, check) =>
|
||||
sum +
|
||||
(check.status === "pass"
|
||||
(check.status === "pass" || check.suppressed
|
||||
? check.weight
|
||||
: check.status === "warning"
|
||||
? check.weight * 0.45
|
||||
@@ -409,8 +523,9 @@ class GitValidatorService {
|
||||
checks,
|
||||
summary: {
|
||||
passed: checks.filter((check) => check.status === "pass").length,
|
||||
warnings: checks.filter((check) => check.status === "warning").length,
|
||||
errors: checks.filter((check) => check.status === "error").length,
|
||||
warnings: checks.filter((check) => check.status === "warning" && !check.suppressed).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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -264,6 +264,19 @@ class GiteaService {
|
||||
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 } = {}) {
|
||||
const query = new URLSearchParams({
|
||||
state,
|
||||
@@ -413,6 +426,12 @@ class GiteaService {
|
||||
if (!downloadUrl) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } };
|
||||
@@ -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] };
|
||||
@@ -1,82 +1,25 @@
|
||||
"use strict";
|
||||
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs/promises");
|
||||
const { fileURLToPath } = require("node:url");
|
||||
const { ipcMain, dialog, shell, app } = require("electron");
|
||||
const { dialog, shell, app } = require("electron");
|
||||
const { matchRemoteToRepository } = require("../shared/repository-match.cjs");
|
||||
const {
|
||||
cloneDirectoryName,
|
||||
resolveCloneTarget,
|
||||
} = require("../shared/clone-target.cjs");
|
||||
const {
|
||||
createChannelRegistrar,
|
||||
assertTrustedSender,
|
||||
toErrorPayload,
|
||||
} = require("./ipc/channel.cjs");
|
||||
const { registerRepositoryIpc } = require("./ipc/repository-handlers.cjs");
|
||||
const { registerDeploymentIpc } = require("./ipc/deployment-handlers.cjs");
|
||||
const { registerOperationsIpc } = require("./ipc/operations-handlers.cjs");
|
||||
const {
|
||||
createEncryptedBackup,
|
||||
readEncryptedBackup,
|
||||
} = require("./configuration-backup.cjs");
|
||||
const { evaluateDeploymentPolicy } = require("../shared/deployment-policy.cjs");
|
||||
|
||||
let diagnosticsService = null;
|
||||
const TRUSTED_RENDERER_PATH = path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"renderer",
|
||||
"index.html",
|
||||
);
|
||||
|
||||
function toErrorPayload(error) {
|
||||
return {
|
||||
message: error?.message || "Unknown error",
|
||||
code: error?.code || null,
|
||||
status: error?.status || null,
|
||||
recoverable: Boolean(error?.recoverable),
|
||||
commitSha: error?.commitSha || null,
|
||||
};
|
||||
}
|
||||
|
||||
function assertTrustedSender(event) {
|
||||
const url = event?.senderFrame?.url || event?.sender?.getURL?.() || "";
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "file:") throw new Error("not a file URL");
|
||||
const senderPath = path.resolve(fileURLToPath(parsed));
|
||||
const normalize = (value) =>
|
||||
process.platform === "win32" ? value.toLowerCase() : value;
|
||||
if (normalize(senderPath) !== normalize(TRUSTED_RENDERER_PATH))
|
||||
throw new Error("unexpected renderer file");
|
||||
} catch {
|
||||
throw new Error("Rejected IPC request from an untrusted renderer origin.");
|
||||
}
|
||||
}
|
||||
|
||||
function register(channel, handler) {
|
||||
ipcMain.handle(channel, async (event, payload) => {
|
||||
const started = Date.now();
|
||||
try {
|
||||
assertTrustedSender(event);
|
||||
const data = await handler(payload || {}, event);
|
||||
await diagnosticsService?.debug("ipc.completed", {
|
||||
channel,
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
return { ok: true, data };
|
||||
} catch (error) {
|
||||
await diagnosticsService?.error("ipc.failed", {
|
||||
channel,
|
||||
durationMs: Date.now() - started,
|
||||
error: {
|
||||
name: error?.name,
|
||||
message: error?.message,
|
||||
code: error?.code,
|
||||
status: error?.status,
|
||||
stack: error?.stack,
|
||||
},
|
||||
});
|
||||
console.error(`[${channel}]`, error);
|
||||
return { ok: false, error: toErrorPayload(error) };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function registerIpc({
|
||||
store,
|
||||
git,
|
||||
@@ -84,6 +27,8 @@ function registerIpc({
|
||||
repositories,
|
||||
deployments,
|
||||
unraid,
|
||||
deployKeys,
|
||||
inventoryReviews,
|
||||
ssh,
|
||||
updates,
|
||||
preflight,
|
||||
@@ -94,7 +39,7 @@ function registerIpc({
|
||||
monitor,
|
||||
onPreferencesChanged,
|
||||
}) {
|
||||
diagnosticsService = diagnostics;
|
||||
const register = createChannelRegistrar(diagnostics);
|
||||
const repositoryMutations = new Map();
|
||||
const withRepositoryPause = async (localPath, action) => {
|
||||
monitor?.pause(localPath);
|
||||
@@ -160,8 +105,11 @@ function registerIpc({
|
||||
await repositories.refresh();
|
||||
knownPaths = repositories.getWatchPaths();
|
||||
}
|
||||
const canonicalKnown = await Promise.all(knownPaths.map(canonicalPath));
|
||||
if (!canonicalKnown.some((known) => known === candidate))
|
||||
// Watch paths are already canonical, so re-resolving all of them on every
|
||||
// guarded call is only needed when the cheap comparison finds no match.
|
||||
const matched = knownPaths.some((known) => path.resolve(known) === candidate)
|
||||
|| (await Promise.all(knownPaths.map(canonicalPath))).some((known) => known === candidate);
|
||||
if (!matched)
|
||||
throw new Error(
|
||||
"The requested local repository is not linked or discovered by ForgeFlow.",
|
||||
);
|
||||
@@ -171,9 +119,7 @@ function registerIpc({
|
||||
const resolveRepository = async (repositoryPayload) => {
|
||||
const fullName = String(repositoryPayload?.fullName || "").trim();
|
||||
if (!fullName) throw new Error("Repository identity is required.");
|
||||
const current = (await repositories.refresh()).find(
|
||||
(item) => item.fullName === fullName,
|
||||
);
|
||||
const current = await repositories.resolveByFullName(fullName);
|
||||
if (!current)
|
||||
throw new Error(
|
||||
"The repository is no longer available through the configured Gitea account.",
|
||||
@@ -469,13 +415,25 @@ function registerIpc({
|
||||
await diagnostics.info("server.deleted", { serverId });
|
||||
return store.getPublicState();
|
||||
});
|
||||
register("server:test", async ({ serverId }) => {
|
||||
register("server:test", async ({ serverId, expectedFingerprint = "" }) => {
|
||||
const server = store.getServer(serverId);
|
||||
if (!server) throw new Error("The configured server no longer exists.");
|
||||
const expected = String(expectedFingerprint || "").trim();
|
||||
if (!server.hostFingerprint && !expected) {
|
||||
const probe = await ssh.probeHostFingerprint(serverId);
|
||||
return { ...probe, connected: false, needsTrust: true, state: store.getPublicState() };
|
||||
}
|
||||
if (!server.hostFingerprint && !/^SHA256:[A-Za-z0-9+/]{40,44}$/.test(expected))
|
||||
throw new Error("Confirm the exact SSH host fingerprint returned by ForgeFlow.");
|
||||
const result = await ssh.test(serverId, {
|
||||
trustOnFirstUse: !server.hostFingerprint,
|
||||
expectedFingerprint: server.hostFingerprint ? null : expected,
|
||||
});
|
||||
if (!server.hostFingerprint) {
|
||||
if (result.fingerprint !== expected) {
|
||||
const error = new Error("The SSH host identity changed between preview and confirmation.");
|
||||
error.code = "SSH_HOST_KEY_MISMATCH";
|
||||
throw error;
|
||||
}
|
||||
await store.saveServer(
|
||||
{ ...server, hostFingerprint: result.fingerprint },
|
||||
{},
|
||||
@@ -500,383 +458,11 @@ function registerIpc({
|
||||
}),
|
||||
);
|
||||
|
||||
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: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;
|
||||
registerRepositoryIpc({
|
||||
register, repositories, store, git, gitea, monitor, diagnostics, audit,
|
||||
externalTools, gitValidator, withRepositoryMutation, assertKnownRepositoryPath,
|
||||
resolveRepository, cloneRepositoryInto, cloneDirectoryName,
|
||||
matchRemoteToRepository, shell, dialog,
|
||||
});
|
||||
|
||||
register("troubleshooter:scan", async ({ fullName = null }) => {
|
||||
@@ -1100,291 +686,14 @@ function registerIpc({
|
||||
return results;
|
||||
});
|
||||
|
||||
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() };
|
||||
registerDeploymentIpc({
|
||||
register, store, resolveRepository, unraid, deployments, evaluateDeploymentPolicy,
|
||||
audit, deployKeys, repositories, inventoryReviews, diagnostics, git, gitea, ssh,
|
||||
preflight,
|
||||
});
|
||||
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: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();
|
||||
registerOperationsIpc({
|
||||
register, store, unraid, deployments, diagnostics, shell, dialog, path, app,
|
||||
repositories, preflight, monitor,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"use strict";
|
||||
|
||||
const path = require("node:path");
|
||||
const { fileURLToPath } = require("node:url");
|
||||
const { ipcMain } = require("electron");
|
||||
|
||||
const TRUSTED_RENDERER_PATH = path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"renderer",
|
||||
"index.html",
|
||||
);
|
||||
|
||||
function toErrorPayload(error) {
|
||||
return {
|
||||
message: error?.message || "Unknown error",
|
||||
code: error?.code || null,
|
||||
status: error?.status || null,
|
||||
recoverable: Boolean(error?.recoverable),
|
||||
commitSha: error?.commitSha || null,
|
||||
};
|
||||
}
|
||||
|
||||
function assertTrustedSender(event) {
|
||||
const url = event?.senderFrame?.url || event?.sender?.getURL?.() || "";
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "file:") throw new Error("not a file URL");
|
||||
const senderPath = path.resolve(fileURLToPath(parsed));
|
||||
const normalize = (value) =>
|
||||
process.platform === "win32" ? value.toLowerCase() : value;
|
||||
if (normalize(senderPath) !== normalize(TRUSTED_RENDERER_PATH))
|
||||
throw new Error("unexpected renderer file");
|
||||
} catch {
|
||||
throw new Error("Rejected IPC request from an untrusted renderer origin.");
|
||||
}
|
||||
}
|
||||
|
||||
// Built per registerIpc() call so the diagnostics sink is an argument instead of
|
||||
// module-level mutable state that every handler silently depends on.
|
||||
function createChannelRegistrar(diagnostics) {
|
||||
return function register(channel, handler) {
|
||||
ipcMain.handle(channel, async (event, payload) => {
|
||||
const started = Date.now();
|
||||
try {
|
||||
assertTrustedSender(event);
|
||||
const data = await handler(payload || {}, event);
|
||||
await diagnostics?.debug("ipc.completed", {
|
||||
channel,
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
return { ok: true, data };
|
||||
} catch (error) {
|
||||
await diagnostics?.error("ipc.failed", {
|
||||
channel,
|
||||
durationMs: Date.now() - started,
|
||||
error: {
|
||||
name: error?.name,
|
||||
message: error?.message,
|
||||
code: error?.code,
|
||||
status: error?.status,
|
||||
stack: error?.stack,
|
||||
},
|
||||
});
|
||||
return { ok: false, error: toErrorPayload(error) };
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createChannelRegistrar,
|
||||
assertTrustedSender,
|
||||
toErrorPayload,
|
||||
TRUSTED_RENDERER_PATH,
|
||||
};
|
||||
@@ -0,0 +1,285 @@
|
||||
"use strict";
|
||||
|
||||
function registerDeploymentIpc({
|
||||
register, store, resolveRepository, unraid, deployments, evaluateDeploymentPolicy,
|
||||
audit, deployKeys, repositories, inventoryReviews, diagnostics, git, gitea, ssh,
|
||||
preflight,
|
||||
}) {
|
||||
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 };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,450 @@
|
||||
"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 ({ force = false }) => {
|
||||
const result = await repositories.refresh({ force: force === true });
|
||||
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:workspace-sync-preview", async ({ localPath }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
const plan = await withRepositoryMutation(safePath, () =>
|
||||
git.previewWorkspaceSync(safePath),
|
||||
);
|
||||
await diagnostics.info("repository.workspace-sync.previewed", {
|
||||
localPath: safePath,
|
||||
branch: plan.branch,
|
||||
upstream: plan.upstream,
|
||||
currentSha: plan.currentSha,
|
||||
targetSha: plan.targetSha,
|
||||
planId: plan.id,
|
||||
summary: plan.summary,
|
||||
blockers: plan.blockers,
|
||||
});
|
||||
return plan;
|
||||
});
|
||||
register(
|
||||
"repository:workspace-sync-apply",
|
||||
async ({ localPath, expectedPlanId }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
const result = await withRepositoryMutation(safePath, () =>
|
||||
git.synchronizeWorkspace(safePath, expectedPlanId),
|
||||
);
|
||||
await audit.append("repository.workspace-synchronized", {
|
||||
localPath: safePath,
|
||||
branch: result.plan.branch,
|
||||
upstream: result.plan.upstream,
|
||||
previousSha: result.plan.currentSha,
|
||||
targetSha: result.plan.targetSha,
|
||||
backupBranch: result.backupBranch,
|
||||
stashSha: result.stash?.sha || null,
|
||||
ignoredFilesPreserved: true,
|
||||
applied: result.applied,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
);
|
||||
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 };
|
||||
@@ -0,0 +1,26 @@
|
||||
'use strict';
|
||||
|
||||
function isBrokenPipeError(error) {
|
||||
return error?.code === 'EPIPE';
|
||||
}
|
||||
|
||||
function installOutputPipeGuards({
|
||||
stdout = process.stdout,
|
||||
stderr = process.stderr,
|
||||
onBrokenPipe = () => {}
|
||||
} = {}) {
|
||||
const guardedStreams = [stdout, stderr].filter(Boolean);
|
||||
const handlers = guardedStreams.map((stream) => {
|
||||
const handler = (error) => {
|
||||
if (!isBrokenPipeError(error)) throw error;
|
||||
onBrokenPipe(error);
|
||||
};
|
||||
stream.on('error', handler);
|
||||
return { stream, handler };
|
||||
});
|
||||
return () => {
|
||||
for (const { stream, handler } of handlers) stream.off('error', handler);
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { installOutputPipeGuards, isBrokenPipeError };
|
||||
@@ -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 };
|
||||
@@ -1,5 +1,16 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
|
||||
// A watched repository is only re-read when the filesystem reports activity. The
|
||||
// interval below stays as a safety net for watchers that silently stop
|
||||
// delivering, which happens on network shares and removed folders.
|
||||
const SAFETY_CHECK_INTERVAL_MS = 30_000;
|
||||
const WATCH_DEBOUNCE_MS = 250;
|
||||
// Busy trees (a build, an install, a fetch) produce a continuous event stream.
|
||||
// This bounds how often that can turn into a Git read.
|
||||
const MIN_WATCH_CHECK_INTERVAL_MS = 1_000;
|
||||
|
||||
class RepositoryMonitor {
|
||||
constructor({ store, git, onChange, diagnostics = null }) {
|
||||
this.store = store;
|
||||
@@ -11,12 +22,149 @@ class RepositoryMonitor {
|
||||
this.timer = null;
|
||||
this.running = false;
|
||||
this.paused = new Set();
|
||||
this.active = false;
|
||||
this.watchers = new Map();
|
||||
this.changed = new Set();
|
||||
this.lastCheckedAt = new Map();
|
||||
this.lastFetchedAt = new Map();
|
||||
this.watchTimer = null;
|
||||
this.fetchRunning = false;
|
||||
}
|
||||
|
||||
setPaths(paths) {
|
||||
this.paths = [...new Set((paths || []).filter(Boolean))];
|
||||
const watched = new Set(this.paths);
|
||||
for (const existing of [...this.fingerprints.keys()]) {
|
||||
if (!this.paths.includes(existing)) this.fingerprints.delete(existing);
|
||||
if (!watched.has(existing)) this.fingerprints.delete(existing);
|
||||
}
|
||||
// A repository that is unlinked while a mutation holds it paused would keep
|
||||
// that pause forever, silently freezing its status once it is watched again.
|
||||
for (const existing of [...this.paused]) {
|
||||
if (!watched.has(existing)) this.paused.delete(existing);
|
||||
}
|
||||
for (const existing of [...this.changed]) {
|
||||
if (!watched.has(existing)) this.changed.delete(existing);
|
||||
}
|
||||
for (const existing of [...this.lastCheckedAt.keys()]) {
|
||||
if (!watched.has(existing)) this.lastCheckedAt.delete(existing);
|
||||
}
|
||||
for (const existing of [...this.lastFetchedAt.keys()]) {
|
||||
if (!watched.has(existing)) this.lastFetchedAt.delete(existing);
|
||||
}
|
||||
const now = Date.now();
|
||||
for (const localPath of this.paths) {
|
||||
if (!this.lastFetchedAt.has(localPath)) this.lastFetchedAt.set(localPath, now);
|
||||
}
|
||||
this.syncWatchers();
|
||||
}
|
||||
|
||||
syncWatchers() {
|
||||
for (const [localPath, watcher] of [...this.watchers]) {
|
||||
if (this.active && this.paths.includes(localPath)) continue;
|
||||
this.closeWatcher(localPath, watcher);
|
||||
}
|
||||
if (!this.active) return;
|
||||
for (const localPath of this.paths) {
|
||||
if (this.watchers.has(localPath)) continue;
|
||||
try {
|
||||
const watcher = fs.watch(
|
||||
localPath,
|
||||
{ recursive: true, persistent: false },
|
||||
() => this.noteFilesystemChange(localPath)
|
||||
);
|
||||
watcher.on('error', () => this.dropWatcher(localPath));
|
||||
this.watchers.set(localPath, watcher);
|
||||
} catch {
|
||||
// Watching is unavailable for this folder. Leaving it unwatched makes
|
||||
// shouldCheck() fall back to the interval for that repository only.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeWatcher(localPath, watcher = this.watchers.get(localPath)) {
|
||||
if (!watcher) return;
|
||||
try { watcher.close(); } catch { /* already closed */ }
|
||||
this.watchers.delete(localPath);
|
||||
}
|
||||
|
||||
dropWatcher(localPath) {
|
||||
this.closeWatcher(localPath);
|
||||
this.changed.add(localPath);
|
||||
}
|
||||
|
||||
noteFilesystemChange(localPath) {
|
||||
this.changed.add(localPath);
|
||||
this.scheduleWatchTick();
|
||||
}
|
||||
|
||||
scheduleWatchTick() {
|
||||
if (this.watchTimer) return;
|
||||
this.watchTimer = setTimeout(() => {
|
||||
this.watchTimer = null;
|
||||
this.tick().catch((error) => this.diagnostics?.warning('repository-monitor.tick.failed', error));
|
||||
}, WATCH_DEBOUNCE_MS);
|
||||
this.watchTimer.unref?.();
|
||||
}
|
||||
|
||||
shouldCheck(localPath, now) {
|
||||
if (this.paused.has(localPath)) return false;
|
||||
if (!this.watchers.has(localPath)) return true;
|
||||
const sinceLastCheck = now - (this.lastCheckedAt.get(localPath) || 0);
|
||||
if (this.changed.has(localPath)) return sinceLastCheck >= MIN_WATCH_CHECK_INTERVAL_MS;
|
||||
return sinceLastCheck >= SAFETY_CHECK_INTERVAL_MS;
|
||||
}
|
||||
|
||||
fetchIntervalMs() {
|
||||
const minutes = Number(this.store.data.preferences.fetchIntervalMinutes);
|
||||
return Number.isFinite(minutes) && minutes > 0 ? Math.min(minutes, 240) * 60_000 : 0;
|
||||
}
|
||||
|
||||
shouldFetch(localPath, now) {
|
||||
const interval = this.fetchIntervalMs();
|
||||
return interval > 0
|
||||
&& !this.paused.has(localPath)
|
||||
&& now - (this.lastFetchedAt.get(localPath) || now) >= interval;
|
||||
}
|
||||
|
||||
async recordStatus(localPath, status, reason) {
|
||||
const next = this.git.statusFingerprint(status);
|
||||
const previous = this.fingerprints.get(localPath);
|
||||
this.fingerprints.set(localPath, next);
|
||||
if (previous && previous !== next) {
|
||||
await this.diagnostics?.debug('repository-monitor.changed', { localPath, head: status.head, branch: status.branch?.head, counts: status.counts, reason });
|
||||
this.onChange?.({ localPath, status, reason });
|
||||
}
|
||||
}
|
||||
|
||||
async fetchRemoteUpdates(now = Date.now()) {
|
||||
if (this.fetchRunning) return;
|
||||
const queue = this.paths.filter((localPath) => this.shouldFetch(localPath, now));
|
||||
if (!queue.length) return;
|
||||
this.fetchRunning = true;
|
||||
try {
|
||||
const workers = Array.from({ length: Math.min(2, queue.length) }, async () => {
|
||||
while (queue.length) {
|
||||
const localPath = queue.shift();
|
||||
// Mark the attempt before awaiting the network. A failing remote should
|
||||
// not be retried every local poll interval.
|
||||
this.lastFetchedAt.set(localPath, Date.now());
|
||||
try {
|
||||
const result = await this.git.fetch(localPath);
|
||||
await this.recordStatus(localPath, result.status, 'remote-state-changed');
|
||||
await this.diagnostics?.debug('repository-monitor.fetch.completed', {
|
||||
localPath,
|
||||
branch: result.status?.branch?.head,
|
||||
ahead: result.status?.branch?.ahead,
|
||||
behind: result.status?.branch?.behind,
|
||||
});
|
||||
} catch (error) {
|
||||
await this.diagnostics?.warning('repository-monitor.fetch.failed', { localPath, message: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
} finally {
|
||||
this.fetchRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +174,8 @@ class RepositoryMonitor {
|
||||
restart() {
|
||||
this.stop();
|
||||
if (!this.store.data.preferences.autoRefresh) return;
|
||||
this.active = true;
|
||||
this.syncWatchers();
|
||||
const seconds = Math.min(Math.max(Number(this.store.data.preferences.repositoryPollSeconds) || 4, 2), 60);
|
||||
this.timer = setInterval(() => this.tick().catch((error) => this.diagnostics?.warning('repository-monitor.tick.failed', error)), seconds * 1000);
|
||||
this.timer.unref?.();
|
||||
@@ -34,37 +184,46 @@ class RepositoryMonitor {
|
||||
stop() {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
if (this.watchTimer) clearTimeout(this.watchTimer);
|
||||
this.watchTimer = null;
|
||||
this.active = false;
|
||||
this.syncWatchers();
|
||||
}
|
||||
|
||||
async tick() {
|
||||
void this.fetchRemoteUpdates().catch((error) => this.diagnostics?.warning('repository-monitor.fetch-cycle.failed', error));
|
||||
if (this.running || !this.paths.length) return;
|
||||
this.running = true;
|
||||
try {
|
||||
for (const localPath of this.paths) {
|
||||
if (this.paused.has(localPath)) continue;
|
||||
try {
|
||||
const status = await this.git.status(localPath);
|
||||
const next = this.git.statusFingerprint(status);
|
||||
const previous = this.fingerprints.get(localPath);
|
||||
this.fingerprints.set(localPath, next);
|
||||
if (previous && previous !== next) {
|
||||
await this.diagnostics?.debug('repository-monitor.changed', { localPath, head: status.head, branch: status.branch?.head, counts: status.counts });
|
||||
this.onChange?.({ localPath, status, reason: 'working-tree-changed' });
|
||||
}
|
||||
} catch (error) {
|
||||
const next = `error:${error.message}`;
|
||||
const previous = this.fingerprints.get(localPath);
|
||||
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' });
|
||||
const now = Date.now();
|
||||
const queue = this.paths.filter((localPath) => this.shouldCheck(localPath, now));
|
||||
const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
|
||||
while (queue.length) {
|
||||
const localPath = queue.shift();
|
||||
this.changed.delete(localPath);
|
||||
this.lastCheckedAt.set(localPath, Date.now());
|
||||
try {
|
||||
const status = await this.git.status(localPath);
|
||||
await this.recordStatus(localPath, status, 'working-tree-changed');
|
||||
} catch (error) {
|
||||
const next = `error:${error.message}`;
|
||||
const previous = this.fingerprints.get(localPath);
|
||||
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 {
|
||||
this.running = false;
|
||||
// Activity that arrived while the check was running keeps its flag set, so
|
||||
// it must not wait for the safety interval.
|
||||
if (this.active && this.changed.size) this.scheduleWatchTick();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { RepositoryMonitor };
|
||||
module.exports = { RepositoryMonitor, SAFETY_CHECK_INTERVAL_MS, WATCH_DEBOUNCE_MS, MIN_WATCH_CHECK_INTERVAL_MS };
|
||||
|
||||
@@ -29,6 +29,13 @@ class RepositoryService {
|
||||
this.gitea = giteaService;
|
||||
this.diagnostics = diagnostics;
|
||||
this.lastKnownLocalPaths = [];
|
||||
this.lastKnownRemoteRepositories = [];
|
||||
this.lastSuccessfulRemoteRefreshAt = null;
|
||||
this.lastRemoteRefreshAtMs = 0;
|
||||
this.lastDiscoveredPaths = [];
|
||||
this.lastDiscoveryAtMs = 0;
|
||||
this.refreshPromise = null;
|
||||
this.lastResult = null;
|
||||
}
|
||||
|
||||
async discoverInRoot(root, maxDepth = 4) {
|
||||
@@ -51,8 +58,13 @@ class RepositoryService {
|
||||
|
||||
let entries;
|
||||
try { entries = await fs.readdir(real, { withFileTypes: true }); } catch { return; }
|
||||
// Directory entries report as a symbolic link instead of a directory, which
|
||||
// is how Windows junctions surface. Skipping those made a project folder
|
||||
// that is mapped through a junction invisible; visit() resolves each entry
|
||||
// and the `seen` set above keeps links that point back into the tree from
|
||||
// being scanned twice.
|
||||
await mapLimit(entries
|
||||
.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink() && !SKIP_DIRECTORIES.has(entry.name)), 12,
|
||||
.filter((entry) => (entry.isDirectory() || entry.isSymbolicLink()) && !SKIP_DIRECTORIES.has(entry.name)), 12,
|
||||
(entry) => visit(path.join(real, entry.name), depth + 1));
|
||||
};
|
||||
|
||||
@@ -80,13 +92,97 @@ class RepositoryService {
|
||||
return [...this.lastKnownLocalPaths];
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
const started = Date.now();
|
||||
const remoteRepositories = this.store.data.gitea.baseUrl && this.store.getToken()
|
||||
? await this.gitea.listRepositories()
|
||||
: [];
|
||||
async getRemoteRepositories({ force = false } = {}) {
|
||||
if (!this.store.data.gitea.baseUrl || !this.store.getToken()) {
|
||||
this.lastKnownRemoteRepositories = [];
|
||||
this.lastSuccessfulRemoteRefreshAt = null;
|
||||
return { repositories: [], stale: false, error: null };
|
||||
}
|
||||
|
||||
const discoveredPaths = await this.discoverAll(this.store.data.workspaceRoots);
|
||||
if (!force && this.lastSuccessfulRemoteRefreshAt && Date.now() - this.lastRemoteRefreshAtMs < 15_000) {
|
||||
return {
|
||||
repositories: this.lastKnownRemoteRepositories.map((repository) => ({ ...repository })),
|
||||
stale: false,
|
||||
error: null,
|
||||
cached: true
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const repositories = await this.gitea.listRepositories();
|
||||
this.lastKnownRemoteRepositories = repositories.map((repository) => ({ ...repository }));
|
||||
this.lastSuccessfulRemoteRefreshAt = new Date().toISOString();
|
||||
this.lastRemoteRefreshAtMs = Date.now();
|
||||
return { repositories, stale: false, error: null };
|
||||
} catch (error) {
|
||||
if (!this.lastSuccessfulRemoteRefreshAt) throw error;
|
||||
await this.diagnostics?.warning('repositories.remote-refresh.degraded', {
|
||||
message: error.message,
|
||||
cachedCount: this.lastKnownRemoteRepositories.length,
|
||||
lastSuccessfulAt: this.lastSuccessfulRemoteRefreshAt
|
||||
});
|
||||
return {
|
||||
repositories: this.lastKnownRemoteRepositories.map((repository) => ({ ...repository })),
|
||||
stale: true,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async getDiscoveredPaths({ force = false } = {}) {
|
||||
if (!force && this.lastDiscoveryAtMs && Date.now() - this.lastDiscoveryAtMs < 30_000) {
|
||||
return [...this.lastDiscoveredPaths];
|
||||
}
|
||||
const paths = await this.discoverAll(this.store.data.workspaceRoots);
|
||||
this.lastDiscoveredPaths = [...paths];
|
||||
this.lastDiscoveryAtMs = Date.now();
|
||||
return paths;
|
||||
}
|
||||
|
||||
// Resolving a single repository used to go through a full refresh, which runs
|
||||
// `git status` for every discovered repository. Handlers that act on one
|
||||
// repository only need that one, so its local state is read directly. Anything
|
||||
// this cannot answer confidently still falls back to the full scan.
|
||||
async resolveByFullName(fullName) {
|
||||
const name = String(fullName || '').trim();
|
||||
if (!name) return null;
|
||||
const fromFullRefresh = async () => (await this.refresh()).find((item) => item.fullName === name) || null;
|
||||
|
||||
const remoteResult = await this.getRemoteRepositories({});
|
||||
const remote = remoteResult.repositories.find((item) => item.full_name === name);
|
||||
if (!remote) return fromFullRefresh();
|
||||
|
||||
const explicitPath = this.store.data.repositoryMappings[repositoryKey(remote)];
|
||||
const knownPath = explicitPath || (this.lastResult || []).find((item) => item.fullName === name)?.localPath || null;
|
||||
// Without a known path the link can still exist through remote-URL matching,
|
||||
// which only the discovery pass can establish.
|
||||
if (!knownPath && !this.lastResult) return fromFullRefresh();
|
||||
|
||||
const local = knownPath ? (await this.getLocalDescriptors([knownPath]))[0] : null;
|
||||
const profiles = this.store.getDeploymentProfiles(remote.full_name).map((profile) => ({
|
||||
...profile,
|
||||
state: this.store.getDeploymentState(profile.id)
|
||||
}));
|
||||
return {
|
||||
...this.decorate(remote, local, profiles),
|
||||
remoteStale: remoteResult.stale,
|
||||
remoteRefreshError: remoteResult.error,
|
||||
remoteLastRefreshedAt: this.lastSuccessfulRemoteRefreshAt
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(options = {}) {
|
||||
if (this.refreshPromise) return this.refreshPromise;
|
||||
this.refreshPromise = this.performRefresh(options).finally(() => { this.refreshPromise = null; });
|
||||
return this.refreshPromise;
|
||||
}
|
||||
|
||||
async performRefresh({ force = false } = {}) {
|
||||
const started = Date.now();
|
||||
const remoteResult = await this.getRemoteRepositories({ force });
|
||||
const remoteRepositories = remoteResult.repositories;
|
||||
|
||||
const discoveredPaths = await this.getDiscoveredPaths({ force });
|
||||
const mappedPaths = Object.values(this.store.data.repositoryMappings || {});
|
||||
const localPaths = [...new Set([...discoveredPaths, ...mappedPaths])];
|
||||
const localDescriptors = await this.getLocalDescriptors(localPaths);
|
||||
@@ -106,7 +202,12 @@ class RepositoryService {
|
||||
...profile,
|
||||
state: this.store.getDeploymentState(profile.id)
|
||||
}));
|
||||
repositories.push(this.decorate(remote, local, profiles));
|
||||
repositories.push({
|
||||
...this.decorate(remote, local, profiles),
|
||||
remoteStale: remoteResult.stale,
|
||||
remoteRefreshError: remoteResult.error,
|
||||
remoteLastRefreshedAt: this.lastSuccessfulRemoteRefreshAt
|
||||
});
|
||||
}
|
||||
|
||||
for (const local of localDescriptors.filter((item) => !usedLocalPaths.has(item.localPath))) {
|
||||
@@ -145,11 +246,14 @@ class RepositoryService {
|
||||
await this.diagnostics?.debug('repositories.refresh.completed', {
|
||||
durationMs: Date.now() - started,
|
||||
remoteCount: remoteRepositories.length,
|
||||
remoteStale: remoteResult.stale,
|
||||
remoteCached: remoteResult.cached === true,
|
||||
discoveredCount: discoveredPaths.length,
|
||||
linkedCount: sorted.filter((item) => item.localPath).length,
|
||||
attentionCount: sorted.filter((item) => item.attention).length,
|
||||
readyToDeployCount: sorted.filter((item) => item.readyToDeploy).length
|
||||
});
|
||||
this.lastResult = sorted;
|
||||
return sorted;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const fsp = require('node:fs/promises');
|
||||
const crypto = require('node:crypto');
|
||||
const path = require('node:path').posix;
|
||||
@@ -54,9 +53,29 @@ function parseCapabilityOutput(output) {
|
||||
}
|
||||
|
||||
class SshService {
|
||||
constructor({ store, diagnostics }) {
|
||||
constructor({ store, diagnostics, idleConnectionMs = 60_000, clientFactory = loadSshClient }) {
|
||||
this.store = store;
|
||||
this.diagnostics = diagnostics;
|
||||
this.clientFactory = clientFactory;
|
||||
// Every command used to pay for a TCP handshake, a key exchange and an
|
||||
// authentication round trip. Sessions are kept per server for a short while
|
||||
// so a sequence of commands shares one connection.
|
||||
this.sessions = new Map();
|
||||
this.idleConnectionMs = idleConnectionMs;
|
||||
}
|
||||
|
||||
// A connection is only reusable for a server whose identity and credentials
|
||||
// are unchanged. Anything in this key changing means a new connection.
|
||||
sessionKey(server) {
|
||||
return JSON.stringify([
|
||||
server.id,
|
||||
server.host,
|
||||
server.port || 22,
|
||||
server.username,
|
||||
server.authType,
|
||||
server.privateKeyPath || '',
|
||||
server.hostFingerprint || '',
|
||||
]);
|
||||
}
|
||||
|
||||
async validateServerConfiguration(server, secrets = {}) {
|
||||
@@ -86,7 +105,7 @@ class SshService {
|
||||
return { valid: true, method: 'privateKey', encrypted: Boolean(passphrase), privateKeyPath };
|
||||
}
|
||||
|
||||
async connectionOptions(server, { trustOnFirstUse = false } = {}) {
|
||||
async connectionOptions(server, { trustOnFirstUse = false, expectedFingerprint = null } = {}) {
|
||||
const credentials = this.store.getServerCredentials(server.id);
|
||||
let observedFingerprint = null;
|
||||
const options = {
|
||||
@@ -98,7 +117,8 @@ class SshService {
|
||||
keepaliveCountMax: 3,
|
||||
hostVerifier: (key) => {
|
||||
observedFingerprint = fingerprintKey(key);
|
||||
return trustOnFirstUse || Boolean(server.hostFingerprint && observedFingerprint === server.hostFingerprint);
|
||||
const trustedFingerprint = String(server.hostFingerprint || expectedFingerprint || '').trim();
|
||||
return trustOnFirstUse || Boolean(trustedFingerprint && observedFingerprint === trustedFingerprint);
|
||||
},
|
||||
};
|
||||
if (server.authType === 'password') options.password = credentials.password;
|
||||
@@ -117,7 +137,115 @@ class SshService {
|
||||
async withClient(serverId, action, options = {}) {
|
||||
const server = this.store.getServer(serverId);
|
||||
if (!server) throw new Error('The configured SSH server no longer exists.');
|
||||
const Client = loadSshClient();
|
||||
// A trust-on-first-use connection is established without checking the
|
||||
// fingerprint, so it must never serve a later verified call.
|
||||
if (options.trustOnFirstUse || options.expectedFingerprint) return this.withDedicatedClient(server, action, options);
|
||||
return this.withPooledClient(server, action, options);
|
||||
}
|
||||
|
||||
// Retrying is only safe while the command has not reached the server. Once a
|
||||
// stream is open the remote side may already be deploying, and repeating that
|
||||
// is not something this layer is allowed to decide.
|
||||
isPreCommandFailure(error) {
|
||||
return error?.beforeCommand === true;
|
||||
}
|
||||
|
||||
async withPooledClient(server, action, options) {
|
||||
const key = this.sessionKey(server);
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
const session = await this.leaseSession(server, key, options);
|
||||
try {
|
||||
const result = await action(session.client, server, session.fingerprint);
|
||||
this.releaseSession(session);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const staleConnection = session.reused && attempt === 0 && this.isPreCommandFailure(error);
|
||||
this.discardSession(session);
|
||||
if (!staleConnection) throw error;
|
||||
await this.diagnostics?.debug('ssh.session.stale-retry', { serverId: server.id, host: server.host, message: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createSession(server, key, options) {
|
||||
const entry = { key, client: null, fingerprint: null, leases: 0, dead: false, established: false, idleTimer: null, opening: null };
|
||||
entry.opening = this
|
||||
.withDedicatedClient(server, async (client, _server, fingerprint) => ({ client, fingerprint }), options, { keepOpen: true })
|
||||
.then((opened) => {
|
||||
entry.client = opened.client;
|
||||
entry.fingerprint = opened.fingerprint;
|
||||
entry.established = true;
|
||||
// Without a standing listener an error on an idle connection is
|
||||
// unhandled, which terminates the main process.
|
||||
opened.client.on('error', () => this.markSessionDead(entry));
|
||||
opened.client.on('close', () => this.markSessionDead(entry));
|
||||
opened.client.on('end', () => this.markSessionDead(entry));
|
||||
});
|
||||
this.sessions.set(key, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
async leaseSession(server, key, options) {
|
||||
const pooled = this.sessions.get(key);
|
||||
// Only a connection that was already up before this call may be retried on
|
||||
// failure. Callers that arrive while one is still being opened share both
|
||||
// the connection and its outcome.
|
||||
const reused = Boolean(pooled && !pooled.dead && pooled.established);
|
||||
const entry = pooled && !pooled.dead ? pooled : this.createSession(server, key, options);
|
||||
entry.leases += 1;
|
||||
if (entry.idleTimer) { clearTimeout(entry.idleTimer); entry.idleTimer = null; }
|
||||
try {
|
||||
await entry.opening;
|
||||
} catch (error) {
|
||||
entry.leases -= 1;
|
||||
this.markSessionDead(entry);
|
||||
throw error;
|
||||
}
|
||||
return { client: entry.client, fingerprint: entry.fingerprint, reused, entry };
|
||||
}
|
||||
|
||||
markSessionDead(entry) {
|
||||
entry.dead = true;
|
||||
if (this.sessions.get(entry.key) === entry) this.sessions.delete(entry.key);
|
||||
if (entry.idleTimer) { clearTimeout(entry.idleTimer); entry.idleTimer = null; }
|
||||
if (entry.leases <= 0) this.endSession(entry);
|
||||
}
|
||||
|
||||
endSession(entry) {
|
||||
if (!entry.client) return;
|
||||
try { entry.client.end(); } catch { /* already closed */ }
|
||||
}
|
||||
|
||||
releaseSession(session) {
|
||||
const entry = session.entry;
|
||||
entry.leases -= 1;
|
||||
if (entry.dead) { if (entry.leases <= 0) this.endSession(entry); return; }
|
||||
if (entry.leases > 0) return;
|
||||
entry.idleTimer = setTimeout(() => {
|
||||
entry.idleTimer = null;
|
||||
this.markSessionDead(entry);
|
||||
}, this.idleConnectionMs);
|
||||
entry.idleTimer.unref?.();
|
||||
}
|
||||
|
||||
discardSession(session) {
|
||||
const entry = session.entry;
|
||||
entry.leases -= 1;
|
||||
this.markSessionDead(entry);
|
||||
}
|
||||
|
||||
// Closes every pooled connection. The application calls this while quitting so
|
||||
// no socket outlives the process.
|
||||
closeAll() {
|
||||
for (const entry of [...this.sessions.values()]) {
|
||||
entry.leases = 0;
|
||||
this.markSessionDead(entry);
|
||||
}
|
||||
}
|
||||
|
||||
async withDedicatedClient(server, action, options = {}, { keepOpen = false } = {}) {
|
||||
const serverId = server.id;
|
||||
const Client = this.clientFactory();
|
||||
const connection = await this.connectionOptions(server, options);
|
||||
const client = new Client();
|
||||
const started = Date.now();
|
||||
@@ -126,7 +254,8 @@ class SshService {
|
||||
const finish = (callback, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try { client.end(); } catch {}
|
||||
// A session that stays in the pool is closed by the pool, not here.
|
||||
if (!(keepOpen && callback === resolve)) { try { client.end(); } catch { /* already closed */ } }
|
||||
callback(value);
|
||||
};
|
||||
client.once('ready', async () => {
|
||||
@@ -136,7 +265,11 @@ class SshService {
|
||||
finish(resolve, data);
|
||||
} catch (error) { finish(reject, error); }
|
||||
});
|
||||
client.once('error', async (error) => {
|
||||
// Deliberately not `once`: a connection that already failed can emit a
|
||||
// second error while it is being torn down, and an unhandled 'error' event
|
||||
// on an EventEmitter terminates the main process.
|
||||
client.on('error', async (error) => {
|
||||
if (settled) return;
|
||||
const observed = connection.getObservedFingerprint();
|
||||
const mismatch = Boolean(server.hostFingerprint && observed && server.hostFingerprint !== observed);
|
||||
const wrapped = new Error(mismatch
|
||||
@@ -164,6 +297,9 @@ class SshService {
|
||||
if (error) {
|
||||
clearTimeout(timer);
|
||||
completed = true;
|
||||
// The channel never opened, so the command did not reach the server.
|
||||
// This is the only failure the pool is allowed to retry.
|
||||
error.beforeCommand = true;
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
@@ -280,7 +416,51 @@ class SshService {
|
||||
}));
|
||||
}
|
||||
|
||||
async test(serverId, { trustOnFirstUse = true } = {}) {
|
||||
async probeHostFingerprint(serverId) {
|
||||
const server = this.store.getServer(serverId);
|
||||
if (!server) throw new Error('The configured SSH server no longer exists.');
|
||||
const Client = this.clientFactory();
|
||||
const client = new Client();
|
||||
let observedFingerprint = null;
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (callback, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
try { client.end(); } catch { /* handshake already closed */ }
|
||||
callback(value);
|
||||
};
|
||||
const completeProbe = (error = null) => {
|
||||
if (observedFingerprint) {
|
||||
finish(resolve, {
|
||||
fingerprint: observedFingerprint,
|
||||
server: { id: server.id, name: server.name, host: server.host, port: server.port || 22 },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const wrapped = new Error(`Could not read the SSH host fingerprint: ${error?.message || 'the server closed the handshake'}`);
|
||||
wrapped.code = error?.code || 'SSH_HOST_KEY_PROBE_FAILED';
|
||||
finish(reject, wrapped);
|
||||
};
|
||||
const timer = setTimeout(() => completeProbe(new Error('The SSH host-key probe timed out.')), 25_000);
|
||||
client.on('error', completeProbe);
|
||||
client.on('close', () => completeProbe());
|
||||
client.on('end', () => completeProbe());
|
||||
client.connect({
|
||||
host: server.host,
|
||||
port: server.port || 22,
|
||||
username: server.username,
|
||||
readyTimeout: 20_000,
|
||||
hostVerifier: (key) => {
|
||||
observedFingerprint = fingerprintKey(key);
|
||||
return false;
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async test(serverId, { trustOnFirstUse = false, expectedFingerprint = null } = {}) {
|
||||
return this.withClient(serverId, async (client, server, fingerprint) => {
|
||||
const script = `
|
||||
platform=$(uname -srm 2>/dev/null || true)
|
||||
@@ -315,7 +495,7 @@ printf 'baseWritable=%s\\n' "$base_writable"
|
||||
capabilities,
|
||||
output: [capabilities.platform, capabilities.composeVersion].filter(Boolean).join('\n'),
|
||||
};
|
||||
}, { trustOnFirstUse });
|
||||
}, { trustOnFirstUse, expectedFingerprint });
|
||||
}
|
||||
|
||||
async exec(serverId, command, options = {}) {
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
"use strict";
|
||||
|
||||
function createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto, parsePermissionInspection, safeRelativeRemoteFile }) {
|
||||
class UnraidAccessMethods {
|
||||
serverGitRemote(repository, profile) {
|
||||
const candidates = [
|
||||
repository.localStatus?.remoteUrl,
|
||||
repository.sshUrl,
|
||||
repository.preferredCloneUrl,
|
||||
profile.cloneUrl,
|
||||
]
|
||||
.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 deploymentCheckIds = new Set(["gitea-access", "remote-branch", "deploy-key-scope", "server-git-access", "server-inspection"]);
|
||||
const deploymentBlockers = checks.filter((item) => deploymentCheckIds.has(item.id) && item.status !== "pass");
|
||||
const deployReady = Boolean(branchSha) && deploymentBlockers.length === 0;
|
||||
const failed = checks.some((item) => item.status === "fail");
|
||||
const incomplete = checks.some((item) => ["warning", "incomplete", "unsupported"].includes(item.status));
|
||||
const readiness = deploymentBlockers.length
|
||||
? "Access failed"
|
||||
: failed
|
||||
? "Deploy-ready; runtime unhealthy"
|
||||
: incomplete
|
||||
? (branchSha && liveSha && branchSha !== liveSha ? "Deployable update available" : "Deploy-ready; runtime verification incomplete")
|
||||
: "Ready";
|
||||
return { readiness, ready: deployReady, deployReady, deploymentBlockers, 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 };
|
||||
@@ -0,0 +1,79 @@
|
||||
"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.localStatus?.remoteUrl, repository.sshUrl, repository.preferredCloneUrl, profile.cloneUrl].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 };
|
||||
}
|
||||
// A caller that just verified this candidate passes its proof in. Re-running
|
||||
// `git ls-remote` would open a second SSH connection to ask the same question,
|
||||
// with nothing in between that could change the answer.
|
||||
async preflightCandidate(context) { const proof = context?.proof?.remoteSha ? context.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 };
|
||||
@@ -0,0 +1,582 @@
|
||||
"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 deploymentRepositoryUrl = mode === "server-git"
|
||||
? this.serverGitRemote(repository, profile)
|
||||
: repository.localStatus?.remoteUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName;
|
||||
const metadata = this.metadataCompose(profile, repository, iconReference, {
|
||||
sha: targetSha,
|
||||
repositoryUrl: deploymentRepositoryUrl,
|
||||
});
|
||||
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 rollbackRepositoryUrl = rollbackMode === "server-git"
|
||||
? this.serverGitRemote(repository, profile)
|
||||
: repository.localStatus?.remoteUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName;
|
||||
const metadata = this.metadataCompose(profile, repository, iconReference, {
|
||||
sha: target,
|
||||
repositoryUrl: rollbackRepositoryUrl,
|
||||
});
|
||||
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 };
|
||||
@@ -0,0 +1,709 @@
|
||||
"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
|
||||
mapfile -t container_ids <<< "$ids"
|
||||
# Docker accepts multiple IDs and returns one JSON array. This avoids one
|
||||
# daemon round-trip per container on larger Unraid installations.
|
||||
if inspect=$(docker inspect "\${container_ids[@]}" 2>/dev/null); then
|
||||
printf 'C\\t%s\\n' "$(printf '%s' "$inspect" | base64 | tr -d '\\r\\n')"
|
||||
else
|
||||
# A container can disappear between docker ps and inspect. Fall back to
|
||||
# individual reads so the remaining inventory stays complete.
|
||||
for container_id in "\${container_ids[@]}"; do
|
||||
[ -n "$container_id" ] || continue
|
||||
if inspect=$(docker inspect "$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
|
||||
fi
|
||||
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
|
||||
has_override=false
|
||||
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
|
||||
has_override=true
|
||||
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=''; compose_error=''
|
||||
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)
|
||||
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
|
||||
if [ "$has_override" = true ] || [ -z "$images" ] || printf '%s' "$images" | grep -q '\$'; then images=$(cd "$dir" && docker compose "$@" config --images 2>/dev/null || true); fi
|
||||
else compose_error=$services; services=''; fi
|
||||
else
|
||||
if services=$(cd "$dir" && docker-compose "$@" config --services 2>&1); then
|
||||
valid=true
|
||||
if [ "$has_override" = true ] || [ -z "$images" ] || printf '%s' "$images" | grep -q '\$'; then images=$(cd "$dir" && docker-compose "$@" config --images 2>/dev/null || true); fi
|
||||
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
|
||||
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 configuredRoot = path.join(server.basePath, existingProfile.remoteFolder || "").replace(/\/+$/, "");
|
||||
const composeWorkingDir = String(workload.compose?.workingDir || "").replace(/\/+$/, "");
|
||||
const configuredRootOwnsCompose = Boolean(
|
||||
configuredRoot
|
||||
&& composeWorkingDir
|
||||
&& (composeWorkingDir === configuredRoot || composeWorkingDir.startsWith(`${configuredRoot}/`)),
|
||||
);
|
||||
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: configuredRootOwnsCompose
|
||||
? existingProfile.remoteFolder
|
||||
: workload.remoteFolderCandidate || existingProfile.remoteFolder,
|
||||
});
|
||||
const repositoryRelativeComposeFiles = configuredRootOwnsCompose
|
||||
? [...new Set((workload.compose?.configFiles || []).map((file) => {
|
||||
const value = String(file || "").trim().replace(/\\/g, "/");
|
||||
if (value.startsWith(`${configuredRoot}/`)) return value.slice(configuredRoot.length + 1);
|
||||
return value.startsWith("/") ? "" : value;
|
||||
}).filter(Boolean))]
|
||||
: [];
|
||||
const composeFiles = repositoryRelativeComposeFiles.length
|
||||
? repositoryRelativeComposeFiles
|
||||
: detected.composeFiles;
|
||||
return {
|
||||
...existingProfile,
|
||||
deploymentMode: detected.deploymentMode,
|
||||
remoteFolder: detected.remoteFolder,
|
||||
composeFile: composeFiles[0],
|
||||
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, { expectedGiteaSha = null, health = null } = {}) {
|
||||
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 verifiedGiteaSha = /^[0-9a-f]{40}$/i.test(String(expectedGiteaSha || ""))
|
||||
? String(expectedGiteaSha).toLowerCase()
|
||||
: null;
|
||||
const matchesGitea = Boolean(repositoryMatches && verifiedGiteaSha && observedLiveSha === verifiedGiteaSha);
|
||||
const primary = workload.containers.find((container) => container.running) || workload.containers[0] || {};
|
||||
const dockerHealthy = workload.runtime.health === "healthy" ? true : workload.runtime.health === "unhealthy" ? false : null;
|
||||
const effectiveHealthy = workload.runtime.running === false
|
||||
? false
|
||||
: health?.configured ? health.healthy : dockerHealthy;
|
||||
return this.store.saveDeploymentState(profile.id, {
|
||||
liveSha,
|
||||
healthy: effectiveHealthy,
|
||||
healthStatus: health?.status ?? null,
|
||||
healthLatencyMs: health?.latencyMs ?? null,
|
||||
runtimeVerification: workload.runtime.running === false ? "stopped" : health?.configured ? "desktop-healthcheck" : 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: verifiedGiteaSha,
|
||||
matchesGitea,
|
||||
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, { autoLink = false } = {}) {
|
||||
const started = Date.now();
|
||||
const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories);
|
||||
let adopted = 0;
|
||||
const adoptedLinks = [];
|
||||
if (autoLink) {
|
||||
const plan = this.reconciliationPlan(server, workloads, repositories, { autoLink: true });
|
||||
if (plan.additions.length) await this.store.createRecoverySnapshot?.(`automatic-server-links-${serverId}`);
|
||||
const linkedRepositories = new Set(workloads
|
||||
.filter((workload) => workload.classification?.type !== "stale-link" && workload.link?.repositoryFullName)
|
||||
.map((workload) => String(workload.link.repositoryFullName).toLowerCase()));
|
||||
for (const addition of plan.additions) {
|
||||
const workload = workloads.find((item) => item.workloadId === addition.workloadId);
|
||||
const repository = (repositories || []).find((item) => String(item.fullName).toLowerCase() === String(addition.repositoryFullName).toLowerCase());
|
||||
const key = String(repository?.fullName || "").toLowerCase();
|
||||
if (!workload || !repository || linkedRepositories.has(key)) continue;
|
||||
const linkSource = addition.evidence === "exact-provenance" ? "automatic" : "automatic-runtime-identity";
|
||||
const profile = this.profileFromWorkload(repository, server, workload, { linkSource, deploymentMode: "server-git" });
|
||||
const saved = await this.store.saveDeploymentProfile(repository.fullName, profile);
|
||||
await this.saveWorkloadState(saved, workload, server);
|
||||
workload.status = "linked";
|
||||
workload.link = { status: "linked", profileId: saved.id, repositoryFullName: repository.fullName, source: linkSource };
|
||||
linkedRepositories.add(key);
|
||||
adopted += 1;
|
||||
adoptedLinks.push({ repositoryFullName: repository.fullName, profileId: saved.id, workloadId: workload.workloadId });
|
||||
}
|
||||
}
|
||||
const response = this.inventoryResponse(server, inventory, workloads, { adopted });
|
||||
await this.diagnostics?.info("unraid.workloads.scanned", {
|
||||
serverId,
|
||||
detected: response.detected,
|
||||
linked: response.linked,
|
||||
needsReview: response.needsReview,
|
||||
adopted,
|
||||
adoptedLinks,
|
||||
readOnly: !autoLink,
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
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.classification?.type !== "stale-link" && 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",
|
||||
});
|
||||
linkedRepositories.add(String(candidate.repositoryFullName).toLowerCase());
|
||||
} 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) {
|
||||
const started = Date.now();
|
||||
const inventory = await this.scanServerInventory(serverId, repositories, { autoLink: true });
|
||||
const server = this.store.getServer(serverId);
|
||||
const queue = inventory.workloads.filter((workload) => workload.link?.profileId && workload.link?.repositoryFullName);
|
||||
const refreshedProfileIds = [];
|
||||
let giteaUnavailable = false;
|
||||
let giteaFailureReported = false;
|
||||
const workers = Array.from({ length: Math.min(5, queue.length) }, async () => {
|
||||
while (queue.length) {
|
||||
const workload = queue.shift();
|
||||
const repository = repositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link.repositoryFullName).toLowerCase());
|
||||
const profile = this.store.getDeploymentProfile?.(workload.link.repositoryFullName, workload.link.profileId)
|
||||
|| this.store.getDeploymentProfiles?.(workload.link.repositoryFullName)?.find((item) => item.id === workload.link.profileId)
|
||||
|| this.allSshProfiles().find((item) => item.id === workload.link.profileId);
|
||||
if (!repository || !profile) continue;
|
||||
let expectedGiteaSha = null;
|
||||
const status = repository.localStatus;
|
||||
if (status?.head && status.branch?.head === profile.branch && status.branch?.upstream && status.branch.ahead === 0 && status.branch.behind === 0) {
|
||||
expectedGiteaSha = status.head;
|
||||
} else if (!giteaUnavailable) {
|
||||
try {
|
||||
const [owner, repo] = String(repository.fullName).split("/");
|
||||
const branch = await this.gitea.getBranch(owner, repo, profile.branch);
|
||||
expectedGiteaSha = branch?.commit?.id || branch?.commit?.sha || null;
|
||||
} catch (error) {
|
||||
if (!error?.status || Number(error.status) >= 500) {
|
||||
giteaUnavailable = true;
|
||||
if (!giteaFailureReported) {
|
||||
giteaFailureReported = true;
|
||||
await this.diagnostics?.warning("unraid.workloads.gitea-verification-degraded", {
|
||||
serverId,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const health = workload.runtime.running
|
||||
? await this.checkHealth(profile.healthcheckUrl)
|
||||
: { configured: false, healthy: false, skipped: "container-stopped" };
|
||||
await this.saveWorkloadState(profile, workload, server, { expectedGiteaSha, health });
|
||||
refreshedProfileIds.push(profile.id);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
await this.diagnostics?.debug("unraid.workloads.states-refreshed", {
|
||||
serverId,
|
||||
profiles: refreshedProfileIds.length,
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
return { ...inventory, refreshedProfiles: refreshedProfileIds.length, refreshedProfileIds };
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -0,0 +1,622 @@
|
||||
"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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (deploymentMode === "server-git") {
|
||||
const [owner, repo] = String(repository.fullName || "").split("/");
|
||||
const deploymentFiles = profile.generatedCompose
|
||||
? ["Dockerfile"]
|
||||
: this.deploymentComposeFiles(profile);
|
||||
try {
|
||||
const existence = await Promise.all(deploymentFiles.map(async (filePath) => ({
|
||||
filePath,
|
||||
exists: await this.gitea.repositoryFileExists({ owner, repo, filePath, ref: targetSha }),
|
||||
})));
|
||||
const missing = existence.filter((item) => !item.exists).map((item) => item.filePath);
|
||||
checks.push({
|
||||
id: "gitea-deployment-files",
|
||||
label: profile.generatedCompose ? "Dockerfile at Gitea commit" : "Compose files at Gitea commit",
|
||||
status: missing.length ? "fail" : "pass",
|
||||
detail: missing.length
|
||||
? `Missing at exact commit ${targetSha.slice(0, 12)}: ${missing.join(", ")}.`
|
||||
: `${deploymentFiles.join(", ")} verified at exact commit ${targetSha.slice(0, 12)}.`,
|
||||
});
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: "gitea-deployment-files",
|
||||
label: "Deployment files at Gitea commit",
|
||||
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 };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,293 @@
|
||||
"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 containerRunning = fields.containerRunning === "true";
|
||||
const health = containerRunning
|
||||
? await this.checkHealth(profile.healthcheckUrl)
|
||||
: { configured: false, healthy: false, skipped: "container-stopped" };
|
||||
const dockerHealthy = fields.dockerHealth
|
||||
? fields.dockerHealth === "healthy"
|
||||
: null;
|
||||
const effectiveHealthy = !containerRunning ? false : health.configured ? health.healthy : dockerHealthy;
|
||||
const runtimeVerification = !containerRunning
|
||||
? "stopped"
|
||||
: health.configured
|
||||
? "desktop-healthcheck"
|
||||
: dockerHealthy === true
|
||||
? "docker-healthcheck"
|
||||
: dockerHealthy === false
|
||||
? "docker-unhealthy"
|
||||
: containerRunning
|
||||
? "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,
|
||||
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 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,
|
||||
),
|
||||
);
|
||||
const queue = [...active];
|
||||
const results = [];
|
||||
const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
|
||||
while (queue.length) {
|
||||
const operation = queue.shift();
|
||||
results.push(await this.refreshOperation(operation.id));
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
}
|
||||
return UnraidStateMethods.prototype;
|
||||
}
|
||||
|
||||
module.exports = { createUnraidStateMethods };
|
||||
@@ -14,6 +14,85 @@ function safeRepositoryPart(value, label) {
|
||||
return text;
|
||||
}
|
||||
|
||||
function verifyReleaseManifest({
|
||||
manifestBytes,
|
||||
signatureBytes,
|
||||
publicKey,
|
||||
update,
|
||||
assetName,
|
||||
}) {
|
||||
if (
|
||||
!Buffer.isBuffer(manifestBytes) ||
|
||||
manifestBytes.length < 100 ||
|
||||
manifestBytes.length > 1_000_000
|
||||
) {
|
||||
throw new Error("The signed release manifest has an invalid size.");
|
||||
}
|
||||
const signatureText = Buffer.from(signatureBytes || "")
|
||||
.toString("utf8")
|
||||
.trim();
|
||||
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(signatureText)) {
|
||||
throw new Error("The release manifest signature is invalid.");
|
||||
}
|
||||
const signature = Buffer.from(signatureText, "base64");
|
||||
if (signature.length !== 64) {
|
||||
throw new Error("The release manifest signature is invalid.");
|
||||
}
|
||||
let verified = false;
|
||||
try {
|
||||
verified = crypto.verify(null, manifestBytes, publicKey, signature);
|
||||
} catch {
|
||||
verified = false;
|
||||
}
|
||||
if (!verified) {
|
||||
const error = new Error(
|
||||
"The release manifest was not signed by the trusted ForgeFlow publisher key.",
|
||||
);
|
||||
error.code = "RELEASE_SIGNATURE_INVALID";
|
||||
throw error;
|
||||
}
|
||||
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(manifestBytes.toString("utf8"));
|
||||
} catch {
|
||||
throw new Error("The signed release manifest is not valid JSON.");
|
||||
}
|
||||
const expectedVersion = String(update.remoteVersion || "").trim();
|
||||
const expectedCommit = String(update.remoteSha || "").toLowerCase();
|
||||
if (
|
||||
manifest.schemaVersion !== 1 ||
|
||||
manifest.product !== "ForgeFlow" ||
|
||||
manifest.version !== expectedVersion ||
|
||||
manifest.tag !== `v${expectedVersion}` ||
|
||||
manifest.signature?.algorithm !== "Ed25519" ||
|
||||
(expectedCommit &&
|
||||
String(manifest.commit || "").toLowerCase() !== expectedCommit)
|
||||
) {
|
||||
const error = new Error(
|
||||
"The signed release manifest does not match the requested ForgeFlow update.",
|
||||
);
|
||||
error.code = "RELEASE_MANIFEST_MISMATCH";
|
||||
throw error;
|
||||
}
|
||||
const artifact = Array.isArray(manifest.artifacts)
|
||||
? manifest.artifacts.find((item) => item?.name === assetName)
|
||||
: null;
|
||||
if (
|
||||
!artifact ||
|
||||
!Number.isSafeInteger(artifact.bytes) ||
|
||||
artifact.bytes < 1_000_000 ||
|
||||
!/^[a-f0-9]{64}$/.test(String(artifact.sha256 || ""))
|
||||
) {
|
||||
const error = new Error(
|
||||
`The signed release manifest has no valid entry for ${assetName}.`,
|
||||
);
|
||||
error.code = "RELEASE_MANIFEST_INCOMPLETE";
|
||||
throw error;
|
||||
}
|
||||
return { manifest, artifact };
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -33,6 +112,18 @@ function resolveWindowsPowerShellPath(environment = process.env) {
|
||||
return "powershell.exe";
|
||||
}
|
||||
|
||||
function windowsUpdaterSpawnOptions(cwd) {
|
||||
return {
|
||||
// A detached hidden PowerShell child can exit successfully on Windows
|
||||
// without ever executing its -File script. Normal Windows children survive
|
||||
// their parent; unref() below releases the event-loop reference instead.
|
||||
detached: false,
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
cwd,
|
||||
};
|
||||
}
|
||||
|
||||
async function readJsonFile(filePath) {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8"));
|
||||
@@ -144,6 +235,7 @@ class UpdateService {
|
||||
powershellPath = null,
|
||||
handshakeTimeoutMs = 12000,
|
||||
handshakePollMs = 100,
|
||||
updatePublicKey = null,
|
||||
}) {
|
||||
this.store = store;
|
||||
this.gitea = gitea;
|
||||
@@ -156,6 +248,7 @@ class UpdateService {
|
||||
this.powershellPath = powershellPath;
|
||||
this.handshakeTimeoutMs = handshakeTimeoutMs;
|
||||
this.handshakePollMs = handshakePollMs;
|
||||
this.updatePublicKey = updatePublicKey;
|
||||
this.staged = null;
|
||||
}
|
||||
|
||||
@@ -279,7 +372,7 @@ class UpdateService {
|
||||
));
|
||||
if (!release || release.draft || release.prerelease) {
|
||||
const error = new Error(
|
||||
`ForgeFlow ${update.remoteVersion} has no published binary release yet. The source branch was updated, but the matching Windows installer/portable assets were not published. Run Publish-Missing-Binary-Release.ps1 from the release source or publish the four required assets in Gitea.`,
|
||||
`ForgeFlow ${update.remoteVersion} has no published binary release yet. The source branch was updated, but the matching signed Windows release was not published. Run Publish-Missing-Binary-Release.ps1 from the release source.`,
|
||||
);
|
||||
error.code = "BINARY_RELEASE_NOT_FOUND";
|
||||
throw error;
|
||||
@@ -288,33 +381,70 @@ class UpdateService {
|
||||
const portable = Boolean(this.appInfo.portableExecutablePath);
|
||||
const assetName = `ForgeFlow-${portable ? "Portable" : "Setup"}-${update.remoteVersion}-win-x64.exe`;
|
||||
const checksumName = `${assetName}.sha256`;
|
||||
const manifestName = `ForgeFlow-${update.remoteVersion}-release-manifest.json`;
|
||||
const signatureName = `${manifestName}.sig`;
|
||||
const assets = Array.isArray(release.assets) ? release.assets : [];
|
||||
const asset = assets.find((item) => item.name === assetName);
|
||||
const checksumAsset = assets.find((item) => item.name === checksumName);
|
||||
if (!asset?.id || !checksumAsset?.id) {
|
||||
const manifestAsset = assets.find((item) => item.name === manifestName);
|
||||
const signatureAsset = assets.find((item) => item.name === signatureName);
|
||||
if (
|
||||
!asset?.id ||
|
||||
!checksumAsset?.id ||
|
||||
!manifestAsset?.id ||
|
||||
!signatureAsset?.id
|
||||
) {
|
||||
const error = new Error(
|
||||
`Release v${update.remoteVersion} is missing ${assetName} or its SHA-256 file.`,
|
||||
`Release v${update.remoteVersion} is incomplete: the executable, SHA-256 file, signed manifest and signature are all required.`,
|
||||
);
|
||||
error.code = "BINARY_RELEASE_INCOMPLETE";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const [binary, checksumBytes] = await Promise.all([
|
||||
this.gitea.downloadReleaseAsset(
|
||||
update.owner,
|
||||
update.repo,
|
||||
release.id,
|
||||
asset.id,
|
||||
{ downloadUrl: asset.browser_download_url },
|
||||
),
|
||||
this.gitea.downloadReleaseAsset(
|
||||
update.owner,
|
||||
update.repo,
|
||||
release.id,
|
||||
checksumAsset.id,
|
||||
{ downloadUrl: checksumAsset.browser_download_url },
|
||||
),
|
||||
]);
|
||||
const [binary, checksumBytes, manifestBytes, signatureBytes] =
|
||||
await Promise.all([
|
||||
this.gitea.downloadReleaseAsset(
|
||||
update.owner,
|
||||
update.repo,
|
||||
release.id,
|
||||
asset.id,
|
||||
{ downloadUrl: asset.browser_download_url },
|
||||
),
|
||||
this.gitea.downloadReleaseAsset(
|
||||
update.owner,
|
||||
update.repo,
|
||||
release.id,
|
||||
checksumAsset.id,
|
||||
{ downloadUrl: checksumAsset.browser_download_url },
|
||||
),
|
||||
this.gitea.downloadReleaseAsset(
|
||||
update.owner,
|
||||
update.repo,
|
||||
release.id,
|
||||
manifestAsset.id,
|
||||
{ downloadUrl: manifestAsset.browser_download_url },
|
||||
),
|
||||
this.gitea.downloadReleaseAsset(
|
||||
update.owner,
|
||||
update.repo,
|
||||
release.id,
|
||||
signatureAsset.id,
|
||||
{ downloadUrl: signatureAsset.browser_download_url },
|
||||
),
|
||||
]);
|
||||
|
||||
const publicKey =
|
||||
this.updatePublicKey ||
|
||||
(await fs.readFile(
|
||||
path.join(this.sourcePath, "build", "update-signing-public.pem"),
|
||||
));
|
||||
const { manifest, artifact } = verifyReleaseManifest({
|
||||
manifestBytes,
|
||||
signatureBytes,
|
||||
publicKey,
|
||||
update,
|
||||
assetName,
|
||||
});
|
||||
if (binary.length < 1_000_000 || binary[0] !== 0x4d || binary[1] !== 0x5a) {
|
||||
const preview = binary.subarray(0, 200).toString("utf8").trim();
|
||||
const looksLikeMetadata =
|
||||
@@ -336,6 +466,16 @@ class UpdateService {
|
||||
?.toLowerCase();
|
||||
if (!/^[a-f0-9]{64}$/.test(expectedSha256 || ""))
|
||||
throw new Error("The release SHA-256 file is invalid.");
|
||||
if (expectedSha256 !== artifact.sha256) {
|
||||
throw new Error(
|
||||
"The release checksum does not match the signed publisher manifest.",
|
||||
);
|
||||
}
|
||||
if (binary.length !== artifact.bytes) {
|
||||
throw new Error(
|
||||
"The downloaded Windows update size does not match the signed publisher manifest.",
|
||||
);
|
||||
}
|
||||
const sha256 = crypto.createHash("sha256").update(binary).digest("hex");
|
||||
if (sha256 !== expectedSha256)
|
||||
throw new Error(
|
||||
@@ -356,6 +496,8 @@ class UpdateService {
|
||||
? this.appInfo.portableExecutablePath
|
||||
: this.appInfo.executablePath,
|
||||
releaseTag: release.tag_name,
|
||||
publisherKeyId: manifest.signature.keyId,
|
||||
releaseManifest: manifestName,
|
||||
downloadedAt: new Date().toISOString(),
|
||||
downloaded: true,
|
||||
};
|
||||
@@ -371,6 +513,7 @@ class UpdateService {
|
||||
bytes: binary.length,
|
||||
sha256,
|
||||
portable,
|
||||
publisherKeyId: manifest.signature.keyId,
|
||||
});
|
||||
return metadata;
|
||||
}
|
||||
@@ -450,12 +593,11 @@ class UpdateService {
|
||||
const childState = { exited: false, code: null, error: null };
|
||||
let child;
|
||||
try {
|
||||
child = this.spawnProcess(executable, args, {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
cwd: this.sourcePath,
|
||||
});
|
||||
child = this.spawnProcess(
|
||||
executable,
|
||||
args,
|
||||
windowsUpdaterSpawnOptions(this.sourcePath),
|
||||
);
|
||||
} catch (error) {
|
||||
error.code ||= "UPDATE_HELPER_SPAWN_FAILED";
|
||||
throw error;
|
||||
@@ -488,7 +630,9 @@ class UpdateService {
|
||||
5000,
|
||||
);
|
||||
child.once?.("spawn", () => finish(resolve));
|
||||
child.once?.("error", (error) => finish(reject, error));
|
||||
// Kept attached rather than `once`: a process that fails to start can
|
||||
// report a second error, and an unhandled 'error' event ends this process.
|
||||
child.on?.("error", (error) => finish(reject, error));
|
||||
if (!child.once) finish(resolve);
|
||||
});
|
||||
|
||||
@@ -591,12 +735,11 @@ class UpdateService {
|
||||
"-UpdateId",
|
||||
updateId,
|
||||
];
|
||||
const child = this.spawnProcess(executable, args, {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
cwd: this.updateDirectory,
|
||||
});
|
||||
const child = this.spawnProcess(
|
||||
executable,
|
||||
args,
|
||||
windowsUpdaterSpawnOptions(this.updateDirectory),
|
||||
);
|
||||
const childState = { exited: false, code: null, error: null };
|
||||
child.once?.("error", (error) => {
|
||||
childState.error = error;
|
||||
@@ -620,7 +763,9 @@ class UpdateService {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
child.once?.("error", (error) => {
|
||||
// Kept attached rather than `once`: a second error would otherwise have no
|
||||
// listener left, and an unhandled 'error' event ends this process.
|
||||
child.on?.("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
@@ -699,7 +844,9 @@ class UpdateService {
|
||||
module.exports = {
|
||||
UpdateService,
|
||||
safeRepositoryPart,
|
||||
verifyReleaseManifest,
|
||||
resolveWindowsPowerShellPath,
|
||||
windowsUpdaterSpawnOptions,
|
||||
waitForUpdaterStarted,
|
||||
readJsonFile,
|
||||
readLogTail,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
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") {
|
||||
if (ui.modal?.type === "workspace-sync") ui.workspaceSyncPlan = null;
|
||||
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 blockers = result.deploymentBlockers || [];
|
||||
const warnings = result.checks.filter((check) => check.status !== "pass" && !blockers.some((blocker) => blocker.id === check.id));
|
||||
showToast(
|
||||
result.readiness,
|
||||
blockers[0]?.detail || warnings[0]?.detail || `Verified ${result.checks.length} server-pull checks without changing the server.`,
|
||||
blockers.length ? "error" : warnings.length ? "warning" : "success",
|
||||
);
|
||||
} 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
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 === "preview-workspace-sync") {
|
||||
if (!repository?.localPath) return;
|
||||
setLoading(true, "Fetching Gitea and building a safe synchronization plan…");
|
||||
try {
|
||||
ui.workspaceSyncPlan = await window.forgeflow.previewWorkspaceSync(
|
||||
repository.localPath,
|
||||
);
|
||||
ui.modal = { type: "workspace-sync" };
|
||||
showToast(
|
||||
ui.workspaceSyncPlan.needsSync
|
||||
? "Workspace sync preview ready"
|
||||
: "Workspace already synchronized",
|
||||
ui.workspaceSyncPlan.needsSync
|
||||
? `${ui.workspaceSyncPlan.summary.resultingTrackedChanges} tracked change(s) and ${ui.workspaceSyncPlan.summary.localFilesToStash} local file(s) reviewed.`
|
||||
: `Local ${ui.workspaceSyncPlan.branch} already matches ${ui.workspaceSyncPlan.upstream}.`,
|
||||
ui.workspaceSyncPlan.blockers?.length ? "error" : "success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Could not preview Gitea sync", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
render();
|
||||
} else if (action === "confirm-workspace-sync") {
|
||||
if (!repository?.localPath || !ui.workspaceSyncPlan) return;
|
||||
const expectedPlanId = target.dataset.planId;
|
||||
setLoading(true, "Protecting local work and synchronizing exact Gitea state…");
|
||||
try {
|
||||
const result = await window.forgeflow.applyWorkspaceSync(
|
||||
repository.localPath,
|
||||
expectedPlanId,
|
||||
);
|
||||
ui.modal = null;
|
||||
ui.workspaceSyncPlan = null;
|
||||
await refreshRepositories(false);
|
||||
[ui.branches, ui.stashes] = await Promise.all([
|
||||
window.forgeflow.branches(repository.localPath),
|
||||
window.forgeflow.stashList(repository.localPath),
|
||||
]);
|
||||
const recovery = [
|
||||
result.backupBranch ? `recovery branch ${result.backupBranch}` : null,
|
||||
result.stash ? `stash ${result.stash.ref}` : null,
|
||||
].filter(Boolean).join(" and ");
|
||||
showToast(
|
||||
"Workspace synchronized with Gitea",
|
||||
recovery
|
||||
? `Local work is preserved in ${recovery}. Ignored runtime files were retained.`
|
||||
: `Tracked files now match ${result.plan.upstream}; ignored runtime files were retained.`,
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.code === "WORKSPACE_SYNC_PLAN_STALE") {
|
||||
try {
|
||||
ui.workspaceSyncPlan = await window.forgeflow.previewWorkspaceSync(
|
||||
repository.localPath,
|
||||
);
|
||||
ui.modal = { type: "workspace-sync" };
|
||||
} catch {
|
||||
ui.modal = null;
|
||||
ui.workspaceSyncPlan = null;
|
||||
}
|
||||
}
|
||||
showToast("Workspace synchronization stopped", 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;
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
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 {
|
||||
let result = await window.forgeflow.testServer(target.dataset.serverId);
|
||||
if (result.needsTrust) {
|
||||
const approved = confirm(
|
||||
`Verify this fingerprint on the SSH server before trusting it:\n\n${result.fingerprint}\n\nServer: ${result.server.host}:${result.server.port}\n\nTrust this exact host identity and continue with authentication?`,
|
||||
);
|
||||
if (!approved) {
|
||||
showToast("SSH trust cancelled", "No credentials were sent and the host identity was not saved.", "info");
|
||||
setLoading(false);
|
||||
return true;
|
||||
}
|
||||
result = await window.forgeflow.testServer(target.dataset.serverId, result.fingerprint);
|
||||
}
|
||||
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,
|
||||
),
|
||||
fetchIntervalMinutes: Number(
|
||||
document.querySelector("#pref-fetch-interval").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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
// These three sections used to be pushed into the DOM after render() had already
|
||||
// written the shell. Keeping them in the markup makes the rendered output the
|
||||
// single source of truth, so an unchanged render can be skipped safely.
|
||||
function renderDeploymentPolicyFields(policy) {
|
||||
const windows = (policy.maintenanceWindows || [])
|
||||
.map((window) => `${window.days.join(",")}:${window.start}-${window.end}`)
|
||||
.join(" | ");
|
||||
return `<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(windows)}" placeholder="1,2,3,4,5:09:00-17:00"/><small>Day 0 is Sunday. Separate windows with |.</small></div>`;
|
||||
}
|
||||
|
||||
function renderReleaseNoteFields(profile) {
|
||||
return `<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>`;
|
||||
}
|
||||
|
||||
function renderWorkloadClassificationFields(workload) {
|
||||
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("");
|
||||
return `<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>`;
|
||||
}
|
||||
|
||||
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 === "workspace-sync") {
|
||||
const plan = ui.workspaceSyncPlan;
|
||||
if (!plan) return "";
|
||||
const summary = plan.summary || {};
|
||||
const blocked = Boolean(plan.blockers?.length);
|
||||
const changeRows = (plan.changes || []).map((change) => `<div class="tool-row"><div><strong>${escapeHtml(change.path)}</strong><span>${change.originalPath ? `${escapeHtml(change.originalPath)} → ` : ""}${escapeHtml(change.status)}</span></div><span class="status-pill ${change.code === "D" ? "danger" : change.code === "A" ? "success" : "warning"}">${escapeHtml(change.code)}</span></div>`).join("");
|
||||
const recoveryRows = [
|
||||
plan.recovery?.safetyBranch ? "Local commits → recovery branch" : "No local commits require a recovery branch",
|
||||
plan.recovery?.stash ? "Modified and untracked files → named Git stash" : "No working-tree files require a stash",
|
||||
plan.recovery?.untrackedCleanup ? "Untracked files are removed after they are stashed" : "No untracked cleanup required",
|
||||
"Ignored runtime files remain in place",
|
||||
];
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true" aria-labelledby="workspace-sync-title"><header class="modal-header"><h2 id="workspace-sync-title">Review Gitea workspace sync</h2><button class="icon-button" data-action="close-modal" aria-label="Close workspace sync preview">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero ${blocked ? "danger" : plan.needsSync ? "" : "success"}">${icon(blocked ? "error" : "shield")}<div><strong>${blocked ? "Synchronization is blocked" : plan.needsSync ? `${escapeHtml(plan.branch)} will match ${escapeHtml(plan.upstream)}` : "Workspace already matches Gitea"}</strong><span>${shortSha(plan.currentSha)} → ${shortSha(plan.targetSha)} · reviewed plan ${escapeHtml(plan.id.slice(0, 12))}</span></div></div>${blocked ? `<div class="notice danger" style="margin-top:12px">${icon("error")}<div><strong>Resolve before applying</strong><p>${escapeHtml(plan.blockers.join(" "))}</p></div></div>` : ""}<div class="summary-grid" style="margin-top:12px"><div class="summary-card"><span>Incoming commits</span><strong>${Number(summary.incomingCommits || 0)}</strong></div><div class="summary-card"><span>Tracked file changes</span><strong>${Number(summary.resultingTrackedChanges || 0)}</strong></div><div class="summary-card"><span>Files removed by sync</span><strong>${Number(summary.deleted || 0)}</strong></div><div class="summary-card"><span>Local files protected</span><strong>${Number(summary.localFilesToStash || 0)}</strong></div><div class="summary-card"><span>Local commits protected</span><strong>${Number(summary.localCommitsToProtect || 0)}</strong></div></div><section class="settings-group" style="margin-top:14px"><h3>Recovery contract</h3><ul>${recoveryRows.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul><div class="notice success">${icon("archive")}ForgeFlow never reapplies saved local work automatically. You can review the recovery branch or stash later, file by file.</div></section><section class="settings-group"><div class="section-heading"><div><h3>Resulting tracked changes</h3><span class="meta">${summary.added || 0} added · ${summary.modified || 0} modified · ${summary.deleted || 0} deleted · ${summary.renamed || 0} renamed</span></div></div><div class="tool-list">${changeRows || '<div class="empty-state compact"><p>No tracked file changes between local HEAD and Gitea.</p></div>'}</div>${plan.changesTruncated ? '<p class="meta">Only the first 250 paths are shown. Counts include the complete plan.</p>' : ""}</section></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="confirm-workspace-sync" data-plan-id="${attr(plan.id)}" ${blocked || !plan.needsSync ? "disabled" : ""}>Protect local work & synchronize</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>${renderWorkloadClassificationFields(workload)}</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>${renderDeploymentPolicyFields(storedProfile.deploymentPolicy || {})}</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>` : ""}${renderReleaseNoteFields(profile)}</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() {
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
// A render replaces the complete application shell. Without this, a background
|
||||
// repository poll or deployment poll destroys the element the user is typing in,
|
||||
// discarding the caret position and every scroll offset on screen.
|
||||
function elementRenderPath(element) {
|
||||
const parts = [];
|
||||
let node = element;
|
||||
while (node && node !== app) {
|
||||
const parent = node.parentElement;
|
||||
if (!parent) return null;
|
||||
parts.push(`${node.tagName}.${Array.prototype.indexOf.call(parent.children, node)}`);
|
||||
node = parent;
|
||||
}
|
||||
return node === app ? parts.reverse().join(">") : null;
|
||||
}
|
||||
|
||||
function elementAtRenderPath(renderPath) {
|
||||
let node = app;
|
||||
for (const part of renderPath.split(">")) {
|
||||
const separator = part.lastIndexOf(".");
|
||||
node = node?.children?.[Number(part.slice(separator + 1))];
|
||||
// The shell can be structurally different after a view change, in which case
|
||||
// the old offset belongs to an unrelated element and must be dropped.
|
||||
if (!node || node.tagName !== part.slice(0, separator)) return null;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
// enhanceRenderedUi() re-injects these controls empty on every render, so a
|
||||
// background refresh would otherwise discard a release note or review reason
|
||||
// while the user is still writing it.
|
||||
const INJECTED_FIELD_IDS = [
|
||||
"deployment-note",
|
||||
"deployment-override",
|
||||
"deployment-override-reason",
|
||||
"inventory-review-action",
|
||||
"inventory-review-reason",
|
||||
"profile-policy-frozen",
|
||||
"profile-policy-note",
|
||||
"profile-policy-freeze-reason",
|
||||
"profile-policy-windows",
|
||||
];
|
||||
|
||||
function captureInjectedFieldValues() {
|
||||
const values = [];
|
||||
for (const id of INJECTED_FIELD_IDS) {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) continue;
|
||||
if (element.type === "checkbox") values.push({ id, checked: element.checked });
|
||||
else if (element.value) values.push({ id, value: element.value });
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function restoreInjectedFieldValues(values) {
|
||||
for (const entry of values) {
|
||||
const element = document.getElementById(entry.id);
|
||||
if (!element) continue;
|
||||
// Never overwrite a value the freshly rendered control already carries; only
|
||||
// fill back in what the injection left empty.
|
||||
if ("checked" in entry) {
|
||||
if (!element.checked) element.checked = entry.checked;
|
||||
} else if (!element.value) element.value = entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
function captureInteractionState() {
|
||||
const scroll = [];
|
||||
for (const element of app.querySelectorAll("*")) {
|
||||
if (!element.scrollTop && !element.scrollLeft) continue;
|
||||
const renderPath = elementRenderPath(element);
|
||||
if (renderPath) scroll.push({ renderPath, top: element.scrollTop, left: element.scrollLeft });
|
||||
}
|
||||
const injectedFields = captureInjectedFieldValues();
|
||||
const active = document.activeElement;
|
||||
if (!active?.id || !app.contains(active)) return { scroll, injectedFields, focus: null };
|
||||
const focus = { id: active.id, start: null, end: null, direction: "none" };
|
||||
try {
|
||||
focus.start = active.selectionStart;
|
||||
focus.end = active.selectionEnd;
|
||||
focus.direction = active.selectionDirection || "none";
|
||||
} catch {}
|
||||
return { scroll, injectedFields, focus };
|
||||
}
|
||||
|
||||
function restoreInteractionState(state) {
|
||||
restoreInjectedFieldValues(state.injectedFields);
|
||||
for (const entry of state.scroll) {
|
||||
const element = elementAtRenderPath(entry.renderPath);
|
||||
if (!element) continue;
|
||||
element.scrollTop = entry.top;
|
||||
element.scrollLeft = entry.left;
|
||||
}
|
||||
if (!state.focus) return;
|
||||
const element = document.getElementById(state.focus.id);
|
||||
if (!element || !app.contains(element)) return;
|
||||
element.focus({ preventScroll: true });
|
||||
if (state.focus.start === null) return;
|
||||
try {
|
||||
element.setSelectionRange(state.focus.start, state.focus.end, state.focus.direction);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
let lastRenderedMarkup = null;
|
||||
|
||||
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;
|
||||
const markup = `<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()}`;
|
||||
// Most renders are triggered by a poll that found nothing new. Rebuilding an
|
||||
// identical shell would only cost layout work and interrupt the user. The
|
||||
// markup is the complete rendered state, so comparing it is sufficient:
|
||||
// enhanceRenderedUi() only derives labels and ids from what is already there.
|
||||
if (markup === lastRenderedMarkup) return;
|
||||
const interaction = captureInteractionState();
|
||||
app.innerHTML = markup;
|
||||
enhanceRenderedUi();
|
||||
restoreInteractionState(interaction);
|
||||
lastRenderedMarkup = markup;
|
||||
if (ui.modal?.type === "command-palette")
|
||||
requestAnimationFrame(() =>
|
||||
document.querySelector("#palette-input")?.focus(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Rendering a unified diff is a self-contained concern with its own size
|
||||
// limits, kept out of views.js so that file stays within the project's
|
||||
// architecture budget.
|
||||
// A regenerated lock file runs into tens of thousands of lines, and one element
|
||||
// per line freezes the window. Only the rendered view is capped.
|
||||
const DIFF_RENDER_LINE_LIMIT = 2000;
|
||||
|
||||
function diffAtmosphere(diff, allLines = null) {
|
||||
if (!ui.selectedFile) return "";
|
||||
const lines = allLines || 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 diffLineType(line) {
|
||||
if (line.startsWith("+") && !line.startsWith("+++")) return "add";
|
||||
if (line.startsWith("-") && !line.startsWith("---")) return "remove";
|
||||
return line.startsWith("@@") ? "hunk" : "";
|
||||
}
|
||||
|
||||
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 lines = String(diff).split("\n");
|
||||
const rendered = lines
|
||||
.slice(0, DIFF_RENDER_LINE_LIMIT)
|
||||
.map((line) => `<span class="diff-line ${diffLineType(line)}">${escapeHtml(line) || " "}</span>`)
|
||||
.join("");
|
||||
const hidden = Math.max(0, lines.length - DIFF_RENDER_LINE_LIMIT);
|
||||
const notice = hidden ? `<span class="diff-line hunk">… ${hidden.toLocaleString()} more line${hidden === 1 ? "" : "s"} are not shown. Copy diff and the editor still give you the complete change.</span>` : "";
|
||||
return `${rendered}${notice}${diffAtmosphere(diff, lines)}`;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -9,7 +9,7 @@
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" aria-live="polite">
|
||||
<div id="app">
|
||||
<div class="boot-screen">
|
||||
<img class="boot-brand-logo" src="./assets/itworx-mark.png" alt="ITWorx.tech"/>
|
||||
<strong>Starting ForgeFlow</strong>
|
||||
@@ -17,7 +17,21 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="toast-root" class="toast-root" aria-live="assertive"></div>
|
||||
<script src="mock-bridge.js"></script>
|
||||
<script defer src="mock-repository-bridge.js"></script>
|
||||
<script defer src="mock-deployment-bridge.js"></script>
|
||||
<script defer src="mock-bridge.js"></script>
|
||||
<script defer src="app.js"></script>
|
||||
<script defer src="diff-view.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>
|
||||
</html>
|
||||
|
||||