feat: add safe Gitea sync and signed updates
This commit is contained in:
@@ -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.12** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest)
|
> Huidige release: **0.10.13** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ 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.
|
||||||
@@ -28,7 +29,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
|
||||||
|
|
||||||
@@ -94,6 +95,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 +116,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 +158,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. |
|
||||||
|
|
||||||
|
|||||||
+46
-42
@@ -1,4 +1,4 @@
|
|||||||
ForgeFlow 0.10.12 source manifest
|
ForgeFlow 0.10.13 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.)
|
||||||
61f37822ae5502219a38b2eaf23fdcb611875f0e675efb4abe6157c9f072c0cc 937 .gitea/workflows/quality.yml
|
61f37822ae5502219a38b2eaf23fdcb611875f0e675efb4abe6157c9f072c0cc 937 .gitea/workflows/quality.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
|
||||||
@@ -37,6 +38,7 @@ f79908fb3dad98c38030c6e6be7c79a1999e0478ed9c2496923891954438daa1 4581
|
|||||||
5d3240169765e3fb1d3cd391d09547101227e76dd4670ee46be8ca3a21553a03 894 docs/RELEASE_NOTES_0.10.10.md
|
5d3240169765e3fb1d3cd391d09547101227e76dd4670ee46be8ca3a21553a03 894 docs/RELEASE_NOTES_0.10.10.md
|
||||||
36edb4f096a248fb8679bd13e5766befb478cf628c6ccfb21e3eda71bbec7633 992 docs/RELEASE_NOTES_0.10.11.md
|
36edb4f096a248fb8679bd13e5766befb478cf628c6ccfb21e3eda71bbec7633 992 docs/RELEASE_NOTES_0.10.11.md
|
||||||
a355d3f577c2ec85dde5dfd7b6995f4f1615e2f6bac597529f3ff102acd93c35 1292 docs/RELEASE_NOTES_0.10.12.md
|
a355d3f577c2ec85dde5dfd7b6995f4f1615e2f6bac597529f3ff102acd93c35 1292 docs/RELEASE_NOTES_0.10.12.md
|
||||||
|
609c55a1c0b06c307ebe16f2daaf1e48601edd57137586e4f2be1febd6a7060a 1931 docs/RELEASE_NOTES_0.10.13.md
|
||||||
8d713471a437a8a55b00d7e1dd95290680862107bc4e586cf27d727f6274e46c 577 docs/RELEASE_NOTES_0.10.2.md
|
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
|
||||||
@@ -79,7 +81,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
|
||||||
@@ -88,13 +90,13 @@ 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
|
||||||
73f094a2f0db3de053e515feb2771cd5a4f3aa4178f2c5f37be01ca65ff1c938 2705 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
|
||||||
@@ -106,16 +108,16 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532
|
|||||||
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE
|
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE
|
||||||
e2daa28bbc01c68c3702add6ea8259dff5920b22f6fdc3c9193ed78a153f2e9e 14708 main.cjs
|
e2daa28bbc01c68c3702add6ea8259dff5920b22f6fdc3c9193ed78a153f2e9e 14708 main.cjs
|
||||||
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
|
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
|
||||||
198a8a51705fcea2ddf716558a49e06df5a8181c5476709c474d183b64890ca0 179808 package-lock.json
|
46e77e759b75c39737fc9812d105b31c20b68f0c46c7994bebf01a487ec9f207 179808 package-lock.json
|
||||||
fb12ca4da1105da46b4aacacb67260357c29aa362385c9cfaaeadeffd1bf82b3 6142 package.json
|
ba81105b16f3f3605a9e578a9e7a40d6fbc29f0001ad96f7f296441e563ceeb4 6367 package.json
|
||||||
1237df9ddcbb5ac7dc4316f18c34ff4a7030e3e0d56216ade6dd07369e5e2a04 1353 playwright.config.mjs
|
1237df9ddcbb5ac7dc4316f18c34ff4a7030e3e0d56216ade6dd07369e5e2a04 1353 playwright.config.mjs
|
||||||
16d6c37007c6c14dbf541060535c594308758477b867fc2a190c2946d74022b9 12196 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
|
||||||
42e2f375c2fa9bea138428b85eb2bb93fe8bf6e4a5fc0cd728f2c6713a019df7 10315 README.md
|
423447da25b001d869f9e9d7c860710f470945e72344332fc16b6c42a70d3ff6 10965 README.md
|
||||||
bfddedaf5e21a4948a4ba3d0ca89aee968028990f0079748963d4088f22d4537 14962 reports/architecture-audit.json
|
79d93000a8b9dec65448f866f48d83b62d6bf37af26480becd73654544d454e1 15831 reports/architecture-audit.json
|
||||||
68c2e5d245f93bb6c8e53bed105ed827601443827972b157a60b858aa440afaf 1343 reports/architecture-audit.md
|
dd54fd4a24120a466603acedab329bd3ac6f482ddb1e65c305d4cc755be5ec0f 1516 reports/architecture-audit.md
|
||||||
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
|
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
|
||||||
d0745072321aca2c80f44460974a7926715a9f429164aaf7660dced40b52c736 4790 scripts/apply-binary-update.ps1
|
d0745072321aca2c80f44460974a7926715a9f429164aaf7660dced40b52c736 4790 scripts/apply-binary-update.ps1
|
||||||
404863bcbe7292355662e3a326455df864d7279badc29f90866a3b837420df54 10745 scripts/apply-source-update.ps1
|
404863bcbe7292355662e3a326455df864d7279badc29f90866a3b837420df54 10745 scripts/apply-source-update.ps1
|
||||||
@@ -124,73 +126,75 @@ d0745072321aca2c80f44460974a7926715a9f429164aaf7660dced40b52c736 4790
|
|||||||
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
|
||||||
558ff442988f1396c174c7161ff5bd3ef0b2f43cfc31459ec7c3967faa146bc3 1694 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
|
||||||
02832d62cc6a4b9bc4e300a8c4528983272c903498eab77f97bda6467504608d 21420 scripts/verify.mjs
|
587efcb60a363614fb61f722fa84a81e4da09c5ea35e6897009f82c5d244d740 21919 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
|
||||||
4ca0fd6e1084db8db67e0b1c1c2d54ca6d29fe76da4f322dc417db00ef91c3dc 33088 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
|
||||||
ce30ddac403d1adf21176e5df21b0cc3db435305d2628f51f1486eacf20df6f2 23708 src/main/deployment-service.cjs
|
ce30ddac403d1adf21176e5df21b0cc3db435305d2628f51f1486eacf20df6f2 23708 src/main/deployment-service.cjs
|
||||||
165a37f8fbc82fb17e66c177f32aef70ac1ea293b00b82fc9fe301fe7e8b7080 15943 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
|
||||||
665859c48be68e5f256b9fac9a4722390cf1154705c099778f36e0e0a6c07e27 35214 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
|
||||||
0e01dfeb9ad230ee13ee913e17b577b1d34507b9b436ebf849fa05312e470d46 24398 src/main/ipc.cjs
|
5e10cf3759bbf9294909866ed16f206d394789f0564549fc0fe23cdd89118ca6 25110 src/main/ipc.cjs
|
||||||
26efebb4c147ed560966e7e60e64a013b3476327b3bbdb4e4439949142fa7846 2250 src/main/ipc/channel.cjs
|
26efebb4c147ed560966e7e60e64a013b3476327b3bbdb4e4439949142fa7846 2250 src/main/ipc/channel.cjs
|
||||||
b80357dd1f0aa18022d92db85b6cc8f29bc691ef11f9a0e90b4886ae5e19c763 12543 src/main/ipc/deployment-handlers.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
|
||||||
253d4f027de09e1e8fd46b40ef0da4eaf8350c0edfbf069b0a3429d712c7e8c4 15929 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
|
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
|
||||||
c629efa832a806409954ab5a4986bf88606ce5fa5278bb1885a76222d1cb6faf 6441 src/main/repository-monitor.cjs
|
27bd6621c731545ec46d8914e9408c89928a8ce563b40eb4bcd8a516662a54d1 8716 src/main/repository-monitor.cjs
|
||||||
6393583911263575c6e2a19d9baab6e638cce90252c386b0a5144f2fb6f81f15 12154 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
|
||||||
792c5cf1b884e71a601b80ee373ff81178c34a910da5a7d64d3040f266bb0cee 20398 src/main/ssh-service.cjs
|
793003566823e1d5c02283f583888ecc07e44477525620579b3d858f488b3c08 22347 src/main/ssh-service.cjs
|
||||||
19538a3c40ea3489bbaee9a23af36a5e99962af6bb3d04259f05ece6588cbeb2 25901 src/main/unraid-access-methods.cjs
|
19538a3c40ea3489bbaee9a23af36a5e99962af6bb3d04259f05ece6588cbeb2 25901 src/main/unraid-access-methods.cjs
|
||||||
5621e35323e4f81fb14a05670f81579ec1e66bea3a55fa6457ece0f807421424 9801 src/main/unraid-deploy-key-host.cjs
|
5621e35323e4f81fb14a05670f81579ec1e66bea3a55fa6457ece0f807421424 9801 src/main/unraid-deploy-key-host.cjs
|
||||||
6d9910dace52625f88e066a8485af2663c3735ff15e9ce9031441ce742710a21 30793 src/main/unraid-deployment-methods.cjs
|
6d9910dace52625f88e066a8485af2663c3735ff15e9ce9031441ce742710a21 30793 src/main/unraid-deployment-methods.cjs
|
||||||
673b1692e7c2b5197545df98750b5d048bddf44206263e25be4f17d9bf900e2c 17208 src/main/unraid-deployment-service.cjs
|
673b1692e7c2b5197545df98750b5d048bddf44206263e25be4f17d9bf900e2c 17208 src/main/unraid-deployment-service.cjs
|
||||||
a2fa253bb2664ad7e430d7a4cd4c4434b89e6c54c19c7b9e4f53fe08485339d3 42652 src/main/unraid-inventory-methods.cjs
|
bb4a99c3526fcf4db4fbae88a058e8598fd10a87990e7d502bfdc765328bdaa1 42766 src/main/unraid-inventory-methods.cjs
|
||||||
2c0cf07921ca7ee5a9085ced44498c2e6798e5cc1e8a5ecf704c3cecabe39a25 27607 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
|
||||||
4c5cf01922e1feb36a31b50af22e973d8aee3fecccd406e449690604111898ac 11608 src/main/unraid-state-methods.cjs
|
4c5cf01922e1feb36a31b50af22e973d8aee3fecccd406e449690604111898ac 11608 src/main/unraid-state-methods.cjs
|
||||||
d968d64d1e65f4a0fbb7a19f16775469d8e2e35b5ae249813c0b635b27bef8fe 22311 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
|
||||||
33e65457e44708cecbe859b8c8a0c51b4ab33219c3b7a9f9dad884089f07d367 17904 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
|
a980f2e86d8286ea605a7259b9e9adcf3fda4f657b8d54a5d2d8765a7bbbec13 19065 src/renderer/actions/shell.js
|
||||||
984da4d93acc7461078b70c1c767f4843ef22fa5f3e1d947a1f3d5a5175ed20d 26601 src/renderer/app.js
|
65e305965d6d00d45b516f271c0f905854797af07c38982768199bb30d988c2b 26659 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
|
||||||
02ae73c50856c827ed13e929396643699a29ac03114ceef1a8706e2e172578c5 51148 src/renderer/dialogs.js
|
fd47efd296409c19b80fd7719429e3eb33963ef78f30bc748d1770308aa472a3 55138 src/renderer/dialogs.js
|
||||||
dede1f21a06c73a2c2a462a869d27530d85f99baff202a2eb509c57436ad6aec 2732 src/renderer/diff-view.js
|
dede1f21a06c73a2c2a462a869d27530d85f99baff202a2eb509c57436ad6aec 2732 src/renderer/diff-view.js
|
||||||
eef2f269ba4fbb76bf66ad328d481b461255d0acb753b30878dd4d4eaac57dc6 6924 src/renderer/events.js
|
eef2f269ba4fbb76bf66ad328d481b461255d0acb753b30878dd4d4eaac57dc6 6924 src/renderer/events.js
|
||||||
c4a71213d412166093f7bd8254b847de4d8beb58c1aaa356a0cdc8d728080326 1524 src/renderer/index.html
|
c4a71213d412166093f7bd8254b847de4d8beb58c1aaa356a0cdc8d728080326 1524 src/renderer/index.html
|
||||||
06180d9656dd254edfb6949c397f8e313954fc560ddcb22b3a35fce3c3e35655 21350 src/renderer/mock-bridge.js
|
06180d9656dd254edfb6949c397f8e313954fc560ddcb22b3a35fce3c3e35655 21350 src/renderer/mock-bridge.js
|
||||||
870024aff376826a92c9cf7452689cc1ecc5d9034f055bea56734f3f7fcea5e5 28703 src/renderer/mock-deployment-bridge.js
|
870024aff376826a92c9cf7452689cc1ecc5d9034f055bea56734f3f7fcea5e5 28703 src/renderer/mock-deployment-bridge.js
|
||||||
81b0155dd51c7bc8871fd29bd7fdb28857cf11a86ed6d6953238c04c2774800c 20033 src/renderer/mock-repository-bridge.js
|
92cacf58a3576044800ee5f9823d4fd2a1c768571b7f5d271950f5002a80d212 25031 src/renderer/mock-repository-bridge.js
|
||||||
94fa265c2fe9ca8d644f0ce9b620b6f85d9b25dca5802c4e9195b66dcbe80120 6522 src/renderer/operations.js
|
94fa265c2fe9ca8d644f0ce9b620b6f85d9b25dca5802c4e9195b66dcbe80120 6522 src/renderer/operations.js
|
||||||
21b8f3228bddd6da06613857763282d1f88f4eeab3d063f3a63395d7b567f2e4 80004 src/renderer/styles.css
|
431ed7bbadecbcc99572aea9a5ae5550dbb5bacbd2d8028f8a05bf77f97b251e 81161 src/renderer/styles.css
|
||||||
f06b2ef7cc4d7f6ce83f5227212271c25d7a904f45bb7ab84f06b082bd9298b5 100508 src/renderer/views.js
|
8ef063feee96c0a3692da21fa797bf44325388b94e051d53f20a5198036c9252 102998 src/renderer/views.js
|
||||||
e9e72c072a5c5d04f59cd6763de0cfbf736c2a5ffa2f722143f3bad2bdbc630b 1411 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
|
||||||
@@ -198,7 +202,7 @@ e9e72c072a5c5d04f59cd6763de0cfbf736c2a5ffa2f722143f3bad2bdbc630b 1411
|
|||||||
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
|
||||||
@@ -206,7 +210,7 @@ f5b0ea887fcdeadec78c1ad49b0ec7979723562f5c0b730703acb77a37281ee0 1009
|
|||||||
a4e5947204ff6878e601e32477bc85b53cd0153baf95a161c8935b6e5466c257 1155 tests/audit-service.test.mjs
|
a4e5947204ff6878e601e32477bc85b53cd0153baf95a161c8935b6e5466c257 1155 tests/audit-service.test.mjs
|
||||||
0841f6a2515508f8d28562d5c565c1519d357b8579515f890883dc2aec7d7737 16881 tests/browser/forgeflow.spec.mjs
|
0841f6a2515508f8d28562d5c565c1519d357b8579515f890883dc2aec7d7737 16881 tests/browser/forgeflow.spec.mjs
|
||||||
1728c0a7abd92f4d7d9e68df32e4a6b00730555f23795e9b36416795d9d127af 5978 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
|
144c8e217a334cd69f502938d944e0f2dac61703d5df47e287b9ed542918c779 8129 tests/dependency-wiring.test.mjs
|
||||||
aad5948ea374d1e56e777005c73639654c96a90364dd398c949052cf5ae343a2 11130 tests/deploy-key-host.test.mjs
|
aad5948ea374d1e56e777005c73639654c96a90364dd398c949052cf5ae343a2 11130 tests/deploy-key-host.test.mjs
|
||||||
@@ -216,7 +220,7 @@ b7e009fed4171d6dd6b4c3154ba1d3f7198e98f5b79b298687841fc8169447cd 9354
|
|||||||
bf4576901e32662d832687a2761852aa1b2cffe256de5044f18c6637c189463b 9780 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
|
||||||
@@ -229,20 +233,20 @@ caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392
|
|||||||
1b6c920e18a248f78acaed6187197c88ec8d911b62d5e2a9f8ad57b91ae80499 11827 tests/preflight.test.mjs
|
1b6c920e18a248f78acaed6187197c88ec8d911b62d5e2a9f8ad57b91ae80499 11827 tests/preflight.test.mjs
|
||||||
7f2751ea2621f76b5427f442e931344d13e97faa7b6ef3151949bbd6a03097cf 1205 tests/process-error-policy.test.mjs
|
7f2751ea2621f76b5427f442e931344d13e97faa7b6ef3151949bbd6a03097cf 1205 tests/process-error-policy.test.mjs
|
||||||
0cb884cf62c1cb02cf59a81662be055bcb5339d176de85e2a3eeb8e8573e11b3 6435 tests/production-acceptance.test.mjs
|
0cb884cf62c1cb02cf59a81662be055bcb5339d176de85e2a3eeb8e8573e11b3 6435 tests/production-acceptance.test.mjs
|
||||||
9e4064295909405674c945378062b772a370fcc26c855d343c0840f9e18bbb1d 9927 tests/renderer-workflow.test.mjs
|
252e0345fcd3cd3899467f2a9ed363df0675d529e59d4d6e1204f4464fd8a5f7 11485 tests/renderer-workflow.test.mjs
|
||||||
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs
|
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs
|
||||||
e3c6e0f5c9351fce2ccf96b8abb15de404a0f935633df0b5b1c3fefa207c723b 3993 tests/repository-monitor.test.mjs
|
76712a5d26f2598b00b83b925c9c84a90ab81c0eb1760895e9d6a2bd2f6eb425 5828 tests/repository-monitor.test.mjs
|
||||||
5476f3ba90bc096d4172900d9b54ada7c12da521f8627913d87794eade3cee23 13494 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
|
||||||
12cb3b240bdd0922566323c0014838ca067ad10d9d4009943165ae2c4e93bc6f 11786 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
|
||||||
a39d30f47813dfb98c998811f3d76ebbb1544ecfd017a165d44f9afb80d7daf9 9090 tests/ssh-connection-pool.test.mjs
|
a39d30f47813dfb98c998811f3d76ebbb1544ecfd017a165d44f9afb80d7daf9 9090 tests/ssh-connection-pool.test.mjs
|
||||||
7ee9166327ed227d2b7c6929692dea5c5d7a41c3e566596fa92d9ec4f42e8677 4085 tests/ssh-connection.test.mjs
|
7ee9166327ed227d2b7c6929692dea5c5d7a41c3e566596fa92d9ec4f42e8677 4085 tests/ssh-connection.test.mjs
|
||||||
0d1bc4d623ce299337736c577ec61c8ffd6974ebe20335b72838d10eae35ecb1 7993 tests/ssh-service.test.mjs
|
c9354e4bf3720c28cff21c15ff8b9474ba4a23b7f55389de4326f4dffde54d78 9510 tests/ssh-service.test.mjs
|
||||||
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs
|
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs
|
||||||
db0a347cdab1da11a04d41981695a65d7244367c46f0cdc868326e8d00d73cbf 56694 tests/unraid-deployment.test.mjs
|
3e4a1a6d6a744df9badcfece2cf8d09f8c34efb3c437cb08a6f2e6c9d428c0d4 59353 tests/unraid-deployment.test.mjs
|
||||||
bf95071f8d8bec6dcf2c889f1f9d83be1b56178dc0d2be84da555848b6319cbc 25650 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,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.
|
||||||
+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.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "forgeflow",
|
"name": "forgeflow",
|
||||||
"version": "0.10.12",
|
"version": "0.10.13",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "forgeflow",
|
"name": "forgeflow",
|
||||||
"version": "0.10.12",
|
"version": "0.10.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ssh2": "1.17.0"
|
"ssh2": "1.17.0"
|
||||||
},
|
},
|
||||||
|
|||||||
+6
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "forgeflow",
|
"name": "forgeflow",
|
||||||
"version": "0.10.12",
|
"version": "0.10.13",
|
||||||
"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",
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"coverage": "c8 --check-coverage --lines 85 --functions 85 --branches 68 --statements 85 node --test tests/*.test.mjs && npm run coverage:modules",
|
"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",
|
"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",
|
||||||
@@ -24,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",
|
||||||
@@ -50,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",
|
||||||
@@ -67,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",
|
||||||
@@ -118,6 +121,7 @@
|
|||||||
"docs/RELEASE_NOTES_0.10.10.md",
|
"docs/RELEASE_NOTES_0.10.10.md",
|
||||||
"docs/RELEASE_NOTES_0.10.11.md",
|
"docs/RELEASE_NOTES_0.10.11.md",
|
||||||
"docs/RELEASE_NOTES_0.10.12.md",
|
"docs/RELEASE_NOTES_0.10.12.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.13.md",
|
||||||
"docs/CURRENT_STATE.md",
|
"docs/CURRENT_STATE.md",
|
||||||
"docs/MUTATION_MODEL.md",
|
"docs/MUTATION_MODEL.md",
|
||||||
"docs/RELEASING.md",
|
"docs/RELEASING.md",
|
||||||
|
|||||||
+3
-1
@@ -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', {
|
||||||
@@ -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 }),
|
||||||
|
|||||||
+139
-95
@@ -1,12 +1,54 @@
|
|||||||
{
|
{
|
||||||
"generatedAt": "2026-08-23T13:06:16.630Z",
|
"generatedAt": "2026-08-26T22:18:32.525Z",
|
||||||
"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/main/update-service.cjs",
|
||||||
|
"lines": 854,
|
||||||
|
"branches": 64,
|
||||||
|
"functions": 65,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"git",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 74
|
||||||
|
}
|
||||||
|
],
|
||||||
"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": 518,
|
||||||
@@ -24,7 +66,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/renderer/app.js",
|
"file": "src/renderer/app.js",
|
||||||
"lines": 732,
|
"lines": 734,
|
||||||
"branches": 80,
|
"branches": 80,
|
||||||
"functions": 124,
|
"functions": 124,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 0,
|
||||||
@@ -53,21 +95,9 @@
|
|||||||
],
|
],
|
||||||
"hotspotScore": 119
|
"hotspotScore": 119
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"file": "src/main/git-service.cjs",
|
|
||||||
"lines": 687,
|
|
||||||
"branches": 111,
|
|
||||||
"functions": 118,
|
|
||||||
"ipcHandlers": 0,
|
|
||||||
"responsibilities": [
|
|
||||||
"git",
|
|
||||||
"security"
|
|
||||||
],
|
|
||||||
"hotspotScore": 111
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"file": "src/main/unraid-inventory-methods.cjs",
|
"file": "src/main/unraid-inventory-methods.cjs",
|
||||||
"lines": 708,
|
"lines": 710,
|
||||||
"branches": 76,
|
"branches": 76,
|
||||||
"functions": 93,
|
"functions": 93,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 0,
|
||||||
@@ -82,6 +112,20 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"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": 518,
|
||||||
@@ -99,7 +143,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/renderer/app.js",
|
"file": "src/renderer/app.js",
|
||||||
"lines": 732,
|
"lines": 734,
|
||||||
"branches": 80,
|
"branches": 80,
|
||||||
"functions": 124,
|
"functions": 124,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 0,
|
||||||
@@ -130,7 +174,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/main/unraid-inventory-methods.cjs",
|
"file": "src/main/unraid-inventory-methods.cjs",
|
||||||
"lines": 708,
|
"lines": 710,
|
||||||
"branches": 76,
|
"branches": 76,
|
||||||
"functions": 93,
|
"functions": 93,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 0,
|
||||||
@@ -143,6 +187,22 @@
|
|||||||
],
|
],
|
||||||
"hotspotScore": 106
|
"hotspotScore": 106
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"file": "src/renderer/dialogs.js",
|
||||||
|
"lines": 433,
|
||||||
|
"branches": 60,
|
||||||
|
"functions": 83,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"renderer",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 100
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"file": "src/main/unraid-deployment-methods.cjs",
|
"file": "src/main/unraid-deployment-methods.cjs",
|
||||||
"lines": 583,
|
"lines": 583,
|
||||||
@@ -159,24 +219,24 @@
|
|||||||
"hotspotScore": 99
|
"hotspotScore": 99
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/renderer/dialogs.js",
|
"file": "src/main/ipc.cjs",
|
||||||
"lines": 419,
|
"lines": 706,
|
||||||
"branches": 58,
|
"branches": 54,
|
||||||
"functions": 80,
|
"functions": 72,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 27,
|
||||||
"responsibilities": [
|
"responsibilities": [
|
||||||
"inventory",
|
"inventory",
|
||||||
"deployment",
|
"deployment",
|
||||||
"git",
|
"git",
|
||||||
"renderer",
|
"ipc",
|
||||||
"security",
|
"security",
|
||||||
"updates"
|
"updates"
|
||||||
],
|
],
|
||||||
"hotspotScore": 98
|
"hotspotScore": 94
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/renderer/views.js",
|
"file": "src/renderer/views.js",
|
||||||
"lines": 715,
|
"lines": 736,
|
||||||
"branches": 53,
|
"branches": 53,
|
||||||
"functions": 152,
|
"functions": 152,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 0,
|
||||||
@@ -191,20 +251,33 @@
|
|||||||
"hotspotScore": 93
|
"hotspotScore": 93
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/main/ipc.cjs",
|
"file": "src/main/ssh-service.cjs",
|
||||||
"lines": 694,
|
"lines": 513,
|
||||||
"branches": 51,
|
"branches": 70,
|
||||||
"functions": 70,
|
"functions": 101,
|
||||||
"ipcHandlers": 27,
|
"ipcHandlers": 0,
|
||||||
"responsibilities": [
|
"responsibilities": [
|
||||||
"inventory",
|
|
||||||
"deployment",
|
"deployment",
|
||||||
"git",
|
"git",
|
||||||
"ipc",
|
|
||||||
"security",
|
"security",
|
||||||
"updates"
|
"updates"
|
||||||
],
|
],
|
||||||
"hotspotScore": 91
|
"hotspotScore": 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/renderer/actions/setup-and-settings.js",
|
||||||
|
"lines": 441,
|
||||||
|
"branches": 60,
|
||||||
|
"functions": 60,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"renderer",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 90
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "main.cjs",
|
"file": "main.cjs",
|
||||||
@@ -223,21 +296,6 @@
|
|||||||
],
|
],
|
||||||
"hotspotScore": 89
|
"hotspotScore": 89
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"file": "src/renderer/actions/setup-and-settings.js",
|
|
||||||
"lines": 427,
|
|
||||||
"branches": 58,
|
|
||||||
"functions": 58,
|
|
||||||
"ipcHandlers": 0,
|
|
||||||
"responsibilities": [
|
|
||||||
"deployment",
|
|
||||||
"git",
|
|
||||||
"renderer",
|
|
||||||
"security",
|
|
||||||
"updates"
|
|
||||||
],
|
|
||||||
"hotspotScore": 88
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"file": "src/main/gitea-service.cjs",
|
"file": "src/main/gitea-service.cjs",
|
||||||
"lines": 624,
|
"lines": 624,
|
||||||
@@ -252,24 +310,10 @@
|
|||||||
],
|
],
|
||||||
"hotspotScore": 87
|
"hotspotScore": 87
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"file": "src/main/ssh-service.cjs",
|
|
||||||
"lines": 467,
|
|
||||||
"branches": 66,
|
|
||||||
"functions": 92,
|
|
||||||
"ipcHandlers": 0,
|
|
||||||
"responsibilities": [
|
|
||||||
"deployment",
|
|
||||||
"git",
|
|
||||||
"security",
|
|
||||||
"updates"
|
|
||||||
],
|
|
||||||
"hotspotScore": 86
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"file": "src/renderer/actions/deployment-profile.js",
|
"file": "src/renderer/actions/deployment-profile.js",
|
||||||
"lines": 407,
|
"lines": 408,
|
||||||
"branches": 56,
|
"branches": 57,
|
||||||
"functions": 44,
|
"functions": 44,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 0,
|
||||||
"responsibilities": [
|
"responsibilities": [
|
||||||
@@ -279,11 +323,25 @@
|
|||||||
"renderer",
|
"renderer",
|
||||||
"security"
|
"security"
|
||||||
],
|
],
|
||||||
"hotspotScore": 86
|
"hotspotScore": 87
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/renderer/actions/recovery.js",
|
||||||
|
"lines": 421,
|
||||||
|
"branches": 64,
|
||||||
|
"functions": 43,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"renderer",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 84
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/main/config-store.cjs",
|
"file": "src/main/config-store.cjs",
|
||||||
"lines": 665,
|
"lines": 668,
|
||||||
"branches": 52,
|
"branches": 52,
|
||||||
"functions": 89,
|
"functions": 89,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 0,
|
||||||
@@ -328,20 +386,6 @@
|
|||||||
],
|
],
|
||||||
"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/main/unraid-access-methods.cjs",
|
"file": "src/main/unraid-access-methods.cjs",
|
||||||
"lines": 462,
|
"lines": 462,
|
||||||
@@ -359,7 +403,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/main/diagnostics-service.cjs",
|
"file": "src/main/diagnostics-service.cjs",
|
||||||
"lines": 373,
|
"lines": 377,
|
||||||
"branches": 39,
|
"branches": 39,
|
||||||
"functions": 51,
|
"functions": 51,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 0,
|
||||||
@@ -477,9 +521,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",
|
||||||
@@ -612,9 +656,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/renderer/mock-repository-bridge.js",
|
"file": "src/renderer/mock-repository-bridge.js",
|
||||||
"lines": 636,
|
"lines": 701,
|
||||||
"branches": 13,
|
"branches": 14,
|
||||||
"functions": 89,
|
"functions": 91,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 0,
|
||||||
"responsibilities": [
|
"responsibilities": [
|
||||||
"deployment",
|
"deployment",
|
||||||
@@ -622,7 +666,7 @@
|
|||||||
"security",
|
"security",
|
||||||
"updates"
|
"updates"
|
||||||
],
|
],
|
||||||
"hotspotScore": 33
|
"hotspotScore": 34
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/main/deployment-identity.cjs",
|
"file": "src/main/deployment-identity.cjs",
|
||||||
@@ -685,9 +729,9 @@
|
|||||||
"ipcHotspots": [
|
"ipcHotspots": [
|
||||||
{
|
{
|
||||||
"file": "src/main/ipc.cjs",
|
"file": "src/main/ipc.cjs",
|
||||||
"lines": 694,
|
"lines": 706,
|
||||||
"branches": 51,
|
"branches": 54,
|
||||||
"functions": 70,
|
"functions": 72,
|
||||||
"ipcHandlers": 27,
|
"ipcHandlers": 27,
|
||||||
"responsibilities": [
|
"responsibilities": [
|
||||||
"inventory",
|
"inventory",
|
||||||
@@ -697,7 +741,7 @@
|
|||||||
"security",
|
"security",
|
||||||
"updates"
|
"updates"
|
||||||
],
|
],
|
||||||
"hotspotScore": 91
|
"hotspotScore": 94
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/main/ipc/deployment-handlers.cjs",
|
"file": "src/main/ipc/deployment-handlers.cjs",
|
||||||
@@ -717,10 +761,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,13 @@
|
|||||||
# ForgeFlow architecture audit
|
# ForgeFlow architecture audit
|
||||||
|
|
||||||
Generated 2026-08-23T13:06:16.630Z. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity.
|
Generated 2026-08-26T22:18:32.525Z. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity.
|
||||||
|
|
||||||
## Files above 750 lines
|
## 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/main/update-service.cjs` | 854 | 64 | 65 | 0 | git, security, updates |
|
||||||
|
|
||||||
## Files above 1,000 lines
|
## Files above 1,000 lines
|
||||||
|
|
||||||
@@ -18,11 +19,11 @@ No findings.
|
|||||||
|
|
||||||
| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |
|
| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |
|
||||||
|---|---:|---:|---:|---:|---|
|
|---|---:|---:|---:|---:|---|
|
||||||
|
| `src/main/git-service.cjs` | 881 | 134 | 147 | 0 | git, renderer, security, updates |
|
||||||
| `src/renderer/actions/shell.js` | 518 | 101 | 86 | 0 | inventory, deployment, git, renderer, updates |
|
| `src/renderer/actions/shell.js` | 518 | 101 | 86 | 0 | inventory, deployment, git, renderer, updates |
|
||||||
| `src/renderer/app.js` | 732 | 80 | 124 | 0 | inventory, deployment, git, renderer, security, updates |
|
| `src/renderer/app.js` | 734 | 80 | 124 | 0 | inventory, deployment, git, renderer, security, updates |
|
||||||
| `src/main/server-inventory.cjs` | 578 | 89 | 104 | 0 | inventory, deployment, git, security, updates |
|
| `src/main/server-inventory.cjs` | 578 | 89 | 104 | 0 | inventory, deployment, git, security, updates |
|
||||||
| `src/main/git-service.cjs` | 687 | 111 | 118 | 0 | git, security |
|
| `src/main/unraid-inventory-methods.cjs` | 710 | 76 | 93 | 0 | inventory, deployment, git, security, updates |
|
||||||
| `src/main/unraid-inventory-methods.cjs` | 708 | 76 | 93 | 0 | inventory, deployment, git, security, updates |
|
|
||||||
|
|
||||||
## Interpretation
|
## Interpretation
|
||||||
|
|
||||||
|
|||||||
@@ -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(", ")}`);
|
||||||
|
|||||||
@@ -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}`);
|
||||||
+10
-2
@@ -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",
|
||||||
@@ -92,6 +94,7 @@ const required = [
|
|||||||
"docs/RELEASE_NOTES_0.10.10.md",
|
"docs/RELEASE_NOTES_0.10.10.md",
|
||||||
"docs/RELEASE_NOTES_0.10.11.md",
|
"docs/RELEASE_NOTES_0.10.11.md",
|
||||||
"docs/RELEASE_NOTES_0.10.12.md",
|
"docs/RELEASE_NOTES_0.10.12.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.13.md",
|
||||||
"docs/UPDATING.md",
|
"docs/UPDATING.md",
|
||||||
"docs/DIAGNOSTICS.md",
|
"docs/DIAGNOSTICS.md",
|
||||||
"docs/DEPLOYMENT_SETUP.md",
|
"docs/DEPLOYMENT_SETUP.md",
|
||||||
@@ -123,6 +126,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));
|
||||||
@@ -130,9 +134,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.12")
|
if (packageJson.version !== "0.10.13")
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Expected package version 0.10.12, got ${packageJson.version}.`,
|
`Expected package version 0.10.13, got ${packageJson.version}.`,
|
||||||
);
|
);
|
||||||
const sourceManifest = await readFile(
|
const sourceManifest = await readFile(
|
||||||
path.join(root, "SOURCE_MANIFEST.txt"),
|
path.join(root, "SOURCE_MANIFEST.txt"),
|
||||||
@@ -494,6 +498,10 @@ const release01012 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.12.
|
|||||||
for (const phrase of ["coalesced", "exact Gitea commit parity", "batched Docker inspect", "bounded worker pools", "stopped container"]) {
|
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}`);
|
if (!release01012.includes(phrase)) throw new Error(`0.10.12 release notes are missing: ${phrase}`);
|
||||||
}
|
}
|
||||||
|
const release01013 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.13.md"), "utf8");
|
||||||
|
for (const phrase of ["Gitea workspace sync", "recovery branch", "Stale deployment links", "Ed25519-signed release manifest", "Git-toolsgrid"]) {
|
||||||
|
if (!release01013.includes(phrase)) throw new Error(`0.10.13 release notes are missing: ${phrase}`);
|
||||||
|
}
|
||||||
const configSource = await readFile(path.join(root, "src/main/config-store.cjs"), "utf8");
|
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));
|
||||||
|
|||||||
@@ -615,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;
|
||||||
|
|||||||
@@ -291,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,
|
||||||
|
|||||||
+195
-1
@@ -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,6 +30,34 @@ 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() {
|
constructor() {
|
||||||
// `git remote get-url` is only re-run when the repository configuration file
|
// `git remote get-url` is only re-run when the repository configuration file
|
||||||
@@ -316,6 +346,151 @@ 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);
|
||||||
@@ -336,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;
|
||||||
|
|||||||
+14
-2
@@ -415,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 },
|
||||||
{},
|
{},
|
||||||
|
|||||||
@@ -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, () =>
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ class RepositoryMonitor {
|
|||||||
this.watchers = new Map();
|
this.watchers = new Map();
|
||||||
this.changed = new Set();
|
this.changed = new Set();
|
||||||
this.lastCheckedAt = new Map();
|
this.lastCheckedAt = new Map();
|
||||||
|
this.lastFetchedAt = new Map();
|
||||||
this.watchTimer = null;
|
this.watchTimer = null;
|
||||||
|
this.fetchRunning = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
setPaths(paths) {
|
setPaths(paths) {
|
||||||
@@ -46,6 +48,13 @@ class RepositoryMonitor {
|
|||||||
for (const existing of [...this.lastCheckedAt.keys()]) {
|
for (const existing of [...this.lastCheckedAt.keys()]) {
|
||||||
if (!watched.has(existing)) this.lastCheckedAt.delete(existing);
|
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();
|
this.syncWatchers();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +114,60 @@ class RepositoryMonitor {
|
|||||||
return sinceLastCheck >= SAFETY_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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pause(localPath) { if (localPath) this.paused.add(localPath); }
|
pause(localPath) { if (localPath) this.paused.add(localPath); }
|
||||||
resume(localPath) { if (localPath) this.paused.delete(localPath); }
|
resume(localPath) { if (localPath) this.paused.delete(localPath); }
|
||||||
|
|
||||||
@@ -128,6 +191,7 @@ class RepositoryMonitor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
@@ -140,13 +204,7 @@ class RepositoryMonitor {
|
|||||||
this.lastCheckedAt.set(localPath, Date.now());
|
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);
|
||||||
|
|||||||
@@ -53,9 +53,10 @@ function parseCapabilityOutput(output) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class SshService {
|
class SshService {
|
||||||
constructor({ store, diagnostics, idleConnectionMs = 60_000 }) {
|
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
|
// 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
|
// authentication round trip. Sessions are kept per server for a short while
|
||||||
// so a sequence of commands shares one connection.
|
// so a sequence of commands shares one connection.
|
||||||
@@ -104,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 = {
|
||||||
@@ -116,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;
|
||||||
@@ -137,7 +139,7 @@ class SshService {
|
|||||||
if (!server) throw new Error('The configured SSH server no longer exists.');
|
if (!server) throw new Error('The configured SSH server no longer exists.');
|
||||||
// A trust-on-first-use connection is established without checking the
|
// A trust-on-first-use connection is established without checking the
|
||||||
// fingerprint, so it must never serve a later verified call.
|
// fingerprint, so it must never serve a later verified call.
|
||||||
if (options.trustOnFirstUse) return this.withDedicatedClient(server, action, options);
|
if (options.trustOnFirstUse || options.expectedFingerprint) return this.withDedicatedClient(server, action, options);
|
||||||
return this.withPooledClient(server, action, options);
|
return this.withPooledClient(server, action, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +245,7 @@ class SshService {
|
|||||||
|
|
||||||
async withDedicatedClient(server, action, options = {}, { keepOpen = false } = {}) {
|
async withDedicatedClient(server, action, options = {}, { keepOpen = false } = {}) {
|
||||||
const serverId = server.id;
|
const serverId = server.id;
|
||||||
const Client = loadSshClient();
|
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();
|
||||||
@@ -414,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)
|
||||||
@@ -449,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 = {}) {
|
||||||
|
|||||||
@@ -431,7 +431,7 @@ function createUnraidInventoryMethods({
|
|||||||
const plan = this.reconciliationPlan(server, workloads, repositories, { autoLink: true });
|
const plan = this.reconciliationPlan(server, workloads, repositories, { autoLink: true });
|
||||||
if (plan.additions.length) await this.store.createRecoverySnapshot?.(`automatic-server-links-${serverId}`);
|
if (plan.additions.length) await this.store.createRecoverySnapshot?.(`automatic-server-links-${serverId}`);
|
||||||
const linkedRepositories = new Set(workloads
|
const linkedRepositories = new Set(workloads
|
||||||
.filter((workload) => workload.link?.repositoryFullName)
|
.filter((workload) => workload.classification?.type !== "stale-link" && workload.link?.repositoryFullName)
|
||||||
.map((workload) => String(workload.link.repositoryFullName).toLowerCase()));
|
.map((workload) => String(workload.link.repositoryFullName).toLowerCase()));
|
||||||
for (const addition of plan.additions) {
|
for (const addition of plan.additions) {
|
||||||
const workload = workloads.find((item) => item.workloadId === addition.workloadId);
|
const workload = workloads.find((item) => item.workloadId === addition.workloadId);
|
||||||
@@ -466,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 = [];
|
||||||
|
|||||||
+151
-19
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -713,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") {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ const ui = {
|
|||||||
servers: [],
|
servers: [],
|
||||||
serverInspection: null,
|
serverInspection: null,
|
||||||
gitRecovery: null,
|
gitRecovery: null,
|
||||||
|
workspaceSyncPlan: null,
|
||||||
gitValidation: null,
|
gitValidation: null,
|
||||||
diffHunks: null,
|
diffHunks: null,
|
||||||
conflictState: null,
|
conflictState: null,
|
||||||
@@ -607,6 +608,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();
|
||||||
|
|||||||
@@ -44,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,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ function createMockRepositoryBridge(context) {
|
|||||||
await wait(80);
|
await wait(80);
|
||||||
snapshot();
|
snapshot();
|
||||||
return {
|
return {
|
||||||
appVersion: "0.10.12-demo",
|
appVersion: "0.10.13-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 };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -648,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);
|
||||||
@@ -2653,6 +2671,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;
|
||||||
}
|
}
|
||||||
@@ -3318,6 +3337,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;
|
||||||
@@ -3351,6 +3397,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;
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-5
@@ -183,7 +183,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>`;
|
||||||
@@ -361,7 +361,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><span class="status-pill ${status.branch?.behind || status.branch?.ahead || status.counts?.changed ? "warning" : "success"}">${escapeHtml(syncState)}</span></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) {
|
||||||
@@ -573,7 +594,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>`
|
||||||
@@ -602,10 +623,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>
|
||||||
|
|||||||
@@ -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(/\/$/, '');
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 }));
|
||||||
|
|||||||
@@ -121,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(
|
||||||
|
|||||||
@@ -102,3 +102,47 @@ test('a watched repository is read on filesystem activity instead of on every in
|
|||||||
monitor.stop();
|
monitor.stop();
|
||||||
assert.equal(monitor.watchers.size, 0, 'stopping releases every watcher');
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -320,6 +320,86 @@ test("low-level inventory scan is read-only and user discovery auto-links exact
|
|||||||
assert.deepEqual(discovery.refreshedProfileIds, [profiles[0].id]);
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
test("server inventory includes stopped DockerMan containers without Git and keeps name matches manual", () => {
|
test("server inventory includes stopped DockerMan containers without Git and keeps name matches manual", () => {
|
||||||
|
|||||||
+124
-24
@@ -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 () => {
|
||||||
@@ -480,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;
|
||||||
@@ -508,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({
|
||||||
@@ -537,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 });
|
||||||
@@ -558,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: {
|
||||||
@@ -576,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,
|
||||||
@@ -595,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(
|
||||||
() =>
|
() =>
|
||||||
@@ -602,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/);
|
||||||
@@ -623,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/);
|
||||||
@@ -630,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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user