Release ForgeFlow 0.5.2

This commit is contained in:
NuklearRabbit
2026-07-25 01:07:54 +02:00
parent 602309b203
commit cf1f67a823
23 changed files with 550 additions and 72 deletions
+22 -1
View File
@@ -1,4 +1,25 @@
## 0.4.4 # Changelog
## 0.5.2
- Made release verification and built-in update validation independent of Windows Bash shims.
- Added portable structural safety validation for the Unraid deployment script.
- Kept GNU Bash syntax validation on Linux and other non-Windows systems.
## 0.5.1
- Fixed Windows publication quality gate by validating Bash syntax through standard input.
- Added regression coverage for path-independent shell validation.
## 0.5.0
- Viewport-safe scrollable dialogs with persistent actions.
- NUL-delimited Git pathspec transport for large selections.
- Per-repository mutation serialization and stale lock repair.
- Bulk normalization of legacy Gitea origins.
- Expanded regression coverage.
- Correctly stage deleted and renamed paths. - Correctly stage deleted and renamed paths.
- Preserve and surface local commits when push fails. - Preserve and surface local commits when push fails.
+49
View File
@@ -0,0 +1,49 @@
param(
[string]$Remote = "git@gitea.itworx.tech:Jens/ForgeFlow.git",
[string]$Branch = "main"
)
$ErrorActionPreference = "Stop"
$source = $PSScriptRoot
$manifest = Get-Content (Join-Path $source "package.json") -Raw | ConvertFrom-Json
if ($manifest.name -ne "forgeflow") { throw "Run this script from an extracted ForgeFlow source release." }
$version = [string]$manifest.version
$temp = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-publish-" + [guid]::NewGuid().ToString("N"))
$clone = Join-Path $temp "ForgeFlow"
try {
Write-Host "Validating ForgeFlow $version before publishing..." -ForegroundColor Cyan
Push-Location $source
try {
& cmd.exe /d /s /c "npm install --no-audit --no-fund"
if ($LASTEXITCODE -ne 0) { throw "npm install failed." }
& cmd.exe /d /s /c "npm run check"
if ($LASTEXITCODE -ne 0) { throw "ForgeFlow quality gate failed." }
} finally { Pop-Location }
New-Item -ItemType Directory -Force -Path $temp | Out-Null
Write-Host "Cloning $Remote..." -ForegroundColor Cyan
& git clone --branch $Branch --single-branch $Remote $clone
if ($LASTEXITCODE -ne 0) { throw "Could not clone the ForgeFlow update repository." }
& robocopy.exe $source $clone /MIR /R:2 /W:1 /NFL /NDL /NJH /NJS /NP /XD .git node_modules dist /XF *.zip *.sha256
if ($LASTEXITCODE -gt 7) { throw "Robocopy failed with exit code $LASTEXITCODE." }
Push-Location $clone
try {
& git add -A
$changes = & git status --porcelain
if (-not $changes) {
Write-Host "Gitea already contains ForgeFlow $version; nothing to publish." -ForegroundColor Yellow
exit 0
}
& git commit -m "Release ForgeFlow $version"
if ($LASTEXITCODE -ne 0) { throw "Could not create the release commit." }
& git push origin $Branch
if ($LASTEXITCODE -ne 0) { throw "Could not push ForgeFlow $version to Gitea." }
Write-Host "ForgeFlow $version is now available on Gitea for the built-in updater." -ForegroundColor Green
} finally { Pop-Location }
}
finally {
Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue
}
+22 -2
View File
@@ -1,5 +1,18 @@
# ForgeFlow # ForgeFlow
## Publish this release to the built-in updater repository
Extract the full source ZIP to a folder under Downloads and run:
```powershell
Set-ExecutionPolicy -Scope Process Bypass
.\Publish-ForgeFlow-Release.ps1
```
The script runs the complete quality gate, clones `git@gitea.itworx.tech:Jens/ForgeFlow.git` into a temporary folder, mirrors the verified source without `.git`, `node_modules` or release ZIPs, commits it on `main` and pushes it. A running older ForgeFlow source installation can then update through **Settings → Updates**.
ForgeFlow is a desktop release cockpit that turns the complete path from a local ForgeFlow is a desktop release cockpit that turns the complete path from a local
code change to a verified server deployment into one guided flow: code change to a verified server deployment into one guided flow:
@@ -13,9 +26,16 @@ credentials on the user's own computer.
![ForgeFlow overview](docs/screenshots/overview.png) ![ForgeFlow overview](docs/screenshots/overview.png)
## Current status: v0.4.2 integrated desktop test release ## Current status: v0.5.2 Windows publication reliability release
### v0.4.2 workflow and deployment expansion ### v0.5.2 publication reliability and v0.5.0 workflow hardening
- viewport-safe deployment and server dialogs with persistent action buttons;
- NUL-delimited Git pathspec transport for thousands of selected files;
- per-repository Git mutation queues and explicit stale-lock repair;
- one-click normalization of legacy Gitea remotes;
- corrected password capture before the loading overlay re-renders the server form;
- a verified publishing script for testing the built-in updater.
- large changed-file sets scroll independently; - large changed-file sets scroll independently;
- commit actions explain the missing prerequisite and auto-stage selected files; - commit actions explain the missing prerequisite and auto-stage selected files;
+21 -17
View File
@@ -1,7 +1,8 @@
755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 .gitignore 755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 .gitignore
af25ba17d5e51503943c84366c724bf6f32f4b61e4985e4ffd78e1bb54197493 CHANGELOG.md 330b5ef8c603a39bc04e2eb5b67351c957d43fb8b60b595e3c055ea5facc2162 CHANGELOG.md
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db LICENSE 4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db LICENSE
f1a69461c2ea8edec2b0fecc23db196cafccd730a309f0ad84acd5c5110b45ef README.md 7769cb56a09533305837c155ce8212c55a5f12a933a9e5f72d6f69853c66e274 Publish-ForgeFlow-Release.ps1
2460ec9580231f9786a5f3dfdfdc58e3aecdc57d0b43dfdec3344445275d0350 README.md
e5414be56177664a12d31f8d668d6273628c62ae1a608b9f61580c2f9359bbf1 START_HERE.md e5414be56177664a12d31f8d668d6273628c62ae1a608b9f61580c2f9359bbf1 START_HERE.md
8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea UPDATE_FROM_0.3.2.md 8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea UPDATE_FROM_0.3.2.md
7fbfbba99e6f38029b9a77c8fad8a8e5a91c186306e9e167aae5357c666aab2e build/icon-128.png 7fbfbba99e6f38029b9a77c8fad8a8e5a91c186306e9e167aae5357c666aab2e build/icon-128.png
@@ -28,6 +29,9 @@ e2d67c816a919f00f9e26bf59cf29e5e8cf894536b743d282075c646c5accc96 docs/RELEASE_N
1aef74fb109541903c4dbc4d9c48d2bd63507420eaf8cceb31890797a5e4f5fd docs/RELEASE_NOTES_0.4.3.md 1aef74fb109541903c4dbc4d9c48d2bd63507420eaf8cceb31890797a5e4f5fd docs/RELEASE_NOTES_0.4.3.md
85fecec65f7687e1382547166eff62777613825a8d81960dfcb4ae16aa15c8be docs/RELEASE_NOTES_0.4.4.md 85fecec65f7687e1382547166eff62777613825a8d81960dfcb4ae16aa15c8be docs/RELEASE_NOTES_0.4.4.md
5cd0cffecdce942fb1024a0410568174e7704af9bacbe42f81891693a1817a19 docs/RELEASE_NOTES_0.4.5.md 5cd0cffecdce942fb1024a0410568174e7704af9bacbe42f81891693a1817a19 docs/RELEASE_NOTES_0.4.5.md
f9554c10f56d41d916330f06175b07f099c9ed1534f00abc9ea7f94be70f4a97 docs/RELEASE_NOTES_0.5.0.md
11a932e2c401c53d117175aa312b6d20508918b12192d37c5084bd54a5ea3a72 docs/RELEASE_NOTES_0.5.1.md
d7d007e4c2807698db07b2ebe1cb48c36bd162bf4daad77c9d299096c9654d5a docs/RELEASE_NOTES_0.5.2.md
c465f1a9c4454c9a18f38f68a243037b8897c2c9929077a586604acd4ff26d35 docs/ROADMAP.md c465f1a9c4454c9a18f38f68a243037b8897c2c9929077a586604acd4ff26d35 docs/ROADMAP.md
322624242d246d07180cc719e14c91e8fb69e123676a02e5046f4e576cca1ca1 docs/SECURITY.md 322624242d246d07180cc719e14c91e8fb69e123676a02e5046f4e576cca1ca1 docs/SECURITY.md
c79123aa4c718ac3ab0d79771f2967710c28f939b58fca0094b02e3172f2c024 docs/SETUP_GUIDE.md c79123aa4c718ac3ab0d79771f2967710c28f939b58fca0094b02e3172f2c024 docs/SETUP_GUIDE.md
@@ -35,7 +39,7 @@ b5ba1f7580e47e1f01900964b866d9f15b973a9e9dcccf2650f403595020e949 docs/SSH_UNRAI
b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 docs/STATUS_ENDPOINT.md b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 docs/STATUS_ENDPOINT.md
0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 docs/STITCH_REVIEW.md 0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 docs/STITCH_REVIEW.md
08640f1b5e26048b5ae501909f415d2426b07cc316a0bd2178023f2457aa7a2a docs/TEST_MATRIX.md 08640f1b5e26048b5ae501909f415d2426b07cc316a0bd2178023f2457aa7a2a docs/TEST_MATRIX.md
a620b634532b3cc13c17a5e7de35efab1eb34673279d64de86df9b369cf7d65b docs/UPDATING.md 6794a12b10ce7f35223862b422c202e5ba0308eabedf03339dea0d54f8f3d191 docs/UPDATING.md
1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 docs/screenshots/deploy-confirmation.png 1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 docs/screenshots/deploy-confirmation.png
b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa docs/screenshots/deployment-run.png b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa docs/screenshots/deployment-run.png
070e6700bdae8c628c907ba181bbf0dde0bbbbb4208f7a875503f933ff1b882e docs/screenshots/deployment-success.png 070e6700bdae8c628c907ba181bbf0dde0bbbbb4208f7a875503f933ff1b882e docs/screenshots/deployment-success.png
@@ -49,57 +53,57 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 examples/gitea
106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a examples/server/nginx-forgeflow-status.conf 106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a examples/server/nginx-forgeflow-status.conf
2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a examples/server/status-example.json 2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a examples/server/status-example.json
6e4ef7ec12358d756d2ee6105420a5a440ba8d207a7ee26a35c873c24661d84d main.cjs 6e4ef7ec12358d756d2ee6105420a5a440ba8d207a7ee26a35c873c24661d84d main.cjs
e5bf62b9bef693dfed707a7771d7a25dab3e781ed52223beadf3322b9c1f53be package.json a8f32272f09ca019c6e9b3c92298b47558c1731ea761ae00d93ae77501b91a7f package.json
1e9cfc496c61702a9b083265f08ce451008d5ce8829bb4a447429e6abea3dc89 preload.cjs 97af4d20a26dbf4f9643c4ff007bb48840231a245b02dee9c004fcd1fc6d888f preload.cjs
a39ab8ac36fc81c37c1718ec620d4e590a1c404d01264e07e3c904a5189bdc27 scripts/apply-source-update.ps1 a39ab8ac36fc81c37c1718ec620d4e590a1c404d01264e07e3c904a5189bdc27 scripts/apply-source-update.ps1
f427dfcd7b5ee7079de13633c8d7d22a91115e0bbc4f2a9a96246f42176d4880 scripts/doctor.mjs f427dfcd7b5ee7079de13633c8d7d22a91115e0bbc4f2a9a96246f42176d4880 scripts/doctor.mjs
444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 scripts/serve-demo.mjs 444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 scripts/serve-demo.mjs
13d27b4dc4217a7e77d69056f23c46382b2bcd6b0642c2f25a743ace8283e5dc scripts/verify.mjs 3d6b25c37c92607dbc08b4b6303addb6fb2a28dc2845a5d4eeb9c48deeae7818 scripts/verify.mjs
92524adae60aced3af23f8afe82c011873ae9f1e53d854e4d12a94e8d1be1aa9 setup-windows.ps1 92524adae60aced3af23f8afe82c011873ae9f1e53d854e4d12a94e8d1be1aa9 setup-windows.ps1
366c1edbc90a00fcbf660002e55291ba234d896e7afbe24002d9d6db84b9f44c src/main/config-store.cjs 366c1edbc90a00fcbf660002e55291ba234d896e7afbe24002d9d6db84b9f44c src/main/config-store.cjs
a970ff3f47d1641bf1ab9611e1122349aa65ff8fee4789585e078431368b8c6b src/main/deployment-service.cjs a970ff3f47d1641bf1ab9611e1122349aa65ff8fee4789585e078431368b8c6b src/main/deployment-service.cjs
c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c src/main/diagnostics-service.cjs c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c src/main/diagnostics-service.cjs
a921c1c3a70ffff78f208b69431c525393bdb524d61ab484e85372f1b7d176dc src/main/git-service.cjs 1558fccc76d4eb563940e51b5d483edfe5e0c7987a35ad1dbfcd5f406eb4d44d src/main/git-service.cjs
ab7344b1951c87e982cab5c293891bc45dad48a48a3b4b63991b0e76ef785ba6 src/main/gitea-service.cjs ab7344b1951c87e982cab5c293891bc45dad48a48a3b4b63991b0e76ef785ba6 src/main/gitea-service.cjs
2af3674ca1faaae24a8858ec6dc2f4ccb271112de1534e5d49c00ff50047e731 src/main/ipc.cjs 2d273442b55d7e3ddf0b1cd4c606a162a4ead03c44028e149976a9e1f4f0470a src/main/ipc.cjs
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c src/main/log-redaction.cjs 62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c src/main/log-redaction.cjs
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e src/main/preflight-service.cjs 958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e src/main/preflight-service.cjs
9e35f867b1be78cb850c7872456b2fc00c837b8552c6244057862b7166c26661 src/main/process-runner.cjs 1dc0c997bd2d837f3d27dff58a9443888597b7981c8a1dd1eaa4487176ef716c src/main/process-runner.cjs
e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 src/main/repository-monitor.cjs e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 src/main/repository-monitor.cjs
eca26673564284fce8715bd74201e59fa390800926d968d642d07eec5cc3af66 src/main/repository-service.cjs eca26673564284fce8715bd74201e59fa390800926d968d642d07eec5cc3af66 src/main/repository-service.cjs
65db01a05d842c40bb784c34560870b2e9c3b973c088fcc5ce0db654a585b146 src/main/ssh-service.cjs 65db01a05d842c40bb784c34560870b2e9c3b973c088fcc5ce0db654a585b146 src/main/ssh-service.cjs
db7fa63d85cec92afc9017207495571e7c5ec215aca02cf2aa18e6b8e265ba27 src/main/unraid-deployment-service.cjs db7fa63d85cec92afc9017207495571e7c5ec215aca02cf2aa18e6b8e265ba27 src/main/unraid-deployment-service.cjs
2b39c0c1e84da52026dc95c9962c4976b7f69bc636b186dc8a41f9d4904f208a src/main/update-service.cjs 2b39c0c1e84da52026dc95c9962c4976b7f69bc636b186dc8a41f9d4904f208a src/main/update-service.cjs
63e5b88b5b0a1801ec21d889a7c29ac70767877599c3155e2b06e548f35c4d9e src/renderer/app.js 7ec82a1d4f6d74b44f4bf4a9641f66195c07d82937fa3c89c681ba9d751370fb src/renderer/app.js
2f3448ddaa016105769d20cbe30c461fd7ba5d3bd105865b750aa0ef6b66e1af src/renderer/assets/itworx-mark.png 2f3448ddaa016105769d20cbe30c461fd7ba5d3bd105865b750aa0ef6b66e1af src/renderer/assets/itworx-mark.png
37f7da5a438b88be731c45c027a0fd88d08bd1af3150afa787836fa7baadbc48 src/renderer/assets/itworx-wordmark.png 37f7da5a438b88be731c45c027a0fd88d08bd1af3150afa787836fa7baadbc48 src/renderer/assets/itworx-wordmark.png
0fc26fbc70918e92586098fb0ee5c2f9946758020f930a08a005b270794b5998 src/renderer/index.html 0fc26fbc70918e92586098fb0ee5c2f9946758020f930a08a005b270794b5998 src/renderer/index.html
c25dd7ee946fc7973fe3e9431cb39b22ee4ad608f9c5056f778788c7a387c2b9 src/renderer/mock-bridge.js 37b2dce2a55befd968f589d3ff29b66bb407162c9d0de5e3ffe833ac5c7556ec src/renderer/mock-bridge.js
7fcd281b5307ed9dccbf2fee9b917943d9b02bb0bd3a8577fa607f62ea66d1d5 src/renderer/styles.css 9908a81d4f5d23313eaea042a3593d8107984272698d131ebe4ea246a12f013c src/renderer/styles.css
0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d src/shared/clone-target.cjs 0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d src/shared/clone-target.cjs
029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f src/shared/git-status.cjs 029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f src/shared/git-status.cjs
2778ebcbdf60fdc1cb0749f15565e0e1bd66f3a0d31eb70ae7942a7511a3de75 src/shared/repository-match.cjs 2778ebcbdf60fdc1cb0749f15565e0e1bd66f3a0d31eb70ae7942a7511a3de75 src/shared/repository-match.cjs
7f4d057a3c8e8d22eda9477eea7b144237824ef0f514737831d1881ff8e7f4a4 src/shared/semver.cjs 7f4d057a3c8e8d22eda9477eea7b144237824ef0f514737831d1881ff8e7f4a4 src/shared/semver.cjs
c3135dea1c0d35ad3b4cda9597eac3335d1f16a556f8fb96694df4f9d3b0bf6f src/shared/shell-verification.cjs ede2c95bb045c0005a3931709a0116d9fbcb3faa5f609848a0066c6ba382ca0b src/shared/shell-verification.cjs
2daa98fd421598bfe5fc9757c9b6f4d82c31d1bfece15829928473581d5d2639 src/shared/tool-invocation.cjs 2daa98fd421598bfe5fc9757c9b6f4d82c31d1bfece15829928473581d5d2639 src/shared/tool-invocation.cjs
a97c83b8023d6c0cf49d6f2d5b626ef2341f02670f0de170e840026d28fd1f0e src/shared/validation.cjs a97c83b8023d6c0cf49d6f2d5b626ef2341f02670f0de170e840026d28fd1f0e src/shared/validation.cjs
13b731c38863b1007b0312fd9d89562401b7cce875c952f52429bde74f77a8af src/shared/zip-writer.cjs 13b731c38863b1007b0312fd9d89562401b7cce875c952f52429bde74f77a8af src/shared/zip-writer.cjs
454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 tests/clone-target.test.mjs 454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 tests/clone-target.test.mjs
abb65b39f285da518a48be41aff40d89ceb9c5b0e6091772c2bde171f65daf9b tests/deployment-status.test.mjs abb65b39f285da518a48be41aff40d89ceb9c5b0e6091772c2bde171f65daf9b tests/deployment-status.test.mjs
fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 tests/diagnostics.test.mjs fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 tests/diagnostics.test.mjs
64f3736e8576536221307a0571ea2769893106a6b6b9887f51f5279312b0a0eb tests/git-integration.test.mjs 625d600edf89dadc63e7c7989227d6509bd6f43a298f88804e2ee42afe2036c3 tests/git-integration.test.mjs
5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db tests/git-status.test.mjs 5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db tests/git-status.test.mjs
681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 tests/git-workflows.test.mjs 681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 tests/git-workflows.test.mjs
e914b2bcafbd674c06adfd9bd851ca04e134210691b7f91cd3de26cee37ef5f3 tests/gitea-actions.test.mjs e914b2bcafbd674c06adfd9bd851ca04e134210691b7f91cd3de26cee37ef5f3 tests/gitea-actions.test.mjs
caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 tests/log-redaction.test.mjs caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 tests/log-redaction.test.mjs
c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 tests/preflight.test.mjs c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 tests/preflight.test.mjs
035c18b6801313f9fbdb5d8c5a26e12beae37d33b18c8433b96bd6cb65910a36 tests/renderer-workflow.test.mjs 462fffc71845d6e79f07e1298ef9de7648a8d680087db377ed4901d9ae96a732 tests/renderer-workflow.test.mjs
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 tests/repository-matching.test.mjs 2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 tests/repository-matching.test.mjs
f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 tests/repository-monitor.test.mjs f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 tests/repository-monitor.test.mjs
3c71aa5fb30d9c6fbc4b0ccfcf5112f45cbcc2a60cb990e4551a8813f7155505 tests/security-validation.test.mjs 3c71aa5fb30d9c6fbc4b0ccfcf5112f45cbcc2a60cb990e4551a8813f7155505 tests/security-validation.test.mjs
ecfdad2a03c24898c822fcf05abac89c8f8fe452a05b16fdafc0236a64c27a23 tests/semver.test.mjs ecfdad2a03c24898c822fcf05abac89c8f8fe452a05b16fdafc0236a64c27a23 tests/semver.test.mjs
60ce18a6e538acb29191b0857f45b78ce71102440655f5e070251325261a8151 tests/shell-verification.test.mjs 020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 tests/shell-verification.test.mjs
0f80df71dda957d18a7dabc8286e117a01b3c0de73dc06458beb6e9fc23af0da tests/tool-invocation.test.mjs 8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be tests/tool-invocation.test.mjs
80f748687bc3fb72812388faf34732c8e71cbccd1f9258bc95b7f32c34eb3a84 tests/unraid-deployment.test.mjs 80f748687bc3fb72812388faf34732c8e71cbccd1f9258bc95b7f32c34eb3a84 tests/unraid-deployment.test.mjs
cfc143a618be64456512313f0b244c1310e9c79ce94b7dacb1b627d9de26f750 tests/update-service.test.mjs cfc143a618be64456512313f0b244c1310e9c79ce94b7dacb1b627d9de26f750 tests/update-service.test.mjs
4d1f0a4c46190ca72b51fddf79ec6d4d02e65fa6f42ef3755de5d414f7da75bb tests/validation.test.mjs 4d1f0a4c46190ca72b51fddf79ec6d4d02e65fa6f42ef3755de5d414f7da75bb tests/validation.test.mjs
+14
View File
@@ -0,0 +1,14 @@
# ForgeFlow 0.5.0
## Reliability and viewport release
- All dialogs are constrained to the visible desktop viewport. Long deployment configuration and preflight content scrolls independently while the action footer remains available.
- Large partial selections use Git's NUL-delimited `--pathspec-from-file` interface instead of thousands of command-line arguments. This removes Windows `ENAMETOOLONG` failures.
- Mutating Git operations are serialized per repository, preventing ForgeFlow background actions from competing for `.git/index.lock`.
- Added explicit stale index-lock inspection and repair APIs.
- Added one-click normalization of every linked repository origin to the current Gitea SSH URL, replacing legacy aliases and renamed owners without changing files or commits.
- Source updater remains exact-commit pinned and runs the full quality gate before restart.
## Upgrade test
Push the extracted source to `Jens/ForgeFlow` with package version `0.5.0`. A running 0.4.5 source installation can then use Settings → Updates → Check now → Download update → Apply & restart.
+16
View File
@@ -0,0 +1,16 @@
# ForgeFlow 0.5.1
## Windows publication reliability
- Validates the server deployment shell script through Bash standard input instead of passing a Windows working directory to Bash.
- Removes the Git Bash versus WSL path ambiguity that caused a blank-error quality-gate failure from Downloads.
- Adds regression coverage proving shell validation no longer depends on a Windows path or a path containing spaces.
- Retains all v0.5.0 viewport, Git batching, remote normalization, repository serialization and password-form fixes.
Publish the extracted source to `Jens/ForgeFlow` with package version `0.5.1`. A running older source installation can then discover and apply it through the built-in updater.
## Carried forward from 0.5.0
- Responsive viewport handling keeps long modals and their actions reachable.
- Large Git selections continue to use `--pathspec-from-file` with NUL separation.
- Mutating Git work remains serialized per repository.
+11
View File
@@ -0,0 +1,11 @@
# ForgeFlow 0.5.2
## Windows publication and update reliability
- Removes the external Bash executable as a Windows publication/update prerequisite.
- Always performs deterministic structural validation of the Linux/Unraid deployment script.
- Runs GNU Bash `-n` syntax validation on non-Windows hosts and Linux CI.
- Prevents Git Bash, WSL launcher, or another `bash.exe` shim from blocking a valid Windows release.
- Keeps all ForgeFlow 0.5.0 and 0.5.1 viewport, Git batching, remote normalization, serialized per repository, lock handling, SSH form, and updater improvements.
The release retains the viewport fixes, Git `--pathspec-from-file` batching, and Git mutations serialized per repository from 0.5.0/0.5.1.
+5
View File
@@ -53,3 +53,8 @@ The updater deliberately checks the semantic version stored in the remote
`package.json`. Merely pushing a new commit without increasing that version does `package.json`. Merely pushing a new commit without increasing that version does
not present an update. Publish the complete validated ForgeFlow source to the not present an update. Publish the complete validated ForgeFlow source to the
configured repository and bump the version for every release. configured repository and bump the version for every release.
## Publishing v0.5.1 from a Downloads folder
Extract the complete release and run `Publish-ForgeFlow-Release.ps1`. The script validates the source, clones `Jens/ForgeFlow` into a temporary directory, mirrors the verified source, commits it and pushes `main`. Keep the currently running older ForgeFlow source folder untouched; use its **Settings → Updates** screen to test the exact-commit download, validation, rollback and restart path.
+5 -5
View File
@@ -1,12 +1,12 @@
{ {
"name": "forgeflow", "name": "forgeflow",
"version": "0.4.5", "version": "0.5.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "forgeflow", "name": "forgeflow",
"version": "0.4.5", "version": "0.5.2",
"dependencies": { "dependencies": {
"ssh2": "1.17.0" "ssh2": "1.17.0"
}, },
@@ -3121,9 +3121,9 @@
} }
}, },
"node_modules/sax": { "node_modules/sax": {
"version": "1.6.0", "version": "1.6.1",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
"dev": true, "dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"engines": { "engines": {
+9 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "forgeflow", "name": "forgeflow",
"version": "0.4.5", "version": "0.5.2",
"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",
@@ -51,7 +51,13 @@
"docs/SSH_UNRAID_DEPLOYMENT.md", "docs/SSH_UNRAID_DEPLOYMENT.md",
"docs/RELEASE_NOTES_0.4.1.md", "docs/RELEASE_NOTES_0.4.1.md",
"docs/RELEASE_NOTES_0.4.2.md", "docs/RELEASE_NOTES_0.4.2.md",
"docs/RELEASE_NOTES_0.4.3.md" "docs/RELEASE_NOTES_0.4.3.md",
"docs/RELEASE_NOTES_0.4.4.md",
"docs/RELEASE_NOTES_0.4.5.md",
"docs/RELEASE_NOTES_0.5.0.md",
"docs/RELEASE_NOTES_0.5.1.md",
"Publish-ForgeFlow-Release.ps1",
"docs/RELEASE_NOTES_0.5.2.md"
], ],
"directories": { "directories": {
"output": "dist" "output": "dist"
@@ -85,8 +91,5 @@
"author": "Jens", "author": "Jens",
"dependencies": { "dependencies": {
"ssh2": "1.17.0" "ssh2": "1.17.0"
}, }
"files": [
"docs/RELEASE_NOTES_0.4.4.md"
]
} }
+4
View File
@@ -62,6 +62,10 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({
stash: (localPath, message) => invoke('repository:stash', { localPath, message }), stash: (localPath, message) => invoke('repository:stash', { localPath, message }),
stashList: (localPath) => invoke('repository:stash-list', { localPath }), stashList: (localPath) => invoke('repository:stash-list', { localPath }),
popStash: (localPath, ref) => invoke('repository:stash-pop', { localPath, ref }), popStash: (localPath, ref) => invoke('repository:stash-pop', { localPath, ref }),
indexLockInfo: (localPath) => invoke('repository:index-lock', { localPath }),
repairIndexLock: (localPath) => invoke('repository:repair-index-lock', { localPath }),
setOrigin: (localPath, remoteUrl) => invoke('repository:set-origin', { localPath, remoteUrl }),
normalizeOrigins: () => invoke('repositories:normalize-origins'),
cloneRepository: (fullName, mode = 'default') => invoke('repository:clone', { fullName, mode }), cloneRepository: (fullName, mode = 'default') => invoke('repository:clone', { fullName, mode }),
openPath: (localPath) => invoke('repository:open-path', { localPath }), openPath: (localPath) => invoke('repository:open-path', { localPath }),
openExternal: (url) => invoke('external:open', { url }), openExternal: (url) => invoke('external:open', { url }),
+23 -8
View File
@@ -18,7 +18,9 @@ const required = [
'setup-windows.ps1', 'update-windows.ps1', 'build-windows.ps1', 'UPDATE_FROM_0.3.2.md', 'scripts/apply-source-update.ps1', 'setup-windows.ps1', 'update-windows.ps1', 'build-windows.ps1', 'UPDATE_FROM_0.3.2.md', 'scripts/apply-source-update.ps1',
'docs/ARCHITECTURE.md', 'docs/SECURITY.md', 'docs/ROADMAP.md', 'docs/SETUP_GUIDE.md', 'docs/ARCHITECTURE.md', 'docs/SECURITY.md', 'docs/ROADMAP.md', 'docs/SETUP_GUIDE.md',
'docs/UPDATING.md', 'docs/DIAGNOSTICS.md', 'docs/DEPLOYMENT_SETUP.md', 'docs/SSH_UNRAID_DEPLOYMENT.md', 'docs/UPDATING.md', 'docs/DIAGNOSTICS.md', 'docs/DEPLOYMENT_SETUP.md', 'docs/SSH_UNRAID_DEPLOYMENT.md',
'docs/LUMAOPS_SERVER_AUDIT.md', 'docs/STATUS_ENDPOINT.md', 'docs/TEST_MATRIX.md', 'docs/RELEASE_NOTES_0.4.0.md', 'docs/RELEASE_NOTES_0.4.1.md', 'docs/RELEASE_NOTES_0.4.2.md', 'docs/RELEASE_NOTES_0.4.3.md', 'docs/RELEASE_NOTES_0.4.4.md', 'docs/RELEASE_NOTES_0.4.5.md', 'docs/LUMAOPS_SERVER_AUDIT.md', 'docs/STATUS_ENDPOINT.md', 'docs/TEST_MATRIX.md', 'docs/RELEASE_NOTES_0.4.0.md', 'docs/RELEASE_NOTES_0.4.1.md', 'docs/RELEASE_NOTES_0.4.2.md', 'docs/RELEASE_NOTES_0.4.3.md',
'docs/RELEASE_NOTES_0.5.0.md', 'docs/RELEASE_NOTES_0.5.1.md', 'docs/RELEASE_NOTES_0.5.2.md',
'Publish-ForgeFlow-Release.ps1', 'docs/RELEASE_NOTES_0.4.4.md', 'docs/RELEASE_NOTES_0.4.5.md',
'examples/gitea-actions/deploy.yml', 'examples/gitea-actions/rollback.yml', 'examples/gitea-actions/deploy.yml', 'examples/gitea-actions/rollback.yml',
'examples/server/forgeflow-deploy', 'examples/server/forgeflow-targets.conf', 'examples/server/forgeflow-deploy', 'examples/server/forgeflow-targets.conf',
'examples/server/forgeflow-runner.sudoers', 'examples/server/status-example.json', 'examples/server/forgeflow-runner.sudoers', 'examples/server/status-example.json',
@@ -28,7 +30,7 @@ const required = [
for (const file of required) await access(path.join(root, file)); for (const file of required) await access(path.join(root, file));
const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')); const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
if (packageJson.version !== '0.4.5') throw new Error(`Expected package version 0.4.5, got ${packageJson.version}.`); if (packageJson.version !== '0.5.2') throw new Error(`Expected package version 0.5.2, got ${packageJson.version}.`);
for (const group of ['dependencies', 'devDependencies']) { for (const group of ['dependencies', 'devDependencies']) {
for (const [name, version] of Object.entries(packageJson[group] || {})) { for (const [name, version] of Object.entries(packageJson[group] || {})) {
if (/^[~^*]/.test(version)) throw new Error(`${group} dependency ${name} must be pinned exactly, got ${version}.`); if (/^[~^*]/.test(version)) throw new Error(`${group} dependency ${name} must be pinned exactly, got ${version}.`);
@@ -58,16 +60,29 @@ for (const file of javascriptFiles) {
if (result.status !== 0) throw new Error(`${path.relative(root, file)} failed syntax validation:\n${result.stderr}`); if (result.status !== 0) throw new Error(`${path.relative(root, file)} failed syntax validation:\n${result.stderr}`);
} }
const bashCheck = shellVerification.bashSyntaxCheckInvocation(root); const deploymentScript = await readFile(path.join(root, 'examples/server/forgeflow-deploy'), 'utf8');
const shell = spawnSync(bashCheck.command, bashCheck.args, bashCheck.options); shellVerification.validateShellScriptStructure(deploymentScript);
if (shell.error) throw new Error(`Unable to start Bash for server deployment syntax validation: ${shell.error.message}`);
if (shell.status !== 0) throw new Error(`Server deployment example failed bash syntax validation:\n${shell.stderr}`); // The server deployment script targets Linux/Unraid. On Windows, different tools may
// register themselves as bash.exe (Git Bash, WSL launcher, MSYS), and several of
// those cannot reliably accept a script over stdin from Node. Publishing and applying
// a desktop update therefore never depend on a Windows Bash shim. Portable structural
// validation always runs; GNU Bash syntax validation additionally runs on non-Windows.
if (shellVerification.shouldRunExternalBash(process.platform)) {
const bashCheck = shellVerification.bashSyntaxCheckFromTextInvocation(deploymentScript);
const shell = spawnSync(bashCheck.command, bashCheck.args, bashCheck.options);
if (shell.error) throw new Error(`Unable to start Bash for server deployment syntax validation: ${shell.error.message}`);
if (shell.status !== 0) throw new Error(`Server deployment example failed bash syntax validation:
${shell.stderr || shell.stdout || 'Bash returned a non-zero status.'}`);
} else {
console.log('Windows: external Bash syntax validation skipped; portable server-script validation passed.');
}
JSON.parse(await readFile(path.join(root, 'examples/server/status-example.json'), 'utf8')); JSON.parse(await readFile(path.join(root, 'examples/server/status-example.json'), 'utf8'));
const setupGuide = await readFile(path.join(root, 'docs/SETUP_GUIDE.md'), 'utf8'); const setupGuide = await readFile(path.join(root, 'docs/SETUP_GUIDE.md'), 'utf8');
const sshGuide = await readFile(path.join(root, 'docs/SSH_UNRAID_DEPLOYMENT.md'), 'utf8'); const sshGuide = await readFile(path.join(root, 'docs/SSH_UNRAID_DEPLOYMENT.md'), 'utf8');
const audit = await readFile(path.join(root, 'docs/LUMAOPS_SERVER_AUDIT.md'), 'utf8'); const audit = await readFile(path.join(root, 'docs/LUMAOPS_SERVER_AUDIT.md'), 'utf8');
const releaseNotes = await readFile(path.join(root, 'docs/RELEASE_NOTES_0.4.5.md'), 'utf8'); const releaseNotes = await readFile(path.join(root, 'docs/RELEASE_NOTES_0.5.2.md'), 'utf8');
if (!setupGuide.includes('Gitea access token') || !setupGuide.includes('diagnostic bundle')) { if (!setupGuide.includes('Gitea access token') || !setupGuide.includes('diagnostic bundle')) {
throw new Error('Setup guide is missing required connection or diagnostics instructions.'); throw new Error('Setup guide is missing required connection or diagnostics instructions.');
} }
@@ -77,7 +92,7 @@ if (!sshGuide.includes('/mnt/user/appdata') || !sshGuide.includes('host-key fing
if (!audit.includes('d42d4a7f08240c478d07466e3fabec654dc71367') || !audit.includes('source/')) { if (!audit.includes('d42d4a7f08240c478d07466e3fabec654dc71367') || !audit.includes('source/')) {
throw new Error('LumaOps audit is missing the exact matching SHA or nested repository finding.'); throw new Error('LumaOps audit is missing the exact matching SHA or nested repository finding.');
} }
for (const phrase of ['already staged', 'git add -A', 'silent-zebra-glow.zip']) { for (const phrase of ['viewport', '--pathspec-from-file', 'serialized per repository']) {
if (!releaseNotes.includes(phrase)) throw new Error(`Release notes are missing: ${phrase}`); if (!releaseNotes.includes(phrase)) throw new Error(`Release notes are missing: ${phrase}`);
} }
const renderer = await readFile(path.join(root, 'src/renderer/app.js'), 'utf8'); const renderer = await readFile(path.join(root, 'src/renderer/app.js'), 'utf8');
+49 -3
View File
@@ -57,6 +57,50 @@ class GitService {
return result.stdout.trim(); return result.stdout.trim();
} }
pathspecInput(paths) {
const selected = assertRepositoryRelativePaths(paths);
return selected.length ? `${selected.join('\0')}\0` : '';
}
async runWithPathspec(root, args, paths, options = {}) {
const selected = assertRepositoryRelativePaths(paths);
if (!selected.length) return run('git', args, { cwd: root, ...options });
return run('git', [...args, '--pathspec-from-file=-', '--pathspec-file-nul'], {
cwd: root,
input: this.pathspecInput(selected),
...options
});
}
async getIndexLockInfo(repoPath) {
const root = await this.ensureRepository(repoPath);
const lockPath = path.join(root, '.git', 'index.lock');
const stat = await fs.stat(lockPath).catch(() => null);
return stat ? { exists: true, lockPath, ageMs: Math.max(0, Date.now() - stat.mtimeMs) } : { exists: false, lockPath, ageMs: 0 };
}
async removeStaleIndexLock(repoPath, minimumAgeMs = 30_000) {
const info = await this.getIndexLockInfo(repoPath);
if (!info.exists) return { removed: false, reason: 'missing', ...info };
if (info.ageMs < minimumAgeMs) {
const error = new Error('The Git index lock is recent. Close other Git tools and try again before removing it.');
error.code = 'INDEX_LOCK_RECENT';
throw error;
}
await fs.rm(info.lockPath, { force: true });
return { removed: true, ...info };
}
async setRemoteUrl(repoPath, remoteUrl, remote = 'origin') {
const root = await this.ensureRepository(repoPath);
const safeRemote = assertCloneRemote(remoteUrl);
const name = String(remote || 'origin').trim();
if (!/^[A-Za-z0-9._-]+$/.test(name)) throw new Error('Invalid Git remote name.');
await run('git', ['remote', 'set-url', name, safeRemote], { cwd: root, timeout: 30_000 });
return this.status(root);
}
async diff(repoPath, filePath, staged = false) { async diff(repoPath, filePath, staged = false) {
const root = await this.ensureRepository(repoPath); const root = await this.ensureRepository(repoPath);
const safeFile = filePath ? assertRepositoryRelativePath(filePath) : ''; const safeFile = filePath ? assertRepositoryRelativePath(filePath) : '';
@@ -109,7 +153,7 @@ class GitService {
// renames are already ready for commit and must therefore be left alone. // renames are already ready for commit and must therefore be left alone.
const selected = await this.expandSelectedPaths(root, requested, { unstagedOnly: true }); const selected = await this.expandSelectedPaths(root, requested, { unstagedOnly: true });
if (selected.length) { if (selected.length) {
await run('git', ['add', '-A', '--', ...selected], { cwd: root, timeout: 60_000 }); await this.runWithPathspec(root, ['add', '-A'], selected, { timeout: 120_000 });
} }
return this.status(root); return this.status(root);
} }
@@ -119,9 +163,11 @@ class GitService {
const selected = await this.expandSelectedPaths(root, files); const selected = await this.expandSelectedPaths(root, files);
const hasHead = await run('git', ['rev-parse', '--verify', 'HEAD'], { cwd: root, allowExitCodes: [128] }); const hasHead = await run('git', ['rev-parse', '--verify', 'HEAD'], { cwd: root, allowExitCodes: [128] });
if (hasHead.exitCode === 0) { if (hasHead.exitCode === 0) {
await run('git', selected.length ? ['restore', '--staged', '--', ...selected] : ['restore', '--staged', '.'], { cwd: root }); if (selected.length) await this.runWithPathspec(root, ['restore', '--staged'], selected, { timeout: 120_000 });
else await run('git', ['restore', '--staged', '.'], { cwd: root });
} else { } else {
await run('git', selected.length ? ['rm', '--cached', '--ignore-unmatch', '--', ...selected] : ['rm', '--cached', '-r', '.'], { cwd: root, allowExitCodes: [1] }); if (selected.length) await this.runWithPathspec(root, ['rm', '--cached', '--ignore-unmatch'], selected, { timeout: 120_000, allowExitCodes: [1] });
else await run('git', ['rm', '--cached', '-r', '.'], { cwd: root, allowExitCodes: [1] });
} }
return this.status(root); return this.status(root);
} }
+39 -11
View File
@@ -55,11 +55,20 @@ function register(channel, handler) {
function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh, updates, preflight, diagnostics, monitor }) { function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh, updates, preflight, diagnostics, monitor }) {
diagnosticsService = diagnostics; diagnosticsService = diagnostics;
const repositoryMutations = new Map();
const withRepositoryPause = async (localPath, action) => { const withRepositoryPause = async (localPath, action) => {
monitor?.pause(localPath); monitor?.pause(localPath);
try { return await action(); } try { return await action(); }
finally { monitor?.resume(localPath); } finally { monitor?.resume(localPath); }
}; };
const withRepositoryMutation = async (localPath, action) => {
const key = path.resolve(localPath);
const previous = repositoryMutations.get(key) || Promise.resolve();
const current = previous.catch(() => {}).then(() => withRepositoryPause(key, action));
repositoryMutations.set(key, current);
try { return await current; }
finally { if (repositoryMutations.get(key) === current) repositoryMutations.delete(key); }
};
const canonicalPath = async (value) => { const canonicalPath = async (value) => {
const resolved = path.resolve(String(value || '')); const resolved = path.resolve(String(value || ''));
@@ -262,20 +271,39 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
register('repository:status', async ({ localPath }) => git.status(await assertKnownRepositoryPath(localPath))); register('repository:status', async ({ localPath }) => git.status(await assertKnownRepositoryPath(localPath)));
register('repository:diff', async ({ localPath, filePath, staged }) => git.diff(await assertKnownRepositoryPath(localPath), filePath, staged)); register('repository:diff', async ({ localPath, filePath, staged }) => git.diff(await assertKnownRepositoryPath(localPath), filePath, staged));
register('repository:stage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.stage(safePath, files)); }); register('repository:stage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.stage(safePath, files)); });
register('repository:unstage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.unstage(safePath, files)); }); register('repository:unstage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.unstage(safePath, files)); });
register('repository:commit', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.commit(safePath, message, files)); }); register('repository:commit', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.commit(safePath, message, files)); });
register('repository:commit-push', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.commitAndPush(safePath, message, files)); }); register('repository:commit-push', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.commitAndPush(safePath, message, files)); });
register('repository:push', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.push(safePath)); }); register('repository:push', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.push(safePath)); });
register('repository:fetch', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.fetch(safePath)); }); register('repository:fetch', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.fetch(safePath)); });
register('repository:pull', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.pullFastForward(safePath)); }); register('repository:pull', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.pullFastForward(safePath)); });
register('repository:history', async ({ localPath, limit }) => git.history(await assertKnownRepositoryPath(localPath), limit)); register('repository:history', async ({ localPath, limit }) => git.history(await assertKnownRepositoryPath(localPath), limit));
register('repository:branches', async ({ localPath }) => git.branches(await assertKnownRepositoryPath(localPath))); register('repository:branches', async ({ localPath }) => git.branches(await assertKnownRepositoryPath(localPath)));
register('repository:checkout-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.checkoutBranch(safePath, branch)); }); register('repository:checkout-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.checkoutBranch(safePath, branch)); });
register('repository:create-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.createBranch(safePath, branch)); }); register('repository:create-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.createBranch(safePath, branch)); });
register('repository:stash', async ({ localPath, message }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.stash(safePath, message)); }); register('repository:stash', async ({ localPath, message }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.stash(safePath, message)); });
register('repository:stash-list', async ({ localPath }) => git.stashList(await assertKnownRepositoryPath(localPath))); register('repository:stash-list', async ({ localPath }) => git.stashList(await assertKnownRepositoryPath(localPath)));
register('repository:stash-pop', async ({ localPath, ref }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.popStash(safePath, ref)); }); register('repository:stash-pop', async ({ localPath, ref }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.popStash(safePath, ref)); });
register('repository:index-lock', async ({ localPath }) => git.getIndexLockInfo(await assertKnownRepositoryPath(localPath)));
register('repository:repair-index-lock', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.removeStaleIndexLock(safePath)); });
register('repository:set-origin', async ({ localPath, remoteUrl }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.setRemoteUrl(safePath, remoteUrl)); });
register('repositories:normalize-origins', async () => {
const current = await repositories.refresh();
const changes = [];
for (const repository of current) {
if (!repository.localPath || !repository.sshUrl) continue;
const actual = await git.getRemoteUrl(repository.localPath).catch(() => '');
if (actual === repository.sshUrl) continue;
await withRepositoryMutation(repository.localPath, () => git.setRemoteUrl(repository.localPath, repository.sshUrl));
changes.push({ fullName: repository.fullName, previous: actual, next: repository.sshUrl });
}
const refreshed = await repositories.refresh();
monitor?.setPaths(repositories.getWatchPaths());
await diagnostics.info('repositories.origins.normalized', { count: changes.length, changes });
return { changes, repositories: refreshed };
});
register('repository:clone', async ({ fullName, mode = 'default' }) => { register('repository:clone', async ({ fullName, mode = 'default' }) => {
if (!['default', 'custom'].includes(mode)) throw new Error('Unsupported clone location mode.'); if (!['default', 'custom'].includes(mode)) throw new Error('Unsupported clone location mode.');
+15 -2
View File
@@ -8,11 +8,12 @@ function run(command, args = [], options = {}) {
timeout = 60_000, timeout = 60_000,
maxBuffer = 8 * 1024 * 1024, maxBuffer = 8 * 1024 * 1024,
env, env,
input = null,
allowExitCodes = [] allowExitCodes = []
} = options; } = options;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
execFile(command, args, { const child = execFile(command, args, {
cwd, cwd,
timeout, timeout,
maxBuffer, maxBuffer,
@@ -21,8 +22,16 @@ function run(command, args = [], options = {}) {
env: { ...process.env, ...(env || {}) } env: { ...process.env, ...(env || {}) }
}, (error, stdout, stderr) => { }, (error, stdout, stderr) => {
if (error && !allowExitCodes.includes(error.code)) { if (error && !allowExitCodes.includes(error.code)) {
const wrapped = new Error((stderr || stdout || error.message).trim()); const message = (stderr || stdout || error.message).trim();
const wrapped = new Error(message);
wrapped.code = error.code; wrapped.code = error.code;
if (/\.git[\\/]index\.lock[\s\S]*File exists/i.test(message) || /Unable to create .*index\.lock/i.test(message)) {
wrapped.code = 'GIT_INDEX_LOCKED';
wrapped.recoverable = true;
} else if (error.code === 'ENAMETOOLONG') {
wrapped.code = 'GIT_ARGUMENT_LIST_TOO_LONG';
wrapped.recoverable = true;
}
wrapped.stdout = stdout; wrapped.stdout = stdout;
wrapped.stderr = stderr; wrapped.stderr = stderr;
wrapped.command = `${command} ${args.join(' ')}`; wrapped.command = `${command} ${args.join(' ')}`;
@@ -31,6 +40,10 @@ function run(command, args = [], options = {}) {
} }
resolve({ stdout: stdout || '', stderr: stderr || '', exitCode: error?.code || 0 }); resolve({ stdout: stdout || '', stderr: stderr || '', exitCode: error?.code || 0 });
}); });
if (input !== null && input !== undefined) {
child.stdin.on('error', () => {});
child.stdin.end(input);
}
}); });
} }
+38 -7
View File
@@ -453,7 +453,10 @@ function renderGitTools(repository) {
function renderRepositorySettings(repository) { function renderRepositorySettings(repository) {
const automaticTarget = displayCloneTarget(repository); const automaticTarget = displayCloneTarget(repository);
return `<div class="tab-page"><section class="settings-group"><h2>Repository identity</h2><div class="form-grid"><div class="field full"><label>Gitea repository</label><input class="input" value="${attr(repository.fullName)}" readonly/></div><div class="field full"><label>Local working tree</label><input class="input mono" value="${attr(repository.localPath || automaticTarget || 'Not linked')}" readonly/></div></div><div class="card-actions"><button class="button" data-action="${repository.localPath ? 'open-path' : 'link-repo'}">${icon('folder')}${repository.localPath ? 'Open project folder' : 'Link local folder'}</button>${repository.localPath ? `<button class="button danger" data-action="unlink-repo">${icon('link')}Remove link</button>` : `<button class="button primary" data-action="clone-repo">${icon('cloud')}${escapeHtml(clonePrimaryLabel(repository))}</button><button class="button ghost" data-action="clone-repo-custom">Choose another location</button>`}</div></section><section class="settings-group"><h2>Repository behavior</h2><div class="notice">${icon('shield')}ForgeFlow automatically creates a repository-named subfolder and never overwrites a non-empty conflicting folder. Existing matching clones are linked instead.</div></section></div>`; const currentOrigin = repository.localStatus?.remoteUrl || 'Unavailable';
const desiredOrigin = repository.sshUrl || repository.preferredCloneUrl || '';
const originNeedsRepair = Boolean(repository.localPath && desiredOrigin && currentOrigin !== desiredOrigin);
return `<div class="tab-page"><section class="settings-group"><h2>Repository identity</h2><div class="form-grid"><div class="field full"><label>Gitea repository</label><input class="input" value="${attr(repository.fullName)}" readonly/></div><div class="field full"><label>Local working tree</label><input class="input mono" value="${attr(repository.localPath || automaticTarget || 'Not linked')}" readonly/></div><div class="field full"><label>Current origin</label><input class="input mono" value="${attr(currentOrigin)}" readonly/></div>${desiredOrigin ? `<div class="field full"><label>Current Gitea SSH origin</label><input class="input mono" value="${attr(desiredOrigin)}" readonly/></div>` : ''}</div><div class="card-actions"><button class="button" data-action="${repository.localPath ? 'open-path' : 'link-repo'}">${icon('folder')}${repository.localPath ? 'Open project folder' : 'Link local folder'}</button>${originNeedsRepair ? `<button class="button primary" data-action="repair-origin">${icon('link')}Use current Gitea origin</button>` : ''}${repository.localPath ? `<button class="button" data-action="repair-index-lock">${icon('key')}Repair stale Git lock</button><button class="button danger" data-action="unlink-repo">${icon('link')}Remove link</button>` : `<button class="button primary" data-action="clone-repo">${icon('cloud')}${escapeHtml(clonePrimaryLabel(repository))}</button><button class="button ghost" data-action="clone-repo-custom">Choose another location</button>`}</div></section><section class="settings-group"><h2>Repository behavior</h2><div class="notice">${icon('shield')}Origin repair changes only the Git remote URL. Lock repair refuses recent locks and never changes files or commits.</div></section></div>`;
} }
function renderRepositoryWorkspace(repository) { function renderRepositoryWorkspace(repository) {
@@ -508,6 +511,7 @@ function renderSettings() {
<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 entered locally and encrypted with the Windows credential protection used by Electron.</span></div><button class="button primary" data-action="open-add-server">${icon('plus')}Add server</button></div>${servers.length ? `<div class="server-list">${servers.map((server) => `<article class="server-card"><div class="server-card-main">${icon('server')}<div><strong>${escapeHtml(server.name)}</strong><span>${escapeHtml(server.username)}@${escapeHtml(server.host)}:${escapeHtml(server.port)} · ${escapeHtml(server.basePath)}</span><small>${server.hostFingerprint ? `Trusted ${escapeHtml(server.hostFingerprint)}` : 'Host identity not trusted yet'}</small></div></div><div class="stack horizontal compact"><button class="button" data-action="test-server" data-server-id="${attr(server.id)}">Test & trust</button><button class="button" data-action="edit-server" data-server-id="${attr(server.id)}">Edit</button><button class="icon-button danger" data-action="delete-server" data-server-id="${attr(server.id)}" title="Delete server">${icon('trash')}</button></div></article>`).join('')}</div>` : '<div class="empty-state compact"><p>No SSH server configured. Add your Unraid server before creating an SSH deployment profile.</p></div>'}</section>
<section class="settings-group"><div class="section-heading"><div><h2>Git remote maintenance</h2><span class="meta">Standardize linked repositories to the current Gitea SSH URLs.</span></div><button class="button" data-action="normalize-origins">${icon('link')}Normalize all origins</button></div><p>This replaces legacy aliases and renamed owners only after an explicit click. Local commits and files are not changed.</p></section>
<section class="settings-group"><h2>Project roots</h2><p>The first folder is the default clone destination. ForgeFlow automatically creates one subfolder per repository.</p><div class="stack">${state.workspaceRoots.map((root, index) => `<div class="root-row">${index === 0 ? '<span class="status-pill success">Default</span>' : ''}<input class="input" data-root-index="${index}" value="${attr(root)}"/><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)}"/><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>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>Appearance</h2><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></section> <section class="settings-group"><h2>Appearance</h2><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></section>
@@ -584,7 +588,7 @@ function renderModal() {
const provider = ui.modal.provider || existing.provider || (servers.length ? 'ssh-unraid' : 'gitea-actions'); const provider = ui.modal.provider || existing.provider || (servers.length ? 'ssh-unraid' : 'gitea-actions');
const ssh = provider === 'ssh-unraid'; const ssh = provider === 'ssh-unraid';
const remoteFolder = existing.remoteFolder || safeCloneFolderName(repository); const remoteFolder = existing.remoteFolder || safeCloneFolderName(repository);
return `<div class="modal-backdrop"><section class="modal wide-modal"><header class="modal-header"><h2>${existing.id ? 'Edit' : 'Add'} deployment environment</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="form-grid"><div class="field full"><label>Deployment provider</label><select id="profile-provider" class="select"><option value="ssh-unraid" ${ssh ? 'selected' : ''}>SSH / Unraid · direct controlled deployment</option><option value="gitea-actions" ${!ssh ? 'selected' : ''}>Gitea Actions · runner workflow</option></select></div><div class="field"><label>Profile name</label><input id="profile-name" class="input" value="${attr(existing.name || 'Production')}" /></div><div class="field"><label>Environment</label><input id="profile-environment" class="input" value="${attr(existing.environment || 'production')}" /></div><div class="field"><label>Allowed branch</label><input id="profile-branch" class="input" value="${attr(existing.branch || repository?.defaultBranch || 'main')}" /></div>${ssh ? ` return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>${existing.id ? 'Edit' : 'Add'} deployment environment</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="form-grid"><div class="field full"><label>Deployment provider</label><select id="profile-provider" class="select"><option value="ssh-unraid" ${ssh ? 'selected' : ''}>SSH / Unraid · direct controlled deployment</option><option value="gitea-actions" ${!ssh ? 'selected' : ''}>Gitea Actions · runner workflow</option></select></div><div class="field"><label>Profile name</label><input id="profile-name" class="input" value="${attr(existing.name || 'Production')}" /></div><div class="field"><label>Environment</label><input id="profile-environment" class="input" value="${attr(existing.environment || 'production')}" /></div><div class="field"><label>Allowed branch</label><input id="profile-branch" class="input" value="${attr(existing.branch || repository?.defaultBranch || 'main')}" /></div>${ssh ? `
<div class="field"><label>Unraid server</label><select id="profile-server" class="select">${servers.length ? servers.map((server) => `<option value="${attr(server.id)}" ${server.id === existing.serverId ? 'selected' : ''}>${escapeHtml(server.name)} · ${escapeHtml(server.host)}</option>`).join('') : '<option value="">Configure a server first</option>'}</select></div> <div class="field"><label>Unraid server</label><select id="profile-server" class="select">${servers.length ? servers.map((server) => `<option value="${attr(server.id)}" ${server.id === existing.serverId ? 'selected' : ''}>${escapeHtml(server.name)} · ${escapeHtml(server.host)}</option>`).join('') : '<option value="">Configure a server first</option>'}</select></div>
<div class="field"><label>Server folder name</label><input id="profile-remote-folder" class="input" value="${attr(remoteFolder)}"/></div> <div class="field"><label>Server folder name</label><input id="profile-remote-folder" class="input" value="${attr(remoteFolder)}"/></div>
<div class="field"><label>Git clone URL used by Unraid</label><input id="profile-clone-url" class="input" value="${attr(existing.cloneUrl || repository?.sshUrl || '')}" placeholder="ssh://git@gitea:222/Jens/project.git"/></div> <div class="field"><label>Git clone URL used by Unraid</label><input id="profile-clone-url" class="input" value="${attr(existing.cloneUrl || repository?.sshUrl || '')}" placeholder="ssh://git@gitea:222/Jens/project.git"/></div>
@@ -607,21 +611,21 @@ function renderModal() {
if (ui.modal.type === 'deployment-preflight') { if (ui.modal.type === 'deployment-preflight') {
const profile = repository?.deploymentProfiles?.find((item) => item.id === ui.modal.profileId) || selectedProfile(repository); const profile = repository?.deploymentProfiles?.find((item) => item.id === ui.modal.profileId) || selectedProfile(repository);
const report = ui.deploymentPreflight; const report = ui.deploymentPreflight;
return `<div class="modal-backdrop"><section class="modal wide-modal"><header class="modal-header"><h2>Deployment preflight</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="confirm-hero ${report?.summary?.ready ? '' : 'danger'}">${icon(report?.summary?.ready ? 'shield' : 'error')}<div><strong>${report?.summary?.ready ? 'Environment is ready to test' : 'Deployment is blocked'}</strong><span>${escapeHtml(repository?.fullName || '')}${escapeHtml(profile?.environment || '')}</span></div></div><div class="preflight-summary"><span class="status-pill ${report?.summary?.ready ? 'success' : 'danger'}">${report?.summary?.ready ? 'Ready' : `${report?.summary?.blocking?.length || 0} blocking`}</span><span>${report?.summary?.counts?.pass || 0} passed · ${report?.summary?.counts?.warning || 0} warnings · ${report?.summary?.counts?.fail || 0} failed</span></div>${renderPreflightChecks(report)}</div><footer class="modal-footer"><button class="button" data-action="close-modal">Close</button>${report?.summary?.ready ? `<button class="button success" data-action="continue-after-preflight" data-profile-id="${attr(profile?.id || '')}">${icon('rocket')}Continue</button>` : `<button class="button" data-action="edit-deployment-profile" data-profile-id="${attr(profile?.id || '')}">Edit environment</button>`}</footer></section></div>`; return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Deployment preflight</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="confirm-hero ${report?.summary?.ready ? '' : 'danger'}">${icon(report?.summary?.ready ? 'shield' : 'error')}<div><strong>${report?.summary?.ready ? 'Environment is ready to test' : 'Deployment is blocked'}</strong><span>${escapeHtml(repository?.fullName || '')}${escapeHtml(profile?.environment || '')}</span></div></div><div class="preflight-summary"><span class="status-pill ${report?.summary?.ready ? 'success' : 'danger'}">${report?.summary?.ready ? 'Ready' : `${report?.summary?.blocking?.length || 0} blocking`}</span><span>${report?.summary?.counts?.pass || 0} passed · ${report?.summary?.counts?.warning || 0} warnings · ${report?.summary?.counts?.fail || 0} failed</span></div>${renderPreflightChecks(report)}</div><footer class="modal-footer"><button class="button" data-action="close-modal">Close</button>${report?.summary?.ready ? `<button class="button success" data-action="continue-after-preflight" data-profile-id="${attr(profile?.id || '')}">${icon('rocket')}Continue</button>` : `<button class="button" data-action="edit-deployment-profile" data-profile-id="${attr(profile?.id || '')}">Edit environment</button>`}</footer></section></div>`;
} }
if (ui.modal.type === 'deploy-confirm') { if (ui.modal.type === 'deploy-confirm') {
const profile = repository?.deploymentProfiles?.find((item) => item.id === ui.modal.profileId) || selectedProfile(repository); const profile = repository?.deploymentProfiles?.find((item) => item.id === ui.modal.profileId) || selectedProfile(repository);
return `<div class="modal-backdrop"><section class="modal"><header class="modal-header"><h2>Confirm production action</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="confirm-hero">${icon('rocket')}<div><strong>Deploy ${escapeHtml(repository.localStatus.shortHead)}${escapeHtml(profile.environment)}</strong><span>${escapeHtml(repository.fullName)}</span></div></div><div class="confirm-grid"><span>Exact commit</span><strong class="mono">${escapeHtml(repository.localStatus.head)}</strong><span>Branch</span><strong>${escapeHtml(profile.branch)}</strong><span>Provider</span><strong>${profile.provider === 'ssh-unraid' ? `SSH → ${escapeHtml(profile.remoteFolder)}` : escapeHtml(profile.workflowFile)}</strong><span>Healthcheck</span><strong>${escapeHtml(profile.healthcheckUrl || 'Not configured')}</strong></div>${ui.deploymentPreflight ? `<div class="notice success" style="margin-top:12px">${icon('shield')}Preflight passed with ${ui.deploymentPreflight.summary.counts.warning} warning(s). Backend safety checks run again at dispatch time.</div>` : ''}</div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button success" data-action="confirm-deploy" data-profile-id="${attr(profile.id)}">Deploy exact commit</button></footer></section></div>`; return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Confirm production action</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="confirm-hero">${icon('rocket')}<div><strong>Deploy ${escapeHtml(repository.localStatus.shortHead)}${escapeHtml(profile.environment)}</strong><span>${escapeHtml(repository.fullName)}</span></div></div><div class="confirm-grid"><span>Exact commit</span><strong class="mono">${escapeHtml(repository.localStatus.head)}</strong><span>Branch</span><strong>${escapeHtml(profile.branch)}</strong><span>Provider</span><strong>${profile.provider === 'ssh-unraid' ? `SSH → ${escapeHtml(profile.remoteFolder)}` : escapeHtml(profile.workflowFile)}</strong><span>Healthcheck</span><strong>${escapeHtml(profile.healthcheckUrl || 'Not configured')}</strong></div>${ui.deploymentPreflight ? `<div class="notice success" style="margin-top:12px">${icon('shield')}Preflight passed with ${ui.deploymentPreflight.summary.counts.warning} warning(s). Backend safety checks run again at dispatch time.</div>` : ''}</div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button success" data-action="confirm-deploy" data-profile-id="${attr(profile.id)}">Deploy exact commit</button></footer></section></div>`;
} }
if (ui.modal.type === 'rollback-confirm') { if (ui.modal.type === 'rollback-confirm') {
const profile = repository?.deploymentProfiles?.find((item) => item.id === ui.modal.profileId); const profile = repository?.deploymentProfiles?.find((item) => item.id === ui.modal.profileId);
const target = profile?.state?.previousSha; const target = profile?.state?.previousSha;
return `<div class="modal-backdrop"><section class="modal"><header class="modal-header"><h2>Confirm rollback</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="confirm-hero danger">${icon('undo')}<div><strong>Rollback ${escapeHtml(profile?.environment || '')} to ${shortSha(target)}</strong><span>The target must still exist on origin/${escapeHtml(profile?.branch || '')}.</span></div></div><div class="confirm-grid"><span>Target commit</span><strong class="mono">${escapeHtml(target || 'Unavailable')}</strong><span>Provider</span><strong>${profile?.provider === 'ssh-unraid' ? 'SSH exact-SHA reset' : escapeHtml(profile?.rollbackWorkflowFile || 'Not configured')}</strong><span>Current live</span><strong class="mono">${escapeHtml(profile?.state?.liveSha || 'Unknown')}</strong></div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button danger" data-action="confirm-rollback" data-profile-id="${attr(profile?.id || '')}" ${target ? '' : 'disabled'}>Rollback exact commit</button></footer></section></div>`; return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Confirm rollback</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="confirm-hero danger">${icon('undo')}<div><strong>Rollback ${escapeHtml(profile?.environment || '')} to ${shortSha(target)}</strong><span>The target must still exist on origin/${escapeHtml(profile?.branch || '')}.</span></div></div><div class="confirm-grid"><span>Target commit</span><strong class="mono">${escapeHtml(target || 'Unavailable')}</strong><span>Provider</span><strong>${profile?.provider === 'ssh-unraid' ? 'SSH exact-SHA reset' : escapeHtml(profile?.rollbackWorkflowFile || 'Not configured')}</strong><span>Current live</span><strong class="mono">${escapeHtml(profile?.state?.liveSha || 'Unknown')}</strong></div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button danger" data-action="confirm-rollback" data-profile-id="${attr(profile?.id || '')}" ${target ? '' : 'disabled'}>Rollback exact commit</button></footer></section></div>`;
} }
if (ui.modal.type === 'server-config') { if (ui.modal.type === 'server-config') {
const server = (ui.boot.state.servers || []).find((item) => item.id === ui.modal.serverId) || {}; const server = (ui.boot.state.servers || []).find((item) => item.id === ui.modal.serverId) || {};
const authType = ui.modal.authType || server.authType || 'privateKey'; const authType = ui.modal.authType || server.authType || 'privateKey';
return `<div class="modal-backdrop"><section class="modal wide-modal"><header class="modal-header"><h2>${server.id ? 'Edit' : 'Add'} SSH / Unraid server</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="form-grid"><div class="field"><label>Name</label><input id="server-name" class="input" value="${attr(server.name || 'Unraid')}"/></div><div class="field"><label>Host or IP</label><input id="server-host" class="input" value="${attr(server.host || '')}" placeholder="192.168.1.10"/></div><div class="field"><label>SSH port</label><input id="server-port" class="input" type="number" min="1" max="65535" value="${attr(server.port || 22)}"/></div><div class="field"><label>Username</label><input id="server-username" class="input" value="${attr(server.username || 'root')}"/></div><div class="field"><label>Authentication</label><select id="server-auth-type" class="select"><option value="privateKey" ${authType === 'privateKey' ? 'selected' : ''}>Private key · recommended</option><option value="password" ${authType === 'password' ? 'selected' : ''}>Password</option></select></div><div class="field"><label>Appdata base path</label><input id="server-base-path" class="input" value="${attr(server.basePath || '/mnt/user/appdata')}"/></div>${authType === 'privateKey' ? `<div class="field full"><label>Private key file</label><div class="input-action"><input id="server-private-key" class="input" value="${attr(server.privateKeyPath || '')}" placeholder="C:\\Users\\Jens\\.ssh\\id_ed25519"/><button class="button" data-action="select-private-key">Browse</button></div></div><div class="field full"><label>Private key passphrase</label><input id="server-passphrase" class="input" type="password" placeholder="${server.hasPassphrase ? 'Leave empty to keep stored passphrase' : 'Only when the key is encrypted'}"/></div>` : `<div class="field full"><label>SSH password</label><input id="server-password" class="input" type="password" placeholder="${server.hasPassword ? 'Leave empty to keep stored password' : 'Password'}"/></div>`}<div class="field full"><label>Trusted host fingerprint</label><input id="server-fingerprint" class="input mono" value="${attr(server.hostFingerprint || '')}" readonly placeholder="Filled automatically after Test & trust"/></div></div><div class="notice warning" style="margin-top:12px">${icon('key')}The first connection records the SSH host-key fingerprint. Later deployments fail closed when the server presents a different key.</div></div><footer class="modal-footer">${server.id ? `<button class="button danger" data-action="delete-server" data-server-id="${attr(server.id)}">Delete</button>` : ''}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-server" data-server-id="${attr(server.id || '')}">Save server</button></footer></section></div>`; return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>${server.id ? 'Edit' : 'Add'} SSH / Unraid server</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="form-grid"><div class="field"><label>Name</label><input id="server-name" class="input" value="${attr(server.name || 'Unraid')}"/></div><div class="field"><label>Host or IP</label><input id="server-host" class="input" value="${attr(server.host || '')}" placeholder="192.168.1.10"/></div><div class="field"><label>SSH port</label><input id="server-port" class="input" type="number" min="1" max="65535" value="${attr(server.port || 22)}"/></div><div class="field"><label>Username</label><input id="server-username" class="input" value="${attr(server.username || 'root')}"/></div><div class="field"><label>Authentication</label><select id="server-auth-type" class="select"><option value="privateKey" ${authType === 'privateKey' ? 'selected' : ''}>Private key · recommended</option><option value="password" ${authType === 'password' ? 'selected' : ''}>Password</option></select></div><div class="field"><label>Appdata base path</label><input id="server-base-path" class="input" value="${attr(server.basePath || '/mnt/user/appdata')}"/></div>${authType === 'privateKey' ? `<div class="field full"><label>Private key file</label><div class="input-action"><input id="server-private-key" class="input" value="${attr(server.privateKeyPath || '')}" placeholder="C:\\Users\\Jens\\.ssh\\id_ed25519"/><button class="button" data-action="select-private-key">Browse</button></div></div><div class="field full"><label>Private key passphrase</label><input id="server-passphrase" class="input" type="password" placeholder="${server.hasPassphrase ? 'Leave empty to keep stored passphrase' : 'Only when the key is encrypted'}"/></div>` : `<div class="field full"><label>SSH password</label><input id="server-password" class="input" type="password" placeholder="${server.hasPassword ? 'Leave empty to keep stored password' : 'Password'}"/></div>`}<div class="field full"><label>Trusted host fingerprint</label><input id="server-fingerprint" class="input mono" value="${attr(server.hostFingerprint || '')}" readonly placeholder="Filled automatically after Test & trust"/></div></div><div class="notice warning" style="margin-top:12px">${icon('key')}The first connection records the SSH host-key fingerprint. Later deployments fail closed when the server presents a different key.</div></div><footer class="modal-footer">${server.id ? `<button class="button danger" data-action="delete-server" data-server-id="${attr(server.id)}">Delete</button>` : ''}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-server" data-server-id="${attr(server.id || '')}">Save server</button></footer></section></div>`;
} }
if (ui.modal.type === 'command-palette') return renderCommandPalette(); if (ui.modal.type === 'command-palette') return renderCommandPalette();
return ''; return '';
@@ -950,9 +954,11 @@ app.addEventListener('click', async (event) => {
privateKeyPath: document.querySelector('#server-private-key')?.value.trim() || '', privateKeyPath: document.querySelector('#server-private-key')?.value.trim() || '',
hostFingerprint: document.querySelector('#server-fingerprint').value.trim() hostFingerprint: document.querySelector('#server-fingerprint').value.trim()
}; };
const password = document.querySelector('#server-password')?.value || '';
const passphrase = document.querySelector('#server-passphrase')?.value || '';
setLoading(true, 'Saving encrypted SSH configuration…'); setLoading(true, 'Saving encrypted SSH configuration…');
try { try {
const result = await window.forgeflow.saveServer(server, document.querySelector('#server-password')?.value || '', document.querySelector('#server-passphrase')?.value || ''); const result = await window.forgeflow.saveServer(server, password, passphrase);
ui.boot.state = result.state; ui.modal = null; showToast('Server saved', 'Run Test & trust before creating a deployment.', 'success'); ui.boot.state = result.state; ui.modal = null; showToast('Server saved', 'Run Test & trust before creating a deployment.', 'success');
} catch (error) { showToast('Could not save server', error.message, 'error'); } } catch (error) { showToast('Could not save server', error.message, 'error'); }
setLoading(false); setLoading(false);
@@ -973,6 +979,31 @@ app.addEventListener('click', async (event) => {
else if (action === 'save-roots') { const roots = [...document.querySelectorAll('[data-root-index]')].map((input) => input.value.trim()).filter(Boolean); setLoading(true, 'Saving workspace folders…'); try { ui.boot.state = await window.forgeflow.setWorkspaceRoots(roots); await refreshRepositories(false); showToast('Folders saved', 'Repository discovery has been refreshed.', 'success'); } catch (error) { showToast('Could not save folders', error.message, 'error'); } setLoading(false); } else if (action === 'save-roots') { const roots = [...document.querySelectorAll('[data-root-index]')].map((input) => input.value.trim()).filter(Boolean); setLoading(true, 'Saving workspace folders…'); try { ui.boot.state = await window.forgeflow.setWorkspaceRoots(roots); await refreshRepositories(false); showToast('Folders saved', 'Repository discovery has been refreshed.', 'success'); } catch (error) { showToast('Could not save folders', error.message, 'error'); } setLoading(false); }
else if (action === 'save-gitea-settings') { const baseUrl = document.querySelector('#settings-gitea-url').value.trim(); const token = document.querySelector('#settings-gitea-token').value.trim(); setLoading(true, 'Validating Gitea…'); try { const result = await window.forgeflow.updateGitea({ baseUrl, token }); ui.boot.state = result.state; await refreshRepositories(false); showToast('Gitea connected', `Signed in as ${result.validation.user.login}.`, 'success'); } catch (error) { showToast('Connection failed', error.message, 'error'); } setLoading(false); } else if (action === 'save-gitea-settings') { const baseUrl = document.querySelector('#settings-gitea-url').value.trim(); const token = document.querySelector('#settings-gitea-token').value.trim(); setLoading(true, 'Validating Gitea…'); try { const result = await window.forgeflow.updateGitea({ baseUrl, token }); ui.boot.state = result.state; await refreshRepositories(false); showToast('Gitea connected', `Signed in as ${result.validation.user.login}.`, 'success'); } catch (error) { showToast('Connection failed', error.message, 'error'); } setLoading(false); }
else if (action === 'save-preferences') { const preferences = { autoRefresh: document.querySelector('#pref-auto-refresh').value === 'true', repositoryPollSeconds: Number(document.querySelector('#pref-repo-poll').value), operationPollSeconds: Number(document.querySelector('#pref-operation-poll').value), preferredCloneProtocol: document.querySelector('#pref-clone-protocol').value }; setLoading(true, 'Saving background settings…'); try { ui.boot.state = await window.forgeflow.setPreferences(preferences); await refreshRepositories(false); showToast('Settings saved', 'Background awareness has been updated.', 'success'); } catch (error) { showToast('Could not save settings', error.message, 'error'); } setLoading(false); } else if (action === 'save-preferences') { const preferences = { autoRefresh: document.querySelector('#pref-auto-refresh').value === 'true', repositoryPollSeconds: Number(document.querySelector('#pref-repo-poll').value), operationPollSeconds: Number(document.querySelector('#pref-operation-poll').value), preferredCloneProtocol: document.querySelector('#pref-clone-protocol').value }; setLoading(true, 'Saving background settings…'); try { ui.boot.state = await window.forgeflow.setPreferences(preferences); await refreshRepositories(false); showToast('Settings saved', 'Background awareness has been updated.', 'success'); } catch (error) { showToast('Could not save settings', error.message, 'error'); } setLoading(false); }
else if (action === 'repair-origin') {
if (!repository?.localPath || !repository.sshUrl) return;
if (!confirm(`Replace origin with ${repository.sshUrl}? Local files and commits are not changed.`)) return;
setLoading(true, 'Updating Git origin…');
try { await window.forgeflow.setOrigin(repository.localPath, repository.sshUrl); await refreshRepositories(false); showToast('Git origin updated', repository.sshUrl, 'success'); }
catch (error) { showToast('Could not update origin', error.message, 'error'); }
setLoading(false);
}
else if (action === 'normalize-origins') {
if (!confirm('Replace legacy origin URLs for every linked repository with the current Gitea SSH URL? Local files and commits are not changed.')) return;
setLoading(true, 'Normalizing linked Git origins…');
try {
const result = await window.forgeflow.normalizeOrigins();
ui.repositories = result.repositories;
showToast('Git origins normalized', `${result.changes.length} repository origin${result.changes.length === 1 ? '' : 's'} updated.`, 'success');
} catch (error) { showToast('Could not normalize origins', error.message, 'error'); }
setLoading(false); render();
}
else if (action === 'repair-index-lock') {
if (!repository?.localPath || !confirm('Remove the stale Git index.lock for this repository? Only continue after other Git tools have stopped.')) return;
setLoading(true, 'Repairing stale Git lock…');
try { await window.forgeflow.repairIndexLock(repository.localPath); await refreshRepositories(false); showToast('Git lock removed', 'The repository can accept Git changes again.', 'success'); }
catch (error) { showToast('Could not remove Git lock', error.message, 'error'); }
setLoading(false);
}
else if (action === 'run-system-preflight') await runSystemPreflight(); else if (action === 'run-system-preflight') await runSystemPreflight();
else if (action === 'save-diagnostics-preferences') { else if (action === 'save-diagnostics-preferences') {
const preferences = { const preferences = {
+7 -3
View File
@@ -250,7 +250,7 @@
} }
window.forgeflow = Object.freeze({ window.forgeflow = Object.freeze({
async bootstrap() { await wait(80); snapshot(); return { appVersion: '0.4.0-demo', platform: 'win32', state: clone(state), git: { available: true, version: 'git version 2.47.3' }, diagnostics: { enabled: true, level: state.preferences.diagnosticLevel, retentionDays: state.preferences.logRetentionDays, maxFileMb: state.preferences.maxLogFileMb, directory: '<HOME>/AppData/Roaming/ForgeFlow/diagnostics', fileCount: 2, totalBytes: 18432, totalSize: '18.0 KB', latestAt: iso(-2000), lastWriteError: null } }; }, async bootstrap() { await wait(80); snapshot(); return { appVersion: '0.5.1-demo', platform: 'win32', state: clone(state), git: { available: true, version: 'git version 2.47.3' }, diagnostics: { enabled: true, level: state.preferences.diagnosticLevel, retentionDays: state.preferences.logRetentionDays, maxFileMb: state.preferences.maxLogFileMb, directory: '<HOME>/AppData/Roaming/ForgeFlow/diagnostics', fileCount: 2, totalBytes: 18432, totalSize: '18.0 KB', latestAt: iso(-2000), lastWriteError: null } }; },
async selectDirectory() { await wait(); return 'C:\\Development'; }, async selectDirectory() { await wait(); return 'C:\\Development'; },
async selectKeyFile() { await wait(); return 'C:\\Users\\Jens\\.ssh\\id_ed25519'; }, async selectKeyFile() { await wait(); return 'C:\\Users\\Jens\\.ssh\\id_ed25519'; },
async setupPreflight({ baseUrl, token, roots = [] }) { await wait(240); const checks = [ async setupPreflight({ baseUrl, token, roots = [] }) { await wait(240); const checks = [
@@ -269,9 +269,9 @@
async setAppearance(appearance) { state.appearance = appearance; storage.set('forgeflow-theme', appearance); return clone(state); }, async setAppearance(appearance) { state.appearance = appearance; storage.set('forgeflow-theme', appearance); return clone(state); },
async setPreferences(preferences) { state.preferences = { ...state.preferences, ...preferences }; snapshot(); return clone(state); }, async setPreferences(preferences) { state.preferences = { ...state.preferences, ...preferences }; snapshot(); return clone(state); },
async setUpdatePreferences(updates) { state.updates = { ...state.updates, ...updates }; return clone(state); }, async setUpdatePreferences(updates) { state.updates = { ...state.updates, ...updates }; return clone(state); },
async checkForUpdates() { await wait(300); return { checkedAt: iso(), owner: state.updates.owner, repo: state.updates.repo, branch: state.updates.branch, currentVersion: '0.4.0', remoteVersion: '0.4.1', remoteSha: 'a'.repeat(40), shortSha: 'aaaaaaa', available: true, mode: 'source' }; }, async checkForUpdates() { await wait(300); return { checkedAt: iso(), owner: state.updates.owner, repo: state.updates.repo, branch: state.updates.branch, currentVersion: '0.5.1', remoteVersion: '0.5.2', remoteSha: 'a'.repeat(40), shortSha: 'aaaaaaa', available: true, mode: 'source' }; },
async downloadUpdate() { await wait(500); return { ...(await this.checkForUpdates()), downloaded: true, archivePath: 'C:\\Temp\\ForgeFlow-0.4.1.zip', sha256: 'b'.repeat(64) }; }, async downloadUpdate() { await wait(500); return { ...(await this.checkForUpdates()), downloaded: true, archivePath: 'C:\\Temp\\ForgeFlow-0.4.1.zip', sha256: 'b'.repeat(64) }; },
async applyUpdate() { await wait(200); return { launched: true, version: '0.4.1' }; }, async applyUpdate() { await wait(200); return { launched: true, version: '0.5.1' }; },
async saveServer(server) { const saved = { ...server, id: server.id || `server-${Date.now()}`, hasPassword: server.authType === 'password', hasPassphrase: false }; state.servers = [saved, ...state.servers.filter((item) => item.id !== saved.id)]; return { server: clone(saved), state: clone(state) }; }, async saveServer(server) { const saved = { ...server, id: server.id || `server-${Date.now()}`, hasPassword: server.authType === 'password', hasPassphrase: false }; state.servers = [saved, ...state.servers.filter((item) => item.id !== saved.id)]; return { server: clone(saved), state: clone(state) }; },
async deleteServer(serverId) { state.servers = state.servers.filter((item) => item.id !== serverId); return clone(state); }, async deleteServer(serverId) { state.servers = state.servers.filter((item) => item.id !== serverId); return clone(state); },
async testServer(serverId) { const server = state.servers.find((item) => item.id === serverId); server.hostFingerprint = server.hostFingerprint || 'SHA256:demo'; return { connected: true, fingerprint: server.hostFingerprint, server: clone(server), output: 'Linux\n/usr/bin/git\nDocker Compose version v2', state: clone(state) }; }, async testServer(serverId) { const server = state.servers.find((item) => item.id === serverId); server.hostFingerprint = server.hostFingerprint || 'SHA256:demo'; return { connected: true, fingerprint: server.hostFingerprint, server: clone(server), output: 'Linux\n/usr/bin/git\nDocker Compose version v2', state: clone(state) }; },
@@ -297,6 +297,10 @@
async stash(localPath, message) { const repo = findRepo(localPath); const list = stashesByRepo.get(localPath) || []; list.unshift({ ref: `stash@{${list.length}}`, subject: message || 'ForgeFlow stash', date: iso() }); stashesByRepo.set(localPath, list); repo.localStatus.files = []; recompute(repo); emitRepositories(); return { output: 'Saved working directory and index state.', status: clone(repo.localStatus), stashes: clone(list) }; }, async stash(localPath, message) { const repo = findRepo(localPath); const list = stashesByRepo.get(localPath) || []; list.unshift({ ref: `stash@{${list.length}}`, subject: message || 'ForgeFlow stash', date: iso() }); stashesByRepo.set(localPath, list); repo.localStatus.files = []; recompute(repo); emitRepositories(); return { output: 'Saved working directory and index state.', status: clone(repo.localStatus), stashes: clone(list) }; },
async stashList(localPath) { return clone(stashesByRepo.get(localPath) || []); }, async stashList(localPath) { return clone(stashesByRepo.get(localPath) || []); },
async popStash(localPath, ref) { const repo = findRepo(localPath); const list = stashesByRepo.get(localPath) || []; const index = list.findIndex((item) => item.ref === ref); if (index < 0) throw new Error('Stash not found.'); list.splice(index, 1); stashesByRepo.set(localPath, list); repo.localStatus.files = [makeFile('src/restored-from-stash.ts')]; recompute(repo); emitRepositories(); return { output: 'Stash applied.', status: clone(repo.localStatus), stashes: clone(list) }; }, async popStash(localPath, ref) { const repo = findRepo(localPath); const list = stashesByRepo.get(localPath) || []; const index = list.findIndex((item) => item.ref === ref); if (index < 0) throw new Error('Stash not found.'); list.splice(index, 1); stashesByRepo.set(localPath, list); repo.localStatus.files = [makeFile('src/restored-from-stash.ts')]; recompute(repo); emitRepositories(); return { output: 'Stash applied.', status: clone(repo.localStatus), stashes: clone(list) }; },
async indexLockInfo() { return { exists: false, ageMs: 0 }; },
async repairIndexLock() { return { removed: true }; },
async setOrigin(localPath, remoteUrl) { const repo = findRepo(localPath); repo.localStatus.remoteUrl = remoteUrl; repo.sshUrl = remoteUrl; emitRepositories(); return clone(repo.localStatus); },
async normalizeOrigins() { const changes = []; repositories.filter((repo) => repo.localPath && repo.sshUrl).forEach((repo) => { if (repo.localStatus.remoteUrl !== repo.sshUrl) { changes.push({ fullName: repo.fullName, previous: repo.localStatus.remoteUrl, next: repo.sshUrl }); repo.localStatus.remoteUrl = repo.sshUrl; } }); emitRepositories(); return { changes, repositories: snapshot() }; },
async cloneRepository(fullName, mode = 'default') { async cloneRepository(fullName, mode = 'default') {
await wait(620); await wait(620);
const repository = repositories.find((item) => item.fullName === fullName); const repository = repositories.find((item) => item.fullName === fullName);
+25 -5
View File
@@ -326,12 +326,12 @@ html[data-theme="light"] .diff-line.remove { color: #a31f1f; }
.discovery-row strong { display: block; font-size: 12px; } .discovery-row strong { display: block; font-size: 12px; }
.discovery-row span { display: block; margin-top: 2px; color: var(--text-faint); font: 10px var(--font-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .discovery-row span { display: block; margin-top: 2px; color: var(--text-faint); font: 10px var(--font-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.modal-backdrop { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 20px; background: rgba(4,7,12,.7); backdrop-filter: blur(4px); } .modal-backdrop { position: fixed; inset: 0; z-index: 60; display: grid; align-items: center; justify-items: center; padding: clamp(8px,2vh,20px); overflow: auto; overscroll-behavior: contain; background: rgba(4,7,12,.7); backdrop-filter: blur(4px); }
.modal { width: min(520px,94vw); border: 1px solid var(--line); border-radius: 9px; background: var(--surface-1); box-shadow: var(--shadow); overflow: hidden; } .modal { width: min(520px,94vw); max-height: calc(100dvh - clamp(16px,4vh,40px)); min-height: 0; display: flex; flex-direction: column; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-1); box-shadow: var(--shadow); overflow: hidden; }
.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 15px 17px; border-bottom: 1px solid var(--line); } .modal-header { flex: 0 0 auto; display: flex; justify-content: space-between; align-items: center; padding: 15px 17px; border-bottom: 1px solid var(--line); background: var(--surface-1); }
.modal-header h2 { margin: 0; font-size: 15px; } .modal-header h2 { margin: 0; font-size: 15px; }
.modal-body { padding: 17px; } .modal-body { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding: 17px; }
.modal-footer { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 17px; border-top: 1px solid var(--line-soft); background: var(--surface-0); } .modal-footer { flex: 0 0 auto; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; padding: 12px 17px; border-top: 1px solid var(--line-soft); background: var(--surface-0); box-shadow: 0 -8px 18px rgba(0,0,0,.08); }
.statusbar { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 0 9px; border-top: 1px solid var(--line); background: var(--surface-1); color: var(--text-faint); font-size: 10px; font-weight: 620; } .statusbar { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 0 9px; border-top: 1px solid var(--line); background: var(--surface-1); color: var(--text-faint); font-size: 10px; font-weight: 620; }
.statusbar-left, .statusbar-right { display: flex; align-items: center; gap: 13px; min-width: 0; } .statusbar-left, .statusbar-right { display: flex; align-items: center; gap: 13px; min-width: 0; }
@@ -533,3 +533,23 @@ kbd { min-width: 24px; padding: 2px 5px; border: 1px solid var(--line); border-b
} }
.setup-brand-logo { width: 150px; height: auto; display: block; margin-bottom: 12px; } .setup-brand-logo { width: 150px; height: auto; display: block; margin-bottom: 12px; }
/* v0.5 viewport-safe dialogs */
.modal-body > .preflight-list:last-child { margin-bottom: 2px; }
.modal-footer .button { flex: 0 0 auto; }
@media (max-height: 720px) {
.modal-backdrop { align-items: start; }
.modal { max-height: calc(100dvh - 16px); }
.modal-header { padding-block: 11px; }
.modal-body { padding-block: 13px; }
.modal-footer { padding-block: 10px; }
}
@media (max-width: 680px) {
.modal-backdrop { padding: 0; align-items: stretch; }
.modal, .wide-modal { width: 100vw; max-height: 100dvh; border-radius: 0; }
.modal-footer .modal-spacer { display: none; }
.modal-footer .button { flex: 1 1 auto; }
.form-grid { grid-template-columns: 1fr; }
.field.full, .check-field.full { grid-column: 1; }
}
+55 -1
View File
@@ -30,7 +30,61 @@ function bashSyntaxCheckInvocation(root, scriptPath = 'examples/server/forgeflow
}; };
} }
function bashSyntaxCheckFromTextInvocation(scriptText) {
if (typeof scriptText !== 'string' || !scriptText.trim()) {
throw new Error('Shell script text is required for syntax validation.');
}
return {
command: 'bash',
args: ['-n'],
options: {
input: scriptText,
encoding: 'utf8',
windowsHide: true
}
};
}
function shouldRunExternalBash(platform = process.platform) {
return platform !== 'win32';
}
function validateShellScriptStructure(scriptText) {
if (typeof scriptText !== 'string' || !scriptText.trim()) {
throw new Error('Shell script text is required for structural validation.');
}
if (scriptText.includes('\0')) {
throw new Error('Shell script may not contain NUL bytes.');
}
const normalized = scriptText.replace(/\r\n/g, '\n');
const firstLine = normalized.split('\n', 1)[0];
if (!/^#!\/(?:usr\/bin\/env bash|bin\/bash)$/.test(firstLine)) {
throw new Error('Server deployment script must declare Bash in its shebang.');
}
if (!/^set -E?euo pipefail$/m.test(normalized)) {
throw new Error('Server deployment script must enable strict Bash error handling.');
}
for (const marker of [
'readonly CONFIG_FILE="/etc/forgeflow/targets.conf"',
'Target configuration must be owned by root',
'flock -n 9',
'git -C "$APP_DIR" fetch',
'git -C "$APP_DIR" reset --hard "$SHA"',
'docker compose -f "$COMPOSE_FILE" up -d --build --remove-orphans',
'write_status "healthy"',
'write_status "unhealthy"'
]) {
if (!normalized.includes(marker)) {
throw new Error(`Server deployment script is missing required safety marker: ${marker}`);
}
}
return true;
}
module.exports = { module.exports = {
bashSyntaxCheckInvocation, bashSyntaxCheckInvocation,
normalizeRelativePosixPath bashSyntaxCheckFromTextInvocation,
normalizeRelativePosixPath,
shouldRunExternalBash,
validateShellScriptStructure
}; };
+26
View File
@@ -152,3 +152,29 @@ test('keeps a successful local commit visible as ahead when the following push f
const subject = await git(['log', '-1', '--pretty=%s'], working); const subject = await git(['log', '-1', '--pretty=%s'], working);
assert.equal(subject.stdout.trim(), 'Update portfolio'); assert.equal(subject.stdout.trim(), 'Update portfolio');
}); });
test('stages a large Windows-sized partial selection through NUL-delimited stdin', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-git-large-selection-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const working = path.join(root, 'working');
await fs.mkdir(working);
await git(['init'], working);
await git(['config', 'user.name', 'ForgeFlow Test'], working);
await git(['config', 'user.email', 'forgeflow@example.invalid'], working);
await fs.writeFile(path.join(working, 'README.md'), '# Large selection\n');
await git(['add', '.'], working);
await git(['commit', '-m', 'Initial'], working);
const names = [];
for (let index = 0; index < 850; index += 1) {
const name = `generated/feature-${String(index).padStart(4, '0')}-${'x'.repeat(28)}.txt`;
names.push(name);
await fs.mkdir(path.dirname(path.join(working, name)), { recursive: true });
await fs.writeFile(path.join(working, name), `file ${index}\n`);
}
const service = new GitService();
const status = await service.stage(working, names);
assert.equal(status.counts.staged, names.length);
assert.equal(status.counts.unstaged, 0);
});
+32
View File
@@ -22,3 +22,35 @@ test('ITWorx branding is integrated into titlebar and setup', async () => {
assert.match(renderer, /itworx-mark\.png/); assert.match(renderer, /itworx-mark\.png/);
assert.match(renderer, /itworx-wordmark\.png/); assert.match(renderer, /itworx-wordmark\.png/);
}); });
test('all modal content stays inside the viewport with a persistent action footer', async () => {
const css = await readFile(new URL('../src/renderer/styles.css', import.meta.url), 'utf8');
assert.match(css, /\.modal\s*\{[^}]*max-height:\s*calc\(100dvh[^}]*display:\s*flex[^}]*flex-direction:\s*column/);
assert.match(css, /\.modal-body\s*\{[^}]*min-height:\s*0[^}]*overflow-y:\s*auto/);
assert.match(css, /\.modal-footer\s*\{[^}]*flex:\s*0 0 auto/);
});
test('settings provides one-click normalization for legacy Gitea origins', async () => {
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
assert.match(renderer, /data-action="normalize-origins"/);
assert.match(renderer, /Normalize all origins/);
});
test('Git mutations are serialized per repository and expose repair actions', async () => {
const ipc = await readFile(new URL('../src/main/ipc.cjs', import.meta.url), 'utf8');
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
assert.match(ipc, /repositoryMutations = new Map/);
assert.match(ipc, /withRepositoryMutation/);
assert.match(renderer, /data-action="repair-index-lock"/);
assert.match(renderer, /data-action="repair-origin"/);
});
test('SSH secrets are captured before the loading render clears password inputs', async () => {
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
const passwordCapture = renderer.indexOf("const password = document.querySelector('#server-password')");
const loading = renderer.indexOf("setLoading(true, 'Saving encrypted SSH configuration…')");
assert.ok(passwordCapture >= 0 && loading > passwordCapture);
});
+55 -1
View File
@@ -6,7 +6,7 @@ import { spawnSync } from 'node:child_process';
import test from 'node:test'; import test from 'node:test';
import shellVerification from '../src/shared/shell-verification.cjs'; import shellVerification from '../src/shared/shell-verification.cjs';
const { bashSyntaxCheckInvocation, normalizeRelativePosixPath } = shellVerification; const { bashSyntaxCheckInvocation, bashSyntaxCheckFromTextInvocation, normalizeRelativePosixPath, validateShellScriptStructure, shouldRunExternalBash } = shellVerification;
test('Bash syntax validation keeps Windows project roots in cwd and passes a relative POSIX path', () => { test('Bash syntax validation keeps Windows project roots in cwd and passes a relative POSIX path', () => {
const invocation = bashSyntaxCheckInvocation('C:\\Projects\\ForgeFlow'); const invocation = bashSyntaxCheckInvocation('C:\\Projects\\ForgeFlow');
@@ -47,3 +47,57 @@ test('Bash syntax validation works from a project root containing spaces', async
} }
} }
}); });
test('Bash syntax validation from text does not depend on a Windows working directory', () => {
const invocation = bashSyntaxCheckFromTextInvocation('#!/usr/bin/env bash\nset -euo pipefail\necho ok\n');
assert.equal(invocation.command, 'bash');
assert.deepEqual(invocation.args, ['-n']);
assert.equal(invocation.options.cwd, undefined);
assert.match(invocation.options.input, /set -euo pipefail/);
});
test('Bash syntax validation from text detects malformed scripts', (t) => {
if (spawnSync('bash', ['--version'], { encoding: 'utf8' }).status !== 0) {
t.skip('Bash is not available in this environment.');
return;
}
const invocation = bashSyntaxCheckFromTextInvocation('if true; then\n echo missing fi\n');
const result = spawnSync(invocation.command, invocation.args, invocation.options);
assert.notEqual(result.status, 0);
});
test('portable server-script validation does not require a local Bash executable', () => {
const script = `#!/usr/bin/env bash
set -Eeuo pipefail
readonly CONFIG_FILE="/etc/forgeflow/targets.conf"
echo "Target configuration must be owned by root"
APP_DIR=/tmp/app
SHA=0123456789012345678901234567890123456789
COMPOSE_FILE=docker-compose.yml
write_status() { :; }
exec 9>/tmp/test.lock
flock -n 9
git -C "$APP_DIR" fetch origin main
git -C "$APP_DIR" reset --hard "$SHA"
docker compose -f "$COMPOSE_FILE" up -d --build --remove-orphans
write_status "healthy"
write_status "unhealthy"
`;
assert.equal(validateShellScriptStructure(script), true);
});
test('portable server-script validation refuses missing deployment safety markers', () => {
assert.throws(
() => validateShellScriptStructure('#!/usr/bin/env bash\nset -Eeuo pipefail\necho unsafe\n'),
/missing required safety marker/
);
});
test('Windows publication never depends on an external Bash shim', () => {
assert.equal(shouldRunExternalBash('win32'), false);
assert.equal(shouldRunExternalBash('linux'), true);
assert.equal(shouldRunExternalBash('darwin'), true);
});
+8
View File
@@ -1,8 +1,10 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import toolInvocation from '../src/shared/tool-invocation.cjs'; import toolInvocation from '../src/shared/tool-invocation.cjs';
import processRunner from '../src/main/process-runner.cjs';
const { npmProbeCandidates } = toolInvocation; const { npmProbeCandidates } = toolInvocation;
const { run } = processRunner;
test('uses npm CLI through Node when doctor is launched by npm on Windows', () => { test('uses npm CLI through Node when doctor is launched by npm on Windows', () => {
const candidates = npmProbeCandidates({ const candidates = npmProbeCandidates({
@@ -39,3 +41,9 @@ test('uses npm directly on non-Windows systems', () => {
{ file: 'npm', args: ['--version'], source: 'path' } { file: 'npm', args: ['--version'], source: 'path' }
]); ]);
}); });
test('process runner accepts stdin for Git pathspec transport', async () => {
const result = await run(process.execPath, ['-e', 'process.stdin.pipe(process.stdout)'], { input: 'a\0b\0' });
assert.equal(result.stdout, 'a\0b\0');
});