Release ForgeFlow 0.6.0
@@ -1,5 +1,33 @@
|
||||
# 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
|
||||
|
||||
- Made release verification and built-in update validation independent of Windows Bash shims.
|
||||
@@ -32,8 +60,6 @@
|
||||
- Added deterministic SSH inspection contract coverage.
|
||||
- Released as one complete clean source archive.
|
||||
|
||||
# Changelog
|
||||
|
||||
## 0.4.2
|
||||
|
||||
- Replaced nested SSH Bash quoting with a single-line base64 transport.
|
||||
|
||||
@@ -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`.
|
||||
@@ -1,15 +1,19 @@
|
||||
param(
|
||||
[string]$Remote = "git@gitea.itworx.tech:Jens/ForgeFlow.git",
|
||||
[string]$Branch = "main"
|
||||
[string]$Branch = "main",
|
||||
[string]$InstalledSource = "C:\Projects\ForgeFlow"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$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." }
|
||||
$version = [string]$manifest.version
|
||||
$temp = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-publish-" + [guid]::NewGuid().ToString("N"))
|
||||
$clone = Join-Path $temp "ForgeFlow"
|
||||
$publishedCommit = $null
|
||||
|
||||
try {
|
||||
Write-Host "Validating ForgeFlow $version before publishing..." -ForegroundColor Cyan
|
||||
@@ -17,6 +21,7 @@ try {
|
||||
try {
|
||||
& cmd.exe /d /s /c "npm install --no-audit --no-fund"
|
||||
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"
|
||||
if ($LASTEXITCODE -ne 0) { throw "ForgeFlow quality gate failed." }
|
||||
} finally { Pop-Location }
|
||||
@@ -32,17 +37,50 @@ try {
|
||||
Push-Location $clone
|
||||
try {
|
||||
& git add -A
|
||||
$changes = & git status --porcelain
|
||||
if (-not $changes) {
|
||||
Write-Host "Gitea already contains ForgeFlow $version; nothing to publish." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) { throw "Could not stage the release source." }
|
||||
$changes = @(& git status --porcelain)
|
||||
if ($changes.Count -gt 0) {
|
||||
& git commit -m "Release ForgeFlow $version"
|
||||
if ($LASTEXITCODE -ne 0) { throw "Could not create the release commit." }
|
||||
& git push origin $Branch
|
||||
if ($LASTEXITCODE -ne 0) { throw "Could not push ForgeFlow $version to Gitea." }
|
||||
Write-Host "ForgeFlow $version is now available on Gitea for the built-in updater." -ForegroundColor Green
|
||||
} 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 }
|
||||
|
||||
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 {
|
||||
Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue
|
||||
|
||||
@@ -26,25 +26,30 @@ credentials on the user's own computer.
|
||||
|
||||

|
||||
|
||||
## 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;
|
||||
- NUL-delimited Git pathspec transport for thousands of selected files;
|
||||
- per-repository Git mutation queues and explicit stale-lock repair;
|
||||
- one-click normalization of legacy Gitea remotes;
|
||||
- corrected password capture before the loading overlay re-renders the server form;
|
||||
- a verified publishing script for testing the built-in updater.
|
||||
- serializes every mutating Git action per repository;
|
||||
- waits through a short grace period and automatically retries after safely removing a proven stale lock;
|
||||
- detects `HEAD.lock`, `index.lock`, ref locks and worktree locks while skipping object storage;
|
||||
- provides repository-specific actions for fetch, fast-forward, push and backed-up divergence reset;
|
||||
- creates a `forgeflow/backup-*` safety branch before any reset to upstream.
|
||||
|
||||
- large changed-file sets scroll independently;
|
||||
- commit actions explain the missing prerequisite and auto-stage selected files;
|
||||
- ITWorx.tech branding is integrated;
|
||||
- the app can update itself from `Jens/ForgeFlow` in the configured Gitea instance;
|
||||
- SSH / Unraid servers can be configured once with pinned host identity;
|
||||
- exact commits can be deployed to `/mnt/user/appdata/<project>`;
|
||||
- existing Compose definitions and untracked runtime data are preserved;
|
||||
- a basic Unraid-compatible Compose file can be generated for simple new projects.
|
||||
### SSH / Unraid and DockerMan
|
||||
|
||||
- reconciles interrupted deployments against the live SHA, container state and health;
|
||||
- applies DockerMan WebUI, icon and shell labels through an override and writes a persistent XML template fallback;
|
||||
- keeps the visible container name such as `Portfolio` while enforcing lowercase internal service/image identities;
|
||||
- includes a built-in high-contrast ITWorx icon, local PNG upload, persistent DockerMan image storage and cache refresh;
|
||||
- exposes **Open Web UI**, **Repair DockerMan integration** and **Reconcile** directly on deployment cards.
|
||||
|
||||
### 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
|
||||
[LumaOps audit](docs/LUMAOPS_SERVER_AUDIT.md).
|
||||
|
||||
@@ -1,111 +1,123 @@
|
||||
755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 .gitignore
|
||||
330b5ef8c603a39bc04e2eb5b67351c957d43fb8b60b595e3c055ea5facc2162 CHANGELOG.md
|
||||
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db LICENSE
|
||||
7769cb56a09533305837c155ce8212c55a5f12a933a9e5f72d6f69853c66e274 Publish-ForgeFlow-Release.ps1
|
||||
2460ec9580231f9786a5f3dfdfdc58e3aecdc57d0b43dfdec3344445275d0350 README.md
|
||||
e5414be56177664a12d31f8d668d6273628c62ae1a608b9f61580c2f9359bbf1 START_HERE.md
|
||||
8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea UPDATE_FROM_0.3.2.md
|
||||
7fbfbba99e6f38029b9a77c8fad8a8e5a91c186306e9e167aae5357c666aab2e build/icon-128.png
|
||||
2e4519b3fff1f06c2f706b11ab3ee87c5100092df6c6e03763c2e3010dfdca8f build/icon-16.png
|
||||
5e331c05fc5f76beb229ebc973ca4c88ad6663cef23ed38b67b03d233275371d build/icon-256.png
|
||||
d6342683ccadb6d5ae4b4013f4b4fa8dfb3c27d2d3449ba23e82c4dd4f68cb46 build/icon-32.png
|
||||
1a58c1454ec6bf39509f6c56094ee9927ac1fac71d4a52d4e35068ead80dd696 build/icon-48.png
|
||||
c2b306a28dc374700c857a482a28ebc805845141e8eba1907486eeec9a76aba4 build/icon-512.png
|
||||
4d2adb598bf4ee3593f7293009a7dd2739742f44234daf4ecebd8b77bc74608f build/icon-64.png
|
||||
ca6fbcee9cd986155c760953ab4a56cf6eea448ba8adf227412e7069aa4e211f build/icon.ico
|
||||
c2b306a28dc374700c857a482a28ebc805845141e8eba1907486eeec9a76aba4 build/icon.png
|
||||
6337a7d0791e8749e3a576d7735257b0031db7a9eff4a2d6818cc251544774d2 build-windows.ps1
|
||||
2d9836ae6d576bab5494b9f094bc673e5ca4772bf5771006583d1bc46fe46698 docs/ARCHITECTURE.md
|
||||
30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 docs/DEPLOYMENT_SETUP.md
|
||||
eb42f979666e05d51c587e4223282914926a2b9b1ade9f3fb75525019ce7f738 docs/DIAGNOSTICS.md
|
||||
a0cd06a96f23a94e118feb012be0fa1ac51345951cb2ba8e67fb8c889c4c342a docs/LUMAOPS_SERVER_AUDIT.md
|
||||
a0c00ff76acd1682bb5e0e8dcf6589c9480da436c9c6d30780a1ed58b4dad94f docs/RELEASE_NOTES_0.2.0.md
|
||||
5773ead01aa4c522c556295553787482d01b1f5242f053b2c61f120c4de4fa76 docs/RELEASE_NOTES_0.3.0.md
|
||||
d46de73cf6c4cd5c2ba3f455a7a2af2e0d64ee9d94a97fd1a0bfb44e35c1624a docs/RELEASE_NOTES_0.3.1.md
|
||||
0d697d241a08d2427a6e7f5c2f27bd1830a41836a01e08eeff239c7ad5d89982 docs/RELEASE_NOTES_0.3.2.md
|
||||
bc6933c303d3d9b3bfdbf678cae1a717bfe5a893780a1871af8b48589f62f0e3 docs/RELEASE_NOTES_0.4.0.md
|
||||
343862445061e1a8282a7aa9b2304e7d799e58f9956d50eb5352db18d790efad docs/RELEASE_NOTES_0.4.1.md
|
||||
e2d67c816a919f00f9e26bf59cf29e5e8cf894536b743d282075c646c5accc96 docs/RELEASE_NOTES_0.4.2.md
|
||||
1aef74fb109541903c4dbc4d9c48d2bd63507420eaf8cceb31890797a5e4f5fd docs/RELEASE_NOTES_0.4.3.md
|
||||
85fecec65f7687e1382547166eff62777613825a8d81960dfcb4ae16aa15c8be docs/RELEASE_NOTES_0.4.4.md
|
||||
5cd0cffecdce942fb1024a0410568174e7704af9bacbe42f81891693a1817a19 docs/RELEASE_NOTES_0.4.5.md
|
||||
f9554c10f56d41d916330f06175b07f099c9ed1534f00abc9ea7f94be70f4a97 docs/RELEASE_NOTES_0.5.0.md
|
||||
11a932e2c401c53d117175aa312b6d20508918b12192d37c5084bd54a5ea3a72 docs/RELEASE_NOTES_0.5.1.md
|
||||
d7d007e4c2807698db07b2ebe1cb48c36bd162bf4daad77c9d299096c9654d5a docs/RELEASE_NOTES_0.5.2.md
|
||||
c465f1a9c4454c9a18f38f68a243037b8897c2c9929077a586604acd4ff26d35 docs/ROADMAP.md
|
||||
322624242d246d07180cc719e14c91e8fb69e123676a02e5046f4e576cca1ca1 docs/SECURITY.md
|
||||
c79123aa4c718ac3ab0d79771f2967710c28f939b58fca0094b02e3172f2c024 docs/SETUP_GUIDE.md
|
||||
b5ba1f7580e47e1f01900964b866d9f15b973a9e9dcccf2650f403595020e949 docs/SSH_UNRAID_DEPLOYMENT.md
|
||||
b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 docs/STATUS_ENDPOINT.md
|
||||
0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 docs/STITCH_REVIEW.md
|
||||
08640f1b5e26048b5ae501909f415d2426b07cc316a0bd2178023f2457aa7a2a docs/TEST_MATRIX.md
|
||||
6794a12b10ce7f35223862b422c202e5ba0308eabedf03339dea0d54f8f3d191 docs/UPDATING.md
|
||||
1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 docs/screenshots/deploy-confirmation.png
|
||||
b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa docs/screenshots/deployment-run.png
|
||||
070e6700bdae8c628c907ba181bbf0dde0bbbbb4208f7a875503f933ff1b882e docs/screenshots/deployment-success.png
|
||||
bcb1e4daf1eeedc5b3f61d2406f1a65312dba130082528007e1629d9df99570a docs/screenshots/overview.png
|
||||
224e34ab45877bbb97b07d2a14c4a5aa6e28339522a8015b33a2a81477177143 docs/screenshots/repository-workspace.png
|
||||
c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 examples/gitea-actions/deploy.yml
|
||||
4c792cc9fd57ed36da291300c252a6ef75b08a249cf6f2561e23c4c22522138a examples/gitea-actions/rollback.yml
|
||||
1d2cde1bef4882f56006823d2806f6105882fa098a665a303150fdf18ada2004 examples/server/forgeflow-deploy
|
||||
4fe3eee5c2d8705964c24b8c4dd909883a05e7d6eb85629c84b0d64473e0a92b examples/server/forgeflow-runner.sudoers
|
||||
0423fe2cc7f43fe793986a3f62a395668897cdf07348756aa7742a8cd40ac51c examples/server/forgeflow-targets.conf
|
||||
106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a examples/server/nginx-forgeflow-status.conf
|
||||
2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a examples/server/status-example.json
|
||||
6e4ef7ec12358d756d2ee6105420a5a440ba8d207a7ee26a35c873c24661d84d main.cjs
|
||||
a8f32272f09ca019c6e9b3c92298b47558c1731ea761ae00d93ae77501b91a7f package.json
|
||||
97af4d20a26dbf4f9643c4ff007bb48840231a245b02dee9c004fcd1fc6d888f preload.cjs
|
||||
a39ab8ac36fc81c37c1718ec620d4e590a1c404d01264e07e3c904a5189bdc27 scripts/apply-source-update.ps1
|
||||
f427dfcd7b5ee7079de13633c8d7d22a91115e0bbc4f2a9a96246f42176d4880 scripts/doctor.mjs
|
||||
444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 scripts/serve-demo.mjs
|
||||
3d6b25c37c92607dbc08b4b6303addb6fb2a28dc2845a5d4eeb9c48deeae7818 scripts/verify.mjs
|
||||
92524adae60aced3af23f8afe82c011873ae9f1e53d854e4d12a94e8d1be1aa9 setup-windows.ps1
|
||||
366c1edbc90a00fcbf660002e55291ba234d896e7afbe24002d9d6db84b9f44c src/main/config-store.cjs
|
||||
a970ff3f47d1641bf1ab9611e1122349aa65ff8fee4789585e078431368b8c6b src/main/deployment-service.cjs
|
||||
c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c src/main/diagnostics-service.cjs
|
||||
1558fccc76d4eb563940e51b5d483edfe5e0c7987a35ad1dbfcd5f406eb4d44d src/main/git-service.cjs
|
||||
ab7344b1951c87e982cab5c293891bc45dad48a48a3b4b63991b0e76ef785ba6 src/main/gitea-service.cjs
|
||||
2d273442b55d7e3ddf0b1cd4c606a162a4ead03c44028e149976a9e1f4f0470a src/main/ipc.cjs
|
||||
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c src/main/log-redaction.cjs
|
||||
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e src/main/preflight-service.cjs
|
||||
1dc0c997bd2d837f3d27dff58a9443888597b7981c8a1dd1eaa4487176ef716c src/main/process-runner.cjs
|
||||
e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 src/main/repository-monitor.cjs
|
||||
eca26673564284fce8715bd74201e59fa390800926d968d642d07eec5cc3af66 src/main/repository-service.cjs
|
||||
65db01a05d842c40bb784c34560870b2e9c3b973c088fcc5ce0db654a585b146 src/main/ssh-service.cjs
|
||||
db7fa63d85cec92afc9017207495571e7c5ec215aca02cf2aa18e6b8e265ba27 src/main/unraid-deployment-service.cjs
|
||||
2b39c0c1e84da52026dc95c9962c4976b7f69bc636b186dc8a41f9d4904f208a src/main/update-service.cjs
|
||||
7ec82a1d4f6d74b44f4bf4a9641f66195c07d82937fa3c89c681ba9d751370fb src/renderer/app.js
|
||||
2f3448ddaa016105769d20cbe30c461fd7ba5d3bd105865b750aa0ef6b66e1af src/renderer/assets/itworx-mark.png
|
||||
37f7da5a438b88be731c45c027a0fd88d08bd1af3150afa787836fa7baadbc48 src/renderer/assets/itworx-wordmark.png
|
||||
0fc26fbc70918e92586098fb0ee5c2f9946758020f930a08a005b270794b5998 src/renderer/index.html
|
||||
37b2dce2a55befd968f589d3ff29b66bb407162c9d0de5e3ffe833ac5c7556ec src/renderer/mock-bridge.js
|
||||
9908a81d4f5d23313eaea042a3593d8107984272698d131ebe4ea246a12f013c src/renderer/styles.css
|
||||
0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d src/shared/clone-target.cjs
|
||||
029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f src/shared/git-status.cjs
|
||||
2778ebcbdf60fdc1cb0749f15565e0e1bd66f3a0d31eb70ae7942a7511a3de75 src/shared/repository-match.cjs
|
||||
7f4d057a3c8e8d22eda9477eea7b144237824ef0f514737831d1881ff8e7f4a4 src/shared/semver.cjs
|
||||
ede2c95bb045c0005a3931709a0116d9fbcb3faa5f609848a0066c6ba382ca0b src/shared/shell-verification.cjs
|
||||
2daa98fd421598bfe5fc9757c9b6f4d82c31d1bfece15829928473581d5d2639 src/shared/tool-invocation.cjs
|
||||
a97c83b8023d6c0cf49d6f2d5b626ef2341f02670f0de170e840026d28fd1f0e src/shared/validation.cjs
|
||||
13b731c38863b1007b0312fd9d89562401b7cce875c952f52429bde74f77a8af src/shared/zip-writer.cjs
|
||||
454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 tests/clone-target.test.mjs
|
||||
abb65b39f285da518a48be41aff40d89ceb9c5b0e6091772c2bde171f65daf9b tests/deployment-status.test.mjs
|
||||
fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 tests/diagnostics.test.mjs
|
||||
625d600edf89dadc63e7c7989227d6509bd6f43a298f88804e2ee42afe2036c3 tests/git-integration.test.mjs
|
||||
5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db tests/git-status.test.mjs
|
||||
681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 tests/git-workflows.test.mjs
|
||||
e914b2bcafbd674c06adfd9bd851ca04e134210691b7f91cd3de26cee37ef5f3 tests/gitea-actions.test.mjs
|
||||
caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 tests/log-redaction.test.mjs
|
||||
c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 tests/preflight.test.mjs
|
||||
462fffc71845d6e79f07e1298ef9de7648a8d680087db377ed4901d9ae96a732 tests/renderer-workflow.test.mjs
|
||||
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 tests/repository-matching.test.mjs
|
||||
f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 tests/repository-monitor.test.mjs
|
||||
3c71aa5fb30d9c6fbc4b0ccfcf5112f45cbcc2a60cb990e4551a8813f7155505 tests/security-validation.test.mjs
|
||||
ecfdad2a03c24898c822fcf05abac89c8f8fe452a05b16fdafc0236a64c27a23 tests/semver.test.mjs
|
||||
020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 tests/shell-verification.test.mjs
|
||||
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be tests/tool-invocation.test.mjs
|
||||
80f748687bc3fb72812388faf34732c8e71cbccd1f9258bc95b7f32c34eb3a84 tests/unraid-deployment.test.mjs
|
||||
cfc143a618be64456512313f0b244c1310e9c79ce94b7dacb1b627d9de26f750 tests/update-service.test.mjs
|
||||
4d1f0a4c46190ca72b51fddf79ec6d4d02e65fa6f42ef3755de5d414f7da75bb tests/validation.test.mjs
|
||||
7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d tests/zip-writer.test.mjs
|
||||
3ea68269b66f639b3aba50c9605ccbfaa32c22a1d2cc842d8296c4ee59df6212 update-windows.ps1
|
||||
ForgeFlow 0.6.0 source manifest
|
||||
SHA-256 BYTES PATH
|
||||
(The manifest excludes itself and generated release archives.)
|
||||
755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 58 .gitignore
|
||||
aeb3772c830e37f23eca14c61d527e0021a69165e0c2bf7b9bb9374b7a409ec9 6195 CHANGELOG.md
|
||||
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE
|
||||
217817c7e10a287f852735f412c25098c0983866d0b45769c828534912895c1a 347 OVERLAY-INSTRUCTIONS.md
|
||||
581214b50ae378af44d773016ec7899d73e4027c523ab1f083590ae9171569fd 4927 Publish-ForgeFlow-Release.ps1
|
||||
a94b84bb0c568b7c4f7df12f86a3fcca93ec2ae5ce25597e0a2d6feb6ea31106 13355 README.md
|
||||
058aeaa5d9bfe377c7e322f213c7871ecc4151b5d08ef790992f4ee28d857658 743 START-FORGEFLOW-OVERLAY.ps1
|
||||
c7b1ecc475931577a914c94a326fbf25ec4a4a742286f6f5c2bae8a38528f6c0 2098 START_HERE.md
|
||||
8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea 767 UPDATE_FROM_0.3.2.md
|
||||
0970821475a4452aa19e447e9397a95db836791f16890a1a83fd748ac033dc86 8830 build/icon-128.png
|
||||
09112c1425ca953d8dd8b2bcfd221e5a84b9f81752f7168f360e295030cbc8f2 521 build/icon-16.png
|
||||
510aa27935a63ad16cc22978ccfde3bdd441cb970ad42d9f05af52c0e5999195 28923 build/icon-256.png
|
||||
fd895bf8f1772359110432b89fffa2efbf7785a8a3d973a99e429930de559b8c 1291 build/icon-32.png
|
||||
ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263 build/icon-48.png
|
||||
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon-512.png
|
||||
4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183 3364 build/icon-64.png
|
||||
25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico
|
||||
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png
|
||||
6337a7d0791e8749e3a576d7735257b0031db7a9eff4a2d6818cc251544774d2 1704 build-windows.ps1
|
||||
2d9836ae6d576bab5494b9f094bc673e5ca4772bf5771006583d1bc46fe46698 8296 docs/ARCHITECTURE.md
|
||||
30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md
|
||||
eb42f979666e05d51c587e4223282914926a2b9b1ade9f3fb75525019ce7f738 4616 docs/DIAGNOSTICS.md
|
||||
a0cd06a96f23a94e118feb012be0fa1ac51345951cb2ba8e67fb8c889c4c342a 5007 docs/LUMAOPS_SERVER_AUDIT.md
|
||||
84ae90829ecd0eb9b56b7c7a9139e12a86f74c3d938c29b63020286955d317ca 4508 docs/RELEASE_AUDIT_0.6.0.md
|
||||
a0c00ff76acd1682bb5e0e8dcf6589c9480da436c9c6d30780a1ed58b4dad94f 1770 docs/RELEASE_NOTES_0.2.0.md
|
||||
5773ead01aa4c522c556295553787482d01b1f5242f053b2c61f120c4de4fa76 5963 docs/RELEASE_NOTES_0.3.0.md
|
||||
d46de73cf6c4cd5c2ba3f455a7a2af2e0d64ee9d94a97fd1a0bfb44e35c1624a 1093 docs/RELEASE_NOTES_0.3.1.md
|
||||
0d697d241a08d2427a6e7f5c2f27bd1830a41836a01e08eeff239c7ad5d89982 2445 docs/RELEASE_NOTES_0.3.2.md
|
||||
bc6933c303d3d9b3bfdbf678cae1a717bfe5a893780a1871af8b48589f62f0e3 2160 docs/RELEASE_NOTES_0.4.0.md
|
||||
343862445061e1a8282a7aa9b2304e7d799e58f9956d50eb5352db18d790efad 1134 docs/RELEASE_NOTES_0.4.1.md
|
||||
e2d67c816a919f00f9e26bf59cf29e5e8cf894536b743d282075c646c5accc96 1605 docs/RELEASE_NOTES_0.4.2.md
|
||||
1aef74fb109541903c4dbc4d9c48d2bd63507420eaf8cceb31890797a5e4f5fd 670 docs/RELEASE_NOTES_0.4.3.md
|
||||
85fecec65f7687e1382547166eff62777613825a8d81960dfcb4ae16aa15c8be 617 docs/RELEASE_NOTES_0.4.4.md
|
||||
5cd0cffecdce942fb1024a0410568174e7704af9bacbe42f81891693a1817a19 378 docs/RELEASE_NOTES_0.4.5.md
|
||||
f9554c10f56d41d916330f06175b07f099c9ed1534f00abc9ea7f94be70f4a97 1097 docs/RELEASE_NOTES_0.5.0.md
|
||||
11a932e2c401c53d117175aa312b6d20508918b12192d37c5084bd54a5ea3a72 957 docs/RELEASE_NOTES_0.5.1.md
|
||||
d7d007e4c2807698db07b2ebe1cb48c36bd162bf4daad77c9d299096c9654d5a 721 docs/RELEASE_NOTES_0.5.2.md
|
||||
3e77df12a7ff4b545069933410bf14fe8891f39915182df25112c722cf4e243d 1030 docs/RELEASE_NOTES_0.5.3.md
|
||||
61f6cbc1c3f263fa96b5c6a70a26baa7cd577d37ac633455eed45d9b63169a35 710 docs/RELEASE_NOTES_0.5.4.md
|
||||
9f72a5d039615785ccd4771f38b060bb58386b3217ba2a56459067cebbbb812f 4875 docs/RELEASE_NOTES_0.6.0.md
|
||||
c465f1a9c4454c9a18f38f68a243037b8897c2c9929077a586604acd4ff26d35 3655 docs/ROADMAP.md
|
||||
322624242d246d07180cc719e14c91e8fb69e123676a02e5046f4e576cca1ca1 5569 docs/SECURITY.md
|
||||
c79123aa4c718ac3ab0d79771f2967710c28f939b58fca0094b02e3172f2c024 13067 docs/SETUP_GUIDE.md
|
||||
43f73b6674ee0c5d7ace8ab39db95b5b42b61c73dd79ffc8d87fe214d8b51d7e 5070 docs/SSH_UNRAID_DEPLOYMENT.md
|
||||
b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md
|
||||
0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md
|
||||
08640f1b5e26048b5ae501909f415d2426b07cc316a0bd2178023f2457aa7a2a 3978 docs/TEST_MATRIX.md
|
||||
95b5b2915a11065a22eb2822ecce9fb30fec0c08874d63c8ffb5182cd20b3459 2328 docs/UPDATING.md
|
||||
1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png
|
||||
b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png
|
||||
070e6700bdae8c628c907ba181bbf0dde0bbbbb4208f7a875503f933ff1b882e 118819 docs/screenshots/deployment-success.png
|
||||
bcb1e4daf1eeedc5b3f61d2406f1a65312dba130082528007e1629d9df99570a 153240 docs/screenshots/overview.png
|
||||
224e34ab45877bbb97b07d2a14c4a5aa6e28339522a8015b33a2a81477177143 135102 docs/screenshots/repository-workspace.png
|
||||
c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 examples/gitea-actions/deploy.yml
|
||||
4c792cc9fd57ed36da291300c252a6ef75b08a249cf6f2561e23c4c22522138a 1477 examples/gitea-actions/rollback.yml
|
||||
1d2cde1bef4882f56006823d2806f6105882fa098a665a303150fdf18ada2004 5705 examples/server/forgeflow-deploy
|
||||
4fe3eee5c2d8705964c24b8c4dd909883a05e7d6eb85629c84b0d64473e0a92b 258 examples/server/forgeflow-runner.sudoers
|
||||
0423fe2cc7f43fe793986a3f62a395668897cdf07348756aa7742a8cd40ac51c 569 examples/server/forgeflow-targets.conf
|
||||
106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf
|
||||
2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json
|
||||
1e47552cfde3ca925ba1f24fbc471f3fc29468ca7ef3351beb9bde2bf0747bc7 7887 main.cjs
|
||||
70ffe80e89f979ca1e72c6c1ea75b6456e685485aae53447ecbd4a4de16d104a 2708 package.json
|
||||
0cd434cb21af86e7f6983416e4ed14762565df90edc2d00d4a60378df75b7419 6846 preload.cjs
|
||||
118c2600734d9a25310a148f791f8adeec793b186c3c3c764790c323c73ecfb6 8663 scripts/apply-source-update.ps1
|
||||
f427dfcd7b5ee7079de13633c8d7d22a91115e0bbc4f2a9a96246f42176d4880 3596 scripts/doctor.mjs
|
||||
444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs
|
||||
4300ef0cee50d1aaf1f63be7f9884e67b2411b8f6743f48e5aa26fa724d4e428 9821 scripts/verify.mjs
|
||||
92524adae60aced3af23f8afe82c011873ae9f1e53d854e4d12a94e8d1be1aa9 2075 setup-windows.ps1
|
||||
87885d640a1148078426522c87d7c9b7fced1fabb371020a4781bb94b256ee00 19664 src/main/config-store.cjs
|
||||
a970ff3f47d1641bf1ab9611e1122349aa65ff8fee4789585e078431368b8c6b 23655 src/main/deployment-service.cjs
|
||||
c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs
|
||||
c50bf93d0d1abfc0319e59a545877413bd3aca1e5b57c9a48a2467db5e89d7f8 25731 src/main/git-service.cjs
|
||||
ab7344b1951c87e982cab5c293891bc45dad48a48a3b4b63991b0e76ef785ba6 12759 src/main/gitea-service.cjs
|
||||
743002a5dae3c8e6aa5236609dd58c786420408c321eebf6d6b9e32a684cd696 26042 src/main/ipc.cjs
|
||||
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs
|
||||
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs
|
||||
1dc0c997bd2d837f3d27dff58a9443888597b7981c8a1dd1eaa4487176ef716c 1520 src/main/process-runner.cjs
|
||||
e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508 src/main/repository-monitor.cjs
|
||||
a302bdcfbf2e2b66fdb4e13d94cc7a78cd4065a779b980f4487b6075a6472017 7583 src/main/repository-service.cjs
|
||||
ad9e8b67bd10f2f5708d00ebacf660ab4917dd02b3d22b3b6d730bcb2a7e1c18 8124 src/main/ssh-service.cjs
|
||||
a716f7402d3039296f99f1a7958a9aa521032f091e4171e224fa548d131a4183 49354 src/main/unraid-deployment-service.cjs
|
||||
86f6b762e85de29dc36a53427f9f62f0fb21b2e389bd7ce6ad747e527c28e7e7 11322 src/main/update-service.cjs
|
||||
cea387e7996de8d185cd11f7d8d4e0675a58df1ca8f3cb6c3c6d6363c72f5f12 150880 src/renderer/app.js
|
||||
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png
|
||||
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png
|
||||
094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png
|
||||
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png
|
||||
394f901b0e4add6b788c006d9869a66dc021dcebf2916f5a732bc42786510efc 740 src/renderer/index.html
|
||||
4f3d7fa6126fe609a64cbe2de4817c6a9f2b6b31ba8abee4f8e35a93a185c601 39109 src/renderer/mock-bridge.js
|
||||
6f0bd7b898677b9bcf6a104d7b1292b9ed5db826e4afdc86907e1c55bd889456 48072 src/renderer/styles.css
|
||||
0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs
|
||||
029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs
|
||||
2778ebcbdf60fdc1cb0749f15565e0e1bd66f3a0d31eb70ae7942a7511a3de75 1295 src/shared/repository-match.cjs
|
||||
7f4d057a3c8e8d22eda9477eea7b144237824ef0f514737831d1881ff8e7f4a4 1120 src/shared/semver.cjs
|
||||
ede2c95bb045c0005a3931709a0116d9fbcb3faa5f609848a0066c6ba382ca0b 2906 src/shared/shell-verification.cjs
|
||||
2daa98fd421598bfe5fc9757c9b6f4d82c31d1bfece15829928473581d5d2639 1210 src/shared/tool-invocation.cjs
|
||||
a97c83b8023d6c0cf49d6f2d5b626ef2341f02670f0de170e840026d28fd1f0e 5202 src/shared/validation.cjs
|
||||
13b731c38863b1007b0312fd9d89562401b7cce875c952f52429bde74f77a8af 3096 src/shared/zip-writer.cjs
|
||||
454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 4318 tests/clone-target.test.mjs
|
||||
abb65b39f285da518a48be41aff40d89ceb9c5b0e6091772c2bde171f65daf9b 7523 tests/deployment-status.test.mjs
|
||||
fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800 tests/diagnostics.test.mjs
|
||||
efee4d7ae2a51b27d8643bd40e896aaddcdfdaa98bd4ac16ec83670b82b13791 12066 tests/git-integration.test.mjs
|
||||
5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db 1239 tests/git-status.test.mjs
|
||||
681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.test.mjs
|
||||
e914b2bcafbd674c06adfd9bd851ca04e134210691b7f91cd3de26cee37ef5f3 4154 tests/gitea-actions.test.mjs
|
||||
caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs
|
||||
c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 3718 tests/preflight.test.mjs
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
All credentials are entered locally in ForgeFlow during setup. Diagnostic logging
|
||||
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).
|
||||
Your Gitea token and ForgeFlow configuration are stored outside the source
|
||||
folder and are preserved.
|
||||
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.
|
||||
|
||||
|
||||
## Fast path on Windows
|
||||
|
||||
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 483 B After Width: | Height: | Size: 521 B |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 84 KiB |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,112 +1,95 @@
|
||||
# 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
|
||||
|
||||
- Enter credentials only in the local ForgeFlow desktop window.
|
||||
- Prefer an Ed25519 private key over a password.
|
||||
- ForgeFlow stores passwords and private-key passphrases through Electron safe storage.
|
||||
- The first successful test records the SSH host-key fingerprint.
|
||||
- Later connections fail closed when that fingerprint changes.
|
||||
- Diagnostics redact the Gitea token, SSH password and private-key passphrase.
|
||||
- ForgeFlow never sends arbitrary commands entered through the renderer. Deployment commands are assembled from validated profile fields.
|
||||
- Credentials are entered only in the local ForgeFlow desktop application.
|
||||
- Ed25519 private keys are preferred.
|
||||
- Passwords and key passphrases use Electron safe storage.
|
||||
- The first trusted connection records the SSH host-key fingerprint; later changes fail closed.
|
||||
- Unraid-to-Gitea repository access is tested during every deployment preflight.
|
||||
- The renderer cannot submit arbitrary shell commands. Remote scripts are assembled from validated profile fields and transported as base64-encoded Bash input.
|
||||
- Tracked server-side modifications block deployment and rollback.
|
||||
|
||||
## Configure the server
|
||||
## Profile identity
|
||||
|
||||
Open **Settings → SSH / Unraid servers → Add server**.
|
||||
|
||||
Typical Unraid values:
|
||||
ForgeFlow separates names that users see from names Docker requires:
|
||||
|
||||
```text
|
||||
Name: Unraid
|
||||
Host: 192.168.1.10
|
||||
Port: 22
|
||||
Username: root
|
||||
Base path: /mnt/user/appdata
|
||||
Auth: Private key
|
||||
Visible project/container: Portfolio
|
||||
Server folder: Portfolio
|
||||
Internal Compose project: portfolio
|
||||
Internal Compose service: portfolio
|
||||
Internal image: forgeflow/portfolio:production
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
Create a deployment profile and choose **SSH / Unraid**.
|
||||
|
||||
For an existing folder:
|
||||
|
||||
```text
|
||||
Server folder: lumaops
|
||||
Remote path: /mnt/user/appdata/lumaops
|
||||
Server folder: Portfolio
|
||||
Remote path: /mnt/user/appdata/Portfolio
|
||||
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.
|
||||
|
||||
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.
|
||||
Preflight inspects `.dockerignore` when a Dockerfile exists. It warns when `.git`, preserved runtime data or nested repositories would be sent into the build context.
|
||||
|
||||
## 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
|
||||
/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.
|
||||
Use repository Compose for real applications. Generated Compose is intended only for a simple single-service Dockerfile application with basic port mapping.
|
||||
|
||||
## Deployment sequence
|
||||
|
||||
1. Verify that the local repository is clean, on the allowed branch and fully synchronized with Gitea.
|
||||
2. Verify that the exact requested SHA exists on `origin/<branch>`.
|
||||
3. Verify the repository Compose file or Dockerfile locally.
|
||||
4. Connect through pinned SSH.
|
||||
5. Inspect the target folder.
|
||||
6. Refuse tracked server-only modifications.
|
||||
7. Clone when the folder does not exist.
|
||||
8. Fetch the configured branch without allowing interactive credential prompts.
|
||||
9. Verify again on the server that the requested full SHA belongs to `origin/<branch>`.
|
||||
10. Save the current SHA as the rollback target.
|
||||
11. Reset the working tree to the exact requested SHA.
|
||||
12. Validate the selected Compose file.
|
||||
13. Run `docker compose up -d --build --remove-orphans`.
|
||||
14. Store non-secret state under `.forgeflow/` and run the configured healthcheck.
|
||||
1. Verify clean local tree, allowed branch, upstream and ahead/behind state.
|
||||
2. Verify the exact SHA exists on the allowed remote branch.
|
||||
3. Verify Unraid can read the Gitea repository.
|
||||
4. Inspect the server folder and refuse tracked server changes.
|
||||
5. Clone when the folder is absent.
|
||||
6. Fetch the branch and verify the exact SHA is an ancestor of `origin/<branch>`.
|
||||
7. Save the previous SHA and reset to the requested SHA.
|
||||
8. Write generated Compose when selected.
|
||||
9. Write the DockerMan metadata override and persistent template fallback.
|
||||
10. Validate the merged Compose model.
|
||||
11. Run `docker compose up -d --build --remove-orphans --force-recreate`.
|
||||
12. Clear relevant icon caches.
|
||||
13. Inspect the visible container and write `.forgeflow/status.json`.
|
||||
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.
|
||||
|
||||
A later controlled migration can align names after a successful backup and downtime window.
|
||||
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.
|
||||
|
||||
## Rollback
|
||||
|
||||
After a successful deployment, the previous SHA is stored in:
|
||||
|
||||
```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.
|
||||
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.
|
||||
|
||||
@@ -1,60 +1,49 @@
|
||||
# Updating ForgeFlow on Windows
|
||||
|
||||
ForgeFlow stores its local token, server credentials, repository mappings,
|
||||
preferences, deployment profiles, diagnostics and operation history outside the
|
||||
source directory.
|
||||
ForgeFlow stores credentials, repository mappings, preferences, deployment profiles, diagnostics and operation history outside the source directory.
|
||||
|
||||
## Manual update to v0.4.0
|
||||
## Built-in source update
|
||||
|
||||
1. Close ForgeFlow.
|
||||
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:
|
||||
Open **Settings → ForgeFlow updates** and choose:
|
||||
|
||||
1. **Check now**
|
||||
2. **Download update**
|
||||
3. **Apply & restart**
|
||||
|
||||
The source updater downloads the exact branch commit, records a SHA-256 checksum,
|
||||
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 default update source is the configured Gitea instance, repository `Jens/ForgeFlow`, branch `main`.
|
||||
|
||||
The update log is written beneath ForgeFlow's local user-data `updates` folder
|
||||
and does not contain the Gitea token.
|
||||
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:
|
||||
|
||||
The updater deliberately checks the semantic version stored in the remote
|
||||
`package.json`. Merely pushing a new commit without increasing that version does
|
||||
not present an update. Publish the complete validated ForgeFlow source to the
|
||||
configured repository and bump the version for every release.
|
||||
1. waits for the old process to exit;
|
||||
2. backs up the current source;
|
||||
3. extracts and validates the requested semantic version;
|
||||
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.
|
||||
|
||||
@@ -107,7 +107,7 @@ app.whenReady().then(async () => {
|
||||
const repositories = new RepositoryService(store, git, gitea, diagnostics);
|
||||
const deployments = new DeploymentService(store, gitea, git, 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({
|
||||
store,
|
||||
gitea,
|
||||
@@ -159,13 +159,14 @@ app.whenReady().then(async () => {
|
||||
if (operationTimer) clearTimeout(operationTimer);
|
||||
const intervalMs = Math.max(3, Number(store.data.preferences.operationPollSeconds) || 5) * 1000;
|
||||
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));
|
||||
if (active) {
|
||||
const updated = await deployments.refreshActiveOperations().catch(async (error) => {
|
||||
await diagnostics.error('operation-monitor.failed', error);
|
||||
return [];
|
||||
});
|
||||
const [actions, sshOperations] = await Promise.all([
|
||||
store.getToken() ? deployments.refreshActiveOperations().catch(async (error) => { await diagnostics.error('operation-monitor.actions.failed', error); 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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "forgeflow",
|
||||
"version": "0.5.2",
|
||||
"version": "0.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "forgeflow",
|
||||
"version": "0.5.2",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"ssh2": "1.17.0"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "forgeflow",
|
||||
"version": "0.5.2",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
|
||||
"main": "main.cjs",
|
||||
@@ -57,7 +57,12 @@
|
||||
"docs/RELEASE_NOTES_0.5.0.md",
|
||||
"docs/RELEASE_NOTES_0.5.1.md",
|
||||
"Publish-ForgeFlow-Release.ps1",
|
||||
"docs/RELEASE_NOTES_0.5.2.md"
|
||||
"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": {
|
||||
"output": "dist"
|
||||
|
||||
@@ -26,6 +26,7 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({
|
||||
bootstrap: () => invoke('app:bootstrap'),
|
||||
selectDirectory: (payload) => invoke('dialog:select-directory', payload),
|
||||
selectKeyFile: (payload) => invoke('dialog:select-key-file', payload),
|
||||
selectImageFile: (payload) => invoke('dialog:select-image-file', payload),
|
||||
setupPreflight: (payload) => invoke('setup:preflight', payload),
|
||||
validateGitea: (payload) => invoke('setup:validate-gitea', 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 }),
|
||||
indexLockInfo: (localPath) => invoke('repository: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 }),
|
||||
normalizeOrigins: () => invoke('repositories:normalize-origins'),
|
||||
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 }),
|
||||
healthcheck: (url) => invoke('deployment:health', { url }),
|
||||
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 }),
|
||||
getOperation: (operationId) => invoke('operations:get', { operationId }),
|
||||
diagnosticsStatus: () => invoke('diagnostics:status'),
|
||||
|
||||
@@ -4,11 +4,15 @@ param(
|
||||
[Parameter(Mandatory=$true)][string]$ExpectedVersion,
|
||||
[Parameter(Mandatory=$true)][string]$ExpectedSha256,
|
||||
[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"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
$working = $null
|
||||
$backup = $null
|
||||
|
||||
function Write-UpdateLog {
|
||||
param([string]$Message)
|
||||
@@ -17,6 +21,33 @@ function Write-UpdateLog {
|
||||
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 {
|
||||
param([string]$From, [string]$To)
|
||||
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" }
|
||||
}
|
||||
|
||||
function Install-ForgeFlowDependencies {
|
||||
param([string]$WorkingDirectory)
|
||||
Push-Location $WorkingDirectory
|
||||
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)
|
||||
while (Get-Process -Id $ParentPid -ErrorAction SilentlyContinue) {
|
||||
if ((Get-Date) -gt $deadline) { throw "ForgeFlow did not exit before the update timeout." }
|
||||
@@ -39,10 +99,13 @@ try {
|
||||
$extract = Join-Path $working "extract"
|
||||
$backup = Join-Path $working "backup"
|
||||
New-Item -ItemType Directory -Force -Path $extract | Out-Null
|
||||
|
||||
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
|
||||
|
||||
Write-UpdateLog "Extracting update archive."
|
||||
Write-UpdateState -State "extracting" -Message "Extracting the verified update archive."
|
||||
Expand-Archive -LiteralPath $ArchivePath -DestinationPath $extract -Force
|
||||
$manifest = Get-ChildItem -Path $extract -Filter package.json -File -Recurse |
|
||||
Where-Object {
|
||||
@@ -56,37 +119,77 @@ try {
|
||||
if (-not $manifest) { throw "The update does not contain ForgeFlow version $ExpectedVersion." }
|
||||
$incoming = Split-Path -Parent $manifest.FullName
|
||||
Write-UpdateLog "Applying verified source files."
|
||||
Write-UpdateState -State "applying" -Message "Replacing the local source with ForgeFlow $ExpectedVersion."
|
||||
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
|
||||
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."
|
||||
& cmd.exe /d /s /c "npm run check" *>> $LogPath
|
||||
if ($LASTEXITCODE -ne 0) { throw "npm run check failed with exit code $LASTEXITCODE." }
|
||||
} finally { Pop-Location }
|
||||
|
||||
Write-UpdateLog "Update validated successfully. Restarting ForgeFlow."
|
||||
Start-Process -FilePath "cmd.exe" -WorkingDirectory $SourcePath -ArgumentList "/d", "/s", "/c", "npm start"
|
||||
Remove-Item -LiteralPath $working -Recurse -Force -ErrorAction SilentlyContinue
|
||||
$completedAt = (Get-Date).ToUniversalTime().ToString("o")
|
||||
Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion was installed successfully." -Extra @{
|
||||
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
|
||||
}
|
||||
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 {
|
||||
if ($backup -and (Test-Path $backup)) {
|
||||
Write-UpdateLog "Restoring previous source version."
|
||||
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 {
|
||||
& cmd.exe /d /s /c "npm install --no-audit --no-fund" *>> $LogPath
|
||||
} finally { Pop-Location }
|
||||
Start-Process -FilePath "cmd.exe" -WorkingDirectory $SourcePath -ArgumentList "/d", "/s", "/c", "npm start"
|
||||
$rollbackRestart = Start-ForgeFlow -WorkingDirectory $SourcePath
|
||||
Write-UpdateLog "Rollback restored and ForgeFlow restarted directly with Electron PID $($rollbackRestart.Id)."
|
||||
} 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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -8,18 +8,18 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const required = [
|
||||
'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/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/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/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/tool-invocation.cjs', 'src/shared/shell-verification.cjs', 'START_HERE.md', 'README.md',
|
||||
'setup-windows.ps1', 'update-windows.ps1', 'build-windows.ps1', 'UPDATE_FROM_0.3.2.md', 'scripts/apply-source-update.ps1',
|
||||
'src/shared/tool-invocation.cjs', 'src/shared/shell-verification.cjs', 'START_HERE.md', 'README.md', 'SOURCE_MANIFEST.txt',
|
||||
'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/UPDATING.md', 'docs/DIAGNOSTICS.md', 'docs/DEPLOYMENT_SETUP.md', 'docs/SSH_UNRAID_DEPLOYMENT.md',
|
||||
'docs/LUMAOPS_SERVER_AUDIT.md', 'docs/STATUS_ENDPOINT.md', 'docs/TEST_MATRIX.md', 'docs/RELEASE_NOTES_0.4.0.md', 'docs/RELEASE_NOTES_0.4.1.md', 'docs/RELEASE_NOTES_0.4.2.md', 'docs/RELEASE_NOTES_0.4.3.md',
|
||||
'docs/RELEASE_NOTES_0.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',
|
||||
'examples/gitea-actions/deploy.yml', 'examples/gitea-actions/rollback.yml',
|
||||
'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));
|
||||
|
||||
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 [name, version] of Object.entries(packageJson[group] || {})) {
|
||||
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 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 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')) {
|
||||
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/')) {
|
||||
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}`);
|
||||
}
|
||||
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 styles = await readFile(path.join(root, 'src/renderer/styles.css'), 'utf8');
|
||||
const preload = await readFile(path.join(root, 'preload.cjs'), 'utf8');
|
||||
const ipc = await readFile(path.join(root, 'src/main/ipc.cjs'), 'utf8');
|
||||
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 (!styles.includes('.file-list { flex: 1 1 auto;') || !styles.includes('.main-canvas.repository-canvas')) {
|
||||
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}`);
|
||||
}
|
||||
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}.`);
|
||||
|
||||
@@ -7,7 +7,7 @@ const { safeStorage } = require('electron');
|
||||
const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
schemaVersion: 5,
|
||||
schemaVersion: 7,
|
||||
setupComplete: false,
|
||||
appearance: 'dark',
|
||||
gitea: { baseUrl: '', user: null, encryptedToken: null },
|
||||
@@ -58,7 +58,20 @@ class ConfigStore {
|
||||
gitea: { ...DEFAULT_CONFIG.gitea, ...(source.gitea || {}) },
|
||||
workspaceRoots: uniqueStrings(source.workspaceRoots),
|
||||
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 : {},
|
||||
favorites: uniqueStrings(source.favorites).map((item) => item.toLowerCase()),
|
||||
updates: { ...DEFAULT_CONFIG.updates, ...(source.updates || {}) },
|
||||
@@ -300,13 +313,27 @@ class ConfigStore {
|
||||
serverId: String(profile.serverId || '').trim(),
|
||||
remoteFolder,
|
||||
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) : '',
|
||||
alignRemote: profile.alignRemote === true,
|
||||
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,
|
||||
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' }),
|
||||
iconFilePath: String(profile.iconFilePath || '').trim(),
|
||||
dockerShell: ['/bin/sh', '/bin/bash'].includes(profile.dockerShell) ? profile.dockerShell : '/bin/sh',
|
||||
preservePaths,
|
||||
generatedCompose: profile.generatedCompose === true
|
||||
};
|
||||
|
||||
@@ -5,6 +5,8 @@ const fs = require('node:fs/promises');
|
||||
const { run } = require('./process-runner.cjs');
|
||||
const { parsePorcelainV2 } = require('../shared/git-status.cjs');
|
||||
const { normalizeRemoteUrl } = require('../shared/repository-match.cjs');
|
||||
|
||||
const COMMON_GIT_LOCK_FILES = ['HEAD.lock', 'index.lock'];
|
||||
const {
|
||||
assertSafeRepositoryPath,
|
||||
assertRepositoryRelativePath,
|
||||
@@ -73,23 +75,156 @@ class GitService {
|
||||
});
|
||||
}
|
||||
|
||||
async getIndexLockInfo(repoPath) {
|
||||
|
||||
async gitDirectory(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const lockPath = path.join(root, '.git', 'index.lock');
|
||||
const stat = await fs.stat(lockPath).catch(() => null);
|
||||
return stat ? { exists: true, lockPath, ageMs: Math.max(0, Date.now() - stat.mtimeMs) } : { exists: false, lockPath, ageMs: 0 };
|
||||
const result = await run('git', ['rev-parse', '--path-format=absolute', '--git-dir'], { cwd: root, timeout: 15_000 });
|
||||
return { root, gitDir: path.resolve(result.stdout.trim()) };
|
||||
}
|
||||
|
||||
async removeStaleIndexLock(repoPath, minimumAgeMs = 30_000) {
|
||||
const info = await this.getIndexLockInfo(repoPath);
|
||||
if (!info.exists) return { removed: false, reason: 'missing', ...info };
|
||||
if (info.ageMs < minimumAgeMs) {
|
||||
const error = new Error('The Git index lock is recent. Close other Git tools and try again before removing it.');
|
||||
error.code = 'INDEX_LOCK_RECENT';
|
||||
isGitLockError(error) {
|
||||
const message = String(error?.message || error || '');
|
||||
return /(?:cannot lock ref|Unable to create .*\.lock|another git process)/i.test(message)
|
||||
|| COMMON_GIT_LOCK_FILES.some((lockName) => message.toLowerCase().includes(lockName.toLowerCase()));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
await fs.rm(info.lockPath, { force: true });
|
||||
return { removed: true, ...info };
|
||||
if (!report.processes.available && !allowWithoutProcessProbe) {
|
||||
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') {
|
||||
|
||||
@@ -64,7 +64,32 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
|
||||
const withRepositoryMutation = async (localPath, action) => {
|
||||
const key = path.resolve(localPath);
|
||||
const previous = repositoryMutations.get(key) || Promise.resolve();
|
||||
const current = previous.catch(() => {}).then(() => withRepositoryPause(key, action));
|
||||
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);
|
||||
try { return await current; }
|
||||
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,
|
||||
state: store.getPublicState(),
|
||||
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 }) => {
|
||||
@@ -156,6 +182,16 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
|
||||
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:validate-gitea', ({ baseUrl, token }) => gitea.validateConnection(baseUrl, token));
|
||||
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:apply', async () => {
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -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-pop', async ({ localPath, ref }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.popStash(safePath, ref)); });
|
||||
register('repository:index-lock', async ({ localPath }) => git.getIndexLockInfo(await assertKnownRepositoryPath(localPath)));
|
||||
register('repository:git-recovery-status', async ({ localPath }) => git.reconcile(await assertKnownRepositoryPath(localPath)));
|
||||
register('repository:repair-index-lock', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.removeStaleIndexLock(safePath)); });
|
||||
register('repository:repair-git-locks', async ({ localPath, force = false }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.repairStaleGitLocks(safePath, { minimumAgeMs: force ? 0 : 10_000, allowWithoutProcessProbe: force === true })); });
|
||||
register('repository:reconcile', async ({ localPath }) => git.reconcile(await assertKnownRepositoryPath(localPath)));
|
||||
register('repository:repair-sync', async ({ localPath, strategy }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.repairSync(safePath, strategy)); });
|
||||
register('repository:set-origin', async ({ localPath, remoteUrl }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.setRemoteUrl(safePath, remoteUrl)); });
|
||||
|
||||
register('repositories:normalize-origins', async () => {
|
||||
@@ -371,13 +412,26 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
|
||||
if (profile?.provider === 'ssh-unraid') return unraid.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 }) => {
|
||||
if (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.refreshActiveOperations();
|
||||
const [actions, sshOperations] = await Promise.all([deployments.refreshActiveOperations(), unraid.refreshActiveOperations()]);
|
||||
return [...actions, ...sshOperations];
|
||||
});
|
||||
register('operations:get', ({ operationId }) => store.getOperation(operationId));
|
||||
|
||||
|
||||
@@ -160,7 +160,13 @@ class RepositoryService {
|
||||
const behind = status?.branch.behind || 0;
|
||||
const conflict = Boolean(status?.counts.conflicts);
|
||||
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 preferredCloneUrl = this.store.data.preferences.preferredCloneProtocol === 'ssh'
|
||||
? (remote.ssh_url || remote.clone_url)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
const fs = require('node:fs/promises');
|
||||
const crypto = require('node:crypto');
|
||||
const path = require('node:path').posix;
|
||||
|
||||
function loadSshClient() {
|
||||
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 } = {}) {
|
||||
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 });
|
||||
|
||||
@@ -88,12 +88,36 @@ function checksSummary(checks) {
|
||||
};
|
||||
}
|
||||
|
||||
function xmlEscape(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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 {
|
||||
constructor({ store, ssh, git, diagnostics }) {
|
||||
constructor({ store, ssh, git, diagnostics, sourcePath = process.cwd(), onOperationChange = null }) {
|
||||
this.store = store;
|
||||
this.ssh = ssh;
|
||||
this.git = git;
|
||||
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) {
|
||||
@@ -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.' });
|
||||
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 {
|
||||
inspection = await this.inspect({ repository, profileId });
|
||||
if (!inspection.exists) {
|
||||
@@ -274,6 +312,19 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths"
|
||||
} catch (error) {
|
||||
checks.push({ id: 'inspection', label: 'Server project inspection', status: 'fail', detail: error.message });
|
||||
}
|
||||
const iconMode = profile.iconMode || (profile.iconFilePath ? 'upload' : profile.iconUrl ? 'url' : 'builtin');
|
||||
if (iconMode === 'upload') {
|
||||
const iconStat = await fs.stat(profile.iconFilePath).catch(() => null);
|
||||
checks.push({ id: 'dockerman-icon-file', label: 'DockerMan icon upload', status: iconStat?.isFile() && nativePath.extname(profile.iconFilePath).toLowerCase() === '.png' ? 'pass' : 'fail', detail: iconStat?.isFile() ? profile.iconFilePath : 'The selected local PNG icon file was not found.' });
|
||||
} else if (iconMode === 'builtin') {
|
||||
const builtinIcon = nativePath.join(this.sourcePath, 'src', 'renderer', 'assets', 'itworx-mark.png');
|
||||
const iconStat = await fs.stat(builtinIcon).catch(() => null);
|
||||
checks.push({ id: 'dockerman-icon-builtin', label: 'DockerMan icon', status: iconStat?.isFile() ? 'pass' : 'fail', detail: iconStat?.isFile() ? 'Built-in high-contrast ITWorx mark.' : 'The built-in ITWorx icon asset is missing.' });
|
||||
} else if (iconMode === 'url') checks.push({ id: 'dockerman-icon', label: 'DockerMan icon', status: profile.iconUrl ? 'pass' : 'fail', detail: profile.iconUrl || 'Icon URL mode requires an HTTPS or HTTP PNG URL.' });
|
||||
else checks.push({ id: 'dockerman-icon', label: 'DockerMan icon', status: 'warning', detail: 'Custom DockerMan icon disabled.' });
|
||||
const webUiLabel = this.dockerManWebUi(profile);
|
||||
checks.push({ id: 'dockerman-webui', label: 'DockerMan Web UI action', status: webUiLabel ? 'pass' : 'warning', detail: webUiLabel || 'No Web UI URL or host port is configured.' });
|
||||
checks.push({ id: 'compose-identity', label: 'Safe Docker Compose identity', status: 'pass', detail: `Internal project/image: ${this.internalSlug(profile, repository)}; visible container: ${profile.containerName || profile.remoteFolder || repository.name}.` });
|
||||
checks.push({ id: 'exact-sha', label: 'Exact deployment commit', status: 'pass', detail: targetSha });
|
||||
return {
|
||||
provider: 'ssh-unraid',
|
||||
@@ -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) {
|
||||
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.');
|
||||
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 [
|
||||
'services:',
|
||||
` ${service}:`,
|
||||
` image: forgeflow/${this.internalSlug(profile, repository)}:${String(profile.environment || 'production').toLowerCase()}`,
|
||||
' build:',
|
||||
' context: ..',
|
||||
` container_name: ${service}`,
|
||||
` container_name: ${containerName}`,
|
||||
' restart: unless-stopped',
|
||||
' ports:',
|
||||
` - "${profile.hostPort}:${profile.containerPort}"`,
|
||||
...(labels.length ? [' labels:', ...labels.map((label) => ` - ${JSON.stringify(label)}`)] : [])
|
||||
` - "${profile.hostPort}:${profile.containerPort}"`
|
||||
].join('\n') + '\n';
|
||||
}
|
||||
|
||||
dockerManWebUi(profile) {
|
||||
if (profile.hostPort) {
|
||||
let suffix = '/';
|
||||
try {
|
||||
const parsed = profile.webUiUrl ? new URL(profile.webUiUrl) : null;
|
||||
suffix = parsed ? `${parsed.pathname || '/'}${parsed.search || ''}${parsed.hash || ''}` : '/';
|
||||
} catch {}
|
||||
if (!suffix.startsWith('/')) suffix = `/${suffix}`;
|
||||
return `http://[IP]:[PORT:${profile.hostPort}]${suffix}`;
|
||||
}
|
||||
return profile.webUiUrl || '';
|
||||
}
|
||||
|
||||
dockerManShell(profile) {
|
||||
return String(profile.dockerShell || '/bin/sh').toLowerCase().includes('bash') ? 'bash' : 'sh';
|
||||
}
|
||||
|
||||
dockerManTemplatePath(profile, repository) {
|
||||
const containerName = String(profile.containerName || profile.remoteFolder || repository.name || 'app').replace(/[^A-Za-z0-9._-]/g, '-') || 'app';
|
||||
return `/boot/config/plugins/dockerMan/templates-user/my-${containerName}.xml`;
|
||||
}
|
||||
|
||||
dockerManTemplate(profile, repository, iconReference = '') {
|
||||
const containerName = String(profile.containerName || profile.remoteFolder || repository.name || 'app').replace(/[^A-Za-z0-9._-]/g, '-') || 'app';
|
||||
const slug = this.internalSlug(profile, repository);
|
||||
const environment = String(profile.environment || 'production').toLowerCase().replace(/[^a-z0-9._-]/g, '-') || 'production';
|
||||
const image = `forgeflow/${slug}:${environment}`;
|
||||
const webUi = this.dockerManWebUi(profile);
|
||||
return [
|
||||
'<?xml version="1.0"?>',
|
||||
'<Container version="2">',
|
||||
` <Name>${xmlEscape(containerName)}</Name>`,
|
||||
` <Repository>${xmlEscape(image)}</Repository>`,
|
||||
' <Registry/>',
|
||||
' <Network>bridge</Network>',
|
||||
' <MyIP/>',
|
||||
` <Shell>${xmlEscape(this.dockerManShell(profile))}</Shell>`,
|
||||
' <Privileged>false</Privileged>',
|
||||
' <Support/>',
|
||||
' <Project/>',
|
||||
' <Overview>Managed by ForgeFlow through Docker Compose. Use ForgeFlow or the Compose files for configuration changes.</Overview>',
|
||||
' <Category>Tools:</Category>',
|
||||
` <WebUI>${xmlEscape(webUi)}</WebUI>`,
|
||||
' <TemplateURL/>',
|
||||
` <Icon>${xmlEscape(iconReference)}</Icon>`,
|
||||
' <ExtraParams/>',
|
||||
' <PostArgs/>',
|
||||
' <CPUset/>',
|
||||
' <DonateText/>',
|
||||
' <DonateLink/>',
|
||||
'</Container>'
|
||||
].join('\n') + '\n';
|
||||
}
|
||||
|
||||
iconCacheRefresh(profile, repository, iconReference = '') {
|
||||
const containerName = String(profile.containerName || profile.remoteFolder || repository.name || 'app').replace(/[^A-Za-z0-9._-]/g, '-') || 'app';
|
||||
const cacheLoop = `for icon_dir in /var/lib/docker/unraid/images /usr/local/emhttp/state/plugins/dynamix.docker.manager/images /var/local/emhttp/plugins/dynamix.docker.manager/images; do [ -d "$icon_dir" ] || continue; rm -f "$icon_dir/${containerName}-icon.png" "$icon_dir/${containerName}.png"; done`;
|
||||
const invalidateMetadata = `rm -f /usr/local/emhttp/state/plugins/dynamix.docker.manager/docker.json`;
|
||||
const localIconPath = iconReferenceLocalPath(iconReference);
|
||||
if (!localIconPath) return `${cacheLoop}\n${invalidateMetadata}`;
|
||||
return `${cacheLoop}
|
||||
if [ -f ${shellQuote(localIconPath)} ]; then for icon_dir in /var/lib/docker/unraid/images /usr/local/emhttp/state/plugins/dynamix.docker.manager/images /var/local/emhttp/plugins/dynamix.docker.manager/images; do [ -d "$icon_dir" ] || continue; cp ${shellQuote(localIconPath)} "$icon_dir/${containerName}-icon.png"; chmod 0644 "$icon_dir/${containerName}-icon.png"; done; fi
|
||||
${invalidateMetadata}`;
|
||||
}
|
||||
|
||||
dockerManRefreshScript(profile, repository, iconReference = '') {
|
||||
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) {
|
||||
if (!url) return { configured: false, healthy: null, status: null, latencyMs: null };
|
||||
let last = null;
|
||||
@@ -336,7 +508,7 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths"
|
||||
throw error;
|
||||
}
|
||||
const requestId = crypto.randomUUID();
|
||||
const operation = await this.store.addOperation({
|
||||
const operation = await this.saveOperation({
|
||||
id: requestId,
|
||||
type: 'deployment',
|
||||
action: 'deploy',
|
||||
@@ -349,13 +521,15 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths"
|
||||
sha: targetSha,
|
||||
shortSha: targetSha.slice(0, 7),
|
||||
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();
|
||||
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 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 statusJson = JSON.stringify({
|
||||
repository: repository.fullName,
|
||||
@@ -377,7 +551,7 @@ fi
|
||||
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)}` : ''}
|
||||
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" cat-file -e ${shellQuote(`${targetSha}^{commit}`)}
|
||||
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' ${shellQuote(targetSha)} > "$root/.forgeflow/current-sha"
|
||||
${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"
|
||||
docker compose -f ${shellQuote(composeFile)} config >/dev/null
|
||||
docker compose -f ${shellQuote(composeFile)} up -d --build --remove-orphans
|
||||
${compose} config >/dev/null
|
||||
${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'
|
||||
${statusJson}
|
||||
FORGEFLOW_STATUS
|
||||
`;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
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 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 = [
|
||||
...operation.logs,
|
||||
...result.stdout.trim().split('\n').filter(Boolean).slice(-60),
|
||||
'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,
|
||||
status: finalStatus,
|
||||
previousSha: preflight.inspection?.head || null,
|
||||
health,
|
||||
health: { ...health, healthy: effectiveHealthy },
|
||||
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, {
|
||||
liveSha: targetSha,
|
||||
previousSha: preflight.inspection?.head || null,
|
||||
healthy: health.healthy,
|
||||
healthStatus: health.status,
|
||||
healthLatencyMs: health.latencyMs,
|
||||
healthy: effectiveHealthy,
|
||||
healthStatus: health.status ?? null,
|
||||
healthLatencyMs: health.latencyMs ?? null,
|
||||
requestId,
|
||||
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', {
|
||||
requestId,
|
||||
@@ -429,20 +636,17 @@ FORGEFLOW_STATUS
|
||||
serverId: server.id,
|
||||
remotePath,
|
||||
sha: targetSha,
|
||||
healthy: health.healthy,
|
||||
healthStatus: health.status
|
||||
healthy: effectiveHealthy,
|
||||
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) {
|
||||
if (error.code !== 'DEPLOYMENT_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,
|
||||
failure: { stage: 'SSH / Docker deployment', message: error.message },
|
||||
logs: [...operation.logs, error.message]
|
||||
});
|
||||
await this.diagnostics?.error('unraid.deployment.failed', {
|
||||
requestId,
|
||||
repository: repository.fullName,
|
||||
@@ -451,8 +655,10 @@ FORGEFLOW_STATUS
|
||||
sha: targetSha,
|
||||
error
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
return operation;
|
||||
}
|
||||
|
||||
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.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 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 operation = await this.store.addOperation({
|
||||
const operation = await this.saveOperation({
|
||||
id: requestId,
|
||||
type: 'deployment',
|
||||
action: 'rollback',
|
||||
@@ -504,9 +713,12 @@ git -C "$root" fetch --prune origin ${shellQuote(profile.branch)}
|
||||
git -C "$root" cat-file -e ${shellQuote(`${target}^{commit}`)}
|
||||
current=$(git -C "$root" rev-parse HEAD)
|
||||
git -C "$root" reset --hard ${shellQuote(target)}
|
||||
cat > "$root/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA'
|
||||
${metadata}FORGEFLOW_METADATA
|
||||
cd "$root"
|
||||
docker compose -f ${shellQuote(composeFile)} config >/dev/null
|
||||
docker compose -f ${shellQuote(composeFile)} up -d --build --remove-orphans
|
||||
${compose} config >/dev/null
|
||||
${compose} up -d --build --remove-orphans --force-recreate
|
||||
${this.dockerManRefreshScript(profile, repository, iconReference)}
|
||||
printf '%s' "$current" > "$root/.forgeflow/previous-sha"
|
||||
printf '%s' ${shellQuote(target)} > "$root/.forgeflow/current-sha"
|
||||
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 health = await this.checkHealth(profile.healthcheckUrl);
|
||||
const finalStatus = health.healthy === false ? 'failed' : 'rolled-back';
|
||||
const completed = await this.store.addOperation({
|
||||
const completed = await this.saveOperation({
|
||||
...operation,
|
||||
status: finalStatus,
|
||||
previousSha: deploymentState.liveSha || inspection.head || null,
|
||||
@@ -549,7 +761,7 @@ FORGEFLOW_STATUS
|
||||
return completed;
|
||||
} catch (error) {
|
||||
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;
|
||||
}
|
||||
@@ -558,30 +770,131 @@ FORGEFLOW_STATUS
|
||||
async refreshProfileState(fullName, profileId) {
|
||||
const repository = { fullName, name: fullName.split('/').pop() };
|
||||
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
||||
const containerName = String(profile.containerName || profile.remoteFolder || repository.name);
|
||||
const script = `
|
||||
root=${shellQuote(remotePath)}
|
||||
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")
|
||||
[ -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/status.json" ] && status=$(base64 "$root/.forgeflow/status.json" | tr -d '\\r\\n')
|
||||
printf '__FORGEFLOW_JSON__\\n{"liveSha":"%s","previousSha":"%s","statusBase64":"%s"}\\n' "$live" "$previous" "$status"
|
||||
if docker inspect "$container" >/dev/null 2>&1; then
|
||||
running=$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null || echo false)
|
||||
docker_health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container" 2>/dev/null || true)
|
||||
webui=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.webui"}}' "$container" 2>/dev/null || true)
|
||||
icon=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.icon"}}' "$container" 2>/dev/null || true)
|
||||
shell_label=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.shell"}}' "$container" 2>/dev/null || true)
|
||||
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 raw = parseInspection(result.stdout);
|
||||
let remoteStatus = null;
|
||||
try { remoteStatus = raw.statusBase64 ? JSON.parse(Buffer.from(raw.statusBase64, 'base64').toString('utf8')) : null; } catch {}
|
||||
const existing = this.store.getDeploymentState(profile.id) || {};
|
||||
const marker = result.stdout.lastIndexOf('__FORGEFLOW_KV__');
|
||||
if (marker < 0) throw new Error('Unraid state inspection did not return a ForgeFlow marker.');
|
||||
const fields = {};
|
||||
for (const line of result.stdout.slice(marker + '__FORGEFLOW_KV__'.length).trim().split(/\r?\n/)) {
|
||||
const index = line.indexOf('=');
|
||||
if (index > 0) fields[line.slice(0, index)] = line.slice(index + 1);
|
||||
}
|
||||
const decode = (value) => { try { return value ? Buffer.from(value, 'base64').toString('utf8') : ''; } catch { return ''; } };
|
||||
const 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, {
|
||||
liveSha: /^[0-9a-f]{40}$/i.test(raw.liveSha || '') ? raw.liveSha : null,
|
||||
previousSha: /^[0-9a-f]{40}$/i.test(raw.previousSha || '') ? raw.previousSha : null,
|
||||
healthy: remoteStatus?.healthy ?? existing.healthy ?? null,
|
||||
healthStatus: existing.healthStatus ?? null,
|
||||
healthLatencyMs: existing.healthLatencyMs ?? null,
|
||||
requestId: remoteStatus?.request_id || existing.requestId || null,
|
||||
liveSha: /^[0-9a-f]{40}$/i.test(fields.liveSha || '') ? fields.liveSha : null,
|
||||
previousSha: /^[0-9a-f]{40}$/i.test(fields.previousSha || '') ? fields.previousSha : null,
|
||||
healthy: effectiveHealthy,
|
||||
healthStatus: health.status,
|
||||
healthLatencyMs: health.latencyMs,
|
||||
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,
|
||||
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 = {
|
||||
@@ -591,5 +904,7 @@ module.exports = {
|
||||
parseInspection,
|
||||
dockerIgnoreHasPath,
|
||||
checksSummary,
|
||||
xmlEscape,
|
||||
iconReferenceLocalPath,
|
||||
bash
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs/promises');
|
||||
const fsSync = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { spawn } = require('node:child_process');
|
||||
@@ -12,14 +13,73 @@ function safeRepositoryPart(value, label) {
|
||||
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 {
|
||||
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.gitea = gitea;
|
||||
this.diagnostics = diagnostics;
|
||||
this.appInfo = appInfo;
|
||||
this.sourcePath = sourcePath;
|
||||
this.updateDirectory = path.join(userDataPath, 'updates');
|
||||
this.platform = platform;
|
||||
this.spawnProcess = spawnProcess;
|
||||
this.powershellPath = powershellPath;
|
||||
this.handshakeTimeoutMs = handshakeTimeoutMs;
|
||||
this.handshakePollMs = handshakePollMs;
|
||||
this.staged = null;
|
||||
}
|
||||
|
||||
@@ -99,37 +159,127 @@ class UpdateService {
|
||||
async apply(staged = null) {
|
||||
const update = staged?.archivePath ? staged : this.staged;
|
||||
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);
|
||||
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 scriptStat = await fs.stat(scriptPath).catch(() => null);
|
||||
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 = [
|
||||
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath,
|
||||
'-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath,
|
||||
'-SourcePath', this.sourcePath,
|
||||
'-ArchivePath', update.archivePath,
|
||||
'-ExpectedVersion', update.remoteVersion,
|
||||
'-ExpectedSha256', update.sha256,
|
||||
'-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,
|
||||
stdio: 'ignore',
|
||||
windowsHide: false,
|
||||
windowsHide: true,
|
||||
cwd: this.sourcePath
|
||||
});
|
||||
child.unref();
|
||||
await this.diagnostics?.info('updates.apply-launched', {
|
||||
} catch (error) {
|
||||
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,
|
||||
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
|
||||
};
|
||||
|
||||
@@ -44,7 +44,8 @@ const icons = {
|
||||
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"/>',
|
||||
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 = '') {
|
||||
@@ -116,6 +117,7 @@ const ui = {
|
||||
setupDraft: { baseUrl: 'https://', token: '', user: null, roots: [], discovered: [] },
|
||||
setupValidation: null,
|
||||
activeDeployment: null,
|
||||
operationPollTimer: null,
|
||||
isMock: false,
|
||||
refreshError: null,
|
||||
autoRefreshPending: false,
|
||||
@@ -123,7 +125,8 @@ const ui = {
|
||||
updateStatus: null,
|
||||
updateChecking: false,
|
||||
servers: [],
|
||||
serverInspection: null
|
||||
serverInspection: null,
|
||||
gitRecovery: 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;
|
||||
}
|
||||
|
||||
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() {
|
||||
try {
|
||||
ui.boot = await window.forgeflow.bootstrap();
|
||||
@@ -166,11 +193,14 @@ async function bootstrap() {
|
||||
ui.setupDraft.roots = [...(ui.boot.state.workspaceRoots || [])];
|
||||
if (ui.boot.state.setupComplete) {
|
||||
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.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();
|
||||
});
|
||||
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');
|
||||
});
|
||||
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) {
|
||||
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);
|
||||
return updated;
|
||||
} catch (error) {
|
||||
if (showErrors) showToast('Actions status unavailable', error.message, 'error');
|
||||
if (showErrors) showToast('Deployment status unavailable', error.message, 'error');
|
||||
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) {
|
||||
ui.selectedRepoId = id;
|
||||
ui.currentView = 'repository';
|
||||
@@ -238,6 +304,7 @@ function selectRepository(id, shouldRender = true) {
|
||||
ui.history = [];
|
||||
ui.branches = [];
|
||||
ui.stashes = [];
|
||||
ui.gitRecovery = null;
|
||||
const repository = selectedRepository();
|
||||
ui.selectedProfileId = selectedProfile(repository)?.id || null;
|
||||
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.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.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.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.' };
|
||||
@@ -428,6 +495,23 @@ function environmentState(profile) {
|
||||
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) {
|
||||
const state = profile.state || {};
|
||||
const health = environmentState(profile);
|
||||
@@ -437,7 +521,11 @@ function renderProfileCard(repository, profile, compact = false) {
|
||||
? `SSH / Unraid · ${profile.remoteFolder || repository.name} · ${profile.branch}`
|
||||
: `${profile.workflowFile} · ${profile.branch}`;
|
||||
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) {
|
||||
@@ -448,7 +536,11 @@ function renderRepositoryDeployments(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>';
|
||||
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) {
|
||||
@@ -456,7 +548,7 @@ function renderRepositorySettings(repository) {
|
||||
const currentOrigin = repository.localStatus?.remoteUrl || 'Unavailable';
|
||||
const desiredOrigin = repository.sshUrl || repository.preferredCloneUrl || '';
|
||||
const originNeedsRepair = Boolean(repository.localPath && desiredOrigin && currentOrigin !== desiredOrigin);
|
||||
return `<div class="tab-page"><section class="settings-group"><h2>Repository identity</h2><div class="form-grid"><div class="field full"><label>Gitea repository</label><input class="input" value="${attr(repository.fullName)}" readonly/></div><div class="field full"><label>Local working tree</label><input class="input mono" value="${attr(repository.localPath || automaticTarget || 'Not linked')}" readonly/></div><div class="field full"><label>Current origin</label><input class="input mono" value="${attr(currentOrigin)}" readonly/></div>${desiredOrigin ? `<div class="field full"><label>Current Gitea SSH origin</label><input class="input mono" value="${attr(desiredOrigin)}" readonly/></div>` : ''}</div><div class="card-actions"><button class="button" data-action="${repository.localPath ? 'open-path' : 'link-repo'}">${icon('folder')}${repository.localPath ? 'Open project folder' : 'Link local folder'}</button>${originNeedsRepair ? `<button class="button primary" data-action="repair-origin">${icon('link')}Use current Gitea origin</button>` : ''}${repository.localPath ? `<button class="button" data-action="repair-index-lock">${icon('key')}Repair stale Git lock</button><button class="button danger" data-action="unlink-repo">${icon('link')}Remove link</button>` : `<button class="button primary" data-action="clone-repo">${icon('cloud')}${escapeHtml(clonePrimaryLabel(repository))}</button><button class="button ghost" data-action="clone-repo-custom">Choose another location</button>`}</div></section><section class="settings-group"><h2>Repository behavior</h2><div class="notice">${icon('shield')}Origin repair changes only the Git remote URL. Lock repair refuses recent locks and never changes files or commits.</div></section></div>`;
|
||||
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) {
|
||||
@@ -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 === '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 === '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>`;
|
||||
@@ -499,7 +591,8 @@ function renderActionPanel(repository) {
|
||||
function renderDeployments() {
|
||||
const cards = ui.repositories.flatMap((repository) => (repository.deploymentProfiles || []).map((profile) => ({ repository, profile })));
|
||||
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() {
|
||||
@@ -576,7 +669,7 @@ function renderSetup() {
|
||||
: 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 === 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() {
|
||||
@@ -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>
|
||||
<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 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>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 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>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);
|
||||
updateOperationInState(ui.activeDeployment);
|
||||
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'); }
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -718,7 +813,11 @@ async function loadGitTools(repository) {
|
||||
if (!repository?.localPath) return;
|
||||
setLoading(true, 'Loading branches and stashes…');
|
||||
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';
|
||||
} catch (error) { showToast('Git tools unavailable', error.message, 'error'); }
|
||||
setLoading(false);
|
||||
@@ -780,8 +879,17 @@ app.addEventListener('click', async (event) => {
|
||||
|
||||
if (action === 'navigate') { ui.currentView = target.dataset.view; ui.modal = null; render(); }
|
||||
else if (action === 'select-repo') selectRepository(target.dataset.id);
|
||||
else if (action === 'refresh') await refreshRepositories(true);
|
||||
else if (action === 'refresh-operations') { setLoading(true, 'Refreshing Gitea Actions runs…'); await refreshActiveOperations(); setLoading(false); }
|
||||
else if (action === 'refresh') {
|
||||
await refreshRepositories(true);
|
||||
await refreshActiveOperations(false);
|
||||
await refreshDeploymentTruth(false);
|
||||
}
|
||||
else if (action === 'refresh-operations') {
|
||||
setLoading(true, 'Refreshing deployment operations and live server state…');
|
||||
await refreshActiveOperations();
|
||||
await refreshDeploymentTruth(true);
|
||||
setLoading(false);
|
||||
}
|
||||
else if (action === 'toggle-theme') { const appearance = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; applyTheme(appearance); ui.boot.state = await window.forgeflow.setAppearance(appearance); render(); }
|
||||
else if (action === 'open-palette') { ui.paletteQuery = ''; ui.modal = { type: 'command-palette' }; render(); }
|
||||
else if (action === 'repo-tab') {
|
||||
@@ -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 === '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 === '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') {
|
||||
const provider = document.querySelector('#profile-provider').value;
|
||||
const profile = {
|
||||
@@ -850,10 +963,14 @@ app.addEventListener('click', async (event) => {
|
||||
generatedCompose: document.querySelector('#profile-generated-compose').value === 'true',
|
||||
composeFile: document.querySelector('#profile-compose-file').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,
|
||||
containerPort: Number(document.querySelector('#profile-container-port').value) || null,
|
||||
webUiUrl: document.querySelector('#profile-web-ui').value.trim(),
|
||||
iconMode: document.querySelector('#profile-icon-mode').value,
|
||||
iconUrl: document.querySelector('#profile-icon-url').value.trim(),
|
||||
iconFilePath: document.querySelector('#profile-icon-file').value.trim(),
|
||||
dockerShell: document.querySelector('#profile-docker-shell').value,
|
||||
preservePaths: document.querySelector('#profile-preserve-paths').value.split(',').map((item) => item.trim()).filter(Boolean)
|
||||
} : {
|
||||
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'); }
|
||||
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 === '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); }
|
||||
else if (action === 'repair-missing-dockerman') {
|
||||
const targets = ui.repositories.flatMap((candidate) =>
|
||||
(candidate.deploymentProfiles || [])
|
||||
.filter((profile) => profile.provider === 'ssh-unraid' && profile.state?.containerRunning && !dockerManIntegration(profile).ready)
|
||||
.map((profile) => ({ repository: candidate, profile }))
|
||||
);
|
||||
if (!targets.length) return;
|
||||
if (!confirm(`Recreate ${targets.length} running container${targets.length === 1 ? '' : 's'} with the missing DockerMan WebUI, icon and template metadata?`)) return;
|
||||
setLoading(true, 'Repairing missing DockerMan integrations…');
|
||||
let repaired = 0;
|
||||
const failures = [];
|
||||
for (const item of targets) {
|
||||
try {
|
||||
await window.forgeflow.applyDockerManMetadata(item.repository, item.profile.id);
|
||||
repaired += 1;
|
||||
} catch (error) {
|
||||
failures.push(`${item.repository.name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
await refreshDeploymentTruth(false);
|
||||
showToast(
|
||||
failures.length ? 'DockerMan repair partially completed' : 'DockerMan integrations repaired',
|
||||
failures.length ? `${repaired} repaired, ${failures.length} failed.` : `${repaired} running container${repaired === 1 ? '' : 's'} updated.`,
|
||||
failures.length ? 'error' : 'success'
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
else if (action === 'apply-dockerman-metadata') {
|
||||
if (!repository) repository = profileRepository(target.dataset.profileId);
|
||||
setLoading(true, 'Applying DockerMan labels, template, icon and WebUI metadata…');
|
||||
try { await window.forgeflow.applyDockerManMetadata(repository, target.dataset.profileId); await refreshRepositories(false); showToast('DockerMan integration repaired', 'The container was recreated with labels, a persistent template, WebUI and icon metadata.', 'success'); }
|
||||
catch (error) { showToast('Could not repair DockerMan integration', error.message, 'error'); }
|
||||
setLoading(false);
|
||||
}
|
||||
else if (action === 'reconcile-deployment') {
|
||||
if (!repository) repository = profileRepository(target.dataset.profileId);
|
||||
setLoading(true, 'Reconciling ForgeFlow with the live Unraid container…');
|
||||
try { await window.forgeflow.reconcileDeployment(repository.fullName, target.dataset.profileId); await refreshActiveOperations(false); await refreshRepositories(false); showToast('Deployment reconciled', 'Live SHA, container health and operation status were refreshed.', 'success'); }
|
||||
catch (error) { showToast('Could not reconcile deployment', error.message, 'error'); }
|
||||
setLoading(false);
|
||||
}
|
||||
else if (action === 'open-profile-webui') await window.forgeflow.openExternal(target.dataset.url);
|
||||
else if (action === 'open-operation') { const operation = await window.forgeflow.getOperation(target.dataset.operationId); if (operation) { ui.activeDeployment = operation; ui.currentView = 'deployment-run'; render(); startOperationPolling(); } }
|
||||
else if (action === 'refresh-current-operation') { setLoading(true, 'Refreshing deployment status…'); try { const operation = await window.forgeflow.refreshOperations(ui.activeDeployment.id); updateOperationInState(operation); if (!isTerminalOperation(operation.status)) startOperationPolling(); } catch (error) { showToast('Status refresh failed', error.message, 'error'); } setLoading(false); }
|
||||
else if (action === 'open-run-url') await window.forgeflow.openExternal(ui.activeDeployment.runUrl);
|
||||
else if (action === 'close-deployment') { 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-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); }
|
||||
@@ -997,12 +1156,50 @@ app.addEventListener('click', async (event) => {
|
||||
} catch (error) { showToast('Could not normalize origins', error.message, 'error'); }
|
||||
setLoading(false); render();
|
||||
}
|
||||
else if (action === 'repair-index-lock') {
|
||||
if (!repository?.localPath || !confirm('Remove the stale Git index.lock for this repository? Only continue after other Git tools have stopped.')) return;
|
||||
setLoading(true, 'Repairing stale Git lock…');
|
||||
try { await window.forgeflow.repairIndexLock(repository.localPath); await refreshRepositories(false); showToast('Git lock removed', 'The repository can accept Git changes again.', 'success'); }
|
||||
catch (error) { showToast('Could not remove Git lock', error.message, 'error'); }
|
||||
setLoading(false);
|
||||
else if (action === 'scan-git-recovery') {
|
||||
if (!repository?.localPath) return;
|
||||
setLoading(true, 'Scanning Git directory and active processes…');
|
||||
try { ui.gitRecovery = await window.forgeflow.gitRecoveryStatus(repository.localPath); ui.repositoryTab = 'gittools'; showToast('Git health scan complete', `${ui.gitRecovery.lockReport.locks.length} lock file(s) found.`, ui.gitRecovery.lockReport.locks.length ? 'info' : 'success'); }
|
||||
catch (error) { showToast('Git health scan failed', error.message, 'error'); }
|
||||
setLoading(false); render();
|
||||
}
|
||||
else if (action === 'repair-git-locks' || action === 'repair-index-lock') {
|
||||
if (!repository?.localPath || !confirm('Repair stale Git lock files for this repository? ForgeFlow refuses while a matching Git process is active.')) return;
|
||||
setLoading(true, 'Safely repairing stale Git locks…');
|
||||
try { const result = await window.forgeflow.repairGitLocks(repository.localPath, false); ui.gitRecovery = await window.forgeflow.gitRecoveryStatus(repository.localPath); await refreshRepositories(false); showToast('Git locks repaired', `${result.removed.length} stale lock file(s) removed.`, 'success'); }
|
||||
catch (error) {
|
||||
if (error.code === 'GIT_PROCESS_PROBE_UNAVAILABLE' && confirm(`${error.message}
|
||||
|
||||
Force repair after you have closed all Git tools for this repository?`)) {
|
||||
try { const result = await window.forgeflow.repairGitLocks(repository.localPath, true); showToast('Git locks force-repaired', `${result.removed.length} lock file(s) removed.`, 'success'); await refreshRepositories(false); }
|
||||
catch (forceError) { showToast('Could not repair Git locks', forceError.message, 'error'); }
|
||||
} else showToast('Could not repair Git locks', error.message, 'error');
|
||||
}
|
||||
setLoading(false); render();
|
||||
}
|
||||
else if (action === 'reconcile-repository') {
|
||||
if (!repository?.localPath) return;
|
||||
setLoading(true, 'Refreshing repository truth from Git…');
|
||||
try { ui.gitRecovery = await window.forgeflow.reconcileRepository(repository.localPath); await refreshRepositories(false); showToast('Repository reconciled', 'Branch, upstream, lock and working-tree state were refreshed.', 'success'); }
|
||||
catch (error) { showToast('Could not reconcile repository', error.message, 'error'); }
|
||||
setLoading(false); render();
|
||||
}
|
||||
else if (action === '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 === 'save-diagnostics-preferences') {
|
||||
|
||||
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 80 KiB |
@@ -10,7 +10,7 @@
|
||||
<body>
|
||||
<div id="app" aria-live="polite">
|
||||
<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>
|
||||
<span>Checking Git and local configuration…</span>
|
||||
</div>
|
||||
|
||||
@@ -269,9 +269,9 @@
|
||||
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 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 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 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) }; },
|
||||
|
||||
@@ -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 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); }
|
||||
|
||||
.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; }
|
||||
}
|
||||
|
||||
.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 */
|
||||
@@ -553,3 +557,11 @@ kbd { min-width: 24px; padding: 2px 5px; border: 1px solid var(--line); border-b
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.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; }
|
||||
|
||||
@@ -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.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);
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ test('commit workflow explains every disabled prerequisite', 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');
|
||||
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');
|
||||
assert.match(ipc, /repositoryMutations = new Map/);
|
||||
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, /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…')");
|
||||
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/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
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');
|
||||
|
||||
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 operations = [];
|
||||
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' }),
|
||||
getDeploymentState: () => ({ liveSha, previousSha }),
|
||||
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(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&B<"x">');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import test from 'node:test';
|
||||
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 path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
import { EventEmitter } from 'node:events';
|
||||
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 () => {
|
||||
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 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/);
|
||||
});
|
||||
|
||||
|
||||