45 Commits
Author SHA1 Message Date
Jens cc78f4be65 Merge pull request 'Release 0.10.16 as the legacy updater bridge to ForgeFlow-Public' (#14) from codex/public-updater-bridge-20260930 into main
ForgeFlow quality gate / secret-scan (push) Successful in 4s
ForgeFlow quality gate / quality (push) Successful in 9m39s
2026-09-30 22:51:00 +00:00
NuklearRabbit 8769f07539 release: bridge legacy installations to ForgeFlow-Public
Managed validation / full (pull_request) Successful in 27s
ChatGPT validation / quality (push) Successful in 21m36s
2026-09-30 23:02:50 +02:00
Jens 1192d75d15 Merge pull request 'Document legacy downloads in English' (#13) from codex/legacy-readme-20260929 into main
ForgeFlow quality gate / quality (push) Successful in 4m44s
ForgeFlow quality gate / secret-scan (push) Successful in 4s
2026-09-29 23:57:05 +00:00
NuklearRabbit c34d0eb854 Document legacy ForgeFlow downloads in English
ChatGPT validation / quality (push) Successful in 4m38s
Managed validation / full (pull_request) Successful in 2s
2026-09-29 23:45:27 +02:00
Jens 941f2d9aa0 Merge pull request 'ci: route validation to available runners' (#12) from codex/runner-fast-path-20260903 into main
Fix ForgeFlow runner label mismatch. [skip ci]
2026-09-03 18:58:57 +00:00
Jens 57914f1c79 ci: target the hardened native Windows runner
Managed validation / full (pull_request) Successful in 23s
ChatGPT validation / quality (push) Successful in 5m30s
2026-09-03 18:47:56 +00:00
Jens 3dd301a3cb ci: add lightweight validation fast path
ChatGPT validation / quality (push) Successful in 12s
2026-09-03 00:28:43 +00:00
Jens e2293f08d9 Merge pull request 'hygiene: prepare public release' (#11) from chore/public-release-hygiene-20260902 into main
ForgeFlow quality gate / quality (push) Successful in 9m3s
ForgeFlow quality gate / secret-scan (push) Successful in 18s
ChatGPT validation / quality (push) Successful in 5m42s
2026-09-02 21:44:29 +00:00
NuklearRabbit dec3b79793 hygiene: prepare ForgeFlow for public release
Managed validation / full (pull_request) Successful in 27s
2026-09-02 23:37:30 +02:00
Jens 0a8a10df1b Merge pull request 'Prepare ForgeFlow for public release' (#10) from codex/public-readiness-forgeflow into main
ForgeFlow quality gate / secret-scan (push) Successful in 16s
ForgeFlow quality gate / quality (push) Successful in 5m43s
ForgeFlow signed release / release (push) Failing after 15s
2026-08-31 20:17:56 +02:00
NuklearRabbit b5d615d53d Avoid Electron initialization in plain Node tests
Managed validation / full (pull_request) Successful in 27s
ChatGPT validation / quality (push) Successful in 4m57s
2026-08-31 20:15:31 +02:00
NuklearRabbit 8cca1bfc01 Prepare ForgeFlow for public release
Managed validation / full (pull_request) Successful in 44s
ChatGPT validation / quality (push) Failing after 2m28s
2026-08-31 20:10:07 +02:00
NuklearRabbit 57929ea973 Merge remote-tracking branch 'origin/codex/portfolio-integration-fabric-20260826' 2026-08-31 07:52:36 +02:00
NuklearRabbit 5ddb6fe8f5 Merge remote-tracking branch 'origin/chatgpt/repo-hygiene-forgeflow'
# Conflicts:
#	SECURITY.md
2026-08-31 07:52:31 +02:00
NuklearRabbit 20290e65c8 Merge remote-tracking branch 'origin/chatgpt/release-hygiene-20260830' 2026-08-31 07:52:06 +02:00
NuklearRabbit e6e4f2ecf5 Merge branch 'codex/fix-release-0.10.15-metadata' 2026-08-31 07:52:01 +02:00
Jens 6fd69a2cd8 Add contribution and release hygiene guidance
Managed validation / full (pull_request) Successful in 25s
2026-08-31 01:40:57 +02:00
Jens 976a1fc0df Add top-level security entry point 2026-08-31 01:40:49 +02:00
Jens 0bbfbbad51 Harden local secret and workspace ignores 2026-08-31 01:40:41 +02:00
Jens b486285027 docs: add top-level security entry point 2026-08-30 23:17:57 +02:00
Jens 060171d714 docs: align readme with ForgeFlow 0.10.15 2026-08-30 23:17:50 +02:00
Jens b454bafec3 ci: avoid corrupt GitHub action cache on release runner 2026-08-30 23:17:17 +02:00
Jens e62a1d8c1b ci: use Gitea action mirrors on native runner 2026-08-30 23:16:59 +02:00
NuklearRabbit 0191af2ed8 fix(release): align 0.10.15 package metadata 2026-08-30 01:01:20 +02:00
Jens b883c1ad83 Release ForgeFlow 0.10.15 (#7)
ForgeFlow quality gate / quality (push) Failing after 5s
ForgeFlow signed release / release (push) Failing after 6s
ForgeFlow quality gate / secret-scan (push) Successful in 7s
2026-08-30 00:49:45 +02:00
Jens a93231d69f Harden workspace sync quarantine and updater recovery (#6)
ForgeFlow quality gate / secret-scan (push) Successful in 8s
ForgeFlow quality gate / quality (push) Failing after 11m39s
2026-08-30 00:34:02 +02:00
Jens d926007dae Merge pull request 'fix(ci): normalize shell validation input (fixes #3)' (#5) from codex/resolve-open-issues into main
ForgeFlow quality gate / quality (push) Successful in 8m40s
ForgeFlow quality gate / secret-scan (push) Successful in 5s
Reviewed-on: #5
2026-08-29 04:10:28 +02:00
NuklearRabbit 4b4718d231 fix(ci): preserve workflow line endings
Managed validation / full (pull_request) Successful in 36s
2026-08-29 03:36:34 +02:00
NuklearRabbit 0b8deed1e3 fix(ci): use one required pull-request gate
Managed validation / full (pull_request) Canceled after 0s
2026-08-29 03:35:32 +02:00
NuklearRabbit 408dea0c2d fix(ci): normalize shell validation input (fixes #3)
ForgeFlow quality gate / quality (pull_request) Successful in 7m26s
Managed validation / full (pull_request) Canceled after 0s
ForgeFlow quality gate / secret-scan (pull_request) Successful in 6s
2026-08-29 02:30:19 +02:00
Jens 79dc6d367b Fix ForgeFlow Windows quality reliability (#4)
ForgeFlow quality gate / quality (push) Successful in 6m28s
ForgeFlow quality gate / secret-scan (push) Successful in 6s
2026-08-29 01:41:46 +02:00
Jens 49f43b3875 ci: run browser quality on native Windows (#2)
ForgeFlow quality gate / quality (push) Failing after 2m35s
ForgeFlow quality gate / secret-scan (push) Successful in 19s
2026-08-27 20:27:52 +02:00
Jens 736944bd91 ci: align managed validation contract [skip ci] 2026-08-27 07:40:29 +02:00
Jens 42ccfc781c ci: add managed validation contract [skip ci] 2026-08-27 06:27:39 +02:00
Jens 2abfca7abc [skip ci] Stop unschedulable Windows Actions jobs 2026-08-27 05:21:42 +02:00
Jens ff1fcd3303 test(deploy): lock one-shot approved request semantics
ChatGPT validation / quality (push) Failing after 1s
2026-08-26 23:56:35 +02:00
Jens e377889263 fix(deploy): consume signed approval evidence exactly once
ChatGPT validation / quality (push) Failing after 0s
2026-08-26 23:55:49 +02:00
Jens c5cf384f9a test(deploy): prove central workflow carries signed target
ChatGPT validation / quality (push) Failing after 0s
2026-08-26 23:47:20 +02:00
Jens 84ed89bccf fix(deploy): centralize approved workflow dispatch
ChatGPT validation / quality (push) Failing after 0s
2026-08-26 23:46:49 +02:00
Jens 2174b79544 ci: run managed validation on codex change branches
ChatGPT validation / quality (push) Canceled after 0s
2026-08-26 23:26:16 +02:00
Jens 1dc3bea8dd ci: add managed exact-head validation gate 2026-08-26 23:25:51 +02:00
Jens 858b09afeb docs(deploy): include signed approval evidence in status example 2026-08-26 23:23:40 +02:00
Jens b5b6660fdc test(deploy): lock signed AppOps evidence contract 2026-08-26 23:21:44 +02:00
Jens d1f4cb6ba8 feat(deploy): verify AppOps Ed25519 evidence before machine deploy 2026-08-26 23:20:26 +02:00
Jens 181330b78f feat(deploy): add signed AppOps-approved workflow 2026-08-26 23:19:45 +02:00
61 changed files with 1654 additions and 762 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
*.sh text eol=lf
examples/server/forgeflow-deploy text eol=lf
scripts/* text eol=lf
+29
View File
@@ -0,0 +1,29 @@
name: ChatGPT validation
on:
push:
branches:
- 'codex/**'
workflow_dispatch:
jobs:
quality:
runs-on: windows-native
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run quality
- run: npx playwright install --with-deps chromium
- run: npm run test:browser:ci
- name: Preserve browser failure evidence
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: forgeflow-browser-failure-evidence
path: artifacts/
if-no-files-found: ignore
- run: npm audit --omit=dev --audit-level=high
+147
View File
@@ -0,0 +1,147 @@
name: Managed validation
on:
pull_request:
workflow_dispatch:
inputs:
profile:
description: Allowlisted validation profile
required: true
default: full
type: choice
options: [test, lint, typecheck, build, security, full]
permissions:
contents: read
concurrency:
group: managed-validation-${{ gitea.repository }}-${{ gitea.ref }}
cancel-in-progress: true
jobs:
full:
name: full
# Public fork code must never execute automatically on the private runner.
if: ${{ gitea.event_name != 'pull_request' || gitea.event.pull_request.head.repo.full_name == gitea.repository }}
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Validate repository with a bounded profile
shell: bash
env:
REQUESTED_PROFILE: ${{ inputs.profile }}
run: |
set -euo pipefail
profile="${REQUESTED_PROFILE:-full}"
case "${profile}" in
test|lint|typecheck|build|security|full) ;;
*) echo "Profile is not allowlisted" >&2; exit 2 ;;
esac
git diff --check
if git grep -nE '^(<<<<<<< |=======$|>>>>>>> )' -- . ':!*.lock' ':!*.patch'; then
echo "Unresolved merge markers detected" >&2
exit 1
fi
# MANAGED_FAST_PATH: documentation and this baseline workflow cannot
# affect the shipped runtime. Keep the required status check, but do
# not install toolchains or execute the full product suite.
if [[ -n "${GITHUB_BASE_REF:-}" ]]; then
git fetch --no-tags --depth=1 origin "${GITHUB_BASE_REF}"
managed_base="origin/${GITHUB_BASE_REF}"
git diff --check "${managed_base}..HEAD"
mapfile -t managed_changed_files < <(
git diff --name-only --diff-filter=ACMR "${managed_base}..HEAD"
)
managed_runtime_change=0
for managed_path in "${managed_changed_files[@]}"; do
case "${managed_path}" in
*.md|*.mdx|docs/*|.github/ISSUE_TEMPLATE/*|.gitea/ISSUE_TEMPLATE/*|.gitea/runner-scope.sh|.gitea/workflows/managed-validation.yml)
;;
*)
managed_runtime_change=1
break
;;
esac
done
if [[ "${#managed_changed_files[@]}" -gt 0 && "${managed_runtime_change}" -eq 0 ]]; then
printf 'Managed validation fast path: %s non-runtime file(s); full product suite skipped.\n' \
"${#managed_changed_files[@]}"
exit 0
fi
fi
if [[ -f pyproject.toml || -f requirements.txt ]]; then
# Compile only tracked Python sources. Running compileall after a
# Node install would otherwise traverse node_modules and turn a
# lightweight baseline into a large runner workload.
git ls-files -z '*.py' | xargs -0 -r python -m py_compile
if [[ -f uv.lock ]]; then
python -m venv "${RUNNER_TEMP}/managed-uv"
uv_python="${RUNNER_TEMP}/managed-uv/bin/python"
"${uv_python}" -m pip install --disable-pip-version-check uv==0.10.0
managed_uv="${RUNNER_TEMP}/managed-uv/bin/uv"
export UV_PROJECT_ENVIRONMENT="${RUNNER_TEMP}/managed-project-venv"
"${managed_uv}" sync --locked
export PATH="${UV_PROJECT_ENVIRONMENT}/bin:${PATH}"
if [[ "${profile}" == test || "${profile}" == full ]]; then
if "${managed_uv}" run python -c 'import pytest' 2>/dev/null; then
"${managed_uv}" run python -m pytest
fi
fi
if [[ "${profile}" == lint || "${profile}" == full ]]; then
if "${managed_uv}" run python -c 'import ruff' 2>/dev/null; then
"${managed_uv}" run python -m ruff check .
fi
fi
elif [[ -f requirements.txt ]]; then
python -m venv "${RUNNER_TEMP}/managed-python"
managed_python="${RUNNER_TEMP}/managed-python/bin/python"
"${managed_python}" -m pip install --disable-pip-version-check -r requirements.txt
export PATH="${RUNNER_TEMP}/managed-python/bin:${PATH}"
if [[ "${profile}" == test || "${profile}" == full ]]; then
if "${managed_python}" -c 'import pytest' 2>/dev/null; then
"${managed_python}" -m pytest
fi
fi
fi
fi
# Prepare Python before invoking Node scripts. Polyglot repositories
# commonly delegate their test script to Python and need the managed
# virtual environment to be active first.
if [[ -f package.json ]]; then
corepack enable
if [[ -f pnpm-lock.yaml ]]; then
pnpm install --frozen-lockfile
[[ "${profile}" == test || "${profile}" == full ]] && pnpm --if-present test
[[ "${profile}" == lint || "${profile}" == full ]] && pnpm --if-present lint
[[ "${profile}" == typecheck || "${profile}" == full ]] && pnpm --if-present typecheck
[[ "${profile}" == build || "${profile}" == full ]] && pnpm --if-present build
elif [[ -f package-lock.json ]]; then
npm ci
[[ "${profile}" == test || "${profile}" == full ]] && npm run --if-present test
[[ "${profile}" == lint || "${profile}" == full ]] && npm run --if-present lint
if [[ "${profile}" == typecheck || "${profile}" == full ]]; then
npm run --if-present typecheck
fi
[[ "${profile}" == build || "${profile}" == full ]] && npm run --if-present build
fi
fi
if [[ -f go.mod ]]; then
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
go test ./...
fi
fi
if [[ -f Cargo.toml ]]; then
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
cargo test --locked
fi
fi
if compgen -G '*.sln' >/dev/null; then
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
dotnet test --configuration Release
fi
fi
+19 -11
View File
@@ -3,34 +3,42 @@ name: ForgeFlow quality gate
on: on:
push: push:
branches: [main] branches: [main]
pull_request: workflow_dispatch:
jobs: jobs:
secret-scan: secret-scan:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: https://gitea.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Secret scan - name: Secret scan
uses: trufflesecurity/trufflehog@v3.79.0 shell: bash
with: run: |
path: ./ set -euo pipefail
extra_args: --only-verified scan_container="$(docker create ghcr.io/trufflesecurity/trufflehog:3.79.0 filesystem /scan --only-verified --fail --no-update)"
trap 'docker rm -f "${scan_container}" >/dev/null 2>&1 || true' EXIT
tar --exclude=.git --transform='s#^\.$#scan#;s#^\./#scan/#' -cf - . | docker cp - "${scan_container}:/"
docker start -a "${scan_container}"
quality: quality:
runs-on: windows-latest # Browser quality runs against the dedicated bounded Windows 11 VM runner.
runs-on: windows-native
steps: steps:
- uses: actions/checkout@v4 - uses: https://gitea.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@v4 - uses: https://gitea.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version: 22 node-version: 22
cache: npm cache: npm
- run: npm ci - run: npm ci
# The native runner deliberately skips Electron's install-time binary
# download. Prime it once before Node's parallel test workers require
# Electron, otherwise they can race while creating the same directory.
- run: npx electron --version
- run: npm run quality - run: npm run quality
- run: npx playwright install --with-deps chromium - run: npx playwright install chromium
- run: npm run test:browser:ci - run: npm run test:browser:ci
- name: Preserve browser failure evidence - name: Preserve browser failure evidence
if: failure() if: failure()
uses: actions/upload-artifact@v4 uses: https://gitea.com/actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
name: forgeflow-browser-failure-evidence name: forgeflow-browser-failure-evidence
path: artifacts/ path: artifacts/
+104
View File
@@ -0,0 +1,104 @@
name: ForgeFlow signed release
on:
workflow_dispatch:
permissions:
code: read
releases: write
jobs:
release:
runs-on: windows-native
steps:
- uses: https://gitea.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 2
- uses: https://gitea.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
cache: npm
- name: Validate version bump and build release artifacts
shell: powershell
env:
GITEA_EVENT_NAME: ${{ gitea.event_name }}
run: |
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$manifest = Get-Content -LiteralPath "package.json" -Raw | ConvertFrom-Json
$version = [string]$manifest.version
$previousVersion = ""
try {
$previousJson = (& git show "HEAD^:package.json" 2>$null | Out-String)
if ($LASTEXITCODE -eq 0 -and $previousJson.Trim()) {
$previousVersion = [string](ConvertFrom-Json $previousJson).version
}
} catch {
$previousVersion = ""
}
if ($env:GITEA_EVENT_NAME -eq "push" -and $previousVersion -eq $version) {
Write-Host "package.json changed without a version bump ($version); no release will be published."
exit 0
}
if ($version -notmatch '^\d+\.\d+\.\d+$') {
throw "ForgeFlow version '$version' is not a stable semantic version."
}
& cmd.exe /d /s /c "npm ci --no-audit --no-fund"
if ($LASTEXITCODE -ne 0) { throw "npm ci failed." }
& cmd.exe /d /s /c "npx electron --version"
if ($LASTEXITCODE -ne 0) { throw "Electron preflight failed." }
& cmd.exe /d /s /c "npm run quality"
if ($LASTEXITCODE -ne 0) { throw "ForgeFlow release quality gate failed." }
& cmd.exe /d /s /c "npx playwright install chromium"
if ($LASTEXITCODE -ne 0) { throw "Playwright Chromium installation failed." }
& cmd.exe /d /s /c "npm run test:browser:ci"
if ($LASTEXITCODE -ne 0) { throw "ForgeFlow browser acceptance suite failed." }
& cmd.exe /d /s /c "npm audit --omit=dev --audit-level=high"
if ($LASTEXITCODE -ne 0) { throw "ForgeFlow production dependency audit failed." }
& cmd.exe /d /s /c "npx electron-builder --win nsis portable"
if ($LASTEXITCODE -ne 0) { throw "ForgeFlow Windows build failed." }
& node scripts/write-release-checksums.mjs
if ($LASTEXITCODE -ne 0) { throw "ForgeFlow checksum generation failed." }
- name: Sign and publish validated artifacts
shell: powershell
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
FORGEFLOW_RELEASE_BASE_URL: ${{ gitea.server_url }}
FORGEFLOW_RELEASE_OWNER: Jens
FORGEFLOW_RELEASE_REPO: ForgeFlow
FORGEFLOW_RELEASE_BRANCH: main
FORGEFLOW_RELEASE_SIGNING_KEY_PEM: ${{ secrets.FORGEFLOW_RELEASE_SIGNING_KEY_PEM }}
run: |
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$privateKeyPath = Join-Path $env:RUNNER_TEMP "forgeflow-release-signing-private.pem"
try {
if (-not $env:FORGEFLOW_RELEASE_SIGNING_KEY_PEM) {
throw "FORGEFLOW_RELEASE_SIGNING_KEY_PEM is not configured."
}
if (-not $env:GITEA_TOKEN) {
throw "GITEA_TOKEN is not configured."
}
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($privateKeyPath, $env:FORGEFLOW_RELEASE_SIGNING_KEY_PEM, $utf8NoBom)
$env:FORGEFLOW_UPDATE_SIGNING_PRIVATE_KEY = $privateKeyPath
& node scripts/sign-release-manifest.mjs
if ($LASTEXITCODE -ne 0) { throw "ForgeFlow manifest signing failed." }
& node scripts/verify-release-signatures.mjs
if ($LASTEXITCODE -ne 0) { throw "ForgeFlow release signature verification failed." }
& node scripts/prune-dist.mjs
if ($LASTEXITCODE -ne 0) { throw "ForgeFlow artifact pruning failed." }
& .\node_modules\.bin\electron.cmd scripts/publish-binary-release.cjs
if ($LASTEXITCODE -ne 0) { throw "ForgeFlow Gitea release publication failed." }
} finally {
$env:FORGEFLOW_UPDATE_SIGNING_PRIVATE_KEY = $null
if (Test-Path -LiteralPath $privateKeyPath) {
Remove-Item -LiteralPath $privateKeyPath -Force
}
}
Write-Host "ForgeFlow artifacts were signed and published from exact main HEAD $env:GITEA_SHA."
+15
View File
@@ -1,9 +1,24 @@
node_modules/ node_modules/
dist/ dist/
.DS_Store .DS_Store
Thumbs.db
*.log *.log
coverage/ coverage/
artifacts/ artifacts/
playwright-report/ playwright-report/
.forgeflow/ .forgeflow/
.playwright-mcp/ .playwright-mcp/
.env
.env.*
!.env.example
*.pfx
*.p12
*.key
*.pem
!build/update-signing-public.pem
.codex/
.claude/
.agents/
.dyad/
.idea/
.vs/
+13
View File
@@ -0,0 +1,13 @@
# Contributing
ForgeFlow changes must preserve exact-commit provenance, update integrity and safe deployment boundaries.
Before opening a pull request:
- do not commit tokens, SSH credentials, signing private keys, deployment secrets or local repository state;
- keep update manifests/checksums/signatures deterministic and reviewable;
- add regression tests for repository synchronization, dirty-file handling, update and deployment changes;
- keep real deployment targets configurable rather than embedding private infrastructure;
- run `npm run quality` and the managed validation workflow where supported.
Release metadata should distinguish an unreleased package version from the latest published Gitea Release; do not advance public release claims until the corresponding release exists.
+55 -155
View File
@@ -1,186 +1,86 @@
# ForgeFlow # ForgeFlow
**Van lokale wijziging naar aantoonbaar juiste serverversie — zonder de Git- en deploymentcontext over verschillende tools te verspreiden.** <p align="center"><strong>From a local change to a verified server revision.</strong></p>
<p align="center"><a href="#see-the-workflow">Product tour</a> · <a href="#key-capabilities">Features</a> · <a href="#get-started">Get started</a> · <a href="#deployment-model">Deployment model</a></p>
ForgeFlow is een Windows-desktopapp voor wie Git, Gitea en eigen Docker- of Unraid-servers gebruikt. Je ziet in één werkruimte wat lokaal gewijzigd is, wat op Gitea staat en welke exacte commit op de server draait. ForgeFlow begeleidt je daarna veilig door review, commit, push, deployment en verificatie. ForgeFlow is a Windows desktop application for teams that use Git, Gitea and self-hosted Docker or Unraid servers. It brings local changes, remote commits and the exact revision running on a server into one workspace, then guides review, commit, push, deployment and verification.
> Huidige release: **0.10.14** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest) > **Legacy update compatibility:** This repository remains available for existing installations. Version 0.10.16 is the bridge release that moves the default updater to `Jens/ForgeFlow-Public`.
![ForgeFlow release-overzicht](docs/screenshots/overview.png) **Downloads:** Use [ForgeFlow-Public releases](https://gitea.itworx.tech/Jens/ForgeFlow-Public/releases/latest) for new installations. Older installations can receive the bridge from [this legacy release page](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest).
## Wat ForgeFlow voor je oplost ## See the workflow
- **Eén duidelijke actielijst:** zie meteen welke repository aandacht nodig heeft en waarom. ![ForgeFlow release overview using demo repositories](docs/screenshots/overview.png)
- **Veilige Git-flow:** review wijzigingen, stage volledige bestanden of afzonderlijke hunks, commit, push en herstel conflicten zonder contextwissel.
- **Veilige Gitea-sync:** bekijk vooraf welke bestanden wijzigen of verdwijnen, bewaar lokale commits in een recovery branch en zet gewijzigde of untracked bestanden in een stash voordat de werkmap exact gelijk wordt gemaakt aan Gitea.
- **Deployment op een exacte commit:** ForgeFlow gebruikt volledige commit-SHA's en toont lokaal, Gitea en server naast elkaar.
- **Automatische serverinventaris:** ForgeFlow herkent draaiende en gestopte Docker-, Compose- en DockerMan-workloads, koppelt alleen op betrouwbaar bewijs en houdt tijdelijke of externe containers apart.
- **Veilige server-pull:** Unraid haalt de exacte commit uit Gitea met een unieke, repository-scoped read-only deploy key en een vastgepinde SSH-hostsleutel.
- **Ingebouwde Git Validator:** controleer repository-identiteit, branch protection, synchronisatie-instellingen, documentatie, geheimen en grote bestanden; veilige verbeteringen kunnen gericht worden toegepast.
- **Doorzoekbaar Helpcentrum:** open **Help** voor stapsgewijze uitleg of spring vanuit workspace sync meteen naar de relevante veiligheidsinstructies.
- **Lokale controle:** configuratie en credentials blijven op het toestel en diagnostische exports worden lokaal geredigeerd.
## Snel starten | Review local work | Reconcile deployments |
| --- | --- |
| ![Repository workspace and selective staging](docs/screenshots/repository-workspace.png) | ![Server inventory and deployment links](docs/screenshots/deployments.png) |
### Aanbevolen: de Windows-app installeren *The captures use example repositories and demo state; they are not a live infrastructure dashboard.*
1. Open de [laatste ForgeFlow-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest). ## Key capabilities
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. Voeg optioneel een Docker- of Unraid-server toe. Start daarna **Scan servers** om bestaande deployments te ontdekken en veilig aan repositories te koppelen.
Vanuit **Settings → Updates** kun je nieuwe packaged releases ophalen. ForgeFlow accepteert uitsluitend de release die bij de exacte Gitea-commit hoort, controleert de SHA-256-checksum én verifieert vanaf 0.10.13 een onafhankelijk Ed25519-releasemanifest met de ingebouwde publieke sleutel. Zie [UPDATING.md](docs/UPDATING.md) voor oudere of source-only installaties. | Area | What ForgeFlow helps you do |
| --- | --- |
| Action queue | See which repositories need review, a push, deployment or health attention. |
| Git review | Inspect diffs, stage files or hunks, commit, push and recover from common sync problems. |
| Gitea awareness | Compare local and remote revisions, review branch protection and open a pull request. |
| Server inventory | Discover Docker, Compose and DockerMan workloads and link them only when repository evidence matches. |
| Exact-commit deployment | Dispatch a reviewed revision and compare the full local, Gitea and live commit SHAs. |
| Git Validator | Inspect repository identity, protection, documentation, secret hygiene and oversized files. |
| Diagnostics | Keep configuration local and redact sensitive values in support exports. |
### Eerst vrijblijvend bekijken ForgeFlow distinguishes a matching commit from a healthy deployment. It keeps incomplete evidence visible instead of claiming that a server is current when its live SHA is unknown.
De interactieve demomodus gebruikt uitsluitend representatieve voorbeelddata en maakt geen verbinding met Git, Gitea of een server: ## Get started
### Install the Windows app
1. Download an installer or portable executable from the [published release page](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest).
2. Launch ForgeFlow and complete the setup wizard.
3. Add your Gitea URL, a token with the required repository permissions, and the local folders to scan.
4. Optionally add a Docker or Unraid host, then use **Scan servers** to review detected workloads.
The packaged updater checks a release against the exact remote commit, its SHA-256 checksum and the embedded Ed25519 publisher key. Existing installations continue to use the legacy release endpoint until the migration is qualified.
### Explore with example data
Requirements: Node.js 22, npm and Git.
```powershell ```powershell
npm install npm ci
npm run demo npm run demo
``` ```
Open daarna `http://127.0.0.1:41737`. Open <http://127.0.0.1:41737>. The browser demo uses example repositories and server state; it does not mutate your Git checkouts or deploy a workload. To run the full desktop application from source, use `npm start` after `npm ci`.
## De dagelijkse workflow ## Deployment model
### 1. Begin bij wat aandacht vraagt ```mermaid
flowchart LR
Desktop[ForgeFlow desktop] --> Git[Local Git worktree]
Desktop --> Gitea[Gitea repository and Actions]
Gitea --> Runner[Approved deployment workflow]
Runner --> Server[Docker or Unraid host]
Server --> Evidence[Live revision and health evidence]
Evidence --> Desktop
```
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. For server-pull deployments, the host fetches the **exact approved commit** with a repository-scoped read-only deploy key and a pinned SSH host key. ForgeFlow validates Compose configuration and checks post-deployment health. A direct copy remains an explicit fallback, never an implicit replacement for the verified pull path.
### 2. Review en publiceer code Start with the [setup guide](docs/SETUP_GUIDE.md), [deployment setup](docs/DEPLOYMENT_SETUP.md), [SSH and Unraid deployment](docs/SSH_UNRAID_DEPLOYMENT.md), and [migration example](docs/DEPLOYMENT_MIGRATION_EXAMPLE.md). Keep runtime data and credentials outside Git. The [diagnostics guide](docs/DIAGNOSTICS.md) explains safe support bundles.
![Repositorywerkruimte met diff en volgende actie](docs/screenshots/repository-workspace.png) ## Develop and verify
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.
### 3. Deploy en verifieer de live versie
![Deploymentsoverzicht met herkenbare containerkaarten](docs/screenshots/deployments.png)
Elke deploymentkaart benoemt repository, container, omgeving, uitvoeringsmethode, live commit, Gitea-commit, vorige versie en healthstatus. Zo blijven ook tientallen containers visueel van elkaar te onderscheiden. ForgeFlow ondersteunt gecontroleerde deployments via Gitea Actions en SSH/Unraid, met preflightcontrole en rollback waar beschikbaar.
Bij server discovery vergelijkt ForgeFlow runtime-, Compose-, DockerMan- en Git-bewijs met Gitea. Exact bewezen matches worden automatisch gekoppeld; kandidaten, historische mappen en externe containers worden niet als productie-deployment geforceerd. Een exacte overeenkomst tussen de volledige live SHA en de actuele Gitea-SHA wordt als gelijklopende versie weergegeven. Ontbreekt de live SHA, dan meldt ForgeFlow eerlijk dat verificatie nog onvolledig is.
De belangrijkste statussen zijn:
| Status | Wat je ermee doet |
| --- | --- |
| **Ready** | De repository, servertoegang, live commit en runtime zijn geverifieerd. |
| **Commit mismatch** | De workload is correct gekoppeld, maar Gitea en de server draaien niet dezelfde commit. |
| **Verification incomplete** | De koppeling bestaat, maar de server bevat nog onvoldoende commitbewijs. Een ForgeFlow-beheerde deployment vult dit veilig aan. |
| **Access failed** | Controleer of herstel de repositorygebonden read-only deploy key voordat je deployt. |
### 4. Verbeter de repository met Git Validator
![Git Validator met assurance score en veilige fixes](docs/screenshots/git-validator.png)
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.
## Wanneer is een release werkelijk in orde?
ForgeFlow houdt drie soorten waarheid bewust apart:
| 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. |
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.
## Belangrijkste functies
### Git en Gitea
- repositories ontdekken, favorieten beheren en ontbrekende lokale clones koppelen;
- status, diff, staging, partial hunks, commit, push, fetch, pull, stash en conflict recovery;
- read-only achtergrondfetch en een expliciete preview om een lokale projectmap veilig exact met de upstream Gitea-branch te synchroniseren;
- branches maken, wisselen, vergelijken en opruimen;
- branch protection controleren en pull requests openen;
- Git Validator met assurance score, bewijs per controle en gerichte veilige fixes.
### Deployments
- 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, expliciete review voor echte twijfelgevallen en herkenning van tijdelijke, historische en externe workloads;
- server-pull als aanbevolen route, met een afzonderlijke read-only deploy key per repository;
- directe checksum-gecontroleerde copy als alternatief zonder servertoegang tot Gitea;
- reconciliatie van deployments die buiten ForgeFlow werden bijgewerkt, op basis van de actuele Gitea- en serverwaarheid;
- verificatie op volledige SHA, runtime health en recente serverwaarheid;
- preflight, live logs, deploymenthistoriek en rollback naar de vorige bekende versie.
### Veiligheid en beheer
- 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 plus een vastgepinde Ed25519-publisherhandtekening 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.
Meer achtergrond staat in [SECURITY.md](docs/SECURITY.md) en [ARCHITECTURE.md](docs/ARCHITECTURE.md).
## Eerste configuratie
Voor normaal gebruik heb je nodig:
- 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.
Voor serverdetectie en SSH-deployments heb je daarnaast een bereikbare Docker- of Unraid-host en een werkende SSH-configuratie nodig. Begin bij:
- [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.
## Ontwikkelen vanuit de broncode
Vereisten: Node.js 22, npm en Git.
```powershell ```powershell
npm ci npm ci
npm run check npm run check
npm start npm run acceptance
``` ```
Handige opdrachten: `npm run check` performs source verification, linting and tests. Browser acceptance and Windows packaging are separate release gates. `src/main/` contains desktop services and IPC, `src/renderer/` contains the UI, `src/shared/` holds shared policies, `scripts/` contains validation and release tooling, and `tests/` covers core behavior.
| Opdracht | Doel | This repository remains the download and update endpoint for existing Windows installations during the migration. The curated, content-only source is published at [ForgeFlow-Public](https://gitea.itworx.tech/Jens/ForgeFlow-Public). New binary publication there remains manual until signing and updater compatibility are verified.
| --- | --- |
| `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 signing:setup` | Maakt eenmalig de lokale Ed25519-releasesleutel en schrijft alleen de publieke sleutel naar het project. |
| `npm run dist:win` | Bouwt Windows installer + portable package, schrijft checksums en een ondertekend releasemanifest en ruimt oude dist-artifacts op. |
| `.\Publish-ForgeFlow-Release.ps1` | Publiceert broncode én de bijbehorende Windows-release-assets als één gecontroleerde release. |
| `.\Publish-Missing-Binary-Release.ps1` | Herstelt een reeds gepushte versie waarvoor de Gitea binary release ontbreekt. |
De belangrijkste onderdelen zijn: ForgeFlow is available under the [MIT License](LICENSE). Report security issues through [SECURITY.md](SECURITY.md).
```text
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
```
Aanvullende kwaliteitsdocumentatie:
- [TEST_MATRIX.md](docs/TEST_MATRIX.md)
- [ACCEPTANCE.md](docs/ACCEPTANCE.md)
- [STATUS_ENDPOINT.md](docs/STATUS_ENDPOINT.md)
- [ROADMAP.md](docs/ROADMAP.md)
## Licentie
ForgeFlow is beschikbaar onder de [MIT-licentie](LICENSE).
+11
View File
@@ -0,0 +1,11 @@
# Security Policy
ForgeFlow's detailed security model is documented in [`docs/SECURITY.md`](docs/SECURITY.md).
Report suspected vulnerabilities privately to `security@itworx.tech`. Do not publish Gitea tokens, SSH credentials, update-signing material, private server addresses, support bundles containing sensitive data or other operational secrets in a public issue.
For a useful report, include the affected ForgeFlow version/commit, component, minimal reproduction steps, expected and observed behaviour and security impact. Use sanitized or synthetic repository/server data whenever possible.
The current release model requires exact-commit verification, origin-constrained credential use, signed update manifests, redacted diagnostics and bounded deployment adapters. Changes must not silently weaken those guarantees.
Never commit Gitea tokens, SSH private keys, release-signing private keys, deployment credentials or local repository state. The packaged signing public key is intentionally public; private signing material must remain outside Git.
+216 -208
View File
@@ -1,15 +1,14 @@
ForgeFlow 0.10.14 source manifest ForgeFlow 0.10.16 source manifest
SHA-256 BYTES PATH SHA-256 BYTES PATH
(The manifest excludes itself, dependencies and generated release artifacts.) (The manifest includes tracked and non-ignored source files, excluding itself.)
61f37822ae5502219a38b2eaf23fdcb611875f0e675efb4abe6157c9f072c0cc 937 .gitea/workflows/quality.yml ec40b1ed8e5152ca4175bbe97be43f0e2e911894112dc6a47c2c348069f79abf 87 .gitattributes
4a9e8a955ad8c9fa7ba3f8f89cf9920ac1d28c6e5b344782e12d02c3b0fab1ee 105 .gitignore 9ae6d151643de45b36312f1345960cc03137b5e79a9025e9c7b9acf4ac08a2ae 886 .gitea/workflows/chatgpt-validation.yml
f14b4987904bcb5814e4459a057ed4d20f58a633152288a761214dcd28780b56 3 .nvmrc ee3b2cbd2a09a95f3ebd7dfa6af3ac401b13b2ba9496f83cd9f12e15fb975c89 6740 .gitea/workflows/managed-validation.yml
d0b1bd421359311871224f9fa1cff5a802000933668017d9e42e5190f8d2d8e5 152 .playwright-mcp/page-2026-07-29T17-41-03-014Z.yml d80f0fe159d2a1604002db4272d1d49cc3ec2097100b3a3bbb3741a855fb1a11 1900 .gitea/workflows/quality.yml
528fe408ad4b49c621dd57dd831cecf7ec00b8865069f6ed3b80fefc2e0b7823 8267 .playwright-mcp/page-2026-07-29T17-41-26-269Z.yml 72166e4caa3d3d09ce52115ac2098975a6c97a95d3f5da2305a1f8b10ae4cd02 4892 .gitea/workflows/release.yml
804c6dac953c5094671784919ff35ebda756306a04ef34fe01cd7cf7cd50b2dc 16909 .playwright-mcp/page-2026-07-29T17-41-46-590Z.yml 83fac3efff45f3dc926080b280ae190b6bb40eb8dae7b8cb5d27255759bc9c47 267 .gitignore
0f17fdea98e15ebcf7f3ed356d31b0fc89be62f7bc1d25b26c8a41e4b5deca68 160 .playwright-mcp/page-2026-07-29T17-47-10-112Z.yml 12d3a4efa6646b3ece4782f70033b9785bf0d167b553c43e22579b031cea5c4d 4 .nvmrc
5f348bcf74baa845958884bd2258e1ce4121d386c945777b0e80912602e5b5f6 17227 .playwright-mcp/page-2026-07-29T17-47-19-366Z.yml 89cfdec9f5e47fa1c4ce3f883462ffbd5a95938f1e228535782b2561142afdeb 1754 build-windows.ps1
89545860bd6f7566da81edc8328cd2a1ebf33e81a4b0dcf2cec74338c05e8cac 1753 build-windows.ps1
0970821475a4452aa19e447e9397a95db836791f16890a1a83fd748ac033dc86 8830 build/icon-128.png 0970821475a4452aa19e447e9397a95db836791f16890a1a83fd748ac033dc86 8830 build/icon-128.png
09112c1425ca953d8dd8b2bcfd221e5a84b9f81752f7168f360e295030cbc8f2 521 build/icon-16.png 09112c1425ca953d8dd8b2bcfd221e5a84b9f81752f7168f360e295030cbc8f2 521 build/icon-16.png
510aa27935a63ad16cc22978ccfde3bdd441cb970ad42d9f05af52c0e5999195 28923 build/icon-256.png 510aa27935a63ad16cc22978ccfde3bdd441cb970ad42d9f05af52c0e5999195 28923 build/icon-256.png
@@ -19,71 +18,74 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263
4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183 3364 build/icon-64.png 4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183 3364 build/icon-64.png
25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico 25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png
164c059453a5737110b4e5e98b6211650c757f0aff710f8f7523ffe0ff1815d7 113 build/update-signing-public.pem 5cc93571f6c5648cad5116680ae5218f486407c5026ee37cb0ab2fe93a16a0b4 116 build/update-signing-public.pem
5f4aca19a35cbcaffa1a6993ce96b7d66052ec2b286022f2af74594e8a310568 15712 CHANGELOG.md 343ea8dc00a3257801ecc199518a65d4d4adb2644c3acf7b614eb66032ab2afb 15969 CHANGELOG.md
c612fcc44ff222db0c9a4cfd11a4076fafe080e4ada31e689a08739a4f14e74f 1650 docs/ACCEPTANCE.md 3754dcaa776ead5dc60b4955ed4294fd8580577ecb9ce29cb0d9fbbaf9253313 811 CONTRIBUTING.md
a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md 33954dcf8b898885c94fa486a1753a1298dfe6846fe4c5c6dff91d22f64ffe60 1684 docs/ACCEPTANCE.md
e05458ee2696e3c57e2475bb42ae1f914f6a36e01768d7a26a3199f1fffed490 1157 docs/COVERAGE_POLICY.md b89ffb60db36a9cca0ed9ca70edd7e8e5d427d51846b93e8383ce6f406309a9d 8514 docs/ARCHITECTURE.md
9eb9eec82518c0bfc7686f5faaf690a93ed63c71d35f1dc5a2c5ec5199da652a 3124 docs/CURRENT_STATE.md 6450bf6b1b5028a6dd9f928f6cbb5a84281a68c24361e2a4216679993e7fb149 1175 docs/COVERAGE_POLICY.md
8ea655d1912ac2e17f8834e33a566a8b14461b396ec4268c396ca189a1749b94 2205 docs/DEPENDENCY_AUDIT.md af6d5af037687a09a43f14cc19ffe75f959a110765fbddc1ab2803833f735764 3178 docs/CURRENT_STATE.md
30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md 1923c673d5cf8b5aa5fbd500eaef648f3b3eaf47527d34e3d0bb34474da8822e 2251 docs/DEPENDENCY_AUDIT.md
eb42f979666e05d51c587e4223282914926a2b9b1ade9f3fb75525019ce7f738 4616 docs/DIAGNOSTICS.md 4d4b5539024c3805a087f92c85ab48e9aaab366aa68d3e4aa558139a019dc4ba 2851 docs/DEPLOYMENT_MIGRATION_EXAMPLE.md
1d8aa3c688a9d330b9a5303b09e9e3049c50bf04f97807c7ff659607baa34c32 1464 docs/ERROR_CODES.md e9163fbd6a0532f483e32b4009cedd92f84946e4b949107f8c21cc7c2e4f86d3 2103 docs/DEPLOYMENT_SETUP.md
a0cd06a96f23a94e118feb012be0fa1ac51345951cb2ba8e67fb8c889c4c342a 5007 docs/LUMAOPS_SERVER_AUDIT.md 8a1be38b097c1cbfaaaee150c64b99f44bbe007817200d675a2b583d8f2198e5 4766 docs/DIAGNOSTICS.md
648dd6bca6b45668fb86eb3e1f6c5898dd8da0291b990f1bb5105cfd79421343 1301 docs/MUTATION_MODEL.md ff0d8aea8eff6b211493c99fbb8ee7d67bb70eb1d0b4312f3961f651a17c5683 1479 docs/ERROR_CODES.md
8dc95f69e6f6c8415702c8e79fb6b466c4d60061afded9140450e7e558eaefe5 3704 docs/PRODUCTION_READINESS_1.0.md 303d2558da306550e50f2704bfae0fb23e8abee428db436097d9838a9c6956df 1327 docs/MUTATION_MODEL.md
52fd7c73bcf82ae27ffe12751590d0b05e5fefbafe4a4939e225a6b9258cf0a1 3782 docs/PRODUCTION_READINESS_1.0.md
f79908fb3dad98c38030c6e6be7c79a1999e0478ed9c2496923891954438daa1 4581 docs/RELEASE_AUDIT_0.6.0.md f79908fb3dad98c38030c6e6be7c79a1999e0478ed9c2496923891954438daa1 4581 docs/RELEASE_AUDIT_0.6.0.md
979a0b8e129979be6b265e8571d0a3c1e9ddd4ddb6b0bf55ae748d3478e51854 2296 docs/RELEASE_NOTES_0.10.0.md 2b07fd178fe6dd7c2233876c4aa7de5d2371635755e7ac1f2acbbd5b298d491f 2328 docs/RELEASE_NOTES_0.10.0.md
0eb44bda2209a5979a6ac693ac4cd4d235c0015031e54b9e895990f37bf60054 1433 docs/RELEASE_NOTES_0.10.1.md 48da50bb0bd0628187ca2f879f1e58d54978f2a7e30cf0c71df9b55a240d7ba6 1454 docs/RELEASE_NOTES_0.10.1.md
5d3240169765e3fb1d3cd391d09547101227e76dd4670ee46be8ca3a21553a03 894 docs/RELEASE_NOTES_0.10.10.md b0202cf6615c149bd79ec8033e99966b54603df3ffad966d91ad0304371b8f97 904 docs/RELEASE_NOTES_0.10.10.md
36edb4f096a248fb8679bd13e5766befb478cf628c6ccfb21e3eda71bbec7633 992 docs/RELEASE_NOTES_0.10.11.md 4724c6f2ca9a8d5126552fa1577c4c53d3c6f14946dc29981408e2543f3a76b5 1002 docs/RELEASE_NOTES_0.10.11.md
a355d3f577c2ec85dde5dfd7b6995f4f1615e2f6bac597529f3ff102acd93c35 1292 docs/RELEASE_NOTES_0.10.12.md 876c6794ed47ac83d7042b681946c80ba66ac5cc3e24f5ac53f15c4f194850e8 1304 docs/RELEASE_NOTES_0.10.12.md
609c55a1c0b06c307ebe16f2daaf1e48601edd57137586e4f2be1febd6a7060a 1931 docs/RELEASE_NOTES_0.10.13.md 7ad75d0a052d9b99b91e78f9b8d3bfe55e3df15e7182d43bd7cfe405a8cfb5fd 1949 docs/RELEASE_NOTES_0.10.13.md
09eaf3f7671fbc5c25d5ca444415616ccf6dd8e6351855291d392b0aeac20bce 1556 docs/RELEASE_NOTES_0.10.14.md 1d5832048dd834a773ee8f34e6599c590373358132203b022b521dd5dde2f179 1571 docs/RELEASE_NOTES_0.10.14.md
8d713471a437a8a55b00d7e1dd95290680862107bc4e586cf27d727f6274e46c 577 docs/RELEASE_NOTES_0.10.2.md 055ad0c73f0854a708eedc3bc4e9dfb991b4e6021348c05484da9a47022e995b 1871 docs/RELEASE_NOTES_0.10.15.md
0942fb2c4a4f972296423b5232687f7389e2c6417a9d48a3244beef9dec907b9 1164 docs/RELEASE_NOTES_0.10.3.md 36d0686bf5760b0d8445cbd1abfc76de71c98d037a1b05d3cbdd4a3e7810bb27 1062 docs/RELEASE_NOTES_0.10.16.md
8f4a0fe6dc250ae210cc2fc1c57c46091822ae6c2a58caa76e0091f255f9f30d 775 docs/RELEASE_NOTES_0.10.4.md 5b10081a98fab0a0394f5216beb3c2e81d9451796d1c325965e984a4edcef44c 586 docs/RELEASE_NOTES_0.10.2.md
05ed618f5a74a854363930128ca98939808517eedd28b9a508660a0c46e91d97 884 docs/RELEASE_NOTES_0.10.5.md 53e4c0d64342715a981ba0f695fa2a20cac22a33839db808816dbfa1a1e0296a 1182 docs/RELEASE_NOTES_0.10.3.md
94bfb2783c1befad1197e1c5e32fc002222c94a28d70d48360a8d53ecd260d5c 772 docs/RELEASE_NOTES_0.10.6.md a26021f356a0b78e4393e14cf5f2dd5752316698ac926c67b7978ebe8859c6b5 783 docs/RELEASE_NOTES_0.10.4.md
226a3b2d4bc7f54841749a283fcdd71b643cd585ba74d673084bee829fef6ea2 903 docs/RELEASE_NOTES_0.10.7.md cd3f7bd1236013ba9fe0afe0ad8758fbbb9beddaf51872936b2c07323cd65eca 894 docs/RELEASE_NOTES_0.10.5.md
4be29ad0cb7ebcf5625172b8d2bd7a67cdc6d64d3a94e2c3f0656cdfd42dcb7a 642 docs/RELEASE_NOTES_0.10.8.md f1bbcb3a61c7b3ef255e921ef016d85f11b1eb2a1dcc7ad7bb8a738efe6e213f 781 docs/RELEASE_NOTES_0.10.6.md
fb64517aa64d3ecfe8b51b09e198c2c9fbba96d0cd24a87301c7f6dea3076095 961 docs/RELEASE_NOTES_0.10.9.md a3b08b9680c5db304c05a0880ac1bb987b02dc20700e4ae61b0e4ebb71d63cfd 913 docs/RELEASE_NOTES_0.10.7.md
a0c00ff76acd1682bb5e0e8dcf6589c9480da436c9c6d30780a1ed58b4dad94f 1770 docs/RELEASE_NOTES_0.2.0.md 3b68776b94c73b72fd069fee7792680bd58523b8a5f34de74216c6255a9fdfdf 651 docs/RELEASE_NOTES_0.10.8.md
5773ead01aa4c522c556295553787482d01b1f5242f053b2c61f120c4de4fa76 5963 docs/RELEASE_NOTES_0.3.0.md 753e0b0f2c00a6c8ec687294625731d6c4f158509ac76bd08005fff626943bee 971 docs/RELEASE_NOTES_0.10.9.md
d46de73cf6c4cd5c2ba3f455a7a2af2e0d64ee9d94a97fd1a0bfb44e35c1624a 1093 docs/RELEASE_NOTES_0.3.1.md 532414fab6a23780dd1fdf9905846582905de9ad2f978542a8376915e73bae9b 1802 docs/RELEASE_NOTES_0.2.0.md
0d697d241a08d2427a6e7f5c2f27bd1830a41836a01e08eeff239c7ad5d89982 2445 docs/RELEASE_NOTES_0.3.2.md 9a79ba9626b852be368afdb687b39e5c12b4674dd716e565c1a3776acd365ef7 6103 docs/RELEASE_NOTES_0.3.0.md
bc6933c303d3d9b3bfdbf678cae1a717bfe5a893780a1871af8b48589f62f0e3 2160 docs/RELEASE_NOTES_0.4.0.md cc9eed982cf7c99af2ccbb8e0dd9fd61f0d494603fc2fe7d14a3288aa4ea0d76 1118 docs/RELEASE_NOTES_0.3.1.md
343862445061e1a8282a7aa9b2304e7d799e58f9956d50eb5352db18d790efad 1134 docs/RELEASE_NOTES_0.4.1.md 8498aeb28590640608191add4dcc11f58adcd8d0caafc947cf98019ee557cd42 2527 docs/RELEASE_NOTES_0.3.2.md
e2d67c816a919f00f9e26bf59cf29e5e8cf894536b743d282075c646c5accc96 1605 docs/RELEASE_NOTES_0.4.2.md 48aec5e22fd5392114b8a862f9d2b42c0a891a3af0d935b765706bf21f694b64 2211 docs/RELEASE_NOTES_0.4.0.md
1aef74fb109541903c4dbc4d9c48d2bd63507420eaf8cceb31890797a5e4f5fd 670 docs/RELEASE_NOTES_0.4.3.md e583003bfcf6563e13855ad1c9eb6ff55f6bb1a618982fb0d7336fe6bf8de22a 1160 docs/RELEASE_NOTES_0.4.1.md
85fecec65f7687e1382547166eff62777613825a8d81960dfcb4ae16aa15c8be 617 docs/RELEASE_NOTES_0.4.4.md 98c881fe56ca1fea2e4a6cc22926fb5345629677a525c87167f835b4d5184bab 1634 docs/RELEASE_NOTES_0.4.2.md
5cd0cffecdce942fb1024a0410568174e7704af9bacbe42f81891693a1817a19 378 docs/RELEASE_NOTES_0.4.5.md c4891e1c31aeee208855d496053a373ed9cbfd4d8353cb3ef5e4ba28dcd70c95 679 docs/RELEASE_NOTES_0.4.3.md
f9554c10f56d41d916330f06175b07f099c9ed1534f00abc9ea7f94be70f4a97 1097 docs/RELEASE_NOTES_0.5.0.md dc6402c30f48b57d000a207779e659f45319d890aceb2ec34b56c79668b10c6f 626 docs/RELEASE_NOTES_0.4.4.md
11a932e2c401c53d117175aa312b6d20508918b12192d37c5084bd54a5ea3a72 957 docs/RELEASE_NOTES_0.5.1.md 6b326bff00dada52d678c1d9da893227f27df2c5835349387477ac41151dbf4c 386 docs/RELEASE_NOTES_0.4.5.md
d7d007e4c2807698db07b2ebe1cb48c36bd162bf4daad77c9d299096c9654d5a 721 docs/RELEASE_NOTES_0.5.2.md dcf07ac5b9ef7f08ef3c16631a9af7196c4e7589559eeb3512adfc8b354628aa 1111 docs/RELEASE_NOTES_0.5.0.md
3e77df12a7ff4b545069933410bf14fe8891f39915182df25112c722cf4e243d 1030 docs/RELEASE_NOTES_0.5.3.md 1d89630a53d0d598b4fbf245be46773cef79eff58b3bdca2b79ae9975957464b 973 docs/RELEASE_NOTES_0.5.1.md
61f6cbc1c3f263fa96b5c6a70a26baa7cd577d37ac633455eed45d9b63169a35 710 docs/RELEASE_NOTES_0.5.4.md 2a4268b28bb0ae3dea0b647d1ddada6406f7cccab12844e93d2306e5d5a76c81 732 docs/RELEASE_NOTES_0.5.2.md
319c39f7499e96513031e419501a0b01a4379b06e2b172d0e7d85876509bda9c 1050 docs/RELEASE_NOTES_0.5.3.md
aa00a9f187987419aedefa2c8667f664b4ad0123b3e7e205288d73b5b4f0b4e1 720 docs/RELEASE_NOTES_0.5.4.md
1ecca96cf8a6f01d7ead37d5a6b678549c2561bfd84011b6149f718a25971661 4936 docs/RELEASE_NOTES_0.6.0.md 1ecca96cf8a6f01d7ead37d5a6b678549c2561bfd84011b6149f718a25971661 4936 docs/RELEASE_NOTES_0.6.0.md
23150c58e6416d48c2ed6e378fff99179ed810b766ed50b70d4d829c6774b8ef 685 docs/RELEASE_NOTES_0.6.1.md 23150c58e6416d48c2ed6e378fff99179ed810b766ed50b70d4d829c6774b8ef 685 docs/RELEASE_NOTES_0.6.1.md
f3d04f2d3419a7a010d5399cdd9351ff85ab2b3fdf8023977e559b0a5f8bcdc3 2571 docs/RELEASE_NOTES_0.7.0.md d4e43b10b64e1c04a0e444ed39bdfcd05869cf5a5d8db5caa8583578ed2c3e9a 2618 docs/RELEASE_NOTES_0.7.0.md
d7bdc61d9b617ad5acf0b2d468eda547fd7509d4af08f33d2661393f25bdcb5a 576 docs/RELEASE_NOTES_0.8.0.md 9afcf47ae2feea72e04cabc08e2a5c058891c04e4a99a7f014d6e9b791d52f3d 586 docs/RELEASE_NOTES_0.8.0.md
1e056bfcf2105843402f4b14c63480240cc55456a4a63e229b3fdbaf3156b803 754 docs/RELEASE_NOTES_0.8.1.md 674fc8b6c656bd2bfba583388d50e02d0a5c38db4c8396f78d217f4a8b58759c 767 docs/RELEASE_NOTES_0.8.1.md
7f1d7c8bc895d309dad2f8ab444d6d2ba3e68c8daa240fecd2abdd9d8a56ba20 729 docs/RELEASE_NOTES_0.8.2.md 65885a64005125a24e9d25b70f01dab6d97cdfc8ce017ea0850e913164a5b07b 740 docs/RELEASE_NOTES_0.8.2.md
c2802fa5dbff392c846b82b55e84a8bfb8e1625546fd1eba39f7129318bece96 654 docs/RELEASE_NOTES_0.8.3.md 0f7b3078069a9d7616690fc9aae89fcfa3916eb9b952b24b39ab2ff7bedb6879 667 docs/RELEASE_NOTES_0.8.3.md
8c13279987672314332f648889f52338bcdcb243249f9e1c20fb09d85d7808f5 505 docs/RELEASE_NOTES_0.8.4.md 9cb2b202fff4de466cc0bcf1945dcfacbda698a0306e293bd97c1a16ab6b9180 515 docs/RELEASE_NOTES_0.8.4.md
b516db97a0353babc810c24a87a971d30d72a8021d809e6b833ff7ae0458f442 538 docs/RELEASE_NOTES_0.8.5.md 82059b894fee48bf9726e9e65c335e221f0c0ccc03a0d27720e918633fbc6421 549 docs/RELEASE_NOTES_0.8.5.md
838d196f3fbbfeee8df375a0502107f56b6df28babca30aa09ef1c7aa5196d09 738 docs/RELEASE_NOTES_0.8.6.md a4997e09ff6b3ce5a1754ece03ab45fb9da8bfaa61e5c3d970be7aa953f13b99 752 docs/RELEASE_NOTES_0.8.6.md
ef049adcfa204908e6dc3a059124b39ba0e2739cc54e38945ce73a57049df0d8 1185 docs/RELEASE_NOTES_0.8.7.md f28b4e027a6417ada385070eff7436bdbc147f80229c65b1306cf8402f622675 1207 docs/RELEASE_NOTES_0.8.7.md
7eedb25e1aae3b06a04bb9b2f4843bd6af614418737e600b4bdc9161edabd76a 632 docs/RELEASE_NOTES_0.8.8.md 6b4c9bafcf50917129c64aba13491fb263116ef1a7e9393ed2b951671cecab9c 645 docs/RELEASE_NOTES_0.8.8.md
35dcfda990946480d6d55bd2d2e6360c336260bcd05cdb51d92e07a4e8d76945 1046 docs/RELEASE_NOTES_0.8.9.md 3a007e5d2081d33769f217f44de3eb4b5b720b564d646a8655910956bfc16f6a 1066 docs/RELEASE_NOTES_0.8.9.md
29f7b11fef1e4960ef874f643b2206ca73a310b0b75402bcd3764a01e59db2e0 3114 docs/RELEASE_NOTES_0.9.0.md 4e650b94caecc137735a7b27f8a17a907dfe3a5b9a3f9c7541dd6912fc1092d1 3144 docs/RELEASE_NOTES_0.9.0.md
ed40e08bac8792f95970bc05e49bce3cc9e288a08d11565a1bd156d787360a3b 720 docs/RELEASE_NOTES_0.9.1.md 318e665005a8246e49ed74678c09374f03148c96949f8cdce5dfac773f616959 730 docs/RELEASE_NOTES_0.9.1.md
01bf2cc72593b10010b667ae017b9eb62f4658a9711bbf24c81f5f95d93fe8c8 827 docs/RELEASE_NOTES_0.9.2.md 6ec7ddf1290c63bdb1da3dd1f64381f0c92db7d560460f3264ed8f1f23a15f61 837 docs/RELEASE_NOTES_0.9.2.md
25169225d73d22b9d884ab3b5c1625f03fd44e53c7a7a4c4067775e80482c9f8 2182 docs/RELEASE_NOTES_0.9.3.md 1b9f884aeaa030388b2a55b50daaa3dcc525b6771ba50b84908cb25a5437c58b 2207 docs/RELEASE_NOTES_0.9.3.md
720506842e0aeb30c9fc635f86a52a5545556f092e678cf37f08436243244c3d 933 docs/RELEASE_NOTES_0.9.4.md bc3fa8731534e0b4f792ee2159bf7cd7d81bbbbe214caf269c5e6fc93f49152c 940 docs/RELEASE_NOTES_0.9.4.md
dd90c81a375f97dfb7fa8f7808db03b19d7e7dafe3818a93537397f57eaae829 2109 docs/RELEASE_NOTES_0.9.5.md 9c0256fc09525dc63650c1d1cb3afce8a2742a2e033a471595e5fb734bab40b8 2120 docs/RELEASE_NOTES_0.9.5.md
1bf75f25d704dab0c6bc56c639d259f34523f0fb46718dd5a8419a59911ad2c3 2242 docs/RELEASING.md 96272da7be735148e8971717f198eea2819aac915105af1533573156aa9d6b57 2298 docs/RELEASING.md
ac76cb50fabde6a00f28d7e9eccd3ef1129a40665eabdc90d78690a38d424652 4195 docs/ROADMAP.md 0e44c88c3a1bbb6899864132f55b02770e5ec53cd380eb4319648e25b774e8e0 4301 docs/ROADMAP.md
1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png 1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png
b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png
070e6700bdae8c628c907ba181bbf0dde0bbbbb4208f7a875503f933ff1b882e 118819 docs/screenshots/deployment-success.png 070e6700bdae8c628c907ba181bbf0dde0bbbbb4208f7a875503f933ff1b882e 118819 docs/screenshots/deployment-success.png
@@ -91,164 +93,170 @@ ed69b8beb948a2cf9a6deb6c82368e2bb44ffe8d8990a900dc878b0938d1084f 95937
3868ab978de2a7945761c53a9a718aecd54dc791605d07660bcd5cad62a33ea8 103569 docs/screenshots/git-validator.png 3868ab978de2a7945761c53a9a718aecd54dc791605d07660bcd5cad62a33ea8 103569 docs/screenshots/git-validator.png
007681714895ac062c980db1dda806ac17d4f01019ce9c46491a108d17c2dbda 85338 docs/screenshots/overview.png 007681714895ac062c980db1dda806ac17d4f01019ce9c46491a108d17c2dbda 85338 docs/screenshots/overview.png
1f78414b00ec100af2ec9bf5c9a3e400b6c9bf6dca6fcc317fd951789acc4536 112852 docs/screenshots/repository-workspace.png 1f78414b00ec100af2ec9bf5c9a3e400b6c9bf6dca6fcc317fd951789acc4536 112852 docs/screenshots/repository-workspace.png
735950c1e77bd4a1cf5ee986a7a307600741e5fdb90918adb0687bd29a89aaec 5877 docs/SECURITY.md 11a7bafaa63c7746a938a247b9315e6f54c083ef219504c88d3658cc1b86e98c 6173 docs/SECURITY.md
32a34ec13a284d3f9ceebbc107b25a844e3db096f8cafa4e43951fc2050c9a03 13552 docs/SETUP_GUIDE.md 32a34ec13a284d3f9ceebbc107b25a844e3db096f8cafa4e43951fc2050c9a03 13552 docs/SETUP_GUIDE.md
2fd71e9bcaeb4cb10c3fa2496b7e52fedf70c5b7f871cd587e22dc060c399079 4421 docs/SSH_UNRAID_DEPLOYMENT.md 080438fd3e5cb17a46baee6f0aa51fc95ca4ba37c55905bdf60fb7cd44fd1ba5 4494 docs/SSH_UNRAID_DEPLOYMENT.md
b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md b1c3f399bddfc488060a88b58043a3faaa2ced67237ad21391efaf6af66cddb1 2206 docs/STATUS_ENDPOINT.md
0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md baa7b621b95a9ad6ca44d7b1ec0bc80f400068e93716a525470b5121f380cb19 3238 docs/STITCH_REVIEW.md
4983414a980075e6faae687b0d71c8e57bfe53fcb4cadb8b979b8abca636fe95 6654 docs/TEST_MATRIX.md 3610e0981257c5dd2455859902b20e7aa4568c3c93a1dfa6475be6032b564cf2 6703 docs/TEST_MATRIX.md
03fb2fe52a863b9d3d536f3c8abe23e47b9851be7fd7ccbfb106554b9c595385 5013 docs/UPDATING.md 85c74adb8c7d4356b6f696013d911ba9041b8d8c4b8900dcb4b491115ba1fbc0 4512 docs/UPDATING.md
73f094a2f0db3de053e515feb2771cd5a4f3aa4178f2c5f37be01ca65ff1c938 2705 eslint.config.js af13cb6d12f31904875cd5bf242a64bd92e15e81fe432fd423802db0f2040394 2772 eslint.config.js
c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 examples/gitea-actions/deploy.yml a24c5ce4c7147d286ef26258cb64a9f96a54c6d901e7dd5f925e48779df9053d 1583 examples/gitea-actions/deploy.yml
4c792cc9fd57ed36da291300c252a6ef75b08a249cf6f2561e23c4c22522138a 1477 examples/gitea-actions/rollback.yml 5d2577d4f9f635a12dcc8795879c079b9e66630bb3d96e21510ef7ee13ebef05 3319 examples/gitea-actions/forgeflow-approved-deploy.yml
577f3fa2131a3baa84549a6523f5816ef9da94f5bac6bc274d4588b6e7ab6594 5688 examples/server/forgeflow-deploy aa016403cc795880e29d933b60b52192bc73ad8e1d1eb20a93cbe11b4808b3cf 1527 examples/gitea-actions/rollback.yml
4fe3eee5c2d8705964c24b8c4dd909883a05e7d6eb85629c84b0d64473e0a92b 258 examples/server/forgeflow-runner.sudoers 4a84041fcf2d7f36d1807e2c19d6cab816f9635112756a675f32cd24d9757fbb 9661 examples/server/forgeflow-deploy
0423fe2cc7f43fe793986a3f62a395668897cdf07348756aa7742a8cd40ac51c 569 examples/server/forgeflow-targets.conf 13f5b67c8896d9ae3437bae0c9542be9084347b28ee84b59ab1a608263828cba 262 examples/server/forgeflow-runner.sudoers
106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf 9e3dd8267eccebfc02583519ae2ef56b81e781b386d1b92b497d5cfa061be4a7 578 examples/server/forgeflow-targets.conf
2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json afb0480c781c800bf20834d91e36d60dcce520ab90c91c687745abe96bf87e3a 405 examples/server/nginx-forgeflow-status.conf
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE a0de3fe4e09b6f246e1513bccc170334e60f63f98c24377192b4335cbf16dff4 593 examples/server/status-example.json
e2daa28bbc01c68c3702add6ea8259dff5920b22f6fdc3c9193ed78a153f2e9e 14708 main.cjs 2033fd1ba7aeb8d2c6377d970516c4b7b82762dfc35efafb4be5a15e298c5e6b 1088 LICENSE
b3d22a6d3222a02144c0ce147a93fa2ebd77c14b1b260503f338284cf9d9d107 15178 main.cjs
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md 91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
65c548b9072c90530ee32686c34959e940e694684af243b17d5cbdb6bd47cb61 179808 package-lock.json 1caed6bb0843d7164abcc7280f97427aea6f9cc2424ff159be03fb02f689ac86 184860 package-lock.json
fe6f311914aa4513e476267c795ca22311ab78d1bb4388d5a3e50f687d5e193f 6415 package.json 93f2bdaf830eebe78e9f6381c201a798ed80cd7c9a78f788c5cf4904d3e53762 6547 package.json
1237df9ddcbb5ac7dc4316f18c34ff4a7030e3e0d56216ade6dd07369e5e2a04 1353 playwright.config.mjs c36f7f245023e31abe058ece7cc1d6899b83a6cd2c8e42c67fa396fb1085fc12 1395 playwright.config.mjs
e8f678b26a1b651ee0e06e499b0538d8a193d0565687e9f750b58f801e4fabd8 12473 preload.cjs 3020fb7661321b468a2d4532cb43ef50ec6a38cf973db453ec0c662f823c1da6 12571 preload.cjs
abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd fc4a0cd199e4a6b3a8ed745261b770d8202d774bb1234cb9e074997b8a10b072 499 PUBLISH-AND-ENABLE-UPDATE.cmd
6d0858d6654c3c3dc7083ebbd234c88324afcebaecd7b772719440a8afbc2e4e 10736 Publish-ForgeFlow-Release.ps1 17d6dd3174c9cbe15073031f82ed760c472bb4543c8fc77cf0bc9f15c136e863 10917 Publish-ForgeFlow-Release.ps1
33f3c4795705ab77c6e6603c88a32c123b3a286bc77e8e472b76970485699338 4386 Publish-Missing-Binary-Release.ps1 bf882b8a3ba55c489a76393f65e0f3cc43a74282e8ef04bc31bdf6548495e590 4502 Publish-Missing-Binary-Release.ps1
521356cc921145e8229751f85d57652fa50e0267f0d953a9f86cdcfd33eb5838 11117 README.md a3a6296c4ddcbfd22f01bc9f5a6fdd4a7eec66f3057cded8430a81eb28f956f9 5493 README.md
d75cfae88987ff6e8b92a53c988e59cf6dcffb74cd7cf8d27b417001f68f6bc6 16450 reports/architecture-audit.json 0f1bf0696ca6a3de7c222a935953156cdd1bb0aa27f2215b8000901c4db2be31 17255 reports/architecture-audit.json
654fea47bb851d5865908debcdc2140593c8bd78d03c7a5bfb6573518de08919 1726 reports/architecture-audit.md 6c50c58f464e2f93fb7255a59d6cbb76354755f63c6f1d4ff14a9a88c8c54574 1758 reports/architecture-audit.md
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs 509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
d0745072321aca2c80f44460974a7926715a9f429164aaf7660dced40b52c736 4790 scripts/apply-binary-update.ps1 5f220dc8ee24d2339aa3eb696ac7a5bd6f55c993b5fd9001ec9784788f2a6e46 6747 scripts/apply-binary-update.ps1
404863bcbe7292355662e3a326455df864d7279badc29f90866a3b837420df54 10745 scripts/apply-source-update.ps1 358d0ecbd50d8ba1ff9460c761cc2a1990fb27104d6ad74104a8800877343e19 10954 scripts/apply-source-update.ps1
02e924227f6cad3777fd06660230c85df590d8ce95e134194a4d18970a240b88 4145 scripts/architecture-audit.mjs 02e924227f6cad3777fd06660230c85df590d8ce95e134194a4d18970a240b88 4145 scripts/architecture-audit.mjs
47a5b16e95934bfe510c18bf94547ae65acb980c0f0506ae156d1a486dfbdfc9 8985 scripts/audit-installed-deployments.cjs 47a5b16e95934bfe510c18bf94547ae65acb980c0f0506ae156d1a486dfbdfc9 8985 scripts/audit-installed-deployments.cjs
6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs 6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs
0244d42896b8c44f734d0bb6cdcb29b5981342be2f070ce89f8d9eaf3e4d49e6 1793 scripts/generate-source-manifest.mjs 7b483476ddd909b085335cb78c9b0ffe71939c50011b5fbfcfdef6a340fa7ce8 2032 scripts/generate-source-manifest.mjs
842436680521311594e798848b050ae4e488d0595f0de57315f6ec081c049fb9 1266 scripts/prune-dist.mjs 842436680521311594e798848b050ae4e488d0595f0de57315f6ec081c049fb9 1266 scripts/prune-dist.mjs
d0e6fd6ce67b553a3654acd4393e5b9c3be825c45d03d957fee36fb2a3c56a85 8308 scripts/publish-binary-release.cjs bcd934f9c085b073dbccdb6fe55c042dc365d12ae7e48afcad11379d9cd32e3d 9399 scripts/publish-binary-release.cjs
558ff442988f1396c174c7161ff5bd3ef0b2f43cfc31459ec7c3967faa146bc3 1694 scripts/serve-demo.mjs 558ff442988f1396c174c7161ff5bd3ef0b2f43cfc31459ec7c3967faa146bc3 1694 scripts/serve-demo.mjs
288c4b93f6006c0b32cdf90555bdc0d1d3b61d24a8763fcc30f1e6425ce1684d 1713 scripts/setup-update-signing-key.mjs 288c4b93f6006c0b32cdf90555bdc0d1d3b61d24a8763fcc30f1e6425ce1684d 1713 scripts/setup-update-signing-key.mjs
431d3d7eabf7e2ea2d5cbb96fb0ddc13f26d85afebcb9692f3e30242197cbd8d 2607 scripts/sign-release-manifest.mjs 431d3d7eabf7e2ea2d5cbb96fb0ddc13f26d85afebcb9692f3e30242197cbd8d 2607 scripts/sign-release-manifest.mjs
c76507857292c5713e1c699cf02e24b80265da39af2cecd148034bdb874adbb6 5246 scripts/test-authenticode-chain.ps1 c76507857292c5713e1c699cf02e24b80265da39af2cecd148034bdb874adbb6 5246 scripts/test-authenticode-chain.ps1
4393f7dc5f417e6d601a68238f4e26791799a3634acec228fe4d79deaee85eb5 3109 scripts/validate-installed-connections.cjs 4393f7dc5f417e6d601a68238f4e26791799a3634acec228fe4d79deaee85eb5 3109 scripts/validate-installed-connections.cjs
e6127e1e62f39c70ddb1abf72f4d7e7b8e3f19ff1f219e1a3660353c2e0cdfac 2411 scripts/verify-release-signatures.mjs e6127e1e62f39c70ddb1abf72f4d7e7b8e3f19ff1f219e1a3660353c2e0cdfac 2411 scripts/verify-release-signatures.mjs
de14527e9fddfb904dd73ffe4e7836d8bb65abfdf7bd0c350bb9eb911b1d5de3 22289 scripts/verify.mjs 3e15aadddf71962d938d5331dd32495dc4c3de1f5f235506bec8c2fb42aef5dd 23313 scripts/verify.mjs
c2c9e4ba251d93a530a52b2d0079787680261c314083bb99d2356fc177719613 2434 scripts/write-release-checksums.mjs c2c9e4ba251d93a530a52b2d0079787680261c314083bb99d2356fc177719613 2434 scripts/write-release-checksums.mjs
619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1 247af21a5d7b42943ac5f6d297366f5e042a15a62c0c616872b9dadc7e47aa0f 1072 SECURITY.md
dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs 2785092555fad70fe679d1d15c8bee735705676c737a1ffcfc022d8d01999902 2128 setup-windows.ps1
5506ac1e5e49006ffd028a29c89bd0b95485ea3f2abc22db4f2b9fedd959852f 33194 src/main/config-store.cjs fd5c733f9faebd26f5fb14f38bef99b03cc71452bb8ede7d184c8eef0c0bdf78 2469 src/main/audit-service.cjs
2fb04b1494b39f5d7c0720fa5fd298cd46fa85dc1b696d77657592347fcf1819 2731 src/main/configuration-backup.cjs 8f5b5f7cb197f7e0619f3ca14f89760222155bdfc9e4eae81b5182354c87c82b 35714 src/main/config-store.cjs
86e9fc2eda66b4b563f6c4bbb87d3e8514340d484fb503b73137e63b6b05c3c9 14597 src/main/deploy-key-lifecycle-service.cjs 9223757fcbf46fab6e4dbd60333bed78b771c25f3fe4824cf80850a46ba43b8d 2793 src/main/configuration-backup.cjs
7cbfe51973d6607203cb197652ed7f296a3f6b6b644df876957117866a47d802 2159 src/main/deployment-identity.cjs 1c74a88f5ee97063915eccccce854c7d6433f38800ff99fda732a092c649bd16 14788 src/main/deploy-key-lifecycle-service.cjs
ce30ddac403d1adf21176e5df21b0cc3db435305d2628f51f1486eacf20df6f2 23708 src/main/deployment-service.cjs b18f5cddbcd3eb1448ae227bddca7bd2d0866c3e309d852fb0ce45f0815eac3d 2194 src/main/deployment-identity.cjs
f22348297291199e858656248cec70f94f002144ea7ea0807bd844cc5016baaf 16277 src/main/diagnostics-service.cjs a0284c366693b42d373794e55eec83bf5f6c500b95091b2796a283c1e69fdcdc 24135 src/main/deployment-service.cjs
a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs 774a6f4370e75bf086e04e7b4b7961463d97d798c2512525458dbaaa5c008140 16860 src/main/diagnostics-service.cjs
3d19a328eec427329fd9123b24fe79b5ab6670c0b33ac68397948e8df636a99d 43961 src/main/git-service.cjs 5d70b96d045820891d3a33f2fc5d54b0d4730bdcf82cc0ab32149b4d3328eb64 2448 src/main/external-tools-service.cjs
e28fc1ca2fd4c0116148f5005d793feddf04c36ef711d2d348560394d209a613 7253 src/main/git-validator-policy.cjs 76fdc5576dcd6fdb88921009d4a923854650bd5efe5b837cd7438eba0dc733c8 48002 src/main/git-service.cjs
3a101b63ad3761c26350c2ac0793279a0b27672b91a5b1d8dc75bc44d92f0b52 27128 src/main/git-validator-service.cjs 871553beef7613d30493a9af9fdcac4d3305c0d89ae96cff8050f310d4865ff0 7340 src/main/git-validator-policy.cjs
3cc53e24e023aa0d8bf36c35ce9672ca98e6c74066512c8b59ab42274e838c22 21307 src/main/gitea-service.cjs e4384b175d2ebff809581861a8b7a950c2bf2780118f3610ca5f9ec673fbb617 27727 src/main/git-validator-service.cjs
2ad3b2e647377f687ad987fe248a142ad399ecac98e4b49965aa7efc6093e5fa 6914 src/main/inventory-classifier.cjs 0eecfff92532523990bf4a4bf2fdd6f9f1150d9c453706cdadf003954c90a162 21930 src/main/gitea-service.cjs
dafdb09133d2b6ec2161a3f0b09354551e54fc606c8107976fca37405643be91 3404 src/main/inventory-review-service.cjs 2246cd52935cc4b5546a1412f1aa2e33be99da4891be588da95644cc6d1ee8f0 6997 src/main/inventory-classifier.cjs
5e10cf3759bbf9294909866ed16f206d394789f0564549fc0fe23cdd89118ca6 25110 src/main/ipc.cjs 180f142fcdf41c7c458bbf541a0c2c6e337d99555591593732bc22272b36c43c 3435 src/main/inventory-review-service.cjs
26efebb4c147ed560966e7e60e64a013b3476327b3bbdb4e4439949142fa7846 2250 src/main/ipc/channel.cjs d618f9cd1625e291d1f48cad665b38cd5ef322214a1d293ab8c28dd1162d688e 26441 src/main/ipc.cjs
b80357dd1f0aa18022d92db85b6cc8f29bc691ef11f9a0e90b4886ae5e19c763 12543 src/main/ipc/deployment-handlers.cjs 619e89f4489115f60d7cf858e3dac6c6b3e562ca7439e2398e66ca90fadb46d1 2327 src/main/ipc/channel.cjs
dc9b5971c9fefe8c374aa31916f5513601ce86003fd48b1d0e51330a909ae3a5 3442 src/main/ipc/operations-handlers.cjs a2fb14037189bdad1f2913a713d04f8316d5ee60942dd0982f026cff9aa3ac8c 12751 src/main/ipc/deployment-handlers.cjs
8072252821b1245d121eac534a18eeb64f0d7d18429e272e21a6e90b010005b9 17272 src/main/ipc/repository-handlers.cjs c38777b4fe0711a404003765ec4f4032f353d4c01622fa17752b96f5f41ca458 3542 src/main/ipc/operations-handlers.cjs
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs 64c6dffcb232c547976a5ed8c98db4b5225eee10bdb650c8aaaace8eb3b67792 17722 src/main/ipc/repository-handlers.cjs
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs 0ae045dab3165a05a518e9a0aca4aba972de3da3be301f219512c7d198e59008 5062 src/main/log-redaction.cjs
720c4a0c554f46386d87c3ab6607d1fbcae66e50b69483c7dbba169d5128c851 680 src/main/process-error-policy.cjs f819385c391b24dfb173d0b9befe82edcc1fdd26492a6fab7b168d03462a4daa 13117 src/main/preflight-service.cjs
31e449c00daf48639fdf3effddee52e5fc5f8ee4dc27c57cfe9212e6418535f5 706 src/main/process-error-policy.cjs
3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs 3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs
e64f7257d478955c675a133b3735b6afe138a69d2ad090898061e56f557c43e5 9926 src/main/production-acceptance-harness.cjs bc9fbb70cc5cc51cbf9115c610fadcb61e83316a69ae3862bbc2f013353c1e4b 10075 src/main/production-acceptance-harness.cjs
27bd6621c731545ec46d8914e9408c89928a8ce563b40eb4bcd8a516662a54d1 8716 src/main/repository-monitor.cjs 538ec017ee855b6585f1fb4efb88d828500b75545fccaacf66160a3d76d8a63e 8945 src/main/repository-monitor.cjs
6393583911263575c6e2a19d9baab6e638cce90252c386b0a5144f2fb6f81f15 12154 src/main/repository-service.cjs a17983bf15aa0daa96bdc692e136386b3cae79b878e76694f19eab9e8aea2ace 12457 src/main/repository-service.cjs
52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs a80440baafc3655dca2c6b5a5726e80368ac686015c7c184c5cc0353e807376c 28505 src/main/server-inventory.cjs
793003566823e1d5c02283f583888ecc07e44477525620579b3d858f488b3c08 22347 src/main/ssh-service.cjs b344669439fbc6ccadf77277119efb10f200fccf2c7ea417e2aae83456acae4d 22859 src/main/ssh-service.cjs
19538a3c40ea3489bbaee9a23af36a5e99962af6bb3d04259f05ece6588cbeb2 25901 src/main/unraid-access-methods.cjs 79e96d402d8e7fddef722ebb9e528ac4a031d0286a994c0a2d3b8f21904ba0d7 26362 src/main/unraid-access-methods.cjs
5621e35323e4f81fb14a05670f81579ec1e66bea3a55fa6457ece0f807421424 9801 src/main/unraid-deploy-key-host.cjs ee87b750d89f8df9ea4d8d7edb852b4681b3f826263bc8360d06a1e1f80f82e6 9880 src/main/unraid-deploy-key-host.cjs
6d9910dace52625f88e066a8485af2663c3735ff15e9ce9031441ce742710a21 30793 src/main/unraid-deployment-methods.cjs 518c3f9d017923407b9ceffb5132caf99cae2a3d5a103d21b303207893d38067 31375 src/main/unraid-deployment-methods.cjs
673b1692e7c2b5197545df98750b5d048bddf44206263e25be4f17d9bf900e2c 17208 src/main/unraid-deployment-service.cjs b6cd92952b7fb2cd6f03301f78c9c6bfa962d1f0ddd56cc3931c7688acc538ad 17732 src/main/unraid-deployment-service.cjs
bb4a99c3526fcf4db4fbae88a058e8598fd10a87990e7d502bfdc765328bdaa1 42766 src/main/unraid-inventory-methods.cjs d2d76767c05bbd604e8e557e1ba06ceba12305096fa73c8a9edbe439f2eb8fc2 43475 src/main/unraid-inventory-methods.cjs
2c0cf07921ca7ee5a9085ced44498c2e6798e5cc1e8a5ecf704c3cecabe39a25 27607 src/main/unraid-preflight-methods.cjs aa2b8681d72dc9c232ef9436854da9967ebef35d6a71c66ff6f9a566ad031c06 28229 src/main/unraid-preflight-methods.cjs
d45220176aed72d692f9ae5534f9d40bcc359a2d08e025e74a3b3b505b8b9ed4 16559 src/main/unraid-runtime-methods.cjs 78fdb88c0ab376233c114b116559af26c47d83fced47c7a0a0b7248a0ecff8e2 16946 src/main/unraid-runtime-methods.cjs
4c5cf01922e1feb36a31b50af22e973d8aee3fecccd406e449690604111898ac 11608 src/main/unraid-state-methods.cjs a3d3c0327f9120ca956735bf4e63a61e0b48e5b120a2b9fbe7b47d8bcf69b286 11901 src/main/unraid-state-methods.cjs
0b25c3729c5fffe3cd412c2325616bb86a6c916ae248eeb39d837378bb78c144 26420 src/main/update-service.cjs 0bac9bb0e30b7ed9b7c37fb4453a7a8d612a66b49caadc9546e0799c09774d98 28795 src/main/update-service.cjs
b5c304531bec358d059189a27cd9db8fa20cefb7f817e5eb0287001f7353f6a7 985 src/renderer/actions/command.js 856119ff96dd343f5460eb830a586684c1dda7468ddaec858d38d960614f1a42 1007 src/renderer/actions/command.js
d0bf607dd1de9d55f2947d0adf0997cd3ca5c269d10a5362cc1d8bc4d1a2a8ae 6706 src/renderer/actions/deployment-operation.js 6ea98804892017a27450047c6fc5a6193cd98b6f56ded9880eb02c06393683a1 6889 src/renderer/actions/deployment-operation.js
0db283b1a458ae0b31538940b1ddc931ffdb53bd04ceb7fd8903813f9200d071 17978 src/renderer/actions/deployment-profile.js d58d8fed4128d6c512fc1c6cac42dfdf5878380cabab9f068abc371d29036d27 18385 src/renderer/actions/deployment-profile.js
48bed91dd2a85bb51ee7307f7acc3b79c881ce8cf63b22ba79d5d079b265eb4b 7785 src/renderer/actions/inventory.js 3efbd97d65ecd74aa30c7ab51c7ddd3da71c3b9f6296597a51e3d921cf7930e1 7970 src/renderer/actions/inventory.js
13b8611b5389625deeec59ff2a6cfebcfc93bd7972902439715be371d1f9f573 14936 src/renderer/actions/recovery.js 9e8adf1ba89ffc61a7b595f813c784688bdf50daa259204d74b2cdaa81650895 15493 src/renderer/actions/recovery.js
2414a0d29a0380d343b9b0e58ba1909e7a7eeb46357fd45ddbb3ad411d119f78 16280 src/renderer/actions/setup-and-settings.js 6c26c3604344230df8ee13129526a271b76db48809d02d4e9ff1e3db08dbe2ee 16720 src/renderer/actions/setup-and-settings.js
6900075c7cbcf6638b071735a3ce34954339d3d0ad324373c2bf5afcbd3cfe02 19618 src/renderer/actions/shell.js 058722de35ba33bfcfd29d355a75e1513a2be80c572773472cf9816dd13f894a 20148 src/renderer/actions/shell.js
d8eee8bc10b23d877919560790c311924bc3c339775dfe225b5b460e040f25de 26829 src/renderer/app.js 0d48993bc26ae28bdab5fbfa8d9be4ef896efdb9a5c34094087721e4274593b4 27566 src/renderer/app.js
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png
094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png 094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png
a9dfda1adb8910bb882428c237907b56163ee4901992a54a6e50c74f6037786a 55199 src/renderer/dialogs.js f826ab1f2f35882c59995497219fcfd500e46a94dac0906ee47fb732e8023fb4 55633 src/renderer/dialogs.js
dede1f21a06c73a2c2a462a869d27530d85f99baff202a2eb509c57436ad6aec 2732 src/renderer/diff-view.js 196ee9c174de6afaae524d6e1bc7c5fccc8165e653adec44b12627f3be2af42b 2772 src/renderer/diff-view.js
e0ea09d8d3ab1033452a77a9dfa4557b29ad5428c9050713661fe4699e007bda 7245 src/renderer/events.js b7698de13b872aa80d27b0a4d977c12ca2303b2246f05e6af4223db9b727e525 7433 src/renderer/events.js
c4a71213d412166093f7bd8254b847de4d8beb58c1aaa356a0cdc8d728080326 1524 src/renderer/index.html 4eae9d462d04b6c65399a13ed679fccd6fc0469b2da97ff6861b932486319621 1561 src/renderer/index.html
06180d9656dd254edfb6949c397f8e313954fc560ddcb22b3a35fce3c3e35655 21350 src/renderer/mock-bridge.js aee36aa371a9c6ba2b85a1b269a5d43b0a5edb05c1d89aa5b7748b656ee92c2b 21951 src/renderer/mock-bridge.js
870024aff376826a92c9cf7452689cc1ecc5d9034f055bea56734f3f7fcea5e5 28703 src/renderer/mock-deployment-bridge.js 9853a4b17e000efa81d76596d7fbdc7277103811302061fae7a0caf6085a8a0d 29408 src/renderer/mock-deployment-bridge.js
2e7ed10ac9555470f989e6e8d721fcf0db979a03c9508844e028fd9028e31465 25031 src/renderer/mock-repository-bridge.js 4a314d7a0fa00d84c8431ade6d598084ac0fd6ded518f035029f230f6f03a1c1 25815 src/renderer/mock-repository-bridge.js
94fa265c2fe9ca8d644f0ce9b620b6f85d9b25dca5802c4e9195b66dcbe80120 6522 src/renderer/operations.js 297dcb573c61f553c82fb5fad9dae82bc6f1fe958b2b026a49f1dee0fc7600c0 6733 src/renderer/operations.js
b541b5173d9b2a0063825243e87be4b8709d34d4f82a96d3a91d17471b3a60b7 87983 src/renderer/styles.css 9299c83e43eef194bac2946c43b2ffda6309ccbdcc5b9ce1775e668ae7f172ef 92319 src/renderer/styles.css
17a568f0844d6bbdac2c4e9e8d9e564b06c0557d9c2ddcb2a8eb513962306360 112565 src/renderer/views.js a4ff14882d0fe3f1f467eb9e263ad1a984b065d1534e14376c42d755a7a0f689 113894 src/renderer/views.js
e9e72c072a5c5d04f59cd6763de0cfbf736c2a5ffa2f722143f3bad2bdbc630b 1411 src/shared/clone-target.cjs e9e72c072a5c5d04f59cd6763de0cfbf736c2a5ffa2f722143f3bad2bdbc630b 1411 src/shared/clone-target.cjs
5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs 7d194738a75be91182d558dfdd22fdc66e8ca9713dcc9622605b9c49c59eaa90 2541 src/shared/deployment-policy.cjs
029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs bc13c4d35579e2c64dfd8c0245c48ca4f8bfc8bd1a80210821f4366b799764a9 3150 src/shared/git-status.cjs
2778ebcbdf60fdc1cb0749f15565e0e1bd66f3a0d31eb70ae7942a7511a3de75 1295 src/shared/repository-match.cjs 59b4c20d3cfdecdeb8f2126b5e525f2ae99b26a53a3914a2a7a63bbc1cf5643d 1099 src/shared/release-policy.cjs
98bf82663ecd159c92eb3d4a9d05996797103e620cd9017f9ccee5c614c35ff7 1334 src/shared/repository-match.cjs
c7e120ea53c5ef3c01b8cce71afe913f34bb461bb73aa3ade24656e09f99f338 1152 src/shared/semver.cjs c7e120ea53c5ef3c01b8cce71afe913f34bb461bb73aa3ade24656e09f99f338 1152 src/shared/semver.cjs
8791d3813e6cf285ee6aa49f76e75fc1f3af76fd98c76bcb3c92ee18e9cb699f 2889 src/shared/shell-verification.cjs a31b275114a2ac376f3f8c69f4328286219767d22024ddeb01070550ad62109f 3189 src/shared/shell-verification.cjs
2daa98fd421598bfe5fc9757c9b6f4d82c31d1bfece15829928473581d5d2639 1210 src/shared/tool-invocation.cjs f6acb9c9a3cb9ca771d9cfd133babd8132445322644fc23082313dd1ac1ab2b2 1252 src/shared/tool-invocation.cjs
ee73fdf9c591c029243385cb2d2085c3005c7b08c5b9e1b89102201f0ab30759 5702 src/shared/validation.cjs f2bd787532454b7f52a19d4161458ced05b3496ceb37ef3db4db84a67a99393e 5832 src/shared/validation.cjs
13b731c38863b1007b0312fd9d89562401b7cce875c952f52429bde74f77a8af 3096 src/shared/zip-writer.cjs cfa9298d5ab390f5f1ad8988c73a157e8bee1c2854afc58c4fa0941cfe1da871 3187 src/shared/zip-writer.cjs
f8853dce6fdf360d5df2fbe2b6df3e5687630c807fee5ba8436679b34ec737ea 2436 START_HERE.md 39260d5268764844f744278f99a1ce934cc3bf3e2f5eb56687122bb75482255f 2496 START_HERE.md
058aeaa5d9bfe377c7e322f213c7871ecc4151b5d08ef790992f4ee28d857658 743 START-FORGEFLOW-OVERLAY.ps1 058aeaa5d9bfe377c7e322f213c7871ecc4151b5d08ef790992f4ee28d857658 743 START-FORGEFLOW-OVERLAY.ps1
f5b0ea887fcdeadec78c1ad49b0ec7979723562f5c0b730703acb77a37281ee0 1009 tests/acceptance.test.mjs 1efc658df6e29f5db5aefd3c515115357d33fe7d920e37e1a99a118251babeee 1020 tests/acceptance.test.mjs
a4e5947204ff6878e601e32477bc85b53cd0153baf95a161c8935b6e5466c257 1155 tests/audit-service.test.mjs 2f2b21754dccd8b734d6c7bd4fdd655df79c7739a1de140e562520c8234aa7a2 2854 tests/approved-deployment-evidence.test.mjs
8eb8023bfd1366f4cc8c1867fe16fa0bed8d6e9d29219100ae1336aa06d10110 19040 tests/browser/forgeflow.spec.mjs 720ff5b549a3dd70854eb1bac3589c77a2019a61148be8e41e112196c8821ee3 1079 tests/approved-deployment-one-shot.test.mjs
17ce23545d113267f414173ccd8cbbcc8e6eaeac4a831c5d53f37d7946775905 1180 tests/audit-service.test.mjs
6e119cda76b2ee36b93623d98f41371798227f9b3f7c20fbef035a7d7a50cc95 19402 tests/browser/forgeflow.spec.mjs
1728c0a7abd92f4d7d9e68df32e4a6b00730555f23795e9b36416795d9d127af 5978 tests/clone-target.test.mjs 1728c0a7abd92f4d7d9e68df32e4a6b00730555f23795e9b36416795d9d127af 5978 tests/clone-target.test.mjs
aa2ae0e5a12bc47f8024e0d7408148e3af797e3f3f0ecb2525fb1cc54cf4e1f0 17378 tests/config-store.test.mjs 1d85b0bfdd47d3f645904d4db85e1699addd6a0f02ed6c71fa4fe4b568fd21ae 19513 tests/config-store.test.mjs
f1463326aee79842d265687ae628189ce54e92544600f2bd14073780287cfb14 2502 tests/configuration-backup.test.mjs afc181d00bd191f3e8d8074c9fa382d29ddcc1d4bf9186f8f6395b93e3725f14 2547 tests/configuration-backup.test.mjs
144c8e217a334cd69f502938d944e0f2dac61703d5df47e287b9ed542918c779 8129 tests/dependency-wiring.test.mjs a460d1ebb731c89ef0be9a4679604c73d0151e44e24da94788c132c3b298bff5 8294 tests/dependency-wiring.test.mjs
aad5948ea374d1e56e777005c73639654c96a90364dd398c949052cf5ae343a2 11130 tests/deploy-key-host.test.mjs aad5948ea374d1e56e777005c73639654c96a90364dd398c949052cf5ae343a2 11130 tests/deploy-key-host.test.mjs
b7e009fed4171d6dd6b4c3154ba1d3f7198e98f5b79b298687841fc8169447cd 9354 tests/deploy-key-lifecycle.test.mjs 636c62293a576e62fbeae3adca3ad6e1345e68da0db555dd3bfde3a532066bc3 9493 tests/deploy-key-lifecycle.test.mjs
49bf9cf9842e7899015013675208f83a95402065a082320927a677ee4bab0766 24875 tests/deployment-operations.test.mjs 49bf9cf9842e7899015013675208f83a95402065a082320927a677ee4bab0766 24875 tests/deployment-operations.test.mjs
1dc6477bd07de78be189e6e8195ec339eb9d75820c4dbd5b073b8520ee21f6b5 1938 tests/deployment-policy.test.mjs 41589cf470702c7a5af966a096be7ec469c8cfb6133957ec4f4d7ecba395a152 1967 tests/deployment-policy.test.mjs
bf4576901e32662d832687a2761852aa1b2cffe256de5044f18c6637c189463b 9780 tests/deployment-status.test.mjs e8724cf72de796b763acff18aa01ea95a4ff5b2de211246ec89fb8c67d079e54 9823 tests/deployment-status.test.mjs
fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800 tests/diagnostics.test.mjs 34d824c1bee1b1756c279c938ece61a5b7e4b0fd796ba4c0a594d131e2f1ef0e 4142 tests/diagnostics.test.mjs
dd121d96ca265a027cd415a52064500a4541b2f8a662f4f4b25f2f996d52b5da 762 tests/external-tools.test.mjs 9f862a67d93edf4adc7c8b04040f9ea56394b56445a70cfd658b8def6cd336d1 952 tests/external-tools.test.mjs
1da4abd9355183ee04410b3d89403cc36094eb5df622ecfe6736e8807135bd28 19532 tests/git-integration.test.mjs 26e94450c6ab1dd0d5149d6812e66814a719f80d6f95fcb3a9dee7a3d7f46293 20559 tests/git-integration.test.mjs
5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db 1239 tests/git-status.test.mjs cd294dcaf050d639472cb1f9c1a48484fbc74271e527bf9d8153b00e903e2101 1273 tests/git-status.test.mjs
61e0b8cad926acd22b5b17e4044f7edcbe96b6977cbbe2b6fbe123406626fc89 4283 tests/git-validator-policy.test.mjs 8ac410d7173bfc3e4b967138d2d6343fb1796b2cbde4fb538b8a38b801377d24 4350 tests/git-validator-policy.test.mjs
2b31459f14a5e36e30cf84c1054f634f4dba8676d29adeb9c2a8e18179f56fa0 6097 tests/git-validator.test.mjs 8950162b8ab1b644bca230cf9871f64e530e6d612a9d89a942c3e49b686fce30 6239 tests/git-validator.test.mjs
681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.test.mjs eeb7ebc5b2deb882590f8abca3ce76a6e1f9ee26e75d37d3ed1d753790785973 2449 tests/git-workflows.test.mjs
d633c59bd910008223c834c6d7f3e5666c685a0881944263ede2d42cc69d3151 18710 tests/gitea-actions.test.mjs 4e9391976989b302d626fb820738d49622e3bc20ad46441685015addc180009f 19052 tests/gitea-actions.test.mjs
fcc9a063882840dd89d74c2785284c8f2f6a9e5acec482b6d89ed8de62efdb85 9635 tests/inventory-classifier.test.mjs f7d9e24146ac87a180fd60e0a2514c971eaaac9001014f7d63336281aae7403e 9759 tests/inventory-classifier.test.mjs
62b90c21c15b841af30d26ccb0b9e88d25674fa7dbff9dc231dd8a1dddc3d657 2025 tests/ipc-contract.test.mjs b73b49e177e0436988d4d38132ea008a35290187ee33deb289b12b621923caa2 2074 tests/ipc-contract.test.mjs
caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs c6614369c43ba42531fea6d365187d1a579018374b2fd5c317b4cbc902d1cf61 3067 tests/log-redaction.test.mjs
96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs 107d5eb14d1733709f05e817ef303b4c592470b6f9fd057fa804c9a1e2a10a7c 2331 tests/partial-staging.test.mjs
1b6c920e18a248f78acaed6187197c88ec8d911b62d5e2a9f8ad57b91ae80499 11827 tests/preflight.test.mjs d5433897838ea42146cfc15e140e55d1f254e72186a0a5f50d2d43d50490f33c 12004 tests/preflight.test.mjs
7f2751ea2621f76b5427f442e931344d13e97faa7b6ef3151949bbd6a03097cf 1205 tests/process-error-policy.test.mjs 070f2857e70d2b36310f2636582ef0eec49f398b3f009bd3742c6c9e676ba508 1231 tests/process-error-policy.test.mjs
0cb884cf62c1cb02cf59a81662be055bcb5339d176de85e2a3eeb8e8573e11b3 6435 tests/production-acceptance.test.mjs 0abe5fcea0ab8769188f2131e99301979d4f4983cf9924266291bc716c9e5d33 6564 tests/production-acceptance.test.mjs
1635efed857c776e677a064175a01b0f8b21bf7ead0b02c08956286077b8a37e 12294 tests/renderer-workflow.test.mjs 6f26ae12a39a4711cf699f1a68e8f673e1e53e2cc34f5a6f362a66ae15ba9d1a 1229 tests/release-policy.test.mjs
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs 11fd2029593c0f4e5c36f1ce8572734f8ac9afead5abca7f5b619c5814b40a6c 12602 tests/renderer-workflow.test.mjs
76712a5d26f2598b00b83b925c9c84a90ab81c0eb1760895e9d6a2bd2f6eb425 5828 tests/repository-monitor.test.mjs af43f29c5c1dd78095a3a471b904964c613f1eb567310ffcfc432cbbc9f8f986 872 tests/repository-matching.test.mjs
5476f3ba90bc096d4172900d9b54ada7c12da521f8627913d87794eade3cee23 13494 tests/repository-service.test.mjs 4defa3c5f21db7fefbd79397c96b6c231a4330df60b54c0fea7e91412e336fd3 6237 tests/repository-monitor.test.mjs
5fea04e668344508fb4e16da9bb6fe8733e2b83d1c227acb3421e51da26b2ffa 3636 tests/security-validation.test.mjs d4d2f9e2f6e7c672273a4126966f739c10d584d9e976d8630fe4b988c02dbb47 13784 tests/repository-service.test.mjs
1e4807cbcb19f4dd7787dc1779e3b60a6462989b13bdf482b38a489a756b0b5e 3639 tests/security-validation.test.mjs
bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs
12cb3b240bdd0922566323c0014838ca067ad10d9d4009943165ae2c4e93bc6f 11786 tests/server-inventory-branches.test.mjs 76ec40ba206203c89ee41212b75f53159a86ef94af9c64ff3fa204b160cb37eb 11936 tests/server-inventory-branches.test.mjs
020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs 5df7f331cc1120a0914f5ddbf39d9a6b83bd5ada70a9953b3f6bd0701ba2485e 4394 tests/shell-verification.test.mjs
a39d30f47813dfb98c998811f3d76ebbb1544ecfd017a165d44f9afb80d7daf9 9090 tests/ssh-connection-pool.test.mjs 977fc36c38a3af16558fb2c3b2b38a9466aaf08b0e7234d4cab354dee181b4b0 9345 tests/ssh-connection-pool.test.mjs
7ee9166327ed227d2b7c6929692dea5c5d7a41c3e566596fa92d9ec4f42e8677 4085 tests/ssh-connection.test.mjs 0149be4165bd5379c8324813cfe536f1620a5c324bead43dd0f463e63bb84d08 4183 tests/ssh-connection.test.mjs
c9354e4bf3720c28cff21c15ff8b9474ba4a23b7f55389de4326f4dffde54d78 9510 tests/ssh-service.test.mjs 259f4952baea6e4631f1670c2dfc1cf2e2b4c4ec670a691be922a3784c294491 9667 tests/ssh-service.test.mjs
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs c61d8ef97ec7f63f3ba0f7bbda11ba1d1bfbe1a5fd85caab2bfec612ad393aa0 1843 tests/tool-invocation.test.mjs
3e4a1a6d6a744df9badcfece2cf8d09f8c34efb3c437cb08a6f2e6c9d428c0d4 59353 tests/unraid-deployment.test.mjs 8df71b18f53ac6fe4cb8dc11d526f66c4db374ef9f738a6d85bb5ee2de224515 60887 tests/unraid-deployment.test.mjs
3bd3247ed821ba261ad7c02d649c26979e3591df456afd1bda04e351b2296fa1 29168 tests/update-service.test.mjs c750272ba4616f868b3f5a8b516674342b06b4de7eb85aa4461a107da084b6d0 32655 tests/update-service.test.mjs
9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs 9ecd063698bba4c49f308617399d6ad9ca87946f9ed885412f5a71fb0ee424bd 970 tests/validation.test.mjs
7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs b9b5ebe3ef0d15f5d6d45d2e8bc49097b59633e7684e72a1f8fad084e6010746 1822 tests/zip-writer.test.mjs
8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea 767 UPDATE_FROM_0.3.2.md ebafe424ed9dd3d3558949bfcd523352565102da4221769187dcacf4777b4977 786 UPDATE_FROM_0.3.2.md
2ebde94f664d86f42aea9bb3112b70b272c6706f4d364cad2f5c6191d2873e98 1655 update-windows.ps1 c2ea145fd3dc55cc82ae2c975d48e3aee1061fd184ca2a688b498a91743a8a2c 1690 update-windows.ps1
+1 -1
View File
@@ -3,7 +3,7 @@
1. Close ForgeFlow completely. 1. Close ForgeFlow completely.
2. Extract `ForgeFlow-0.4.0-update-from-0.3.2.zip`. 2. Extract `ForgeFlow-0.4.0-update-from-0.3.2.zip`.
3. Copy the contents of the included `ForgeFlow` folder into your existing 3. Copy the contents of the included `ForgeFlow` folder into your existing
`C:\Users\Jens\dyad-apps\ForgeFlow` folder and replace existing files. `C:\Users\your-name\Apps\ForgeFlow` folder and replace existing files.
4. Do not create a nested `ForgeFlow\ForgeFlow` folder. 4. Do not create a nested `ForgeFlow\ForgeFlow` folder.
5. Open Windows PowerShell in the existing ForgeFlow folder and run: 5. Open Windows PowerShell in the existing ForgeFlow folder and run:
+2 -2
View File
@@ -212,8 +212,8 @@ and last exit code. See `STATUS_ENDPOINT.md`.
## v0.4 services ## v0.4 services
- `UpdateService` reads `package.json` at an exact Gitea branch SHA, downloads an - `UpdateService` reports `package.json` at an exact Gitea branch SHA, refuses
authenticated archive and launches the rollback-capable Windows source updater. unsigned source replacement and applies only publisher-signed packaged updates.
- `SshService` provides pinned-host SSH execution with encrypted password or - `SshService` provides pinned-host SSH execution with encrypted password or
private-key passphrase storage. private-key passphrase storage.
- `UnraidDeploymentService` inspects existing application folders and performs - `UnraidDeploymentService` inspects existing application folders and performs
+63
View File
@@ -0,0 +1,63 @@
# Deployment migration example
This example shows how to bring an existing Git-backed Docker or Unraid application under ForgeFlow control without exposing or overwriting runtime data.
Use synthetic names and values while testing. Replace them with your own repository, server and paths only in ForgeFlow's local configuration; do not commit credentials or environment-specific diagnostics.
## 1. Establish the authoritative repository
Before deploying, verify that the server checkout and Gitea repository represent the same application:
- compare the complete 40-character commit SHA;
- confirm the configured remote belongs to the intended Gitea origin and repository;
- preserve the root `.git` directory for exact-SHA verification and rollback;
- resolve any remote URL mismatch explicitly instead of silently rewriting it.
ForgeFlow blocks deployment when the existing origin conflicts with the selected repository unless the user explicitly approves alignment.
## 2. Protect runtime data
Typical persistent paths include:
```text
.env
appdata/
config/
data/
logs/
compose.override.yml
```
Keep those paths outside the tracked deployment payload and add runtime-only directories to `.dockerignore` when they are not build inputs. ForgeFlow uses a controlled Git reset without `git clean`, but the repository's own Compose and ignore rules remain authoritative.
## 3. Reuse the maintained Compose definition
Prefer the repository's existing `compose.yml` or `docker-compose.yml` when it already defines ports, volumes, device mappings, labels and health checks. These application-specific settings should be reviewed and versioned with the application rather than regenerated during deployment.
## 4. Handle nested repositories separately
A historical checkout such as `source/` may contain another `.git` directory. Treat this as a migration warning:
1. verify that the root Compose file builds from the intended root;
2. back up the application folder;
3. stop modifying the nested checkout;
4. rename it temporarily;
5. rebuild and verify the application from the root checkout;
6. remove the legacy copy only after rollback has also been tested.
ForgeFlow reports nested repositories but does not delete them automatically.
## 5. Recommended profile
```text
Provider: SSH / Unraid
Server folder: example-app
Branch: main
Compose mode: Repository/server Compose
Compose file: compose.yml
Clone URL: a Git URL reachable from the server
Healthcheck: the application's existing health endpoint
Preserve paths: .env, appdata, config, data, logs, compose.override.yml
```
Complete a preflight first, deploy one exact commit, verify both the live SHA and runtime health, and test rollback before treating the migration as production-ready.
-147
View File
@@ -1,147 +0,0 @@
# LumaOps server versus Gitea audit
This audit compares the supplied `lumaops_server.zip` and `LumaOps_gitea.zip`.
## Main result
The main Unraid working tree and the supplied Gitea checkout point to exactly the same commit:
```text
d42d4a7f08240c478d07466e3fabec654dc71367
```
Latest subject:
```text
Preserve colors across Aura zone updates
```
There is therefore no source-version drift at the root of the live LumaOps folder.
## Root Git repository
The root `.git` directory should remain in place. It enables:
- exact-SHA verification;
- controlled fetch and reset;
- a reliable previous-version reference;
- rollback without copying a second complete source tree.
The archived server copy showed one root status difference for `scripts/unraid-hardware-setup.sh`: file mode `100755 → 100644`. This is consistent with Unix executable bits being lost during ZIP handling. The file content did not differ. Check the executable bit directly on Unraid before deployment.
## Origin URL mismatch to resolve
The supplied server root uses:
```text
ssh://git@127.0.0.1:222/NuklearRabbit/LumaOps.git
```
The supplied Gitea checkout uses:
```text
https://gitea.itworx.tech/Jens/LumaOps.git
```
Although both archives currently point to the same commit, these are different
repository paths. Before the first ForgeFlow deployment, choose the server-
reachable URL for the authoritative `Jens/LumaOps` repository, for example an
SSH URL through `127.0.0.1:222` when Gitea runs on the same Unraid host.
ForgeFlow 0.4 detects this mismatch. Deployment is blocked unless the profile
matches the existing origin or **Align an existing server origin to this URL**
is explicitly enabled.
## Runtime and persistent paths
The server copy contains runtime data that must not be replaced by source updates:
- `appdata/`
- `data/`
- `logs/`
- `.env` and application-specific configuration
The repository `.gitignore` already excludes the principal runtime paths. ForgeFlow's SSH strategy uses Git reset without `git clean`, so untracked persistent data remains in place.
## Compose and Unraid integration
The root `docker-compose.yml` is already suitable as the authoritative deployment definition. It includes:
- build context at the project root;
- container name `lumaops`;
- the Unraid `dockerman` label;
- a Web UI label;
- an Unraid icon label;
- `${WEB_PORT:-1223}:${APP_PORT:-8080}`;
- persistent relative volumes;
- USB, HID and I²C devices;
- a healthcheck.
ForgeFlow should use this existing Compose file rather than generate a replacement. Ports and complex device mappings belong in the repository's maintained Compose definition.
The supplied `.dockerignore` already excludes `.git`, so keeping the root Git
working tree does **not** copy Git history into the Docker build context. It does
not yet explicitly exclude the existing runtime/legacy folders `appdata/`,
`data/`, `logs/` and `source/`. Before the first production rebuild, add the
paths that are not build inputs:
```text
appdata/
data/
logs/
source/
.forgeflow/
```
ForgeFlow 0.4 detects existing preserved paths and nested Git repositories that
are missing from `.dockerignore` and reports them as a preflight warning. The
tool does not silently edit a source-controlled `.dockerignore`; the correction
should be committed to Gitea so every deployment uses the same build context.
## Nested `source/` repository
The server archive also contains a nested Git working tree under:
```text
source/
```
Its HEAD is:
```text
b746a52af1613f4291235f5e8165b8197a269a79
```
It was ahead of its own upstream and included rebase metadata in the supplied archive. The root Compose file uses build context `.` and does not reference `source/`. This strongly indicates that `source/` is an abandoned or historical checkout rather than the active deployment source.
ForgeFlow reports this as a nested-repository warning and does not remove it automatically.
Recommended migration:
1. Back up `/mnt/user/appdata/lumaops`.
2. Verify on Unraid that `docker compose config` uses the root project.
3. Stop changing files in `source/`.
4. Rename it temporarily to `source.legacy-backup`.
5. Rebuild and test LumaOps from the root.
6. Remove the legacy copy only after a successful validation period.
Do not delete the root `.git` directory. Also do not delete the nested `source/`
directory as part of the first ForgeFlow test. Treat its cleanup as a separate,
backed-up migration after the root deployment and rollback have both been
validated.
## Recommended ForgeFlow profile
```text
Provider: SSH / Unraid
Server folder: lumaops
Branch: main
Compose mode: Repository/server Compose
Compose file: docker-compose.yml
Clone URL: the Git URL reachable from Unraid
Healthcheck: the existing LumaOps health URL, when exposed
Preserve paths: .env, appdata, data, logs, config, compose.override.yml
```
No folder rename is required for LumaOps because `lumaops` already aligns with the repository name.
+15
View File
@@ -0,0 +1,15 @@
# ForgeFlow 0.10.15
## Veilige exacte workspace-sync en robuustere updates
- **Workspace Sync** brengt een repository gecontroleerd naar de exacte Gitea-commit zonder lokale wijzigingen stilzwijgend terug naar de server te sturen. Lokale commits krijgen een recovery branch en gewijzigde of niet-getrackte bestanden worden in een expliciete ForgeFlow-quarantaine bewaard.
- Elke quarantaine krijgt een lokaal **Codex review manifest** met bron- en doelcommit, recovery branch, stash-identiteit en betrokken bestanden. Quarantainestashes kunnen niet via de normale ForgeFlow-herstelactie in één keer worden teruggezet; eerst moet de inhoud gericht worden nagekeken.
- ForgeFlow behandelt `forgeflow/recovery-*` branches als **local-only** en weigert ze via de normale pushactie te publiceren, zodat herstelmateriaal niet per ongeluk opnieuw in Gitea terechtkomt.
- De source updater voert checksum- en Git-working-tree-preflight uit **voordat** ForgeFlow de update aan de externe helper overdraagt. Een Git-checkout wordt niet meer destructief met een bronarchief overschreven.
- De Windows binary updater controleert het nieuwe uitvoerbare bestand vóór de ownership handoff, verifieert na update dat ForgeFlow werkelijk blijft draaien en kan bij een mislukte portable update de vorige executable herstellen en opnieuw starten.
- Een geslaagde installer-update waarbij alleen de automatische herstart mislukt, wordt correct als geïnstalleerd gerapporteerd met een duidelijke instructie om ForgeFlow handmatig te starten.
## Verificatie
- Managed full validation op de sync/updater-hardening is geslaagd op de exacte feature-head en opnieuw als verplichte pull-requestvalidatie vóór merge.
- De merge naar `main` is uitgevoerd via de beschermde pull-requestflow; de releaseversie wordt afzonderlijk gevalideerd voordat 0.10.15 wordt gepubliceerd.
+11
View File
@@ -0,0 +1,11 @@
# ForgeFlow 0.10.16
## Signed releases from the public source repository
- Packaged updates follow the exact published release commit, so later documentation or source-only changes do not invalidate the signed executable.
- New installations check `Jens/ForgeFlow-Public` for Windows updates.
- Existing installations that still use the legacy `Jens/ForgeFlow` endpoint move to `ForgeFlow-Public` after installing this bridge release. An intentionally configured alternative update repository is preserved.
- A version change in the curated public source now starts the Windows quality, build, signing and release workflow. Source-only updates do not create another binary release.
- The publisher refuses to replace an already published version or attach assets built from a different commit. Release manifests continue to bind the executable checksums to the exact public source commit and the existing Ed25519 publisher key.
The legacy repository remains available for older installations until the bridge update has been verified in the field.
+2 -2
View File
@@ -17,9 +17,9 @@ for every repository. ForgeFlow now:
Example: Example:
```text ```text
Default project root: C:\Users\Jens\Projects Default project root: C:\Users\your-name\Projects
Gitea repository: Jens/Portfolio Gitea repository: Jens/Portfolio
Automatic target: C:\Users\Jens\Projects\Portfolio Automatic target: C:\Users\your-name\Projects\Portfolio
``` ```
A separate **Choose another location** action remains available for exceptional A separate **Choose another location** action remains available for exceptional
+2 -2
View File
@@ -48,9 +48,9 @@ Deployment profiles support:
- runtime-data preservation; - runtime-data preservation;
- rollback to the previous SHA. - rollback to the previous SHA.
## LumaOps audit ## Deployment migration example
The supplied server and Gitea roots both match commit `d42d4a7f08240c478d07466e3fabec654dc71367`. The root Git checkout and Compose file should remain. A stale nested `source/` Git checkout is documented for controlled cleanup. The documented migration flow keeps the root Git checkout and maintained Compose file in place, verifies the complete commit SHA and treats a stale nested `source/` checkout as separate, controlled cleanup.
## Validation ## Validation
+5 -4
View File
@@ -23,7 +23,7 @@ flexibility.
- session-only fallback when OS encryption is unavailable; - session-only fallback when OS encryption is unavailable;
- token omitted from renderer-visible public state; - token omitted from renderer-visible public state;
- encrypted token blob excluded from diagnostic bundles; - encrypted token blob excluded from diagnostic bundles;
- blank settings token field preserves the existing token; - a blank settings token field preserves the existing token only when the normalized Gitea origin is unchanged;
- atomic config replacement and restrictive permissions where supported; - atomic config replacement and restrictive permissions where supported;
- service URLs reject embedded user credentials; - service URLs reject embedded user credentials;
- no token is required by setup/build scripts or documentation. - no token is required by setup/build scripts or documentation.
@@ -128,9 +128,10 @@ included model uses:
require the exact user-confirmed pinned fingerprint; require the exact user-confirmed pinned fingerprint;
- remote folders and Compose paths are validated against traversal; - remote folders and Compose paths are validated against traversal;
- tracked server-side changes block exact-SHA reset; - tracked server-side changes block exact-SHA reset;
- updater tokens are sent only to the configured Gitea origin; - updater tokens are sent only to the configured Gitea origin, and changing that origin requires a newly entered token;
- non-loopback Gitea connections require HTTPS; - non-loopback Gitea connections require HTTPS;
- packaged updates require a publisher-signed Ed25519 manifest that binds the - packaged updates require a publisher-signed Ed25519 manifest that binds the
source commit, artifact identity, byte length and SHA-256 digest; source commit, artifact identity, byte length and SHA-256 digest;
- update archives are checksummed and validated by the full local quality gate; - packaged update bytes are rehashed immediately before apply;
- source backup is restored when an update fails. - integrated source replacement is disabled until source archives carry the
same independent publisher signature.
+11 -23
View File
@@ -2,31 +2,19 @@
ForgeFlow stores credentials, repository mappings, preferences, deployment profiles, diagnostics and operation history outside the source directory. ForgeFlow stores credentials, repository mappings, preferences, deployment profiles, diagnostics and operation history outside the source directory.
## Built-in source update ## Source checkouts
Open **Settings → ForgeFlow updates** and choose: Integrated source replacement is disabled until source archives are covered by the same independent publisher signature as packaged releases. A server-provided commit SHA and a checksum calculated from the downloaded archive do not independently authenticate its publisher, while dependency installation can execute package lifecycle scripts.
1. **Check now** Update a source checkout through Git instead:
2. **Download update**
3. **Apply & restart**
The default update source is the configured Gitea instance, repository `Jens/ForgeFlow`, branch `main`. 1. fetch the configured upstream;
2. review the exact commit and release notes;
3. switch to the intended release commit or tag;
4. run `npm ci --ignore-scripts` and review the dependency lifecycle allowlist;
5. run `npm run check` before starting ForgeFlow.
The updater pins the download to the exact remote commit, checks the archive SHA-256, starts an external PowerShell helper and waits for a structured `started` marker. ForgeFlow closes only after that marker exists. The helper then: The in-app updater remains available for signed packaged Windows releases.
1. waits for the old process to exit;
2. backs up the current source;
3. extracts and validates the requested semantic version;
4. mirrors the incoming source;
5. runs `npm ci --no-audit --no-fund` when the published release contains `package-lock.json`, otherwise a pinned direct-dependency `npm install`;
6. runs `npm run check`;
7. writes the successful installation result before restart;
8. launches the installed Electron executable directly;
9. persists `success`, `failed` or `rolled-back` state for the next launch.
A failed validation restores the previous source. A successful installation is not rolled back merely because automatic restart fails; start ForgeFlow manually and the persisted result is shown.
Update logs and status files are stored beneath ForgeFlow's local user-data `updates` folder and exclude the Gitea token.
## Packaged Windows updates ## Packaged Windows updates
@@ -65,13 +53,13 @@ The binary publisher refuses to upload when local `HEAD` differs from the config
Extract the complete source ZIP so this file exists: Extract the complete source ZIP so this file exists:
```text ```text
C:\Users\Jens\Downloads\ForgeFlow-<version>\ForgeFlow\package.json C:\Users\your-name\Downloads\ForgeFlow-<version>\ForgeFlow\package.json
``` ```
Run: Run:
```powershell ```powershell
cd C:\Users\Jens\Downloads\ForgeFlow-<version>\ForgeFlow cd C:\Users\your-name\Downloads\ForgeFlow-<version>\ForgeFlow
Set-ExecutionPolicy -Scope Process Bypass Set-ExecutionPolicy -Scope Process Bypass
.\Publish-ForgeFlow-Release.ps1 .\Publish-ForgeFlow-Release.ps1
``` ```
@@ -0,0 +1,90 @@
name: ForgeFlow approved deploy
on:
workflow_dispatch:
inputs:
repository:
description: Signed allowlisted deployment target (owner/repository)
required: true
type: string
environment:
description: Allowlisted ForgeFlow environment
required: true
type: string
commit_sha:
description: Exact approved commit SHA
required: true
type: string
request_id:
description: Immutable AppOps request identifier
required: true
type: string
approval_id:
description: AppOps approval identifier
required: true
type: string
approval_fingerprint:
description: Immutable AppOps approval fingerprint
required: true
type: string
evidence_issued_at:
description: Signed evidence UNIX timestamp
required: true
type: string
evidence_signature:
description: Base64 Ed25519 signature over the exact deployment evidence
required: true
type: string
concurrency:
group: forgeflow-approved-${{ inputs.repository }}-${{ inputs.environment }}
cancel-in-progress: false
jobs:
deploy:
runs-on: forgeflow
steps:
- name: Validate signed deployment inputs
shell: bash
env:
FF_REPOSITORY: ${{ inputs.repository }}
FF_ENVIRONMENT: ${{ inputs.environment }}
FF_COMMIT_SHA: ${{ inputs.commit_sha }}
FF_REQUEST_ID: ${{ inputs.request_id }}
FF_APPROVAL_ID: ${{ inputs.approval_id }}
FF_APPROVAL_FINGERPRINT: ${{ inputs.approval_fingerprint }}
FF_EVIDENCE_ISSUED_AT: ${{ inputs.evidence_issued_at }}
FF_EVIDENCE_SIGNATURE: ${{ inputs.evidence_signature }}
run: |
set -Eeuo pipefail
[[ "$FF_REPOSITORY" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]
[[ "$FF_ENVIRONMENT" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]]
[[ "$FF_COMMIT_SHA" =~ ^[0-9a-fA-F]{40,64}$ ]]
[[ "$FF_REQUEST_ID" =~ ^appr-[A-Za-z0-9._-]{1,75}$ ]]
[[ "$FF_APPROVAL_ID" == "$FF_REQUEST_ID" ]]
[[ "$FF_APPROVAL_FINGERPRINT" =~ ^[0-9a-f]{64}$ ]]
[[ "$FF_EVIDENCE_ISSUED_AT" =~ ^[0-9]{10,11}$ ]]
[[ "$FF_EVIDENCE_SIGNATURE" =~ ^[A-Za-z0-9+/]{86}==$ ]]
- name: Execute root-owned verified deployment
shell: bash
env:
FF_REPOSITORY: ${{ inputs.repository }}
FF_ENVIRONMENT: ${{ inputs.environment }}
FF_COMMIT_SHA: ${{ inputs.commit_sha }}
FF_REQUEST_ID: ${{ inputs.request_id }}
FF_APPROVAL_ID: ${{ inputs.approval_id }}
FF_APPROVAL_FINGERPRINT: ${{ inputs.approval_fingerprint }}
FF_EVIDENCE_ISSUED_AT: ${{ inputs.evidence_issued_at }}
FF_EVIDENCE_SIGNATURE: ${{ inputs.evidence_signature }}
run: |
set -Eeuo pipefail
sudo /usr/local/bin/forgeflow-deploy \
"$FF_REPOSITORY" \
"$FF_ENVIRONMENT" \
"$FF_COMMIT_SHA" \
"$FF_REQUEST_ID" \
"$FF_APPROVAL_ID" \
"$FF_APPROVAL_FINGERPRINT" \
"$FF_EVIDENCE_ISSUED_AT" \
"$FF_EVIDENCE_SIGNATURE"
+77 -1
View File
@@ -4,18 +4,29 @@ umask 027
# Install as /usr/local/bin/forgeflow-deploy, owned by root and not writable by # Install as /usr/local/bin/forgeflow-deploy, owned by root and not writable by
# the Gitea runner. Targets are read from the root-owned data file below. # the Gitea runner. Targets are read from the root-owned data file below.
# Approved machine deployments additionally verify an AppOps Ed25519 signature
# using the root-controlled public key; the Actions runner never receives that
# trust anchor's private key. Every verified approval id is consumed exactly
# once in a root-owned replay journal before target lookup or mutation.
readonly CONFIG_FILE="/etc/forgeflow/targets.conf" readonly CONFIG_FILE="/etc/forgeflow/targets.conf"
readonly EVIDENCE_PUBLIC_KEY_FILE="/etc/forgeflow/evidence.pub"
readonly EVIDENCE_REPLAY_DIR="/var/lib/forgeflow-status/approved-requests"
readonly REPOSITORY="${1:-}" readonly REPOSITORY="${1:-}"
readonly ENVIRONMENT="${2:-}" readonly ENVIRONMENT="${2:-}"
readonly SHA="${3:-}" readonly SHA="${3:-}"
readonly REQUEST_ID="${4:-manual-$(date +%s)}" readonly REQUEST_ID="${4:-manual-$(date +%s)}"
readonly APPROVAL_ID="${5:-}"
readonly APPROVAL_FINGERPRINT="${6:-}"
readonly EVIDENCE_ISSUED_AT="${7:-}"
readonly EVIDENCE_SIGNATURE="${8:-}"
fail_usage() { fail_usage() {
echo "Usage: forgeflow-deploy <owner/repository> <environment> <full-sha> [request-id]" >&2 echo "Usage: forgeflow-deploy <owner/repository> <environment> <full-sha> [request-id] [approval-id approval-fingerprint evidence-issued-at evidence-signature]" >&2
exit 64 exit 64
} }
(( $# == 3 || $# == 4 || $# == 8 )) || fail_usage
[[ "$REPOSITORY" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || fail_usage [[ "$REPOSITORY" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || fail_usage
[[ "$ENVIRONMENT" =~ ^[A-Za-z0-9._-]+$ ]] || fail_usage [[ "$ENVIRONMENT" =~ ^[A-Za-z0-9._-]+$ ]] || fail_usage
[[ "$SHA" =~ ^[0-9a-fA-F]{40,64}$ ]] || fail_usage [[ "$SHA" =~ ^[0-9a-fA-F]{40,64}$ ]] || fail_usage
@@ -29,6 +40,61 @@ config_mode="$(stat -c '%a' "$CONFIG_FILE")"
# Reject group/other write bits. GNU stat returns an octal string such as 640. # Reject group/other write bits. GNU stat returns an octal string such as 640.
(( (8#$config_mode & 8#022) == 0 )) || { echo "Target configuration may not be group/other writable" >&2; exit 78; } (( (8#$config_mode & 8#022) == 0 )) || { echo "Target configuration may not be group/other writable" >&2; exit 78; }
EVIDENCE_VERIFIED=false
if (( $# == 8 )); then
[[ "$REQUEST_ID" =~ ^appr-[A-Za-z0-9._-]{1,75}$ ]] || { echo "Approved deployment request ID is invalid" >&2; exit 64; }
[[ "$APPROVAL_ID" == "$REQUEST_ID" ]] || { echo "Approval ID must equal the immutable request ID" >&2; exit 65; }
[[ "$APPROVAL_FINGERPRINT" =~ ^[0-9a-f]{64}$ ]] || { echo "Approval fingerprint is invalid" >&2; exit 64; }
[[ "$EVIDENCE_ISSUED_AT" =~ ^[0-9]{10,11}$ ]] || { echo "Evidence timestamp is invalid" >&2; exit 64; }
[[ "$EVIDENCE_SIGNATURE" =~ ^[A-Za-z0-9+/]{86}==$ ]] || { echo "Evidence signature encoding is invalid" >&2; exit 64; }
[[ -f "$EVIDENCE_PUBLIC_KEY_FILE" ]] || { echo "Missing AppOps evidence public key: $EVIDENCE_PUBLIC_KEY_FILE" >&2; exit 78; }
evidence_owner="$(stat -c '%U' "$EVIDENCE_PUBLIC_KEY_FILE")"
evidence_mode="$(stat -c '%a' "$EVIDENCE_PUBLIC_KEY_FILE")"
[[ "$evidence_owner" == "root" ]] || { echo "Evidence public key must be owned by root" >&2; exit 78; }
(( (8#$evidence_mode & 8#022) == 0 )) || { echo "Evidence public key may not be group/other writable" >&2; exit 78; }
command -v openssl >/dev/null 2>&1 || { echo "OpenSSL is required for approved deployment evidence verification" >&2; exit 69; }
now_epoch="$(date +%s)"
(( EVIDENCE_ISSUED_AT <= now_epoch + 60 )) || { echo "Deployment evidence is issued too far in the future" >&2; exit 65; }
(( EVIDENCE_ISSUED_AT >= now_epoch - 1800 )) || { echo "Deployment evidence expired before execution" >&2; exit 65; }
evidence_tmp="$(mktemp -d /run/forgeflow-evidence.XXXXXX)"
cleanup_evidence() { rm -rf "$evidence_tmp"; }
trap cleanup_evidence EXIT
printf 'forgeflow-evidence-v1\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n' \
"$APPROVAL_ID" \
"$APPROVAL_FINGERPRINT" \
"$REPOSITORY" \
"$ENVIRONMENT" \
"${SHA,,}" \
"$REQUEST_ID" \
"$EVIDENCE_ISSUED_AT" > "$evidence_tmp/message"
printf '%s' "$EVIDENCE_SIGNATURE" | base64 --decode > "$evidence_tmp/signature" 2>/dev/null || {
echo "Deployment evidence signature could not be decoded" >&2
exit 65
}
openssl pkeyutl -verify \
-pubin \
-inkey "$EVIDENCE_PUBLIC_KEY_FILE" \
-rawin \
-in "$evidence_tmp/message" \
-sigfile "$evidence_tmp/signature" >/dev/null 2>&1 || {
echo "Deployment evidence signature verification failed" >&2
exit 65
}
# Consume the verified approval before any target lookup. mkdir is atomic,
# making this a cross-process replay fence. A failed first deployment still
# requires a fresh human approval, matching AppOps' terminal execution model.
install -d -o root -g root -m 0700 "$EVIDENCE_REPLAY_DIR"
if ! mkdir -m 0700 "$EVIDENCE_REPLAY_DIR/$APPROVAL_ID" 2>/dev/null; then
echo "Approved deployment evidence was already consumed" >&2
exit 65
fi
EVIDENCE_VERIFIED=true
fi
APP_DIR="" APP_DIR=""
BRANCH="" BRANCH=""
COMPOSE_FILE="" COMPOSE_FILE=""
@@ -75,6 +141,9 @@ write_status() {
temporary="${STATUS_FILE}.${$}.tmp" temporary="${STATUS_FILE}.${$}.tmp"
json_string "$health" >/dev/null json_string "$health" >/dev/null
json_string "$REQUEST_ID" >/dev/null json_string "$REQUEST_ID" >/dev/null
json_string "$APPROVAL_ID" >/dev/null
json_string "$APPROVAL_FINGERPRINT" >/dev/null
json_string "$EVIDENCE_ISSUED_AT" >/dev/null
[[ "$live_sha" =~ ^[0-9a-fA-F]{40,64}$ ]] || { echo "Invalid live SHA for status output" >&2; return 1; } [[ "$live_sha" =~ ^[0-9a-fA-F]{40,64}$ ]] || { echo "Invalid live SHA for status output" >&2; return 1; }
[[ "$previous_sha" =~ ^[0-9a-fA-F]{40,64}$ ]] || { echo "Invalid previous SHA for status output" >&2; return 1; } [[ "$previous_sha" =~ ^[0-9a-fA-F]{40,64}$ ]] || { echo "Invalid previous SHA for status output" >&2; return 1; }
cat > "$temporary" <<JSON cat > "$temporary" <<JSON
@@ -85,6 +154,10 @@ write_status() {
"commit_sha": "$live_sha", "commit_sha": "$live_sha",
"previous_sha": "$previous_sha", "previous_sha": "$previous_sha",
"requested_sha": "$SHA", "requested_sha": "$SHA",
"approval_id": "$APPROVAL_ID",
"approval_fingerprint": "$APPROVAL_FINGERPRINT",
"evidence_verified": $EVIDENCE_VERIFIED,
"evidence_issued_at": "$EVIDENCE_ISSUED_AT",
"deployed_at": "$deployed_at", "deployed_at": "$deployed_at",
"health": "$health", "health": "$health",
"last_exit_code": $exit_code "last_exit_code": $exit_code
@@ -105,6 +178,9 @@ echo "ForgeFlow request: $REQUEST_ID"
echo "Target: $REPOSITORY / $ENVIRONMENT" echo "Target: $REPOSITORY / $ENVIRONMENT"
echo "Current SHA: $current_sha" echo "Current SHA: $current_sha"
echo "Requested SHA: $SHA" echo "Requested SHA: $SHA"
if [[ "$EVIDENCE_VERIFIED" == "true" ]]; then
echo "Approval: $APPROVAL_ID (signed evidence verified)"
fi
on_error() { on_error() {
local exit_code=$? local exit_code=$?
+6 -2
View File
@@ -1,11 +1,15 @@
{ {
"repository": "jens/example-app", "repository": "jens/example-app",
"environment": "production", "environment": "production",
"request_id": "3a6ed71c-d52d-4d8d-9678-96e0c9456a81", "request_id": "appr-3a6ed71cd52d",
"commit_sha": "0123456789abcdef0123456789abcdef01234567", "commit_sha": "0123456789abcdef0123456789abcdef01234567",
"previous_sha": "89abcdef0123456789abcdef0123456789abcdef", "previous_sha": "89abcdef0123456789abcdef0123456789abcdef",
"requested_sha": "0123456789abcdef0123456789abcdef01234567", "requested_sha": "0123456789abcdef0123456789abcdef01234567",
"deployed_at": "2026-07-24T13:00:00Z", "approval_id": "appr-3a6ed71cd52d",
"approval_fingerprint": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"evidence_verified": true,
"evidence_issued_at": "1787778000",
"deployed_at": "2026-08-26T21:00:00Z",
"health": "healthy", "health": "healthy",
"last_exit_code": 0 "last_exit_code": 0
} }
+35 -35
View File
@@ -1,12 +1,12 @@
{ {
"name": "forgeflow", "name": "forgeflow",
"version": "0.10.14", "version": "0.10.16",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "forgeflow", "name": "forgeflow",
"version": "0.10.14", "version": "0.10.16",
"dependencies": { "dependencies": {
"ssh2": "1.17.0" "ssh2": "1.17.0"
}, },
@@ -68,9 +68,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@electron/asar/node_modules/brace-expansion": { "node_modules/@electron/asar/node_modules/brace-expansion": {
"version": "1.1.17", "version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.17.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-w+aeW/mkgM4PyRMOJCgi3fOrTm5Q8QY1OSfn2TO2iuDj3ezIHqejmuxbjfPrqUkgqRew1iqkyAn0tr0ZwHD9+w==", "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -257,9 +257,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@electron/universal/node_modules/brace-expansion": { "node_modules/@electron/universal/node_modules/brace-expansion": {
"version": "2.1.3", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.3.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-DRdx5neNsG/QXbniLFWi2YmC/68oeOOmKz6zOjVk6ZS1ZLXgLIKqVEc6hWsmkjBbgii0SwaBTcJ5XKj5gzY/4A==", "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -401,9 +401,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@eslint/config-array/node_modules/brace-expansion": { "node_modules/@eslint/config-array/node_modules/brace-expansion": {
"version": "1.1.17", "version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.17.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-w+aeW/mkgM4PyRMOJCgi3fOrTm5Q8QY1OSfn2TO2iuDj3ezIHqejmuxbjfPrqUkgqRew1iqkyAn0tr0ZwHD9+w==", "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -482,9 +482,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": { "node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
"version": "1.1.17", "version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.17.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-w+aeW/mkgM4PyRMOJCgi3fOrTm5Q8QY1OSfn2TO2iuDj3ezIHqejmuxbjfPrqUkgqRew1iqkyAn0tr0ZwHD9+w==", "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1357,9 +1357,9 @@
"optional": true "optional": true
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.8", "version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2014,9 +2014,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/dir-compare/node_modules/brace-expansion": { "node_modules/dir-compare/node_modules/brace-expansion": {
"version": "1.1.17", "version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.17.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-w+aeW/mkgM4PyRMOJCgi3fOrTm5Q8QY1OSfn2TO2iuDj3ezIHqejmuxbjfPrqUkgqRew1iqkyAn0tr0ZwHD9+w==", "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2471,9 +2471,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/eslint/node_modules/brace-expansion": { "node_modules/eslint/node_modules/brace-expansion": {
"version": "1.1.17", "version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.17.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-w+aeW/mkgM4PyRMOJCgi3fOrTm5Q8QY1OSfn2TO2iuDj3ezIHqejmuxbjfPrqUkgqRew1iqkyAn0tr0ZwHD9+w==", "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2587,9 +2587,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.4", "version": "3.1.6",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz",
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -2652,9 +2652,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/filelist/node_modules/brace-expansion": { "node_modules/filelist/node_modules/brace-expansion": {
"version": "2.1.3", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.3.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-DRdx5neNsG/QXbniLFWi2YmC/68oeOOmKz6zOjVk6ZS1ZLXgLIKqVEc6hWsmkjBbgii0SwaBTcJ5XKj5gzY/4A==", "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2927,9 +2927,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/glob/node_modules/brace-expansion": { "node_modules/glob/node_modules/brace-expansion": {
"version": "1.1.17", "version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.17.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-w+aeW/mkgM4PyRMOJCgi3fOrTm5Q8QY1OSfn2TO2iuDj3ezIHqejmuxbjfPrqUkgqRew1iqkyAn0tr0ZwHD9+w==", "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3365,9 +3365,9 @@
} }
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.3.0", "version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
+6 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "forgeflow", "name": "forgeflow",
"version": "0.10.14", "version": "0.10.16",
"private": true, "private": true,
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.", "description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
"main": "main.cjs", "main": "main.cjs",
@@ -9,9 +9,9 @@
"start": "electron .", "start": "electron .",
"dev": "electron . --dev", "dev": "electron . --dev",
"demo": "node scripts/serve-demo.mjs", "demo": "node scripts/serve-demo.mjs",
"test": "node --test tests/*.test.mjs", "test": "node --test --test-concurrency=1 tests/*.test.mjs",
"lint": "eslint .", "lint": "eslint .",
"coverage": "c8 --check-coverage --lines 85 --functions 85 --branches 68 --statements 85 node --test tests/*.test.mjs && npm run coverage:modules", "coverage": "c8 --check-coverage --lines 85 --functions 85 --branches 68 --statements 85 node --test --test-concurrency=1 tests/*.test.mjs && npm run coverage:modules",
"coverage:modules": "c8 report --check-coverage --per-file --include src/** --statements 60 --lines 60 --functions 50 --branches 36 --reporter=text-summary", "coverage:modules": "c8 report --check-coverage --per-file --include src/** --statements 60 --lines 60 --functions 50 --branches 36 --reporter=text-summary",
"verify": "node scripts/verify.mjs", "verify": "node scripts/verify.mjs",
"dist:win": "electron-builder --win nsis portable && node scripts/write-release-checksums.mjs && node scripts/sign-release-manifest.mjs && node scripts/verify-release-signatures.mjs && node scripts/prune-dist.mjs", "dist:win": "electron-builder --win nsis portable && node scripts/write-release-checksums.mjs && node scripts/sign-release-manifest.mjs && node scripts/verify-release-signatures.mjs && node scripts/prune-dist.mjs",
@@ -71,7 +71,7 @@
"scripts/prune-dist.mjs", "scripts/prune-dist.mjs",
"scripts/sign-release-manifest.mjs", "scripts/sign-release-manifest.mjs",
"docs/RELEASE_NOTES_0.4.0.md", "docs/RELEASE_NOTES_0.4.0.md",
"docs/LUMAOPS_SERVER_AUDIT.md", "docs/DEPLOYMENT_MIGRATION_EXAMPLE.md",
"docs/SSH_UNRAID_DEPLOYMENT.md", "docs/SSH_UNRAID_DEPLOYMENT.md",
"docs/RELEASE_NOTES_0.4.1.md", "docs/RELEASE_NOTES_0.4.1.md",
"docs/RELEASE_NOTES_0.4.2.md", "docs/RELEASE_NOTES_0.4.2.md",
@@ -123,6 +123,8 @@
"docs/RELEASE_NOTES_0.10.12.md", "docs/RELEASE_NOTES_0.10.12.md",
"docs/RELEASE_NOTES_0.10.13.md", "docs/RELEASE_NOTES_0.10.13.md",
"docs/RELEASE_NOTES_0.10.14.md", "docs/RELEASE_NOTES_0.10.14.md",
"docs/RELEASE_NOTES_0.10.15.md",
"docs/RELEASE_NOTES_0.10.16.md",
"docs/CURRENT_STATE.md", "docs/CURRENT_STATE.md",
"docs/MUTATION_MODEL.md", "docs/MUTATION_MODEL.md",
"docs/RELEASING.md", "docs/RELEASING.md",
-1
View File
@@ -120,7 +120,6 @@ contextBridge.exposeInMainWorld(
overrideReason: options.overrideReason || '', overrideReason: options.overrideReason || '',
}), }),
rollback: (repository, profileId, targetSha) => invoke('deployment:rollback', { repository, profileId, targetSha }), rollback: (repository, profileId, targetSha) => invoke('deployment:rollback', { repository, profileId, targetSha }),
healthcheck: (url) => invoke('deployment:health', { url }),
refreshProfileState: (fullName, profileId) => invoke('deployment:profile-state', { fullName, profileId }), refreshProfileState: (fullName, profileId) => invoke('deployment:profile-state', { fullName, profileId }),
discoverServerDeployments: () => invoke('deployment:discover-server-workloads'), discoverServerDeployments: () => invoke('deployment:discover-server-workloads'),
planServerReconciliation: (serverId) => invoke('deployment:plan-server-reconciliation', { serverId }), planServerReconciliation: (serverId) => invoke('deployment:plan-server-reconciliation', { serverId }),
+14 -14
View File
@@ -1,5 +1,5 @@
{ {
"generatedAt": "2026-08-26T23:19:01.915Z", "generatedAt": "2026-08-29T22:58:20.416Z",
"thresholds": { "thresholds": {
"preferredMaximumLines": 750, "preferredMaximumLines": 750,
"justificationRequiredLines": 1000 "justificationRequiredLines": 1000
@@ -7,9 +7,9 @@
"over750": [ "over750": [
{ {
"file": "src/main/git-service.cjs", "file": "src/main/git-service.cjs",
"lines": 881, "lines": 950,
"branches": 134, "branches": 139,
"functions": 147, "functions": 152,
"ipcHandlers": 0, "ipcHandlers": 0,
"responsibilities": [ "responsibilities": [
"git", "git",
@@ -17,7 +17,7 @@
"security", "security",
"updates" "updates"
], ],
"hotspotScore": 154 "hotspotScore": 159
}, },
{ {
"file": "src/renderer/views.js", "file": "src/renderer/views.js",
@@ -67,9 +67,9 @@
"cyclomaticHotspots": [ "cyclomaticHotspots": [
{ {
"file": "src/main/git-service.cjs", "file": "src/main/git-service.cjs",
"lines": 881, "lines": 950,
"branches": 134, "branches": 139,
"functions": 147, "functions": 152,
"ipcHandlers": 0, "ipcHandlers": 0,
"responsibilities": [ "responsibilities": [
"git", "git",
@@ -77,7 +77,7 @@
"security", "security",
"updates" "updates"
], ],
"hotspotScore": 154 "hotspotScore": 159
}, },
{ {
"file": "src/renderer/actions/shell.js", "file": "src/renderer/actions/shell.js",
@@ -144,9 +144,9 @@
"mixedResponsibilityModules": [ "mixedResponsibilityModules": [
{ {
"file": "src/main/git-service.cjs", "file": "src/main/git-service.cjs",
"lines": 881, "lines": 950,
"branches": 134, "branches": 139,
"functions": 147, "functions": 152,
"ipcHandlers": 0, "ipcHandlers": 0,
"responsibilities": [ "responsibilities": [
"git", "git",
@@ -154,7 +154,7 @@
"security", "security",
"updates" "updates"
], ],
"hotspotScore": 154 "hotspotScore": 159
}, },
{ {
"file": "src/renderer/actions/shell.js", "file": "src/renderer/actions/shell.js",
@@ -357,7 +357,7 @@
}, },
{ {
"file": "src/renderer/actions/recovery.js", "file": "src/renderer/actions/recovery.js",
"lines": 421, "lines": 422,
"branches": 64, "branches": 64,
"functions": 43, "functions": 43,
"ipcHandlers": 0, "ipcHandlers": 0,
+3 -3
View File
@@ -1,12 +1,12 @@
# ForgeFlow architecture audit # ForgeFlow architecture audit
Generated 2026-08-26T23:19:01.915Z. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity. Generated 2026-08-29T22:58:20.416Z. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity.
## Files above 750 lines ## Files above 750 lines
| File | Lines | Decisions | Functions | IPC handlers | Responsibilities | | File | Lines | Decisions | Functions | IPC handlers | Responsibilities |
|---|---:|---:|---:|---:|---| |---|---:|---:|---:|---:|---|
| `src/main/git-service.cjs` | 881 | 134 | 147 | 0 | git, renderer, security, updates | | `src/main/git-service.cjs` | 950 | 139 | 152 | 0 | git, renderer, security, updates |
| `src/renderer/views.js` | 876 | 61 | 161 | 0 | inventory, deployment, git, renderer, security, updates | | `src/renderer/views.js` | 876 | 61 | 161 | 0 | inventory, deployment, git, renderer, security, updates |
| `src/main/update-service.cjs` | 854 | 64 | 65 | 0 | git, security, updates | | `src/main/update-service.cjs` | 854 | 64 | 65 | 0 | git, security, updates |
| `src/renderer/mock-repository-bridge.js` | 780 | 19 | 100 | 0 | deployment, git, security, updates | | `src/renderer/mock-repository-bridge.js` | 780 | 19 | 100 | 0 | deployment, git, security, updates |
@@ -21,7 +21,7 @@ No findings.
| File | Lines | Decisions | Functions | IPC handlers | Responsibilities | | File | Lines | Decisions | Functions | IPC handlers | Responsibilities |
|---|---:|---:|---:|---:|---| |---|---:|---:|---:|---:|---|
| `src/main/git-service.cjs` | 881 | 134 | 147 | 0 | git, renderer, security, updates | | `src/main/git-service.cjs` | 950 | 139 | 152 | 0 | git, renderer, security, updates |
| `src/renderer/actions/shell.js` | 531 | 103 | 90 | 0 | inventory, deployment, git, renderer, updates | | `src/renderer/actions/shell.js` | 531 | 103 | 90 | 0 | inventory, deployment, git, renderer, updates |
| `src/renderer/app.js` | 738 | 80 | 124 | 0 | inventory, deployment, git, renderer, security, updates | | `src/renderer/app.js` | 738 | 80 | 124 | 0 | inventory, deployment, git, renderer, security, updates |
| `src/main/server-inventory.cjs` | 578 | 89 | 104 | 0 | inventory, deployment, git, security, updates | | `src/main/server-inventory.cjs` | 578 | 89 | 104 | 0 | inventory, deployment, git, security, updates |
+41 -8
View File
@@ -63,16 +63,24 @@ function Get-Sha256([string]$Path) {
} }
} }
function Start-ForgeFlowAndVerify([string]$Executable) {
$process = Start-Process -FilePath $Executable -WorkingDirectory (Split-Path -Parent $Executable) -PassThru
Start-Sleep -Milliseconds 1500
if (-not $process -or $process.HasExited) { throw "ForgeFlow restart process exited before the application could stay running." }
return $process
}
try { try {
Write-UpdateState -State "started" -Message "Binary updater owns the update request."
Write-Log "Validating ForgeFlow $ExpectedVersion binary update." Write-Log "Validating ForgeFlow $ExpectedVersion binary update."
if ($HandshakeOnly) { if ($HandshakeOnly) {
Write-UpdateState -State "started" -Message "Binary updater owns the update request."
Write-Log "Handshake-only verification completed successfully." Write-Log "Handshake-only verification completed successfully."
exit 0 exit 0
} }
$actualSha256 = Get-Sha256 -Path $BinaryPath $actualSha256 = Get-Sha256 -Path $BinaryPath
if ($actualSha256 -ne $ExpectedSha256.ToLowerInvariant()) { throw "Binary update SHA-256 verification failed." } if ($actualSha256 -ne $ExpectedSha256.ToLowerInvariant()) { throw "Binary update SHA-256 verification failed." }
if (-not (Test-Path -LiteralPath $CurrentExecutable -PathType Leaf)) { throw "Current ForgeFlow executable was not found." } if (-not (Test-Path -LiteralPath $CurrentExecutable -PathType Leaf)) { throw "Current ForgeFlow executable was not found." }
Write-UpdateState -State "started" -Message "Binary preflight passed; updater owns the update request."
if ($VerifyOnly) { if ($VerifyOnly) {
Write-Log "Verification-only SHA-256 check completed successfully." Write-Log "Verification-only SHA-256 check completed successfully."
exit 0 exit 0
@@ -90,9 +98,16 @@ try {
try { try {
Copy-Item -LiteralPath $BinaryPath -Destination $CurrentExecutable -Force Copy-Item -LiteralPath $BinaryPath -Destination $CurrentExecutable -Force
} catch { } catch {
Copy-Item -LiteralPath $backupPath -Destination $CurrentExecutable -Force $copyFailure = $_.Exception.Message
Write-UpdateState -State "rolled-back" -Message $_.Exception.Message try {
throw Copy-Item -LiteralPath $backupPath -Destination $CurrentExecutable -Force
$rollbackRestart = Start-ForgeFlowAndVerify -Executable $CurrentExecutable
Write-Log "Portable replacement failed; previous ForgeFlow restored and restarted as PID $($rollbackRestart.Id)."
Write-UpdateState -State "rolled-back" -Message $copyFailure -RestartLaunched $true
} catch {
Write-UpdateState -State "failed" -Message "$copyFailure Rollback also failed: $($_.Exception.Message)" -RestartLaunched $false
}
throw $copyFailure
} }
} else { } else {
Write-UpdateState -State "applying" -Message "Running the verified ForgeFlow installer." Write-UpdateState -State "applying" -Message "Running the verified ForgeFlow installer."
@@ -100,13 +115,31 @@ try {
if ($installer.ExitCode -ne 0) { throw "ForgeFlow installer exited with code $($installer.ExitCode)." } if ($installer.ExitCode -ne 0) { throw "ForgeFlow installer exited with code $($installer.ExitCode)." }
} }
$restart = Start-Process -FilePath $CurrentExecutable -WorkingDirectory (Split-Path -Parent $CurrentExecutable) -PassThru try {
Write-Log "ForgeFlow $ExpectedVersion installed; restart PID $($restart.Id)." $restart = Start-ForgeFlowAndVerify -Executable $CurrentExecutable
Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion installed successfully." -RestartLaunched $true Write-Log "ForgeFlow $ExpectedVersion installed; verified restart PID $($restart.Id)."
Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion installed successfully." -RestartLaunched $true
} catch {
$restartFailure = $_.Exception.Message
if ($isPortable -and $backupPath -and (Test-Path -LiteralPath $backupPath -PathType Leaf)) {
Write-Log "Updated portable executable failed its restart probe; restoring the previous executable."
try {
Copy-Item -LiteralPath $backupPath -Destination $CurrentExecutable -Force
$rollbackRestart = Start-ForgeFlowAndVerify -Executable $CurrentExecutable
Write-Log "Previous ForgeFlow restored and restarted as PID $($rollbackRestart.Id)."
Write-UpdateState -State "rolled-back" -Message $restartFailure -RestartLaunched $true
} catch {
Write-UpdateState -State "failed" -Message "$restartFailure Rollback also failed: $($_.Exception.Message)" -RestartLaunched $false
}
exit 1
}
Write-Log "ForgeFlow $ExpectedVersion installed, but automatic restart failed: $restartFailure"
Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion installed successfully, but must be started manually." -RestartLaunched $false
}
} catch { } catch {
Write-Log $_.Exception.Message Write-Log $_.Exception.Message
$current = $null $current = $null
try { $current = Get-Content -LiteralPath $StatusPath -Raw | ConvertFrom-Json } catch {} try { $current = Get-Content -LiteralPath $StatusPath -Raw | ConvertFrom-Json } catch {}
if ($current.state -ne "rolled-back") { Write-UpdateState -State "failed" -Message $_.Exception.Message } if ($current.state -notin @("rolled-back", "failed")) { Write-UpdateState -State "failed" -Message $_.Exception.Message }
exit 1 exit 1
} }
+8 -4
View File
@@ -117,13 +117,20 @@ function Start-ForgeFlow {
try { try {
Write-UpdateLog "ForgeFlow source update helper started for version $ExpectedVersion." Write-UpdateLog "ForgeFlow source update helper started for version $ExpectedVersion."
Write-UpdateState -State "started" -Message "The external update helper started successfully." -Extra @{ helperPid = $PID; startedAt = (Get-Date).ToUniversalTime().ToString("o") }
if ($HandshakeOnly) { if ($HandshakeOnly) {
Write-UpdateState -State "started" -Message "The external update helper started successfully." -Extra @{ helperPid = $PID; startedAt = (Get-Date).ToUniversalTime().ToString("o") }
Write-UpdateLog "Handshake-only verification completed successfully." Write-UpdateLog "Handshake-only verification completed successfully."
exit 0 exit 0
} }
if (Test-Path -LiteralPath (Join-Path $SourcePath ".git")) {
throw "Integrated source update refuses to overwrite a Git working tree. Use normal Git/ForgeFlow workspace sync so local commits and dirty files remain reviewable."
}
$actualHash = Get-Sha256 -Path $ArchivePath
if ($actualHash -ne $ExpectedSha256.ToLowerInvariant()) { throw "Update archive checksum mismatch." }
Write-UpdateState -State "started" -Message "Source update preflight passed; the external helper owns the request." -Extra @{ helperPid = $PID; startedAt = (Get-Date).ToUniversalTime().ToString("o") }
Write-UpdateState -State "waiting-for-exit" -Message "Waiting for the running ForgeFlow process to exit." Write-UpdateState -State "waiting-for-exit" -Message "Waiting for the running ForgeFlow process to exit."
$deadline = (Get-Date).AddMinutes(2) $deadline = (Get-Date).AddMinutes(2)
while (Get-Process -Id $ParentPid -ErrorAction SilentlyContinue) { while (Get-Process -Id $ParentPid -ErrorAction SilentlyContinue) {
@@ -131,9 +138,6 @@ try {
Start-Sleep -Milliseconds 500 Start-Sleep -Milliseconds 500
} }
$actualHash = Get-Sha256 -Path $ArchivePath
if ($actualHash -ne $ExpectedSha256.ToLowerInvariant()) { throw "Update archive checksum mismatch." }
$working = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-update-" + [guid]::NewGuid().ToString("N")) $working = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-update-" + [guid]::NewGuid().ToString("N"))
$extract = Join-Path $working "extract" $extract = Join-Path $working "extract"
$backup = Join-Path $working "backup" $backup = Join-Path $working "backup"
+27 -11
View File
@@ -1,28 +1,44 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { readdir, readFile, stat, writeFile } from 'node:fs/promises'; import { execFile } from 'node:child_process';
import { readFile, stat, writeFile } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { promisify } from 'node:util';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const excludedDirectories = new Set(['.git', '.forgeflow', 'artifacts', 'coverage', 'dist', 'node_modules', 'playwright-report', 'ForgeFlow-runtime-win-x64']);
const excludedFiles = new Set(['SOURCE_MANIFEST.txt']); const excludedFiles = new Set(['SOURCE_MANIFEST.txt']);
const execFileAsync = promisify(execFile);
async function collect(directory, output = []) { async function collect() {
for (const entry of await readdir(directory, { withFileTypes: true })) { const { stdout } = await execFileAsync(
if (excludedDirectories.has(entry.name)) continue; 'git',
const absolute = path.join(directory, entry.name); ['ls-files', '--cached', '--others', '--exclude-standard', '-z'],
if (entry.isDirectory()) await collect(absolute, output); { cwd: root, encoding: 'buffer', maxBuffer: 16 * 1024 * 1024 },
else if (!excludedFiles.has(entry.name)) output.push(absolute); );
const relativePaths = stdout
.toString('utf8')
.split('\0')
.filter(Boolean)
.filter((relative) => !excludedFiles.has(relative));
const existing = [];
for (const relative of relativePaths) {
const absolute = path.resolve(root, relative);
try {
if ((await stat(absolute)).isFile()) existing.push(absolute);
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
} }
return output; return existing;
} }
const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')); const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
const files = (await collect(root)).sort((left, right) => left.localeCompare(right, 'en')); const files = (await collect()).sort((left, right) => left.localeCompare(right, 'en'));
const lines = [ const lines = [
`ForgeFlow ${packageJson.version} source manifest`, `ForgeFlow ${packageJson.version} source manifest`,
'SHA-256 BYTES PATH', 'SHA-256 BYTES PATH',
'(The manifest excludes itself, dependencies and generated release artifacts.)' '(The manifest includes tracked and non-ignored source files, excluding itself.)'
]; ];
for (const absolute of files) { for (const absolute of files) {
+79 -31
View File
@@ -5,6 +5,7 @@ const path = require("node:path");
const { execFileSync } = require("node:child_process"); const { execFileSync } = require("node:child_process");
const { app, safeStorage } = require("electron"); const { app, safeStorage } = require("electron");
const { normalizeBaseUrl } = require("../src/shared/validation.cjs"); const { normalizeBaseUrl } = require("../src/shared/validation.cjs");
const { existingReleaseState } = require("../src/shared/release-policy.cjs");
const root = path.resolve(__dirname, ".."); const root = path.resolve(__dirname, "..");
const configuredUserData = const configuredUserData =
@@ -20,6 +21,15 @@ function safeRepositoryPart(value, label) {
return text; return text;
} }
async function readOptionalConfig(configPath) {
try {
return JSON.parse(await fs.readFile(configPath, "utf8"));
} catch (error) {
if (error.code === "ENOENT") return null;
throw error;
}
}
async function api(baseUrl, token, pathname, options = {}) { async function api(baseUrl, token, pathname, options = {}) {
const response = await fetch(`${baseUrl}/api/v1${pathname}`, { const response = await fetch(`${baseUrl}/api/v1${pathname}`, {
...options, ...options,
@@ -51,22 +61,35 @@ app.whenReady().then(async () => {
await fs.readFile(path.join(root, "package.json"), "utf8"), await fs.readFile(path.join(root, "package.json"), "utf8"),
); );
const configPath = path.join(configuredUserData, "forgeflow-config.json"); const configPath = path.join(configuredUserData, "forgeflow-config.json");
const config = JSON.parse(await fs.readFile(configPath, "utf8")); const config = (await readOptionalConfig(configPath)) || {};
if (!config?.gitea?.encryptedToken) { const actionsToken = String(
throw new Error( process.env.GITEA_TOKEN || process.env.FORGEFLOW_RELEASE_TOKEN || "",
`No encrypted Gitea token was found in ${configPath}. Sign in to Gitea once from ForgeFlow first.`, ).trim();
let token = actionsToken;
if (!token) {
if (!config.gitea?.encryptedToken) {
throw new Error(
`No release token was supplied and no encrypted Gitea token was found in ${configPath}. Sign in to Gitea once from ForgeFlow or run from Gitea Actions with GITEA_TOKEN.`,
);
}
token = safeStorage.decryptString(
Buffer.from(config.gitea.encryptedToken, "base64"),
); );
} }
const token = safeStorage.decryptString( const configuredBaseUrl =
Buffer.from(config.gitea.encryptedToken, "base64"), process.env.FORGEFLOW_RELEASE_BASE_URL || config.gitea?.baseUrl;
); if (!configuredBaseUrl) {
const baseUrl = normalizeBaseUrl(config.gitea.baseUrl); throw new Error(
"No Gitea release base URL was supplied. Set FORGEFLOW_RELEASE_BASE_URL or configure Gitea in ForgeFlow.",
);
}
const baseUrl = normalizeBaseUrl(configuredBaseUrl);
const owner = safeRepositoryPart( const owner = safeRepositoryPart(
process.env.FORGEFLOW_RELEASE_OWNER || config.updates?.owner || "Jens", process.env.FORGEFLOW_RELEASE_OWNER || config.updates?.owner || "Jens",
"Release repository owner", "Release repository owner",
); );
const repo = safeRepositoryPart( const repo = safeRepositoryPart(
process.env.FORGEFLOW_RELEASE_REPO || config.updates?.repo || "ForgeFlow", process.env.FORGEFLOW_RELEASE_REPO || config.updates?.repo || "ForgeFlow-Public",
"Release repository name", "Release repository name",
); );
const branch = safeRepositoryPart( const branch = safeRepositoryPart(
@@ -121,12 +144,10 @@ app.whenReady().then(async () => {
); );
} }
if (release.draft !== true) { if (existingReleaseState(release, version, commit) === "published") {
release = await api(baseUrl, token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}`, { console.log(`PASS ForgeFlow ${version} is already published for ${commit.slice(0, 7)}`);
method: "PATCH", app.exit(0);
headers: { "Content-Type": "application/json" }, return;
body: JSON.stringify({ draft: true }),
});
} }
const binaries = [ const binaries = [
path.join(root, "dist", `ForgeFlow-Setup-${version}-win-x64.exe`), path.join(root, "dist", `ForgeFlow-Setup-${version}-win-x64.exe`),
@@ -145,10 +166,6 @@ app.whenReady().then(async () => {
const existing = (release.assets || []).find( const existing = (release.assets || []).find(
(asset) => asset.name === name, (asset) => asset.name === name,
); );
if (existing && Number(existing.size) === bytes.length) {
console.log(`SKIP ${name} already published`);
continue;
}
if (existing) { if (existing) {
await api( await api(
baseUrl, baseUrl,
@@ -183,27 +200,58 @@ app.whenReady().then(async () => {
[`ForgeFlow-${version}-release-manifest.json.sig`, "application/octet-stream"], [`ForgeFlow-${version}-release-manifest.json.sig`, "application/octet-stream"],
]) { ]) {
const bytes = await fs.readFile(path.join(root, "dist", name)); const bytes = await fs.readFile(path.join(root, "dist", name));
const existing = (release.assets || []).find((asset) => asset.name === name); const existing = (release.assets || []).find(
if (existing) await api(baseUrl, token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets/${existing.id}`, { method: "DELETE" }); (asset) => asset.name === name,
);
if (existing) {
await api(
baseUrl,
token,
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets/${existing.id}`,
{ method: "DELETE" },
);
}
const form = new FormData(); const form = new FormData();
form.append("attachment", new Blob([bytes], { type }), name); form.append("attachment", new Blob([bytes], { type }), name);
const uploaded = await api(baseUrl, token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets?name=${encodeURIComponent(name)}`, { method: "POST", body: form, timeout: 300_000 }); const uploaded = await api(
release.assets = [...(release.assets || []).filter((asset) => asset.name !== name), uploaded]; baseUrl,
token,
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets?name=${encodeURIComponent(name)}`,
{ method: "POST", body: form, timeout: 300_000 },
);
release.assets = [
...(release.assets || []).filter((asset) => asset.name !== name),
uploaded,
];
} }
const requiredAssets = [ const requiredAssets = [
...binaries.flatMap((binaryPath) => [path.basename(binaryPath), `${path.basename(binaryPath)}.sha256`]), ...binaries.flatMap((binaryPath) => [
path.basename(binaryPath),
`${path.basename(binaryPath)}.sha256`,
]),
`ForgeFlow-${version}-provenance.json`, `ForgeFlow-${version}-provenance.json`,
`ForgeFlow-${version}-sbom.cdx.json`, `ForgeFlow-${version}-sbom.cdx.json`,
`ForgeFlow-${version}-release-manifest.json`, `ForgeFlow-${version}-release-manifest.json`,
`ForgeFlow-${version}-release-manifest.json.sig`, `ForgeFlow-${version}-release-manifest.json.sig`,
]; ];
const missingAssets = requiredAssets.filter((name) => !(release.assets || []).some((asset) => asset.name === name)); const missingAssets = requiredAssets.filter(
if (missingAssets.length) throw new Error(`Release remains draft because required assets are missing: ${missingAssets.join(", ")}`); (name) => !(release.assets || []).some((asset) => asset.name === name),
release = await api(baseUrl, token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}`, { );
method: "PATCH", if (missingAssets.length) {
headers: { "Content-Type": "application/json" }, throw new Error(
body: JSON.stringify({ draft: false }), `Release remains draft because required assets are missing: ${missingAssets.join(", ")}`,
}); );
}
release = await api(
baseUrl,
token,
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ draft: false }),
},
);
console.log( console.log(
`PASS ForgeFlow ${version} binary release published to ${owner}/${repo} for ${commit.slice(0, 7)}`, `PASS ForgeFlow ${version} binary release published to ${owner}/${repo} for ${commit.slice(0, 7)}`,
); );
+14 -8
View File
@@ -96,11 +96,13 @@ const required = [
"docs/RELEASE_NOTES_0.10.12.md", "docs/RELEASE_NOTES_0.10.12.md",
"docs/RELEASE_NOTES_0.10.13.md", "docs/RELEASE_NOTES_0.10.13.md",
"docs/RELEASE_NOTES_0.10.14.md", "docs/RELEASE_NOTES_0.10.14.md",
"docs/RELEASE_NOTES_0.10.15.md",
"docs/RELEASE_NOTES_0.10.16.md",
"docs/UPDATING.md", "docs/UPDATING.md",
"docs/DIAGNOSTICS.md", "docs/DIAGNOSTICS.md",
"docs/DEPLOYMENT_SETUP.md", "docs/DEPLOYMENT_SETUP.md",
"docs/SSH_UNRAID_DEPLOYMENT.md", "docs/SSH_UNRAID_DEPLOYMENT.md",
"docs/LUMAOPS_SERVER_AUDIT.md", "docs/DEPLOYMENT_MIGRATION_EXAMPLE.md",
"docs/STATUS_ENDPOINT.md", "docs/STATUS_ENDPOINT.md",
"docs/TEST_MATRIX.md", "docs/TEST_MATRIX.md",
"docs/RELEASE_NOTES_0.4.0.md", "docs/RELEASE_NOTES_0.4.0.md",
@@ -135,9 +137,9 @@ for (const file of required) await access(path.join(root, file));
const packageJson = JSON.parse( const packageJson = JSON.parse(
await readFile(path.join(root, "package.json"), "utf8"), await readFile(path.join(root, "package.json"), "utf8"),
); );
if (packageJson.version !== "0.10.14") if (packageJson.version !== "0.10.16")
throw new Error( throw new Error(
`Expected package version 0.10.14, got ${packageJson.version}.`, `Expected package version 0.10.16, got ${packageJson.version}.`,
); );
const sourceManifest = await readFile( const sourceManifest = await readFile(
path.join(root, "SOURCE_MANIFEST.txt"), path.join(root, "SOURCE_MANIFEST.txt"),
@@ -234,8 +236,8 @@ const sshGuide = await readFile(
path.join(root, "docs/SSH_UNRAID_DEPLOYMENT.md"), path.join(root, "docs/SSH_UNRAID_DEPLOYMENT.md"),
"utf8", "utf8",
); );
const audit = await readFile( const migrationExample = await readFile(
path.join(root, "docs/LUMAOPS_SERVER_AUDIT.md"), path.join(root, "docs/DEPLOYMENT_MIGRATION_EXAMPLE.md"),
"utf8", "utf8",
); );
const releaseNotes = await readFile( const releaseNotes = await readFile(
@@ -263,11 +265,11 @@ if (
); );
} }
if ( if (
!audit.includes("d42d4a7f08240c478d07466e3fabec654dc71367") || !migrationExample.includes("complete 40-character commit SHA") ||
!audit.includes("source/") !migrationExample.includes("source/")
) { ) {
throw new Error( throw new Error(
"LumaOps audit is missing the exact matching SHA or nested repository finding.", "Deployment migration example is missing exact-SHA or nested repository guidance.",
); );
} }
for (const phrase of [ for (const phrase of [
@@ -507,6 +509,10 @@ const release01014 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.14.
for (const phrase of ["Help center", "Gitea workspace sync", "repository context", "horizontal tab navigation", "84 browser flows"]) { for (const phrase of ["Help center", "Gitea workspace sync", "repository context", "horizontal tab navigation", "84 browser flows"]) {
if (!release01014.includes(phrase)) throw new Error(`0.10.14 release notes are missing: ${phrase}`); if (!release01014.includes(phrase)) throw new Error(`0.10.14 release notes are missing: ${phrase}`);
} }
const release01015 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.15.md"), "utf8");
for (const phrase of ["Workspace Sync", "Codex review manifest", "local-only", "source updater", "binary updater"]) {
if (!release01015.includes(phrase)) throw new Error(`0.10.15 release notes are missing: ${phrase}`);
}
const configSource = await readFile(path.join(root, "src/main/config-store.cjs"), "utf8"); const configSource = await readFile(path.join(root, "src/main/config-store.cjs"), "utf8");
for (const mode of ["server-git", "push-bundle", "monitor-only"]) { for (const mode of ["server-git", "push-bundle", "monitor-only"]) {
if (!configSource.includes(mode)) throw new Error(`Deployment configuration is missing mode: ${mode}`); if (!configSource.includes(mode)) throw new Error(`Deployment configuration is missing mode: ${mode}`);
+54 -10
View File
@@ -3,8 +3,20 @@
const fs = require('node:fs/promises'); const fs = require('node:fs/promises');
const path = require('node:path'); const path = require('node:path');
const crypto = require('node:crypto'); const crypto = require('node:crypto');
const { safeStorage } = require('electron'); const { normalizeBaseUrl, assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePath, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePath, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
let cachedSafeStorage;
function getSafeStorage() {
if (cachedSafeStorage !== undefined) return cachedSafeStorage;
try {
const electron = require('electron');
cachedSafeStorage = electron && typeof electron === 'object' ? electron.safeStorage || null : null;
} catch {
cachedSafeStorage = null;
}
return cachedSafeStorage;
}
const DEFAULT_CONFIG = { const DEFAULT_CONFIG = {
schemaVersion: 13, schemaVersion: 13,
@@ -20,7 +32,7 @@ const DEFAULT_CONFIG = {
favorites: [], favorites: [],
updates: { updates: {
owner: 'Jens', owner: 'Jens',
repo: 'ForgeFlow', repo: 'ForgeFlow-Public',
branch: 'main', branch: 'main',
autoCheck: true, autoCheck: true,
lastCheckedAt: null lastCheckedAt: null
@@ -112,7 +124,15 @@ class ConfigStore {
: {}, : {},
deploymentStates: source.deploymentStates && typeof source.deploymentStates === 'object' ? source.deploymentStates : {}, deploymentStates: source.deploymentStates && typeof source.deploymentStates === 'object' ? source.deploymentStates : {},
favorites: [...new Set(uniqueStrings(source.favorites).map((item) => item.toLowerCase()))], favorites: [...new Set(uniqueStrings(source.favorites).map((item) => item.toLowerCase()))],
updates: { ...DEFAULT_CONFIG.updates, ...(source.updates || {}) }, updates: {
...DEFAULT_CONFIG.updates,
...(source.updates || {}),
// Move existing installations off the legacy endpoint while preserving
// a separately configured update repository.
...((source.updates?.owner ?? 'Jens') === 'Jens' && source.updates?.repo === 'ForgeFlow' && (source.updates?.branch ?? 'main') === 'main'
? { repo: 'ForgeFlow-Public' }
: {})
},
servers: Array.isArray(source.servers) ? source.servers.filter((item) => item && typeof item === 'object') : [], servers: Array.isArray(source.servers) ? source.servers.filter((item) => item && typeof item === 'object') : [],
preferences: { ...DEFAULT_CONFIG.preferences, ...(source.preferences || {}) }, preferences: { ...DEFAULT_CONFIG.preferences, ...(source.preferences || {}) },
operations: Array.isArray(source.operations) ? source.operations.slice(0, 250).map((operation) => { const { runnerLog, ...safeOperation } = operation || {}; return safeOperation; }) : [] operations: Array.isArray(source.operations) ? source.operations.slice(0, 250).map((operation) => { const { runnerLog, ...safeOperation } = operation || {}; return safeOperation; }) : []
@@ -215,6 +235,7 @@ class ConfigStore {
return { persistent: true, preserved: false }; return { persistent: true, preserved: false };
} }
const safeStorage = getSafeStorage();
if (safeStorage?.isEncryptionAvailable?.()) { if (safeStorage?.isEncryptionAvailable?.()) {
this.data.gitea.encryptedToken = safeStorage.encryptString(value).toString('base64'); this.data.gitea.encryptedToken = safeStorage.encryptString(value).toString('base64');
this.sessionToken = null; this.sessionToken = null;
@@ -230,6 +251,7 @@ class ConfigStore {
if (this.sessionToken) return this.sessionToken; if (this.sessionToken) return this.sessionToken;
if (!this.data.gitea.encryptedToken) return ''; if (!this.data.gitea.encryptedToken) return '';
try { try {
const safeStorage = getSafeStorage();
return safeStorage?.decryptString?.(Buffer.from(this.data.gitea.encryptedToken, 'base64')) || ''; return safeStorage?.decryptString?.(Buffer.from(this.data.gitea.encryptedToken, 'base64')) || '';
} catch { } catch {
return ''; return '';
@@ -240,6 +262,7 @@ class ConfigStore {
encryptSecret(value) { encryptSecret(value) {
const text = String(value || ''); const text = String(value || '');
if (!text) return null; if (!text) return null;
const safeStorage = getSafeStorage();
if (!safeStorage?.isEncryptionAvailable?.()) { if (!safeStorage?.isEncryptionAvailable?.()) {
const error = new Error('Secure credential storage is unavailable. ForgeFlow will not persist server passwords or key passphrases.'); const error = new Error('Secure credential storage is unavailable. ForgeFlow will not persist server passwords or key passphrases.');
error.code = 'SECURE_STORAGE_UNAVAILABLE'; error.code = 'SECURE_STORAGE_UNAVAILABLE';
@@ -250,7 +273,10 @@ class ConfigStore {
decryptSecret(value) { decryptSecret(value) {
if (!value) return ''; if (!value) return '';
try { return safeStorage?.decryptString?.(Buffer.from(value, 'base64')) || ''; } try {
const safeStorage = getSafeStorage();
return safeStorage?.decryptString?.(Buffer.from(value, 'base64')) || '';
}
catch { return ''; } catch { return ''; }
} }
@@ -266,7 +292,16 @@ class ConfigStore {
const basePath = String(source.basePath || existing?.basePath || '/mnt/user/appdata').trim().replace(/\/+$/, ''); 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.'); 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(); const privateKeyPath = String(source.privateKeyPath || existing?.privateKeyPath || '').trim();
const hostFingerprint = String(source.hostFingerprint || existing?.hostFingerprint || '').trim(); const credentialIdentityChanged = Boolean(existing && [
['host', existing.host, host],
['port', existing.port, port],
['username', existing.username, username],
['authType', existing.authType, authType],
['privateKeyPath', existing.privateKeyPath, privateKeyPath]
].some(([, previous, next]) => String(previous || '') !== String(next || '')));
const hostFingerprint = credentialIdentityChanged
? ''
: String(source.hostFingerprint || existing?.hostFingerprint || '').trim();
const scanRoots = uniqueStrings(source.scanRoots || existing?.scanRoots || [basePath]).map((value) => value.replace(/\/+$/, '')).filter((value) => value.startsWith('/') && !/[\r\n\0]/.test(value)); const scanRoots = uniqueStrings(source.scanRoots || existing?.scanRoots || [basePath]).map((value) => value.replace(/\/+$/, '')).filter((value) => value.startsWith('/') && !/[\r\n\0]/.test(value));
const scanExcludes = uniqueStrings(source.scanExcludes || existing?.scanExcludes || ['backups', 'archives', 'releases', 'staging', 'testdata']).filter((value) => /^[a-zA-Z0-9._*-]+$/.test(value)); const scanExcludes = uniqueStrings(source.scanExcludes || existing?.scanExcludes || ['backups', 'archives', 'releases', 'staging', 'testdata']).filter((value) => /^[a-zA-Z0-9._*-]+$/.test(value));
return { return {
@@ -281,8 +316,8 @@ class ConfigStore {
scanExcludes, scanExcludes,
privateKeyPath, privateKeyPath,
hostFingerprint, hostFingerprint,
encryptedPassword: existing?.encryptedPassword || null, encryptedPassword: credentialIdentityChanged ? null : existing?.encryptedPassword || null,
encryptedPassphrase: existing?.encryptedPassphrase || null, encryptedPassphrase: credentialIdentityChanged ? null : existing?.encryptedPassphrase || null,
createdAt: existing?.createdAt || new Date().toISOString(), createdAt: existing?.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
}; };
@@ -348,7 +383,7 @@ class ConfigStore {
async setUpdatePreferences(updates) { async setUpdatePreferences(updates) {
const next = { ...this.data.updates, ...(updates || {}) }; const next = { ...this.data.updates, ...(updates || {}) };
next.owner = String(next.owner || 'Jens').trim().slice(0, 100); next.owner = String(next.owner || 'Jens').trim().slice(0, 100);
next.repo = String(next.repo || 'ForgeFlow').trim().slice(0, 100); next.repo = String(next.repo || 'ForgeFlow-Public').trim().slice(0, 100);
next.branch = assertBranchName(next.branch || 'main'); next.branch = assertBranchName(next.branch || 'main');
next.autoCheck = next.autoCheck !== false; next.autoCheck = next.autoCheck !== false;
this.data.updates = next; this.data.updates = next;
@@ -385,10 +420,19 @@ class ConfigStore {
} }
async updateGitea({ baseUrl, token, user }) { async updateGitea({ baseUrl, token, user }) {
const nextBaseUrl = normalizeBaseUrl(baseUrl);
const currentBaseUrl = this.data.gitea.baseUrl
? normalizeBaseUrl(this.data.gitea.baseUrl)
: '';
if (!String(token || '').trim() && nextBaseUrl !== currentBaseUrl && this.getToken()) {
const error = new Error('Enter a new Gitea token when changing the server address.');
error.code = 'GITEA_TOKEN_ORIGIN_CHANGED';
throw error;
}
const tokenState = this.setToken(token, { preserveExisting: true }); const tokenState = this.setToken(token, { preserveExisting: true });
this.data.gitea = { this.data.gitea = {
...this.data.gitea, ...this.data.gitea,
baseUrl, baseUrl: nextBaseUrl,
user: user || this.data.gitea.user, user: user || this.data.gitea.user,
encryptedToken: this.data.gitea.encryptedToken encryptedToken: this.data.gitea.encryptedToken
}; };
+13 -4
View File
@@ -5,7 +5,7 @@ const path = require('node:path');
const os = require('node:os'); const os = require('node:os');
const crypto = require('node:crypto'); const crypto = require('node:crypto');
const { createZip } = require('../shared/zip-writer.cjs'); const { createZip } = require('../shared/zip-writer.cjs');
const { sanitizeForDiagnostics, redactSecrets } = require('./log-redaction.cjs'); const { sanitizeForDiagnostics } = require('./log-redaction.cjs');
const LEVELS = { debug: 10, info: 20, warning: 30, error: 40 }; const LEVELS = { debug: 10, info: 20, warning: 30, error: 40 };
@@ -202,7 +202,7 @@ class DiagnosticsService {
return this.getStatus(); return this.getStatus();
} }
async collectLogs(maxBytes = 20 * 1024 * 1024) { async collectLogs(maxBytes = 20 * 1024 * 1024, { strictIdentifiers = false } = {}) {
await this.flush(); await this.flush();
const output = []; const output = [];
let used = 0; let used = 0;
@@ -211,7 +211,16 @@ class DiagnosticsService {
const remaining = maxBytes - used; const remaining = maxBytes - used;
const content = await fs.readFile(file.path); const content = await fs.readFile(file.path);
const slice = content.length > remaining ? content.subarray(content.length - remaining) : content; const slice = content.length > remaining ? content.subarray(content.length - remaining) : content;
output.push({ name: `logs/${file.name}`, data: Buffer.from(redactSecrets(slice.toString('utf8'), this.secretProvider?.() || []), 'utf8') }); output.push({
name: `logs/${file.name}`,
data: Buffer.from(
sanitizeForDiagnostics(slice.toString('utf8'), {
secrets: this.secretProvider?.() || [],
strictIdentifiers,
}),
'utf8',
),
});
used += slice.length; used += slice.length;
} }
return output; return output;
@@ -345,7 +354,7 @@ class DiagnosticsService {
{ name: 'operations-sanitized.json', data: safeJson(sanitizedOperations) }, { name: 'operations-sanitized.json', data: safeJson(sanitizedOperations) },
{ name: 'preflight.json', data: safeJson(sanitize(preflight || {})) }, { name: 'preflight.json', data: safeJson(sanitize(preflight || {})) },
{ name: 'context.json', data: safeJson(sanitize(extra || {})) }, { name: 'context.json', data: safeJson(sanitize(extra || {})) },
...(await this.collectLogs()) ...(await this.collectLogs(20 * 1024 * 1024, { strictIdentifiers: strict }))
]; ];
const safetyAudit = auditBundleEntries(entries, this.secretProvider?.() || []); const safetyAudit = auditBundleEntries(entries, this.secretProvider?.() || []);
+19 -6
View File
@@ -3,13 +3,26 @@
const { spawn } = require('node:child_process'); const { spawn } = require('node:child_process');
const path = require('node:path'); const path = require('node:path');
function normalizeTool(tool, defaults) { const TOOL_PROFILES = Object.freeze({
editor: Object.freeze({
code: ['--reuse-window', '--goto', '{file}:{line}'],
'code.exe': ['--reuse-window', '--goto', '{file}:{line}'],
codium: ['--reuse-window', '--goto', '{file}:{line}'],
'codium.exe': ['--reuse-window', '--goto', '{file}:{line}'],
}),
terminal: Object.freeze({
wt: ['-d', '{path}'],
'wt.exe': ['-d', '{path}'],
}),
});
function normalizeTool(tool, defaults, kind) {
const source = tool && typeof tool === 'object' ? tool : {}; const source = tool && typeof tool === 'object' ? tool : {};
const executable = String(source.executable || defaults.executable).trim(); const executable = String(source.executable || defaults.executable).trim();
if (!executable || /[\r\n\0]/.test(executable)) throw new Error('Tool executable is invalid.'); if (!executable || /[\r\n\0]/.test(executable)) throw new Error('Tool executable is invalid.');
const args = (Array.isArray(source.args) ? source.args : defaults.args).map((item) => String(item)).slice(0, 20); const profile = TOOL_PROFILES[kind]?.[executable.toLowerCase()];
if (args.some((item) => /[\r\n\0]/.test(item))) throw new Error('Tool argument is invalid.'); if (!profile) throw new Error(`Unsupported ${kind || 'external'} tool. Select a built-in trusted tool profile.`);
return { executable, args }; return { executable, args: [...profile] };
} }
function expandTool(tool, context) { function expandTool(tool, context) {
@@ -27,7 +40,7 @@ class ExternalToolsService {
const defaults = kind === 'terminal' const defaults = kind === 'terminal'
? { executable: 'wt.exe', args: ['-d', '{path}'] } ? { executable: 'wt.exe', args: ['-d', '{path}'] }
: { executable: 'code', args: ['--reuse-window', '--goto', '{file}:{line}'] }; : { executable: 'code', args: ['--reuse-window', '--goto', '{file}:{line}'] };
const configured = normalizeTool(this.store.data.preferences?.[kind], defaults); const configured = normalizeTool(this.store.data.preferences?.[kind], defaults, kind);
const invocation = expandTool(configured, { path: root, file: candidate, line }); const invocation = expandTool(configured, { path: root, file: candidate, line });
const child = spawn(invocation.executable, invocation.args, { cwd: root, detached: true, stdio: 'ignore', windowsHide: false, shell: false }); const child = spawn(invocation.executable, invocation.args, { cwd: root, detached: true, stdio: 'ignore', windowsHide: false, shell: false });
child.unref(); child.unref();
@@ -35,4 +48,4 @@ class ExternalToolsService {
} }
} }
module.exports = { ExternalToolsService, normalizeTool, expandTool }; module.exports = { ExternalToolsService, normalizeTool, expandTool, TOOL_PROFILES };
+71 -2
View File
@@ -167,6 +167,48 @@ class GitService {
return { root, gitDir: path.resolve(result.stdout.trim()) }; return { root, gitDir: path.resolve(result.stdout.trim()) };
} }
async writeWorkspaceReviewManifest(repoPath, plan, { backupBranch = null, stash = null } = {}) {
const { root, gitDir } = await this.gitDirectory(repoPath);
const reviewId = String(plan?.id || '').trim();
if (!/^[0-9a-f]{64}$/i.test(reviewId)) throw new Error('Workspace review manifest requires a valid synchronization plan.');
const reviewDirectory = path.join(gitDir, 'forgeflow', 'workspace-reviews');
await fs.mkdir(reviewDirectory, { recursive: true });
const manifestPath = path.join(reviewDirectory, `${reviewId}.json`);
const payload = {
schemaVersion: 1,
kind: 'workspace-sync-quarantine',
id: reviewId,
status: 'pending-codex-review',
createdAt: new Date().toISOString(),
repositoryRoot: root,
branch: plan.branch,
upstream: plan.upstream,
sourceSha: plan.currentSha,
targetSha: plan.targetSha,
recoveryBranch: backupBranch,
stashRef: stash?.ref || null,
stashSha: stash?.sha || null,
files: (plan.localFiles || []).map((file) => ({
path: file.path,
originalPath: file.originalPath || null,
status: file.status,
staged: Boolean(file.staged),
unstaged: Boolean(file.unstaged),
untracked: Boolean(file.untracked)
})),
instructions: [
'Review the recovery branch and quarantine stash with Codex before restoring anything.',
'ForgeFlow recovery branches are local-only and cannot be pushed to Gitea.',
'Restore only files that are still useful; obsolete files can be dropped after review.'
],
manifestPath
};
const temporaryPath = `${manifestPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
await fs.writeFile(temporaryPath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
await fs.rename(temporaryPath, manifestPath);
return payload;
}
isGitLockError(error) { isGitLockError(error) {
const message = String(error?.message || error || ''); const message = String(error?.message || error || '');
return /(?:cannot lock ref|Unable to create .*\.lock|another git process)/i.test(message) return /(?:cannot lock ref|Unable to create .*\.lock|another git process)/i.test(message)
@@ -447,6 +489,7 @@ class GitService {
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-'); const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
let backupBranch = null; let backupBranch = null;
let stash = null; let stash = null;
let review = null;
if (plan.summary.localCommitsToProtect > 0) { if (plan.summary.localCommitsToProtect > 0) {
const safeBranch = plan.branch.replace(/[^A-Za-z0-9._-]/g, '-'); const safeBranch = plan.branch.replace(/[^A-Za-z0-9._-]/g, '-');
backupBranch = `forgeflow/recovery-${safeBranch}-${stamp}-${plan.currentSha.slice(0, 7)}`; backupBranch = `forgeflow/recovery-${safeBranch}-${stamp}-${plan.currentSha.slice(0, 7)}`;
@@ -454,10 +497,13 @@ class GitService {
await run('git', ['branch', backupBranch, 'HEAD'], { cwd: root, timeout: 30_000 }); await run('git', ['branch', backupBranch, 'HEAD'], { cwd: root, timeout: 30_000 });
} }
if (plan.summary.localFilesToStash > 0) { if (plan.summary.localFilesToStash > 0) {
const label = `ForgeFlow workspace sync ${plan.branch} ${stamp}`; const label = `FORGEFLOW-QUARANTINE:${plan.id} workspace sync ${plan.branch} ${stamp}`;
await run('git', ['stash', 'push', '--include-untracked', '-m', label], { cwd: root, timeout: 120_000 }); await run('git', ['stash', 'push', '--include-untracked', '-m', label], { cwd: root, timeout: 120_000 });
stash = (await this.stashList(root))[0] || null; stash = (await this.stashList(root))[0] || null;
} }
if (backupBranch || stash) {
review = await this.writeWorkspaceReviewManifest(root, plan, { backupBranch, stash });
}
const protectedStatus = await this.status(root); const protectedStatus = await this.status(root);
if (!protectedStatus.clean || protectedStatus.head !== plan.currentSha) { if (!protectedStatus.clean || protectedStatus.head !== plan.currentSha) {
@@ -486,6 +532,7 @@ class GitService {
status, status,
backupBranch, backupBranch,
stash, stash,
review,
cleaned: plan.localFiles.filter((file) => file.untracked).map((file) => file.path), cleaned: plan.localFiles.filter((file) => file.untracked).map((file) => file.path),
ignoredFilesPreserved: true ignoredFilesPreserved: true
}; };
@@ -718,6 +765,12 @@ class GitService {
const status = await this.status(root); const status = await this.status(root);
const branch = status.branch.head; const branch = status.branch.head;
if (!branch || branch === '(detached)') throw new Error('Cannot push from a detached HEAD.'); if (!branch || branch === '(detached)') throw new Error('Cannot push from a detached HEAD.');
if (/^forgeflow\/recovery-/.test(branch)) {
const error = new Error('ForgeFlow recovery branches are local quarantine references and cannot be pushed to Gitea. Review them with Codex and move only approved work onto a normal branch.');
error.code = 'WORKSPACE_RECOVERY_BRANCH_LOCAL_ONLY';
error.recoverable = true;
throw error;
}
const args = status.branch.upstream ? ['push', '--porcelain'] : ['push', '--porcelain', '--set-upstream', 'origin', branch]; const args = status.branch.upstream ? ['push', '--porcelain'] : ['push', '--porcelain', '--set-upstream', 'origin', branch];
const result = await run('git', args, { cwd: root, timeout: 180_000, maxBuffer: 16 * 1024 * 1024 }); const result = await run('git', args, { cwd: root, timeout: 180_000, maxBuffer: 16 * 1024 * 1024 });
return { output: `${result.stdout}\n${result.stderr}`.trim(), status: await this.status(root) }; return { output: `${result.stdout}\n${result.stderr}`.trim(), status: await this.status(root) };
@@ -804,7 +857,16 @@ class GitService {
const result = await run('git', ['stash', 'list', `--format=${format}`], { cwd: root }); const result = await run('git', ['stash', 'list', `--format=${format}`], { cwd: root });
return result.stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => { return result.stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
const [ref, sha, date, subject] = record.split('\x1f'); const [ref, sha, date, subject] = record.split('\x1f');
return { ref, sha, shortSha: sha.slice(0, 7), date, subject }; const quarantine = String(subject || '').match(/FORGEFLOW-QUARANTINE:([0-9a-f]{64})/i);
return {
ref,
sha,
shortSha: sha.slice(0, 7),
date,
subject,
quarantined: Boolean(quarantine),
reviewId: quarantine?.[1] || null
};
}); });
} }
@@ -812,6 +874,13 @@ class GitService {
const root = await this.ensureRepository(repoPath); const root = await this.ensureRepository(repoPath);
const value = String(ref || 'stash@{0}'); const value = String(ref || 'stash@{0}');
if (!/^stash@\{\d+\}$/.test(value)) throw new Error('Invalid stash reference.'); if (!/^stash@\{\d+\}$/.test(value)) throw new Error('Invalid stash reference.');
const candidate = (await this.stashList(root)).find((item) => item.ref === value);
if (candidate?.quarantined) {
const error = new Error(`This stash is quarantined for Codex review (${candidate.reviewId}). ForgeFlow will not apply and drop it wholesale; restore only reviewed files manually.`);
error.code = 'WORKSPACE_QUARANTINE_REVIEW_REQUIRED';
error.recoverable = true;
throw error;
}
const result = await run('git', ['stash', 'pop', value], { cwd: root, timeout: 120_000 }); const result = await run('git', ['stash', 'pop', value], { cwd: root, timeout: 120_000 });
return { output: result.stdout.trim(), status: await this.status(root), stashes: await this.stashList(root) }; return { output: result.stdout.trim(), status: await this.status(root), stashes: await this.stashList(root) };
} }
+18 -2
View File
@@ -20,6 +20,7 @@ const {
readEncryptedBackup, readEncryptedBackup,
} = require("./configuration-backup.cjs"); } = require("./configuration-backup.cjs");
const { evaluateDeploymentPolicy } = require("../shared/deployment-policy.cjs"); const { evaluateDeploymentPolicy } = require("../shared/deployment-policy.cjs");
const { normalizeBaseUrl } = require("../shared/validation.cjs");
function registerIpc({ function registerIpc({
store, store,
git, git,
@@ -253,8 +254,23 @@ function registerIpc({
}); });
register("settings:update-gitea", async ({ baseUrl, token }) => { register("settings:update-gitea", async ({ baseUrl, token }) => {
const effectiveToken = String(token || "").trim() || store.getToken(); const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
const validation = await gitea.validateConnection(baseUrl, effectiveToken); const currentBaseUrl = store.data.gitea.baseUrl
? normalizeBaseUrl(store.data.gitea.baseUrl)
: "";
const submittedToken = String(token || "").trim();
if (!submittedToken && normalizedBaseUrl !== currentBaseUrl) {
const error = new Error(
"Enter a new Gitea token when changing the server address. Stored tokens are bound to their original origin.",
);
error.code = "GITEA_TOKEN_ORIGIN_CHANGED";
throw error;
}
const effectiveToken = submittedToken || store.getToken();
const validation = await gitea.validateConnection(
normalizedBaseUrl,
effectiveToken,
);
const tokenState = await store.updateGitea({ const tokenState = await store.updateGitea({
baseUrl: validation.baseUrl, baseUrl: validation.baseUrl,
token, token,
-1
View File
@@ -96,7 +96,6 @@ function registerDeploymentIpc({
}); });
}, },
); );
register("deployment:health", ({ url }) => deployments.checkHealth(url));
register("deployment:link-server-workload", async ({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) => { register("deployment:link-server-workload", async ({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) => {
const current = await resolveRepository(repository); const current = await resolveRepository(repository);
const result = await unraid.linkServerWorkload({ const result = await unraid.linkServerWorkload({
+10 -2
View File
@@ -49,6 +49,13 @@ function stableAlias(value, prefix = 'item') {
return `${prefix}-${hash}`; return `${prefix}-${hash}`;
} }
function redactPrivateInfrastructure(value) {
return String(value ?? '')
.replace(/\b(?:10(?:\.\d{1,3}){3}|127(?:\.\d{1,3}){3}|169\.254(?:\.\d{1,3}){2}|172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}|192\.168(?:\.\d{1,3}){2})\b/g, '<PRIVATE_ADDRESS>')
.replace(/\b(?:https?|ssh):\/\/[^\s"'<>]+/gi, '<PRIVATE_URL>')
.replace(/\/(?:mnt|srv|opt|var\/lib)\/[^\s"'<>]*/g, '<SERVER_PATH>');
}
function sanitizeForDiagnostics(value, options = {}, seen = new WeakSet()) { function sanitizeForDiagnostics(value, options = {}, seen = new WeakSet()) {
const { const {
secrets = [], secrets = [],
@@ -63,6 +70,7 @@ function sanitizeForDiagnostics(value, options = {}, seen = new WeakSet()) {
if (typeof value === 'string') { if (typeof value === 'string') {
let output = redactSecrets(value, secrets); let output = redactSecrets(value, secrets);
if (pathMode === 'alias') output = pathAlias(output, { homeDir, cwd }); if (pathMode === 'alias') output = pathAlias(output, { homeDir, cwd });
if (strictIdentifiers) output = redactPrivateInfrastructure(output);
return output; return output;
} }
if (value instanceof Error) { if (value instanceof Error) {
@@ -80,7 +88,7 @@ function sanitizeForDiagnostics(value, options = {}, seen = new WeakSet()) {
output[key] = '[REDACTED]'; output[key] = '[REDACTED]';
continue; continue;
} }
if (strictIdentifiers && ['fullName', 'repository', 'owner', 'user', 'login', 'email'].includes(key)) { if (strictIdentifiers && ['full_name', 'repository', 'owner', 'user', 'login', 'email', 'host', 'hostname', 'username', 'base_path', 'private_key_path', 'local_path', 'remote_folder', 'remote_url', 'clone_url', 'status_url', 'healthcheck_url', 'web_ui_url', 'workspace_roots', 'scan_roots'].includes(normalizedKey.toLowerCase())) {
output[key] = stableAlias(typeof item === 'object' ? JSON.stringify(item) : item, key.toLowerCase()); output[key] = stableAlias(typeof item === 'object' ? JSON.stringify(item) : item, key.toLowerCase());
continue; continue;
} }
@@ -90,4 +98,4 @@ function sanitizeForDiagnostics(value, options = {}, seen = new WeakSet()) {
return output; return output;
} }
module.exports = { redactSecrets, sanitizeForDiagnostics, pathAlias, stableAlias, SENSITIVE_KEY }; module.exports = { redactSecrets, sanitizeForDiagnostics, pathAlias, stableAlias, redactPrivateInfrastructure, SENSITIVE_KEY };
+39 -5
View File
@@ -97,6 +97,12 @@ function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
function requireSignedSourceUpdate(message) {
const error = new Error(message);
error.code = "SIGNED_SOURCE_UPDATE_REQUIRED";
throw error;
}
function resolveWindowsPowerShellPath(environment = process.env) { function resolveWindowsPowerShellPath(environment = process.env) {
const windowsRoot = environment.SystemRoot || environment.WINDIR; const windowsRoot = environment.SystemRoot || environment.WINDIR;
if (windowsRoot) { if (windowsRoot) {
@@ -259,16 +265,28 @@ class UpdateService {
"Update repository owner", "Update repository owner",
); );
const repo = safeRepositoryPart( const repo = safeRepositoryPart(
settings.repo || "ForgeFlow", settings.repo || "ForgeFlow-Public",
"Update repository name", "Update repository name",
); );
const branchName = String(settings.branch || "main").trim(); const branchName = String(settings.branch || "main").trim();
const branch = await this.gitea.getBranch(owner, repo, branchName); let remoteSha;
const remoteSha = let releaseTag = null;
branch?.commit?.id || branch?.commit?.sha || branch?.commit?.commit?.id; if (this.appInfo.packaged) {
const release = await this.gitea.getLatestRelease(owner, repo);
if (!release || release.draft || release.prerelease) {
const error = new Error("The configured update repository has no published stable Windows release yet.");
error.code = "BINARY_RELEASE_NOT_FOUND";
throw error;
}
remoteSha = release.target_commitish;
releaseTag = release.tag_name;
} else {
const branch = await this.gitea.getBranch(owner, repo, branchName);
remoteSha = branch?.commit?.id || branch?.commit?.sha || branch?.commit?.commit?.id;
}
if (!/^[0-9a-f]{40}$/i.test(String(remoteSha || ""))) if (!/^[0-9a-f]{40}$/i.test(String(remoteSha || "")))
throw new Error( throw new Error(
"Gitea did not return a full commit SHA for the update branch.", "Gitea did not return a full commit SHA for the update source.",
); );
const file = await this.gitea.getRepositoryFile({ const file = await this.gitea.getRepositoryFile({
@@ -288,6 +306,9 @@ class UpdateService {
"The configured update repository is not a ForgeFlow source repository.", "The configured update repository is not a ForgeFlow source repository.",
); );
const remoteVersion = String(manifest.version || "").trim(); const remoteVersion = String(manifest.version || "").trim();
if (releaseTag && releaseTag !== `v${remoteVersion}` && releaseTag !== remoteVersion) {
throw new Error("The published release tag does not match its source version.");
}
const currentVersion = String(this.appInfo.version || "").trim(); const currentVersion = String(this.appInfo.version || "").trim();
const available = isNewerVersion(remoteVersion, currentVersion); const available = isNewerVersion(remoteVersion, currentVersion);
const result = { const result = {
@@ -295,6 +316,7 @@ class UpdateService {
owner, owner,
repo, repo,
branch: branchName, branch: branchName,
releaseTag,
currentVersion, currentVersion,
remoteVersion, remoteVersion,
remoteSha, remoteSha,
@@ -325,6 +347,11 @@ class UpdateService {
return this.downloadPackaged(update); return this.downloadPackaged(update);
} }
requireSignedSourceUpdate(
"Integrated source updates are disabled because source archives do not yet carry an independently signed publisher manifest. Update a source checkout with Git after reviewing the exact commit.",
);
/* c8 ignore start -- retained for a future signed source-archive implementation */
await fs.mkdir(this.updateDirectory, { recursive: true }); await fs.mkdir(this.updateDirectory, { recursive: true });
const archiveUrl = `${this.store.data.gitea.baseUrl.replace(/\/+$/, "")}/${encodeURIComponent(update.owner)}/${encodeURIComponent(update.repo)}/archive/${update.remoteSha}.zip`; const archiveUrl = `${this.store.data.gitea.baseUrl.replace(/\/+$/, "")}/${encodeURIComponent(update.owner)}/${encodeURIComponent(update.repo)}/archive/${update.remoteSha}.zip`;
const archive = await this.gitea.downloadAuthenticated(archiveUrl); const archive = await this.gitea.downloadAuthenticated(archiveUrl);
@@ -354,6 +381,7 @@ class UpdateService {
sha256, sha256,
}); });
return { ...metadata, downloaded: true }; return { ...metadata, downloaded: true };
/* c8 ignore stop */
} }
async downloadPackaged(update) { async downloadPackaged(update) {
@@ -528,6 +556,10 @@ class UpdateService {
"The integrated updater currently supports Windows only.", "The integrated updater currently supports Windows only.",
); );
if (update.kind === "binary") return this.applyPackaged(update); if (update.kind === "binary") return this.applyPackaged(update);
requireSignedSourceUpdate(
"This source archive cannot be applied because it has no independently signed publisher manifest.",
);
/* c8 ignore start -- legacy helper retained only for migration compatibility */
const stat = await fs.stat(update.archivePath).catch(() => null); const stat = await fs.stat(update.archivePath).catch(() => null);
if (!stat?.isFile()) if (!stat?.isFile())
throw new Error("The staged update archive is no longer available."); throw new Error("The staged update archive is no longer available.");
@@ -662,6 +694,7 @@ class UpdateService {
logPath, logPath,
statusPath, statusPath,
}; };
/* c8 ignore stop */
} }
async applyPackaged(update) { async applyPackaged(update) {
@@ -850,4 +883,5 @@ module.exports = {
waitForUpdaterStarted, waitForUpdaterStarted,
readJsonFile, readJsonFile,
readLogTail, readLogTail,
requireSignedSourceUpdate,
}; };
+3 -2
View File
@@ -161,12 +161,13 @@ Force repair after you have closed all Git tools for this repository?`)
]); ]);
const recovery = [ const recovery = [
result.backupBranch ? `recovery branch ${result.backupBranch}` : null, result.backupBranch ? `recovery branch ${result.backupBranch}` : null,
result.stash ? `stash ${result.stash.ref}` : null, result.stash ? `quarantine stash ${result.stash.ref}` : null,
result.review ? `Codex review manifest ${result.review.manifestPath}` : null,
].filter(Boolean).join(" and "); ].filter(Boolean).join(" and ");
showToast( showToast(
"Workspace synchronized with Gitea", "Workspace synchronized with Gitea",
recovery recovery
? `Local work is preserved in ${recovery}. Ignored runtime files were retained.` ? `Local work is quarantined in ${recovery}. Review it before restoring anything; ignored runtime files were retained.`
: `Tracked files now match ${result.plan.upstream}; ignored runtime files were retained.`, : `Tracked files now match ${result.plan.upstream}; ignored runtime files were retained.`,
"success", "success",
); );
+2 -2
View File
@@ -162,7 +162,7 @@
], ],
updates: { updates: {
owner: "Jens", owner: "Jens",
repo: "ForgeFlow", repo: "ForgeFlow-Public",
branch: "main", branch: "main",
autoCheck: true, autoCheck: true,
lastCheckedAt: null, lastCheckedAt: null,
@@ -176,7 +176,7 @@
username: "root", username: "root",
authType: "privateKey", authType: "privateKey",
basePath: "/mnt/user/appdata", basePath: "/mnt/user/appdata",
privateKeyPath: "C:\\Users\\Jens\\.ssh\\id_ed25519", privateKeyPath: "C:\\Users\\your-name\\.ssh\\id_ed25519",
hostFingerprint: "SHA256:demo", hostFingerprint: "SHA256:demo",
hasPassword: false, hasPassword: false,
hasPassphrase: false, hasPassphrase: false,
+1 -1
View File
@@ -665,7 +665,7 @@ function createMockDeploymentBridge(context) {
async exportDiagnostics(privacyMode = "standard") { async exportDiagnostics(privacyMode = "standard") {
await wait(500); await wait(500);
return { return {
path: `C:\Users\Jens\Downloads\ForgeFlow-Diagnostics-demo.zip`, path: `C:\Users\your-name\Downloads\ForgeFlow-Diagnostics-demo.zip`,
bytes: 38221, bytes: 38221,
size: "37.3 KB", size: "37.3 KB",
sha256: "b".repeat(64), sha256: "b".repeat(64),
+2 -2
View File
@@ -5,7 +5,7 @@ function createMockRepositoryBridge(context) {
await wait(80); await wait(80);
snapshot(); snapshot();
return { return {
appVersion: "0.10.14-demo", appVersion: "0.10.15-demo",
platform: "win32", platform: "win32",
state: clone(state), state: clone(state),
git: { available: true, version: "git version 2.47.3" }, git: { available: true, version: "git version 2.47.3" },
@@ -29,7 +29,7 @@ function createMockRepositoryBridge(context) {
}, },
async selectKeyFile() { async selectKeyFile() {
await wait(); await wait();
return "C:\\Users\\Jens\\.ssh\\id_ed25519"; return "C:\\Users\\your-name\\.ssh\\id_ed25519";
}, },
async setupPreflight({ baseUrl, token, roots = [] }) { async setupPreflight({ baseUrl, token, roots = [] }) {
await wait(240); await wait(240);
+3 -3
View File
@@ -367,7 +367,7 @@ function renderGitTools(repository) {
? ui.branches.map((branch) => `<div class="tool-row"><div><strong>${escapeHtml(branch.name)}</strong><span>${escapeHtml(branch.shortSha)}${branch.upstream ? ` · ${escapeHtml(branch.upstream)}` : " · unpublished"}</span></div>${branch.current ? '<span class="status-pill success">Current</span>' : `<button class="button" data-action="checkout-branch" data-branch="${attr(branch.name)}">Switch</button>`}</div>`).join("") ? ui.branches.map((branch) => `<div class="tool-row"><div><strong>${escapeHtml(branch.name)}</strong><span>${escapeHtml(branch.shortSha)}${branch.upstream ? ` · ${escapeHtml(branch.upstream)}` : " · unpublished"}</span></div>${branch.current ? '<span class="status-pill success">Current</span>' : `<button class="button" data-action="checkout-branch" data-branch="${attr(branch.name)}">Switch</button>`}</div>`).join("")
: '<div class="empty-state compact"><p>Load branch information.</p></div>'; : '<div class="empty-state compact"><p>Load branch information.</p></div>';
const stashRows = ui.stashes.length const stashRows = ui.stashes.length
? ui.stashes.map((stash) => `<div class="tool-row"><div><strong>${escapeHtml(stash.ref)}</strong><span>${escapeHtml(stash.subject)} · ${formatDate(stash.date)}</span></div><button class="button" data-action="pop-stash" data-stash-ref="${attr(stash.ref)}">Apply & drop</button></div>`).join("") ? ui.stashes.map((stash) => `<div class="tool-row"><div><strong>${escapeHtml(stash.ref)}</strong><span>${escapeHtml(stash.subject)} · ${formatDate(stash.date)}</span></div>${stash.quarantined ? `<span class="status-pill warning" title="Workspace review ${attr(stash.reviewId || "")}">Codex review required</span>` : `<button class="button" data-action="pop-stash" data-stash-ref="${attr(stash.ref)}">Apply & drop</button>`}</div>`).join("")
: '<div class="empty-state compact"><p>No stashes, or Git tools have not been loaded.</p></div>'; : '<div class="empty-state compact"><p>No stashes, or Git tools have not been loaded.</p></div>';
const recoveryBody = recovery const recoveryBody = recovery
? `<div class="troubleshooting-summary"><span class="status-pill ${locks.length ? "warning" : "success"}">${locks.length ? `${locks.length} lock${locks.length === 1 ? "" : "s"}` : "No Git locks"}</span><span>${activeProcesses.length ? `${activeProcesses.length} active Git process(es)` : "No matching active Git process detected"}</span></div>${locks.length ? `<div class="tool-list">${locks.map((lock) => `<div class="tool-row"><div><strong>${escapeHtml(lock.name)}</strong><span>${Math.round(lock.ageMs / 1000)}s old · ${escapeHtml(lock.modifiedAt)}</span></div></div>`).join("")}</div>` : ""}${recommendations.length ? `<div class="tool-list recovery-actions">${recommendations.map((item) => `<div class="tool-row"><div><strong>${escapeHtml(item.label)}</strong><span>${item.safe ? "Safe automated action" : item.action ? "Creates a safety branch before changing history" : "Review required"}</span></div>${item.action ? `<button class="button ${item.safe ? "" : "danger"}" data-action="repair-repository-sync" data-strategy="${attr(item.action)}">Run</button>` : ""}</div>`).join("")}</div>` : ""}` ? `<div class="troubleshooting-summary"><span class="status-pill ${locks.length ? "warning" : "success"}">${locks.length ? `${locks.length} lock${locks.length === 1 ? "" : "s"}` : "No Git locks"}</span><span>${activeProcesses.length ? `${activeProcesses.length} active Git process(es)` : "No matching active Git process detected"}</span></div>${locks.length ? `<div class="tool-list">${locks.map((lock) => `<div class="tool-row"><div><strong>${escapeHtml(lock.name)}</strong><span>${Math.round(lock.ageMs / 1000)}s old · ${escapeHtml(lock.modifiedAt)}</span></div></div>`).join("")}</div>` : ""}${recommendations.length ? `<div class="tool-list recovery-actions">${recommendations.map((item) => `<div class="tool-row"><div><strong>${escapeHtml(item.label)}</strong><span>${item.safe ? "Safe automated action" : item.action ? "Creates a safety branch before changing history" : "Review required"}</span></div>${item.action ? `<button class="button ${item.safe ? "" : "danger"}" data-action="repair-repository-sync" data-strategy="${attr(item.action)}">Run</button>` : ""}</div>`).join("")}</div>` : ""}`
@@ -761,8 +761,8 @@ function renderSettings() {
const update = ui.updateStatus; const update = ui.updateStatus;
const servers = state.servers || []; const servers = state.servers || [];
return `<div class="settings-layout"><aside class="settings-nav"><button class="nav-button active">${icon("settings")}<span>General</span></button><button class="nav-button" data-action="check-updates">${icon("update")}<span>Updates</span></button><button class="nav-button" data-action="open-add-server">${icon("server")}<span>Servers</span></button><button class="nav-button" data-action="reset-app">${icon("trash")}<span>Reset setup</span></button></aside><div class="settings-content"><div class="page-header"><div><div class="eyebrow">Application</div><h1>Settings</h1><p>Connections, project discovery, secure SSH servers and application updates.</p></div></div> return `<div class="settings-layout"><aside class="settings-nav"><button class="nav-button active">${icon("settings")}<span>General</span></button><button class="nav-button" data-action="check-updates">${icon("update")}<span>Updates</span></button><button class="nav-button" data-action="open-add-server">${icon("server")}<span>Servers</span></button><button class="nav-button" data-action="reset-app">${icon("trash")}<span>Reset setup</span></button></aside><div class="settings-content"><div class="page-header"><div><div class="eyebrow">Application</div><h1>Settings</h1><p>Connections, project discovery, secure SSH servers and application updates.</p></div></div>
<section class="settings-group"><h2>Gitea connection</h2><div class="form-grid"><div class="field full"><label for="settings-gitea-url">Instance URL</label><input id="settings-gitea-url" class="input" value="${attr(state.gitea.baseUrl)}" placeholder="https://gitea.example.com" /></div><div class="field full"><label for="settings-gitea-token">New access token</label><input id="settings-gitea-token" class="input" type="password" placeholder="Leave empty to keep the existing token" /></div></div><div class="connection-card" style="margin-top:10px"><div><strong>${state.gitea.hasToken ? `Connected as ${escapeHtml(state.gitea.user?.login || "user")}` : "Not connected"}</strong><div class="queue-sub">${escapeHtml(state.gitea.baseUrl || "No Gitea instance configured")}</div></div><button class="button primary" data-action="save-gitea-settings">Validate & save</button></div></section> <section class="settings-group"><h2>Gitea connection</h2><div class="form-grid"><div class="field full"><label for="settings-gitea-url">Instance URL</label><input id="settings-gitea-url" class="input" value="${attr(state.gitea.baseUrl)}" placeholder="https://gitea.example.com" /></div><div class="field full"><label for="settings-gitea-token">New access token</label><input id="settings-gitea-token" class="input" type="password" placeholder="Leave empty only when keeping the same server" /></div></div><div class="connection-card" style="margin-top:10px"><div><strong>${state.gitea.hasToken ? `Connected as ${escapeHtml(state.gitea.user?.login || "user")}` : "Not connected"}</strong><div class="queue-sub">${escapeHtml(state.gitea.baseUrl || "No Gitea instance configured")}</div></div><button class="button primary" data-action="save-gitea-settings">Validate & save</button></div></section>
<section class="settings-group"><div class="section-heading"><div><h2>ForgeFlow updates</h2><span class="meta">Secure source update from ${escapeHtml(state.updates?.owner || "Jens")}/${escapeHtml(state.updates?.repo || "ForgeFlow")}</span></div><button class="button" data-action="check-updates" ${ui.updateChecking ? "disabled" : ""}>${icon("update")}${ui.updateChecking ? "Checking…" : "Check now"}</button></div><div class="form-grid"><div class="field"><label>Repository owner</label><input id="update-owner" class="input" value="${attr(state.updates?.owner || "Jens")}"/></div><div class="field"><label>Repository name</label><input id="update-repo" class="input" value="${attr(state.updates?.repo || "ForgeFlow")}"/></div><div class="field"><label>Release branch</label><input id="update-branch" class="input" value="${attr(state.updates?.branch || "main")}"/></div><div class="field"><label>Automatic startup check</label><select id="update-auto-check" class="select"><option value="true" ${state.updates?.autoCheck !== false ? "selected" : ""}>Enabled</option><option value="false" ${state.updates?.autoCheck === false ? "selected" : ""}>Disabled</option></select></div></div><div class="update-card ${update?.available ? "available" : ""}"><div>${icon(update?.available ? "download" : "check")}<span><strong>${update ? (update.available ? `ForgeFlow ${escapeHtml(update.remoteVersion)} is available` : `ForgeFlow ${escapeHtml(update.currentVersion)} is up to date`) : `Current version ${escapeHtml(ui.boot.appVersion)}`}</strong><small>${update ? `Branch ${escapeHtml(update.branch)} · commit ${escapeHtml(update.shortSha)} · checked ${formatDate(update.checkedAt)}` : "No update check in this session."}</small></span></div><div class="stack horizontal compact">${update?.available && !update.downloaded ? `<button class="button primary" data-action="download-update">${icon("download")}Download update</button>` : ""}${update?.downloaded ? `<button class="button success" data-action="apply-update">${icon("update")}Apply & restart</button>` : ""}<button class="button" data-action="save-update-settings">Save update settings</button></div></div><div class="notice" style="margin-top:10px">${icon("shield")}The updater downloads an authenticated ZIP for the exact remote commit, verifies its SHA-256 checksum, runs the complete quality gate and restores the previous source version if validation fails.</div></section> <section class="settings-group"><div class="section-heading"><div><h2>ForgeFlow updates</h2><span class="meta">Signed packaged updates from ${escapeHtml(state.updates?.owner || "Jens")}/${escapeHtml(state.updates?.repo || "ForgeFlow-Public")}</span></div><button class="button" data-action="check-updates" ${ui.updateChecking ? "disabled" : ""}>${icon("update")}${ui.updateChecking ? "Checking…" : "Check now"}</button></div><div class="form-grid"><div class="field"><label>Repository owner</label><input id="update-owner" class="input" value="${attr(state.updates?.owner || "Jens")}"/></div><div class="field"><label>Repository name</label><input id="update-repo" class="input" value="${attr(state.updates?.repo || "ForgeFlow-Public")}"/></div><div class="field"><label>Release branch</label><input id="update-branch" class="input" value="${attr(state.updates?.branch || "main")}"/></div><div class="field"><label>Automatic startup check</label><select id="update-auto-check" class="select"><option value="true" ${state.updates?.autoCheck !== false ? "selected" : ""}>Enabled</option><option value="false" ${state.updates?.autoCheck === false ? "selected" : ""}>Disabled</option></select></div></div><div class="update-card ${update?.available ? "available" : ""}"><div>${icon(update?.available ? "download" : "check")}<span><strong>${update ? (update.available ? `ForgeFlow ${escapeHtml(update.remoteVersion)} is available${update.packaged ? "" : " in the source repository"}` : `ForgeFlow ${escapeHtml(update.currentVersion)} is up to date`) : `Current version ${escapeHtml(ui.boot.appVersion)}`}</strong><small>${update ? `${update.releaseTag ? `Release ${escapeHtml(update.releaseTag)}` : `Branch ${escapeHtml(update.branch)}`} · commit ${escapeHtml(update.shortSha)} · checked ${formatDate(update.checkedAt)}` : "No update check in this session."}</small></span></div><div class="stack horizontal compact">${update?.available && update.packaged && !update.downloaded ? `<button class="button primary" data-action="download-update">${icon("download")}Download signed update</button>` : ""}${update?.downloaded ? `<button class="button success" data-action="apply-update">${icon("update")}Apply & restart</button>` : ""}<button class="button" data-action="save-update-settings">Save update settings</button></div></div><div class="notice" style="margin-top:10px">${icon("shield")}${update && !update.packaged ? "Source checkouts must be updated with Git after reviewing the exact commit. Integrated source replacement remains disabled until source archives are publisher-signed." : "Packaged updates require an Ed25519 publisher signature that binds the exact commit, artifact name, size and SHA-256 digest."}</div></section>
<section class="settings-group"><div class="section-heading"><div><h2>SSH / Unraid servers</h2><span class="meta">Credentials are encrypted locally; a new host fingerprint is shown before authentication.</span></div><button class="button primary" data-action="open-add-server">${icon("plus")}Add server</button></div>${servers.length ? `<div class="server-list">${servers.map((server) => `<article class="server-card"><div class="server-card-main">${icon("server")}<div><strong>${escapeHtml(server.name)}</strong><span>${escapeHtml(server.username)}@${escapeHtml(server.host)}:${escapeHtml(server.port)} · ${escapeHtml(server.basePath)}</span><small>${server.hostFingerprint ? `Trusted ${escapeHtml(server.hostFingerprint)}` : "Host identity not trusted yet"}</small></div></div><div class="stack horizontal compact"><button class="button" data-action="test-server" data-server-id="${attr(server.id)}">${server.hostFingerprint ? "Test connection" : "Preview & trust fingerprint"}</button><button class="button" data-action="edit-server" data-server-id="${attr(server.id)}">Edit</button><button class="icon-button danger" data-action="delete-server" data-server-id="${attr(server.id)}" title="Delete server">${icon("trash")}</button></div></article>`).join("")}</div>` : '<div class="empty-state compact"><p>No SSH server configured. Add your Unraid server before creating an SSH deployment profile.</p></div>'}</section> <section class="settings-group"><div class="section-heading"><div><h2>SSH / Unraid servers</h2><span class="meta">Credentials are encrypted locally; a new host fingerprint is shown before authentication.</span></div><button class="button primary" data-action="open-add-server">${icon("plus")}Add server</button></div>${servers.length ? `<div class="server-list">${servers.map((server) => `<article class="server-card"><div class="server-card-main">${icon("server")}<div><strong>${escapeHtml(server.name)}</strong><span>${escapeHtml(server.username)}@${escapeHtml(server.host)}:${escapeHtml(server.port)} · ${escapeHtml(server.basePath)}</span><small>${server.hostFingerprint ? `Trusted ${escapeHtml(server.hostFingerprint)}` : "Host identity not trusted yet"}</small></div></div><div class="stack horizontal compact"><button class="button" data-action="test-server" data-server-id="${attr(server.id)}">${server.hostFingerprint ? "Test connection" : "Preview & trust fingerprint"}</button><button class="button" data-action="edit-server" data-server-id="${attr(server.id)}">Edit</button><button class="icon-button danger" data-action="delete-server" data-server-id="${attr(server.id)}" title="Delete server">${icon("trash")}</button></div></article>`).join("")}</div>` : '<div class="empty-state compact"><p>No SSH server configured. Add your Unraid server before creating an SSH deployment profile.</p></div>'}</section>
<section class="settings-group"><div class="section-heading"><div><h2>Git remote maintenance</h2><span class="meta">Standardize linked repositories to the current Gitea SSH URLs.</span></div><button class="button" data-action="normalize-origins">${icon("link")}Normalize all origins</button></div><p>This replaces legacy aliases and renamed owners only after an explicit click. Local commits and files are not changed.</p></section> <section class="settings-group"><div class="section-heading"><div><h2>Git remote maintenance</h2><span class="meta">Standardize linked repositories to the current Gitea SSH URLs.</span></div><button class="button" data-action="normalize-origins">${icon("link")}Normalize all origins</button></div><p>This replaces legacy aliases and renamed owners only after an explicit click. Local commits and files are not changed.</p></section>
<section class="settings-group"><h2>Project roots</h2><p>The first folder is the default clone destination. ForgeFlow automatically creates one subfolder per repository.</p><div class="stack">${state.workspaceRoots.map((root, index) => `<div class="root-row">${index === 0 ? '<span class="status-pill success">Default</span>' : ""}<input class="input" data-root-index="${index}" value="${attr(root)}" aria-label="Project root ${index + 1}"/><button class="icon-button" data-action="remove-root" data-index="${index}" title="Remove">${icon("trash")}</button></div>`).join("")}<button class="button" data-action="add-root">${icon("plus")}Add project root</button><button class="button primary" data-action="save-roots">Save folders & rescan</button></div></section> <section class="settings-group"><h2>Project roots</h2><p>The first folder is the default clone destination. ForgeFlow automatically creates one subfolder per repository.</p><div class="stack">${state.workspaceRoots.map((root, index) => `<div class="root-row">${index === 0 ? '<span class="status-pill success">Default</span>' : ""}<input class="input" data-root-index="${index}" value="${attr(root)}" aria-label="Project root ${index + 1}"/><button class="icon-button" data-action="remove-root" data-index="${index}" title="Remove">${icon("trash")}</button></div>`).join("")}<button class="button" data-action="add-root">${icon("plus")}Add project root</button><button class="button primary" data-action="save-roots">Save folders & rescan</button></div></section>
+27
View File
@@ -0,0 +1,27 @@
'use strict';
function windowsReleaseAssetNames(version) {
return [
...['Setup', 'Portable'].flatMap((kind) => {
const name = `ForgeFlow-${kind}-${version}-win-x64.exe`;
return [name, `${name}.sha256`];
}),
`ForgeFlow-${version}-provenance.json`,
`ForgeFlow-${version}-sbom.cdx.json`,
`ForgeFlow-${version}-release-manifest.json`,
`ForgeFlow-${version}-release-manifest.json.sig`
];
}
function existingReleaseState(release, version, commit) {
if (release.target_commitish !== commit) {
throw new Error(`Release v${version} already targets ${release.target_commitish}; refusing assets from ${commit}. Bump the version for a new source commit.`);
}
if (release.draft) return 'draft';
const names = new Set((release.assets || []).map((asset) => asset.name));
const missing = windowsReleaseAssetNames(version).filter((name) => !names.has(name));
if (missing.length) throw new Error(`Published release v${version} is missing: ${missing.join(', ')}.`);
return 'published';
}
module.exports = { windowsReleaseAssetNames, existingReleaseState };
+5 -1
View File
@@ -1,3 +1,4 @@
const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
function normalizeRelativePosixPath(value) { function normalizeRelativePosixPath(value) {
@@ -19,11 +20,14 @@ function bashSyntaxCheckInvocation(root, scriptPath = 'examples/server/forgeflow
if (typeof root !== 'string' || !root.trim()) { if (typeof root !== 'string' || !root.trim()) {
throw new Error('Project root is required for shell validation.'); throw new Error('Project root is required for shell validation.');
} }
const relativeScriptPath = normalizeRelativePosixPath(scriptPath);
const scriptText = fs.readFileSync(path.join(root, ...relativeScriptPath.split('/')), 'utf8');
return { return {
command: 'bash', command: 'bash',
args: ['-n', normalizeRelativePosixPath(scriptPath)], args: ['-n'],
options: { options: {
cwd: root, cwd: root,
input: scriptText.replace(/\r\n?/g, '\n'),
encoding: 'utf8', encoding: 'utf8',
windowsHide: true windowsHide: true
} }
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const workflowUrl = new URL('../examples/gitea-actions/forgeflow-approved-deploy.yml', import.meta.url);
const deployUrl = new URL('../examples/server/forgeflow-deploy', import.meta.url);
test('central approved workflow transports signed target evidence only to the root-owned deploy wrapper', async () => {
const workflow = await readFile(workflowUrl, 'utf8');
for (const input of [
'repository',
'environment',
'commit_sha',
'request_id',
'approval_id',
'approval_fingerprint',
'evidence_issued_at',
'evidence_signature',
]) {
assert.match(workflow, new RegExp(`\\b${input}:`));
}
assert.match(workflow, /\$\{\{ inputs\.repository \}\}/);
assert.doesNotMatch(workflow, /\$\{\{ gitea\.repository \}\}/);
assert.match(workflow, /FF_APPROVAL_ID.*FF_REQUEST_ID/s);
assert.match(workflow, /sudo \/usr\/local\/bin\/forgeflow-deploy/);
assert.doesNotMatch(workflow, /actions\/checkout/);
assert.doesNotMatch(workflow, /docker compose/);
assert.doesNotMatch(workflow, /git\s+-C/);
});
test('server wrapper verifies Ed25519 evidence before any live git or compose mutation', async () => {
const script = await readFile(deployUrl, 'utf8');
const verifyIndex = script.indexOf('openssl pkeyutl -verify');
const resetIndex = script.indexOf('git -C "$APP_DIR" reset --hard "$SHA"');
const composeIndex = script.indexOf('docker compose -f "$COMPOSE_FILE" up -d --build');
assert.ok(verifyIndex > 0, 'expected cryptographic verification');
assert.ok(resetIndex > verifyIndex, 'git reset must happen after evidence verification');
assert.ok(composeIndex > verifyIndex, 'compose mutation must happen after evidence verification');
assert.match(script, /EVIDENCE_PUBLIC_KEY_FILE="\/etc\/forgeflow\/evidence\.pub"/);
assert.match(script, /evidence_owner.*root/s);
assert.match(script, /8#022/);
assert.match(script, /EVIDENCE_ISSUED_AT >= now_epoch - 1800/);
assert.match(script, /"evidence_verified": \$EVIDENCE_VERIFIED/);
assert.match(script, /\(\( \$# == 3 \|\| \$# == 4 \|\| \$# == 8 \)\)/);
});
test('signed message fields match the AppOps evidence v1 contract and exclude runner-chosen workflow/ref', async () => {
const script = await readFile(deployUrl, 'utf8');
const marker = "printf 'forgeflow-evidence-v1\\n%s\\n%s\\n%s\\n%s\\n%s\\n%s\\n%s\\n'";
assert.ok(script.includes(marker));
assert.match(
script,
/"\$APPROVAL_ID"[\s\\]+"\$APPROVAL_FINGERPRINT"[\s\\]+"\$REPOSITORY"[\s\\]+"\$ENVIRONMENT"[\s\\]+"\$\{SHA,,\}"[\s\\]+"\$REQUEST_ID"[\s\\]+"\$EVIDENCE_ISSUED_AT"/s,
);
assert.doesNotMatch(script.slice(0, script.indexOf('APP_DIR=""')), /WORKFLOW|workflow|ref=/);
});
@@ -0,0 +1,21 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const deployUrl = new URL('../examples/server/forgeflow-deploy', import.meta.url);
test('a signed approved request is consumed once before target selection or mutation', async () => {
const script = await readFile(deployUrl, 'utf8');
const verifyIndex = script.indexOf('openssl pkeyutl -verify');
const consumeIndex = script.indexOf('mkdir -m 0700 "$EVIDENCE_REPLAY_DIR/$APPROVAL_ID"');
const targetIndex = script.indexOf('APP_DIR=""');
const resetIndex = script.indexOf('git -C "$APP_DIR" reset --hard "$SHA"');
assert.ok(verifyIndex > 0);
assert.ok(consumeIndex > verifyIndex);
assert.ok(targetIndex > consumeIndex);
assert.ok(resetIndex > consumeIndex);
assert.match(script, /EVIDENCE_REPLAY_DIR="\/var\/lib\/forgeflow-status\/approved-requests"/);
assert.match(script, /install -d -o root -g root -m 0700 "\$EVIDENCE_REPLAY_DIR"/);
assert.match(script, /Approved deployment evidence was already consumed/);
});
+33 -1
View File
@@ -48,6 +48,20 @@ test("load creates missing config and recovers malformed JSON", async (t) => {
assert.ok((await readdir(directory)).some((name) => name.includes(".corrupt-"))); assert.ok((await readdir(directory)).some((name) => name.includes(".corrupt-")));
}); });
test("legacy ForgeFlow update endpoint migrates to the signed public releases", async (t) => {
const { directory, store } = await storeFixture(t);
assert.equal(store.data.updates.repo, "ForgeFlow-Public");
await writeFile(store.filePath, JSON.stringify({
updates: { owner: "Jens", repo: "ForgeFlow", branch: "main", autoCheck: true },
}), "utf8");
await store.load();
assert.equal(store.data.updates.repo, "ForgeFlow-Public");
assert.equal(JSON.parse(await readFile(store.filePath, "utf8")).updates.repo, "ForgeFlow-Public");
assert.equal(store.migrate({ updates: { owner: "Team", repo: "ForgeFlow" } }).updates.repo, "ForgeFlow");
assert.equal(store.migrate({ updates: { owner: "Jens", repo: "CustomUpdates" } }).updates.repo, "CustomUpdates");
assert.equal(store.migrate({ updates: { owner: "Jens", repo: "ForgeFlow", branch: "custom" } }).updates.repo, "ForgeFlow");
});
test("server normalization rejects unsafe targets and preserves bounded scan configuration", async (t) => { test("server normalization rejects unsafe targets and preserves bounded scan configuration", async (t) => {
const { store } = await storeFixture(t); const { store } = await storeFixture(t);
assert.throws(() => store.normalizeServer({ host: "bad host", username: "root" }), /hostname/); assert.throws(() => store.normalizeServer({ host: "bad host", username: "root" }), /hostname/);
@@ -193,7 +207,11 @@ test("setup, Gitea updates and generic patches retain normalized public state",
assert.equal(completed.state.setupComplete, true); assert.equal(completed.state.setupComplete, true);
assert.equal(completed.state.gitea.hasToken, true); assert.equal(completed.state.gitea.hasToken, true);
assert.deepEqual(completed.state.workspaceRoots, ["C:/Projects"]); assert.deepEqual(completed.state.workspaceRoots, ["C:/Projects"]);
const update = await store.updateGitea({ baseUrl: "https://new.test", token: "", user: null }); await assert.rejects(
store.updateGitea({ baseUrl: "https://new.test", token: "", user: null }),
(error) => error.code === "GITEA_TOKEN_ORIGIN_CHANGED"
);
const update = await store.updateGitea({ baseUrl: "https://gitea.test", token: "", user: null });
assert.equal(update.preserved, true); assert.equal(update.preserved, true);
assert.equal(store.data.gitea.user.login, "jens"); assert.equal(store.data.gitea.user.login, "jens");
const patched = await store.patch({ appearance: "light", workspaceRoots: ["D:/Code", "D:/Code"] }); const patched = await store.patch({ appearance: "light", workspaceRoots: ["D:/Code", "D:/Code"] });
@@ -215,6 +233,20 @@ test("server saves reject absent credentials before mutating configuration", asy
assert.deepEqual(store.data.servers, []); assert.deepEqual(store.data.servers, []);
}); });
test("server credentials and trust are cleared when the connection identity changes", async (t) => {
const { store } = await storeFixture(t);
store.encryptSecret = (value) => `encrypted:${value}`;
const saved = await store.saveServer({ host: "server-one", username: "deploy", authType: "password", basePath: "/mnt/apps", hostFingerprint: "SHA256:trusted" }, { password: "test-password" });
await assert.rejects(
store.saveServer({ ...saved, host: "server-two" }, {}),
/password is required/i
);
assert.equal(store.data.servers[0].host, "server-one");
const changed = await store.saveServer({ ...saved, host: "server-two" }, { password: "replacement-password" });
assert.equal(changed.hostFingerprint, "");
assert.equal(changed.hasPassword, true);
});
test("deployment profile normalization covers safe defaults and every optional Unraid control", async (t) => { test("deployment profile normalization covers safe defaults and every optional Unraid control", async (t) => {
const { store } = await storeFixture(t); const { store } = await storeFixture(t);
const actions = store.normalizeDeploymentProfile({ environment: "qa", statusUrl: "https://app.test/status" }); const actions = store.normalizeDeploymentProfile({ environment: "qa", statusUrl: "https://app.test/status" });
+5 -2
View File
@@ -41,7 +41,9 @@ test('writes structured local diagnostics and exports a secret-free support bund
authorization: `token ${secret}`, authorization: `token ${secret}`,
password: 'unsafe-password', password: 'unsafe-password',
message: `request failed with ${secret}`, message: `request failed with ${secret}`,
path: path.join(os.homedir(), 'private', 'repository') path: path.join(os.homedir(), 'private', 'repository'),
host: '192.168.10.20',
basePath: '/mnt/user/appdata/private-app'
}); });
await service.flush(); await service.flush();
@@ -57,7 +59,7 @@ test('writes structured local diagnostics and exports a secret-free support bund
const result = await service.exportSupportBundle({ const result = await service.exportSupportBundle({
destinationPath: destination, destinationPath: destination,
privacyMode: 'strict', privacyMode: 'strict',
publicState: { gitea: { baseUrl: 'https://gitea.example.test', hasToken: true, encryptedToken: 'ciphertext' }, preferences: {} }, publicState: { gitea: { baseUrl: 'https://gitea.example.test', hasToken: true, encryptedToken: 'ciphertext' }, servers: [{ host: '192.168.10.20', username: 'deploy', basePath: '/mnt/user/appdata/private-app' }], preferences: {} },
repositories: [{ id: 1, fullName: 'jens/private-repo', localPath: path.join(os.homedir(), 'private-repo'), localStatus: { head: 'a'.repeat(40), branch: { head: 'main' }, counts: {}, clean: true } }], repositories: [{ id: 1, fullName: 'jens/private-repo', localPath: path.join(os.homedir(), 'private-repo'), localStatus: { head: 'a'.repeat(40), branch: { head: 'main' }, counts: {}, clean: true } }],
operations: [{ repository: 'jens/private-repo', status: 'failed', runnerLog: `Authorization: token ${secret}` }], operations: [{ repository: 'jens/private-repo', status: 'failed', runnerLog: `Authorization: token ${secret}` }],
preflight: { checks: [] } preflight: { checks: [] }
@@ -67,6 +69,7 @@ test('writes structured local diagnostics and exports a secret-free support bund
const bundleText = [...entries.values()].map((value) => value.toString('utf8')).join('\n'); const bundleText = [...entries.values()].map((value) => value.toString('utf8')).join('\n');
assert.doesNotMatch(bundleText, new RegExp(secret)); assert.doesNotMatch(bundleText, new RegExp(secret));
assert.doesNotMatch(bundleText, /ciphertext|unsafe-password|jens\/private-repo/); assert.doesNotMatch(bundleText, /ciphertext|unsafe-password|jens\/private-repo/);
assert.doesNotMatch(bundleText, /192\.168\.10\.20|\/mnt\/user\/appdata\/private-app/);
assert.match(entries.get('manifest.json').toString(), /"containsSecrets": false/); assert.match(entries.get('manifest.json').toString(), /"containsSecrets": false/);
assert.match(entries.get('repositories-sanitized.json').toString(), /fullname-[a-f0-9]{12}/); assert.match(entries.get('repositories-sanitized.json').toString(), /fullname-[a-f0-9]{12}/);
+4 -3
View File
@@ -5,9 +5,10 @@ import toolsModule from '../src/main/external-tools-service.cjs';
const { normalizeTool, expandTool } = toolsModule; const { normalizeTool, expandTool } = toolsModule;
test('external tool templates expand as argument arrays without a shell', () => { test('external tool templates expand as argument arrays without a shell', () => {
const tool = normalizeTool({ executable: 'code.exe', args: ['--goto', '{file}:{line}', '{path}'] }, {}); const tool = normalizeTool({ executable: 'code.exe', args: ['--malicious', 'ignored'] }, { executable: 'code.exe' }, 'editor');
const invocation = expandTool(tool, { path: 'C:\\Projects\\App', file: 'C:\\Projects\\App\\src\\app.js', line: 12 }); const invocation = expandTool(tool, { path: 'C:\\Projects\\App', file: 'C:\\Projects\\App\\src\\app.js', line: 12 });
assert.equal(invocation.executable, 'code.exe'); assert.equal(invocation.executable, 'code.exe');
assert.deepEqual(invocation.args, ['--goto', 'C:\\Projects\\App\\src\\app.js:12', 'C:\\Projects\\App']); assert.deepEqual(invocation.args, ['--reuse-window', '--goto', 'C:\\Projects\\App\\src\\app.js:12']);
assert.throws(() => normalizeTool({ executable: 'code.exe\ncalc.exe', args: [] }, {}), /invalid/); assert.throws(() => normalizeTool({ executable: 'code.exe\ncalc.exe', args: [] }, {}, 'editor'), /invalid/);
assert.throws(() => normalizeTool({ executable: 'powershell.exe', args: ['-Command', 'calc'] }, {}, 'terminal'), /unsupported terminal tool/i);
}); });
+19
View File
@@ -295,6 +295,16 @@ test('previews and safely mirrors a workspace to Gitea while preserving every cl
assert.equal(result.status.head, reviewedPlan.targetSha); assert.equal(result.status.head, reviewedPlan.targetSha);
assert.match(result.backupBranch, /^forgeflow\/recovery-main-/); assert.match(result.backupBranch, /^forgeflow\/recovery-main-/);
assert.ok(result.stash?.sha); assert.ok(result.stash?.sha);
assert.equal(result.stash.quarantined, true);
assert.equal(result.review.id, reviewedPlan.id);
assert.equal(result.review.status, 'pending-codex-review');
const reviewManifest = JSON.parse(await fs.readFile(result.review.manifestPath, 'utf8'));
assert.equal(reviewManifest.recoveryBranch, result.backupBranch);
assert.equal(reviewManifest.stashSha, result.stash.sha);
assert.deepEqual(
new Set(reviewManifest.files.map((file) => file.path)),
new Set(['README.md', 'local-notes.txt', 'changed-after-preview.txt'])
);
assert.equal((await git(['rev-parse', result.backupBranch], working)).stdout.trim(), localHead); assert.equal((await git(['rev-parse', result.backupBranch], working)).stdout.trim(), localHead);
assert.equal((await fs.readFile(path.join(working, 'README.md'), 'utf8')).replace(/\r\n/g, '\n'), 'changed on Gitea\n'); assert.equal((await fs.readFile(path.join(working, 'README.md'), 'utf8')).replace(/\r\n/g, '\n'), 'changed on Gitea\n');
assert.equal((await fs.readFile(path.join(working, 'remote-only.txt'), 'utf8')).replace(/\r\n/g, '\n'), 'new on Gitea\n'); assert.equal((await fs.readFile(path.join(working, 'remote-only.txt'), 'utf8')).replace(/\r\n/g, '\n'), 'new on Gitea\n');
@@ -306,6 +316,15 @@ test('previews and safely mirrors a workspace to Gitea while preserving every cl
assert.match(stashedPaths, /README\.md/); assert.match(stashedPaths, /README\.md/);
assert.match(stashedPaths, /local-notes\.txt/); assert.match(stashedPaths, /local-notes\.txt/);
assert.match(stashedPaths, /changed-after-preview\.txt/); assert.match(stashedPaths, /changed-after-preview\.txt/);
await assert.rejects(
service.popStash(working, result.stash.ref),
(error) => error.code === 'WORKSPACE_QUARANTINE_REVIEW_REQUIRED'
);
await git(['switch', result.backupBranch], working);
await assert.rejects(
service.push(working),
(error) => error.code === 'WORKSPACE_RECOVERY_BRANCH_LOCAL_ONLY'
);
}); });
test('repairs a diverged branch by creating a safety branch before resetting to upstream', async (t) => { test('repairs a diverged branch by creating a safety branch before resetting to upstream', async (t) => {
+1 -1
View File
@@ -164,7 +164,7 @@ test('rewrites Gitea internal HTTP release URLs to the configured public origin'
return Buffer.from('asset'); return Buffer.from('asset');
}; };
await service.downloadReleaseAsset('Jens', 'ForgeFlow', 107, 412, { await service.downloadReleaseAsset('Jens', 'ForgeFlow', 107, 412, {
downloadUrl: 'http://192.168.10.150:3000/Jens/ForgeFlow/releases/download/v0.10.1/ForgeFlow.exe', downloadUrl: 'http://192.168.56.10:3000/Jens/ForgeFlow/releases/download/v0.10.1/ForgeFlow.exe',
}); });
assert.equal(requested, 'https://gitea.example.test/Jens/ForgeFlow/releases/download/v0.10.1/ForgeFlow.exe'); assert.equal(requested, 'https://gitea.example.test/Jens/ForgeFlow/releases/download/v0.10.1/ForgeFlow.exe');
}); });
+12 -5
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import redaction from '../src/main/log-redaction.cjs'; import redaction from '../src/main/log-redaction.cjs';
const { redactSecrets, sanitizeForDiagnostics, pathAlias, stableAlias } = redaction; const { redactSecrets, sanitizeForDiagnostics, pathAlias, stableAlias, redactPrivateInfrastructure } = redaction;
test('redacts runtime credentials, structured secrets, private keys and URL credentials', () => { test('redacts runtime credentials, structured secrets, private keys and URL credentials', () => {
const token = ['gitea', 'TEST', 'ONLY', 'SecretToken123456'].join('_'); const token = ['gitea', 'TEST', 'ONLY', 'SecretToken123456'].join('_');
@@ -20,7 +20,7 @@ test('redacts runtime credentials, structured secrets, private keys and URL cred
test('sanitizes nested sensitive keys and aliases user paths', () => { test('sanitizes nested sensitive keys and aliases user paths', () => {
const value = { const value = {
accessToken: 'do-not-keep', accessToken: 'do-not-keep',
nested: { password: 'do-not-keep-either', path: 'C:\\Users\\Jens\\Projects\\ForgeFlow' }, nested: { password: 'do-not-keep-either', path: 'C:\\Users\\example-user\\Projects\\ForgeFlow' },
home: '/home/jens/projects/forgeflow' home: '/home/jens/projects/forgeflow'
}; };
const sanitized = sanitizeForDiagnostics(value, { homeDir: '/home/jens', cwd: '/work/ForgeFlow' }); const sanitized = sanitizeForDiagnostics(value, { homeDir: '/home/jens', cwd: '/work/ForgeFlow' });
@@ -31,17 +31,24 @@ test('sanitizes nested sensitive keys and aliases user paths', () => {
}); });
test('strict privacy mode replaces stable identifiers deterministically', () => { test('strict privacy mode replaces stable identifiers deterministically', () => {
const first = sanitizeForDiagnostics({ fullName: 'jens/private-project', login: 'jens' }, { strictIdentifiers: true }); const first = sanitizeForDiagnostics({ fullName: 'jens/private-project', login: 'jens', host: '192.168.10.20', basePath: '/mnt/user/appdata' }, { strictIdentifiers: true });
const second = sanitizeForDiagnostics({ fullName: 'jens/private-project', login: 'jens' }, { strictIdentifiers: true }); const second = sanitizeForDiagnostics({ fullName: 'jens/private-project', login: 'jens', host: '192.168.10.20', basePath: '/mnt/user/appdata' }, { strictIdentifiers: true });
assert.equal(first.fullName, second.fullName); assert.equal(first.fullName, second.fullName);
assert.equal(first.login, second.login); assert.equal(first.login, second.login);
assert.notEqual(first.fullName, 'jens/private-project'); assert.notEqual(first.fullName, 'jens/private-project');
assert.match(first.fullName, /^fullname-[a-f0-9]{12}$/); assert.match(first.fullName, /^fullname-[a-f0-9]{12}$/);
assert.notEqual(first.host, '192.168.10.20');
assert.notEqual(first.basePath, '/mnt/user/appdata');
assert.equal(stableAlias('same', 'repo'), stableAlias('same', 'repo')); assert.equal(stableAlias('same', 'repo'), stableAlias('same', 'repo'));
}); });
test('strict privacy redacts private addresses, infrastructure URLs and server paths in log text', () => {
const result = redactPrivateInfrastructure('host 192.168.10.20 url https://internal.example.test/status path /mnt/user/appdata/example');
assert.doesNotMatch(result, /192\.168\.10\.20|internal\.example\.test|\/mnt\/user\/appdata/);
});
test('path aliasing handles slash variants', () => { test('path aliasing handles slash variants', () => {
const result = pathAlias('C:\\Users\\Jens\\src and C:/Users/Jens/src', { homeDir: 'C:\\Users\\Jens', cwd: 'D:\\ForgeFlow' }); const result = pathAlias('C:\\Users\\example-user\\src and C:/Users/example-user/src', { homeDir: 'C:\\Users\\example-user', cwd: 'D:\\ForgeFlow' });
assert.doesNotMatch(result, /Users[\\/]Jens/); assert.doesNotMatch(result, /Users[\\/]Jens/);
assert.match(result, /<HOME>/); assert.match(result, /<HOME>/);
}); });
+21
View File
@@ -0,0 +1,21 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import releasePolicy from '../src/shared/release-policy.cjs';
const { windowsReleaseAssetNames, existingReleaseState } = releasePolicy;
const commit = 'a'.repeat(40);
const version = '0.10.16';
test('a draft for another commit cannot receive replacement release assets', () => {
assert.throws(() => existingReleaseState({ target_commitish: 'b'.repeat(40), draft: true }, version, commit), /Bump the version/);
});
test('a matching draft can resume while a complete published release is immutable', () => {
assert.equal(existingReleaseState({ target_commitish: commit, draft: true }, version, commit), 'draft');
assert.equal(existingReleaseState({ target_commitish: commit, draft: false, assets: windowsReleaseAssetNames(version).map((name) => ({ name })) }, version, commit), 'published');
});
test('a published release missing its signature cannot be treated as complete', () => {
const assets = windowsReleaseAssetNames(version).filter((name) => !name.endsWith('.sig')).map((name) => ({ name }));
assert.throws(() => existingReleaseState({ target_commitish: commit, draft: false, assets }, version, commit), /release-manifest.json.sig/);
});
+4
View File
@@ -85,6 +85,10 @@ test('a watched repository is read on filesystem activity instead of on every in
revision = 2; revision = 2;
await writeFile(path.join(root, 'feature.txt'), 'changed\n'); await writeFile(path.join(root, 'feature.txt'), 'changed\n');
// Exercise the monitor's filesystem-activity boundary deterministically.
// Native fs.watch delivery is platform/overlay specific and is covered by
// the product's safety interval rather than by this unit test.
monitor.noteFilesystemChange(root);
// The watcher debounce and the per-repository cooldown both apply here. // The watcher debounce and the per-repository cooldown both apply here.
const deadline = Date.now() + 5_000; const deadline = Date.now() + 5_000;
while (changes.length === 0 && Date.now() < deadline) { while (changes.length === 0 && Date.now() < deadline) {
+10 -17
View File
@@ -8,15 +8,6 @@ import shellVerification from '../src/shared/shell-verification.cjs';
const { bashSyntaxCheckInvocation, bashSyntaxCheckFromTextInvocation, normalizeRelativePosixPath, validateShellScriptStructure, shouldRunExternalBash } = shellVerification; const { bashSyntaxCheckInvocation, bashSyntaxCheckFromTextInvocation, normalizeRelativePosixPath, validateShellScriptStructure, shouldRunExternalBash } = shellVerification;
test('Bash syntax validation keeps Windows project roots in cwd and passes a relative POSIX path', () => {
const invocation = bashSyntaxCheckInvocation('C:\\Projects\\ForgeFlow');
assert.equal(invocation.command, 'bash');
assert.deepEqual(invocation.args, ['-n', 'examples/server/forgeflow-deploy']);
assert.equal(invocation.options.cwd, 'C:\\Projects\\ForgeFlow');
assert.equal(invocation.args[1].includes('\\'), false);
assert.equal(/^[A-Za-z]:/.test(invocation.args[1]), false);
});
test('Shell validation refuses absolute and escaping script paths', () => { test('Shell validation refuses absolute and escaping script paths', () => {
assert.throws(() => normalizeRelativePosixPath('C:\\Projects\\ForgeFlow\\script.sh'), /must be relative/); assert.throws(() => normalizeRelativePosixPath('C:\\Projects\\ForgeFlow\\script.sh'), /must be relative/);
assert.throws(() => normalizeRelativePosixPath('/tmp/script.sh'), /must be relative/); assert.throws(() => normalizeRelativePosixPath('/tmp/script.sh'), /must be relative/);
@@ -34,11 +25,19 @@ test('Bash syntax validation works from a project root containing spaces', async
await mkdir(relativeDirectory, { recursive: true }); await mkdir(relativeDirectory, { recursive: true });
await copyFile(new URL('../examples/server/forgeflow-deploy', import.meta.url), path.join(relativeDirectory, 'forgeflow-deploy')); await copyFile(new URL('../examples/server/forgeflow-deploy', import.meta.url), path.join(relativeDirectory, 'forgeflow-deploy'));
const invocation = bashSyntaxCheckInvocation(tempBase); const invocation = bashSyntaxCheckInvocation(tempBase);
assert.equal(invocation.options.cwd, tempBase);
assert.deepEqual(invocation.args, ['-n']);
assert.equal(invocation.options.input.includes('\r'), false);
const result = spawnSync(invocation.command, invocation.args, invocation.options); const result = spawnSync(invocation.command, invocation.args, invocation.options);
assert.equal(result.status, 0, result.stderr); assert.equal(result.status, 0, result.stderr);
} finally { } finally {
try { try {
await rm(tempBase, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }); await rm(tempBase, {
recursive: true,
force: true,
maxRetries: 20,
retryDelay: 100
});
} catch (error) { } catch (error) {
// Git Bash on Windows can retain a short-lived working-directory handle // Git Bash on Windows can retain a short-lived working-directory handle
// after bash -n exits. Do not fail a successful syntax test solely because // after bash -n exits. Do not fail a successful syntax test solely because
@@ -48,7 +47,6 @@ test('Bash syntax validation works from a project root containing spaces', async
} }
}); });
test('Bash syntax validation from text does not depend on a Windows working directory', () => { test('Bash syntax validation from text does not depend on a Windows working directory', () => {
const invocation = bashSyntaxCheckFromTextInvocation('#!/usr/bin/env bash\nset -euo pipefail\necho ok\n'); const invocation = bashSyntaxCheckFromTextInvocation('#!/usr/bin/env bash\nset -euo pipefail\necho ok\n');
assert.equal(invocation.command, 'bash'); assert.equal(invocation.command, 'bash');
@@ -67,7 +65,6 @@ test('Bash syntax validation from text detects malformed scripts', (t) => {
assert.notEqual(result.status, 0); assert.notEqual(result.status, 0);
}); });
test('portable server-script validation does not require a local Bash executable', () => { test('portable server-script validation does not require a local Bash executable', () => {
const script = `#!/usr/bin/env bash const script = `#!/usr/bin/env bash
set -Eeuo pipefail set -Eeuo pipefail
@@ -89,13 +86,9 @@ write_status "unhealthy"
}); });
test('portable server-script validation refuses missing deployment safety markers', () => { test('portable server-script validation refuses missing deployment safety markers', () => {
assert.throws( assert.throws(() => validateShellScriptStructure('#!/usr/bin/env bash\nset -Eeuo pipefail\necho unsafe\n'), /missing required safety marker/);
() => validateShellScriptStructure('#!/usr/bin/env bash\nset -Eeuo pipefail\necho unsafe\n'),
/missing required safety marker/
);
}); });
test('Windows publication never depends on an external Bash shim', () => { test('Windows publication never depends on an external Bash shim', () => {
assert.equal(shouldRunExternalBash('win32'), false); assert.equal(shouldRunExternalBash('win32'), false);
assert.equal(shouldRunExternalBash('linux'), true); assert.equal(shouldRunExternalBash('linux'), true);
+7 -7
View File
@@ -121,10 +121,10 @@ test("server pull prefers the linked checkout origin over stale detected SSH end
const repository = { const repository = {
fullName: "Jens/Portfolio", fullName: "Jens/Portfolio",
localStatus: { remoteUrl: "git@gitea.itworx.tech:Jens/Portfolio.git" }, localStatus: { remoteUrl: "git@gitea.itworx.tech:Jens/Portfolio.git" },
sshUrl: "ssh://git@192.168.10.150:222/Jens/Portfolio.git", sshUrl: "ssh://git@192.168.56.10:222/Jens/Portfolio.git",
preferredCloneUrl: "ssh://git@192.168.10.150:222/Jens/Portfolio.git", preferredCloneUrl: "ssh://git@192.168.56.10:222/Jens/Portfolio.git",
}; };
const profile = { cloneUrl: "ssh://git@192.168.10.150:222/Jens/Portfolio.git" }; const profile = { cloneUrl: "ssh://git@192.168.56.10:222/Jens/Portfolio.git" };
assert.equal(service.serverGitRemote(repository, profile), "git@gitea.itworx.tech:Jens/Portfolio.git"); assert.equal(service.serverGitRemote(repository, profile), "git@gitea.itworx.tech:Jens/Portfolio.git");
assert.deepEqual(service.serverGitHost(repository, profile), { host: "gitea.itworx.tech", port: 22 }); assert.deepEqual(service.serverGitHost(repository, profile), { host: "gitea.itworx.tech", port: 22 });
@@ -894,7 +894,7 @@ test("DockerMan metadata uses dockerman labels, a template WebUI and lowercase-s
remoteFolder: "Portfolio", remoteFolder: "Portfolio",
environment: "production", environment: "production",
hostPort: 5150, hostPort: 5150,
webUiUrl: "http://192.168.10.150:5150/admin", webUiUrl: "http://192.168.56.10:5150/admin",
dockerShell: "/bin/sh", dockerShell: "/bin/sh",
}, },
{ name: "Portfolio" }, { name: "Portfolio" },
@@ -927,7 +927,7 @@ test("DockerMan integration writes a persistent template fallback and invalidate
remoteFolder: "Portfolio", remoteFolder: "Portfolio",
environment: "production", environment: "production",
hostPort: 5150, hostPort: 5150,
webUiUrl: "http://192.168.10.150:5150/", webUiUrl: "http://192.168.56.10:5150/",
dockerShell: "/bin/sh", dockerShell: "/bin/sh",
manageDockerMan: true, manageDockerMan: true,
generatedCompose: true, generatedCompose: true,
@@ -1124,7 +1124,7 @@ test("existing Unraid deployment discovery derives profile values from Docker, C
defaultBranch: "main", defaultBranch: "main",
sshUrl: "ssh://git@gitea/Jens/blockpilot-autonomous.git", sshUrl: "ssh://git@gitea/Jens/blockpilot-autonomous.git",
}, },
server: { id: "unraid", host: "192.168.10.150" }, server: { id: "unraid", host: "192.168.56.10" },
remoteFolder: "blockpilot-autonomous", remoteFolder: "blockpilot-autonomous",
remotePath: "/mnt/user/appdata/blockpilot-autonomous", remotePath: "/mnt/user/appdata/blockpilot-autonomous",
payload: { payload: {
@@ -1399,7 +1399,7 @@ test("push bundle preflight does not require Git or Gitea credentials on Unraid"
getServer: () => ({ getServer: () => ({
id: "unraid", id: "unraid",
name: "Unraid", name: "Unraid",
host: "192.168.10.150", host: "192.168.56.10",
port: 22, port: 22,
username: "root", username: "root",
basePath: "/mnt/user/appdata", basePath: "/mnt/user/appdata",
+65 -6
View File
@@ -118,7 +118,34 @@ test("update repository parts reject path injection", async () => {
await rm(temp, { recursive: true, force: true }); await rm(temp, { recursive: true, force: true });
}); });
test("source updater confirms an external STARTED marker before ForgeFlow may close", async () => { test("packaged updates pin the published release commit despite later source-only changes", async (t) => {
const temp = await mkdtemp(path.join(os.tmpdir(), "forgeflow-release-check-"));
t.after(() => rm(temp, { recursive: true, force: true }));
const releaseCommit = "b".repeat(40);
const service = new UpdateService({
store: { data: { updates: {} }, save: async () => {} },
gitea: {
async getLatestRelease(owner, repo) {
assert.equal(repo, "ForgeFlow-Public");
return { tag_name: "v0.10.16", target_commitish: releaseCommit, draft: false, prerelease: false };
},
async getBranch() { throw new Error("Packaged updates must not follow mutable main."); },
async getRepositoryFile(input) {
assert.equal(input.ref, releaseCommit);
return { decoded: JSON.stringify({ name: "forgeflow", version: "0.10.16" }) };
},
},
appInfo: { version: "0.10.14", packaged: true },
sourcePath: temp,
userDataPath: temp,
});
const result = await service.check();
assert.equal(result.available, true);
assert.equal(result.remoteSha, releaseCommit);
assert.equal(result.releaseTag, "v0.10.16");
});
test("source updater refuses an unsigned archive before launching a helper", async () => {
const temp = await mkdtemp( const temp = await mkdtemp(
path.join(os.tmpdir(), "forgeflow-update-handshake-"), path.join(os.tmpdir(), "forgeflow-update-handshake-"),
); );
@@ -181,10 +208,11 @@ test("source updater confirms an external STARTED marker before ForgeFlow may cl
remoteSha: "a".repeat(40), remoteSha: "a".repeat(40),
sha256: "b".repeat(64), sha256: "b".repeat(64),
}; };
const result = await service.apply(); await assert.rejects(
assert.equal(result.confirmed, true); service.apply(),
assert.ok(capturedArgs.includes("-StatusPath")); (error) => error.code === "SIGNED_SOURCE_UPDATE_REQUIRED",
assert.ok(capturedArgs.includes("-UpdateId")); );
assert.equal(capturedArgs, null);
await rm(temp, { recursive: true, force: true }); await rm(temp, { recursive: true, force: true });
}); });
@@ -268,6 +296,15 @@ test("PowerShell update helper starts with param and has no BOM or stray leading
assert.match(text, /npm ci --no-audit --no-fund/); assert.match(text, /npm ci --no-audit --no-fund/);
assert.match(text, /package-lock\.json/); assert.match(text, /package-lock\.json/);
assert.doesNotMatch(text, /Get-Command npm\.cmd/); assert.doesNotMatch(text, /Get-Command npm\.cmd/);
assert.match(text, /Integrated source update refuses to overwrite a Git working tree/);
assert.ok(
text.indexOf("Handshake-only verification completed successfully") <
text.indexOf("Integrated source update refuses to overwrite a Git working tree"),
);
assert.ok(
text.indexOf("$actualHash = Get-Sha256") <
text.indexOf("Source update preflight passed"),
);
assert.ok( assert.ok(
text.indexOf('Write-UpdateState -State "success"') < text.indexOf('Write-UpdateState -State "success"') <
text.indexOf("Start-ForgeFlow -WorkingDirectory $SourcePath"), text.indexOf("Start-ForgeFlow -WorkingDirectory $SourcePath"),
@@ -701,11 +738,12 @@ test("release manifest verification rejects a different publisher key", () => {
}); });
test("Windows release pipeline emits signed provenance, manifest and SBOM evidence", async () => { test("Windows release pipeline emits signed provenance, manifest and SBOM evidence", async () => {
const [pkgSource, signatureSource, checksumSource, manifestSigner] = await Promise.all([ const [pkgSource, signatureSource, checksumSource, manifestSigner, releaseWorkflow] = await Promise.all([
readFile(new URL("../package.json", import.meta.url), "utf8"), readFile(new URL("../package.json", import.meta.url), "utf8"),
readFile(new URL("../scripts/verify-release-signatures.mjs", import.meta.url), "utf8"), readFile(new URL("../scripts/verify-release-signatures.mjs", import.meta.url), "utf8"),
readFile(new URL("../scripts/write-release-checksums.mjs", import.meta.url), "utf8"), readFile(new URL("../scripts/write-release-checksums.mjs", import.meta.url), "utf8"),
readFile(new URL("../scripts/sign-release-manifest.mjs", import.meta.url), "utf8"), readFile(new URL("../scripts/sign-release-manifest.mjs", import.meta.url), "utf8"),
readFile(new URL("../.gitea/workflows/release.yml", import.meta.url), "utf8"),
]); ]);
assert.match(pkgSource, /verify-release-signatures\.mjs/); assert.match(pkgSource, /verify-release-signatures\.mjs/);
assert.match(signatureSource, /FORGEFLOW_SIGNED_RELEASE/); assert.match(signatureSource, /FORGEFLOW_SIGNED_RELEASE/);
@@ -720,6 +758,16 @@ test("Windows release pipeline emits signed provenance, manifest and SBOM eviden
assert.match(manifestSigner, /Ed25519/); assert.match(manifestSigner, /Ed25519/);
assert.match(manifestSigner, /release-manifest\.json/); assert.match(manifestSigner, /release-manifest\.json/);
assert.match(pkgSource, /sign-release-manifest\.mjs/); assert.match(pkgSource, /sign-release-manifest\.mjs/);
assert.doesNotMatch(releaseWorkflow, /checkout@v\d|setup-node@v\d/);
assert.match(releaseWorkflow, /checkout@[a-f0-9]{40}/);
assert.match(releaseWorkflow, /setup-node@[a-f0-9]{40}/);
assert.ok(
releaseWorkflow.indexOf("Validate version bump and build release artifacts") <
releaseWorkflow.indexOf("FORGEFLOW_RELEASE_SIGNING_KEY_PEM"),
"signing secrets must not be present during dependency installation and quality checks",
);
assert.match(releaseWorkflow, /finally \{/);
assert.match(releaseWorkflow, /Remove-Item -LiteralPath \$privateKeyPath -Force/);
const publisher = await readFile(new URL("../scripts/publish-binary-release.cjs", import.meta.url), "utf8"); const publisher = await readFile(new URL("../scripts/publish-binary-release.cjs", import.meta.url), "utf8");
assert.match(publisher, /draft: true/); assert.match(publisher, /draft: true/);
assert.match(publisher, /requiredAssets/); assert.match(publisher, /requiredAssets/);
@@ -764,4 +812,15 @@ test("binary update helper verifies, waits, applies and records restart state",
); );
} }
assert.doesNotMatch(helper, /Get-FileHash/); assert.doesNotMatch(helper, /Get-FileHash/);
assert.match(helper, /function Start-ForgeFlowAndVerify/);
assert.match(helper, /Start-Sleep -Milliseconds 1500/);
assert.match(helper, /Updated portable executable failed its restart probe/);
assert.ok(
helper.indexOf("$actualSha256 = Get-Sha256") <
helper.indexOf("Binary preflight passed"),
);
assert.ok(
helper.indexOf("Binary preflight passed") <
helper.indexOf('Write-UpdateState -State "waiting-for-exit"'),
);
}); });