6 Commits
51 changed files with 6035 additions and 1508 deletions
+57
View File
@@ -1,5 +1,62 @@
# Changelog
## 0.10.0 - 2026-07-28
- Added safe Gitea Server pull with repository-scoped read-only deploy keys and pinned SSH host fingerprints.
- Automatically discovers and links unique running repository workloads while filtering system containers and stale release folders.
- Reconciles live server SHA, Gitea SHA and runtime health, including deployments changed outside ForgeFlow.
- Preserves adopted Compose and DockerMan identity and avoids duplicate repository links.
- Expanded Git Validator with editor, line-ending, dependency-lock and Gitea Actions checks plus reviewable fixes.
- Refined the dark deployment workspace with clearer workload identity, focused inventory and motion-safe illustration.
- Added live connection/deployment audit scripts and kept automatic cleanup of older packaged artifacts.
## 0.9.5 - 2026-07-28
- Added a mandatory remote write-access preflight before every Direct copy deployment and again immediately before upload.
- The preflight reports the exact failing path, SSH user, owner, group and mode instead of allowing a partial copy or Docker activation.
- Added an in-app **Check / fix write access** action on every SSH / Unraid deployment and directly beside failed permission checks.
- The repair is restricted to the linked project source and `.forgeflow` state, assigns the Unraid `users` group where available, preserves executable bits and excludes configured runtime data.
- Explicit permission repair now also normalizes SMB/manual-copy access even when the SSH account is root and could already write.
- Direct copy is fail-closed: candidate Compose configuration and images are validated before live files change; no implicit `down`, `--remove-orphans` or `--force-recreate` is used.
- A failed activation restores the prior source and image tags, attempts to restore the previous runtime and retains rollback evidence for diagnosis.
- Removed orphan deletion from the legacy server deployment helper as well.
## 0.9.4 - 2026-07-28
- Fixed imported deployments failing with `service has neither an image nor a build context` because a stale labels-only metadata overlay introduced a phantom service.
- Existing workloads now activate strictly from their real Compose files; ForgeFlow metadata is stored outside the active Compose model.
- Redeploy always uses `--force-recreate` and verifies every service returned by `docker compose config --services`.
- A deployment is rejected when the container ID did not change or when the previous hinted container remains running beside a duplicate workload.
- Deployment SHA is promoted only after recreation and runtime checks complete.
## 0.9.3 - 2026-07-28
- removed every server-to-repository authentication check and all server-side Git deployment branches from SSH/Unraid deploy and rollback;
- forcibly migrated legacy SSH/Unraid profiles to direct local bundle copy, except explicit monitor-only profiles;
- discovered Compose YAML definitions directly from configured Unraid appdata roots even when Docker inspection fails;
- merged YAML definitions with runtime containers, stopped containers and DockerMan templates;
- automatically linked unique high-confidence Compose folder/project matches and added one-click linking for remaining strong matches with all fields prefilled;
- made inventory failure handling compatible with strict Bash execution, pruned large runtime folders during YAML discovery and normalized user-share/cache/disk appdata paths.
## 0.9.0 - 2026-07-27
- replaced Git-checkout-only discovery with a complete Unraid workload inventory, including stopped, Compose, DockerMan and standalone containers;
- added persistent manual workload linking with repository, deployment mode, Compose project, files and service identity;
- made checksum-verified exact-commit push bundles the default for new SSH/Unraid profiles, so Unraid no longer needs a Gitea key;
- separated desktop-to-Unraid authentication, Docker/Compose capabilities and optional Unraid-to-Gitea access in diagnostics;
- preserved adopted DockerMan templates and disabled aggressive recreate/orphan flags by default;
- promoted deployment state only after Compose validation and service verification, with atomic manifests, rollback restoration and live lock ownership;
- retained server-side Git and monitor-only modes for explicit use cases;
- corrected release publication so source, installer, portable executable and SHA-256 sidecars are published together, with a recovery publisher for source-only Gitea releases.
## 0.8.9 - 2026-07-26
- added a per-repository Git Validator with a weighted assurance score and evidence-backed checks;
- validates Gitea branch governance, repository identity, upstream tracking, effective author identity, safe synchronization defaults, README and gitignore hygiene, tracked secret-shaped files and oversized files;
- provides audited one-click repairs for origin alignment and repository-local safety configuration;
- offers confirmed repairs for default-branch protection and a reviewable uncommitted `.gitignore`;
- introduced a premium, theme-aware and container-responsive Validator workspace with safe-fix batching.
## 0.8.8 - 2026-07-26
- fixed binary update downloads on Gitea servers that require release-scoped attachment routes;
+15
View File
@@ -0,0 +1,15 @@
@echo off
setlocal
cd /d "%~dp0"
echo ForgeFlow source and binary release publisher
echo.
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0Publish-ForgeFlow-Release.ps1"
set "forgeflowExitCode=%ERRORLEVEL%"
echo.
if not "%forgeflowExitCode%"=="0" (
echo Publication failed. The existing ForgeFlow installation was not modified.
) else (
echo Publication completed. The older ForgeFlow updater can now install this release.
)
pause
exit /b %forgeflowExitCode%
+48 -1
View File
@@ -1,7 +1,9 @@
param(
[string]$Remote = "git@gitea.itworx.tech:Jens/ForgeFlow.git",
[string]$Branch = "main",
[string]$InstalledSource = "C:\Projects\ForgeFlow"
[string]$InstalledSource = "C:\Projects\ForgeFlow",
[string]$UserDataPath = "",
[switch]$SkipBinaryRelease
)
$ErrorActionPreference = "Stop"
@@ -58,6 +60,47 @@ try {
Write-Host "ForgeFlow $version is available on Gitea at commit $($publishedCommit.Substring(0,7))." -ForegroundColor Green
if (-not $SkipBinaryRelease) {
Write-Host "Building and publishing the matching Windows installer and portable release..." -ForegroundColor Cyan
Push-Location $clone
try {
& cmd.exe /d /s /c "npm ci --no-audit --no-fund"
if ($LASTEXITCODE -ne 0) { throw "npm ci failed in the exact published checkout." }
& cmd.exe /d /s /c "npm run check"
if ($LASTEXITCODE -ne 0) { throw "The exact published checkout failed the release quality gate." }
& cmd.exe /d /s /c "npm run dist:win"
if ($LASTEXITCODE -ne 0) { throw "The Windows release build failed." }
$expectedAssets = @(
"ForgeFlow-Setup-$version-win-x64.exe",
"ForgeFlow-Setup-$version-win-x64.exe.sha256",
"ForgeFlow-Portable-$version-win-x64.exe",
"ForgeFlow-Portable-$version-win-x64.exe.sha256"
)
foreach ($assetName in $expectedAssets) {
if (-not (Test-Path -LiteralPath (Join-Path $clone "dist\$assetName"))) {
throw "The Windows build did not produce $assetName."
}
}
$resolvedUserData = if ($UserDataPath) { $UserDataPath } else { Join-Path $env:APPDATA "forgeflow" }
$previousUserData = $env:FORGEFLOW_USER_DATA
$previousBranch = $env:FORGEFLOW_RELEASE_BRANCH
try {
$env:FORGEFLOW_USER_DATA = $resolvedUserData
$env:FORGEFLOW_RELEASE_BRANCH = $Branch
& cmd.exe /d /s /c "npm run release:binary"
if ($LASTEXITCODE -ne 0) { throw "The Gitea binary release publisher failed." }
} finally {
$env:FORGEFLOW_USER_DATA = $previousUserData
$env:FORGEFLOW_RELEASE_BRANCH = $previousBranch
}
} finally { Pop-Location }
Write-Host "ForgeFlow $version source and Windows release assets are both published." -ForegroundColor Green
} else {
Write-Host "Binary publication was skipped explicitly. Packaged ForgeFlow installations cannot auto-update until the release assets are published." -ForegroundColor Yellow
}
$installedManifestPath = Join-Path $InstalledSource "package.json"
if (Test-Path -LiteralPath $installedManifestPath) {
try {
@@ -123,8 +166,12 @@ try {
Write-Host "Installed source was not found at $InstalledSource; publication itself succeeded." -ForegroundColor Yellow
}
if ($SkipBinaryRelease) {
Write-Host "Source publication completed. Run Publish-Missing-Binary-Release.ps1 before using the updater from an installed EXE." -ForegroundColor Yellow
} else {
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
}
+112
View File
@@ -0,0 +1,112 @@
param(
[string]$Remote = "git@gitea.itworx.tech:Jens/ForgeFlow.git",
[string]$Branch = "main",
[string]$ExpectedVersion = "0.9.1",
[string]$UserDataPath = ""
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$temp = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-binary-release-" + [guid]::NewGuid().ToString("N"))
$clone = Join-Path $temp "ForgeFlow"
function Invoke-CheckedCommand {
param(
[Parameter(Mandatory = $true)][string]$Title,
[Parameter(Mandatory = $true)][scriptblock]$Action
)
Write-Host "`n$Title" -ForegroundColor Cyan
& $Action
if ($LASTEXITCODE -ne 0) {
throw "$Title failed with exit code $LASTEXITCODE."
}
}
try {
foreach ($command in @("git", "node", "npm")) {
if (-not (Get-Command $command -ErrorAction SilentlyContinue)) {
throw "Required command '$command' was not found on PATH."
}
}
New-Item -ItemType Directory -Force -Path $temp | Out-Null
Invoke-CheckedCommand "Cloning the exact published ForgeFlow source..." {
& git clone --branch $Branch --single-branch $Remote $clone
}
$manifestPath = Join-Path $clone "package.json"
if (-not (Test-Path -LiteralPath $manifestPath)) {
throw "The cloned repository does not contain package.json."
}
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
if ($manifest.name -ne "forgeflow") {
throw "The cloned repository is not ForgeFlow."
}
$version = [string]$manifest.version
if ($ExpectedVersion -and $version -ne $ExpectedVersion) {
throw "Gitea branch '$Branch' contains ForgeFlow $version, not the expected $ExpectedVersion."
}
$localCommit = (& git -C $clone rev-parse HEAD).Trim()
$remoteLines = @(& git -C $clone ls-remote origin "refs/heads/$Branch")
if ($LASTEXITCODE -ne 0 -or $remoteLines.Count -lt 1) {
throw "Could not verify origin/$Branch."
}
$remoteCommit = ($remoteLines[0] -split "`t")[0].Trim()
if ($localCommit -ne $remoteCommit) {
throw "The temporary checkout is not the current origin/$Branch commit."
}
Push-Location $clone
try {
Invoke-CheckedCommand "Installing exact dependencies..." {
& cmd.exe /d /s /c "npm ci --no-audit --no-fund"
}
Invoke-CheckedCommand "Running the complete ForgeFlow quality gate..." {
& cmd.exe /d /s /c "npm run check"
}
Invoke-CheckedCommand "Building installer and portable Windows assets..." {
& cmd.exe /d /s /c "npm run dist:win"
}
$expectedAssets = @(
"ForgeFlow-Setup-$version-win-x64.exe",
"ForgeFlow-Setup-$version-win-x64.exe.sha256",
"ForgeFlow-Portable-$version-win-x64.exe",
"ForgeFlow-Portable-$version-win-x64.exe.sha256"
)
foreach ($assetName in $expectedAssets) {
$assetPath = Join-Path $clone "dist\$assetName"
if (-not (Test-Path -LiteralPath $assetPath)) {
throw "The build did not produce $assetName."
}
}
$resolvedUserData = if ($UserDataPath) { $UserDataPath } else { Join-Path $env:APPDATA "forgeflow" }
$configPath = Join-Path $resolvedUserData "forgeflow-config.json"
if (-not (Test-Path -LiteralPath $configPath)) {
throw "ForgeFlow configuration was not found at $configPath. Open ForgeFlow and sign in to Gitea once, then run this script again."
}
$previousUserData = $env:FORGEFLOW_USER_DATA
$previousBranch = $env:FORGEFLOW_RELEASE_BRANCH
try {
$env:FORGEFLOW_USER_DATA = $resolvedUserData
$env:FORGEFLOW_RELEASE_BRANCH = $Branch
Invoke-CheckedCommand "Creating the Gitea release and uploading all four assets..." {
& cmd.exe /d /s /c "npm run release:binary"
}
} finally {
$env:FORGEFLOW_USER_DATA = $previousUserData
$env:FORGEFLOW_RELEASE_BRANCH = $previousBranch
}
} finally {
Pop-Location
}
Write-Host "`nForgeFlow $version now has a published binary release for commit $($localCommit.Substring(0,7))." -ForegroundColor Green
Write-Host "Return to ForgeFlow $ExpectedVersion's predecessor and choose Check now -> Download update -> Apply & restart." -ForegroundColor Green
}
finally {
Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue
}
+117 -308
View File
@@ -1,363 +1,172 @@
# ForgeFlow
ForgeFlow 0.8 adds partial-hunk commits, guided conflict recovery, Gitea pull
requests and protected-branch awareness, configurable editor/terminal actions,
deployment policies and release notes, append-only audit export, encrypted
configuration backup, native tray/notifications and a guarded end-to-end
acceptance harness.
**Van lokale wijziging naar aantoonbaar juiste serverversie — zonder de Git- en deploymentcontext over verschillende tools te verspreiden.**
## Publish this release to the built-in updater repository
ForgeFlow is een desktopapp voor teams die met Git, Gitea en eigen servers werken. De app toont wat lokaal gewijzigd is, wat al op Gitea staat en welke exacte commit op de server draait. Daarna begeleidt ForgeFlow je door review, commit, push, deployment en verificatie.
Extract the full source ZIP to a folder under Downloads and run:
> Huidige release: **0.10.0** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest)
![ForgeFlow release-overzicht](docs/screenshots/overview.png)
## Wat ForgeFlow voor je oplost
- **Eén duidelijke actielijst:** zie meteen welke repository aandacht nodig heeft en waarom.
- **Veilige Git-flow:** review wijzigingen, stage volledige bestanden of afzonderlijke hunks, commit, push en herstel conflicten zonder contextwissel.
- **Deployment op een exacte commit:** ForgeFlow gebruikt volledige commit-SHA's en toont lokaal, Gitea en server naast elkaar.
- **Volledige serverinventaris:** zie ook gestopte, DockerMan- en niet-Git-installaties, koppel twijfelgevallen handmatig en behoud hun bestaande Compose-identiteit.
- **Veilige server-pull:** Unraid haalt de exacte commit uit Gitea met een unieke, repository-scoped read-only deploy key en een vastgepinde SSH-hostsleutel.
- **Ingebouwde Git Validator:** controleer repository-identiteit, branch protection, synchronisatie-instellingen, documentatie, geheimen en grote bestanden; veilige verbeteringen kunnen gericht worden toegepast.
- **Lokale controle:** configuratie en credentials blijven op het toestel en diagnostische exports worden lokaal geredigeerd.
## Snel starten
### Aanbevolen: de Windows-app installeren
1. Open de [laatste ForgeFlow-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest).
2. Download de Windows-installer of portable executable.
3. Start ForgeFlow en doorloop de setupwizard.
4. Voeg je Gitea-server, token en lokale projectmappen toe.
5. Configureer optioneel een serververbinding en één of meer deploymentprofielen.
Na installatie kun je nieuwe packaged releases vanuit **Settings → Updates** ophalen. Downloads worden tegen de gepubliceerde SHA-256-checksums gecontroleerd. Zie [UPDATING.md](docs/UPDATING.md) wanneer een oudere of source-only build nog niet binair kan updaten.
### Eerst vrijblijvend bekijken
De interactieve demomodus gebruikt uitsluitend representatieve voorbeelddata en maakt geen verbinding met Git, Gitea of een server:
```powershell
Set-ExecutionPolicy -Scope Process Bypass
.\Publish-ForgeFlow-Release.ps1
npm install
npm run demo
```
The script runs the complete quality gate, clones `git@gitea.itworx.tech:Jens/ForgeFlow.git` into a temporary folder, mirrors the verified source without `.git`, `node_modules` or release ZIPs, commits it on `main` and pushes it. A running older ForgeFlow source installation can then update through **Settings → Updates**.
Open daarna `http://127.0.0.1:4173`.
## De dagelijkse workflow
### 1. Begin bij wat aandacht vraagt
ForgeFlow is a desktop release cockpit that turns the complete path from a local
code change to a verified server deployment into one guided flow:
Het release-overzicht vertaalt technische status naar concrete acties: wijzigingen reviewen, commits pushen, synchroniseren, deployen of een ongezonde omgeving onderzoeken. De repositorylijst blijft beschikbaar zodat je snel van context kunt wisselen.
```text
Local working tree -> Gitea repository -> exact deployed server version
```
### 2. Review en publiceer code
It is not an IDE and it does not replace Gitea Actions. ForgeFlow coordinates
local Git, a private Gitea instance and fixed deployment workflows while keeping
credentials on the user's own computer.
![Repositorywerkruimte met diff en volgende actie](docs/screenshots/repository-workspace.png)
![ForgeFlow overview](docs/screenshots/overview.png)
In de repositorywerkruimte zie je de volledige keten **Local → Gitea → Server**. Je kunt wijzigingen selecteren, diffs bekijken, gedeeltelijke hunk-staging behouden, branch protection controleren en een pull request openen. Destructieve of publicerende acties vereisen altijd expliciete bevestiging.
## Current status: v0.6.0 self-healing Git and Unraid operations
### 3. Deploy en verifieer de live versie
### Git recovery and repository truth
![Deploymentsoverzicht met herkenbare containerkaarten](docs/screenshots/deployments.png)
- 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.
Elke deploymentkaart benoemt repository, container, omgeving, uitvoeringsmethode, live commit, Gitea-commit, vorige versie en healthstatus. ForgeFlow ondersteunt gecontroleerde deployments via Gitea Actions en SSH/Unraid, met preflightcontrole en rollback waar beschikbaar.
### SSH / Unraid and DockerMan
Bij server discovery probeert ForgeFlow bestaande containers aan Gitea-repositories te koppelen. Een exacte overeenkomst tussen de volledige live SHA en de actuele Gitea-SHA wordt als gelijklopende versie weergegeven; een runtime-healthcheck blijft een afzonderlijke voorwaarde voor een gezonde deployment.
- 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.
### 4. Verbeter de repository met Git Validator
### Update reliability
![Git Validator met assurance score en veilige fixes](docs/screenshots/git-validator.png)
- 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.
Git Validator groepeert bevindingen per onderwerp en maakt onderscheid tussen geslaagde controles, aanbevelingen en kritieke problemen. Alleen fixes die ForgeFlow veilig en voorspelbaar kan uitvoeren worden als automatische actie aangeboden; governancewijzigingen zoals branch protection blijven zichtbaar en expliciet.
Read [SSH / Unraid deployment](docs/SSH_UNRAID_DEPLOYMENT.md) and the
[LumaOps audit](docs/LUMAOPS_SERVER_AUDIT.md).
## Wanneer is een release werkelijk in orde?
### v0.4.2 automatic clone workflow
ForgeFlow houdt drie soorten waarheid bewust apart:
**Clone from Gitea** now uses the first configured project root and creates the
repository-named subfolder automatically. The native folder picker is reserved
for the explicit **Choose another location** action. Existing matching checkouts
are linked; conflicting folders are never overwritten.
| Controle | Betekenis |
| --- | --- |
| **Local ↔ Gitea** | De lokale branch volgt de juiste upstream en is niet onverwacht ahead, behind of divergent. |
| **Gitea ↔ Server** | De volledige commit-SHA op de server is exact gelijk aan de relevante commit op Gitea. |
| **Runtime health** | De container of applicatie draait en de geconfigureerde healthcheck slaagt. |
The Windows environment doctor retains the v0.3.1 npm command-shim correction.
Een gelijke commit bewijst welke code draait; een geslaagde healthcheck bewijst dat die versie ook functioneert. ForgeFlow combineert beide signalen zonder het ene voor het andere te laten doorgaan.
Version 0.3.2 is designed so the user does not need to give a developer any
Gitea token, SSH key, server password or other credential. The application now
includes:
## Belangrijkste functies
- a five-step setup wizard;
- computer, Gitea and deployment preflights;
- protected local token entry;
- structured local diagnostics with aggressive secret redaction;
- standard and strict privacy support bundles;
- fail-closed bundle safety auditing;
- request IDs that correlate desktop, Actions and server events;
- root-owned allowlisted server target configuration;
- a complete setup guide from fresh checkout to first rollback test.
### Git en Gitea
Start with [START_HERE.md](START_HERE.md). The full installation and acceptance
procedure is in [docs/SETUP_GUIDE.md](docs/SETUP_GUIDE.md).
- repositories ontdekken, favorieten beheren en ontbrekende lokale clones koppelen;
- status, diff, staging, partial hunks, commit, push, fetch, pull, stash en conflict recovery;
- branches maken, wisselen, vergelijken en opruimen;
- branch protection controleren en pull requests openen;
- Git Validator met assurance score, bewijs per controle en gerichte veilige fixes.
## What ForgeFlow can do
### Deployments
### Repository workflow
- deploymentprofielen per repository en omgeving;
- Gitea Actions en SSH/Unraid als gecontroleerde uitvoeringsroutes;
- serverinventaris van draaiende en gestopte Docker-, Compose- en DockerMan-workloads;
- automatische koppeling op exact bewijs en een handmatige koppelwizard voor twijfelgevallen;
- server-pull als aanbevolen route, met een afzonderlijke read-only deploy key per repository;
- directe checksum-gecontroleerde copy als alternatief zonder servertoegang tot Gitea;
- verificatie op volledige SHA, runtime health en recente serverwaarheid;
- preflight, live logs, deploymenthistoriek en rollback naar de vorige bekende versie.
- Connect to a private Gitea instance and validate the account locally.
- Discover Git working trees beneath one or more project roots.
- Use the first project root as the automatic clone destination.
- Match local folders to Gitea repositories using normalized `origin` identity.
- Link existing repositories or clone directly into the default project root.
- Read real Git status through porcelain v2 and NUL-delimited output.
- Inspect diffs and select files.
- Stage and unstage changes.
- Commit locally or commit and push in one action.
- Push, fetch and use fast-forward-only pull.
- Review recent history.
- Create, switch and publish branches.
- Stash, list and restore local work.
- Mark favorites and prioritize repositories requiring attention.
- Refresh repository state automatically while the application is open.
### Veiligheid en beheer
### Deployment workflow
- credentials versleuteld via de beveiligde opslag van het besturingssysteem;
- origin-checks voorkomen dat een Gitea-token naar een andere host wordt gestuurd;
- updatepakketten worden alleen vanaf de geconfigureerde Gitea-origin gedownload en met checksums geverifieerd;
- lokale redactie van tokens, wachtwoorden en gevoelige diagnostische data;
- versleutelde configuratieback-up, herstelvoorbeeld en lokale audittrail;
- packaged builds als Windows-installer en portable executable.
- Configure multiple environments per repository.
- Bind each environment to a fixed branch and fixed Gitea Actions workflows.
- Run a visible deployment preflight before confirmation.
- Re-run mandatory checks in the privileged backend before dispatch.
- Verify a clean tree, correct branch, upstream and ahead/behind state.
- Verify that the exact full SHA exists on the allowed remote branch.
- Verify local and remote workflow files and Gitea Actions availability.
- Dispatch a unique request ID with the exact commit SHA.
- Poll Gitea Actions runs and jobs.
- Correlate the workflow with the server status endpoint.
- Verify that the requested SHA is actually live.
- Run an independent application healthcheck.
- Roll back through a separate fixed workflow to the previous exact SHA.
- Preserve a bounded local operation history.
Meer achtergrond staat in [SECURITY.md](docs/SECURITY.md) en [ARCHITECTURE.md](docs/ARCHITECTURE.md).
### Safe diagnostics
## Eerste configuratie
ForgeFlow stores structured JSONL diagnostics in the application-data folder.
They are intended to make development and troubleshooting possible without
requesting credentials.
Voor normaal gebruik heb je nodig:
The logger records useful operational facts such as:
- Windows 10 of 11;
- Git op het toestel;
- toegang tot een Gitea-account en een token met de benodigde repositoryrechten;
- minstens één lokale hoofdmap waarin ForgeFlow projecten mag ontdekken.
- timestamps, stable event names and duration;
- Git action outcomes and repository state;
- Gitea endpoint paths and HTTP status, but not authorization headers;
- deployment request IDs, workflow stages and health states;
- preflight results;
- sanitized error names, codes, messages and stacks.
Voor serverdetectie en SSH-deployments heb je daarnaast een bereikbare Docker- of Unraid-host en een werkende SSH-configuratie nodig. Begin bij:
It excludes or redacts:
- [SETUP_GUIDE.md](docs/SETUP_GUIDE.md) — Gitea, projectmappen en eerste ingebruikname;
- [DEPLOYMENT_SETUP.md](docs/DEPLOYMENT_SETUP.md) — deploymentprofielen en verificatie;
- [SSH_UNRAID_DEPLOYMENT.md](docs/SSH_UNRAID_DEPLOYMENT.md) — SSH- en Unraid-vereisten;
- [DIAGNOSTICS.md](docs/DIAGNOSTICS.md) — veilige controles en supportbundels.
- Gitea tokens and encrypted token blobs;
- passwords, authorization headers and credential-bearing URLs;
- private keys and common token patterns;
- arbitrary environment-variable dumps;
- local source contents and Git diffs;
- raw Gitea runner logs from support bundles;
- user-home paths.
## Ontwikkelen vanuit de broncode
Support bundles are audited before writing. If a known runtime secret, private
key marker or URL credential remains, ForgeFlow aborts the export instead of
creating the ZIP. Strict privacy mode also hashes repository and user
identifiers while keeping related events correlatable.
Read [docs/DIAGNOSTICS.md](docs/DIAGNOSTICS.md) for the exact policy and its
limitations.
## Security model
- Electron main/preload/renderer separation.
- Sandboxed renderer with context isolation and no direct Node.js access.
- Narrow, frozen preload API.
- Trusted renderer-origin checks on privileged IPC.
- No arbitrary shell command field in the interface.
- Gitea token stored using Electron `safeStorage` when available.
- Blank token updates preserve the existing protected token.
- Atomic local configuration writes and schema migration.
- Repository-relative path validation for file actions.
- Supported-protocol and credential-in-URL validation.
- Fixed workflow, branch and environment allowlists.
- Root-owned server target configuration.
- Exact-SHA deployment and rollback.
- Server-side locking, health validation and status records.
- Local diagnostic retention, rotation and clear/export controls.
More detail is available in [docs/SECURITY.md](docs/SECURITY.md).
## Prerequisites
For a Windows source setup:
- Windows 10 or 11;
- Node.js 22 or newer;
- npm;
- Git on `PATH`;
- a normal signed-in desktop session;
- access to your own Gitea instance.
For deployment:
- Gitea Actions enabled;
- a trusted Gitea runner with an environment-specific label;
- a Linux target server reachable by that runner;
- Git, Docker Compose, `curl` and `flock` on the target server;
- an existing non-interactive server checkout of the application.
## Fastest Windows start
Extract the release, open PowerShell in the folder and run:
Vereisten: Node.js 22, npm en Git.
```powershell
Set-ExecutionPolicy -Scope Process Bypass
.\setup-windows.ps1
```
The script:
1. checks Node.js, npm and Git;
2. installs the exact dependency versions from `package-lock.json` when present;
3. runs the environment doctor;
4. validates the source tree;
5. runs all automated tests;
6. starts ForgeFlow.
No Gitea or server credential is requested by the PowerShell script. Tokens are
entered later in the local desktop wizard.
Manual equivalent:
```bash
npm install --no-audit --no-fund
npm run doctor
npm ci
npm run check
npm start
```
## Setup and first acceptance test
Handige opdrachten:
Follow these documents in order:
| Opdracht | Doel |
| --- | --- |
| `npm run dev` | Start Electron in ontwikkelmodus. |
| `npm run demo` | Start de browserdemo met voorbeelddata. |
| `npm run check` | Voert bronverificatie en de volledige testset uit. |
| `npm run doctor` | Controleert de lokale ontwikkelomgeving. |
| `npm run acceptance` | Voert de release-acceptatiecontroles uit. |
| `npm run dist:win` | Bouwt Windows installer + portable package, schrijft checksums en ruimt oude dist-artifacts op. |
| `.\Publish-ForgeFlow-Release.ps1` | Publiceert broncode én de bijbehorende Windows-release-assets als één gecontroleerde release. |
| `.\Publish-Missing-Binary-Release.ps1` | Herstelt een reeds gepushte versie waarvoor de Gitea binary release ontbreekt. |
1. [START_HERE.md](START_HERE.md)
2. [docs/SETUP_GUIDE.md](docs/SETUP_GUIDE.md)
3. [docs/UPDATING.md](docs/UPDATING.md)
4. [docs/DEPLOYMENT_SETUP.md](docs/DEPLOYMENT_SETUP.md)
5. [docs/STATUS_ENDPOINT.md](docs/STATUS_ENDPOINT.md)
6. [docs/DIAGNOSTICS.md](docs/DIAGNOSTICS.md)
The recommended first test uses a non-critical staging deployment and validates:
De belangrijkste onderdelen zijn:
```text
preflight -> commit -> push -> deploy -> Actions -> server SHA -> healthcheck -> rollback
electron/ beveiligde desktopintegraties en IPC
src/ renderer, gebruikersflows en visuele componenten
scripts/ build-, release-, demo- en verificatiehulpmiddelen
tests/ unit- en integratietests
docs/ setup, deployment, beveiliging en release-informatie
```
## Updating an existing source installation
Aanvullende kwaliteitsdocumentatie:
Close ForgeFlow, copy the v0.4.2 update overlay over the existing source folder
and run:
- [TEST_MATRIX.md](docs/TEST_MATRIX.md)
- [ACCEPTANCE.md](docs/ACCEPTANCE.md)
- [STATUS_ENDPOINT.md](docs/STATUS_ENDPOINT.md)
- [ROADMAP.md](docs/ROADMAP.md)
```powershell
Set-ExecutionPolicy -Scope Process Bypass
.\update-windows.ps1
```
## Licentie
Application configuration and protected credentials are stored outside the
source folder and are not reset. See [docs/UPDATING.md](docs/UPDATING.md).
## Browser demo
The standalone visual demo requires no Gitea connection or credentials:
```bash
npm run demo
```
Open the printed local URL. The demo uses `src/renderer/mock-bridge.js`; the
packaged desktop application uses the privileged `preload.cjs` bridge.
## Quality checks
```bash
npm run verify
npm test
npm run check
npm run doctor -- --json
```
Version 0.3.2 contains 45 automated tests. Two integration suites create real
temporary bare Git remotes and exercise commit, push, branch, stash and exact
remote-SHA behavior. Detailed coverage is listed in
[docs/TEST_MATRIX.md](docs/TEST_MATRIX.md).
## Development packages
```bash
npm run dist:win
npm run dist:linux
npm run dist:mac
```
Windows users can run:
```powershell
.\build-windows.ps1
```
These commands create unsigned development packages. Public distribution still
requires platform code signing and platform-specific release acceptance.
## Server-side deployment model
ForgeFlow never sends a free-form deployment command. It dispatches a fixed
workflow with controlled inputs:
```json
{
"ref": "main",
"inputs": {
"environment": "staging",
"commit_sha": "0123456789abcdef0123456789abcdef01234567",
"request_id": "generated-by-forgeflow"
}
}
```
The workflow calls a root-owned allowlisted server script. Deployment targets
are defined in `/etc/forgeflow/targets.conf`, not supplied by the desktop app.
The server checks repository, environment, paths, branch, exact remote ancestry,
locking, Docker Compose result and health before it updates the public status
JSON.
## Project structure
```text
main.cjs Electron lifecycle and service composition
preload.cjs Whitelisted renderer API and event bridge
src/main/config-store.cjs State, migration and protected token storage
src/main/git-service.cjs Real Git CLI adapter
src/main/gitea-service.cjs Gitea REST and Actions adapter
src/main/repository-service.cjs Discovery, matching and repository aggregation
src/main/repository-monitor.cjs Background working-tree awareness
src/main/deployment-service.cjs Dispatch, polling, SHA checks and rollback
src/main/preflight-service.cjs Computer and deployment readiness checks
src/main/diagnostics-service.cjs Safe JSONL diagnostics and support bundles
src/main/log-redaction.cjs Recursive secret and privacy sanitizer
src/main/ipc.cjs Validated privileged operation boundary
src/shared/clone-target.cjs Safe automatic clone target planning
src/shared/zip-writer.cjs Dependency-free diagnostic ZIP writer
src/renderer/ Desktop UI and browser demo
examples/gitea-actions/ Fixed deploy and rollback workflow templates
examples/server/ Allowlisted Linux deployment implementation
tests/ Unit and real Git integration tests
docs/ Setup, diagnostics, security and architecture
```
## Scope and remaining acceptance
The v0.4.2 logic is prepared for self-service configuration and testing, but no
source release can prove compatibility with a private environment it has never
connected to. The remaining acceptance is deliberately local to the user:
- validate the exact Gitea version and Actions API;
- validate the runner label and runner permissions;
- adapt one target line in the root-owned server configuration;
- verify the application-specific Docker Compose and health endpoint;
- execute the documented staging deployment and rollback test;
- export a Strict support bundle if a failure occurs.
No credential needs to be shared for that process.
## License
MIT. See [LICENSE](LICENSE).
ForgeFlow is beschikbaar onder de [MIT-licentie](LICENSE).
+51 -34
View File
@@ -1,4 +1,4 @@
ForgeFlow 0.8.8 source manifest
ForgeFlow 0.10.0 source manifest
SHA-256 BYTES PATH
(The manifest excludes itself, dependencies and generated release artifacts.)
755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 58 .gitignore
@@ -12,13 +12,14 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263
4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183 3364 build/icon-64.png
25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png
b5ebb381645c9a7e9de693ff30c563819b70182931057727dc6e48db551adf9f 10557 CHANGELOG.md
5f4aca19a35cbcaffa1a6993ce96b7d66052ec2b286022f2af74594e8a310568 15712 CHANGELOG.md
21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md
a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md
30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md
eb42f979666e05d51c587e4223282914926a2b9b1ade9f3fb75525019ce7f738 4616 docs/DIAGNOSTICS.md
a0cd06a96f23a94e118feb012be0fa1ac51345951cb2ba8e67fb8c889c4c342a 5007 docs/LUMAOPS_SERVER_AUDIT.md
f79908fb3dad98c38030c6e6be7c79a1999e0478ed9c2496923891954438daa1 4581 docs/RELEASE_AUDIT_0.6.0.md
979a0b8e129979be6b265e8571d0a3c1e9ddd4ddb6b0bf55ae748d3478e51854 2296 docs/RELEASE_NOTES_0.10.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
@@ -46,81 +47,95 @@ b516db97a0353babc810c24a87a971d30d72a8021d809e6b833ff7ae0458f442 538
838d196f3fbbfeee8df375a0502107f56b6df28babca30aa09ef1c7aa5196d09 738 docs/RELEASE_NOTES_0.8.6.md
ef049adcfa204908e6dc3a059124b39ba0e2739cc54e38945ce73a57049df0d8 1185 docs/RELEASE_NOTES_0.8.7.md
7eedb25e1aae3b06a04bb9b2f4843bd6af614418737e600b4bdc9161edabd76a 632 docs/RELEASE_NOTES_0.8.8.md
35dcfda990946480d6d55bd2d2e6360c336260bcd05cdb51d92e07a4e8d76945 1046 docs/RELEASE_NOTES_0.8.9.md
29f7b11fef1e4960ef874f643b2206ca73a310b0b75402bcd3764a01e59db2e0 3114 docs/RELEASE_NOTES_0.9.0.md
ed40e08bac8792f95970bc05e49bce3cc9e288a08d11565a1bd156d787360a3b 720 docs/RELEASE_NOTES_0.9.1.md
01bf2cc72593b10010b667ae017b9eb62f4658a9711bbf24c81f5f95d93fe8c8 827 docs/RELEASE_NOTES_0.9.2.md
25169225d73d22b9d884ab3b5c1625f03fd44e53c7a7a4c4067775e80482c9f8 2182 docs/RELEASE_NOTES_0.9.3.md
720506842e0aeb30c9fc635f86a52a5545556f092e678cf37f08436243244c3d 933 docs/RELEASE_NOTES_0.9.4.md
dd90c81a375f97dfb7fa8f7808db03b19d7e7dafe3818a93537397f57eaae829 2109 docs/RELEASE_NOTES_0.9.5.md
2b631b9d6d973bdd70869d84886ff339da351e29e17598970b3b27915674661d 4175 docs/ROADMAP.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
581375ee0727911f85b0441f09734c6215ea8dd6cfaba4a7555599df0edb24f1 333382 docs/screenshots/deployments.png
87546583580e8591b1306f997d27445725b0bf5a5a79a839af3a727964e65bc2 103901 docs/screenshots/git-validator.png
bbdbe91679b486cc92dec4758ce1cdaf24e3277d038c57e794a04c0dee7e3a5b 84046 docs/screenshots/overview.png
c8a5e80bb9fd2d442d2d23d30e6ac1528cf2330e6e19492b7c6799e2d1508b53 112868 docs/screenshots/repository-workspace.png
322624242d246d07180cc719e14c91e8fb69e123676a02e5046f4e576cca1ca1 5569 docs/SECURITY.md
32a34ec13a284d3f9ceebbc107b25a844e3db096f8cafa4e43951fc2050c9a03 13552 docs/SETUP_GUIDE.md
4dcdbd42550a4cc53fe948349b20bfe0e445d40144ef82df54b3aab8b00a8f46 5165 docs/SSH_UNRAID_DEPLOYMENT.md
2fd71e9bcaeb4cb10c3fa2496b7e52fedf70c5b7f871cd587e22dc060c399079 4421 docs/SSH_UNRAID_DEPLOYMENT.md
b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md
0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md
4625a10ebd3c749f60b2a7bef6b1716cd05dbc44ccceba0491a1b46bc293c195 4883 docs/TEST_MATRIX.md
b3f317faba63d574ee83924f01b5275c118b5b04f6d9109c7cf9ed18500835d8 3389 docs/UPDATING.md
dbbd9fa96988e7543e98c85da864adaadd3057815f18d20a3b3ccb5c540a169d 4558 docs/UPDATING.md
c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 examples/gitea-actions/deploy.yml
4c792cc9fd57ed36da291300c252a6ef75b08a249cf6f2561e23c4c22522138a 1477 examples/gitea-actions/rollback.yml
1d2cde1bef4882f56006823d2806f6105882fa098a665a303150fdf18ada2004 5705 examples/server/forgeflow-deploy
577f3fa2131a3baa84549a6523f5816ef9da94f5bac6bc274d4588b6e7ab6594 5688 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
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE
0d9f0ac837b4d3b39476d9bcbb57fb3e12d6b7084bb1f7435401e416cff1854f 12920 main.cjs
3b16a087c73b600415394dff8b8e34e7f7519e48fde1cf443007b2e11ca77b27 13123 main.cjs
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
dc26585558f060c74a11bc7033cf1f22c626e91668a6349a06a928114ecf417d 130466 package-lock.json
e3d60b3adde674e5aa80cc7776db3efbc0ced66d49e89490cb9fbb6786c0db70 3906 package.json
45eea5fe4fd58cc0e96497295223e81a4f3acc3778bf546d4c7c96af03da4d26 9496 preload.cjs
b31c43d9355c13b5ae4efc0f3649d8cb8d509b2bb7ebb042ff546b7820fb7de8 8411 Publish-ForgeFlow-Release.ps1
a6d32a742412b7836606be00f17be0465f1b6f55d3911f6c73a14029787ba206 14037 README.md
efe2f75ec8bfdbad8e2ee68d0f2c4b412ba460c6b841163d7853f56d5b7b63ea 130468 package-lock.json
605514833fe95a59cbfd5cee6a509ce8a0228a8b79c1fcc5bdb4e8fea92084b7 4364 package.json
e95be3f3736f2c1ef249fdc9a083fffa46c07a0b885979878a552a4846995aa6 10160 preload.cjs
abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd
f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1
688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1
794bbe1937077788f34c64398fd73dc9a3c43d095084aa32673f3e968b115de2 9150 README.md
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
00d57bda5af8c8eda294b72d18b318f024a307b81b0d9205a0821f5240151e31 3814 scripts/apply-binary-update.ps1
f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287 scripts/apply-source-update.ps1
33a040b12fc5deab05f9df5f71986d0bb71b91f15a8cd25e9ab3cf7ca9a26766 4212 scripts/audit-installed-deployments.cjs
6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs
5e9a2a819522f6a32bbd9d3303263d5e5eaec95898ea2cd5776b221168008d75 1727 scripts/generate-source-manifest.mjs
f6f89e893195b9c8ef0ff01e256005b9b3cd7d4a278722979a5e0e616c86a89f 1733 scripts/generate-source-manifest.mjs
842436680521311594e798848b050ae4e488d0595f0de57315f6ec081c049fb9 1266 scripts/prune-dist.mjs
74433d8a6b24afe368197a469e2fe0c5050c239d7250b84c2f3f598c304778b0 4736 scripts/publish-binary-release.cjs
403a64db5069595a83006a4e293d7e5ceeaefcfb74e820ed3e899864a0f182d2 6066 scripts/publish-binary-release.cjs
444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs
42203f9e0fd4aae517284d387f265cf1b0b180379bc253a092b5c3c5c4caef0a 2992 scripts/validate-installed-connections.cjs
1a0931346c89d2d49b736a25aa58c5d29fbdf4acd593ea3614dba904ec462843 12374 scripts/verify.mjs
4393f7dc5f417e6d601a68238f4e26791799a3634acec228fe4d79deaee85eb5 3109 scripts/validate-installed-connections.cjs
43e84c3cced1e23ba5b070d87051a235cd7e2d8c02e2e5ddc1e5e0ff2afabace 16487 scripts/verify.mjs
0079701b5acbfef07b71a9623613d1940805ccd20649d77e3f34c37e79df7655 735 scripts/write-release-checksums.mjs
619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1
dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs
a381848a296c28f6d14093c96f722967acf9c994ffb867d54dd92bf5ada2729b 23648 src/main/config-store.cjs
c9dee05857b6eb9475dace885579291b9ce6830023ab54acafaa146d15962a7a 27754 src/main/config-store.cjs
2fb04b1494b39f5d7c0720fa5fd298cd46fa85dc1b696d77657592347fcf1819 2731 src/main/configuration-backup.cjs
9d0af5074093108a5248d0dde0ff70a666748e61f1954b630886a81e8f34072c 24079 src/main/deployment-service.cjs
c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs
a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs
0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs
6ee0c645521c20043708595d1a9dd0fe7642fc300be1306effd7e1142eebcfb7 17201 src/main/gitea-service.cjs
13f6521bb82aa5a3399d6839ea7563459b19969c1e5af7525e4116cf771282e9 46526 src/main/ipc.cjs
3ce45837099ac7bddc024974bd839575b4b765a7df9055e7d45ef889dc85bf7f 15623 src/main/git-validator-service.cjs
d85d5b1abb35e8bd7f1273a697914eeaf96567d3d4ce3f55f364765d35ea4ad9 19900 src/main/gitea-service.cjs
8adeebd08aeafaf79ed8fc7bcd2c1e110d3dc17c14596fb623b9d2c7255fcd11 49662 src/main/ipc.cjs
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs
3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs
e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508 src/main/repository-monitor.cjs
17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs
b31a63bf8cb1807b3e838e2bf8a0e742738f119d13de8ca9f42e471f072217d3 8328 src/main/ssh-service.cjs
f4e221edace12e2120ec439e0f91274de110872c5bfbe968f2d7935eeb7193c4 78544 src/main/unraid-deployment-service.cjs
45e65564e1e8b9db487dc6dda03a752c51130189f23ec3b1260dc62e3621a925 20806 src/main/update-service.cjs
00b5c44b7d04452498288d42a2c345e0e3922c8ddfabfee9eb04654f238811d5 197015 src/renderer/app.js
52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs
afef3841a3948b2121f8fba809aae4ea3da71bd2fda86973ba50200a5b1f89b2 14894 src/main/ssh-service.cjs
75f4135bc068d6c615fc317cfda81f33a4387c4ff44fbb9799a055f302cc154d 137170 src/main/unraid-deployment-service.cjs
b654a9e45044ad32c61fabe4a6d897288615ec83739b53e3241ff881e32f56bd 21677 src/main/update-service.cjs
07822cdaf34745678b82b7eb0f20fedb16bdbcd6ab2b9b774ae0cf9c6069032d 234703 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
e1c463d6cda9f2b9b78c468845c0a7e8688f0362be5642074a1a5f7122dfe811 762 src/renderer/index.html
2b68194e9ca0caab1ea2d1e5f7222a42f962ea16f3dd9517a7a13af811963969 51166 src/renderer/mock-bridge.js
e30ea37ee00f34110d0d252c79f9d8e0bb127c4db0bd1bf8b374609dea433361 72160 src/renderer/styles.css
fb7ed47f9aac50d9259d7d3c3bb2010c7bfdd2fe8e8e47ca2744bb22f0057d54 830 src/renderer/index.html
b63786e7e4f70eac8b65d29484c0040aec530a410da5f554574d8a056cd90003 60621 src/renderer/mock-bridge.js
34ee56ed08dcd1c2295985b9bb419b981598a2699ee8e65394d86f4177f4797b 77647 src/renderer/styles.css
0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs
5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs
029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs
2778ebcbdf60fdc1cb0749f15565e0e1bd66f3a0d31eb70ae7942a7511a3de75 1295 src/shared/repository-match.cjs
c7e120ea53c5ef3c01b8cce71afe913f34bb461bb73aa3ade24656e09f99f338 1152 src/shared/semver.cjs
ede2c95bb045c0005a3931709a0116d9fbcb3faa5f609848a0066c6ba382ca0b 2906 src/shared/shell-verification.cjs
8791d3813e6cf285ee6aa49f76e75fc1f3af76fd98c76bcb3c92ee18e9cb699f 2889 src/shared/shell-verification.cjs
2daa98fd421598bfe5fc9757c9b6f4d82c31d1bfece15829928473581d5d2639 1210 src/shared/tool-invocation.cjs
114f01be8bd54c91b90af82d8e1604e24cc0c5f8e64e63c40cf3f4042623a98e 5402 src/shared/validation.cjs
13b731c38863b1007b0312fd9d89562401b7cce875c952f52429bde74f77a8af 3096 src/shared/zip-writer.cjs
c1b0fd37a6ae74a56750138626fb2bc3523485c1124a0b8ac8ee3ef41089206a 2156 START_HERE.md
f8853dce6fdf360d5df2fbe2b6df3e5687630c807fee5ba8436679b34ec737ea 2436 START_HERE.md
058aeaa5d9bfe377c7e322f213c7871ecc4151b5d08ef790992f4ee28d857658 743 START-FORGEFLOW-OVERLAY.ps1
f5b0ea887fcdeadec78c1ad49b0ec7979723562f5c0b730703acb77a37281ee0 1009 tests/acceptance.test.mjs
a4e5947204ff6878e601e32477bc85b53cd0153baf95a161c8935b6e5466c257 1155 tests/audit-service.test.mjs
@@ -132,22 +147,24 @@ fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800
dd121d96ca265a027cd415a52064500a4541b2f8a662f4f4b25f2f996d52b5da 762 tests/external-tools.test.mjs
e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 tests/git-integration.test.mjs
5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db 1239 tests/git-status.test.mjs
73d00729696e5067ba33dd6d43b018d89ce7fdd561a60ab66648d3283fb54d21 5370 tests/git-validator.test.mjs
681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.test.mjs
e9c45a70635adea853a0a420caabd5ba465070296d2e73ffbb947d4ade52ed46 6165 tests/gitea-actions.test.mjs
267d76b868d8d06ea031c14acd09a7715fb44668a25ade51a9e62e0170888bc8 1522 tests/ipc-contract.test.mjs
771eeb4fa5443d581991cedb4107d7c414ce1b7d2e14bac7cf719ec8ba131647 8969 tests/gitea-actions.test.mjs
48bca4711e7c193d19c78a0cb45ea1c83179b3c23640195f66058268e8a11b52 1520 tests/ipc-contract.test.mjs
caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs
96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs
c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 3718 tests/preflight.test.mjs
185e96ff8fd029f06b77155117f492bc16684e08818a160148e00737dd8c220b 8395 tests/renderer-workflow.test.mjs
15e05220fd282895b02ea52ceaa38327a61297af8db1484e6d9964e8b03a8fea 9156 tests/renderer-workflow.test.mjs
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs
f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 1068 tests/repository-monitor.test.mjs
75b5b83836c75675bb9a48fe4363fcb8a24fc425e6af6f822d7955c6f3c79eac 2265 tests/repository-service.test.mjs
d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463 tests/security-validation.test.mjs
bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs
020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs
2571128f0b8e650071df17755baa09c4dfc441af0c20a7a4e9aa445b59e87d11 1654 tests/ssh-service.test.mjs
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs
8f44579de3af7d7d23d55572d1d97cb0690425d4d9dffc30c6bf2ec3ff9c54eb 24553 tests/unraid-deployment.test.mjs
11d6e6329f775617a1ce3657d0454cc97d7bcf3d9759f9a5a623c9d18e13d03e 15118 tests/update-service.test.mjs
5634e82a3a3c782cd6d7cd4df42b3102c22b81c5566fb072d7cd30de65c1f395 40003 tests/unraid-deployment.test.mjs
4abe7b2fc113c486f35f15c2d629c5b4f24589eada718c4ea58f93551c77d5eb 17777 tests/update-service.test.mjs
9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs
7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs
8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea 767 UPDATE_FROM_0.3.2.md
+5 -3
View File
@@ -1,12 +1,14 @@
# Start here — ForgeFlow v0.6.0
# Start here — ForgeFlow v0.9.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 an older source release?
## Already running an older ForgeFlow release?
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.
Run `Publish-ForgeFlow-Release.ps1` from the validated source. It now publishes the source commit and matching Windows installer/portable assets together. Leave the currently installed older folder or executable untouched, then test **Settings → ForgeFlow updates → Check now → Download update → Apply & restart**. Configuration and credentials remain outside the application directory.
When version 0.9.0 source was already pushed without a Gitea binary release, run `Publish-Missing-Binary-Release.ps1 -ExpectedVersion 0.9.0` once. Afterwards the existing 0.8.9 updater can install 0.9.0 normally.
## Fast path on Windows
+32
View File
@@ -0,0 +1,32 @@
# ForgeFlow 0.10.0
ForgeFlow 0.10.0 makes existing Unraid workloads substantially easier and safer to adopt, verify and deploy.
## Deployment discovery and verification
- Automatic discovery links unique running server workloads to their matching Gitea repositories while excluding unrelated infrastructure and stale release folders.
- Deployment cards preserve the last verified live commit and compare it with the current Gitea branch, even when a container was updated outside ForgeFlow.
- Existing Compose project names, files, services, remote folders and DockerMan metadata are adopted from server truth instead of guessed or overwritten.
## Safe Server pull
- Server pull is now the recommended deployment route and fetches the exact requested commit from Gitea.
- Each repository receives its own repository-scoped read-only deploy key; ForgeFlow never installs the desktop Gitea token on Unraid.
- Gitea SSH host fingerprints are pinned and checked before trust is changed and before every pull.
- Fetch, archive, checksum, Compose validation, activation and rollback remain exact-commit and transactional.
- Direct copy remains available when server-side Git access is undesirable, and monitoring-only links cannot deploy accidentally.
## Git hygiene
- Git Validator now also checks `.gitattributes`, `.editorconfig`, dependency lockfiles and a Gitea Actions workflow.
- Safe repairs create reviewable files without committing or pushing them automatically.
- Existing identity, upstream, synchronization, branch-protection, documentation, secret-path and oversized-file checks remain available in one scored view.
## Interface and reliability
- The deployment workspace now emphasizes repository, container, environment and live-versus-Gitea evidence, with a focused animated project illustration and clearer server inventory.
- Large unrelated server inventories are summarized instead of producing dozens of indistinguishable cards.
- Connection validation consistently opens the same encrypted Electron profile as the installed app.
- Obsolete ForgeFlow artifacts are removed from `dist` after every successful packaged build.
No container was restarted or replaced during automatic discovery. Deployments still require a successful preflight and an explicit user action.
+20
View File
@@ -0,0 +1,20 @@
# ForgeFlow 0.8.9
ForgeFlow 0.8.9 introduces Git Validator, a dedicated repository assurance
workspace that checks whether practical Git and Gitea best practices are being
followed.
The validator produces a weighted score with evidence for repository identity,
upstream tracking, working-tree state, effective commit identity, safe local
synchronization defaults, default-branch and force-push protection, README and
gitignore hygiene, tracked secret-shaped filenames and oversized tracked files.
Every repair is deliberately bounded. Origin alignment and repository-local
fetch/pull/autostash safeguards can be applied as safe fixes. Creating default
branch protection or a recommended `.gitignore` requires explicit confirmation.
The generated `.gitignore` remains uncommitted for review, and secret/history
findings are never modified automatically.
The new workspace includes grouped findings, an assurance score, safe-fix
batching, audit events, interactive project illustration and responsive premium
layouts for light and dark themes.
+30
View File
@@ -0,0 +1,30 @@
# ForgeFlow 0.9.0
ForgeFlow 0.9.0 changes Unraid deployments from a Git-checkout-first workflow into a server-inventory-first workflow.
## Existing installations are now visible
Server Inventory collects a safe, selected subset of Docker, Compose and DockerMan metadata for both running and stopped containers. It recognizes Compose project names, working directories, active Compose files and services, mounts, ports, runtime state, image provenance and existing DockerMan templates. Environment values and other container secrets are not collected.
Exact repository provenance may be linked automatically. Name similarity is never treated as proof: uncertain workloads remain visible as suggestions and can be linked through the new manual wizard. The saved link preserves the workload identity rather than depending on the disposable container ID.
## Push bundle is the new default
New SSH/Unraid profiles use **Push bundle**. ForgeFlow creates a tar archive from the exact local Git commit, calculates its SHA-256 digest and uploads it over the already trusted desktop-to-Unraid SSH connection. Unraid therefore does not need a Git client, Gitea host-key entry or Gitea private key for this mode.
The server verifies the checksum and archive paths, rejects symlink payloads, preserves configured runtime paths, updates only ForgeFlow-managed files and validates the merged Compose model before starting services. The active SHA and managed-file manifest are promoted atomically only after the expected services are running. Failure restoration keeps the previous deployment truth and restores overwritten files and Compose metadata.
**Server-side Git** remains available as an explicit mode. Its Gitea access check is now reported separately from desktop SSH and Docker/Compose capabilities. **Monitor only** links an existing workload without granting ForgeFlow permission to deploy it.
## Safer adoption
Adopted workloads retain their existing Compose project, Compose files and service set. ForgeFlow no longer overrides their image or container name in the metadata overlay. Existing DockerMan templates are left untouched; generated templates are managed only for explicitly generated Compose profiles. `--force-recreate` and `--remove-orphans` are opt-in rather than defaults.
A deployment lock records the live shell process, and an old lock is removed only when it is sufficiently old and its owner no longer runs. Deployment output truncation now fails explicitly instead of allowing ForgeFlow to interpret an incomplete inventory or command result.
## Release publication correction
- The standard release publisher now publishes the validated source and matching Windows binary assets as one workflow.
- Added `Publish-Missing-Binary-Release.ps1` to repair a source-only Gitea release without reinstalling ForgeFlow manually.
- Binary publication now derives the repository owner, repository name and branch from ForgeFlow settings instead of hardcoding them.
- Missing-release errors now explain that packaged installations require both Windows executables and their SHA-256 sidecars.
+10
View File
@@ -0,0 +1,10 @@
# ForgeFlow 0.9.1
## Gitea binary updater repair
- Downloads the actual release attachment through `browser_download_url` instead of treating the Gitea attachment metadata response as an executable.
- Keeps the Gitea token on the configured Gitea origin and follows HTTPS object-storage redirects without leaking credentials.
- Adds regression coverage for attachment metadata lookup, direct browser download URLs and cross-origin redirect safety.
- Improves diagnostics when an older updater receives JSON attachment metadata.
Because ForgeFlow 0.8.9 and 0.9.0 contain the broken attachment endpoint, upgrading to 0.9.1 requires one manual installer run. in-app updates work normally again after 0.9.1 is installed.
+10
View File
@@ -0,0 +1,10 @@
# ForgeFlow 0.9.2
## Emergency deployment and discovery repair
- Existing deployment profiles without an explicit mode migrate to **Push bundle**, never Server-side Git.
- Push bundle deploys the clean, committed local HEAD directly over SSH/SFTP and never requires a Gitea key, upstream, remote sync, or Git on Unraid.
- Desktop-to-Unraid authentication can use the server password; a failed key opens a password recovery flow instead of blocking deployment.
- Server inventory scans `docker ps -a`, complete Docker inspect data, Compose projects, and every DockerMan user template, including stopped and template-only workloads.
- Inventory connection failures are shown as failures rather than misleading zero counts.
- Existing Compose and DockerMan workloads can be linked manually without restarting or modifying them.
+25
View File
@@ -0,0 +1,25 @@
# ForgeFlow 0.9.3
This release removes server-side repository authentication from every SSH/Unraid deployment path and makes server Compose files the primary discovery source.
## Direct deployment only
- Every existing SSH/Unraid deployment profile is migrated to **Direct copy**, except profiles explicitly marked **Monitor only**.
- Deployment and rollback archive the exact committed local HEAD, upload it over the already configured desktop-to-Unraid connection and run Docker Compose on Unraid.
- Preflight contains no Unraid-to-repository access probe, no remote `git ls-remote`, no repository-key validation and no Git requirement on Unraid.
- Password authentication for the desktop-to-Unraid connection remains supported and is independent of repository access.
## Compose-file inventory and automatic linking
- Server Inventory scans Compose YAML files such as `compose.yml`, `compose.yaml`, `docker-compose.yml`, `docker-compose.yaml`, overrides and stack YAML files below the configured appdata roots.
- YAML discovery still runs when Docker inspection fails or Docker is unavailable, so a container-query problem no longer produces a false empty inventory.
- ForgeFlow reads the Compose project name, file set, service names and image names from the server.
- A unique high-confidence repository match based on both Compose folder and project identity is linked automatically.
- Remaining strong matches use a one-click link action with server folder, Compose project, Compose files, services, container identity and preservation paths already filled in.
- Runtime containers, stopped containers and DockerMan templates are merged with the YAML definition when available. `/mnt/user/appdata`, `/mnt/cache/appdata` and disk-backed appdata paths are treated as the same logical deployment location.
## Reliability
- The inventory shell script is safe under `set -euo pipefail`; Docker or Compose command failures are captured as warnings instead of aborting the scan.
- Large runtime, cache, log and database folders are pruned while searching for Compose files.
- A failed inventory operation remains visible as an error and is never presented as zero workloads.
+7
View File
@@ -0,0 +1,7 @@
# ForgeFlow 0.9.4
ForgeFlow now treats the real Compose files discovered on Unraid as the only activation source for adopted workloads. A generated labels fragment is no longer merged into an imported project, so stale service hints such as `geointel` cannot create a phantom service without an image or build context.
Direct copy redeployments always use `--force-recreate`. The runtime service list comes from `docker compose config --services`, not from manually stored service names. Before activation ForgeFlow records the existing container ID for every service; after activation it verifies that each service is running, not unhealthy, and uses a different container ID.
ForgeFlow also rejects the deployment when Compose starts a duplicate workload while leaving the previous container running. The new SHA is written only after these checks pass. Existing DockerMan templates and the real Compose files remain untouched.
+11
View File
@@ -0,0 +1,11 @@
# ForgeFlow 0.9.5
ForgeFlow 0.9.5 makes Direct copy deployments fail closed and adds an in-app repair for Unraid write permissions.
Before a bundle is created or uploaded, ForgeFlow probes the linked project folder, `.forgeflow` upload/state folders and every active Compose file using the configured SSH identity. A failed check names the exact path, user, owner, group and mode. Deployment stops before file transfer and before any Docker or Compose command changes the runtime. The same write-access check runs again immediately before upload to prevent a stale preflight result.
Every SSH / Unraid deployment card now includes **Check / fix write access**. The same action appears beside a blocking preflight result. It normalizes the linked source tree and ForgeFlow state folders to safe shared access, uses the Unraid `users` group where available and preserves existing executable bits. Configured runtime locations such as `.env`, `appdata`, `data`, `config`, `logs`, mounted data paths and common generated dependency folders are excluded. It never runs Docker, stops a container, removes a container or uses `chmod 777`. The action also runs when the SSH account is root so manual SMB/file-copy access can be repaired, not only ForgeFlow's own write access.
Direct copy now validates candidate Compose configuration and builds candidate images before replacing live source files. It never implicitly executes `docker compose down`, `--remove-orphans` or `--force-recreate`. Existing container IDs, image IDs and source files are captured first. When activation fails, ForgeFlow restores the prior source, retags the previous images, attempts to restore the previous runtime and retains the backup evidence. A release is only promoted after the exact linked Compose services are running and verified.
This release does not claim live validation against a specific private Unraid server. The automated suite validates generated Bash syntax, permission-report parsing, scoped repair commands, no server-to-Gitea authentication, no destructive Compose flags and transactional deployment ordering.
+65 -87
View File
@@ -1,95 +1,73 @@
# SSH / Unraid deployment
ForgeFlow deploys an exact Gitea commit directly to an Unraid server over pinned SSH.
ForgeFlow uses one deployment flow for Unraid: it copies the exact committed local project from the desktop to the server and activates the Compose definition found for that deployment.
## Security model
## Deployment modes
- Credentials are entered only in the local ForgeFlow desktop application.
- Ed25519 private keys are preferred.
- Passwords and key passphrases use Electron safe storage.
### Direct copy — default
ForgeFlow creates an archive from the exact local commit and uploads it through the configured desktop-to-Unraid connection. Unraid needs Docker, Docker Compose, `tar` and a SHA-256 checksum tool. Unraid does not clone, fetch or authenticate to a repository.
### Monitor only
ForgeFlow inventories and tracks the workload but refuses deploy and rollback operations until **Direct copy** is selected.
All older SSH/Unraid profiles are migrated to Direct copy unless they were explicitly Monitor only.
## Server Inventory and automatic linking
Server Inventory reads the server itself instead of relying on ForgeFlow history. The default scan root is `/mnt/user/appdata`, together with the configured server base path and the cache-backed appdata path when present. It combines:
- running and stopped containers from `docker ps -a` and Docker Inspect;
- active and stopped Compose projects;
- DockerMan templates;
- Compose YAML files below the configured appdata roots, including standard override files.
YAML discovery continues even when Docker inspection fails. For each Compose definition ForgeFlow reads the working directory, project name, file set, services and images. It then compares those values with the linked local repositories.
A unique high-confidence match based on both the Compose folder and project identity is linked automatically. Other strong matches show a one-click **Link to repository** action. The server folder, Compose project, Compose files, service list, visible container identity, ports and preservation paths are already filled in; linking does not recreate the container.
## Compose identity
An adopted installation retains the identity detected on the server:
```text
Visible container: geointel
Server folder: GeoIntel
Compose project: geointel
Compose files: compose.yml, compose.override.yml
Compose services: web, worker
```
ForgeFlow adds `.forgeflow/compose.metadata.yml` as the final Compose overlay. For adopted workloads this overlay adds safe labels only; it does not replace the existing image, volumes, ports, networks or `container_name`.
`--force-recreate` and `--remove-orphans` remain disabled by default. Existing DockerMan templates are not rewritten.
## Direct-copy sequence
1. Verify the selected local branch, clean working tree and exact committed HEAD.
2. Test the desktop-to-Unraid connection, Docker, Compose, `tar`, checksum tooling and deployment storage.
3. Create the release locally with `git archive`.
4. Upload a temporary `.part` file through SFTP.
5. Verify SHA-256 and reject unsafe archive paths or symbolic links.
6. Preserve `.forgeflow`, `.git` and configured runtime paths such as `.env`, `data`, `config`, `logs` and application-specific folders.
7. Update only files covered by the managed release manifests; unrelated server files remain untouched.
8. Validate the detected merged Compose configuration.
9. Activate the retained Compose project and verify every selected service is running and not unhealthy.
10. Promote the active SHA and manifests only after activation succeeds.
11. Run the optional desktop health check and persist runtime state.
If activation fails, ForgeFlow restores the previous managed files and Compose metadata and leaves the previous active SHA authoritative.
## Authentication model
- The only remote authentication used for Direct copy is the configured desktop-to-Unraid connection.
- That connection may use an Unraid password or a private key.
- Passwords and private-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.
## Profile identity
ForgeFlow separates names that users see from names Docker requires:
```text
Visible project/container: Portfolio
Server folder: Portfolio
Internal Compose project: portfolio
Internal Compose service: portfolio
Internal image: forgeflow/portfolio:production
```
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
For an existing folder:
```text
Server folder: Portfolio
Remote path: /mnt/user/appdata/Portfolio
Compose file: docker-compose.yml
```
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.
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
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.
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 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.
## Interrupted operation recovery
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.
- Remote inventory collects selected labels, mounts, ports and runtime state; it does not collect container environment values.
- The renderer cannot submit arbitrary shell commands; remote scripts are assembled from validated profile fields.
## Rollback
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.
Rollback is allowed only to the exact `previousSha` recorded for the profile. ForgeFlow recreates that commit archive locally and uses the same upload, checksum, backup, Compose validation and atomic promotion flow.
+17 -7
View File
@@ -30,19 +30,29 @@ Update logs and status files are stored beneath ForgeFlow's local user-data `upd
## Packaged Windows updates
ForgeFlow 0.8.2 and newer use authenticated Gitea release assets when running from the installer or portable executable. The updater selects the installer or portable artifact that matches the current installation mode, requires its `.sha256` sidecar, validates the Windows executable header and SHA-256 digest, then verifies the digest again immediately before applying it. An external PowerShell helper waits for ForgeFlow to exit, installs or replaces the executable and restarts it.
ForgeFlow 0.9.1 and newer use authenticated Gitea release assets when running from the installer or portable executable. The updater selects the installer or portable artifact that matches the current installation mode, requires its `.sha256` sidecar, validates the Windows executable header and SHA-256 digest, then verifies the digest again immediately before applying it. An external PowerShell helper waits for ForgeFlow to exit, installs or replaces the executable and restarts it.
Publish a verified binary release after pushing its source commit:
`Publish-ForgeFlow-Release.ps1` now treats source and binaries as one release transaction. By default it pushes the validated source, builds the exact published commit and uploads all four required assets:
- `ForgeFlow-Setup-<version>-win-x64.exe`
- `ForgeFlow-Setup-<version>-win-x64.exe.sha256`
- `ForgeFlow-Portable-<version>-win-x64.exe`
- `ForgeFlow-Portable-<version>-win-x64.exe.sha256`
Use `-SkipBinaryRelease` only when intentionally publishing source without enabling packaged auto-update.
When the source was already pushed without a binary release, run the recovery publisher from Windows:
```powershell
npm run dist:win
$env:FORGEFLOW_USER_DATA = "$env:APPDATA\forgeflow"
npm run release:binary
Set-ExecutionPolicy -Scope Process Bypass
.\Publish-Missing-Binary-Release.ps1 -ExpectedVersion 0.9.1
```
The recovery script clones the current Gitea branch into a temporary directory, verifies the exact branch commit, runs the complete quality gate, builds both Windows artifacts and creates or repairs the matching Gitea release. It uses the encrypted Gitea token already stored by ForgeFlow.
Every platform build finishes by removing ForgeFlow artifacts for older versions from `dist`. The unpacked application directory and builder diagnostics are kept.
The publisher refuses to upload when local `HEAD` differs from `origin/main`. Users on 0.8.1 or older need one manual 0.8.2 installation because those versions deliberately disabled packaged updates.
The binary publisher refuses to upload when local `HEAD` differs from the configured Gitea branch. ForgeFlow 0.8.9 and 0.9.0 queried Gitea attachment metadata as though it were the executable. Those versions require one manual 0.9.1 installer run. From 0.9.1 onward, the updater follows the release asset browser download URL and in-app updates work normally.
## Publishing a release from Downloads
@@ -60,6 +70,6 @@ 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.
The script installs dependencies, runs the complete quality gate, clones `git@gitea.itworx.tech:Jens/ForgeFlow.git` into a temporary folder, mirrors the validated source without `.git`, `node_modules`, `dist` or release archives, commits it and pushes `main`. It then compares local `HEAD` with `git ls-remote`, builds the exact published checkout and uploads the installer, portable executable and both checksums to the matching Gitea release. Publication fails when either the source commit or any required binary asset cannot be verified.
Keep the currently installed older ForgeFlow source folder untouched until the built-in updater test is complete.
Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 110 KiB

+1 -1
View File
@@ -126,7 +126,7 @@ git -C "$APP_DIR" merge-base --is-ancestor "$SHA" "origin/$BRANCH" || {
write_status "deploying" "$current_sha" "$previous_sha" 0
git -C "$APP_DIR" reset --hard "$SHA"
docker compose -f "$COMPOSE_FILE" up -d --build --remove-orphans
docker compose -f "$COMPOSE_FILE" up -d --build
for attempt in $(seq 1 30); do
if curl --fail --silent --show-error --max-time 5 "$HEALTHCHECK_URL" >/dev/null; then
+7
View File
@@ -25,6 +25,7 @@ const {
UnraidDeploymentService,
} = require("./src/main/unraid-deployment-service.cjs");
const { AuditService } = require("./src/main/audit-service.cjs");
const { GitValidatorService } = require("./src/main/git-validator-service.cjs");
const {
ExternalToolsService,
} = require("./src/main/external-tools-service.cjs");
@@ -285,6 +286,11 @@ app
userDataPath,
secureStorageAvailable: () => safeStorage.isEncryptionAvailable(),
});
const gitValidator = new GitValidatorService({
git,
gitea,
diagnostics,
});
repositoryMonitor = new RepositoryMonitor({
store,
git,
@@ -302,6 +308,7 @@ app
ssh,
updates,
preflight,
gitValidator,
diagnostics,
audit,
externalTools,
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "forgeflow",
"version": "0.8.8",
"version": "0.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "forgeflow",
"version": "0.8.8",
"version": "0.10.0",
"dependencies": {
"ssh2": "1.17.0"
},
+13 -2
View File
@@ -1,6 +1,6 @@
{
"name": "forgeflow",
"version": "0.8.8",
"version": "0.10.0",
"private": true,
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
"main": "main.cjs",
@@ -17,6 +17,7 @@
"doctor": "node scripts/doctor.mjs",
"acceptance": "node scripts/acceptance.mjs",
"connections:check": "electron scripts/validate-installed-connections.cjs",
"deployments:audit": "electron scripts/audit-installed-deployments.cjs",
"release:binary": "electron scripts/publish-binary-release.cjs",
"manifest": "node scripts/generate-source-manifest.mjs",
"check": "npm run verify && npm test"
@@ -63,7 +64,9 @@
"docs/RELEASE_NOTES_0.4.5.md",
"docs/RELEASE_NOTES_0.5.0.md",
"docs/RELEASE_NOTES_0.5.1.md",
"PUBLISH-AND-ENABLE-UPDATE.cmd",
"Publish-ForgeFlow-Release.ps1",
"Publish-Missing-Binary-Release.ps1",
"docs/RELEASE_NOTES_0.5.2.md",
"docs/RELEASE_NOTES_0.5.3.md",
"docs/RELEASE_NOTES_0.5.4.md",
@@ -81,7 +84,15 @@
"docs/RELEASE_NOTES_0.8.6.md",
"docs/RELEASE_NOTES_0.8.7.md",
"docs/RELEASE_NOTES_0.8.8.md",
"docs/ACCEPTANCE.md"
"docs/RELEASE_NOTES_0.8.9.md",
"docs/RELEASE_NOTES_0.9.0.md",
"docs/RELEASE_NOTES_0.9.1.md",
"docs/ACCEPTANCE.md",
"docs/RELEASE_NOTES_0.9.2.md",
"docs/RELEASE_NOTES_0.9.3.md",
"docs/RELEASE_NOTES_0.9.4.md",
"docs/RELEASE_NOTES_0.9.5.md",
"docs/RELEASE_NOTES_0.10.0.md"
],
"asarUnpack": [
"scripts/apply-binary-update.ps1"
+5
View File
@@ -106,6 +106,7 @@ contextBridge.exposeInMainWorld(
openExternal: (url) => invoke('external:open', { url }),
saveDeploymentProfile: (fullName, profile) => invoke('deployment:save-profile', { fullName, profile }),
deploymentPreflight: (repository, profileId) => invoke('deployment:preflight', { repository, profileId }),
repairDeploymentWriteAccess: (repository, profileId) => invoke('deployment:repair-write-access', { repository, profileId }),
deleteDeploymentProfile: (fullName, profileId) => invoke('deployment:delete-profile', { fullName, profileId }),
deploy: (repository, profileId, sha, options = {}) =>
invoke('deployment:dispatch', {
@@ -120,6 +121,8 @@ contextBridge.exposeInMainWorld(
healthcheck: (url) => invoke('deployment:health', { url }),
refreshProfileState: (fullName, profileId) => invoke('deployment:profile-state', { fullName, profileId }),
discoverServerDeployments: () => invoke('deployment:discover-server-workloads'),
linkServerWorkload: (repository, serverId, workloadId, deploymentMode = 'server-git', remoteFolder = '') => invoke('deployment:link-server-workload', { repository, serverId, workloadId, deploymentMode, remoteFolder }),
configureServerGitAccess: (repository, profileId) => invoke('deployment:configure-server-git-access', { repository, 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 }),
@@ -127,6 +130,8 @@ contextBridge.exposeInMainWorld(
troubleshooterScan: (fullName = null) => invoke('troubleshooter:scan', { fullName }),
troubleshooterRepair: (issue) => invoke('troubleshooter:repair', { issue }),
troubleshooterAutoRepair: (issues) => invoke('troubleshooter:auto-repair', { issues }),
gitValidatorScan: (fullName) => invoke('git-validator:scan', { fullName }),
gitValidatorRepair: (fullName, check) => invoke('git-validator:repair', { fullName, check }),
diagnosticsStatus: () => invoke('diagnostics:status'),
clearDiagnostics: () => invoke('diagnostics:clear'),
openDiagnosticsFolder: () => invoke('diagnostics:open-folder'),
+81
View File
@@ -0,0 +1,81 @@
"use strict";
const path = require("node:path");
const { app } = require("electron");
const { ConfigStore } = require("../src/main/config-store.cjs");
const { GitService } = require("../src/main/git-service.cjs");
const { GiteaService } = require("../src/main/gitea-service.cjs");
const { RepositoryService } = require("../src/main/repository-service.cjs");
const { SshService } = require("../src/main/ssh-service.cjs");
const { UnraidDeploymentService } = require("../src/main/unraid-deployment-service.cjs");
const userDataPath = process.env.FORGEFLOW_USER_DATA
? path.resolve(process.env.FORGEFLOW_USER_DATA)
: path.join(app.getPath("appData"), "forgeflow");
app.setPath("userData", userDataPath);
app.whenReady().then(async () => {
try {
const reconcile = process.argv.includes("--reconcile");
const configureAccess = process.argv.includes("--configure-access");
const repositoryFilter = new Set(String(process.argv.find((value) => value.startsWith("--repository=")) || "")
.slice("--repository=".length).toLowerCase().split(",").map((value) => value.trim()).filter(Boolean));
const store = new ConfigStore(userDataPath);
await store.load();
const git = new GitService();
const gitea = new GiteaService(store);
const repositories = await new RepositoryService(store, git, gitea).refresh();
const ssh = new SshService({ store });
const deployments = new UnraidDeploymentService({ store, ssh, git, gitea, sourcePath: path.resolve(__dirname, "..") });
const reports = [];
for (const server of store.data.servers || []) {
const report = await deployments.scanServerInventory(server.id, repositories, { autoLink: reconcile });
const access = [];
if (configureAccess) {
const refreshedRepositories = await new RepositoryService(store, git, gitea).refresh();
const seenProfiles = new Set();
for (const workload of report.workloads.filter((item) => item.runtime?.running && item.link?.profileId && item.link?.repositoryFullName)) {
if (seenProfiles.has(workload.link.profileId)) continue;
seenProfiles.add(workload.link.profileId);
const repository = refreshedRepositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link.repositoryFullName).toLowerCase());
if (!repository) continue;
if (repositoryFilter.size && !repositoryFilter.has(String(repository.fullName).toLowerCase())) continue;
try {
const configured = await deployments.configureServerGitAccess({ repository, profileId: workload.link.profileId });
access.push({ repository: repository.fullName, ready: true, created: configured.created, remoteSha: configured.remoteSha });
await new Promise((resolve) => setTimeout(resolve, 1500));
} catch (error) {
access.push({ repository: repository.fullName, ready: false, error: error.message });
}
}
}
reports.push({
server: server.name,
capabilities: report.capabilities,
warnings: (report.warnings || []).map((warning) => String(warning).slice(0, 300)),
summary: {
detected: report.detected,
running: report.running,
linked: report.linked,
needsReview: report.needsReview,
},
access,
workloads: report.workloads.filter((workload) => workload.link || (workload.runtime?.running && workload.status !== "unmatched")).map((workload) => ({
name: workload.displayName,
running: workload.runtime?.running === true,
health: workload.runtime?.health || "unknown",
repository: workload.link?.repositoryFullName || workload.suggestedRepository?.fullName || null,
confidence: workload.matchConfidence || workload.status,
folder: workload.remoteFolderCandidate || null,
containers: (workload.containers || []).map((container) => container.name),
})),
});
}
console.log(JSON.stringify(reports, null, 2));
} catch (error) {
console.error(error?.stack || error?.message || String(error));
process.exitCode = 1;
} finally {
app.quit();
}
});
+2 -2
View File
@@ -4,12 +4,12 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const excludedDirectories = new Set(['.git', 'dist', 'node_modules']);
const excludedDirectories = new Set(['.git', 'dist', 'node_modules', 'ForgeFlow-runtime-win-x64']);
const excludedFiles = new Set(['SOURCE_MANIFEST.txt']);
async function collect(directory, output = []) {
for (const entry of await readdir(directory, { withFileTypes: true })) {
if (entry.isDirectory() && excludedDirectories.has(entry.name)) continue;
if (excludedDirectories.has(entry.name)) continue;
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) await collect(absolute, output);
else if (!excludedFiles.has(entry.name)) output.push(absolute);
+49 -16
View File
@@ -11,6 +11,14 @@ const configuredUserData =
path.join(app.getPath("appData"), "forgeflow");
app.setPath("userData", path.resolve(configuredUserData));
function safeRepositoryPart(value, label) {
const text = String(value || "").trim();
if (!/^[a-zA-Z0-9_.-]+$/.test(text)) {
throw new Error(`${label} contains unsupported characters.`);
}
return text;
}
async function api(baseUrl, token, pathname, options = {}) {
const response = await fetch(`${baseUrl}/api/v1${pathname}`, {
...options,
@@ -28,10 +36,11 @@ async function api(baseUrl, token, pathname, options = {}) {
} catch {
data = text;
}
if (!response.ok)
if (!response.ok) {
throw new Error(
`Gitea returned HTTP ${response.status}: ${data?.message || text || response.statusText}`,
);
}
return data;
}
@@ -40,16 +49,32 @@ app.whenReady().then(async () => {
const manifest = JSON.parse(
await fs.readFile(path.join(root, "package.json"), "utf8"),
);
const config = JSON.parse(
await fs.readFile(
path.join(configuredUserData, "forgeflow-config.json"),
"utf8",
),
const configPath = path.join(configuredUserData, "forgeflow-config.json");
const config = JSON.parse(await fs.readFile(configPath, "utf8"));
if (!config?.gitea?.encryptedToken) {
throw new Error(
`No encrypted Gitea token was found in ${configPath}. Sign in to Gitea once from ForgeFlow first.`,
);
}
const token = safeStorage.decryptString(
Buffer.from(config.gitea.encryptedToken, "base64"),
);
const baseUrl = String(config.gitea.baseUrl).replace(/\/+$/, "");
const baseUrl = String(config.gitea.baseUrl || "").replace(/\/+$/, "");
if (!/^https?:\/\//i.test(baseUrl)) {
throw new Error("The configured Gitea base URL is invalid.");
}
const owner = safeRepositoryPart(
process.env.FORGEFLOW_RELEASE_OWNER || config.updates?.owner || "Jens",
"Release repository owner",
);
const repo = safeRepositoryPart(
process.env.FORGEFLOW_RELEASE_REPO || config.updates?.repo || "ForgeFlow",
"Release repository name",
);
const branch = safeRepositoryPart(
process.env.FORGEFLOW_RELEASE_BRANCH || config.updates?.branch || "main",
"Release branch",
);
const version = manifest.version;
const tag = `v${version}`;
const commit = execFileSync("git", ["rev-parse", "HEAD"], {
@@ -58,13 +83,16 @@ app.whenReady().then(async () => {
}).trim();
const remote = execFileSync(
"git",
["ls-remote", "origin", "refs/heads/main"],
["ls-remote", "origin", `refs/heads/${branch}`],
{ cwd: root, encoding: "utf8" },
)
.trim()
.split(/\s+/)[0];
if (commit !== remote)
throw new Error("Local HEAD is not the published origin/main commit.");
if (commit !== remote) {
throw new Error(
`Local HEAD is not the published origin/${branch} commit. Push the exact source before publishing binaries.`,
);
}
const notesPath = path.join(root, "docs", `RELEASE_NOTES_${version}.md`);
const body = await fs.readFile(notesPath, "utf8");
let release;
@@ -72,11 +100,15 @@ app.whenReady().then(async () => {
release = await api(
baseUrl,
token,
`/repos/Jens/ForgeFlow/releases/tags/${encodeURIComponent(tag)}`,
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`,
);
} catch (error) {
if (!/HTTP 404/.test(error.message)) throw error;
release = await api(baseUrl, token, "/repos/Jens/ForgeFlow/releases", {
release = await api(
baseUrl,
token,
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -87,7 +119,8 @@ app.whenReady().then(async () => {
draft: false,
prerelease: false,
}),
});
},
);
}
const binaries = [
@@ -115,7 +148,7 @@ app.whenReady().then(async () => {
await api(
baseUrl,
token,
`/repos/Jens/ForgeFlow/releases/${release.id}/assets/${existing.id}`,
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets/${existing.id}`,
{ method: "DELETE" },
);
}
@@ -124,7 +157,7 @@ app.whenReady().then(async () => {
const uploaded = await api(
baseUrl,
token,
`/repos/Jens/ForgeFlow/releases/${release.id}/assets?name=${encodeURIComponent(name)}`,
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets?name=${encodeURIComponent(name)}`,
{
method: "POST",
body: form,
@@ -139,7 +172,7 @@ app.whenReady().then(async () => {
}
}
console.log(
`PASS ForgeFlow ${version} binary release published for ${commit.slice(0, 7)}`,
`PASS ForgeFlow ${version} binary release published to ${owner}/${repo} for ${commit.slice(0, 7)}`,
);
app.exit(0);
} catch (error) {
+7 -6
View File
@@ -4,9 +4,12 @@ const fs = require("node:fs/promises");
const path = require("node:path");
const { app, safeStorage } = require("electron");
const configuredUserData = process.env.FORGEFLOW_USER_DATA;
if (configuredUserData)
app.setPath("userData", path.resolve(configuredUserData));
const configuredUserData = process.env.FORGEFLOW_USER_DATA
? path.resolve(process.env.FORGEFLOW_USER_DATA)
: path.join(app.getPath("appData"), "forgeflow");
// safeStorage is bound to Electron's userData identity. Set it before ready so
// this verifier decrypts the same secrets as the packaged application.
app.setPath("userData", configuredUserData);
function result(name, ok, detail) {
console.log(
@@ -18,9 +21,7 @@ function result(name, ok, detail) {
app.whenReady().then(async () => {
let passed = true;
try {
const userDataPath = configuredUserData
? path.resolve(configuredUserData)
: path.join(app.getPath("appData"), "forgeflow");
const userDataPath = configuredUserData;
const configPath = path.join(userDataPath, "forgeflow-config.json");
const config = JSON.parse(await fs.readFile(configPath, "utf8"));
const baseUrl = String(config.gitea?.baseUrl || "").replace(/\/+$/, "");
+113 -2
View File
@@ -27,6 +27,7 @@ const required = [
"src/main/repository-monitor.cjs",
"src/main/deployment-service.cjs",
"src/main/unraid-deployment-service.cjs",
"src/main/server-inventory.cjs",
"src/main/ssh-service.cjs",
"src/main/update-service.cjs",
"src/main/diagnostics-service.cjs",
@@ -69,6 +70,14 @@ const required = [
"docs/RELEASE_NOTES_0.8.6.md",
"docs/RELEASE_NOTES_0.8.7.md",
"docs/RELEASE_NOTES_0.8.8.md",
"docs/RELEASE_NOTES_0.8.9.md",
"docs/RELEASE_NOTES_0.9.0.md",
"docs/RELEASE_NOTES_0.9.1.md",
"docs/RELEASE_NOTES_0.9.2.md",
"docs/RELEASE_NOTES_0.9.3.md",
"docs/RELEASE_NOTES_0.9.4.md",
"docs/RELEASE_NOTES_0.9.5.md",
"docs/RELEASE_NOTES_0.10.0.md",
"docs/UPDATING.md",
"docs/DIAGNOSTICS.md",
"docs/DEPLOYMENT_SETUP.md",
@@ -107,9 +116,9 @@ 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.8.8")
if (packageJson.version !== "0.10.0")
throw new Error(
`Expected package version 0.8.8, got ${packageJson.version}.`,
`Expected package version 0.10.0, got ${packageJson.version}.`,
);
const sourceManifest = await readFile(
path.join(root, "SOURCE_MANIFEST.txt"),
@@ -344,10 +353,109 @@ for (const channel of [
"repository:repair-sync",
"deployment:apply-dockerman-metadata",
"deployment:reconcile",
"deployment:link-server-workload",
]) {
if (!ipc.includes(channel))
throw new Error(`IPC registration is missing: ${channel}`);
}
const release090 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.0.md"), "utf8");
for (const phrase of [
"Push bundle",
"manual wizard",
"Monitor only",
"DockerMan templates",
"SHA-256",
]) {
if (!release090.includes(phrase)) throw new Error(`0.9.0 release notes are missing: ${phrase}`);
}
const release091 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.1.md"), "utf8");
for (const phrase of [
"browser_download_url",
"cross-origin",
"manual installer",
"in-app updates",
]) {
if (!release091.includes(phrase)) throw new Error(`0.9.1 release notes are missing: ${phrase}`);
}
const release092 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.2.md"), "utf8");
for (const phrase of [
"Push bundle",
"server password",
"docker ps -a",
"DockerMan",
"zero counts",
]) {
if (!release092.includes(phrase)) throw new Error(`0.9.2 release notes are missing: ${phrase}`);
}
const release093 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.3.md"), "utf8");
for (const phrase of [
"Direct copy",
"Compose YAML",
"linked automatically",
"one-click",
"no remote `git ls-remote`",
]) {
if (!release093.includes(phrase)) throw new Error(`0.9.3 release notes are missing: ${phrase}`);
}
const release094 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.4.md"), "utf8");
for (const phrase of [
"real Compose files",
"stale service hints",
"force-recreate",
"container ID",
"previous container",
]) {
if (!release094.includes(phrase)) throw new Error(`0.9.4 release notes are missing: ${phrase}`);
}
const release095 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.5.md"), "utf8");
for (const phrase of [
"Check / fix write access",
"exact path, user, owner, group and mode",
"preserves existing executable bits",
"never implicitly executes `docker compose down`",
"retains the backup evidence",
]) {
if (!release095.includes(phrase)) throw new Error(`0.9.5 release notes are missing: ${phrase}`);
}
const release0100 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.0.md"), "utf8");
for (const phrase of [
"Server pull",
"read-only deploy key",
"automatic discovery",
"Git Validator",
"SSH host fingerprint",
]) {
if (!release0100.includes(phrase)) throw new Error(`0.10.0 release notes are missing: ${phrase}`);
}
const configSource = await readFile(path.join(root, "src/main/config-store.cjs"), "utf8");
for (const mode of ["server-git", "push-bundle", "monitor-only"]) {
if (!configSource.includes(mode)) throw new Error(`Deployment configuration is missing mode: ${mode}`);
}
const unraidDirectSource = await readFile(path.join(root, "src/main/unraid-deployment-service.cjs"), "utf8");
for (const requiredPhrase of [
"executePushBundle",
"executeServerGitBundle",
"configureServerGitAccess",
"server-git-access",
"git ls-remote --exit-code",
"repository-scoped read-only deploy key",
]) {
if (!unraidDirectSource.includes(requiredPhrase)) throw new Error(`Deployment source is missing: ${requiredPhrase}`);
}
const serverInventorySource = await readFile(path.join(root, "src/main/server-inventory.cjs"), "utf8");
for (const requiredPhrase of ["server-compose-file", "composeDefinitions", "remoteFolderCandidate"]) {
if (!serverInventorySource.includes(requiredPhrase)) throw new Error(`Server inventory source is missing: ${requiredPhrase}`);
}
const giteaUpdateSource = await readFile(path.join(root, "src/main/gitea-service.cjs"), "utf8");
for (const phrase of [
"browser_download_url",
"insecure cross-origin",
"downloadReleaseAsset",
]) {
if (!giteaUpdateSource.includes(phrase)) throw new Error(`0.9.1 updater repair is missing: ${phrase}`);
}
const gitSource = await readFile(
path.join(root, "src/main/git-service.cjs"),
"utf8",
@@ -378,6 +486,9 @@ for (const phrase of [
"iconCacheRefresh",
"[PORT:",
"Superseded by live commit",
"pushBundleScript",
"linkServerWorkload",
"deploymentMode",
]) {
if (!unraidSource.includes(phrase))
throw new Error(`Unraid recovery implementation is missing: ${phrase}`);
+68 -13
View File
@@ -4,10 +4,10 @@ const fs = require('node:fs/promises');
const path = require('node:path');
const crypto = require('node:crypto');
const { safeStorage } = require('electron');
const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePath, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
const DEFAULT_CONFIG = {
schemaVersion: 8,
schemaVersion: 11,
setupComplete: false,
appearance: 'dark',
gitea: { baseUrl: '', user: null, encryptedToken: null },
@@ -76,7 +76,28 @@ class ConfigStore {
: 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 };
const requestedDeploymentMode = String(profile.deploymentMode || '').trim();
const deploymentMode = ['push-bundle', 'server-git', 'monitor-only'].includes(requestedDeploymentMode)
? requestedDeploymentMode
: 'push-bundle';
const composeFiles = uniqueStrings(profile.composeFiles || [profile.composeFile || 'docker-compose.yml']);
const composeServices = uniqueStrings(profile.composeServices || [internalService]).map((value) => value.toLowerCase());
return {
...profile,
deploymentMode,
composeFile: composeFiles[0] || 'docker-compose.yml',
composeFiles: composeFiles.length ? composeFiles : ['docker-compose.yml'],
composeServices,
composeProject: String(profile.composeProject || '').trim(),
composeWorkingDir: String(profile.composeWorkingDir || '').trim(),
composeService: internalService,
containerName: visibleName || internalService,
iconMode,
manageDockerMan: profile.manageDockerMan === true,
forceRecreate: profile.forceRecreate === true,
removeOrphans: profile.removeOrphans === true,
workloadIdentity: profile.workloadIdentity && typeof profile.workloadIdentity === 'object' ? structuredClone(profile.workloadIdentity) : null
};
})]))
: {},
deploymentStates: source.deploymentStates && typeof source.deploymentStates === 'object' ? source.deploymentStates : {},
@@ -177,7 +198,7 @@ class ConfigStore {
const port = Math.min(Math.max(Number(source.port || existing?.port || 22), 1), 65535);
const username = String(source.username || existing?.username || '').trim();
if (!username || /[\s@]/.test(username)) throw new Error('Enter a valid SSH username.');
const authType = ['password', 'privateKey'].includes(source.authType) ? source.authType : (existing?.authType || 'privateKey');
const authType = ['password', 'privateKey'].includes(source.authType) ? source.authType : (existing?.authType || 'password');
const basePath = String(source.basePath || existing?.basePath || '/mnt/user/appdata').trim().replace(/\/+$/, '');
if (!basePath.startsWith('/') || /[\r\n\0]/.test(basePath)) throw new Error('The server base path must be an absolute Unix path.');
const privateKeyPath = String(source.privateKeyPath || existing?.privateKeyPath || '').trim();
@@ -223,10 +244,13 @@ class ConfigStore {
async deleteServer(serverId) {
this.data.servers = this.data.servers.filter((item) => item.id !== serverId);
const removedProfileIds = new Set();
for (const [key, profiles] of Object.entries(this.data.deploymentProfiles)) {
for (const profile of profiles) if (profile.serverId === serverId) removedProfileIds.add(profile.id);
this.data.deploymentProfiles[key] = profiles.filter((profile) => profile.serverId !== serverId);
if (!this.data.deploymentProfiles[key].length) delete this.data.deploymentProfiles[key];
}
for (const profileId of removedProfileIds) delete this.data.deploymentStates[profileId];
await this.save();
}
@@ -357,21 +381,41 @@ class ConfigStore {
inputs: {}
};
if (provider === 'ssh-unraid') {
const remoteFolder = String(profile.remoteFolder || '').trim();
if (!remoteFolder || !/^[a-zA-Z0-9._-]+$/.test(remoteFolder)) throw new Error('Remote folder must contain only letters, numbers, dots, underscores and dashes.');
const remoteFolder = assertRepositoryRelativePath(String(profile.remoteFolder || '').trim());
if (!remoteFolder || remoteFolder === '.' || remoteFolder.split('/').some((part) => !part || part === '.')) throw new Error('Remote folder must be a safe path relative to the configured server base path.');
const preservePaths = assertRepositoryRelativePaths(uniqueStrings(profile.preservePaths || ['.env', 'appdata', 'data', 'logs', 'config', 'compose.override.yml']));
const composeFiles = assertRepositoryRelativePaths(uniqueStrings(profile.composeFiles || [profile.composeFile || 'docker-compose.yml']));
if (!composeFiles.length && profile.generatedCompose !== true) throw new Error('Select at least one Compose file.');
const composeService = (() => {
const value = String(profile.composeService || profile.composeServices?.[0] || remoteFolder.split('/').pop()).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;
})();
const composeServices = uniqueStrings(profile.composeServices || [composeService]).map((value) => {
const normalized = String(value).trim().toLowerCase();
if (!/^[a-z0-9._-]+$/.test(normalized)) throw new Error('Compose services must be lowercase and contain only letters, numbers, dots, underscores and dashes.');
return normalized;
});
const composeProject = String(profile.composeProject || '').trim();
if (composeProject && !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(composeProject)) throw new Error('Compose project name contains unsupported characters.');
const composeWorkingDir = String(profile.composeWorkingDir || '').trim();
if (composeWorkingDir && (!composeWorkingDir.startsWith('/') || /[\r\n\0]/.test(composeWorkingDir))) throw new Error('Compose working directory must be an absolute safe Unix path.');
const deploymentMode = ['push-bundle', 'server-git', 'monitor-only'].includes(profile.deploymentMode)
? profile.deploymentMode
: 'push-bundle';
return {
...common,
serverId: String(profile.serverId || '').trim(),
remoteFolder,
composeFile: String(profile.composeFile || 'docker-compose.yml').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;
})(),
deploymentMode,
composeFile: composeFiles[0] || 'docker-compose.yml',
composeFiles: composeFiles.length ? composeFiles : ['docker-compose.yml'],
composeProject,
composeWorkingDir,
composeService,
composeServices,
containerName: (() => {
const value = String(profile.containerName || remoteFolder).trim();
const value = String(profile.containerName || remoteFolder.split('/').pop()).trim();
if (!/^[A-Za-z0-9._-]+$/.test(value)) throw new Error('Container name must contain only letters, numbers, dots, underscores and dashes.');
return value;
})(),
@@ -390,6 +434,17 @@ class ConfigStore {
generatedCompose: profile.generatedCompose === true,
adoptedFromServer: profile.adoptedFromServer === true,
serverSourceOfTruth: profile.serverSourceOfTruth === true,
manageDockerMan: profile.manageDockerMan === true,
forceRecreate: profile.forceRecreate === true,
removeOrphans: profile.removeOrphans === true,
workloadIdentity: profile.workloadIdentity && typeof profile.workloadIdentity === 'object' ? structuredClone(profile.workloadIdentity) : null,
serverGitAccess: profile.serverGitAccess && typeof profile.serverGitAccess === 'object' ? {
configured: profile.serverGitAccess.configured === true,
deployKeyId: Number.isFinite(Number(profile.serverGitAccess.deployKeyId)) ? Number(profile.serverGitAccess.deployKeyId) : null,
keyFingerprint: String(profile.serverGitAccess.keyFingerprint || '').trim().slice(0, 200) || null,
hostFingerprint: String(profile.serverGitAccess.hostFingerprint || '').trim().slice(0, 200) || null,
configuredAt: profile.serverGitAccess.configuredAt || null
} : null,
detectedAt: profile.detectedAt || null,
provenance: profile.provenance && typeof profile.provenance === 'object' ? structuredClone(profile.provenance) : {},
detectedMetadata: profile.detectedMetadata && typeof profile.detectedMetadata === 'object' ? structuredClone(profile.detectedMetadata) : {},
+484
View File
@@ -0,0 +1,484 @@
"use strict";
const fs = require("node:fs/promises");
const path = require("node:path");
const { run } = require("./process-runner.cjs");
const { normalizeRemoteUrl } = require("../shared/repository-match.cjs");
const RECOMMENDED_GITIGNORE = `# Local configuration and secrets
.env
.env.*
!.env.example
!.env.sample
# Dependencies and generated output
node_modules/
dist/
build/
coverage/
# Editors and operating systems
.idea/
.vscode/
.DS_Store
Thumbs.db
`;
const RECOMMENDED_GITATTRIBUTES = `* text=auto eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.zip binary
`;
const RECOMMENDED_EDITORCONFIG = `root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.{bat,cmd,ps1}]
end_of_line = crlf
`;
function sameRemote(left, right) {
const a = normalizeRemoteUrl(left);
const b = normalizeRemoteUrl(right);
return Boolean(a && b && a.host === b.host && a.path === b.path);
}
function result(id, category, title, status, detail, options = {}) {
return {
id,
category,
title,
status,
detail,
weight: options.weight || 5,
fixAction: options.fixAction || null,
safe: options.safe === true,
confirmation: options.confirmation || null,
};
}
function isSensitiveTrackedPath(filePath) {
const value = String(filePath || "")
.replace(/\\/g, "/")
.toLowerCase();
if (/\.env\.(example|sample|template)$/.test(value)) return false;
return (
/(^|\/)\.env($|\.)/.test(value) ||
/(^|\/)(id_rsa|id_ed25519)$/.test(value) ||
/\.(pem|p12|pfx|key)$/.test(value) ||
/(^|\/)(credentials|secrets?)(\.[^/]+)?\.(json|ya?ml)$/.test(value)
);
}
class GitValidatorService {
constructor({ git, gitea, diagnostics }) {
this.git = git;
this.gitea = gitea;
this.diagnostics = diagnostics;
}
async config(root, key, { local = true } = {}) {
const response = await run(
"git",
["config", ...(local ? ["--local"] : []), "--get", key],
{
cwd: root,
timeout: 10_000,
allowExitCodes: [1],
},
);
return response.stdout.trim();
}
async trackedFiles(root) {
const response = await run("git", ["ls-files", "-z"], {
cwd: root,
timeout: 30_000,
maxBuffer: 16 * 1024 * 1024,
});
return response.stdout.split("\0").filter(Boolean);
}
async scan(repository) {
const checks = [];
const defaultBranch = repository.defaultBranch || "main";
const owner = repository.owner?.login;
try {
const protection = await this.gitea.getBranchProtection(
owner,
repository.name,
defaultBranch,
);
checks.push(
result(
"default-branch-protection",
"Gitea governance",
"Default branch protection",
protection.protected ? "pass" : "warning",
protection.protected
? `${defaultBranch} is protected; force push is ${protection.enableForcePush ? "allowed" : "blocked"}.`
: `${defaultBranch} accepts unprotected direct changes.`,
{
weight: 18,
fixAction: protection.protected ? null : "protect-default-branch",
safe: false,
confirmation: `Protect ${defaultBranch} on Gitea and block direct and force pushes?`,
},
),
);
if (protection.protected)
checks.push(
result(
"force-push",
"Gitea governance",
"Force-push protection",
protection.enableForcePush ? "warning" : "pass",
protection.enableForcePush
? "Force pushes remain enabled on the protected branch."
: "Force pushes are blocked on the protected branch.",
{ weight: 8 },
),
);
} catch (error) {
checks.push(
result(
"branch-protection-unavailable",
"Gitea governance",
"Branch protection could not be verified",
"warning",
error.message,
{ weight: 18 },
),
);
}
if (!repository.localPath) {
checks.push(
result(
"local-link",
"Local repository",
"Local working tree",
"warning",
"Link or clone this repository to validate files and local Git configuration.",
{ weight: 35 },
),
);
return this.summarize(repository, checks);
}
const root = await this.git.ensureRepository(repository.localPath);
const status = await this.git.status(root);
const tracked = await this.trackedFiles(root);
const lowerFiles = tracked.map((file) => file.toLowerCase());
const desiredRemote =
repository.preferredCloneUrl || repository.cloneUrl || repository.sshUrl;
checks.push(
result(
"origin",
"Repository identity",
"Origin matches Gitea",
sameRemote(status.remoteUrl, desiredRemote) ? "pass" : "error",
sameRemote(status.remoteUrl, desiredRemote)
? status.remoteUrl
: `Current origin ${status.remoteUrl || "is missing"}; expected ${desiredRemote}.`,
{
weight: 15,
fixAction: sameRemote(status.remoteUrl, desiredRemote)
? null
: "align-origin",
safe: true,
},
),
);
checks.push(
result(
"upstream",
"Branch hygiene",
"Current branch has an upstream",
status.branch?.upstream ? "pass" : "warning",
status.branch?.upstream
? `${status.branch.head} tracks ${status.branch.upstream}.`
: `${status.branch?.head || "The current branch"} is not published or tracked.`,
{ weight: 8 },
),
);
checks.push(
result(
"working-tree",
"Branch hygiene",
"Working tree is intentional",
status.clean ? "pass" : "warning",
status.clean
? "No uncommitted changes."
: `${status.counts.changed} changed file(s) require review, commit or stash.`,
{ weight: 5 },
),
);
const [userName, userEmail, fetchPrune, pullFf, autoStash] =
await Promise.all([
this.config(root, "user.name", { local: false }),
this.config(root, "user.email", { local: false }),
this.config(root, "fetch.prune"),
this.config(root, "pull.ff"),
this.config(root, "rebase.autoStash"),
]);
checks.push(
result(
"identity",
"Commit integrity",
"Repository author identity",
userName && userEmail ? "pass" : "warning",
userName && userEmail
? `${userName} <${userEmail}>`
: "The effective Git user.name or user.email is missing.",
{ weight: 7 },
),
);
const safetyReady =
fetchPrune === "true" && pullFf === "only" && autoStash === "true";
checks.push(
result(
"local-safety",
"Local configuration",
"Safe synchronization defaults",
safetyReady ? "pass" : "warning",
safetyReady
? "Stale remotes are pruned, pulls are fast-forward-only and rebase autostash is enabled."
: "Recommended repository-local fetch, pull and autostash safeguards are incomplete.",
{
weight: 10,
fixAction: safetyReady ? null : "configure-local-safety",
safe: true,
},
),
);
const hasReadme = lowerFiles.some((file) =>
/(^|\/)readme(\.[^/]+)?$/.test(file),
);
checks.push(
result(
"readme",
"Repository documentation",
"README is versioned",
hasReadme ? "pass" : "warning",
hasReadme
? "Repository purpose and usage can be documented at the source."
: "No tracked README was found.",
{ weight: 7 },
),
);
const hasGitignore = lowerFiles.includes(".gitignore");
checks.push(
result(
"gitignore",
"Repository hygiene",
".gitignore is versioned",
hasGitignore ? "pass" : "warning",
hasGitignore
? "Generated and local-only files can be excluded centrally."
: "No tracked .gitignore was found.",
{
weight: 8,
fixAction: hasGitignore ? null : "add-gitignore",
safe: false,
confirmation:
"Create a recommended .gitignore in the working tree? It will remain uncommitted for review.",
},
),
);
for (const [id, title, filename, action] of [
["gitattributes", ".gitattributes normalizes text and binary files", ".gitattributes", "add-gitattributes"],
["editorconfig", ".editorconfig keeps editors consistent", ".editorconfig", "add-editorconfig"],
]) {
const present = lowerFiles.includes(filename);
checks.push(result(id, "Repository hygiene", title, present ? "pass" : "warning",
present ? `${filename} is versioned.` : `No tracked ${filename} was found.`, {
weight: 5,
fixAction: present ? null : action,
safe: false,
confirmation: `Create a recommended ${filename} in the working tree for review?`,
}));
}
const packageManagers = [
{ manifests: ["package.json"], locks: ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"] },
{ manifests: ["pyproject.toml", "requirements.in", "pipfile"], locks: ["uv.lock", "poetry.lock", "requirements.txt", "pipfile.lock"] },
{ manifests: ["composer.json"], locks: ["composer.lock"] },
{ manifests: ["gemfile"], locks: ["gemfile.lock"] },
];
const lockCheck = packageManagers.find((entry) => entry.manifests.some((name) => lowerFiles.includes(name)));
if (lockCheck) {
const lockfile = lockCheck.locks.find((name) => lowerFiles.includes(name));
checks.push(result("dependency-lock", "Supply chain", "Dependencies are reproducibly locked", lockfile ? "pass" : "warning",
lockfile ? `${lockfile} is versioned.` : "A dependency manifest exists without a recognized lockfile.", { weight: 9 }));
}
const hasCi = lowerFiles.some((file) => /^\.gitea\/workflows\/[^/]+\.ya?ml$/.test(file));
checks.push(result("continuous-integration", "Gitea governance", "Automated checks run on Gitea", hasCi ? "pass" : "warning",
hasCi ? "At least one Gitea Actions workflow is versioned." : "No .gitea/workflows YAML file was found.", { weight: 8 }));
const sensitive = tracked.filter(isSensitiveTrackedPath);
checks.push(
result(
"tracked-secrets",
"Security",
"No secret-shaped files are tracked",
sensitive.length ? "error" : "pass",
sensitive.length
? `Review immediately: ${sensitive.slice(0, 8).join(", ")}${sensitive.length > 8 ? "…" : ""}. Removing a file does not erase Git history.`
: "No tracked environment, private-key or credential filenames were detected.",
{ weight: 22 },
),
);
const large = [];
const candidates = tracked.slice(0, 5000);
for (
let index = 0;
index < candidates.length && large.length < 12;
index += 64
) {
const batch = candidates.slice(index, index + 64);
const stats = await Promise.all(
batch.map(async (file) => ({
file,
stat: await fs.stat(path.join(root, file)).catch(() => null),
})),
);
for (const item of stats) {
if (item.stat?.isFile() && item.stat.size > 10 * 1024 * 1024)
large.push({ file: item.file, size: item.stat.size });
if (large.length >= 12) break;
}
}
checks.push(
result(
"large-files",
"Repository performance",
"No oversized tracked files",
large.length ? "warning" : "pass",
large.length
? `${large.map((item) => `${item.file} (${Math.ceil(item.size / 1024 / 1024)} MB)`).join(", ")}. Consider Git LFS.`
: "No tracked files above 10 MB were found.",
{ weight: 7 },
),
);
return this.summarize(repository, checks);
}
summarize(repository, checks) {
const totalWeight = checks.reduce((sum, check) => sum + check.weight, 0);
const earned = checks.reduce(
(sum, check) =>
sum +
(check.status === "pass"
? check.weight
: check.status === "warning"
? check.weight * 0.45
: 0),
0,
);
const score = totalWeight ? Math.round((earned / totalWeight) * 100) : 0;
return {
repository: repository.fullName,
checkedAt: new Date().toISOString(),
score,
grade:
score >= 90
? "Excellent"
: score >= 75
? "Good"
: score >= 55
? "Needs attention"
: "High risk",
checks,
summary: {
passed: checks.filter((check) => check.status === "pass").length,
warnings: checks.filter((check) => check.status === "warning").length,
errors: checks.filter((check) => check.status === "error").length,
repairable: checks.filter((check) => check.fixAction).length,
},
};
}
async repair(repository, check) {
if (!check?.fixAction)
throw new Error("This validator check has no repair action.");
const root = repository.localPath
? await this.git.ensureRepository(repository.localPath)
: null;
if (check.fixAction === "align-origin") {
return this.git.setRemoteUrl(
root,
repository.preferredCloneUrl ||
repository.cloneUrl ||
repository.sshUrl,
);
}
if (check.fixAction === "configure-local-safety") {
for (const [key, value] of [
["fetch.prune", "true"],
["pull.ff", "only"],
["rebase.autoStash", "true"],
])
await run("git", ["config", "--local", key, value], {
cwd: root,
timeout: 10_000,
});
return { configured: true };
}
if (check.fixAction === "add-gitignore") {
const target = path.join(root, ".gitignore");
const exists = await fs.stat(target).catch(() => null);
if (exists)
throw new Error(".gitignore already exists; rescan before repairing.");
await fs.writeFile(target, RECOMMENDED_GITIGNORE, {
encoding: "utf8",
flag: "wx",
});
return { created: ".gitignore" };
}
if (["add-gitattributes", "add-editorconfig"].includes(check.fixAction)) {
const definition = check.fixAction === "add-gitattributes"
? { name: ".gitattributes", content: RECOMMENDED_GITATTRIBUTES }
: { name: ".editorconfig", content: RECOMMENDED_EDITORCONFIG };
const target = path.join(root, definition.name);
if (await fs.stat(target).catch(() => null))
throw new Error(`${definition.name} already exists; rescan before repairing.`);
await fs.writeFile(target, definition.content, { encoding: "utf8", flag: "wx" });
return { created: definition.name };
}
if (check.fixAction === "protect-default-branch") {
return this.gitea.createBranchProtection(
repository.owner.login,
repository.name,
repository.defaultBranch || "main",
);
}
throw new Error("Unsupported Git Validator repair action.");
}
}
module.exports = {
GitValidatorService,
RECOMMENDED_GITIGNORE,
RECOMMENDED_GITATTRIBUTES,
RECOMMENDED_EDITORCONFIG,
sameRemote,
isSensitiveTrackedPath,
};
+79 -5
View File
@@ -204,6 +204,66 @@ class GiteaService {
};
}
async createBranchProtection(owner, repo, branch) {
const target = assertBranchName(branch);
return (
await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branch_protections`,
{
method: "POST",
body: {
rule_name: target,
branch_name: target,
enable_push: false,
enable_force_push: false,
required_approvals: 0,
dismiss_stale_approvals: true,
block_on_rejected_reviews: true,
block_on_outdated_branch: true,
},
},
)
).data;
}
async listDeployKeys(owner, repo) {
const result = await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys?limit=100`,
);
return Array.isArray(result.data) ? result.data : [];
}
async ensureReadOnlyDeployKey({ owner, repo, title, publicKey }) {
const key = String(publicKey || "").trim();
if (!/^ssh-(ed25519|rsa)\s+[A-Za-z0-9+/=]+(?:\s+.*)?$/.test(key))
throw new Error("The server did not return a valid SSH public key.");
const keys = await this.listDeployKeys(owner, repo);
const keyMaterial = key.split(/\s+/).slice(0, 2).join(" ");
const existing = keys.find((item) =>
String(item?.key || "").trim().split(/\s+/).slice(0, 2).join(" ") === keyMaterial,
);
if (existing) {
if (existing.read_only !== true) {
const error = new Error("The matching Gitea deploy key has write access. Revoke it before ForgeFlow configures a read-only server key.");
error.code = "DEPLOY_KEY_NOT_READ_ONLY";
throw error;
}
return { ...existing, created: false };
}
const result = await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys`,
{
method: "POST",
body: {
title: String(title || "ForgeFlow server deploy key").trim().slice(0, 255),
key,
read_only: true,
},
},
);
return { ...result.data, created: true };
}
async listPullRequests({ owner, repo, state = "open", limit = 30 } = {}) {
const query = new URLSearchParams({
state,
@@ -303,13 +363,15 @@ class GiteaService {
const token = this.store.getToken();
let target = new URL(url, `${baseUrl}/`);
for (let redirects = 0; redirects <= 5; redirects += 1) {
if (target.origin !== base.origin)
const sameOrigin = target.origin === base.origin;
if (!sameOrigin && target.protocol !== "https:") {
throw new Error(
"Refusing to send the Gitea token to a different origin.",
"Refusing an insecure cross-origin update download redirect.",
);
}
const response = await fetch(target, {
headers: {
Authorization: `token ${token}`,
...(sameOrigin && token ? { Authorization: `token ${token}` } : {}),
Accept: "application/octet-stream",
},
signal: AbortSignal.timeout(timeout),
@@ -338,8 +400,20 @@ class GiteaService {
throw new Error("Gitea returned an invalid release ID.");
if (!Number.isSafeInteger(numericId) || numericId <= 0)
throw new Error("Gitea returned an invalid release asset ID.");
const assetPath = `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${numericReleaseId}/assets/${numericId}`;
return this.downloadAuthenticated(assetPath, options);
let downloadUrl = String(options.downloadUrl || "").trim();
if (!downloadUrl) {
const metadataPath = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${numericReleaseId}/assets/${numericId}`;
const metadata = (await this.request(metadataPath)).data;
if (Number(metadata?.id) !== numericId) {
throw new Error("Gitea returned metadata for a different release asset.");
}
downloadUrl = String(metadata?.browser_download_url || "").trim();
}
if (!downloadUrl) {
throw new Error("Gitea did not provide a release asset download URL.");
}
return this.downloadAuthenticated(downloadUrl, options);
}
async dispatchWorkflow({ owner, repo, workflowFile, ref, inputs = {} }) {
+80
View File
@@ -87,6 +87,7 @@ function registerIpc({
ssh,
updates,
preflight,
gitValidator,
diagnostics,
audit,
externalTools,
@@ -449,6 +450,7 @@ function registerIpc({
register(
"server:save",
async ({ server, password = "", passphrase = "" }) => {
await ssh.validateServerConfiguration(server, { password, passphrase });
const saved = await store.saveServer(server, { password, passphrase });
await diagnostics.info("server.saved", {
serverId: saved.id,
@@ -841,6 +843,42 @@ function registerIpc({
return true;
});
register("git-validator:scan", async ({ fullName }) => {
const repository = await resolveRepository({ fullName });
const report = await gitValidator.scan(repository);
await diagnostics.info("git-validator.scan.completed", {
repository: repository.fullName,
score: report.score,
summary: report.summary,
});
return report;
});
register("git-validator:repair", async ({ fullName, check }) => {
const repository = await resolveRepository({ fullName });
const allowed = new Set([
"align-origin",
"configure-local-safety",
"add-gitignore",
"add-gitattributes",
"add-editorconfig",
"protect-default-branch",
]);
if (!allowed.has(check?.fixAction))
throw new Error("Unsupported Git Validator repair request.");
const result = await gitValidator.repair(repository, check);
await audit.append("git-validator.repair", {
repository: repository.fullName,
checkId: check.id,
action: check.fixAction,
});
await diagnostics.info("git-validator.repair.completed", {
repository: repository.fullName,
checkId: check.id,
action: check.fixAction,
});
return result;
});
register("troubleshooter:scan", async ({ fullName = null }) => {
const currentRepositories = await repositories.refresh();
const candidates = fullName
@@ -1085,6 +1123,20 @@ function registerIpc({
return unraid.preflight({ repository: current, profileId });
return preflight.runDeployment({ repository: current, profileId });
});
register("deployment:repair-write-access", async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
const profile = store.getDeploymentProfile(current.fullName, profileId);
if (profile?.provider !== "ssh-unraid")
throw new Error("Write-access repair is available only for SSH / Unraid deployment profiles.");
const result = await unraid.repairWriteAccess({ repository: current, profileId });
await audit.append("deployment.write-access.repaired", {
repository: current.fullName,
profileId,
changed: result.changed,
remotePath: result.after?.remotePath || result.before?.remotePath || null,
});
return result;
});
register(
"deployment:dispatch",
async ({
@@ -1140,6 +1192,28 @@ function registerIpc({
},
);
register("deployment:health", ({ url }) => deployments.checkHealth(url));
register("deployment:link-server-workload", async ({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) => {
const current = await resolveRepository(repository);
const result = await unraid.linkServerWorkload({
repository: current,
serverId,
workloadId,
deploymentMode,
remoteFolder,
});
return { ...result, state: store.getPublicState() };
});
register("deployment:configure-server-git-access", async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
const result = await unraid.configureServerGitAccess({ repository: current, profileId });
await audit.append("deployment.server-git-access-configured", {
repository: current.fullName,
profileId,
keyFingerprint: result.keyFingerprint,
hostFingerprint: result.hostFingerprint,
});
return { ...result, state: store.getPublicState() };
});
register("deployment:discover-server-workloads", async () => {
const repositoryList = await repositories.refresh();
const remoteRepositories = repositoryList.filter(
@@ -1154,10 +1228,16 @@ function registerIpc({
} catch (error) {
results.push({
serverId: server.id,
serverName: server.name,
detected: 0,
adopted: 0,
verified: 0,
linked: 0,
unmatched: 0,
needsReview: 0,
capabilities: {},
warnings: [],
workloads: [],
error: error.message,
});
}
+577
View File
@@ -0,0 +1,577 @@
'use strict';
const crypto = require('node:crypto');
const path = require('node:path').posix;
const { normalizeRemoteUrl } = require('../shared/repository-match.cjs');
function decodeBase64(value) {
try { return Buffer.from(String(value || ''), 'base64').toString('utf8'); }
catch { return ''; }
}
function remoteIdentity(value) {
const normalized = normalizeRemoteUrl(value);
return normalized ? `${normalized.host}/${normalized.path}` : '';
}
function normalizedName(value) {
return String(value || '').toLowerCase().replace(/\.git$/i, '').replace(/[^a-z0-9]/g, '');
}
function safeJson(value, fallback) {
try { return JSON.parse(value); }
catch { return fallback; }
}
function sanitizeLegacyContainer(container) {
const labels = container?.Config?.Labels || {};
return {
id: container?.Id || '',
name: String(container?.Name || '').replace(/^\//, ''),
image: container?.Config?.Image || '',
imageId: container?.Image || '',
running: container?.State?.Running === true,
status: container?.State?.Status || '',
health: container?.State?.Health?.Status || null,
labels: {
'com.docker.compose.project': labels['com.docker.compose.project'] || '',
'com.docker.compose.project.working_dir': labels['com.docker.compose.project.working_dir'] || '',
'com.docker.compose.project.config_files': labels['com.docker.compose.project.config_files'] || '',
'com.docker.compose.service': labels['com.docker.compose.service'] || '',
'org.opencontainers.image.source': labels['org.opencontainers.image.source'] || '',
'org.opencontainers.image.revision': labels['org.opencontainers.image.revision'] || '',
'tech.itworx.forgeflow.repository': labels['tech.itworx.forgeflow.repository'] || '',
'tech.itworx.forgeflow.commit': labels['tech.itworx.forgeflow.commit'] || '',
'tech.itworx.forgeflow.branch': labels['tech.itworx.forgeflow.branch'] || '',
'net.unraid.docker.webui': labels['net.unraid.docker.webui'] || '',
'net.unraid.docker.icon': labels['net.unraid.docker.icon'] || '',
'net.unraid.docker.shell': labels['net.unraid.docker.shell'] || '',
'net.unraid.docker.managed': labels['net.unraid.docker.managed'] || '',
},
ports: container?.NetworkSettings?.Ports || {},
mounts: Array.isArray(container?.Mounts) ? container.Mounts : [],
networks: container?.NetworkSettings?.Networks || {},
restartPolicy: container?.HostConfig?.RestartPolicy?.Name || '',
};
}
function parseServerInventory(output) {
const marker = '__FORGEFLOW_INVENTORY__';
const index = String(output || '').lastIndexOf(marker);
if (index < 0) throw new Error('The server did not return a ForgeFlow workload inventory.');
const inventory = {
capabilities: {},
checkouts: [],
containers: [],
dockerMan: [],
composeProjects: [],
composeDefinitions: [],
warnings: [],
};
for (const line of String(output).slice(index + marker.length).trim().split(/\r?\n/)) {
if (!line) continue;
const [kind, ...parts] = line.split('\t');
if (kind === 'H') {
inventory.capabilities = {
docker: parts[0] === 'true',
compose: parts[1] === 'true',
git: parts[2] === 'true',
tar: parts[3] === 'true',
checksum: parts[4] === 'true',
baseWritable: parts[5] === 'true',
composeVersion: decodeBase64(parts[6]),
platform: decodeBase64(parts[7]),
};
} else if (kind === 'R' && parts.length >= 4) {
inventory.checkouts.push({
root: decodeBase64(parts[0]),
remote: decodeBase64(parts[1]),
liveSha: parts[2] || '',
branch: decodeBase64(parts[3]),
});
} else if (kind === 'C' && parts[0]) {
const parsed = safeJson(decodeBase64(parts[0]), null);
if (!parsed) continue;
if (Array.isArray(parsed)) {
for (const item of parsed) if (item) inventory.containers.push(sanitizeLegacyContainer(item));
} else if (parsed.Config || parsed.State) inventory.containers.push(sanitizeLegacyContainer(parsed));
else inventory.containers.push({
...parsed,
name: String(parsed.name || '').replace(/^\//, ''),
labels: parsed.labels && typeof parsed.labels === 'object' ? parsed.labels : {},
mounts: Array.isArray(parsed.mounts) ? parsed.mounts : [],
ports: parsed.ports && typeof parsed.ports === 'object' ? parsed.ports : {},
networks: parsed.networks && typeof parsed.networks === 'object' ? parsed.networks : {},
});
} else if (kind === 'D' && parts[0]) {
inventory.dockerMan.push({
name: decodeBase64(parts[0]),
templatePath: decodeBase64(parts[1]),
webUiUrl: decodeBase64(parts[2]),
iconUrl: decodeBase64(parts[3]),
shell: decodeBase64(parts[4]),
repository: decodeBase64(parts[5]),
network: decodeBase64(parts[6]),
});
} else if (kind === 'P' && parts[0]) {
const parsed = safeJson(decodeBase64(parts[0]), []);
const projects = Array.isArray(parsed) ? parsed : parsed ? [parsed] : [];
for (const project of projects) {
const name = String(project?.Name || project?.name || '').trim();
if (!name) continue;
const rawFiles = project?.ConfigFiles || project?.configFiles || project?.config_files || [];
const configFiles = (Array.isArray(rawFiles) ? rawFiles : String(rawFiles || '').split(','))
.map((item) => String(item || '').trim())
.filter(Boolean);
inventory.composeProjects.push({
name,
status: String(project?.Status || project?.status || ''),
configFiles,
});
}
} else if (kind === 'Y' && parts[0]) {
inventory.composeDefinitions.push({
workingDir: decodeBase64(parts[0]).replace(/\/+$/, ''),
configFiles: decodeBase64(parts[1]).split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
projectName: decodeBase64(parts[2]).trim(),
services: decodeBase64(parts[3]).split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
images: decodeBase64(parts[4]).split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
valid: parts[5] === 'true',
error: decodeBase64(parts[6]).trim(),
});
} else if (kind === 'W') inventory.warnings.push(decodeBase64(parts[0]));
}
return inventory;
}
function configFilesFor(container) {
return String(container?.labels?.['com.docker.compose.project.config_files'] || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
function containerPorts(container) {
const ports = [];
for (const [containerKey, bindings] of Object.entries(container?.ports || {})) {
const [containerPortText, protocol = 'tcp'] = containerKey.split('/');
const containerPort = Number(containerPortText) || null;
if (Array.isArray(bindings) && bindings.length) {
for (const binding of bindings) ports.push({
hostIp: binding?.HostIp || '',
hostPort: Number(binding?.HostPort) || null,
containerPort,
protocol,
});
} else ports.push({ hostIp: '', hostPort: null, containerPort, protocol });
}
return ports;
}
function safeRelativeToBase(basePath, candidate) {
const base = String(basePath || '').replace(/\/+$/, '');
const value = String(candidate || '').replace(/\/+$/, '');
if (!base || !value || !value.startsWith(`${base}/`)) return '';
const relative = value.slice(base.length + 1).replace(/^\/+|\/+$/g, '');
if (!relative || relative.split('/').some((part) => !part || part === '.' || part === '..')) return '';
return relative;
}
function topLevelRelativeToBase(basePath, candidate) {
const relative = safeRelativeToBase(basePath, candidate);
return relative ? relative.split('/')[0] : '';
}
function canonicalServerAppdataPath(basePath, candidate) {
const value = String(candidate || '').replace(/\\/g, '/').replace(/\/+$/, '');
if (!value) return '';
const bases = [...new Set([
String(basePath || '').replace(/\/+$/, ''),
'/mnt/user/appdata',
'/mnt/cache/appdata',
].filter(Boolean))];
for (const base of bases) {
const relative = safeRelativeToBase(base, value);
if (relative) return `${String(basePath || base).replace(/\/+$/, '')}/${relative}`;
if (value === base) return String(basePath || base).replace(/\/+$/, '');
}
const diskMatch = value.match(/^\/mnt\/disk\d+\/appdata\/(.+)$/i);
if (diskMatch) return `${String(basePath || '/mnt/user/appdata').replace(/\/+$/, '')}/${diskMatch[1]}`;
return value;
}
function isDeploymentBackupPath(value) {
const segments = String(value || '').replace(/\\/g, '/').split('/').filter(Boolean);
return segments.some((segment) =>
/^source-pre-[0-9a-f]{7,64}$/i.test(segment)
|| /^forgeflow-(backup|staging|rollback)(?:[-_.].*)?$/i.test(segment)
|| ['.forgeflow', 'releases', 'backups', 'staging', 'incoming', '_audit_quarantine', 'devrunbook-validation'].includes(segment.toLowerCase()),
);
}
function deploymentRootCandidate(relativePath) {
const segments = String(relativePath || '').replace(/\\/g, '/').split('/').filter(Boolean);
const forgeFlowIndex = segments.indexOf('.forgeflow');
if (forgeFlowIndex > 0) return segments.slice(0, forgeFlowIndex).join('/');
const releasesIndex = segments.indexOf('releases');
if (releasesIndex > 0 && segments.length > releasesIndex + 1) return segments.slice(0, releasesIndex).join('/');
const backupIndex = segments.findIndex((segment) => /^source-pre-[0-9a-f]{7,64}$/i.test(segment));
if (backupIndex > 0) return segments.slice(0, backupIndex).join('/');
return segments.join('/');
}
function workloadSelector(group) {
if (group.composeProject) return {
kind: 'compose',
composeProject: group.composeProject,
workingDir: group.workingDir || '',
configFiles: group.configFiles,
};
const dockerMan = group.dockerMan || null;
if (dockerMan?.templatePath) return {
kind: 'dockerman-container',
templatePath: dockerMan.templatePath,
containerName: group.containers[0]?.name || '',
};
return { kind: 'docker-container', containerName: group.containers[0]?.name || '' };
}
function stableWorkloadId(serverId, selector) {
return `workload-${crypto.createHash('sha256').update(`${serverId}:${JSON.stringify(selector)}`).digest('hex').slice(0, 24)}`;
}
function profileMatchesWorkload(profile, workload) {
if (!profile || profile.provider !== 'ssh-unraid' || profile.serverId !== workload.serverId) return false;
const identity = profile.workloadIdentity || {};
if (identity.workloadId && identity.workloadId === workload.workloadId) return true;
if (identity.selector && JSON.stringify(identity.selector) === JSON.stringify(workload.selector)) return true;
if (profile.composeProject && workload.compose?.project && profile.composeProject === workload.compose.project) {
if (!profile.composeWorkingDir || !workload.compose.workingDir || profile.composeWorkingDir === workload.compose.workingDir) return true;
}
if (profile.remoteFolder && workload.remoteFolderCandidate && profile.remoteFolder === workload.remoteFolderCandidate) return true;
return workload.containers.some((container) => container.name === profile.containerName);
}
function repositoryRemoteMap(repositories) {
const map = new Map();
for (const repository of repositories || []) {
for (const value of [repository.cloneUrl, repository.sshUrl, repository.htmlUrl, repository.preferredCloneUrl]) {
const id = remoteIdentity(value);
if (id) map.set(id, repository);
}
}
return map;
}
function candidateRepositories(workload, repositories, checkouts) {
const candidates = new Map();
const add = (repository, points, reason, exact = false, identityExact = false) => {
if (!repository?.fullName) return;
const current = candidates.get(repository.fullName) || { repositoryFullName: repository.fullName, repositoryName: repository.name, score: 0, exact: false, identityExact: false, reasons: [] };
current.score += points;
current.exact ||= exact;
current.identityExact ||= identityExact;
if (reason && !current.reasons.includes(reason)) current.reasons.push(reason);
candidates.set(repository.fullName, current);
};
const remotes = repositoryRemoteMap(repositories);
const exactRemoteHints = new Set();
for (const container of workload.containers) {
const labels = container.labels || {};
for (const value of [labels['tech.itworx.forgeflow.repository'], labels['org.opencontainers.image.source']]) {
const id = remoteIdentity(value);
if (id) exactRemoteHints.add(id);
}
}
for (const checkout of checkouts || []) {
const root = String(checkout.root || '').replace(/\/+$/, '');
const matchesPath = root && (root === workload.compose.workingDir || workload.containers.some((container) => (container.mounts || []).some((mount) => {
const source = String(mount?.Source || '').replace(/\/+$/, '');
return source === root || source.startsWith(`${root}/`);
})));
if (matchesPath) {
const id = remoteIdentity(checkout.remote);
if (id) exactRemoteHints.add(id);
}
}
for (const id of exactRemoteHints) {
const repository = remotes.get(id);
if (repository) add(repository, 100, 'Exact repository provenance from container or server checkout', true);
}
const composeProjectName = normalizedName(workload.compose.project);
const composeFolderName = normalizedName(path.basename(workload.compose.workingDir || ''));
const deploymentFolderName = normalizedName(String(workload.remoteFolderCandidate || '').split('/')[0]);
const serviceNames = new Set((workload.compose.services || []).map(normalizedName).filter(Boolean));
const containerNames = new Set(workload.containers.map((container) => normalizedName(container.name)).filter(Boolean));
const imageNames = new Set([
...workload.containers.map((container) => String(container.image || '').split('/').pop()?.split(':')[0]),
...(workload.metadata?.images || []).map((image) => String(image || '').split('/').pop()?.split(':')[0]),
].map(normalizedName).filter(Boolean));
for (const repository of repositories || []) {
const repoName = normalizedName(repository.name);
if (!repoName) continue;
if (composeProjectName && composeProjectName === repoName) add(repository, 55, 'Compose project name matches repository', false, true);
if (deploymentFolderName && deploymentFolderName === repoName) add(repository, 70, 'Top-level appdata folder exactly matches repository', false, true);
if (composeFolderName && composeFolderName === repoName) add(repository, 50, 'Compose file folder matches repository');
if (serviceNames.has(repoName)) add(repository, 25, 'Compose service name matches repository');
if (containerNames.has(repoName)) add(repository, 70, 'Container name exactly matches repository', false, true);
if (imageNames.has(repoName)) add(repository, 20, 'Container image name matches repository');
}
return [...candidates.values()].sort((a, b) => b.score - a.score || a.repositoryFullName.localeCompare(b.repositoryFullName)).map((candidate) => ({
...candidate,
reasons: candidate.exact
? candidate.reasons
: [...candidate.reasons, 'Manual confirmation is reduced to one click; Compose identity and paths are prefilled from the server.'],
confidence: candidate.exact ? 'exact' : candidate.score >= 35 ? 'strong' : 'weak',
}));
}
function buildWorkloadInventory({ inventory, server, repositories = [], profiles = [] }) {
const dockerManByName = new Map((inventory.dockerMan || []).map((item) => [String(item.name || '').toLowerCase(), item]));
const groups = new Map();
for (const container of inventory.containers || []) {
const labels = container.labels || {};
const composeProject = String(labels['com.docker.compose.project'] || '').trim();
const workingDir = canonicalServerAppdataPath(server.basePath, labels['com.docker.compose.project.working_dir']);
const configFiles = [...new Set(configFilesFor(container).map((file) => canonicalServerAppdataPath(server.basePath, file)))];
const key = composeProject
? `compose:${composeProject}:${workingDir}:${configFiles.join('|')}`
: `container:${container.name}`;
const group = groups.get(key) || {
composeProject,
workingDir,
configFiles,
services: [],
images: [],
containers: [],
dockerMan: null,
};
group.containers.push(container);
const service = String(labels['com.docker.compose.service'] || '').trim();
if (service && !group.services.includes(service)) group.services.push(service);
group.dockerMan ||= dockerManByName.get(String(container.name || '').toLowerCase()) || null;
groups.set(key, group);
}
for (const project of inventory.composeProjects || []) {
const configFiles = [...new Set((project.configFiles || []).filter(Boolean).map((file) => canonicalServerAppdataPath(server.basePath, file)))];
const workingDir = configFiles.length ? canonicalServerAppdataPath(server.basePath, path.dirname(configFiles[0])) : '';
const key = `compose:${project.name}:${workingDir}:${configFiles.join('|')}`;
if (groups.has(key)) continue;
const existingByProject = [...groups.values()].find((group) => group.composeProject === project.name);
if (existingByProject) {
if (!existingByProject.configFiles.length && configFiles.length) existingByProject.configFiles = configFiles;
if (!existingByProject.workingDir && workingDir) existingByProject.workingDir = workingDir;
continue;
}
groups.set(key, {
composeProject: project.name,
workingDir,
configFiles,
services: [],
images: [],
containers: [],
dockerMan: dockerManByName.get(String(project.name || '').toLowerCase()) || null,
composeStatus: project.status || '',
});
}
for (const definition of inventory.composeDefinitions || []) {
const configFiles = [...new Set((definition.configFiles || []).filter(Boolean).map((file) => canonicalServerAppdataPath(server.basePath, file)))];
const workingDir = canonicalServerAppdataPath(server.basePath, definition.workingDir || (configFiles[0] ? path.dirname(configFiles[0]) : ''));
if (isDeploymentBackupPath(workingDir) || configFiles.some(isDeploymentBackupPath)) continue;
const projectName = String(definition.projectName || path.basename(workingDir || '')).trim();
const existing = [...groups.values()].find((group) => {
if (workingDir && group.workingDir && group.workingDir === workingDir) return true;
if (configFiles.length && (group.configFiles || []).some((file) => configFiles.includes(file))) return true;
return Boolean(projectName && group.composeProject === projectName && (!workingDir || !group.workingDir));
});
if (existing) {
existing.composeProject ||= projectName;
existing.workingDir ||= workingDir;
existing.configFiles = [...new Set([...(existing.configFiles || []), ...configFiles])];
existing.services = [...new Set([...(existing.services || []), ...(definition.services || [])])];
existing.images = [...new Set([...(existing.images || []), ...(definition.images || [])])];
existing.composeDefinitionValid = definition.valid;
existing.composeDefinitionError = definition.error || '';
existing.composeSource = 'server-compose-file';
continue;
}
const key = `compose-file:${projectName}:${workingDir}:${configFiles.join('|')}`;
groups.set(key, {
composeProject: projectName,
workingDir,
configFiles,
services: [...new Set(definition.services || [])],
images: [...new Set(definition.images || [])],
containers: [],
dockerMan: dockerManByName.get(projectName.toLowerCase()) || null,
composeStatus: '',
composeDefinitionValid: definition.valid,
composeDefinitionError: definition.error || '',
composeSource: 'server-compose-file',
});
}
const containerNames = new Set((inventory.containers || []).map((container) => String(container.name || '').toLowerCase()));
for (const dockerMan of inventory.dockerMan || []) {
const normalized = String(dockerMan.name || '').toLowerCase();
if (!normalized || containerNames.has(normalized)) continue;
const key = `container:${dockerMan.name}`;
if (groups.has(key)) continue;
groups.set(key, {
composeProject: '',
workingDir: '',
configFiles: [],
services: [],
images: dockerMan.repository ? [dockerMan.repository] : [],
containers: [{
id: '',
name: dockerMan.name,
image: dockerMan.repository || '',
imageId: '',
running: false,
status: 'template-only',
health: null,
labels: {},
ports: {},
mounts: [],
networks: dockerMan.network ? { [dockerMan.network]: {} } : {},
restartPolicy: '',
}],
dockerMan,
});
}
const workloads = [];
for (const group of groups.values()) {
const selector = workloadSelector(group);
const workloadId = stableWorkloadId(server.id, selector);
const primary = group.containers.find((item) => item.running) || group.containers[0];
const ports = group.containers.flatMap(containerPorts);
const mounts = group.containers.flatMap((container) => container.mounts || []);
const remoteFolderCandidate = deploymentRootCandidate(safeRelativeToBase(server.basePath, canonicalServerAppdataPath(server.basePath, group.workingDir)))
|| mounts.map((mount) => topLevelRelativeToBase(server.basePath, canonicalServerAppdataPath(server.basePath, mount?.Source))).find(Boolean)
|| '';
const workload = {
workloadId,
serverId: server.id,
serverName: server.name,
kind: selector.kind,
selector,
displayName: group.composeProject || primary?.name || group.dockerMan?.name || 'Unnamed workload',
compose: {
project: group.composeProject,
workingDir: group.workingDir,
configFiles: group.configFiles,
services: group.services,
},
containers: group.containers.map((container) => ({
id: container.id,
name: container.name,
image: container.image,
imageId: container.imageId,
running: container.running === true,
status: container.status || '',
health: container.health || null,
service: container.labels?.['com.docker.compose.service'] || '',
ports: containerPorts(container),
mounts: (container.mounts || []).map((mount) => ({
type: mount?.Type || '',
source: mount?.Source || '',
target: mount?.Destination || '',
readOnly: mount?.RW === false,
})),
networks: Object.keys(container.networks || {}),
restartPolicy: container.restartPolicy || '',
})),
dockerMan: group.dockerMan,
metadata: {
webUiUrl: primary?.labels?.['net.unraid.docker.webui'] || group.dockerMan?.webUiUrl || '',
iconUrl: primary?.labels?.['net.unraid.docker.icon'] || group.dockerMan?.iconUrl || '',
shell: primary?.labels?.['net.unraid.docker.shell'] || group.dockerMan?.shell || '/bin/sh',
sourceRepository: primary?.labels?.['tech.itworx.forgeflow.repository'] || primary?.labels?.['org.opencontainers.image.source'] || '',
liveRevision: primary?.labels?.['tech.itworx.forgeflow.commit'] || primary?.labels?.['org.opencontainers.image.revision'] || '',
branch: primary?.labels?.['tech.itworx.forgeflow.branch'] || '',
composeStatus: group.composeStatus || '',
images: [...new Set(group.images || [])],
composeSource: group.composeSource || (group.configFiles?.length ? 'docker-compose-runtime' : ''),
composeDefinitionValid: group.composeDefinitionValid !== false,
composeDefinitionError: group.composeDefinitionError || '',
},
runtime: {
running: group.containers.some((container) => container.running === true),
allRunning: group.containers.length > 0 && group.containers.every((container) => container.running === true),
health: group.containers.some((container) => container.health === 'unhealthy')
? 'unhealthy'
: group.containers.length && group.containers.every((container) => container.health === 'healthy')
? 'healthy'
: 'unverified',
ports,
},
remoteFolderCandidate,
observedAt: new Date().toISOString(),
};
const matchingCheckout = (inventory.checkouts || []).find((checkout) => {
const root = String(checkout.root || '').replace(/\/+$/, '');
if (!root) return false;
if (root === workload.compose.workingDir) return true;
return mounts.some((mount) => {
const source = String(mount?.Source || '').replace(/\/+$/, '');
return source === root || source.startsWith(`${root}/`);
});
});
if (matchingCheckout) {
workload.metadata.sourceRepository ||= matchingCheckout.remote || '';
workload.metadata.liveRevision ||= matchingCheckout.liveSha || '';
workload.metadata.branch ||= matchingCheckout.branch || '';
}
workload.candidates = candidateRepositories(workload, repositories, inventory.checkouts || []);
const linked = profiles.find((profile) => profileMatchesWorkload(profile, workload));
if (linked) {
workload.link = {
status: 'linked',
profileId: linked.id,
repositoryFullName: linked.repositoryFullName || linked._repositoryFullName || '',
source: linked.workloadIdentity?.linkSource || (linked.adoptedFromServer ? 'automatic' : 'manual'),
};
workload.status = 'linked';
} else if (workload.candidates.length === 1 && workload.candidates[0].exact) workload.status = 'exact-match';
else if (workload.candidates.length) workload.status = workload.candidates[1]?.score === workload.candidates[0]?.score ? 'ambiguous' : 'suggested';
else workload.status = 'unmatched';
workloads.push(workload);
}
workloads.sort((a, b) => Number(b.runtime.running) - Number(a.runtime.running) || a.displayName.localeCompare(b.displayName));
return workloads;
}
function inventoryContainerMatch(checkout, repository, container) {
const safe = container?.Config || container?.State ? sanitizeLegacyContainer(container) : container;
if (!safe?.running) return 0;
const labels = safe.labels || {};
const workingDir = String(labels['com.docker.compose.project.working_dir'] || '').replace(/\/$/, '');
const source = remoteIdentity(labels['org.opencontainers.image.source'] || labels['tech.itworx.forgeflow.repository'] || '');
const mounts = Array.isArray(safe.mounts) ? safe.mounts : [];
const root = String(checkout.root || '').replace(/\/$/, '');
const name = String(safe.name || '').replace(/^\//, '');
const project = String(labels['com.docker.compose.project'] || '');
const expectedNames = new Set([repository.name, root.split('/').pop()].filter(Boolean).map(normalizedName));
if (workingDir && workingDir === root) return 100;
if (mounts.some((mount) => {
const mountSource = String(mount.Source || '').replace(/\/$/, '');
return mountSource === root || mountSource.startsWith(`${root}/`);
})) return 90;
if (source && source === remoteIdentity(checkout.remote)) return 85;
if (expectedNames.has(normalizedName(project))) return 70;
if (expectedNames.has(normalizedName(name))) return 60;
return 0;
}
module.exports = {
parseServerInventory,
buildWorkloadInventory,
inventoryContainerMatch,
remoteIdentity,
stableWorkloadId,
profileMatchesWorkload,
sanitizeLegacyContainer,
safeRelativeToBase,
canonicalServerAppdataPath,
deploymentRootCandidate,
};
+185 -57
View File
@@ -1,11 +1,12 @@
'use strict';
const fs = require('node:fs/promises');
const fs = require('node:fs');
const fsp = require('node:fs/promises');
const crypto = require('node:crypto');
const path = require('node:path').posix;
function loadSshClient() {
try { return require('ssh2').Client; }
function loadSshModule() {
try { return require('ssh2'); }
catch {
const error = new Error('The ssh2 dependency is not installed. Run npm install before configuring SSH deployments.');
error.code = 'SSH2_NOT_INSTALLED';
@@ -13,6 +14,10 @@ function loadSshClient() {
}
}
function loadSshClient() {
return loadSshModule().Client;
}
function fingerprintKey(key) {
const buffer = Buffer.isBuffer(key) ? key : Buffer.from(key);
return `SHA256:${crypto.createHash('sha256').update(buffer).digest('base64').replace(/=+$/, '')}`;
@@ -22,12 +27,65 @@ function shellQuote(value) {
return `'${String(value ?? '').replace(/'/g, `'\\''`)}'`;
}
function parseCapabilityOutput(output) {
const marker = '__FORGEFLOW_SERVER_TEST__';
const index = String(output || '').lastIndexOf(marker);
if (index < 0) return { platform: String(output || '').trim(), docker: false, dockerReady: false, compose: false, git: false, tar: false, checksum: false };
const fields = {};
for (const line of String(output).slice(index + marker.length).trim().split(/\r?\n/)) {
const separator = line.indexOf('=');
if (separator > 0) fields[line.slice(0, separator)] = line.slice(separator + 1);
}
const decode = (value) => {
try { return value ? Buffer.from(value, 'base64').toString('utf8') : ''; }
catch { return ''; }
};
return {
platform: decode(fields.platform),
docker: fields.docker === 'true',
dockerReady: fields.dockerReady === 'true',
compose: fields.compose === 'true',
composeVersion: decode(fields.composeVersion),
git: fields.git === 'true',
tar: fields.tar === 'true',
checksum: fields.checksum === 'true',
baseWritable: fields.baseWritable === 'true',
};
}
class SshService {
constructor({ store, diagnostics }) {
this.store = store;
this.diagnostics = diagnostics;
}
async validateServerConfiguration(server, secrets = {}) {
if (server?.authType !== 'privateKey') return { valid: true, method: 'password' };
const privateKeyPath = String(server.privateKeyPath || '').trim();
if (!privateKeyPath) throw new Error('Select a private key file.');
const stat = await fsp.stat(privateKeyPath).catch(() => null);
if (!stat?.isFile()) {
const error = new Error(`The SSH private key file was not found: ${privateKeyPath}`);
error.code = 'SSH_PRIVATE_KEY_NOT_FOUND';
throw error;
}
const existing = server.id ? this.store.getServer(server.id) : null;
const sameKey = existing && String(existing.privateKeyPath || '') === privateKeyPath;
const storedPassphrase = sameKey ? this.store.getServerCredentials(existing.id).passphrase : '';
const passphrase = Object.prototype.hasOwnProperty.call(secrets, 'passphrase') && String(secrets.passphrase || '')
? String(secrets.passphrase)
: storedPassphrase;
const key = await fsp.readFile(privateKeyPath);
const parsed = loadSshModule().utils.parseKey(key, passphrase || undefined);
const errorResult = Array.isArray(parsed) ? parsed.find((item) => item instanceof Error) : parsed instanceof Error ? parsed : null;
if (errorResult) {
const error = new Error(`The selected file is not a usable SSH private key${passphrase ? ' with the supplied passphrase' : ''}: ${errorResult.message}`);
error.code = /encrypted|passphrase|decrypt/i.test(errorResult.message) ? 'SSH_PRIVATE_KEY_PASSPHRASE_INVALID' : 'SSH_PRIVATE_KEY_INVALID';
throw error;
}
return { valid: true, method: 'privateKey', encrypted: Boolean(passphrase), privateKeyPath };
}
async connectionOptions(server, { trustOnFirstUse = false } = {}) {
const credentials = this.store.getServerCredentials(server.id);
let observedFingerprint = null;
@@ -41,12 +99,16 @@ class SshService {
hostVerifier: (key) => {
observedFingerprint = fingerprintKey(key);
return trustOnFirstUse || Boolean(server.hostFingerprint && observedFingerprint === server.hostFingerprint);
}
},
};
if (server.authType === 'password') {
options.password = credentials.password;
} else {
options.privateKey = await fs.readFile(server.privateKeyPath);
if (server.authType === 'password') options.password = credentials.password;
else {
try { options.privateKey = await fsp.readFile(server.privateKeyPath); }
catch (error) {
const wrapped = new Error(`Could not read SSH private key ${server.privateKeyPath}: ${error.message}`);
wrapped.code = 'SSH_PRIVATE_KEY_READ_FAILED';
throw wrapped;
}
if (credentials.passphrase) options.passphrase = credentials.passphrase;
}
return { options, getObservedFingerprint: () => observedFingerprint };
@@ -70,24 +132,20 @@ class SshService {
client.once('ready', async () => {
try {
const data = await action(client, server, connection.getObservedFingerprint());
await this.diagnostics?.debug('ssh.connection.completed', {
serverId,
host: server.host,
durationMs: Date.now() - started
});
await this.diagnostics?.debug('ssh.connection.completed', { serverId, host: server.host, durationMs: Date.now() - started });
finish(resolve, data);
} catch (error) { finish(reject, error); }
});
client.once('error', async (error) => {
const wrapped = new Error(`SSH connection failed: ${error.message}`);
wrapped.code = error.code || 'SSH_CONNECTION_FAILED';
await this.diagnostics?.warning('ssh.connection.failed', {
serverId,
host: server.host,
durationMs: Date.now() - started,
code: wrapped.code,
message: wrapped.message
});
const observed = connection.getObservedFingerprint();
const mismatch = Boolean(server.hostFingerprint && observed && server.hostFingerprint !== observed);
const wrapped = new Error(mismatch
? `SSH host identity changed. Expected ${server.hostFingerprint}, but the server presented ${observed}.`
: `SSH connection failed: ${error.message}`);
wrapped.code = mismatch ? 'SSH_HOST_KEY_MISMATCH' : (error.code || 'SSH_CONNECTION_FAILED');
wrapped.expectedFingerprint = mismatch ? server.hostFingerprint : undefined;
wrapped.observedFingerprint = mismatch ? observed : undefined;
await this.diagnostics?.warning('ssh.connection.failed', { serverId, host: server.host, durationMs: Date.now() - started, code: wrapped.code, message: wrapped.message });
finish(reject, wrapped);
});
client.connect(connection.options);
@@ -96,96 +154,166 @@ class SshService {
execClient(client, command, { timeout = 15 * 60_000, maxOutput = 2 * 1024 * 1024 } = {}) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('The SSH command timed out.')), timeout);
let completed = false;
const timer = setTimeout(() => {
if (completed) return;
completed = true;
reject(new Error('The SSH command timed out.'));
}, timeout);
client.exec(command, (error, stream) => {
if (error) {
clearTimeout(timer);
completed = true;
reject(error);
return;
}
let stdout = '';
let stderr = '';
stream.on('data', (chunk) => { if (stdout.length < maxOutput) stdout += chunk.toString(); });
stream.stderr.on('data', (chunk) => { if (stderr.length < maxOutput) stderr += chunk.toString(); });
let stdoutBytes = 0;
let stderrBytes = 0;
let truncated = false;
const append = (target, chunk) => {
const text = chunk.toString();
const bytes = Buffer.byteLength(text);
if (target === 'stdout') {
if (stdoutBytes + bytes <= maxOutput) stdout += text;
else truncated = true;
stdoutBytes += bytes;
} else {
if (stderrBytes + bytes <= maxOutput) stderr += text;
else truncated = true;
stderrBytes += bytes;
}
};
stream.on('data', (chunk) => append('stdout', chunk));
stream.stderr.on('data', (chunk) => append('stderr', chunk));
stream.on('close', (code, signal) => {
if (completed) return;
completed = true;
clearTimeout(timer);
if (code !== 0) {
if (truncated) {
const failure = new Error(`Remote command output exceeded the ${maxOutput}-byte safety limit. ForgeFlow refused to use an incomplete result.`);
failure.code = 'SSH_OUTPUT_TRUNCATED';
failure.stdoutBytes = stdoutBytes;
failure.stderrBytes = stderrBytes;
reject(failure);
} else if (code !== 0) {
const failure = new Error(`Remote command failed with exit code ${code}: ${(stderr || stdout).trim().slice(-4000)}`);
failure.code = 'SSH_COMMAND_FAILED';
failure.exitCode = code;
failure.signal = signal;
reject(failure);
} else resolve({ stdout, stderr, exitCode: code });
} else resolve({ stdout, stderr, exitCode: code, truncated: false });
});
});
});
}
async uploadBuffer(serverId, remotePath, content, { mode = 0o600 } = {}) {
ensureUploadTarget(target) {
const normalized = String(target || '').replace(/\\/g, '/');
if (!normalized.startsWith('/') || normalized.includes('\0') || normalized.split('/').includes('..')) throw new Error('Remote upload path must be an absolute safe Unix path.');
return normalized;
}
async withSftp(serverId, remotePath, action) {
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);
const target = this.ensureUploadTarget(remotePath);
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);
const parts = path.dirname(target).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);
const ensureNext = (index) => {
if (index >= parts.length) {
Promise.resolve(action(sftp, target)).then(resolve, reject);
return;
}
current += `/${mkdirParts[index]}`;
const ensureDirectory = () => {
current += `/${parts[index]}`;
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);
if (typeof attributes?.isDirectory === 'function' && !attributes.isDirectory()) { reject(new Error(`Remote upload parent exists but is not a directory: ${current}`)); return; }
ensureNext(index + 1);
return;
}
if (![2, 'ENOENT'].includes(statError.code)) { reject(statError); return; }
sftp.mkdir(current, { mode: 0o755 }, (mkdirError) => {
if (!mkdirError) { makeNext(index + 1); return; }
if (!mkdirError) { ensureNext(index + 1); return; }
sftp.stat(current, (retryError, retryAttributes) => {
if (!retryError && (typeof retryAttributes?.isDirectory !== 'function' || retryAttributes.isDirectory())) makeNext(index + 1);
if (!retryError && (typeof retryAttributes?.isDirectory !== 'function' || retryAttributes.isDirectory())) ensureNext(index + 1);
else reject(mkdirError);
});
});
});
};
ensureDirectory();
};
makeNext(0);
ensureNext(0);
});
}), { trustOnFirstUse: false });
}
async uploadFile(serverId, localPath, remotePath, options = {}) {
const data = await fs.readFile(localPath);
return this.uploadBuffer(serverId, remotePath, data, options);
async uploadBuffer(serverId, remotePath, content, { mode = 0o600 } = {}) {
const data = Buffer.isBuffer(content) ? content : Buffer.from(content);
return this.withSftp(serverId, remotePath, (sftp, target) => new Promise((resolve, reject) => {
const stream = sftp.createWriteStream(target, { mode });
stream.once('error', reject);
stream.once('close', () => resolve({ remotePath: target, size: data.length }));
stream.end(data);
}));
}
async uploadFile(serverId, localPath, remotePath, { mode = 0o600, onProgress = null } = {}) {
const stat = await fsp.stat(localPath);
if (!stat.isFile()) throw new Error(`Local upload source is not a file: ${localPath}`);
return this.withSftp(serverId, remotePath, (sftp, target) => new Promise((resolve, reject) => {
const options = {
mode,
step: (totalTransferred, _chunk, total) => onProgress?.({ transferred: totalTransferred, total: total || stat.size }),
};
sftp.fastPut(localPath, target, options, (error) => {
if (error) { reject(error); return; }
resolve({ remotePath: target, size: stat.size });
});
}));
}
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 });
const script = `
platform=$(uname -srm 2>/dev/null || true)
docker=false; docker_ready=false; compose=false; compose_version=''; git=false; tar_ok=false; checksum=false; base_writable=false
command -v docker >/dev/null 2>&1 && docker=true
[ "$docker" = true ] && docker info >/dev/null 2>&1 && docker_ready=true
if [ "$docker" = true ]; then
if docker compose version >/dev/null 2>&1; then compose=true; compose_version=$(docker compose version 2>/dev/null | head -n1); elif command -v docker-compose >/dev/null 2>&1; then compose=true; compose_version=$(docker-compose version 2>/dev/null | head -n1); fi
fi
command -v git >/dev/null 2>&1 && git=true
command -v tar >/dev/null 2>&1 && tar_ok=true
(command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1) && checksum=true
base=${shellQuote(server.basePath)}
if [ -d "$base" ]; then [ -w "$base" ] && base_writable=true; else parent=$(dirname "$base"); [ -d "$parent" ] && [ -w "$parent" ] && base_writable=true; fi
printf '__FORGEFLOW_SERVER_TEST__\\n'
printf 'platform=%s\\n' "$(printf '%s' "$platform" | base64 | tr -d '\\r\\n')"
printf 'docker=%s\\n' "$docker"
printf 'dockerReady=%s\\n' "$docker_ready"
printf 'compose=%s\\n' "$compose"
printf 'composeVersion=%s\\n' "$(printf '%s' "$compose_version" | base64 | tr -d '\\r\\n')"
printf 'git=%s\\n' "$git"
printf 'tar=%s\\n' "$tar_ok"
printf 'checksum=%s\\n' "$checksum"
printf 'baseWritable=%s\\n' "$base_writable"
`;
const result = await this.execClient(client, script, { timeout: 30_000, maxOutput: 256 * 1024 });
const capabilities = parseCapabilityOutput(result.stdout);
return {
connected: true,
fingerprint,
server: { id: server.id, name: server.name, host: server.host, basePath: server.basePath },
output: result.stdout.trim()
capabilities,
output: [capabilities.platform, capabilities.composeVersion].filter(Boolean).join('\n'),
};
}, { trustOnFirstUse });
}
@@ -201,4 +329,4 @@ class SshService {
}
}
module.exports = { SshService, shellQuote, fingerprintKey };
module.exports = { SshService, shellQuote, fingerprintKey, parseCapabilityOutput };
File diff suppressed because it is too large Load Diff
+14 -3
View File
@@ -279,7 +279,7 @@ class UpdateService {
));
if (!release || release.draft || release.prerelease) {
const error = new Error(
`ForgeFlow ${update.remoteVersion} has no published binary release yet.`,
`ForgeFlow ${update.remoteVersion} has no published binary release yet. The source branch was updated, but the matching Windows installer/portable assets were not published. Run Publish-Missing-Binary-Release.ps1 from the release source or publish the four required assets in Gitea.`,
);
error.code = "BINARY_RELEASE_NOT_FOUND";
throw error;
@@ -305,18 +305,29 @@ class UpdateService {
update.repo,
release.id,
asset.id,
{ downloadUrl: asset.browser_download_url },
),
this.gitea.downloadReleaseAsset(
update.owner,
update.repo,
release.id,
checksumAsset.id,
{ downloadUrl: checksumAsset.browser_download_url },
),
]);
if (binary.length < 1_000_000 || binary[0] !== 0x4d || binary[1] !== 0x5a) {
throw new Error(
"The downloaded Windows update is not a valid executable.",
const preview = binary.subarray(0, 200).toString("utf8").trim();
const looksLikeMetadata =
/^\s*[{[]/.test(preview) || /browser_download_url/i.test(preview);
const error = new Error(
looksLikeMetadata
? "Gitea returned release-asset metadata instead of the Windows executable. Upgrade ForgeFlow with the 0.9.1 installer once; later in-app updates use the actual browser download URL."
: "The downloaded Windows update is not a valid executable.",
);
error.code = looksLikeMetadata
? "RELEASE_ASSET_METADATA_RECEIVED"
: "INVALID_WINDOWS_UPDATE";
throw error;
}
const expectedSha256 = checksumBytes
.toString("utf8")
+639 -90
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark light" />
<title>ForgeFlow</title>
<link rel="icon" type="image/png" href="./assets/itworx-mark.png" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
+246 -4
View File
@@ -112,6 +112,24 @@
},
});
const sshProfile = (id, name, environment, options = {}) => ({
id, name, environment, provider: "ssh-unraid", branch: options.branch || "main",
serverId: "demo-unraid", remoteFolder: options.remoteFolder || name,
deploymentMode: "server-git", composeFiles: ["compose.yml"],
composeProject: options.composeProject || String(options.remoteFolder || name).toLowerCase(),
composeServices: options.composeServices || [String(options.remoteFolder || name).toLowerCase()],
containerName: options.containerName || options.remoteFolder || name,
generatedCompose: false, adoptedFromServer: true, serverSourceOfTruth: true,
confirmationRequired: true,
serverGitAccess: { configured: options.accessConfigured !== false, keyFingerprint: "SHA256:demo", hostFingerprint: "SHA256:gitea", configuredAt: iso(-3600000) },
state: {
liveSha: options.liveSha || null, giteaSha: options.giteaSha || options.liveSha || null,
previousSha: options.previousSha || null, healthy: options.healthy ?? true,
containerRunning: true, runtimeVerification: "verified", matchesGitea: options.matchesGitea ?? true,
checkedAt: iso(-120000), dockerMan: { templateExists: true, webUi: true, icon: true },
},
});
const now = iso();
const defaultPreferences = {
autoRefresh: true,
@@ -360,10 +378,14 @@
}),
linkState: "linked",
deploymentProfiles: [
profile("profile-portfolio", "Production", "production", {
sshProfile("profile-portfolio", "Production", "production", {
remoteFolder: "Portfolio",
containerName: "Portfolio",
liveSha: "4c20dd11bb6147fc8b6633d2b08500c93402a719",
giteaSha: "a7f2e1c1bb6147fc8b6633d2b08500c93402a719",
previousSha: "31adfe11bb6147fc8b6633d2b08500c93402a719",
healthy: false,
matchesGitea: false,
}),
],
},
@@ -564,7 +586,7 @@
await wait(80);
snapshot();
return {
appVersion: "0.8.8-demo",
appVersion: "0.10.0-demo",
platform: "win32",
state: clone(state),
git: { available: true, version: "git version 2.47.3" },
@@ -1204,7 +1226,7 @@
)),
...input,
id: input.id || `profile-${Date.now()}`,
provider: "gitea-actions",
provider: input.provider || existing?.provider || "gitea-actions",
inputs: existing?.inputs || {},
state: existing?.state || {
liveSha: null,
@@ -1425,14 +1447,123 @@
await wait(80);
return [
{
serverId: "unraid-primary",
serverId: "server-unraid",
serverName: "Unraid",
detected: 2,
adopted: 0,
verified: 1,
linked: 1,
unmatched: 0,
needsReview: 1,
running: 2,
stopped: 0,
capabilities: {
docker: true,
dockerReady: true,
compose: true,
git: false,
tar: true,
checksum: true,
},
warnings: [],
workloads: [
{
workloadId: "workload-demo-linked",
displayName: "Portfolio",
status: "linked",
runtime: { running: true, health: "healthy" },
compose: {
project: "portfolio",
workingDir: "/mnt/user/appdata/portfolio",
configFiles: ["/mnt/user/appdata/portfolio/docker-compose.yml"],
services: ["web"],
},
containers: [{ name: "Portfolio", running: true }],
candidates: [],
link: {
profileId: "profile-portfolio",
repositoryFullName: "jens/portfolio",
source: "manual",
},
},
{
workloadId: "workload-demo-review",
displayName: "OmniRoute",
status: "suggested",
runtime: { running: true, health: "unverified" },
compose: {
project: "omniroute",
workingDir: "/mnt/user/appdata/OmniRoute",
configFiles: ["/mnt/user/appdata/OmniRoute/docker-compose.yml"],
services: ["omniroute"],
},
containers: [{ name: "omniroute", running: true }],
remoteFolderCandidate: "OmniRoute",
candidates: repositories.slice(0, 1).map((repository) => ({
repositoryFullName: repository.fullName,
repositoryName: repository.name,
score: 55,
exact: false,
reasons: ["container and repository names are similar"],
})),
},
],
},
];
},
async linkServerWorkload(repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "") {
await wait(120);
const repo = repositories.find((item) => item.fullName === repository.fullName);
if (!repo) throw new Error("Repository not found.");
const id = `profile-${workloadId}`;
const saved = {
id,
name: `Unraid · ${remoteFolder || repo.name}`,
environment: "production",
provider: "ssh-unraid",
branch: repo.defaultBranch || "main",
serverId,
remoteFolder: remoteFolder || repo.name,
deploymentMode,
composeFile: "docker-compose.yml",
composeFiles: ["docker-compose.yml"],
composeProject: String(remoteFolder || repo.name).toLowerCase(),
composeService: String(remoteFolder || repo.name).toLowerCase(),
composeServices: [String(remoteFolder || repo.name).toLowerCase()],
containerName: remoteFolder || repo.name,
preservePaths: [".env", "appdata", "data", "logs", "config"],
generatedCompose: false,
adoptedFromServer: true,
serverSourceOfTruth: true,
manageDockerMan: false,
forceRecreate: false,
removeOrphans: false,
workloadIdentity: { workloadId, linkSource: "manual", linkedAt: iso() },
confirmationRequired: true,
state: {
liveSha: null,
healthy: null,
containerRunning: true,
runtimeVerification: "running-unverified",
checkedAt: iso(),
},
};
repo.deploymentProfiles = [
...repo.deploymentProfiles.filter((item) => item.id !== id),
saved,
];
syncState();
return { profile: clone(saved), state: clone(state) };
},
async configureServerGitAccess(repository, profileId) {
const repo = repositories.find((item) => item.fullName === repository.fullName);
const target = repo?.deploymentProfiles.find((item) => item.id === profileId);
if (!target) throw new Error("Deployment profile not found.");
target.deploymentMode = "server-git";
target.serverGitAccess = { configured: true, keyFingerprint: "SHA256:demo", hostFingerprint: "SHA256:gitea", configuredAt: iso() };
syncState();
return { profile: clone(target), created: true, remoteSha: target.state?.giteaSha || repo.localStatus?.head };
},
async refreshOperations(operationId = null) {
await wait(300);
if (operationId) {
@@ -1461,6 +1592,117 @@
state.operations.find((item) => item.id === operationId) || null,
);
},
async gitValidatorScan(fullName) {
await wait(260);
return {
repository: fullName,
checkedAt: iso(),
score: 78,
grade: "Good",
summary: { passed: 7, warnings: 3, errors: 0, repairable: 2 },
checks: [
{
id: "origin",
category: "Repository identity",
title: "Origin matches Gitea",
status: "pass",
detail: "The local origin resolves to this Gitea repository.",
weight: 15,
},
{
id: "default-branch-protection",
category: "Gitea governance",
title: "Default branch protection",
status: "warning",
detail: "main accepts unprotected direct changes.",
weight: 18,
fixAction: "protect-default-branch",
safe: false,
confirmation:
"Protect main on Gitea and block direct and force pushes?",
},
{
id: "force-push",
category: "Gitea governance",
title: "Force-push protection",
status: "pass",
detail: "Force pushes are blocked.",
weight: 8,
},
{
id: "upstream",
category: "Branch hygiene",
title: "Current branch has an upstream",
status: "pass",
detail: "main tracks origin/main.",
weight: 8,
},
{
id: "working-tree",
category: "Branch hygiene",
title: "Working tree is intentional",
status: "warning",
detail: "3 changed files require review, commit or stash.",
weight: 5,
},
{
id: "identity",
category: "Commit integrity",
title: "Repository author identity",
status: "pass",
detail: "Jens <jens@example.test>",
weight: 7,
},
{
id: "local-safety",
category: "Local configuration",
title: "Safe synchronization defaults",
status: "warning",
detail: "Recommended repository-local safeguards are incomplete.",
weight: 10,
fixAction: "configure-local-safety",
safe: true,
},
{
id: "readme",
category: "Repository documentation",
title: "README is versioned",
status: "pass",
detail: "Repository documentation is tracked.",
weight: 7,
},
{
id: "gitignore",
category: "Repository hygiene",
title: ".gitignore is versioned",
status: "pass",
detail: "Generated files are excluded centrally.",
weight: 8,
},
{
id: "tracked-secrets",
category: "Security",
title: "No secret-shaped files are tracked",
status: "pass",
detail:
"No tracked environment, key or credential filenames detected.",
weight: 22,
},
{
id: "large-files",
category: "Repository performance",
title: "No oversized tracked files",
status: "pass",
detail: "No tracked files above 10 MB were found.",
weight: 7,
},
],
};
},
async gitValidatorRepair() {
await wait(180);
return { repaired: true };
},
async diagnosticsStatus() {
return {
enabled: state.preferences.diagnosticsEnabled !== false,
+246
View File
@@ -1280,6 +1280,227 @@ html[data-theme="light"] .diff-line.remove {
color: #caa7ff;
background: rgba(148, 97, 214, 0.08);
}
.validator-page {
container-type: inline-size;
padding: 18px;
display: grid;
gap: 14px;
overflow: auto;
}
.validator-empty {
min-height: 360px;
margin: 18px;
padding: 38px;
display: flex;
align-items: center;
justify-content: center;
gap: 36px;
text-align: left;
overflow: hidden;
}
.validator-empty > div:last-child {
max-width: 520px;
}
.validator-empty h2 {
margin: 5px 0 8px;
font-size: 24px;
}
.validator-empty p {
margin: 0 0 18px;
color: var(--text-muted);
line-height: 1.65;
}
.validator-hero {
position: relative;
min-height: 160px;
padding: 24px;
display: grid;
grid-template-columns: auto minmax(220px, 1fr) minmax(180px, 260px) auto;
align-items: center;
gap: 22px;
overflow: hidden;
background:
radial-gradient(
circle at 68% 16%,
color-mix(in srgb, var(--primary) 16%, transparent),
transparent 28%
),
linear-gradient(
120deg,
var(--surface-1),
color-mix(in srgb, var(--surface-2) 84%, var(--primary-soft))
);
}
.validator-hero.success {
--validator-accent: var(--success);
}
.validator-hero.warning {
--validator-accent: var(--warning);
}
.validator-hero.danger {
--validator-accent: var(--danger);
}
.validator-score {
width: 116px;
height: 116px;
border-radius: 32px;
display: grid;
place-content: center;
text-align: center;
background: color-mix(in srgb, var(--validator-accent) 10%, var(--surface-2));
border: 1px solid color-mix(in srgb, var(--validator-accent) 42%, var(--line));
box-shadow:
inset 0 0 34px color-mix(in srgb, var(--validator-accent) 10%, transparent),
0 18px 38px rgba(0, 0, 0, 0.15);
}
.validator-score strong {
color: var(--validator-accent);
font-size: 42px;
line-height: 0.9;
letter-spacing: -0.05em;
}
.validator-score span {
margin-top: 7px;
color: var(--text-muted);
font: 700 10px/1 var(--font-mono);
}
.validator-hero h2 {
margin: 4px 0 6px;
font-size: 24px;
}
.validator-hero p {
margin: 0;
color: var(--text-muted);
}
.validator-hero .project-illustration {
width: 220px;
opacity: 0.82;
}
.validator-actions {
display: grid;
gap: 8px;
min-width: 150px;
}
.validator-groups {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
align-items: start;
}
.validator-group {
overflow: hidden;
}
.validator-checks {
display: grid;
}
.validator-check {
min-height: 78px;
padding: 13px 14px;
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
gap: 11px;
align-items: center;
border-top: 1px solid var(--line-soft);
transition:
background 160ms ease,
transform 160ms ease;
}
.validator-check:hover {
background: var(--surface-hover);
}
.validator-check-icon {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: 9px;
color: var(--text-muted);
background: var(--surface-2);
}
.validator-check.pass .validator-check-icon {
color: var(--success);
background: color-mix(in srgb, var(--success) 12%, transparent);
}
.validator-check.warning .validator-check-icon {
color: var(--warning);
background: color-mix(in srgb, var(--warning) 12%, transparent);
}
.validator-check.error .validator-check-icon {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 12%, transparent);
}
.validator-check strong {
display: block;
font-size: 12px;
}
.validator-check p {
margin: 4px 0 0;
color: var(--text-muted);
font-size: 11px;
line-height: 1.45;
overflow-wrap: anywhere;
}
html[data-theme="light"] .validator-hero {
background:
radial-gradient(
circle at 68% 16%,
rgba(66, 91, 220, 0.18),
transparent 30%
),
linear-gradient(
120deg,
rgba(255, 255, 255, 0.98),
rgba(236, 243, 255, 0.96)
);
}
@media (max-width: 1180px) {
.validator-hero {
grid-template-columns: auto 1fr auto;
}
.validator-hero .project-illustration {
display: none;
}
.validator-groups {
grid-template-columns: 1fr;
}
}
@container (max-width: 900px) {
.validator-hero {
grid-template-columns: auto minmax(0, 1fr);
}
.validator-hero .project-illustration {
display: none;
}
.validator-actions {
grid-column: 1 / -1;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.validator-groups {
grid-template-columns: 1fr;
}
}
@container (max-width: 520px) {
.validator-hero {
grid-template-columns: 1fr;
}
.validator-score {
width: 92px;
height: 92px;
border-radius: 25px;
}
.validator-actions {
grid-template-columns: 1fr;
}
.validator-check {
grid-template-columns: 30px minmax(0, 1fr);
}
.validator-check > .button,
.validator-check > .status-pill {
grid-column: 2;
justify-self: start;
}
}
.empty-state {
height: 100%;
min-height: 260px;
@@ -2391,6 +2612,27 @@ kbd {
color: var(--text-faint);
font: 10px var(--font-mono);
}
.server-inventory-panel {
position: relative;
overflow: hidden;
border-color: color-mix(in srgb, var(--primary) 22%, var(--line));
background:
radial-gradient(circle at 94% 0%, color-mix(in srgb, var(--primary) 10%, transparent), transparent 34%),
var(--surface-1);
}
.server-inventory-panel::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 3px;
background: linear-gradient(180deg, var(--primary), var(--success));
}
.server-inventory-panel .tool-row {
transition: background 150ms ease, transform 150ms ease;
}
.server-inventory-panel .tool-row:hover {
transform: translateX(2px);
}
.deploy-card-header h3 {
margin: 4px 0 3px;
font-size: 14px;
@@ -2694,6 +2936,10 @@ kbd {
.preflight-row small {
color: var(--text-muted);
}
.preflight-row .compact-button {
width: fit-content;
margin-top: 9px;
}
.preflight-state {
width: 25px;
height: 25px;
+1 -1
View File
@@ -70,7 +70,7 @@ function validateShellScriptStructure(scriptText) {
'flock -n 9',
'git -C "$APP_DIR" fetch',
'git -C "$APP_DIR" reset --hard "$SHA"',
'docker compose -f "$COMPOSE_FILE" up -d --build --remove-orphans',
'docker compose -f "$COMPOSE_FILE" up -d --build',
'write_status "healthy"',
'write_status "unhealthy"'
]) {
+124
View File
@@ -0,0 +1,124 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { createRequire } from "node:module";
const exec = promisify(execFile);
const require = createRequire(import.meta.url);
const { GitService } = require("../src/main/git-service.cjs");
const {
GitValidatorService,
isSensitiveTrackedPath,
sameRemote,
} = require("../src/main/git-validator-service.cjs");
async function git(args, cwd) {
return exec("git", args, { cwd, encoding: "utf8" });
}
test("Git Validator scores repository hygiene and offers bounded safe repairs", async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), "forgeflow-validator-"));
t.after(() => rm(root, { recursive: true, force: true }));
await git(["init", "-b", "main"], root);
await git(["config", "user.name", "ForgeFlow Test"], root);
await git(["config", "user.email", "forgeflow@example.invalid"], root);
await git(
["remote", "add", "origin", "https://gitea.example.test/jens/app.git"],
root,
);
await writeFile(path.join(root, "README.md"), "# App\n", "utf8");
await writeFile(path.join(root, ".gitignore"), ".env\n", "utf8");
await git(["add", "."], root);
await git(["commit", "-m", "Initial"], root);
const validator = new GitValidatorService({
git: new GitService(),
gitea: {
getBranchProtection: async () => ({
protected: false,
enableForcePush: false,
}),
},
});
const repository = {
fullName: "jens/app",
name: "app",
owner: { login: "jens" },
defaultBranch: "main",
localPath: root,
cloneUrl: "https://gitea.example.test/jens/app.git",
sshUrl: "git@gitea.example.test:jens/app.git",
};
const report = await validator.scan(repository);
assert.ok(report.score > 60);
assert.equal(
report.checks.find((check) => check.id === "origin").status,
"pass",
);
assert.equal(
report.checks.find((check) => check.id === "default-branch-protection")
.fixAction,
"protect-default-branch",
);
const safety = report.checks.find((check) => check.id === "local-safety");
assert.equal(safety.safe, true);
await validator.repair(repository, safety);
const rescanned = await validator.scan(repository);
assert.equal(
rescanned.checks.find((check) => check.id === "local-safety").status,
"pass",
);
});
test("Git Validator creates a reviewable gitignore without committing it", async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), "forgeflow-ignore-"));
t.after(() => rm(root, { recursive: true, force: true }));
await git(["init", "-b", "main"], root);
const validator = new GitValidatorService({ git: new GitService() });
const repository = { localPath: root };
await validator.repair(repository, { fixAction: "add-gitignore" });
const content = await readFile(path.join(root, ".gitignore"), "utf8");
assert.match(content, /\.env/);
const status = await git(["status", "--short"], root);
assert.match(status.stdout, /\?\? \.gitignore/);
});
test("Git Validator recognizes remote aliases and secret-shaped tracked paths", () => {
assert.equal(
sameRemote(
"git@gitea.example.test:jens/app.git",
"https://gitea.example.test/jens/app",
),
true,
);
assert.equal(isSensitiveTrackedPath(".env.production"), true);
assert.equal(isSensitiveTrackedPath("config/private.pem"), true);
assert.equal(isSensitiveTrackedPath(".env.example"), false);
});
test("Git Validator reports reproducibility, CI and editor hygiene and creates reviewable defaults", async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), "forgeflow-hygiene-"));
t.after(() => rm(root, { recursive: true, force: true }));
await git(["init", "-b", "main"], root);
await git(["config", "user.name", "ForgeFlow Test"], root);
await git(["config", "user.email", "forgeflow@example.invalid"], root);
await git(["remote", "add", "origin", "https://gitea.example.test/jens/app.git"], root);
await writeFile(path.join(root, "package.json"), '{"name":"app"}\n', "utf8");
await writeFile(path.join(root, "README.md"), "# App\n", "utf8");
await writeFile(path.join(root, ".gitignore"), ".env\n", "utf8");
await git(["add", "."], root);
await git(["commit", "-m", "Initial"], root);
const validator = new GitValidatorService({ git: new GitService(), gitea: { getBranchProtection: async () => ({ protected: true, enableForcePush: false }) } });
const repository = { fullName: "jens/app", name: "app", owner: { login: "jens" }, defaultBranch: "main", localPath: root, cloneUrl: "https://gitea.example.test/jens/app.git" };
const report = await validator.scan(repository);
assert.equal(report.checks.find((check) => check.id === "dependency-lock").status, "warning");
assert.equal(report.checks.find((check) => check.id === "continuous-integration").status, "warning");
for (const action of ["add-gitattributes", "add-editorconfig"])
await validator.repair(repository, { fixAction: action });
assert.match(await readFile(path.join(root, ".gitattributes"), "utf8"), /text=auto/);
assert.match(await readFile(path.join(root, ".editorconfig"), "utf8"), /root = true/);
});
+70 -2
View File
@@ -108,21 +108,89 @@ test('creates controlled pull requests and reads branch protection', async () =>
await assert.rejects(() => service.createPullRequest({ owner: 'owner', repo: 'app', head: 'main', base: 'main', title: 'Invalid' }), /different/);
});
test('downloads release assets through the release-scoped Gitea endpoint', async () => {
test('resolves release attachment metadata before downloading the actual asset', async () => {
const service = new GiteaService(makeStore());
let metadataPath = '';
let requested = '';
service.request = async (pathname) => {
metadataPath = pathname;
return {
data: {
id: 412,
browser_download_url: 'https://gitea.example.test/attachments/release.exe',
},
};
};
service.downloadAuthenticated = async (pathname) => {
requested = pathname;
return Buffer.from('asset');
};
const asset = await service.downloadReleaseAsset('Jens', 'ForgeFlow', 107, 412);
assert.equal(asset.toString(), 'asset');
assert.equal(
metadataPath,
'/repos/Jens/ForgeFlow/releases/107/assets/412',
);
assert.equal(
requested,
'/api/v1/repos/Jens/ForgeFlow/releases/107/assets/412',
'https://gitea.example.test/attachments/release.exe',
);
await assert.rejects(
() => service.downloadReleaseAsset('Jens', 'ForgeFlow', null, 412),
/invalid release ID/,
);
});
test('uses a release-provided browser download URL without requesting metadata again', async () => {
const service = new GiteaService(makeStore());
service.request = async () => { throw new Error('metadata lookup should not run'); };
let requested = '';
service.downloadAuthenticated = async (pathname) => {
requested = pathname;
return Buffer.from('asset');
};
const asset = await service.downloadReleaseAsset('Jens', 'ForgeFlow', 107, 412, {
downloadUrl: 'https://gitea.example.test/attachments/direct.exe',
});
assert.equal(asset.toString(), 'asset');
assert.equal(requested, 'https://gitea.example.test/attachments/direct.exe');
});
test('creates conservative default branch protection rules', async () => {
const service = new GiteaService(makeStore());
let request = null;
service.request = async (pathname, options) => {
request = { pathname, options };
return { data: { rule_name: 'main' } };
};
const result = await service.createBranchProtection('jens', 'app', 'main');
assert.equal(result.rule_name, 'main');
assert.equal(request.options.method, 'POST');
assert.equal(request.options.body.enable_push, false);
assert.equal(request.options.body.enable_force_push, false);
assert.equal(request.options.body.rule_name, 'main');
});
test('creates repository-scoped read-only deploy keys and reuses only safe matches', async () => {
const service = new GiteaService(makeStore());
const publicKey = `ssh-ed25519 ${Buffer.from('public-key-material').toString('base64')} forgeflow:test`;
const requests = [];
service.request = async (pathname, options = {}) => {
requests.push({ pathname, options });
if (!options.method) return { data: [] };
return { data: { id: 41, key: publicKey, read_only: true } };
};
const created = await service.ensureReadOnlyDeployKey({ owner: 'jens', repo: 'app', title: 'ForgeFlow', publicKey });
assert.equal(created.created, true);
assert.equal(requests[1].options.body.read_only, true);
service.request = async () => ({ data: [{ id: 41, key: publicKey, read_only: true }] });
const reused = await service.ensureReadOnlyDeployKey({ owner: 'jens', repo: 'app', title: 'ForgeFlow', publicKey });
assert.equal(reused.created, false);
service.request = async () => ({ data: [{ id: 41, key: publicKey, read_only: false }] });
await assert.rejects(
() => service.ensureReadOnlyDeployKey({ owner: 'jens', repo: 'app', title: 'ForgeFlow', publicKey }),
(error) => error.code === 'DEPLOY_KEY_NOT_READ_ONLY',
);
});
+1 -1
View File
@@ -41,7 +41,7 @@ test("every renderer bridge call is exposed by the preload contract", async () =
),
);
const exposed = new Set(
[...preload.matchAll(/^\s{2}([A-Za-z0-9_]+):/gm)].map((match) => match[1]),
[...preload.matchAll(/^\s+([A-Za-z0-9_]+):/gm)].map((match) => match[1]),
);
assert.ok(calls.size > 40, "expected the complete renderer bridge surface");
assert.deepEqual(
+23 -1
View File
@@ -108,7 +108,7 @@ test("SSH deployments are polled in the background and Portfolio casing is prese
assert.match(renderer, /function startOperationPolling\(\)/);
assert.match(renderer, /startOperationPolling\(\);/);
assert.match(renderer, /Visible container name/);
assert.match(renderer, /Compose service \(internal\)/);
assert.match(renderer, /Compose services to verify/);
});
test("deployment profiles expose built-in/uploaded DockerMan icons and automatic metadata repair", async () => {
@@ -243,3 +243,25 @@ test("the diff canvas uses a contextual and motion-safe code illustration", asyn
assert.match(styles, /@keyframes code-packet-travel/);
assert.match(styles, /prefers-reduced-motion/);
});
test("Git Validator exposes scored best-practice checks and bounded repairs", async () => {
const renderer = await readFile(
new URL("../src/renderer/app.js", import.meta.url),
"utf8",
);
const styles = await readFile(
new URL("../src/renderer/styles.css", import.meta.url),
"utf8",
);
const preload = await readFile(
new URL("../preload.cjs", import.meta.url),
"utf8",
);
assert.match(renderer, /function renderGitValidator/);
assert.match(renderer, /git-validator-repair-safe/);
assert.match(renderer, /check\.safe/);
assert.match(styles, /\.validator-score/);
assert.match(styles, /@container \(max-width: 900px\)/);
assert.match(preload, /gitValidatorScan/);
assert.match(preload, /gitValidatorRepair/);
});
+37
View File
@@ -0,0 +1,37 @@
import test from "node:test";
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { SshService, parseCapabilityOutput } = require("../src/main/ssh-service.cjs");
test("SSH capability parsing keeps Git optional and reports deployment prerequisites separately", () => {
const b64 = (value) => Buffer.from(value).toString("base64");
const parsed = parseCapabilityOutput(`noise\n__FORGEFLOW_SERVER_TEST__\nplatform=${b64("Linux Unraid")}\ndocker=true\ndockerReady=true\ncompose=true\ncomposeVersion=${b64("Docker Compose version v2.40.0")}\ngit=false\ntar=true\nchecksum=true\nbaseWritable=true\n`);
assert.equal(parsed.dockerReady, true);
assert.equal(parsed.compose, true);
assert.equal(parsed.git, false);
assert.equal(parsed.tar, true);
assert.equal(parsed.checksum, true);
assert.equal(parsed.baseWritable, true);
});
test("SSH execution rejects truncated output instead of using an incomplete inventory", async () => {
const service = new SshService({ store: {}, diagnostics: null });
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
const client = {
exec(_command, callback) {
callback(null, stream);
queueMicrotask(() => {
stream.emit("data", Buffer.from("x".repeat(64)));
stream.emit("close", 0, null);
});
},
};
await assert.rejects(
service.execClient(client, "inventory", { maxOutput: 16, timeout: 1_000 }),
(error) => error?.code === "SSH_OUTPUT_TRUNCATED" && /incomplete result/.test(error.message),
);
});
+418 -4
View File
@@ -16,6 +16,7 @@ const {
bash,
} = require("../src/main/unraid-deployment-service.cjs");
const { fingerprintKey, shellQuote } = require("../src/main/ssh-service.cjs");
const { buildWorkloadInventory, deploymentRootCandidate } = require("../src/main/server-inventory.cjs");
test("Unraid remote paths cannot escape appdata project folder", () => {
assert.equal(safeRemoteFolder("lumaops"), "lumaops");
@@ -27,6 +28,12 @@ test("Unraid remote paths cannot escape appdata project folder", () => {
assert.throws(() => safeRelativeRemoteFile("../../etc/passwd"));
});
test("server release directories resolve to the stable deployment root", () => {
assert.equal(deploymentRootCandidate("infrabinder/releases/f8b0dd8"), "infrabinder");
assert.equal(deploymentRootCandidate("portfolio/.forgeflow/releases/abc/source"), "portfolio");
assert.equal(deploymentRootCandidate("ludarium/source/deploy"), "ludarium/source/deploy");
});
test("server inspection key-value payload is decoded safely", () => {
const b64 = (value) => Buffer.from(value).toString("base64");
const parsed = parseInspection(
@@ -40,6 +47,39 @@ test("server inspection key-value payload is decoded safely", () => {
assert.deepEqual(parsed.existingPreservePaths, ["data", "logs"]);
});
test("server pull provisions a pinned repository-scoped key and records access metadata", async () => {
const publicKey = `ssh-ed25519 ${Buffer.from("server-public-key").toString("base64")} forgeflow:test`;
const profile = {
id: "profile-1", provider: "ssh-unraid", serverId: "unraid", remoteFolder: "portfolio",
branch: "main", deploymentMode: "server-git", composeFiles: ["compose.yml"], composeServices: ["portfolio"],
};
let saved = null;
let deployKeyRequest = null;
let calls = 0;
const service = new UnraidDeploymentService({
store: {
getDeploymentProfile: () => profile,
getServer: () => ({ id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata" }),
saveDeploymentProfile: async (_fullName, value) => { saved = value; return value; },
},
ssh: { exec: async () => {
calls += 1;
if (calls === 1) return { stdout: `__FORGEFLOW_DEPLOY_KEY__\npublicKey=${Buffer.from(publicKey).toString("base64")}\nfingerprint=SHA256:key\nhostFingerprint=SHA256:host\n` };
return { stdout: `${"a".repeat(40)}\trefs/heads/main\n` };
} },
gitea: { ensureReadOnlyDeployKey: async (request) => { deployKeyRequest = request; return { id: 17, created: true }; } },
});
const result = await service.configureServerGitAccess({
repository: { fullName: "Jens/Portfolio", sshUrl: "git@gitea.example.test:Jens/Portfolio.git" },
profileId: profile.id,
});
assert.equal(deployKeyRequest.owner, "Jens");
assert.equal(deployKeyRequest.repo, "Portfolio");
assert.equal(saved.deploymentMode, "server-git");
assert.equal(saved.serverGitAccess.hostFingerprint, "SHA256:host");
assert.equal(result.remoteSha, "a".repeat(40));
});
test("server workload inventory links running containers to exact Gitea checkouts", () => {
const b64 = (value) => Buffer.from(value).toString("base64");
const inspect = JSON.stringify([
@@ -131,6 +171,164 @@ test("automatic server discovery adopts and verifies a running Gitea deployment"
assert.equal(states.get(profiles[0].id).matchesGitea, true);
});
test("server inventory includes stopped DockerMan containers without Git and keeps name matches manual", () => {
const workloads = buildWorkloadInventory({
inventory: {
checkouts: [],
dockerMan: [
{
name: "omniroute",
templatePath: "/boot/config/plugins/dockerMan/templates-user/my-omniroute.xml",
webUiUrl: "http://[IP]:[PORT:20128]/",
iconUrl: "",
shell: "sh",
repository: "diegosouzapw/omniroute:latest",
network: "bridge",
},
],
containers: [
{
id: "container-1",
name: "omniroute",
image: "ghcr.io/diegosouzapw/omniroute:latest",
imageId: "sha256:image",
running: false,
status: "exited",
health: null,
labels: {},
ports: { "3000/tcp": [{ HostPort: "20128", HostIp: "0.0.0.0" }] },
mounts: [
{
Type: "bind",
Source: "/mnt/user/appdata/OmniRoute/config",
Destination: "/app/config",
RW: true,
},
],
networks: { bridge: {} },
restartPolicy: "unless-stopped",
},
],
warnings: [],
capabilities: { docker: true, compose: true },
},
server: {
id: "unraid",
name: "Unraid",
basePath: "/mnt/user/appdata",
},
repositories: [
{
fullName: "Jens/OmniRoute",
name: "OmniRoute",
cloneUrl: "https://gitea.itworx.tech/Jens/OmniRoute.git",
},
],
profiles: [],
});
assert.equal(workloads.length, 1);
assert.equal(workloads[0].runtime.running, false);
assert.equal(workloads[0].kind, "dockerman-container");
assert.equal(workloads[0].remoteFolderCandidate, "OmniRoute");
assert.equal(workloads[0].status, "suggested");
assert.equal(workloads[0].candidates[0].exact, false);
assert.match(workloads[0].candidates[0].reasons.join(" "), /manual confirmation/i);
});
test("server inventory groups multi-service Compose projects and preserves their identity", () => {
const baseContainer = {
image: "example/app:latest",
imageId: "sha256:image",
running: true,
status: "running",
health: null,
ports: {},
mounts: [],
networks: { appnet: {} },
restartPolicy: "unless-stopped",
};
const labels = {
"com.docker.compose.project": "forgeflow",
"com.docker.compose.project.working_dir": "/mnt/user/appdata/ForgeFlow",
"com.docker.compose.project.config_files": "/mnt/user/appdata/ForgeFlow/compose.yml,/mnt/user/appdata/ForgeFlow/compose.prod.yml",
};
const workloads = buildWorkloadInventory({
inventory: {
checkouts: [],
dockerMan: [],
warnings: [],
capabilities: {},
containers: [
{
...baseContainer,
id: "web",
name: "forgeflow-web-1",
labels: { ...labels, "com.docker.compose.service": "web" },
},
{
...baseContainer,
id: "worker",
name: "forgeflow-worker-1",
labels: { ...labels, "com.docker.compose.service": "worker" },
},
],
},
server: { id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata" },
repositories: [],
profiles: [],
});
assert.equal(workloads.length, 1);
assert.deepEqual(workloads[0].compose.services.sort(), ["web", "worker"]);
assert.deepEqual(workloads[0].compose.configFiles, [
"/mnt/user/appdata/ForgeFlow/compose.yml",
"/mnt/user/appdata/ForgeFlow/compose.prod.yml",
]);
assert.equal(workloads[0].compose.project, "forgeflow");
assert.equal(workloads[0].remoteFolderCandidate, "ForgeFlow");
});
test("manual workload linking does not claim Gitea parity for unrelated provenance", async () => {
let savedState = null;
const service = new UnraidDeploymentService({
store: {
saveDeploymentState: async (_id, state) => {
savedState = state;
return state;
},
},
ssh: {},
git: {},
diagnostics: null,
});
await service.saveWorkloadState(
{
id: "profile",
containerName: "app",
remoteFolder: "app",
cloneUrl: "https://gitea.itworx.tech/Jens/Expected.git",
},
{
workloadId: "workload",
observedAt: new Date().toISOString(),
metadata: {
sourceRepository: "https://gitea.itworx.tech/Jens/Other.git",
liveRevision: "a".repeat(40),
},
runtime: { running: true, health: "healthy" },
containers: [{ name: "app", running: true, health: "healthy" }],
compose: { project: "app" },
},
{ basePath: "/mnt/user/appdata" },
);
assert.equal(savedState.liveSha, "a".repeat(40));
assert.equal(savedState.matchesGitea, false);
assert.equal(savedState.giteaSha, null);
});
test("Docker ignore checks identify exact runtime and Git context exclusions", () => {
const rules = "# build context\n.git\ndata/\nlogs/**\n!logs/keep.txt\n";
assert.equal(dockerIgnoreHasPath(rules, ".git"), true);
@@ -207,6 +405,9 @@ test("SSH helpers produce pinned fingerprints and quoted commands", () => {
const decoded = Buffer.from(encoded, "base64").toString("utf8");
assert.match(decoded, /GIT_TERMINAL_PROMPT=0/);
assert.match(decoded, /BatchMode=yes/);
assert.match(decoded, /forgeflow_compose\(\)/);
assert.match(decoded, /docker compose "\$@"/);
assert.match(decoded, /docker-compose "\$@"/);
assert.match(decoded, /git fetch origin main/);
});
@@ -296,6 +497,7 @@ test("successful SSH rollback records the formerly live SHA as the new rollback
status: null,
latencyMs: null,
});
service.executePushBundle = async () => ({ stdout: "rollback activated", stderr: "", exitCode: 0 });
const result = await service.rollback({
repository: {
fullName: "Jens/LumaOps",
@@ -377,6 +579,7 @@ test("SSH deployment dispatch returns a running operation while the remote build
status: null,
latencyMs: null,
});
service.executePushBundle = async () => new Promise((resolve) => { resolveRemote = resolve; });
const operation = await service.deploy({
repository: { fullName: "Jens/Portfolio", name: "Portfolio" },
@@ -404,7 +607,7 @@ test("Unraid preflight verifies repository access before a deployment can start"
);
assert.match(source, /server-git-access/);
assert.match(source, /git ls-remote --exit-code/);
assert.match(source, /Unraid → Gitea access/);
assert.match(source, /Unraid → Gitea read access/);
});
test("DockerMan metadata uses dockerman labels, a template WebUI and lowercase-safe service/image names", () => {
@@ -428,8 +631,8 @@ test("DockerMan metadata uses dockerman labels, a template WebUI and lowercase-s
"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.doesNotMatch(metadata, /image: forgeflow\/portfolio:production/);
assert.doesNotMatch(metadata, /container_name: Portfolio/);
assert.match(metadata, /net\.unraid\.docker\.managed.*dockerman/);
assert.match(
metadata,
@@ -456,6 +659,8 @@ test("DockerMan integration writes a persistent template fallback and invalidate
hostPort: 5150,
webUiUrl: "http://192.168.10.150:5150/",
dockerShell: "/bin/sh",
manageDockerMan: true,
generatedCompose: true,
};
const repository = { name: "Portfolio" };
const icon =
@@ -482,6 +687,32 @@ test("DockerMan integration writes a persistent template fallback and invalidate
assert.equal(xmlEscape('A&B<"x">'), "A&amp;B&lt;&quot;x&quot;&gt;");
});
test("adopted DockerMan templates are never rewritten", () => {
const service = new UnraidDeploymentService({
store: {},
ssh: {},
git: {},
diagnostics: null,
});
const refresh = service.dockerManRefreshScript(
{
composeService: "omniroute",
containerName: "omniroute",
remoteFolder: "OmniRoute",
environment: "production",
manageDockerMan: true,
generatedCompose: false,
adoptedFromServer: true,
},
{ name: "OmniRoute" },
"",
);
assert.match(refresh, /left the existing DockerMan template unchanged/);
assert.doesNotMatch(refresh, /templates-user/);
});
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");
@@ -577,7 +808,7 @@ test("DockerMan metadata repair refreshes known Unraid icon caches after contain
assert.match(source, /dynamix\.docker\.manager\/images/);
assert.match(source, /-icon\.png/);
assert.match(source, /cp \${shellQuote\(localIconPath\)}/);
assert.match(source, /--force-recreate/);
assert.match(source, /never adds destructive recreation or orphan-removal flags/);
});
test("stuck deployment is cleared as superseded when a different healthy commit is already live", async () => {
@@ -759,3 +990,186 @@ test("a failed deployment is marked superseded when Gitea and Unraid agree on a
assert.equal(operations[0].status, "cancelled");
assert.match(operations[0].error, /Superseded/);
});
test("linked Compose deployments retain the existing project, files and service set", () => {
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {} });
const repository = { name: "OmniRoute", fullName: "Jens/OmniRoute" };
const profile = {
composeProject: "omniroute-production",
composeFiles: ["compose.yml", "compose.unraid.yml"],
composeServices: ["api", "worker"],
generatedCompose: false,
};
const invocation = service.composeInvocation(profile, repository);
assert.match(invocation, /-p 'omniroute-production'/);
assert.ok(invocation.indexOf("-f 'compose.yml'") < invocation.indexOf("-f 'compose.unraid.yml'"));
assert.doesNotMatch(invocation, /compose\.metadata\.yml/);
assert.deepEqual(service.deploymentServices(profile, repository), ["api", "worker"]);
});
test("push bundle activation validates Compose and services before promoting current SHA", () => {
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {} });
const repository = { name: "OmniRoute", fullName: "Jens/OmniRoute" };
const profile = {
environment: "production",
deploymentMode: "push-bundle",
composeProject: "omniroute",
composeFiles: ["compose.yml", "compose.unraid.yml"],
composeServices: ["api", "worker"],
preservePaths: ["data", "config"],
generatedCompose: false,
adoptedFromServer: true,
manageDockerMan: false,
};
const script = service.pushBundleScript({
repository,
profile,
remotePath: "/mnt/user/appdata/OmniRoute",
targetSha: "a".repeat(40),
requestId: "request-1",
remotePart: "/mnt/user/appdata/.forgeflow/incoming/request-1.tar.part",
digest: "b".repeat(64),
metadata: "services:\n api:\n labels: {}\n worker:\n labels: {}\n",
generated: "",
iconReference: "",
});
const configIndex = script.indexOf("config >/dev/null");
const buildIndex = script.indexOf("build");
const upIndex = script.indexOf("up -d --no-build", buildIndex);
const serviceCheckIndex = script.indexOf("Compose service $service did not create a container");
const promoteIndex = script.indexOf('current-sha.pending');
assert.ok(configIndex >= 0 && configIndex < buildIndex);
assert.ok(buildIndex < upIndex);
assert.ok(upIndex < serviceCheckIndex);
assert.ok(serviceCheckIndex < promoteIndex);
assert.match(script, /mmin \+120/);
assert.match(script, /grep -E '\(\^\/\|\(\^\|\/\)\\\.\\\.\(\/\|\$\)\)'/);
assert.match(script, /kill -0 "\$lock_pid"/);
assert.match(script, /is_preserved "\$rel" && continue/);
assert.doesNotMatch(script, /git clone|git -C "\$root" fetch/);
assert.match(script, /ForgeFlow left the existing DockerMan template unchanged/);
});
test("generated push-bundle command passes Bash syntax validation", { skip: process.platform === "win32" }, async () => {
const { spawnSync } = await import("node:child_process");
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {} });
const repository = { name: "Demo", fullName: "Jens/Demo" };
const profile = {
environment: "production",
deploymentMode: "push-bundle",
composeProject: "demo",
composeFiles: ["compose.yml"],
composeServices: ["app"],
preservePaths: ["data"],
generatedCompose: false,
adoptedFromServer: true,
manageDockerMan: false,
};
const generated = service.pushBundleScript({
repository,
profile,
remotePath: "/mnt/user/appdata/Demo",
targetSha: "d".repeat(40),
requestId: "syntax-test",
remotePart: "/mnt/user/appdata/Demo/.forgeflow/incoming/syntax-test.tar.part",
digest: "e".repeat(64),
metadata: "services:\n app:\n labels: {}\n",
generated: "",
iconReference: "",
});
const wrapped = bash(generated);
const encoded = wrapped.match(/printf '%s' '([A-Za-z0-9+/=]+)'/)[1];
const script = Buffer.from(encoded, "base64").toString("utf8");
const result = spawnSync("bash", ["-n"], { input: script, encoding: "utf8" });
assert.equal(result.status, 0, result.stderr || result.stdout);
});
test("push bundle preflight does not require Git or Gitea credentials on Unraid", async (context) => {
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const localPath = await mkdtemp(join(tmpdir(), "forgeflow-push-preflight-"));
context.after(() => rm(localPath, { recursive: true, force: true }));
await writeFile(join(localPath, "compose.yml"), "services:\n app:\n image: example/app:latest\n");
const sha = "c".repeat(40);
const profile = {
id: "production",
name: "Production",
environment: "production",
provider: "ssh-unraid",
branch: "main",
serverId: "unraid",
remoteFolder: "OmniRoute",
deploymentMode: "push-bundle",
composeFile: "compose.yml",
composeFiles: ["compose.yml"],
composeService: "app",
composeServices: ["app"],
iconMode: "none",
generatedCompose: false,
preservePaths: ["data"],
};
let remoteGitProbeCount = 0;
const service = new UnraidDeploymentService({
store: {
getDeploymentProfile: () => profile,
getServer: () => ({
id: "unraid",
name: "Unraid",
host: "192.168.10.150",
port: 22,
username: "root",
basePath: "/mnt/user/appdata",
hostFingerprint: "SHA256:test",
}),
},
git: {
status: async () => ({
root: localPath,
head: sha,
clean: true,
counts: { changed: 0 },
branch: { head: "main", upstream: "origin/main", ahead: 0, behind: 0 },
}),
verifyCommitOnRemoteBranch: async () => true,
},
ssh: {
test: async () => ({
capabilities: {
docker: true,
dockerReady: true,
compose: true,
composeVersion: "Docker Compose version v2",
git: false,
tar: true,
checksum: true,
baseWritable: true,
},
}),
exec: async (_serverId, command) => {
if (String(command).includes("git ls-remote")) remoteGitProbeCount += 1;
const encodedFiles = Buffer.from("").toString("base64");
return {
stdout: `__FORGEFLOW_KV__\nexists=false\nrootGit=false\nhead=\nbranch=\nremote=\ntrackedChanges=\ncomposeFiles=${encodedFiles}\nnestedGit=\ndockerfile=false\ndockerignoreContent=\nexistingPreservePaths=\n`,
};
},
},
sourcePath: new URL("..", import.meta.url).pathname,
});
service.inspectWriteAccess = async () => ({ ready: true, blocking: [], targets: [], identity: { user: "root" } });
const result = await service.preflight({
repository: {
fullName: "Jens/OmniRoute",
name: "OmniRoute",
localPath,
localStatus: { head: sha },
},
profileId: "production",
sha,
});
assert.equal(remoteGitProbeCount, 0);
assert.equal(result.checks.find((item) => item.id === "transfer-path")?.status, "pass");
assert.match(result.checks.find((item) => item.id === "transfer-path")?.detail || "", /No Gitea credential/);
assert.equal(result.checks.some((item) => item.id === "server-git-command"), false);
assert.equal(result.summary.ready, true, JSON.stringify(result.checks.filter((item) => item.status === "fail")));
});
+63 -1
View File
@@ -242,6 +242,11 @@ test("release publisher verifies Gitea and bootstraps the installed updater serv
assert.match(script, /package-lock\.json/);
assert.match(script, /non-reproducible update/);
assert.match(script, /npm run check/);
assert.match(script, /npm run dist:win/);
assert.match(script, /npm run release:binary/);
assert.match(script, /SkipBinaryRelease/);
assert.match(script, /ForgeFlow-Setup-\$version-win-x64\.exe/);
assert.match(script, /ForgeFlow-Portable-\$version-win-x64\.exe/);
assert.match(script, /git ls-remote origin/);
assert.match(script, /publishedCommit -ne \$localCommit/);
assert.match(script, /scripts\\apply-source-update\.ps1/);
@@ -255,6 +260,57 @@ test("release publisher verifies Gitea and bootstraps the installed updater serv
assert.doesNotMatch(script, /Copy-Item[^\n]+package\.json/);
});
test("one-click Windows release wrapper invokes the atomic publisher", async () => {
const script = await readFile(
new URL("../PUBLISH-AND-ENABLE-UPDATE.cmd", import.meta.url),
"utf8",
);
assert.match(script, /ExecutionPolicy Bypass/);
assert.match(script, /Publish-ForgeFlow-Release\.ps1/);
assert.match(script, /older ForgeFlow updater can now install/);
assert.match(script, /exit \/b %forgeflowExitCode%/);
});
test("missing binary release recovery script builds the exact Gitea commit and uploads all assets", async () => {
const script = await readFile(
new URL("../Publish-Missing-Binary-Release.ps1", import.meta.url),
"utf8",
);
assert.match(script.trimStart(), /^param\(/);
assert.match(script, /git clone --branch \$Branch --single-branch/);
assert.match(script, /git -C \$clone ls-remote origin/);
assert.match(script, /npm ci --no-audit --no-fund/);
assert.match(script, /npm run check/);
assert.match(script, /npm run dist:win/);
assert.match(script, /npm run release:binary/);
assert.match(script, /FORGEFLOW_USER_DATA/);
assert.match(script, /ForgeFlow-Setup-\$version-win-x64\.exe/);
assert.match(script, /ForgeFlow-Portable-\$version-win-x64\.exe/);
});
test("binary publisher derives repository coordinates from ForgeFlow settings", async () => {
const script = await readFile(
new URL("../scripts/publish-binary-release.cjs", import.meta.url),
"utf8",
);
assert.match(script, /config\.updates\?\.owner/);
assert.match(script, /config\.updates\?\.repo/);
assert.match(script, /config\.updates\?\.branch/);
assert.match(script, /encodeURIComponent\(owner\)/);
assert.match(script, /encodeURIComponent\(repo\)/);
assert.doesNotMatch(script, /\/repos\/Jens\/ForgeFlow\/releases/);
});
test("packaged updater passes Gitea browser download URLs to the asset downloader", async () => {
const source = await readFile(
new URL("../src/main/update-service.cjs", import.meta.url),
"utf8",
);
assert.match(source, /downloadUrl: asset\.browser_download_url/);
assert.match(source, /downloadUrl: checksumAsset\.browser_download_url/);
assert.match(source, /RELEASE_ASSET_METADATA_RECEIVED/);
});
test("PowerShell helper replaces an existing launching status with a Windows-safe file API", async () => {
const script = await readFile(
new URL("../scripts/apply-source-update.ps1", import.meta.url),
@@ -354,8 +410,14 @@ test("packaged updater downloads only a published checksum-matched Windows asset
],
};
},
async downloadReleaseAsset(_owner, _repo, releaseId, assetId) {
async downloadReleaseAsset(_owner, _repo, releaseId, assetId, options) {
assert.equal(releaseId, 82);
assert.equal(
options.downloadUrl,
assetId === 42
? "http://wrong-origin.test/checksum"
: "http://wrong-origin.test/setup",
);
return assetId === 42 ? Buffer.from(`${sha256} ${assetName}\n`) : binary;
},
};