Release ForgeFlow 0.6.0

This commit is contained in:
NuklearRabbit
2026-07-25 05:59:07 +02:00
parent cf1f67a823
commit 9d3933c878
48 changed files with 2208 additions and 466 deletions
+28 -2
View File
@@ -1,5 +1,33 @@
# Changelog # Changelog
## 0.6.0
- Added complete repository troubleshooting for stale `HEAD.lock`, `index.lock`, ref locks and diverged branches.
- Added safe one-click fetch, fast-forward, push and backup-then-reset synchronization repairs.
- Reconciled interrupted SSH deployments from live Unraid state at startup and on demand.
- Added DockerMan WebUI, icon and shell metadata plus a persistent XML template fallback for repository and generated Compose deployments.
- Added a built-in high-contrast ITWorx icon, local PNG upload, DockerMan image storage and metadata/icon-cache invalidation.
- Enforced lowercase internal Compose identities while preserving visible names such as `Portfolio`.
- Hardened source updater startup, status reporting, rollback and restart behavior.
- Completed successful SSH operations before post-deployment reconciliation to prevent stale deployment mode.
- Added startup server-truth refresh, batch DockerMan repair and healthy-live-SHA deploy suppression.
- Added lockfile-aware updater installs and direct Electron restart.
- Expanded automated coverage to Git lock, divergence, DockerMan, deployment reconciliation and updater regressions.
## 0.5.4
- Added exact Unraid-to-Gitea clone preflight.
- Made SSH deployments background operations with automatic polling.
- Preserved configured Compose casing such as Portfolio.
- Guaranteed failed remote operations become terminal failed records.
## 0.5.3
- Confirmed updater handoff before application exit.
- Persistent lifecycle state and startup result notification.
- Reliable success/rollback restart tracking.
## 0.5.2 ## 0.5.2
- Made release verification and built-in update validation independent of Windows Bash shims. - Made release verification and built-in update validation independent of Windows Bash shims.
@@ -32,8 +60,6 @@
- Added deterministic SSH inspection contract coverage. - Added deterministic SSH inspection contract coverage.
- Released as one complete clean source archive. - Released as one complete clean source archive.
# Changelog
## 0.4.2 ## 0.4.2
- Replaced nested SSH Bash quoting with a single-line base64 transport. - Replaced nested SSH Bash quoting with a single-line base64 transport.
+5
View File
@@ -0,0 +1,5 @@
# ForgeFlow 0.6.0 overlay
Close ForgeFlow, extract this archive directly over `C:\Projects\ForgeFlow`, replace existing files, and run `START-FORGEFLOW-OVERLAY.ps1`.
This version verifies Unraid-to-Gitea access before deployment, runs SSH deployments in the background, automatically polls status, and preserves the configured name `Portfolio`.
+46 -8
View File
@@ -1,15 +1,19 @@
param( param(
[string]$Remote = "git@gitea.itworx.tech:Jens/ForgeFlow.git", [string]$Remote = "git@gitea.itworx.tech:Jens/ForgeFlow.git",
[string]$Branch = "main" [string]$Branch = "main",
[string]$InstalledSource = "C:\Projects\ForgeFlow"
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$source = $PSScriptRoot $source = $PSScriptRoot
$manifest = Get-Content (Join-Path $source "package.json") -Raw | ConvertFrom-Json $manifestPath = Join-Path $source "package.json"
if (-not (Test-Path -LiteralPath $manifestPath)) { throw "package.json was not found beside the publishing script." }
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
if ($manifest.name -ne "forgeflow") { throw "Run this script from an extracted ForgeFlow source release." } if ($manifest.name -ne "forgeflow") { throw "Run this script from an extracted ForgeFlow source release." }
$version = [string]$manifest.version $version = [string]$manifest.version
$temp = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-publish-" + [guid]::NewGuid().ToString("N")) $temp = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-publish-" + [guid]::NewGuid().ToString("N"))
$clone = Join-Path $temp "ForgeFlow" $clone = Join-Path $temp "ForgeFlow"
$publishedCommit = $null
try { try {
Write-Host "Validating ForgeFlow $version before publishing..." -ForegroundColor Cyan Write-Host "Validating ForgeFlow $version before publishing..." -ForegroundColor Cyan
@@ -17,6 +21,7 @@ try {
try { try {
& cmd.exe /d /s /c "npm install --no-audit --no-fund" & cmd.exe /d /s /c "npm install --no-audit --no-fund"
if ($LASTEXITCODE -ne 0) { throw "npm install failed." } if ($LASTEXITCODE -ne 0) { throw "npm install failed." }
if (-not (Test-Path -LiteralPath (Join-Path $source "package-lock.json"))) { throw "npm install did not create package-lock.json; publication was stopped to avoid a non-reproducible update." }
& cmd.exe /d /s /c "npm run check" & cmd.exe /d /s /c "npm run check"
if ($LASTEXITCODE -ne 0) { throw "ForgeFlow quality gate failed." } if ($LASTEXITCODE -ne 0) { throw "ForgeFlow quality gate failed." }
} finally { Pop-Location } } finally { Pop-Location }
@@ -32,17 +37,50 @@ try {
Push-Location $clone Push-Location $clone
try { try {
& git add -A & git add -A
$changes = & git status --porcelain if ($LASTEXITCODE -ne 0) { throw "Could not stage the release source." }
if (-not $changes) { $changes = @(& git status --porcelain)
Write-Host "Gitea already contains ForgeFlow $version; nothing to publish." -ForegroundColor Yellow if ($changes.Count -gt 0) {
exit 0
}
& git commit -m "Release ForgeFlow $version" & git commit -m "Release ForgeFlow $version"
if ($LASTEXITCODE -ne 0) { throw "Could not create the release commit." } if ($LASTEXITCODE -ne 0) { throw "Could not create the release commit." }
& git push origin $Branch & git push origin $Branch
if ($LASTEXITCODE -ne 0) { throw "Could not push ForgeFlow $version to Gitea." } 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 } else {
Write-Host "Gitea already contains the ForgeFlow $version source; verifying the branch head." -ForegroundColor Yellow
}
$localCommit = (& git rev-parse HEAD).Trim()
if ($LASTEXITCODE -ne 0 -or $localCommit -notmatch '^[0-9a-f]{40}$') { throw "Could not read the local release commit." }
$remoteLines = @(& git ls-remote origin "refs/heads/$Branch")
if ($LASTEXITCODE -ne 0 -or $remoteLines.Count -lt 1) { throw "Could not verify the Gitea release branch." }
$publishedCommit = ($remoteLines[0] -split "`t")[0].Trim()
if ($publishedCommit -ne $localCommit) { throw "Gitea did not report the exact release commit after publication." }
} finally { Pop-Location } } finally { Pop-Location }
Write-Host "ForgeFlow $version is available on Gitea at commit $($publishedCommit.Substring(0,7))." -ForegroundColor Green
$installedManifestPath = Join-Path $InstalledSource "package.json"
if (Test-Path -LiteralPath $installedManifestPath) {
try {
$installedManifest = Get-Content -LiteralPath $installedManifestPath -Raw | ConvertFrom-Json
if ($installedManifest.name -eq "forgeflow" -and [string]$installedManifest.version -ne $version) {
$bootstrapSource = Join-Path $source "scripts\apply-source-update.ps1"
$bootstrapTarget = Join-Path $InstalledSource "scripts\apply-source-update.ps1"
$bootstrapText = Get-Content -LiteralPath $bootstrapSource -Raw
if ($bootstrapText.TrimStart() -notmatch '^param\(') { throw "The validated updater bootstrap does not start with param(." }
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $bootstrapTarget) | Out-Null
Copy-Item -LiteralPath $bootstrapSource -Destination $bootstrapTarget -Force
$copiedText = Get-Content -LiteralPath $bootstrapTarget -Raw
if ($copiedText.TrimStart() -notmatch '^param\(') { throw "The updater bootstrap copy failed validation." }
Write-Host "Prepared the installed ForgeFlow $($installedManifest.version) updater helper without changing its version." -ForegroundColor Green
}
} catch {
throw "Release was published, but the installed updater bootstrap could not be prepared: $($_.Exception.Message)"
}
} else {
Write-Host "Installed source was not found at $InstalledSource; publication itself succeeded." -ForegroundColor Yellow
}
Write-Host "Open the installed older ForgeFlow and use Settings -> ForgeFlow updates -> Check now." -ForegroundColor Cyan
} }
finally { finally {
Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue
+21 -16
View File
@@ -26,25 +26,30 @@ credentials on the user's own computer.
![ForgeFlow overview](docs/screenshots/overview.png) ![ForgeFlow overview](docs/screenshots/overview.png)
## Current status: v0.5.2 Windows publication reliability release ## Current status: v0.6.0 self-healing Git and Unraid operations
### v0.5.2 publication reliability and v0.5.0 workflow hardening ### Git recovery and repository truth
- viewport-safe deployment and server dialogs with persistent action buttons; - serializes every mutating Git action per repository;
- NUL-delimited Git pathspec transport for thousands of selected files; - waits through a short grace period and automatically retries after safely removing a proven stale lock;
- per-repository Git mutation queues and explicit stale-lock repair; - detects `HEAD.lock`, `index.lock`, ref locks and worktree locks while skipping object storage;
- one-click normalization of legacy Gitea remotes; - provides repository-specific actions for fetch, fast-forward, push and backed-up divergence reset;
- corrected password capture before the loading overlay re-renders the server form; - creates a `forgeflow/backup-*` safety branch before any reset to upstream.
- a verified publishing script for testing the built-in updater.
- large changed-file sets scroll independently; ### SSH / Unraid and DockerMan
- commit actions explain the missing prerequisite and auto-stage selected files;
- ITWorx.tech branding is integrated; - reconciles interrupted deployments against the live SHA, container state and health;
- the app can update itself from `Jens/ForgeFlow` in the configured Gitea instance; - applies DockerMan WebUI, icon and shell labels through an override and writes a persistent XML template fallback;
- SSH / Unraid servers can be configured once with pinned host identity; - keeps the visible container name such as `Portfolio` while enforcing lowercase internal service/image identities;
- exact commits can be deployed to `/mnt/user/appdata/<project>`; - includes a built-in high-contrast ITWorx icon, local PNG upload, persistent DockerMan image storage and cache refresh;
- existing Compose definitions and untracked runtime data are preserved; - exposes **Open Web UI**, **Repair DockerMan integration** and **Reconcile** directly on deployment cards.
- a basic Unraid-compatible Compose file can be generated for simple new projects.
### Update reliability
- confirms the external updater handshake before closing ForgeFlow;
- rejects malformed PowerShell update helpers before publication;
- validates the replacement source and keeps rollback/success status for the next launch;
- verifies that Gitea reports the exact published release commit.
Read [SSH / Unraid deployment](docs/SSH_UNRAID_DEPLOYMENT.md) and the Read [SSH / Unraid deployment](docs/SSH_UNRAID_DEPLOYMENT.md) and the
[LumaOps audit](docs/LUMAOPS_SERVER_AUDIT.md). [LumaOps audit](docs/LUMAOPS_SERVER_AUDIT.md).
+123 -111
View File
@@ -1,111 +1,123 @@
755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 .gitignore ForgeFlow 0.6.0 source manifest
330b5ef8c603a39bc04e2eb5b67351c957d43fb8b60b595e3c055ea5facc2162 CHANGELOG.md SHA-256 BYTES PATH
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db LICENSE (The manifest excludes itself and generated release archives.)
7769cb56a09533305837c155ce8212c55a5f12a933a9e5f72d6f69853c66e274 Publish-ForgeFlow-Release.ps1 755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 58 .gitignore
2460ec9580231f9786a5f3dfdfdc58e3aecdc57d0b43dfdec3344445275d0350 README.md aeb3772c830e37f23eca14c61d527e0021a69165e0c2bf7b9bb9374b7a409ec9 6195 CHANGELOG.md
e5414be56177664a12d31f8d668d6273628c62ae1a608b9f61580c2f9359bbf1 START_HERE.md 4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE
8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea UPDATE_FROM_0.3.2.md 217817c7e10a287f852735f412c25098c0983866d0b45769c828534912895c1a 347 OVERLAY-INSTRUCTIONS.md
7fbfbba99e6f38029b9a77c8fad8a8e5a91c186306e9e167aae5357c666aab2e build/icon-128.png 581214b50ae378af44d773016ec7899d73e4027c523ab1f083590ae9171569fd 4927 Publish-ForgeFlow-Release.ps1
2e4519b3fff1f06c2f706b11ab3ee87c5100092df6c6e03763c2e3010dfdca8f build/icon-16.png a94b84bb0c568b7c4f7df12f86a3fcca93ec2ae5ce25597e0a2d6feb6ea31106 13355 README.md
5e331c05fc5f76beb229ebc973ca4c88ad6663cef23ed38b67b03d233275371d build/icon-256.png 058aeaa5d9bfe377c7e322f213c7871ecc4151b5d08ef790992f4ee28d857658 743 START-FORGEFLOW-OVERLAY.ps1
d6342683ccadb6d5ae4b4013f4b4fa8dfb3c27d2d3449ba23e82c4dd4f68cb46 build/icon-32.png c7b1ecc475931577a914c94a326fbf25ec4a4a742286f6f5c2bae8a38528f6c0 2098 START_HERE.md
1a58c1454ec6bf39509f6c56094ee9927ac1fac71d4a52d4e35068ead80dd696 build/icon-48.png 8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea 767 UPDATE_FROM_0.3.2.md
c2b306a28dc374700c857a482a28ebc805845141e8eba1907486eeec9a76aba4 build/icon-512.png 0970821475a4452aa19e447e9397a95db836791f16890a1a83fd748ac033dc86 8830 build/icon-128.png
4d2adb598bf4ee3593f7293009a7dd2739742f44234daf4ecebd8b77bc74608f build/icon-64.png 09112c1425ca953d8dd8b2bcfd221e5a84b9f81752f7168f360e295030cbc8f2 521 build/icon-16.png
ca6fbcee9cd986155c760953ab4a56cf6eea448ba8adf227412e7069aa4e211f build/icon.ico 510aa27935a63ad16cc22978ccfde3bdd441cb970ad42d9f05af52c0e5999195 28923 build/icon-256.png
c2b306a28dc374700c857a482a28ebc805845141e8eba1907486eeec9a76aba4 build/icon.png fd895bf8f1772359110432b89fffa2efbf7785a8a3d973a99e429930de559b8c 1291 build/icon-32.png
6337a7d0791e8749e3a576d7735257b0031db7a9eff4a2d6818cc251544774d2 build-windows.ps1 ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263 build/icon-48.png
2d9836ae6d576bab5494b9f094bc673e5ca4772bf5771006583d1bc46fe46698 docs/ARCHITECTURE.md 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon-512.png
30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 docs/DEPLOYMENT_SETUP.md 4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183 3364 build/icon-64.png
eb42f979666e05d51c587e4223282914926a2b9b1ade9f3fb75525019ce7f738 docs/DIAGNOSTICS.md 25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico
a0cd06a96f23a94e118feb012be0fa1ac51345951cb2ba8e67fb8c889c4c342a docs/LUMAOPS_SERVER_AUDIT.md 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png
a0c00ff76acd1682bb5e0e8dcf6589c9480da436c9c6d30780a1ed58b4dad94f docs/RELEASE_NOTES_0.2.0.md 6337a7d0791e8749e3a576d7735257b0031db7a9eff4a2d6818cc251544774d2 1704 build-windows.ps1
5773ead01aa4c522c556295553787482d01b1f5242f053b2c61f120c4de4fa76 docs/RELEASE_NOTES_0.3.0.md 2d9836ae6d576bab5494b9f094bc673e5ca4772bf5771006583d1bc46fe46698 8296 docs/ARCHITECTURE.md
d46de73cf6c4cd5c2ba3f455a7a2af2e0d64ee9d94a97fd1a0bfb44e35c1624a docs/RELEASE_NOTES_0.3.1.md 30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md
0d697d241a08d2427a6e7f5c2f27bd1830a41836a01e08eeff239c7ad5d89982 docs/RELEASE_NOTES_0.3.2.md eb42f979666e05d51c587e4223282914926a2b9b1ade9f3fb75525019ce7f738 4616 docs/DIAGNOSTICS.md
bc6933c303d3d9b3bfdbf678cae1a717bfe5a893780a1871af8b48589f62f0e3 docs/RELEASE_NOTES_0.4.0.md a0cd06a96f23a94e118feb012be0fa1ac51345951cb2ba8e67fb8c889c4c342a 5007 docs/LUMAOPS_SERVER_AUDIT.md
343862445061e1a8282a7aa9b2304e7d799e58f9956d50eb5352db18d790efad docs/RELEASE_NOTES_0.4.1.md 84ae90829ecd0eb9b56b7c7a9139e12a86f74c3d938c29b63020286955d317ca 4508 docs/RELEASE_AUDIT_0.6.0.md
e2d67c816a919f00f9e26bf59cf29e5e8cf894536b743d282075c646c5accc96 docs/RELEASE_NOTES_0.4.2.md a0c00ff76acd1682bb5e0e8dcf6589c9480da436c9c6d30780a1ed58b4dad94f 1770 docs/RELEASE_NOTES_0.2.0.md
1aef74fb109541903c4dbc4d9c48d2bd63507420eaf8cceb31890797a5e4f5fd docs/RELEASE_NOTES_0.4.3.md 5773ead01aa4c522c556295553787482d01b1f5242f053b2c61f120c4de4fa76 5963 docs/RELEASE_NOTES_0.3.0.md
85fecec65f7687e1382547166eff62777613825a8d81960dfcb4ae16aa15c8be docs/RELEASE_NOTES_0.4.4.md d46de73cf6c4cd5c2ba3f455a7a2af2e0d64ee9d94a97fd1a0bfb44e35c1624a 1093 docs/RELEASE_NOTES_0.3.1.md
5cd0cffecdce942fb1024a0410568174e7704af9bacbe42f81891693a1817a19 docs/RELEASE_NOTES_0.4.5.md 0d697d241a08d2427a6e7f5c2f27bd1830a41836a01e08eeff239c7ad5d89982 2445 docs/RELEASE_NOTES_0.3.2.md
f9554c10f56d41d916330f06175b07f099c9ed1534f00abc9ea7f94be70f4a97 docs/RELEASE_NOTES_0.5.0.md bc6933c303d3d9b3bfdbf678cae1a717bfe5a893780a1871af8b48589f62f0e3 2160 docs/RELEASE_NOTES_0.4.0.md
11a932e2c401c53d117175aa312b6d20508918b12192d37c5084bd54a5ea3a72 docs/RELEASE_NOTES_0.5.1.md 343862445061e1a8282a7aa9b2304e7d799e58f9956d50eb5352db18d790efad 1134 docs/RELEASE_NOTES_0.4.1.md
d7d007e4c2807698db07b2ebe1cb48c36bd162bf4daad77c9d299096c9654d5a docs/RELEASE_NOTES_0.5.2.md e2d67c816a919f00f9e26bf59cf29e5e8cf894536b743d282075c646c5accc96 1605 docs/RELEASE_NOTES_0.4.2.md
c465f1a9c4454c9a18f38f68a243037b8897c2c9929077a586604acd4ff26d35 docs/ROADMAP.md 1aef74fb109541903c4dbc4d9c48d2bd63507420eaf8cceb31890797a5e4f5fd 670 docs/RELEASE_NOTES_0.4.3.md
322624242d246d07180cc719e14c91e8fb69e123676a02e5046f4e576cca1ca1 docs/SECURITY.md 85fecec65f7687e1382547166eff62777613825a8d81960dfcb4ae16aa15c8be 617 docs/RELEASE_NOTES_0.4.4.md
c79123aa4c718ac3ab0d79771f2967710c28f939b58fca0094b02e3172f2c024 docs/SETUP_GUIDE.md 5cd0cffecdce942fb1024a0410568174e7704af9bacbe42f81891693a1817a19 378 docs/RELEASE_NOTES_0.4.5.md
b5ba1f7580e47e1f01900964b866d9f15b973a9e9dcccf2650f403595020e949 docs/SSH_UNRAID_DEPLOYMENT.md f9554c10f56d41d916330f06175b07f099c9ed1534f00abc9ea7f94be70f4a97 1097 docs/RELEASE_NOTES_0.5.0.md
b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 docs/STATUS_ENDPOINT.md 11a932e2c401c53d117175aa312b6d20508918b12192d37c5084bd54a5ea3a72 957 docs/RELEASE_NOTES_0.5.1.md
0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 docs/STITCH_REVIEW.md d7d007e4c2807698db07b2ebe1cb48c36bd162bf4daad77c9d299096c9654d5a 721 docs/RELEASE_NOTES_0.5.2.md
08640f1b5e26048b5ae501909f415d2426b07cc316a0bd2178023f2457aa7a2a docs/TEST_MATRIX.md 3e77df12a7ff4b545069933410bf14fe8891f39915182df25112c722cf4e243d 1030 docs/RELEASE_NOTES_0.5.3.md
6794a12b10ce7f35223862b422c202e5ba0308eabedf03339dea0d54f8f3d191 docs/UPDATING.md 61f6cbc1c3f263fa96b5c6a70a26baa7cd577d37ac633455eed45d9b63169a35 710 docs/RELEASE_NOTES_0.5.4.md
1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 docs/screenshots/deploy-confirmation.png 9f72a5d039615785ccd4771f38b060bb58386b3217ba2a56459067cebbbb812f 4875 docs/RELEASE_NOTES_0.6.0.md
b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa docs/screenshots/deployment-run.png c465f1a9c4454c9a18f38f68a243037b8897c2c9929077a586604acd4ff26d35 3655 docs/ROADMAP.md
070e6700bdae8c628c907ba181bbf0dde0bbbbb4208f7a875503f933ff1b882e docs/screenshots/deployment-success.png 322624242d246d07180cc719e14c91e8fb69e123676a02e5046f4e576cca1ca1 5569 docs/SECURITY.md
bcb1e4daf1eeedc5b3f61d2406f1a65312dba130082528007e1629d9df99570a docs/screenshots/overview.png c79123aa4c718ac3ab0d79771f2967710c28f939b58fca0094b02e3172f2c024 13067 docs/SETUP_GUIDE.md
224e34ab45877bbb97b07d2a14c4a5aa6e28339522a8015b33a2a81477177143 docs/screenshots/repository-workspace.png 43f73b6674ee0c5d7ace8ab39db95b5b42b61c73dd79ffc8d87fe214d8b51d7e 5070 docs/SSH_UNRAID_DEPLOYMENT.md
c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 examples/gitea-actions/deploy.yml b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md
4c792cc9fd57ed36da291300c252a6ef75b08a249cf6f2561e23c4c22522138a examples/gitea-actions/rollback.yml 0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md
1d2cde1bef4882f56006823d2806f6105882fa098a665a303150fdf18ada2004 examples/server/forgeflow-deploy 08640f1b5e26048b5ae501909f415d2426b07cc316a0bd2178023f2457aa7a2a 3978 docs/TEST_MATRIX.md
4fe3eee5c2d8705964c24b8c4dd909883a05e7d6eb85629c84b0d64473e0a92b examples/server/forgeflow-runner.sudoers 95b5b2915a11065a22eb2822ecce9fb30fec0c08874d63c8ffb5182cd20b3459 2328 docs/UPDATING.md
0423fe2cc7f43fe793986a3f62a395668897cdf07348756aa7742a8cd40ac51c examples/server/forgeflow-targets.conf 1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png
106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a examples/server/nginx-forgeflow-status.conf b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png
2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a examples/server/status-example.json 070e6700bdae8c628c907ba181bbf0dde0bbbbb4208f7a875503f933ff1b882e 118819 docs/screenshots/deployment-success.png
6e4ef7ec12358d756d2ee6105420a5a440ba8d207a7ee26a35c873c24661d84d main.cjs bcb1e4daf1eeedc5b3f61d2406f1a65312dba130082528007e1629d9df99570a 153240 docs/screenshots/overview.png
a8f32272f09ca019c6e9b3c92298b47558c1731ea761ae00d93ae77501b91a7f package.json 224e34ab45877bbb97b07d2a14c4a5aa6e28339522a8015b33a2a81477177143 135102 docs/screenshots/repository-workspace.png
97af4d20a26dbf4f9643c4ff007bb48840231a245b02dee9c004fcd1fc6d888f preload.cjs c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 examples/gitea-actions/deploy.yml
a39ab8ac36fc81c37c1718ec620d4e590a1c404d01264e07e3c904a5189bdc27 scripts/apply-source-update.ps1 4c792cc9fd57ed36da291300c252a6ef75b08a249cf6f2561e23c4c22522138a 1477 examples/gitea-actions/rollback.yml
f427dfcd7b5ee7079de13633c8d7d22a91115e0bbc4f2a9a96246f42176d4880 scripts/doctor.mjs 1d2cde1bef4882f56006823d2806f6105882fa098a665a303150fdf18ada2004 5705 examples/server/forgeflow-deploy
444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 scripts/serve-demo.mjs 4fe3eee5c2d8705964c24b8c4dd909883a05e7d6eb85629c84b0d64473e0a92b 258 examples/server/forgeflow-runner.sudoers
3d6b25c37c92607dbc08b4b6303addb6fb2a28dc2845a5d4eeb9c48deeae7818 scripts/verify.mjs 0423fe2cc7f43fe793986a3f62a395668897cdf07348756aa7742a8cd40ac51c 569 examples/server/forgeflow-targets.conf
92524adae60aced3af23f8afe82c011873ae9f1e53d854e4d12a94e8d1be1aa9 setup-windows.ps1 106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf
366c1edbc90a00fcbf660002e55291ba234d896e7afbe24002d9d6db84b9f44c src/main/config-store.cjs 2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json
a970ff3f47d1641bf1ab9611e1122349aa65ff8fee4789585e078431368b8c6b src/main/deployment-service.cjs 1e47552cfde3ca925ba1f24fbc471f3fc29468ca7ef3351beb9bde2bf0747bc7 7887 main.cjs
c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c src/main/diagnostics-service.cjs 70ffe80e89f979ca1e72c6c1ea75b6456e685485aae53447ecbd4a4de16d104a 2708 package.json
1558fccc76d4eb563940e51b5d483edfe5e0c7987a35ad1dbfcd5f406eb4d44d src/main/git-service.cjs 0cd434cb21af86e7f6983416e4ed14762565df90edc2d00d4a60378df75b7419 6846 preload.cjs
ab7344b1951c87e982cab5c293891bc45dad48a48a3b4b63991b0e76ef785ba6 src/main/gitea-service.cjs 118c2600734d9a25310a148f791f8adeec793b186c3c3c764790c323c73ecfb6 8663 scripts/apply-source-update.ps1
2d273442b55d7e3ddf0b1cd4c606a162a4ead03c44028e149976a9e1f4f0470a src/main/ipc.cjs f427dfcd7b5ee7079de13633c8d7d22a91115e0bbc4f2a9a96246f42176d4880 3596 scripts/doctor.mjs
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c src/main/log-redaction.cjs 444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e src/main/preflight-service.cjs 4300ef0cee50d1aaf1f63be7f9884e67b2411b8f6743f48e5aa26fa724d4e428 9821 scripts/verify.mjs
1dc0c997bd2d837f3d27dff58a9443888597b7981c8a1dd1eaa4487176ef716c src/main/process-runner.cjs 92524adae60aced3af23f8afe82c011873ae9f1e53d854e4d12a94e8d1be1aa9 2075 setup-windows.ps1
e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 src/main/repository-monitor.cjs 87885d640a1148078426522c87d7c9b7fced1fabb371020a4781bb94b256ee00 19664 src/main/config-store.cjs
eca26673564284fce8715bd74201e59fa390800926d968d642d07eec5cc3af66 src/main/repository-service.cjs a970ff3f47d1641bf1ab9611e1122349aa65ff8fee4789585e078431368b8c6b 23655 src/main/deployment-service.cjs
65db01a05d842c40bb784c34560870b2e9c3b973c088fcc5ce0db654a585b146 src/main/ssh-service.cjs c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs
db7fa63d85cec92afc9017207495571e7c5ec215aca02cf2aa18e6b8e265ba27 src/main/unraid-deployment-service.cjs c50bf93d0d1abfc0319e59a545877413bd3aca1e5b57c9a48a2467db5e89d7f8 25731 src/main/git-service.cjs
2b39c0c1e84da52026dc95c9962c4976b7f69bc636b186dc8a41f9d4904f208a src/main/update-service.cjs ab7344b1951c87e982cab5c293891bc45dad48a48a3b4b63991b0e76ef785ba6 12759 src/main/gitea-service.cjs
7ec82a1d4f6d74b44f4bf4a9641f66195c07d82937fa3c89c681ba9d751370fb src/renderer/app.js 743002a5dae3c8e6aa5236609dd58c786420408c321eebf6d6b9e32a684cd696 26042 src/main/ipc.cjs
2f3448ddaa016105769d20cbe30c461fd7ba5d3bd105865b750aa0ef6b66e1af src/renderer/assets/itworx-mark.png 62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs
37f7da5a438b88be731c45c027a0fd88d08bd1af3150afa787836fa7baadbc48 src/renderer/assets/itworx-wordmark.png 958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs
0fc26fbc70918e92586098fb0ee5c2f9946758020f930a08a005b270794b5998 src/renderer/index.html 1dc0c997bd2d837f3d27dff58a9443888597b7981c8a1dd1eaa4487176ef716c 1520 src/main/process-runner.cjs
37b2dce2a55befd968f589d3ff29b66bb407162c9d0de5e3ffe833ac5c7556ec src/renderer/mock-bridge.js e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508 src/main/repository-monitor.cjs
9908a81d4f5d23313eaea042a3593d8107984272698d131ebe4ea246a12f013c src/renderer/styles.css a302bdcfbf2e2b66fdb4e13d94cc7a78cd4065a779b980f4487b6075a6472017 7583 src/main/repository-service.cjs
0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d src/shared/clone-target.cjs ad9e8b67bd10f2f5708d00ebacf660ab4917dd02b3d22b3b6d730bcb2a7e1c18 8124 src/main/ssh-service.cjs
029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f src/shared/git-status.cjs a716f7402d3039296f99f1a7958a9aa521032f091e4171e224fa548d131a4183 49354 src/main/unraid-deployment-service.cjs
2778ebcbdf60fdc1cb0749f15565e0e1bd66f3a0d31eb70ae7942a7511a3de75 src/shared/repository-match.cjs 86f6b762e85de29dc36a53427f9f62f0fb21b2e389bd7ce6ad747e527c28e7e7 11322 src/main/update-service.cjs
7f4d057a3c8e8d22eda9477eea7b144237824ef0f514737831d1881ff8e7f4a4 src/shared/semver.cjs cea387e7996de8d185cd11f7d8d4e0675a58df1ca8f3cb6c3c6d6363c72f5f12 150880 src/renderer/app.js
ede2c95bb045c0005a3931709a0116d9fbcb3faa5f609848a0066c6ba382ca0b src/shared/shell-verification.cjs 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png
2daa98fd421598bfe5fc9757c9b6f4d82c31d1bfece15829928473581d5d2639 src/shared/tool-invocation.cjs 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png
a97c83b8023d6c0cf49d6f2d5b626ef2341f02670f0de170e840026d28fd1f0e src/shared/validation.cjs 094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png
13b731c38863b1007b0312fd9d89562401b7cce875c952f52429bde74f77a8af src/shared/zip-writer.cjs 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png
454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 tests/clone-target.test.mjs 394f901b0e4add6b788c006d9869a66dc021dcebf2916f5a732bc42786510efc 740 src/renderer/index.html
abb65b39f285da518a48be41aff40d89ceb9c5b0e6091772c2bde171f65daf9b tests/deployment-status.test.mjs 4f3d7fa6126fe609a64cbe2de4817c6a9f2b6b31ba8abee4f8e35a93a185c601 39109 src/renderer/mock-bridge.js
fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 tests/diagnostics.test.mjs 6f0bd7b898677b9bcf6a104d7b1292b9ed5db826e4afdc86907e1c55bd889456 48072 src/renderer/styles.css
625d600edf89dadc63e7c7989227d6509bd6f43a298f88804e2ee42afe2036c3 tests/git-integration.test.mjs 0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs
5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db tests/git-status.test.mjs 029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs
681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 tests/git-workflows.test.mjs 2778ebcbdf60fdc1cb0749f15565e0e1bd66f3a0d31eb70ae7942a7511a3de75 1295 src/shared/repository-match.cjs
e914b2bcafbd674c06adfd9bd851ca04e134210691b7f91cd3de26cee37ef5f3 tests/gitea-actions.test.mjs 7f4d057a3c8e8d22eda9477eea7b144237824ef0f514737831d1881ff8e7f4a4 1120 src/shared/semver.cjs
caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 tests/log-redaction.test.mjs ede2c95bb045c0005a3931709a0116d9fbcb3faa5f609848a0066c6ba382ca0b 2906 src/shared/shell-verification.cjs
c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 tests/preflight.test.mjs 2daa98fd421598bfe5fc9757c9b6f4d82c31d1bfece15829928473581d5d2639 1210 src/shared/tool-invocation.cjs
462fffc71845d6e79f07e1298ef9de7648a8d680087db377ed4901d9ae96a732 tests/renderer-workflow.test.mjs a97c83b8023d6c0cf49d6f2d5b626ef2341f02670f0de170e840026d28fd1f0e 5202 src/shared/validation.cjs
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 tests/repository-matching.test.mjs 13b731c38863b1007b0312fd9d89562401b7cce875c952f52429bde74f77a8af 3096 src/shared/zip-writer.cjs
f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 tests/repository-monitor.test.mjs 454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 4318 tests/clone-target.test.mjs
3c71aa5fb30d9c6fbc4b0ccfcf5112f45cbcc2a60cb990e4551a8813f7155505 tests/security-validation.test.mjs abb65b39f285da518a48be41aff40d89ceb9c5b0e6091772c2bde171f65daf9b 7523 tests/deployment-status.test.mjs
ecfdad2a03c24898c822fcf05abac89c8f8fe452a05b16fdafc0236a64c27a23 tests/semver.test.mjs fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800 tests/diagnostics.test.mjs
020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 tests/shell-verification.test.mjs efee4d7ae2a51b27d8643bd40e896aaddcdfdaa98bd4ac16ec83670b82b13791 12066 tests/git-integration.test.mjs
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be tests/tool-invocation.test.mjs 5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db 1239 tests/git-status.test.mjs
80f748687bc3fb72812388faf34732c8e71cbccd1f9258bc95b7f32c34eb3a84 tests/unraid-deployment.test.mjs 681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.test.mjs
cfc143a618be64456512313f0b244c1310e9c79ce94b7dacb1b627d9de26f750 tests/update-service.test.mjs e914b2bcafbd674c06adfd9bd851ca04e134210691b7f91cd3de26cee37ef5f3 4154 tests/gitea-actions.test.mjs
4d1f0a4c46190ca72b51fddf79ec6d4d02e65fa6f42ef3755de5d414f7da75bb tests/validation.test.mjs caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs
7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d tests/zip-writer.test.mjs c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 3718 tests/preflight.test.mjs
3ea68269b66f639b3aba50c9605ccbfaa32c22a1d2cc842d8296c4ee59df6212 update-windows.ps1 d837c7ee9f9c3f6ee37d6546c4f8bcee49463339dab165a1fc1edaa6735b4ff9 4550 tests/renderer-workflow.test.mjs
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs
f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 1068 tests/repository-monitor.test.mjs
6527813f2ae318f7a6aca57d375a3d3583173a208c540d2e8772f84f479d42b0 2205 tests/repository-service.test.mjs
3c71aa5fb30d9c6fbc4b0ccfcf5112f45cbcc2a60cb990e4551a8813f7155505 3397 tests/security-validation.test.mjs
ecfdad2a03c24898c822fcf05abac89c8f8fe452a05b16fdafc0236a64c27a23 614 tests/semver.test.mjs
020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs
7c0f5268028cf8904b446c5d9c5a8b6450966f493018777256104df2626d0f3e 15662 tests/unraid-deployment.test.mjs
927a3c13ce054fa1210fd9f25e7030af0125b01b8fddd1790153f5f823fc1a64 7712 tests/update-service.test.mjs
4d1f0a4c46190ca72b51fddf79ec6d4d02e65fa6f42ef3755de5d414f7da75bb 655 tests/validation.test.mjs
7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs
3ea68269b66f639b3aba50c9605ccbfaa32c22a1d2cc842d8296c4ee59df6212 1537 update-windows.ps1
+19
View File
@@ -0,0 +1,19 @@
$ErrorActionPreference = "Stop"
$project = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $project
$manifest = Get-Content (Join-Path $project "package.json") -Raw | ConvertFrom-Json
if ($manifest.name -ne "forgeflow" -or $manifest.version -ne "0.6.0") {
throw "This overlay is not ForgeFlow 0.6.0."
}
Write-Host "ForgeFlow 0.6.0 overlay validation" -ForegroundColor Cyan
Write-Host "Project: $project"
& npm install --no-audit --no-fund
if ($LASTEXITCODE -ne 0) { throw "npm install failed with exit code $LASTEXITCODE." }
& npm run check
if ($LASTEXITCODE -ne 0) { throw "npm run check failed with exit code $LASTEXITCODE." }
Write-Host "Starting ForgeFlow 0.6.0..." -ForegroundColor Green
& npm start
+3 -5
View File
@@ -1,14 +1,12 @@
# Start here — ForgeFlow v0.4.2 # Start here — ForgeFlow v0.6.0
You do **not** need to send anyone your Gitea token, SSH key or server password. You do **not** need to send anyone your Gitea token, SSH key or server password.
All credentials are entered locally in ForgeFlow during setup. Diagnostic logging All credentials are entered locally in ForgeFlow during setup. Diagnostic logging
is designed to exclude them. is designed to exclude them.
## Already running v0.3.2? ## Already running an older source release?
Use the v0.4.2 update overlay and follow [docs/UPDATING.md](docs/UPDATING.md). Publish v0.6.0 with `Publish-ForgeFlow-Release.ps1`, leave the currently installed source folder untouched, and test **Settings → ForgeFlow updates → Check now → Download update → Apply & restart**. Configuration and credentials remain outside the source directory.
Your Gitea token and ForgeFlow configuration are stored outside the source
folder and are preserved.
## Fast path on Windows ## Fast path on Windows
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 483 B

After

Width:  |  Height:  |  Size: 521 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 28 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 84 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 45 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 84 KiB

+73
View File
@@ -0,0 +1,73 @@
# ForgeFlow 0.6.0 release audit
## Scope
This audit covers the source release intended for publication to `Jens/ForgeFlow` and subsequent installation through ForgeFlow's built-in source updater.
Reviewed areas:
- local Git discovery, status, staging, commit, push, fetch and fast-forward;
- large Windows path selections and deleted/renamed files;
- stale Git lock diagnosis, conservative repair and automatic retry;
- divergence recovery with a safety branch;
- Gitea repository and Actions integration;
- SSH host identity, Unraid-to-Gitea preflight and exact-SHA deployment;
- Docker Compose identity normalization while preserving visible container names;
- DockerMan WebUI, icon and shell labels, XML fallback and cache refresh;
- interrupted/stale deployment reconciliation;
- renderer viewport behavior and guided troubleshooting;
- diagnostics redaction and support bundles;
- release publication and built-in source-update lifecycle.
## Regression coverage
The automated suite contains 99 passing tests, including real temporary Git repositories and bare remotes. High-risk regressions covered directly include:
- staged and unstaged deletions;
- renamed files;
- a local commit followed by a failed push;
- 850 long selected paths transported through NUL-delimited stdin;
- stale `HEAD.lock` removal while excluding Git object/LFS storage;
- backup-before-reset repair of a diverged branch;
- exact remote-SHA checks;
- background SSH deployment completion without a stuck operation;
- startup/manual reconciliation of live Unraid state;
- lowercase-safe Compose project/service/image identities with visible `Portfolio` casing;
- DockerMan labels, built-in icon upload, XML fallback and cache invalidation;
- source-updater STARTED handshake, result acknowledgement, direct Electron restart and rollback state.
## Product behavior added for the reported incidents
- Git mutations are serialized per repository.
- A lock failure triggers a safe diagnosis and one automatic repair/retry when no active Git process is detected.
- Git Tools provides personalized scan, lock repair, origin repair, fast-forward, push and safety-branch divergence recovery actions.
- Successful SSH deployments become terminal before the secondary server refresh, preventing a live container from leaving ForgeFlow in deployment mode.
- ForgeFlow refreshes configured server truth after startup and through the combined refresh action.
- A healthy live SHA equal to local/Gitea is not offered for deployment again.
- Running containers missing DockerMan metadata can be repaired individually or in one batch from Deployments.
- Built-in/uploaded icons are placed in persistent DockerMan storage, referenced through a `file:///` label, written into a user template and copied into known icon caches.
- WebUI uses the Unraid label placeholders based on the configured host port and path.
- Update publication creates and publishes `package-lock.json`; the updater uses `npm ci` when it is present.
- Update success is persisted before restart, and restart invokes Electron directly rather than relying on a detached npm process.
## Static and packaging checks
- every JavaScript/CJS/MJS source file passes `node --check`;
- required source, branding, documentation, updater and deployment files are present;
- direct dependency versions are pinned;
- renderer privileged actions remain behind the preload/IPC boundary;
- the PowerShell update helper starts with `param(`, has no UTF-8 BOM and contains lifecycle state before shutdown/restart;
- release archives exclude `.git`, `node_modules`, `dist`, update downloads and generated ZIPs;
- the generated source manifest records SHA-256 and size for every distributed source file.
## Remaining live acceptance step
The automated environment cannot execute Windows PowerShell 5.1 or connect to the user's private Gitea/Unraid services. The final live acceptance is therefore deliberately the requested workflow:
1. publish the release from an extracted Downloads folder;
2. leave the installed older source at `C:\Projects\ForgeFlow` untouched;
3. open that older ForgeFlow;
4. use **Settings → ForgeFlow updates → Check now → Download update → Apply & restart**;
5. confirm the restarted application reports version 0.6.0 and displays the persisted success result.
A failed handoff must keep the old app open. A failed validation must restore the previous source. A successful installation remains installed even when only automatic restart fails.
+20
View File
@@ -0,0 +1,20 @@
# ForgeFlow 0.5.3
## Confirmed source-update handoff
- ForgeFlow writes a launch request and waits for an external PowerShell **STARTED marker** before closing.
- A helper launch failure or timeout leaves ForgeFlow open and surfaces the real error.
- The updater records structured lifecycle state alongside the detailed update log.
- Successful installation remains valid even when automatic restart is unavailable.
- The next manual start shows a **visible update result** for success, failure, or rollback.
- The PowerShell helper uses the absolute Windows PowerShell executable where available.
- Success and rollback both attempt an **automatic restart**, with the result persisted for diagnosis.
## Included 0.5.x reliability improvements
- viewport-safe, scrollable deployment and settings dialogs;
- sticky modal action bars;
- large Git selections through NUL-delimited pathspec input;
- serialized repository mutations;
- SSH password-form persistence correction;
- origin normalization and stale-index-lock repair.
+10
View File
@@ -0,0 +1,10 @@
# ForgeFlow 0.5.4
This release repairs the first real SSH / Unraid deployment path.
- Preflight now verifies **Unraid → Gitea access** with the exact configured clone URL before deployment can start.
- SSH deployments run as a **background deployment** operation; the interface returns immediately and polls the real operation state.
- Failed SSH or Docker operations are written back as terminal failed operations instead of leaving the UI indefinitely active.
- Compose service and container casing are preserved, so the requested name **Portfolio** remains Portfolio.
- Generated Compose no longer forces service names to lowercase.
- Deployment status refreshes automatically until success or failure.
+61
View File
@@ -0,0 +1,61 @@
# ForgeFlow 0.6.0
ForgeFlow 0.6.0 is a product-level reliability release for Git recovery, SSH / Unraid deployment truth, DockerMan integration, source updating and high-contrast ITWorx branding.
## Git operations that recover themselves
- Every mutating Git action is serialized per repository.
- A Git lock error triggers a conservative stale-lock scan, a short grace period for very recent locks, safe removal and automatic retry when ForgeFlow can prove that no matching process is active.
- The scanner resolves the actual Git directory and covers `HEAD.lock`, `index.lock`, ref locks and worktree locks while skipping Git object/LFS storage.
- The Git tools page now provides repository-specific troubleshooting instead of generic terminal advice.
- Available automated actions are selected from the actual branch state: fetch, fast-forward, push, origin repair and divergence recovery.
- Divergence recovery creates a `forgeflow/backup-<branch>-<timestamp>` safety branch before resetting the current branch to upstream.
## Deployment truth instead of stuck spinners
- SSH / Unraid operations are reconciled with the live Git SHA, container state and health.
- Startup deployment reconciliation converts an interrupted but successful deployment to `success` and refreshes repository cards immediately.
- Stale operations are marked failed instead of remaining indefinitely in `running`.
- Deployment cards provide **Reconcile**, **Open Web UI** and **Repair DockerMan integration** actions.
- Background completion broadcasts update the renderer and trigger repository refresh.
- A successful remote Compose run is marked terminal before the secondary Unraid inspection, so a slow refresh can no longer leave the UI stuck in deployment mode.
- Startup and manual refresh reconcile every configured environment and suppress a new Deploy action when the exact healthy SHA is already live.
- The Deployments page can repair all running containers that are missing DockerMan WebUI/icon metadata in one controlled batch.
## DockerMan integration
- ForgeFlow applies `net.unraid.docker.managed=dockerman`, `net.unraid.docker.webui`, `net.unraid.docker.icon` and `net.unraid.docker.shell` through a controlled Compose override.
- WebUI labels use Unraid's `[IP]` and `[PORT:<host-port>]` placeholders.
- Internal Compose project, service and image identities are lowercase-safe while the visible container name can remain `Portfolio`.
- The built-in high-contrast ITWorx mark is the default DockerMan icon for new or migrated SSH profiles.
- A user can instead select a local PNG, use an HTTP(S) PNG URL or disable the icon.
- Built-in/uploaded PNGs are copied persistently to DockerMan's image storage under `/boot/config/plugins/dockerMan/images`.
- ForgeFlow writes a persistent `templates-user/my-<container>.xml` fallback so WebUI and icon metadata remain available when label caching is unreliable.
- Known DockerMan icon caches and the volatile metadata cache are invalidated after container recreation so changes can be re-read.
## SSH and server-side safety
- Unraid-to-Gitea access remains part of preflight before any deployment starts.
- SFTP directory creation now distinguishes existing directories from permission and path errors instead of treating every generic SFTP failure as success.
- Repository Compose files receive the same metadata and exact-SHA controls as generated Compose files.
- Tracked server-side modifications continue to block deployment and rollback.
## Source updater and publication
- The PowerShell update helper starts directly with `param(`, without a UTF-8 BOM or stray leading character.
- ForgeFlow waits for a structured `started` marker before closing the running application.
- Update application performs backup, exact archive checksum validation, source replacement, lockfile-based `npm ci` when available and the complete quality gate.
- Success, restart failure and rollback status are persisted and shown on the next launch.
- Automatic restart launches the installed Electron executable directly, avoiding the unreliable detached `npm start` handoff.
- The publishing script runs the quality gate, mirrors a clean source tree and verifies that Gitea reports the exact pushed release commit.
## Branding
- The title bar, setup flow, desktop icon and installer artwork use the newly supplied higher-contrast ITWorx.tech logo.
- The cloud/check mark was recropped to remove wordmark fragments and remain legible at small icon sizes.
## Verification
The release includes real Git integration coverage for large selections, staged deletions, failed pushes, `HEAD.lock`, object-store exclusion and backup-before-reset divergence recovery. It also covers DockerMan labels and XML fallback, built-in icon upload, cache invalidation, operation reconciliation, viewport-safe dialogs and updater lifecycle behavior.
+62 -79
View File
@@ -1,112 +1,95 @@
# SSH / Unraid deployment # SSH / Unraid deployment
ForgeFlow 0.4 can deploy an exact Gitea commit directly to an Unraid server over SSH. ForgeFlow deploys an exact Gitea commit directly to an Unraid server over pinned SSH.
## Security model ## Security model
- Enter credentials only in the local ForgeFlow desktop window. - Credentials are entered only in the local ForgeFlow desktop application.
- Prefer an Ed25519 private key over a password. - Ed25519 private keys are preferred.
- ForgeFlow stores passwords and private-key passphrases through Electron safe storage. - Passwords and key passphrases use Electron safe storage.
- The first successful test records the SSH host-key fingerprint. - The first trusted connection records the SSH host-key fingerprint; later changes fail closed.
- Later connections fail closed when that fingerprint changes. - Unraid-to-Gitea repository access is tested during every deployment preflight.
- Diagnostics redact the Gitea token, SSH password and private-key passphrase. - The renderer cannot submit arbitrary shell commands. Remote scripts are assembled from validated profile fields and transported as base64-encoded Bash input.
- ForgeFlow never sends arbitrary commands entered through the renderer. Deployment commands are assembled from validated profile fields. - Tracked server-side modifications block deployment and rollback.
## Configure the server ## Profile identity
Open **Settings → SSH / Unraid servers → Add server**. ForgeFlow separates names that users see from names Docker requires:
Typical Unraid values:
```text ```text
Name: Unraid Visible project/container: Portfolio
Host: 192.168.1.10 Server folder: Portfolio
Port: 22 Internal Compose project: portfolio
Username: root Internal Compose service: portfolio
Base path: /mnt/user/appdata Internal image: forgeflow/portfolio:production
Auth: Private key
``` ```
Save the server, then choose **Test & trust**. ForgeFlow verifies SSH, Git and Docker Compose and records the host-key fingerprint. The internal Compose service must match the repository's service key and remain lowercase. The visible container can preserve branding and casing.
## DockerMan WebUI, icon and shell
ForgeFlow writes `.forgeflow/compose.metadata.yml` and combines it with the repository or generated Compose file. The override supplies:
```text
net.unraid.docker.managed=dockerman
net.unraid.docker.webui=http://[IP]:[PORT:<host-port>]/
net.unraid.docker.icon=<PNG URL or persistent Unraid path>
net.unraid.docker.shell=sh
```
Icon modes:
- **Built-in high-contrast ITWorx mark** — default;
- **Upload local PNG** — copied to `/boot/config/plugins/dockerMan/images/<container>-icon.png`;
- **Use icon URL** — HTTP(S) PNG;
- **No custom icon**.
After metadata changes ForgeFlow recreates the container, writes `/boot/config/plugins/dockerMan/templates-user/my-<container>.xml`, removes known icon caches and invalidates DockerMan's volatile `docker.json` metadata cache. The Unraid Docker page may still need one browser refresh.
The deployment card reports whether WebUI and icon labels were confirmed through `docker inspect`. **Repair DockerMan integration** recreates an existing healthy container with labels, a persistent DockerMan template, icon cache refresh and WebUI metadata without creating a Git commit. **Open Web UI** uses the profile URL directly from the desktop.
## Existing application folder ## Existing application folder
Create a deployment profile and choose **SSH / Unraid**.
For an existing folder: For an existing folder:
```text ```text
Server folder: lumaops Server folder: Portfolio
Remote path: /mnt/user/appdata/lumaops Remote path: /mnt/user/appdata/Portfolio
Compose file: docker-compose.yml Compose file: docker-compose.yml
``` ```
ForgeFlow inspects the folder before deployment. An existing deployment is adopted only when the project root is a Git working tree. Tracked server-side changes block deployment. Untracked runtime paths such as `.env`, `appdata`, `data`, `logs`, `config` and `compose.override.yml` remain untouched by `git reset --hard`. The project root must be a Git working tree. Untracked runtime paths such as `.env`, `appdata`, `data`, `logs`, `config` and `compose.override.yml` remain untouched by `git reset --hard`. Nested Git repositories are warnings and never deleted automatically.
Keep the root `.git` directory. It is used to verify the exact commit, update the working tree and roll back to the previous SHA. Preflight inspects `.dockerignore` when a Dockerfile exists. It warns when `.git`, preserved runtime data or nested repositories would be sent into the build context.
Nested Git repositories are reported as warnings and are never removed automatically.
When a Dockerfile is present, preflight also inspects `.dockerignore`. It reports
whether `.git` is excluded and warns when existing preserved runtime folders or
nested repositories would still be sent as Docker build context. Fix those
rules in the repository and commit them rather than changing only the live
server copy.
## New application folder ## New application folder
For a new project, ForgeFlow creates: The server clones the configured URL on the selected branch. The Unraid host therefore needs a non-interactive Gitea SSH identity. Preflight runs `git ls-remote --exit-code` from Unraid before deployment.
```text Use repository Compose for real applications. Generated Compose is intended only for a simple single-service Dockerfile application with basic port mapping.
/mnt/user/appdata/<repository-name>
```
The Unraid server clones the configured Git URL on the selected branch. The server therefore needs access to that repository, normally through an SSH deploy key or an existing trusted Gitea SSH identity.
Two Compose modes are available:
1. **Use repository Compose file** — recommended for real applications. Keep ports, volumes, devices, networks and Unraid labels version-controlled.
2. **Generate basic ForgeFlow Compose** — suitable for a simple Dockerfile-based application. ForgeFlow asks for host port, container port, service/container name, Web UI URL and icon URL and writes `.forgeflow/compose.forgeflow.yml`.
Generated Compose deliberately stays minimal. Projects requiring USB devices, GPU access, custom networks, secrets or multiple services should provide their own Compose file.
## Deployment sequence ## Deployment sequence
1. Verify that the local repository is clean, on the allowed branch and fully synchronized with Gitea. 1. Verify clean local tree, allowed branch, upstream and ahead/behind state.
2. Verify that the exact requested SHA exists on `origin/<branch>`. 2. Verify the exact SHA exists on the allowed remote branch.
3. Verify the repository Compose file or Dockerfile locally. 3. Verify Unraid can read the Gitea repository.
4. Connect through pinned SSH. 4. Inspect the server folder and refuse tracked server changes.
5. Inspect the target folder. 5. Clone when the folder is absent.
6. Refuse tracked server-only modifications. 6. Fetch the branch and verify the exact SHA is an ancestor of `origin/<branch>`.
7. Clone when the folder does not exist. 7. Save the previous SHA and reset to the requested SHA.
8. Fetch the configured branch without allowing interactive credential prompts. 8. Write generated Compose when selected.
9. Verify again on the server that the requested full SHA belongs to `origin/<branch>`. 9. Write the DockerMan metadata override and persistent template fallback.
10. Save the current SHA as the rollback target. 10. Validate the merged Compose model.
11. Reset the working tree to the exact requested SHA. 11. Run `docker compose up -d --build --remove-orphans --force-recreate`.
12. Validate the selected Compose file. 12. Clear relevant icon caches.
13. Run `docker compose up -d --build --remove-orphans`. 13. Inspect the visible container and write `.forgeflow/status.json`.
14. Store non-secret state under `.forgeflow/` and run the configured healthcheck. 14. Run the configured desktop healthcheck.
15. Persist the live SHA, previous SHA, health, container and DockerMan state.
## Folder names ## Interrupted operation recovery
The default folder is the repository name. Existing deployments can keep another folder name by entering it explicitly in the profile. ForgeFlow does not rename populated application folders automatically because Docker paths, scripts and external integrations may depend on them. At startup and through **Reconcile**, ForgeFlow reads the live SHA, container running state, Docker health, labels and persistent template state. When a previously running operation already reached its exact requested SHA and the container is healthy, the operation becomes `success`. Operations that remain unresolved for more than 45 minutes become `failed` rather than staying indefinitely in deployment mode.
A later controlled migration can align names after a successful backup and downtime window.
## Rollback ## Rollback
After a successful deployment, the previous SHA is stored in: Rollback is accepted only for the exact SHA currently recorded as `previousSha`. ForgeFlow re-verifies that commit against Gitea, refuses tracked server changes, resets the same working tree, reapplies Compose and DockerMan metadata, reruns health checks and rotates the former live SHA into the new rollback target.
```text
.forgeflow/previous-sha
```
Rollback is accepted only for the exact SHA currently recorded as the previous
deployment. ForgeFlow rechecks that SHA against the configured Gitea branch,
refuses tracked server-side changes, resets the same working tree, runs Docker
Compose again and repeats the healthcheck. The version that was live before the
rollback becomes the new rollback target.
## Before the first real deployment
Back up the application folder and its persistent data. Run **Preflight** and resolve every failed check. Warnings, such as a nested Git repository, should be reviewed but do not automatically delete or modify anything.
+35 -46
View File
@@ -1,60 +1,49 @@
# Updating ForgeFlow on Windows # Updating ForgeFlow on Windows
ForgeFlow stores its local token, server credentials, repository mappings, ForgeFlow stores credentials, repository mappings, preferences, deployment profiles, diagnostics and operation history outside the source directory.
preferences, deployment profiles, diagnostics and operation history outside the
source directory.
## Manual update to v0.4.0 ## Built-in source update
1. Close ForgeFlow. Open **Settings → ForgeFlow updates** and choose:
2. Extract the v0.4.0 update package.
3. Copy the contents of its `ForgeFlow` folder over the existing source folder.
4. Do not create `ForgeFlow\ForgeFlow`.
5. Open PowerShell in the existing folder.
6. Run:
```powershell
Set-ExecutionPolicy -Scope Process Bypass
.\update-windows.ps1
```
The script installs the new `ssh2` dependency, runs the environment doctor,
verifies the source and executes all tests before starting ForgeFlow.
## Built-in updates after v0.4.0
Open **Settings → ForgeFlow updates**.
The default source is:
```text
Gitea instance: the instance already configured in ForgeFlow
Repository: Jens/ForgeFlow
Branch: main
```
The repository must contain a newer semantic version in `package.json`.
Choose:
1. **Check now** 1. **Check now**
2. **Download update** 2. **Download update**
3. **Apply & restart** 3. **Apply & restart**
The source updater downloads the exact branch commit, records a SHA-256 checksum, The default update source is the configured Gitea instance, repository `Jens/ForgeFlow`, branch `main`.
backs up the installed source, applies the archive, runs `npm install` and
`npm run check`, and restarts ForgeFlow. When validation fails it restores the
previous source and starts that version again.
The update log is written beneath ForgeFlow's local user-data `updates` folder The updater pins the download to the exact remote commit, checks the archive SHA-256, starts an external PowerShell helper and waits for a structured `started` marker. ForgeFlow closes only after that marker exists. The helper then:
and does not contain the Gitea token.
The updater deliberately checks the semantic version stored in the remote 1. waits for the old process to exit;
`package.json`. Merely pushing a new commit without increasing that version does 2. backs up the current source;
not present an update. Publish the complete validated ForgeFlow source to the 3. extracts and validates the requested semantic version;
configured repository and bump the version for every release. 4. mirrors the incoming source;
5. runs `npm ci --no-audit --no-fund` when the published release contains `package-lock.json`, otherwise a pinned direct-dependency `npm install`;
6. runs `npm run check`;
7. writes the successful installation result before restart;
8. launches the installed Electron executable directly;
9. persists `success`, `failed` or `rolled-back` state for the next launch.
A failed validation restores the previous source. A successful installation is not rolled back merely because automatic restart fails; start ForgeFlow manually and the persisted result is shown.
## Publishing v0.5.1 from a Downloads folder Update logs and status files are stored beneath ForgeFlow's local user-data `updates` folder and exclude the Gitea token.
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. ## Publishing a release from Downloads
Extract the complete source ZIP so this file exists:
```text
C:\Users\Jens\Downloads\ForgeFlow-<version>\ForgeFlow\package.json
```
Run:
```powershell
cd C:\Users\Jens\Downloads\ForgeFlow-<version>\ForgeFlow
Set-ExecutionPolicy -Scope Process Bypass
.\Publish-ForgeFlow-Release.ps1
```
The script installs dependencies, runs the complete quality gate, clones `git@gitea.itworx.tech:Jens/ForgeFlow.git` into a temporary folder, mirrors the validated source without `.git`, `node_modules`, `dist` or release archives, commits it and pushes `main`. It then compares local `HEAD` with `git ls-remote` and fails if Gitea does not report the exact release commit.
Keep the currently installed older ForgeFlow source folder untouched until the built-in updater test is complete.
+7 -6
View File
@@ -107,7 +107,7 @@ app.whenReady().then(async () => {
const repositories = new RepositoryService(store, git, gitea, diagnostics); const repositories = new RepositoryService(store, git, gitea, diagnostics);
const deployments = new DeploymentService(store, gitea, git, diagnostics); const deployments = new DeploymentService(store, gitea, git, diagnostics);
const ssh = new SshService({ store, diagnostics }); const ssh = new SshService({ store, diagnostics });
const unraid = new UnraidDeploymentService({ store, ssh, git, diagnostics }); const unraid = new UnraidDeploymentService({ store, ssh, git, diagnostics, sourcePath: app.getAppPath(), onOperationChange: (payload) => broadcast('operations:changed', payload) });
const updates = new UpdateService({ const updates = new UpdateService({
store, store,
gitea, gitea,
@@ -159,13 +159,14 @@ app.whenReady().then(async () => {
if (operationTimer) clearTimeout(operationTimer); if (operationTimer) clearTimeout(operationTimer);
const intervalMs = Math.max(3, Number(store.data.preferences.operationPollSeconds) || 5) * 1000; const intervalMs = Math.max(3, Number(store.data.preferences.operationPollSeconds) || 5) * 1000;
operationTimer = setTimeout(async () => { operationTimer = setTimeout(async () => {
if (store.data.setupComplete && store.getToken()) { if (store.data.setupComplete) {
const active = store.data.operations.some((item) => item.type === 'deployment' && !['success', 'failed', 'cancelled', 'rolled-back'].includes(item.status)); const active = store.data.operations.some((item) => item.type === 'deployment' && !['success', 'failed', 'cancelled', 'rolled-back'].includes(item.status));
if (active) { if (active) {
const updated = await deployments.refreshActiveOperations().catch(async (error) => { const [actions, sshOperations] = await Promise.all([
await diagnostics.error('operation-monitor.failed', error); store.getToken() ? deployments.refreshActiveOperations().catch(async (error) => { await diagnostics.error('operation-monitor.actions.failed', error); return []; }) : [],
return []; unraid.refreshActiveOperations().catch(async (error) => { await diagnostics.error('operation-monitor.unraid.failed', error); return []; })
}); ]);
const updated = [...actions, ...sshOperations];
if (updated.length) broadcast('operations:changed', { operations: updated }); if (updated.length) broadcast('operations:changed', { operations: updated });
} }
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "forgeflow", "name": "forgeflow",
"version": "0.5.2", "version": "0.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "forgeflow", "name": "forgeflow",
"version": "0.5.2", "version": "0.6.0",
"dependencies": { "dependencies": {
"ssh2": "1.17.0" "ssh2": "1.17.0"
}, },
+7 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "forgeflow", "name": "forgeflow",
"version": "0.5.2", "version": "0.6.0",
"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",
@@ -57,7 +57,12 @@
"docs/RELEASE_NOTES_0.5.0.md", "docs/RELEASE_NOTES_0.5.0.md",
"docs/RELEASE_NOTES_0.5.1.md", "docs/RELEASE_NOTES_0.5.1.md",
"Publish-ForgeFlow-Release.ps1", "Publish-ForgeFlow-Release.ps1",
"docs/RELEASE_NOTES_0.5.2.md" "docs/RELEASE_NOTES_0.5.2.md",
"docs/RELEASE_NOTES_0.5.3.md",
"docs/RELEASE_NOTES_0.5.4.md",
"START-FORGEFLOW-OVERLAY.ps1",
"docs/RELEASE_NOTES_0.6.0.md",
"docs/RELEASE_AUDIT_0.6.0.md"
], ],
"directories": { "directories": {
"output": "dist" "output": "dist"
+7
View File
@@ -26,6 +26,7 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({
bootstrap: () => invoke('app:bootstrap'), bootstrap: () => invoke('app:bootstrap'),
selectDirectory: (payload) => invoke('dialog:select-directory', payload), selectDirectory: (payload) => invoke('dialog:select-directory', payload),
selectKeyFile: (payload) => invoke('dialog:select-key-file', payload), selectKeyFile: (payload) => invoke('dialog:select-key-file', payload),
selectImageFile: (payload) => invoke('dialog:select-image-file', payload),
setupPreflight: (payload) => invoke('setup:preflight', payload), setupPreflight: (payload) => invoke('setup:preflight', payload),
validateGitea: (payload) => invoke('setup:validate-gitea', payload), validateGitea: (payload) => invoke('setup:validate-gitea', payload),
completeSetup: (payload) => invoke('setup:complete', payload), completeSetup: (payload) => invoke('setup:complete', payload),
@@ -64,6 +65,10 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({
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 }), indexLockInfo: (localPath) => invoke('repository:index-lock', { localPath }),
repairIndexLock: (localPath) => invoke('repository:repair-index-lock', { localPath }), repairIndexLock: (localPath) => invoke('repository:repair-index-lock', { localPath }),
gitRecoveryStatus: (localPath) => invoke('repository:git-recovery-status', { localPath }),
repairGitLocks: (localPath, force = false) => invoke('repository:repair-git-locks', { localPath, force }),
reconcileRepository: (localPath) => invoke('repository:reconcile', { localPath }),
repairRepositorySync: (localPath, strategy) => invoke('repository:repair-sync', { localPath, strategy }),
setOrigin: (localPath, remoteUrl) => invoke('repository:set-origin', { localPath, remoteUrl }), setOrigin: (localPath, remoteUrl) => invoke('repository:set-origin', { localPath, remoteUrl }),
normalizeOrigins: () => invoke('repositories:normalize-origins'), normalizeOrigins: () => invoke('repositories:normalize-origins'),
cloneRepository: (fullName, mode = 'default') => invoke('repository:clone', { fullName, mode }), cloneRepository: (fullName, mode = 'default') => invoke('repository:clone', { fullName, mode }),
@@ -76,6 +81,8 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({
rollback: (repository, profileId, targetSha) => invoke('deployment:rollback', { repository, profileId, targetSha }), rollback: (repository, profileId, targetSha) => invoke('deployment:rollback', { repository, profileId, targetSha }),
healthcheck: (url) => invoke('deployment:health', { url }), healthcheck: (url) => invoke('deployment:health', { url }),
refreshProfileState: (fullName, profileId) => invoke('deployment:profile-state', { fullName, profileId }), refreshProfileState: (fullName, profileId) => invoke('deployment:profile-state', { fullName, profileId }),
applyDockerManMetadata: (repository, profileId) => invoke('deployment:apply-dockerman-metadata', { repository, profileId }),
reconcileDeployment: (fullName, profileId) => invoke('deployment:reconcile', { fullName, profileId }),
refreshOperations: (operationId = null) => invoke('operations:refresh', { operationId }), refreshOperations: (operationId = null) => invoke('operations:refresh', { operationId }),
getOperation: (operationId) => invoke('operations:get', { operationId }), getOperation: (operationId) => invoke('operations:get', { operationId }),
diagnosticsStatus: () => invoke('diagnostics:status'), diagnosticsStatus: () => invoke('diagnostics:status'),
+116 -13
View File
@@ -4,11 +4,15 @@ param(
[Parameter(Mandatory=$true)][string]$ExpectedVersion, [Parameter(Mandatory=$true)][string]$ExpectedVersion,
[Parameter(Mandatory=$true)][string]$ExpectedSha256, [Parameter(Mandatory=$true)][string]$ExpectedSha256,
[Parameter(Mandatory=$true)][int]$ParentPid, [Parameter(Mandatory=$true)][int]$ParentPid,
[Parameter(Mandatory=$true)][string]$LogPath [Parameter(Mandatory=$true)][string]$LogPath,
[Parameter(Mandatory=$true)][string]$StatusPath,
[Parameter(Mandatory=$true)][string]$UpdateId
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue" $ProgressPreference = "SilentlyContinue"
$working = $null
$backup = $null
function Write-UpdateLog { function Write-UpdateLog {
param([string]$Message) param([string]$Message)
@@ -17,6 +21,33 @@ function Write-UpdateLog {
Add-Content -Path $LogPath -Value $line -Encoding UTF8 Add-Content -Path $LogPath -Value $line -Encoding UTF8
} }
function Write-UpdateState {
param(
[Parameter(Mandatory=$true)][string]$State,
[string]$Message = "",
[hashtable]$Extra = @{}
)
$payload = [ordered]@{
schemaVersion = 1
updateId = $UpdateId
state = $State
expectedVersion = $ExpectedVersion
sourcePath = $SourcePath
logPath = $LogPath
statusPath = $StatusPath
message = $Message
updatedAt = (Get-Date).ToUniversalTime().ToString("o")
}
foreach ($key in $Extra.Keys) { $payload[$key] = $Extra[$key] }
$directory = Split-Path -Parent $StatusPath
New-Item -ItemType Directory -Force -Path $directory | Out-Null
$temporary = "$StatusPath.$PID.tmp"
$json = $payload | ConvertTo-Json -Depth 8
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($temporary, $json, $utf8NoBom)
Move-Item -LiteralPath $temporary -Destination $StatusPath -Force
}
function Invoke-Robocopy { function Invoke-Robocopy {
param([string]$From, [string]$To) param([string]$From, [string]$To)
New-Item -ItemType Directory -Force -Path $To | Out-Null New-Item -ItemType Directory -Force -Path $To | Out-Null
@@ -24,8 +55,37 @@ function Invoke-Robocopy {
if ($LASTEXITCODE -gt 7) { throw "robocopy failed with exit code $LASTEXITCODE" } if ($LASTEXITCODE -gt 7) { throw "robocopy failed with exit code $LASTEXITCODE" }
} }
function Install-ForgeFlowDependencies {
param([string]$WorkingDirectory)
Push-Location $WorkingDirectory
try { try {
Write-UpdateLog "ForgeFlow source update started for version $ExpectedVersion." if (Test-Path -LiteralPath (Join-Path $WorkingDirectory "package-lock.json")) {
Write-UpdateLog "Installing dependencies from package-lock.json with npm ci."
& cmd.exe /d /s /c "npm ci --no-audit --no-fund" *>> $LogPath
if ($LASTEXITCODE -ne 0) { throw "npm ci failed with exit code $LASTEXITCODE." }
} else {
Write-UpdateLog "No package-lock.json was supplied; installing pinned direct dependencies with npm install."
& cmd.exe /d /s /c "npm install --no-audit --no-fund" *>> $LogPath
if ($LASTEXITCODE -ne 0) { throw "npm install failed with exit code $LASTEXITCODE." }
}
} finally { Pop-Location }
}
function Start-ForgeFlow {
param([string]$WorkingDirectory)
$electron = Join-Path $WorkingDirectory "node_modules\electron\dist\electron.exe"
if (-not (Test-Path -LiteralPath $electron)) { throw "electron.exe was not found after dependency installation." }
$process = Start-Process -FilePath $electron -WorkingDirectory $WorkingDirectory -ArgumentList @(".") -PassThru
Start-Sleep -Milliseconds 1200
if (-not $process -or $process.HasExited) { throw "ForgeFlow restart process exited before the application window could start." }
return $process
}
try {
Write-UpdateLog "ForgeFlow source update helper started for version $ExpectedVersion."
Write-UpdateState -State "started" -Message "The external update helper started successfully." -Extra @{ helperPid = $PID; startedAt = (Get-Date).ToUniversalTime().ToString("o") }
Write-UpdateState -State "waiting-for-exit" -Message "Waiting for the running ForgeFlow process to exit."
$deadline = (Get-Date).AddMinutes(2) $deadline = (Get-Date).AddMinutes(2)
while (Get-Process -Id $ParentPid -ErrorAction SilentlyContinue) { while (Get-Process -Id $ParentPid -ErrorAction SilentlyContinue) {
if ((Get-Date) -gt $deadline) { throw "ForgeFlow did not exit before the update timeout." } if ((Get-Date) -gt $deadline) { throw "ForgeFlow did not exit before the update timeout." }
@@ -39,10 +99,13 @@ try {
$extract = Join-Path $working "extract" $extract = Join-Path $working "extract"
$backup = Join-Path $working "backup" $backup = Join-Path $working "backup"
New-Item -ItemType Directory -Force -Path $extract | Out-Null New-Item -ItemType Directory -Force -Path $extract | Out-Null
Write-UpdateLog "Creating source backup." Write-UpdateLog "Creating source backup."
Write-UpdateState -State "backing-up" -Message "Creating a restorable backup of the current source."
Invoke-Robocopy -From $SourcePath -To $backup Invoke-Robocopy -From $SourcePath -To $backup
Write-UpdateLog "Extracting update archive." Write-UpdateLog "Extracting update archive."
Write-UpdateState -State "extracting" -Message "Extracting the verified update archive."
Expand-Archive -LiteralPath $ArchivePath -DestinationPath $extract -Force Expand-Archive -LiteralPath $ArchivePath -DestinationPath $extract -Force
$manifest = Get-ChildItem -Path $extract -Filter package.json -File -Recurse | $manifest = Get-ChildItem -Path $extract -Filter package.json -File -Recurse |
Where-Object { Where-Object {
@@ -56,37 +119,77 @@ try {
if (-not $manifest) { throw "The update does not contain ForgeFlow version $ExpectedVersion." } if (-not $manifest) { throw "The update does not contain ForgeFlow version $ExpectedVersion." }
$incoming = Split-Path -Parent $manifest.FullName $incoming = Split-Path -Parent $manifest.FullName
Write-UpdateLog "Applying verified source files." Write-UpdateLog "Applying verified source files."
Write-UpdateState -State "applying" -Message "Replacing the local source with ForgeFlow $ExpectedVersion."
Invoke-Robocopy -From $incoming -To $SourcePath Invoke-Robocopy -From $incoming -To $SourcePath
Write-UpdateState -State "validating" -Message "Installing dependencies and running the complete quality gate."
Install-ForgeFlowDependencies -WorkingDirectory $SourcePath
Push-Location $SourcePath Push-Location $SourcePath
try { try {
Write-UpdateLog "Installing exact dependencies."
& cmd.exe /d /s /c "npm install --no-audit --no-fund" *>> $LogPath
if ($LASTEXITCODE -ne 0) { throw "npm install failed with exit code $LASTEXITCODE." }
Write-UpdateLog "Running ForgeFlow quality gate." Write-UpdateLog "Running ForgeFlow quality gate."
& cmd.exe /d /s /c "npm run check" *>> $LogPath & cmd.exe /d /s /c "npm run check" *>> $LogPath
if ($LASTEXITCODE -ne 0) { throw "npm run check failed with exit code $LASTEXITCODE." } if ($LASTEXITCODE -ne 0) { throw "npm run check failed with exit code $LASTEXITCODE." }
} finally { Pop-Location } } finally { Pop-Location }
Write-UpdateLog "Update validated successfully. Restarting ForgeFlow." $completedAt = (Get-Date).ToUniversalTime().ToString("o")
Start-Process -FilePath "cmd.exe" -WorkingDirectory $SourcePath -ArgumentList "/d", "/s", "/c", "npm start" Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion was installed successfully." -Extra @{
Remove-Item -LiteralPath $working -Recurse -Force -ErrorAction SilentlyContinue installedVersion = $ExpectedVersion
completedAt = $completedAt
restartLaunched = $true
restartPid = $null
restartError = $null
}
try {
$restart = Start-ForgeFlow -WorkingDirectory $SourcePath
Write-UpdateLog "Update validated successfully. ForgeFlow was restarted directly with Electron PID $($restart.Id)."
} catch {
$restartError = $_.Exception.Message
Write-UpdateLog "Update validated successfully, but automatic restart failed: $restartError"
Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion was installed successfully, but must be started manually." -Extra @{
installedVersion = $ExpectedVersion
completedAt = $completedAt
restartLaunched = $false
restartPid = $null
restartError = $restartError
}
}
if ($working) { Remove-Item -LiteralPath $working -Recurse -Force -ErrorAction SilentlyContinue }
exit 0 exit 0
} }
catch { catch {
Write-UpdateLog ("Update failed: " + $_.Exception.Message) $failureMessage = $_.Exception.Message
Write-UpdateLog ("Update failed: " + $failureMessage)
Write-UpdateState -State "failed" -Message $failureMessage -Extra @{ failedAt = (Get-Date).ToUniversalTime().ToString("o") }
try { try {
if ($backup -and (Test-Path $backup)) { if ($backup -and (Test-Path $backup)) {
Write-UpdateLog "Restoring previous source version." Write-UpdateLog "Restoring previous source version."
Invoke-Robocopy -From $backup -To $SourcePath Invoke-Robocopy -From $backup -To $SourcePath
Push-Location $SourcePath Install-ForgeFlowDependencies -WorkingDirectory $SourcePath
$rollbackCompletedAt = (Get-Date).ToUniversalTime().ToString("o")
Write-UpdateState -State "rolled-back" -Message $failureMessage -Extra @{
completedAt = $rollbackCompletedAt
restartLaunched = $true
restartPid = $null
restartError = $null
}
try { try {
& cmd.exe /d /s /c "npm install --no-audit --no-fund" *>> $LogPath $rollbackRestart = Start-ForgeFlow -WorkingDirectory $SourcePath
} finally { Pop-Location } Write-UpdateLog "Rollback restored and ForgeFlow restarted directly with Electron PID $($rollbackRestart.Id)."
Start-Process -FilePath "cmd.exe" -WorkingDirectory $SourcePath -ArgumentList "/d", "/s", "/c", "npm start" } catch {
$rollbackRestartError = $_.Exception.Message
Write-UpdateLog ("Rollback restart failed: " + $rollbackRestartError)
Write-UpdateState -State "rolled-back" -Message $failureMessage -Extra @{
completedAt = $rollbackCompletedAt
restartLaunched = $false
restartPid = $null
restartError = $rollbackRestartError
}
}
} }
} catch { } catch {
Write-UpdateLog ("Rollback failed: " + $_.Exception.Message) Write-UpdateLog ("Rollback failed: " + $_.Exception.Message)
Write-UpdateState -State "failed" -Message ("$failureMessage Rollback also failed: " + $_.Exception.Message) -Extra @{ completedAt = (Get-Date).ToUniversalTime().ToString("o") }
} }
exit 1 exit 1
} }
+29 -9
View File
@@ -8,18 +8,18 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const required = [ const required = [
'package.json', 'main.cjs', 'preload.cjs', 'package.json', 'main.cjs', 'preload.cjs',
'src/renderer/index.html', 'src/renderer/styles.css', 'src/renderer/app.js', 'src/renderer/mock-bridge.js', 'src/renderer/index.html', 'src/renderer/styles.css', 'src/renderer/app.js', 'src/renderer/mock-bridge.js',
'src/renderer/assets/itworx-mark.png', 'src/renderer/assets/itworx-wordmark.png', 'src/renderer/assets/itworx-mark.png', 'src/renderer/assets/itworx-wordmark.png', 'src/renderer/assets/itworx-wordmark-light.png', 'src/renderer/assets/itworx-wordmark-dark.png',
'src/main/config-store.cjs', 'src/main/git-service.cjs', 'src/main/gitea-service.cjs', 'src/main/config-store.cjs', 'src/main/git-service.cjs', 'src/main/gitea-service.cjs',
'src/main/repository-service.cjs', 'src/main/repository-monitor.cjs', 'src/main/deployment-service.cjs', 'src/main/repository-service.cjs', 'src/main/repository-monitor.cjs', 'src/main/deployment-service.cjs',
'src/main/unraid-deployment-service.cjs', 'src/main/ssh-service.cjs', 'src/main/update-service.cjs', 'src/main/unraid-deployment-service.cjs', 'src/main/ssh-service.cjs', 'src/main/update-service.cjs',
'src/main/diagnostics-service.cjs', 'src/main/preflight-service.cjs', 'src/main/log-redaction.cjs', 'src/main/ipc.cjs', 'src/main/diagnostics-service.cjs', 'src/main/preflight-service.cjs', 'src/main/log-redaction.cjs', 'src/main/ipc.cjs',
'src/shared/clone-target.cjs', 'src/shared/semver.cjs', 'src/shared/zip-writer.cjs', 'src/shared/clone-target.cjs', 'src/shared/semver.cjs', 'src/shared/zip-writer.cjs',
'src/shared/tool-invocation.cjs', 'src/shared/shell-verification.cjs', 'START_HERE.md', 'README.md', 'src/shared/tool-invocation.cjs', 'src/shared/shell-verification.cjs', 'START_HERE.md', 'README.md', 'SOURCE_MANIFEST.txt',
'setup-windows.ps1', 'update-windows.ps1', 'build-windows.ps1', 'UPDATE_FROM_0.3.2.md', 'scripts/apply-source-update.ps1', 'setup-windows.ps1', 'START-FORGEFLOW-OVERLAY.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/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', 'docs/RELEASE_AUDIT_0.6.0.md', 'docs/RELEASE_NOTES_0.5.0.md', 'docs/RELEASE_NOTES_0.5.1.md', 'docs/RELEASE_NOTES_0.5.2.md', 'docs/RELEASE_NOTES_0.5.3.md', 'docs/RELEASE_NOTES_0.5.4.md', 'docs/RELEASE_NOTES_0.6.0.md',
'Publish-ForgeFlow-Release.ps1', 'docs/RELEASE_NOTES_0.4.4.md', 'docs/RELEASE_NOTES_0.4.5.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',
@@ -30,7 +30,9 @@ 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.5.2') throw new Error(`Expected package version 0.5.2, got ${packageJson.version}.`); if (packageJson.version !== '0.6.0') throw new Error(`Expected package version 0.6.0, got ${packageJson.version}.`);
const sourceManifest = await readFile(path.join(root, 'SOURCE_MANIFEST.txt'), 'utf8');
if (!sourceManifest.startsWith(`ForgeFlow ${packageJson.version} source manifest\n`)) throw new Error('SOURCE_MANIFEST.txt does not match the package 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}.`);
@@ -82,7 +84,7 @@ JSON.parse(await readFile(path.join(root, 'examples/server/status-example.json')
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.5.2.md'), 'utf8'); const releaseNotes = await readFile(path.join(root, 'docs/RELEASE_NOTES_0.6.0.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.');
} }
@@ -92,21 +94,39 @@ 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 ['viewport', '--pathspec-from-file', 'serialized per repository']) { for (const phrase of ['DockerMan', 'HEAD.lock', 'deployment reconciliation', 'Portfolio', 'safety branch', 'high-contrast ITWorx']) {
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 updateHelperPath = path.join(root, 'scripts/apply-source-update.ps1');
const updateHelperBytes = await readFile(updateHelperPath);
if (updateHelperBytes[0] === 0xef && updateHelperBytes[1] === 0xbb && updateHelperBytes[2] === 0xbf) throw new Error('PowerShell update helper must not contain a UTF-8 BOM.');
const updateHelper = updateHelperBytes.toString('utf8');
if (!updateHelper.trimStart().startsWith('param(') || updateHelper.trimStart().startsWith('\\')) throw new Error('PowerShell update helper must start directly with param(.');
const renderer = await readFile(path.join(root, 'src/renderer/app.js'), 'utf8'); const renderer = await readFile(path.join(root, 'src/renderer/app.js'), 'utf8');
const styles = await readFile(path.join(root, 'src/renderer/styles.css'), 'utf8'); const styles = await readFile(path.join(root, 'src/renderer/styles.css'), 'utf8');
const preload = await readFile(path.join(root, 'preload.cjs'), 'utf8'); const preload = await readFile(path.join(root, 'preload.cjs'), 'utf8');
const ipc = await readFile(path.join(root, 'src/main/ipc.cjs'), 'utf8'); const ipc = await readFile(path.join(root, 'src/main/ipc.cjs'), 'utf8');
for (const phrase of ['Commit selected & push to Gitea', 'checkForUpdates', 'saveServer', 'profile-provider', 'itworx-mark.png']) { for (const phrase of ['Commit selected & push to Gitea', 'checkForUpdates', 'saveServer', 'profile-provider', 'profile-icon-mode', 'itworx-mark.png', 'Repair DockerMan integration', 'Repository troubleshooting', 'repair-repository-sync']) {
if (!renderer.includes(phrase) && !preload.includes(phrase)) throw new Error(`Frontend integration is missing: ${phrase}`); if (!renderer.includes(phrase) && !preload.includes(phrase)) throw new Error(`Frontend integration is missing: ${phrase}`);
} }
if (!styles.includes('.file-list { flex: 1 1 auto;') || !styles.includes('.main-canvas.repository-canvas')) { if (!styles.includes('.file-list { flex: 1 1 auto;') || !styles.includes('.main-canvas.repository-canvas')) {
throw new Error('Changed-file scrolling constraints are missing.'); throw new Error('Changed-file scrolling constraints are missing.');
} }
for (const channel of ['updates:check', 'updates:download', 'updates:apply', 'server:save', 'server:test', 'server:inspect-project']) { for (const channel of ['updates:check', 'updates:download', 'updates:apply', 'server:save', 'server:test', 'server:inspect-project', 'repository:repair-git-locks', 'repository:repair-sync', 'deployment:apply-dockerman-metadata', 'deployment:reconcile']) {
if (!ipc.includes(channel)) throw new Error(`IPC registration is missing: ${channel}`); if (!ipc.includes(channel)) throw new Error(`IPC registration is missing: ${channel}`);
} }
const gitSource = await readFile(path.join(root, 'src/main/git-service.cjs'), 'utf8');
const unraidSource = await readFile(path.join(root, 'src/main/unraid-deployment-service.cjs'), 'utf8');
const publisher = await readFile(path.join(root, 'Publish-ForgeFlow-Release.ps1'), 'utf8');
for (const phrase of ['HEAD.lock', 'backup-reset', 'repairSync', "segments.includes('objects')"]) {
if (!gitSource.includes(phrase)) throw new Error(`Git recovery implementation is missing: ${phrase}`);
}
for (const phrase of ['net.unraid.docker.managed', "'dockerman'", 'iconCacheRefresh', '[PORT:', 'Superseded by live commit']) {
if (!unraidSource.includes(phrase)) throw new Error(`Unraid recovery implementation is missing: ${phrase}`);
}
for (const phrase of ['git ls-remote origin', 'apply-source-update.ps1', 'without changing its version']) {
if (!publisher.includes(phrase)) throw new Error(`Publishing workflow is missing: ${phrase}`);
}
console.log(`Verified ${required.length} required project files and ${javascriptFiles.length} JavaScript files for ForgeFlow ${packageJson.version}.`); console.log(`Verified ${required.length} required project files and ${javascriptFiles.length} JavaScript files for ForgeFlow ${packageJson.version}.`);
+30 -3
View File
@@ -7,7 +7,7 @@ const { safeStorage } = require('electron');
const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePaths } = require('../shared/validation.cjs'); const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
const DEFAULT_CONFIG = { const DEFAULT_CONFIG = {
schemaVersion: 5, schemaVersion: 7,
setupComplete: false, setupComplete: false,
appearance: 'dark', appearance: 'dark',
gitea: { baseUrl: '', user: null, encryptedToken: null }, gitea: { baseUrl: '', user: null, encryptedToken: null },
@@ -58,7 +58,20 @@ class ConfigStore {
gitea: { ...DEFAULT_CONFIG.gitea, ...(source.gitea || {}) }, gitea: { ...DEFAULT_CONFIG.gitea, ...(source.gitea || {}) },
workspaceRoots: uniqueStrings(source.workspaceRoots), workspaceRoots: uniqueStrings(source.workspaceRoots),
repositoryMappings: source.repositoryMappings && typeof source.repositoryMappings === 'object' ? source.repositoryMappings : {}, repositoryMappings: source.repositoryMappings && typeof source.repositoryMappings === 'object' ? source.repositoryMappings : {},
deploymentProfiles: source.deploymentProfiles && typeof source.deploymentProfiles === 'object' ? source.deploymentProfiles : {}, deploymentProfiles: source.deploymentProfiles && typeof source.deploymentProfiles === 'object'
? Object.fromEntries(Object.entries(source.deploymentProfiles).map(([key, profiles]) => [key, (Array.isArray(profiles) ? profiles : []).map((profile) => {
if (!profile || typeof profile !== 'object' || profile.provider !== 'ssh-unraid') return profile;
const iconUrl = String(profile.iconUrl || '').trim();
const iconFilePath = String(profile.iconFilePath || '').trim();
const requestedMode = String(profile.iconMode || '').trim();
const iconMode = ['builtin', 'upload', 'url', 'none'].includes(requestedMode)
? requestedMode
: iconFilePath ? 'upload' : iconUrl && !/itworx\.tech\/assets\/itworx-icon\.png/i.test(iconUrl) ? 'url' : 'builtin';
const visibleName = String(profile.containerName || profile.remoteFolder || '').trim();
const internalService = String(profile.composeService || profile.remoteFolder || 'app').trim().toLowerCase().replace(/[^a-z0-9._-]/g, '-') || 'app';
return { ...profile, composeService: internalService, containerName: visibleName || internalService, iconMode };
})]))
: {},
deploymentStates: source.deploymentStates && typeof source.deploymentStates === 'object' ? source.deploymentStates : {}, deploymentStates: source.deploymentStates && typeof source.deploymentStates === 'object' ? source.deploymentStates : {},
favorites: uniqueStrings(source.favorites).map((item) => item.toLowerCase()), favorites: uniqueStrings(source.favorites).map((item) => item.toLowerCase()),
updates: { ...DEFAULT_CONFIG.updates, ...(source.updates || {}) }, updates: { ...DEFAULT_CONFIG.updates, ...(source.updates || {}) },
@@ -300,13 +313,27 @@ class ConfigStore {
serverId: String(profile.serverId || '').trim(), serverId: String(profile.serverId || '').trim(),
remoteFolder, remoteFolder,
composeFile: String(profile.composeFile || 'docker-compose.yml').trim(), composeFile: String(profile.composeFile || 'docker-compose.yml').trim(),
composeService: String(profile.composeService || '').trim(), composeService: (() => {
const value = String(profile.composeService || remoteFolder).trim().toLowerCase();
if (!/^[a-z0-9._-]+$/.test(value)) throw new Error('Compose service must be lowercase and contain only letters, numbers, dots, underscores and dashes.');
return value;
})(),
containerName: (() => {
const value = String(profile.containerName || remoteFolder).trim();
if (!/^[A-Za-z0-9._-]+$/.test(value)) throw new Error('Container name must contain only letters, numbers, dots, underscores and dashes.');
return value;
})(),
cloneUrl: profile.cloneUrl ? assertCloneRemote(profile.cloneUrl) : '', cloneUrl: profile.cloneUrl ? assertCloneRemote(profile.cloneUrl) : '',
alignRemote: profile.alignRemote === true, alignRemote: profile.alignRemote === true,
hostPort: profile.hostPort ? Math.min(Math.max(Number(profile.hostPort), 1), 65535) : null, hostPort: profile.hostPort ? Math.min(Math.max(Number(profile.hostPort), 1), 65535) : null,
containerPort: profile.containerPort ? Math.min(Math.max(Number(profile.containerPort), 1), 65535) : null, containerPort: profile.containerPort ? Math.min(Math.max(Number(profile.containerPort), 1), 65535) : null,
webUiUrl: assertHttpUrl(profile.webUiUrl, { optional: true, label: 'Web UI URL' }), webUiUrl: assertHttpUrl(profile.webUiUrl, { optional: true, label: 'Web UI URL' }),
iconMode: ['builtin', 'upload', 'url', 'none'].includes(profile.iconMode)
? profile.iconMode
: profile.iconFilePath ? 'upload' : profile.iconUrl ? 'url' : 'builtin',
iconUrl: assertHttpUrl(profile.iconUrl, { optional: true, label: 'Icon URL' }), iconUrl: assertHttpUrl(profile.iconUrl, { optional: true, label: 'Icon URL' }),
iconFilePath: String(profile.iconFilePath || '').trim(),
dockerShell: ['/bin/sh', '/bin/bash'].includes(profile.dockerShell) ? profile.dockerShell : '/bin/sh',
preservePaths, preservePaths,
generatedCompose: profile.generatedCompose === true generatedCompose: profile.generatedCompose === true
}; };
+147 -12
View File
@@ -5,6 +5,8 @@ const fs = require('node:fs/promises');
const { run } = require('./process-runner.cjs'); const { run } = require('./process-runner.cjs');
const { parsePorcelainV2 } = require('../shared/git-status.cjs'); const { parsePorcelainV2 } = require('../shared/git-status.cjs');
const { normalizeRemoteUrl } = require('../shared/repository-match.cjs'); const { normalizeRemoteUrl } = require('../shared/repository-match.cjs');
const COMMON_GIT_LOCK_FILES = ['HEAD.lock', 'index.lock'];
const { const {
assertSafeRepositoryPath, assertSafeRepositoryPath,
assertRepositoryRelativePath, assertRepositoryRelativePath,
@@ -73,23 +75,156 @@ class GitService {
}); });
} }
async getIndexLockInfo(repoPath) {
async gitDirectory(repoPath) {
const root = await this.ensureRepository(repoPath); const root = await this.ensureRepository(repoPath);
const lockPath = path.join(root, '.git', 'index.lock'); const result = await run('git', ['rev-parse', '--path-format=absolute', '--git-dir'], { cwd: root, timeout: 15_000 });
const stat = await fs.stat(lockPath).catch(() => null); return { root, gitDir: path.resolve(result.stdout.trim()) };
return stat ? { exists: true, lockPath, ageMs: Math.max(0, Date.now() - stat.mtimeMs) } : { exists: false, lockPath, ageMs: 0 };
} }
async removeStaleIndexLock(repoPath, minimumAgeMs = 30_000) { isGitLockError(error) {
const info = await this.getIndexLockInfo(repoPath); const message = String(error?.message || error || '');
if (!info.exists) return { removed: false, reason: 'missing', ...info }; return /(?:cannot lock ref|Unable to create .*\.lock|another git process)/i.test(message)
if (info.ageMs < minimumAgeMs) { || COMMON_GIT_LOCK_FILES.some((lockName) => message.toLowerCase().includes(lockName.toLowerCase()));
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';
async gitProcessProbe(root) {
if (process.platform !== 'win32') return { available: false, active: [], reason: 'process probe is Windows-only' };
const escaped = root.replace(/'/g, "''");
const script = `$root='${escaped}'; Get-CimInstance Win32_Process -Filter \"Name='git.exe' OR Name='git-remote-https.exe' OR Name='ssh.exe'\" -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -and $_.CommandLine.IndexOf($root,[System.StringComparison]::OrdinalIgnoreCase) -ge 0 } | Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Compress`;
try {
const result = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], { timeout: 15_000, allowExitCodes: [1] });
const text = result.stdout.trim();
const parsed = text ? JSON.parse(text) : [];
return { available: true, active: Array.isArray(parsed) ? parsed : [parsed] };
} catch (error) {
return { available: false, active: [], reason: error.message };
}
}
async listGitLocks(repoPath) {
const { root, gitDir } = await this.gitDirectory(repoPath);
const locks = [];
const walk = async (directory, depth = 0) => {
if (depth > 8) return;
const entries = await fs.readdir(directory, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
const fullPath = path.join(directory, entry.name);
const relative = path.relative(gitDir, fullPath).replace(/\\/g, '/');
if (entry.isDirectory()) {
const segments = relative.split('/');
if (segments.includes('objects') || segments.includes('lfs')) continue;
await walk(fullPath, depth + 1);
} else if (entry.isFile() && entry.name.endsWith('.lock')) {
const stat = await fs.stat(fullPath).catch(() => null);
if (stat) locks.push({
name: path.relative(gitDir, fullPath).replace(/\\/g, '/'),
lockPath: fullPath,
ageMs: Math.max(0, Date.now() - stat.mtimeMs),
size: stat.size,
modifiedAt: stat.mtime.toISOString()
});
}
}
};
await walk(gitDir);
const processes = await this.gitProcessProbe(root);
return { root, gitDir, locks: locks.sort((a, b) => a.name.localeCompare(b.name)), processes };
}
async repairStaleGitLocks(repoPath, { minimumAgeMs = 15_000, allowWithoutProcessProbe = false } = {}) {
const report = await this.listGitLocks(repoPath);
if (!report.locks.length) return { ...report, removed: [], skipped: [], repaired: false };
if (report.processes.active.length) {
const error = new Error(`A Git-related process is still using this repository (${report.processes.active.map((item) => `${item.Name || 'process'} ${item.ProcessId || ''}`.trim()).join(', ')}). Close it before repairing locks.`);
error.code = 'GIT_PROCESS_ACTIVE';
error.processes = report.processes.active;
throw error; throw error;
} }
await fs.rm(info.lockPath, { force: true }); if (!report.processes.available && !allowWithoutProcessProbe) {
return { removed: true, ...info }; const error = new Error('ForgeFlow could not prove that no Git process is active. Use the explicit force repair only after closing Git tools for this repository.');
error.code = 'GIT_PROCESS_PROBE_UNAVAILABLE';
error.recoverable = true;
throw error;
}
const removed = [];
const skipped = [];
for (const lock of report.locks) {
if (lock.ageMs < minimumAgeMs) { skipped.push({ ...lock, reason: 'recent' }); continue; }
await fs.rm(lock.lockPath, { force: true });
removed.push(lock);
}
if (!removed.length && skipped.length) {
const error = new Error('All Git lock files are recent. Wait a few seconds after closing Git tools, then scan again.');
error.code = 'GIT_LOCKS_RECENT';
error.recoverable = true;
throw error;
}
return { ...report, removed, skipped, repaired: removed.length > 0 };
}
async getIndexLockInfo(repoPath) {
const report = await this.listGitLocks(repoPath);
const lock = report.locks.find((item) => item.name === 'index.lock');
return lock ? { exists: true, ...lock } : { exists: false, lockPath: path.join(report.gitDir, 'index.lock'), ageMs: 0 };
}
async removeStaleIndexLock(repoPath, minimumAgeMs = 15_000) {
const result = await this.repairStaleGitLocks(repoPath, { minimumAgeMs });
const removed = result.removed.find((item) => item.name === 'index.lock');
return removed ? { removed: true, ...removed } : { removed: false, reason: 'missing', ...(await this.getIndexLockInfo(repoPath)) };
}
async reconcile(repoPath) {
const root = await this.ensureRepository(repoPath);
await this.fetch(root).catch(() => null);
const status = await this.status(root);
const upstream = status.branch?.upstream || '';
return {
status,
lockReport: await this.listGitLocks(root),
recommendations: [
{ id: 'fetch', label: 'Fetch and recalculate remote state', action: 'fetch', safe: true },
...(status.branch?.behind > 0 && status.branch?.ahead === 0 && status.clean && upstream ? [{ id: 'pull', label: `Fast-forward from ${upstream}`, action: 'fast-forward', safe: true }] : []),
...(status.branch?.ahead > 0 && status.branch?.behind === 0 && upstream ? [{ id: 'push', label: `Push ${status.branch.ahead} local commit(s)`, action: 'push', safe: true }] : []),
...(status.branch?.ahead > 0 && status.branch?.behind > 0 && upstream ? [
{ id: 'diverged', label: `Branch diverged (${status.branch.ahead} ahead, ${status.branch.behind} behind)`, action: null, safe: false },
{ id: 'backup-reset', label: `Create a safety branch and reset to ${upstream}`, action: 'backup-reset', safe: false }
] : [])
]
};
}
async repairSync(repoPath, strategy) {
const root = await this.ensureRepository(repoPath);
const requested = String(strategy || '').trim();
if (!['fetch', 'fast-forward', 'push', 'backup-reset'].includes(requested)) throw new Error('Unsupported Git synchronization repair strategy.');
await this.fetch(root);
let status = await this.status(root);
const branch = status.branch?.head;
const upstream = status.branch?.upstream;
if (!branch || branch === '(detached)') throw new Error('Synchronization repair requires a named local branch.');
if (!upstream && requested !== 'fetch') throw new Error('The current branch has no upstream branch. Repair origin or publish the branch first.');
if (requested === 'fast-forward') {
if (!status.clean) throw new Error('Fast-forward repair requires a clean working tree. Commit or stash changes first.');
if (status.branch.ahead > 0) throw new Error('Fast-forward repair is only safe when there are no local commits ahead of upstream.');
await run('git', ['merge', '--ff-only', upstream], { cwd: root, timeout: 2 * 60_000 });
} else if (requested === 'push') {
if (status.branch.behind > 0) throw new Error('Push repair is blocked because the remote branch contains commits that are not local.');
await this.push(root);
} else if (requested === 'backup-reset') {
if (!status.clean) throw new Error('Backup-and-reset requires a clean working tree. Commit or stash changes first.');
if (!(status.branch.ahead > 0 && status.branch.behind > 0)) throw new Error('Backup-and-reset is only offered for a diverged branch.');
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
const backupBranch = `forgeflow/backup-${branch.replace(/[^A-Za-z0-9._-]/g, '-')}-${stamp}`;
await run('git', ['branch', backupBranch, 'HEAD'], { cwd: root, timeout: 30_000 });
await run('git', ['reset', '--hard', upstream], { cwd: root, timeout: 2 * 60_000 });
status = await this.status(root);
return { strategy: requested, backupBranch, status, lockReport: await this.listGitLocks(root) };
}
status = await this.status(root);
return { strategy: requested, backupBranch: null, status, lockReport: await this.listGitLocks(root) };
} }
async setRemoteUrl(repoPath, remoteUrl, remote = 'origin') { async setRemoteUrl(repoPath, remoteUrl, remote = 'origin') {
+59 -5
View File
@@ -64,7 +64,32 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
const withRepositoryMutation = async (localPath, action) => { const withRepositoryMutation = async (localPath, action) => {
const key = path.resolve(localPath); const key = path.resolve(localPath);
const previous = repositoryMutations.get(key) || Promise.resolve(); const previous = repositoryMutations.get(key) || Promise.resolve();
const current = previous.catch(() => {}).then(() => withRepositoryPause(key, action)); const execute = async () => {
try { return await withRepositoryPause(key, action); }
catch (error) {
if (!git.isGitLockError(error)) throw error;
let repair = null;
let lockDiagnosis = null;
try {
repair = await git.repairStaleGitLocks(key, { minimumAgeMs: 2_000 });
} catch (repairError) {
lockDiagnosis = repairError;
if (repairError?.code === 'GIT_LOCKS_RECENT') {
await new Promise((resolve) => setTimeout(resolve, 2_500));
try {
repair = await git.repairStaleGitLocks(key, { minimumAgeMs: 2_000 });
lockDiagnosis = null;
} catch (retryError) {
lockDiagnosis = retryError;
}
}
}
if (!repair?.repaired) throw lockDiagnosis || error;
await diagnostics.info('git.lock.auto-repaired', { localPath: key, locks: repair.removed.map((item) => item.name) });
return withRepositoryPause(key, action);
}
};
const current = previous.catch(() => {}).then(execute);
repositoryMutations.set(key, current); repositoryMutations.set(key, current);
try { return await current; } try { return await current; }
finally { if (repositoryMutations.get(key) === current) repositoryMutations.delete(key); } finally { if (repositoryMutations.get(key) === current) repositoryMutations.delete(key); }
@@ -139,7 +164,8 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
platform: process.platform, platform: process.platform,
state: store.getPublicState(), state: store.getPublicState(),
git: await git.isAvailable(), git: await git.isAvailable(),
diagnostics: await diagnostics.getStatus() diagnostics: await diagnostics.getStatus(),
updateResult: await updates.consumeLatestResult()
})); }));
register('dialog:select-directory', async ({ title = 'Select folder', defaultPath }) => { register('dialog:select-directory', async ({ title = 'Select folder', defaultPath }) => {
@@ -156,6 +182,16 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
return result.canceled ? null : result.filePaths[0]; return result.canceled ? null : result.filePaths[0];
}); });
register('dialog:select-image-file', async ({ title = 'Select PNG image', defaultPath }) => {
const result = await dialog.showOpenDialog({
title,
defaultPath,
properties: ['openFile'],
filters: [{ name: 'PNG image', extensions: ['png'] }]
});
return result.canceled ? null : result.filePaths[0];
});
register('setup:preflight', ({ baseUrl, token, roots }) => preflight.runSystem({ baseUrl, token, roots })); register('setup:preflight', ({ baseUrl, token, roots }) => preflight.runSystem({ baseUrl, token, roots }));
register('setup:validate-gitea', ({ baseUrl, token }) => gitea.validateConnection(baseUrl, token)); register('setup:validate-gitea', ({ baseUrl, token }) => gitea.validateConnection(baseUrl, token));
register('setup:complete', async ({ baseUrl, token, workspaceRoots }) => { register('setup:complete', async ({ baseUrl, token, workspaceRoots }) => {
@@ -201,7 +237,8 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
register('updates:download', () => updates.download()); register('updates:download', () => updates.download());
register('updates:apply', async () => { register('updates:apply', async () => {
const result = await updates.apply(); const result = await updates.apply();
setTimeout(() => app.quit(), 650).unref?.(); if (!result?.confirmed) throw new Error('The update helper did not confirm ownership of the update. ForgeFlow will remain open.');
setTimeout(() => app.quit(), 350).unref?.();
return result; return result;
}); });
@@ -286,7 +323,11 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
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 withRepositoryMutation(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:index-lock', async ({ localPath }) => git.getIndexLockInfo(await assertKnownRepositoryPath(localPath)));
register('repository:git-recovery-status', async ({ localPath }) => git.reconcile(await assertKnownRepositoryPath(localPath)));
register('repository:repair-index-lock', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.removeStaleIndexLock(safePath)); }); register('repository:repair-index-lock', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.removeStaleIndexLock(safePath)); });
register('repository:repair-git-locks', async ({ localPath, force = false }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.repairStaleGitLocks(safePath, { minimumAgeMs: force ? 0 : 10_000, allowWithoutProcessProbe: force === true })); });
register('repository:reconcile', async ({ localPath }) => git.reconcile(await assertKnownRepositoryPath(localPath)));
register('repository:repair-sync', async ({ localPath, strategy }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.repairSync(safePath, strategy)); });
register('repository:set-origin', async ({ localPath, remoteUrl }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.setRemoteUrl(safePath, remoteUrl)); }); register('repository:set-origin', async ({ localPath, remoteUrl }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.setRemoteUrl(safePath, remoteUrl)); });
register('repositories:normalize-origins', async () => { register('repositories:normalize-origins', async () => {
@@ -371,13 +412,26 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
if (profile?.provider === 'ssh-unraid') return unraid.refreshProfileState(fullName, profileId); if (profile?.provider === 'ssh-unraid') return unraid.refreshProfileState(fullName, profileId);
return deployments.refreshProfileState(fullName, profileId); return deployments.refreshProfileState(fullName, profileId);
}); });
register('deployment:apply-dockerman-metadata', async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
return unraid.applyDockerManMetadata({ repository: current, profileId });
});
register('deployment:reconcile', async ({ fullName, profileId }) => {
const profile = store.getDeploymentProfile(fullName, profileId);
if (profile?.provider !== 'ssh-unraid') return deployments.refreshProfileState(fullName, profileId);
const state = await unraid.refreshProfileState(fullName, profileId);
const operations = store.data.operations.filter((item) => item.profileId === profileId && item.provider === 'ssh-unraid' && !['success', 'failed', 'cancelled', 'rolled-back'].includes(item.status));
for (const operation of operations) await unraid.refreshOperation(operation.id);
return { state, operations: store.data.operations.filter((item) => item.profileId === profileId).slice(0, 10) };
});
register('operations:refresh', async ({ operationId }) => { register('operations:refresh', async ({ operationId }) => {
if (operationId) { if (operationId) {
const operation = store.getOperation(operationId); const operation = store.getOperation(operationId);
if (operation?.provider === 'ssh-unraid') return operation; if (operation?.provider === 'ssh-unraid') return unraid.refreshOperation(operationId);
return deployments.refreshOperation(operationId); return deployments.refreshOperation(operationId);
} }
return deployments.refreshActiveOperations(); const [actions, sshOperations] = await Promise.all([deployments.refreshActiveOperations(), unraid.refreshActiveOperations()]);
return [...actions, ...sshOperations];
}); });
register('operations:get', ({ operationId }) => store.getOperation(operationId)); register('operations:get', ({ operationId }) => store.getOperation(operationId));
+7 -1
View File
@@ -160,7 +160,13 @@ class RepositoryService {
const behind = status?.branch.behind || 0; const behind = status?.branch.behind || 0;
const conflict = Boolean(status?.counts.conflicts); const conflict = Boolean(status?.counts.conflicts);
const profileForBranch = profiles.find((profile) => profile.branch === status?.branch.head); const profileForBranch = profiles.find((profile) => profile.branch === status?.branch.head);
const readyToDeploy = Boolean(profileForBranch && status?.head && status?.branch.upstream && !hasChanges && ahead === 0 && behind === 0); const synchronized = Boolean(profileForBranch && status?.head && status?.branch.upstream && !hasChanges && ahead === 0 && behind === 0);
const alreadyLiveAndHealthy = Boolean(
synchronized
&& profileForBranch?.state?.liveSha === status.head
&& profileForBranch?.state?.healthy !== false
);
const readyToDeploy = synchronized && !alreadyLiveAndHealthy;
const key = String(remote.full_name || '').toLowerCase(); const key = String(remote.full_name || '').toLowerCase();
const preferredCloneUrl = this.store.data.preferences.preferredCloneProtocol === 'ssh' const preferredCloneUrl = this.store.data.preferences.preferredCloneProtocol === 'ssh'
? (remote.ssh_url || remote.clone_url) ? (remote.ssh_url || remote.clone_url)
+58
View File
@@ -2,6 +2,7 @@
const fs = require('node:fs/promises'); const fs = require('node:fs/promises');
const crypto = require('node:crypto'); const crypto = require('node:crypto');
const path = require('node:path').posix;
function loadSshClient() { function loadSshClient() {
try { return require('ssh2').Client; } try { return require('ssh2').Client; }
@@ -120,6 +121,63 @@ class SshService {
}); });
} }
async uploadBuffer(serverId, remotePath, content, { mode = 0o600 } = {}) {
const server = this.store.getServer(serverId);
if (!server?.hostFingerprint) {
const error = new Error('Test and trust the SSH server fingerprint before uploading deployment assets.');
error.code = 'SSH_HOST_NOT_TRUSTED';
throw error;
}
const target = String(remotePath || '').replace(/\\/g, '/');
if (!target.startsWith('/') || target.includes('\0') || target.split('/').includes('..')) throw new Error('Remote upload path must be an absolute safe Unix path.');
const data = Buffer.isBuffer(content) ? content : Buffer.from(content);
return this.withClient(serverId, (client) => new Promise((resolve, reject) => {
client.sftp((sftpError, sftp) => {
if (sftpError) { reject(sftpError); return; }
const directory = path.dirname(target);
const mkdirParts = directory.split('/').filter(Boolean);
let current = '';
const makeNext = (index) => {
if (index >= mkdirParts.length) {
const stream = sftp.createWriteStream(target, { mode });
stream.once('error', reject);
stream.once('close', () => resolve({ remotePath: target, size: data.length }));
stream.end(data);
return;
}
current += `/${mkdirParts[index]}`;
const ensureDirectory = () => {
sftp.stat(current, (statError, attributes) => {
if (!statError) {
if (typeof attributes?.isDirectory === 'function' && !attributes.isDirectory()) {
reject(new Error(`Remote upload parent exists but is not a directory: ${current}`));
return;
}
makeNext(index + 1);
return;
}
if (![2, 'ENOENT'].includes(statError.code)) { reject(statError); return; }
sftp.mkdir(current, { mode: 0o755 }, (mkdirError) => {
if (!mkdirError) { makeNext(index + 1); return; }
sftp.stat(current, (retryError, retryAttributes) => {
if (!retryError && (typeof retryAttributes?.isDirectory !== 'function' || retryAttributes.isDirectory())) makeNext(index + 1);
else reject(mkdirError);
});
});
});
};
ensureDirectory();
};
makeNext(0);
});
}), { trustOnFirstUse: false });
}
async uploadFile(serverId, localPath, remotePath, options = {}) {
const data = await fs.readFile(localPath);
return this.uploadBuffer(serverId, remotePath, data, options);
}
async test(serverId, { trustOnFirstUse = true } = {}) { async test(serverId, { trustOnFirstUse = true } = {}) {
return this.withClient(serverId, async (client, server, fingerprint) => { return this.withClient(serverId, async (client, server, fingerprint) => {
const result = await this.execClient(client, 'uname -srm && command -v git && (docker compose version || docker-compose version)', { timeout: 30_000 }); const result = await this.execClient(client, 'uname -srm && command -v git && (docker compose version || docker-compose version)', { timeout: 30_000 });
+371 -56
View File
@@ -88,12 +88,36 @@ function checksSummary(checks) {
}; };
} }
function xmlEscape(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function iconReferenceLocalPath(iconReference) {
const value = String(iconReference || '').trim();
if (value.startsWith('file:///')) return `/${value.slice('file:///'.length)}`;
if (value.startsWith('/')) return value;
return '';
}
class UnraidDeploymentService { class UnraidDeploymentService {
constructor({ store, ssh, git, diagnostics }) { constructor({ store, ssh, git, diagnostics, sourcePath = process.cwd(), onOperationChange = null }) {
this.store = store; this.store = store;
this.ssh = ssh; this.ssh = ssh;
this.git = git; this.git = git;
this.diagnostics = diagnostics; this.diagnostics = diagnostics;
this.sourcePath = sourcePath;
this.onOperationChange = onOperationChange;
}
async saveOperation(operation) {
const saved = await this.store.addOperation(operation);
this.onOperationChange?.({ operations: [saved] });
return saved;
} }
resolve(repository, profileId) { resolve(repository, profileId) {
@@ -225,6 +249,20 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths"
if (!server.hostFingerprint) checks.push({ id: 'host-key', label: 'Server identity', status: 'fail', detail: 'Test and trust the SSH host key first.' }); if (!server.hostFingerprint) checks.push({ id: 'host-key', label: 'Server identity', status: 'fail', detail: 'Test and trust the SSH host key first.' });
else checks.push({ id: 'host-key', label: 'Server identity', status: 'pass', detail: server.hostFingerprint }); else checks.push({ id: 'host-key', label: 'Server identity', status: 'pass', detail: server.hostFingerprint });
const cloneUrl = String(profile.cloneUrl || repository.sshUrl || repository.preferredCloneUrl || '').trim();
if (!cloneUrl) {
checks.push({ id: 'server-git-access', label: 'Unraid → Gitea access', status: 'fail', detail: 'No server-usable Git clone URL is configured.' });
} else {
try {
const branchRef = `refs/heads/${String(profile.branch || 'main')}`;
const probe = await this.ssh.exec(server.id, bash(`git ls-remote --exit-code ${shellQuote(cloneUrl)} ${shellQuote(branchRef)}`), { timeout: 45_000, maxOutput: 256 * 1024 });
const remoteSha = String(probe.stdout || '').trim().split(/\s+/)[0] || 'reachable';
checks.push({ id: 'server-git-access', label: 'Unraid → Gitea access', status: 'pass', detail: `${cloneUrl} · ${String(remoteSha).slice(0, 7)}` });
} catch (error) {
checks.push({ id: 'server-git-access', label: 'Unraid → Gitea access', status: 'fail', detail: `Unraid cannot read the repository with the configured clone URL: ${error.message}` });
}
}
try { try {
inspection = await this.inspect({ repository, profileId }); inspection = await this.inspect({ repository, profileId });
if (!inspection.exists) { if (!inspection.exists) {
@@ -274,6 +312,19 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths"
} catch (error) { } catch (error) {
checks.push({ id: 'inspection', label: 'Server project inspection', status: 'fail', detail: error.message }); checks.push({ id: 'inspection', label: 'Server project inspection', status: 'fail', detail: error.message });
} }
const iconMode = profile.iconMode || (profile.iconFilePath ? 'upload' : profile.iconUrl ? 'url' : 'builtin');
if (iconMode === 'upload') {
const iconStat = await fs.stat(profile.iconFilePath).catch(() => null);
checks.push({ id: 'dockerman-icon-file', label: 'DockerMan icon upload', status: iconStat?.isFile() && nativePath.extname(profile.iconFilePath).toLowerCase() === '.png' ? 'pass' : 'fail', detail: iconStat?.isFile() ? profile.iconFilePath : 'The selected local PNG icon file was not found.' });
} else if (iconMode === 'builtin') {
const builtinIcon = nativePath.join(this.sourcePath, 'src', 'renderer', 'assets', 'itworx-mark.png');
const iconStat = await fs.stat(builtinIcon).catch(() => null);
checks.push({ id: 'dockerman-icon-builtin', label: 'DockerMan icon', status: iconStat?.isFile() ? 'pass' : 'fail', detail: iconStat?.isFile() ? 'Built-in high-contrast ITWorx mark.' : 'The built-in ITWorx icon asset is missing.' });
} else if (iconMode === 'url') checks.push({ id: 'dockerman-icon', label: 'DockerMan icon', status: profile.iconUrl ? 'pass' : 'fail', detail: profile.iconUrl || 'Icon URL mode requires an HTTPS or HTTP PNG URL.' });
else checks.push({ id: 'dockerman-icon', label: 'DockerMan icon', status: 'warning', detail: 'Custom DockerMan icon disabled.' });
const webUiLabel = this.dockerManWebUi(profile);
checks.push({ id: 'dockerman-webui', label: 'DockerMan Web UI action', status: webUiLabel ? 'pass' : 'warning', detail: webUiLabel || 'No Web UI URL or host port is configured.' });
checks.push({ id: 'compose-identity', label: 'Safe Docker Compose identity', status: 'pass', detail: `Internal project/image: ${this.internalSlug(profile, repository)}; visible container: ${profile.containerName || profile.remoteFolder || repository.name}.` });
checks.push({ id: 'exact-sha', label: 'Exact deployment commit', status: 'pass', detail: targetSha }); checks.push({ id: 'exact-sha', label: 'Exact deployment commit', status: 'pass', detail: targetSha });
return { return {
provider: 'ssh-unraid', provider: 'ssh-unraid',
@@ -288,27 +339,148 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths"
}; };
} }
internalSlug(profile, repository) {
return String(profile.remoteFolder || repository.name || profile.composeService || 'app')
.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'app';
}
generatedCompose(profile, repository) { generatedCompose(profile, repository) {
const service = String(profile.composeService || repository.name || 'app').toLowerCase().replace(/[^a-z0-9_-]/g, '-') || 'app'; const service = String(profile.composeService || repository.name || 'app').toLowerCase().replace(/[^a-z0-9._-]/g, '-') || 'app';
const containerName = String(profile.containerName || profile.remoteFolder || repository.name || service).replace(/[^A-Za-z0-9._-]/g, '-') || service;
if (!profile.hostPort || !profile.containerPort) throw new Error('Host and container ports are required for generated Compose.'); if (!profile.hostPort || !profile.containerPort) throw new Error('Host and container ports are required for generated Compose.');
const labels = [
'net.unraid.docker.managed=dockerman',
profile.webUiUrl ? `net.unraid.docker.webui=${profile.webUiUrl}` : '',
profile.iconUrl ? `net.unraid.docker.icon=${profile.iconUrl}` : ''
].filter(Boolean);
return [ return [
'services:', 'services:',
` ${service}:`, ` ${service}:`,
` image: forgeflow/${this.internalSlug(profile, repository)}:${String(profile.environment || 'production').toLowerCase()}`,
' build:', ' build:',
' context: ..', ' context: ..',
` container_name: ${service}`, ` container_name: ${containerName}`,
' restart: unless-stopped', ' restart: unless-stopped',
' ports:', ' ports:',
` - "${profile.hostPort}:${profile.containerPort}"`, ` - "${profile.hostPort}:${profile.containerPort}"`
...(labels.length ? [' labels:', ...labels.map((label) => ` - ${JSON.stringify(label)}`)] : [])
].join('\n') + '\n'; ].join('\n') + '\n';
} }
dockerManWebUi(profile) {
if (profile.hostPort) {
let suffix = '/';
try {
const parsed = profile.webUiUrl ? new URL(profile.webUiUrl) : null;
suffix = parsed ? `${parsed.pathname || '/'}${parsed.search || ''}${parsed.hash || ''}` : '/';
} catch {}
if (!suffix.startsWith('/')) suffix = `/${suffix}`;
return `http://[IP]:[PORT:${profile.hostPort}]${suffix}`;
}
return profile.webUiUrl || '';
}
dockerManShell(profile) {
return String(profile.dockerShell || '/bin/sh').toLowerCase().includes('bash') ? 'bash' : 'sh';
}
dockerManTemplatePath(profile, repository) {
const containerName = String(profile.containerName || profile.remoteFolder || repository.name || 'app').replace(/[^A-Za-z0-9._-]/g, '-') || 'app';
return `/boot/config/plugins/dockerMan/templates-user/my-${containerName}.xml`;
}
dockerManTemplate(profile, repository, iconReference = '') {
const containerName = String(profile.containerName || profile.remoteFolder || repository.name || 'app').replace(/[^A-Za-z0-9._-]/g, '-') || 'app';
const slug = this.internalSlug(profile, repository);
const environment = String(profile.environment || 'production').toLowerCase().replace(/[^a-z0-9._-]/g, '-') || 'production';
const image = `forgeflow/${slug}:${environment}`;
const webUi = this.dockerManWebUi(profile);
return [
'<?xml version="1.0"?>',
'<Container version="2">',
` <Name>${xmlEscape(containerName)}</Name>`,
` <Repository>${xmlEscape(image)}</Repository>`,
' <Registry/>',
' <Network>bridge</Network>',
' <MyIP/>',
` <Shell>${xmlEscape(this.dockerManShell(profile))}</Shell>`,
' <Privileged>false</Privileged>',
' <Support/>',
' <Project/>',
' <Overview>Managed by ForgeFlow through Docker Compose. Use ForgeFlow or the Compose files for configuration changes.</Overview>',
' <Category>Tools:</Category>',
` <WebUI>${xmlEscape(webUi)}</WebUI>`,
' <TemplateURL/>',
` <Icon>${xmlEscape(iconReference)}</Icon>`,
' <ExtraParams/>',
' <PostArgs/>',
' <CPUset/>',
' <DonateText/>',
' <DonateLink/>',
'</Container>'
].join('\n') + '\n';
}
iconCacheRefresh(profile, repository, iconReference = '') {
const containerName = String(profile.containerName || profile.remoteFolder || repository.name || 'app').replace(/[^A-Za-z0-9._-]/g, '-') || 'app';
const cacheLoop = `for icon_dir in /var/lib/docker/unraid/images /usr/local/emhttp/state/plugins/dynamix.docker.manager/images /var/local/emhttp/plugins/dynamix.docker.manager/images; do [ -d "$icon_dir" ] || continue; rm -f "$icon_dir/${containerName}-icon.png" "$icon_dir/${containerName}.png"; done`;
const invalidateMetadata = `rm -f /usr/local/emhttp/state/plugins/dynamix.docker.manager/docker.json`;
const localIconPath = iconReferenceLocalPath(iconReference);
if (!localIconPath) return `${cacheLoop}\n${invalidateMetadata}`;
return `${cacheLoop}
if [ -f ${shellQuote(localIconPath)} ]; then for icon_dir in /var/lib/docker/unraid/images /usr/local/emhttp/state/plugins/dynamix.docker.manager/images /var/local/emhttp/plugins/dynamix.docker.manager/images; do [ -d "$icon_dir" ] || continue; cp ${shellQuote(localIconPath)} "$icon_dir/${containerName}-icon.png"; chmod 0644 "$icon_dir/${containerName}-icon.png"; done; fi
${invalidateMetadata}`;
}
dockerManRefreshScript(profile, repository, iconReference = '') {
const templatePath = this.dockerManTemplatePath(profile, repository);
const template = this.dockerManTemplate(profile, repository, iconReference);
return `mkdir -p /boot/config/plugins/dockerMan/templates-user
cat > ${shellQuote(templatePath)} <<'FORGEFLOW_DOCKERMAN_TEMPLATE'
${template}FORGEFLOW_DOCKERMAN_TEMPLATE
chmod 0644 ${shellQuote(templatePath)}
${this.iconCacheRefresh(profile, repository, iconReference)}`;
}
metadataCompose(profile, repository, iconReference = '') {
const service = String(profile.composeService || repository.name || 'app').trim().toLowerCase().replace(/[^a-z0-9._-]/g, '-') || 'app';
const containerName = String(profile.containerName || profile.remoteFolder || repository.name || service).replace(/[^A-Za-z0-9._-]/g, '-') || 'app';
const slug = this.internalSlug(profile, repository);
const labels = {
'net.unraid.docker.managed': 'dockerman',
'net.unraid.docker.shell': this.dockerManShell(profile)
};
const webUiLabel = this.dockerManWebUi(profile);
if (webUiLabel) labels['net.unraid.docker.webui'] = webUiLabel;
if (iconReference) labels['net.unraid.docker.icon'] = iconReference;
return [
'services:',
` ${service}:`,
` image: forgeflow/${slug}:${String(profile.environment || 'production').toLowerCase().replace(/[^a-z0-9._-]/g, '-')}`,
` container_name: ${containerName}`,
' labels:',
...Object.entries(labels).map(([key, value]) => ` ${JSON.stringify(key)}: ${JSON.stringify(value)}`)
].join('\n') + '\n';
}
async prepareIcon(profile, repository, server) {
const mode = profile.iconMode || (profile.iconFilePath ? 'upload' : profile.iconUrl ? 'url' : 'builtin');
if (mode === 'none') return '';
if (mode === 'url') {
if (!profile.iconUrl) throw new Error('DockerMan icon URL mode is selected, but no icon URL is configured.');
return profile.iconUrl;
}
const localIconPath = mode === 'builtin'
? nativePath.join(this.sourcePath, 'src', 'renderer', 'assets', 'itworx-mark.png')
: profile.iconFilePath;
const stat = await fs.stat(localIconPath).catch(() => null);
if (!stat?.isFile()) throw new Error(mode === 'builtin' ? 'The built-in ITWorx DockerMan icon is missing.' : `The selected DockerMan icon file no longer exists: ${localIconPath}`);
if (nativePath.extname(localIconPath).toLowerCase() !== '.png') throw new Error('DockerMan icon upload currently accepts PNG files only.');
const containerName = String(profile.containerName || profile.remoteFolder || repository.name || 'app').replace(/[^A-Za-z0-9._-]/g, '-') || 'app';
const remoteIconPath = `/boot/config/plugins/dockerMan/images/${containerName}-icon.png`;
await this.ssh.uploadFile(server.id, localIconPath, remoteIconPath, { mode: 0o644 });
return `file://${remoteIconPath}`;
}
composeInvocation(profile, repository, composeFile) {
const slug = this.internalSlug(profile, repository);
return `docker compose -p ${shellQuote(slug)} -f ${shellQuote(composeFile)} -f '.forgeflow/compose.metadata.yml'`;
}
async checkHealth(url) { async checkHealth(url) {
if (!url) return { configured: false, healthy: null, status: null, latencyMs: null }; if (!url) return { configured: false, healthy: null, status: null, latencyMs: null };
let last = null; let last = null;
@@ -336,7 +508,7 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths"
throw error; throw error;
} }
const requestId = crypto.randomUUID(); const requestId = crypto.randomUUID();
const operation = await this.store.addOperation({ const operation = await this.saveOperation({
id: requestId, id: requestId,
type: 'deployment', type: 'deployment',
action: 'deploy', action: 'deploy',
@@ -349,13 +521,15 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths"
sha: targetSha, sha: targetSha,
shortSha: targetSha.slice(0, 7), shortSha: targetSha.slice(0, 7),
status: 'running', status: 'running',
logs: ['SSH connection verified.', `Deploying exact commit ${targetSha}.`] logs: ['Preflight passed.', 'Unraid can read the Gitea repository.', `Deploying exact commit ${targetSha} in the background.`]
}); });
const cloneUrl = String(profile.cloneUrl || repository.sshUrl || repository.preferredCloneUrl || '').trim(); const cloneUrl = String(profile.cloneUrl || repository.sshUrl || repository.preferredCloneUrl || '').trim();
if (!cloneUrl) throw new Error('No server-usable Git clone URL is configured.');
const composeFile = profile.generatedCompose ? '.forgeflow/compose.forgeflow.yml' : safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml'); const composeFile = profile.generatedCompose ? '.forgeflow/compose.forgeflow.yml' : safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml');
const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : ''; const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : '';
const iconReference = await this.prepareIcon(profile, repository, server);
const metadata = this.metadataCompose(profile, repository, iconReference);
const compose = this.composeInvocation(profile, repository, composeFile);
const branch = String(profile.branch || 'main'); const branch = String(profile.branch || 'main');
const statusJson = JSON.stringify({ const statusJson = JSON.stringify({
repository: repository.fullName, repository: repository.fullName,
@@ -377,7 +551,7 @@ fi
test -d "$root/.git" || { echo "Existing folder is not a Git working tree" >&2; exit 32; } test -d "$root/.git" || { echo "Existing folder is not a Git working tree" >&2; exit 32; }
${profile.alignRemote ? `git -C "$root" remote set-url origin ${shellQuote(cloneUrl)}` : ''} ${profile.alignRemote ? `git -C "$root" remote set-url origin ${shellQuote(cloneUrl)}` : ''}
changes=$(git -C "$root" status --porcelain --untracked-files=no) changes=$(git -C "$root" status --porcelain --untracked-files=no)
test -z "$changes" || { echo "Tracked server-side changes block deployment" >&2; printf '%s\\n' "$changes" >&2; exit 33; } test -z "$changes" || { echo "Tracked server-side changes block deployment" >&2; printf '%s\n' "$changes" >&2; exit 33; }
git -C "$root" fetch --prune origin ${shellQuote(branch)} git -C "$root" fetch --prune origin ${shellQuote(branch)}
git -C "$root" cat-file -e ${shellQuote(`${targetSha}^{commit}`)} git -C "$root" cat-file -e ${shellQuote(`${targetSha}^{commit}`)}
git -C "$root" merge-base --is-ancestor ${shellQuote(targetSha)} ${shellQuote(`origin/${branch}`)} git -C "$root" merge-base --is-ancestor ${shellQuote(targetSha)} ${shellQuote(`origin/${branch}`)}
@@ -388,40 +562,73 @@ mkdir -p "$root/.forgeflow"
printf '%s' "$previous" > "$root/.forgeflow/previous-sha" printf '%s' "$previous" > "$root/.forgeflow/previous-sha"
printf '%s' ${shellQuote(targetSha)} > "$root/.forgeflow/current-sha" printf '%s' ${shellQuote(targetSha)} > "$root/.forgeflow/current-sha"
${profile.generatedCompose ? `cat > "$root/.forgeflow/compose.forgeflow.yml" <<'FORGEFLOW_COMPOSE'\n${generated}FORGEFLOW_COMPOSE` : ''} ${profile.generatedCompose ? `cat > "$root/.forgeflow/compose.forgeflow.yml" <<'FORGEFLOW_COMPOSE'\n${generated}FORGEFLOW_COMPOSE` : ''}
cat > "$root/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA'
${metadata}FORGEFLOW_METADATA
cd "$root" cd "$root"
docker compose -f ${shellQuote(composeFile)} config >/dev/null ${compose} config >/dev/null
docker compose -f ${shellQuote(composeFile)} up -d --build --remove-orphans ${compose} up -d --build --remove-orphans --force-recreate
${this.dockerManRefreshScript(profile, repository, iconReference)}
container=${shellQuote(String(profile.containerName || profile.remoteFolder || repository.name))}
docker inspect "$container" >/dev/null
cat > "$root/.forgeflow/status.json" <<'FORGEFLOW_STATUS' cat > "$root/.forgeflow/status.json" <<'FORGEFLOW_STATUS'
${statusJson} ${statusJson}
FORGEFLOW_STATUS FORGEFLOW_STATUS
`; `;
void (async () => {
try { try {
const result = await this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 4 * 1024 * 1024 }); const result = await this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 4 * 1024 * 1024 });
const health = await this.checkHealth(profile.healthcheckUrl); const health = await this.checkHealth(profile.healthcheckUrl);
const finalStatus = health.healthy === false ? 'failed' : 'success'; // The remote deployment script already verifies that Docker created the expected
// container. Complete the operation before a secondary state inspection so a slow or
// failed refresh cannot leave ForgeFlow stuck in deployment mode after a successful run.
const effectiveHealthy = health.configured ? health.healthy : true;
const finalStatus = effectiveHealthy === false ? 'failed' : 'success';
const finalLogs = [ const finalLogs = [
...operation.logs, ...operation.logs,
...result.stdout.trim().split('\n').filter(Boolean).slice(-60), ...result.stdout.trim().split('\n').filter(Boolean).slice(-60),
'Docker Compose deployment completed.', 'Docker Compose deployment completed.',
health.configured ? `Healthcheck ${health.healthy ? 'passed' : 'failed'}${health.status ? ` with HTTP ${health.status}` : ''}.` : 'No desktop healthcheck URL configured.' health.configured
? `Healthcheck ${health.healthy ? 'passed' : 'failed'}${health.status ? ` with HTTP ${health.status}` : ''}.`
: 'No desktop healthcheck URL configured; the remote container inspection passed.'
]; ];
const completed = await this.store.addOperation({ await this.saveOperation({
...operation, ...operation,
status: finalStatus, status: finalStatus,
previousSha: preflight.inspection?.head || null, previousSha: preflight.inspection?.head || null,
health, health: { ...health, healthy: effectiveHealthy },
logs: finalLogs, logs: finalLogs,
error: health.healthy === false ? 'The application healthcheck did not pass after deployment.' : null error: effectiveHealthy === false ? 'The application healthcheck did not pass after deployment.' : null
}); });
await this.store.saveDeploymentState(profileId, { await this.store.saveDeploymentState(profileId, {
liveSha: targetSha, liveSha: targetSha,
previousSha: preflight.inspection?.head || null, previousSha: preflight.inspection?.head || null,
healthy: health.healthy, healthy: effectiveHealthy,
healthStatus: health.status, healthStatus: health.status ?? null,
healthLatencyMs: health.latencyMs, healthLatencyMs: health.latencyMs ?? null,
requestId, requestId,
remotePath, remotePath,
provider: 'ssh-unraid' provider: 'ssh-unraid',
containerName: String(profile.containerName || profile.remoteFolder || repository.name),
containerRunning: true,
dockerMan: {
webUi: this.dockerManWebUi(profile),
icon: iconReference,
shell: this.dockerManShell(profile),
templateExists: true,
configured: Boolean(this.dockerManWebUi(profile) || iconReference)
},
webUiUrl: profile.webUiUrl || (profile.hostPort ? `http://${server.host}:${profile.hostPort}/` : null)
});
// Reconcile authoritative Unraid/Docker state in the background and preserve the already
// completed operation if that follow-up inspection is unavailable.
void this.refreshProfileState(repository.fullName, profileId).catch(async (refreshError) => {
await this.diagnostics?.warning('unraid.deployment.post-refresh-failed', {
requestId,
repository: repository.fullName,
serverId: server.id,
error: refreshError
});
}); });
await this.diagnostics?.info('unraid.deployment.completed', { await this.diagnostics?.info('unraid.deployment.completed', {
requestId, requestId,
@@ -429,20 +636,17 @@ FORGEFLOW_STATUS
serverId: server.id, serverId: server.id,
remotePath, remotePath,
sha: targetSha, sha: targetSha,
healthy: health.healthy, healthy: effectiveHealthy,
healthStatus: health.status healthStatus: health.status ?? null
}); });
if (health.healthy === false) {
const error = new Error('Deployment completed, but the configured healthcheck failed. The previous SHA remains available for rollback.');
error.code = 'DEPLOYMENT_HEALTHCHECK_FAILED';
error.operationId = completed.id;
throw error;
}
return completed;
} catch (error) { } catch (error) {
if (error.code !== 'DEPLOYMENT_HEALTHCHECK_FAILED') { await this.saveOperation({
await this.store.addOperation({ ...operation, status: 'failed', error: error.message, logs: [...operation.logs, error.message] }); ...operation,
} status: 'failed',
error: error.message,
failure: { stage: 'SSH / Docker deployment', message: error.message },
logs: [...operation.logs, error.message]
});
await this.diagnostics?.error('unraid.deployment.failed', { await this.diagnostics?.error('unraid.deployment.failed', {
requestId, requestId,
repository: repository.fullName, repository: repository.fullName,
@@ -451,8 +655,10 @@ FORGEFLOW_STATUS
sha: targetSha, sha: targetSha,
error error
}); });
throw error;
} }
})();
return operation;
} }
async rollback({ repository, profileId, targetSha }) { async rollback({ repository, profileId, targetSha }) {
@@ -470,8 +676,11 @@ FORGEFLOW_STATUS
if (!inspection.rootGit) throw new Error('The configured server project is not a root Git working tree.'); if (!inspection.rootGit) throw new Error('The configured server project is not a root Git working tree.');
if (inspection.trackedChanges.length) throw new Error('Tracked server-side changes block rollback. Commit, revert or migrate them first.'); if (inspection.trackedChanges.length) throw new Error('Tracked server-side changes block rollback. Commit, revert or migrate them first.');
const composeFile = profile.generatedCompose ? '.forgeflow/compose.forgeflow.yml' : safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml'); const composeFile = profile.generatedCompose ? '.forgeflow/compose.forgeflow.yml' : safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml');
const iconReference = await this.prepareIcon(profile, repository, server);
const metadata = this.metadataCompose(profile, repository, iconReference);
const compose = this.composeInvocation(profile, repository, composeFile);
const requestId = crypto.randomUUID(); const requestId = crypto.randomUUID();
const operation = await this.store.addOperation({ const operation = await this.saveOperation({
id: requestId, id: requestId,
type: 'deployment', type: 'deployment',
action: 'rollback', action: 'rollback',
@@ -504,9 +713,12 @@ git -C "$root" fetch --prune origin ${shellQuote(profile.branch)}
git -C "$root" cat-file -e ${shellQuote(`${target}^{commit}`)} git -C "$root" cat-file -e ${shellQuote(`${target}^{commit}`)}
current=$(git -C "$root" rev-parse HEAD) current=$(git -C "$root" rev-parse HEAD)
git -C "$root" reset --hard ${shellQuote(target)} git -C "$root" reset --hard ${shellQuote(target)}
cat > "$root/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA'
${metadata}FORGEFLOW_METADATA
cd "$root" cd "$root"
docker compose -f ${shellQuote(composeFile)} config >/dev/null ${compose} config >/dev/null
docker compose -f ${shellQuote(composeFile)} up -d --build --remove-orphans ${compose} up -d --build --remove-orphans --force-recreate
${this.dockerManRefreshScript(profile, repository, iconReference)}
printf '%s' "$current" > "$root/.forgeflow/previous-sha" printf '%s' "$current" > "$root/.forgeflow/previous-sha"
printf '%s' ${shellQuote(target)} > "$root/.forgeflow/current-sha" printf '%s' ${shellQuote(target)} > "$root/.forgeflow/current-sha"
cat > "$root/.forgeflow/status.json" <<'FORGEFLOW_STATUS' cat > "$root/.forgeflow/status.json" <<'FORGEFLOW_STATUS'
@@ -517,7 +729,7 @@ FORGEFLOW_STATUS
const result = await this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 4 * 1024 * 1024 }); const result = await this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 4 * 1024 * 1024 });
const health = await this.checkHealth(profile.healthcheckUrl); const health = await this.checkHealth(profile.healthcheckUrl);
const finalStatus = health.healthy === false ? 'failed' : 'rolled-back'; const finalStatus = health.healthy === false ? 'failed' : 'rolled-back';
const completed = await this.store.addOperation({ const completed = await this.saveOperation({
...operation, ...operation,
status: finalStatus, status: finalStatus,
previousSha: deploymentState.liveSha || inspection.head || null, previousSha: deploymentState.liveSha || inspection.head || null,
@@ -549,7 +761,7 @@ FORGEFLOW_STATUS
return completed; return completed;
} catch (error) { } catch (error) {
if (error.code !== 'ROLLBACK_HEALTHCHECK_FAILED') { if (error.code !== 'ROLLBACK_HEALTHCHECK_FAILED') {
await this.store.addOperation({ ...operation, status: 'failed', error: error.message, logs: [...operation.logs, error.message] }); await this.saveOperation({ ...operation, status: 'failed', error: error.message, logs: [...operation.logs, error.message] });
} }
throw error; throw error;
} }
@@ -558,30 +770,131 @@ FORGEFLOW_STATUS
async refreshProfileState(fullName, profileId) { async refreshProfileState(fullName, profileId) {
const repository = { fullName, name: fullName.split('/').pop() }; const repository = { fullName, name: fullName.split('/').pop() };
const { profile, server, remotePath } = this.resolve(repository, profileId); const { profile, server, remotePath } = this.resolve(repository, profileId);
const containerName = String(profile.containerName || profile.remoteFolder || repository.name);
const script = ` const script = `
root=${shellQuote(remotePath)} root=${shellQuote(remotePath)}
live=""; previous=""; status="" container=${shellQuote(containerName)}
template_path=${shellQuote('/boot/config/plugins/dockerMan/templates-user/my-' + containerName + '.xml')}
live=""; previous=""; running=false; docker_health=""; webui=""; icon=""; shell_label=""; template_exists=false
[ -f "$template_path" ] && template_exists=true
[ -f "$root/.forgeflow/current-sha" ] && live=$(cat "$root/.forgeflow/current-sha") [ -f "$root/.forgeflow/current-sha" ] && live=$(cat "$root/.forgeflow/current-sha")
[ -z "$live" ] && [ -d "$root/.git" ] && live=$(git -C "$root" rev-parse HEAD 2>/dev/null || true)
[ -f "$root/.forgeflow/previous-sha" ] && previous=$(cat "$root/.forgeflow/previous-sha") [ -f "$root/.forgeflow/previous-sha" ] && previous=$(cat "$root/.forgeflow/previous-sha")
[ -f "$root/.forgeflow/status.json" ] && status=$(base64 "$root/.forgeflow/status.json" | tr -d '\\r\\n') if docker inspect "$container" >/dev/null 2>&1; then
printf '__FORGEFLOW_JSON__\\n{"liveSha":"%s","previousSha":"%s","statusBase64":"%s"}\\n' "$live" "$previous" "$status" running=$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null || echo false)
docker_health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container" 2>/dev/null || true)
webui=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.webui"}}' "$container" 2>/dev/null || true)
icon=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.icon"}}' "$container" 2>/dev/null || true)
shell_label=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.shell"}}' "$container" 2>/dev/null || true)
fi
printf '__FORGEFLOW_KV__\n'
printf 'liveSha=%s\n' "$live"
printf 'previousSha=%s\n' "$previous"
printf 'containerRunning=%s\n' "$running"
printf 'dockerHealth=%s\n' "$docker_health"
printf 'webUiLabel=%s\n' "$(printf '%s' "$webui" | base64 | tr -d '\r\n')"
printf 'iconLabel=%s\n' "$(printf '%s' "$icon" | base64 | tr -d '\r\n')"
printf 'shellLabel=%s\n' "$(printf '%s' "$shell_label" | base64 | tr -d '\r\n')"
printf 'templateExists=%s\n' "$template_exists"
`; `;
const result = await this.ssh.exec(server.id, bash(script), { timeout: 30_000 }); const result = await this.ssh.exec(server.id, bash(script), { timeout: 30_000 });
const raw = parseInspection(result.stdout); const marker = result.stdout.lastIndexOf('__FORGEFLOW_KV__');
let remoteStatus = null; if (marker < 0) throw new Error('Unraid state inspection did not return a ForgeFlow marker.');
try { remoteStatus = raw.statusBase64 ? JSON.parse(Buffer.from(raw.statusBase64, 'base64').toString('utf8')) : null; } catch {} const fields = {};
const existing = this.store.getDeploymentState(profile.id) || {}; for (const line of result.stdout.slice(marker + '__FORGEFLOW_KV__'.length).trim().split(/\r?\n/)) {
const index = line.indexOf('=');
if (index > 0) fields[line.slice(0, index)] = line.slice(index + 1);
}
const decode = (value) => { try { return value ? Buffer.from(value, 'base64').toString('utf8') : ''; } catch { return ''; } };
const health = await this.checkHealth(profile.healthcheckUrl);
const dockerHealthy = fields.dockerHealth ? fields.dockerHealth === 'healthy' : null;
const effectiveHealthy = health.configured ? health.healthy : (dockerHealthy ?? (fields.containerRunning === 'true' ? true : false));
return this.store.saveDeploymentState(profile.id, { return this.store.saveDeploymentState(profile.id, {
liveSha: /^[0-9a-f]{40}$/i.test(raw.liveSha || '') ? raw.liveSha : null, liveSha: /^[0-9a-f]{40}$/i.test(fields.liveSha || '') ? fields.liveSha : null,
previousSha: /^[0-9a-f]{40}$/i.test(raw.previousSha || '') ? raw.previousSha : null, previousSha: /^[0-9a-f]{40}$/i.test(fields.previousSha || '') ? fields.previousSha : null,
healthy: remoteStatus?.healthy ?? existing.healthy ?? null, healthy: effectiveHealthy,
healthStatus: existing.healthStatus ?? null, healthStatus: health.status,
healthLatencyMs: existing.healthLatencyMs ?? null, healthLatencyMs: health.latencyMs,
requestId: remoteStatus?.request_id || existing.requestId || null, containerName,
containerRunning: fields.containerRunning === 'true',
dockerHealth: fields.dockerHealth || null,
dockerMan: {
webUi: decode(fields.webUiLabel),
icon: decode(fields.iconLabel),
shell: decode(fields.shellLabel),
templateExists: fields.templateExists === 'true',
configured: Boolean(decode(fields.webUiLabel) || decode(fields.iconLabel) || fields.templateExists === 'true')
},
webUiUrl: profile.webUiUrl || (profile.hostPort ? `http://${server.host}:${profile.hostPort}/` : null),
remotePath, remotePath,
provider: 'ssh-unraid' provider: 'ssh-unraid'
}); });
} }
async applyDockerManMetadata({ repository, profileId }) {
const { profile, server, remotePath } = this.resolve(repository, profileId);
const composeFile = profile.generatedCompose ? '.forgeflow/compose.forgeflow.yml' : safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml');
const iconReference = await this.prepareIcon(profile, repository, server);
const metadata = this.metadataCompose(profile, repository, iconReference);
const compose = this.composeInvocation(profile, repository, composeFile);
const script = `
root=${shellQuote(remotePath)}
test -d "$root/.git"
mkdir -p "$root/.forgeflow"
cat > "$root/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA'
${metadata}FORGEFLOW_METADATA
cd "$root"
${compose} config >/dev/null
${compose} up -d --build --remove-orphans --force-recreate
${this.dockerManRefreshScript(profile, repository, iconReference)}
`;
await this.ssh.exec(server.id, bash(script), { timeout: 10 * 60_000, maxOutput: 2 * 1024 * 1024 });
return this.refreshProfileState(repository.fullName, profileId);
}
async refreshOperation(operationId) {
const operation = this.store.getOperation(operationId);
if (!operation || operation.provider !== 'ssh-unraid') return operation;
if (['success', 'failed', 'cancelled', 'rolled-back'].includes(operation.status)) return operation;
try {
const state = await this.refreshProfileState(operation.repository, operation.profileId);
if (state.liveSha === operation.sha && state.containerRunning && state.healthy !== false) {
return this.saveOperation({
...operation,
status: operation.action === 'rollback' ? 'rolled-back' : 'success',
health: { healthy: state.healthy, status: state.healthStatus },
logs: [...(operation.logs || []), 'Deployment state reconciled from Unraid.']
});
}
if (/^[0-9a-f]{40}$/i.test(String(state.liveSha || '')) && state.liveSha !== operation.sha && state.containerRunning && state.healthy !== false) {
return this.saveOperation({
...operation,
status: 'cancelled',
error: `Superseded by live commit ${state.liveSha.slice(0, 7)}.`,
health: { healthy: state.healthy, status: state.healthStatus },
logs: [...(operation.logs || []), `Operation superseded by live Unraid commit ${state.liveSha}.`]
});
}
const ageMs = Date.now() - new Date(operation.updatedAt || operation.createdAt || 0).getTime();
if (ageMs > 45 * 60_000) {
return this.saveOperation({
...operation,
status: 'failed',
error: 'Deployment was interrupted or did not reach the requested commit within 45 minutes.',
logs: [...(operation.logs || []), 'Stale deployment was marked failed during reconciliation.']
});
}
return operation;
} catch {
return operation;
}
}
async refreshActiveOperations() {
const active = this.store.data.operations.filter((item) => item.provider === 'ssh-unraid' && item.type === 'deployment' && !['success', 'failed', 'cancelled', 'rolled-back'].includes(item.status));
return Promise.all(active.map((item) => this.refreshOperation(item.id)));
}
} }
module.exports = { module.exports = {
@@ -591,5 +904,7 @@ module.exports = {
parseInspection, parseInspection,
dockerIgnoreHasPath, dockerIgnoreHasPath,
checksSummary, checksSummary,
xmlEscape,
iconReferenceLocalPath,
bash bash
}; };
+162 -12
View File
@@ -1,6 +1,7 @@
'use strict'; 'use strict';
const fs = require('node:fs/promises'); const fs = require('node:fs/promises');
const fsSync = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const crypto = require('node:crypto'); const crypto = require('node:crypto');
const { spawn } = require('node:child_process'); const { spawn } = require('node:child_process');
@@ -12,14 +13,73 @@ function safeRepositoryPart(value, label) {
return text; return text;
} }
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function resolveWindowsPowerShellPath(environment = process.env) {
const windowsRoot = environment.SystemRoot || environment.WINDIR;
if (windowsRoot) {
const absolute = path.join(windowsRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
if (fsSync.existsSync(absolute)) return absolute;
}
return 'powershell.exe';
}
async function readJsonFile(filePath) {
try { return JSON.parse(await fs.readFile(filePath, 'utf8')); }
catch { return null; }
}
async function waitForUpdaterStarted(statusPath, {
timeoutMs = 12000,
pollMs = 100,
childState = null
} = {}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const status = await readJsonFile(statusPath);
if (status && ['started', 'waiting-for-exit', 'backing-up', 'extracting', 'applying', 'validating'].includes(status.state)) {
return status;
}
if (childState?.error) throw childState.error;
if (childState?.exited) {
const error = new Error(`The update helper exited before it confirmed startup (exit code ${childState.code ?? 'unknown'}).`);
error.code = 'UPDATE_HELPER_EXITED_EARLY';
throw error;
}
await delay(pollMs);
}
const error = new Error('The update helper did not confirm startup. ForgeFlow was left open and no source files were changed.');
error.code = 'UPDATE_HELPER_START_TIMEOUT';
throw error;
}
class UpdateService { class UpdateService {
constructor({ store, gitea, diagnostics, appInfo, sourcePath, userDataPath }) { constructor({
store,
gitea,
diagnostics,
appInfo,
sourcePath,
userDataPath,
platform = process.platform,
spawnProcess = spawn,
powershellPath = null,
handshakeTimeoutMs = 12000,
handshakePollMs = 100
}) {
this.store = store; this.store = store;
this.gitea = gitea; this.gitea = gitea;
this.diagnostics = diagnostics; this.diagnostics = diagnostics;
this.appInfo = appInfo; this.appInfo = appInfo;
this.sourcePath = sourcePath; this.sourcePath = sourcePath;
this.updateDirectory = path.join(userDataPath, 'updates'); this.updateDirectory = path.join(userDataPath, 'updates');
this.platform = platform;
this.spawnProcess = spawnProcess;
this.powershellPath = powershellPath;
this.handshakeTimeoutMs = handshakeTimeoutMs;
this.handshakePollMs = handshakePollMs;
this.staged = null; this.staged = null;
} }
@@ -99,37 +159,127 @@ class UpdateService {
async apply(staged = null) { async apply(staged = null) {
const update = staged?.archivePath ? staged : this.staged; const update = staged?.archivePath ? staged : this.staged;
if (!update?.archivePath) throw new Error('Download an update before applying it.'); if (!update?.archivePath) throw new Error('Download an update before applying it.');
if (process.platform !== 'win32') throw new Error('The integrated source updater currently supports Windows only.'); if (this.platform !== 'win32') throw new Error('The integrated source updater currently supports Windows only.');
const stat = await fs.stat(update.archivePath).catch(() => null); const stat = await fs.stat(update.archivePath).catch(() => null);
if (!stat?.isFile()) throw new Error('The staged update archive is no longer available.'); if (!stat?.isFile()) throw new Error('The staged update archive is no longer available.');
const scriptPath = path.join(this.sourcePath, 'scripts', 'apply-source-update.ps1'); const scriptPath = path.join(this.sourcePath, 'scripts', 'apply-source-update.ps1');
const scriptStat = await fs.stat(scriptPath).catch(() => null); const scriptStat = await fs.stat(scriptPath).catch(() => null);
if (!scriptStat?.isFile()) throw new Error('The source update helper is missing.'); if (!scriptStat?.isFile()) throw new Error('The source update helper is missing.');
const logPath = path.join(this.updateDirectory, `apply-${Date.now()}.log`);
await fs.mkdir(this.updateDirectory, { recursive: true });
const updateId = `${Date.now()}-${crypto.randomUUID()}`;
const logPath = path.join(this.updateDirectory, `apply-${updateId}.log`);
const statusPath = path.join(this.updateDirectory, `apply-${updateId}.status.json`);
const launching = {
schemaVersion: 1,
updateId,
state: 'launching',
expectedVersion: update.remoteVersion,
sourcePath: this.sourcePath,
logPath,
statusPath,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
await fs.writeFile(statusPath, JSON.stringify(launching, null, 2), { mode: 0o600 });
const executable = this.powershellPath || resolveWindowsPowerShellPath();
const args = [ const args = [
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath,
'-SourcePath', this.sourcePath, '-SourcePath', this.sourcePath,
'-ArchivePath', update.archivePath, '-ArchivePath', update.archivePath,
'-ExpectedVersion', update.remoteVersion, '-ExpectedVersion', update.remoteVersion,
'-ExpectedSha256', update.sha256, '-ExpectedSha256', update.sha256,
'-ParentPid', String(process.pid), '-ParentPid', String(process.pid),
'-LogPath', logPath '-LogPath', logPath,
'-StatusPath', statusPath,
'-UpdateId', updateId
]; ];
const child = spawn('powershell.exe', args, {
const childState = { exited: false, code: null, error: null };
let child;
try {
child = this.spawnProcess(executable, args, {
detached: true, detached: true,
stdio: 'ignore', stdio: 'ignore',
windowsHide: false, windowsHide: true,
cwd: this.sourcePath cwd: this.sourcePath
}); });
child.unref(); } catch (error) {
await this.diagnostics?.info('updates.apply-launched', { error.code ||= 'UPDATE_HELPER_SPAWN_FAILED';
throw error;
}
child.once?.('error', (error) => { childState.error = error; });
child.once?.('exit', (code) => { childState.exited = true; childState.code = code; });
await new Promise((resolve, reject) => {
let settled = false;
const finish = (handler, value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
handler(value);
};
const timer = setTimeout(() => finish(reject, Object.assign(new Error('Windows did not start the update helper process.'), { code: 'UPDATE_HELPER_SPAWN_TIMEOUT' })), 5000);
child.once?.('spawn', () => finish(resolve));
child.once?.('error', (error) => finish(reject, error));
if (!child.once) finish(resolve);
});
child.unref?.();
const started = await waitForUpdaterStarted(statusPath, {
timeoutMs: this.handshakeTimeoutMs,
pollMs: this.handshakePollMs,
childState
});
await this.diagnostics?.info('updates.apply-started', {
updateId,
remoteVersion: update.remoteVersion, remoteVersion: update.remoteVersion,
remoteSha: update.remoteSha, remoteSha: update.remoteSha,
logPath logPath,
statusPath,
helperPid: child.pid,
helperState: started.state
}); });
return { launched: true, version: update.remoteVersion, logPath }; return { launched: true, confirmed: true, updateId, version: update.remoteVersion, logPath, statusPath };
}
async consumeLatestResult() {
await fs.mkdir(this.updateDirectory, { recursive: true });
const entries = await fs.readdir(this.updateDirectory, { withFileTypes: true }).catch(() => []);
const candidates = [];
for (const entry of entries) {
if (!entry.isFile() || !/^apply-.*\.status\.json$/i.test(entry.name)) continue;
const filePath = path.join(this.updateDirectory, entry.name);
const stat = await fs.stat(filePath).catch(() => null);
if (stat) candidates.push({ filePath, mtimeMs: stat.mtimeMs });
}
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
for (const candidate of candidates) {
const status = await readJsonFile(candidate.filePath);
if (!status || status.acknowledgedAt || !['success', 'rolled-back', 'failed'].includes(status.state)) continue;
status.acknowledgedAt = new Date().toISOString();
await fs.writeFile(candidate.filePath, JSON.stringify(status, null, 2), { mode: 0o600 });
return {
state: status.state,
expectedVersion: status.expectedVersion || null,
installedVersion: status.installedVersion || null,
message: status.message || '',
logPath: status.logPath || null,
restartLaunched: Boolean(status.restartLaunched),
completedAt: status.completedAt || status.updatedAt || null
};
}
return null;
} }
} }
module.exports = { UpdateService, safeRepositoryPart }; module.exports = {
UpdateService,
safeRepositoryPart,
resolveWindowsPowerShellPath,
waitForUpdaterStarted,
readJsonFile
};
+224 -27
View File
@@ -44,7 +44,8 @@ const icons = {
download: '<path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M4 21h16"/>', download: '<path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M4 21h16"/>',
server: '<rect x="3" y="4" width="18" height="6" rx="2"/><rect x="3" y="14" width="18" height="6" rx="2"/><path d="M7 7h.01M7 17h.01"/>', server: '<rect x="3" y="4" width="18" height="6" rx="2"/><rect x="3" y="14" width="18" height="6" rx="2"/><path d="M7 7h.01M7 17h.01"/>',
key: '<circle cx="8" cy="15" r="4"/><path d="m11 12 9-9M16 7l2 2M14 9l2 2"/>', key: '<circle cx="8" cy="15" r="4"/><path d="m11 12 9-9M16 7l2 2M14 9l2 2"/>',
update: '<path d="M21 12a9 9 0 0 1-15.3 6.4L3 16"/><path d="M3 21v-5h5"/><path d="M3 12A9 9 0 0 1 18.3 5.6L21 8"/><path d="M21 3v5h-5"/>' update: '<path d="M21 12a9 9 0 0 1-15.3 6.4L3 16"/><path d="M3 21v-5h5"/><path d="M3 12A9 9 0 0 1 18.3 5.6L21 8"/><path d="M21 3v5h-5"/>',
wrench: '<path d="M14.7 6.3a4 4 0 0 0-5-5l2.1 2.1-2.8 2.8-2.1-2.1a4 4 0 0 0 5 5L20 17.2 17.2 20l-8.1-8.1a4 4 0 0 0-5-5l2.1 2.1-2.8 2.8-2.1-2.1a4 4 0 0 0 5 5"/>'
}; };
function icon(name, className = '') { function icon(name, className = '') {
@@ -116,6 +117,7 @@ const ui = {
setupDraft: { baseUrl: 'https://', token: '', user: null, roots: [], discovered: [] }, setupDraft: { baseUrl: 'https://', token: '', user: null, roots: [], discovered: [] },
setupValidation: null, setupValidation: null,
activeDeployment: null, activeDeployment: null,
operationPollTimer: null,
isMock: false, isMock: false,
refreshError: null, refreshError: null,
autoRefreshPending: false, autoRefreshPending: false,
@@ -123,7 +125,8 @@ const ui = {
updateStatus: null, updateStatus: null,
updateChecking: false, updateChecking: false,
servers: [], servers: [],
serverInspection: null serverInspection: null,
gitRecovery: null
}; };
function selectedRepository() { return ui.repositories.find((repository) => String(repository.id) === String(ui.selectedRepoId)) || null; } function selectedRepository() { return ui.repositories.find((repository) => String(repository.id) === String(ui.selectedRepoId)) || null; }
@@ -157,6 +160,30 @@ function updateOperationInState(operation) {
if (ui.activeDeployment?.id === operation.id) ui.activeDeployment = operation; if (ui.activeDeployment?.id === operation.id) ui.activeDeployment = operation;
} }
function stopOperationPolling() {
if (ui.operationPollTimer) clearTimeout(ui.operationPollTimer);
ui.operationPollTimer = null;
}
function startOperationPolling() {
stopOperationPolling();
const operationId = ui.activeDeployment?.id;
if (!operationId || isTerminalOperation(ui.activeDeployment.status)) return;
const seconds = Math.max(2, Number(ui.boot?.state?.preferences?.operationPollSeconds) || 3);
ui.operationPollTimer = setTimeout(async () => {
try {
const operation = await window.forgeflow.refreshOperations(operationId);
if (operation) updateOperationInState(operation);
render();
if (operation && !isTerminalOperation(operation.status)) startOperationPolling();
else stopOperationPolling();
} catch (error) {
showToast('Deployment status refresh failed', error.message, 'error');
stopOperationPolling();
}
}, seconds * 1000);
}
async function bootstrap() { async function bootstrap() {
try { try {
ui.boot = await window.forgeflow.bootstrap(); ui.boot = await window.forgeflow.bootstrap();
@@ -166,11 +193,14 @@ async function bootstrap() {
ui.setupDraft.roots = [...(ui.boot.state.workspaceRoots || [])]; ui.setupDraft.roots = [...(ui.boot.state.workspaceRoots || [])];
if (ui.boot.state.setupComplete) { if (ui.boot.state.setupComplete) {
await refreshRepositories(false); await refreshRepositories(false);
await refreshActiveOperations(false); const reconciled = await refreshActiveOperations(false);
if ((Array.isArray(reconciled) ? reconciled : []).some((operation) => isTerminalOperation(operation.status))) await refreshRepositories(false);
} }
window.forgeflow.onRepositoriesChanged?.(() => scheduleAutoRefresh()); window.forgeflow.onRepositoriesChanged?.(() => scheduleAutoRefresh());
window.forgeflow.onOperationsChanged?.((payload) => { window.forgeflow.onOperationsChanged?.((payload) => {
for (const operation of payload?.operations || []) updateOperationInState(operation); const changed = payload?.operations || [];
for (const operation of changed) updateOperationInState(operation);
if (changed.some((operation) => isTerminalOperation(operation.status))) scheduleAutoRefresh(250);
render(); render();
}); });
window.forgeflow.onUpdatesChanged?.((payload) => { window.forgeflow.onUpdatesChanged?.((payload) => {
@@ -179,6 +209,16 @@ async function bootstrap() {
if (payload?.available) showToast('ForgeFlow update available', `Version ${payload.remoteVersion} is ready to download.`, 'success'); if (payload?.available) showToast('ForgeFlow update available', `Version ${payload.remoteVersion} is ready to download.`, 'success');
}); });
render(); render();
const updateResult = ui.boot.updateResult;
if (updateResult?.state === 'success') {
const restartNote = updateResult.restartLaunched ? '' : ' Automatic restart was unavailable, but the update itself succeeded.';
showToast('ForgeFlow updated successfully', `Version ${updateResult.installedVersion || updateResult.expectedVersion || ui.boot.appVersion} is installed.${restartNote}`, 'success');
} else if (updateResult?.state === 'rolled-back') {
showToast('ForgeFlow update rolled back', updateResult.message || 'The update failed and the previous version was restored.', 'error');
} else if (updateResult?.state === 'failed') {
showToast('ForgeFlow update failed', updateResult.message || 'See the update log for technical details.', 'error');
}
setTimeout(() => { void refreshDeploymentTruth(false); }, 500);
} catch (error) { } catch (error) {
app.innerHTML = `<div class="boot-screen">${icon('error')}<strong>ForgeFlow could not start</strong><span>${escapeHtml(error.message)}</span></div>`; app.innerHTML = `<div class="boot-screen">${icon('error')}<strong>ForgeFlow could not start</strong><span>${escapeHtml(error.message)}</span></div>`;
} }
@@ -225,11 +265,37 @@ async function refreshActiveOperations(showErrors = true) {
for (const operation of Array.isArray(updated) ? updated : []) updateOperationInState(operation); for (const operation of Array.isArray(updated) ? updated : []) updateOperationInState(operation);
return updated; return updated;
} catch (error) { } catch (error) {
if (showErrors) showToast('Actions status unavailable', error.message, 'error'); if (showErrors) showToast('Deployment status unavailable', error.message, 'error');
return []; return [];
} }
} }
async function refreshDeploymentTruth(showErrors = false) {
const targets = ui.repositories.flatMap((repository) =>
(repository.deploymentProfiles || []).map((profile) => ({ repository, profile }))
);
if (!targets.length) return { checked: 0, failed: 0 };
const failures = [];
const queue = [...targets];
const workers = Array.from({ length: Math.min(3, queue.length) }, async () => {
while (queue.length) {
const target = queue.shift();
try {
target.profile.state = await window.forgeflow.refreshProfileState(target.repository.fullName, target.profile.id);
} catch (error) {
failures.push({ repository: target.repository.fullName, profile: target.profile.name, message: error.message });
}
}
});
await Promise.all(workers);
await refreshRepositories(false);
if (showErrors && failures.length) {
showToast('Some environments could not be checked', `${failures.length} profile${failures.length === 1 ? '' : 's'} could not be refreshed. Open Deployments for details.`, 'error');
}
return { checked: targets.length, failed: failures.length };
}
function selectRepository(id, shouldRender = true) { function selectRepository(id, shouldRender = true) {
ui.selectedRepoId = id; ui.selectedRepoId = id;
ui.currentView = 'repository'; ui.currentView = 'repository';
@@ -238,6 +304,7 @@ function selectRepository(id, shouldRender = true) {
ui.history = []; ui.history = [];
ui.branches = []; ui.branches = [];
ui.stashes = []; ui.stashes = [];
ui.gitRecovery = null;
const repository = selectedRepository(); const repository = selectedRepository();
ui.selectedProfileId = selectedProfile(repository)?.id || null; ui.selectedProfileId = selectedProfile(repository)?.id || null;
const files = repository?.localStatus?.files || []; const files = repository?.localStatus?.files || [];
@@ -264,7 +331,7 @@ function repositoryAction(repository) {
if (!status) return { kind: 'error', title: 'Local repository unavailable', detail: repository.attentionReason || 'The linked folder could not be read.' }; if (!status) return { kind: 'error', title: 'Local repository unavailable', detail: repository.attentionReason || 'The linked folder could not be read.' };
if (status.counts.conflicts) return { kind: 'conflict', title: 'Resolve merge conflicts', detail: `${status.counts.conflicts} conflicted file${status.counts.conflicts === 1 ? '' : 's'} block synchronization.` }; if (status.counts.conflicts) return { kind: 'conflict', title: 'Resolve merge conflicts', detail: `${status.counts.conflicts} conflicted file${status.counts.conflicts === 1 ? '' : 's'} block synchronization.` };
if (status.counts.changed) return { kind: 'commit', title: 'Commit local changes', detail: `${status.counts.changed} changed file${status.counts.changed === 1 ? '' : 's'} detected.` }; if (status.counts.changed) return { kind: 'commit', title: 'Commit local changes', detail: `${status.counts.changed} changed file${status.counts.changed === 1 ? '' : 's'} detected.` };
if (status.branch.behind && status.branch.ahead) return { kind: 'diverged', title: 'Branches have diverged', detail: `Local is ${status.branch.ahead} ahead and ${status.branch.behind} behind. Resolve this in your Git tooling.` }; if (status.branch.behind && status.branch.ahead) return { kind: 'diverged', title: 'Branches have diverged', detail: `Local is ${status.branch.ahead} ahead and ${status.branch.behind} behind. ForgeFlow can create a safety branch and repair this from Git tools.` };
if (status.branch.behind) return { kind: 'pull', title: 'Synchronize from Gitea', detail: `Local ${status.branch.head} is ${status.branch.behind} commit${status.branch.behind === 1 ? '' : 's'} behind.` }; if (status.branch.behind) return { kind: 'pull', title: 'Synchronize from Gitea', detail: `Local ${status.branch.head} is ${status.branch.behind} commit${status.branch.behind === 1 ? '' : 's'} behind.` };
if (status.branch.ahead) return { kind: 'push', title: 'Push local commits', detail: `${status.branch.ahead} commit${status.branch.ahead === 1 ? '' : 's'} ready to push.` }; if (status.branch.ahead) return { kind: 'push', title: 'Push local commits', detail: `${status.branch.ahead} commit${status.branch.ahead === 1 ? '' : 's'} ready to push.` };
if (!repository.deploymentProfiles?.length) return { kind: 'configure', title: 'Configure deployment', detail: 'Connect a predefined Gitea Actions workflow before deploying.' }; if (!repository.deploymentProfiles?.length) return { kind: 'configure', title: 'Configure deployment', detail: 'Connect a predefined Gitea Actions workflow before deploying.' };
@@ -428,6 +495,23 @@ function environmentState(profile) {
return { label: 'Status not configured', tone: '' }; return { label: 'Status not configured', tone: '' };
} }
function dockerManIntegration(profile) {
const state = profile.state || {};
const iconMode = profile.iconMode || (profile.iconFilePath ? 'upload' : profile.iconUrl ? 'url' : 'builtin');
const webUiExpected = Boolean(profile.webUiUrl || profile.hostPort);
const iconExpected = iconMode !== 'none';
const templateReady = Boolean(state.dockerMan?.templateExists);
const webUiReady = !webUiExpected || Boolean(state.dockerMan?.webUi) || templateReady;
const iconReady = !iconExpected || Boolean(state.dockerMan?.icon) || templateReady;
return {
iconMode,
templateReady,
webUiReady,
iconReady,
ready: Boolean(state.containerRunning && webUiReady && iconReady)
};
}
function renderProfileCard(repository, profile, compact = false) { function renderProfileCard(repository, profile, compact = false) {
const state = profile.state || {}; const state = profile.state || {};
const health = environmentState(profile); const health = environmentState(profile);
@@ -437,7 +521,11 @@ function renderProfileCard(repository, profile, compact = false) {
? `SSH / Unraid · ${profile.remoteFolder || repository.name} · ${profile.branch}` ? `SSH / Unraid · ${profile.remoteFolder || repository.name} · ${profile.branch}`
: `${profile.workflowFile} · ${profile.branch}`; : `${profile.workflowFile} · ${profile.branch}`;
const rollbackConfigured = isSsh || Boolean(profile.rollbackWorkflowFile); const rollbackConfigured = isSsh || Boolean(profile.rollbackWorkflowFile);
return `<article class="deploy-card ${compact ? 'compact-card' : ''}"><div class="deploy-card-header"><div><div class="eyebrow">${escapeHtml(profile.environment)}</div><h3>${escapeHtml(profile.name)}</h3><p>${escapeHtml(providerDetail)}</p></div><span class="status-pill ${health.tone}"><span class="state-dot ${health.tone}"></span>${health.label}</span></div><div class="deploy-card-body"><div class="deploy-metadata"><span>Provider</span><strong>${isSsh ? 'SSH / Unraid' : 'Gitea Actions'}</strong><span>Live version</span><strong>${state.liveSha ? shortSha(state.liveSha) : 'Unknown'}</strong><span>Previous version</span><strong>${state.previousSha ? shortSha(state.previousSha) : 'Unknown'}</strong><span>Last checked</span><strong>${state.checkedAt ? formatDate(state.checkedAt) : 'Never'}</strong><span>Rollback</span><strong>${rollbackConfigured ? 'Available after first deploy' : 'Not configured'}</strong></div><div class="card-actions"><button class="button" data-action="run-deployment-preflight" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon('shield')}Preflight</button><button class="button" data-action="refresh-profile-state" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon('pulse')}Check state</button>${ready ? `<button class="button primary" data-action="deploy-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon('rocket')}Deploy ${escapeHtml(repository.localStatus.shortHead)}</button>` : ''}<button class="button ghost" data-action="edit-deployment-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">Edit</button>${state.previousSha && rollbackConfigured ? `<button class="button danger" data-action="rollback-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon('undo')}Rollback</button>` : ''}</div></div></article>`; const dockerMan = dockerManIntegration(profile);
const { templateReady, webUiReady, iconReady } = dockerMan;
const dockerManReady = dockerMan.ready;
const webUi = profile.webUiUrl || state.webUiUrl || state.dockerMan?.webUi || '';
return `<article class="deploy-card ${compact ? 'compact-card' : ''}"><div class="deploy-card-header"><div><div class="eyebrow">${escapeHtml(profile.environment)}</div><h3>${escapeHtml(profile.name)}</h3><p>${escapeHtml(providerDetail)}</p></div><span class="status-pill ${health.tone}"><span class="state-dot ${health.tone}"></span>${health.label}</span></div><div class="deploy-card-body"><div class="deploy-metadata"><span>Provider</span><strong>${isSsh ? 'SSH / Unraid' : 'Gitea Actions'}</strong><span>Live version</span><strong>${state.liveSha ? shortSha(state.liveSha) : 'Unknown'}</strong><span>Previous version</span><strong>${state.previousSha ? shortSha(state.previousSha) : 'Unknown'}</strong><span>Last checked</span><strong>${state.checkedAt ? formatDate(state.checkedAt) : 'Never'}</strong>${isSsh ? `<span>Container</span><strong>${escapeHtml(state.containerName || profile.containerName || profile.remoteFolder || repository.name)}${state.containerRunning === false ? ' · stopped' : state.containerRunning ? ' · running' : ''}</strong><span>DockerMan</span><strong class="${dockerManReady ? 'text-success' : 'text-warning'}">${dockerManReady ? (templateReady ? 'Labels/template active' : 'WebUI/icon labels active') : `WebUI ${webUiReady ? 'ready' : 'missing'} · icon ${iconReady ? 'ready' : 'missing'}`}</strong>` : ''}<span>Rollback</span><strong>${rollbackConfigured ? 'Available after first deploy' : 'Not configured'}</strong></div><div class="card-actions"><button class="button" data-action="run-deployment-preflight" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon('shield')}Preflight</button><button class="button" data-action="reconcile-deployment" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon('refresh')}Reconcile</button>${webUi ? `<button class="button" data-action="open-profile-webui" data-url="${attr(webUi)}">${icon('external')}Open Web UI</button>` : ''}${isSsh ? `<button class="button ${dockerManReady ? 'ghost' : ''}" data-action="apply-dockerman-metadata" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon('wrench')}${dockerManReady ? 'Reapply DockerMan metadata' : 'Repair DockerMan integration'}</button>` : ''}${ready ? `<button class="button primary" data-action="deploy-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon('rocket')}Deploy ${escapeHtml(repository.localStatus.shortHead)}</button>` : ''}<button class="button ghost" data-action="edit-deployment-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">Edit</button>${state.previousSha && rollbackConfigured ? `<button class="button danger" data-action="rollback-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon('undo')}Rollback</button>` : ''}</div></div></article>`;
} }
function renderRepositoryDeployments(repository) { function renderRepositoryDeployments(repository) {
@@ -448,7 +536,11 @@ function renderRepositoryDeployments(repository) {
function renderGitTools(repository) { function renderGitTools(repository) {
if (!repository.localPath) return '<div class="empty-state full"><p>Link a local repository to manage branches and stashes.</p></div>'; if (!repository.localPath) return '<div class="empty-state full"><p>Link a local repository to manage branches and stashes.</p></div>';
return `<div class="tab-page git-tools-grid"><section class="panel"><div class="panel-header"><h2>Branches</h2><button class="button ghost" data-action="load-git-tools">${icon('refresh')}Refresh</button></div><div class="panel-body"><div class="inline-form"><input id="new-branch-name" class="input" placeholder="feature/name"/><button class="button" data-action="create-branch">${icon('plus')}Create & switch</button></div><div class="tool-list">${ui.branches.length ? ui.branches.map((branch) => `<div class="tool-row"><div><strong>${escapeHtml(branch.name)}</strong><span>${escapeHtml(branch.shortSha)}${branch.upstream ? ` · ${escapeHtml(branch.upstream)}` : ' · unpublished'}</span></div>${branch.current ? '<span class="status-pill success">Current</span>' : `<button class="button" data-action="checkout-branch" data-branch="${attr(branch.name)}">Switch</button>`}</div>`).join('') : '<div class="empty-state compact"><p>Load branch information.</p></div>'}</div></div></section><section class="panel"><div class="panel-header"><h2>Stashes</h2><button class="button" data-action="stash-changes" ${repository.localStatus?.clean ? 'disabled' : ''}>${icon('archive')}Stash changes</button></div><div class="panel-body"><div class="tool-list">${ui.stashes.length ? ui.stashes.map((stash) => `<div class="tool-row"><div><strong>${escapeHtml(stash.ref)}</strong><span>${escapeHtml(stash.subject)} · ${formatDate(stash.date)}</span></div><button class="button" data-action="pop-stash" data-stash-ref="${attr(stash.ref)}">Apply & drop</button></div>`).join('') : '<div class="empty-state compact"><p>No stashes, or Git tools have not been loaded.</p></div>'}</div></div></section></div>`; const recovery = ui.gitRecovery;
const locks = recovery?.lockReport?.locks || [];
const activeProcesses = recovery?.lockReport?.processes?.active || [];
const recommendations = recovery?.recommendations || [];
return `<div class="tab-page git-tools-grid"><section class="panel"><div class="panel-header"><h2>Branches</h2><button class="button ghost" data-action="load-git-tools">${icon('refresh')}Refresh</button></div><div class="panel-body"><div class="inline-form"><input id="new-branch-name" class="input" placeholder="feature/name"/><button class="button" data-action="create-branch">${icon('plus')}Create & switch</button></div><div class="tool-list">${ui.branches.length ? ui.branches.map((branch) => `<div class="tool-row"><div><strong>${escapeHtml(branch.name)}</strong><span>${escapeHtml(branch.shortSha)}${branch.upstream ? ` · ${escapeHtml(branch.upstream)}` : ' · unpublished'}</span></div>${branch.current ? '<span class="status-pill success">Current</span>' : `<button class="button" data-action="checkout-branch" data-branch="${attr(branch.name)}">Switch</button>`}</div>`).join('') : '<div class="empty-state compact"><p>Load branch information.</p></div>'}</div></div></section><section class="panel"><div class="panel-header"><h2>Stashes</h2><button class="button" data-action="stash-changes" ${repository.localStatus?.clean ? 'disabled' : ''}>${icon('archive')}Stash changes</button></div><div class="panel-body"><div class="tool-list">${ui.stashes.length ? ui.stashes.map((stash) => `<div class="tool-row"><div><strong>${escapeHtml(stash.ref)}</strong><span>${escapeHtml(stash.subject)} · ${formatDate(stash.date)}</span></div><button class="button" data-action="pop-stash" data-stash-ref="${attr(stash.ref)}">Apply & drop</button></div>`).join('') : '<div class="empty-state compact"><p>No stashes, or Git tools have not been loaded.</p></div>'}</div></div></section><section class="panel troubleshooting-panel"><div class="panel-header"><div><h2>Repository troubleshooting</h2><span class="meta">Safe, repository-specific recovery actions</span></div><button class="button primary" data-action="scan-git-recovery">${icon('pulse')}Scan</button></div><div class="panel-body">${recovery ? `<div class="troubleshooting-summary"><span class="status-pill ${locks.length ? 'warning' : 'success'}">${locks.length ? `${locks.length} lock${locks.length === 1 ? '' : 's'}` : 'No Git locks'}</span><span>${activeProcesses.length ? `${activeProcesses.length} active Git process(es)` : 'No matching active Git process detected'}</span></div>${locks.length ? `<div class="tool-list">${locks.map((lock) => `<div class="tool-row"><div><strong>${escapeHtml(lock.name)}</strong><span>${Math.round(lock.ageMs / 1000)}s old · ${escapeHtml(lock.modifiedAt)}</span></div></div>`).join('')}</div>` : ''}${recommendations.length ? `<div class="tool-list recovery-actions">${recommendations.map((item) => `<div class="tool-row"><div><strong>${escapeHtml(item.label)}</strong><span>${item.safe ? 'Safe automated action' : item.action ? 'Creates a safety branch before changing history' : 'Review required'}</span></div>${item.action ? `<button class="button ${item.safe ? '' : 'danger'}" data-action="repair-repository-sync" data-strategy="${attr(item.action)}">Run</button>` : ''}</div>`).join('')}</div>` : ''}` : '<div class="empty-state compact"><p>Scan before repairing. ForgeFlow checks every .lock file in the actual Git directory, not only index.lock.</p></div>'}<div class="card-actions"><button class="button" data-action="repair-git-locks">${icon('wrench')}Repair proven stale locks</button><button class="button" data-action="reconcile-repository">${icon('refresh')}Refresh Git state</button>${repository.sshUrl && repository.localStatus?.remoteUrl !== repository.sshUrl ? `<button class="button" data-action="repair-origin">${icon('link')}Repair origin</button>` : ''}</div><div class="notice warning">Lock repair refuses to run while a matching Git process is active. A force option is shown only when process detection itself is unavailable.</div></div></section></div>`;
} }
function renderRepositorySettings(repository) { function renderRepositorySettings(repository) {
@@ -456,7 +548,7 @@ function renderRepositorySettings(repository) {
const currentOrigin = repository.localStatus?.remoteUrl || 'Unavailable'; const currentOrigin = repository.localStatus?.remoteUrl || 'Unavailable';
const desiredOrigin = repository.sshUrl || repository.preferredCloneUrl || ''; const desiredOrigin = repository.sshUrl || repository.preferredCloneUrl || '';
const originNeedsRepair = Boolean(repository.localPath && desiredOrigin && currentOrigin !== desiredOrigin); 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>`; return `<div class="tab-page"><section class="settings-group"><h2>Repository identity</h2><div class="form-grid"><div class="field full"><label>Gitea repository</label><input class="input" value="${attr(repository.fullName)}" readonly/></div><div class="field full"><label>Local working tree</label><input class="input mono" value="${attr(repository.localPath || automaticTarget || 'Not linked')}" readonly/></div><div class="field full"><label>Current origin</label><input class="input mono" value="${attr(currentOrigin)}" readonly/></div>${desiredOrigin ? `<div class="field full"><label>Current Gitea SSH origin</label><input class="input mono" value="${attr(desiredOrigin)}" readonly/></div>` : ''}</div><div class="card-actions"><button class="button" data-action="${repository.localPath ? 'open-path' : 'link-repo'}">${icon('folder')}${repository.localPath ? 'Open project folder' : 'Link local folder'}</button>${originNeedsRepair ? `<button class="button primary" data-action="repair-origin">${icon('link')}Use current Gitea origin</button>` : ''}${repository.localPath ? `<button class="button" data-action="scan-git-recovery">${icon('pulse')}Scan Git health</button><button class="button danger" data-action="unlink-repo">${icon('link')}Remove link</button>` : `<button class="button primary" data-action="clone-repo">${icon('cloud')}${escapeHtml(clonePrimaryLabel(repository))}</button><button class="button ghost" data-action="clone-repo-custom">Choose another location</button>`}</div></section><section class="settings-group"><h2>Repository behavior</h2><div class="notice">${icon('shield')}Origin repair changes only the Git remote URL. Git health scans the actual Git directory, repairs only proven stale lock files and never changes source files or commits.</div></section></div>`;
} }
function renderRepositoryWorkspace(repository) { function renderRepositoryWorkspace(repository) {
@@ -488,7 +580,7 @@ function renderActionPanel(repository) {
} }
else if (action.kind === 'pull') body = `<div class="panel-callout"><div class="callout-icon warning">${icon('arrowDown')}</div><h2>${action.title}</h2><p>${action.detail}</p><button class="button primary block" data-action="pull">Fast-forward from Gitea</button></div>`; else if (action.kind === 'pull') body = `<div class="panel-callout"><div class="callout-icon warning">${icon('arrowDown')}</div><h2>${action.title}</h2><p>${action.detail}</p><button class="button primary block" data-action="pull">Fast-forward from Gitea</button></div>`;
else if (action.kind === 'push') body = `<div class="panel-callout"><div class="callout-icon">${icon('arrowUp')}</div><h2>${action.title}</h2><p>${action.detail}</p><button class="button primary block" data-action="push">Push ${status.branch.ahead} commit${status.branch.ahead === 1 ? '' : 's'}</button></div>`; else if (action.kind === 'push') body = `<div class="panel-callout"><div class="callout-icon">${icon('arrowUp')}</div><h2>${action.title}</h2><p>${action.detail}</p><button class="button primary block" data-action="push">Push ${status.branch.ahead} commit${status.branch.ahead === 1 ? '' : 's'}</button></div>`;
else if (action.kind === 'diverged' || action.kind === 'conflict' || action.kind === 'error') body = `<div class="panel-callout"><div class="callout-icon danger">${icon('error')}</div><h2>${action.title}</h2><p>${action.detail}</p><button class="button block" data-action="open-path">Open project folder</button><button class="button block" data-action="refresh">Refresh status</button></div>`; else if (action.kind === 'diverged' || action.kind === 'conflict' || action.kind === 'error') body = `<div class="panel-callout"><div class="callout-icon danger">${icon('error')}</div><h2>${action.title}</h2><p>${action.detail}</p>${action.kind === 'diverged' ? `<button class="button primary block" data-action="load-git-tools">${icon('wrench')}Open guided repository repair</button>` : ''}<button class="button block" style="margin-top:8px" data-action="open-path">Open project folder</button><button class="button block" style="margin-top:8px" data-action="refresh">Refresh status</button></div>`;
else if (action.kind === 'configure') body = `<div class="panel-callout"><div class="callout-icon">${icon('settings')}</div><h2>${action.title}</h2><p>${action.detail}</p><button class="button primary block" data-action="configure-deployment">Configure first environment</button></div>`; else if (action.kind === 'configure') body = `<div class="panel-callout"><div class="callout-icon">${icon('settings')}</div><h2>${action.title}</h2><p>${action.detail}</p><button class="button primary block" data-action="configure-deployment">Configure first environment</button></div>`;
else if (action.kind === 'branch-profile') body = `<div class="panel-callout"><div class="callout-icon">${icon('branch')}</div><h2>${action.title}</h2><p>${action.detail}</p>${repository.deploymentProfiles.length > 1 ? `<label class="field-label">Deployment profile</label><select id="action-profile-select" class="select">${repository.deploymentProfiles.map((item) => `<option value="${attr(item.id)}" ${item.id === profile?.id ? 'selected' : ''}>${escapeHtml(item.name)} · ${escapeHtml(item.branch)}</option>`).join('')}</select>` : ''}<button class="button block" style="margin-top:8px" data-action="edit-deployment-profile" data-profile-id="${attr(profile?.id || '')}">Edit profile</button></div>`; else if (action.kind === 'branch-profile') body = `<div class="panel-callout"><div class="callout-icon">${icon('branch')}</div><h2>${action.title}</h2><p>${action.detail}</p>${repository.deploymentProfiles.length > 1 ? `<label class="field-label">Deployment profile</label><select id="action-profile-select" class="select">${repository.deploymentProfiles.map((item) => `<option value="${attr(item.id)}" ${item.id === profile?.id ? 'selected' : ''}>${escapeHtml(item.name)} · ${escapeHtml(item.branch)}</option>`).join('')}</select>` : ''}<button class="button block" style="margin-top:8px" data-action="edit-deployment-profile" data-profile-id="${attr(profile?.id || '')}">Edit profile</button></div>`;
else if (action.kind === 'deploy') body = `<div class="panel-callout"><div class="callout-icon success">${icon('rocket')}</div><h2>Release ${escapeHtml(status.shortHead)}</h2><p>${escapeHtml(profile.name)} will deploy the exact commit from ${escapeHtml(profile.branch)} to ${escapeHtml(profile.environment)}.</p>${repository.deploymentProfiles.length > 1 ? `<label class="field-label">Environment</label><select id="action-profile-select" class="select">${repository.deploymentProfiles.map((item) => `<option value="${attr(item.id)}" ${item.id === profile.id ? 'selected' : ''}>${escapeHtml(item.name)} · ${escapeHtml(item.environment)}</option>`).join('')}</select>` : ''}<div class="deploy-proof"><span>Local</span><strong>${escapeHtml(status.shortHead)}</strong><span>Gitea</span><strong>${escapeHtml(status.shortHead)}</strong><span>Target</span><strong>${escapeHtml(profile.environment)}</strong></div><button class="button success block" data-action="deploy-profile" data-profile-id="${attr(profile.id)}">${icon('rocket')}Deploy ${escapeHtml(status.shortHead)}${escapeHtml(profile.environment)}</button>${profile.state?.previousSha && profile.rollbackWorkflowFile ? `<button class="button danger block" style="margin-top:8px" data-action="rollback-profile" data-profile-id="${attr(profile.id)}">${icon('undo')}Rollback to ${shortSha(profile.state.previousSha)}</button>` : ''}</div>`; else if (action.kind === 'deploy') body = `<div class="panel-callout"><div class="callout-icon success">${icon('rocket')}</div><h2>Release ${escapeHtml(status.shortHead)}</h2><p>${escapeHtml(profile.name)} will deploy the exact commit from ${escapeHtml(profile.branch)} to ${escapeHtml(profile.environment)}.</p>${repository.deploymentProfiles.length > 1 ? `<label class="field-label">Environment</label><select id="action-profile-select" class="select">${repository.deploymentProfiles.map((item) => `<option value="${attr(item.id)}" ${item.id === profile.id ? 'selected' : ''}>${escapeHtml(item.name)} · ${escapeHtml(item.environment)}</option>`).join('')}</select>` : ''}<div class="deploy-proof"><span>Local</span><strong>${escapeHtml(status.shortHead)}</strong><span>Gitea</span><strong>${escapeHtml(status.shortHead)}</strong><span>Target</span><strong>${escapeHtml(profile.environment)}</strong></div><button class="button success block" data-action="deploy-profile" data-profile-id="${attr(profile.id)}">${icon('rocket')}Deploy ${escapeHtml(status.shortHead)}${escapeHtml(profile.environment)}</button>${profile.state?.previousSha && profile.rollbackWorkflowFile ? `<button class="button danger block" style="margin-top:8px" data-action="rollback-profile" data-profile-id="${attr(profile.id)}">${icon('undo')}Rollback to ${shortSha(profile.state.previousSha)}</button>` : ''}</div>`;
@@ -499,7 +591,8 @@ function renderActionPanel(repository) {
function renderDeployments() { function renderDeployments() {
const cards = ui.repositories.flatMap((repository) => (repository.deploymentProfiles || []).map((profile) => ({ repository, profile }))); const cards = ui.repositories.flatMap((repository) => (repository.deploymentProfiles || []).map((profile) => ({ repository, profile })));
const active = operations().filter((operation) => !isTerminalOperation(operation.status)); const active = operations().filter((operation) => !isTerminalOperation(operation.status));
return `<div class="page"><div class="page-header"><div><div class="eyebrow">Server releases</div><h1>Deployments</h1><p>Exact commits, predefined workflows, authoritative Actions status and server-side version checks.</p></div><button class="button" data-action="refresh-operations">${icon('refresh')}Refresh runs</button></div>${active.length ? `<div class="notice warning">${icon('pulse')} ${active.length} deployment operation${active.length === 1 ? ' is' : 's are'} still active.</div>` : ''}<div class="deploy-card-grid">${cards.length ? cards.map(({ repository, profile }) => renderProfileCard(repository, profile, true)).join('') : '<div class="empty-state panel"><h3>No deployment environments configured</h3><p>Open a repository and add an environment.</p></div>'}</div><section class="section-block"><div class="section-heading"><h2>All operations</h2><span class="meta">Newest first</span></div><div class="panel">${operations().length ? `<table class="data-table"><thead><tr><th>Repository</th><th>Action</th><th>Environment</th><th>Commit</th><th>Status</th><th>Updated</th><th></th></tr></thead><tbody>${operations().map((operation) => `<tr><td>${escapeHtml(operation.repository)}</td><td>${escapeHtml(operation.action || 'deploy')}</td><td>${escapeHtml(operation.environment || '—')}</td><td class="mono">${escapeHtml(operation.shortSha || shortSha(operation.sha))}</td><td><span class="status-pill ${toneForStatus(operation.status)}">${escapeHtml(operation.status)}</span></td><td>${formatDate(operation.updatedAt || operation.createdAt)}</td><td><button class="button ghost" data-action="open-operation" data-operation-id="${attr(operation.id)}">Open</button></td></tr>`).join('')}</tbody></table>` : '<div class="empty-state compact"><p>No operations recorded.</p></div>'}</div></section></div>`; const missingDockerMan = cards.filter(({ profile }) => profile.provider === 'ssh-unraid' && profile.state?.containerRunning && !dockerManIntegration(profile).ready);
return `<div class="page"><div class="page-header"><div><div class="eyebrow">Server releases</div><h1>Deployments</h1><p>Exact commits, live container truth, DockerMan integration and controlled release recovery.</p></div><div class="stack horizontal compact"><button class="button" data-action="refresh-operations">${icon('refresh')}Refresh runs & servers</button>${missingDockerMan.length ? `<button class="button primary" data-action="repair-missing-dockerman">${icon('wrench')}Repair ${missingDockerMan.length} missing integration${missingDockerMan.length === 1 ? '' : 's'}</button>` : ''}</div></div>${active.length ? `<div class="notice warning">${icon('pulse')} ${active.length} deployment operation${active.length === 1 ? ' is' : 's are'} still active. ForgeFlow reconciles these against the live server automatically.</div>` : ''}<div class="deploy-card-grid">${cards.length ? cards.map(({ repository, profile }) => renderProfileCard(repository, profile, true)).join('') : '<div class="empty-state panel"><h3>No deployment environments configured</h3><p>Open a repository and add an environment.</p></div>'}</div><section class="section-block"><div class="section-heading"><h2>All operations</h2><span class="meta">Newest first</span></div><div class="panel">${operations().length ? `<table class="data-table"><thead><tr><th>Repository</th><th>Action</th><th>Environment</th><th>Commit</th><th>Status</th><th>Updated</th><th></th></tr></thead><tbody>${operations().map((operation) => `<tr><td>${escapeHtml(operation.repository)}</td><td>${escapeHtml(operation.action || 'deploy')}</td><td>${escapeHtml(operation.environment || '—')}</td><td class="mono">${escapeHtml(operation.shortSha || shortSha(operation.sha))}</td><td><span class="status-pill ${toneForStatus(operation.status)}">${escapeHtml(operation.status)}</span></td><td>${formatDate(operation.updatedAt || operation.createdAt)}</td><td><button class="button ghost" data-action="open-operation" data-operation-id="${attr(operation.id)}">Open</button></td></tr>`).join('')}</tbody></table>` : '<div class="empty-state compact"><p>No operations recorded.</p></div>'}</div></section></div>`;
} }
function renderSettings() { function renderSettings() {
@@ -576,7 +669,7 @@ function renderSetup() {
: ui.setupStep === 1 ? '<button class="button primary" data-action="setup-validate">Validate & continue</button>' : ui.setupStep === 1 ? '<button class="button primary" data-action="setup-validate">Validate & continue</button>'
: ui.setupStep === 2 ? `<button class="button primary" data-action="setup-next" ${ui.setupDraft.roots.length ? '' : 'disabled'}>Scan folders</button>` : ui.setupStep === 2 ? `<button class="button primary" data-action="setup-next" ${ui.setupDraft.roots.length ? '' : 'disabled'}>Scan folders</button>`
: ui.setupStep === 4 ? '<button class="button primary" data-action="setup-finish">Enter ForgeFlow</button>' : ''; : ui.setupStep === 4 ? '<button class="button primary" data-action="setup-finish">Enter ForgeFlow</button>' : '';
return `<div class="setup-backdrop"><section class="setup-window"><aside class="setup-sidebar"><img class="setup-brand-logo" src="./assets/itworx-wordmark.png" alt="ITWorx.tech"/><h2>Set up ForgeFlow</h2><p>Local code to controlled deployment.</p>${steps.map((step,index) => `<div class="setup-step ${ui.setupStep === index ? 'active' : ui.setupStep > index ? 'complete' : ''}"><span class="step-number">${ui.setupStep > index ? '✓' : index + 1}</span><span>${step}</span></div>`).join('')}</aside><div class="setup-content">${body}<footer class="setup-actions"><button class="button" data-action="setup-back" ${ui.setupStep === 0 || ui.setupStep === 3 ? 'disabled' : ''}>Back</button>${nextAction}</footer></div></section></div>`; return `<div class="setup-backdrop"><section class="setup-window"><aside class="setup-sidebar"><img class="setup-brand-logo setup-brand-logo-dark" src="./assets/itworx-wordmark-dark.png" alt="ITWorx.tech"/><img class="setup-brand-logo setup-brand-logo-light" src="./assets/itworx-wordmark-light.png" alt="ITWorx.tech"/><h2>Set up ForgeFlow</h2><p>Local code to controlled deployment.</p>${steps.map((step,index) => `<div class="setup-step ${ui.setupStep === index ? 'active' : ui.setupStep > index ? 'complete' : ''}"><span class="step-number">${ui.setupStep > index ? '✓' : index + 1}</span><span>${step}</span></div>`).join('')}</aside><div class="setup-content">${body}<footer class="setup-actions"><button class="button" data-action="setup-back" ${ui.setupStep === 0 || ui.setupStep === 3 ? 'disabled' : ''}>Back</button>${nextAction}</footer></div></section></div>`;
} }
function renderModal() { function renderModal() {
@@ -595,11 +688,12 @@ function renderModal() {
<label class="check-field"><input id="profile-align-remote" type="checkbox" ${existing.alignRemote === true ? 'checked' : ''}/><span>Align an existing server origin to this URL</span></label> <label class="check-field"><input id="profile-align-remote" type="checkbox" ${existing.alignRemote === true ? 'checked' : ''}/><span>Align an existing server origin to this URL</span></label>
<div class="field"><label>Compose mode</label><select id="profile-generated-compose" class="select"><option value="false" ${existing.generatedCompose !== true ? 'selected' : ''}>Use Compose file from repository/server</option><option value="true" ${existing.generatedCompose === true ? 'selected' : ''}>Generate a basic ForgeFlow Compose file</option></select></div> <div class="field"><label>Compose mode</label><select id="profile-generated-compose" class="select"><option value="false" ${existing.generatedCompose !== true ? 'selected' : ''}>Use Compose file from repository/server</option><option value="true" ${existing.generatedCompose === true ? 'selected' : ''}>Generate a basic ForgeFlow Compose file</option></select></div>
<div class="field"><label>Compose file</label><input id="profile-compose-file" class="input" value="${attr(existing.composeFile || 'docker-compose.yml')}"/></div> <div class="field"><label>Compose file</label><input id="profile-compose-file" class="input" value="${attr(existing.composeFile || 'docker-compose.yml')}"/></div>
<div class="field"><label>Compose service / container name</label><input id="profile-compose-service" class="input" value="${attr(existing.composeService || safeCloneFolderName(repository).toLowerCase())}"/></div> <div class="field"><label>Compose service (internal)</label><input id="profile-compose-service" class="input" value="${attr(existing.composeService || safeCloneFolderName(repository).toLowerCase())}"/><small>Must match the Compose service key and remain lowercase.</small></div><div class="field"><label>Visible container name</label><input id="profile-container-name" class="input" value="${attr(existing.containerName || remoteFolder)}"/><small>May remain Portfolio while internal image/service names are lowercase.</small></div>
<div class="field"><label>Host port</label><input id="profile-host-port" class="input" type="number" min="1" max="65535" value="${attr(existing.hostPort || '')}" placeholder="1223"/></div> <div class="field"><label>Host port</label><input id="profile-host-port" class="input" type="number" min="1" max="65535" value="${attr(existing.hostPort || '')}" placeholder="1223"/></div>
<div class="field"><label>Container port</label><input id="profile-container-port" class="input" type="number" min="1" max="65535" value="${attr(existing.containerPort || '')}" placeholder="8080"/></div> <div class="field"><label>Container port</label><input id="profile-container-port" class="input" type="number" min="1" max="65535" value="${attr(existing.containerPort || '')}" placeholder="8080"/></div>
<div class="field full"><label>Unraid Web UI URL (optional)</label><input id="profile-web-ui" class="input" value="${attr(existing.webUiUrl || '')}" placeholder="http://[IP]:[PORT:1223]/"/></div> <div class="field full"><label>Unraid Web UI URL (optional)</label><input id="profile-web-ui" class="input" value="${attr(existing.webUiUrl || '')}" placeholder="http://[IP]:[PORT:1223]/"/></div>
<div class="field full"><label>Unraid icon URL (optional)</label><input id="profile-icon-url" class="input" value="${attr(existing.iconUrl || '')}" placeholder="https://…/icon.png"/></div> <div class="field"><label>DockerMan icon source</label><select id="profile-icon-mode" class="select"><option value="builtin" ${(existing.iconMode || (!existing.iconUrl && !existing.iconFilePath ? 'builtin' : existing.iconFilePath ? 'upload' : 'url')) === 'builtin' ? 'selected' : ''}>Built-in high-contrast ITWorx mark</option><option value="upload" ${existing.iconMode === 'upload' || (!existing.iconMode && existing.iconFilePath) ? 'selected' : ''}>Upload local PNG</option><option value="url" ${existing.iconMode === 'url' || (!existing.iconMode && existing.iconUrl) ? 'selected' : ''}>Use icon URL</option><option value="none" ${existing.iconMode === 'none' ? 'selected' : ''}>No custom icon</option></select></div><div class="field"><label>Container shell</label><select id="profile-docker-shell" class="select"><option value="/bin/sh" ${(existing.dockerShell || '/bin/sh') === '/bin/sh' ? 'selected' : ''}>/bin/sh</option><option value="/bin/bash" ${existing.dockerShell === '/bin/bash' ? 'selected' : ''}>/bin/bash</option></select></div>
<div class="field full"><label>DockerMan icon URL</label><input id="profile-icon-url" class="input" value="${attr(existing.iconUrl || '')}" placeholder="https://…/icon.png"/></div><div class="field full"><label>Local PNG</label><div class="inline-form"><input id="profile-icon-file" class="input mono" value="${attr(existing.iconFilePath || '')}" placeholder="Select a local transparent PNG" readonly/><button class="button" data-action="select-profile-icon">${icon('folder')}Browse</button><button class="button ghost" data-action="clear-profile-icon">Clear</button></div><small>Built-in or uploaded PNGs are copied to DockerMan's persistent image folder and referenced through a file:/// URL. ForgeFlow also refreshes the relevant Unraid icon cache after recreating the container.</small></div>
<div class="field full"><label>Healthcheck URL from this desktop (optional)</label><input id="profile-healthcheck" class="input" value="${attr(existing.healthcheckUrl || '')}" placeholder="http://unraid:1223/health"/></div> <div class="field full"><label>Healthcheck URL from this desktop (optional)</label><input id="profile-healthcheck" class="input" value="${attr(existing.healthcheckUrl || '')}" placeholder="http://unraid:1223/health"/></div>
<div class="field full"><label>Preserve server-only paths</label><input id="profile-preserve-paths" class="input" value="${attr((existing.preservePaths || ['.env','appdata','data','logs','config','compose.override.yml']).join(', '))}"/><small>These untracked runtime paths remain untouched by Git deployments.</small></div> <div class="field full"><label>Preserve server-only paths</label><input id="profile-preserve-paths" class="input" value="${attr((existing.preservePaths || ['.env','appdata','data','logs','config','compose.override.yml']).join(', '))}"/><small>These untracked runtime paths remain untouched by Git deployments.</small></div>
` : ` ` : `
@@ -693,7 +787,8 @@ async function executeDeployment(profileId) {
ui.activeDeployment = await window.forgeflow.deploy(repository, profile.id, repository.localStatus.head); ui.activeDeployment = await window.forgeflow.deploy(repository, profile.id, repository.localStatus.head);
updateOperationInState(ui.activeDeployment); updateOperationInState(ui.activeDeployment);
ui.currentView = 'deployment-run'; ui.currentView = 'deployment-run';
showToast('Deployment requested', `${repository.name} ${repository.localStatus.shortHead}${profile.environment}`, 'success'); showToast('Deployment started', `${repository.name} ${repository.localStatus.shortHead}${profile.environment}`, 'success');
startOperationPolling();
} catch (error) { showToast('Deployment failed to start', error.message, 'error'); } } catch (error) { showToast('Deployment failed to start', error.message, 'error'); }
setLoading(false); setLoading(false);
} }
@@ -718,7 +813,11 @@ async function loadGitTools(repository) {
if (!repository?.localPath) return; if (!repository?.localPath) return;
setLoading(true, 'Loading branches and stashes…'); setLoading(true, 'Loading branches and stashes…');
try { try {
[ui.branches, ui.stashes] = await Promise.all([window.forgeflow.branches(repository.localPath), window.forgeflow.stashList(repository.localPath)]); [ui.branches, ui.stashes, ui.gitRecovery] = await Promise.all([
window.forgeflow.branches(repository.localPath),
window.forgeflow.stashList(repository.localPath),
window.forgeflow.gitRecoveryStatus(repository.localPath)
]);
ui.repositoryTab = 'gittools'; ui.repositoryTab = 'gittools';
} catch (error) { showToast('Git tools unavailable', error.message, 'error'); } } catch (error) { showToast('Git tools unavailable', error.message, 'error'); }
setLoading(false); setLoading(false);
@@ -780,8 +879,17 @@ app.addEventListener('click', async (event) => {
if (action === 'navigate') { ui.currentView = target.dataset.view; ui.modal = null; render(); } if (action === 'navigate') { ui.currentView = target.dataset.view; ui.modal = null; render(); }
else if (action === 'select-repo') selectRepository(target.dataset.id); else if (action === 'select-repo') selectRepository(target.dataset.id);
else if (action === 'refresh') await refreshRepositories(true); else if (action === 'refresh') {
else if (action === 'refresh-operations') { setLoading(true, 'Refreshing Gitea Actions runs…'); await refreshActiveOperations(); setLoading(false); } await refreshRepositories(true);
await refreshActiveOperations(false);
await refreshDeploymentTruth(false);
}
else if (action === 'refresh-operations') {
setLoading(true, 'Refreshing deployment operations and live server state…');
await refreshActiveOperations();
await refreshDeploymentTruth(true);
setLoading(false);
}
else if (action === 'toggle-theme') { const appearance = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; applyTheme(appearance); ui.boot.state = await window.forgeflow.setAppearance(appearance); render(); } else if (action === 'toggle-theme') { const appearance = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; applyTheme(appearance); ui.boot.state = await window.forgeflow.setAppearance(appearance); render(); }
else if (action === 'open-palette') { ui.paletteQuery = ''; ui.modal = { type: 'command-palette' }; render(); } else if (action === 'open-palette') { ui.paletteQuery = ''; ui.modal = { type: 'command-palette' }; render(); }
else if (action === 'repo-tab') { else if (action === 'repo-tab') {
@@ -832,6 +940,11 @@ app.addEventListener('click', async (event) => {
else if (action === 'configure-deployment') { ui.modal = { type: 'deployment-config', profileId: null, provider: (ui.boot.state.servers || []).length ? 'ssh-unraid' : 'gitea-actions' }; render(); } else if (action === 'configure-deployment') { ui.modal = { type: 'deployment-config', profileId: null, provider: (ui.boot.state.servers || []).length ? 'ssh-unraid' : 'gitea-actions' }; render(); }
else if (action === 'edit-deployment-profile') { if (!repository) repository = profileRepository(target.dataset.profileId); if (repository && String(repository.id) !== String(ui.selectedRepoId)) selectRepository(repository.id, false); ui.modal = { type: 'deployment-config', profileId: target.dataset.profileId || null, provider: repository?.deploymentProfiles?.find((item) => item.id === target.dataset.profileId)?.provider }; render(); } else if (action === 'edit-deployment-profile') { if (!repository) repository = profileRepository(target.dataset.profileId); if (repository && String(repository.id) !== String(ui.selectedRepoId)) selectRepository(repository.id, false); ui.modal = { type: 'deployment-config', profileId: target.dataset.profileId || null, provider: repository?.deploymentProfiles?.find((item) => item.id === target.dataset.profileId)?.provider }; render(); }
else if (action === 'close-modal') { ui.modal = null; render(); } else if (action === 'close-modal') { ui.modal = null; render(); }
else if (action === 'select-profile-icon') {
const iconPath = await window.forgeflow.selectImageFile({ title: 'Select DockerMan PNG icon', defaultPath: document.querySelector('#profile-icon-file')?.value || undefined });
if (iconPath) { document.querySelector('#profile-icon-file').value = iconPath; const mode = document.querySelector('#profile-icon-mode'); if (mode) mode.value = 'upload'; }
}
else if (action === 'clear-profile-icon') { const input = document.querySelector('#profile-icon-file'); if (input) input.value = ''; const mode = document.querySelector('#profile-icon-mode'); if (mode) mode.value = 'builtin'; }
else if (action === 'save-deployment-profile') { else if (action === 'save-deployment-profile') {
const provider = document.querySelector('#profile-provider').value; const provider = document.querySelector('#profile-provider').value;
const profile = { const profile = {
@@ -850,10 +963,14 @@ app.addEventListener('click', async (event) => {
generatedCompose: document.querySelector('#profile-generated-compose').value === 'true', generatedCompose: document.querySelector('#profile-generated-compose').value === 'true',
composeFile: document.querySelector('#profile-compose-file').value.trim(), composeFile: document.querySelector('#profile-compose-file').value.trim(),
composeService: document.querySelector('#profile-compose-service').value.trim(), composeService: document.querySelector('#profile-compose-service').value.trim(),
containerName: document.querySelector('#profile-container-name').value.trim(),
hostPort: Number(document.querySelector('#profile-host-port').value) || null, hostPort: Number(document.querySelector('#profile-host-port').value) || null,
containerPort: Number(document.querySelector('#profile-container-port').value) || null, containerPort: Number(document.querySelector('#profile-container-port').value) || null,
webUiUrl: document.querySelector('#profile-web-ui').value.trim(), webUiUrl: document.querySelector('#profile-web-ui').value.trim(),
iconMode: document.querySelector('#profile-icon-mode').value,
iconUrl: document.querySelector('#profile-icon-url').value.trim(), iconUrl: document.querySelector('#profile-icon-url').value.trim(),
iconFilePath: document.querySelector('#profile-icon-file').value.trim(),
dockerShell: document.querySelector('#profile-docker-shell').value,
preservePaths: document.querySelector('#profile-preserve-paths').value.split(',').map((item) => item.trim()).filter(Boolean) preservePaths: document.querySelector('#profile-preserve-paths').value.split(',').map((item) => item.trim()).filter(Boolean)
} : { } : {
workflowFile: document.querySelector('#profile-workflow').value.trim(), workflowFile: document.querySelector('#profile-workflow').value.trim(),
@@ -894,10 +1011,52 @@ app.addEventListener('click', async (event) => {
try { const state = await window.forgeflow.refreshProfileState(repository.fullName, target.dataset.profileId); profile.state = state; showToast('Environment checked', state.healthy === false ? 'Healthcheck reports an unhealthy state.' : state.liveSha ? `Server reports ${shortSha(state.liveSha)}.` : 'Connection checked; no live SHA reported.', state.healthy === false ? 'error' : 'success'); } catch (error) { showToast('Status check failed', error.message, 'error'); } try { const state = await window.forgeflow.refreshProfileState(repository.fullName, target.dataset.profileId); profile.state = state; showToast('Environment checked', state.healthy === false ? 'Healthcheck reports an unhealthy state.' : state.liveSha ? `Server reports ${shortSha(state.liveSha)}.` : 'Connection checked; no live SHA reported.', state.healthy === false ? 'error' : 'success'); } catch (error) { showToast('Status check failed', error.message, 'error'); }
setLoading(false); setLoading(false);
} }
else if (action === 'open-operation') { const operation = await window.forgeflow.getOperation(target.dataset.operationId); if (operation) { ui.activeDeployment = operation; ui.currentView = 'deployment-run'; render(); } } else if (action === 'repair-missing-dockerman') {
else if (action === 'refresh-current-operation') { setLoading(true, 'Refreshing Actions run…'); try { const operation = await window.forgeflow.refreshOperations(ui.activeDeployment.id); updateOperationInState(operation); } catch (error) { showToast('Status refresh failed', error.message, 'error'); } setLoading(false); } const targets = ui.repositories.flatMap((candidate) =>
(candidate.deploymentProfiles || [])
.filter((profile) => profile.provider === 'ssh-unraid' && profile.state?.containerRunning && !dockerManIntegration(profile).ready)
.map((profile) => ({ repository: candidate, profile }))
);
if (!targets.length) return;
if (!confirm(`Recreate ${targets.length} running container${targets.length === 1 ? '' : 's'} with the missing DockerMan WebUI, icon and template metadata?`)) return;
setLoading(true, 'Repairing missing DockerMan integrations…');
let repaired = 0;
const failures = [];
for (const item of targets) {
try {
await window.forgeflow.applyDockerManMetadata(item.repository, item.profile.id);
repaired += 1;
} catch (error) {
failures.push(`${item.repository.name}: ${error.message}`);
}
}
await refreshDeploymentTruth(false);
showToast(
failures.length ? 'DockerMan repair partially completed' : 'DockerMan integrations repaired',
failures.length ? `${repaired} repaired, ${failures.length} failed.` : `${repaired} running container${repaired === 1 ? '' : 's'} updated.`,
failures.length ? 'error' : 'success'
);
setLoading(false);
}
else if (action === 'apply-dockerman-metadata') {
if (!repository) repository = profileRepository(target.dataset.profileId);
setLoading(true, 'Applying DockerMan labels, template, icon and WebUI metadata…');
try { await window.forgeflow.applyDockerManMetadata(repository, target.dataset.profileId); await refreshRepositories(false); showToast('DockerMan integration repaired', 'The container was recreated with labels, a persistent template, WebUI and icon metadata.', 'success'); }
catch (error) { showToast('Could not repair DockerMan integration', error.message, 'error'); }
setLoading(false);
}
else if (action === 'reconcile-deployment') {
if (!repository) repository = profileRepository(target.dataset.profileId);
setLoading(true, 'Reconciling ForgeFlow with the live Unraid container…');
try { await window.forgeflow.reconcileDeployment(repository.fullName, target.dataset.profileId); await refreshActiveOperations(false); await refreshRepositories(false); showToast('Deployment reconciled', 'Live SHA, container health and operation status were refreshed.', 'success'); }
catch (error) { showToast('Could not reconcile deployment', error.message, 'error'); }
setLoading(false);
}
else if (action === 'open-profile-webui') await window.forgeflow.openExternal(target.dataset.url);
else if (action === 'open-operation') { const operation = await window.forgeflow.getOperation(target.dataset.operationId); if (operation) { ui.activeDeployment = operation; ui.currentView = 'deployment-run'; render(); startOperationPolling(); } }
else if (action === 'refresh-current-operation') { setLoading(true, 'Refreshing deployment status…'); try { const operation = await window.forgeflow.refreshOperations(ui.activeDeployment.id); updateOperationInState(operation); if (!isTerminalOperation(operation.status)) startOperationPolling(); } catch (error) { showToast('Status refresh failed', error.message, 'error'); } setLoading(false); }
else if (action === 'open-run-url') await window.forgeflow.openExternal(ui.activeDeployment.runUrl); else if (action === 'open-run-url') await window.forgeflow.openExternal(ui.activeDeployment.runUrl);
else if (action === 'close-deployment') { ui.activeDeployment = null; ui.currentView = selectedRepository() ? 'repository' : 'deployments'; render(); } else if (action === 'close-deployment') { stopOperationPolling(); ui.activeDeployment = null; ui.currentView = selectedRepository() ? 'repository' : 'deployments'; render(); }
else if (action === 'setup-run-preflight') await runSystemPreflight({ setup: true }); else if (action === 'setup-run-preflight') await runSystemPreflight({ setup: true });
else if (action === 'setup-continue') { if (ui.systemPreflight?.summary?.ready) { ui.setupStep = 1; render(); } } else if (action === 'setup-continue') { if (ui.systemPreflight?.summary?.ready) { ui.setupStep = 1; render(); } }
else if (action === 'setup-validate') { setLoading(true, 'Validating Gitea connection…'); try { ui.setupValidation = await window.forgeflow.validateGitea(ui.setupDraft); ui.setupDraft.baseUrl = ui.setupValidation.baseUrl; ui.setupDraft.user = ui.setupValidation.user; ui.setupStep = 2; } catch (error) { showToast('Connection failed', error.message, 'error'); } setLoading(false); } else if (action === 'setup-validate') { setLoading(true, 'Validating Gitea connection…'); try { ui.setupValidation = await window.forgeflow.validateGitea(ui.setupDraft); ui.setupDraft.baseUrl = ui.setupValidation.baseUrl; ui.setupDraft.user = ui.setupValidation.user; ui.setupStep = 2; } catch (error) { showToast('Connection failed', error.message, 'error'); } setLoading(false); }
@@ -997,12 +1156,50 @@ app.addEventListener('click', async (event) => {
} catch (error) { showToast('Could not normalize origins', error.message, 'error'); } } catch (error) { showToast('Could not normalize origins', error.message, 'error'); }
setLoading(false); render(); setLoading(false); render();
} }
else if (action === 'repair-index-lock') { else if (action === 'scan-git-recovery') {
if (!repository?.localPath || !confirm('Remove the stale Git index.lock for this repository? Only continue after other Git tools have stopped.')) return; if (!repository?.localPath) return;
setLoading(true, 'Repairing stale Git lock…'); setLoading(true, 'Scanning Git directory and active processes…');
try { await window.forgeflow.repairIndexLock(repository.localPath); await refreshRepositories(false); showToast('Git lock removed', 'The repository can accept Git changes again.', 'success'); } try { ui.gitRecovery = await window.forgeflow.gitRecoveryStatus(repository.localPath); ui.repositoryTab = 'gittools'; showToast('Git health scan complete', `${ui.gitRecovery.lockReport.locks.length} lock file(s) found.`, ui.gitRecovery.lockReport.locks.length ? 'info' : 'success'); }
catch (error) { showToast('Could not remove Git lock', error.message, 'error'); } catch (error) { showToast('Git health scan failed', error.message, 'error'); }
setLoading(false); setLoading(false); render();
}
else if (action === 'repair-git-locks' || action === 'repair-index-lock') {
if (!repository?.localPath || !confirm('Repair stale Git lock files for this repository? ForgeFlow refuses while a matching Git process is active.')) return;
setLoading(true, 'Safely repairing stale Git locks…');
try { const result = await window.forgeflow.repairGitLocks(repository.localPath, false); ui.gitRecovery = await window.forgeflow.gitRecoveryStatus(repository.localPath); await refreshRepositories(false); showToast('Git locks repaired', `${result.removed.length} stale lock file(s) removed.`, 'success'); }
catch (error) {
if (error.code === 'GIT_PROCESS_PROBE_UNAVAILABLE' && confirm(`${error.message}
Force repair after you have closed all Git tools for this repository?`)) {
try { const result = await window.forgeflow.repairGitLocks(repository.localPath, true); showToast('Git locks force-repaired', `${result.removed.length} lock file(s) removed.`, 'success'); await refreshRepositories(false); }
catch (forceError) { showToast('Could not repair Git locks', forceError.message, 'error'); }
} else showToast('Could not repair Git locks', error.message, 'error');
}
setLoading(false); render();
}
else if (action === 'reconcile-repository') {
if (!repository?.localPath) return;
setLoading(true, 'Refreshing repository truth from Git…');
try { ui.gitRecovery = await window.forgeflow.reconcileRepository(repository.localPath); await refreshRepositories(false); showToast('Repository reconciled', 'Branch, upstream, lock and working-tree state were refreshed.', 'success'); }
catch (error) { showToast('Could not reconcile repository', error.message, 'error'); }
setLoading(false); render();
}
else if (action === 'repair-repository-sync') {
if (!repository?.localPath) return;
const strategy = target.dataset.strategy;
const destructive = strategy === 'backup-reset';
const message = destructive
? 'Create a safety branch from the current HEAD and reset this branch to its upstream? Uncommitted changes are never discarded.'
: `Run the repository-specific ${strategy} repair now?`;
if (!confirm(message)) return;
setLoading(true, destructive ? 'Creating safety branch and repairing divergence…' : 'Repairing repository synchronization…');
try {
const result = await window.forgeflow.repairRepositorySync(repository.localPath, strategy);
ui.gitRecovery = await window.forgeflow.gitRecoveryStatus(repository.localPath);
await refreshRepositories(false);
showToast('Repository synchronization repaired', result.backupBranch ? `Safety branch created: ${result.backupBranch}` : `Completed ${strategy}.`, 'success');
} catch (error) { showToast('Synchronization repair failed', error.message, 'error'); }
setLoading(false); render();
} }
else if (action === 'run-system-preflight') await runSystemPreflight(); else if (action === 'run-system-preflight') await runSystemPreflight();
else if (action === 'save-diagnostics-preferences') { else if (action === 'save-diagnostics-preferences') {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 80 KiB

+1 -1
View File
@@ -10,7 +10,7 @@
<body> <body>
<div id="app" aria-live="polite"> <div id="app" aria-live="polite">
<div class="boot-screen"> <div class="boot-screen">
<div class="brand-mark">F</div> <img class="boot-brand-logo" src="./assets/itworx-mark.png" alt="ITWorx.tech"/>
<strong>Starting ForgeFlow</strong> <strong>Starting ForgeFlow</strong>
<span>Checking Git and local configuration…</span> <span>Checking Git and local configuration…</span>
</div> </div>
+2 -2
View File
@@ -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.5.1', remoteVersion: '0.5.2', 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.4', remoteVersion: '0.6.0', remoteSha: 'a'.repeat(40), shortSha: 'aaaaaaa', available: true, mode: 'source' }; },
async downloadUpdate() { await wait(500); return { ...(await this.checkForUpdates()), downloaded: true, archivePath: 'C:\\Temp\\ForgeFlow-0.4.1.zip', sha256: 'b'.repeat(64) }; }, async 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.5.1' }; }, async applyUpdate() { await wait(200); return { launched: true, confirmed: true, version: '0.6.0' }; },
async saveServer(server) { const saved = { ...server, id: server.id || `server-${Date.now()}`, hasPassword: server.authType === 'password', hasPassphrase: false }; state.servers = [saved, ...state.servers.filter((item) => item.id !== saved.id)]; return { server: clone(saved), state: clone(state) }; }, async 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) }; },
+13 -1
View File
@@ -66,6 +66,7 @@ button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-
.boot-screen { height: 100vh; display: grid; place-content: center; justify-items: center; gap: 10px; color: var(--text-muted); } .boot-screen { height: 100vh; display: grid; place-content: center; justify-items: center; gap: 10px; color: var(--text-muted); }
.boot-screen strong { color: var(--text); font-size: 16px; } .boot-screen strong { color: var(--text); font-size: 16px; }
.boot-brand-logo { width: 72px; height: 56px; object-fit: contain; filter: drop-shadow(0 10px 28px rgba(0,174,255,.24)); }
.brand-mark { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 10px; background: linear-gradient(145deg, var(--primary), var(--primary-strong)); color: #07152e; font-weight: 800; font-size: 20px; box-shadow: 0 8px 30px rgba(91,143,249,.25); } .brand-mark { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 10px; background: linear-gradient(145deg, var(--primary), var(--primary-strong)); color: #07152e; font-weight: 800; font-size: 20px; box-shadow: 0 8px 30px rgba(91,143,249,.25); }
.app-shell { height: 100vh; display: grid; grid-template-rows: 48px minmax(0,1fr) 25px; background: var(--bg); } .app-shell { height: 100vh; display: grid; grid-template-rows: 48px minmax(0,1fr) 25px; background: var(--bg); }
@@ -532,7 +533,10 @@ kbd { min-width: 24px; padding: 2px 5px; border: 1px solid var(--line); border-b
.update-card, .server-card { align-items: flex-start; flex-direction: column; } .update-card, .server-card { align-items: flex-start; flex-direction: column; }
} }
.setup-brand-logo { width: 150px; height: auto; display: block; margin-bottom: 12px; } .setup-brand-logo { width: 180px; max-height: 76px; object-fit: contain; object-position: left center; display: block; margin-bottom: 12px; }
.setup-brand-logo-light { display: none; }
html[data-theme="light"] .setup-brand-logo-dark { display: none; }
html[data-theme="light"] .setup-brand-logo-light { display: block; }
/* v0.5 viewport-safe dialogs */ /* v0.5 viewport-safe dialogs */
@@ -553,3 +557,11 @@ kbd { min-width: 24px; padding: 2px 5px; border: 1px solid var(--line); border-b
.form-grid { grid-template-columns: 1fr; } .form-grid { grid-template-columns: 1fr; }
.field.full, .check-field.full { grid-column: 1; } .field.full, .check-field.full { grid-column: 1; }
} }
/* ForgeFlow 0.6 recovery and DockerMan controls */
.git-tools-grid .troubleshooting-panel { grid-column: 1 / -1; }
.troubleshooting-summary { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; color: var(--text-muted); }
.text-success { color: var(--success) !important; }
.text-warning { color: var(--warning) !important; }
.deploy-card .card-actions { flex-wrap: wrap; }
.field small { display: block; margin-top: 5px; color: var(--text-faint); line-height: 1.35; }
+65
View File
@@ -178,3 +178,68 @@ test('stages a large Windows-sized partial selection through NUL-delimited stdin
assert.equal(status.counts.staged, names.length); assert.equal(status.counts.staged, names.length);
assert.equal(status.counts.unstaged, 0); assert.equal(status.counts.unstaged, 0);
}); });
test('detects and removes a stale HEAD.lock while skipping Git object storage', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-head-lock-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
await git(['init'], root);
await git(['config', 'user.name', 'ForgeFlow Test'], root);
await git(['config', 'user.email', 'forgeflow@example.invalid'], root);
await fs.writeFile(path.join(root, 'README.md'), 'lock test\n');
await git(['add', '.'], root);
await git(['commit', '-m', 'Initial'], root);
const headLock = path.join(root, '.git', 'HEAD.lock');
const ignoredObjectLock = path.join(root, '.git', 'objects', 'fake.lock');
await fs.writeFile(headLock, 'stale');
await fs.writeFile(ignoredObjectLock, 'not a repository mutation lock');
const old = new Date(Date.now() - 60_000);
await fs.utimes(headLock, old, old);
const service = new GitService();
const report = await service.listGitLocks(root);
assert.deepEqual(report.locks.map((item) => item.name), ['HEAD.lock']);
const repaired = await service.repairStaleGitLocks(root, { minimumAgeMs: 0, allowWithoutProcessProbe: true });
assert.equal(repaired.removed.length, 1);
await assert.rejects(() => fs.stat(headLock), (error) => error.code === 'ENOENT');
assert.ok(await fs.stat(ignoredObjectLock));
});
test('repairs a diverged branch by creating a safety branch before resetting to upstream', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-diverged-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const remote = path.join(root, 'remote.git');
const working = path.join(root, 'working');
const other = path.join(root, 'other');
await git(['init', '--bare', remote], root);
await git(['clone', remote, working], root);
await git(['config', 'user.name', 'ForgeFlow Test'], working);
await git(['config', 'user.email', 'forgeflow@example.invalid'], working);
await fs.writeFile(path.join(working, 'README.md'), 'initial\n');
await git(['add', '.'], working);
await git(['commit', '-m', 'Initial'], working);
await git(['branch', '-M', 'main'], working);
await git(['push', '-u', 'origin', 'main'], working);
await git(['clone', remote, other], root);
await git(['config', 'user.name', 'Other Test'], other);
await git(['config', 'user.email', 'other@example.invalid'], other);
await git(['checkout', 'main'], other);
await fs.writeFile(path.join(other, 'remote.txt'), 'remote\n');
await git(['add', '.'], other);
await git(['commit', '-m', 'Remote commit'], other);
await git(['push', 'origin', 'main'], other);
await fs.writeFile(path.join(working, 'local.txt'), 'local\n');
await git(['add', '.'], working);
await git(['commit', '-m', 'Local commit'], working);
const localBefore = (await git(['rev-parse', 'HEAD'], working)).stdout.trim();
const service = new GitService();
const scan = await service.reconcile(working);
assert.equal(scan.status.branch.ahead, 1);
assert.equal(scan.status.branch.behind, 1);
assert.ok(scan.recommendations.some((item) => item.action === 'backup-reset'));
const repaired = await service.repairSync(working, 'backup-reset');
assert.match(repaired.backupBranch, /^forgeflow\/backup-main-/);
assert.equal(repaired.status.branch.ahead, 0);
assert.equal(repaired.status.branch.behind, 0);
const backupSha = (await git(['rev-parse', repaired.backupBranch], working)).stdout.trim();
assert.equal(backupSha, localBefore);
});
+32 -2
View File
@@ -20,7 +20,7 @@ test('commit workflow explains every disabled prerequisite', async () => {
test('ITWorx branding is integrated into titlebar and setup', async () => { test('ITWorx branding is integrated into titlebar and setup', async () => {
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8'); const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
assert.match(renderer, /itworx-mark\.png/); assert.match(renderer, /itworx-mark\.png/);
assert.match(renderer, /itworx-wordmark\.png/); assert.match(renderer, /itworx-wordmark-(?:light|dark)\.png/);
}); });
@@ -43,8 +43,12 @@ test('Git mutations are serialized per repository and expose repair actions', as
const renderer = await readFile(new URL('../src/renderer/app.js', 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, /repositoryMutations = new Map/);
assert.match(ipc, /withRepositoryMutation/); assert.match(ipc, /withRepositoryMutation/);
assert.match(renderer, /data-action="repair-index-lock"/); assert.match(ipc, /GIT_LOCKS_RECENT/);
assert.match(ipc, /setTimeout\(resolve, 2_500\)/);
assert.match(renderer, /data-action="repair-git-locks"/);
assert.match(renderer, /Repository troubleshooting/);
assert.match(renderer, /data-action="repair-origin"/); assert.match(renderer, /data-action="repair-origin"/);
assert.match(renderer, /Open guided repository repair/);
}); });
@@ -54,3 +58,29 @@ test('SSH secrets are captured before the loading render clears password inputs'
const loading = renderer.indexOf("setLoading(true, 'Saving encrypted SSH configuration…')"); const loading = renderer.indexOf("setLoading(true, 'Saving encrypted SSH configuration…')");
assert.ok(passwordCapture >= 0 && loading > passwordCapture); assert.ok(passwordCapture >= 0 && loading > passwordCapture);
}); });
test('SSH deployments are polled in the background and Portfolio casing is preserved', async () => {
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
assert.match(renderer, /function startOperationPolling\(\)/);
assert.match(renderer, /startOperationPolling\(\);/);
assert.match(renderer, /Visible container name/);
assert.match(renderer, /Compose service \(internal\)/);
});
test('deployment profiles expose built-in/uploaded DockerMan icons and automatic metadata repair', async () => {
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
assert.match(renderer, /Built-in high-contrast ITWorx mark/);
assert.match(renderer, /profile-icon-mode/);
assert.match(renderer, /Repair DockerMan integration/);
assert.match(renderer, /reconcile-deployment/);
});
test('repository troubleshooting offers personalized synchronization repair actions', async () => {
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
const ipc = await readFile(new URL('../src/main/ipc.cjs', import.meta.url), 'utf8');
assert.match(renderer, /repair-repository-sync/);
assert.match(renderer, /safety branch/);
assert.match(ipc, /repository:repair-sync/);
});
+60
View File
@@ -0,0 +1,60 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import repositoryModule from '../src/main/repository-service.cjs';
const { RepositoryService } = repositoryModule;
function status(head = 'a'.repeat(40)) {
return {
head,
shortHead: head.slice(0, 7),
clean: true,
counts: { changed: 0, conflicts: 0 },
branch: { head: 'main', upstream: 'origin/main', ahead: 0, behind: 0 }
};
}
const remote = {
id: 1,
name: 'Portfolio',
full_name: 'Jens/Portfolio',
owner: { login: 'Jens' },
private: true,
default_branch: 'main',
html_url: 'https://gitea.example/Jens/Portfolio',
clone_url: 'https://gitea.example/Jens/Portfolio.git',
ssh_url: 'git@gitea.example:Jens/Portfolio.git'
};
function service() {
return new RepositoryService({ data: { preferences: { preferredCloneProtocol: 'ssh' }, favorites: [] } }, {}, {});
}
test('a synchronized commit is deployable when the server is unknown or older', () => {
const current = status();
const unknown = service().decorate(remote, { localPath: 'C:/Projects/Portfolio', status: current }, [
{ id: 'prod', branch: 'main', state: { liveSha: null, healthy: null } }
]);
assert.equal(unknown.readyToDeploy, true);
const older = service().decorate(remote, { localPath: 'C:/Projects/Portfolio', status: current }, [
{ id: 'prod', branch: 'main', state: { liveSha: 'b'.repeat(40), healthy: true } }
]);
assert.equal(older.readyToDeploy, true);
});
test('a healthy commit already live on the server is not offered for deployment again', () => {
const current = status();
const repository = service().decorate(remote, { localPath: 'C:/Projects/Portfolio', status: current }, [
{ id: 'prod', branch: 'main', state: { liveSha: current.head, healthy: true } }
]);
assert.equal(repository.readyToDeploy, false);
});
test('an unhealthy live commit remains eligible for a controlled redeploy', () => {
const current = status();
const repository = service().decorate(remote, { localPath: 'C:/Projects/Portfolio', status: current }, [
{ id: 'prod', branch: 'main', state: { liveSha: current.head, healthy: false } }
]);
assert.equal(repository.readyToDeploy, true);
});
+143 -2
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const { UnraidDeploymentService, safeRemoteFolder, safeRelativeRemoteFile, parseInspection, dockerIgnoreHasPath, checksSummary, bash } = require('../src/main/unraid-deployment-service.cjs'); const { UnraidDeploymentService, safeRemoteFolder, safeRelativeRemoteFile, parseInspection, dockerIgnoreHasPath, checksSummary, xmlEscape, bash } = require('../src/main/unraid-deployment-service.cjs');
const { fingerprintKey, shellQuote } = require('../src/main/ssh-service.cjs'); const { fingerprintKey, shellQuote } = require('../src/main/ssh-service.cjs');
test('Unraid remote paths cannot escape appdata project folder', () => { test('Unraid remote paths cannot escape appdata project folder', () => {
@@ -107,7 +107,7 @@ test('successful SSH rollback records the formerly live SHA as the new rollback
const savedStates = []; const savedStates = [];
const operations = []; const operations = [];
const store = { const store = {
getDeploymentProfile: () => ({ id: 'production', provider: 'ssh-unraid', serverId: 'unraid', remoteFolder: 'lumaops', composeFile: 'docker-compose.yml', branch: 'main', environment: 'production', healthcheckUrl: '' }), getDeploymentProfile: () => ({ id: 'production', provider: 'ssh-unraid', serverId: 'unraid', remoteFolder: 'lumaops', composeFile: 'docker-compose.yml', branch: 'main', environment: 'production', healthcheckUrl: '', iconMode: 'none' }),
getServer: () => ({ id: 'unraid', name: 'Unraid', basePath: '/mnt/user/appdata' }), getServer: () => ({ id: 'unraid', name: 'Unraid', basePath: '/mnt/user/appdata' }),
getDeploymentState: () => ({ liveSha, previousSha }), getDeploymentState: () => ({ liveSha, previousSha }),
addOperation: async (operation) => { operations.push(operation); return operation; }, addOperation: async (operation) => { operations.push(operation); return operation; },
@@ -124,3 +124,144 @@ test('successful SSH rollback records the formerly live SHA as the new rollback
assert.equal(savedStates.at(-1).previousSha, liveSha); assert.equal(savedStates.at(-1).previousSha, liveSha);
assert.equal(operations.at(-1).previousSha, liveSha); assert.equal(operations.at(-1).previousSha, liveSha);
}); });
test('generated Compose uses a lowercase-safe service while preserving the visible Portfolio container name', () => {
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {}, diagnostics: null });
const compose = service.generatedCompose({ composeService: 'Portfolio', hostPort: 5150, containerPort: 80 }, { name: 'Portfolio' });
assert.match(compose, / portfolio:/);
assert.match(compose, /image: forgeflow\/portfolio:production/);
assert.match(compose, /container_name: Portfolio/);
});
test('SSH deployment dispatch returns a running operation while the remote build continues in background', async () => {
const sha = 'd'.repeat(40);
const operations = [];
let resolveRemote;
const store = {
getDeploymentProfile: () => ({ id: 'production', provider: 'ssh-unraid', serverId: 'unraid', remoteFolder: 'Portfolio', cloneUrl: 'forgeflow-gitea:Jens/Portfolio.git', composeFile: 'docker-compose.yml', branch: 'main', environment: 'production', healthcheckUrl: '', iconMode: 'none' }),
getServer: () => ({ id: 'unraid', name: 'Unraid', basePath: '/mnt/user/appdata' }),
addOperation: async (operation) => { operations.push(structuredClone(operation)); return structuredClone(operation); },
saveDeploymentState: async () => ({})
};
const ssh = { exec: async () => new Promise((resolve) => { resolveRemote = resolve; }) };
const service = new UnraidDeploymentService({ store, ssh, git: {}, diagnostics: null });
service.preflight = async () => ({ summary: { ready: true, blocking: [] }, inspection: { head: null } });
service.checkHealth = async () => ({ configured: false, healthy: null, status: null, latencyMs: null });
const operation = await service.deploy({ repository: { fullName: 'Jens/Portfolio', name: 'Portfolio' }, profileId: 'production', sha });
assert.equal(operation.status, 'running');
assert.match(operation.logs.join('\n'), /background/i);
resolveRemote({ stdout: 'Container Portfolio started\n', stderr: '', exitCode: 0 });
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(operations.at(-1).status, 'success');
});
test('Unraid preflight verifies repository access before a deployment can start', async () => {
const source = await import('node:fs/promises').then(({ readFile }) => readFile(new URL('../src/main/unraid-deployment-service.cjs', import.meta.url), 'utf8'));
assert.match(source, /server-git-access/);
assert.match(source, /git ls-remote --exit-code/);
assert.match(source, /Unraid → Gitea access/);
});
test('DockerMan metadata uses dockerman labels, a template WebUI and lowercase-safe service/image names', () => {
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {}, diagnostics: null });
const metadata = service.metadataCompose({
composeService: 'portfolio', containerName: 'Portfolio', remoteFolder: 'Portfolio',
environment: 'production', hostPort: 5150, webUiUrl: 'http://192.168.10.150:5150/admin', dockerShell: '/bin/sh'
}, { name: 'Portfolio' }, 'file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png');
assert.match(metadata, / portfolio:/);
assert.match(metadata, /image: forgeflow\/portfolio:production/);
assert.match(metadata, /container_name: Portfolio/);
assert.match(metadata, /net\.unraid\.docker\.managed.*dockerman/);
assert.match(metadata, /net\.unraid\.docker\.webui.*http:\/\/\[IP\]:\[PORT:5150\]\/admin/);
assert.match(metadata, /net\.unraid\.docker\.icon.*file:\/\/\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png/);
});
test('DockerMan integration writes a persistent template fallback and invalidates cached metadata', () => {
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {}, diagnostics: null });
const profile = {
composeService: 'portfolio', containerName: 'Portfolio', remoteFolder: 'Portfolio',
environment: 'production', hostPort: 5150, webUiUrl: 'http://192.168.10.150:5150/', dockerShell: '/bin/sh'
};
const repository = { name: 'Portfolio' };
const icon = 'file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png';
const template = service.dockerManTemplate(profile, repository, icon);
const refresh = service.dockerManRefreshScript(profile, repository, icon);
assert.match(template, /<Name>Portfolio<\/Name>/);
assert.match(template, /<Repository>forgeflow\/portfolio:production<\/Repository>/);
assert.match(template, /<WebUI>http:\/\/\[IP\]:\[PORT:5150\]\/<\/WebUI>/);
assert.match(template, /<Icon>file:\/\/\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png<\/Icon>/);
assert.match(refresh, /templates-user\/my-Portfolio\.xml/);
assert.match(refresh, /dynamix\.docker\.manager\/docker\.json/);
assert.match(refresh, /cp '\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png'/);
assert.doesNotMatch(refresh, /dockerManRefreshScript/);
assert.equal(xmlEscape('A&B<"x">'), 'A&amp;B&lt;&quot;x&quot;&gt;');
});
test('built-in ITWorx DockerMan icon is uploaded to persistent Unraid storage', async (t) => {
const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises');
const os = await import('node:os');
const path = await import('node:path');
const sourcePath = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-icon-'));
t.after(() => rm(sourcePath, { recursive: true, force: true }));
const asset = path.join(sourcePath, 'src', 'renderer', 'assets', 'itworx-mark.png');
await mkdir(path.dirname(asset), { recursive: true });
await writeFile(asset, Buffer.from([137, 80, 78, 71]));
const uploads = [];
const service = new UnraidDeploymentService({
store: {}, git: {}, diagnostics: null, sourcePath,
ssh: { uploadFile: async (...args) => { uploads.push(args); return {}; } }
});
const icon = await service.prepareIcon({ iconMode: 'builtin', remoteFolder: 'Portfolio' }, { name: 'Portfolio' }, { id: 'unraid' });
assert.equal(icon, 'file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png');
assert.equal(uploads.length, 1);
assert.equal(uploads[0][0], 'unraid');
assert.equal(uploads[0][1], asset);
assert.equal(uploads[0][2], '/boot/config/plugins/dockerMan/images/Portfolio-icon.png');
});
test('stuck SSH deployment is reconciled to success when exact SHA and container health are live', async () => {
const sha = 'f'.repeat(40);
const saved = [];
const operation = { id: 'op-1', type: 'deployment', provider: 'ssh-unraid', action: 'deploy', repository: 'Jens/Portfolio', profileId: 'production', sha, status: 'running', logs: [] };
const store = {
getOperation: () => operation,
addOperation: async (next) => { saved.push(next); return next; }
};
const service = new UnraidDeploymentService({ store, ssh: {}, git: {}, diagnostics: null });
service.refreshProfileState = async () => ({ liveSha: sha, containerRunning: true, healthy: true });
const result = await service.refreshOperation('op-1');
assert.equal(result.status, 'success');
assert.match(result.logs.at(-1), /reconciled/i);
assert.equal(saved.at(-1).status, 'success');
});
test('DockerMan metadata repair refreshes known Unraid icon caches after container recreation', async () => {
const source = await import('node:fs/promises').then(({ readFile }) => readFile(new URL('../src/main/unraid-deployment-service.cjs', import.meta.url), 'utf8'));
assert.match(source, /\/var\/lib\/docker\/unraid\/images/);
assert.match(source, /dynamix\.docker\.manager\/images/);
assert.match(source, /-icon\.png/);
assert.match(source, /cp \${shellQuote\(localIconPath\)}/);
assert.match(source, /--force-recreate/);
});
test('stuck deployment is cleared as superseded when a different healthy commit is already live', async () => {
const requested = 'a'.repeat(40);
const live = 'b'.repeat(40);
const operation = { id: 'op-superseded', type: 'deployment', provider: 'ssh-unraid', action: 'deploy', repository: 'Jens/Portfolio', profileId: 'production', sha: requested, status: 'running', logs: [] };
const saved = [];
const service = new UnraidDeploymentService({
store: { getOperation: () => operation, addOperation: async (next) => { saved.push(next); return next; } },
ssh: {}, git: {}, diagnostics: null
});
service.refreshProfileState = async () => ({ liveSha: live, containerRunning: true, healthy: true });
const result = await service.refreshOperation(operation.id);
assert.equal(result.status, 'cancelled');
assert.match(result.error, /Superseded/);
assert.equal(saved.at(-1).status, 'cancelled');
});
+119 -2
View File
@@ -1,11 +1,12 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises'; import { mkdtemp, rm, mkdir, writeFile, readFile } from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { EventEmitter } from 'node:events';
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const { UpdateService } = require('../src/main/update-service.cjs'); const { UpdateService, waitForUpdaterStarted } = require('../src/main/update-service.cjs');
test('update check pins version to an exact branch commit', async () => { test('update check pins version to an exact branch commit', async () => {
const temp = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-update-test-')); const temp = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-update-test-'));
@@ -50,3 +51,119 @@ test('update repository parts reject path injection', async () => {
await assert.rejects(() => service.check(), /unsupported characters/); await assert.rejects(() => service.check(), /unsupported characters/);
await rm(temp, { recursive: true, force: true }); await rm(temp, { recursive: true, force: true });
}); });
test('source updater confirms an external STARTED marker before ForgeFlow may close', async () => {
const temp = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-update-handshake-'));
const source = path.join(temp, 'source');
const scripts = path.join(source, 'scripts');
const archive = path.join(temp, 'update.zip');
await mkdir(scripts, { recursive: true });
await writeFile(path.join(scripts, 'apply-source-update.ps1'), '# test helper');
await writeFile(archive, 'PK fake archive');
let capturedArgs = null;
const spawnProcess = (_command, args) => {
capturedArgs = args;
const child = new EventEmitter();
child.pid = 4321;
child.unref = () => {};
queueMicrotask(() => child.emit('spawn'));
const statusIndex = args.indexOf('-StatusPath');
const statusPath = args[statusIndex + 1];
setTimeout(() => writeFile(statusPath, JSON.stringify({ state: 'started', expectedVersion: '0.5.3' })), 30);
return child;
};
const service = new UpdateService({
store: { data: { updates: {}, gitea: { baseUrl: 'https://example.test' } }, save: async () => {} },
gitea: {}, diagnostics: null,
appInfo: { version: '0.5.2', packaged: false },
sourcePath: source,
userDataPath: temp,
platform: 'win32',
spawnProcess,
powershellPath: 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
handshakeTimeoutMs: 1000,
handshakePollMs: 10
});
service.staged = { archivePath: archive, remoteVersion: '0.5.3', remoteSha: 'a'.repeat(40), sha256: 'b'.repeat(64) };
const result = await service.apply();
assert.equal(result.confirmed, true);
assert.ok(capturedArgs.includes('-StatusPath'));
assert.ok(capturedArgs.includes('-UpdateId'));
await rm(temp, { recursive: true, force: true });
});
test('source updater leaves ForgeFlow open when no STARTED marker arrives', async () => {
const temp = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-update-timeout-'));
const statusPath = path.join(temp, 'status.json');
await writeFile(statusPath, JSON.stringify({ state: 'launching' }));
await assert.rejects(
() => waitForUpdaterStarted(statusPath, { timeoutMs: 80, pollMs: 10, childState: { exited: false, error: null } }),
(error) => error.code === 'UPDATE_HELPER_START_TIMEOUT'
);
await rm(temp, { recursive: true, force: true });
});
test('completed source update result is returned once and acknowledged', async () => {
const temp = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-update-result-'));
const updates = path.join(temp, 'updates');
await mkdir(updates, { recursive: true });
const statusPath = path.join(updates, 'apply-test.status.json');
await writeFile(statusPath, JSON.stringify({
state: 'success', expectedVersion: '0.5.3', installedVersion: '0.5.3', restartLaunched: false,
message: 'installed', logPath: 'C:\\log.txt', updatedAt: new Date().toISOString()
}));
const service = new UpdateService({
store: { data: { updates: {} }, save: async () => {} }, gitea: {}, diagnostics: null,
appInfo: { version: '0.5.3', packaged: false }, sourcePath: temp, userDataPath: temp
});
const first = await service.consumeLatestResult();
const second = await service.consumeLatestResult();
assert.equal(first.state, 'success');
assert.equal(first.restartLaunched, false);
assert.equal(second, null);
const persisted = JSON.parse(await readFile(statusPath, 'utf8'));
assert.ok(persisted.acknowledgedAt);
await rm(temp, { recursive: true, force: true });
});
test('PowerShell update helper writes lifecycle status before waiting for ForgeFlow exit', async () => {
const script = await readFile(new URL('../scripts/apply-source-update.ps1', import.meta.url), 'utf8');
assert.match(script, /\[string\]\$StatusPath/);
assert.match(script, /Write-UpdateState -State "started"/);
assert.match(script, /Write-UpdateState -State "success"/);
assert.match(script, /Write-UpdateState -State "rolled-back"/);
assert.match(script, /UTF8Encoding\(\$false\)/);
assert.match(script, /WriteAllText/);
});
test('PowerShell update helper starts with param and has no BOM or stray leading slash', async () => {
const bytes = await readFile(new URL('../scripts/apply-source-update.ps1', import.meta.url));
assert.notDeepEqual([...bytes.subarray(0, 3)], [0xEF, 0xBB, 0xBF]);
const text = bytes.toString('utf8');
assert.match(text.trimStart(), /^param\(/);
assert.doesNotMatch(text.trimStart(), /^\\/);
assert.match(text, /node_modules\\electron\\dist\\electron\.exe/);
assert.match(text, /npm ci --no-audit --no-fund/);
assert.match(text, /package-lock\.json/);
assert.doesNotMatch(text, /Get-Command npm\.cmd/);
assert.ok(text.indexOf('Write-UpdateState -State "success"') < text.indexOf('Start-ForgeFlow -WorkingDirectory $SourcePath'));
});
test('release publisher verifies Gitea and bootstraps only the installed updater helper', async () => {
const script = await readFile(new URL('../Publish-ForgeFlow-Release.ps1', import.meta.url), 'utf8');
assert.match(script, /npm install --no-audit --no-fund/);
assert.match(script, /package-lock\.json/);
assert.match(script, /non-reproducible update/);
assert.match(script, /npm run check/);
assert.match(script, /git ls-remote origin/);
assert.match(script, /publishedCommit -ne \$localCommit/);
assert.match(script, /scripts\\apply-source-update\.ps1/);
assert.match(script, /without changing its version/);
assert.doesNotMatch(script, /Copy-Item[^\n]+package\.json/);
});