Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e882656e85 | ||
|
|
d47c7b5e41 | ||
|
|
cb9bdcd713 | ||
|
|
beeafdcba7 | ||
|
|
d77643c058 | ||
|
|
5cecaa080d | ||
|
|
a5666e95f2 | ||
|
|
9260d35957 | ||
|
|
cf1da8a2fa | ||
|
|
32ed4fcb5e | ||
|
|
38e221cbd1 | ||
|
|
bffad670ef | ||
|
|
4c21616e72 | ||
|
|
f866b12fbf | ||
|
|
f7d6bc374f |
@@ -6,6 +6,16 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
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:
|
quality:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -75,7 +75,11 @@ try {
|
|||||||
"ForgeFlow-Setup-$version-win-x64.exe",
|
"ForgeFlow-Setup-$version-win-x64.exe",
|
||||||
"ForgeFlow-Setup-$version-win-x64.exe.sha256",
|
"ForgeFlow-Setup-$version-win-x64.exe.sha256",
|
||||||
"ForgeFlow-Portable-$version-win-x64.exe",
|
"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) {
|
foreach ($assetName in $expectedAssets) {
|
||||||
if (-not (Test-Path -LiteralPath (Join-Path $clone "dist\$assetName"))) {
|
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",
|
||||||
"ForgeFlow-Setup-$version-win-x64.exe.sha256",
|
"ForgeFlow-Setup-$version-win-x64.exe.sha256",
|
||||||
"ForgeFlow-Portable-$version-win-x64.exe",
|
"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) {
|
foreach ($assetName in $expectedAssets) {
|
||||||
$assetPath = Join-Path $clone "dist\$assetName"
|
$assetPath = Join-Path $clone "dist\$assetName"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
ForgeFlow is een Windows-desktopapp voor wie Git, Gitea en eigen Docker- of Unraid-servers gebruikt. Je ziet in één werkruimte wat lokaal gewijzigd is, wat op Gitea staat en welke exacte commit op de server draait. ForgeFlow begeleidt je daarna veilig door review, commit, push, deployment en verificatie.
|
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.6** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest)
|
> Huidige release: **0.10.14** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -12,10 +12,12 @@ ForgeFlow is een Windows-desktopapp voor wie Git, Gitea en eigen Docker- of Unra
|
|||||||
|
|
||||||
- **Eén duidelijke actielijst:** zie meteen welke repository aandacht nodig heeft en waarom.
|
- **Eén duidelijke actielijst:** zie meteen welke repository aandacht nodig heeft en waarom.
|
||||||
- **Veilige Git-flow:** review wijzigingen, stage volledige bestanden of afzonderlijke hunks, commit, push en herstel conflicten zonder contextwissel.
|
- **Veilige Git-flow:** review wijzigingen, stage volledige bestanden of afzonderlijke hunks, commit, push en herstel conflicten zonder contextwissel.
|
||||||
|
- **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.
|
- **Deployment op een exacte commit:** ForgeFlow gebruikt volledige commit-SHA's en toont lokaal, Gitea en server naast elkaar.
|
||||||
- **Automatische serverinventaris:** ForgeFlow herkent draaiende en gestopte Docker-, Compose- en DockerMan-workloads, koppelt alleen op betrouwbaar bewijs en houdt tijdelijke of externe containers apart.
|
- **Automatische serverinventaris:** ForgeFlow herkent draaiende en gestopte Docker-, Compose- en DockerMan-workloads, koppelt alleen op betrouwbaar bewijs en houdt tijdelijke of externe containers apart.
|
||||||
- **Veilige server-pull:** Unraid haalt de exacte commit uit Gitea met een unieke, repository-scoped read-only deploy key en een vastgepinde SSH-hostsleutel.
|
- **Veilige server-pull:** Unraid haalt de exacte commit uit Gitea met een unieke, repository-scoped read-only deploy key en een vastgepinde SSH-hostsleutel.
|
||||||
- **Ingebouwde Git Validator:** controleer repository-identiteit, branch protection, synchronisatie-instellingen, documentatie, geheimen en grote bestanden; veilige verbeteringen kunnen gericht worden toegepast.
|
- **Ingebouwde Git Validator:** controleer repository-identiteit, branch protection, synchronisatie-instellingen, documentatie, geheimen en grote bestanden; veilige verbeteringen kunnen gericht worden toegepast.
|
||||||
|
- **Doorzoekbaar Helpcentrum:** open **Help** voor stapsgewijze uitleg of spring vanuit workspace sync meteen naar de relevante veiligheidsinstructies.
|
||||||
- **Lokale controle:** configuratie en credentials blijven op het toestel en diagnostische exports worden lokaal geredigeerd.
|
- **Lokale controle:** configuratie en credentials blijven op het toestel en diagnostische exports worden lokaal geredigeerd.
|
||||||
|
|
||||||
## Snel starten
|
## Snel starten
|
||||||
@@ -28,7 +30,7 @@ ForgeFlow is een Windows-desktopapp voor wie Git, Gitea en eigen Docker- of Unra
|
|||||||
4. Voeg je Gitea-server, token en lokale projectmappen toe.
|
4. Voeg je Gitea-server, token en lokale projectmappen toe.
|
||||||
5. Voeg optioneel een Docker- of Unraid-server toe. Start daarna **Scan servers** om bestaande deployments te ontdekken en veilig aan repositories te koppelen.
|
5. Voeg optioneel een Docker- of Unraid-server toe. Start daarna **Scan servers** om bestaande deployments te ontdekken en veilig aan repositories te koppelen.
|
||||||
|
|
||||||
Vanaf versie 0.10.2 kun je nieuwe packaged releases vanuit **Settings → Updates** ophalen. ForgeFlow accepteert uitsluitend de release die bij de exacte Gitea-commit hoort en controleert de download tegen de gepubliceerde SHA-256-checksum. Zie [UPDATING.md](docs/UPDATING.md) voor oudere of source-only installaties.
|
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
|
### Eerst vrijblijvend bekijken
|
||||||
|
|
||||||
@@ -39,7 +41,7 @@ npm install
|
|||||||
npm run demo
|
npm run demo
|
||||||
```
|
```
|
||||||
|
|
||||||
Open daarna `http://127.0.0.1:4173`.
|
Open daarna `http://127.0.0.1:41737`.
|
||||||
|
|
||||||
## De dagelijkse workflow
|
## De dagelijkse workflow
|
||||||
|
|
||||||
@@ -94,6 +96,7 @@ Een gelijke commit bewijst welke code draait; een geslaagde healthcheck bewijst
|
|||||||
|
|
||||||
- repositories ontdekken, favorieten beheren en ontbrekende lokale clones koppelen;
|
- repositories ontdekken, favorieten beheren en ontbrekende lokale clones koppelen;
|
||||||
- status, diff, staging, partial hunks, commit, push, fetch, pull, stash en conflict recovery;
|
- 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;
|
- branches maken, wisselen, vergelijken en opruimen;
|
||||||
- branch protection controleren en pull requests openen;
|
- branch protection controleren en pull requests openen;
|
||||||
- Git Validator met assurance score, bewijs per controle en gerichte veilige fixes.
|
- Git Validator met assurance score, bewijs per controle en gerichte veilige fixes.
|
||||||
@@ -114,7 +117,7 @@ Een gelijke commit bewijst welke code draait; een geslaagde healthcheck bewijst
|
|||||||
|
|
||||||
- credentials versleuteld via de beveiligde opslag van het besturingssysteem;
|
- credentials versleuteld via de beveiligde opslag van het besturingssysteem;
|
||||||
- origin-checks voorkomen dat een Gitea-token naar een andere host wordt gestuurd;
|
- 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;
|
- lokale redactie van tokens, wachtwoorden en gevoelige diagnostische data;
|
||||||
- versleutelde configuratieback-up, herstelvoorbeeld en lokale audittrail;
|
- versleutelde configuratieback-up, herstelvoorbeeld en lokale audittrail;
|
||||||
- packaged builds als Windows-installer en portable executable.
|
- packaged builds als Windows-installer en portable executable.
|
||||||
@@ -156,7 +159,8 @@ Handige opdrachten:
|
|||||||
| `npm run check` | Voert bronverificatie en de volledige testset uit. |
|
| `npm run check` | Voert bronverificatie en de volledige testset uit. |
|
||||||
| `npm run doctor` | Controleert de lokale ontwikkelomgeving. |
|
| `npm run doctor` | Controleert de lokale ontwikkelomgeving. |
|
||||||
| `npm run acceptance` | Voert de release-acceptatiecontroles uit. |
|
| `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-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. |
|
| `.\Publish-Missing-Binary-Release.ps1` | Herstelt een reeds gepushte versie waarvoor de Gitea binary release ontbreekt. |
|
||||||
|
|
||||||
|
|||||||
+90
-70
@@ -1,7 +1,7 @@
|
|||||||
ForgeFlow 0.10.6 source manifest
|
ForgeFlow 0.10.14 source manifest
|
||||||
SHA-256 BYTES PATH
|
SHA-256 BYTES PATH
|
||||||
(The manifest excludes itself, dependencies and generated release artifacts.)
|
(The manifest excludes itself, dependencies and generated release artifacts.)
|
||||||
cedceb71eb846d99c7c4019031833c1c7f93b84a1c6073aec7d2435dc744ca3d 703 .gitea/workflows/quality.yml
|
61f37822ae5502219a38b2eaf23fdcb611875f0e675efb4abe6157c9f072c0cc 937 .gitea/workflows/quality.yml
|
||||||
4a9e8a955ad8c9fa7ba3f8f89cf9920ac1d28c6e5b344782e12d02c3b0fab1ee 105 .gitignore
|
4a9e8a955ad8c9fa7ba3f8f89cf9920ac1d28c6e5b344782e12d02c3b0fab1ee 105 .gitignore
|
||||||
f14b4987904bcb5814e4459a057ed4d20f58a633152288a761214dcd28780b56 3 .nvmrc
|
f14b4987904bcb5814e4459a057ed4d20f58a633152288a761214dcd28780b56 3 .nvmrc
|
||||||
d0b1bd421359311871224f9fa1cff5a802000933668017d9e42e5190f8d2d8e5 152 .playwright-mcp/page-2026-07-29T17-41-03-014Z.yml
|
d0b1bd421359311871224f9fa1cff5a802000933668017d9e42e5190f8d2d8e5 152 .playwright-mcp/page-2026-07-29T17-41-03-014Z.yml
|
||||||
@@ -19,11 +19,12 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263
|
|||||||
4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183 3364 build/icon-64.png
|
4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183 3364 build/icon-64.png
|
||||||
25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico
|
25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico
|
||||||
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png
|
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png
|
||||||
|
164c059453a5737110b4e5e98b6211650c757f0aff710f8f7523ffe0ff1815d7 113 build/update-signing-public.pem
|
||||||
5f4aca19a35cbcaffa1a6993ce96b7d66052ec2b286022f2af74594e8a310568 15712 CHANGELOG.md
|
5f4aca19a35cbcaffa1a6993ce96b7d66052ec2b286022f2af74594e8a310568 15712 CHANGELOG.md
|
||||||
c612fcc44ff222db0c9a4cfd11a4076fafe080e4ada31e689a08739a4f14e74f 1650 docs/ACCEPTANCE.md
|
c612fcc44ff222db0c9a4cfd11a4076fafe080e4ada31e689a08739a4f14e74f 1650 docs/ACCEPTANCE.md
|
||||||
a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md
|
a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md
|
||||||
e05458ee2696e3c57e2475bb42ae1f914f6a36e01768d7a26a3199f1fffed490 1157 docs/COVERAGE_POLICY.md
|
e05458ee2696e3c57e2475bb42ae1f914f6a36e01768d7a26a3199f1fffed490 1157 docs/COVERAGE_POLICY.md
|
||||||
b9e39748ff125031be0ee8a963ef0d457c342f7998113fc2dd042ec237ae32ad 3084 docs/CURRENT_STATE.md
|
9eb9eec82518c0bfc7686f5faaf690a93ed63c71d35f1dc5a2c5ec5199da652a 3124 docs/CURRENT_STATE.md
|
||||||
8ea655d1912ac2e17f8834e33a566a8b14461b396ec4268c396ca189a1749b94 2205 docs/DEPENDENCY_AUDIT.md
|
8ea655d1912ac2e17f8834e33a566a8b14461b396ec4268c396ca189a1749b94 2205 docs/DEPENDENCY_AUDIT.md
|
||||||
30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md
|
30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md
|
||||||
eb42f979666e05d51c587e4223282914926a2b9b1ade9f3fb75525019ce7f738 4616 docs/DIAGNOSTICS.md
|
eb42f979666e05d51c587e4223282914926a2b9b1ade9f3fb75525019ce7f738 4616 docs/DIAGNOSTICS.md
|
||||||
@@ -34,11 +35,19 @@ a0cd06a96f23a94e118feb012be0fa1ac51345951cb2ba8e67fb8c889c4c342a 5007
|
|||||||
f79908fb3dad98c38030c6e6be7c79a1999e0478ed9c2496923891954438daa1 4581 docs/RELEASE_AUDIT_0.6.0.md
|
f79908fb3dad98c38030c6e6be7c79a1999e0478ed9c2496923891954438daa1 4581 docs/RELEASE_AUDIT_0.6.0.md
|
||||||
979a0b8e129979be6b265e8571d0a3c1e9ddd4ddb6b0bf55ae748d3478e51854 2296 docs/RELEASE_NOTES_0.10.0.md
|
979a0b8e129979be6b265e8571d0a3c1e9ddd4ddb6b0bf55ae748d3478e51854 2296 docs/RELEASE_NOTES_0.10.0.md
|
||||||
0eb44bda2209a5979a6ac693ac4cd4d235c0015031e54b9e895990f37bf60054 1433 docs/RELEASE_NOTES_0.10.1.md
|
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
|
||||||
|
09eaf3f7671fbc5c25d5ca444415616ccf6dd8e6351855291d392b0aeac20bce 1556 docs/RELEASE_NOTES_0.10.14.md
|
||||||
8d713471a437a8a55b00d7e1dd95290680862107bc4e586cf27d727f6274e46c 577 docs/RELEASE_NOTES_0.10.2.md
|
8d713471a437a8a55b00d7e1dd95290680862107bc4e586cf27d727f6274e46c 577 docs/RELEASE_NOTES_0.10.2.md
|
||||||
0942fb2c4a4f972296423b5232687f7389e2c6417a9d48a3244beef9dec907b9 1164 docs/RELEASE_NOTES_0.10.3.md
|
0942fb2c4a4f972296423b5232687f7389e2c6417a9d48a3244beef9dec907b9 1164 docs/RELEASE_NOTES_0.10.3.md
|
||||||
8f4a0fe6dc250ae210cc2fc1c57c46091822ae6c2a58caa76e0091f255f9f30d 775 docs/RELEASE_NOTES_0.10.4.md
|
8f4a0fe6dc250ae210cc2fc1c57c46091822ae6c2a58caa76e0091f255f9f30d 775 docs/RELEASE_NOTES_0.10.4.md
|
||||||
05ed618f5a74a854363930128ca98939808517eedd28b9a508660a0c46e91d97 884 docs/RELEASE_NOTES_0.10.5.md
|
05ed618f5a74a854363930128ca98939808517eedd28b9a508660a0c46e91d97 884 docs/RELEASE_NOTES_0.10.5.md
|
||||||
94bfb2783c1befad1197e1c5e32fc002222c94a28d70d48360a8d53ecd260d5c 772 docs/RELEASE_NOTES_0.10.6.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
|
a0c00ff76acd1682bb5e0e8dcf6589c9480da436c9c6d30780a1ed58b4dad94f 1770 docs/RELEASE_NOTES_0.2.0.md
|
||||||
5773ead01aa4c522c556295553787482d01b1f5242f053b2c61f120c4de4fa76 5963 docs/RELEASE_NOTES_0.3.0.md
|
5773ead01aa4c522c556295553787482d01b1f5242f053b2c61f120c4de4fa76 5963 docs/RELEASE_NOTES_0.3.0.md
|
||||||
d46de73cf6c4cd5c2ba3f455a7a2af2e0d64ee9d94a97fd1a0bfb44e35c1624a 1093 docs/RELEASE_NOTES_0.3.1.md
|
d46de73cf6c4cd5c2ba3f455a7a2af2e0d64ee9d94a97fd1a0bfb44e35c1624a 1093 docs/RELEASE_NOTES_0.3.1.md
|
||||||
@@ -73,7 +82,7 @@ ed40e08bac8792f95970bc05e49bce3cc9e288a08d11565a1bd156d787360a3b 720
|
|||||||
25169225d73d22b9d884ab3b5c1625f03fd44e53c7a7a4c4067775e80482c9f8 2182 docs/RELEASE_NOTES_0.9.3.md
|
25169225d73d22b9d884ab3b5c1625f03fd44e53c7a7a4c4067775e80482c9f8 2182 docs/RELEASE_NOTES_0.9.3.md
|
||||||
720506842e0aeb30c9fc635f86a52a5545556f092e678cf37f08436243244c3d 933 docs/RELEASE_NOTES_0.9.4.md
|
720506842e0aeb30c9fc635f86a52a5545556f092e678cf37f08436243244c3d 933 docs/RELEASE_NOTES_0.9.4.md
|
||||||
dd90c81a375f97dfb7fa8f7808db03b19d7e7dafe3818a93537397f57eaae829 2109 docs/RELEASE_NOTES_0.9.5.md
|
dd90c81a375f97dfb7fa8f7808db03b19d7e7dafe3818a93537397f57eaae829 2109 docs/RELEASE_NOTES_0.9.5.md
|
||||||
60cb1f1ed55322b519236dde8388ecf9ee5fd67c169fd8093b5acf61387557d8 2068 docs/RELEASING.md
|
1bf75f25d704dab0c6bc56c639d259f34523f0fb46718dd5a8419a59911ad2c3 2242 docs/RELEASING.md
|
||||||
ac76cb50fabde6a00f28d7e9eccd3ef1129a40665eabdc90d78690a38d424652 4195 docs/ROADMAP.md
|
ac76cb50fabde6a00f28d7e9eccd3ef1129a40665eabdc90d78690a38d424652 4195 docs/ROADMAP.md
|
||||||
1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png
|
1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png
|
||||||
b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png
|
b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png
|
||||||
@@ -82,14 +91,14 @@ ed69b8beb948a2cf9a6deb6c82368e2bb44ffe8d8990a900dc878b0938d1084f 95937
|
|||||||
3868ab978de2a7945761c53a9a718aecd54dc791605d07660bcd5cad62a33ea8 103569 docs/screenshots/git-validator.png
|
3868ab978de2a7945761c53a9a718aecd54dc791605d07660bcd5cad62a33ea8 103569 docs/screenshots/git-validator.png
|
||||||
007681714895ac062c980db1dda806ac17d4f01019ce9c46491a108d17c2dbda 85338 docs/screenshots/overview.png
|
007681714895ac062c980db1dda806ac17d4f01019ce9c46491a108d17c2dbda 85338 docs/screenshots/overview.png
|
||||||
1f78414b00ec100af2ec9bf5c9a3e400b6c9bf6dca6fcc317fd951789acc4536 112852 docs/screenshots/repository-workspace.png
|
1f78414b00ec100af2ec9bf5c9a3e400b6c9bf6dca6fcc317fd951789acc4536 112852 docs/screenshots/repository-workspace.png
|
||||||
158cd3a13e9c4d081a63575fbafc77e0b23812f793a15096888b3667f41fa28c 5605 docs/SECURITY.md
|
735950c1e77bd4a1cf5ee986a7a307600741e5fdb90918adb0687bd29a89aaec 5877 docs/SECURITY.md
|
||||||
32a34ec13a284d3f9ceebbc107b25a844e3db096f8cafa4e43951fc2050c9a03 13552 docs/SETUP_GUIDE.md
|
32a34ec13a284d3f9ceebbc107b25a844e3db096f8cafa4e43951fc2050c9a03 13552 docs/SETUP_GUIDE.md
|
||||||
2fd71e9bcaeb4cb10c3fa2496b7e52fedf70c5b7f871cd587e22dc060c399079 4421 docs/SSH_UNRAID_DEPLOYMENT.md
|
2fd71e9bcaeb4cb10c3fa2496b7e52fedf70c5b7f871cd587e22dc060c399079 4421 docs/SSH_UNRAID_DEPLOYMENT.md
|
||||||
b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md
|
b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md
|
||||||
0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md
|
0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md
|
||||||
4983414a980075e6faae687b0d71c8e57bfe53fcb4cadb8b979b8abca636fe95 6654 docs/TEST_MATRIX.md
|
4983414a980075e6faae687b0d71c8e57bfe53fcb4cadb8b979b8abca636fe95 6654 docs/TEST_MATRIX.md
|
||||||
dbbd9fa96988e7543e98c85da864adaadd3057815f18d20a3b3ccb5c540a169d 4558 docs/UPDATING.md
|
03fb2fe52a863b9d3d536f3c8abe23e47b9851be7fd7ccbfb106554b9c595385 5013 docs/UPDATING.md
|
||||||
4bffda594058697345569d937d7a524f094ac85a0f338f0ef18fcf3f94d8c299 1292 eslint.config.js
|
73f094a2f0db3de053e515feb2771cd5a4f3aa4178f2c5f37be01ca65ff1c938 2705 eslint.config.js
|
||||||
c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 examples/gitea-actions/deploy.yml
|
c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 examples/gitea-actions/deploy.yml
|
||||||
4c792cc9fd57ed36da291300c252a6ef75b08a249cf6f2561e23c4c22522138a 1477 examples/gitea-actions/rollback.yml
|
4c792cc9fd57ed36da291300c252a6ef75b08a249cf6f2561e23c4c22522138a 1477 examples/gitea-actions/rollback.yml
|
||||||
577f3fa2131a3baa84549a6523f5816ef9da94f5bac6bc274d4588b6e7ab6594 5688 examples/server/forgeflow-deploy
|
577f3fa2131a3baa84549a6523f5816ef9da94f5bac6bc274d4588b6e7ab6594 5688 examples/server/forgeflow-deploy
|
||||||
@@ -98,136 +107,147 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532
|
|||||||
106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf
|
106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf
|
||||||
2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json
|
2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json
|
||||||
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE
|
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE
|
||||||
1f0f388df4397e548887bbc7579fd3c864581b86469c01703201ece7a6cbf931 13667 main.cjs
|
e2daa28bbc01c68c3702add6ea8259dff5920b22f6fdc3c9193ed78a153f2e9e 14708 main.cjs
|
||||||
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
|
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
|
||||||
32b8a6a210910623eb078956a2f06a33b2649d60333a567e620d46c6ed4bcc0d 179806 package-lock.json
|
65c548b9072c90530ee32686c34959e940e694684af243b17d5cbdb6bd47cb61 179808 package-lock.json
|
||||||
681fb8a118b372ad9b62f8940d7c921eb20267c4eaf8a99431370f8d64d39992 5550 package.json
|
fe6f311914aa4513e476267c795ca22311ab78d1bb4388d5a3e50f687d5e193f 6415 package.json
|
||||||
2a597a5704c576783b8a72407fbc377fa7506b36a4596ea7f7bce126e394f837 1326 playwright.config.mjs
|
1237df9ddcbb5ac7dc4316f18c34ff4a7030e3e0d56216ade6dd07369e5e2a04 1353 playwright.config.mjs
|
||||||
69318fdf054be7aa2fe86ead9847da9da65745d8d5de548c8346f3ba0afc4892 12175 preload.cjs
|
e8f678b26a1b651ee0e06e499b0538d8a193d0565687e9f750b58f801e4fabd8 12473 preload.cjs
|
||||||
abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd
|
abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd
|
||||||
f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1
|
6d0858d6654c3c3dc7083ebbd234c88324afcebaecd7b772719440a8afbc2e4e 10736 Publish-ForgeFlow-Release.ps1
|
||||||
688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1
|
33f3c4795705ab77c6e6603c88a32c123b3a286bc77e8e472b76970485699338 4386 Publish-Missing-Binary-Release.ps1
|
||||||
51dff94dcdf3ae86ff1d544bb52eeab2ab51421a51b35c5dbe8f7c9f8d23c50a 10313 README.md
|
521356cc921145e8229751f85d57652fa50e0267f0d953a9f86cdcfd33eb5838 11117 README.md
|
||||||
8f8dc5ff0c2967563457e4027d795f6f515253e0a459ecca5a9f5bcf0b658870 14650 reports/architecture-audit.json
|
d75cfae88987ff6e8b92a53c988e59cf6dcffb74cd7cf8d27b417001f68f6bc6 16450 reports/architecture-audit.json
|
||||||
5e923f0096895f588fe66b187779cdb7cb5af6644010c6dcc4d55876930ae376 1220 reports/architecture-audit.md
|
654fea47bb851d5865908debcdc2140593c8bd78d03c7a5bfb6573518de08919 1726 reports/architecture-audit.md
|
||||||
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
|
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
|
||||||
8fe616851e6a089a59fd134905465019c33d26f6408ebd0613fff587b6c589e0 4400 scripts/apply-binary-update.ps1
|
d0745072321aca2c80f44460974a7926715a9f429164aaf7660dced40b52c736 4790 scripts/apply-binary-update.ps1
|
||||||
706b49e001837c91134044d3af132d58fb14955245bc1d5c36b413eb65867002 10486 scripts/apply-source-update.ps1
|
404863bcbe7292355662e3a326455df864d7279badc29f90866a3b837420df54 10745 scripts/apply-source-update.ps1
|
||||||
02e924227f6cad3777fd06660230c85df590d8ce95e134194a4d18970a240b88 4145 scripts/architecture-audit.mjs
|
02e924227f6cad3777fd06660230c85df590d8ce95e134194a4d18970a240b88 4145 scripts/architecture-audit.mjs
|
||||||
4490bed84761f76e1fd87ee3117fe53e82e60b7760329c97d68772d6d820ebae 8521 scripts/audit-installed-deployments.cjs
|
47a5b16e95934bfe510c18bf94547ae65acb980c0f0506ae156d1a486dfbdfc9 8985 scripts/audit-installed-deployments.cjs
|
||||||
6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs
|
6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs
|
||||||
0244d42896b8c44f734d0bb6cdcb29b5981342be2f070ce89f8d9eaf3e4d49e6 1793 scripts/generate-source-manifest.mjs
|
0244d42896b8c44f734d0bb6cdcb29b5981342be2f070ce89f8d9eaf3e4d49e6 1793 scripts/generate-source-manifest.mjs
|
||||||
842436680521311594e798848b050ae4e488d0595f0de57315f6ec081c049fb9 1266 scripts/prune-dist.mjs
|
842436680521311594e798848b050ae4e488d0595f0de57315f6ec081c049fb9 1266 scripts/prune-dist.mjs
|
||||||
b83d443f5724ac15393567f3a688aed8315fbe3e5966832c864a9466e0669464 8102 scripts/publish-binary-release.cjs
|
d0e6fd6ce67b553a3654acd4393e5b9c3be825c45d03d957fee36fb2a3c56a85 8308 scripts/publish-binary-release.cjs
|
||||||
444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs
|
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
|
c76507857292c5713e1c699cf02e24b80265da39af2cecd148034bdb874adbb6 5246 scripts/test-authenticode-chain.ps1
|
||||||
4393f7dc5f417e6d601a68238f4e26791799a3634acec228fe4d79deaee85eb5 3109 scripts/validate-installed-connections.cjs
|
4393f7dc5f417e6d601a68238f4e26791799a3634acec228fe4d79deaee85eb5 3109 scripts/validate-installed-connections.cjs
|
||||||
e6127e1e62f39c70ddb1abf72f4d7e7b8e3f19ff1f219e1a3660353c2e0cdfac 2411 scripts/verify-release-signatures.mjs
|
e6127e1e62f39c70ddb1abf72f4d7e7b8e3f19ff1f219e1a3660353c2e0cdfac 2411 scripts/verify-release-signatures.mjs
|
||||||
85134984297c90a5de812c075d7f75eaa5242d5ee33713c6a4a2fe9703f82be9 19210 scripts/verify.mjs
|
de14527e9fddfb904dd73ffe4e7836d8bb65abfdf7bd0c350bb9eb911b1d5de3 22289 scripts/verify.mjs
|
||||||
0b9f03ba3c67ff7cdb2916a902ad8ce25e81a7c90b210e4ae52d2ad029efabf3 2353 scripts/write-release-checksums.mjs
|
c2c9e4ba251d93a530a52b2d0079787680261c314083bb99d2356fc177719613 2434 scripts/write-release-checksums.mjs
|
||||||
619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1
|
619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1
|
||||||
dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs
|
dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs
|
||||||
92856d698d0a5cc0a3e112e9dc05eb6de473809e9f82e7b08dd21f13f4ec1af8 32309 src/main/config-store.cjs
|
5506ac1e5e49006ffd028a29c89bd0b95485ea3f2abc22db4f2b9fedd959852f 33194 src/main/config-store.cjs
|
||||||
2fb04b1494b39f5d7c0720fa5fd298cd46fa85dc1b696d77657592347fcf1819 2731 src/main/configuration-backup.cjs
|
2fb04b1494b39f5d7c0720fa5fd298cd46fa85dc1b696d77657592347fcf1819 2731 src/main/configuration-backup.cjs
|
||||||
86e9fc2eda66b4b563f6c4bbb87d3e8514340d484fb503b73137e63b6b05c3c9 14597 src/main/deploy-key-lifecycle-service.cjs
|
86e9fc2eda66b4b563f6c4bbb87d3e8514340d484fb503b73137e63b6b05c3c9 14597 src/main/deploy-key-lifecycle-service.cjs
|
||||||
7cbfe51973d6607203cb197652ed7f296a3f6b6b644df876957117866a47d802 2159 src/main/deployment-identity.cjs
|
7cbfe51973d6607203cb197652ed7f296a3f6b6b644df876957117866a47d802 2159 src/main/deployment-identity.cjs
|
||||||
9d0af5074093108a5248d0dde0ff70a666748e61f1954b630886a81e8f34072c 24079 src/main/deployment-service.cjs
|
ce30ddac403d1adf21176e5df21b0cc3db435305d2628f51f1486eacf20df6f2 23708 src/main/deployment-service.cjs
|
||||||
c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs
|
f22348297291199e858656248cec70f94f002144ea7ea0807bd844cc5016baaf 16277 src/main/diagnostics-service.cjs
|
||||||
a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs
|
a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs
|
||||||
0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs
|
3d19a328eec427329fd9123b24fe79b5ab6670c0b33ac68397948e8df636a99d 43961 src/main/git-service.cjs
|
||||||
e28fc1ca2fd4c0116148f5005d793feddf04c36ef711d2d348560394d209a613 7253 src/main/git-validator-policy.cjs
|
e28fc1ca2fd4c0116148f5005d793feddf04c36ef711d2d348560394d209a613 7253 src/main/git-validator-policy.cjs
|
||||||
3a101b63ad3761c26350c2ac0793279a0b27672b91a5b1d8dc75bc44d92f0b52 27128 src/main/git-validator-service.cjs
|
3a101b63ad3761c26350c2ac0793279a0b27672b91a5b1d8dc75bc44d92f0b52 27128 src/main/git-validator-service.cjs
|
||||||
3cc53e24e023aa0d8bf36c35ce9672ca98e6c74066512c8b59ab42274e838c22 21307 src/main/gitea-service.cjs
|
3cc53e24e023aa0d8bf36c35ce9672ca98e6c74066512c8b59ab42274e838c22 21307 src/main/gitea-service.cjs
|
||||||
2ad3b2e647377f687ad987fe248a142ad399ecac98e4b49965aa7efc6093e5fa 6914 src/main/inventory-classifier.cjs
|
2ad3b2e647377f687ad987fe248a142ad399ecac98e4b49965aa7efc6093e5fa 6914 src/main/inventory-classifier.cjs
|
||||||
dafdb09133d2b6ec2161a3f0b09354551e54fc606c8107976fca37405643be91 3404 src/main/inventory-review-service.cjs
|
dafdb09133d2b6ec2161a3f0b09354551e54fc606c8107976fca37405643be91 3404 src/main/inventory-review-service.cjs
|
||||||
00989577aed509a7ddfdf9f4df09a196a393a85b5ea59b21089002215e69f065 25949 src/main/ipc.cjs
|
5e10cf3759bbf9294909866ed16f206d394789f0564549fc0fe23cdd89118ca6 25110 src/main/ipc.cjs
|
||||||
0eb1cfdcd3a37a0ec9502bf753966f87230c03580335798bef9265add42ee6fa 12530 src/main/ipc/deployment-handlers.cjs
|
26efebb4c147ed560966e7e60e64a013b3476327b3bbdb4e4439949142fa7846 2250 src/main/ipc/channel.cjs
|
||||||
|
b80357dd1f0aa18022d92db85b6cc8f29bc691ef11f9a0e90b4886ae5e19c763 12543 src/main/ipc/deployment-handlers.cjs
|
||||||
dc9b5971c9fefe8c374aa31916f5513601ce86003fd48b1d0e51330a909ae3a5 3442 src/main/ipc/operations-handlers.cjs
|
dc9b5971c9fefe8c374aa31916f5513601ce86003fd48b1d0e51330a909ae3a5 3442 src/main/ipc/operations-handlers.cjs
|
||||||
629b0f4704cda6ed6288c5a6ec9e5d73d6ae3e2692c43581b258431b65b58e51 15887 src/main/ipc/repository-handlers.cjs
|
8072252821b1245d121eac534a18eeb64f0d7d18429e272e21a6e90b010005b9 17272 src/main/ipc/repository-handlers.cjs
|
||||||
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs
|
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs
|
||||||
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs
|
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs
|
||||||
|
720c4a0c554f46386d87c3ab6607d1fbcae66e50b69483c7dbba169d5128c851 680 src/main/process-error-policy.cjs
|
||||||
3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs
|
3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs
|
||||||
e64f7257d478955c675a133b3735b6afe138a69d2ad090898061e56f557c43e5 9926 src/main/production-acceptance-harness.cjs
|
e64f7257d478955c675a133b3735b6afe138a69d2ad090898061e56f557c43e5 9926 src/main/production-acceptance-harness.cjs
|
||||||
fc7a5a2a42579a311f60a96bec4776942bf3f63428928820029b0dcf4874d1aa 2745 src/main/repository-monitor.cjs
|
27bd6621c731545ec46d8914e9408c89928a8ce563b40eb4bcd8a516662a54d1 8716 src/main/repository-monitor.cjs
|
||||||
17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs
|
6393583911263575c6e2a19d9baab6e638cce90252c386b0a5144f2fb6f81f15 12154 src/main/repository-service.cjs
|
||||||
52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs
|
52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs
|
||||||
afef3841a3948b2121f8fba809aae4ea3da71bd2fda86973ba50200a5b1f89b2 14894 src/main/ssh-service.cjs
|
793003566823e1d5c02283f583888ecc07e44477525620579b3d858f488b3c08 22347 src/main/ssh-service.cjs
|
||||||
90504e27bfabcd2f927ba29930fad9bb65520fdf871a2a9e49cbcefcb837d84c 25492 src/main/unraid-access-methods.cjs
|
19538a3c40ea3489bbaee9a23af36a5e99962af6bb3d04259f05ece6588cbeb2 25901 src/main/unraid-access-methods.cjs
|
||||||
2ede80cd1565a7f2c282cc58d35dc0889d58d7465346bc723026b9c8be4df0ac 9501 src/main/unraid-deploy-key-host.cjs
|
5621e35323e4f81fb14a05670f81579ec1e66bea3a55fa6457ece0f807421424 9801 src/main/unraid-deploy-key-host.cjs
|
||||||
803a079499f7b8148495209dea43b505f6eb9bb587de19e82054e47183186c6e 30461 src/main/unraid-deployment-methods.cjs
|
6d9910dace52625f88e066a8485af2663c3735ff15e9ce9031441ce742710a21 30793 src/main/unraid-deployment-methods.cjs
|
||||||
2e63fdc0be0bf4d8e5c3d7d45ff5811d786b0821a08b82f511f1301696e12c9d 17157 src/main/unraid-deployment-service.cjs
|
673b1692e7c2b5197545df98750b5d048bddf44206263e25be4f17d9bf900e2c 17208 src/main/unraid-deployment-service.cjs
|
||||||
f0861356c1ff4ca361c7004c1f7d935858f51cc74335a7ad2136021f2e612220 36016 src/main/unraid-inventory-methods.cjs
|
bb4a99c3526fcf4db4fbae88a058e8598fd10a87990e7d502bfdc765328bdaa1 42766 src/main/unraid-inventory-methods.cjs
|
||||||
9e682411f73450f595b5cc4dfb28939c6c58b9547d0a91e287c3efb4955e8840 26299 src/main/unraid-preflight-methods.cjs
|
2c0cf07921ca7ee5a9085ced44498c2e6798e5cc1e8a5ecf704c3cecabe39a25 27607 src/main/unraid-preflight-methods.cjs
|
||||||
d45220176aed72d692f9ae5534f9d40bcc359a2d08e025e74a3b3b505b8b9ed4 16559 src/main/unraid-runtime-methods.cjs
|
d45220176aed72d692f9ae5534f9d40bcc359a2d08e025e74a3b3b505b8b9ed4 16559 src/main/unraid-runtime-methods.cjs
|
||||||
d4b3a07eeca687a49544a94ea574f7c6f7e0bc3aa9311b614d5244a468ca4a1b 11307 src/main/unraid-state-methods.cjs
|
4c5cf01922e1feb36a31b50af22e973d8aee3fecccd406e449690604111898ac 11608 src/main/unraid-state-methods.cjs
|
||||||
a387ac9735624ef89d964ea59e035dae256b9915ba2cee22ae5cbc2894f5808a 22000 src/main/update-service.cjs
|
0b25c3729c5fffe3cd412c2325616bb86a6c916ae248eeb39d837378bb78c144 26420 src/main/update-service.cjs
|
||||||
b5c304531bec358d059189a27cd9db8fa20cefb7f817e5eb0287001f7353f6a7 985 src/renderer/actions/command.js
|
b5c304531bec358d059189a27cd9db8fa20cefb7f817e5eb0287001f7353f6a7 985 src/renderer/actions/command.js
|
||||||
d0bf607dd1de9d55f2947d0adf0997cd3ca5c269d10a5362cc1d8bc4d1a2a8ae 6706 src/renderer/actions/deployment-operation.js
|
d0bf607dd1de9d55f2947d0adf0997cd3ca5c269d10a5362cc1d8bc4d1a2a8ae 6706 src/renderer/actions/deployment-operation.js
|
||||||
0f2070aa3b5c404aedf643837dfd7c5d547e8f45b2e9c97e4cfcf11951555474 17705 src/renderer/actions/deployment-profile.js
|
0db283b1a458ae0b31538940b1ddc931ffdb53bd04ceb7fd8903813f9200d071 17978 src/renderer/actions/deployment-profile.js
|
||||||
48bed91dd2a85bb51ee7307f7acc3b79c881ce8cf63b22ba79d5d079b265eb4b 7785 src/renderer/actions/inventory.js
|
48bed91dd2a85bb51ee7307f7acc3b79c881ce8cf63b22ba79d5d079b265eb4b 7785 src/renderer/actions/inventory.js
|
||||||
4227a05a20580a31127d2c929640defc3d36e8e3e89d6be830aab1940da81082 12267 src/renderer/actions/recovery.js
|
13b8611b5389625deeec59ff2a6cfebcfc93bd7972902439715be371d1f9f573 14936 src/renderer/actions/recovery.js
|
||||||
cdfaacdcd5ae04b0e5c79fefa21f5e09d5c810bcea504c5b6e1d6b744182ff84 15567 src/renderer/actions/setup-and-settings.js
|
2414a0d29a0380d343b9b0e58ba1909e7a7eeb46357fd45ddbb3ad411d119f78 16280 src/renderer/actions/setup-and-settings.js
|
||||||
a980f2e86d8286ea605a7259b9e9adcf3fda4f657b8d54a5d2d8765a7bbbec13 19065 src/renderer/actions/shell.js
|
6900075c7cbcf6638b071735a3ce34954339d3d0ad324373c2bf5afcbd3cfe02 19618 src/renderer/actions/shell.js
|
||||||
edcfa0585af4b11ec3b7458969132bdf215830505859f1519f1635dc111ce48a 24333 src/renderer/app.js
|
d8eee8bc10b23d877919560790c311924bc3c339775dfe225b5b460e040f25de 26829 src/renderer/app.js
|
||||||
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png
|
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png
|
||||||
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png
|
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png
|
||||||
094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png
|
094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png
|
||||||
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png
|
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png
|
||||||
1a577af2459715cf38d49d118da8c5f897a7ff1d3eb42ad41f121675eb871732 48389 src/renderer/dialogs.js
|
a9dfda1adb8910bb882428c237907b56163ee4901992a54a6e50c74f6037786a 55199 src/renderer/dialogs.js
|
||||||
eef2f269ba4fbb76bf66ad328d481b461255d0acb753b30878dd4d4eaac57dc6 6924 src/renderer/events.js
|
dede1f21a06c73a2c2a462a869d27530d85f99baff202a2eb509c57436ad6aec 2732 src/renderer/diff-view.js
|
||||||
a84da5aecbb16ce7983dba1f6d6aab1bf47b2e9a87c2933fa1afb8123f7ef7d6 1497 src/renderer/index.html
|
e0ea09d8d3ab1033452a77a9dfa4557b29ad5428c9050713661fe4699e007bda 7245 src/renderer/events.js
|
||||||
|
c4a71213d412166093f7bd8254b847de4d8beb58c1aaa356a0cdc8d728080326 1524 src/renderer/index.html
|
||||||
06180d9656dd254edfb6949c397f8e313954fc560ddcb22b3a35fce3c3e35655 21350 src/renderer/mock-bridge.js
|
06180d9656dd254edfb6949c397f8e313954fc560ddcb22b3a35fce3c3e35655 21350 src/renderer/mock-bridge.js
|
||||||
5a2c0616a8a0ba23c182a8928abdf40a69612ce34c4cba47b1fd5f21fe5843d4 28617 src/renderer/mock-deployment-bridge.js
|
870024aff376826a92c9cf7452689cc1ecc5d9034f055bea56734f3f7fcea5e5 28703 src/renderer/mock-deployment-bridge.js
|
||||||
b30f44e8e4285e40e7ef87474aeb34467a40dbedc9023a080deaa204ce84bbeb 20032 src/renderer/mock-repository-bridge.js
|
2e7ed10ac9555470f989e6e8d721fcf0db979a03c9508844e028fd9028e31465 25031 src/renderer/mock-repository-bridge.js
|
||||||
94fa265c2fe9ca8d644f0ce9b620b6f85d9b25dca5802c4e9195b66dcbe80120 6522 src/renderer/operations.js
|
94fa265c2fe9ca8d644f0ce9b620b6f85d9b25dca5802c4e9195b66dcbe80120 6522 src/renderer/operations.js
|
||||||
1fc4c28859f7283aa2b771a50f1fd26addc8862b97a09250f806930ab4483f7a 79874 src/renderer/styles.css
|
b541b5173d9b2a0063825243e87be4b8709d34d4f82a96d3a91d17471b3a60b7 87983 src/renderer/styles.css
|
||||||
5230c238acb87381303ec30026906cf8a7163c33380859d351b268adfc26b4f4 100196 src/renderer/views.js
|
17a568f0844d6bbdac2c4e9e8d9e564b06c0557d9c2ddcb2a8eb513962306360 112565 src/renderer/views.js
|
||||||
0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs
|
e9e72c072a5c5d04f59cd6763de0cfbf736c2a5ffa2f722143f3bad2bdbc630b 1411 src/shared/clone-target.cjs
|
||||||
5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs
|
5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs
|
||||||
029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs
|
029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs
|
||||||
2778ebcbdf60fdc1cb0749f15565e0e1bd66f3a0d31eb70ae7942a7511a3de75 1295 src/shared/repository-match.cjs
|
2778ebcbdf60fdc1cb0749f15565e0e1bd66f3a0d31eb70ae7942a7511a3de75 1295 src/shared/repository-match.cjs
|
||||||
c7e120ea53c5ef3c01b8cce71afe913f34bb461bb73aa3ade24656e09f99f338 1152 src/shared/semver.cjs
|
c7e120ea53c5ef3c01b8cce71afe913f34bb461bb73aa3ade24656e09f99f338 1152 src/shared/semver.cjs
|
||||||
8791d3813e6cf285ee6aa49f76e75fc1f3af76fd98c76bcb3c92ee18e9cb699f 2889 src/shared/shell-verification.cjs
|
8791d3813e6cf285ee6aa49f76e75fc1f3af76fd98c76bcb3c92ee18e9cb699f 2889 src/shared/shell-verification.cjs
|
||||||
2daa98fd421598bfe5fc9757c9b6f4d82c31d1bfece15829928473581d5d2639 1210 src/shared/tool-invocation.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
|
13b731c38863b1007b0312fd9d89562401b7cce875c952f52429bde74f77a8af 3096 src/shared/zip-writer.cjs
|
||||||
f8853dce6fdf360d5df2fbe2b6df3e5687630c807fee5ba8436679b34ec737ea 2436 START_HERE.md
|
f8853dce6fdf360d5df2fbe2b6df3e5687630c807fee5ba8436679b34ec737ea 2436 START_HERE.md
|
||||||
058aeaa5d9bfe377c7e322f213c7871ecc4151b5d08ef790992f4ee28d857658 743 START-FORGEFLOW-OVERLAY.ps1
|
058aeaa5d9bfe377c7e322f213c7871ecc4151b5d08ef790992f4ee28d857658 743 START-FORGEFLOW-OVERLAY.ps1
|
||||||
f5b0ea887fcdeadec78c1ad49b0ec7979723562f5c0b730703acb77a37281ee0 1009 tests/acceptance.test.mjs
|
f5b0ea887fcdeadec78c1ad49b0ec7979723562f5c0b730703acb77a37281ee0 1009 tests/acceptance.test.mjs
|
||||||
a4e5947204ff6878e601e32477bc85b53cd0153baf95a161c8935b6e5466c257 1155 tests/audit-service.test.mjs
|
a4e5947204ff6878e601e32477bc85b53cd0153baf95a161c8935b6e5466c257 1155 tests/audit-service.test.mjs
|
||||||
b46c9624c20bae767e50b4ab85e6b0ea6a9ee4a5c5c30f55554217f25134f1e7 11218 tests/browser/forgeflow.spec.mjs
|
8eb8023bfd1366f4cc8c1867fe16fa0bed8d6e9d29219100ae1336aa06d10110 19040 tests/browser/forgeflow.spec.mjs
|
||||||
454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 4318 tests/clone-target.test.mjs
|
1728c0a7abd92f4d7d9e68df32e4a6b00730555f23795e9b36416795d9d127af 5978 tests/clone-target.test.mjs
|
||||||
ac17f8bbe9e388b80abef7792c8b184a1fd482c93f13d23a478e433961020f75 17214 tests/config-store.test.mjs
|
aa2ae0e5a12bc47f8024e0d7408148e3af797e3f3f0ecb2525fb1cc54cf4e1f0 17378 tests/config-store.test.mjs
|
||||||
f1463326aee79842d265687ae628189ce54e92544600f2bd14073780287cfb14 2502 tests/configuration-backup.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
|
b7e009fed4171d6dd6b4c3154ba1d3f7198e98f5b79b298687841fc8169447cd 9354 tests/deploy-key-lifecycle.test.mjs
|
||||||
|
49bf9cf9842e7899015013675208f83a95402065a082320927a677ee4bab0766 24875 tests/deployment-operations.test.mjs
|
||||||
1dc6477bd07de78be189e6e8195ec339eb9d75820c4dbd5b073b8520ee21f6b5 1938 tests/deployment-policy.test.mjs
|
1dc6477bd07de78be189e6e8195ec339eb9d75820c4dbd5b073b8520ee21f6b5 1938 tests/deployment-policy.test.mjs
|
||||||
50e90cd41dae952a14903c40c0cb1fd191d7b875cfeb730f06a454e755fad7ce 9076 tests/deployment-status.test.mjs
|
bf4576901e32662d832687a2761852aa1b2cffe256de5044f18c6637c189463b 9780 tests/deployment-status.test.mjs
|
||||||
fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800 tests/diagnostics.test.mjs
|
fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800 tests/diagnostics.test.mjs
|
||||||
dd121d96ca265a027cd415a52064500a4541b2f8a662f4f4b25f2f996d52b5da 762 tests/external-tools.test.mjs
|
dd121d96ca265a027cd415a52064500a4541b2f8a662f4f4b25f2f996d52b5da 762 tests/external-tools.test.mjs
|
||||||
e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 tests/git-integration.test.mjs
|
1da4abd9355183ee04410b3d89403cc36094eb5df622ecfe6736e8807135bd28 19532 tests/git-integration.test.mjs
|
||||||
5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db 1239 tests/git-status.test.mjs
|
5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db 1239 tests/git-status.test.mjs
|
||||||
61e0b8cad926acd22b5b17e4044f7edcbe96b6977cbbe2b6fbe123406626fc89 4283 tests/git-validator-policy.test.mjs
|
61e0b8cad926acd22b5b17e4044f7edcbe96b6977cbbe2b6fbe123406626fc89 4283 tests/git-validator-policy.test.mjs
|
||||||
2b31459f14a5e36e30cf84c1054f634f4dba8676d29adeb9c2a8e18179f56fa0 6097 tests/git-validator.test.mjs
|
2b31459f14a5e36e30cf84c1054f634f4dba8676d29adeb9c2a8e18179f56fa0 6097 tests/git-validator.test.mjs
|
||||||
681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.test.mjs
|
681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.test.mjs
|
||||||
d633c59bd910008223c834c6d7f3e5666c685a0881944263ede2d42cc69d3151 18710 tests/gitea-actions.test.mjs
|
d633c59bd910008223c834c6d7f3e5666c685a0881944263ede2d42cc69d3151 18710 tests/gitea-actions.test.mjs
|
||||||
fcc9a063882840dd89d74c2785284c8f2f6a9e5acec482b6d89ed8de62efdb85 9635 tests/inventory-classifier.test.mjs
|
fcc9a063882840dd89d74c2785284c8f2f6a9e5acec482b6d89ed8de62efdb85 9635 tests/inventory-classifier.test.mjs
|
||||||
9643622a03ea0a88fb7d72ce43e469ff4f814902f4b3d2a672990637d66ef075 2009 tests/ipc-contract.test.mjs
|
62b90c21c15b841af30d26ccb0b9e88d25674fa7dbff9dc231dd8a1dddc3d657 2025 tests/ipc-contract.test.mjs
|
||||||
caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs
|
caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs
|
||||||
96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs
|
96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs
|
||||||
1b6c920e18a248f78acaed6187197c88ec8d911b62d5e2a9f8ad57b91ae80499 11827 tests/preflight.test.mjs
|
1b6c920e18a248f78acaed6187197c88ec8d911b62d5e2a9f8ad57b91ae80499 11827 tests/preflight.test.mjs
|
||||||
|
7f2751ea2621f76b5427f442e931344d13e97faa7b6ef3151949bbd6a03097cf 1205 tests/process-error-policy.test.mjs
|
||||||
0cb884cf62c1cb02cf59a81662be055bcb5339d176de85e2a3eeb8e8573e11b3 6435 tests/production-acceptance.test.mjs
|
0cb884cf62c1cb02cf59a81662be055bcb5339d176de85e2a3eeb8e8573e11b3 6435 tests/production-acceptance.test.mjs
|
||||||
629ba26395c0b49cc5fdee6b0646d75369eb6338e1cc7b59509938f97eea08ec 9601 tests/renderer-workflow.test.mjs
|
1635efed857c776e677a064175a01b0f8b21bf7ead0b02c08956286077b8a37e 12294 tests/renderer-workflow.test.mjs
|
||||||
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs
|
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs
|
||||||
62e6d3df7051778124648c808caec634a6b2c439660dc556392ea10beec926d1 1870 tests/repository-monitor.test.mjs
|
76712a5d26f2598b00b83b925c9c84a90ab81c0eb1760895e9d6a2bd2f6eb425 5828 tests/repository-monitor.test.mjs
|
||||||
ebd3c0825bc9e2f1690cfd51e93a96bd33e939eb9c546aa373dd948b8cf71a69 7781 tests/repository-service.test.mjs
|
5476f3ba90bc096d4172900d9b54ada7c12da521f8627913d87794eade3cee23 13494 tests/repository-service.test.mjs
|
||||||
d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463 tests/security-validation.test.mjs
|
5fea04e668344508fb4e16da9bb6fe8733e2b83d1c227acb3421e51da26b2ffa 3636 tests/security-validation.test.mjs
|
||||||
bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs
|
bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs
|
||||||
e631e9ca49a5bac7075860aac2ff4d377a32a78377b70e06ecf833f0f192fd5f 11552 tests/server-inventory-branches.test.mjs
|
12cb3b240bdd0922566323c0014838ca067ad10d9d4009943165ae2c4e93bc6f 11786 tests/server-inventory-branches.test.mjs
|
||||||
020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs
|
020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs
|
||||||
0d1bc4d623ce299337736c577ec61c8ffd6974ebe20335b72838d10eae35ecb1 7993 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
|
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs
|
||||||
4182b61e395aff310b9a964c973a43c3566df0b44c454054c3abdd9459e86e3b 49134 tests/unraid-deployment.test.mjs
|
3e4a1a6d6a744df9badcfece2cf8d09f8c34efb3c437cb08a6f2e6c9d428c0d4 59353 tests/unraid-deployment.test.mjs
|
||||||
9c58c600d925db6a3ad526e661010c05619e24d61c9d19ec0e207a09ae4cb93a 23981 tests/update-service.test.mjs
|
3bd3247ed821ba261ad7c02d649c26979e3591df456afd1bda04e351b2296fa1 29168 tests/update-service.test.mjs
|
||||||
9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs
|
9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs
|
||||||
7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs
|
7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs
|
||||||
8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea 767 UPDATE_FROM_0.3.2.md
|
8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea 767 UPDATE_FROM_0.3.2.md
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MCowBQYDK2VwAyEApGKe81NzC5mU3jfMNAQUnAOfQnCnMFry8cNpmjQsdtE=
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
@@ -51,4 +51,4 @@ ForgeFlow writes its configuration atomically. Explicit server reconciliation ad
|
|||||||
- Read-only repository-scoped deploy keys for server pull.
|
- Read-only repository-scoped deploy keys for server pull.
|
||||||
- SSH host-key changes fail closed.
|
- SSH host-key changes fail closed.
|
||||||
- Live commit, remote commit and runtime health remain separate evidence.
|
- Live commit, remote commit and runtime health remain separate evidence.
|
||||||
- Packaged updates must fail closed on missing or mismatched release assets and SHA-256 evidence; paid code signing is optional.
|
- 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,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,15 @@
|
|||||||
|
# ForgeFlow 0.10.14
|
||||||
|
|
||||||
|
## Betere uitleg en een stabiele repositorywerkruimte
|
||||||
|
|
||||||
|
- Een nieuw doorzoekbaar **Help center** legt de belangrijkste workflows stap voor stap uit: eerste configuratie, changes en commits, Gitea workspace sync, deploymentdetectie, exacte serverdeployments, deploykeys, Git Validator, updates en diagnose.
|
||||||
|
- Contextuele help vanuit **Gitea workspace sync** opent onmiddellijk de relevante uitleg. De instructies maken expliciet wat ForgeFlow wijzigt, welke recovery ForgeFlow vooraf maakt en welke genegeerde runtimebestanden onaangeroerd blijven.
|
||||||
|
- De variabele **repository context** is samengebracht in één structurele zone. Quick actions, Local → Gitea → Server-status en gekoppelde deployments kunnen daardoor niet langer over de tabnavigatie of inhoud heen schuiven.
|
||||||
|
- Smalle werkruimtes gebruiken gecontroleerde **horizontal tab navigation**. Elke tab behoudt zijn volledige label en blijft bereikbaar zonder dat tekst door andere bedieningselementen loopt.
|
||||||
|
- Het Help center heeft een eigen premium, responsieve presentatie met categorieën, zoekresultaten, uitklapbare stappen, veiligheidsnotities en motion-safe projectillustratie.
|
||||||
|
|
||||||
|
## Verificatie
|
||||||
|
|
||||||
|
- Volledige Node-testset en statische renderercontroles.
|
||||||
|
- **84 browser flows** over dark/light, compact/desktop/wide, 100–150% schaal en reduced motion; de drie tijdens een semantische testaanpassing geraakte flows zijn daarna opnieuw groen uitgevoerd.
|
||||||
|
- Extra layoutasserties bewijzen dat repository context, tabs en tabinhoud elkaar niet overlappen bij 1024 × 768.
|
||||||
@@ -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.
|
||||||
+10
-8
@@ -19,11 +19,13 @@ no certificate, Azure or other paid-service dependency:
|
|||||||
npm run dist:win
|
npm run dist:win
|
||||||
```
|
```
|
||||||
|
|
||||||
This produces the installer and portable executable, SHA-256 sidecars, a
|
Run `npm run signing:setup` once on the release workstation. It stores the
|
||||||
CycloneDX SBOM and provenance evidence. The in-app updater downloads only the
|
private Ed25519 key outside the repository and writes only its public key into
|
||||||
matching Gitea release asset, checks its Windows executable format and verifies
|
the packaged app. `npm run dist:win` then produces the installer and portable
|
||||||
the published SHA-256 digest before staging it. The update helper verifies the
|
executable, SHA-256 sidecars, CycloneDX SBOM, provenance and an Ed25519-signed
|
||||||
digest again immediately before replacing the installed executable.
|
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.
|
Windows can display an `Unknown publisher` warning for an unsigned installer.
|
||||||
That warning concerns public publisher reputation; it does not prevent ForgeFlow
|
That warning concerns public publisher reputation; it does not prevent ForgeFlow
|
||||||
@@ -34,9 +36,9 @@ correct operation.
|
|||||||
## Atomic publication
|
## Atomic publication
|
||||||
|
|
||||||
`npm run release:binary` keeps the Gitea release in draft state while uploading
|
`npm run release:binary` keeps the Gitea release in draft state while uploading
|
||||||
the installer, portable executable, two checksums, provenance and SBOM. It only
|
the installer, portable executable, two checksums, provenance, SBOM, signed
|
||||||
publishes after all six assets are present. A failed upload leaves a draft rather
|
manifest and signature. It only publishes after all eight assets are present. A
|
||||||
than exposing an incomplete updater target.
|
failed upload leaves a draft rather than exposing an incomplete updater target.
|
||||||
|
|
||||||
The optional signing acceptance fixture can still validate the complete local
|
The optional signing acceptance fixture can still validate the complete local
|
||||||
Authenticode chain without purchasing or retaining a certificate:
|
Authenticode chain without purchasing or retaining a certificate:
|
||||||
|
|||||||
+5
-1
@@ -124,9 +124,13 @@ included model uses:
|
|||||||
- SSH passwords and private-key passphrases use Electron `safeStorage`;
|
- SSH passwords and private-key passphrases use Electron `safeStorage`;
|
||||||
- diagnostics receive those runtime secrets only for redaction and never export
|
- diagnostics receive those runtime secrets only for redaction and never export
|
||||||
encrypted credential fields;
|
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;
|
- remote folders and Compose paths are validated against traversal;
|
||||||
- tracked server-side changes block exact-SHA reset;
|
- tracked server-side changes block exact-SHA reset;
|
||||||
- updater tokens are sent only to the configured Gitea origin;
|
- 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;
|
- update archives are checksummed and validated by the full local quality gate;
|
||||||
- source backup is restored when an update fails.
|
- source backup is restored when an update fails.
|
||||||
|
|||||||
+9
-3
@@ -30,14 +30,20 @@ Update logs and status files are stored beneath ForgeFlow's local user-data `upd
|
|||||||
|
|
||||||
## Packaged Windows updates
|
## 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`
|
||||||
- `ForgeFlow-Setup-<version>-win-x64.exe.sha256`
|
- `ForgeFlow-Setup-<version>-win-x64.exe.sha256`
|
||||||
- `ForgeFlow-Portable-<version>-win-x64.exe`
|
- `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`
|
||||||
|
|
||||||
|
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.
|
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
|
.\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.
|
Keep the currently installed older ForgeFlow source folder untouched until the built-in updater test is complete.
|
||||||
|
|||||||
@@ -29,6 +29,34 @@ export default [
|
|||||||
eqeqeq: ["error", "always", { null: "ignore" }],
|
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"],
|
files: ["tests/**/*.mjs"],
|
||||||
rules: {
|
rules: {
|
||||||
|
|||||||
@@ -33,14 +33,31 @@ const {
|
|||||||
ExternalToolsService,
|
ExternalToolsService,
|
||||||
} = require("./src/main/external-tools-service.cjs");
|
} = require("./src/main/external-tools-service.cjs");
|
||||||
const { registerIpc } = require("./src/main/ipc.cjs");
|
const { registerIpc } = require("./src/main/ipc.cjs");
|
||||||
|
const {
|
||||||
|
installOutputPipeGuards,
|
||||||
|
isBrokenPipeError,
|
||||||
|
} = require("./src/main/process-error-policy.cjs");
|
||||||
|
|
||||||
let mainWindow;
|
let mainWindow;
|
||||||
let repositoryMonitor;
|
let repositoryMonitor;
|
||||||
|
let sshService;
|
||||||
let operationTimer;
|
let operationTimer;
|
||||||
let diagnostics;
|
let diagnostics;
|
||||||
let configStore;
|
let configStore;
|
||||||
let tray;
|
let tray;
|
||||||
let quitCleanupStarted = false;
|
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) {
|
function broadcast(channel, payload) {
|
||||||
for (const window of BrowserWindow.getAllWindows()) {
|
for (const window of BrowserWindow.getAllWindows()) {
|
||||||
@@ -171,6 +188,7 @@ function createWindow() {
|
|||||||
app
|
app
|
||||||
.whenReady()
|
.whenReady()
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
|
if (!ownsSingleInstanceLock) return;
|
||||||
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
||||||
callback({
|
callback({
|
||||||
responseHeaders: {
|
responseHeaders: {
|
||||||
@@ -214,6 +232,10 @@ app
|
|||||||
await audit.initialize();
|
await audit.initialize();
|
||||||
|
|
||||||
process.on("uncaughtException", (error) => {
|
process.on("uncaughtException", (error) => {
|
||||||
|
if (isBrokenPipeError(error)) {
|
||||||
|
reportBrokenOutputPipe(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
diagnostics
|
diagnostics
|
||||||
?.error("process.uncaught-exception", error)
|
?.error("process.uncaught-exception", error)
|
||||||
.finally(() => app.exit(1));
|
.finally(() => app.exit(1));
|
||||||
@@ -231,6 +253,7 @@ app
|
|||||||
const repositories = new RepositoryService(store, git, gitea, diagnostics);
|
const repositories = new RepositoryService(store, git, gitea, diagnostics);
|
||||||
const deployments = new DeploymentService(store, gitea, git, diagnostics);
|
const deployments = new DeploymentService(store, gitea, git, diagnostics);
|
||||||
const ssh = new SshService({ store, diagnostics });
|
const ssh = new SshService({ store, diagnostics });
|
||||||
|
sshService = ssh;
|
||||||
const auditedOperationStates = new Set();
|
const auditedOperationStates = new Set();
|
||||||
const reportOperationChange = (payload) => {
|
const reportOperationChange = (payload) => {
|
||||||
broadcast("operations:changed", payload);
|
broadcast("operations:changed", payload);
|
||||||
@@ -241,6 +264,11 @@ app
|
|||||||
) {
|
) {
|
||||||
const key = `${operation.id}:${operation.status}`;
|
const key = `${operation.id}:${operation.status}`;
|
||||||
if (!auditedOperationStates.has(key)) {
|
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);
|
auditedOperationStates.add(key);
|
||||||
notify(
|
notify(
|
||||||
`Deployment ${operation.status}`,
|
`Deployment ${operation.status}`,
|
||||||
@@ -419,7 +447,6 @@ app
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(async (error) => {
|
.catch(async (error) => {
|
||||||
console.error("[startup]", error);
|
|
||||||
await diagnostics?.error("app.startup.failed", error);
|
await diagnostics?.error("app.startup.failed", error);
|
||||||
await diagnostics?.flush();
|
await diagnostics?.flush();
|
||||||
app.exit(1);
|
app.exit(1);
|
||||||
@@ -430,6 +457,7 @@ app.on("before-quit", (event) => {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
quitCleanupStarted = true;
|
quitCleanupStarted = true;
|
||||||
repositoryMonitor?.stop();
|
repositoryMonitor?.stop();
|
||||||
|
sshService?.closeAll();
|
||||||
if (operationTimer) clearTimeout(operationTimer);
|
if (operationTimer) clearTimeout(operationTimer);
|
||||||
Promise.resolve()
|
Promise.resolve()
|
||||||
.then(() => diagnostics?.info("app.quitting", {}))
|
.then(() => diagnostics?.info("app.quitting", {}))
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "forgeflow",
|
"name": "forgeflow",
|
||||||
"version": "0.10.6",
|
"version": "0.10.14",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "forgeflow",
|
"name": "forgeflow",
|
||||||
"version": "0.10.6",
|
"version": "0.10.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ssh2": "1.17.0"
|
"ssh2": "1.17.0"
|
||||||
},
|
},
|
||||||
|
|||||||
+15
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "forgeflow",
|
"name": "forgeflow",
|
||||||
"version": "0.10.6",
|
"version": "0.10.14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
|
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
|
||||||
"main": "main.cjs",
|
"main": "main.cjs",
|
||||||
@@ -11,9 +11,10 @@
|
|||||||
"demo": "node scripts/serve-demo.mjs",
|
"demo": "node scripts/serve-demo.mjs",
|
||||||
"test": "node --test tests/*.test.mjs",
|
"test": "node --test tests/*.test.mjs",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"coverage": "c8 --check-coverage --lines 75 --functions 75 --branches 65 --statements 75 node --test tests/*.test.mjs",
|
"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",
|
"verify": "node scripts/verify.mjs",
|
||||||
"dist:win": "electron-builder --win nsis portable && node scripts/write-release-checksums.mjs && node scripts/verify-release-signatures.mjs && node scripts/prune-dist.mjs",
|
"dist: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:linux": "electron-builder --linux AppImage && node scripts/prune-dist.mjs",
|
||||||
"dist:mac": "electron-builder --mac dmg && node scripts/prune-dist.mjs",
|
"dist:mac": "electron-builder --mac dmg && node scripts/prune-dist.mjs",
|
||||||
"doctor": "node scripts/doctor.mjs",
|
"doctor": "node scripts/doctor.mjs",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"test:browser": "playwright test",
|
"test:browser": "playwright test",
|
||||||
"test:browser:ci": "playwright test --reporter=line,html",
|
"test:browser:ci": "playwright test --reporter=line,html",
|
||||||
"test:signing": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/test-authenticode-chain.ps1",
|
"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",
|
"connections:check": "electron scripts/validate-installed-connections.cjs",
|
||||||
"deployments:audit": "electron scripts/audit-installed-deployments.cjs",
|
"deployments:audit": "electron scripts/audit-installed-deployments.cjs",
|
||||||
"release:binary": "electron scripts/publish-binary-release.cjs",
|
"release:binary": "electron scripts/publish-binary-release.cjs",
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
"package.json",
|
"package.json",
|
||||||
"build/icon.png",
|
"build/icon.png",
|
||||||
"build/icon.ico",
|
"build/icon.ico",
|
||||||
|
"build/update-signing-public.pem",
|
||||||
"docs/SETUP_GUIDE.md",
|
"docs/SETUP_GUIDE.md",
|
||||||
"docs/DIAGNOSTICS.md",
|
"docs/DIAGNOSTICS.md",
|
||||||
"docs/STATUS_ENDPOINT.md",
|
"docs/STATUS_ENDPOINT.md",
|
||||||
@@ -66,6 +69,7 @@
|
|||||||
"scripts/apply-source-update.ps1",
|
"scripts/apply-source-update.ps1",
|
||||||
"scripts/apply-binary-update.ps1",
|
"scripts/apply-binary-update.ps1",
|
||||||
"scripts/prune-dist.mjs",
|
"scripts/prune-dist.mjs",
|
||||||
|
"scripts/sign-release-manifest.mjs",
|
||||||
"docs/RELEASE_NOTES_0.4.0.md",
|
"docs/RELEASE_NOTES_0.4.0.md",
|
||||||
"docs/LUMAOPS_SERVER_AUDIT.md",
|
"docs/LUMAOPS_SERVER_AUDIT.md",
|
||||||
"docs/SSH_UNRAID_DEPLOYMENT.md",
|
"docs/SSH_UNRAID_DEPLOYMENT.md",
|
||||||
@@ -111,6 +115,14 @@
|
|||||||
"docs/RELEASE_NOTES_0.10.4.md",
|
"docs/RELEASE_NOTES_0.10.4.md",
|
||||||
"docs/RELEASE_NOTES_0.10.5.md",
|
"docs/RELEASE_NOTES_0.10.5.md",
|
||||||
"docs/RELEASE_NOTES_0.10.6.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/RELEASE_NOTES_0.10.14.md",
|
||||||
"docs/CURRENT_STATE.md",
|
"docs/CURRENT_STATE.md",
|
||||||
"docs/MUTATION_MODEL.md",
|
"docs/MUTATION_MODEL.md",
|
||||||
"docs/RELEASING.md",
|
"docs/RELEASING.md",
|
||||||
|
|||||||
@@ -18,14 +18,14 @@ export default defineConfig({
|
|||||||
workers: process.env.CI ? 2 : 3,
|
workers: process.env.CI ? 2 : 3,
|
||||||
reporter: [["line"], ["html", { outputFolder: "artifacts/browser-report", open: "never" }]],
|
reporter: [["line"], ["html", { outputFolder: "artifacts/browser-report", open: "never" }]],
|
||||||
use: {
|
use: {
|
||||||
baseURL: "http://127.0.0.1:4173",
|
baseURL: "http://127.0.0.1:41737",
|
||||||
screenshot: "only-on-failure",
|
screenshot: "only-on-failure",
|
||||||
trace: "retain-on-failure",
|
trace: "retain-on-failure",
|
||||||
video: "retain-on-failure",
|
video: "retain-on-failure",
|
||||||
},
|
},
|
||||||
webServer: {
|
webServer: {
|
||||||
command: "node scripts/serve-demo.mjs",
|
command: "node scripts/serve-demo.mjs",
|
||||||
url: "http://127.0.0.1:4173",
|
url: "http://127.0.0.1:41737/__forgeflow_test_ready__",
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
},
|
},
|
||||||
|
|||||||
+4
-2
@@ -46,7 +46,7 @@ contextBridge.exposeInMainWorld(
|
|||||||
applyUpdate: () => invoke('updates:apply'),
|
applyUpdate: () => invoke('updates:apply'),
|
||||||
saveServer: (server, password = '', passphrase = '') => invoke('server:save', { server, password, passphrase }),
|
saveServer: (server, password = '', passphrase = '') => invoke('server:save', { server, password, passphrase }),
|
||||||
deleteServer: (serverId) => invoke('server:delete', { serverId }),
|
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 }),
|
inspectServerProject: (repository, profileId) => invoke('server:inspect-project', { repository, profileId }),
|
||||||
discoverExistingDeployment: (repository, serverId, remoteFolder) =>
|
discoverExistingDeployment: (repository, serverId, remoteFolder) =>
|
||||||
invoke('server:discover-existing', {
|
invoke('server:discover-existing', {
|
||||||
@@ -54,7 +54,7 @@ contextBridge.exposeInMainWorld(
|
|||||||
serverId,
|
serverId,
|
||||||
remoteFolder,
|
remoteFolder,
|
||||||
}),
|
}),
|
||||||
refreshRepositories: () => invoke('repositories:refresh'),
|
refreshRepositories: (options = {}) => invoke('repositories:refresh', options),
|
||||||
discoverRepositories: (roots) => invoke('repositories:discover', { roots }),
|
discoverRepositories: (roots) => invoke('repositories:discover', { roots }),
|
||||||
favoriteRepository: (fullName, favorite) => invoke('repository:favorite', { fullName, favorite }),
|
favoriteRepository: (fullName, favorite) => invoke('repository:favorite', { fullName, favorite }),
|
||||||
linkRepository: (fullName, localPath) => invoke('repository:link', { fullName, localPath }),
|
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 }),
|
repairGitLocks: (localPath, force = false) => invoke('repository:repair-git-locks', { localPath, force }),
|
||||||
reconcileRepository: (localPath) => invoke('repository:reconcile', { localPath }),
|
reconcileRepository: (localPath) => invoke('repository:reconcile', { localPath }),
|
||||||
repairRepositorySync: (localPath, strategy) => invoke('repository:repair-sync', { localPath, strategy }),
|
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 }),
|
setOrigin: (localPath, remoteUrl) => invoke('repository:set-origin', { localPath, remoteUrl }),
|
||||||
normalizeOrigins: () => invoke('repositories:normalize-origins'),
|
normalizeOrigins: () => invoke('repositories:normalize-origins'),
|
||||||
cloneRepository: (fullName, mode = 'default') => invoke('repository:clone', { fullName, mode }),
|
cloneRepository: (fullName, mode = 'default') => invoke('repository:clone', { fullName, mode }),
|
||||||
|
|||||||
+288
-199
@@ -1,17 +1,89 @@
|
|||||||
{
|
{
|
||||||
"generatedAt": "2026-08-01T15:23:33.157Z",
|
"generatedAt": "2026-08-26T23:19:01.915Z",
|
||||||
"thresholds": {
|
"thresholds": {
|
||||||
"preferredMaximumLines": 750,
|
"preferredMaximumLines": 750,
|
||||||
"justificationRequiredLines": 1000
|
"justificationRequiredLines": 1000
|
||||||
},
|
},
|
||||||
"over750": [],
|
"over750": [
|
||||||
|
{
|
||||||
|
"file": "src/main/git-service.cjs",
|
||||||
|
"lines": 881,
|
||||||
|
"branches": 134,
|
||||||
|
"functions": 147,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"git",
|
||||||
|
"renderer",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 154
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/renderer/views.js",
|
||||||
|
"lines": 876,
|
||||||
|
"branches": 61,
|
||||||
|
"functions": 161,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"renderer",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 101
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/update-service.cjs",
|
||||||
|
"lines": 854,
|
||||||
|
"branches": 64,
|
||||||
|
"functions": 65,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"git",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 74
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/renderer/mock-repository-bridge.js",
|
||||||
|
"lines": 780,
|
||||||
|
"branches": 19,
|
||||||
|
"functions": 100,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 39
|
||||||
|
}
|
||||||
|
],
|
||||||
"over1000": [],
|
"over1000": [],
|
||||||
"cyclomaticHotspots": [
|
"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",
|
"file": "src/renderer/actions/shell.js",
|
||||||
"lines": 518,
|
"lines": 531,
|
||||||
"branches": 101,
|
"branches": 103,
|
||||||
"functions": 86,
|
"functions": 90,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 0,
|
||||||
"responsibilities": [
|
"responsibilities": [
|
||||||
"inventory",
|
"inventory",
|
||||||
@@ -20,7 +92,23 @@
|
|||||||
"renderer",
|
"renderer",
|
||||||
"updates"
|
"updates"
|
||||||
],
|
],
|
||||||
"hotspotScore": 131
|
"hotspotScore": 133
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/renderer/app.js",
|
||||||
|
"lines": 738,
|
||||||
|
"branches": 80,
|
||||||
|
"functions": 124,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"renderer",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 120
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/main/server-inventory.cjs",
|
"file": "src/main/server-inventory.cjs",
|
||||||
@@ -38,39 +126,41 @@
|
|||||||
"hotspotScore": 119
|
"hotspotScore": 119
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/renderer/app.js",
|
"file": "src/main/unraid-inventory-methods.cjs",
|
||||||
"lines": 676,
|
"lines": 710,
|
||||||
"branches": 75,
|
"branches": 76,
|
||||||
"functions": 113,
|
"functions": 93,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 0,
|
||||||
"responsibilities": [
|
"responsibilities": [
|
||||||
"inventory",
|
"inventory",
|
||||||
"deployment",
|
"deployment",
|
||||||
"git",
|
"git",
|
||||||
"renderer",
|
|
||||||
"security",
|
"security",
|
||||||
"updates"
|
"updates"
|
||||||
],
|
],
|
||||||
"hotspotScore": 115
|
"hotspotScore": 106
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "src/main/git-service.cjs",
|
|
||||||
"lines": 632,
|
|
||||||
"branches": 98,
|
|
||||||
"functions": 109,
|
|
||||||
"ipcHandlers": 0,
|
|
||||||
"responsibilities": [
|
|
||||||
"git"
|
|
||||||
],
|
|
||||||
"hotspotScore": 98
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"mixedResponsibilityModules": [
|
"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",
|
"file": "src/renderer/actions/shell.js",
|
||||||
"lines": 518,
|
"lines": 531,
|
||||||
"branches": 101,
|
"branches": 103,
|
||||||
"functions": 86,
|
"functions": 90,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 0,
|
||||||
"responsibilities": [
|
"responsibilities": [
|
||||||
"inventory",
|
"inventory",
|
||||||
@@ -79,7 +169,23 @@
|
|||||||
"renderer",
|
"renderer",
|
||||||
"updates"
|
"updates"
|
||||||
],
|
],
|
||||||
"hotspotScore": 131
|
"hotspotScore": 133
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/renderer/app.js",
|
||||||
|
"lines": 738,
|
||||||
|
"branches": 80,
|
||||||
|
"functions": 124,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"renderer",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 120
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/main/server-inventory.cjs",
|
"file": "src/main/server-inventory.cjs",
|
||||||
@@ -97,10 +203,25 @@
|
|||||||
"hotspotScore": 119
|
"hotspotScore": 119
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/renderer/app.js",
|
"file": "src/main/unraid-inventory-methods.cjs",
|
||||||
"lines": 676,
|
"lines": 710,
|
||||||
"branches": 75,
|
"branches": 76,
|
||||||
"functions": 113,
|
"functions": 93,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 106
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/renderer/views.js",
|
||||||
|
"lines": 876,
|
||||||
|
"branches": 61,
|
||||||
|
"functions": 161,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 0,
|
||||||
"responsibilities": [
|
"responsibilities": [
|
||||||
"inventory",
|
"inventory",
|
||||||
@@ -110,28 +231,27 @@
|
|||||||
"security",
|
"security",
|
||||||
"updates"
|
"updates"
|
||||||
],
|
],
|
||||||
"hotspotScore": 115
|
"hotspotScore": 101
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/main/ipc.cjs",
|
"file": "src/renderer/dialogs.js",
|
||||||
"lines": 749,
|
"lines": 435,
|
||||||
"branches": 55,
|
"branches": 60,
|
||||||
"functions": 79,
|
"functions": 83,
|
||||||
"ipcHandlers": 27,
|
"ipcHandlers": 0,
|
||||||
"responsibilities": [
|
"responsibilities": [
|
||||||
"inventory",
|
"inventory",
|
||||||
"deployment",
|
"deployment",
|
||||||
"git",
|
"git",
|
||||||
"ipc",
|
|
||||||
"renderer",
|
"renderer",
|
||||||
"security",
|
"security",
|
||||||
"updates"
|
"updates"
|
||||||
],
|
],
|
||||||
"hotspotScore": 105
|
"hotspotScore": 100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/main/unraid-deployment-methods.cjs",
|
"file": "src/main/unraid-deployment-methods.cjs",
|
||||||
"lines": 577,
|
"lines": 583,
|
||||||
"branches": 69,
|
"branches": 69,
|
||||||
"functions": 31,
|
"functions": 31,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 0,
|
||||||
@@ -145,41 +265,40 @@
|
|||||||
"hotspotScore": 99
|
"hotspotScore": 99
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/renderer/views.js",
|
"file": "src/main/ipc.cjs",
|
||||||
"lines": 737,
|
"lines": 706,
|
||||||
"branches": 55,
|
"branches": 54,
|
||||||
"functions": 156,
|
"functions": 72,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 27,
|
||||||
"responsibilities": [
|
"responsibilities": [
|
||||||
"inventory",
|
"inventory",
|
||||||
"deployment",
|
"deployment",
|
||||||
"git",
|
"git",
|
||||||
"renderer",
|
"ipc",
|
||||||
"security",
|
"security",
|
||||||
"updates"
|
"updates"
|
||||||
],
|
],
|
||||||
"hotspotScore": 95
|
"hotspotScore": 94
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/main/unraid-inventory-methods.cjs",
|
"file": "src/main/ssh-service.cjs",
|
||||||
"lines": 591,
|
"lines": 513,
|
||||||
"branches": 61,
|
"branches": 70,
|
||||||
"functions": 76,
|
"functions": 101,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 0,
|
||||||
"responsibilities": [
|
"responsibilities": [
|
||||||
"inventory",
|
|
||||||
"deployment",
|
"deployment",
|
||||||
"git",
|
"git",
|
||||||
"security",
|
"security",
|
||||||
"updates"
|
"updates"
|
||||||
],
|
],
|
||||||
"hotspotScore": 91
|
"hotspotScore": 90
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/renderer/actions/setup-and-settings.js",
|
"file": "src/renderer/actions/setup-and-settings.js",
|
||||||
"lines": 427,
|
"lines": 441,
|
||||||
"branches": 58,
|
"branches": 60,
|
||||||
"functions": 58,
|
"functions": 60,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 0,
|
||||||
"responsibilities": [
|
"responsibilities": [
|
||||||
"deployment",
|
"deployment",
|
||||||
@@ -188,7 +307,24 @@
|
|||||||
"security",
|
"security",
|
||||||
"updates"
|
"updates"
|
||||||
],
|
],
|
||||||
"hotspotScore": 88
|
"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",
|
"file": "src/main/gitea-service.cjs",
|
||||||
@@ -206,9 +342,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/renderer/actions/deployment-profile.js",
|
"file": "src/renderer/actions/deployment-profile.js",
|
||||||
"lines": 402,
|
"lines": 408,
|
||||||
"branches": 56,
|
"branches": 57,
|
||||||
"functions": 43,
|
"functions": 44,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 0,
|
||||||
"responsibilities": [
|
"responsibilities": [
|
||||||
"inventory",
|
"inventory",
|
||||||
@@ -217,46 +353,27 @@
|
|||||||
"renderer",
|
"renderer",
|
||||||
"security"
|
"security"
|
||||||
],
|
],
|
||||||
"hotspotScore": 86
|
"hotspotScore": 87
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "main.cjs",
|
"file": "src/renderer/actions/recovery.js",
|
||||||
"lines": 443,
|
"lines": 421,
|
||||||
"branches": 33,
|
"branches": 64,
|
||||||
"functions": 53,
|
"functions": 43,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 0,
|
||||||
"responsibilities": [
|
"responsibilities": [
|
||||||
"inventory",
|
|
||||||
"deployment",
|
|
||||||
"git",
|
|
||||||
"ipc",
|
|
||||||
"renderer",
|
|
||||||
"security",
|
|
||||||
"updates"
|
|
||||||
],
|
|
||||||
"hotspotScore": 83
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "src/main/unraid-preflight-methods.cjs",
|
|
||||||
"lines": 594,
|
|
||||||
"branches": 38,
|
|
||||||
"functions": 42,
|
|
||||||
"ipcHandlers": 0,
|
|
||||||
"responsibilities": [
|
|
||||||
"inventory",
|
|
||||||
"deployment",
|
"deployment",
|
||||||
"git",
|
"git",
|
||||||
"renderer",
|
"renderer",
|
||||||
"security",
|
|
||||||
"updates"
|
"updates"
|
||||||
],
|
],
|
||||||
"hotspotScore": 78
|
"hotspotScore": 84
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/main/config-store.cjs",
|
"file": "src/main/config-store.cjs",
|
||||||
"lines": 652,
|
"lines": 668,
|
||||||
"branches": 47,
|
"branches": 52,
|
||||||
"functions": 86,
|
"functions": 89,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 0,
|
||||||
"responsibilities": [
|
"responsibilities": [
|
||||||
"inventory",
|
"inventory",
|
||||||
@@ -265,7 +382,23 @@
|
|||||||
"security",
|
"security",
|
||||||
"updates"
|
"updates"
|
||||||
],
|
],
|
||||||
"hotspotScore": 77
|
"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",
|
"file": "src/main/unraid-runtime-methods.cjs",
|
||||||
@@ -283,41 +416,11 @@
|
|||||||
],
|
],
|
||||||
"hotspotScore": 77
|
"hotspotScore": 77
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"file": "src/renderer/actions/recovery.js",
|
|
||||||
"lines": 356,
|
|
||||||
"branches": 56,
|
|
||||||
"functions": 38,
|
|
||||||
"ipcHandlers": 0,
|
|
||||||
"responsibilities": [
|
|
||||||
"deployment",
|
|
||||||
"git",
|
|
||||||
"renderer",
|
|
||||||
"updates"
|
|
||||||
],
|
|
||||||
"hotspotScore": 76
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "src/renderer/dialogs.js",
|
|
||||||
"lines": 324,
|
|
||||||
"branches": 35,
|
|
||||||
"functions": 66,
|
|
||||||
"ipcHandlers": 0,
|
|
||||||
"responsibilities": [
|
|
||||||
"inventory",
|
|
||||||
"deployment",
|
|
||||||
"git",
|
|
||||||
"renderer",
|
|
||||||
"security",
|
|
||||||
"updates"
|
|
||||||
],
|
|
||||||
"hotspotScore": 75
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"file": "src/main/unraid-access-methods.cjs",
|
"file": "src/main/unraid-access-methods.cjs",
|
||||||
"lines": 448,
|
"lines": 462,
|
||||||
"branches": 43,
|
"branches": 43,
|
||||||
"functions": 42,
|
"functions": 41,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 0,
|
||||||
"responsibilities": [
|
"responsibilities": [
|
||||||
"inventory",
|
"inventory",
|
||||||
@@ -328,11 +431,26 @@
|
|||||||
],
|
],
|
||||||
"hotspotScore": 73
|
"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",
|
"file": "src/renderer/events.js",
|
||||||
"lines": 181,
|
"lines": 189,
|
||||||
"branches": 35,
|
"branches": 37,
|
||||||
"functions": 27,
|
"functions": 28,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 0,
|
||||||
"responsibilities": [
|
"responsibilities": [
|
||||||
"inventory",
|
"inventory",
|
||||||
@@ -341,7 +459,7 @@
|
|||||||
"renderer",
|
"renderer",
|
||||||
"security"
|
"security"
|
||||||
],
|
],
|
||||||
"hotspotScore": 65
|
"hotspotScore": 67
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/main/git-validator-service.cjs",
|
"file": "src/main/git-validator-service.cjs",
|
||||||
@@ -357,20 +475,6 @@
|
|||||||
],
|
],
|
||||||
"hotspotScore": 63
|
"hotspotScore": 63
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"file": "src/main/ssh-service.cjs",
|
|
||||||
"lines": 333,
|
|
||||||
"branches": 41,
|
|
||||||
"functions": 69,
|
|
||||||
"ipcHandlers": 0,
|
|
||||||
"responsibilities": [
|
|
||||||
"deployment",
|
|
||||||
"git",
|
|
||||||
"security",
|
|
||||||
"updates"
|
|
||||||
],
|
|
||||||
"hotspotScore": 61
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"file": "src/main/deploy-key-lifecycle-service.cjs",
|
"file": "src/main/deploy-key-lifecycle-service.cjs",
|
||||||
"lines": 192,
|
"lines": 192,
|
||||||
@@ -400,20 +504,6 @@
|
|||||||
],
|
],
|
||||||
"hotspotScore": 55
|
"hotspotScore": 55
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"file": "src/main/diagnostics-service.cjs",
|
|
||||||
"lines": 357,
|
|
||||||
"branches": 34,
|
|
||||||
"functions": 50,
|
|
||||||
"ipcHandlers": 0,
|
|
||||||
"responsibilities": [
|
|
||||||
"deployment",
|
|
||||||
"git",
|
|
||||||
"security",
|
|
||||||
"updates"
|
|
||||||
],
|
|
||||||
"hotspotScore": 54
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"file": "src/renderer/actions/inventory.js",
|
"file": "src/renderer/actions/inventory.js",
|
||||||
"lines": 186,
|
"lines": 186,
|
||||||
@@ -445,7 +535,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/main/ipc/deployment-handlers.cjs",
|
"file": "src/main/ipc/deployment-handlers.cjs",
|
||||||
"lines": 285,
|
"lines": 286,
|
||||||
"branches": 13,
|
"branches": 13,
|
||||||
"functions": 38,
|
"functions": 38,
|
||||||
"ipcHandlers": 24,
|
"ipcHandlers": 24,
|
||||||
@@ -461,9 +551,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "preload.cjs",
|
"file": "preload.cjs",
|
||||||
"lines": 162,
|
"lines": 164,
|
||||||
"branches": 2,
|
"branches": 2,
|
||||||
"functions": 123,
|
"functions": 125,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 0,
|
||||||
"responsibilities": [
|
"responsibilities": [
|
||||||
"inventory",
|
"inventory",
|
||||||
@@ -493,7 +583,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/renderer/mock-deployment-bridge.js",
|
"file": "src/renderer/mock-deployment-bridge.js",
|
||||||
"lines": 699,
|
"lines": 701,
|
||||||
"branches": 11,
|
"branches": 11,
|
||||||
"functions": 87,
|
"functions": 87,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 0,
|
||||||
@@ -539,9 +629,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/main/unraid-state-methods.cjs",
|
"file": "src/main/unraid-state-methods.cjs",
|
||||||
"lines": 283,
|
"lines": 294,
|
||||||
"branches": 15,
|
"branches": 16,
|
||||||
"functions": 20,
|
"functions": 21,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 0,
|
||||||
"responsibilities": [
|
"responsibilities": [
|
||||||
"inventory",
|
"inventory",
|
||||||
@@ -550,7 +640,7 @@
|
|||||||
"security",
|
"security",
|
||||||
"updates"
|
"updates"
|
||||||
],
|
],
|
||||||
"hotspotScore": 45
|
"hotspotScore": 46
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/main/preflight-service.cjs",
|
"file": "src/main/preflight-service.cjs",
|
||||||
@@ -566,6 +656,34 @@
|
|||||||
],
|
],
|
||||||
"hotspotScore": 44
|
"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-repository-bridge.js",
|
||||||
|
"lines": 780,
|
||||||
|
"branches": 19,
|
||||||
|
"functions": 100,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 39
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"file": "src/renderer/mock-bridge.js",
|
"file": "src/renderer/mock-bridge.js",
|
||||||
"lines": 590,
|
"lines": 590,
|
||||||
@@ -580,34 +698,6 @@
|
|||||||
],
|
],
|
||||||
"hotspotScore": 37
|
"hotspotScore": 37
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"file": "src/renderer/mock-repository-bridge.js",
|
|
||||||
"lines": 636,
|
|
||||||
"branches": 13,
|
|
||||||
"functions": 89,
|
|
||||||
"ipcHandlers": 0,
|
|
||||||
"responsibilities": [
|
|
||||||
"deployment",
|
|
||||||
"git",
|
|
||||||
"security",
|
|
||||||
"updates"
|
|
||||||
],
|
|
||||||
"hotspotScore": 33
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "src/main/repository-service.cjs",
|
|
||||||
"lines": 200,
|
|
||||||
"branches": 12,
|
|
||||||
"functions": 32,
|
|
||||||
"ipcHandlers": 0,
|
|
||||||
"responsibilities": [
|
|
||||||
"deployment",
|
|
||||||
"git",
|
|
||||||
"security",
|
|
||||||
"updates"
|
|
||||||
],
|
|
||||||
"hotspotScore": 32
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"file": "src/main/deployment-identity.cjs",
|
"file": "src/main/deployment-identity.cjs",
|
||||||
"lines": 36,
|
"lines": 36,
|
||||||
@@ -653,7 +743,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/main/unraid-deploy-key-host.cjs",
|
"file": "src/main/unraid-deploy-key-host.cjs",
|
||||||
"lines": 77,
|
"lines": 80,
|
||||||
"branches": 7,
|
"branches": 7,
|
||||||
"functions": 22,
|
"functions": 22,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 0,
|
||||||
@@ -669,24 +759,23 @@
|
|||||||
"ipcHotspots": [
|
"ipcHotspots": [
|
||||||
{
|
{
|
||||||
"file": "src/main/ipc.cjs",
|
"file": "src/main/ipc.cjs",
|
||||||
"lines": 749,
|
"lines": 706,
|
||||||
"branches": 55,
|
"branches": 54,
|
||||||
"functions": 79,
|
"functions": 72,
|
||||||
"ipcHandlers": 27,
|
"ipcHandlers": 27,
|
||||||
"responsibilities": [
|
"responsibilities": [
|
||||||
"inventory",
|
"inventory",
|
||||||
"deployment",
|
"deployment",
|
||||||
"git",
|
"git",
|
||||||
"ipc",
|
"ipc",
|
||||||
"renderer",
|
|
||||||
"security",
|
"security",
|
||||||
"updates"
|
"updates"
|
||||||
],
|
],
|
||||||
"hotspotScore": 105
|
"hotspotScore": 94
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/main/ipc/deployment-handlers.cjs",
|
"file": "src/main/ipc/deployment-handlers.cjs",
|
||||||
"lines": 285,
|
"lines": 286,
|
||||||
"branches": 13,
|
"branches": 13,
|
||||||
"functions": 38,
|
"functions": 38,
|
||||||
"ipcHandlers": 24,
|
"ipcHandlers": 24,
|
||||||
@@ -702,10 +791,10 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/main/ipc/repository-handlers.cjs",
|
"file": "src/main/ipc/repository-handlers.cjs",
|
||||||
"lines": 413,
|
"lines": 451,
|
||||||
"branches": 16,
|
"branches": 16,
|
||||||
"functions": 79,
|
"functions": 83,
|
||||||
"ipcHandlers": 51,
|
"ipcHandlers": 53,
|
||||||
"responsibilities": [
|
"responsibilities": [
|
||||||
"git",
|
"git",
|
||||||
"ipc"
|
"ipc"
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
# ForgeFlow architecture audit
|
# ForgeFlow architecture audit
|
||||||
|
|
||||||
Generated 2026-08-01T15:23:33.157Z. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity.
|
Generated 2026-08-26T23:19:01.915Z. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity.
|
||||||
|
|
||||||
## Files above 750 lines
|
## Files above 750 lines
|
||||||
|
|
||||||
| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |
|
| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |
|
||||||
|---|---:|---:|---:|---:|---|
|
|---|---:|---:|---:|---:|---|
|
||||||
No findings.
|
| `src/main/git-service.cjs` | 881 | 134 | 147 | 0 | git, renderer, security, updates |
|
||||||
|
| `src/renderer/views.js` | 876 | 61 | 161 | 0 | inventory, deployment, git, renderer, security, updates |
|
||||||
|
| `src/main/update-service.cjs` | 854 | 64 | 65 | 0 | git, security, updates |
|
||||||
|
| `src/renderer/mock-repository-bridge.js` | 780 | 19 | 100 | 0 | deployment, git, security, updates |
|
||||||
|
|
||||||
## Files above 1,000 lines
|
## Files above 1,000 lines
|
||||||
|
|
||||||
@@ -18,10 +21,11 @@ No findings.
|
|||||||
|
|
||||||
| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |
|
| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |
|
||||||
|---|---:|---:|---:|---:|---|
|
|---|---:|---:|---:|---:|---|
|
||||||
| `src/renderer/actions/shell.js` | 518 | 101 | 86 | 0 | inventory, deployment, git, renderer, updates |
|
| `src/main/git-service.cjs` | 881 | 134 | 147 | 0 | git, renderer, security, updates |
|
||||||
|
| `src/renderer/actions/shell.js` | 531 | 103 | 90 | 0 | inventory, deployment, git, renderer, updates |
|
||||||
|
| `src/renderer/app.js` | 738 | 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/server-inventory.cjs` | 578 | 89 | 104 | 0 | inventory, deployment, git, security, updates |
|
||||||
| `src/renderer/app.js` | 676 | 75 | 113 | 0 | inventory, deployment, git, renderer, security, updates |
|
| `src/main/unraid-inventory-methods.cjs` | 710 | 76 | 93 | 0 | inventory, deployment, git, security, updates |
|
||||||
| `src/main/git-service.cjs` | 632 | 98 | 109 | 0 | git |
|
|
||||||
|
|
||||||
## Interpretation
|
## Interpretation
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ param(
|
|||||||
[Parameter(Mandatory = $true)][string]$LogPath,
|
[Parameter(Mandatory = $true)][string]$LogPath,
|
||||||
[Parameter(Mandatory = $true)][string]$StatusPath,
|
[Parameter(Mandatory = $true)][string]$StatusPath,
|
||||||
[Parameter(Mandatory = $true)][string]$UpdateId,
|
[Parameter(Mandatory = $true)][string]$UpdateId,
|
||||||
[switch]$HandshakeOnly
|
[switch]$HandshakeOnly,
|
||||||
|
[switch]$VerifyOnly
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
@@ -51,6 +52,16 @@ function Write-UpdateState {
|
|||||||
function Write-Log([string]$Message) {
|
function Write-Log([string]$Message) {
|
||||||
"{0} {1}" -f [DateTime]::UtcNow.ToString("o"), $Message | Add-Content -LiteralPath $LogPath -Encoding UTF8
|
"{0} {1}" -f [DateTime]::UtcNow.ToString("o"), $Message | Add-Content -LiteralPath $LogPath -Encoding UTF8
|
||||||
}
|
}
|
||||||
|
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 {
|
try {
|
||||||
Write-UpdateState -State "started" -Message "Binary updater owns the update request."
|
Write-UpdateState -State "started" -Message "Binary updater owns the update request."
|
||||||
@@ -59,9 +70,13 @@ try {
|
|||||||
Write-Log "Handshake-only verification completed successfully."
|
Write-Log "Handshake-only verification completed successfully."
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
$actualSha256 = (Get-FileHash -LiteralPath $BinaryPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
$actualSha256 = Get-Sha256 -Path $BinaryPath
|
||||||
if ($actualSha256 -ne $ExpectedSha256.ToLowerInvariant()) { throw "Binary update SHA-256 verification failed." }
|
if ($actualSha256 -ne $ExpectedSha256.ToLowerInvariant()) { throw "Binary update SHA-256 verification failed." }
|
||||||
if (-not (Test-Path -LiteralPath $CurrentExecutable -PathType Leaf)) { throw "Current ForgeFlow executable was not found." }
|
if (-not (Test-Path -LiteralPath $CurrentExecutable -PathType Leaf)) { throw "Current ForgeFlow executable was not found." }
|
||||||
|
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."
|
Write-UpdateState -State "waiting-for-exit" -Message "Waiting for ForgeFlow to close."
|
||||||
try { Wait-Process -Id $ParentPid -Timeout 60 -ErrorAction Stop } catch {
|
try { Wait-Process -Id $ParentPid -Timeout 60 -ErrorAction Stop } catch {
|
||||||
|
|||||||
@@ -71,6 +71,17 @@ function Write-UpdateState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Invoke-Robocopy {
|
function Invoke-Robocopy {
|
||||||
param([string]$From, [string]$To)
|
param([string]$From, [string]$To)
|
||||||
New-Item -ItemType Directory -Force -Path $To | Out-Null
|
New-Item -ItemType Directory -Force -Path $To | Out-Null
|
||||||
@@ -120,7 +131,7 @@ try {
|
|||||||
Start-Sleep -Milliseconds 500
|
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." }
|
if ($actualHash -ne $ExpectedSha256.ToLowerInvariant()) { throw "Update archive checksum mismatch." }
|
||||||
|
|
||||||
$working = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-update-" + [guid]::NewGuid().ToString("N"))
|
$working = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-update-" + [guid]::NewGuid().ToString("N"))
|
||||||
|
|||||||
@@ -117,7 +117,18 @@ app.whenReady().then(async () => {
|
|||||||
reviewBreakdown: Object.fromEntries(Object.entries(report.reviewBreakdown || {}).sort(([left], [right]) => left.localeCompare(right))),
|
reviewBreakdown: Object.fromEntries(Object.entries(report.reviewBreakdown || {}).sort(([left], [right]) => left.localeCompare(right))),
|
||||||
reviewSamples: report.reviewSamples,
|
reviewSamples: report.reviewSamples,
|
||||||
reconciliation: report.reconciliation,
|
reconciliation: report.reconciliation,
|
||||||
access: report.access.map((item) => ({ repository: item.repository, profileId: item.profileId || null, ready: item.ready, readiness: item.readiness || item.action || null, remoteSha: item.remoteSha || item.branchSha || null, liveSha: item.liveSha || null, error: item.error || null })),
|
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 })),
|
review: report.workloads.filter((item) => !item.repository && item.running).map((item) => ({ name: item.name, confidence: item.confidence, folder: item.folder })),
|
||||||
})) : reports;
|
})) : reports;
|
||||||
console.log(JSON.stringify(output, null, 2));
|
console.log(JSON.stringify(output, null, 2));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const fs = require("node:fs/promises");
|
|||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const { execFileSync } = require("node:child_process");
|
const { execFileSync } = require("node:child_process");
|
||||||
const { app, safeStorage } = require("electron");
|
const { app, safeStorage } = require("electron");
|
||||||
|
const { normalizeBaseUrl } = require("../src/shared/validation.cjs");
|
||||||
|
|
||||||
const root = path.resolve(__dirname, "..");
|
const root = path.resolve(__dirname, "..");
|
||||||
const configuredUserData =
|
const configuredUserData =
|
||||||
@@ -59,10 +60,7 @@ app.whenReady().then(async () => {
|
|||||||
const token = safeStorage.decryptString(
|
const token = safeStorage.decryptString(
|
||||||
Buffer.from(config.gitea.encryptedToken, "base64"),
|
Buffer.from(config.gitea.encryptedToken, "base64"),
|
||||||
);
|
);
|
||||||
const baseUrl = String(config.gitea.baseUrl || "").replace(/\/+$/, "");
|
const baseUrl = normalizeBaseUrl(config.gitea.baseUrl);
|
||||||
if (!/^https?:\/\//i.test(baseUrl)) {
|
|
||||||
throw new Error("The configured Gitea base URL is invalid.");
|
|
||||||
}
|
|
||||||
const owner = safeRepositoryPart(
|
const owner = safeRepositoryPart(
|
||||||
process.env.FORGEFLOW_RELEASE_OWNER || config.updates?.owner || "Jens",
|
process.env.FORGEFLOW_RELEASE_OWNER || config.updates?.owner || "Jens",
|
||||||
"Release repository owner",
|
"Release repository owner",
|
||||||
@@ -181,6 +179,8 @@ app.whenReady().then(async () => {
|
|||||||
for (const [name, type] of [
|
for (const [name, type] of [
|
||||||
[`ForgeFlow-${version}-provenance.json`, "application/json"],
|
[`ForgeFlow-${version}-provenance.json`, "application/json"],
|
||||||
[`ForgeFlow-${version}-sbom.cdx.json`, "application/vnd.cyclonedx+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 bytes = await fs.readFile(path.join(root, "dist", name));
|
||||||
const existing = (release.assets || []).find((asset) => asset.name === name);
|
const existing = (release.assets || []).find((asset) => asset.name === name);
|
||||||
@@ -194,6 +194,8 @@ app.whenReady().then(async () => {
|
|||||||
...binaries.flatMap((binaryPath) => [path.basename(binaryPath), `${path.basename(binaryPath)}.sha256`]),
|
...binaries.flatMap((binaryPath) => [path.basename(binaryPath), `${path.basename(binaryPath)}.sha256`]),
|
||||||
`ForgeFlow-${version}-provenance.json`,
|
`ForgeFlow-${version}-provenance.json`,
|
||||||
`ForgeFlow-${version}-sbom.cdx.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));
|
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(", ")}`);
|
if (missingAssets.length) throw new Error(`Release remains draft because required assets are missing: ${missingAssets.join(", ")}`);
|
||||||
|
|||||||
@@ -4,12 +4,17 @@ import path from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'renderer');
|
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 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) => {
|
const server = http.createServer(async (request, response) => {
|
||||||
try {
|
try {
|
||||||
const pathname = decodeURIComponent(new URL(request.url, `http://${request.headers.host}`).pathname);
|
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 relative = pathname === '/' ? 'index.html' : pathname.replace(/^\//, '');
|
||||||
const target = path.resolve(root, relative);
|
const target = path.resolve(root, relative);
|
||||||
if (!target.startsWith(root)) throw Object.assign(new Error('Forbidden'), { code: 'EACCES' });
|
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}`);
|
||||||
+46
-3
@@ -47,6 +47,8 @@ const required = [
|
|||||||
"scripts/validate-installed-connections.cjs",
|
"scripts/validate-installed-connections.cjs",
|
||||||
"scripts/publish-binary-release.cjs",
|
"scripts/publish-binary-release.cjs",
|
||||||
"scripts/write-release-checksums.mjs",
|
"scripts/write-release-checksums.mjs",
|
||||||
|
"scripts/setup-update-signing-key.mjs",
|
||||||
|
"scripts/sign-release-manifest.mjs",
|
||||||
"scripts/prune-dist.mjs",
|
"scripts/prune-dist.mjs",
|
||||||
"scripts/generate-source-manifest.mjs",
|
"scripts/generate-source-manifest.mjs",
|
||||||
"setup-windows.ps1",
|
"setup-windows.ps1",
|
||||||
@@ -86,6 +88,14 @@ const required = [
|
|||||||
"docs/RELEASE_NOTES_0.10.4.md",
|
"docs/RELEASE_NOTES_0.10.4.md",
|
||||||
"docs/RELEASE_NOTES_0.10.5.md",
|
"docs/RELEASE_NOTES_0.10.5.md",
|
||||||
"docs/RELEASE_NOTES_0.10.6.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/RELEASE_NOTES_0.10.14.md",
|
||||||
"docs/UPDATING.md",
|
"docs/UPDATING.md",
|
||||||
"docs/DIAGNOSTICS.md",
|
"docs/DIAGNOSTICS.md",
|
||||||
"docs/DEPLOYMENT_SETUP.md",
|
"docs/DEPLOYMENT_SETUP.md",
|
||||||
@@ -117,6 +127,7 @@ const required = [
|
|||||||
"examples/server/status-example.json",
|
"examples/server/status-example.json",
|
||||||
"build/icon.png",
|
"build/icon.png",
|
||||||
"build/icon.ico",
|
"build/icon.ico",
|
||||||
|
"build/update-signing-public.pem",
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const file of required) await access(path.join(root, file));
|
for (const file of required) await access(path.join(root, file));
|
||||||
@@ -124,9 +135,9 @@ for (const file of required) await access(path.join(root, file));
|
|||||||
const packageJson = JSON.parse(
|
const packageJson = JSON.parse(
|
||||||
await readFile(path.join(root, "package.json"), "utf8"),
|
await readFile(path.join(root, "package.json"), "utf8"),
|
||||||
);
|
);
|
||||||
if (packageJson.version !== "0.10.6")
|
if (packageJson.version !== "0.10.14")
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Expected package version 0.10.6, got ${packageJson.version}.`,
|
`Expected package version 0.10.14, got ${packageJson.version}.`,
|
||||||
);
|
);
|
||||||
const sourceManifest = await readFile(
|
const sourceManifest = await readFile(
|
||||||
path.join(root, "SOURCE_MANIFEST.txt"),
|
path.join(root, "SOURCE_MANIFEST.txt"),
|
||||||
@@ -319,7 +330,7 @@ if (
|
|||||||
)
|
)
|
||||||
throw new Error("PowerShell update helper must start directly with param(.");
|
throw new Error("PowerShell update helper must start directly with param(.");
|
||||||
|
|
||||||
const renderer = (await Promise.all(["app.js", "views.js", "dialogs.js", "operations.js", "actions/shell.js", "actions/inventory.js", "actions/deployment-profile.js", "actions/deployment-operation.js", "actions/setup-and-settings.js", "actions/recovery.js", "actions/command.js", "events.js"].map((file) =>
|
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"),
|
readFile(path.join(root, "src/renderer", file), "utf8"),
|
||||||
))).join("\n");
|
))).join("\n");
|
||||||
const styles = await readFile(
|
const styles = await readFile(
|
||||||
@@ -464,6 +475,38 @@ const release0106 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.6.md
|
|||||||
for (const phrase of ["detached", "PowerShell", "production Node spawn", "source updater", "one-time direct installation"]) {
|
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}`);
|
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 release01014 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.14.md"), "utf8");
|
||||||
|
for (const phrase of ["Help center", "Gitea workspace sync", "repository context", "horizontal tab navigation", "84 browser flows"]) {
|
||||||
|
if (!release01014.includes(phrase)) throw new Error(`0.10.14 release notes are missing: ${phrase}`);
|
||||||
|
}
|
||||||
const configSource = await readFile(path.join(root, "src/main/config-store.cjs"), "utf8");
|
const configSource = await readFile(path.join(root, "src/main/config-store.cjs"), "utf8");
|
||||||
for (const mode of ["server-git", "push-bundle", "monitor-only"]) {
|
for (const mode of ["server-git", "push-bundle", "monitor-only"]) {
|
||||||
if (!configSource.includes(mode)) throw new Error(`Deployment configuration is missing mode: ${mode}`);
|
if (!configSource.includes(mode)) throw new Error(`Deployment configuration is missing mode: ${mode}`);
|
||||||
|
|||||||
@@ -25,7 +25,19 @@ for (const kind of ["Setup", "Portable"]) {
|
|||||||
}
|
}
|
||||||
const commit = String(process.env.FORGEFLOW_BUILD_COMMIT || (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: root })).stdout).trim();
|
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 buildId = String(process.env.FORGEFLOW_BUILD_ID || `${manifest.version}-${commit.slice(0, 12)}`);
|
||||||
const provenance = { schemaVersion: 1, product: "ForgeFlow", version: manifest.version, commit, buildId, createdAt: new Date().toISOString(), signedRelease: process.env.FORGEFLOW_SIGNED_RELEASE === "1", expectedPublisher: process.env.FORGEFLOW_EXPECTED_PUBLISHER || null, artifacts };
|
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");
|
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 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));
|
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));
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ class ConfigStore {
|
|||||||
this.sessionToken = null;
|
this.sessionToken = null;
|
||||||
this.data = structuredClone(DEFAULT_CONFIG);
|
this.data = structuredClone(DEFAULT_CONFIG);
|
||||||
this.saveQueue = Promise.resolve();
|
this.saveQueue = Promise.resolve();
|
||||||
|
this.pendingSave = null;
|
||||||
|
this.lastWrittenSnapshot = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
migrate(parsed) {
|
migrate(parsed) {
|
||||||
@@ -138,16 +140,27 @@ class ConfigStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async save() {
|
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 () => {
|
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 });
|
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||||
const temporary = `${this.filePath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`;
|
const temporary = `${this.filePath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`;
|
||||||
await fs.writeFile(temporary, snapshot, { mode: 0o600 });
|
await fs.writeFile(temporary, snapshot, { mode: 0o600 });
|
||||||
await fs.rename(temporary, this.filePath);
|
await fs.rename(temporary, this.filePath);
|
||||||
try { await fs.chmod(this.filePath, 0o600); } catch {}
|
try { await fs.chmod(this.filePath, 0o600); } catch {}
|
||||||
|
this.lastWrittenSnapshot = snapshot;
|
||||||
};
|
};
|
||||||
this.saveQueue = this.saveQueue.then(operation, operation);
|
this.pendingSave = this.saveQueue.then(operation, operation);
|
||||||
return this.saveQueue;
|
this.saveQueue = this.pendingSave.catch(() => {});
|
||||||
|
return this.pendingSave;
|
||||||
}
|
}
|
||||||
|
|
||||||
getGitValidatorState(fullName) {
|
getGitValidatorState(fullName) {
|
||||||
@@ -602,7 +615,10 @@ class ConfigStore {
|
|||||||
const next = { ...this.data.preferences, ...(preferences || {}) };
|
const next = { ...this.data.preferences, ...(preferences || {}) };
|
||||||
next.repositoryPollSeconds = Math.min(Math.max(Number(next.repositoryPollSeconds) || 4, 2), 60);
|
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.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.autoRefresh = next.autoRefresh !== false;
|
||||||
next.preferredCloneProtocol = ['https', 'ssh'].includes(next.preferredCloneProtocol) ? next.preferredCloneProtocol : 'https';
|
next.preferredCloneProtocol = ['https', 'ssh'].includes(next.preferredCloneProtocol) ? next.preferredCloneProtocol : 'https';
|
||||||
next.diagnosticsEnabled = next.diagnosticsEnabled !== false;
|
next.diagnosticsEnabled = next.diagnosticsEnabled !== false;
|
||||||
|
|||||||
@@ -36,10 +36,6 @@ function isRunningStatus(value) {
|
|||||||
return ['running', 'in_progress', 'processing'].includes(String(value || '').toLowerCase());
|
return ['running', 'in_progress', 'processing'].includes(String(value || '').toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
function isQueuedStatus(value) {
|
|
||||||
return ['pending', 'queued', 'waiting', 'blocked', 'requested'].includes(String(value || '').toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
class DeploymentService {
|
class DeploymentService {
|
||||||
constructor(store, giteaService, gitService, diagnostics = null) {
|
constructor(store, giteaService, gitService, diagnostics = null) {
|
||||||
this.store = store;
|
this.store = store;
|
||||||
@@ -328,8 +324,15 @@ class DeploymentService {
|
|||||||
|
|
||||||
async refreshActiveOperations() {
|
async refreshActiveOperations() {
|
||||||
const active = this.store.data.operations.filter((item) => item.type === 'deployment' && !TERMINAL_STATUSES.has(item.status));
|
const active = this.store.data.operations.filter((item) => item.type === 'deployment' && !TERMINAL_STATUSES.has(item.status));
|
||||||
|
const queue = active.slice(0, 20);
|
||||||
const results = [];
|
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;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ class DiagnosticsService {
|
|||||||
this.preferencesProvider = preferencesProvider;
|
this.preferencesProvider = preferencesProvider;
|
||||||
this.sessionId = crypto.randomUUID();
|
this.sessionId = crypto.randomUUID();
|
||||||
this.writeChain = Promise.resolve();
|
this.writeChain = Promise.resolve();
|
||||||
|
this.pendingLines = [];
|
||||||
|
this.pendingFlush = null;
|
||||||
|
this.securedFiles = new Set();
|
||||||
this.initialized = false;
|
this.initialized = false;
|
||||||
this.lastWriteError = null;
|
this.lastWriteError = null;
|
||||||
this.lastBundlePath = null;
|
this.lastBundlePath = null;
|
||||||
@@ -115,13 +118,25 @@ class DiagnosticsService {
|
|||||||
sessionId: this.sessionId,
|
sessionId: this.sessionId,
|
||||||
details
|
details
|
||||||
});
|
});
|
||||||
const line = `${JSON.stringify(record)}\n`;
|
this.pendingLines.push(`${JSON.stringify(record)}\n`);
|
||||||
this.writeChain = this.writeChain.then(async () => {
|
// 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 {
|
try {
|
||||||
if (!this.initialized) await fs.mkdir(this.logDirectory, { recursive: true, mode: 0o700 });
|
if (!this.initialized) await fs.mkdir(this.logDirectory, { recursive: true, mode: 0o700 });
|
||||||
const target = await this.rotateIfNeeded(this.filePathForToday());
|
const target = await this.rotateIfNeeded(this.filePathForToday());
|
||||||
await fs.appendFile(target, line, { encoding: 'utf8', mode: 0o600 });
|
await fs.appendFile(target, lines, { encoding: 'utf8', mode: 0o600 });
|
||||||
try { await fs.chmod(target, 0o600); } catch {}
|
// 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;
|
this.lastWriteError = null;
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -129,7 +144,8 @@ class DiagnosticsService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return this.writeChain;
|
this.writeChain = this.pendingFlush.catch(() => {});
|
||||||
|
return this.pendingFlush;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug(event, details) { return this.log('debug', event, details); }
|
debug(event, details) { return this.log('debug', event, details); }
|
||||||
@@ -275,9 +291,13 @@ class DiagnosticsService {
|
|||||||
dispatchedAt: operation.dispatchedAt,
|
dispatchedAt: operation.dispatchedAt,
|
||||||
stages: operation.stages,
|
stages: operation.stages,
|
||||||
jobs: operation.jobs,
|
jobs: operation.jobs,
|
||||||
logs: operation.logs,
|
remoteOutput: operation.logs || operation.failure || operation.pollError ? {
|
||||||
failure: operation.failure,
|
included: false,
|
||||||
pollError: operation.pollError,
|
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,
|
applicationState: operation.applicationState,
|
||||||
run: operation.run ? {
|
run: operation.run ? {
|
||||||
id: operation.run.id,
|
id: operation.run.id,
|
||||||
|
|||||||
+261
-12
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const fs = require('node:fs/promises');
|
const fs = require('node:fs/promises');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
const { run } = require('./process-runner.cjs');
|
const { run } = require('./process-runner.cjs');
|
||||||
const { parsePorcelainV2 } = require('../shared/git-status.cjs');
|
const { parsePorcelainV2 } = require('../shared/git-status.cjs');
|
||||||
const { normalizeRemoteUrl } = require('../shared/repository-match.cjs');
|
const { normalizeRemoteUrl } = require('../shared/repository-match.cjs');
|
||||||
|
|
||||||
const COMMON_GIT_LOCK_FILES = ['HEAD.lock', 'index.lock'];
|
const COMMON_GIT_LOCK_FILES = ['HEAD.lock', 'index.lock'];
|
||||||
|
const MAX_UNTRACKED_DIFF_BYTES = 16 * 1024 * 1024;
|
||||||
const {
|
const {
|
||||||
assertSafeRepositoryPath,
|
assertSafeRepositoryPath,
|
||||||
assertRepositoryRelativePath,
|
assertRepositoryRelativePath,
|
||||||
@@ -28,7 +30,42 @@ function parseUnifiedDiff(diffText) {
|
|||||||
return { header, hunks };
|
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 {
|
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() {
|
async isAvailable() {
|
||||||
try {
|
try {
|
||||||
const result = await run('git', ['--version'], { timeout: 10_000 });
|
const result = await run('git', ['--version'], { timeout: 10_000 });
|
||||||
@@ -42,13 +79,24 @@ class GitService {
|
|||||||
const resolved = assertSafeRepositoryPath(repoPath);
|
const resolved = assertSafeRepositoryPath(repoPath);
|
||||||
const stat = await fs.stat(resolved).catch(() => null);
|
const stat = await fs.stat(resolved).catch(() => null);
|
||||||
if (!stat?.isDirectory()) throw new Error('The linked local folder no longer exists.');
|
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 });
|
const result = await run('git', ['rev-parse', '--show-toplevel'], { cwd: resolved, timeout: 15_000 });
|
||||||
return path.resolve(result.stdout.trim());
|
return path.resolve(result.stdout.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
async status(repoPath) {
|
async status(repoPath) {
|
||||||
const root = await this.ensureRepository(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,
|
cwd: root,
|
||||||
timeout: 30_000
|
timeout: 30_000
|
||||||
});
|
});
|
||||||
@@ -66,9 +114,34 @@ class GitService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
remoteUrlCacheKey(repoPath, remote) {
|
||||||
|
return JSON.stringify([path.resolve(repoPath), remote]);
|
||||||
|
}
|
||||||
|
|
||||||
async getRemoteUrl(repoPath, remote = 'origin') {
|
async getRemoteUrl(repoPath, remote = 'origin') {
|
||||||
const result = await run('git', ['remote', 'get-url', remote], { cwd: repoPath, timeout: 15_000 });
|
const cacheKey = this.remoteUrlCacheKey(repoPath, remote);
|
||||||
return result.stdout.trim();
|
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) };
|
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') {
|
async setRemoteUrl(repoPath, remoteUrl, remote = 'origin') {
|
||||||
const root = await this.ensureRepository(repoPath);
|
const root = await this.ensureRepository(repoPath);
|
||||||
const safeRemote = assertCloneRemote(remoteUrl);
|
const safeRemote = assertCloneRemote(remoteUrl);
|
||||||
const name = String(remote || 'origin').trim();
|
const name = String(remote || 'origin').trim();
|
||||||
if (!/^[A-Za-z0-9._-]+$/.test(name)) throw new Error('Invalid Git remote name.');
|
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 });
|
await run('git', ['remote', 'set-url', name, safeRemote], { cwd: root, timeout: 30_000 });
|
||||||
|
this.remoteUrlCache.delete(this.remoteUrlCacheKey(root, name));
|
||||||
return this.status(root);
|
return this.status(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +511,26 @@ class GitService {
|
|||||||
if (!result.stdout && safeFile && !staged) {
|
if (!result.stdout && safeFile && !staged) {
|
||||||
const candidate = path.resolve(root, safeFile);
|
const candidate = path.resolve(root, safeFile);
|
||||||
if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) throw new Error('File path escapes repository root.');
|
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')}`;
|
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;
|
return result.stdout;
|
||||||
@@ -354,8 +592,7 @@ class GitService {
|
|||||||
return { selected, matches };
|
return { selected, matches };
|
||||||
}
|
}
|
||||||
|
|
||||||
async expandSelectedPaths(root, files, { unstagedOnly = false } = {}) {
|
expandStatusPaths(status, files, { unstagedOnly = false } = {}) {
|
||||||
const status = await this.status(root);
|
|
||||||
const { selected, matches } = this.selectedStatusFiles(status, files);
|
const { selected, matches } = this.selectedStatusFiles(status, files);
|
||||||
if (!selected.length) return [];
|
if (!selected.length) return [];
|
||||||
const expanded = new Set();
|
const expanded = new Set();
|
||||||
@@ -367,12 +604,17 @@ class GitService {
|
|||||||
return [...expanded];
|
return [...expanded];
|
||||||
}
|
}
|
||||||
|
|
||||||
async stage(repoPath, files) {
|
async expandSelectedPaths(root, files, options = {}) {
|
||||||
const root = await this.ensureRepository(repoPath);
|
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);
|
const requested = assertRepositoryRelativePaths(files);
|
||||||
if (!requested.length) {
|
if (!requested.length) {
|
||||||
await run('git', ['add', '--all'], { cwd: root, timeout: 60_000 });
|
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
|
// 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
|
// 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
|
// longer exists in either the worktree or HEAD. Staged-only deletions and
|
||||||
// renames are already ready for commit and must therefore be left alone.
|
// 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) {
|
if (selected.length) {
|
||||||
await this.runWithPathspec(root, ['add', '-A'], selected, { timeout: 120_000 });
|
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);
|
return this.status(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,8 +651,9 @@ class GitService {
|
|||||||
|
|
||||||
async prepareSelectedStage(root, files) {
|
async prepareSelectedStage(root, files) {
|
||||||
const selected = assertRepositoryRelativePaths(files);
|
const selected = assertRepositoryRelativePaths(files);
|
||||||
|
let current = null;
|
||||||
if (selected.length) {
|
if (selected.length) {
|
||||||
const current = await this.status(root);
|
current = await this.status(root);
|
||||||
const excludedStaged = current.files
|
const excludedStaged = current.files
|
||||||
.filter((file) => file.staged)
|
.filter((file) => file.staged)
|
||||||
.filter((file) => !selected.includes(file.path) && !(file.originalPath && selected.includes(file.originalPath)))
|
.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.`);
|
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] });
|
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.');
|
if (stagedCheck.exitCode === 0) throw new Error('There are no staged changes to commit.');
|
||||||
return selected;
|
return selected;
|
||||||
|
|||||||
+28
-71
@@ -1,13 +1,17 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const fs = require("node:fs/promises");
|
const fs = require("node:fs/promises");
|
||||||
const { fileURLToPath } = require("node:url");
|
const { dialog, shell, app } = require("electron");
|
||||||
const { ipcMain, dialog, shell, app } = require("electron");
|
|
||||||
const { matchRemoteToRepository } = require("../shared/repository-match.cjs");
|
const { matchRemoteToRepository } = require("../shared/repository-match.cjs");
|
||||||
const {
|
const {
|
||||||
cloneDirectoryName,
|
cloneDirectoryName,
|
||||||
resolveCloneTarget,
|
resolveCloneTarget,
|
||||||
} = require("../shared/clone-target.cjs");
|
} = require("../shared/clone-target.cjs");
|
||||||
|
const {
|
||||||
|
createChannelRegistrar,
|
||||||
|
assertTrustedSender,
|
||||||
|
toErrorPayload,
|
||||||
|
} = require("./ipc/channel.cjs");
|
||||||
const { registerRepositoryIpc } = require("./ipc/repository-handlers.cjs");
|
const { registerRepositoryIpc } = require("./ipc/repository-handlers.cjs");
|
||||||
const { registerDeploymentIpc } = require("./ipc/deployment-handlers.cjs");
|
const { registerDeploymentIpc } = require("./ipc/deployment-handlers.cjs");
|
||||||
const { registerOperationsIpc } = require("./ipc/operations-handlers.cjs");
|
const { registerOperationsIpc } = require("./ipc/operations-handlers.cjs");
|
||||||
@@ -16,67 +20,6 @@ const {
|
|||||||
readEncryptedBackup,
|
readEncryptedBackup,
|
||||||
} = require("./configuration-backup.cjs");
|
} = require("./configuration-backup.cjs");
|
||||||
const { evaluateDeploymentPolicy } = require("../shared/deployment-policy.cjs");
|
const { evaluateDeploymentPolicy } = require("../shared/deployment-policy.cjs");
|
||||||
let diagnosticsService = null;
|
|
||||||
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({
|
function registerIpc({
|
||||||
store,
|
store,
|
||||||
git,
|
git,
|
||||||
@@ -96,7 +39,7 @@ function registerIpc({
|
|||||||
monitor,
|
monitor,
|
||||||
onPreferencesChanged,
|
onPreferencesChanged,
|
||||||
}) {
|
}) {
|
||||||
diagnosticsService = diagnostics;
|
const register = createChannelRegistrar(diagnostics);
|
||||||
const repositoryMutations = new Map();
|
const repositoryMutations = new Map();
|
||||||
const withRepositoryPause = async (localPath, action) => {
|
const withRepositoryPause = async (localPath, action) => {
|
||||||
monitor?.pause(localPath);
|
monitor?.pause(localPath);
|
||||||
@@ -162,8 +105,11 @@ function registerIpc({
|
|||||||
await repositories.refresh();
|
await repositories.refresh();
|
||||||
knownPaths = repositories.getWatchPaths();
|
knownPaths = repositories.getWatchPaths();
|
||||||
}
|
}
|
||||||
const canonicalKnown = await Promise.all(knownPaths.map(canonicalPath));
|
// Watch paths are already canonical, so re-resolving all of them on every
|
||||||
if (!canonicalKnown.some((known) => known === candidate))
|
// 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(
|
throw new Error(
|
||||||
"The requested local repository is not linked or discovered by ForgeFlow.",
|
"The requested local repository is not linked or discovered by ForgeFlow.",
|
||||||
);
|
);
|
||||||
@@ -173,9 +119,7 @@ function registerIpc({
|
|||||||
const resolveRepository = async (repositoryPayload) => {
|
const resolveRepository = async (repositoryPayload) => {
|
||||||
const fullName = String(repositoryPayload?.fullName || "").trim();
|
const fullName = String(repositoryPayload?.fullName || "").trim();
|
||||||
if (!fullName) throw new Error("Repository identity is required.");
|
if (!fullName) throw new Error("Repository identity is required.");
|
||||||
const current = (await repositories.refresh()).find(
|
const current = await repositories.resolveByFullName(fullName);
|
||||||
(item) => item.fullName === fullName,
|
|
||||||
);
|
|
||||||
if (!current)
|
if (!current)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"The repository is no longer available through the configured Gitea account.",
|
"The repository is no longer available through the configured Gitea account.",
|
||||||
@@ -471,13 +415,25 @@ function registerIpc({
|
|||||||
await diagnostics.info("server.deleted", { serverId });
|
await diagnostics.info("server.deleted", { serverId });
|
||||||
return store.getPublicState();
|
return store.getPublicState();
|
||||||
});
|
});
|
||||||
register("server:test", async ({ serverId }) => {
|
register("server:test", async ({ serverId, expectedFingerprint = "" }) => {
|
||||||
const server = store.getServer(serverId);
|
const server = store.getServer(serverId);
|
||||||
if (!server) throw new Error("The configured server no longer exists.");
|
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, {
|
const result = await ssh.test(serverId, {
|
||||||
trustOnFirstUse: !server.hostFingerprint,
|
expectedFingerprint: server.hostFingerprint ? null : expected,
|
||||||
});
|
});
|
||||||
if (!server.hostFingerprint) {
|
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(
|
await store.saveServer(
|
||||||
{ ...server, hostFingerprint: result.fingerprint },
|
{ ...server, hostFingerprint: result.fingerprint },
|
||||||
{},
|
{},
|
||||||
@@ -733,6 +689,7 @@ function registerIpc({
|
|||||||
registerDeploymentIpc({
|
registerDeploymentIpc({
|
||||||
register, store, resolveRepository, unraid, deployments, evaluateDeploymentPolicy,
|
register, store, resolveRepository, unraid, deployments, evaluateDeploymentPolicy,
|
||||||
audit, deployKeys, repositories, inventoryReviews, diagnostics, git, gitea, ssh,
|
audit, deployKeys, repositories, inventoryReviews, diagnostics, git, gitea, ssh,
|
||||||
|
preflight,
|
||||||
});
|
});
|
||||||
registerOperationsIpc({
|
registerOperationsIpc({
|
||||||
register, store, unraid, deployments, diagnostics, shell, dialog, path, app,
|
register, store, unraid, deployments, diagnostics, shell, dialog, path, app,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
function registerDeploymentIpc({
|
function registerDeploymentIpc({
|
||||||
register, store, resolveRepository, unraid, deployments, evaluateDeploymentPolicy,
|
register, store, resolveRepository, unraid, deployments, evaluateDeploymentPolicy,
|
||||||
audit, deployKeys, repositories, inventoryReviews, diagnostics, git, gitea, ssh,
|
audit, deployKeys, repositories, inventoryReviews, diagnostics, git, gitea, ssh,
|
||||||
|
preflight,
|
||||||
}) {
|
}) {
|
||||||
register("deployment:save-profile", async ({ fullName, profile }) => {
|
register("deployment:save-profile", async ({ fullName, profile }) => {
|
||||||
const saved = await store.saveDeploymentProfile(fullName, profile);
|
const saved = await store.saveDeploymentProfile(fullName, profile);
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ function registerRepositoryIpc({
|
|||||||
resolveRepository, cloneRepositoryInto, cloneDirectoryName,
|
resolveRepository, cloneRepositoryInto, cloneDirectoryName,
|
||||||
matchRemoteToRepository, shell, dialog,
|
matchRemoteToRepository, shell, dialog,
|
||||||
}) {
|
}) {
|
||||||
register("repositories:refresh", async () => {
|
register("repositories:refresh", async ({ force = false }) => {
|
||||||
const result = await repositories.refresh();
|
const result = await repositories.refresh({ force: force === true });
|
||||||
monitor?.setPaths(repositories.getWatchPaths());
|
monitor?.setPaths(repositories.getWatchPaths());
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
@@ -265,6 +265,44 @@ function registerRepositoryIpc({
|
|||||||
git.repairSync(safePath, strategy),
|
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 }) => {
|
register("repository:set-origin", async ({ localPath, remoteUrl }) => {
|
||||||
const safePath = await assertKnownRepositoryPath(localPath);
|
const safePath = await assertKnownRepositoryPath(localPath);
|
||||||
return withRepositoryMutation(safePath, () =>
|
return withRepositoryMutation(safePath, () =>
|
||||||
|
|||||||
@@ -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 };
|
||||||
+165
-11
@@ -1,5 +1,16 @@
|
|||||||
'use strict';
|
'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 {
|
class RepositoryMonitor {
|
||||||
constructor({ store, git, onChange, diagnostics = null }) {
|
constructor({ store, git, onChange, diagnostics = null }) {
|
||||||
this.store = store;
|
this.store = store;
|
||||||
@@ -11,12 +22,149 @@ class RepositoryMonitor {
|
|||||||
this.timer = null;
|
this.timer = null;
|
||||||
this.running = false;
|
this.running = false;
|
||||||
this.paused = new Set();
|
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) {
|
setPaths(paths) {
|
||||||
this.paths = [...new Set((paths || []).filter(Boolean))];
|
this.paths = [...new Set((paths || []).filter(Boolean))];
|
||||||
|
const watched = new Set(this.paths);
|
||||||
for (const existing of [...this.fingerprints.keys()]) {
|
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() {
|
restart() {
|
||||||
this.stop();
|
this.stop();
|
||||||
if (!this.store.data.preferences.autoRefresh) return;
|
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);
|
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 = setInterval(() => this.tick().catch((error) => this.diagnostics?.warning('repository-monitor.tick.failed', error)), seconds * 1000);
|
||||||
this.timer.unref?.();
|
this.timer.unref?.();
|
||||||
@@ -34,26 +184,27 @@ class RepositoryMonitor {
|
|||||||
stop() {
|
stop() {
|
||||||
if (this.timer) clearInterval(this.timer);
|
if (this.timer) clearInterval(this.timer);
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
|
if (this.watchTimer) clearTimeout(this.watchTimer);
|
||||||
|
this.watchTimer = null;
|
||||||
|
this.active = false;
|
||||||
|
this.syncWatchers();
|
||||||
}
|
}
|
||||||
|
|
||||||
async tick() {
|
async tick() {
|
||||||
|
void this.fetchRemoteUpdates().catch((error) => this.diagnostics?.warning('repository-monitor.fetch-cycle.failed', error));
|
||||||
if (this.running || !this.paths.length) return;
|
if (this.running || !this.paths.length) return;
|
||||||
this.running = true;
|
this.running = true;
|
||||||
try {
|
try {
|
||||||
const queue = [...this.paths];
|
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 () => {
|
const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
|
||||||
while (queue.length) {
|
while (queue.length) {
|
||||||
const localPath = queue.shift();
|
const localPath = queue.shift();
|
||||||
if (this.paused.has(localPath)) continue;
|
this.changed.delete(localPath);
|
||||||
|
this.lastCheckedAt.set(localPath, Date.now());
|
||||||
try {
|
try {
|
||||||
const status = await this.git.status(localPath);
|
const status = await this.git.status(localPath);
|
||||||
const next = this.git.statusFingerprint(status);
|
await this.recordStatus(localPath, status, 'working-tree-changed');
|
||||||
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) {
|
} catch (error) {
|
||||||
const next = `error:${error.message}`;
|
const next = `error:${error.message}`;
|
||||||
const previous = this.fingerprints.get(localPath);
|
const previous = this.fingerprints.get(localPath);
|
||||||
@@ -68,8 +219,11 @@ class RepositoryMonitor {
|
|||||||
await Promise.all(workers);
|
await Promise.all(workers);
|
||||||
} finally {
|
} finally {
|
||||||
this.running = false;
|
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.gitea = giteaService;
|
||||||
this.diagnostics = diagnostics;
|
this.diagnostics = diagnostics;
|
||||||
this.lastKnownLocalPaths = [];
|
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) {
|
async discoverInRoot(root, maxDepth = 4) {
|
||||||
@@ -51,8 +58,13 @@ class RepositoryService {
|
|||||||
|
|
||||||
let entries;
|
let entries;
|
||||||
try { entries = await fs.readdir(real, { withFileTypes: true }); } catch { return; }
|
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
|
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));
|
(entry) => visit(path.join(real, entry.name), depth + 1));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,13 +92,97 @@ class RepositoryService {
|
|||||||
return [...this.lastKnownLocalPaths];
|
return [...this.lastKnownLocalPaths];
|
||||||
}
|
}
|
||||||
|
|
||||||
async refresh() {
|
async getRemoteRepositories({ force = false } = {}) {
|
||||||
const started = Date.now();
|
if (!this.store.data.gitea.baseUrl || !this.store.getToken()) {
|
||||||
const remoteRepositories = this.store.data.gitea.baseUrl && this.store.getToken()
|
this.lastKnownRemoteRepositories = [];
|
||||||
? await this.gitea.listRepositories()
|
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 mappedPaths = Object.values(this.store.data.repositoryMappings || {});
|
||||||
const localPaths = [...new Set([...discoveredPaths, ...mappedPaths])];
|
const localPaths = [...new Set([...discoveredPaths, ...mappedPaths])];
|
||||||
const localDescriptors = await this.getLocalDescriptors(localPaths);
|
const localDescriptors = await this.getLocalDescriptors(localPaths);
|
||||||
@@ -106,7 +202,12 @@ class RepositoryService {
|
|||||||
...profile,
|
...profile,
|
||||||
state: this.store.getDeploymentState(profile.id)
|
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))) {
|
for (const local of localDescriptors.filter((item) => !usedLocalPaths.has(item.localPath))) {
|
||||||
@@ -145,11 +246,14 @@ class RepositoryService {
|
|||||||
await this.diagnostics?.debug('repositories.refresh.completed', {
|
await this.diagnostics?.debug('repositories.refresh.completed', {
|
||||||
durationMs: Date.now() - started,
|
durationMs: Date.now() - started,
|
||||||
remoteCount: remoteRepositories.length,
|
remoteCount: remoteRepositories.length,
|
||||||
|
remoteStale: remoteResult.stale,
|
||||||
|
remoteCached: remoteResult.cached === true,
|
||||||
discoveredCount: discoveredPaths.length,
|
discoveredCount: discoveredPaths.length,
|
||||||
linkedCount: sorted.filter((item) => item.localPath).length,
|
linkedCount: sorted.filter((item) => item.localPath).length,
|
||||||
attentionCount: sorted.filter((item) => item.attention).length,
|
attentionCount: sorted.filter((item) => item.attention).length,
|
||||||
readyToDeployCount: sorted.filter((item) => item.readyToDeploy).length
|
readyToDeployCount: sorted.filter((item) => item.readyToDeploy).length
|
||||||
});
|
});
|
||||||
|
this.lastResult = sorted;
|
||||||
return sorted;
|
return sorted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+189
-9
@@ -1,6 +1,5 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const fsp = require('node:fs/promises');
|
const fsp = require('node:fs/promises');
|
||||||
const crypto = require('node:crypto');
|
const crypto = require('node:crypto');
|
||||||
const path = require('node:path').posix;
|
const path = require('node:path').posix;
|
||||||
@@ -54,9 +53,29 @@ function parseCapabilityOutput(output) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class SshService {
|
class SshService {
|
||||||
constructor({ store, diagnostics }) {
|
constructor({ store, diagnostics, idleConnectionMs = 60_000, clientFactory = loadSshClient }) {
|
||||||
this.store = store;
|
this.store = store;
|
||||||
this.diagnostics = diagnostics;
|
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 = {}) {
|
async validateServerConfiguration(server, secrets = {}) {
|
||||||
@@ -86,7 +105,7 @@ class SshService {
|
|||||||
return { valid: true, method: 'privateKey', encrypted: Boolean(passphrase), privateKeyPath };
|
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);
|
const credentials = this.store.getServerCredentials(server.id);
|
||||||
let observedFingerprint = null;
|
let observedFingerprint = null;
|
||||||
const options = {
|
const options = {
|
||||||
@@ -98,7 +117,8 @@ class SshService {
|
|||||||
keepaliveCountMax: 3,
|
keepaliveCountMax: 3,
|
||||||
hostVerifier: (key) => {
|
hostVerifier: (key) => {
|
||||||
observedFingerprint = fingerprintKey(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;
|
if (server.authType === 'password') options.password = credentials.password;
|
||||||
@@ -117,7 +137,115 @@ class SshService {
|
|||||||
async withClient(serverId, action, options = {}) {
|
async withClient(serverId, action, options = {}) {
|
||||||
const server = this.store.getServer(serverId);
|
const server = this.store.getServer(serverId);
|
||||||
if (!server) throw new Error('The configured SSH server no longer exists.');
|
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 connection = await this.connectionOptions(server, options);
|
||||||
const client = new Client();
|
const client = new Client();
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
@@ -126,7 +254,8 @@ class SshService {
|
|||||||
const finish = (callback, value) => {
|
const finish = (callback, value) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
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);
|
callback(value);
|
||||||
};
|
};
|
||||||
client.once('ready', async () => {
|
client.once('ready', async () => {
|
||||||
@@ -136,7 +265,11 @@ class SshService {
|
|||||||
finish(resolve, data);
|
finish(resolve, data);
|
||||||
} catch (error) { finish(reject, error); }
|
} 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 observed = connection.getObservedFingerprint();
|
||||||
const mismatch = Boolean(server.hostFingerprint && observed && server.hostFingerprint !== observed);
|
const mismatch = Boolean(server.hostFingerprint && observed && server.hostFingerprint !== observed);
|
||||||
const wrapped = new Error(mismatch
|
const wrapped = new Error(mismatch
|
||||||
@@ -164,6 +297,9 @@ class SshService {
|
|||||||
if (error) {
|
if (error) {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
completed = true;
|
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);
|
reject(error);
|
||||||
return;
|
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) => {
|
return this.withClient(serverId, async (client, server, fingerprint) => {
|
||||||
const script = `
|
const script = `
|
||||||
platform=$(uname -srm 2>/dev/null || true)
|
platform=$(uname -srm 2>/dev/null || true)
|
||||||
@@ -315,7 +495,7 @@ printf 'baseWritable=%s\\n' "$base_writable"
|
|||||||
capabilities,
|
capabilities,
|
||||||
output: [capabilities.platform, capabilities.composeVersion].filter(Boolean).join('\n'),
|
output: [capabilities.platform, capabilities.composeVersion].filter(Boolean).join('\n'),
|
||||||
};
|
};
|
||||||
}, { trustOnFirstUse });
|
}, { trustOnFirstUse, expectedFingerprint });
|
||||||
}
|
}
|
||||||
|
|
||||||
async exec(serverId, command, options = {}) {
|
async exec(serverId, command, options = {}) {
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
function createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto }) {
|
function createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto, parsePermissionInspection, safeRelativeRemoteFile }) {
|
||||||
class UnraidAccessMethods {
|
class UnraidAccessMethods {
|
||||||
serverGitRemote(repository, profile) {
|
serverGitRemote(repository, profile) {
|
||||||
const candidates = [repository.sshUrl, profile.cloneUrl, repository.preferredCloneUrl]
|
const candidates = [
|
||||||
|
repository.localStatus?.remoteUrl,
|
||||||
|
repository.sshUrl,
|
||||||
|
repository.preferredCloneUrl,
|
||||||
|
profile.cloneUrl,
|
||||||
|
]
|
||||||
.map((value) => String(value || "").trim())
|
.map((value) => String(value || "").trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const value = candidates.find((candidate) => /^ssh:\/\//i.test(candidate) || /^[^@\s]+@[^:\s]+:.+/.test(candidate));
|
const value = candidates.find((candidate) => /^ssh:\/\//i.test(candidate) || /^[^@\s]+@[^:\s]+:.+/.test(candidate));
|
||||||
@@ -212,10 +217,19 @@ function createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIden
|
|||||||
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("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("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.");
|
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 failed = checks.some((item) => item.status === "fail");
|
||||||
const incomplete = checks.some((item) => ["warning", "incomplete", "unsupported"].includes(item.status));
|
const incomplete = checks.some((item) => ["warning", "incomplete", "unsupported"].includes(item.status));
|
||||||
const readiness = failed ? (checks.some((item) => item.id.includes("access") || item.id.includes("key")) ? "Access failed" : checks.some((item) => item.id === "runtime" || item.id === "health") ? "Runtime unhealthy" : "Configuration required") : incomplete ? (branchSha && liveSha && branchSha !== liveSha ? "Commit mismatch" : "Verification incomplete") : "Ready";
|
const readiness = deploymentBlockers.length
|
||||||
return { readiness, ready: readiness === "Ready" || readiness === "Commit mismatch", checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, server: { id: server.id, name: server.name }, remotePath, branch: profile.branch, branchSha, liveSha, checks };
|
? "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) {
|
permissionTargets(profile, server, remotePath) {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class UnraidDeployKeyHost {
|
|||||||
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") };
|
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) {
|
remote(repository, profile) {
|
||||||
const value = [repository.sshUrl, profile.cloneUrl, repository.preferredCloneUrl].map((item) => String(item || "").trim()).find((item) => /^ssh:\/\//i.test(item) || /^[^@\s]+@[^:\s]+:.+/.test(item));
|
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" });
|
if (!value) throw Object.assign(new Error("Server pull requires a Gitea SSH URL."), { code: "SERVER_GIT_SSH_URL_REQUIRED" });
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,10 @@ class UnraidDeployKeyHost {
|
|||||||
const f = parseMarker((await this.execute(server, script, { timeout: 45_000, maxOutput: 256 * 1024 })).stdout, marker);
|
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 };
|
return { ready: /^[0-9a-f]{40}$/i.test(f.remoteSha || ""), remoteSha: f.remoteSha || null, fingerprint: f.fingerprint || null, hostFingerprint: f.hostFingerprint || null };
|
||||||
}
|
}
|
||||||
async preflightCandidate(context) { const proof = await this.verifyCandidate(context); if (!proof.ready) throw new Error("Candidate preflight did not prove the remote branch."); return proof; }
|
// 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 }) {
|
async promote({ repository, server, candidate }) {
|
||||||
const p = this.paths(repository, server); const c = candidate.paths;
|
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)}`);
|
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)}`);
|
||||||
|
|||||||
@@ -418,9 +418,12 @@ function createUnraidDeploymentMethods({
|
|||||||
|
|
||||||
const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : "";
|
const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : "";
|
||||||
const iconReference = await this.prepareIcon(profile, repository, server);
|
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, {
|
const metadata = this.metadataCompose(profile, repository, iconReference, {
|
||||||
sha: targetSha,
|
sha: targetSha,
|
||||||
repositoryUrl: profile.cloneUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName,
|
repositoryUrl: deploymentRepositoryUrl,
|
||||||
});
|
});
|
||||||
const previousState = this.store.getDeploymentState?.(profileId) || null;
|
const previousState = this.store.getDeploymentState?.(profileId) || null;
|
||||||
|
|
||||||
@@ -518,9 +521,12 @@ function createUnraidDeploymentMethods({
|
|||||||
});
|
});
|
||||||
const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : "";
|
const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : "";
|
||||||
const iconReference = await this.prepareIcon(profile, repository, server);
|
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, {
|
const metadata = this.metadataCompose(profile, repository, iconReference, {
|
||||||
sha: target,
|
sha: target,
|
||||||
repositoryUrl: profile.cloneUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName,
|
repositoryUrl: rollbackRepositoryUrl,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const mode = rollbackMode;
|
const mode = rollbackMode;
|
||||||
|
|||||||
@@ -490,7 +490,7 @@ for (const name of Object.getOwnPropertyNames(preflightMethods)) {
|
|||||||
if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(preflightMethods, name));
|
if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(preflightMethods, name));
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessMethods = createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto });
|
const accessMethods = createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto, parsePermissionInspection, safeRelativeRemoteFile });
|
||||||
for (const name of Object.getOwnPropertyNames(accessMethods)) {
|
for (const name of Object.getOwnPropertyNames(accessMethods)) {
|
||||||
if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(accessMethods, name));
|
if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(accessMethods, name));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,14 +34,23 @@ function createUnraidInventoryMethods({
|
|||||||
fi
|
fi
|
||||||
if [ -n "$ids" ]; then
|
if [ -n "$ids" ]; then
|
||||||
disappeared=0
|
disappeared=0
|
||||||
while IFS= read -r container_id; do
|
mapfile -t container_ids <<< "$ids"
|
||||||
[ -n "$container_id" ] || continue
|
# Docker accepts multiple IDs and returns one JSON array. This avoids one
|
||||||
if inspect=$(docker inspect --format '{"id":{{json .Id}},"name":{{json .Name}},"image":{{json .Config.Image}},"imageId":{{json .Image}},"running":{{json .State.Running}},"status":{{json .State.Status}},"health":{{if .State.Health}}{{json .State.Health.Status}}{{else}}null{{end}},"labels":{{json .Config.Labels}},"ports":{{json .NetworkSettings.Ports}},"mounts":{{json .Mounts}},"networks":{{json .NetworkSettings.Networks}},"restartPolicy":{{json .HostConfig.RestartPolicy.Name}}}' "$container_id" 2>/dev/null); then
|
# daemon round-trip per container on larger Unraid installations.
|
||||||
printf 'C\\t%s\\n' "$(printf '%s' "$inspect" | base64 | tr -d '\\r\\n')"
|
if inspect=$(docker inspect "\${container_ids[@]}" 2>/dev/null); then
|
||||||
else
|
printf 'C\\t%s\\n' "$(printf '%s' "$inspect" | base64 | tr -d '\\r\\n')"
|
||||||
disappeared=$((disappeared + 1))
|
else
|
||||||
fi
|
# A container can disappear between docker ps and inspect. Fall back to
|
||||||
done <<< "$ids"
|
# 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
|
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')"
|
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
|
||||||
@@ -95,26 +104,35 @@ function createUnraidInventoryMethods({
|
|||||||
(
|
(
|
||||||
set -- -f "$primary"
|
set -- -f "$primary"
|
||||||
files_text=$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
|
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
|
[ -f "$extra" ] || continue
|
||||||
|
has_override=true
|
||||||
set -- "$@" -f "$extra"
|
set -- "$@" -f "$extra"
|
||||||
files_text="$files_text
|
files_text="$files_text
|
||||||
$extra"
|
$extra"
|
||||||
done
|
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)
|
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")
|
[ -n "$project_name" ] || project_name=$(basename "$dir")
|
||||||
valid=false; services=''; images=''; compose_error=''
|
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
|
if [ "$compose_ok" != true ]; then
|
||||||
compose_error='Docker Compose is unavailable; file metadata was still detected.'
|
compose_error='Docker Compose is unavailable; file metadata was still detected.'
|
||||||
elif [ "$compose_v2" = true ]; then
|
elif [ "$compose_v2" = true ]; then
|
||||||
if services=$(cd "$dir" && docker compose "$@" config --services 2>&1); then
|
if services=$(cd "$dir" && docker compose "$@" config --services 2>&1); then
|
||||||
valid=true
|
valid=true
|
||||||
images=$(cd "$dir" && docker compose "$@" config --images 2>/dev/null || 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 compose_error=$services; services=''; fi
|
||||||
else
|
else
|
||||||
if services=$(cd "$dir" && docker-compose "$@" config --services 2>&1); then
|
if services=$(cd "$dir" && docker-compose "$@" config --services 2>&1); then
|
||||||
valid=true
|
valid=true
|
||||||
images=$(cd "$dir" && docker-compose "$@" config --images 2>/dev/null || 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 compose_error=$services; services=''; fi
|
||||||
fi
|
fi
|
||||||
if [ -z "$services" ]; then
|
if [ -z "$services" ]; then
|
||||||
@@ -126,15 +144,6 @@ function createUnraidInventoryMethods({
|
|||||||
}
|
}
|
||||||
' "$primary" 2>/dev/null || true)
|
' "$primary" 2>/dev/null || true)
|
||||||
fi
|
fi
|
||||||
if [ -z "$images" ]; then
|
|
||||||
images=$(awk '
|
|
||||||
/^[[:space:]]*services:[[:space:]]*($|#)/ { in_services=1; next }
|
|
||||||
in_services && /^[^[:space:]]/ { exit }
|
|
||||||
in_services && /^[[:space:]]+image:[[:space:]]*/ {
|
|
||||||
line=$0; sub(/^[[:space:]]*image:[[:space:]]*/, "", line); sub(/[[:space:]]+#.*/, "", line); gsub(/"/, "", line); print line
|
|
||||||
}
|
|
||||||
' "$primary" 2>/dev/null || true)
|
|
||||||
fi
|
|
||||||
printf 'Y\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \\
|
printf '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' "$dir" | base64 | tr -d '\\r\\n')" \\
|
||||||
"$(printf '%s' "$files_text" | base64 | tr -d '\\r\\n')" \\
|
"$(printf '%s' "$files_text" | base64 | tr -d '\\r\\n')" \\
|
||||||
@@ -251,19 +260,38 @@ function createUnraidInventoryMethods({
|
|||||||
}
|
}
|
||||||
|
|
||||||
refreshedProfileFromWorkload(repository, server, workload, existingProfile) {
|
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, {
|
const detected = this.profileFromWorkload(repository, server, workload, {
|
||||||
linkSource: existingProfile.workloadIdentity?.linkSource || "automatic-compose",
|
linkSource: existingProfile.workloadIdentity?.linkSource || "automatic-compose",
|
||||||
deploymentMode: ["push-bundle", "server-git", "monitor-only"].includes(existingProfile.deploymentMode)
|
deploymentMode: ["push-bundle", "server-git", "monitor-only"].includes(existingProfile.deploymentMode)
|
||||||
? existingProfile.deploymentMode
|
? existingProfile.deploymentMode
|
||||||
: "push-bundle",
|
: "push-bundle",
|
||||||
remoteFolder: workload.remoteFolderCandidate || existingProfile.remoteFolder,
|
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 {
|
return {
|
||||||
...existingProfile,
|
...existingProfile,
|
||||||
deploymentMode: detected.deploymentMode,
|
deploymentMode: detected.deploymentMode,
|
||||||
remoteFolder: detected.remoteFolder,
|
remoteFolder: detected.remoteFolder,
|
||||||
composeFile: detected.composeFile,
|
composeFile: composeFiles[0],
|
||||||
composeFiles: detected.composeFiles,
|
composeFiles,
|
||||||
composeProject: detected.composeProject,
|
composeProject: detected.composeProject,
|
||||||
composeWorkingDir: detected.composeWorkingDir,
|
composeWorkingDir: detected.composeWorkingDir,
|
||||||
composeService: detected.composeService,
|
composeService: detected.composeService,
|
||||||
@@ -287,7 +315,7 @@ function createUnraidInventoryMethods({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveWorkloadState(profile, workload, server) {
|
async saveWorkloadState(profile, workload, server, { expectedGiteaSha = null, health = null } = {}) {
|
||||||
const candidateSha = String(workload.metadata?.liveRevision || "");
|
const candidateSha = String(workload.metadata?.liveRevision || "");
|
||||||
const previousState = this.store.getDeploymentState?.(profile.id) || {};
|
const previousState = this.store.getDeploymentState?.(profile.id) || {};
|
||||||
const observedLiveSha = /^[0-9a-f]{40,64}$/i.test(candidateSha) ? candidateSha.toLowerCase() : null;
|
const observedLiveSha = /^[0-9a-f]{40,64}$/i.test(candidateSha) ? candidateSha.toLowerCase() : null;
|
||||||
@@ -295,11 +323,21 @@ function createUnraidInventoryMethods({
|
|||||||
const profileRemote = inventoryRemoteIdentity(profile.cloneUrl);
|
const profileRemote = inventoryRemoteIdentity(profile.cloneUrl);
|
||||||
const workloadRemote = inventoryRemoteIdentity(workload.metadata?.sourceRepository);
|
const workloadRemote = inventoryRemoteIdentity(workload.metadata?.sourceRepository);
|
||||||
const repositoryMatches = Boolean(observedLiveSha && profileRemote && workloadRemote && profileRemote === workloadRemote);
|
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 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, {
|
return this.store.saveDeploymentState(profile.id, {
|
||||||
liveSha,
|
liveSha,
|
||||||
healthy: workload.runtime.health === "healthy" ? true : workload.runtime.health === "unhealthy" ? false : null,
|
healthy: effectiveHealthy,
|
||||||
runtimeVerification: workload.runtime.health === "unverified" ? "running-unverified" : workload.runtime.health,
|
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,
|
containerRunning: workload.runtime.running,
|
||||||
dockerHealth: primary.health || null,
|
dockerHealth: primary.health || null,
|
||||||
containerName: primary.name || profile.containerName,
|
containerName: primary.name || profile.containerName,
|
||||||
@@ -309,8 +347,8 @@ function createUnraidInventoryMethods({
|
|||||||
composeProject: workload.compose?.project || null,
|
composeProject: workload.compose?.project || null,
|
||||||
observedAt: workload.observedAt,
|
observedAt: workload.observedAt,
|
||||||
evidence: liveSha ? "container-provenance-label" : "runtime-only",
|
evidence: liveSha ? "container-provenance-label" : "runtime-only",
|
||||||
giteaSha: repositoryMatches ? observedLiveSha : previousState.giteaSha || null,
|
giteaSha: verifiedGiteaSha,
|
||||||
matchesGitea: repositoryMatches ? true : previousState.matchesGitea === true && previousState.liveSha === liveSha,
|
matchesGitea,
|
||||||
previousSha: previousState.previousSha || null,
|
previousSha: previousState.previousSha || null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -384,15 +422,43 @@ function createUnraidInventoryMethods({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async scanServerInventory(serverId, repositories) {
|
async scanServerInventory(serverId, repositories, { autoLink = false } = {}) {
|
||||||
|
const started = Date.now();
|
||||||
const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories);
|
const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories);
|
||||||
const response = this.inventoryResponse(server, inventory, workloads);
|
let adopted = 0;
|
||||||
|
const adoptedLinks = [];
|
||||||
|
if (autoLink) {
|
||||||
|
const plan = this.reconciliationPlan(server, workloads, repositories, { autoLink: true });
|
||||||
|
if (plan.additions.length) await this.store.createRecoverySnapshot?.(`automatic-server-links-${serverId}`);
|
||||||
|
const linkedRepositories = new Set(workloads
|
||||||
|
.filter((workload) => workload.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", {
|
await this.diagnostics?.info("unraid.workloads.scanned", {
|
||||||
serverId,
|
serverId,
|
||||||
detected: response.detected,
|
detected: response.detected,
|
||||||
linked: response.linked,
|
linked: response.linked,
|
||||||
needsReview: response.needsReview,
|
needsReview: response.needsReview,
|
||||||
readOnly: true,
|
adopted,
|
||||||
|
adoptedLinks,
|
||||||
|
readOnly: !autoLink,
|
||||||
|
durationMs: Date.now() - started,
|
||||||
});
|
});
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@@ -400,7 +466,9 @@ function createUnraidInventoryMethods({
|
|||||||
reconciliationPlan(server, workloads, repositories, { autoLink = true } = {}) {
|
reconciliationPlan(server, workloads, repositories, { autoLink = true } = {}) {
|
||||||
const profiles = this.allSshProfiles().filter((profile) => profile.serverId === server.id);
|
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 activeWorkloadIds = new Set(workloads.filter((item) => item.classification?.type !== "stale-link").map((item) => item.workloadId));
|
||||||
const linkedRepositories = new Set(workloads.filter((item) => item.link?.repositoryFullName).map((item) => String(item.link.repositoryFullName).toLowerCase()));
|
const linkedRepositories = new Set(workloads
|
||||||
|
.filter((item) => item.classification?.type !== "stale-link" && item.link?.repositoryFullName)
|
||||||
|
.map((item) => String(item.link.repositoryFullName).toLowerCase()));
|
||||||
const additions = [];
|
const additions = [];
|
||||||
const updates = [];
|
const updates = [];
|
||||||
const conflicts = [];
|
const conflicts = [];
|
||||||
@@ -428,6 +496,7 @@ function createUnraidInventoryMethods({
|
|||||||
evidence: candidate.exact ? "exact-provenance" : "exact-runtime-identity",
|
evidence: candidate.exact ? "exact-provenance" : "exact-runtime-identity",
|
||||||
impact: "Create a server-pull deployment profile; no container changes",
|
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)) {
|
} else if (["suggested", "ambiguous"].includes(workload.status) || (workload.runtime?.running && workload.candidates?.length)) {
|
||||||
conflicts.push({
|
conflicts.push({
|
||||||
workloadId: workload.workloadId,
|
workloadId: workload.workloadId,
|
||||||
@@ -559,7 +628,57 @@ function createUnraidInventoryMethods({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async discoverServerWorkloads(serverId, repositories) {
|
async discoverServerWorkloads(serverId, repositories) {
|
||||||
return this.scanServerInventory(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 = "" }) {
|
async linkServerWorkload({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) {
|
||||||
|
|||||||
@@ -283,6 +283,35 @@ function createUnraidPreflightMethods({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
const connection = await this.ssh.test(server.id, { trustOnFirstUse: false });
|
const connection = await this.ssh.test(server.id, { trustOnFirstUse: false });
|
||||||
connectionCapabilities = connection.capabilities || {};
|
connectionCapabilities = connection.capabilities || {};
|
||||||
|
|||||||
@@ -59,18 +59,23 @@ function createUnraidStateMethods({ path, bash, shellQuote, inventoryRemoteIdent
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const health = await this.checkHealth(profile.healthcheckUrl);
|
const containerRunning = fields.containerRunning === "true";
|
||||||
|
const health = containerRunning
|
||||||
|
? await this.checkHealth(profile.healthcheckUrl)
|
||||||
|
: { configured: false, healthy: false, skipped: "container-stopped" };
|
||||||
const dockerHealthy = fields.dockerHealth
|
const dockerHealthy = fields.dockerHealth
|
||||||
? fields.dockerHealth === "healthy"
|
? fields.dockerHealth === "healthy"
|
||||||
: null;
|
: null;
|
||||||
const effectiveHealthy = health.configured ? health.healthy : dockerHealthy;
|
const effectiveHealthy = !containerRunning ? false : health.configured ? health.healthy : dockerHealthy;
|
||||||
const runtimeVerification = health.configured
|
const runtimeVerification = !containerRunning
|
||||||
|
? "stopped"
|
||||||
|
: health.configured
|
||||||
? "desktop-healthcheck"
|
? "desktop-healthcheck"
|
||||||
: dockerHealthy === true
|
: dockerHealthy === true
|
||||||
? "docker-healthcheck"
|
? "docker-healthcheck"
|
||||||
: dockerHealthy === false
|
: dockerHealthy === false
|
||||||
? "docker-unhealthy"
|
? "docker-unhealthy"
|
||||||
: fields.containerRunning === "true"
|
: containerRunning
|
||||||
? "running-unverified"
|
? "running-unverified"
|
||||||
: "stopped";
|
: "stopped";
|
||||||
return this.store.saveDeploymentState(profile.id, {
|
return this.store.saveDeploymentState(profile.id, {
|
||||||
@@ -85,7 +90,7 @@ function createUnraidStateMethods({ path, bash, shellQuote, inventoryRemoteIdent
|
|||||||
healthStatus: health.status,
|
healthStatus: health.status,
|
||||||
healthLatencyMs: health.latencyMs,
|
healthLatencyMs: health.latencyMs,
|
||||||
containerName,
|
containerName,
|
||||||
containerRunning: fields.containerRunning === "true",
|
containerRunning,
|
||||||
dockerHealth: fields.dockerHealth || null,
|
dockerHealth: fields.dockerHealth || null,
|
||||||
dockerMan: {
|
dockerMan: {
|
||||||
webUi: decode(fields.webUiLabel),
|
webUi: decode(fields.webUiLabel),
|
||||||
@@ -122,9 +127,6 @@ function createUnraidStateMethods({ path, bash, shellQuote, inventoryRemoteIdent
|
|||||||
// profile hint no longer matches the real Compose service keys.
|
// profile hint no longer matches the real Compose service keys.
|
||||||
return this.refreshProfileState(repository.fullName, profileId);
|
return this.refreshProfileState(repository.fullName, profileId);
|
||||||
}
|
}
|
||||||
const composeFile = profile.generatedCompose
|
|
||||||
? ".forgeflow/compose.forgeflow.yml"
|
|
||||||
: safeRelativeRemoteFile(profile.composeFile || "docker-compose.yml");
|
|
||||||
const iconReference = await this.prepareIcon(profile, repository, server);
|
const iconReference = await this.prepareIcon(profile, repository, server);
|
||||||
const metadata = this.metadataCompose(profile, repository, iconReference);
|
const metadata = this.metadataCompose(profile, repository, iconReference);
|
||||||
const compose = this.composeInvocation(profile, repository);
|
const compose = this.composeInvocation(profile, repository);
|
||||||
@@ -273,7 +275,16 @@ function createUnraidStateMethods({ path, bash, shellQuote, inventoryRemoteIdent
|
|||||||
item.status,
|
item.status,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return Promise.all(active.map((item) => this.refreshOperation(item.id)));
|
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;
|
return UnraidStateMethods.prototype;
|
||||||
|
|||||||
+157
-21
@@ -14,6 +14,85 @@ function safeRepositoryPart(value, label) {
|
|||||||
return text;
|
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) {
|
function delay(ms) {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
@@ -156,6 +235,7 @@ class UpdateService {
|
|||||||
powershellPath = null,
|
powershellPath = null,
|
||||||
handshakeTimeoutMs = 12000,
|
handshakeTimeoutMs = 12000,
|
||||||
handshakePollMs = 100,
|
handshakePollMs = 100,
|
||||||
|
updatePublicKey = null,
|
||||||
}) {
|
}) {
|
||||||
this.store = store;
|
this.store = store;
|
||||||
this.gitea = gitea;
|
this.gitea = gitea;
|
||||||
@@ -168,6 +248,7 @@ class UpdateService {
|
|||||||
this.powershellPath = powershellPath;
|
this.powershellPath = powershellPath;
|
||||||
this.handshakeTimeoutMs = handshakeTimeoutMs;
|
this.handshakeTimeoutMs = handshakeTimeoutMs;
|
||||||
this.handshakePollMs = handshakePollMs;
|
this.handshakePollMs = handshakePollMs;
|
||||||
|
this.updatePublicKey = updatePublicKey;
|
||||||
this.staged = null;
|
this.staged = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +372,7 @@ class UpdateService {
|
|||||||
));
|
));
|
||||||
if (!release || release.draft || release.prerelease) {
|
if (!release || release.draft || release.prerelease) {
|
||||||
const error = new Error(
|
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";
|
error.code = "BINARY_RELEASE_NOT_FOUND";
|
||||||
throw error;
|
throw error;
|
||||||
@@ -300,33 +381,70 @@ class UpdateService {
|
|||||||
const portable = Boolean(this.appInfo.portableExecutablePath);
|
const portable = Boolean(this.appInfo.portableExecutablePath);
|
||||||
const assetName = `ForgeFlow-${portable ? "Portable" : "Setup"}-${update.remoteVersion}-win-x64.exe`;
|
const assetName = `ForgeFlow-${portable ? "Portable" : "Setup"}-${update.remoteVersion}-win-x64.exe`;
|
||||||
const checksumName = `${assetName}.sha256`;
|
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 assets = Array.isArray(release.assets) ? release.assets : [];
|
||||||
const asset = assets.find((item) => item.name === assetName);
|
const asset = assets.find((item) => item.name === assetName);
|
||||||
const checksumAsset = assets.find((item) => item.name === checksumName);
|
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(
|
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";
|
error.code = "BINARY_RELEASE_INCOMPLETE";
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [binary, checksumBytes] = await Promise.all([
|
const [binary, checksumBytes, manifestBytes, signatureBytes] =
|
||||||
this.gitea.downloadReleaseAsset(
|
await Promise.all([
|
||||||
update.owner,
|
this.gitea.downloadReleaseAsset(
|
||||||
update.repo,
|
update.owner,
|
||||||
release.id,
|
update.repo,
|
||||||
asset.id,
|
release.id,
|
||||||
{ downloadUrl: asset.browser_download_url },
|
asset.id,
|
||||||
),
|
{ downloadUrl: asset.browser_download_url },
|
||||||
this.gitea.downloadReleaseAsset(
|
),
|
||||||
update.owner,
|
this.gitea.downloadReleaseAsset(
|
||||||
update.repo,
|
update.owner,
|
||||||
release.id,
|
update.repo,
|
||||||
checksumAsset.id,
|
release.id,
|
||||||
{ downloadUrl: checksumAsset.browser_download_url },
|
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) {
|
if (binary.length < 1_000_000 || binary[0] !== 0x4d || binary[1] !== 0x5a) {
|
||||||
const preview = binary.subarray(0, 200).toString("utf8").trim();
|
const preview = binary.subarray(0, 200).toString("utf8").trim();
|
||||||
const looksLikeMetadata =
|
const looksLikeMetadata =
|
||||||
@@ -348,6 +466,16 @@ class UpdateService {
|
|||||||
?.toLowerCase();
|
?.toLowerCase();
|
||||||
if (!/^[a-f0-9]{64}$/.test(expectedSha256 || ""))
|
if (!/^[a-f0-9]{64}$/.test(expectedSha256 || ""))
|
||||||
throw new Error("The release SHA-256 file is invalid.");
|
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");
|
const sha256 = crypto.createHash("sha256").update(binary).digest("hex");
|
||||||
if (sha256 !== expectedSha256)
|
if (sha256 !== expectedSha256)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -368,6 +496,8 @@ class UpdateService {
|
|||||||
? this.appInfo.portableExecutablePath
|
? this.appInfo.portableExecutablePath
|
||||||
: this.appInfo.executablePath,
|
: this.appInfo.executablePath,
|
||||||
releaseTag: release.tag_name,
|
releaseTag: release.tag_name,
|
||||||
|
publisherKeyId: manifest.signature.keyId,
|
||||||
|
releaseManifest: manifestName,
|
||||||
downloadedAt: new Date().toISOString(),
|
downloadedAt: new Date().toISOString(),
|
||||||
downloaded: true,
|
downloaded: true,
|
||||||
};
|
};
|
||||||
@@ -383,6 +513,7 @@ class UpdateService {
|
|||||||
bytes: binary.length,
|
bytes: binary.length,
|
||||||
sha256,
|
sha256,
|
||||||
portable,
|
portable,
|
||||||
|
publisherKeyId: manifest.signature.keyId,
|
||||||
});
|
});
|
||||||
return metadata;
|
return metadata;
|
||||||
}
|
}
|
||||||
@@ -499,7 +630,9 @@ class UpdateService {
|
|||||||
5000,
|
5000,
|
||||||
);
|
);
|
||||||
child.once?.("spawn", () => finish(resolve));
|
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);
|
if (!child.once) finish(resolve);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -630,7 +763,9 @@ class UpdateService {
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
resolve();
|
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);
|
clearTimeout(timer);
|
||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
@@ -709,6 +844,7 @@ class UpdateService {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
UpdateService,
|
UpdateService,
|
||||||
safeRepositoryPart,
|
safeRepositoryPart,
|
||||||
|
verifyReleaseManifest,
|
||||||
resolveWindowsPowerShellPath,
|
resolveWindowsPowerShellPath,
|
||||||
windowsUpdaterSpawnOptions,
|
windowsUpdaterSpawnOptions,
|
||||||
waitForUpdaterStarted,
|
waitForUpdaterStarted,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ async function handleDeploymentProfileActions(event, target, action, repository)
|
|||||||
};
|
};
|
||||||
render();
|
render();
|
||||||
} else if (action === "close-modal") {
|
} else if (action === "close-modal") {
|
||||||
|
if (ui.modal?.type === "workspace-sync") ui.workspaceSyncPlan = null;
|
||||||
ui.modal = null;
|
ui.modal = null;
|
||||||
render();
|
render();
|
||||||
} else if (action === "select-profile-icon") {
|
} else if (action === "select-profile-icon") {
|
||||||
@@ -337,8 +338,13 @@ async function handleDeploymentProfileActions(event, target, action, repository)
|
|||||||
const result = await window.forgeflow.verifyServerGitProfile(repository, profileId);
|
const result = await window.forgeflow.verifyServerGitProfile(repository, profileId);
|
||||||
ui.serverGitVerifications[profileId] = result;
|
ui.serverGitVerifications[profileId] = result;
|
||||||
render();
|
render();
|
||||||
const failures = result.checks.filter((check) => check.status === "fail");
|
const blockers = result.deploymentBlockers || [];
|
||||||
showToast(result.readiness, failures[0]?.detail || `Verified ${result.checks.length} server-pull checks without changing the server.`, result.ready ? "success" : "warning");
|
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) {
|
} catch (error) {
|
||||||
showToast("Server-pull verification failed", error.message, "error");
|
showToast("Server-pull verification failed", error.message, "error");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -121,6 +121,71 @@ Force repair after you have closed all Git tools for this repository?`)
|
|||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
render();
|
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") {
|
} else if (action === "repair-repository-sync") {
|
||||||
if (!repository?.localPath) return;
|
if (!repository?.localPath) return;
|
||||||
const strategy = target.dataset.strategy;
|
const strategy = target.dataset.strategy;
|
||||||
|
|||||||
@@ -242,7 +242,18 @@ async function handleSetupAndSettingsActions(event, target, action, repository)
|
|||||||
"Checking SSH identity, Docker, Compose and optional Git capabilities…",
|
"Checking SSH identity, Docker, Compose and optional Git capabilities…",
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const result = await window.forgeflow.testServer(target.dataset.serverId);
|
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;
|
ui.boot.state = result.state;
|
||||||
const capabilities = result.capabilities || {};
|
const capabilities = result.capabilities || {};
|
||||||
const deploymentReady =
|
const deploymentReady =
|
||||||
@@ -329,6 +340,9 @@ async function handleSetupAndSettingsActions(event, target, action, repository)
|
|||||||
operationPollSeconds: Number(
|
operationPollSeconds: Number(
|
||||||
document.querySelector("#pref-operation-poll").value,
|
document.querySelector("#pref-operation-poll").value,
|
||||||
),
|
),
|
||||||
|
fetchIntervalMinutes: Number(
|
||||||
|
document.querySelector("#pref-fetch-interval").value,
|
||||||
|
),
|
||||||
preferredCloneProtocol: document.querySelector("#pref-clone-protocol")
|
preferredCloneProtocol: document.querySelector("#pref-clone-protocol")
|
||||||
.value,
|
.value,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,19 @@ async function handleShellActions(event, target, action, repository) {
|
|||||||
await refreshDeploymentTruth(true);
|
await refreshDeploymentTruth(true);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
} else if (action === "open-context-help" || action === "help-topic") {
|
||||||
|
ui.helpTopic = target.dataset.topic || "getting-started";
|
||||||
|
ui.helpQuery = "";
|
||||||
|
ui.currentView = "help";
|
||||||
|
ui.modal = null;
|
||||||
|
render();
|
||||||
|
requestAnimationFrame(() =>
|
||||||
|
document.querySelector(`[data-help-topic="${ui.helpTopic}"]`)?.scrollIntoView({ block: "start", behavior: "smooth" }),
|
||||||
|
);
|
||||||
|
} else if (action === "clear-help-search") {
|
||||||
|
ui.helpQuery = "";
|
||||||
|
render();
|
||||||
|
requestAnimationFrame(() => document.querySelector("#help-search")?.focus());
|
||||||
} else if (action === "select-repo") selectRepository(target.dataset.id);
|
} else if (action === "select-repo") selectRepository(target.dataset.id);
|
||||||
else if (action === "open-deployment-link") {
|
else if (action === "open-deployment-link") {
|
||||||
if (!repository) return true;
|
if (!repository) return true;
|
||||||
|
|||||||
+67
-5
@@ -59,6 +59,8 @@ const icons = {
|
|||||||
'<path d="M21 12a9 9 0 0 1-15.3 6.4L3 16"/><path d="M3 21v-5h5"/><path d="M3 12A9 9 0 0 1 18.3 5.6L21 8"/><path d="M21 3v5h-5"/>',
|
'<path d="M21 12a9 9 0 0 1-15.3 6.4L3 16"/><path d="M3 21v-5h5"/><path d="M3 12A9 9 0 0 1 18.3 5.6L21 8"/><path d="M21 3v5h-5"/>',
|
||||||
wrench:
|
wrench:
|
||||||
'<path d="M14.7 6.3a4 4 0 0 0-5-5l2.1 2.1-2.8 2.8-2.1-2.1a4 4 0 0 0 5 5L20 17.2 17.2 20l-8.1-8.1a4 4 0 0 0-5-5l2.1 2.1-2.8 2.8-2.1-2.1a4 4 0 0 0 5 5"/>',
|
'<path d="M14.7 6.3a4 4 0 0 0-5-5l2.1 2.1-2.8 2.8-2.1-2.1a4 4 0 0 0 5 5L20 17.2 17.2 20l-8.1-8.1a4 4 0 0 0-5-5l2.1 2.1-2.8 2.8-2.1-2.1a4 4 0 0 0 5 5"/>',
|
||||||
|
help:
|
||||||
|
'<circle cx="12" cy="12" r="9"/><path d="M9.7 9a2.5 2.5 0 1 1 3.8 2.1c-.9.5-1.5 1.1-1.5 2.4M12 17.5h.01"/>',
|
||||||
};
|
};
|
||||||
|
|
||||||
function icon(name, className = "") {
|
function icon(name, className = "") {
|
||||||
@@ -176,13 +178,21 @@ const ui = {
|
|||||||
inputRenderTimer: null,
|
inputRenderTimer: null,
|
||||||
isMock: false,
|
isMock: false,
|
||||||
refreshError: null,
|
refreshError: null,
|
||||||
|
refreshWarning: null,
|
||||||
autoRefreshPending: false,
|
autoRefreshPending: false,
|
||||||
|
repositoryRefreshPromise: null,
|
||||||
|
repositoryRefreshRequest: null,
|
||||||
|
deploymentTruthPromise: null,
|
||||||
|
deploymentTruthRequest: null,
|
||||||
paletteQuery: "",
|
paletteQuery: "",
|
||||||
|
helpQuery: "",
|
||||||
|
helpTopic: "getting-started",
|
||||||
updateStatus: null,
|
updateStatus: null,
|
||||||
updateChecking: false,
|
updateChecking: false,
|
||||||
servers: [],
|
servers: [],
|
||||||
serverInspection: null,
|
serverInspection: null,
|
||||||
gitRecovery: null,
|
gitRecovery: null,
|
||||||
|
workspaceSyncPlan: null,
|
||||||
gitValidation: null,
|
gitValidation: null,
|
||||||
diffHunks: null,
|
diffHunks: null,
|
||||||
conflictState: null,
|
conflictState: null,
|
||||||
@@ -408,7 +418,7 @@ async function bootstrap() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleAutoRefresh() {
|
function scheduleAutoRefresh(delay = 450) {
|
||||||
if (
|
if (
|
||||||
ui.loading ||
|
ui.loading ||
|
||||||
ui.autoRefreshPending ||
|
ui.autoRefreshPending ||
|
||||||
@@ -419,15 +429,43 @@ function scheduleAutoRefresh() {
|
|||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
ui.autoRefreshPending = false;
|
ui.autoRefreshPending = false;
|
||||||
await refreshRepositories(false, true);
|
await refreshRepositories(false, true);
|
||||||
}, 450);
|
}, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshRepositories(withLoader = true, silent = false) {
|
async function refreshRepositories(withLoader = true, silent = false) {
|
||||||
|
ui.repositoryRefreshRequest = {
|
||||||
|
withLoader: ui.repositoryRefreshRequest?.withLoader === true || withLoader,
|
||||||
|
silent: ui.repositoryRefreshRequest ? ui.repositoryRefreshRequest.silent && silent : silent,
|
||||||
|
};
|
||||||
|
if (ui.repositoryRefreshPromise) return ui.repositoryRefreshPromise;
|
||||||
|
ui.repositoryRefreshPromise = (async () => {
|
||||||
|
let result;
|
||||||
|
while (ui.repositoryRefreshRequest) {
|
||||||
|
const request = ui.repositoryRefreshRequest;
|
||||||
|
ui.repositoryRefreshRequest = null;
|
||||||
|
result = await performRepositoryRefresh(request.withLoader, request.silent);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
})();
|
||||||
|
try {
|
||||||
|
return await ui.repositoryRefreshPromise;
|
||||||
|
} finally {
|
||||||
|
ui.repositoryRefreshPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performRepositoryRefresh(withLoader = true, silent = false) {
|
||||||
if (withLoader) setLoading(true, "Refreshing Local → Gitea → Server state…");
|
if (withLoader) setLoading(true, "Refreshing Local → Gitea → Server state…");
|
||||||
try {
|
try {
|
||||||
const selectedId = ui.selectedRepoId;
|
const selectedId = ui.selectedRepoId;
|
||||||
ui.repositories = await window.forgeflow.refreshRepositories();
|
ui.repositories = await window.forgeflow.refreshRepositories({ force: withLoader });
|
||||||
ui.refreshError = null;
|
ui.refreshError = null;
|
||||||
|
const staleRepository = ui.repositories.find(
|
||||||
|
(repository) => repository.remoteStale,
|
||||||
|
);
|
||||||
|
ui.refreshWarning = staleRepository
|
||||||
|
? `Gitea could not be reached. Showing repository data last refreshed ${formatDate(staleRepository.remoteLastRefreshedAt)} while local and server state continue to refresh.`
|
||||||
|
: null;
|
||||||
if (selectedId && !selectedRepository()) ui.selectedRepoId = null;
|
if (selectedId && !selectedRepository()) ui.selectedRepoId = null;
|
||||||
const repository = selectedRepository();
|
const repository = selectedRepository();
|
||||||
if (
|
if (
|
||||||
@@ -459,6 +497,7 @@ async function refreshRepositories(withLoader = true, silent = false) {
|
|||||||
selectRepository(ui.repositories[0].id, false);
|
selectRepository(ui.repositories[0].id, false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ui.refreshError = error.message;
|
ui.refreshError = error.message;
|
||||||
|
ui.refreshWarning = null;
|
||||||
if (!silent) showToast("Refresh failed", error.message, "error");
|
if (!silent) showToast("Refresh failed", error.message, "error");
|
||||||
} finally {
|
} finally {
|
||||||
if (withLoader) setLoading(false);
|
if (withLoader) setLoading(false);
|
||||||
@@ -480,6 +519,25 @@ async function refreshActiveOperations(showErrors = true) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshDeploymentTruth(showErrors = false) {
|
async function refreshDeploymentTruth(showErrors = false) {
|
||||||
|
ui.deploymentTruthRequest = { showErrors: ui.deploymentTruthRequest?.showErrors === true || showErrors };
|
||||||
|
if (ui.deploymentTruthPromise) return ui.deploymentTruthPromise;
|
||||||
|
ui.deploymentTruthPromise = (async () => {
|
||||||
|
let result;
|
||||||
|
while (ui.deploymentTruthRequest) {
|
||||||
|
const request = ui.deploymentTruthRequest;
|
||||||
|
ui.deploymentTruthRequest = null;
|
||||||
|
result = await performDeploymentTruthRefresh(request.showErrors);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
})();
|
||||||
|
try {
|
||||||
|
return await ui.deploymentTruthPromise;
|
||||||
|
} finally {
|
||||||
|
ui.deploymentTruthPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performDeploymentTruthRefresh(showErrors = false) {
|
||||||
let discovery = [];
|
let discovery = [];
|
||||||
try {
|
try {
|
||||||
discovery = (await window.forgeflow.discoverServerDeployments?.()) || [];
|
discovery = (await window.forgeflow.discoverServerDeployments?.()) || [];
|
||||||
@@ -508,8 +566,11 @@ async function refreshDeploymentTruth(showErrors = false) {
|
|||||||
);
|
);
|
||||||
if (!targets.length) return { checked: 0, failed: 0, discovery };
|
if (!targets.length) return { checked: 0, failed: 0, discovery };
|
||||||
|
|
||||||
|
const inventoryRefreshedProfiles = new Set(discovery.flatMap((server) => server.refreshedProfileIds || []));
|
||||||
|
const pendingTargets = targets.filter(({ profile }) => !inventoryRefreshedProfiles.has(profile.id));
|
||||||
|
|
||||||
const failures = [];
|
const failures = [];
|
||||||
const queue = [...targets];
|
const queue = [...pendingTargets];
|
||||||
const workers = Array.from(
|
const workers = Array.from(
|
||||||
{ length: Math.min(3, queue.length) },
|
{ length: Math.min(3, queue.length) },
|
||||||
async () => {
|
async () => {
|
||||||
@@ -539,7 +600,7 @@ async function refreshDeploymentTruth(showErrors = false) {
|
|||||||
"error",
|
"error",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { checked: targets.length, failed: failures.length, discovery };
|
return { checked: targets.length, reusedInventory: targets.length - pendingTargets.length, failed: failures.length, discovery };
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectRepository(id, shouldRender = true) {
|
function selectRepository(id, shouldRender = true) {
|
||||||
@@ -551,6 +612,7 @@ function selectRepository(id, shouldRender = true) {
|
|||||||
ui.branches = [];
|
ui.branches = [];
|
||||||
ui.stashes = [];
|
ui.stashes = [];
|
||||||
ui.gitRecovery = null;
|
ui.gitRecovery = null;
|
||||||
|
ui.workspaceSyncPlan = null;
|
||||||
ui.gitValidation = null;
|
ui.gitValidation = null;
|
||||||
ui.branchProtection = null;
|
ui.branchProtection = null;
|
||||||
const repository = selectedRepository();
|
const repository = selectedRepository();
|
||||||
|
|||||||
+155
-44
@@ -1,3 +1,25 @@
|
|||||||
|
// 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() {
|
function renderModal() {
|
||||||
if (!ui.modal) return "";
|
if (!ui.modal) return "";
|
||||||
const repository =
|
const repository =
|
||||||
@@ -22,6 +44,20 @@ function renderModal() {
|
|||||||
];
|
];
|
||||||
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>`;
|
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") {
|
if (ui.modal.type === "workload-link") {
|
||||||
const serverResult = (ui.serverDiscovery || []).find(
|
const serverResult = (ui.serverDiscovery || []).find(
|
||||||
(item) => item.serverId === ui.modal.serverId,
|
(item) => item.serverId === ui.modal.serverId,
|
||||||
@@ -59,7 +95,7 @@ function renderModal() {
|
|||||||
.map((container) => container.name)
|
.map((container) => container.name)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Link existing server workload</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero">${icon("link")}<div><strong>${escapeHtml(workload.displayName)}</strong><span>${escapeHtml(serverResult?.serverName || serverResult?.server?.name || ui.modal.serverId)} · ${workload.runtime?.running ? "running" : "stopped"}</span></div></div><div class="context-summary"><div class="context-row"><span>Containers</span><strong>${escapeHtml(containerNames || "Unknown")}</strong></div><div class="context-row"><span>Compose identity</span><strong>${escapeHtml(workload.compose?.project || "DockerMan / standalone container")} ${workload.compose?.services?.length ? `· ${escapeHtml(workload.compose.services.join(", "))}` : ""}</strong></div><div class="context-row"><span>Detected folder</span><strong class="mono">${escapeHtml(workload.compose?.workingDir || workload.dockerMan?.templatePath || "No Git checkout required")}</strong></div>${candidateSummary}</div><div class="form-grid" style="margin-top:14px"><div class="field full"><label>Repository to link</label><select id="workload-repository" class="select">${availableRepositories.map((item) => `<option value="${attr(item.fullName)}" ${item.fullName === suggestedRepository ? "selected" : ""}>${escapeHtml(item.fullName)}</option>`).join("") || '<option value="">No repositories available</option>'}</select></div><div class="field"><label>Deployment source</label><select id="workload-deployment-mode" class="select"><option value="server-git" selected>Server pull from Gitea</option><option value="push-bundle">Direct copy fallback</option><option value="monitor-only">Monitor only</option></select></div><div class="field"><label>Detected deployment folder</label><input id="workload-remote-folder" class="input" value="${attr(remoteFolder)}" readonly/></div></div><div class="notice success" style="margin-top:12px">${icon("shield")}ForgeFlow preserves the detected Compose project, services and container identity. Server pull provisions a repository-scoped read-only key and activates only the selected Gitea commit.</div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="confirm-link-server-workload" data-server-id="${attr(ui.modal.serverId)}" data-workload-id="${attr(ui.modal.workloadId)}" ${availableRepositories.length ? "" : "disabled"}>Link workload</button></footer></section></div>`;
|
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") {
|
if (ui.modal.type === "deployment-config") {
|
||||||
const storedProfile =
|
const storedProfile =
|
||||||
@@ -106,7 +142,7 @@ function renderModal() {
|
|||||||
<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>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>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>`
|
<div class="field full"><label>Healthcheck URL (optional)</label><input id="profile-healthcheck" class="input" value="${attr(existing.healthcheckUrl || "")}" placeholder="https://app.example.com/health" /></div>`
|
||||||
}<label class="check-field full"><input id="profile-confirmation" type="checkbox" ${existing.confirmationRequired !== false ? "checked" : ""}/><span>Require an explicit confirmation before deployment</span></label></div><div class="notice" style="margin-top:13px">${icon("shield")}${ssh ? "Server pull fetches the exact selected Gitea commit with a repository-scoped read-only key, validates Compose and services, then promotes atomically with rollback protection." : "ForgeFlow sends only controlled workflow inputs: environment, exact SHA and a unique request ID."}</div></div><footer class="modal-footer">${existing.id ? `<button class="button danger" data-action="delete-deployment-profile" data-profile-id="${attr(existing.id)}">Delete</button>` : ""}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-deployment-profile" data-profile-id="${attr(existing.id || "")}" ${ssh && !servers.length ? "disabled" : ""}>Save environment</button></footer></section></div>`;
|
}<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") {
|
if (ui.modal.type === "inventory-review-plan") {
|
||||||
const plan = ui.inventoryReviewPlan;
|
const plan = ui.inventoryReviewPlan;
|
||||||
@@ -133,7 +169,7 @@ function renderModal() {
|
|||||||
(item) => item.id === ui.modal.profileId,
|
(item) => item.id === ui.modal.profileId,
|
||||||
) || selectedProfile(repository);
|
) || selectedProfile(repository);
|
||||||
const targetSha = deploymentTargetSha(repository, profile);
|
const targetSha = deploymentTargetSha(repository, profile);
|
||||||
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Confirm production action</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero">${icon("rocket")}<div><strong>Deploy ${escapeHtml(shortSha(targetSha))} → ${escapeHtml(profile.environment)}</strong><span>${escapeHtml(repository.fullName)}</span></div></div><div class="confirm-grid"><span>Exact commit</span><strong class="mono">${escapeHtml(targetSha || "Unavailable")}</strong><span>Branch</span><strong>${escapeHtml(profile.branch)}</strong><span>Provider</span><strong>${profile.provider === "ssh-unraid" ? `${deploymentMode(profile) === "server-git" ? "Gitea → Unraid" : "Desktop → Unraid"} · ${escapeHtml(profile.remoteFolder)}` : escapeHtml(profile.workflowFile)}</strong><span>Healthcheck</span><strong>${escapeHtml(profile.healthcheckUrl || "Not configured")}</strong></div>${ui.deploymentPreflight ? `<div class="notice success" style="margin-top:12px">${icon("shield")}Preflight passed with ${ui.deploymentPreflight.summary.counts.warning} warning(s). Backend safety checks run again at dispatch time.</div>` : ""}</div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button success" data-action="confirm-deploy" data-profile-id="${attr(profile.id)}" ${targetSha ? "" : "disabled"}>Deploy exact commit</button></footer></section></div>`;
|
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") {
|
if (ui.modal.type === "rollback-confirm") {
|
||||||
const profile = repository?.deploymentProfiles?.find(
|
const profile = repository?.deploymentProfiles?.find(
|
||||||
@@ -239,46 +275,6 @@ function renderCommandPalette() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function enhanceRenderedUi() {
|
function enhanceRenderedUi() {
|
||||||
const repository = selectedRepository();
|
|
||||||
if (ui.modal?.type === "deployment-config") {
|
|
||||||
const profile =
|
|
||||||
repository?.deploymentProfiles?.find(
|
|
||||||
(item) => item.id === ui.modal.profileId,
|
|
||||||
) || {};
|
|
||||||
const policy = profile.deploymentPolicy || {};
|
|
||||||
document
|
|
||||||
.querySelector(".modal-body .form-grid")
|
|
||||||
?.insertAdjacentHTML(
|
|
||||||
"beforeend",
|
|
||||||
`<div class="field full"><h3>Deployment policy</h3></div><label class="check-field"><input id="profile-policy-frozen" type="checkbox" ${policy.frozen ? "checked" : ""}/><span>Freeze deployments</span></label><label class="check-field"><input id="profile-policy-note" type="checkbox" ${policy.requireNote ? "checked" : ""}/><span>Require release note</span></label><div class="field full"><label>Freeze reason</label><input id="profile-policy-freeze-reason" class="input" value="${attr(policy.freezeReason || "")}"/></div><div class="field full"><label>Maintenance windows</label><input id="profile-policy-windows" class="input" value="${attr((policy.maintenanceWindows || []).map((window) => `${window.days.join(",")}:${window.start}-${window.end}`).join(" | "))}" placeholder="1,2,3,4,5:09:00-17:00"/><small>Day 0 is Sunday. Separate windows with |.</small></div>`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (ui.modal?.type === "workload-link") {
|
|
||||||
const workload = (ui.serverDiscovery || []).find((server) => server.serverId === ui.modal.serverId)?.workloads?.find((item) => item.workloadId === ui.modal.workloadId);
|
|
||||||
const type = workload?.classification?.type || "ambiguous";
|
|
||||||
const recommended = type === "duplicate" ? "select-authoritative" : type === "stale-link" ? "archive-link" : type === "historical-compose" ? "mark-historical" : type === "orphan-container" ? "monitor-only" : "manual-link";
|
|
||||||
const actions = [["manual-link", "Confirm selected repository match"], ["select-authoritative", "Select as authoritative instance"], ["mark-historical", "Mark historical definition"], ["archive-link", "Archive stale link"], ["monitor-only", "Keep for monitoring only"], ["manual-exclude", "Exclude this workload"], ["ignore", "Ignore with reason"]];
|
|
||||||
const options = actions.map(([value, label]) => `<option value="${value}" ${value === recommended ? "selected" : ""}>${escapeHtml(label)}${value === recommended ? " · recommended" : ""}</option>`).join("");
|
|
||||||
document.querySelector(".modal-body")?.insertAdjacentHTML("beforeend", `<section class="settings-group" style="margin-top:14px"><h3>Classify without touching containers</h3><div class="notice" style="margin-bottom:10px">${icon("info")}<div><strong>${escapeHtml(type)}</strong><p>${escapeHtml(workload?.classification?.reason || "ForgeFlow needs an explicit decision for this workload.")}</p></div></div><div class="form-grid"><div class="field"><label for="inventory-review-action">Review decision</label><select id="inventory-review-action" class="select">${options}</select></div><div class="field"><label for="inventory-review-reason">Reason</label><input id="inventory-review-reason" class="input" placeholder="Why is this the correct classification?"/></div></div><button class="button" style="margin-top:10px" data-action="preview-inventory-review" data-server-id="${attr(ui.modal.serverId)}" data-workload-id="${attr(ui.modal.workloadId)}">${icon("shield")}Preview classification impact</button><p class="meta">The decision is tied to current evidence and becomes stale automatically when server truth changes.</p></section>`);
|
|
||||||
}
|
|
||||||
if (ui.modal?.type === "deploy-confirm") {
|
|
||||||
const profile = repository?.deploymentProfiles?.find(
|
|
||||||
(item) => item.id === ui.modal.profileId,
|
|
||||||
);
|
|
||||||
document
|
|
||||||
.querySelector(".modal-body")
|
|
||||||
?.insertAdjacentHTML(
|
|
||||||
"beforeend",
|
|
||||||
`<div class="form-grid" style="margin-top:14px"><div class="field full"><label>Release note ${profile?.deploymentPolicy?.requireNote ? "(required)" : "(optional)"}</label><textarea id="deployment-note" class="textarea" placeholder="What is being released and why?"></textarea></div><label class="check-field"><input id="deployment-override" type="checkbox"/><span>Emergency policy override</span></label><div class="field"><label>Override reason</label><input id="deployment-override-reason" class="input" placeholder="Required when overriding"/></div></div>`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (ui.currentView === "diagnostics") {
|
|
||||||
const container = document.querySelector(".diagnostics-page");
|
|
||||||
container?.insertAdjacentHTML(
|
|
||||||
"beforeend",
|
|
||||||
`<section class="section-block"><div class="section-heading"><div><h2>Operational audit log</h2><span class="meta">Append-only release, pull-request and recovery events</span></div><div class="stack horizontal compact"><button class="button" data-action="load-audit-log">Refresh</button><button class="button" data-action="export-audit-json">Export JSON</button><button class="button" data-action="export-audit-csv">Export CSV</button></div></div><div class="panel">${ui.auditEvents.length ? `<table class="data-table"><thead><tr><th>Time</th><th>Event</th><th>Repository</th><th>Result</th></tr></thead><tbody>${ui.auditEvents.map((item) => `<tr><td>${formatDate(item.timestamp)}</td><td>${escapeHtml(item.event)}</td><td>${escapeHtml(item.details?.repository || "—")}</td><td>${escapeHtml(item.details?.result || item.details?.note || "—")}</td></tr>`).join("")}</tbody></table>` : '<div class="empty-state compact"><p>Load the operational audit log.</p></div>'}</div></section>`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
document.querySelectorAll("button.icon-button:not([aria-label])").forEach((button) => {
|
document.querySelectorAll("button.icon-button:not([aria-label])").forEach((button) => {
|
||||||
const action = String(button.title || button.dataset.action || "Action").replaceAll("-", " ");
|
const action = String(button.title || button.dataset.action || "Action").replaceAll("-", " ");
|
||||||
button.setAttribute("aria-label", action.charAt(0).toUpperCase() + action.slice(1));
|
button.setAttribute("aria-label", action.charAt(0).toUpperCase() + action.slice(1));
|
||||||
@@ -296,6 +292,110 @@ function enhanceRenderedUi() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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() {
|
function render() {
|
||||||
if (!ui.boot) return;
|
if (!ui.boot) return;
|
||||||
const repository = selectedRepository();
|
const repository = selectedRepository();
|
||||||
@@ -304,6 +404,8 @@ function render() {
|
|||||||
? renderOverview()
|
? renderOverview()
|
||||||
: ui.currentView === "deployments"
|
: ui.currentView === "deployments"
|
||||||
? renderDeployments()
|
? renderDeployments()
|
||||||
|
: ui.currentView === "help"
|
||||||
|
? renderHelp()
|
||||||
: ui.currentView === "settings"
|
: ui.currentView === "settings"
|
||||||
? renderSettings()
|
? renderSettings()
|
||||||
: ui.currentView === "diagnostics"
|
: ui.currentView === "diagnostics"
|
||||||
@@ -314,8 +416,17 @@ function render() {
|
|||||||
? renderRepositoryWorkspace(repository)
|
? renderRepositoryWorkspace(repository)
|
||||||
: renderOverview();
|
: renderOverview();
|
||||||
const withPanel = ui.currentView === "repository" && repository;
|
const withPanel = ui.currentView === "repository" && repository;
|
||||||
app.innerHTML = `<div class="app-shell">${renderTitlebar()}<div class="app-body">${renderSidebar()}<main class="workspace ${withPanel ? "with-panel" : ""}"><section class="main-canvas ${withPanel ? "repository-canvas" : ""}">${main}</section>${withPanel ? renderActionPanel(repository) : ""}${ui.loading ? `<div class="loading-overlay"><div class="boot-screen"><div class="spinner"></div><strong>${escapeHtml(ui.loadingMessage || "Working…")}</strong></div></div>` : ""}</main></div>${renderStatusbar()}</div>${ui.boot.state.setupComplete ? "" : renderSetup()}${renderModal()}`;
|
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();
|
enhanceRenderedUi();
|
||||||
|
restoreInteractionState(interaction);
|
||||||
|
lastRenderedMarkup = markup;
|
||||||
if (ui.modal?.type === "command-palette")
|
if (ui.modal?.type === "command-palette")
|
||||||
requestAnimationFrame(() =>
|
requestAnimationFrame(() =>
|
||||||
document.querySelector("#palette-input")?.focus(),
|
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)}`;
|
||||||
|
}
|
||||||
@@ -51,6 +51,9 @@ app.addEventListener("input", (event) => {
|
|||||||
else if (event.target.id === "palette-input") {
|
else if (event.target.id === "palette-input") {
|
||||||
ui.paletteQuery = event.target.value;
|
ui.paletteQuery = event.target.value;
|
||||||
scheduleInputRender(60);
|
scheduleInputRender(60);
|
||||||
|
} else if (event.target.id === "help-search") {
|
||||||
|
ui.helpQuery = event.target.value;
|
||||||
|
scheduleInputRender(60);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,6 +97,11 @@ document.addEventListener("keydown", (event) => {
|
|||||||
render();
|
render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "f" && ui.currentView === "help") {
|
||||||
|
event.preventDefault();
|
||||||
|
document.querySelector("#help-search")?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
(event.ctrlKey || event.metaKey) &&
|
(event.ctrlKey || event.metaKey) &&
|
||||||
event.key === "Enter" &&
|
event.key === "Enter" &&
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<link rel="stylesheet" href="styles.css" />
|
<link rel="stylesheet" href="styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app" aria-live="polite">
|
<div id="app">
|
||||||
<div class="boot-screen">
|
<div class="boot-screen">
|
||||||
<img class="boot-brand-logo" src="./assets/itworx-mark.png" alt="ITWorx.tech"/>
|
<img class="boot-brand-logo" src="./assets/itworx-mark.png" alt="ITWorx.tech"/>
|
||||||
<strong>Starting ForgeFlow</strong>
|
<strong>Starting ForgeFlow</strong>
|
||||||
@@ -17,10 +17,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="toast-root" class="toast-root" aria-live="assertive"></div>
|
<div id="toast-root" class="toast-root" aria-live="assertive"></div>
|
||||||
<script src="mock-repository-bridge.js"></script>
|
<script defer src="mock-repository-bridge.js"></script>
|
||||||
<script src="mock-deployment-bridge.js"></script>
|
<script defer src="mock-deployment-bridge.js"></script>
|
||||||
<script src="mock-bridge.js"></script>
|
<script defer src="mock-bridge.js"></script>
|
||||||
<script defer src="app.js"></script>
|
<script defer src="app.js"></script>
|
||||||
|
<script defer src="diff-view.js"></script>
|
||||||
<script defer src="views.js"></script>
|
<script defer src="views.js"></script>
|
||||||
<script defer src="dialogs.js"></script>
|
<script defer src="dialogs.js"></script>
|
||||||
<script defer src="operations.js"></script>
|
<script defer src="operations.js"></script>
|
||||||
|
|||||||
@@ -241,6 +241,8 @@ function createMockDeploymentBridge(context) {
|
|||||||
detected: 3,
|
detected: 3,
|
||||||
adopted: 0,
|
adopted: 0,
|
||||||
verified: 1,
|
verified: 1,
|
||||||
|
refreshedProfiles: 1,
|
||||||
|
refreshedProfileIds: ["profile-portfolio"],
|
||||||
linked: 2,
|
linked: 2,
|
||||||
unmatched: 0,
|
unmatched: 0,
|
||||||
needsReview: 2,
|
needsReview: 2,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ function createMockRepositoryBridge(context) {
|
|||||||
await wait(80);
|
await wait(80);
|
||||||
snapshot();
|
snapshot();
|
||||||
return {
|
return {
|
||||||
appVersion: "0.10.6-demo",
|
appVersion: "0.10.14-demo",
|
||||||
platform: "win32",
|
platform: "win32",
|
||||||
state: clone(state),
|
state: clone(state),
|
||||||
git: { available: true, version: "git version 2.47.3" },
|
git: { available: true, version: "git version 2.47.3" },
|
||||||
@@ -427,6 +427,71 @@ function createMockRepositoryBridge(context) {
|
|||||||
emitRepositories();
|
emitRepositories();
|
||||||
return { output: "Fast-forwarded.", status: clone(repo.localStatus) };
|
return { output: "Fast-forwarded.", status: clone(repo.localStatus) };
|
||||||
},
|
},
|
||||||
|
async previewWorkspaceSync(localPath) {
|
||||||
|
await wait(260);
|
||||||
|
const repo = findRepo(localPath);
|
||||||
|
const status = repo.localStatus;
|
||||||
|
const targetSha = status.branch.behind ? "f".repeat(40) : status.head;
|
||||||
|
return {
|
||||||
|
id: `demo-${String(status.head).slice(0, 7)}-${status.branch.ahead}-${status.branch.behind}`.padEnd(64, "0").slice(0, 64),
|
||||||
|
branch: status.branch.head,
|
||||||
|
upstream: status.branch.upstream || `origin/${status.branch.head}`,
|
||||||
|
currentSha: status.head,
|
||||||
|
targetSha,
|
||||||
|
needsSync: !status.clean || status.head !== targetSha || status.branch.ahead > 0,
|
||||||
|
blockers: [],
|
||||||
|
summary: {
|
||||||
|
resultingTrackedChanges: status.branch.behind ? 3 : 0,
|
||||||
|
added: status.branch.behind ? 1 : 0,
|
||||||
|
modified: status.branch.behind ? 1 : 0,
|
||||||
|
deleted: status.branch.behind ? 1 : 0,
|
||||||
|
renamed: 0,
|
||||||
|
localFilesToStash: status.counts.changed,
|
||||||
|
untrackedFilesToStash: status.counts.untracked,
|
||||||
|
localCommitsToProtect: status.branch.ahead,
|
||||||
|
incomingCommits: status.branch.behind,
|
||||||
|
},
|
||||||
|
changes: status.branch.behind
|
||||||
|
? [
|
||||||
|
{ code: "A", status: "added", path: "src/remote-feature.js" },
|
||||||
|
{ code: "M", status: "modified", path: "README.md" },
|
||||||
|
{ code: "D", status: "deleted", path: "docs/obsolete.md" },
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
localFiles: clone(status.files),
|
||||||
|
incomingCommits: [],
|
||||||
|
localCommits: [],
|
||||||
|
recovery: {
|
||||||
|
safetyBranch: status.branch.ahead > 0,
|
||||||
|
stash: status.counts.changed > 0,
|
||||||
|
untrackedCleanup: status.counts.untracked > 0,
|
||||||
|
ignoredFilesPreserved: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async applyWorkspaceSync(localPath, expectedPlanId) {
|
||||||
|
const plan = await this.previewWorkspaceSync(localPath);
|
||||||
|
if (plan.id !== expectedPlanId) throw new Error("The workspace sync preview is stale.");
|
||||||
|
const repo = findRepo(localPath);
|
||||||
|
const hadChanges = repo.localStatus.counts.changed > 0;
|
||||||
|
repo.localStatus.head = plan.targetSha;
|
||||||
|
repo.localStatus.shortHead = plan.targetSha.slice(0, 7);
|
||||||
|
repo.localStatus.files = [];
|
||||||
|
repo.localStatus.branch.ahead = 0;
|
||||||
|
repo.localStatus.branch.behind = 0;
|
||||||
|
recompute(repo);
|
||||||
|
emitRepositories();
|
||||||
|
return {
|
||||||
|
applied: plan.needsSync,
|
||||||
|
unchanged: !plan.needsSync,
|
||||||
|
plan,
|
||||||
|
status: clone(repo.localStatus),
|
||||||
|
backupBranch: plan.summary.localCommitsToProtect ? `forgeflow/recovery-${plan.branch}-demo` : null,
|
||||||
|
stash: hadChanges ? { ref: "stash@{0}", shortSha: "demo123", subject: "ForgeFlow workspace sync" } : null,
|
||||||
|
ignoredFilesPreserved: true,
|
||||||
|
cleaned: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
async history() {
|
async history() {
|
||||||
await wait(100);
|
await wait(100);
|
||||||
return clone(commitHistory);
|
return clone(commitHistory);
|
||||||
@@ -556,6 +621,85 @@ function createMockRepositoryBridge(context) {
|
|||||||
stashes: clone(list),
|
stashes: clone(list),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
async gitRecoveryStatus(localPath) {
|
||||||
|
const repo = findRepo(localPath);
|
||||||
|
const status = clone(repo.localStatus);
|
||||||
|
const upstream = status.branch?.upstream;
|
||||||
|
const recommendations = [
|
||||||
|
{
|
||||||
|
id: "fetch",
|
||||||
|
label: "Fetch and recalculate remote state",
|
||||||
|
action: "fetch",
|
||||||
|
safe: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (
|
||||||
|
status.clean &&
|
||||||
|
status.branch.behind > 0 &&
|
||||||
|
status.branch.ahead === 0 &&
|
||||||
|
upstream
|
||||||
|
) {
|
||||||
|
recommendations.push({
|
||||||
|
id: "pull",
|
||||||
|
label: `Fast-forward from ${upstream}`,
|
||||||
|
action: "fast-forward",
|
||||||
|
safe: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
status.branch.ahead > 0 &&
|
||||||
|
status.branch.behind === 0 &&
|
||||||
|
upstream
|
||||||
|
) {
|
||||||
|
recommendations.push({
|
||||||
|
id: "push",
|
||||||
|
label: `Push ${status.branch.ahead} local commit(s)`,
|
||||||
|
action: "push",
|
||||||
|
safe: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
lockReport: {
|
||||||
|
root: localPath,
|
||||||
|
gitDir: `${localPath}\\.git`,
|
||||||
|
locks: [],
|
||||||
|
processes: { available: true, active: [] },
|
||||||
|
},
|
||||||
|
recommendations,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async reconcileRepository(localPath) {
|
||||||
|
await wait(160);
|
||||||
|
return this.gitRecoveryStatus(localPath);
|
||||||
|
},
|
||||||
|
async repairGitLocks(localPath) {
|
||||||
|
return {
|
||||||
|
...(await this.gitRecoveryStatus(localPath)).lockReport,
|
||||||
|
removed: [],
|
||||||
|
skipped: [],
|
||||||
|
repaired: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async repairRepositorySync(localPath, strategy) {
|
||||||
|
const repo = findRepo(localPath);
|
||||||
|
if (strategy === "fast-forward") {
|
||||||
|
repo.localStatus.branch.behind = 0;
|
||||||
|
repo.localStatus.head = "f".repeat(40);
|
||||||
|
} else if (strategy === "push") {
|
||||||
|
repo.localStatus.branch.ahead = 0;
|
||||||
|
} else if (strategy !== "fetch") {
|
||||||
|
throw new Error("Unsupported demo synchronization strategy.");
|
||||||
|
}
|
||||||
|
recompute(repo);
|
||||||
|
emitRepositories();
|
||||||
|
return {
|
||||||
|
strategy,
|
||||||
|
backupBranch: null,
|
||||||
|
status: clone(repo.localStatus),
|
||||||
|
lockReport: (await this.gitRecoveryStatus(localPath)).lockReport,
|
||||||
|
};
|
||||||
|
},
|
||||||
async indexLockInfo() {
|
async indexLockInfo() {
|
||||||
return { exists: false, ageMs: 0 };
|
return { exists: false, ageMs: 0 };
|
||||||
},
|
},
|
||||||
|
|||||||
+366
-1
@@ -357,6 +357,8 @@ select:focus-visible {
|
|||||||
padding: 0 6px 10px;
|
padding: 0 6px 10px;
|
||||||
}
|
}
|
||||||
.repo-row {
|
.repo-row {
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: auto 48px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 18px minmax(0, 1fr) auto;
|
grid-template-columns: 18px minmax(0, 1fr) auto;
|
||||||
@@ -646,6 +648,24 @@ select:focus-visible {
|
|||||||
right: 14px;
|
right: 14px;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
}
|
}
|
||||||
|
.modal .summary-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
|
||||||
|
}
|
||||||
|
.modal .summary-card {
|
||||||
|
min-height: 108px;
|
||||||
|
}
|
||||||
|
.modal .summary-card > span {
|
||||||
|
display: block;
|
||||||
|
max-width: 12ch;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.modal .summary-card > strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 15px;
|
||||||
|
font: 700 27px/1 var(--font-sans);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
.summary-card.warning .summary-value,
|
.summary-card.warning .summary-value,
|
||||||
.summary-card.warning .icon {
|
.summary-card.warning .icon {
|
||||||
color: var(--warning);
|
color: var(--warning);
|
||||||
@@ -803,7 +823,11 @@ select:focus-visible {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto auto auto 39px minmax(0, 1fr);
|
grid-template-rows: auto auto 39px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.repo-context {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.repo-header {
|
.repo-header {
|
||||||
padding: 16px 18px 13px;
|
padding: 16px 18px 13px;
|
||||||
@@ -975,8 +999,14 @@ select:focus-visible {
|
|||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
background: var(--surface-1);
|
background: var(--surface-1);
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
.tab {
|
.tab {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
white-space: nowrap;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -2651,6 +2681,7 @@ kbd {
|
|||||||
.git-tools-grid {
|
.git-tools-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
grid-auto-rows: max-content;
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
@@ -2706,6 +2737,8 @@ kbd {
|
|||||||
background: linear-gradient(180deg, var(--primary), var(--success));
|
background: linear-gradient(180deg, var(--primary), var(--success));
|
||||||
}
|
}
|
||||||
.server-inventory-panel .tool-row {
|
.server-inventory-panel .tool-row {
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: auto 76px;
|
||||||
transition: background 150ms ease, transform 150ms ease;
|
transition: background 150ms ease, transform 150ms ease;
|
||||||
}
|
}
|
||||||
.server-inventory-panel .tool-row:hover {
|
.server-inventory-panel .tool-row:hover {
|
||||||
@@ -3314,6 +3347,33 @@ html[data-theme="light"] .setup-brand-logo-light {
|
|||||||
.git-tools-grid .troubleshooting-panel {
|
.git-tools-grid .troubleshooting-panel {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
.git-tools-grid .workspace-sync-panel {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 88% 10%, color-mix(in srgb, var(--accent) 15%, transparent), transparent 34%),
|
||||||
|
var(--surface-1);
|
||||||
|
}
|
||||||
|
.workspace-sync-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
.workspace-sync-layout h3 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
.workspace-sync-layout p {
|
||||||
|
margin: 0 0 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-width: 820px;
|
||||||
|
}
|
||||||
|
.workspace-sync-actions {
|
||||||
|
display: grid;
|
||||||
|
justify-items: end;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
.troubleshooting-summary {
|
.troubleshooting-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3347,6 +3407,13 @@ html[data-theme="light"] .setup-brand-logo-light {
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
|
.workspace-sync-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.workspace-sync-actions {
|
||||||
|
justify-items: stretch;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
.repo-quick-actions {
|
.repo-quick-actions {
|
||||||
margin-inline: 12px;
|
margin-inline: 12px;
|
||||||
}
|
}
|
||||||
@@ -3969,3 +4036,301 @@ html[data-theme="light"] .visual-page-header {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Help center and compact repository-shell safeguards */
|
||||||
|
.panel-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-page {
|
||||||
|
max-width: 1500px;
|
||||||
|
}
|
||||||
|
.help-hero {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 32px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding: 30px 34px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 78% 15%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 32%),
|
||||||
|
linear-gradient(125deg, color-mix(in srgb, var(--surface-2) 92%, transparent), var(--surface-1));
|
||||||
|
box-shadow: 0 22px 65px -48px color-mix(in srgb, var(--accent) 65%, transparent);
|
||||||
|
}
|
||||||
|
.help-hero::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image: radial-gradient(color-mix(in srgb, var(--text-faint) 20%, transparent) 0.8px, transparent 0.8px);
|
||||||
|
background-size: 18px 18px;
|
||||||
|
mask-image: linear-gradient(90deg, transparent, black 65%, transparent);
|
||||||
|
opacity: 0.42;
|
||||||
|
}
|
||||||
|
.help-hero > div:first-child {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 750px;
|
||||||
|
}
|
||||||
|
.help-hero h1 {
|
||||||
|
margin: 8px 0 6px;
|
||||||
|
font-size: clamp(28px, 3vw, 42px);
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.045em;
|
||||||
|
}
|
||||||
|
.help-hero p {
|
||||||
|
max-width: 680px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.help-search {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
max-width: 680px;
|
||||||
|
margin-top: 22px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--line));
|
||||||
|
border-radius: 11px;
|
||||||
|
background: color-mix(in srgb, var(--surface-0) 90%, transparent);
|
||||||
|
box-shadow: 0 12px 36px -30px var(--accent);
|
||||||
|
}
|
||||||
|
.help-search:focus-within {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
|
||||||
|
}
|
||||||
|
.help-search > .icon {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.help-search input {
|
||||||
|
min-width: 0;
|
||||||
|
height: 46px;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.help-search kbd {
|
||||||
|
padding: 3px 6px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text-faint);
|
||||||
|
font: 10px var(--font-mono);
|
||||||
|
}
|
||||||
|
.help-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(190px, 240px) minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.help-navigation {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: color-mix(in srgb, var(--surface-1) 94%, transparent);
|
||||||
|
}
|
||||||
|
.help-category {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.help-category > strong {
|
||||||
|
padding: 4px 8px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.help-category button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.help-category button:hover,
|
||||||
|
.help-category button.active {
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, var(--surface-2));
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.help-category button.active {
|
||||||
|
box-shadow: inset 2px 0 var(--primary);
|
||||||
|
}
|
||||||
|
.help-category button .icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.help-results {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.help-results-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: end;
|
||||||
|
min-height: 48px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.help-results-header h2 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.help-results-header span {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.help-topic {
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 13px;
|
||||||
|
background: var(--surface-1);
|
||||||
|
transition: border-color 150ms ease, transform 150ms ease, box-shadow 150ms ease;
|
||||||
|
scroll-margin-top: 14px;
|
||||||
|
}
|
||||||
|
.help-topic:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 34%, var(--line));
|
||||||
|
}
|
||||||
|
.help-topic[open] {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 26%, var(--line));
|
||||||
|
box-shadow: 0 18px 42px -40px var(--accent);
|
||||||
|
}
|
||||||
|
.help-topic summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.help-topic summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.help-topic summary > span:nth-child(2) {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.help-topic summary small {
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.help-topic summary strong {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.help-topic summary span span {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.help-topic-icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent) 22%, var(--line));
|
||||||
|
border-radius: 11px;
|
||||||
|
background: color-mix(in srgb, var(--accent) 9%, var(--surface-2));
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.help-topic-icon .icon {
|
||||||
|
width: 19px;
|
||||||
|
height: 19px;
|
||||||
|
}
|
||||||
|
.help-chevron {
|
||||||
|
color: var(--text-faint);
|
||||||
|
transition: transform 150ms ease;
|
||||||
|
}
|
||||||
|
.help-topic[open] .help-chevron {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
.help-topic-body {
|
||||||
|
padding: 0 18px 18px 70px;
|
||||||
|
border-top: 1px solid var(--line-soft);
|
||||||
|
}
|
||||||
|
.help-topic-body ol {
|
||||||
|
display: grid;
|
||||||
|
gap: 11px;
|
||||||
|
margin: 16px 0;
|
||||||
|
padding-left: 22px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.help-topic-body li::marker {
|
||||||
|
color: var(--primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.help-note {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--success) 20%, var(--line));
|
||||||
|
border-radius: 9px;
|
||||||
|
background: color-mix(in srgb, var(--success) 6%, var(--surface-0));
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.help-note .icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.help-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.help-navigation {
|
||||||
|
position: static;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.help-hero .project-illustration {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.help-hero {
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
.help-search kbd {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.help-search {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.help-topic-body {
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+184
-45
@@ -15,6 +15,7 @@ function renderTitlebar() {
|
|||||||
deployments: "Deployments",
|
deployments: "Deployments",
|
||||||
diagnostics: "Diagnostics",
|
diagnostics: "Diagnostics",
|
||||||
settings: "Settings",
|
settings: "Settings",
|
||||||
|
help: "Help center",
|
||||||
"deployment-run": "Deployment run",
|
"deployment-run": "Deployment run",
|
||||||
}[ui.currentView] || "Workspace";
|
}[ui.currentView] || "Workspace";
|
||||||
return `<header class="titlebar">
|
return `<header class="titlebar">
|
||||||
@@ -31,6 +32,9 @@ function renderTitlebar() {
|
|||||||
|
|
||||||
function renderRepositoryRow(repository) {
|
function renderRepositoryRow(repository) {
|
||||||
const status = repository.localStatus;
|
const status = repository.localStatus;
|
||||||
|
const profiles = repository.deploymentProfiles || [];
|
||||||
|
const workloads = linkedWorkloadsForRepository(repository);
|
||||||
|
const runningWorkloads = workloads.filter((workload) => workload.runtime?.running);
|
||||||
const badges = [];
|
const badges = [];
|
||||||
if (status?.counts.conflicts)
|
if (status?.counts.conflicts)
|
||||||
badges.push('<span class="mini-badge danger" title="Conflicts">!</span>');
|
badges.push('<span class="mini-badge danger" title="Conflicts">!</span>');
|
||||||
@@ -50,10 +54,14 @@ function renderRepositoryRow(repository) {
|
|||||||
badges.push(
|
badges.push(
|
||||||
'<span class="mini-badge success" title="Ready to deploy">↗</span>',
|
'<span class="mini-badge success" title="Ready to deploy">↗</span>',
|
||||||
);
|
);
|
||||||
|
if (profiles.length)
|
||||||
|
badges.push(
|
||||||
|
`<span class="mini-badge ${runningWorkloads.length ? "success" : "warning"} deployment-badge" title="${attr(`${profiles.length} server deployment${profiles.length === 1 ? "" : "s"} linked${runningWorkloads.length ? ` · ${runningWorkloads.length} running` : ""}`)}">S${profiles.length}</span>`,
|
||||||
|
);
|
||||||
if (!repository.localPath)
|
if (!repository.localPath)
|
||||||
badges.push('<span class="mini-badge" title="No local folder">—</span>');
|
badges.push('<span class="mini-badge" title="No local folder">—</span>');
|
||||||
const branch = status?.branch.head || repository.defaultBranch || "remote";
|
const branch = status?.branch.head || repository.defaultBranch || "remote";
|
||||||
return `<button class="repo-row ${String(repository.id) === String(ui.selectedRepoId) ? "active" : ""} ${repository.attention ? "attention" : ""}" data-action="select-repo" data-id="${attr(repository.id)}">
|
return `<button class="repo-row ${String(repository.id) === String(ui.selectedRepoId) ? "active" : ""} ${repository.attention ? "attention" : ""}" data-action="select-repo" data-id="${attr(repository.id)}" data-deployment-count="${profiles.length}">
|
||||||
<span class="repo-icon">${repository.favorite ? icon("star") : icon(repository.localPath ? "git" : "cloud")}</span>
|
<span class="repo-icon">${repository.favorite ? icon("star") : icon(repository.localPath ? "git" : "cloud")}</span>
|
||||||
<span class="repo-main"><span class="repo-name">${escapeHtml(repository.name)}</span><span class="repo-sub"><span>${escapeHtml(branch)}</span>${status?.shortHead ? `<span>• ${escapeHtml(status.shortHead)}</span>` : ""}</span></span>
|
<span class="repo-main"><span class="repo-name">${escapeHtml(repository.name)}</span><span class="repo-sub"><span>${escapeHtml(branch)}</span>${status?.shortHead ? `<span>• ${escapeHtml(status.shortHead)}</span>` : ""}</span></span>
|
||||||
<span class="repo-badges">${badges.join("")}</span>
|
<span class="repo-badges">${badges.join("")}</span>
|
||||||
@@ -80,7 +88,7 @@ function renderSidebar() {
|
|||||||
).length;
|
).length;
|
||||||
const rows = (list) => list.map(renderRepositoryRow).join("");
|
const rows = (list) => list.map(renderRepositoryRow).join("");
|
||||||
return `<aside class="sidebar">
|
return `<aside class="sidebar">
|
||||||
<nav class="primary-nav">${navButton("overview", "Overview", "overview", attention || "")}${navButton("deployments", "Deployments", "deploy", operations().filter((item) => item.type === "deployment" && !isTerminalOperation(item.status)).length || "")}${navButton("diagnostics", "Diagnostics", "shield", ui.diagnosticsStatus?.lastWriteError ? "!" : "")}${navButton("settings", "Settings", "settings")}</nav>
|
<nav class="primary-nav">${navButton("overview", "Overview", "overview", attention || "")}${navButton("deployments", "Deployments", "deploy", operations().filter((item) => item.type === "deployment" && !isTerminalOperation(item.status)).length || "")}${navButton("diagnostics", "Diagnostics", "shield", ui.diagnosticsStatus?.lastWriteError ? "!" : "")}${navButton("settings", "Settings", "settings")}${navButton("help", "Help", "help")}</nav>
|
||||||
<div class="sidebar-section"><span>Repositories</span><button data-action="refresh" title="Refresh">${icon("refresh")}</button></div>
|
<div class="sidebar-section"><span>Repositories</span><button data-action="refresh" title="Refresh">${icon("refresh")}</button></div>
|
||||||
<input class="repo-filter" id="repo-filter" value="${attr(ui.repoSearch)}" placeholder="Filter projects" aria-label="Filter projects" />
|
<input class="repo-filter" id="repo-filter" value="${attr(ui.repoSearch)}" placeholder="Filter projects" aria-label="Filter projects" />
|
||||||
<div class="repo-list">
|
<div class="repo-list">
|
||||||
@@ -150,6 +158,7 @@ function renderOverview() {
|
|||||||
return `<div class="page">
|
return `<div class="page">
|
||||||
<div class="page-header visual-page-header"><div><div class="eyebrow">Coding flow</div><h1>Release overview</h1><p>One decision surface for local work, Gitea synchronization and the exact version running on your server.</p></div>${projectIllustration("flow")}<button class="button" data-action="refresh">${icon("refresh")}Refresh all</button></div>
|
<div class="page-header visual-page-header"><div><div class="eyebrow">Coding flow</div><h1>Release overview</h1><p>One decision surface for local work, Gitea synchronization and the exact version running on your server.</p></div>${projectIllustration("flow")}<button class="button" data-action="refresh">${icon("refresh")}Refresh all</button></div>
|
||||||
${ui.refreshError ? `<div class="notice danger">${icon("error")} ${escapeHtml(ui.refreshError)}</div>` : ""}
|
${ui.refreshError ? `<div class="notice danger">${icon("error")} ${escapeHtml(ui.refreshError)}</div>` : ""}
|
||||||
|
${ui.refreshWarning ? `<div class="notice warning">${icon("warning")} ${escapeHtml(ui.refreshWarning)}</div>` : ""}
|
||||||
<div class="summary-grid">
|
<div class="summary-grid">
|
||||||
${renderSummaryCard("Local work", changed, changed === 1 ? "repository has changes" : "repositories have changes", "file", changed ? "warning" : "success")}
|
${renderSummaryCard("Local work", changed, changed === 1 ? "repository has changes" : "repositories have changes", "file", changed ? "warning" : "success")}
|
||||||
${renderSummaryCard("Unpushed", unpushed, "repositories ahead of Gitea", "arrowUp", unpushed ? "warning" : "success")}
|
${renderSummaryCard("Unpushed", unpushed, "repositories ahead of Gitea", "arrowUp", unpushed ? "warning" : "success")}
|
||||||
@@ -175,7 +184,7 @@ function renderOverview() {
|
|||||||
${readinessRow("Git executable", ui.boot.git.available, ui.boot.git.version || ui.boot.git.error)}
|
${readinessRow("Git executable", ui.boot.git.available, ui.boot.git.version || ui.boot.git.error)}
|
||||||
${readinessRow("Gitea connection", ui.boot.state.gitea.hasToken, ui.boot.state.gitea.baseUrl || "Not configured")}
|
${readinessRow("Gitea connection", ui.boot.state.gitea.hasToken, ui.boot.state.gitea.baseUrl || "Not configured")}
|
||||||
${readinessRow("Workspace folders", ui.boot.state.workspaceRoots.length > 0, `${ui.boot.state.workspaceRoots.length} configured`)}
|
${readinessRow("Workspace folders", ui.boot.state.workspaceRoots.length > 0, `${ui.boot.state.workspaceRoots.length} configured`)}
|
||||||
${readinessRow("Automatic awareness", ui.boot.state.preferences?.autoRefresh !== false, ui.boot.state.preferences?.autoRefresh === false ? "Manual refresh only" : `Every ${ui.boot.state.preferences?.repositoryPollSeconds || 4}s`)}
|
${readinessRow("Automatic awareness", ui.boot.state.preferences?.autoRefresh !== false, ui.boot.state.preferences?.autoRefresh === false ? "Manual refresh only" : `Local every ${ui.boot.state.preferences?.repositoryPollSeconds || 4}s · Gitea every ${ui.boot.state.preferences?.fetchIntervalMinutes || "manual"}${ui.boot.state.preferences?.fetchIntervalMinutes ? " min" : ""}`)}
|
||||||
</div></div>
|
</div></div>
|
||||||
</section>
|
</section>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -198,40 +207,6 @@ function linkedWorkloadsForRepository(repository) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function diffAtmosphere(diff) {
|
|
||||||
if (!ui.selectedFile) return "";
|
|
||||||
const lines = String(diff || "").split("\n");
|
|
||||||
const additions = lines.filter(
|
|
||||||
(line) => line.startsWith("+") && !line.startsWith("+++"),
|
|
||||||
).length;
|
|
||||||
const removals = lines.filter(
|
|
||||||
(line) => line.startsWith("-") && !line.startsWith("---"),
|
|
||||||
).length;
|
|
||||||
const extension =
|
|
||||||
String(ui.selectedFile).split(".").pop()?.slice(0, 8).toUpperCase() ||
|
|
||||||
"FILE";
|
|
||||||
return `<div class="diff-atmosphere ${lines.length > 34 ? "dense" : ""}" data-diff-atmosphere aria-hidden="true"><svg viewBox="0 0 360 260" role="presentation"><path class="code-route route-a" d="M38 195 C92 84 178 214 318 74"/><path class="code-route route-b" d="M52 74 C132 8 230 34 310 156"/><g class="code-card"><rect x="110" y="75" width="142" height="106" rx="18"/><path d="M136 108h90M136 128h58M136 148h76"/></g><g class="code-node node-one"><circle cx="48" cy="190" r="15"/><path d="m41 190 5 5 9-12"/></g><g class="code-node node-two"><circle cx="315" cy="76" r="13"/><path d="M308 76h14M315 69v14"/></g><circle class="code-packet packet-one" cx="0" cy="0" r="5"/><circle class="code-packet packet-two" cx="0" cy="0" r="4"/></svg><div class="diff-atmosphere-caption"><span>${escapeHtml(extension)} change map</span><strong><i>+${additions}</i><i>−${removals}</i></strong></div></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDiff(diff) {
|
|
||||||
if (!diff)
|
|
||||||
return '<div class="empty-state"><div class="empty-icon">↔</div><h3>No textual diff</h3><p>Select another file or open the project folder for binary changes.</p></div>';
|
|
||||||
const rendered = escapeHtml(diff)
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => {
|
|
||||||
const type =
|
|
||||||
line.startsWith("+") && !line.startsWith("+++")
|
|
||||||
? "add"
|
|
||||||
: line.startsWith("-") && !line.startsWith("---")
|
|
||||||
? "remove"
|
|
||||||
: line.startsWith("@@")
|
|
||||||
? "hunk"
|
|
||||||
: "";
|
|
||||||
return `<span class="diff-line ${type}">${line || " "}</span>`;
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
return `${rendered}${diffAtmosphere(diff)}`;
|
|
||||||
}
|
|
||||||
function fileStatusCode(file) {
|
function fileStatusCode(file) {
|
||||||
if (file.conflict) return "U";
|
if (file.conflict) return "U";
|
||||||
if (file.untracked) return "?";
|
if (file.untracked) return "?";
|
||||||
@@ -365,7 +340,7 @@ function renderProfileCard(repository, profile, compact = false) {
|
|||||||
const serverAccessAction = isSsh && mode === "server-git"
|
const serverAccessAction = isSsh && mode === "server-git"
|
||||||
? `<button class="button" data-action="verify-server-git-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Verify server pull</button><button class="button" data-action="manage-deploy-key" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("key")}Deploy key lifecycle</button><button class="button" data-action="configure-server-git-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("key")}Configure Gitea access</button>`
|
? `<button class="button" data-action="verify-server-git-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Verify server pull</button><button class="button" data-action="manage-deploy-key" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("key")}Deploy key lifecycle</button><button class="button" data-action="configure-server-git-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("key")}Configure Gitea access</button>`
|
||||||
: "";
|
: "";
|
||||||
return `<article class="deploy-card accent-${identity.accent} ${compact ? "compact-card" : ""}"><div class="container-identity"><span class="container-avatar">${escapeHtml(identity.initial)}</span><div><span>Container</span><strong>${escapeHtml(identity.name)}</strong><small>${escapeHtml(repository.fullName)} · ${escapeHtml(profile.environment)}</small></div>${syncLabel}</div><div class="deploy-card-header"><div><div class="eyebrow">${escapeHtml(isSsh ? "SSH / UNRAID" : "GITEA ACTIONS")}</div><h3>${escapeHtml(profile.name)}</h3><p>${escapeHtml(providerDetail)}</p></div><span class="status-pill ${health.tone}"><span class="state-dot ${health.tone}"></span>${health.label}</span></div><div class="deploy-card-body"><div class="deploy-metadata"><span>Live commit</span><strong>${state.liveSha ? shortSha(state.liveSha) : "Unknown"}</strong><span>Deploy source</span><strong>${escapeHtml(sourceLabel)}</strong><span>Previous version</span><strong>${state.previousSha ? shortSha(state.previousSha) : "Unknown"}</strong><span>Last checked</span><strong>${state.checkedAt ? formatDate(state.checkedAt) : "Never"}</strong>${isSsh ? `<span>Deployment mode</span><strong>${escapeHtml(modeLabel)}</strong><span>Compose project</span><strong>${escapeHtml(profile.composeProject || "ForgeFlow-generated identity")}</strong><span>Runtime</span><strong>${state.containerRunning === false ? "Stopped" : state.containerRunning ? state.runtimeVerification === "running-unverified" ? "Running · unverified" : "Running" : "Unknown"}</strong><span>DockerMan</span><strong class="${managesDockerMan && !dockerManReady ? "text-warning" : "text-success"}">${escapeHtml(dockerManLabel)}</strong>` : ""}<span>Rollback</span><strong>${rollbackConfigured ? "Available after first deploy" : "Not configured"}</strong></div><div class="card-actions"><button class="button" data-action="run-deployment-preflight" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Preflight</button>${isSsh ? `<button class="button" data-action="repair-deployment-write-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}Check / fix write access</button>` : ""}${serverAccessAction}<button class="button" data-action="reconcile-deployment" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("refresh")}Refresh truth</button>${webUi ? `<button class="button" data-action="open-profile-webui" data-url="${attr(webUi)}">${icon("external")}Open Web UI</button>` : ""}${managesDockerMan ? `<button class="button ${dockerManReady ? "ghost" : ""}" data-action="apply-dockerman-metadata" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}${dockerManReady ? "Reapply DockerMan integration" : "Repair DockerMan integration"}</button>` : ""}${ready ? `<button class="button primary" data-action="deploy-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("rocket")}Deploy ${escapeHtml(shortSha(targetSha))}</button>` : ""}<button class="button ghost" data-action="edit-deployment-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">Edit</button>${state.previousSha && rollbackConfigured ? `<button class="button danger" data-action="rollback-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("undo")}Rollback</button>` : ""}</div></div></article>`;
|
return `<article class="deploy-card accent-${identity.accent} ${compact ? "compact-card" : ""}"><div class="container-identity"><span class="container-avatar">${escapeHtml(identity.initial)}</span><div><span>Container</span><strong>${escapeHtml(identity.name)}</strong><small>${escapeHtml(repository.fullName)} · ${escapeHtml(profile.environment)}</small></div>${syncLabel}</div><div class="deploy-card-header"><div><div class="eyebrow">${escapeHtml(isSsh ? "SSH / UNRAID" : "GITEA ACTIONS")}</div><h3>${escapeHtml(profile.name)}</h3><p>${escapeHtml(providerDetail)}</p></div><span class="status-pill ${health.tone}"><span class="state-dot ${health.tone}"></span>${health.label}</span></div><div class="deploy-card-body"><div class="deploy-metadata"><span>Live commit</span><strong>${state.liveSha ? shortSha(state.liveSha) : "Unknown"}</strong><span>Deploy source</span><strong>${escapeHtml(sourceLabel)}</strong><span>Previous version</span><strong>${state.previousSha ? shortSha(state.previousSha) : "Unknown"}</strong><span>Last checked</span><strong>${state.checkedAt ? formatDate(state.checkedAt) : "Never"}</strong>${isSsh ? `<span>Deployment mode</span><strong>${escapeHtml(modeLabel)}</strong>${mode === "server-git" ? `<span>Server pull</span><strong class="${verification ? verification.deployReady ? "text-success" : "text-warning" : ""}">${escapeHtml(verification?.readiness || "Verify before deployment")}</strong>` : ""}<span>Compose project</span><strong>${escapeHtml(profile.composeProject || "ForgeFlow-generated identity")}</strong><span>Runtime</span><strong>${state.containerRunning === false ? "Stopped" : state.containerRunning ? state.runtimeVerification === "running-unverified" ? "Running · unverified" : "Running" : "Unknown"}</strong><span>DockerMan</span><strong class="${managesDockerMan && !dockerManReady ? "text-warning" : "text-success"}">${escapeHtml(dockerManLabel)}</strong>` : ""}<span>Rollback</span><strong>${rollbackConfigured ? "Available after first deploy" : "Not configured"}</strong></div><div class="card-actions"><button class="button" data-action="run-deployment-preflight" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Preflight</button>${isSsh ? `<button class="button" data-action="repair-deployment-write-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}Check / fix write access</button>` : ""}${serverAccessAction}<button class="button" data-action="reconcile-deployment" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("refresh")}Refresh truth</button>${webUi ? `<button class="button" data-action="open-profile-webui" data-url="${attr(webUi)}">${icon("external")}Open Web UI</button>` : ""}${managesDockerMan ? `<button class="button ${dockerManReady ? "ghost" : ""}" data-action="apply-dockerman-metadata" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}${dockerManReady ? "Reapply DockerMan integration" : "Repair DockerMan integration"}</button>` : ""}${ready ? `<button class="button primary" data-action="deploy-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("rocket")}Deploy ${escapeHtml(shortSha(targetSha))}</button>` : ""}<button class="button ghost" data-action="edit-deployment-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">Edit</button>${state.previousSha && rollbackConfigured ? `<button class="button danger" data-action="rollback-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("undo")}Rollback</button>` : ""}</div></div></article>`;
|
||||||
}
|
}
|
||||||
function renderRepositoryDeployments(repository) {
|
function renderRepositoryDeployments(repository) {
|
||||||
const profiles = repository.deploymentProfiles || [];
|
const profiles = repository.deploymentProfiles || [];
|
||||||
@@ -387,7 +362,28 @@ function renderGitTools(repository) {
|
|||||||
const locks = recovery?.lockReport?.locks || [];
|
const locks = recovery?.lockReport?.locks || [];
|
||||||
const activeProcesses = recovery?.lockReport?.processes?.active || [];
|
const activeProcesses = recovery?.lockReport?.processes?.active || [];
|
||||||
const recommendations = recovery?.recommendations || [];
|
const recommendations = recovery?.recommendations || [];
|
||||||
return `<div class="tab-page git-tools-grid"><section class="panel"><div class="panel-header"><h2>Branches</h2><button class="button ghost" data-action="load-git-tools">${icon("refresh")}Refresh</button></div><div class="panel-body"><div class="inline-form"><input id="new-branch-name" class="input" placeholder="feature/name"/><button class="button" data-action="create-branch">${icon("plus")}Create & switch</button></div><div class="tool-list">${ui.branches.length ? ui.branches.map((branch) => `<div class="tool-row"><div><strong>${escapeHtml(branch.name)}</strong><span>${escapeHtml(branch.shortSha)}${branch.upstream ? ` · ${escapeHtml(branch.upstream)}` : " · unpublished"}</span></div>${branch.current ? '<span class="status-pill success">Current</span>' : `<button class="button" data-action="checkout-branch" data-branch="${attr(branch.name)}">Switch</button>`}</div>`).join("") : '<div class="empty-state compact"><p>Load branch information.</p></div>'}</div></div></section><section class="panel"><div class="panel-header"><h2>Stashes</h2><button class="button" data-action="stash-changes" ${repository.localStatus?.clean ? "disabled" : ""}>${icon("archive")}Stash changes</button></div><div class="panel-body"><div class="tool-list">${ui.stashes.length ? ui.stashes.map((stash) => `<div class="tool-row"><div><strong>${escapeHtml(stash.ref)}</strong><span>${escapeHtml(stash.subject)} · ${formatDate(stash.date)}</span></div><button class="button" data-action="pop-stash" data-stash-ref="${attr(stash.ref)}">Apply & drop</button></div>`).join("") : '<div class="empty-state compact"><p>No stashes, or Git tools have not been loaded.</p></div>'}</div></div></section><section class="panel troubleshooting-panel"><div class="panel-header"><div><h2>Repository troubleshooting</h2><span class="meta">Safe, repository-specific recovery actions</span></div><button class="button primary" data-action="scan-git-recovery">${icon("pulse")}Scan</button></div><div class="panel-body">${recovery ? `<div class="troubleshooting-summary"><span class="status-pill ${locks.length ? "warning" : "success"}">${locks.length ? `${locks.length} lock${locks.length === 1 ? "" : "s"}` : "No Git locks"}</span><span>${activeProcesses.length ? `${activeProcesses.length} active Git process(es)` : "No matching active Git process detected"}</span></div>${locks.length ? `<div class="tool-list">${locks.map((lock) => `<div class="tool-row"><div><strong>${escapeHtml(lock.name)}</strong><span>${Math.round(lock.ageMs / 1000)}s old · ${escapeHtml(lock.modifiedAt)}</span></div></div>`).join("")}</div>` : ""}${recommendations.length ? `<div class="tool-list recovery-actions">${recommendations.map((item) => `<div class="tool-row"><div><strong>${escapeHtml(item.label)}</strong><span>${item.safe ? "Safe automated action" : item.action ? "Creates a safety branch before changing history" : "Review required"}</span></div>${item.action ? `<button class="button ${item.safe ? "" : "danger"}" data-action="repair-repository-sync" data-strategy="${attr(item.action)}">Run</button>` : ""}</div>`).join("")}</div>` : ""}` : '<div class="empty-state compact"><p>Scan before repairing. ForgeFlow checks every .lock file in the actual Git directory, not only index.lock.</p></div>'}<div class="card-actions"><button class="button" data-action="repair-git-locks">${icon("wrench")}Repair proven stale locks</button><button class="button" data-action="reconcile-repository">${icon("refresh")}Refresh Git state</button>${repository.sshUrl && repository.localStatus?.remoteUrl !== repository.sshUrl ? `<button class="button" data-action="repair-origin">${icon("link")}Repair origin</button>` : ""}</div><div class="notice warning">Lock repair refuses to run while a matching Git process is active. A force option is shown only when process detection itself is unavailable.</div></div></section></div>`;
|
const status = repository.localStatus || {};
|
||||||
|
const branchRows = ui.branches.length
|
||||||
|
? ui.branches.map((branch) => `<div class="tool-row"><div><strong>${escapeHtml(branch.name)}</strong><span>${escapeHtml(branch.shortSha)}${branch.upstream ? ` · ${escapeHtml(branch.upstream)}` : " · unpublished"}</span></div>${branch.current ? '<span class="status-pill success">Current</span>' : `<button class="button" data-action="checkout-branch" data-branch="${attr(branch.name)}">Switch</button>`}</div>`).join("")
|
||||||
|
: '<div class="empty-state compact"><p>Load branch information.</p></div>';
|
||||||
|
const stashRows = ui.stashes.length
|
||||||
|
? ui.stashes.map((stash) => `<div class="tool-row"><div><strong>${escapeHtml(stash.ref)}</strong><span>${escapeHtml(stash.subject)} · ${formatDate(stash.date)}</span></div><button class="button" data-action="pop-stash" data-stash-ref="${attr(stash.ref)}">Apply & drop</button></div>`).join("")
|
||||||
|
: '<div class="empty-state compact"><p>No stashes, or Git tools have not been loaded.</p></div>';
|
||||||
|
const recoveryBody = recovery
|
||||||
|
? `<div class="troubleshooting-summary"><span class="status-pill ${locks.length ? "warning" : "success"}">${locks.length ? `${locks.length} lock${locks.length === 1 ? "" : "s"}` : "No Git locks"}</span><span>${activeProcesses.length ? `${activeProcesses.length} active Git process(es)` : "No matching active Git process detected"}</span></div>${locks.length ? `<div class="tool-list">${locks.map((lock) => `<div class="tool-row"><div><strong>${escapeHtml(lock.name)}</strong><span>${Math.round(lock.ageMs / 1000)}s old · ${escapeHtml(lock.modifiedAt)}</span></div></div>`).join("")}</div>` : ""}${recommendations.length ? `<div class="tool-list recovery-actions">${recommendations.map((item) => `<div class="tool-row"><div><strong>${escapeHtml(item.label)}</strong><span>${item.safe ? "Safe automated action" : item.action ? "Creates a safety branch before changing history" : "Review required"}</span></div>${item.action ? `<button class="button ${item.safe ? "" : "danger"}" data-action="repair-repository-sync" data-strategy="${attr(item.action)}">Run</button>` : ""}</div>`).join("")}</div>` : ""}`
|
||||||
|
: '<div class="empty-state compact"><p>Scan before repairing. ForgeFlow checks every .lock file in the actual Git directory, not only index.lock.</p></div>';
|
||||||
|
const syncState = status.counts?.changed
|
||||||
|
? `${status.counts.changed} local file${status.counts.changed === 1 ? "" : "s"} need protection`
|
||||||
|
: status.branch?.ahead || status.branch?.behind
|
||||||
|
? `${status.branch.ahead || 0} ahead · ${status.branch.behind || 0} behind`
|
||||||
|
: "Preview against Gitea before changing files";
|
||||||
|
|
||||||
|
return `<div class="tab-page git-tools-grid">
|
||||||
|
<section class="panel"><div class="panel-header"><h2>Branches</h2><button class="button ghost" data-action="load-git-tools">${icon("refresh")}Refresh</button></div><div class="panel-body"><div class="inline-form"><input id="new-branch-name" class="input" placeholder="feature/name"/><button class="button" data-action="create-branch">${icon("plus")}Create & switch</button></div><div class="tool-list">${branchRows}</div></div></section>
|
||||||
|
<section class="panel"><div class="panel-header"><h2>Stashes</h2><button class="button" data-action="stash-changes" ${status.clean ? "disabled" : ""}>${icon("archive")}Stash changes</button></div><div class="panel-body"><div class="tool-list">${stashRows}</div></div></section>
|
||||||
|
<section class="panel workspace-sync-panel"><div class="panel-header"><div><h2>Gitea workspace sync</h2><span class="meta">Make tracked files match the current upstream branch exactly</span></div><div class="panel-header-actions"><button class="button ghost" data-action="open-context-help" data-topic="workspace-sync">${icon("help")}How does this work?</button><span class="status-pill ${status.branch?.behind || status.branch?.ahead || status.counts?.changed ? "warning" : "success"}">${escapeHtml(syncState)}</span></div></div><div class="panel-body"><div class="workspace-sync-layout"><div><h3>Safe mirror, never silent overwrite</h3><p>ForgeFlow fetches Gitea, previews additions, changes and deletions, then protects local Codex work before resetting. Local commits go to a recovery branch; modified and untracked files go to a stash.</p><div class="notice">${icon("shield")}Ignored runtime data such as <span class="mono">.env</span>, dependency folders and local databases is preserved. Background awareness only fetches; it never applies this sync automatically.</div></div><div class="workspace-sync-actions"><span class="meta">${escapeHtml(status.branch?.head || "No branch")} → ${escapeHtml(status.branch?.upstream || "No upstream")}</span><button class="button primary" data-action="preview-workspace-sync">${icon("refresh")}Preview Gitea sync</button></div></div></div></section>
|
||||||
|
<section class="panel troubleshooting-panel"><div class="panel-header"><div><h2>Repository troubleshooting</h2><span class="meta">Safe, repository-specific recovery actions</span></div><button class="button primary" data-action="scan-git-recovery">${icon("pulse")}Scan</button></div><div class="panel-body">${recoveryBody}<div class="card-actions"><button class="button" data-action="repair-git-locks">${icon("wrench")}Repair proven stale locks</button><button class="button" data-action="reconcile-repository">${icon("refresh")}Refresh Git state</button>${repository.sshUrl && status.remoteUrl !== repository.sshUrl ? `<button class="button" data-action="repair-origin">${icon("link")}Repair origin</button>` : ""}</div><div class="notice warning">Lock repair refuses to run while a matching Git process is active. A force option is shown only when process detection itself is unavailable.</div></div></section>
|
||||||
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRepositorySettings(repository) {
|
function renderRepositorySettings(repository) {
|
||||||
@@ -432,6 +428,7 @@ function renderRepositoryWorkspace(repository) {
|
|||||||
const profiles = repository.deploymentProfiles || [];
|
const profiles = repository.deploymentProfiles || [];
|
||||||
const linkedWorkloads = linkedWorkloadsForRepository(repository);
|
const linkedWorkloads = linkedWorkloadsForRepository(repository);
|
||||||
const profile = selectedProfile(repository);
|
const profile = selectedProfile(repository);
|
||||||
|
const profileWorkload = linkedWorkloads.find((workload) => workload.link?.profileId === profile?.id);
|
||||||
const serverState = profile?.state || {};
|
const serverState = profile?.state || {};
|
||||||
const localTone = status?.counts.conflicts
|
const localTone = status?.counts.conflicts
|
||||||
? "danger"
|
? "danger"
|
||||||
@@ -452,6 +449,8 @@ function renderRepositoryWorkspace(repository) {
|
|||||||
? "danger"
|
? "danger"
|
||||||
: serverState.healthy === true
|
: serverState.healthy === true
|
||||||
? "success"
|
? "success"
|
||||||
|
: profileWorkload?.runtime?.running
|
||||||
|
? "success"
|
||||||
: "";
|
: "";
|
||||||
const content = (
|
const content = (
|
||||||
{
|
{
|
||||||
@@ -473,9 +472,11 @@ function renderRepositoryWorkspace(repository) {
|
|||||||
}).join("")}</div><button class="button ghost" data-action="select-deployment-profile" data-profile-id="${attr(profile?.id || profiles[0].id)}">View all</button></div>`
|
}).join("")}</div><button class="button ghost" data-action="select-deployment-profile" data-profile-id="${attr(profile?.id || profiles[0].id)}">View all</button></div>`
|
||||||
: "";
|
: "";
|
||||||
return `<div class="repo-workspace"><header class="repo-header illustrated-repo-header"><div class="repo-heading"><h1><button class="favorite-button ${repository.favorite ? "active" : ""}" data-action="toggle-favorite" title="Toggle favorite">${icon("star")}</button>${escapeHtml(repository.fullName)}</h1><p>${escapeHtml(repository.localPath || "No local working tree linked")}</p></div>${projectIllustration("repo")}<div class="repo-header-actions"><button class="button" data-action="fetch" ${!repository.localPath ? "disabled" : ""}>${icon("refresh")}Fetch</button><button class="button" data-action="open-path" ${!repository.localPath ? "disabled" : ""}>${icon("folder")}Folder</button><button class="button" data-action="open-gitea" ${!repository.htmlUrl ? "disabled" : ""}>${icon("external")}Gitea</button></div></header>
|
return `<div class="repo-workspace"><header class="repo-header illustrated-repo-header"><div class="repo-heading"><h1><button class="favorite-button ${repository.favorite ? "active" : ""}" data-action="toggle-favorite" title="Toggle favorite">${icon("star")}</button>${escapeHtml(repository.fullName)}</h1><p>${escapeHtml(repository.localPath || "No local working tree linked")}</p></div>${projectIllustration("repo")}<div class="repo-header-actions"><button class="button" data-action="fetch" ${!repository.localPath ? "disabled" : ""}>${icon("refresh")}Fetch</button><button class="button" data-action="open-path" ${!repository.localPath ? "disabled" : ""}>${icon("folder")}Folder</button><button class="button" data-action="open-gitea" ${!repository.htmlUrl ? "disabled" : ""}>${icon("external")}Gitea</button></div></header>
|
||||||
${repository.localPath ? `<div class="repo-quick-actions"><button class="button" data-action="open-editor">${icon("external")}Open in editor</button><button class="button" data-action="open-terminal">${icon("terminal")}Open terminal</button><button class="button" data-action="check-branch-protection">${icon("shield")}Check branch protection</button><button class="button primary" data-action="open-pull-request">${icon("git")}Create pull request</button>${ui.branchProtection ? `<span class="status-pill ${ui.branchProtection.protected ? "warning" : "success"}">${ui.branchProtection.protected ? `Protected · ${ui.branchProtection.requiredApprovals || 0} approval(s)` : "Direct pushes allowed"}</span>` : ""}</div>` : ""}
|
<div class="repo-context">
|
||||||
<div class="release-rail">${releaseNode("Local", status?.shortHead || "Not linked", status ? `${status.counts.changed} changes · ${status.branch.head}` : "No working tree", localTone)}${releaseNode("Gitea", status?.shortHead || "Unknown", status?.branch.upstream ? `${status.branch.ahead} ahead · ${status.branch.behind} behind` : "Branch not published", remoteTone)}${releaseNode(`Server${profile ? ` · ${profile.environment}` : ""}`, serverState.liveSha ? shortSha(serverState.liveSha) : "Unknown", profile ? (serverState.checkedAt ? `checked ${formatDate(serverState.checkedAt)}` : "not checked") : "No deployment profile", serverTone)}</div>
|
${repository.localPath ? `<div class="repo-quick-actions"><button class="button" data-action="open-editor">${icon("external")}Open in editor</button><button class="button" data-action="open-terminal">${icon("terminal")}Open terminal</button><button class="button" data-action="check-branch-protection">${icon("shield")}Check branch protection</button><button class="button primary" data-action="open-pull-request">${icon("git")}Create pull request</button>${ui.branchProtection ? `<span class="status-pill ${ui.branchProtection.protected ? "warning" : "success"}">${ui.branchProtection.protected ? `Protected · ${ui.branchProtection.requiredApprovals || 0} approval(s)` : "Direct pushes allowed"}</span>` : ""}</div>` : ""}
|
||||||
${deploymentLinks}
|
<div class="release-rail">${releaseNode("Local", status?.shortHead || "Not linked", status ? `${status.counts.changed} changes · ${status.branch.head}` : "No working tree", localTone)}${releaseNode("Gitea", status?.shortHead || "Unknown", status?.branch.upstream ? `${status.branch.ahead} ahead · ${status.branch.behind} behind` : "Branch not published", remoteTone)}${releaseNode(`Server${profile ? ` · ${profile.environment}` : ""}`, serverState.liveSha ? shortSha(serverState.liveSha) : profile ? "Linked" : "Unknown", profileWorkload ? `${profileWorkload.displayName || profile.containerName || "Container"} · ${profileWorkload.runtime?.running ? "running" : "stopped"} on ${profileWorkload.serverName}` : profile ? (serverState.checkedAt ? `checked ${formatDate(serverState.checkedAt)}` : "profile linked · awaiting live scan") : "No deployment profile", serverTone)}</div>
|
||||||
|
${deploymentLinks}
|
||||||
|
</div>
|
||||||
<nav class="tabs">${[
|
<nav class="tabs">${[
|
||||||
["changes", "Changes"],
|
["changes", "Changes"],
|
||||||
["history", "History"],
|
["history", "History"],
|
||||||
@@ -596,7 +597,7 @@ function renderServerInventory() {
|
|||||||
const repository = ui.repositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link?.repositoryFullName || "").toLowerCase());
|
const repository = ui.repositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link?.repositoryFullName || "").toLowerCase());
|
||||||
return !repository?.deploymentProfiles?.some((profile) => profile.id === workload.link?.profileId);
|
return !repository?.deploymentProfiles?.some((profile) => profile.id === workload.link?.profileId);
|
||||||
}).length;
|
}).length;
|
||||||
return `<section class="panel server-inventory-panel"><div class="panel-header"><div><h3>${escapeHtml(server.serverName || server.server?.name || server.serverId)}</h3><span class="meta">${server.running || 0} running · ${resolvedLinks} visible repository links${unresolvedLinks ? ` · ${unresolvedLinks} unresolved` : ""} · ${visibleWorkloads.filter((workload) => !workload.link).length} to review${hiddenCount ? ` · ${hiddenCount} unrelated/system workloads hidden` : ""}</span></div><div class="stack horizontal compact"><span class="status-pill ${server.error ? "danger" : capabilities.docker && capabilities.compose ? "success" : "warning"}">${server.error ? "Scan failed" : escapeHtml(capabilityText)}</span>${server.error ? "" : `<button class="button" data-action="plan-server-reconciliation" data-server-id="${attr(server.serverId)}">${icon("shield")}Review reconciliation</button>`}</div></div><div class="panel-body">${errorBlock}${warnings}<div class="tool-list">${workloads}</div></div></section>`;
|
return `<section class="panel server-inventory-panel"><div class="panel-header"><div><h3>${escapeHtml(server.serverName || server.server?.name || server.serverId)}</h3><span class="meta">${server.running || 0} running · ${resolvedLinks} visible repository link${resolvedLinks === 1 ? "" : "s"}${unresolvedLinks ? ` · ${unresolvedLinks} unresolved` : ""} · ${visibleWorkloads.filter((workload) => !workload.link).length} to review${hiddenCount ? ` · ${hiddenCount} unrelated/system workloads hidden` : ""}</span></div><div class="stack horizontal compact"><span class="status-pill ${server.error ? "danger" : capabilities.docker && capabilities.compose ? "success" : "warning"}">${server.error ? "Scan failed" : escapeHtml(capabilityText)}</span>${server.error ? "" : `<button class="button" data-action="plan-server-reconciliation" data-server-id="${attr(server.serverId)}">${icon("shield")}Review reconciliation</button>`}</div></div><div class="panel-body">${errorBlock}${warnings}<div class="tool-list">${workloads}</div></div></section>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
const empty = configuredServers.length
|
const empty = configuredServers.length
|
||||||
? `<div class="empty-state panel"><h3>Server inventory has not completed</h3><p>ForgeFlow will query Docker directly. A failed connection is shown explicitly instead of being reported as zero deployments.</p><button class="button primary" data-action="scan-server-inventory">Scan servers now</button></div>`
|
? `<div class="empty-state panel"><h3>Server inventory has not completed</h3><p>ForgeFlow will query Docker directly. A failed connection is shown explicitly instead of being reported as zero deployments.</p><button class="button primary" data-action="scan-server-inventory">Scan servers now</button></div>`
|
||||||
@@ -617,6 +618,143 @@ function renderDeployments() {
|
|||||||
);
|
);
|
||||||
return `<div class="page"><div class="page-header visual-page-header"><div><div class="eyebrow">Server releases</div><h1>Deployments</h1><p>Discover live Unraid workloads, verify them against Gitea and release an exact commit through a protected server pull.</p></div>${projectIllustration("deploy")}<div class="stack horizontal compact"><button class="button" data-action="refresh-operations">${icon("refresh")}Refresh runs & servers</button>${missingDockerMan.length ? `<button class="button primary" data-action="repair-missing-dockerman">${icon("wrench")}Repair ${missingDockerMan.length} managed integration${missingDockerMan.length === 1 ? "" : "s"}</button>` : ""}</div></div>${active.length ? `<div class="notice warning">${icon("pulse")} ${active.length} deployment operation${active.length === 1 ? " is" : "s are"} still active. ForgeFlow reconciles these against the live server automatically.</div>` : ""}${renderServerInventory()}<section class="section-block"><div class="section-heading"><div><h2>Linked deployment environments</h2><span class="meta">Stable Compose identity, live container health and exact Gitea commit parity</span></div></div><div class="deploy-card-grid">${cards.length ? cards.map(({ repository, profile }) => renderProfileCard(repository, profile, true)).join("") : '<div class="empty-state panel"><h3>No deployment environments configured</h3><p>Scan a server and link an existing workload, or open a repository and add an environment.</p></div>'}</div></section><section class="section-block"><div class="section-heading"><h2>All operations</h2><span class="meta">Newest first</span></div><div class="panel">${operations().length ? `<table class="data-table"><thead><tr><th>Repository</th><th>Action</th><th>Environment</th><th>Commit</th><th>Status</th><th>Updated</th><th></th></tr></thead><tbody>${operations().map((operation) => `<tr><td>${escapeHtml(operation.repository)}</td><td>${escapeHtml(operation.action || "deploy")}</td><td>${escapeHtml(operation.environment || "—")}</td><td class="mono">${escapeHtml(operation.shortSha || shortSha(operation.sha))}</td><td><span class="status-pill ${toneForStatus(operation.status)}">${escapeHtml(operation.status)}</span></td><td>${formatDate(operation.updatedAt || operation.createdAt)}</td><td><button class="button ghost" data-action="open-operation" data-operation-id="${attr(operation.id)}">Open</button></td></tr>`).join("")}</tbody></table>` : '<div class="empty-state compact"><p>No operations recorded.</p></div>'}</div></section></div>`;
|
return `<div class="page"><div class="page-header visual-page-header"><div><div class="eyebrow">Server releases</div><h1>Deployments</h1><p>Discover live Unraid workloads, verify them against Gitea and release an exact commit through a protected server pull.</p></div>${projectIllustration("deploy")}<div class="stack horizontal compact"><button class="button" data-action="refresh-operations">${icon("refresh")}Refresh runs & servers</button>${missingDockerMan.length ? `<button class="button primary" data-action="repair-missing-dockerman">${icon("wrench")}Repair ${missingDockerMan.length} managed integration${missingDockerMan.length === 1 ? "" : "s"}</button>` : ""}</div></div>${active.length ? `<div class="notice warning">${icon("pulse")} ${active.length} deployment operation${active.length === 1 ? " is" : "s are"} still active. ForgeFlow reconciles these against the live server automatically.</div>` : ""}${renderServerInventory()}<section class="section-block"><div class="section-heading"><div><h2>Linked deployment environments</h2><span class="meta">Stable Compose identity, live container health and exact Gitea commit parity</span></div></div><div class="deploy-card-grid">${cards.length ? cards.map(({ repository, profile }) => renderProfileCard(repository, profile, true)).join("") : '<div class="empty-state panel"><h3>No deployment environments configured</h3><p>Scan a server and link an existing workload, or open a repository and add an environment.</p></div>'}</div></section><section class="section-block"><div class="section-heading"><h2>All operations</h2><span class="meta">Newest first</span></div><div class="panel">${operations().length ? `<table class="data-table"><thead><tr><th>Repository</th><th>Action</th><th>Environment</th><th>Commit</th><th>Status</th><th>Updated</th><th></th></tr></thead><tbody>${operations().map((operation) => `<tr><td>${escapeHtml(operation.repository)}</td><td>${escapeHtml(operation.action || "deploy")}</td><td>${escapeHtml(operation.environment || "—")}</td><td class="mono">${escapeHtml(operation.shortSha || shortSha(operation.sha))}</td><td><span class="status-pill ${toneForStatus(operation.status)}">${escapeHtml(operation.status)}</span></td><td>${formatDate(operation.updatedAt || operation.createdAt)}</td><td><button class="button ghost" data-action="open-operation" data-operation-id="${attr(operation.id)}">Open</button></td></tr>`).join("")}</tbody></table>` : '<div class="empty-state compact"><p>No operations recorded.</p></div>'}</div></section></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HELP_TOPICS = [
|
||||||
|
{
|
||||||
|
id: "getting-started",
|
||||||
|
icon: "rocket",
|
||||||
|
category: "Basics",
|
||||||
|
title: "Start with a repository",
|
||||||
|
summary: "Connect Gitea, discover local projects and understand the Local → Gitea → Server flow.",
|
||||||
|
keywords: "setup connect token roots clone local remote overview",
|
||||||
|
steps: [
|
||||||
|
"Open Settings and validate the Gitea URL and token.",
|
||||||
|
"Add the parent folders that contain your projects, then save and rescan.",
|
||||||
|
"Select a repository. The release rail shows local changes, Gitea parity and the linked server release.",
|
||||||
|
"Use Changes to review work; use Git tools for branches, synchronization and pull requests.",
|
||||||
|
],
|
||||||
|
note: "ForgeFlow does not modify a project merely because you opened it or refreshed the overview.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "workspace-sync",
|
||||||
|
icon: "refresh",
|
||||||
|
category: "Git & Gitea",
|
||||||
|
title: "Make a local project match Gitea",
|
||||||
|
summary: "Clean tracked leftovers without losing local Codex work or ignored runtime data.",
|
||||||
|
keywords: "sync mirror pull reset deleted files cleanup stash recovery codex upstream dirty",
|
||||||
|
steps: [
|
||||||
|
"Open the repository, choose Git tools and select Preview Gitea sync.",
|
||||||
|
"Review every file that will be added, changed or removed.",
|
||||||
|
"Choose Protect local work & synchronize only when the preview matches your intention.",
|
||||||
|
"ForgeFlow creates a recovery branch for local commits and a stash for modified or untracked files before matching the upstream commit.",
|
||||||
|
],
|
||||||
|
note: "Ignored files such as .env, local databases and dependency folders remain untouched. Background awareness only fetches metadata and never applies a sync.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "changes",
|
||||||
|
icon: "file",
|
||||||
|
category: "Git & Gitea",
|
||||||
|
title: "Review, commit and publish changes",
|
||||||
|
summary: "Keep intentional local changes separate from remote updates.",
|
||||||
|
keywords: "changes stage hunk commit push branch pull request conflict",
|
||||||
|
steps: [
|
||||||
|
"Review selected files or individual hunks in Changes.",
|
||||||
|
"Enter a clear commit message and commit the reviewed selection.",
|
||||||
|
"Fetch before publishing; if Gitea changed, synchronize or resolve divergence first.",
|
||||||
|
"Push directly only when branch protection permits it, otherwise create a pull request.",
|
||||||
|
],
|
||||||
|
note: "The action panel explains the current blocker and only enables operations that are safe for the selected state.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "deployment-linking",
|
||||||
|
icon: "link",
|
||||||
|
category: "Deployments",
|
||||||
|
title: "Link server workloads to repositories",
|
||||||
|
summary: "Turn Docker, Compose and DockerMan evidence into one explicit repository deployment.",
|
||||||
|
keywords: "server inventory docker compose dockerman container detect linked review reconcile",
|
||||||
|
steps: [
|
||||||
|
"Open Deployments and scan the configured server.",
|
||||||
|
"Review proposed matches. ForgeFlow uses Compose paths, Git provenance, labels and container metadata; names alone are not trusted.",
|
||||||
|
"Confirm Review & link for a correct candidate, or choose the repository manually.",
|
||||||
|
"Use Review reconciliation whenever a saved link conflicts with current server evidence.",
|
||||||
|
],
|
||||||
|
note: "External and system containers remain monitoring-only until you explicitly link them. A linked workload also appears on the matching repository.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "deployments",
|
||||||
|
icon: "deploy",
|
||||||
|
category: "Deployments",
|
||||||
|
title: "Deploy an exact Gitea commit",
|
||||||
|
summary: "Validate, pull, build and promote a release without damaging the live service.",
|
||||||
|
keywords: "deploy release unraid preflight rollback health exact sha commit server pull",
|
||||||
|
steps: [
|
||||||
|
"Open the repository Deployments tab and select the intended environment.",
|
||||||
|
"Run preflight and repair blocking configuration before deployment.",
|
||||||
|
"Deploy only the shown target commit. Server pull fetches that exact commit instead of an ambiguous latest branch state.",
|
||||||
|
"Follow the run stages and health result. Failed promotion keeps or restores the previous release where supported.",
|
||||||
|
],
|
||||||
|
note: "Commit parity means Local, Gitea and Server identify the same revision; a running container alone does not prove a correct release.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "deploy-keys",
|
||||||
|
icon: "key",
|
||||||
|
category: "Deployments",
|
||||||
|
title: "Repair repository deploy keys",
|
||||||
|
summary: "Give the server read-only access to exactly the repository it must pull.",
|
||||||
|
keywords: "ssh key deploy key gitea permission server pull fingerprint authentication",
|
||||||
|
steps: [
|
||||||
|
"Run deployment preflight for the environment.",
|
||||||
|
"Use the offered deploy-key repair when repository access is missing.",
|
||||||
|
"ForgeFlow creates or reuses a repository-scoped key, registers the public key read-only in Gitea and verifies access from the server.",
|
||||||
|
"Re-run preflight and deploy only after the server can resolve and fetch the target commit.",
|
||||||
|
],
|
||||||
|
note: "The private key remains on the configured server. ForgeFlow does not copy your personal Gitea token into deployment commands.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "git-validator",
|
||||||
|
icon: "shield",
|
||||||
|
category: "Quality",
|
||||||
|
title: "Use Git Validator safely",
|
||||||
|
summary: "Find repository hygiene issues and apply only reviewed repairs.",
|
||||||
|
keywords: "validator hygiene gitignore readme license branch protection secrets large files fix repair",
|
||||||
|
steps: [
|
||||||
|
"Open a repository and choose Git Validator.",
|
||||||
|
"Select the policy that fits the repository and run a fresh scan.",
|
||||||
|
"Open each finding to understand its evidence and recommended repair.",
|
||||||
|
"Preview repairable findings before applying them, then rescan to verify the result.",
|
||||||
|
],
|
||||||
|
note: "A score is guidance, not proof of correctness. Repairs that can alter repository policy or files always require an explicit action.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "updates-diagnostics",
|
||||||
|
icon: "update",
|
||||||
|
category: "Maintenance",
|
||||||
|
title: "Update or troubleshoot ForgeFlow",
|
||||||
|
summary: "Install signed releases and collect useful diagnostics without exposing credentials.",
|
||||||
|
keywords: "update installer signed release diagnostics logs support error updater restart",
|
||||||
|
steps: [
|
||||||
|
"Open Settings and choose Check now under ForgeFlow updates.",
|
||||||
|
"Download the signed packaged release, then choose Apply & restart.",
|
||||||
|
"If an operation fails, open Diagnostics and run the troubleshooter.",
|
||||||
|
"Export a diagnostic bundle when deeper inspection is needed; tokens, passwords and private keys are redacted.",
|
||||||
|
],
|
||||||
|
note: "Binary auto-update is available only from a packaged installation. Source checkouts continue to use the normal development workflow.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function renderHelp() {
|
||||||
|
const query = String(ui.helpQuery || "").trim().toLowerCase();
|
||||||
|
const topics = HELP_TOPICS.filter((topic) =>
|
||||||
|
!query || `${topic.category} ${topic.title} ${topic.summary} ${topic.keywords} ${topic.steps.join(" ")} ${topic.note}`.toLowerCase().includes(query),
|
||||||
|
);
|
||||||
|
const categories = [...new Set(HELP_TOPICS.map((topic) => topic.category))];
|
||||||
|
const topicMarkup = topics.map((topic) => {
|
||||||
|
const isOpen = topic.id === ui.helpTopic || Boolean(query);
|
||||||
|
return `<details class="help-topic" data-help-topic="${attr(topic.id)}" ${isOpen ? "open" : ""}><summary><span class="help-topic-icon">${icon(topic.icon)}</span><span><small>${escapeHtml(topic.category)}</small><strong>${escapeHtml(topic.title)}</strong><span>${escapeHtml(topic.summary)}</span></span>${icon("chevron", "help-chevron")}</summary><div class="help-topic-body"><ol>${topic.steps.map((step) => `<li>${escapeHtml(step)}</li>`).join("")}</ol><div class="help-note">${icon("shield")}<span>${escapeHtml(topic.note)}</span></div></div></details>`;
|
||||||
|
}).join("");
|
||||||
|
return `<div class="page help-page"><header class="help-hero"><div><div class="eyebrow">ForgeFlow guide</div><h1>How can we help?</h1><p>Clear, practical instructions for repositories, Gitea synchronization, server deployments and maintenance.</p><label class="help-search">${icon("search")}<input id="help-search" value="${attr(ui.helpQuery)}" placeholder="Search sync, deploy keys, Git Validator…" aria-label="Search help"/><kbd>Ctrl F</kbd></label></div>${projectIllustration("flow")}</header><div class="help-layout"><aside class="help-navigation"><span class="eyebrow">Topics</span>${categories.map((category) => `<div class="help-category"><strong>${escapeHtml(category)}</strong>${HELP_TOPICS.filter((topic) => topic.category === category).map((topic) => `<button data-action="help-topic" data-topic="${attr(topic.id)}" class="${ui.helpTopic === topic.id ? "active" : ""}">${icon(topic.icon)}${escapeHtml(topic.title)}</button>`).join("")}</div>`).join("")}</aside><section class="help-results"><div class="help-results-header"><div><h2>${query ? `Search results` : "Everything you need to operate ForgeFlow"}</h2><span>${topics.length} topic${topics.length === 1 ? "" : "s"}${query ? ` matching “${escapeHtml(ui.helpQuery)}”` : ""}</span></div></div>${topicMarkup || `<div class="empty-state panel"><h3>No help topic found</h3><p>Try a broader term such as sync, deployment, keys, validator or update.</p><button class="button" data-action="clear-help-search">Clear search</button></div>`}</section></div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderSettings() {
|
function renderSettings() {
|
||||||
const state = ui.boot.state;
|
const state = ui.boot.state;
|
||||||
const prefs = state.preferences || {};
|
const prefs = state.preferences || {};
|
||||||
@@ -625,10 +763,10 @@ function renderSettings() {
|
|||||||
return `<div class="settings-layout"><aside class="settings-nav"><button class="nav-button active">${icon("settings")}<span>General</span></button><button class="nav-button" data-action="check-updates">${icon("update")}<span>Updates</span></button><button class="nav-button" data-action="open-add-server">${icon("server")}<span>Servers</span></button><button class="nav-button" data-action="reset-app">${icon("trash")}<span>Reset setup</span></button></aside><div class="settings-content"><div class="page-header"><div><div class="eyebrow">Application</div><h1>Settings</h1><p>Connections, project discovery, secure SSH servers and application updates.</p></div></div>
|
return `<div class="settings-layout"><aside class="settings-nav"><button class="nav-button active">${icon("settings")}<span>General</span></button><button class="nav-button" data-action="check-updates">${icon("update")}<span>Updates</span></button><button class="nav-button" data-action="open-add-server">${icon("server")}<span>Servers</span></button><button class="nav-button" data-action="reset-app">${icon("trash")}<span>Reset setup</span></button></aside><div class="settings-content"><div class="page-header"><div><div class="eyebrow">Application</div><h1>Settings</h1><p>Connections, project discovery, secure SSH servers and application updates.</p></div></div>
|
||||||
<section class="settings-group"><h2>Gitea connection</h2><div class="form-grid"><div class="field full"><label for="settings-gitea-url">Instance URL</label><input id="settings-gitea-url" class="input" value="${attr(state.gitea.baseUrl)}" placeholder="https://gitea.example.com" /></div><div class="field full"><label for="settings-gitea-token">New access token</label><input id="settings-gitea-token" class="input" type="password" placeholder="Leave empty to keep the existing token" /></div></div><div class="connection-card" style="margin-top:10px"><div><strong>${state.gitea.hasToken ? `Connected as ${escapeHtml(state.gitea.user?.login || "user")}` : "Not connected"}</strong><div class="queue-sub">${escapeHtml(state.gitea.baseUrl || "No Gitea instance configured")}</div></div><button class="button primary" data-action="save-gitea-settings">Validate & save</button></div></section>
|
<section class="settings-group"><h2>Gitea connection</h2><div class="form-grid"><div class="field full"><label for="settings-gitea-url">Instance URL</label><input id="settings-gitea-url" class="input" value="${attr(state.gitea.baseUrl)}" placeholder="https://gitea.example.com" /></div><div class="field full"><label for="settings-gitea-token">New access token</label><input id="settings-gitea-token" class="input" type="password" placeholder="Leave empty to keep the existing token" /></div></div><div class="connection-card" style="margin-top:10px"><div><strong>${state.gitea.hasToken ? `Connected as ${escapeHtml(state.gitea.user?.login || "user")}` : "Not connected"}</strong><div class="queue-sub">${escapeHtml(state.gitea.baseUrl || "No Gitea instance configured")}</div></div><button class="button primary" data-action="save-gitea-settings">Validate & save</button></div></section>
|
||||||
<section class="settings-group"><div class="section-heading"><div><h2>ForgeFlow updates</h2><span class="meta">Secure source update from ${escapeHtml(state.updates?.owner || "Jens")}/${escapeHtml(state.updates?.repo || "ForgeFlow")}</span></div><button class="button" data-action="check-updates" ${ui.updateChecking ? "disabled" : ""}>${icon("update")}${ui.updateChecking ? "Checking…" : "Check now"}</button></div><div class="form-grid"><div class="field"><label>Repository owner</label><input id="update-owner" class="input" value="${attr(state.updates?.owner || "Jens")}"/></div><div class="field"><label>Repository name</label><input id="update-repo" class="input" value="${attr(state.updates?.repo || "ForgeFlow")}"/></div><div class="field"><label>Release branch</label><input id="update-branch" class="input" value="${attr(state.updates?.branch || "main")}"/></div><div class="field"><label>Automatic startup check</label><select id="update-auto-check" class="select"><option value="true" ${state.updates?.autoCheck !== false ? "selected" : ""}>Enabled</option><option value="false" ${state.updates?.autoCheck === false ? "selected" : ""}>Disabled</option></select></div></div><div class="update-card ${update?.available ? "available" : ""}"><div>${icon(update?.available ? "download" : "check")}<span><strong>${update ? (update.available ? `ForgeFlow ${escapeHtml(update.remoteVersion)} is available` : `ForgeFlow ${escapeHtml(update.currentVersion)} is up to date`) : `Current version ${escapeHtml(ui.boot.appVersion)}`}</strong><small>${update ? `Branch ${escapeHtml(update.branch)} · commit ${escapeHtml(update.shortSha)} · checked ${formatDate(update.checkedAt)}` : "No update check in this session."}</small></span></div><div class="stack horizontal compact">${update?.available && !update.downloaded ? `<button class="button primary" data-action="download-update">${icon("download")}Download update</button>` : ""}${update?.downloaded ? `<button class="button success" data-action="apply-update">${icon("update")}Apply & restart</button>` : ""}<button class="button" data-action="save-update-settings">Save update settings</button></div></div><div class="notice" style="margin-top:10px">${icon("shield")}The updater downloads an authenticated ZIP for the exact remote commit, verifies its SHA-256 checksum, runs the complete quality gate and restores the previous source version if validation fails.</div></section>
|
<section class="settings-group"><div class="section-heading"><div><h2>ForgeFlow updates</h2><span class="meta">Secure source update from ${escapeHtml(state.updates?.owner || "Jens")}/${escapeHtml(state.updates?.repo || "ForgeFlow")}</span></div><button class="button" data-action="check-updates" ${ui.updateChecking ? "disabled" : ""}>${icon("update")}${ui.updateChecking ? "Checking…" : "Check now"}</button></div><div class="form-grid"><div class="field"><label>Repository owner</label><input id="update-owner" class="input" value="${attr(state.updates?.owner || "Jens")}"/></div><div class="field"><label>Repository name</label><input id="update-repo" class="input" value="${attr(state.updates?.repo || "ForgeFlow")}"/></div><div class="field"><label>Release branch</label><input id="update-branch" class="input" value="${attr(state.updates?.branch || "main")}"/></div><div class="field"><label>Automatic startup check</label><select id="update-auto-check" class="select"><option value="true" ${state.updates?.autoCheck !== false ? "selected" : ""}>Enabled</option><option value="false" ${state.updates?.autoCheck === false ? "selected" : ""}>Disabled</option></select></div></div><div class="update-card ${update?.available ? "available" : ""}"><div>${icon(update?.available ? "download" : "check")}<span><strong>${update ? (update.available ? `ForgeFlow ${escapeHtml(update.remoteVersion)} is available` : `ForgeFlow ${escapeHtml(update.currentVersion)} is up to date`) : `Current version ${escapeHtml(ui.boot.appVersion)}`}</strong><small>${update ? `Branch ${escapeHtml(update.branch)} · commit ${escapeHtml(update.shortSha)} · checked ${formatDate(update.checkedAt)}` : "No update check in this session."}</small></span></div><div class="stack horizontal compact">${update?.available && !update.downloaded ? `<button class="button primary" data-action="download-update">${icon("download")}Download update</button>` : ""}${update?.downloaded ? `<button class="button success" data-action="apply-update">${icon("update")}Apply & restart</button>` : ""}<button class="button" data-action="save-update-settings">Save update settings</button></div></div><div class="notice" style="margin-top:10px">${icon("shield")}The updater downloads an authenticated ZIP for the exact remote commit, verifies its SHA-256 checksum, runs the complete quality gate and restores the previous source version if validation fails.</div></section>
|
||||||
<section class="settings-group"><div class="section-heading"><div><h2>SSH / Unraid servers</h2><span class="meta">Credentials are entered locally and encrypted with the Windows credential protection used by Electron.</span></div><button class="button primary" data-action="open-add-server">${icon("plus")}Add server</button></div>${servers.length ? `<div class="server-list">${servers.map((server) => `<article class="server-card"><div class="server-card-main">${icon("server")}<div><strong>${escapeHtml(server.name)}</strong><span>${escapeHtml(server.username)}@${escapeHtml(server.host)}:${escapeHtml(server.port)} · ${escapeHtml(server.basePath)}</span><small>${server.hostFingerprint ? `Trusted ${escapeHtml(server.hostFingerprint)}` : "Host identity not trusted yet"}</small></div></div><div class="stack horizontal compact"><button class="button" data-action="test-server" data-server-id="${attr(server.id)}">Test & trust</button><button class="button" data-action="edit-server" data-server-id="${attr(server.id)}">Edit</button><button class="icon-button danger" data-action="delete-server" data-server-id="${attr(server.id)}" title="Delete server">${icon("trash")}</button></div></article>`).join("")}</div>` : '<div class="empty-state compact"><p>No SSH server configured. Add your Unraid server before creating an SSH deployment profile.</p></div>'}</section>
|
<section class="settings-group"><div class="section-heading"><div><h2>SSH / Unraid servers</h2><span class="meta">Credentials are encrypted locally; a new host fingerprint is shown before authentication.</span></div><button class="button primary" data-action="open-add-server">${icon("plus")}Add server</button></div>${servers.length ? `<div class="server-list">${servers.map((server) => `<article class="server-card"><div class="server-card-main">${icon("server")}<div><strong>${escapeHtml(server.name)}</strong><span>${escapeHtml(server.username)}@${escapeHtml(server.host)}:${escapeHtml(server.port)} · ${escapeHtml(server.basePath)}</span><small>${server.hostFingerprint ? `Trusted ${escapeHtml(server.hostFingerprint)}` : "Host identity not trusted yet"}</small></div></div><div class="stack horizontal compact"><button class="button" data-action="test-server" data-server-id="${attr(server.id)}">${server.hostFingerprint ? "Test connection" : "Preview & trust fingerprint"}</button><button class="button" data-action="edit-server" data-server-id="${attr(server.id)}">Edit</button><button class="icon-button danger" data-action="delete-server" data-server-id="${attr(server.id)}" title="Delete server">${icon("trash")}</button></div></article>`).join("")}</div>` : '<div class="empty-state compact"><p>No SSH server configured. Add your Unraid server before creating an SSH deployment profile.</p></div>'}</section>
|
||||||
<section class="settings-group"><div class="section-heading"><div><h2>Git remote maintenance</h2><span class="meta">Standardize linked repositories to the current Gitea SSH URLs.</span></div><button class="button" data-action="normalize-origins">${icon("link")}Normalize all origins</button></div><p>This replaces legacy aliases and renamed owners only after an explicit click. Local commits and files are not changed.</p></section>
|
<section class="settings-group"><div class="section-heading"><div><h2>Git remote maintenance</h2><span class="meta">Standardize linked repositories to the current Gitea SSH URLs.</span></div><button class="button" data-action="normalize-origins">${icon("link")}Normalize all origins</button></div><p>This replaces legacy aliases and renamed owners only after an explicit click. Local commits and files are not changed.</p></section>
|
||||||
<section class="settings-group"><h2>Project roots</h2><p>The first folder is the default clone destination. ForgeFlow automatically creates one subfolder per repository.</p><div class="stack">${state.workspaceRoots.map((root, index) => `<div class="root-row">${index === 0 ? '<span class="status-pill success">Default</span>' : ""}<input class="input" data-root-index="${index}" value="${attr(root)}" aria-label="Project root ${index + 1}"/><button class="icon-button" data-action="remove-root" data-index="${index}" title="Remove">${icon("trash")}</button></div>`).join("")}<button class="button" data-action="add-root">${icon("plus")}Add project root</button><button class="button primary" data-action="save-roots">Save folders & rescan</button></div></section>
|
<section class="settings-group"><h2>Project roots</h2><p>The first folder is the default clone destination. ForgeFlow automatically creates one subfolder per repository.</p><div class="stack">${state.workspaceRoots.map((root, index) => `<div class="root-row">${index === 0 ? '<span class="status-pill success">Default</span>' : ""}<input class="input" data-root-index="${index}" value="${attr(root)}" aria-label="Project root ${index + 1}"/><button class="icon-button" data-action="remove-root" data-index="${index}" title="Remove">${icon("trash")}</button></div>`).join("")}<button class="button" data-action="add-root">${icon("plus")}Add project root</button><button class="button primary" data-action="save-roots">Save folders & rescan</button></div></section>
|
||||||
<section class="settings-group"><h2>Background awareness</h2><div class="form-grid"><div class="field"><label>Automatic repository refresh</label><select id="pref-auto-refresh" class="select"><option value="true" ${prefs.autoRefresh !== false ? "selected" : ""}>Enabled</option><option value="false" ${prefs.autoRefresh === false ? "selected" : ""}>Disabled</option></select></div><div class="field"><label>Local poll interval</label><input id="pref-repo-poll" class="input" type="number" min="2" max="60" value="${attr(prefs.repositoryPollSeconds || 4)}"/></div><div class="field"><label>Actions poll interval</label><input id="pref-operation-poll" class="input" type="number" min="3" max="120" value="${attr(prefs.operationPollSeconds || 5)}"/></div><div class="field"><label>Preferred clone protocol</label><select id="pref-clone-protocol" class="select"><option value="https" ${prefs.preferredCloneProtocol !== "ssh" ? "selected" : ""}>HTTPS</option><option value="ssh" ${prefs.preferredCloneProtocol === "ssh" ? "selected" : ""}>SSH</option></select></div></div><button class="button primary" style="margin-top:12px" data-action="save-preferences">Save awareness settings</button></section>
|
<section class="settings-group"><h2>Background awareness</h2><div class="form-grid"><div class="field"><label>Automatic repository refresh</label><select id="pref-auto-refresh" class="select"><option value="true" ${prefs.autoRefresh !== false ? "selected" : ""}>Enabled</option><option value="false" ${prefs.autoRefresh === false ? "selected" : ""}>Disabled</option></select></div><div class="field"><label>Local poll interval</label><input id="pref-repo-poll" class="input" type="number" min="2" max="60" value="${attr(prefs.repositoryPollSeconds || 4)}"/></div><div class="field"><label>Gitea fetch interval (minutes)</label><input id="pref-fetch-interval" class="input" type="number" min="0" max="240" value="${attr(Number.isFinite(Number(prefs.fetchIntervalMinutes)) ? prefs.fetchIntervalMinutes : 10)}"/><small>Read-only remote awareness. Use 0 to disable; fetching never changes project files.</small></div><div class="field"><label>Actions poll interval</label><input id="pref-operation-poll" class="input" type="number" min="3" max="120" value="${attr(prefs.operationPollSeconds || 5)}"/></div><div class="field"><label>Preferred clone protocol</label><select id="pref-clone-protocol" class="select"><option value="https" ${prefs.preferredCloneProtocol !== "ssh" ? "selected" : ""}>HTTPS</option><option value="ssh" ${prefs.preferredCloneProtocol === "ssh" ? "selected" : ""}>SSH</option></select></div></div><div class="notice" style="margin-top:12px">${icon("shield")}Remote awareness only fetches branch metadata. ForgeFlow never resets, cleans or overwrites a workspace in the background.</div><button class="button primary" style="margin-top:12px" data-action="save-preferences">Save awareness settings</button></section>
|
||||||
<section class="settings-group"><h2>Desktop integration</h2><div class="form-grid"><div class="field"><label>Editor executable</label><input id="pref-editor-executable" class="input" value="${attr(prefs.editor?.executable || "code")}"/></div><div class="field"><label>Editor arguments</label><input id="pref-editor-args" class="input" value="${attr((prefs.editor?.args || ["--reuse-window", "--goto", "{file}:{line}"]).join(" | "))}"/><small>Separate arguments with |. Placeholders: {path}, {file}, {line}</small></div><div class="field"><label>Terminal executable</label><input id="pref-terminal-executable" class="input" value="${attr(prefs.terminal?.executable || "wt.exe")}"/></div><div class="field"><label>Terminal arguments</label><input id="pref-terminal-args" class="input" value="${attr((prefs.terminal?.args || ["-d", "{path}"]).join(" | "))}"/></div><label class="check-field"><input id="pref-notifications" type="checkbox" ${prefs.notificationsEnabled !== false ? "checked" : ""}/><span>Native deployment notifications</span></label><label class="check-field"><input id="pref-tray" type="checkbox" ${prefs.trayEnabled !== false ? "checked" : ""}/><span>Show system tray icon</span></label><label class="check-field"><input id="pref-close-tray" type="checkbox" ${prefs.closeToTray === true ? "checked" : ""}/><span>Hide to tray when closing</span></label><label class="check-field"><input id="pref-login" type="checkbox" ${prefs.startAtLogin === true ? "checked" : ""}/><span>Start ForgeFlow at login</span></label></div><button class="button primary" data-action="save-desktop-preferences">Save desktop integration</button></section>
|
<section class="settings-group"><h2>Desktop integration</h2><div class="form-grid"><div class="field"><label>Editor executable</label><input id="pref-editor-executable" class="input" value="${attr(prefs.editor?.executable || "code")}"/></div><div class="field"><label>Editor arguments</label><input id="pref-editor-args" class="input" value="${attr((prefs.editor?.args || ["--reuse-window", "--goto", "{file}:{line}"]).join(" | "))}"/><small>Separate arguments with |. Placeholders: {path}, {file}, {line}</small></div><div class="field"><label>Terminal executable</label><input id="pref-terminal-executable" class="input" value="${attr(prefs.terminal?.executable || "wt.exe")}"/></div><div class="field"><label>Terminal arguments</label><input id="pref-terminal-args" class="input" value="${attr((prefs.terminal?.args || ["-d", "{path}"]).join(" | "))}"/></div><label class="check-field"><input id="pref-notifications" type="checkbox" ${prefs.notificationsEnabled !== false ? "checked" : ""}/><span>Native deployment notifications</span></label><label class="check-field"><input id="pref-tray" type="checkbox" ${prefs.trayEnabled !== false ? "checked" : ""}/><span>Show system tray icon</span></label><label class="check-field"><input id="pref-close-tray" type="checkbox" ${prefs.closeToTray === true ? "checked" : ""}/><span>Hide to tray when closing</span></label><label class="check-field"><input id="pref-login" type="checkbox" ${prefs.startAtLogin === true ? "checked" : ""}/><span>Start ForgeFlow at login</span></label></div><button class="button primary" data-action="save-desktop-preferences">Save desktop integration</button></section>
|
||||||
<section class="settings-group"><h2>Encrypted configuration backup</h2><p>Repository mappings, servers, deployment profiles and preferences are encrypted. Tokens, passwords, passphrases and operation history are never exported.</p><div class="inline-form"><input id="backup-passphrase" class="input" type="password" minlength="12" placeholder="Passphrase of at least 12 characters"/><button class="button" data-action="export-config-backup">Export</button><button class="button" data-action="import-config-backup">Import</button></div></section>
|
<section class="settings-group"><h2>Encrypted configuration backup</h2><p>Repository mappings, servers, deployment profiles and preferences are encrypted. Tokens, passwords, passphrases and operation history are never exported.</p><div class="inline-form"><input id="backup-passphrase" class="input" type="password" minlength="12" placeholder="Passphrase of at least 12 characters"/><button class="button" data-action="export-config-backup">Export</button><button class="button" data-action="import-config-backup">Import</button></div></section>
|
||||||
<section class="settings-group"><h2>Appearance</h2><div class="field"><label for="appearance-select">Color theme</label><select id="appearance-select" class="select"><option value="dark" ${state.appearance === "dark" ? "selected" : ""}>Dark</option><option value="light" ${state.appearance === "light" ? "selected" : ""}>Light</option><option value="system" ${state.appearance === "system" ? "selected" : ""}>Follow system</option></select></div></section>
|
<section class="settings-group"><h2>Appearance</h2><div class="field"><label for="appearance-select">Color theme</label><select id="appearance-select" class="select"><option value="dark" ${state.appearance === "dark" ? "selected" : ""}>Dark</option><option value="light" ${state.appearance === "light" ? "selected" : ""}>Light</option><option value="system" ${state.appearance === "system" ? "selected" : ""}>Follow system</option></select></div></section>
|
||||||
@@ -676,6 +814,7 @@ function renderDiagnostics() {
|
|||||||
<section class="section-block"><div class="section-heading"><div><h2>One-click troubleshooter</h2><span class="meta">Git locks, interrupted operations, branch synchronization and deployment/server inconsistencies</span></div><div class="stack horizontal compact"><button class="button" data-action="run-troubleshooter">${icon("pulse")}Scan everything</button>${trouble?.issues?.some((item) => item.repairable && item.safe) ? `<button class="button primary" data-action="troubleshooter-auto-repair">${icon("wrench")}Repair ${trouble.issues.filter((item) => item.repairable && item.safe).length} safe issue(s)</button>` : ""}</div></div><div class="panel"><div class="preflight-summary">${trouble ? `<span class="status-pill ${trouble.summary.errors ? "danger" : trouble.summary.warnings ? "warning" : "success"}">${trouble.summary.total ? `${trouble.summary.total} issue(s)` : "Healthy"}</span><span>${trouble.summary.errors} errors · ${trouble.summary.warnings} warnings · ${trouble.summary.repairable} repairable</span>` : "<span>Run the troubleshooter to inspect all linked repositories and deployments.</span>"}</div>${troubleRows || '<div class="empty-state compact"><p>No problems detected.</p></div>'}</div></section>
|
<section class="section-block"><div class="section-heading"><div><h2>One-click troubleshooter</h2><span class="meta">Git locks, interrupted operations, branch synchronization and deployment/server inconsistencies</span></div><div class="stack horizontal compact"><button class="button" data-action="run-troubleshooter">${icon("pulse")}Scan everything</button>${trouble?.issues?.some((item) => item.repairable && item.safe) ? `<button class="button primary" data-action="troubleshooter-auto-repair">${icon("wrench")}Repair ${trouble.issues.filter((item) => item.repairable && item.safe).length} safe issue(s)</button>` : ""}</div></div><div class="panel"><div class="preflight-summary">${trouble ? `<span class="status-pill ${trouble.summary.errors ? "danger" : trouble.summary.warnings ? "warning" : "success"}">${trouble.summary.total ? `${trouble.summary.total} issue(s)` : "Healthy"}</span><span>${trouble.summary.errors} errors · ${trouble.summary.warnings} warnings · ${trouble.summary.repairable} repairable</span>` : "<span>Run the troubleshooter to inspect all linked repositories and deployments.</span>"}</div>${troubleRows || '<div class="empty-state compact"><p>No problems detected.</p></div>'}</div></section>
|
||||||
<section class="section-block"><div class="section-heading"><div><h2>System preflight</h2><span class="meta">Git, writable storage, credential protection, folders and Gitea</span></div><button class="button" data-action="run-system-preflight">${icon("shield")}Run checks</button></div><div class="panel"><div class="preflight-summary">${report ? `<span class="status-pill ${report.summary.ready ? "success" : "danger"}">${report.summary.ready ? "Ready" : `${report.summary.blocking.length} blocking`}</span><span>${report.summary.counts.pass} passed · ${report.summary.counts.warning} warnings · ${report.summary.counts.fail} failed</span>` : "<span>Not run in this session</span>"}</div>${renderPreflightChecks(report)}</div></section>
|
<section class="section-block"><div class="section-heading"><div><h2>System preflight</h2><span class="meta">Git, writable storage, credential protection, folders and Gitea</span></div><button class="button" data-action="run-system-preflight">${icon("shield")}Run checks</button></div><div class="panel"><div class="preflight-summary">${report ? `<span class="status-pill ${report.summary.ready ? "success" : "danger"}">${report.summary.ready ? "Ready" : `${report.summary.blocking.length} blocking`}</span><span>${report.summary.counts.pass} passed · ${report.summary.counts.warning} warnings · ${report.summary.counts.fail} failed</span>` : "<span>Not run in this session</span>"}</div>${renderPreflightChecks(report)}</div></section>
|
||||||
<section class="section-block"><div class="section-heading"><div><h2>Export support bundle</h2><span class="meta">Configuration summary, repository states, operations, preflight and redacted JSONL logs</span></div></div><div class="panel panel-body"><div class="form-grid"><div class="field"><label>Privacy mode</label><select id="diagnostic-privacy" class="select"><option value="standard">Standard · preserve repository names</option><option value="strict">Strict · hash repository and user identifiers</option></select></div></div><div class="card-actions"><button class="button primary" data-action="export-diagnostics">${icon("archive")}Create diagnostic ZIP</button></div>${ui.lastDiagnosticBundle ? `<div class="notice success" style="margin-top:12px">${icon("check")}<div><strong>${escapeHtml(ui.lastDiagnosticBundle.size)} bundle created</strong><p class="mono">SHA-256 ${escapeHtml(ui.lastDiagnosticBundle.sha256)}</p><button class="button ghost" data-action="show-diagnostic-bundle">Show file</button></div></div>` : ""}</div></section>
|
<section class="section-block"><div class="section-heading"><div><h2>Export support bundle</h2><span class="meta">Configuration summary, repository states, operations, preflight and redacted JSONL logs</span></div></div><div class="panel panel-body"><div class="form-grid"><div class="field"><label>Privacy mode</label><select id="diagnostic-privacy" class="select"><option value="standard">Standard · preserve repository names</option><option value="strict">Strict · hash repository and user identifiers</option></select></div></div><div class="card-actions"><button class="button primary" data-action="export-diagnostics">${icon("archive")}Create diagnostic ZIP</button></div>${ui.lastDiagnosticBundle ? `<div class="notice success" style="margin-top:12px">${icon("check")}<div><strong>${escapeHtml(ui.lastDiagnosticBundle.size)} bundle created</strong><p class="mono">SHA-256 ${escapeHtml(ui.lastDiagnosticBundle.sha256)}</p><button class="button ghost" data-action="show-diagnostic-bundle">Show file</button></div></div>` : ""}</div></section>
|
||||||
|
<section class="section-block"><div class="section-heading"><div><h2>Operational audit log</h2><span class="meta">Append-only release, pull-request and recovery events</span></div><div class="stack horizontal compact"><button class="button" data-action="load-audit-log">Refresh</button><button class="button" data-action="export-audit-json">Export JSON</button><button class="button" data-action="export-audit-csv">Export CSV</button></div></div><div class="panel">${ui.auditEvents.length ? `<table class="data-table"><thead><tr><th>Time</th><th>Event</th><th>Repository</th><th>Result</th></tr></thead><tbody>${ui.auditEvents.map((item) => `<tr><td>${formatDate(item.timestamp)}</td><td>${escapeHtml(item.event)}</td><td>${escapeHtml(item.details?.repository || "—")}</td><td>${escapeHtml(item.details?.result || item.details?.note || "—")}</td></tr>`).join("")}</tbody></table>` : '<div class="empty-state compact"><p>Load the operational audit log.</p></div>'}</div></section>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ const path = require('node:path');
|
|||||||
function cloneDirectoryName(remoteUrl) {
|
function cloneDirectoryName(remoteUrl) {
|
||||||
const raw = String(remoteUrl || '').trim().replace(/[?#].*$/, '').replace(/[\\/]+$/, '');
|
const raw = String(remoteUrl || '').trim().replace(/[?#].*$/, '').replace(/[\\/]+$/, '');
|
||||||
const segment = raw.split(/[\\/:]/).filter(Boolean).at(-1) || 'repository';
|
const segment = raw.split(/[\\/:]/).filter(Boolean).at(-1) || 'repository';
|
||||||
return segment.replace(/\.git$/i, '').replace(/[^a-zA-Z0-9._-]/g, '-') || 'repository';
|
const name = segment.replace(/\.git$/i, '').replace(/[^a-zA-Z0-9._-]/g, '-');
|
||||||
|
// A name made only of dots is not a usable directory. Windows strips trailing
|
||||||
|
// dots, so "..." would resolve back to the project root itself and slip past
|
||||||
|
// the escape check in resolveCloneTarget below.
|
||||||
|
return !name || /^\.+$/.test(name) ? 'repository' : name;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveCloneTarget(workspaceRoot, remoteUrl) {
|
function resolveCloneTarget(workspaceRoot, remoteUrl) {
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ function normalizeBaseUrl(value) {
|
|||||||
const url = new URL(raw);
|
const url = new URL(raw);
|
||||||
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported.');
|
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported.');
|
||||||
if (url.username || url.password) throw new Error('Do not include credentials in the Gitea URL.');
|
if (url.username || url.password) throw new Error('Do not include credentials in the Gitea URL.');
|
||||||
|
const loopback = new Set(['localhost', '127.0.0.1', '[::1]']);
|
||||||
|
if (url.protocol !== 'https:' && !loopback.has(url.hostname.toLowerCase())) {
|
||||||
|
throw new Error('Gitea must use HTTPS so access tokens are never sent over plaintext HTTP. Loopback HTTP is allowed for local development only.');
|
||||||
|
}
|
||||||
url.hash = '';
|
url.hash = '';
|
||||||
url.search = '';
|
url.search = '';
|
||||||
return url.toString().replace(/\/$/, '');
|
return url.toString().replace(/\/$/, '');
|
||||||
|
|||||||
@@ -74,13 +74,16 @@ async function assertScrollableWhenOverflowing(page, selector) {
|
|||||||
return metrics.connected && metrics.clientHeight > 0;
|
return metrics.connected && metrics.clientHeight > 0;
|
||||||
}).toBe(true);
|
}).toBe(true);
|
||||||
if (metrics.scrollHeight > metrics.clientHeight + 1) {
|
if (metrics.scrollHeight > metrics.clientHeight + 1) {
|
||||||
await target.evaluate((element) => { element.scrollTop = element.scrollHeight; });
|
await expect.poll(() => target.evaluate((element) => {
|
||||||
await expect.poll(() => target.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
|
if (element.scrollHeight <= element.clientHeight + 1) return 1;
|
||||||
|
element.scrollTop = element.scrollHeight;
|
||||||
|
return element.scrollTop;
|
||||||
|
})).toBeGreaterThan(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
test("shell, overview, repositories and settings remain responsive and accessible", async ({ page }, testInfo) => {
|
test("shell, overview, repositories and settings remain responsive and accessible", async ({ page }, testInfo) => {
|
||||||
await assertSurface(page);
|
await assertSurface(page);
|
||||||
for (const view of ["overview", "deployments", "settings"]) {
|
for (const view of ["overview", "deployments", "settings", "help"]) {
|
||||||
await page.locator(`.nav-button[data-action="navigate"][data-view="${view}"]`).click();
|
await page.locator(`.nav-button[data-action="navigate"][data-view="${view}"]`).click();
|
||||||
await expect(page.locator("main")).toBeVisible();
|
await expect(page.locator("main")).toBeVisible();
|
||||||
await assertSurface(page);
|
await assertSurface(page);
|
||||||
@@ -113,10 +116,46 @@ test("repository changes, Git tools and Git Validator complete their primary flo
|
|||||||
await expect(page.locator(":focus")).toBeVisible();
|
await expect(page.locator(":focus")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("repository context, tabs and content never overlap in a compact workspace", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1024, height: 768 });
|
||||||
|
await page.locator('[data-action="select-repo"][data-deployment-count]:not([data-deployment-count="0"])').first().click();
|
||||||
|
await page.locator('[data-action="repo-tab"][data-tab="gittools"]').click();
|
||||||
|
const layout = await page.evaluate(() => {
|
||||||
|
const context = document.querySelector(".repo-context")?.getBoundingClientRect();
|
||||||
|
const tabs = document.querySelector(".tabs")?.getBoundingClientRect();
|
||||||
|
const content = document.querySelector(".repo-content")?.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
contextEndsBeforeTabs: Boolean(context && tabs && context.bottom <= tabs.top + 0.5),
|
||||||
|
tabsEndBeforeContent: Boolean(tabs && content && tabs.bottom <= content.top + 0.5),
|
||||||
|
horizontalTabFallback: Boolean(tabs && document.querySelector(".tabs").scrollWidth >= document.querySelector(".tabs").clientWidth),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(layout).toEqual({ contextEndsBeforeTabs: true, tabsEndBeforeContent: true, horizontalTabFallback: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Help center is searchable and contextual guidance opens the requested topic", async ({ page }) => {
|
||||||
|
await page.locator('.nav-button[data-view="help"]').click();
|
||||||
|
await expect(page.getByRole("heading", { name: "How can we help?" })).toBeVisible();
|
||||||
|
await expect(page.locator(".help-topic")).toHaveCount(8);
|
||||||
|
await page.locator("#help-search").fill("deploy key");
|
||||||
|
await expect(page.locator(".help-topic")).toHaveCount(1);
|
||||||
|
await expect(page.locator(".help-topic")).toContainText("Repair repository deploy keys");
|
||||||
|
await page.locator('[data-action="select-repo"]').first().click();
|
||||||
|
await page.locator('[data-action="repo-tab"][data-tab="gittools"]').click();
|
||||||
|
await page.locator('[data-action="open-context-help"][data-topic="workspace-sync"]').click();
|
||||||
|
await expect(page.locator('[data-help-topic="workspace-sync"]')).toHaveAttribute("open", "");
|
||||||
|
await expect(page.locator('[data-help-topic="workspace-sync"]')).toContainText("Make a local project match Gitea");
|
||||||
|
await assertSurface(page);
|
||||||
|
});
|
||||||
|
|
||||||
test("every long application surface retains a working vertical scroll owner", async ({ page }) => {
|
test("every long application surface retains a working vertical scroll owner", async ({ page }) => {
|
||||||
for (const view of ["overview", "deployments", "diagnostics", "settings"]) {
|
for (const view of ["overview", "deployments", "diagnostics", "settings", "help"]) {
|
||||||
await page.locator(`.nav-button[data-view="${view}"]`).click();
|
await test.step(`${view} view scrolls`, async () => {
|
||||||
await assertScrollableWhenOverflowing(page, ".main-canvas");
|
const navigation = page.locator(`.nav-button[data-view="${view}"]`);
|
||||||
|
await navigation.click();
|
||||||
|
await expect(navigation).toHaveClass(/active/);
|
||||||
|
await assertScrollableWhenOverflowing(page, ".main-canvas");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
await page.locator('[data-action="select-repo"]').first().click();
|
await page.locator('[data-action="select-repo"]').first().click();
|
||||||
for (const tab of ["history", "deployments", "gittools", "validator", "settings"]) {
|
for (const tab of ["history", "deployments", "gittools", "validator", "settings"]) {
|
||||||
@@ -142,6 +181,8 @@ test("deployment inventory supports dense workloads without ambiguous blank card
|
|||||||
const repositoryLink = page.locator('[data-action="open-deployment-link"]');
|
const repositoryLink = page.locator('[data-action="open-deployment-link"]');
|
||||||
if (await repositoryLink.count()) {
|
if (await repositoryLink.count()) {
|
||||||
await repositoryLink.first().click();
|
await repositoryLink.first().click();
|
||||||
|
await expect(page.locator('.repo-row.active')).toHaveAttribute("data-deployment-count", /^[1-9]/);
|
||||||
|
await expect(page.locator('.repo-row.active .deployment-badge')).toBeVisible();
|
||||||
await expect(page.locator('.tab[data-action="repo-tab"][data-tab="deployments"]')).toHaveClass(/active/);
|
await expect(page.locator('.tab[data-action="repo-tab"][data-tab="deployments"]')).toHaveClass(/active/);
|
||||||
await expect(page.locator(".repository-workloads")).toBeVisible();
|
await expect(page.locator(".repository-workloads")).toBeVisible();
|
||||||
await expect(page.locator(".repository-workload-row").first()).toContainText("Repository linked");
|
await expect(page.locator(".repository-workload-row").first()).toContainText("Repository linked");
|
||||||
@@ -206,3 +247,116 @@ test("inventory, deployment safety and failure evidence dialogs are reviewable",
|
|||||||
}
|
}
|
||||||
await assertSurface(page);
|
await assertSurface(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A repository or deployment poll renders the whole shell again. Changing an
|
||||||
|
// unrelated part of the state is what a poll effectively does, and it must not
|
||||||
|
// take the caret or the scroll position away from the user.
|
||||||
|
async function forceUnrelatedRerender(page) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
ui.diagnosticsStatus = { ...(ui.diagnosticsStatus || {}), enabled: !(ui.diagnosticsStatus?.enabled === false) };
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a background refresh keeps typing and caret position intact", async ({ page }) => {
|
||||||
|
const search = page.locator("#global-search");
|
||||||
|
await search.click();
|
||||||
|
await search.fill("Forge");
|
||||||
|
// Typing schedules a debounced render. Wait for it, otherwise the caret below
|
||||||
|
// can land on the element that render is about to replace.
|
||||||
|
await expect.poll(() => page.evaluate(() => ui.inputRenderTimer === null)).toBe(true);
|
||||||
|
await search.evaluate((element) => element.setSelectionRange(1, 3));
|
||||||
|
|
||||||
|
await forceUnrelatedRerender(page);
|
||||||
|
|
||||||
|
await expect(search).toBeFocused();
|
||||||
|
expect(await search.inputValue()).toBe("Forge");
|
||||||
|
expect(await search.evaluate((element) => [element.selectionStart, element.selectionEnd])).toEqual([1, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a background refresh keeps scroll offsets intact", async ({ page }) => {
|
||||||
|
await page.locator('.nav-button[data-action="navigate"][data-view="settings"]').click();
|
||||||
|
const canvas = page.locator(".main-canvas");
|
||||||
|
const scrolled = await canvas.evaluate((element) => {
|
||||||
|
element.scrollTop = Math.min(120, Math.max(0, element.scrollHeight - element.clientHeight));
|
||||||
|
return element.scrollTop;
|
||||||
|
});
|
||||||
|
expect(scrolled).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await forceUnrelatedRerender(page);
|
||||||
|
|
||||||
|
expect(await canvas.evaluate((element) => element.scrollTop)).toBe(scrolled);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sections that used to be injected after render are part of the rendered markup", async ({ page }) => {
|
||||||
|
await page.locator('.nav-button[data-action="navigate"][data-view="diagnostics"]').click();
|
||||||
|
const auditPanel = page.locator(".diagnostics-page .section-block", { hasText: "Operational audit log" });
|
||||||
|
await expect(auditPanel).toBeVisible();
|
||||||
|
await expect(auditPanel).toContainText("Load the operational audit log");
|
||||||
|
|
||||||
|
// The audit rows are state the shell renders itself now, so a plain render has
|
||||||
|
// to pick them up without any post-render injection step.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
ui.auditEvents = [{ timestamp: new Date().toISOString(), event: "deployment.requested", details: { repository: "Jens/Probe", result: "queued" } }];
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
await expect(auditPanel.locator("table.data-table")).toContainText("Jens/Probe");
|
||||||
|
await expect(auditPanel.locator("table.data-table")).toContainText("deployment.requested");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a very large diff is capped instead of freezing the window", async ({ page }) => {
|
||||||
|
const selected = await page.evaluate(() => {
|
||||||
|
const withChanges = ui.repositories.find((repository) => repository.localStatus?.counts?.changed);
|
||||||
|
if (!withChanges) return null;
|
||||||
|
selectRepository(withChanges.id);
|
||||||
|
return withChanges.fullName;
|
||||||
|
});
|
||||||
|
expect(selected, "the demo needs a repository with local changes").not.toBeNull();
|
||||||
|
await expect(page.locator(".diff-view")).toBeVisible();
|
||||||
|
// Selecting a repository loads its diff asynchronously; that load would
|
||||||
|
// otherwise overwrite the diff injected below.
|
||||||
|
await expect.poll(() => page.evaluate(() => Boolean(ui.diff) && !ui.diff.startsWith("Loading"))).toBe(true);
|
||||||
|
|
||||||
|
const measured = await page.evaluate(() => {
|
||||||
|
const newline = String.fromCharCode(10);
|
||||||
|
const lines = ["diff --git a/package-lock.json b/package-lock.json"];
|
||||||
|
for (let index = 0; index < 40_000; index += 1) lines.push(`+ "package-${index}": "^1.2.3",`);
|
||||||
|
ui.diff = lines.join(newline);
|
||||||
|
ui.repositoryTab = "changes";
|
||||||
|
const started = performance.now();
|
||||||
|
render();
|
||||||
|
return {
|
||||||
|
renderMs: performance.now() - started,
|
||||||
|
rendered: document.querySelectorAll(".diff-line").length,
|
||||||
|
storedLines: ui.diff.split(newline).length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(measured.storedLines).toBe(40_001);
|
||||||
|
expect(measured.rendered).toBeLessThan(2100);
|
||||||
|
expect(measured.renderMs).toBeLessThan(3000);
|
||||||
|
await expect(page.locator(".diff-view")).toContainText("more lines are not shown");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unchanged render leaves the existing DOM in place", async ({ page }) => {
|
||||||
|
await page.locator('[data-action="select-repo"]').first().click();
|
||||||
|
const marked = await page.evaluate(() => {
|
||||||
|
// Relative timestamps ("just now" turning into "1m ago") and pending async
|
||||||
|
// state legitimately change the markup between two renders that are seconds
|
||||||
|
// apart. Rendering twice inside one synchronous block removes that window,
|
||||||
|
// so the second render can only be skipped because nothing changed.
|
||||||
|
render();
|
||||||
|
document.querySelector(".repo-list").dataset.renderProbe = "kept";
|
||||||
|
render();
|
||||||
|
return document.querySelector(".repo-list")?.dataset.renderProbe || null;
|
||||||
|
});
|
||||||
|
expect(marked).toBe("kept");
|
||||||
|
|
||||||
|
const replaced = await page.evaluate(() => {
|
||||||
|
document.querySelector(".repo-list").dataset.renderProbe = "kept";
|
||||||
|
ui.repoSearch = `probe-${Date.now()}`;
|
||||||
|
render();
|
||||||
|
return document.querySelector(".repo-list")?.dataset.renderProbe || null;
|
||||||
|
});
|
||||||
|
expect(replaced).toBe(null);
|
||||||
|
});
|
||||||
|
|||||||
@@ -26,6 +26,35 @@ test('resolves the automatic clone target inside the configured project root', (
|
|||||||
assert.equal(plan.directoryName, 'portfolio');
|
assert.equal(plan.directoryName, 'portfolio');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a clone target that would leave the project root is refused', () => {
|
||||||
|
const root = path.join(os.tmpdir(), 'forgeflow-projects');
|
||||||
|
const resolved = path.resolve(root);
|
||||||
|
|
||||||
|
// The escape guard inside resolveCloneTarget stays as a backstop, but no
|
||||||
|
// sanitised folder name can reach it any more: the name is a single path
|
||||||
|
// segment and a dots-only segment falls back to "repository".
|
||||||
|
for (const remote of ['..', '.', '../escape', '/', '', '....git', 'https://gitea.example.test/jens/....git']) {
|
||||||
|
const plan = resolveCloneTarget(root, remote);
|
||||||
|
assert.ok(
|
||||||
|
plan.target.startsWith(`${resolved}${path.sep}`) && plan.target !== resolved,
|
||||||
|
`${remote} resolved outside the project root: ${plan.target}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const badRoot of ['', ' ', null, undefined]) {
|
||||||
|
assert.throws(() => resolveCloneTarget(badRoot, 'https://gitea.example.test/jens/app.git'), /project root is required/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a folder name that sanitises away still produces a usable directory', () => {
|
||||||
|
// Windows strips trailing dots, so a dots-only name would land on the project
|
||||||
|
// root itself instead of a subdirectory.
|
||||||
|
assert.equal(cloneDirectoryName('https://gitea.example.test/jens/....git'), 'repository');
|
||||||
|
assert.equal(cloneDirectoryName('..'), 'repository');
|
||||||
|
assert.equal(cloneDirectoryName(''), 'repository');
|
||||||
|
assert.equal(cloneDirectoryName('https://gitea.example.test/jens/app.git#readme'), 'app');
|
||||||
|
assert.equal(cloneDirectoryName('https://gitea.example.test/jens/spaced name.git'), 'spaced-name');
|
||||||
|
});
|
||||||
|
|
||||||
test('clone target inspection accepts missing and empty destinations', async (t) => {
|
test('clone target inspection accepts missing and empty destinations', async (t) => {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-clone-target-'));
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-clone-target-'));
|
||||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||||
|
|||||||
@@ -110,6 +110,8 @@ test("configuration mutations persist mappings, favorites, reviews, trends, oper
|
|||||||
assert.equal(state.preferences.preferredCloneProtocol, "https");
|
assert.equal(state.preferences.preferredCloneProtocol, "https");
|
||||||
assert.equal(state.preferences.diagnosticLevel, "info");
|
assert.equal(state.preferences.diagnosticLevel, "info");
|
||||||
assert.equal(state.preferences.maxLogFileMb, 50);
|
assert.equal(state.preferences.maxLogFileMb, 50);
|
||||||
|
const manualRemoteAwareness = await store.setPreferences({ fetchIntervalMinutes: 0 });
|
||||||
|
assert.equal(manualRemoteAwareness.preferences.fetchIntervalMinutes, 0);
|
||||||
await store.removeMapping("owner/app");
|
await store.removeMapping("owner/app");
|
||||||
assert.equal(store.data.repositoryMappings["owner/app"], undefined);
|
assert.equal(store.data.repositoryMappings["owner/app"], undefined);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { registerDeploymentIpc } = require("../src/main/ipc/deployment-handlers.cjs");
|
||||||
|
const { UnraidDeploymentService } = require("../src/main/unraid-deployment-service.cjs");
|
||||||
|
|
||||||
|
const REPOSITORY = { fullName: "Jens/ForgeFlow", owner: { login: "Jens" }, localPath: "C:/Projects/ForgeFlow" };
|
||||||
|
const WORKLOAD = { workloadId: "workload-1", classification: { type: "ambiguous" } };
|
||||||
|
|
||||||
|
// Every collaborator answers, so a channel can only fail on a dependency the
|
||||||
|
// module references but never receives.
|
||||||
|
function harness(overrides = {}) {
|
||||||
|
const calls = [];
|
||||||
|
const record = (name, result) => async (...args) => { calls.push({ name, args }); return typeof result === "function" ? result(...args) : result; };
|
||||||
|
const handlers = new Map();
|
||||||
|
const profile = overrides.profile || { id: "profile-1", provider: "ssh-unraid", branch: "main", name: "Production" };
|
||||||
|
|
||||||
|
const dependencies = {
|
||||||
|
register: (channel, handler) => handlers.set(channel, handler),
|
||||||
|
store: {
|
||||||
|
data: { servers: [{ id: "server-1", name: "Unraid" }], operations: [] },
|
||||||
|
getDeploymentProfile: () => profile,
|
||||||
|
getPublicState: () => ({ ok: true }),
|
||||||
|
saveDeploymentProfile: record("store.saveDeploymentProfile", profile),
|
||||||
|
deleteDeploymentProfile: record("store.deleteDeploymentProfile", []),
|
||||||
|
addOperation: record("store.addOperation", null),
|
||||||
|
},
|
||||||
|
resolveRepository: record("resolveRepository", REPOSITORY),
|
||||||
|
unraid: {
|
||||||
|
preflight: record("unraid.preflight", { ok: true }),
|
||||||
|
repairWriteAccess: record("unraid.repairWriteAccess", { changed: true, after: {}, before: {} }),
|
||||||
|
deploy: record("unraid.deploy", { id: "operation-1" }),
|
||||||
|
rollback: record("unraid.rollback", { id: "operation-2" }),
|
||||||
|
linkServerWorkload: record("unraid.linkServerWorkload", { linked: true }),
|
||||||
|
configureServerGitAccess: record("unraid.configureServerGitAccess", { keyFingerprint: "a", hostFingerprint: "b" }),
|
||||||
|
verifyServerGitProfile: record("unraid.verifyServerGitProfile", { readiness: "ready", ready: true, checkedAt: "now" }),
|
||||||
|
discoverServerWorkloads: record("unraid.discoverServerWorkloads", { serverId: "server-1", workloads: [] }),
|
||||||
|
planServerInventoryReconciliation: record("unraid.planServerInventoryReconciliation", { plan: { id: "plan-1", summary: {} } }),
|
||||||
|
reconcileServerInventory: record("unraid.reconcileServerInventory", { adopted: 0, refreshed: 0, retired: 0 }),
|
||||||
|
scanServerInventory: record("unraid.scanServerInventory", { workloads: [WORKLOAD] }),
|
||||||
|
refreshProfileState: record("unraid.refreshProfileState", { liveSha: null }),
|
||||||
|
applyDockerManMetadata: record("unraid.applyDockerManMetadata", { applied: true }),
|
||||||
|
refreshOperation: record("unraid.refreshOperation", null),
|
||||||
|
reconcileRecordedOperations: record("unraid.reconcileRecordedOperations", []),
|
||||||
|
},
|
||||||
|
deployments: {
|
||||||
|
deploy: record("deployments.deploy", { id: "operation-3" }),
|
||||||
|
rollback: record("deployments.rollback", { id: "operation-4" }),
|
||||||
|
checkHealth: record("deployments.checkHealth", { healthy: true }),
|
||||||
|
refreshProfileState: record("deployments.refreshProfileState", { liveSha: null }),
|
||||||
|
},
|
||||||
|
evaluateDeploymentPolicy: () => ({ note: "", overridden: false, reason: "", violations: [] }),
|
||||||
|
audit: { append: record("audit.append", null) },
|
||||||
|
deployKeys: {
|
||||||
|
inventory: record("deployKeys.inventory", { keys: [] }),
|
||||||
|
planRotation: record("deployKeys.planRotation", { id: "rotation-1" }),
|
||||||
|
rotate: record("deployKeys.rotate", { rotated: true }),
|
||||||
|
planRevocation: record("deployKeys.planRevocation", { id: "revocation-1" }),
|
||||||
|
revoke: record("deployKeys.revoke", { revoked: true }),
|
||||||
|
restore: record("deployKeys.restore", { restored: true }),
|
||||||
|
},
|
||||||
|
repositories: { refresh: record("repositories.refresh", [REPOSITORY]) },
|
||||||
|
inventoryReviews: {
|
||||||
|
preview: (...args) => { calls.push({ name: "inventoryReviews.preview", args }); return { id: "review-1" }; },
|
||||||
|
apply: record("inventoryReviews.apply", { applied: true }),
|
||||||
|
},
|
||||||
|
diagnostics: { info: record("diagnostics.info"), warning: record("diagnostics.warning"), error: record("diagnostics.error"), debug: record("diagnostics.debug") },
|
||||||
|
git: {},
|
||||||
|
gitea: { getBranch: record("gitea.getBranch", { commit: { id: "c".repeat(40) } }) },
|
||||||
|
ssh: {},
|
||||||
|
preflight: { runDeployment: record("preflight.runDeployment", { ok: "actions" }) },
|
||||||
|
...overrides.dependencies,
|
||||||
|
};
|
||||||
|
|
||||||
|
registerDeploymentIpc(dependencies);
|
||||||
|
return { handlers, calls, names: () => calls.map((item) => item.name) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAYLOAD = {
|
||||||
|
repository: REPOSITORY,
|
||||||
|
fullName: REPOSITORY.fullName,
|
||||||
|
profileId: "profile-1",
|
||||||
|
sha: "a".repeat(40),
|
||||||
|
serverId: "server-1",
|
||||||
|
workloadId: WORKLOAD.workloadId,
|
||||||
|
planId: "plan-1",
|
||||||
|
action: "manual-link",
|
||||||
|
url: "https://app.example/health",
|
||||||
|
profile: { name: "Production" },
|
||||||
|
targetSha: "b".repeat(40),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Both provider paths have to run: a dependency that only the Gitea Actions
|
||||||
|
// branch reads stays invisible while every channel is exercised as SSH/Unraid.
|
||||||
|
for (const provider of ["ssh-unraid", "gitea-actions"]) {
|
||||||
|
test(`every deployment IPC channel runs with the dependencies it is given (${provider})`, async () => {
|
||||||
|
const { handlers } = harness({ profile: { id: "profile-1", provider, branch: "main", name: "Production" } });
|
||||||
|
assert.ok(handlers.size >= 20, "expected the complete deployment channel surface");
|
||||||
|
|
||||||
|
const failures = [];
|
||||||
|
for (const [channel, handler] of handlers) {
|
||||||
|
try {
|
||||||
|
await handler({ ...PAYLOAD });
|
||||||
|
} catch (error) {
|
||||||
|
// A refusal is a decision the handler made; a missing dependency is not.
|
||||||
|
if (error instanceof ReferenceError || error instanceof TypeError) {
|
||||||
|
failures.push(`${channel}: ${error.name}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepEqual(failures, []);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("deployment preflight routes by provider", async () => {
|
||||||
|
const actions = harness({ profile: { id: "profile-1", provider: "gitea-actions" } });
|
||||||
|
assert.deepEqual(await actions.handlers.get("deployment:preflight")({ ...PAYLOAD }), { ok: "actions" });
|
||||||
|
assert.ok(actions.names().includes("preflight.runDeployment"));
|
||||||
|
|
||||||
|
const unraid = harness();
|
||||||
|
assert.deepEqual(await unraid.handlers.get("deployment:preflight")({ ...PAYLOAD }), { ok: true });
|
||||||
|
assert.ok(unraid.names().includes("unraid.preflight"));
|
||||||
|
assert.ok(!unraid.names().includes("preflight.runDeployment"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("write-access repair is refused for anything but an SSH/Unraid profile", async () => {
|
||||||
|
const actions = harness({ profile: { id: "profile-1", provider: "gitea-actions" } });
|
||||||
|
await assert.rejects(
|
||||||
|
() => actions.handlers.get("deployment:repair-write-access")({ ...PAYLOAD }),
|
||||||
|
/available only for SSH \/ Unraid/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a stale workload blocks an inventory review instead of guessing", async () => {
|
||||||
|
const { handlers } = harness({
|
||||||
|
dependencies: { unraid: { scanServerInventory: async () => ({ workloads: [] }) } },
|
||||||
|
});
|
||||||
|
for (const channel of ["deployment:plan-inventory-review", "deployment:apply-inventory-review"]) {
|
||||||
|
await assert.rejects(() => handlers.get(channel)({ ...PAYLOAD }), (error) => {
|
||||||
|
assert.equal(error.code, "INVENTORY_REVIEW_WORKLOAD_STALE");
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("write-access repair builds a repair script that preserves runtime paths", () => {
|
||||||
|
const service = new UnraidDeploymentService({});
|
||||||
|
const profile = {
|
||||||
|
id: "profile-3",
|
||||||
|
provider: "ssh-unraid",
|
||||||
|
remoteFolder: "portfolio",
|
||||||
|
composeFiles: ["docker-compose.yml"],
|
||||||
|
preservePaths: ["data/uploads"],
|
||||||
|
};
|
||||||
|
const server = { id: "server-1", basePath: "/mnt/user/appdata" };
|
||||||
|
|
||||||
|
const script = service.permissionRepairScript(profile, server, "/mnt/user/appdata/portfolio");
|
||||||
|
|
||||||
|
assert.equal(typeof script, "string");
|
||||||
|
assert.match(script, /data\/uploads/);
|
||||||
|
assert.match(script, /node_modules/);
|
||||||
|
assert.match(script, /ForgeFlow repaired project write access/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { UnraidDeployKeyHost, parseDeployKeyMarker } = require("../src/main/unraid-deploy-key-host.cjs");
|
||||||
|
|
||||||
|
const SERVER = { id: "unraid", basePath: "/mnt/user/appdata" };
|
||||||
|
const REPOSITORY = { fullName: "Jens/Portfolio" };
|
||||||
|
|
||||||
|
// The host reaches the server through a single exec call, so capturing the script
|
||||||
|
// it sends is the only way to assert what actually happens to the key material.
|
||||||
|
function keyHost(stdout = "") {
|
||||||
|
const scripts = [];
|
||||||
|
const ssh = {
|
||||||
|
exec: async (serverId, command, options) => {
|
||||||
|
const encoded = command.match(/printf '%s' '([^']+)'/)?.[1] || "";
|
||||||
|
scripts.push({ serverId, options, script: Buffer.from(encoded, "base64").toString("utf8") });
|
||||||
|
return { stdout };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { host: new UnraidDeployKeyHost({ ssh }), scripts };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("deploy-key storage is repository-scoped, deterministic and stays under the server base path", () => {
|
||||||
|
const { host } = keyHost();
|
||||||
|
const first = host.paths(REPOSITORY, SERVER);
|
||||||
|
const again = host.paths({ fullName: "jens/portfolio" }, SERVER);
|
||||||
|
const other = host.paths({ fullName: "Jens/Other" }, SERVER);
|
||||||
|
|
||||||
|
assert.deepEqual(first, again, "the same repository always resolves to the same directory");
|
||||||
|
assert.notEqual(first.directory, other.directory, "a different repository never shares a key directory");
|
||||||
|
for (const value of Object.values(first)) {
|
||||||
|
assert.ok(value.startsWith("/mnt/user/appdata/.forgeflow/git-credentials/"), value);
|
||||||
|
assert.ok(!value.includes(".."));
|
||||||
|
}
|
||||||
|
assert.ok(!first.directory.toLowerCase().includes("portfolio"), "the repository name is hashed, not embedded");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the server pull remote is taken from the first usable SSH URL and refused when there is none", () => {
|
||||||
|
const { host } = keyHost();
|
||||||
|
assert.equal(
|
||||||
|
host.remote({ ...REPOSITORY, localStatus: { remoteUrl: "https://gitea.example/Jens/Portfolio.git" }, sshUrl: "git@gitea.example:Jens/Portfolio.git" }, {}),
|
||||||
|
"git@gitea.example:Jens/Portfolio.git",
|
||||||
|
"an HTTPS remote is skipped in favour of the SSH URL",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
host.remote({ ...REPOSITORY }, { cloneUrl: "ssh://git@gitea.example:2222/Jens/Portfolio.git" }),
|
||||||
|
"ssh://git@gitea.example:2222/Jens/Portfolio.git",
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => host.remote({ ...REPOSITORY, sshUrl: "https://gitea.example/Jens/Portfolio.git" }, {}),
|
||||||
|
(error) => {
|
||||||
|
assert.equal(error.code, "SERVER_GIT_SSH_URL_REQUIRED");
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the Git SSH environment pins the scoped key and refuses an unknown host", () => {
|
||||||
|
const { host } = keyHost();
|
||||||
|
const paths = host.paths(REPOSITORY, SERVER);
|
||||||
|
const environment = host.environment(paths);
|
||||||
|
|
||||||
|
assert.match(environment, /IdentitiesOnly=yes/);
|
||||||
|
assert.match(environment, /BatchMode=yes/);
|
||||||
|
assert.match(environment, /StrictHostKeyChecking=yes/);
|
||||||
|
assert.ok(environment.includes(paths.knownHosts), "the pinned host key file is repository-scoped");
|
||||||
|
assert.ok(environment.includes(paths.privateKey));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a backup copies the current key material into a fresh recovery slot", async () => {
|
||||||
|
const publicKey = "ssh-ed25519 QkFL forgeflow";
|
||||||
|
const { host, scripts } = keyHost(
|
||||||
|
`__FORGEFLOW_KEY_BACKUP__\nrecovery=/mnt/user/appdata/.forgeflow/git-credentials/abc/recovery/backup-1\npublicKey=${Buffer.from(publicKey).toString("base64")}\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const backup = await host.backup({ repository: REPOSITORY, server: SERVER });
|
||||||
|
assert.equal(backup.publicKey, publicKey);
|
||||||
|
assert.match(backup.recovery, /recovery\/backup-1$/);
|
||||||
|
assert.match(scripts[0].script, /umask 077/, "recovered key material is not world readable");
|
||||||
|
assert.match(scripts[0].script, /deploy-key deploy-key\.pub known_hosts/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("candidate verification only reports ready on a real remote commit", async () => {
|
||||||
|
const remoteSha = "d".repeat(40);
|
||||||
|
const candidate = { paths: { privateKey: "/k/deploy-key", publicKey: "/k/deploy-key.pub", knownHosts: "/k/known_hosts" } };
|
||||||
|
const context = {
|
||||||
|
repository: { ...REPOSITORY, sshUrl: "git@gitea.example:Jens/Portfolio.git" },
|
||||||
|
profile: { branch: "main" },
|
||||||
|
server: SERVER,
|
||||||
|
candidate,
|
||||||
|
};
|
||||||
|
|
||||||
|
const proven = keyHost(`__FORGEFLOW_KEY_PROOF__\nremoteSha=${remoteSha}\nfingerprint=SHA256:new\nhostFingerprint=SHA256:host\n`);
|
||||||
|
const proof = await proven.host.verifyCandidate(context);
|
||||||
|
assert.deepEqual(proof, { ready: true, remoteSha, fingerprint: "SHA256:new", hostFingerprint: "SHA256:host" });
|
||||||
|
assert.match(proven.scripts[0].script, /git ls-remote --exit-code/);
|
||||||
|
assert.match(proven.scripts[0].script, /refs\/heads\/main/);
|
||||||
|
assert.equal(proven.scripts[0].options.timeout, 45_000);
|
||||||
|
assert.deepEqual(await proven.host.preflightCandidate(context), proof);
|
||||||
|
|
||||||
|
const unproven = keyHost("__FORGEFLOW_KEY_PROOF__\nremoteSha=\nfingerprint=\nhostFingerprint=\n");
|
||||||
|
assert.equal((await unproven.host.verifyCandidate(context)).ready, false);
|
||||||
|
await assert.rejects(() => unproven.host.preflightCandidate(context), /did not prove the remote branch/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a preflight reuses a proof it was handed instead of asking the server again", async () => {
|
||||||
|
const reused = keyHost("__FORGEFLOW_KEY_PROOF__\nremoteSha=\nfingerprint=\nhostFingerprint=\n");
|
||||||
|
const proof = { ready: true, remoteSha: "f".repeat(40), fingerprint: "SHA256:new", hostFingerprint: "SHA256:host" };
|
||||||
|
const context = {
|
||||||
|
repository: { ...REPOSITORY, sshUrl: "git@gitea.example:Jens/Portfolio.git" },
|
||||||
|
profile: { branch: "main" },
|
||||||
|
server: SERVER,
|
||||||
|
candidate: { paths: { privateKey: "/k/deploy-key", publicKey: "/k/deploy-key.pub", knownHosts: "/k/known_hosts" } },
|
||||||
|
proof,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(await reused.host.preflightCandidate(context), proof);
|
||||||
|
assert.equal(reused.scripts.length, 0, "no second connection is opened");
|
||||||
|
|
||||||
|
// A proof that never established a remote commit is not a shortcut.
|
||||||
|
await assert.rejects(
|
||||||
|
() => reused.host.preflightCandidate({ ...context, proof: { ready: false } }),
|
||||||
|
/did not prove the remote branch/,
|
||||||
|
);
|
||||||
|
assert.equal(reused.scripts.length, 1, "an unusable proof falls back to verifying");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verifying the active key uses the repository-scoped paths rather than a candidate", async () => {
|
||||||
|
const { host, scripts } = keyHost(`__FORGEFLOW_KEY_PROOF__\nremoteSha=${"e".repeat(40)}\nfingerprint=SHA256:active\nhostFingerprint=SHA256:host\n`);
|
||||||
|
const paths = host.paths(REPOSITORY, SERVER);
|
||||||
|
|
||||||
|
const proof = await host.verifyActive({
|
||||||
|
repository: { ...REPOSITORY, sshUrl: "git@gitea.example:Jens/Portfolio.git" },
|
||||||
|
profile: { branch: "main" },
|
||||||
|
server: SERVER,
|
||||||
|
});
|
||||||
|
assert.equal(proof.ready, true);
|
||||||
|
assert.ok(scripts[0].script.includes(paths.privateKey));
|
||||||
|
assert.ok(scripts[0].script.includes(paths.knownHosts));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("promotion only replaces key material after proving the candidate is complete", async () => {
|
||||||
|
const { host, scripts } = keyHost();
|
||||||
|
const paths = host.paths(REPOSITORY, SERVER);
|
||||||
|
const candidate = { paths: { directory: "/c", privateKey: "/c/deploy-key", publicKey: "/c/deploy-key.pub", knownHosts: "/c/known_hosts" } };
|
||||||
|
|
||||||
|
await host.promote({ repository: REPOSITORY, server: SERVER, candidate });
|
||||||
|
const script = scripts[0].script;
|
||||||
|
assert.ok(script.includes("test -s '/c/deploy-key'"), "an empty candidate key is refused before anything is replaced");
|
||||||
|
assert.ok(script.includes("test -s '/c/known_hosts'"));
|
||||||
|
assert.ok(script.indexOf("test -s") < script.indexOf("mv "), "the checks run before the swap");
|
||||||
|
// The staging suffix is appended outside the quoted path, so the command reads
|
||||||
|
// mv '<path>'.new '<path>' rather than mv '<path>.new' '<path>'.
|
||||||
|
assert.ok(script.includes(`mv '${paths.privateKey}'.new '${paths.privateKey}'`), "the swap is atomic");
|
||||||
|
assert.ok(script.includes(`cp -p '/c/deploy-key' '${paths.privateKey}'.new`), "the copy lands on the staging name first");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rollback restores the recovery slot and removes the candidate", async () => {
|
||||||
|
const { host, scripts } = keyHost();
|
||||||
|
const paths = host.paths(REPOSITORY, SERVER);
|
||||||
|
|
||||||
|
await host.rollback({
|
||||||
|
repository: REPOSITORY,
|
||||||
|
server: SERVER,
|
||||||
|
candidate: { paths: { directory: "/candidate" } },
|
||||||
|
previous: { key: { recovery: "/recovery/backup-1" } },
|
||||||
|
});
|
||||||
|
assert.ok(scripts[0].script.includes("cp -p '/recovery/backup-1'"));
|
||||||
|
assert.ok(scripts[0].script.includes(paths.directory));
|
||||||
|
assert.ok(scripts[0].script.includes("rm -rf -- '/candidate'"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("committing a rotation discards only the candidate directory", async () => {
|
||||||
|
const { host, scripts } = keyHost();
|
||||||
|
await host.commit({ server: SERVER, candidate: { paths: { directory: "/candidate" } } });
|
||||||
|
// Every script carries the strict-mode preamble that bash() prepends.
|
||||||
|
assert.equal(scripts[0].script.split("\n").at(-1), "rm -rf -- '/candidate'");
|
||||||
|
assert.ok(!scripts[0].script.includes(".forgeflow/git-credentials"), "the active key directory is never touched on commit");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every server script runs under strict mode with Git prompts disabled", async () => {
|
||||||
|
const { host, scripts } = keyHost();
|
||||||
|
await host.commit({ server: SERVER, candidate: { paths: { directory: "/candidate" } } });
|
||||||
|
assert.match(scripts[0].script, /^set -euo pipefail\nexport GIT_TERMINAL_PROMPT=0\n/);
|
||||||
|
assert.equal(scripts[0].serverId, SERVER.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("revocation moves key material aside so it can still be restored", async () => {
|
||||||
|
const { host, scripts } = keyHost();
|
||||||
|
const paths = host.paths(REPOSITORY, SERVER);
|
||||||
|
|
||||||
|
await host.revoke({ repository: REPOSITORY, server: SERVER });
|
||||||
|
const script = scripts[0].script;
|
||||||
|
assert.ok(script.includes(`${paths.recovery}/revoked-`), "revoked material is kept in the recovery area");
|
||||||
|
assert.match(script, /mv /, "the key is moved, never deleted");
|
||||||
|
assert.ok(!/rm -rf/.test(script), "revocation must not destroy the recovery path");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("restore reinstates the newest recovery slot and reports the public evidence", async () => {
|
||||||
|
const publicKey = "ssh-ed25519 UkVT forgeflow";
|
||||||
|
const { host, scripts } = keyHost(
|
||||||
|
`__FORGEFLOW_KEY_RESTORE__\npublicKey=${Buffer.from(publicKey).toString("base64")}\nfingerprint=SHA256:restored\nhostFingerprint=SHA256:host\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const restored = await host.restore({ repository: REPOSITORY, server: SERVER });
|
||||||
|
assert.deepEqual(restored, { publicKey, fingerprint: "SHA256:restored", hostFingerprint: "SHA256:host" });
|
||||||
|
assert.match(scripts[0].script, /sort \| tail -1/, "the newest slot is chosen deterministically");
|
||||||
|
assert.ok(scripts[0].script.includes('test -n "$slot"'), "restoring without a recovery slot fails loudly");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("marker parsing keeps values that themselves contain separators", () => {
|
||||||
|
const parsed = parseDeployKeyMarker("noise\n__M__\nkey=a=b=c\nempty\nother=1\n", "__M__");
|
||||||
|
assert.deepEqual(parsed, { key: "a=b=c", empty: "", other: "1" });
|
||||||
|
});
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import http from 'node:http';
|
||||||
|
import deploymentModule from '../src/main/deployment-service.cjs';
|
||||||
|
|
||||||
|
const { DeploymentService } = deploymentModule;
|
||||||
|
|
||||||
|
const SHA = 'a'.repeat(40);
|
||||||
|
const PREVIOUS_SHA = 'b'.repeat(40);
|
||||||
|
|
||||||
|
async function serve(handler) {
|
||||||
|
const server = http.createServer(handler);
|
||||||
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
return {
|
||||||
|
url: `http://127.0.0.1:${server.address().port}/status`,
|
||||||
|
close: () => new Promise((resolve) => server.close(resolve))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonEndpoint(body, statusCode = 200) {
|
||||||
|
return serve((request, response) => {
|
||||||
|
response.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
||||||
|
response.end(typeof body === 'string' ? body : JSON.stringify(body));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A port nothing listens on, so the request fails instead of hanging.
|
||||||
|
async function unreachableUrl() {
|
||||||
|
const closed = await serve(() => {});
|
||||||
|
await closed.close();
|
||||||
|
return closed.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStore({ profile = null, operations = [] } = {}) {
|
||||||
|
const saved = new Map(operations.map((item) => [item.id, item]));
|
||||||
|
const states = new Map();
|
||||||
|
return {
|
||||||
|
data: { operations, gitea: { baseUrl: 'https://gitea.example' } },
|
||||||
|
getToken: () => 'gitea-secret-token',
|
||||||
|
getDeploymentProfile: () => profile,
|
||||||
|
getOperation: (id) => saved.get(id) || null,
|
||||||
|
addOperation: async (operation) => {
|
||||||
|
saved.set(operation.id, structuredClone(operation));
|
||||||
|
return structuredClone(operation);
|
||||||
|
},
|
||||||
|
saveDeploymentState: async (profileId, state) => {
|
||||||
|
states.set(profileId, state);
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
saved,
|
||||||
|
states
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeOperation(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: 'operation-1',
|
||||||
|
type: 'deployment',
|
||||||
|
action: 'deploy',
|
||||||
|
status: 'queued',
|
||||||
|
repository: 'jens/app',
|
||||||
|
profileId: 'production',
|
||||||
|
environment: 'production',
|
||||||
|
workflowFile: 'deploy.yml',
|
||||||
|
branch: 'main',
|
||||||
|
sha: SHA,
|
||||||
|
shortSha: SHA.slice(0, 7),
|
||||||
|
dispatchedAt: new Date().toISOString(),
|
||||||
|
stages: new DeploymentService({}, {}, {}).makeStages(),
|
||||||
|
logs: [],
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function successPayload(overrides = {}) {
|
||||||
|
return {
|
||||||
|
repository: 'jens/app',
|
||||||
|
environment: 'production',
|
||||||
|
commit_sha: SHA,
|
||||||
|
previous_sha: PREVIOUS_SHA,
|
||||||
|
requested_sha: SHA,
|
||||||
|
request_id: 'operation-1',
|
||||||
|
last_exit_code: 0,
|
||||||
|
health: 'healthy',
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the status endpoint reader accepts both key spellings and refuses anything that is not a commit SHA', async (context) => {
|
||||||
|
const service = new DeploymentService(makeStore(), {}, {});
|
||||||
|
assert.deepEqual(await service.readStatusEndpoint(''), { configured: false });
|
||||||
|
|
||||||
|
const snake = await jsonEndpoint(successPayload());
|
||||||
|
context.after(() => snake.close());
|
||||||
|
const snakeResult = await service.readStatusEndpoint(snake.url);
|
||||||
|
assert.equal(snakeResult.ok, true);
|
||||||
|
assert.equal(snakeResult.liveSha, SHA);
|
||||||
|
assert.equal(snakeResult.previousSha, PREVIOUS_SHA);
|
||||||
|
assert.equal(snakeResult.requestedSha, SHA);
|
||||||
|
assert.equal(snakeResult.requestId, 'operation-1');
|
||||||
|
assert.equal(snakeResult.lastExitCode, 0);
|
||||||
|
|
||||||
|
const camel = await jsonEndpoint({
|
||||||
|
repository: 'jens/app',
|
||||||
|
environment: 'PRODUCTION',
|
||||||
|
commitSha: SHA.toUpperCase(),
|
||||||
|
previousSha: PREVIOUS_SHA,
|
||||||
|
requestedSha: SHA,
|
||||||
|
requestId: 'operation-1',
|
||||||
|
lastExitCode: 3
|
||||||
|
});
|
||||||
|
context.after(() => camel.close());
|
||||||
|
const camelResult = await service.readStatusEndpoint(camel.url);
|
||||||
|
assert.equal(camelResult.liveSha, SHA, 'a SHA is normalised to lower case');
|
||||||
|
assert.equal(camelResult.environment, 'production', 'the environment is compared case-insensitively');
|
||||||
|
assert.equal(camelResult.lastExitCode, 3);
|
||||||
|
|
||||||
|
const untrusted = await jsonEndpoint({ commit_sha: 'HEAD', previous_sha: 'v1.2.3', request_id: 42, requested_sha: 'not-a-sha' });
|
||||||
|
context.after(() => untrusted.close());
|
||||||
|
const untrustedResult = await service.readStatusEndpoint(untrusted.url);
|
||||||
|
assert.equal(untrustedResult.liveSha, null);
|
||||||
|
assert.equal(untrustedResult.previousSha, null);
|
||||||
|
assert.equal(untrustedResult.requestedSha, null);
|
||||||
|
assert.equal(untrustedResult.requestId, null, 'a non-string request id is not accepted');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unreachable or failing status endpoint is reported instead of assumed healthy', async (context) => {
|
||||||
|
const service = new DeploymentService(makeStore(), {}, {});
|
||||||
|
|
||||||
|
const failing = await jsonEndpoint({ error: 'boom' }, 503);
|
||||||
|
context.after(() => failing.close());
|
||||||
|
const failed = await service.readStatusEndpoint(failing.url);
|
||||||
|
assert.deepEqual(
|
||||||
|
{ configured: failed.configured, reachable: failed.reachable, ok: failed.ok, status: failed.status },
|
||||||
|
{ configured: true, reachable: true, ok: false, status: 503 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const offline = await service.readStatusEndpoint(await unreachableUrl());
|
||||||
|
assert.equal(offline.reachable, false);
|
||||||
|
assert.equal(offline.ok, false);
|
||||||
|
assert.ok(offline.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('healthchecks distinguish unconfigured, healthy, rejected and unreachable', async (context) => {
|
||||||
|
const service = new DeploymentService(makeStore(), {}, {});
|
||||||
|
assert.deepEqual(await service.checkHealth(''), { configured: false, healthy: null });
|
||||||
|
|
||||||
|
const healthy = await jsonEndpoint({ ok: true });
|
||||||
|
context.after(() => healthy.close());
|
||||||
|
const healthyResult = await service.checkHealth(healthy.url);
|
||||||
|
assert.equal(healthyResult.healthy, true);
|
||||||
|
assert.equal(healthyResult.status, 200);
|
||||||
|
|
||||||
|
const rejected = await jsonEndpoint({ ok: false }, 500);
|
||||||
|
context.after(() => rejected.close());
|
||||||
|
assert.equal((await service.checkHealth(rejected.url)).healthy, false);
|
||||||
|
|
||||||
|
const offline = await service.checkHealth(await unreachableUrl());
|
||||||
|
assert.equal(offline.healthy, false);
|
||||||
|
assert.ok(offline.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profile state derives health from the status document when no healthcheck is configured', async (context) => {
|
||||||
|
const endpoint = await jsonEndpoint(successPayload({ health: 'degraded', deployed_at: '2026-08-01T10:00:00.000Z' }));
|
||||||
|
context.after(() => endpoint.close());
|
||||||
|
const profile = { id: 'production', environment: 'production', statusUrl: endpoint.url, healthcheckUrl: '' };
|
||||||
|
const store = makeStore({ profile });
|
||||||
|
const service = new DeploymentService(store, {}, {});
|
||||||
|
|
||||||
|
const state = await service.refreshProfileState('jens/app', 'production', { expectedSha: SHA });
|
||||||
|
assert.equal(state.healthConfigured, false);
|
||||||
|
assert.equal(state.healthy, false, 'a degraded status document is not treated as healthy');
|
||||||
|
assert.equal(state.liveSha, SHA);
|
||||||
|
assert.equal(state.versionMatches, true);
|
||||||
|
assert.equal(state.deployedAt, '2026-08-01T10:00:00.000Z');
|
||||||
|
assert.equal(store.states.get('production').liveSha, SHA, 'the state is persisted');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unknown health word leaves the health state undecided rather than guessing', async (context) => {
|
||||||
|
const endpoint = await jsonEndpoint(successPayload({ health: 'starting' }));
|
||||||
|
context.after(() => endpoint.close());
|
||||||
|
const store = makeStore({ profile: { id: 'production', environment: 'production', statusUrl: endpoint.url, healthcheckUrl: '' } });
|
||||||
|
const state = await new DeploymentService(store, {}, {}).refreshProfileState('jens/app', 'production');
|
||||||
|
assert.equal(state.healthy, null);
|
||||||
|
assert.equal(state.versionMatches, null, 'without an expected SHA there is nothing to compare');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshing the state of a removed profile fails loudly', async () => {
|
||||||
|
const service = new DeploymentService(makeStore({ profile: null }), {}, {});
|
||||||
|
await assert.rejects(() => service.refreshProfileState('jens/app', 'gone'), /Deployment profile not found/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a terminal operation is never polled again', async () => {
|
||||||
|
const operation = makeOperation({ status: 'success' });
|
||||||
|
const store = makeStore({ operations: [operation] });
|
||||||
|
const service = new DeploymentService(store, {
|
||||||
|
findWorkflowRun: async () => assert.fail('a finished deployment must not be polled'),
|
||||||
|
listWorkflowJobs: async () => assert.fail('a finished deployment must not be polled')
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
assert.equal((await service.refreshOperation('operation-1')).status, 'success');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unknown operation is reported instead of silently ignored', async () => {
|
||||||
|
const service = new DeploymentService(makeStore(), {}, {});
|
||||||
|
await assert.rejects(() => service.refreshOperation('missing'), /Deployment operation not found/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a workflow run that is not visible yet keeps the deployment queued', async () => {
|
||||||
|
const store = makeStore({ profile: { id: 'production' }, operations: [makeOperation()] });
|
||||||
|
const service = new DeploymentService(store, {
|
||||||
|
findWorkflowRun: async () => ({ run: null, source: 'actions' })
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const refreshed = await service.refreshOperation('operation-1');
|
||||||
|
assert.equal(refreshed.status, 'queued');
|
||||||
|
assert.equal(refreshed.stages.find((stage) => stage.id === 'queued').status, 'active');
|
||||||
|
assert.match(refreshed.logs.at(-1), /queued or not visible/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed runner marks the deployment failed and skips verification', async () => {
|
||||||
|
const store = makeStore({ profile: { id: 'production' }, operations: [makeOperation()] });
|
||||||
|
const service = new DeploymentService(store, {
|
||||||
|
findWorkflowRun: async () => ({ run: { id: 7, runNumber: 7, status: 'completed', conclusion: 'failure', htmlUrl: 'https://gitea.example/run/7' }, source: 'actions' }),
|
||||||
|
listWorkflowJobs: async () => [{ name: 'build', status: 'completed', conclusion: 'failure' }]
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const refreshed = await service.refreshOperation('operation-1');
|
||||||
|
assert.equal(refreshed.status, 'failed');
|
||||||
|
assert.equal(refreshed.failure.stage, 'runner');
|
||||||
|
assert.equal(refreshed.stages.find((stage) => stage.id === 'healthcheck').status, 'skipped');
|
||||||
|
assert.equal(refreshed.runUrl, 'https://gitea.example/run/7');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a successful runner still fails when the server does not prove it runs the exact commit', async (context) => {
|
||||||
|
const endpoint = await jsonEndpoint(successPayload({ commit_sha: 'c'.repeat(40) }));
|
||||||
|
context.after(() => endpoint.close());
|
||||||
|
const profile = { id: 'production', environment: 'production', statusUrl: endpoint.url, healthcheckUrl: '' };
|
||||||
|
const store = makeStore({ profile, operations: [makeOperation()] });
|
||||||
|
const service = new DeploymentService(store, {
|
||||||
|
findWorkflowRun: async () => ({ run: { id: 8, runNumber: 8, status: 'completed', conclusion: 'success' }, source: 'actions' }),
|
||||||
|
listWorkflowJobs: async () => []
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const refreshed = await service.refreshOperation('operation-1');
|
||||||
|
assert.equal(refreshed.status, 'failed');
|
||||||
|
assert.equal(refreshed.failure.stage, 'version-verification');
|
||||||
|
assert.match(refreshed.failure.message, /instead of/);
|
||||||
|
assert.equal(refreshed.stages.find((stage) => stage.id === 'complete').status, 'failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a verified deployment completes, and the same evidence marks a rollback as rolled back', async (context) => {
|
||||||
|
const endpoint = await jsonEndpoint(successPayload());
|
||||||
|
context.after(() => endpoint.close());
|
||||||
|
const profile = { id: 'production', environment: 'production', statusUrl: endpoint.url, healthcheckUrl: '' };
|
||||||
|
const gitea = {
|
||||||
|
findWorkflowRun: async () => ({ run: { id: 9, runNumber: 9, status: 'completed', conclusion: 'success' }, source: 'actions' }),
|
||||||
|
listWorkflowJobs: async () => [{ name: 'deploy', status: 'completed', conclusion: 'success' }]
|
||||||
|
};
|
||||||
|
|
||||||
|
const deployStore = makeStore({ profile, operations: [makeOperation()] });
|
||||||
|
const deployed = await new DeploymentService(deployStore, gitea, {}).refreshOperation('operation-1');
|
||||||
|
assert.equal(deployed.status, 'success');
|
||||||
|
assert.equal(deployed.stages.find((stage) => stage.id === 'complete').status, 'complete');
|
||||||
|
assert.equal(deployed.applicationState.liveSha, SHA);
|
||||||
|
assert.ok(deployed.logs.some((line) => line.includes('[job] deploy: success')));
|
||||||
|
|
||||||
|
const rollbackStore = makeStore({ profile, operations: [makeOperation({ action: 'rollback' })] });
|
||||||
|
const rolledBack = await new DeploymentService(rollbackStore, gitea, {}).refreshOperation('operation-1');
|
||||||
|
assert.equal(rolledBack.status, 'rolled-back');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unavailable job details degrade to a warning instead of failing the refresh', async () => {
|
||||||
|
const store = makeStore({ profile: { id: 'production' }, operations: [makeOperation()] });
|
||||||
|
const service = new DeploymentService(store, {
|
||||||
|
findWorkflowRun: async () => ({ run: { id: 10, runNumber: 10, status: 'in_progress', conclusion: null }, source: 'actions' }),
|
||||||
|
listWorkflowJobs: async () => { throw new Error('jobs API disabled'); }
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const refreshed = await service.refreshOperation('operation-1');
|
||||||
|
assert.equal(refreshed.status, 'running');
|
||||||
|
assert.equal(refreshed.stages.find((stage) => stage.id === 'runner').status, 'active');
|
||||||
|
assert.ok(refreshed.logs.some((line) => line.includes('Job details unavailable: jobs API disabled')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failing poll is recorded on the operation without losing it', async () => {
|
||||||
|
const store = makeStore({ profile: { id: 'production' }, operations: [makeOperation()] });
|
||||||
|
const service = new DeploymentService(store, {
|
||||||
|
findWorkflowRun: async () => { throw new Error('Gitea unreachable'); }
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const refreshed = await service.refreshOperation('operation-1');
|
||||||
|
assert.equal(refreshed.pollError, 'Gitea unreachable');
|
||||||
|
assert.equal(refreshed.status, 'queued', 'the operation keeps its last known state');
|
||||||
|
assert.ok(refreshed.logs.some((line) => line.includes('Status refresh failed')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a deployment whose profile was deleted reports that instead of crashing the poll', async () => {
|
||||||
|
const store = makeStore({ profile: null, operations: [makeOperation()] });
|
||||||
|
const refreshed = await new DeploymentService(store, {}, {}).refreshOperation('operation-1');
|
||||||
|
assert.match(refreshed.pollError, /profile used by this operation no longer exists/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a refresh already in flight is not started a second time', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
let release;
|
||||||
|
const gate = new Promise((resolve) => { release = resolve; });
|
||||||
|
const store = makeStore({ profile: { id: 'production' }, operations: [makeOperation()] });
|
||||||
|
const service = new DeploymentService(store, {
|
||||||
|
findWorkflowRun: async () => { calls += 1; await gate; return { run: null, source: 'actions' }; }
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const first = service.refreshOperation('operation-1');
|
||||||
|
const second = await service.refreshOperation('operation-1');
|
||||||
|
assert.equal(second.status, 'queued');
|
||||||
|
release();
|
||||||
|
await first;
|
||||||
|
assert.equal(calls, 1, 'the second caller reuses the in-flight refresh');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('job states drive the runner stage', () => {
|
||||||
|
const service = new DeploymentService(makeStore(), {}, {});
|
||||||
|
const stageOf = (jobs) => {
|
||||||
|
const operation = makeOperation();
|
||||||
|
service.mapJobsToStages(operation, jobs);
|
||||||
|
return operation.stages.find((stage) => stage.id === 'runner').status;
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(stageOf([{ status: 'in_progress' }]), 'active');
|
||||||
|
assert.equal(stageOf([{ conclusion: 'success' }, { conclusion: 'failure' }]), 'failed');
|
||||||
|
assert.equal(stageOf([{ conclusion: 'success' }]), 'complete');
|
||||||
|
assert.equal(stageOf([{ status: 'waiting' }]), 'pending');
|
||||||
|
|
||||||
|
const untouched = makeOperation();
|
||||||
|
service.mapJobsToStages(untouched, []);
|
||||||
|
assert.equal(untouched.stages.find((stage) => stage.id === 'queued').status, 'active', 'no jobs leaves the stages alone');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a rejected dispatch records the failure on the operation and still surfaces the error', async () => {
|
||||||
|
const profile = {
|
||||||
|
id: 'production', name: 'Production', environment: 'production', branch: 'main',
|
||||||
|
workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml',
|
||||||
|
statusUrl: 'https://app.example.test/.well-known/forgeflow'
|
||||||
|
};
|
||||||
|
const store = makeStore({ profile });
|
||||||
|
const service = new DeploymentService(store, {
|
||||||
|
listWorkflowRuns: async () => ({ runs: [{ id: 1 }, { id: 2 }] }),
|
||||||
|
dispatchWorkflow: async () => { throw new Error('workflow file not found'); }
|
||||||
|
}, {
|
||||||
|
status: async () => ({ head: SHA, clean: true, counts: { changed: 0 }, branch: { head: 'main', upstream: 'origin/main', ahead: 0, behind: 0 } }),
|
||||||
|
verifyCommitOnRemoteBranch: async () => ({ valid: true })
|
||||||
|
}, { info: async () => {}, error: async () => {} });
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => service.deploy({ repository: { fullName: 'jens/app', localPath: '/repo' }, profileId: 'production', sha: SHA }),
|
||||||
|
/workflow file not found/
|
||||||
|
);
|
||||||
|
|
||||||
|
const stored = [...store.saved.values()].at(-1);
|
||||||
|
assert.equal(stored.status, 'failed');
|
||||||
|
assert.equal(stored.failure.stage, 'dispatch');
|
||||||
|
assert.deepEqual(stored.baselineRunIds, ['1', '2'], 'runs that existed before dispatch are never mistaken for this one');
|
||||||
|
assert.equal(stored.stages.find((stage) => stage.id === 'queued').status, 'failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a rejected rollback dispatch is recorded the same way as a rejected deployment', async (context) => {
|
||||||
|
const endpoint = await jsonEndpoint(successPayload());
|
||||||
|
context.after(() => endpoint.close());
|
||||||
|
const profile = {
|
||||||
|
id: 'production', name: 'Production', environment: 'production', branch: 'main',
|
||||||
|
workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml', statusUrl: endpoint.url, healthcheckUrl: ''
|
||||||
|
};
|
||||||
|
const store = makeStore({ profile });
|
||||||
|
const service = new DeploymentService(store, {
|
||||||
|
listWorkflowRuns: async () => ({ runs: [] }),
|
||||||
|
dispatchWorkflow: async () => { throw new Error('rollback workflow is disabled'); }
|
||||||
|
}, {
|
||||||
|
verifyCommitOnRemoteBranch: async () => ({ valid: true })
|
||||||
|
}, { info: async () => {}, error: async () => {} });
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => service.rollback({ repository: { fullName: 'jens/app', localPath: '/repo' }, profileId: 'production', targetSha: PREVIOUS_SHA }),
|
||||||
|
/rollback workflow is disabled/
|
||||||
|
);
|
||||||
|
|
||||||
|
const stored = [...store.saved.values()].at(-1);
|
||||||
|
assert.equal(stored.action, 'rollback');
|
||||||
|
assert.equal(stored.status, 'failed');
|
||||||
|
assert.equal(stored.failure.stage, 'dispatch');
|
||||||
|
assert.equal(stored.workflowFile, 'rollback.yml');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rollback refuses every state where the target is not the server-reported previous version', async (context) => {
|
||||||
|
const endpoint = await jsonEndpoint(successPayload());
|
||||||
|
context.after(() => endpoint.close());
|
||||||
|
const base = {
|
||||||
|
id: 'production', name: 'Production', environment: 'production', branch: 'main',
|
||||||
|
workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml', statusUrl: endpoint.url, healthcheckUrl: ''
|
||||||
|
};
|
||||||
|
const git = { verifyCommitOnRemoteBranch: async () => ({ valid: true }) };
|
||||||
|
const repository = { fullName: 'jens/app', localPath: '/repo' };
|
||||||
|
const rollback = (profile, targetSha) => new DeploymentService(makeStore({ profile }), {}, git)
|
||||||
|
.rollback({ repository, profileId: 'production', targetSha });
|
||||||
|
|
||||||
|
await assert.rejects(() => rollback({ ...base, rollbackWorkflowFile: '' }, PREVIOUS_SHA), /No rollback workflow is configured/);
|
||||||
|
await assert.rejects(() => rollback(base, 'c'.repeat(40)), /no longer the previous server version/);
|
||||||
|
await assert.rejects(() => rollback(base, SHA), /no longer the previous server version/, 'the live commit is not the previous one either');
|
||||||
|
|
||||||
|
// The "already live" guard only remains reachable when the server reports the
|
||||||
|
// same commit as both its live and its previous version.
|
||||||
|
const stuck = await jsonEndpoint(successPayload({ previous_sha: SHA }));
|
||||||
|
context.after(() => stuck.close());
|
||||||
|
await assert.rejects(() => rollback({ ...base, statusUrl: stuck.url }, SHA), /already live/);
|
||||||
|
|
||||||
|
const noPrevious = await jsonEndpoint(successPayload({ previous_sha: null }));
|
||||||
|
context.after(() => noPrevious.close());
|
||||||
|
await assert.rejects(() => rollback({ ...base, statusUrl: noPrevious.url }, PREVIOUS_SHA), /does not report a previous version/);
|
||||||
|
|
||||||
|
const otherEnvironment = await jsonEndpoint(successPayload({ environment: 'staging' }));
|
||||||
|
context.after(() => otherEnvironment.close());
|
||||||
|
await assert.rejects(() => rollback({ ...base, statusUrl: otherEnvironment.url }, PREVIOUS_SHA), /does not match this repository and environment/);
|
||||||
|
|
||||||
|
// An unreachable endpoint surfaces the underlying network error rather than a
|
||||||
|
// generic message, so the reason a rollback was refused stays diagnosable.
|
||||||
|
const unreachable = { ...base, statusUrl: await unreachableUrl() };
|
||||||
|
await assert.rejects(() => rollback(unreachable, PREVIOUS_SHA), /fetch failed|ECONNREFUSED|must be reachable/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unavailable run baseline degrades to a warning rather than blocking the dispatch', async () => {
|
||||||
|
const profile = {
|
||||||
|
id: 'production', name: 'Production', environment: 'production', branch: 'main',
|
||||||
|
workflowFile: 'deploy.yml', statusUrl: 'https://app.example.test/.well-known/forgeflow'
|
||||||
|
};
|
||||||
|
const store = makeStore({ profile });
|
||||||
|
const service = new DeploymentService(store, {
|
||||||
|
listWorkflowRuns: async () => { throw new Error('Actions API disabled'); },
|
||||||
|
dispatchWorkflow: async () => ({ accepted: true })
|
||||||
|
}, {
|
||||||
|
status: async () => ({ head: SHA, clean: true, counts: { changed: 0 }, branch: { head: 'main', upstream: 'origin/main', ahead: 0, behind: 0 } }),
|
||||||
|
verifyCommitOnRemoteBranch: async () => ({ valid: true })
|
||||||
|
}, { info: async () => {}, error: async () => {} });
|
||||||
|
|
||||||
|
const operation = await service.deploy({ repository: { fullName: 'jens/app', localPath: '/repo' }, profileId: 'production', sha: SHA });
|
||||||
|
assert.equal(operation.status, 'queued');
|
||||||
|
assert.deepEqual(operation.baselineRunIds, []);
|
||||||
|
assert.ok(operation.logs.some((line) => line.includes('Could not capture the pre-dispatch run baseline')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deployment logs never repeat a line and never carry the Gitea token', () => {
|
||||||
|
const service = new DeploymentService(makeStore(), {}, {});
|
||||||
|
const operation = makeOperation({ logs: undefined });
|
||||||
|
|
||||||
|
service.appendLog(operation, 'plain line');
|
||||||
|
service.appendLog(operation, 'plain line');
|
||||||
|
service.appendLog(operation, 'authorization: token gitea-secret-token');
|
||||||
|
assert.equal(operation.logs.length, 2, 'a repeated line is not appended twice');
|
||||||
|
assert.ok(!operation.logs.at(-1).includes('gitea-secret-token'));
|
||||||
|
|
||||||
|
for (let index = 0; index < 1200; index += 1) service.appendLog(operation, `line ${index}`);
|
||||||
|
assert.equal(operation.logs.length, 1000, 'the log is bounded');
|
||||||
|
assert.equal(operation.logs.at(-1), 'line 1199');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a repository identity that is not exactly owner/repo is refused', () => {
|
||||||
|
const service = new DeploymentService(makeStore(), {}, {});
|
||||||
|
assert.deepEqual(service.splitRepository('jens/app'), { owner: 'jens', repo: 'app' });
|
||||||
|
for (const value of ['', 'app', 'jens/app/extra', '/app', 'jens/']) {
|
||||||
|
assert.throws(() => service.splitRepository(value), /Invalid Gitea repository identity/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deployment is refused without a linked local repository', async () => {
|
||||||
|
const service = new DeploymentService(makeStore(), {}, {});
|
||||||
|
await assert.rejects(() => service.deploy({ repository: { fullName: 'jens/app' }, profileId: 'production', sha: SHA }), /linked local repository/);
|
||||||
|
await assert.rejects(() => service.rollback({ repository: { localPath: '/repo' }, profileId: 'production', targetSha: SHA }), /linked local repository/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validation refuses every local state that would deploy something other than the reviewed commit', async () => {
|
||||||
|
const profile = { id: 'production', environment: 'production', branch: 'main', workflowFile: 'deploy.yml', statusUrl: 'https://app.example.test/status' };
|
||||||
|
const base = { head: SHA, clean: true, counts: { changed: 0 }, branch: { head: 'main', upstream: 'origin/main', ahead: 0, behind: 0 } };
|
||||||
|
const cases = [
|
||||||
|
[{ ...base, head: 'c'.repeat(40) }, /no longer matches the local repository/],
|
||||||
|
[{ ...base, branch: { ...base.branch, head: 'feature' } }, /only allows deployments from main/],
|
||||||
|
[{ ...base, counts: { changed: 2 } }, /Commit local changes/],
|
||||||
|
[{ ...base, branch: { ...base.branch, ahead: 1 } }, /Push all local commits/],
|
||||||
|
[{ ...base, branch: { ...base.branch, behind: 1 } }, /Synchronize with Gitea/],
|
||||||
|
[{ ...base, branch: { ...base.branch, upstream: '' } }, /Publish this branch/]
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [status, expected] of cases) {
|
||||||
|
const service = new DeploymentService(makeStore({ profile }), {}, {
|
||||||
|
status: async () => status,
|
||||||
|
verifyCommitOnRemoteBranch: async () => ({ valid: true })
|
||||||
|
});
|
||||||
|
await assert.rejects(() => service.validateDeploy({ localPath: '/repo' }, profile, SHA), expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -172,3 +172,22 @@ test('rollback refuses a stale target that is no longer the server-reported prev
|
|||||||
assert.equal(dispatched, false);
|
assert.equal(dispatched, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('active deployment refreshes run concurrently with a bounded worker pool', async () => {
|
||||||
|
const operations = Array.from({ length: 9 }, (_, index) => ({ id: `operation-${index}`, type: 'deployment', status: 'running' }));
|
||||||
|
const service = new DeploymentService({ data: { operations } }, {}, {});
|
||||||
|
let running = 0;
|
||||||
|
let peak = 0;
|
||||||
|
service.refreshOperation = async (id) => {
|
||||||
|
running += 1;
|
||||||
|
peak = Math.max(peak, running);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
running -= 1;
|
||||||
|
return { id };
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshed = await service.refreshActiveOperations();
|
||||||
|
assert.equal(refreshed.length, operations.length);
|
||||||
|
assert.ok(peak > 1);
|
||||||
|
assert.ok(peak <= 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,35 @@ test('GitService reads changes and commits/pushes selected files to a real bare
|
|||||||
assert.equal(remoteLog.stdout.trim(), 'Add desktop cockpit copy');
|
assert.equal(remoteLog.stdout.trim(), 'Add desktop cockpit copy');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('untracked diff rendering refuses links outside the repository and oversized files', async (t) => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-diff-boundary-'));
|
||||||
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||||
|
const repository = path.join(root, 'repository');
|
||||||
|
const outside = path.join(root, 'outside');
|
||||||
|
await fs.mkdir(repository, { recursive: true });
|
||||||
|
await fs.mkdir(outside, { recursive: true });
|
||||||
|
await git(['init'], repository);
|
||||||
|
await fs.writeFile(path.join(outside, 'secret.txt'), 'outside-secret');
|
||||||
|
try {
|
||||||
|
await fs.symlink(outside, path.join(repository, 'linked'), process.platform === 'win32' ? 'junction' : 'dir');
|
||||||
|
} catch {
|
||||||
|
t.skip('this platform does not allow creating directory links');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = new GitService();
|
||||||
|
await assert.rejects(
|
||||||
|
service.diff(repository, 'linked/secret.txt'),
|
||||||
|
(error) => error.code === 'DIFF_TARGET_OUTSIDE_REPOSITORY',
|
||||||
|
);
|
||||||
|
|
||||||
|
await fs.writeFile(path.join(repository, 'too-large.txt'), Buffer.alloc(16 * 1024 * 1024 + 1, 0x61));
|
||||||
|
await assert.rejects(
|
||||||
|
service.diff(repository, 'too-large.txt'),
|
||||||
|
(error) => error.code === 'DIFF_FILE_TOO_LARGE' && error.recoverable === true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('stages and pushes deleted and renamed files selected from the working tree', async (t) => {
|
test('stages and pushes deleted and renamed files selected from the working tree', async (t) => {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-git-delete-rename-'));
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-git-delete-rename-'));
|
||||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||||
@@ -204,6 +233,81 @@ test('detects and removes a stale HEAD.lock while skipping Git object storage',
|
|||||||
assert.ok(await fs.stat(ignoredObjectLock));
|
assert.ok(await fs.stat(ignoredObjectLock));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('previews and safely mirrors a workspace to Gitea while preserving every class of local work', async (t) => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-workspace-sync-'));
|
||||||
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||||
|
const remote = path.join(root, 'remote.git');
|
||||||
|
const working = path.join(root, 'working');
|
||||||
|
const external = path.join(root, 'external');
|
||||||
|
await git(['init', '--bare', remote], root);
|
||||||
|
await git(['clone', remote, working], root);
|
||||||
|
await git(['config', 'user.name', 'ForgeFlow Test'], working);
|
||||||
|
await git(['config', 'user.email', 'forgeflow@example.invalid'], working);
|
||||||
|
await fs.writeFile(path.join(working, '.gitignore'), 'runtime/\n');
|
||||||
|
await fs.writeFile(path.join(working, 'README.md'), 'initial\n');
|
||||||
|
await fs.writeFile(path.join(working, 'obsolete.txt'), 'remove remotely\n');
|
||||||
|
await git(['add', '.'], working);
|
||||||
|
await git(['commit', '-m', 'Initial'], working);
|
||||||
|
await git(['branch', '-M', 'main'], working);
|
||||||
|
await git(['push', '-u', 'origin', 'main'], working);
|
||||||
|
|
||||||
|
await git(['clone', remote, external], root);
|
||||||
|
await git(['config', 'user.name', 'External Gitea Test'], external);
|
||||||
|
await git(['config', 'user.email', 'external@example.invalid'], external);
|
||||||
|
await git(['checkout', 'main'], external);
|
||||||
|
await fs.writeFile(path.join(external, 'README.md'), 'changed on Gitea\n');
|
||||||
|
await fs.rm(path.join(external, 'obsolete.txt'));
|
||||||
|
await fs.writeFile(path.join(external, 'remote-only.txt'), 'new on Gitea\n');
|
||||||
|
await git(['add', '-A'], external);
|
||||||
|
await git(['commit', '-m', 'External cleanup'], external);
|
||||||
|
await git(['push', 'origin', 'main'], external);
|
||||||
|
|
||||||
|
await fs.writeFile(path.join(working, 'local-commit.txt'), 'local committed work\n');
|
||||||
|
await git(['add', 'local-commit.txt'], working);
|
||||||
|
await git(['commit', '-m', 'Local Codex work'], working);
|
||||||
|
const localHead = (await git(['rev-parse', 'HEAD'], working)).stdout.trim();
|
||||||
|
await fs.appendFile(path.join(working, 'README.md'), 'local uncommitted edit\n');
|
||||||
|
await fs.writeFile(path.join(working, 'local-notes.txt'), 'untracked local notes\n');
|
||||||
|
await fs.mkdir(path.join(working, 'runtime'), { recursive: true });
|
||||||
|
await fs.writeFile(path.join(working, 'runtime', 'local.db'), 'ignored runtime state\n');
|
||||||
|
|
||||||
|
const service = new GitService();
|
||||||
|
const firstPlan = await service.previewWorkspaceSync(working);
|
||||||
|
assert.match(firstPlan.id, /^[0-9a-f]{64}$/);
|
||||||
|
assert.equal(firstPlan.summary.localCommitsToProtect, 1);
|
||||||
|
assert.equal(firstPlan.summary.incomingCommits, 1);
|
||||||
|
assert.equal(firstPlan.summary.localFilesToStash, 2);
|
||||||
|
assert.equal(firstPlan.summary.untrackedFilesToStash, 1);
|
||||||
|
assert.ok(firstPlan.changes.some((item) => item.path === 'obsolete.txt' && item.code === 'D'));
|
||||||
|
assert.equal(firstPlan.recovery.ignoredFilesPreserved, true);
|
||||||
|
|
||||||
|
await fs.writeFile(path.join(working, 'changed-after-preview.txt'), 'forces a stale plan\n');
|
||||||
|
await assert.rejects(
|
||||||
|
service.synchronizeWorkspace(working, firstPlan.id),
|
||||||
|
(error) => error.code === 'WORKSPACE_SYNC_PLAN_STALE'
|
||||||
|
);
|
||||||
|
assert.equal(await fs.readFile(path.join(working, 'changed-after-preview.txt'), 'utf8'), 'forces a stale plan\n');
|
||||||
|
|
||||||
|
const reviewedPlan = await service.previewWorkspaceSync(working);
|
||||||
|
const result = await service.synchronizeWorkspace(working, reviewedPlan.id);
|
||||||
|
assert.equal(result.applied, true);
|
||||||
|
assert.equal(result.status.clean, true);
|
||||||
|
assert.equal(result.status.head, reviewedPlan.targetSha);
|
||||||
|
assert.match(result.backupBranch, /^forgeflow\/recovery-main-/);
|
||||||
|
assert.ok(result.stash?.sha);
|
||||||
|
assert.equal((await git(['rev-parse', result.backupBranch], working)).stdout.trim(), localHead);
|
||||||
|
assert.equal((await fs.readFile(path.join(working, 'README.md'), 'utf8')).replace(/\r\n/g, '\n'), 'changed on Gitea\n');
|
||||||
|
assert.equal((await fs.readFile(path.join(working, 'remote-only.txt'), 'utf8')).replace(/\r\n/g, '\n'), 'new on Gitea\n');
|
||||||
|
await assert.rejects(fs.stat(path.join(working, 'obsolete.txt')), (error) => error.code === 'ENOENT');
|
||||||
|
await assert.rejects(fs.stat(path.join(working, 'local-commit.txt')), (error) => error.code === 'ENOENT');
|
||||||
|
await assert.rejects(fs.stat(path.join(working, 'local-notes.txt')), (error) => error.code === 'ENOENT');
|
||||||
|
assert.equal(await fs.readFile(path.join(working, 'runtime', 'local.db'), 'utf8'), 'ignored runtime state\n');
|
||||||
|
const stashedPaths = (await git(['stash', 'show', '--include-untracked', '--name-only', result.stash.ref], working)).stdout;
|
||||||
|
assert.match(stashedPaths, /README\.md/);
|
||||||
|
assert.match(stashedPaths, /local-notes\.txt/);
|
||||||
|
assert.match(stashedPaths, /changed-after-preview\.txt/);
|
||||||
|
});
|
||||||
|
|
||||||
test('repairs a diverged branch by creating a safety branch before resetting to upstream', async (t) => {
|
test('repairs a diverged branch by creating a safety branch before resetting to upstream', async (t) => {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-diverged-'));
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-diverged-'));
|
||||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
async function rendererSource() {
|
async function rendererSource() {
|
||||||
return (await Promise.all(["app.js", "views.js", "dialogs.js", "operations.js", "actions/shell.js", "actions/inventory.js", "actions/deployment-profile.js", "actions/deployment-operation.js", "actions/setup-and-settings.js", "actions/recovery.js", "actions/command.js", "events.js"].map((file) => readFile(new URL(`../src/renderer/${file}`, import.meta.url), "utf8")))).join("\n");
|
return (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(new URL(`../src/renderer/${file}`, import.meta.url), "utf8")))).join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
test("every preload invoke channel has a registered IPC handler", async () => {
|
test("every preload invoke channel has a registered IPC handler", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import policyModule from '../src/main/process-error-policy.cjs';
|
||||||
|
|
||||||
|
const { installOutputPipeGuards, isBrokenPipeError } = policyModule;
|
||||||
|
|
||||||
|
test('broken output pipes are recognized without treating unrelated failures as EPIPE', () => {
|
||||||
|
assert.equal(isBrokenPipeError(Object.assign(new Error('closed'), { code: 'EPIPE' })), true);
|
||||||
|
assert.equal(isBrokenPipeError(Object.assign(new Error('denied'), { code: 'EACCES' })), false);
|
||||||
|
assert.equal(isBrokenPipeError(null), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('output pipe guard absorbs EPIPE and can be cleanly removed', () => {
|
||||||
|
const stdout = new EventEmitter();
|
||||||
|
const stderr = new EventEmitter();
|
||||||
|
const observed = [];
|
||||||
|
const remove = installOutputPipeGuards({ stdout, stderr, onBrokenPipe: (error) => observed.push(error.code) });
|
||||||
|
|
||||||
|
stdout.emit('error', Object.assign(new Error('closed'), { code: 'EPIPE' }));
|
||||||
|
stderr.emit('error', Object.assign(new Error('closed'), { code: 'EPIPE' }));
|
||||||
|
assert.deepEqual(observed, ['EPIPE', 'EPIPE']);
|
||||||
|
remove();
|
||||||
|
assert.equal(stdout.listenerCount('error'), 0);
|
||||||
|
assert.equal(stderr.listenerCount('error'), 0);
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@ import test from "node:test";
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
const rendererFiles = ["app.js", "views.js", "dialogs.js", "operations.js", "actions/shell.js", "actions/inventory.js", "actions/deployment-profile.js", "actions/deployment-operation.js", "actions/setup-and-settings.js", "actions/recovery.js", "actions/command.js", "events.js"];
|
const rendererFiles = ["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"];
|
||||||
async function rendererSource() {
|
async function rendererSource() {
|
||||||
return (await Promise.all(rendererFiles.map((file) => readFile(new URL(`../src/renderer/${file}`, import.meta.url), "utf8")))).join("\n");
|
return (await Promise.all(rendererFiles.map((file) => readFile(new URL(`../src/renderer/${file}`, import.meta.url), "utf8")))).join("\n");
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,13 @@ async function ipcSource() {
|
|||||||
return (await Promise.all(["ipc.cjs", "ipc/repository-handlers.cjs", "ipc/deployment-handlers.cjs", "ipc/operations-handlers.cjs"].map((file) => readFile(new URL(`../src/main/${file}`, import.meta.url), "utf8")))).join("\n");
|
return (await Promise.all(["ipc.cjs", "ipc/repository-handlers.cjs", "ipc/deployment-handlers.cjs", "ipc/operations-handlers.cjs"].map((file) => readFile(new URL(`../src/main/${file}`, import.meta.url), "utf8")))).join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("desktop shell serializes ForgeFlow to one configuration writer", async () => {
|
||||||
|
const main = await readFile(new URL("../main.cjs", import.meta.url), "utf8");
|
||||||
|
assert.match(main, /requestSingleInstanceLock\(\)/);
|
||||||
|
assert.match(main, /second-instance/);
|
||||||
|
assert.match(main, /showMainWindow\(\)/);
|
||||||
|
});
|
||||||
|
|
||||||
test("changed file list has an independently scrollable bounded layout", async () => {
|
test("changed file list has an independently scrollable bounded layout", async () => {
|
||||||
const css = await readFile(
|
const css = await readFile(
|
||||||
new URL("../src/renderer/styles.css", import.meta.url),
|
new URL("../src/renderer/styles.css", import.meta.url),
|
||||||
@@ -114,6 +121,49 @@ test("repository troubleshooting offers personalized synchronization repair acti
|
|||||||
assert.match(ipc, /repository:repair-sync/);
|
assert.match(ipc, /repository:repair-sync/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Gitea workspace sync is preview-driven, recoverable and never deletes ignored runtime data", async () => {
|
||||||
|
const renderer = await rendererSource();
|
||||||
|
const preload = await readFile(new URL("../preload.cjs", import.meta.url), "utf8");
|
||||||
|
const ipc = await ipcSource();
|
||||||
|
assert.match(renderer, /Gitea workspace sync/);
|
||||||
|
assert.match(renderer, /preview-workspace-sync/);
|
||||||
|
assert.match(renderer, /confirm-workspace-sync/);
|
||||||
|
assert.match(renderer, /Ignored runtime files remain in place/);
|
||||||
|
assert.match(renderer, /recovery branch/);
|
||||||
|
assert.match(renderer, /named Git stash/);
|
||||||
|
assert.match(renderer, /Gitea fetch interval/);
|
||||||
|
assert.match(preload, /previewWorkspaceSync/);
|
||||||
|
assert.match(preload, /applyWorkspaceSync/);
|
||||||
|
assert.match(ipc, /repository:workspace-sync-preview/);
|
||||||
|
assert.match(ipc, /repository:workspace-sync-apply/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("demo bridge implements the complete Git recovery flow", async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
new URL("../src/renderer/mock-repository-bridge.js", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
for (const method of [
|
||||||
|
"gitRecoveryStatus",
|
||||||
|
"reconcileRepository",
|
||||||
|
"repairGitLocks",
|
||||||
|
"repairRepositorySync",
|
||||||
|
]) {
|
||||||
|
assert.match(source, new RegExp(`async ${method}\\(`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Git tools rows retain their content height inside the scrollable tab", async () => {
|
||||||
|
const styles = await readFile(
|
||||||
|
new URL("../src/renderer/styles.css", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
styles,
|
||||||
|
/\.git-tools-grid\s*\{[^}]*grid-auto-rows:\s*max-content/s,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("advanced Git, desktop, backup, policy and audit workflows are exposed in the renderer", async () => {
|
test("advanced Git, desktop, backup, policy and audit workflows are exposed in the renderer", async () => {
|
||||||
const renderer = await rendererSource();
|
const renderer = await rendererSource();
|
||||||
const preload = await readFile(
|
const preload = await readFile(
|
||||||
@@ -168,19 +218,35 @@ test("one-click troubleshooting excludes destructive or publishing Git actions",
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("premium repository workspace reserves separate rows for actions and release status", async () => {
|
test("premium repository workspace groups variable context above a stable tab row", async () => {
|
||||||
|
const renderer = await rendererSource();
|
||||||
const styles = await readFile(
|
const styles = await readFile(
|
||||||
new URL("../src/renderer/styles.css", import.meta.url),
|
new URL("../src/renderer/styles.css", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
styles,
|
styles,
|
||||||
/\.repo-workspace\s*\{[^}]*grid-template-rows:\s*auto auto auto 39px minmax\(0, 1fr\)/s,
|
/\.repo-workspace\s*\{[^}]*grid-template-rows:\s*auto auto 39px minmax\(0, 1fr\)/s,
|
||||||
);
|
);
|
||||||
|
assert.match(renderer, /<div class="repo-context">/);
|
||||||
|
assert.match(styles, /\.tabs\s*\{[^}]*overflow-x:\s*auto/s);
|
||||||
|
assert.match(styles, /\.tab\s*\{[^}]*white-space:\s*nowrap/s);
|
||||||
assert.match(styles, /prefers-reduced-motion/);
|
assert.match(styles, /prefers-reduced-motion/);
|
||||||
assert.match(styles, /ForgeFlow 0\.8 premium visual system/);
|
assert.match(styles, /ForgeFlow 0\.8 premium visual system/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("help center explains core workflows and supports contextual searchable guidance", async () => {
|
||||||
|
const renderer = await rendererSource();
|
||||||
|
assert.match(renderer, /function renderHelp\(\)/);
|
||||||
|
assert.match(renderer, /id: "workspace-sync"/);
|
||||||
|
assert.match(renderer, /id: "deployment-linking"/);
|
||||||
|
assert.match(renderer, /id: "deploy-keys"/);
|
||||||
|
assert.match(renderer, /id: "git-validator"/);
|
||||||
|
assert.match(renderer, /id="help-search"/);
|
||||||
|
assert.match(renderer, /data-action="open-context-help" data-topic="workspace-sync"/);
|
||||||
|
assert.match(renderer, /ui\.currentView === "help"/);
|
||||||
|
});
|
||||||
|
|
||||||
test("interactive project illustrations are semantic, responsive and motion-safe", async () => {
|
test("interactive project illustrations are semantic, responsive and motion-safe", async () => {
|
||||||
const renderer = await rendererSource();
|
const renderer = await rendererSource();
|
||||||
const styles = await readFile(
|
const styles = await readFile(
|
||||||
|
|||||||
@@ -49,3 +49,100 @@ test('repository monitor checks multiple repositories concurrently with a bounde
|
|||||||
assert.equal(active, 0);
|
assert.equal(active, 0);
|
||||||
assert.equal(monitor.fingerprints.size, 10);
|
assert.equal(monitor.fingerprints.size, 10);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a watched repository is read on filesystem activity instead of on every interval', async (context) => {
|
||||||
|
const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises');
|
||||||
|
const os = await import('node:os');
|
||||||
|
const path = await import('node:path');
|
||||||
|
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-watch-'));
|
||||||
|
context.after(() => rm(root, { recursive: true, force: true }));
|
||||||
|
await mkdir(path.join(root, '.git'), { recursive: true });
|
||||||
|
|
||||||
|
let revision = 1;
|
||||||
|
const reads = [];
|
||||||
|
const changes = [];
|
||||||
|
const git = {
|
||||||
|
status: async (localPath) => { reads.push(localPath); return { localPath, revision }; },
|
||||||
|
statusFingerprint: (status) => String(status.revision)
|
||||||
|
};
|
||||||
|
const store = { data: { preferences: { autoRefresh: true, repositoryPollSeconds: 2 } } };
|
||||||
|
const monitor = new RepositoryMonitor({ store, git, onChange: (change) => changes.push(change) });
|
||||||
|
context.after(() => monitor.stop());
|
||||||
|
|
||||||
|
monitor.restart();
|
||||||
|
monitor.setPaths([root]);
|
||||||
|
if (!monitor.watchers.has(root)) {
|
||||||
|
context.skip('this platform does not support recursive directory watching');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await monitor.tick();
|
||||||
|
assert.equal(reads.length, 1, 'the baseline is established once');
|
||||||
|
|
||||||
|
// Without filesystem activity the interval must not spawn another read.
|
||||||
|
await monitor.tick();
|
||||||
|
assert.equal(reads.length, 1);
|
||||||
|
|
||||||
|
revision = 2;
|
||||||
|
await writeFile(path.join(root, 'feature.txt'), 'changed\n');
|
||||||
|
// The watcher debounce and the per-repository cooldown both apply here.
|
||||||
|
const deadline = Date.now() + 5_000;
|
||||||
|
while (changes.length === 0 && Date.now() < deadline) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ok(reads.length > 1, 'filesystem activity triggers a read');
|
||||||
|
assert.equal(changes.length, 1);
|
||||||
|
assert.equal(changes[0].reason, 'working-tree-changed');
|
||||||
|
|
||||||
|
const readsAfterChange = reads.length;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||||
|
assert.equal(reads.length, readsAfterChange, 'a quiet repository is not read again');
|
||||||
|
|
||||||
|
monitor.stop();
|
||||||
|
assert.equal(monitor.watchers.size, 0, 'stopping releases every watcher');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('background Gitea awareness fetches read-only remote state with bounded concurrency', async () => {
|
||||||
|
let active = 0;
|
||||||
|
let peak = 0;
|
||||||
|
const changes = [];
|
||||||
|
const git = {
|
||||||
|
fetch: async (localPath) => {
|
||||||
|
active += 1;
|
||||||
|
peak = Math.max(peak, active);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||||
|
active -= 1;
|
||||||
|
return { status: { localPath, revision: 2, branch: { head: 'main', ahead: 0, behind: 1 }, counts: {} } };
|
||||||
|
},
|
||||||
|
statusFingerprint: (status) => String(status.revision),
|
||||||
|
};
|
||||||
|
const store = { data: { preferences: { autoRefresh: true, repositoryPollSeconds: 2, fetchIntervalMinutes: 1 } } };
|
||||||
|
const monitor = new RepositoryMonitor({ store, git, onChange: (change) => changes.push(change) });
|
||||||
|
const paths = Array.from({ length: 6 }, (_, index) => `/repo-${index}`);
|
||||||
|
monitor.setPaths(paths);
|
||||||
|
for (const localPath of paths) {
|
||||||
|
monitor.fingerprints.set(localPath, '1');
|
||||||
|
monitor.lastFetchedAt.set(localPath, Date.now() - 61_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
await monitor.fetchRemoteUpdates();
|
||||||
|
assert.equal(peak, 2);
|
||||||
|
assert.equal(active, 0);
|
||||||
|
assert.equal(changes.length, paths.length);
|
||||||
|
assert.ok(changes.every((change) => change.reason === 'remote-state-changed'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a zero remote fetch interval disables background network access', async () => {
|
||||||
|
let fetches = 0;
|
||||||
|
const git = {
|
||||||
|
fetch: async () => { fetches += 1; return { status: { revision: 2 } }; },
|
||||||
|
statusFingerprint: (status) => String(status.revision),
|
||||||
|
};
|
||||||
|
const store = { data: { preferences: { autoRefresh: true, repositoryPollSeconds: 2, fetchIntervalMinutes: 0 } } };
|
||||||
|
const monitor = new RepositoryMonitor({ store, git });
|
||||||
|
monitor.setPaths(['/repo']);
|
||||||
|
monitor.lastFetchedAt.set('/repo', 0);
|
||||||
|
await monitor.fetchRemoteUpdates(Date.now() + 24 * 60 * 60_000);
|
||||||
|
assert.equal(fetches, 0);
|
||||||
|
});
|
||||||
|
|||||||
@@ -80,6 +80,24 @@ test('repository discovery is bounded, skips generated trees and ignores inacces
|
|||||||
assert.ok(all.includes(found[0]));
|
assert.ok(all.includes(found[0]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a repository reached through a directory junction is discovered once', async (context) => {
|
||||||
|
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-junction-'));
|
||||||
|
context.after(() => import('node:fs/promises').then(({ rm }) => rm(root, { recursive: true, force: true })));
|
||||||
|
const elsewhere = path.join(root, 'elsewhere', 'service');
|
||||||
|
await mkdir(path.join(elsewhere, '.git'), { recursive: true });
|
||||||
|
await mkdir(path.join(root, 'workspace'), { recursive: true });
|
||||||
|
try {
|
||||||
|
await symlink(elsewhere, path.join(root, 'workspace', 'linked-service'), 'junction');
|
||||||
|
} catch {
|
||||||
|
context.skip('this platform does not allow creating directory links');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { realpath } = await import('node:fs/promises');
|
||||||
|
const found = await service().discoverInRoot(path.join(root, 'workspace'), 3);
|
||||||
|
assert.deepEqual(found, [await realpath(elsewhere)]);
|
||||||
|
});
|
||||||
|
|
||||||
test('local descriptors preserve Git failures and watch paths are defensive copies', async () => {
|
test('local descriptors preserve Git failures and watch paths are defensive copies', async () => {
|
||||||
const instance = new RepositoryService({ data: {} }, {
|
const instance = new RepositoryService({ data: {} }, {
|
||||||
status: async (localPath) => {
|
status: async (localPath) => {
|
||||||
@@ -133,6 +151,59 @@ test('refresh links explicit and remote-matched repositories and retains unmatch
|
|||||||
assert.equal(diagnostics[0][0], 'repositories.refresh.completed');
|
assert.equal(diagnostics[0][0], 'repositories.refresh.completed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolving one repository reads only that repository, not the whole workspace', async () => {
|
||||||
|
const scanned = [];
|
||||||
|
const store = {
|
||||||
|
data: {
|
||||||
|
gitea: { baseUrl: 'https://gitea.example' },
|
||||||
|
workspaceRoots: ['root'],
|
||||||
|
repositoryMappings: { 'jens/portfolio': 'C:/explicit' },
|
||||||
|
preferences: { preferredCloneProtocol: 'https' },
|
||||||
|
favorites: []
|
||||||
|
},
|
||||||
|
getToken: () => 'token',
|
||||||
|
getDeploymentProfiles: () => [{ id: 'prod', branch: 'main' }],
|
||||||
|
getDeploymentState: () => ({ liveSha: null, healthy: null })
|
||||||
|
};
|
||||||
|
const instance = new RepositoryService(store, {
|
||||||
|
status: async (localPath) => {
|
||||||
|
scanned.push(localPath);
|
||||||
|
return { ...status(), root: localPath, remoteUrl: remote.clone_url };
|
||||||
|
}
|
||||||
|
}, { listRepositories: async () => [remote, { ...remote, id: 2, full_name: 'Jens/Other', name: 'Other' }] });
|
||||||
|
instance.discoverAll = async () => ['C:/explicit', 'C:/other', 'C:/third'];
|
||||||
|
|
||||||
|
await instance.refresh();
|
||||||
|
const duringRefresh = scanned.length;
|
||||||
|
assert.equal(duringRefresh, 3);
|
||||||
|
|
||||||
|
scanned.length = 0;
|
||||||
|
const resolved = await instance.resolveByFullName(remote.full_name);
|
||||||
|
assert.equal(resolved.fullName, remote.full_name);
|
||||||
|
assert.equal(resolved.localPath, 'C:/explicit');
|
||||||
|
assert.equal(resolved.deploymentProfiles[0].id, 'prod');
|
||||||
|
assert.deepEqual(scanned, ['C:/explicit']);
|
||||||
|
|
||||||
|
assert.equal(await instance.resolveByFullName(''), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolving an unknown repository still falls back to a full refresh', async () => {
|
||||||
|
const store = {
|
||||||
|
data: { gitea: { baseUrl: 'https://gitea.example' }, workspaceRoots: [], repositoryMappings: {}, preferences: { preferredCloneProtocol: 'https' }, favorites: [] },
|
||||||
|
getToken: () => 'token',
|
||||||
|
getDeploymentProfiles: () => [],
|
||||||
|
getDeploymentState: () => null
|
||||||
|
};
|
||||||
|
const instance = new RepositoryService(store, {
|
||||||
|
status: async (localPath) => ({ ...status(), root: localPath, remoteUrl: '' })
|
||||||
|
}, { listRepositories: async () => [remote] });
|
||||||
|
instance.discoverAll = async () => ['C:/loose-checkout'];
|
||||||
|
|
||||||
|
const local = await instance.resolveByFullName('loose-checkout');
|
||||||
|
assert.equal(local.linkState, 'unmatched-local');
|
||||||
|
assert.equal(await instance.resolveByFullName('Jens/Missing'), null);
|
||||||
|
});
|
||||||
|
|
||||||
test('refresh remains local-only without configured Gitea credentials', async () => {
|
test('refresh remains local-only without configured Gitea credentials', async () => {
|
||||||
const store = {
|
const store = {
|
||||||
data: { gitea: { baseUrl: '' }, workspaceRoots: [], repositoryMappings: {}, preferences: { preferredCloneProtocol: 'ssh' }, favorites: [] },
|
data: { gitea: { baseUrl: '' }, workspaceRoots: [], repositoryMappings: {}, preferences: { preferredCloneProtocol: 'ssh' }, favorites: [] },
|
||||||
@@ -143,6 +214,67 @@ test('refresh remains local-only without configured Gitea credentials', async ()
|
|||||||
assert.deepEqual(await instance.refresh(), []);
|
assert.deepEqual(await instance.refresh(), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('refresh uses last-known Gitea repositories after a transient remote failure', async () => {
|
||||||
|
const warnings = [];
|
||||||
|
let remoteAvailable = true;
|
||||||
|
const store = {
|
||||||
|
data: {
|
||||||
|
gitea: { baseUrl: 'https://gitea.example' }, workspaceRoots: [], repositoryMappings: {},
|
||||||
|
preferences: { preferredCloneProtocol: 'ssh' }, favorites: []
|
||||||
|
},
|
||||||
|
getToken: () => 'token', getDeploymentProfiles: () => [], getDeploymentState: () => null
|
||||||
|
};
|
||||||
|
const instance = new RepositoryService(store, {}, {
|
||||||
|
listRepositories: async () => {
|
||||||
|
if (!remoteAvailable) throw new Error('Gitea timed out');
|
||||||
|
return [remote];
|
||||||
|
}
|
||||||
|
}, { debug: async () => {}, warning: async (...args) => warnings.push(args) });
|
||||||
|
instance.discoverAll = async () => [];
|
||||||
|
|
||||||
|
const fresh = await instance.refresh();
|
||||||
|
remoteAvailable = false;
|
||||||
|
const degraded = await instance.refresh({ force: true });
|
||||||
|
|
||||||
|
assert.equal(fresh[0].remoteStale, false);
|
||||||
|
assert.equal(degraded[0].fullName, remote.full_name);
|
||||||
|
assert.equal(degraded[0].remoteStale, true);
|
||||||
|
assert.equal(degraded[0].remoteRefreshError, 'Gitea timed out');
|
||||||
|
assert.ok(degraded[0].remoteLastRefreshedAt);
|
||||||
|
assert.equal(warnings[0][0], 'repositories.remote-refresh.degraded');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initial Gitea failure remains visible when no safe cache exists', async () => {
|
||||||
|
const store = {
|
||||||
|
data: { gitea: { baseUrl: 'https://gitea.example' } },
|
||||||
|
getToken: () => 'token'
|
||||||
|
};
|
||||||
|
const instance = new RepositoryService(store, {}, {
|
||||||
|
listRepositories: async () => { throw new Error('Gitea unavailable'); }
|
||||||
|
});
|
||||||
|
await assert.rejects(() => instance.refresh(), /Gitea unavailable/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refresh coalesces concurrent work and briefly reuses remote and discovery results', async () => {
|
||||||
|
let remoteCalls = 0;
|
||||||
|
let discoveryCalls = 0;
|
||||||
|
const store = {
|
||||||
|
data: { gitea: { baseUrl: 'https://gitea.example' }, workspaceRoots: [], repositoryMappings: {}, preferences: { preferredCloneProtocol: 'ssh' }, favorites: [] },
|
||||||
|
getToken: () => 'token', getDeploymentProfiles: () => [], getDeploymentState: () => null
|
||||||
|
};
|
||||||
|
const instance = new RepositoryService(store, {}, { listRepositories: async () => { remoteCalls += 1; await new Promise((resolve) => setTimeout(resolve, 10)); return [remote]; } });
|
||||||
|
instance.discoverAll = async () => { discoveryCalls += 1; return []; };
|
||||||
|
|
||||||
|
const [first, second] = await Promise.all([instance.refresh(), instance.refresh()]);
|
||||||
|
assert.deepEqual(first, second);
|
||||||
|
await instance.refresh();
|
||||||
|
assert.equal(remoteCalls, 1);
|
||||||
|
assert.equal(discoveryCalls, 1);
|
||||||
|
await instance.refresh({ force: true });
|
||||||
|
assert.equal(remoteCalls, 2);
|
||||||
|
assert.equal(discoveryCalls, 2);
|
||||||
|
});
|
||||||
|
|
||||||
test('decoration reports conflicts, behind branches, errors and remote-only repositories', () => {
|
test('decoration reports conflicts, behind branches, errors and remote-only repositories', () => {
|
||||||
const instance = service();
|
const instance = service();
|
||||||
const conflicted = instance.decorate(remote, { localPath: 'repo', status: { ...status(), counts: { changed: 1, conflicts: 2 }, branch: { ...status().branch, behind: 3 } } }, []);
|
const conflicted = instance.decorate(remote, { localPath: 'repo', status: { ...status(), counts: { changed: 1, conflicts: 2 }, branch: { ...status().branch, behind: 3 } } }, []);
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const { redactSecrets } = redaction;
|
|||||||
|
|
||||||
test('rejects credentials embedded in service URLs', () => {
|
test('rejects credentials embedded in service URLs', () => {
|
||||||
assert.throws(() => normalizeBaseUrl(`https://${['jens', 'secret'].join(':')}@gitea.example.test`), /credentials/i);
|
assert.throws(() => normalizeBaseUrl(`https://${['jens', 'secret'].join(':')}@gitea.example.test`), /credentials/i);
|
||||||
|
assert.throws(() => normalizeBaseUrl('http://gitea.example.test'), /must use HTTPS/i);
|
||||||
|
assert.equal(normalizeBaseUrl('http://127.0.0.1:3000/'), 'http://127.0.0.1:3000');
|
||||||
assert.throws(() => assertHttpUrl(`https://${['user', 'secret'].join(':')}@app.example.test/health`), /credentials/i);
|
assert.throws(() => assertHttpUrl(`https://${['user', 'secret'].join(':')}@app.example.test/health`), /credentials/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -141,4 +141,10 @@ test('legacy container sanitizer applies safe defaults to partial Docker inspect
|
|||||||
'tech.itworx.forgeflow.branch': '', 'net.unraid.docker.webui': '', 'net.unraid.docker.icon': '', 'net.unraid.docker.shell': '', 'net.unraid.docker.managed': '',
|
'tech.itworx.forgeflow.branch': '', 'net.unraid.docker.webui': '', 'net.unraid.docker.icon': '', 'net.unraid.docker.shell': '', 'net.unraid.docker.managed': '',
|
||||||
}, ports: {}, mounts: [], networks: {}, restartPolicy: '',
|
}, ports: {}, mounts: [], networks: {}, restartPolicy: '',
|
||||||
});
|
});
|
||||||
|
assert.equal(sanitizeLegacyContainer({
|
||||||
|
Id: 'no-healthcheck',
|
||||||
|
Name: '/NoHealthcheck',
|
||||||
|
State: { Running: true, Status: 'running' },
|
||||||
|
Config: { Image: 'example/no-healthcheck:latest', Labels: null },
|
||||||
|
}).health, null);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
const ssh2Path = require.resolve("ssh2");
|
||||||
|
const realSsh2 = require("ssh2");
|
||||||
|
|
||||||
|
// SshService resolves ssh2 lazily and after an await, so the replacement has to
|
||||||
|
// stay in place until the whole operation settles.
|
||||||
|
async function withFakeSsh2(Client, operation) {
|
||||||
|
require.cache[ssh2Path] = { id: ssh2Path, filename: ssh2Path, loaded: true, exports: { ...realSsh2, Client } };
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} finally {
|
||||||
|
require.cache[ssh2Path] = { id: ssh2Path, filename: ssh2Path, loaded: true, exports: realSsh2 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { SshService } = require("../src/main/ssh-service.cjs");
|
||||||
|
|
||||||
|
function makeStore(overrides = {}) {
|
||||||
|
const server = {
|
||||||
|
id: "unraid",
|
||||||
|
host: "tower",
|
||||||
|
port: 22,
|
||||||
|
username: "root",
|
||||||
|
authType: "password",
|
||||||
|
basePath: "/mnt/user/appdata",
|
||||||
|
hostFingerprint: "SHA256:trusted",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
server,
|
||||||
|
getServer: () => server,
|
||||||
|
getServerCredentials: () => ({ password: "secret", passphrase: "" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A client that reports what the pool does to it: how often it connected, how
|
||||||
|
// many channels it opened, and whether it was closed.
|
||||||
|
function fakeClientFactory({ execBehaviour = () => ({ ok: true }) } = {}) {
|
||||||
|
const state = { connects: 0, execs: 0, ends: 0, instances: [] };
|
||||||
|
class FakeClient extends EventEmitter {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.ended = false;
|
||||||
|
state.instances.push(this);
|
||||||
|
}
|
||||||
|
connect(options) {
|
||||||
|
state.connects += 1;
|
||||||
|
options.hostVerifier(Buffer.from("host key"));
|
||||||
|
setImmediate(() => this.emit("ready"));
|
||||||
|
}
|
||||||
|
exec(command, callback) {
|
||||||
|
state.execs += 1;
|
||||||
|
const outcome = execBehaviour(state.execs, this);
|
||||||
|
if (outcome.channelError) {
|
||||||
|
setImmediate(() => callback(outcome.channelError));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stream = new EventEmitter();
|
||||||
|
stream.stderr = new EventEmitter();
|
||||||
|
// A channel that closes without an exit status reports null, which is how
|
||||||
|
// a connection lost mid-command surfaces. That is not the same as 0.
|
||||||
|
const closeCode = Object.hasOwn(outcome, "exitCode") ? outcome.exitCode : 0;
|
||||||
|
setImmediate(() => {
|
||||||
|
stream.emit("data", Buffer.from(outcome.stdout ?? "ok"));
|
||||||
|
stream.emit("close", closeCode, null);
|
||||||
|
});
|
||||||
|
callback(null, stream);
|
||||||
|
}
|
||||||
|
end() {
|
||||||
|
if (this.ended) return;
|
||||||
|
this.ended = true;
|
||||||
|
state.ends += 1;
|
||||||
|
setImmediate(() => this.emit("close"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { FakeClient, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
function service(store, options = {}) {
|
||||||
|
return new SshService({ store, diagnostics: null, ...options });
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = withFakeSsh2;
|
||||||
|
|
||||||
|
test("a sequence of commands to one server shares a single connection", async () => {
|
||||||
|
const { FakeClient, state } = fakeClientFactory();
|
||||||
|
const store = makeStore();
|
||||||
|
const ssh = service(store);
|
||||||
|
|
||||||
|
for (let index = 0; index < 5; index += 1) {
|
||||||
|
await run(FakeClient, () => ssh.exec("unraid", `echo ${index}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(state.execs, 5);
|
||||||
|
assert.equal(state.connects, 1, "five commands, one handshake");
|
||||||
|
ssh.closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("concurrent commands share the connection and it survives until the last one finishes", async () => {
|
||||||
|
const { FakeClient, state } = fakeClientFactory();
|
||||||
|
const ssh = service(makeStore());
|
||||||
|
|
||||||
|
await run(FakeClient, () => Promise.all([
|
||||||
|
ssh.exec("unraid", "one"),
|
||||||
|
ssh.exec("unraid", "two"),
|
||||||
|
ssh.exec("unraid", "three"),
|
||||||
|
]));
|
||||||
|
|
||||||
|
assert.equal(state.connects, 1);
|
||||||
|
assert.equal(state.execs, 3);
|
||||||
|
assert.equal(state.ends, 0, "the shared connection is not closed while it is idle in the pool");
|
||||||
|
ssh.closeAll();
|
||||||
|
assert.equal(state.ends, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a connection that died while pooled is replaced and the command runs once", async () => {
|
||||||
|
const { FakeClient, state } = fakeClientFactory({
|
||||||
|
execBehaviour: (call, client) => (call === 2 && !client.reopened
|
||||||
|
? { channelError: Object.assign(new Error("channel open failure"), { code: "ERR_CHANNEL" }) }
|
||||||
|
: { ok: true }),
|
||||||
|
});
|
||||||
|
const ssh = service(makeStore());
|
||||||
|
|
||||||
|
await run(FakeClient, () => ssh.exec("unraid", "first"));
|
||||||
|
const result = await run(FakeClient, () => ssh.exec("unraid", "second"));
|
||||||
|
|
||||||
|
assert.equal(result.stdout, "ok");
|
||||||
|
assert.equal(state.connects, 2, "the stale connection is replaced");
|
||||||
|
assert.equal(state.execs, 3, "the failed attempt never reached the server, so it is retried once");
|
||||||
|
ssh.closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a command that reached the server is never retried, not even on a reused connection", async () => {
|
||||||
|
let deployAttempts = 0;
|
||||||
|
const { FakeClient, state } = fakeClientFactory({
|
||||||
|
execBehaviour: (call) => {
|
||||||
|
if (call === 1) return { ok: true };
|
||||||
|
deployAttempts += 1;
|
||||||
|
return { exitCode: 1, stdout: "docker compose failed" };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const ssh = service(makeStore());
|
||||||
|
|
||||||
|
// The first command establishes the pooled connection, so the deployment below
|
||||||
|
// runs on a reused one - the case where a retry would be tempting.
|
||||||
|
await run(FakeClient, () => ssh.exec("unraid", "true"));
|
||||||
|
await assert.rejects(() => run(FakeClient, () => ssh.exec("unraid", "docker compose up -d")), (error) => {
|
||||||
|
assert.equal(error.code, "SSH_COMMAND_FAILED");
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(deployAttempts, 1, "a deployment command is never repeated by the pool");
|
||||||
|
assert.equal(state.connects, 1);
|
||||||
|
ssh.closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a connection lost while a command was running is not retried either", async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
const { FakeClient, state } = fakeClientFactory({
|
||||||
|
execBehaviour: (call) => {
|
||||||
|
if (call === 1) return { ok: true };
|
||||||
|
attempts += 1;
|
||||||
|
// The stream opened, so the server may already be acting on this command.
|
||||||
|
return { exitCode: null, stdout: "" };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const ssh = service(makeStore());
|
||||||
|
|
||||||
|
await run(FakeClient, () => ssh.exec("unraid", "true"));
|
||||||
|
await assert.rejects(() => run(FakeClient, () => ssh.exec("unraid", "docker compose up -d")), (error) => {
|
||||||
|
assert.equal(error.code, "SSH_COMMAND_FAILED");
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(attempts, 1);
|
||||||
|
assert.equal(state.connects, 1);
|
||||||
|
ssh.closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a first connection that cannot be established is reported without a retry", async () => {
|
||||||
|
class RefusingClient extends EventEmitter {
|
||||||
|
connect() {
|
||||||
|
setImmediate(() => this.emit("error", Object.assign(new Error("ECONNREFUSED"), { code: "ECONNREFUSED" })));
|
||||||
|
}
|
||||||
|
end() {}
|
||||||
|
}
|
||||||
|
const ssh = service(makeStore());
|
||||||
|
|
||||||
|
await assert.rejects(() => run(RefusingClient, () => ssh.exec("unraid", "true")), /SSH connection failed/);
|
||||||
|
assert.equal(ssh.sessions.size, 0, "a failed connection is not pooled");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a trust-on-first-use connection is never pooled or reused", async () => {
|
||||||
|
const { FakeClient, state } = fakeClientFactory();
|
||||||
|
const ssh = service(makeStore({ hostFingerprint: "" }));
|
||||||
|
|
||||||
|
await run(FakeClient, () => ssh.test("unraid", { trustOnFirstUse: true }));
|
||||||
|
await run(FakeClient, () => ssh.test("unraid", { trustOnFirstUse: true }));
|
||||||
|
|
||||||
|
assert.equal(state.connects, 2, "an unverified connection is opened fresh every time");
|
||||||
|
assert.equal(ssh.sessions.size, 0);
|
||||||
|
assert.equal(state.ends, 2, "and closed immediately afterwards");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changing the server identity or credentials invalidates the pooled connection", async () => {
|
||||||
|
const { FakeClient, state } = fakeClientFactory();
|
||||||
|
const store = makeStore();
|
||||||
|
const ssh = service(store);
|
||||||
|
|
||||||
|
await run(FakeClient, () => ssh.exec("unraid", "before"));
|
||||||
|
assert.equal(state.connects, 1);
|
||||||
|
|
||||||
|
store.server.hostFingerprint = "SHA256:rotated";
|
||||||
|
await run(FakeClient, () => ssh.exec("unraid", "after"));
|
||||||
|
assert.equal(state.connects, 2, "the previous connection is not reused across an identity change");
|
||||||
|
ssh.closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an idle connection is closed after its lifetime and reopened on demand", async () => {
|
||||||
|
const { FakeClient, state } = fakeClientFactory();
|
||||||
|
const ssh = service(makeStore(), { idleConnectionMs: 40 });
|
||||||
|
|
||||||
|
await run(FakeClient, () => ssh.exec("unraid", "one"));
|
||||||
|
assert.equal(state.ends, 0);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||||
|
assert.equal(state.ends, 1, "the idle connection is released");
|
||||||
|
assert.equal(ssh.sessions.size, 0);
|
||||||
|
|
||||||
|
await run(FakeClient, () => ssh.exec("unraid", "two"));
|
||||||
|
assert.equal(state.connects, 2);
|
||||||
|
ssh.closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an error on an idle pooled connection is absorbed instead of terminating the process", async () => {
|
||||||
|
const { FakeClient, state } = fakeClientFactory();
|
||||||
|
const ssh = service(makeStore());
|
||||||
|
|
||||||
|
await run(FakeClient, () => ssh.exec("unraid", "one"));
|
||||||
|
const pooled = state.instances.at(-1);
|
||||||
|
|
||||||
|
pooled.emit("error", new Error("read ECONNRESET"));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
|
||||||
|
assert.equal(ssh.sessions.size, 0, "the dead connection leaves the pool");
|
||||||
|
await run(FakeClient, () => ssh.exec("unraid", "two"));
|
||||||
|
assert.equal(state.connects, 2);
|
||||||
|
ssh.closeAll();
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
// SshService resolves ssh2 lazily, so replacing the cached module is enough to
|
||||||
|
// drive a real connection lifecycle without a server.
|
||||||
|
const ssh2Path = require.resolve("ssh2");
|
||||||
|
const realSsh2 = require("ssh2");
|
||||||
|
|
||||||
|
function withFakeSsh2(Client, run) {
|
||||||
|
require.cache[ssh2Path] = { id: ssh2Path, filename: ssh2Path, loaded: true, exports: { ...realSsh2, Client } };
|
||||||
|
try {
|
||||||
|
return run();
|
||||||
|
} finally {
|
||||||
|
require.cache[ssh2Path] = { id: ssh2Path, filename: ssh2Path, loaded: true, exports: realSsh2 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { SshService } = require("../src/main/ssh-service.cjs");
|
||||||
|
|
||||||
|
function store(server = {}) {
|
||||||
|
return {
|
||||||
|
getServer: () => ({ id: "unraid", host: "tower", port: 22, username: "root", authType: "password", basePath: "/mnt/user/appdata", hostFingerprint: "SHA256:trusted", ...server }),
|
||||||
|
getServerCredentials: () => ({ password: "secret", passphrase: "" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a connection that fails twice rejects once and never terminates the process", async () => {
|
||||||
|
class DoubleFailingClient extends EventEmitter {
|
||||||
|
connect() {
|
||||||
|
setImmediate(() => this.emit("error", Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" })));
|
||||||
|
}
|
||||||
|
end() {
|
||||||
|
// The socket resets shortly after teardown. An unhandled 'error' event on
|
||||||
|
// an EventEmitter takes the whole main process down.
|
||||||
|
setImmediate(() => this.emit("error", new Error("read ECONNRESET")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = withFakeSsh2(DoubleFailingClient, () => new SshService({ store: store(), diagnostics: null }));
|
||||||
|
await assert.rejects(
|
||||||
|
() => withFakeSsh2(DoubleFailingClient, () => service.exec("unraid", "true")),
|
||||||
|
(error) => {
|
||||||
|
assert.equal(error.code, "ECONNREFUSED");
|
||||||
|
assert.match(error.message, /SSH connection failed/);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Give the delayed teardown error time to land while the test is still running.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a host key that does not match the trusted fingerprint is reported as an identity change", async () => {
|
||||||
|
class MismatchingClient extends EventEmitter {
|
||||||
|
connect(options) {
|
||||||
|
options.hostVerifier(Buffer.from("a different host key"));
|
||||||
|
setImmediate(() => this.emit("error", new Error("handshake failed")));
|
||||||
|
}
|
||||||
|
end() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = withFakeSsh2(MismatchingClient, () => new SshService({ store: store(), diagnostics: null }));
|
||||||
|
await assert.rejects(
|
||||||
|
() => withFakeSsh2(MismatchingClient, () => service.exec("unraid", "true")),
|
||||||
|
(error) => {
|
||||||
|
assert.equal(error.code, "SSH_HOST_KEY_MISMATCH");
|
||||||
|
assert.match(error.message, /SSH host identity changed/);
|
||||||
|
assert.equal(error.expectedFingerprint, "SHA256:trusted");
|
||||||
|
assert.ok(error.observedFingerprint.startsWith("SHA256:"));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("running a command requires a trusted host fingerprint", async () => {
|
||||||
|
const service = new SshService({ store: store({ hostFingerprint: "" }), diagnostics: null });
|
||||||
|
await assert.rejects(() => service.exec("unraid", "true"), (error) => {
|
||||||
|
assert.equal(error.code, "SSH_HOST_NOT_TRUSTED");
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
await assert.rejects(() => service.uploadBuffer("unraid", "/mnt/user/appdata/x", "data"), (error) => {
|
||||||
|
assert.equal(error.code, "SSH_HOST_NOT_TRUSTED");
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a remote upload path may not escape into an arbitrary location", () => {
|
||||||
|
const service = new SshService({ store: store(), diagnostics: null });
|
||||||
|
assert.equal(service.ensureUploadTarget("/mnt/user/appdata/app/file.tar"), "/mnt/user/appdata/app/file.tar");
|
||||||
|
assert.equal(service.ensureUploadTarget("\\mnt\\user\\appdata\\app"), "/mnt/user/appdata/app");
|
||||||
|
for (const value of ["relative/path", "/mnt/../etc/passwd", "/mnt/user/../../etc", "", null]) {
|
||||||
|
assert.throws(() => service.ensureUploadTarget(value), /absolute safe Unix path/);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -73,6 +73,35 @@ test("connection options enforce host identity and support password credentials"
|
|||||||
const firstUse = await service.connectionOptions({ id: 'one', host: 'server', username: 'root', authType: 'password' }, { trustOnFirstUse: true });
|
const firstUse = await service.connectionOptions({ id: 'one', host: 'server', username: 'root', authType: 'password' }, { trustOnFirstUse: true });
|
||||||
assert.equal(firstUse.options.port, 22);
|
assert.equal(firstUse.options.port, 22);
|
||||||
assert.equal(firstUse.options.hostVerifier(key), true);
|
assert.equal(firstUse.options.hostVerifier(key), true);
|
||||||
|
const previewBound = await service.connectionOptions(
|
||||||
|
{ id: 'one', host: 'server', username: 'root', authType: 'password' },
|
||||||
|
{ expectedFingerprint: fingerprint },
|
||||||
|
);
|
||||||
|
assert.equal(previewBound.options.hostVerifier(key), true);
|
||||||
|
assert.equal(previewBound.options.hostVerifier(Buffer.from('changed')), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SSH host fingerprint preview rejects the handshake before credentials are requested", async () => {
|
||||||
|
const key = Buffer.from("untrusted-server-key");
|
||||||
|
let connectedOptions = null;
|
||||||
|
class ProbeClient extends EventEmitter {
|
||||||
|
connect(options) {
|
||||||
|
connectedOptions = options;
|
||||||
|
assert.equal(options.hostVerifier(key), false);
|
||||||
|
queueMicrotask(() => this.emit("error", Object.assign(new Error("host rejected"), { code: "HOST_VERIFIER_REJECTED" })));
|
||||||
|
}
|
||||||
|
end() {}
|
||||||
|
}
|
||||||
|
const store = {
|
||||||
|
getServer: () => ({ id: "server", name: "Unraid", host: "192.0.2.10", port: 2222, username: "root", authType: "password" }),
|
||||||
|
getServerCredentials: () => { throw new Error("credentials must not be read during a fingerprint preview"); },
|
||||||
|
};
|
||||||
|
const service = new SshService({ store, diagnostics: null, clientFactory: () => ProbeClient });
|
||||||
|
const result = await service.probeHostFingerprint("server");
|
||||||
|
assert.equal(result.fingerprint, fingerprintKey(key));
|
||||||
|
assert.deepEqual(result.server, { id: "server", name: "Unraid", host: "192.0.2.10", port: 2222 });
|
||||||
|
assert.equal("password" in connectedOptions, false);
|
||||||
|
assert.equal("privateKey" in connectedOptions, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("connection options report unreadable private keys without leaking credentials", async () => {
|
test("connection options report unreadable private keys without leaking credentials", async () => {
|
||||||
|
|||||||
@@ -40,6 +40,36 @@ test("server release directories resolve to the stable deployment root", () => {
|
|||||||
assert.equal(deploymentRootCandidate("ludarium/source/deploy"), "ludarium/source/deploy");
|
assert.equal(deploymentRootCandidate("ludarium/source/deploy"), "ludarium/source/deploy");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("inventory refresh preserves a repository deployment root above its Compose working directory", () => {
|
||||||
|
const service = new UnraidDeploymentService({});
|
||||||
|
const existing = {
|
||||||
|
id: "profile", provider: "ssh-unraid", serverId: "server", environment: "production",
|
||||||
|
branch: "main", deploymentMode: "server-git", remoteFolder: "App/source",
|
||||||
|
composeWorkingDir: "/mnt/user/appdata/App/source/ops", composeProject: "app",
|
||||||
|
composeFiles: ["ops/compose.yml"], composeServices: ["web"], containerName: "app-web",
|
||||||
|
workloadIdentity: { linkSource: "automatic-compose" }, preservePaths: [".env"],
|
||||||
|
};
|
||||||
|
const workload = {
|
||||||
|
workloadId: "workload", kind: "compose-project", displayName: "app",
|
||||||
|
remoteFolderCandidate: "App/source/ops", selector: { kind: "compose-project", composeProject: "app" },
|
||||||
|
compose: {
|
||||||
|
project: "app", workingDir: "/mnt/user/appdata/App/source/ops",
|
||||||
|
configFiles: ["/mnt/user/appdata/App/source/ops/compose.yml"], services: ["web"],
|
||||||
|
},
|
||||||
|
containers: [{ name: "app-web", running: true, service: "web", mounts: [], ports: [] }],
|
||||||
|
metadata: {}, dockerMan: null,
|
||||||
|
};
|
||||||
|
const refreshed = service.refreshedProfileFromWorkload(
|
||||||
|
{ fullName: "Owner/App", name: "App", defaultBranch: "main", sshUrl: "git@gitea.test:Owner/App.git" },
|
||||||
|
{ id: "server", name: "Server", basePath: "/mnt/user/appdata" },
|
||||||
|
workload,
|
||||||
|
existing,
|
||||||
|
);
|
||||||
|
assert.equal(refreshed.remoteFolder, "App/source");
|
||||||
|
assert.equal(refreshed.composeWorkingDir, "/mnt/user/appdata/App/source/ops");
|
||||||
|
assert.deepEqual(refreshed.composeFiles, ["ops/compose.yml"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("server inspection key-value payload is decoded safely", () => {
|
test("server inspection key-value payload is decoded safely", () => {
|
||||||
const b64 = (value) => Buffer.from(value).toString("base64");
|
const b64 = (value) => Buffer.from(value).toString("base64");
|
||||||
const parsed = parseInspection(
|
const parsed = parseInspection(
|
||||||
@@ -86,6 +116,36 @@ test("server pull provisions a pinned repository-scoped key and records access m
|
|||||||
assert.equal(result.remoteSha, "a".repeat(40));
|
assert.equal(result.remoteSha, "a".repeat(40));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("server pull prefers the linked checkout origin over stale detected SSH endpoints", () => {
|
||||||
|
const service = new UnraidDeploymentService({});
|
||||||
|
const repository = {
|
||||||
|
fullName: "Jens/Portfolio",
|
||||||
|
localStatus: { remoteUrl: "git@gitea.itworx.tech:Jens/Portfolio.git" },
|
||||||
|
sshUrl: "ssh://git@192.168.10.150:222/Jens/Portfolio.git",
|
||||||
|
preferredCloneUrl: "ssh://git@192.168.10.150:222/Jens/Portfolio.git",
|
||||||
|
};
|
||||||
|
const profile = { cloneUrl: "ssh://git@192.168.10.150:222/Jens/Portfolio.git" };
|
||||||
|
|
||||||
|
assert.equal(service.serverGitRemote(repository, profile), "git@gitea.itworx.tech:Jens/Portfolio.git");
|
||||||
|
assert.deepEqual(service.serverGitHost(repository, profile), { host: "gitea.itworx.tech", port: 22 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("write-access inspection parses the remote permission report through the access module", async () => {
|
||||||
|
const encoded = (value) => Buffer.from(value).toString("base64");
|
||||||
|
const profile = { id: "profile", provider: "ssh-unraid", serverId: "server", remoteFolder: "App", composeFiles: ["compose.yml"] };
|
||||||
|
const service = new UnraidDeploymentService({
|
||||||
|
store: {
|
||||||
|
getDeploymentProfile: () => profile,
|
||||||
|
getServer: () => ({ id: "server", basePath: "/mnt/user/appdata" }),
|
||||||
|
},
|
||||||
|
ssh: { exec: async () => ({ stdout: `__FORGEFLOW_PERMISSIONS__\nI\t${encoded("deploy")}\t1000\t1000\t${encoded("users")}\tfalse\tfalse\nP\t${encoded("project-root")}\t${encoded("Project folder")}\t${encoded("/mnt/user/appdata/App")}\tdirectory\ttrue\ttrue\ttrue\ttrue\ttrue\ttrue\t${encoded("deploy")}\t${encoded("users")}\t2775\t${encoded("/mnt/user/appdata/App")}\t${encoded("Read/write probe passed.")}\n` }) },
|
||||||
|
});
|
||||||
|
const report = await service.inspectWriteAccess({ repository: { fullName: "Owner/App" }, profileId: "profile" });
|
||||||
|
assert.equal(report.ready, true);
|
||||||
|
assert.equal(report.identity.user, "deploy");
|
||||||
|
assert.equal(report.targets[0].effectiveWritable, true);
|
||||||
|
});
|
||||||
|
|
||||||
test("server pull verification proves a repository-scoped read-only key and exact commit parity", async () => {
|
test("server pull verification proves a repository-scoped read-only key and exact commit parity", async () => {
|
||||||
const sha = "c".repeat(40);
|
const sha = "c".repeat(40);
|
||||||
const profile = {
|
const profile = {
|
||||||
@@ -112,6 +172,33 @@ test("server pull verification proves a repository-scoped read-only key and exac
|
|||||||
assert.equal(report.checks.find((check) => check.id === "deploy-key-scope").status, "pass");
|
assert.equal(report.checks.find((check) => check.id === "deploy-key-scope").status, "pass");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("server pull remains deploy-ready when only live runtime evidence is incomplete", async () => {
|
||||||
|
const sha = "c".repeat(40);
|
||||||
|
const profile = {
|
||||||
|
id: "profile-runtime-incomplete", provider: "ssh-unraid", serverId: "unraid", remoteFolder: "portfolio",
|
||||||
|
environment: "production", branch: "main", deploymentMode: "server-git", composeFiles: ["compose.yml"],
|
||||||
|
serverGitAccess: { deployKeyId: 17, keyFingerprint: "SHA256:key", hostFingerprint: "SHA256:host" },
|
||||||
|
};
|
||||||
|
const service = new UnraidDeploymentService({
|
||||||
|
store: {
|
||||||
|
getDeploymentProfile: () => profile,
|
||||||
|
getServer: () => ({ id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata" }),
|
||||||
|
getDeploymentState: () => ({ containerRunning: true, healthy: null }),
|
||||||
|
},
|
||||||
|
ssh: { exec: async () => ({ stdout: `__FORGEFLOW_SERVER_GIT_PROBE__\nremoteSha=${sha}\nkeyFingerprint=SHA256:key\nhostFingerprint=SHA256:host\n` }) },
|
||||||
|
gitea: {
|
||||||
|
getBranch: async () => ({ commit: { id: sha } }),
|
||||||
|
listDeployKeys: async () => [{ id: 17, read_only: true }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
service.inspect = async () => ({ exists: true, composeFiles: ["compose.yml"], head: null });
|
||||||
|
const report = await service.verifyServerGitProfile({ repository: { fullName: "Jens/Portfolio", sshUrl: "git@gitea.test:Jens/Portfolio.git" }, profileId: profile.id });
|
||||||
|
assert.equal(report.deployReady, true);
|
||||||
|
assert.equal(report.ready, true);
|
||||||
|
assert.equal(report.readiness, "Deploy-ready; runtime verification incomplete");
|
||||||
|
assert.deepEqual(report.deploymentBlockers, []);
|
||||||
|
});
|
||||||
|
|
||||||
test("server pull verification blocks a writable Gitea deploy key", async () => {
|
test("server pull verification blocks a writable Gitea deploy key", async () => {
|
||||||
const sha = "d".repeat(40);
|
const sha = "d".repeat(40);
|
||||||
const profile = {
|
const profile = {
|
||||||
@@ -168,7 +255,7 @@ test("server workload inventory links running containers to exact Gitea checkout
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("server discovery is read-only and explicit reconciliation adopts a verified deployment", async () => {
|
test("low-level inventory scan is read-only and user discovery auto-links exact provenance", async () => {
|
||||||
const b64 = (value) => Buffer.from(value).toString("base64");
|
const b64 = (value) => Buffer.from(value).toString("base64");
|
||||||
const sha = "b".repeat(40);
|
const sha = "b".repeat(40);
|
||||||
const container = {
|
const container = {
|
||||||
@@ -218,20 +305,99 @@ test("server discovery is read-only and explicit reconciliation adopts a verifie
|
|||||||
sshUrl: "git@gitea.itworx.tech:Jens/Portfolio.git",
|
sshUrl: "git@gitea.itworx.tech:Jens/Portfolio.git",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const discovery = await service.discoverServerWorkloads("unraid", repositories);
|
const readOnlyDiscovery = await service.scanServerInventory("unraid", repositories);
|
||||||
assert.equal(discovery.adopted, 0);
|
assert.equal(readOnlyDiscovery.adopted, 0);
|
||||||
assert.equal(discovery.verified, 0);
|
assert.equal(readOnlyDiscovery.verified, 0);
|
||||||
assert.equal(profiles.length, 0);
|
assert.equal(profiles.length, 0);
|
||||||
assert.equal(states.size, 0);
|
assert.equal(states.size, 0);
|
||||||
|
|
||||||
const preview = await service.planServerInventoryReconciliation("unraid", repositories, { autoLink: true });
|
const discovery = await service.discoverServerWorkloads("unraid", repositories);
|
||||||
assert.equal(preview.plan.summary.additions, 1);
|
assert.equal(discovery.adopted, 1);
|
||||||
const result = await service.reconcileServerInventory("unraid", repositories, { autoLink: true, expectedPlanId: preview.plan.id });
|
assert.equal(discovery.verified, 1);
|
||||||
assert.equal(result.adopted, 1);
|
|
||||||
assert.equal(result.verified, 1);
|
|
||||||
assert.equal(profiles[0].containerName, "Portfolio");
|
assert.equal(profiles[0].containerName, "Portfolio");
|
||||||
assert.equal(profiles[0].adoptedFromServer, true);
|
assert.equal(profiles[0].adoptedFromServer, true);
|
||||||
assert.equal(states.get(profiles[0].id).matchesGitea, true);
|
assert.equal(states.get(profiles[0].id).matchesGitea, true);
|
||||||
|
assert.deepEqual(discovery.refreshedProfileIds, [profiles[0].id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a stale deployment link cannot block adoption of its running replacement", async () => {
|
||||||
|
const repository = {
|
||||||
|
fullName: "Jens/DevRunBook",
|
||||||
|
name: "DevRunBook",
|
||||||
|
defaultBranch: "main",
|
||||||
|
sshUrl: "git@gitea.test:Jens/DevRunBook.git",
|
||||||
|
};
|
||||||
|
const stale = {
|
||||||
|
workloadId: "old-devrunbook",
|
||||||
|
status: "stale",
|
||||||
|
classification: { type: "stale-link" },
|
||||||
|
runtime: { running: false, health: "missing" },
|
||||||
|
link: { profileId: "old-profile", repositoryFullName: repository.fullName },
|
||||||
|
candidates: [{ repositoryFullName: repository.fullName, score: 100, exact: true }],
|
||||||
|
};
|
||||||
|
const replacement = {
|
||||||
|
workloadId: "devrunbook-runtime",
|
||||||
|
serverId: "unraid",
|
||||||
|
displayName: "DevRunBook",
|
||||||
|
status: "suggested",
|
||||||
|
classification: { type: "active-application" },
|
||||||
|
runtime: { running: true, health: "healthy" },
|
||||||
|
link: null,
|
||||||
|
candidates: [{
|
||||||
|
repositoryFullName: repository.fullName,
|
||||||
|
score: 85,
|
||||||
|
exact: false,
|
||||||
|
identityExact: true,
|
||||||
|
}],
|
||||||
|
compose: {
|
||||||
|
project: "devrunbook",
|
||||||
|
workingDir: "/mnt/user/appdata/DevRunBook",
|
||||||
|
configFiles: ["/mnt/user/appdata/DevRunBook/compose.yml"],
|
||||||
|
services: ["app"],
|
||||||
|
},
|
||||||
|
containers: [{ name: "DevRunBook", running: true, mounts: [], ports: [] }],
|
||||||
|
metadata: { branch: "main" },
|
||||||
|
remoteFolderCandidate: "DevRunBook",
|
||||||
|
};
|
||||||
|
const saved = [];
|
||||||
|
const service = new UnraidDeploymentService({
|
||||||
|
store: {
|
||||||
|
data: {
|
||||||
|
deploymentProfiles: {
|
||||||
|
[repository.fullName]: [{
|
||||||
|
id: "old-profile",
|
||||||
|
provider: "ssh-unraid",
|
||||||
|
serverId: "unraid",
|
||||||
|
workloadIdentity: { workloadId: stale.workloadId, linkSource: "automatic" },
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createRecoverySnapshot: async () => ({}),
|
||||||
|
saveDeploymentProfile: async (_fullName, profile) => {
|
||||||
|
saved.push(profile);
|
||||||
|
return profile;
|
||||||
|
},
|
||||||
|
saveDeploymentState: async () => ({}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
service.collectServerInventory = async () => ({
|
||||||
|
server: { id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata" },
|
||||||
|
inventory: { capabilities: {}, warnings: [] },
|
||||||
|
workloads: [stale, replacement],
|
||||||
|
});
|
||||||
|
|
||||||
|
const plan = service.reconciliationPlan(
|
||||||
|
{ id: "unraid" },
|
||||||
|
[stale, replacement],
|
||||||
|
[repository],
|
||||||
|
{ autoLink: true },
|
||||||
|
);
|
||||||
|
assert.deepEqual(plan.additions.map((item) => item.workloadId), [replacement.workloadId]);
|
||||||
|
|
||||||
|
const result = await service.scanServerInventory("unraid", [repository], { autoLink: true });
|
||||||
|
assert.equal(result.adopted, 1);
|
||||||
|
assert.equal(replacement.link.repositoryFullName, repository.fullName);
|
||||||
|
assert.equal(saved.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -300,6 +466,18 @@ test("server inventory includes stopped DockerMan containers without Git and kee
|
|||||||
assert.match(workloads[0].candidates[0].reasons.join(" "), /manual confirmation/i);
|
assert.match(workloads[0].candidates[0].reasons.join(" "), /manual confirmation/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("server inventory batches Docker inspect and retains a disappearing-container fallback", async () => {
|
||||||
|
const source = await unraidSource();
|
||||||
|
assert.match(source, /docker inspect "\\\$\{container_ids\[@\]\}"/);
|
||||||
|
assert.match(source, /for container_id in "\\\$\{container_ids\[@\]\}"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("server inventory avoids a second Compose process for static image definitions", async () => {
|
||||||
|
const source = await unraidSource();
|
||||||
|
assert.match(source, /has_override=false/);
|
||||||
|
assert.match(source, /\[ -z "\$images" \].*config --images/);
|
||||||
|
});
|
||||||
|
|
||||||
test("server inventory groups multi-service Compose projects and preserves their identity", () => {
|
test("server inventory groups multi-service Compose projects and preserves their identity", () => {
|
||||||
const baseContainer = {
|
const baseContainer = {
|
||||||
image: "example/app:latest",
|
image: "example/app:latest",
|
||||||
@@ -392,6 +570,40 @@ test("manual workload linking does not claim Gitea parity for unrelated provenan
|
|||||||
assert.equal(savedState.giteaSha, null);
|
assert.equal(savedState.giteaSha, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("inventory provenance alone never claims Gitea commit parity", async () => {
|
||||||
|
let savedState = null;
|
||||||
|
const service = new UnraidDeploymentService({
|
||||||
|
store: { getDeploymentState: () => ({}), saveDeploymentState: async (_id, state) => { savedState = state; return state; } },
|
||||||
|
});
|
||||||
|
await service.saveWorkloadState(
|
||||||
|
{ id: "profile", remoteFolder: "app", cloneUrl: "git@gitea.test:Owner/App.git" },
|
||||||
|
{
|
||||||
|
workloadId: "workload", observedAt: new Date().toISOString(),
|
||||||
|
metadata: { sourceRepository: "git@gitea.test:Owner/App.git", liveRevision: "a".repeat(40) },
|
||||||
|
runtime: { running: true, health: "healthy" }, containers: [{ name: "app", running: true, health: "healthy" }], compose: {},
|
||||||
|
},
|
||||||
|
{ basePath: "/mnt/user/appdata" },
|
||||||
|
);
|
||||||
|
assert.equal(savedState.matchesGitea, false);
|
||||||
|
assert.equal(savedState.giteaSha, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a stopped workload cannot become healthy through a reused healthcheck port", async () => {
|
||||||
|
let savedState = null;
|
||||||
|
const service = new UnraidDeploymentService({
|
||||||
|
store: { getDeploymentState: () => ({}), saveDeploymentState: async (_id, state) => { savedState = state; return state; } },
|
||||||
|
});
|
||||||
|
await service.saveWorkloadState(
|
||||||
|
{ id: "profile", remoteFolder: "app", healthcheckUrl: "http://server.test/health" },
|
||||||
|
{ workloadId: "workload", metadata: {}, runtime: { running: false, health: "unverified" }, containers: [{ name: "app", running: false }], compose: {} },
|
||||||
|
{ basePath: "/mnt/user/appdata" },
|
||||||
|
{ health: { configured: true, healthy: true, status: 200 } },
|
||||||
|
);
|
||||||
|
assert.equal(savedState.containerRunning, false);
|
||||||
|
assert.equal(savedState.healthy, false);
|
||||||
|
assert.equal(savedState.runtimeVerification, "stopped");
|
||||||
|
});
|
||||||
|
|
||||||
test("Docker ignore checks identify exact runtime and Git context exclusions", () => {
|
test("Docker ignore checks identify exact runtime and Git context exclusions", () => {
|
||||||
const rules = "# build context\n.git\ndata/\nlogs/**\n!logs/keep.txt\n";
|
const rules = "# build context\n.git\ndata/\nlogs/**\n!logs/keep.txt\n";
|
||||||
assert.equal(dockerIgnoreHasPath(rules, ".git"), true);
|
assert.equal(dockerIgnoreHasPath(rules, ".git"), true);
|
||||||
@@ -1073,6 +1285,8 @@ test("inventory scan uses only configured roots and reports partial find failure
|
|||||||
assert.match(script, /-name 'scratch'/);
|
assert.match(script, /-name 'scratch'/);
|
||||||
assert.match(script, /Inventory scan partially failed/);
|
assert.match(script, /Inventory scan partially failed/);
|
||||||
assert.match(script, /2>"\$scan_error" \|\| true/);
|
assert.match(script, /2>"\$scan_error" \|\| true/);
|
||||||
|
assert.match(script, /docker inspect "\$container_id"/);
|
||||||
|
assert.doesNotMatch(script, /docker inspect --format/);
|
||||||
assert.doesNotMatch(script, /add_scan_root \/mnt\/cache\/appdata/);
|
assert.doesNotMatch(script, /add_scan_root \/mnt\/cache\/appdata/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1296,7 +1510,10 @@ test("server-pull preflight resolves Gitea SHA and reports every degraded capabi
|
|||||||
},
|
},
|
||||||
git: { status: async () => ({ root: "/local", clean: false, counts: { changed: 3 }, branch: { head: "main" } }) },
|
git: { status: async () => ({ root: "/local", clean: false, counts: { changed: 3 }, branch: { head: "main" } }) },
|
||||||
ssh: { test: async () => ({ capabilities: { docker: true, dockerReady: false, compose: false, git: false, tar: true, checksum: false, baseWritable: false } }) },
|
ssh: { test: async () => ({ capabilities: { docker: true, dockerReady: false, compose: false, git: false, tar: true, checksum: false, baseWritable: false } }) },
|
||||||
gitea: { getBranch: async () => ({ commit: { sha } }) },
|
gitea: {
|
||||||
|
getBranch: async () => ({ commit: { sha } }),
|
||||||
|
repositoryFileExists: async ({ filePath, ref }) => ref === sha && filePath === "compose.yml",
|
||||||
|
},
|
||||||
sourcePath: process.cwd()
|
sourcePath: process.cwd()
|
||||||
});
|
});
|
||||||
service.probeServerGitAccess = async () => ({ ready: false, error: "deploy key missing", remoteSha: null });
|
service.probeServerGitAccess = async () => ({ ready: false, error: "deploy key missing", remoteSha: null });
|
||||||
@@ -1310,6 +1527,8 @@ test("server-pull preflight resolves Gitea SHA and reports every degraded capabi
|
|||||||
assert.equal(result.sha, sha);
|
assert.equal(result.sha, sha);
|
||||||
assert.equal(byId("local-branch").status, "warning");
|
assert.equal(byId("local-branch").status, "warning");
|
||||||
assert.equal(byId("local-clean").status, "warning");
|
assert.equal(byId("local-clean").status, "warning");
|
||||||
|
assert.equal(byId("gitea-deployment-files").status, "fail");
|
||||||
|
assert.match(byId("gitea-deployment-files").detail, /compose\.prod\.yml/);
|
||||||
assert.equal(byId("docker-runtime").status, "fail");
|
assert.equal(byId("docker-runtime").status, "fail");
|
||||||
assert.match(byId("docker-runtime").detail, /cannot query/i);
|
assert.match(byId("docker-runtime").detail, /cannot query/i);
|
||||||
assert.equal(byId("compose-command").status, "fail");
|
assert.equal(byId("compose-command").status, "fail");
|
||||||
|
|||||||
+149
-25
@@ -5,7 +5,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash, generateKeyPairSync, sign } from "node:crypto";
|
||||||
import { execFile, spawn } from "node:child_process";
|
import { execFile, spawn } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
@@ -14,10 +14,36 @@ const require = createRequire(import.meta.url);
|
|||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const {
|
const {
|
||||||
UpdateService,
|
UpdateService,
|
||||||
|
verifyReleaseManifest,
|
||||||
waitForUpdaterStarted,
|
waitForUpdaterStarted,
|
||||||
windowsUpdaterSpawnOptions,
|
windowsUpdaterSpawnOptions,
|
||||||
} = require("../src/main/update-service.cjs");
|
} = require("../src/main/update-service.cjs");
|
||||||
|
|
||||||
|
function createSignedReleaseFixture({
|
||||||
|
version,
|
||||||
|
remoteSha,
|
||||||
|
assetName,
|
||||||
|
binary,
|
||||||
|
}) {
|
||||||
|
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
||||||
|
const sha256 = createHash("sha256").update(binary).digest("hex");
|
||||||
|
const manifest = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
product: "ForgeFlow",
|
||||||
|
version,
|
||||||
|
tag: `v${version}`,
|
||||||
|
commit: remoteSha,
|
||||||
|
buildId: "test-build",
|
||||||
|
signature: { algorithm: "Ed25519", keyId: "SHA256:test" },
|
||||||
|
artifacts: [{ name: assetName, bytes: binary.length, sha256 }],
|
||||||
|
};
|
||||||
|
const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
|
const signatureBytes = Buffer.from(
|
||||||
|
`${sign(null, manifestBytes, privateKey).toString("base64")}\n`,
|
||||||
|
);
|
||||||
|
return { publicKey, sha256, manifestBytes, signatureBytes };
|
||||||
|
}
|
||||||
|
|
||||||
test("Windows updater uses a hidden non-detached PowerShell child", () => {
|
test("Windows updater uses a hidden non-detached PowerShell child", () => {
|
||||||
assert.deepEqual(windowsUpdaterSpawnOptions("C:\\updates"), {
|
assert.deepEqual(windowsUpdaterSpawnOptions("C:\\updates"), {
|
||||||
detached: false,
|
detached: false,
|
||||||
@@ -324,6 +350,8 @@ test("packaged updater passes Gitea browser download URLs to the asset downloade
|
|||||||
);
|
);
|
||||||
assert.match(source, /downloadUrl: asset\.browser_download_url/);
|
assert.match(source, /downloadUrl: asset\.browser_download_url/);
|
||||||
assert.match(source, /downloadUrl: checksumAsset\.browser_download_url/);
|
assert.match(source, /downloadUrl: checksumAsset\.browser_download_url/);
|
||||||
|
assert.match(source, /downloadUrl: manifestAsset\.browser_download_url/);
|
||||||
|
assert.match(source, /downloadUrl: signatureAsset\.browser_download_url/);
|
||||||
assert.match(source, /RELEASE_ASSET_METADATA_RECEIVED/);
|
assert.match(source, /RELEASE_ASSET_METADATA_RECEIVED/);
|
||||||
});
|
});
|
||||||
test("PowerShell helper replaces an existing launching status with a Windows-safe file API", async () => {
|
test("PowerShell helper replaces an existing launching status with a Windows-safe file API", async () => {
|
||||||
@@ -410,6 +438,29 @@ test("binary helper confirms startup through the production Node spawn options",
|
|||||||
}
|
}
|
||||||
await rm(temp, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
await rm(temp, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("binary helper verifies SHA-256 without PowerShell module autoloading", { skip: process.platform !== "win32" }, async () => {
|
||||||
|
const temp = await mkdtemp(path.join(os.tmpdir(), "forgeflow-binary-dotnet-sha-"));
|
||||||
|
const binaryPath = path.join(temp, "update.exe");
|
||||||
|
const currentPath = path.join(temp, "current.exe");
|
||||||
|
const statusPath = path.join(temp, "status.json");
|
||||||
|
const logPath = path.join(temp, "helper.log");
|
||||||
|
const bytes = Buffer.from("verified update bytes");
|
||||||
|
await writeFile(binaryPath, bytes);
|
||||||
|
await writeFile(currentPath, "current");
|
||||||
|
const expectedSha256 = createHash("sha256").update(bytes).digest("hex");
|
||||||
|
const powershell = path.join(process.env.SystemRoot || process.env.WINDIR, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
||||||
|
const scriptPath = fileURLToPath(new URL("../scripts/apply-binary-update.ps1", import.meta.url));
|
||||||
|
const { stderr } = await execFileAsync(powershell, [
|
||||||
|
"-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", scriptPath,
|
||||||
|
"-BinaryPath", binaryPath, "-ExpectedSha256", expectedSha256, "-ExpectedVersion", "9.9.9",
|
||||||
|
"-CurrentExecutable", currentPath, "-Portable", "False", "-ParentPid", String(process.pid),
|
||||||
|
"-LogPath", logPath, "-StatusPath", statusPath, "-UpdateId", "dotnet-sha", "-VerifyOnly",
|
||||||
|
], { windowsHide: true, env: { ...process.env, PSModulePath: "" } });
|
||||||
|
assert.equal(stderr, "");
|
||||||
|
assert.match(await readFile(logPath, "utf8"), /Verification-only SHA-256 check completed successfully/);
|
||||||
|
await rm(temp, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||||
|
});
|
||||||
test("early helper exit reports the helper log instead of only an exit code", async () => {
|
test("early helper exit reports the helper log instead of only an exit code", async () => {
|
||||||
const temp = await mkdtemp(
|
const temp = await mkdtemp(
|
||||||
path.join(os.tmpdir(), "forgeflow-update-log-tail-"),
|
path.join(os.tmpdir(), "forgeflow-update-log-tail-"),
|
||||||
@@ -457,15 +508,22 @@ test("updater handshake rejects a stale status from another update request", asy
|
|||||||
await rm(temp, { recursive: true, force: true });
|
await rm(temp, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("packaged updater downloads only a published checksum-matched Windows asset", async () => {
|
test("packaged updater downloads only a publisher-signed Windows asset", async () => {
|
||||||
const temp = await mkdtemp(
|
const temp = await mkdtemp(
|
||||||
path.join(os.tmpdir(), "forgeflow-binary-update-"),
|
path.join(os.tmpdir(), "forgeflow-binary-update-"),
|
||||||
);
|
);
|
||||||
const binary = Buffer.alloc(1_100_000, 0x5a);
|
const binary = Buffer.alloc(1_100_000, 0x5a);
|
||||||
binary[0] = 0x4d;
|
binary[0] = 0x4d;
|
||||||
binary[1] = 0x5a;
|
binary[1] = 0x5a;
|
||||||
const sha256 = createHash("sha256").update(binary).digest("hex");
|
|
||||||
const assetName = "ForgeFlow-Setup-0.8.2-win-x64.exe";
|
const assetName = "ForgeFlow-Setup-0.8.2-win-x64.exe";
|
||||||
|
const remoteSha = "a".repeat(40);
|
||||||
|
const signed = createSignedReleaseFixture({
|
||||||
|
version: "0.8.2",
|
||||||
|
remoteSha,
|
||||||
|
assetName,
|
||||||
|
binary,
|
||||||
|
});
|
||||||
|
const manifestName = "ForgeFlow-0.8.2-release-manifest.json";
|
||||||
const gitea = {
|
const gitea = {
|
||||||
async getReleaseByTag(_owner, _repo, tag) {
|
async getReleaseByTag(_owner, _repo, tag) {
|
||||||
if (tag !== "v0.8.2") return null;
|
if (tag !== "v0.8.2") return null;
|
||||||
@@ -485,18 +543,32 @@ test("packaged updater downloads only a published checksum-matched Windows asset
|
|||||||
id: 42,
|
id: 42,
|
||||||
browser_download_url: "http://wrong-origin.test/checksum",
|
browser_download_url: "http://wrong-origin.test/checksum",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: manifestName,
|
||||||
|
id: 43,
|
||||||
|
browser_download_url: "http://wrong-origin.test/manifest",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `${manifestName}.sig`,
|
||||||
|
id: 44,
|
||||||
|
browser_download_url: "http://wrong-origin.test/signature",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
async downloadReleaseAsset(_owner, _repo, releaseId, assetId, options) {
|
async downloadReleaseAsset(_owner, _repo, releaseId, assetId, options) {
|
||||||
assert.equal(releaseId, 82);
|
assert.equal(releaseId, 82);
|
||||||
assert.equal(
|
const downloads = {
|
||||||
options.downloadUrl,
|
41: ["http://wrong-origin.test/setup", binary],
|
||||||
assetId === 42
|
42: [
|
||||||
? "http://wrong-origin.test/checksum"
|
"http://wrong-origin.test/checksum",
|
||||||
: "http://wrong-origin.test/setup",
|
Buffer.from(`${signed.sha256} ${assetName}\n`),
|
||||||
);
|
],
|
||||||
return assetId === 42 ? Buffer.from(`${sha256} ${assetName}\n`) : binary;
|
43: ["http://wrong-origin.test/manifest", signed.manifestBytes],
|
||||||
|
44: ["http://wrong-origin.test/signature", signed.signatureBytes],
|
||||||
|
};
|
||||||
|
assert.equal(options.downloadUrl, downloads[assetId][0]);
|
||||||
|
return downloads[assetId][1];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const service = new UpdateService({
|
const service = new UpdateService({
|
||||||
@@ -514,14 +586,17 @@ test("packaged updater downloads only a published checksum-matched Windows asset
|
|||||||
sourcePath: temp,
|
sourcePath: temp,
|
||||||
userDataPath: temp,
|
userDataPath: temp,
|
||||||
platform: "win32",
|
platform: "win32",
|
||||||
|
updatePublicKey: signed.publicKey,
|
||||||
});
|
});
|
||||||
const result = await service.downloadPackaged({
|
const result = await service.downloadPackaged({
|
||||||
owner: "Jens",
|
owner: "Jens",
|
||||||
repo: "ForgeFlow",
|
repo: "ForgeFlow",
|
||||||
remoteVersion: "0.8.2",
|
remoteVersion: "0.8.2",
|
||||||
|
remoteSha,
|
||||||
});
|
});
|
||||||
assert.equal(result.downloaded, true);
|
assert.equal(result.downloaded, true);
|
||||||
assert.equal(result.sha256, sha256);
|
assert.equal(result.sha256, signed.sha256);
|
||||||
|
assert.equal(result.publisherKeyId, "SHA256:test");
|
||||||
assert.equal(result.portable, false);
|
assert.equal(result.portable, false);
|
||||||
assert.equal((await readFile(result.binaryPath)).length, binary.length);
|
assert.equal((await readFile(result.binaryPath)).length, binary.length);
|
||||||
await rm(temp, { recursive: true, force: true });
|
await rm(temp, { recursive: true, force: true });
|
||||||
@@ -535,6 +610,14 @@ test("packaged updater rejects a binary whose checksum does not match", async ()
|
|||||||
binary[0] = 0x4d;
|
binary[0] = 0x4d;
|
||||||
binary[1] = 0x5a;
|
binary[1] = 0x5a;
|
||||||
const assetName = "ForgeFlow-Portable-0.8.2-win-x64.exe";
|
const assetName = "ForgeFlow-Portable-0.8.2-win-x64.exe";
|
||||||
|
const remoteSha = "b".repeat(40);
|
||||||
|
const signed = createSignedReleaseFixture({
|
||||||
|
version: "0.8.2",
|
||||||
|
remoteSha,
|
||||||
|
assetName,
|
||||||
|
binary,
|
||||||
|
});
|
||||||
|
const manifestName = "ForgeFlow-0.8.2-release-manifest.json";
|
||||||
const service = new UpdateService({
|
const service = new UpdateService({
|
||||||
store: { data: { gitea: {} }, save: async () => {} },
|
store: { data: { gitea: {} }, save: async () => {} },
|
||||||
gitea: {
|
gitea: {
|
||||||
@@ -553,14 +636,18 @@ test("packaged updater rejects a binary whose checksum does not match", async ()
|
|||||||
name: `${assetName}.sha256`,
|
name: `${assetName}.sha256`,
|
||||||
browser_download_url: "http://wrong-origin.test/checksum",
|
browser_download_url: "http://wrong-origin.test/checksum",
|
||||||
},
|
},
|
||||||
|
{ id: 53, name: manifestName },
|
||||||
|
{ id: 54, name: `${manifestName}.sig` },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
async downloadReleaseAsset(_owner, _repo, releaseId, assetId) {
|
async downloadReleaseAsset(_owner, _repo, releaseId, assetId) {
|
||||||
assert.equal(releaseId, 83);
|
assert.equal(releaseId, 83);
|
||||||
return assetId === 52
|
if (assetId === 51) return binary;
|
||||||
? Buffer.from(`${"0".repeat(64)} ${assetName}`)
|
if (assetId === 52)
|
||||||
: binary;
|
return Buffer.from(`${"0".repeat(64)} ${assetName}`);
|
||||||
|
if (assetId === 53) return signed.manifestBytes;
|
||||||
|
return signed.signatureBytes;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
diagnostics: null,
|
diagnostics: null,
|
||||||
@@ -572,6 +659,7 @@ test("packaged updater rejects a binary whose checksum does not match", async ()
|
|||||||
sourcePath: temp,
|
sourcePath: temp,
|
||||||
userDataPath: temp,
|
userDataPath: temp,
|
||||||
platform: "win32",
|
platform: "win32",
|
||||||
|
updatePublicKey: signed.publicKey,
|
||||||
});
|
});
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() =>
|
() =>
|
||||||
@@ -579,17 +667,45 @@ test("packaged updater rejects a binary whose checksum does not match", async ()
|
|||||||
owner: "Jens",
|
owner: "Jens",
|
||||||
repo: "ForgeFlow",
|
repo: "ForgeFlow",
|
||||||
remoteVersion: "0.8.2",
|
remoteVersion: "0.8.2",
|
||||||
|
remoteSha,
|
||||||
}),
|
}),
|
||||||
/SHA-256 verification/,
|
/does not match the signed publisher manifest/,
|
||||||
);
|
);
|
||||||
await rm(temp, { recursive: true, force: true });
|
await rm(temp, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Windows release pipeline preserves optional signing checks and emits provenance plus SBOM", async () => {
|
test("release manifest verification rejects a different publisher key", () => {
|
||||||
const [pkgSource, signatureSource, checksumSource] = await Promise.all([
|
const binary = Buffer.alloc(1_100_000, 0x5a);
|
||||||
|
const assetName = "ForgeFlow-Setup-0.8.2-win-x64.exe";
|
||||||
|
const fixture = createSignedReleaseFixture({
|
||||||
|
version: "0.8.2",
|
||||||
|
remoteSha: "c".repeat(40),
|
||||||
|
assetName,
|
||||||
|
binary,
|
||||||
|
});
|
||||||
|
const otherKey = generateKeyPairSync("ed25519").publicKey;
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
verifyReleaseManifest({
|
||||||
|
manifestBytes: fixture.manifestBytes,
|
||||||
|
signatureBytes: fixture.signatureBytes,
|
||||||
|
publicKey: otherKey,
|
||||||
|
update: {
|
||||||
|
remoteVersion: "0.8.2",
|
||||||
|
remoteSha: "c".repeat(40),
|
||||||
|
},
|
||||||
|
assetName,
|
||||||
|
}),
|
||||||
|
(error) => error.code === "RELEASE_SIGNATURE_INVALID",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Windows release pipeline emits signed provenance, manifest and SBOM evidence", async () => {
|
||||||
|
const [pkgSource, signatureSource, checksumSource, manifestSigner] = await Promise.all([
|
||||||
readFile(new URL("../package.json", import.meta.url), "utf8"),
|
readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../scripts/verify-release-signatures.mjs", import.meta.url), "utf8"),
|
readFile(new URL("../scripts/verify-release-signatures.mjs", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../scripts/write-release-checksums.mjs", import.meta.url), "utf8"),
|
readFile(new URL("../scripts/write-release-checksums.mjs", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../scripts/sign-release-manifest.mjs", import.meta.url), "utf8"),
|
||||||
]);
|
]);
|
||||||
assert.match(pkgSource, /verify-release-signatures\.mjs/);
|
assert.match(pkgSource, /verify-release-signatures\.mjs/);
|
||||||
assert.match(signatureSource, /FORGEFLOW_SIGNED_RELEASE/);
|
assert.match(signatureSource, /FORGEFLOW_SIGNED_RELEASE/);
|
||||||
@@ -600,6 +716,10 @@ test("Windows release pipeline preserves optional signing checks and emits prove
|
|||||||
assert.match(checksumSource, /provenance\.json/);
|
assert.match(checksumSource, /provenance\.json/);
|
||||||
assert.match(checksumSource, /sbom\.cdx\.json/);
|
assert.match(checksumSource, /sbom\.cdx\.json/);
|
||||||
assert.match(checksumSource, /CycloneDX/);
|
assert.match(checksumSource, /CycloneDX/);
|
||||||
|
assert.match(checksumSource, /publisherManifestSignature/);
|
||||||
|
assert.match(manifestSigner, /Ed25519/);
|
||||||
|
assert.match(manifestSigner, /release-manifest\.json/);
|
||||||
|
assert.match(pkgSource, /sign-release-manifest\.mjs/);
|
||||||
const publisher = await readFile(new URL("../scripts/publish-binary-release.cjs", import.meta.url), "utf8");
|
const publisher = await readFile(new URL("../scripts/publish-binary-release.cjs", import.meta.url), "utf8");
|
||||||
assert.match(publisher, /draft: true/);
|
assert.match(publisher, /draft: true/);
|
||||||
assert.match(publisher, /requiredAssets/);
|
assert.match(publisher, /requiredAssets/);
|
||||||
@@ -607,17 +727,20 @@ test("Windows release pipeline preserves optional signing checks and emits prove
|
|||||||
assert.match(publisher, /sbom\.cdx\.json/);
|
assert.match(publisher, /sbom\.cdx\.json/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the supported Windows build is free, checksum-protected and updater-compatible", async () => {
|
test("the supported Windows build uses free offline Ed25519 publisher signing", async () => {
|
||||||
const [pkg, signatureCheck, checksumWriter] = await Promise.all([
|
const [pkg, keySetup, manifestSigner, publicKey] = await Promise.all([
|
||||||
readFile(new URL("../package.json", import.meta.url), "utf8"),
|
readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../scripts/verify-release-signatures.mjs", import.meta.url), "utf8"),
|
readFile(new URL("../scripts/setup-update-signing-key.mjs", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../scripts/write-release-checksums.mjs", import.meta.url), "utf8"),
|
readFile(new URL("../scripts/sign-release-manifest.mjs", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../build/update-signing-public.pem", import.meta.url), "utf8"),
|
||||||
]);
|
]);
|
||||||
assert.doesNotMatch(pkg, /dist:win:signed/);
|
assert.doesNotMatch(pkg, /dist:win:signed/);
|
||||||
assert.match(pkg, /dist:win/);
|
assert.match(pkg, /dist:win/);
|
||||||
assert.match(pkg, /write-release-checksums\.mjs/);
|
assert.match(pkg, /signing:setup/);
|
||||||
assert.match(signatureCheck, /checksum-protected unsigned artifact/);
|
assert.match(keySetup, /release-signing-private\.pem/);
|
||||||
assert.match(checksumWriter, /sha256/);
|
assert.match(manifestSigner, /sign\(null, manifestBytes, privateKey\)/);
|
||||||
|
assert.match(publicKey, /BEGIN PUBLIC KEY/);
|
||||||
|
assert.doesNotMatch(publicKey, /PRIVATE KEY/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("binary update helper verifies, waits, applies and records restart state", async () => {
|
test("binary update helper verifies, waits, applies and records restart state", async () => {
|
||||||
@@ -626,7 +749,7 @@ test("binary update helper verifies, waits, applies and records restart state",
|
|||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
for (const marker of [
|
for (const marker of [
|
||||||
"Get-FileHash",
|
"Security.Cryptography.SHA256",
|
||||||
"Wait-Process",
|
"Wait-Process",
|
||||||
'Write-UpdateState -State "started"',
|
'Write-UpdateState -State "started"',
|
||||||
'Write-UpdateState -State "waiting-for-exit"',
|
'Write-UpdateState -State "waiting-for-exit"',
|
||||||
@@ -640,4 +763,5 @@ test("binary update helper verifies, waits, applies and records restart state",
|
|||||||
`missing binary updater marker: ${marker}`,
|
`missing binary updater marker: ${marker}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
assert.doesNotMatch(helper, /Get-FileHash/);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user