Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
941f2d9aa0 | ||
|
|
57914f1c79 | ||
|
|
3dd301a3cb | ||
|
|
e2293f08d9 | ||
|
|
dec3b79793 | ||
|
|
0a8a10df1b | ||
|
|
b5d615d53d | ||
|
|
8cca1bfc01 | ||
|
|
57929ea973 | ||
|
|
5ddb6fe8f5 | ||
|
|
20290e65c8 | ||
|
|
e6e4f2ecf5 | ||
|
|
6fd69a2cd8 | ||
|
|
976a1fc0df | ||
|
|
0bbfbbad51 | ||
|
|
b486285027 | ||
|
|
060171d714 | ||
|
|
b454bafec3 | ||
|
|
e62a1d8c1b | ||
|
|
0191af2ed8 | ||
|
|
b883c1ad83 | ||
|
|
a93231d69f | ||
|
|
d926007dae | ||
|
|
4b4718d231 | ||
|
|
0b8deed1e3 | ||
|
|
408dea0c2d | ||
|
|
79dc6d367b | ||
|
|
49f43b3875 | ||
|
|
736944bd91 | ||
|
|
42ccfc781c | ||
|
|
2abfca7abc | ||
|
|
e882656e85 | ||
|
|
d47c7b5e41 | ||
|
|
ff1fcd3303 | ||
|
|
e377889263 | ||
|
|
c5cf384f9a | ||
|
|
84ed89bccf | ||
|
|
2174b79544 | ||
|
|
1dc3bea8dd | ||
|
|
858b09afeb | ||
|
|
b5b6660fdc | ||
|
|
d1f4cb6ba8 | ||
|
|
181330b78f | ||
|
|
cb9bdcd713 | ||
|
|
beeafdcba7 | ||
|
|
d77643c058 | ||
|
|
5cecaa080d | ||
|
|
a5666e95f2 | ||
|
|
9260d35957 | ||
|
|
cf1da8a2fa | ||
|
|
32ed4fcb5e | ||
|
|
38e221cbd1 | ||
|
|
bffad670ef | ||
|
|
4c21616e72 | ||
|
|
f866b12fbf | ||
|
|
f7d6bc374f | ||
|
|
958d5b84d3 | ||
|
|
8fa4891075 | ||
|
|
58d361bbab | ||
|
|
acad1f8932 | ||
|
|
44aa452a76 | ||
|
|
a0435f4316 | ||
|
|
13f4fe7cd0 | ||
|
|
258f0b1324 | ||
|
|
f8c505e525 | ||
|
|
398f986d95 | ||
|
|
60e8aa8fc2 | ||
|
|
6ef4620388 | ||
|
|
18f42621c2 | ||
|
|
aa4895912a | ||
|
|
347f7132b9 | ||
|
|
e5599f7a5d | ||
|
|
c826561c77 | ||
|
|
0ed202ec95 | ||
|
|
dcbd461a92 | ||
|
|
1e86afb7d9 | ||
|
|
a0733875c4 | ||
|
|
7b05c953b6 | ||
|
|
5d3731a853 | ||
|
|
6b93391a9b | ||
|
|
64ca267384 | ||
|
|
4555023f87 | ||
|
|
4708367b66 | ||
|
|
8e580a0b13 | ||
|
|
a7ab2d6cc8 | ||
|
|
ae6c41ff90 | ||
|
|
430d40354b | ||
|
|
56efd1a00c | ||
|
|
d4d77c827a | ||
|
|
f990c26014 | ||
|
|
03c2608a35 | ||
|
|
82c335ed2b | ||
|
|
24dde9a031 | ||
|
|
e888bd2c1c |
@@ -0,0 +1,3 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
|
examples/server/forgeflow-deploy text eol=lf
|
||||||
|
scripts/* text eol=lf
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
name: ForgeFlow quality gate
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
secret-scan:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: https://gitea.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- name: Secret scan
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
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:
|
||||||
|
# Browser quality runs against the dedicated bounded Windows 11 VM runner.
|
||||||
|
runs-on: windows-native
|
||||||
|
steps:
|
||||||
|
- uses: https://gitea.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- uses: https://gitea.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
- 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: npx playwright install chromium
|
||||||
|
- run: npm run test:browser:ci
|
||||||
|
- name: Preserve browser failure evidence
|
||||||
|
if: failure()
|
||||||
|
uses: https://gitea.com/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
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
name: ForgeFlow signed release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- package.json
|
||||||
|
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."
|
||||||
+18
@@ -1,6 +1,24 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
*.log
|
*.log
|
||||||
coverage/
|
coverage/
|
||||||
|
artifacts/
|
||||||
|
playwright-report/
|
||||||
.forgeflow/
|
.forgeflow/
|
||||||
|
.playwright-mcp/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.pfx
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
!build/update-signing-public.pem
|
||||||
|
.codex/
|
||||||
|
.claude/
|
||||||
|
.agents/
|
||||||
|
.dyad/
|
||||||
|
.idea/
|
||||||
|
.vs/
|
||||||
|
|||||||
@@ -1,5 +1,68 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.10.0 - 2026-07-28
|
||||||
|
|
||||||
|
- Added safe Gitea Server pull with repository-scoped read-only deploy keys and pinned SSH host fingerprints.
|
||||||
|
- Automatically discovers and links unique running repository workloads while filtering system containers and stale release folders.
|
||||||
|
- Reconciles live server SHA, Gitea SHA and runtime health, including deployments changed outside ForgeFlow.
|
||||||
|
- Preserves adopted Compose and DockerMan identity and avoids duplicate repository links.
|
||||||
|
- Expanded Git Validator with editor, line-ending, dependency-lock and Gitea Actions checks plus reviewable fixes.
|
||||||
|
- Refined the dark deployment workspace with clearer workload identity, focused inventory and motion-safe illustration.
|
||||||
|
- Added live connection/deployment audit scripts and kept automatic cleanup of older packaged artifacts.
|
||||||
|
|
||||||
|
## 0.9.5 - 2026-07-28
|
||||||
|
|
||||||
|
- Added a mandatory remote write-access preflight before every Direct copy deployment and again immediately before upload.
|
||||||
|
- The preflight reports the exact failing path, SSH user, owner, group and mode instead of allowing a partial copy or Docker activation.
|
||||||
|
- Added an in-app **Check / fix write access** action on every SSH / Unraid deployment and directly beside failed permission checks.
|
||||||
|
- The repair is restricted to the linked project source and `.forgeflow` state, assigns the Unraid `users` group where available, preserves executable bits and excludes configured runtime data.
|
||||||
|
- Explicit permission repair now also normalizes SMB/manual-copy access even when the SSH account is root and could already write.
|
||||||
|
- Direct copy is fail-closed: candidate Compose configuration and images are validated before live files change; no implicit `down`, `--remove-orphans` or `--force-recreate` is used.
|
||||||
|
- A failed activation restores the prior source and image tags, attempts to restore the previous runtime and retains rollback evidence for diagnosis.
|
||||||
|
- Removed orphan deletion from the legacy server deployment helper as well.
|
||||||
|
|
||||||
|
## 0.9.4 - 2026-07-28
|
||||||
|
|
||||||
|
- Fixed imported deployments failing with `service has neither an image nor a build context` because a stale labels-only metadata overlay introduced a phantom service.
|
||||||
|
- Existing workloads now activate strictly from their real Compose files; ForgeFlow metadata is stored outside the active Compose model.
|
||||||
|
- Redeploy always uses `--force-recreate` and verifies every service returned by `docker compose config --services`.
|
||||||
|
- A deployment is rejected when the container ID did not change or when the previous hinted container remains running beside a duplicate workload.
|
||||||
|
- Deployment SHA is promoted only after recreation and runtime checks complete.
|
||||||
|
|
||||||
|
## 0.9.3 - 2026-07-28
|
||||||
|
|
||||||
|
- removed every server-to-repository authentication check and all server-side Git deployment branches from SSH/Unraid deploy and rollback;
|
||||||
|
- forcibly migrated legacy SSH/Unraid profiles to direct local bundle copy, except explicit monitor-only profiles;
|
||||||
|
- discovered Compose YAML definitions directly from configured Unraid appdata roots even when Docker inspection fails;
|
||||||
|
- merged YAML definitions with runtime containers, stopped containers and DockerMan templates;
|
||||||
|
- automatically linked unique high-confidence Compose folder/project matches and added one-click linking for remaining strong matches with all fields prefilled;
|
||||||
|
- made inventory failure handling compatible with strict Bash execution, pruned large runtime folders during YAML discovery and normalized user-share/cache/disk appdata paths.
|
||||||
|
|
||||||
|
## 0.9.0 - 2026-07-27
|
||||||
|
|
||||||
|
- replaced Git-checkout-only discovery with a complete Unraid workload inventory, including stopped, Compose, DockerMan and standalone containers;
|
||||||
|
- added persistent manual workload linking with repository, deployment mode, Compose project, files and service identity;
|
||||||
|
- made checksum-verified exact-commit push bundles the default for new SSH/Unraid profiles, so Unraid no longer needs a Gitea key;
|
||||||
|
- separated desktop-to-Unraid authentication, Docker/Compose capabilities and optional Unraid-to-Gitea access in diagnostics;
|
||||||
|
- preserved adopted DockerMan templates and disabled aggressive recreate/orphan flags by default;
|
||||||
|
- promoted deployment state only after Compose validation and service verification, with atomic manifests, rollback restoration and live lock ownership;
|
||||||
|
- retained server-side Git and monitor-only modes for explicit use cases;
|
||||||
|
- corrected release publication so source, installer, portable executable and SHA-256 sidecars are published together, with a recovery publisher for source-only Gitea releases.
|
||||||
|
|
||||||
|
## 0.8.9 - 2026-07-26
|
||||||
|
|
||||||
|
- added a per-repository Git Validator with a weighted assurance score and evidence-backed checks;
|
||||||
|
- validates Gitea branch governance, repository identity, upstream tracking, effective author identity, safe synchronization defaults, README and gitignore hygiene, tracked secret-shaped files and oversized files;
|
||||||
|
- provides audited one-click repairs for origin alignment and repository-local safety configuration;
|
||||||
|
- offers confirmed repairs for default-branch protection and a reviewable uncommitted `.gitignore`;
|
||||||
|
- introduced a premium, theme-aware and container-responsive Validator workspace with safe-fix batching.
|
||||||
|
|
||||||
|
## 0.8.8 - 2026-07-26
|
||||||
|
|
||||||
|
- fixed binary update downloads on Gitea servers that require release-scoped attachment routes;
|
||||||
|
- sends both the immutable release ID and attachment ID when downloading update assets;
|
||||||
|
- preserves strict same-origin token handling and SHA-256 verification.
|
||||||
|
|
||||||
## 0.8.7 - 2026-07-26
|
## 0.8.7 - 2026-07-26
|
||||||
|
|
||||||
- automatically inventories running workloads across configured Unraid servers;
|
- automatically inventories running workloads across configured Unraid servers;
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
cd /d "%~dp0"
|
||||||
|
echo ForgeFlow source and binary release publisher
|
||||||
|
echo.
|
||||||
|
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0Publish-ForgeFlow-Release.ps1"
|
||||||
|
set "forgeflowExitCode=%ERRORLEVEL%"
|
||||||
|
echo.
|
||||||
|
if not "%forgeflowExitCode%"=="0" (
|
||||||
|
echo Publication failed. The existing ForgeFlow installation was not modified.
|
||||||
|
) else (
|
||||||
|
echo Publication completed. The older ForgeFlow updater can now install this release.
|
||||||
|
)
|
||||||
|
pause
|
||||||
|
exit /b %forgeflowExitCode%
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
param(
|
param(
|
||||||
[string]$Remote = "git@gitea.itworx.tech:Jens/ForgeFlow.git",
|
[string]$Remote = "git@gitea.itworx.tech:Jens/ForgeFlow.git",
|
||||||
[string]$Branch = "main",
|
[string]$Branch = "main",
|
||||||
[string]$InstalledSource = "C:\Projects\ForgeFlow"
|
[string]$InstalledSource = "C:\Projects\ForgeFlow",
|
||||||
|
[string]$UserDataPath = "",
|
||||||
|
[switch]$SkipBinaryRelease
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
@@ -58,6 +60,51 @@ try {
|
|||||||
|
|
||||||
Write-Host "ForgeFlow $version is available on Gitea at commit $($publishedCommit.Substring(0,7))." -ForegroundColor Green
|
Write-Host "ForgeFlow $version is available on Gitea at commit $($publishedCommit.Substring(0,7))." -ForegroundColor Green
|
||||||
|
|
||||||
|
if (-not $SkipBinaryRelease) {
|
||||||
|
Write-Host "Building and publishing the matching Windows installer and portable release..." -ForegroundColor Cyan
|
||||||
|
Push-Location $clone
|
||||||
|
try {
|
||||||
|
& cmd.exe /d /s /c "npm ci --no-audit --no-fund"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "npm ci failed in the exact published checkout." }
|
||||||
|
& cmd.exe /d /s /c "npm run check"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "The exact published checkout failed the release quality gate." }
|
||||||
|
& cmd.exe /d /s /c "npm run dist:win"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "The Windows release build failed." }
|
||||||
|
|
||||||
|
$expectedAssets = @(
|
||||||
|
"ForgeFlow-Setup-$version-win-x64.exe",
|
||||||
|
"ForgeFlow-Setup-$version-win-x64.exe.sha256",
|
||||||
|
"ForgeFlow-Portable-$version-win-x64.exe",
|
||||||
|
"ForgeFlow-Portable-$version-win-x64.exe.sha256",
|
||||||
|
"ForgeFlow-$version-provenance.json",
|
||||||
|
"ForgeFlow-$version-sbom.cdx.json",
|
||||||
|
"ForgeFlow-$version-release-manifest.json",
|
||||||
|
"ForgeFlow-$version-release-manifest.json.sig"
|
||||||
|
)
|
||||||
|
foreach ($assetName in $expectedAssets) {
|
||||||
|
if (-not (Test-Path -LiteralPath (Join-Path $clone "dist\$assetName"))) {
|
||||||
|
throw "The Windows build did not produce $assetName."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolvedUserData = if ($UserDataPath) { $UserDataPath } else { Join-Path $env:APPDATA "forgeflow" }
|
||||||
|
$previousUserData = $env:FORGEFLOW_USER_DATA
|
||||||
|
$previousBranch = $env:FORGEFLOW_RELEASE_BRANCH
|
||||||
|
try {
|
||||||
|
$env:FORGEFLOW_USER_DATA = $resolvedUserData
|
||||||
|
$env:FORGEFLOW_RELEASE_BRANCH = $Branch
|
||||||
|
& cmd.exe /d /s /c "npm run release:binary"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "The Gitea binary release publisher failed." }
|
||||||
|
} finally {
|
||||||
|
$env:FORGEFLOW_USER_DATA = $previousUserData
|
||||||
|
$env:FORGEFLOW_RELEASE_BRANCH = $previousBranch
|
||||||
|
}
|
||||||
|
} finally { Pop-Location }
|
||||||
|
Write-Host "ForgeFlow $version source and Windows release assets are both published." -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "Binary publication was skipped explicitly. Packaged ForgeFlow installations cannot auto-update until the release assets are published." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
$installedManifestPath = Join-Path $InstalledSource "package.json"
|
$installedManifestPath = Join-Path $InstalledSource "package.json"
|
||||||
if (Test-Path -LiteralPath $installedManifestPath) {
|
if (Test-Path -LiteralPath $installedManifestPath) {
|
||||||
try {
|
try {
|
||||||
@@ -123,7 +170,11 @@ try {
|
|||||||
Write-Host "Installed source was not found at $InstalledSource; publication itself succeeded." -ForegroundColor Yellow
|
Write-Host "Installed source was not found at $InstalledSource; publication itself succeeded." -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($SkipBinaryRelease) {
|
||||||
|
Write-Host "Source publication completed. Run Publish-Missing-Binary-Release.ps1 before using the updater from an installed EXE." -ForegroundColor Yellow
|
||||||
|
} else {
|
||||||
Write-Host "Open the installed older ForgeFlow and use Settings -> ForgeFlow updates -> Check now." -ForegroundColor Cyan
|
Write-Host "Open the installed older ForgeFlow and use Settings -> ForgeFlow updates -> Check now." -ForegroundColor Cyan
|
||||||
|
}
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
param(
|
||||||
|
[string]$Remote = "git@gitea.itworx.tech:Jens/ForgeFlow.git",
|
||||||
|
[string]$Branch = "main",
|
||||||
|
[string]$ExpectedVersion = "0.9.1",
|
||||||
|
[string]$UserDataPath = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$temp = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-binary-release-" + [guid]::NewGuid().ToString("N"))
|
||||||
|
$clone = Join-Path $temp "ForgeFlow"
|
||||||
|
|
||||||
|
function Invoke-CheckedCommand {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$Title,
|
||||||
|
[Parameter(Mandatory = $true)][scriptblock]$Action
|
||||||
|
)
|
||||||
|
Write-Host "`n$Title" -ForegroundColor Cyan
|
||||||
|
& $Action
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "$Title failed with exit code $LASTEXITCODE."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
foreach ($command in @("git", "node", "npm")) {
|
||||||
|
if (-not (Get-Command $command -ErrorAction SilentlyContinue)) {
|
||||||
|
throw "Required command '$command' was not found on PATH."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $temp | Out-Null
|
||||||
|
Invoke-CheckedCommand "Cloning the exact published ForgeFlow source..." {
|
||||||
|
& git clone --branch $Branch --single-branch $Remote $clone
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifestPath = Join-Path $clone "package.json"
|
||||||
|
if (-not (Test-Path -LiteralPath $manifestPath)) {
|
||||||
|
throw "The cloned repository does not contain package.json."
|
||||||
|
}
|
||||||
|
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
|
||||||
|
if ($manifest.name -ne "forgeflow") {
|
||||||
|
throw "The cloned repository is not ForgeFlow."
|
||||||
|
}
|
||||||
|
$version = [string]$manifest.version
|
||||||
|
if ($ExpectedVersion -and $version -ne $ExpectedVersion) {
|
||||||
|
throw "Gitea branch '$Branch' contains ForgeFlow $version, not the expected $ExpectedVersion."
|
||||||
|
}
|
||||||
|
|
||||||
|
$localCommit = (& git -C $clone rev-parse HEAD).Trim()
|
||||||
|
$remoteLines = @(& git -C $clone ls-remote origin "refs/heads/$Branch")
|
||||||
|
if ($LASTEXITCODE -ne 0 -or $remoteLines.Count -lt 1) {
|
||||||
|
throw "Could not verify origin/$Branch."
|
||||||
|
}
|
||||||
|
$remoteCommit = ($remoteLines[0] -split "`t")[0].Trim()
|
||||||
|
if ($localCommit -ne $remoteCommit) {
|
||||||
|
throw "The temporary checkout is not the current origin/$Branch commit."
|
||||||
|
}
|
||||||
|
|
||||||
|
Push-Location $clone
|
||||||
|
try {
|
||||||
|
Invoke-CheckedCommand "Installing exact dependencies..." {
|
||||||
|
& cmd.exe /d /s /c "npm ci --no-audit --no-fund"
|
||||||
|
}
|
||||||
|
Invoke-CheckedCommand "Running the complete ForgeFlow quality gate..." {
|
||||||
|
& cmd.exe /d /s /c "npm run check"
|
||||||
|
}
|
||||||
|
Invoke-CheckedCommand "Building installer and portable Windows assets..." {
|
||||||
|
& cmd.exe /d /s /c "npm run dist:win"
|
||||||
|
}
|
||||||
|
|
||||||
|
$expectedAssets = @(
|
||||||
|
"ForgeFlow-Setup-$version-win-x64.exe",
|
||||||
|
"ForgeFlow-Setup-$version-win-x64.exe.sha256",
|
||||||
|
"ForgeFlow-Portable-$version-win-x64.exe",
|
||||||
|
"ForgeFlow-Portable-$version-win-x64.exe.sha256",
|
||||||
|
"ForgeFlow-$version-provenance.json",
|
||||||
|
"ForgeFlow-$version-sbom.cdx.json",
|
||||||
|
"ForgeFlow-$version-release-manifest.json",
|
||||||
|
"ForgeFlow-$version-release-manifest.json.sig"
|
||||||
|
)
|
||||||
|
foreach ($assetName in $expectedAssets) {
|
||||||
|
$assetPath = Join-Path $clone "dist\$assetName"
|
||||||
|
if (-not (Test-Path -LiteralPath $assetPath)) {
|
||||||
|
throw "The build did not produce $assetName."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolvedUserData = if ($UserDataPath) { $UserDataPath } else { Join-Path $env:APPDATA "forgeflow" }
|
||||||
|
$configPath = Join-Path $resolvedUserData "forgeflow-config.json"
|
||||||
|
if (-not (Test-Path -LiteralPath $configPath)) {
|
||||||
|
throw "ForgeFlow configuration was not found at $configPath. Open ForgeFlow and sign in to Gitea once, then run this script again."
|
||||||
|
}
|
||||||
|
|
||||||
|
$previousUserData = $env:FORGEFLOW_USER_DATA
|
||||||
|
$previousBranch = $env:FORGEFLOW_RELEASE_BRANCH
|
||||||
|
try {
|
||||||
|
$env:FORGEFLOW_USER_DATA = $resolvedUserData
|
||||||
|
$env:FORGEFLOW_RELEASE_BRANCH = $Branch
|
||||||
|
Invoke-CheckedCommand "Creating the Gitea release and uploading all four assets..." {
|
||||||
|
& cmd.exe /d /s /c "npm run release:binary"
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$env:FORGEFLOW_USER_DATA = $previousUserData
|
||||||
|
$env:FORGEFLOW_RELEASE_BRANCH = $previousBranch
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "`nForgeFlow $version now has a published binary release for commit $($localCommit.Substring(0,7))." -ForegroundColor Green
|
||||||
|
Write-Host "Return to ForgeFlow $ExpectedVersion's predecessor and choose Check now -> Download update -> Apply & restart." -ForegroundColor Green
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
@@ -1,363 +1,187 @@
|
|||||||
# ForgeFlow
|
# ForgeFlow
|
||||||
|
|
||||||
ForgeFlow 0.8 adds partial-hunk commits, guided conflict recovery, Gitea pull
|
**Van lokale wijziging naar aantoonbaar juiste serverversie — zonder de Git- en deploymentcontext over verschillende tools te verspreiden.**
|
||||||
requests and protected-branch awareness, configurable editor/terminal actions,
|
|
||||||
deployment policies and release notes, append-only audit export, encrypted
|
|
||||||
configuration backup, native tray/notifications and a guarded end-to-end
|
|
||||||
acceptance harness.
|
|
||||||
|
|
||||||
## Publish this release to the built-in updater repository
|
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.
|
||||||
|
|
||||||
Extract the full source ZIP to a folder under Downloads and run:
|
> Huidige release: **0.10.15** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Wat ForgeFlow voor je oplost
|
||||||
|
|
||||||
|
- **Eén duidelijke actielijst:** zie meteen welke repository aandacht nodig heeft en waarom.
|
||||||
|
- **Veilige Git-flow:** review wijzigingen, stage volledige bestanden of afzonderlijke hunks, commit, push en herstel conflicten zonder contextwissel.
|
||||||
|
- **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
|
||||||
|
|
||||||
|
### Aanbevolen: de Windows-app installeren
|
||||||
|
|
||||||
|
1. Open de [laatste ForgeFlow-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest).
|
||||||
|
2. Download de Windows-installer of portable executable.
|
||||||
|
3. Start ForgeFlow en doorloop de setupwizard.
|
||||||
|
4. Voeg je Gitea-server, token en lokale projectmappen toe.
|
||||||
|
5. 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.
|
||||||
|
|
||||||
|
### Eerst vrijblijvend bekijken
|
||||||
|
|
||||||
|
De interactieve demomodus gebruikt uitsluitend representatieve voorbeelddata en maakt geen verbinding met Git, Gitea of een server:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Set-ExecutionPolicy -Scope Process Bypass
|
npm install
|
||||||
.\Publish-ForgeFlow-Release.ps1
|
npm run demo
|
||||||
```
|
```
|
||||||
|
|
||||||
The script runs the complete quality gate, clones `git@gitea.itworx.tech:Jens/ForgeFlow.git` into a temporary folder, mirrors the verified source without `.git`, `node_modules` or release ZIPs, commits it on `main` and pushes it. A running older ForgeFlow source installation can then update through **Settings → Updates**.
|
Open daarna `http://127.0.0.1:41737`.
|
||||||
|
|
||||||
|
## De dagelijkse workflow
|
||||||
|
|
||||||
|
### 1. Begin bij wat aandacht vraagt
|
||||||
|
|
||||||
ForgeFlow is a desktop release cockpit that turns the complete path from a local
|
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.
|
||||||
code change to a verified server deployment into one guided flow:
|
|
||||||
|
|
||||||
```text
|
### 2. Review en publiceer code
|
||||||
Local working tree -> Gitea repository -> exact deployed server version
|
|
||||||
```
|
|
||||||
|
|
||||||
It is not an IDE and it does not replace Gitea Actions. ForgeFlow coordinates
|

|
||||||
local Git, a private Gitea instance and fixed deployment workflows while keeping
|
|
||||||
credentials on the user's own computer.
|
|
||||||
|
|
||||||

|
In de repositorywerkruimte zie je de volledige keten **Local → Gitea → Server**. Je kunt wijzigingen selecteren, diffs bekijken, gedeeltelijke hunk-staging behouden, branch protection controleren en een pull request openen. Destructieve of publicerende acties vereisen altijd expliciete bevestiging.
|
||||||
|
|
||||||
## Current status: v0.6.0 self-healing Git and Unraid operations
|
### 3. Deploy en verifieer de live versie
|
||||||
|
|
||||||
### Git recovery and repository truth
|

|
||||||
|
|
||||||
- serializes every mutating Git action per repository;
|
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.
|
||||||
- waits through a short grace period and automatically retries after safely removing a proven stale lock;
|
|
||||||
- detects `HEAD.lock`, `index.lock`, ref locks and worktree locks while skipping object storage;
|
|
||||||
- provides repository-specific actions for fetch, fast-forward, push and backed-up divergence reset;
|
|
||||||
- creates a `forgeflow/backup-*` safety branch before any reset to upstream.
|
|
||||||
|
|
||||||
### SSH / Unraid and DockerMan
|
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.
|
||||||
|
|
||||||
- reconciles interrupted deployments against the live SHA, container state and health;
|
De belangrijkste statussen zijn:
|
||||||
- applies DockerMan WebUI, icon and shell labels through an override and writes a persistent XML template fallback;
|
|
||||||
- keeps the visible container name such as `Portfolio` while enforcing lowercase internal service/image identities;
|
|
||||||
- includes a built-in high-contrast ITWorx icon, local PNG upload, persistent DockerMan image storage and cache refresh;
|
|
||||||
- exposes **Open Web UI**, **Repair DockerMan integration** and **Reconcile** directly on deployment cards.
|
|
||||||
|
|
||||||
### Update reliability
|
| 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. |
|
||||||
|
|
||||||
- confirms the external updater handshake before closing ForgeFlow;
|
### 4. Verbeter de repository met Git Validator
|
||||||
- rejects malformed PowerShell update helpers before publication;
|
|
||||||
- validates the replacement source and keeps rollback/success status for the next launch;
|
|
||||||
- verifies that Gitea reports the exact published release commit.
|
|
||||||
|
|
||||||
Read [SSH / Unraid deployment](docs/SSH_UNRAID_DEPLOYMENT.md) and the
|

|
||||||
[LumaOps audit](docs/LUMAOPS_SERVER_AUDIT.md).
|
|
||||||
|
|
||||||
### v0.4.2 automatic clone workflow
|
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.
|
||||||
|
|
||||||
**Clone from Gitea** now uses the first configured project root and creates the
|
## Wanneer is een release werkelijk in orde?
|
||||||
repository-named subfolder automatically. The native folder picker is reserved
|
|
||||||
for the explicit **Choose another location** action. Existing matching checkouts
|
|
||||||
are linked; conflicting folders are never overwritten.
|
|
||||||
|
|
||||||
The Windows environment doctor retains the v0.3.1 npm command-shim correction.
|
ForgeFlow houdt drie soorten waarheid bewust apart:
|
||||||
|
|
||||||
Version 0.3.2 is designed so the user does not need to give a developer any
|
| Controle | Betekenis |
|
||||||
Gitea token, SSH key, server password or other credential. The application now
|
| --- | --- |
|
||||||
includes:
|
| **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. |
|
||||||
|
|
||||||
- a five-step setup wizard;
|
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.
|
||||||
- computer, Gitea and deployment preflights;
|
|
||||||
- protected local token entry;
|
|
||||||
- structured local diagnostics with aggressive secret redaction;
|
|
||||||
- standard and strict privacy support bundles;
|
|
||||||
- fail-closed bundle safety auditing;
|
|
||||||
- request IDs that correlate desktop, Actions and server events;
|
|
||||||
- root-owned allowlisted server target configuration;
|
|
||||||
- a complete setup guide from fresh checkout to first rollback test.
|
|
||||||
|
|
||||||
Start with [START_HERE.md](START_HERE.md). The full installation and acceptance
|
## Belangrijkste functies
|
||||||
procedure is in [docs/SETUP_GUIDE.md](docs/SETUP_GUIDE.md).
|
|
||||||
|
|
||||||
## What ForgeFlow can do
|
### Git en Gitea
|
||||||
|
|
||||||
### Repository workflow
|
- 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.
|
||||||
|
|
||||||
- Connect to a private Gitea instance and validate the account locally.
|
### Deployments
|
||||||
- Discover Git working trees beneath one or more project roots.
|
|
||||||
- Use the first project root as the automatic clone destination.
|
|
||||||
- Match local folders to Gitea repositories using normalized `origin` identity.
|
|
||||||
- Link existing repositories or clone directly into the default project root.
|
|
||||||
- Read real Git status through porcelain v2 and NUL-delimited output.
|
|
||||||
- Inspect diffs and select files.
|
|
||||||
- Stage and unstage changes.
|
|
||||||
- Commit locally or commit and push in one action.
|
|
||||||
- Push, fetch and use fast-forward-only pull.
|
|
||||||
- Review recent history.
|
|
||||||
- Create, switch and publish branches.
|
|
||||||
- Stash, list and restore local work.
|
|
||||||
- Mark favorites and prioritize repositories requiring attention.
|
|
||||||
- Refresh repository state automatically while the application is open.
|
|
||||||
|
|
||||||
### Deployment workflow
|
- deploymentprofielen per repository en omgeving;
|
||||||
|
- Gitea Actions en SSH/Unraid als gecontroleerde uitvoeringsroutes;
|
||||||
|
- serverinventaris van draaiende en gestopte Docker-, Compose- en DockerMan-workloads;
|
||||||
|
- automatische koppeling op exact bewijs, 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.
|
||||||
|
|
||||||
- Configure multiple environments per repository.
|
### Veiligheid en beheer
|
||||||
- Bind each environment to a fixed branch and fixed Gitea Actions workflows.
|
|
||||||
- Run a visible deployment preflight before confirmation.
|
|
||||||
- Re-run mandatory checks in the privileged backend before dispatch.
|
|
||||||
- Verify a clean tree, correct branch, upstream and ahead/behind state.
|
|
||||||
- Verify that the exact full SHA exists on the allowed remote branch.
|
|
||||||
- Verify local and remote workflow files and Gitea Actions availability.
|
|
||||||
- Dispatch a unique request ID with the exact commit SHA.
|
|
||||||
- Poll Gitea Actions runs and jobs.
|
|
||||||
- Correlate the workflow with the server status endpoint.
|
|
||||||
- Verify that the requested SHA is actually live.
|
|
||||||
- Run an independent application healthcheck.
|
|
||||||
- Roll back through a separate fixed workflow to the previous exact SHA.
|
|
||||||
- Preserve a bounded local operation history.
|
|
||||||
|
|
||||||
### Safe diagnostics
|
- 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.
|
||||||
|
|
||||||
ForgeFlow stores structured JSONL diagnostics in the application-data folder.
|
Meer achtergrond staat in [SECURITY.md](docs/SECURITY.md) en [ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||||
They are intended to make development and troubleshooting possible without
|
|
||||||
requesting credentials.
|
|
||||||
|
|
||||||
The logger records useful operational facts such as:
|
## Eerste configuratie
|
||||||
|
|
||||||
- timestamps, stable event names and duration;
|
Voor normaal gebruik heb je nodig:
|
||||||
- Git action outcomes and repository state;
|
|
||||||
- Gitea endpoint paths and HTTP status, but not authorization headers;
|
|
||||||
- deployment request IDs, workflow stages and health states;
|
|
||||||
- preflight results;
|
|
||||||
- sanitized error names, codes, messages and stacks.
|
|
||||||
|
|
||||||
It excludes or redacts:
|
- 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.
|
||||||
|
|
||||||
- Gitea tokens and encrypted token blobs;
|
Voor serverdetectie en SSH-deployments heb je daarnaast een bereikbare Docker- of Unraid-host en een werkende SSH-configuratie nodig. Begin bij:
|
||||||
- passwords, authorization headers and credential-bearing URLs;
|
|
||||||
- private keys and common token patterns;
|
|
||||||
- arbitrary environment-variable dumps;
|
|
||||||
- local source contents and Git diffs;
|
|
||||||
- raw Gitea runner logs from support bundles;
|
|
||||||
- user-home paths.
|
|
||||||
|
|
||||||
Support bundles are audited before writing. If a known runtime secret, private
|
- [SETUP_GUIDE.md](docs/SETUP_GUIDE.md) — Gitea, projectmappen en eerste ingebruikname;
|
||||||
key marker or URL credential remains, ForgeFlow aborts the export instead of
|
- [DEPLOYMENT_SETUP.md](docs/DEPLOYMENT_SETUP.md) — deploymentprofielen en verificatie;
|
||||||
creating the ZIP. Strict privacy mode also hashes repository and user
|
- [SSH_UNRAID_DEPLOYMENT.md](docs/SSH_UNRAID_DEPLOYMENT.md) — SSH- en Unraid-vereisten;
|
||||||
identifiers while keeping related events correlatable.
|
- [DEPLOYMENT_MIGRATION_EXAMPLE.md](docs/DEPLOYMENT_MIGRATION_EXAMPLE.md) — veilig een bestaande servercheckout onder beheer brengen;
|
||||||
|
- [DIAGNOSTICS.md](docs/DIAGNOSTICS.md) — veilige controles en supportbundels.
|
||||||
|
|
||||||
Read [docs/DIAGNOSTICS.md](docs/DIAGNOSTICS.md) for the exact policy and its
|
## Ontwikkelen vanuit de broncode
|
||||||
limitations.
|
|
||||||
|
|
||||||
## Security model
|
Vereisten: Node.js 22, npm en Git.
|
||||||
|
|
||||||
- Electron main/preload/renderer separation.
|
|
||||||
- Sandboxed renderer with context isolation and no direct Node.js access.
|
|
||||||
- Narrow, frozen preload API.
|
|
||||||
- Trusted renderer-origin checks on privileged IPC.
|
|
||||||
- No arbitrary shell command field in the interface.
|
|
||||||
- Gitea token stored using Electron `safeStorage` when available.
|
|
||||||
- Blank token updates preserve the existing protected token.
|
|
||||||
- Atomic local configuration writes and schema migration.
|
|
||||||
- Repository-relative path validation for file actions.
|
|
||||||
- Supported-protocol and credential-in-URL validation.
|
|
||||||
- Fixed workflow, branch and environment allowlists.
|
|
||||||
- Root-owned server target configuration.
|
|
||||||
- Exact-SHA deployment and rollback.
|
|
||||||
- Server-side locking, health validation and status records.
|
|
||||||
- Local diagnostic retention, rotation and clear/export controls.
|
|
||||||
|
|
||||||
More detail is available in [docs/SECURITY.md](docs/SECURITY.md).
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
For a Windows source setup:
|
|
||||||
|
|
||||||
- Windows 10 or 11;
|
|
||||||
- Node.js 22 or newer;
|
|
||||||
- npm;
|
|
||||||
- Git on `PATH`;
|
|
||||||
- a normal signed-in desktop session;
|
|
||||||
- access to your own Gitea instance.
|
|
||||||
|
|
||||||
For deployment:
|
|
||||||
|
|
||||||
- Gitea Actions enabled;
|
|
||||||
- a trusted Gitea runner with an environment-specific label;
|
|
||||||
- a Linux target server reachable by that runner;
|
|
||||||
- Git, Docker Compose, `curl` and `flock` on the target server;
|
|
||||||
- an existing non-interactive server checkout of the application.
|
|
||||||
|
|
||||||
## Fastest Windows start
|
|
||||||
|
|
||||||
Extract the release, open PowerShell in the folder and run:
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Set-ExecutionPolicy -Scope Process Bypass
|
npm ci
|
||||||
.\setup-windows.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
The script:
|
|
||||||
|
|
||||||
1. checks Node.js, npm and Git;
|
|
||||||
2. installs the exact dependency versions from `package-lock.json` when present;
|
|
||||||
3. runs the environment doctor;
|
|
||||||
4. validates the source tree;
|
|
||||||
5. runs all automated tests;
|
|
||||||
6. starts ForgeFlow.
|
|
||||||
|
|
||||||
No Gitea or server credential is requested by the PowerShell script. Tokens are
|
|
||||||
entered later in the local desktop wizard.
|
|
||||||
|
|
||||||
Manual equivalent:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install --no-audit --no-fund
|
|
||||||
npm run doctor
|
|
||||||
npm run check
|
npm run check
|
||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
## Setup and first acceptance test
|
Handige opdrachten:
|
||||||
|
|
||||||
Follow these documents in order:
|
| Opdracht | Doel |
|
||||||
|
| --- | --- |
|
||||||
|
| `npm run dev` | Start Electron in ontwikkelmodus. |
|
||||||
|
| `npm run demo` | Start de browserdemo met voorbeelddata. |
|
||||||
|
| `npm run check` | Voert bronverificatie en de volledige testset uit. |
|
||||||
|
| `npm run doctor` | Controleert de lokale ontwikkelomgeving. |
|
||||||
|
| `npm run acceptance` | Voert de release-acceptatiecontroles uit. |
|
||||||
|
| `npm run 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. |
|
||||||
|
|
||||||
1. [START_HERE.md](START_HERE.md)
|
De belangrijkste onderdelen zijn:
|
||||||
2. [docs/SETUP_GUIDE.md](docs/SETUP_GUIDE.md)
|
|
||||||
3. [docs/UPDATING.md](docs/UPDATING.md)
|
|
||||||
4. [docs/DEPLOYMENT_SETUP.md](docs/DEPLOYMENT_SETUP.md)
|
|
||||||
5. [docs/STATUS_ENDPOINT.md](docs/STATUS_ENDPOINT.md)
|
|
||||||
6. [docs/DIAGNOSTICS.md](docs/DIAGNOSTICS.md)
|
|
||||||
|
|
||||||
The recommended first test uses a non-critical staging deployment and validates:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
preflight -> commit -> push -> deploy -> Actions -> server SHA -> healthcheck -> rollback
|
electron/ beveiligde desktopintegraties en IPC
|
||||||
|
src/ renderer, gebruikersflows en visuele componenten
|
||||||
|
scripts/ build-, release-, demo- en verificatiehulpmiddelen
|
||||||
|
tests/ unit- en integratietests
|
||||||
|
docs/ setup, deployment, beveiliging en release-informatie
|
||||||
```
|
```
|
||||||
|
|
||||||
## Updating an existing source installation
|
Aanvullende kwaliteitsdocumentatie:
|
||||||
|
|
||||||
Close ForgeFlow, copy the v0.4.2 update overlay over the existing source folder
|
- [TEST_MATRIX.md](docs/TEST_MATRIX.md)
|
||||||
and run:
|
- [ACCEPTANCE.md](docs/ACCEPTANCE.md)
|
||||||
|
- [STATUS_ENDPOINT.md](docs/STATUS_ENDPOINT.md)
|
||||||
|
- [ROADMAP.md](docs/ROADMAP.md)
|
||||||
|
|
||||||
```powershell
|
## Licentie
|
||||||
Set-ExecutionPolicy -Scope Process Bypass
|
|
||||||
.\update-windows.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
Application configuration and protected credentials are stored outside the
|
ForgeFlow is beschikbaar onder de [MIT-licentie](LICENSE).
|
||||||
source folder and are not reset. See [docs/UPDATING.md](docs/UPDATING.md).
|
|
||||||
|
|
||||||
## Browser demo
|
|
||||||
|
|
||||||
The standalone visual demo requires no Gitea connection or credentials:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run demo
|
|
||||||
```
|
|
||||||
|
|
||||||
Open the printed local URL. The demo uses `src/renderer/mock-bridge.js`; the
|
|
||||||
packaged desktop application uses the privileged `preload.cjs` bridge.
|
|
||||||
|
|
||||||
## Quality checks
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run verify
|
|
||||||
npm test
|
|
||||||
npm run check
|
|
||||||
npm run doctor -- --json
|
|
||||||
```
|
|
||||||
|
|
||||||
Version 0.3.2 contains 45 automated tests. Two integration suites create real
|
|
||||||
temporary bare Git remotes and exercise commit, push, branch, stash and exact
|
|
||||||
remote-SHA behavior. Detailed coverage is listed in
|
|
||||||
[docs/TEST_MATRIX.md](docs/TEST_MATRIX.md).
|
|
||||||
|
|
||||||
## Development packages
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dist:win
|
|
||||||
npm run dist:linux
|
|
||||||
npm run dist:mac
|
|
||||||
```
|
|
||||||
|
|
||||||
Windows users can run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\build-windows.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
These commands create unsigned development packages. Public distribution still
|
|
||||||
requires platform code signing and platform-specific release acceptance.
|
|
||||||
|
|
||||||
## Server-side deployment model
|
|
||||||
|
|
||||||
ForgeFlow never sends a free-form deployment command. It dispatches a fixed
|
|
||||||
workflow with controlled inputs:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ref": "main",
|
|
||||||
"inputs": {
|
|
||||||
"environment": "staging",
|
|
||||||
"commit_sha": "0123456789abcdef0123456789abcdef01234567",
|
|
||||||
"request_id": "generated-by-forgeflow"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The workflow calls a root-owned allowlisted server script. Deployment targets
|
|
||||||
are defined in `/etc/forgeflow/targets.conf`, not supplied by the desktop app.
|
|
||||||
The server checks repository, environment, paths, branch, exact remote ancestry,
|
|
||||||
locking, Docker Compose result and health before it updates the public status
|
|
||||||
JSON.
|
|
||||||
|
|
||||||
## Project structure
|
|
||||||
|
|
||||||
```text
|
|
||||||
main.cjs Electron lifecycle and service composition
|
|
||||||
preload.cjs Whitelisted renderer API and event bridge
|
|
||||||
src/main/config-store.cjs State, migration and protected token storage
|
|
||||||
src/main/git-service.cjs Real Git CLI adapter
|
|
||||||
src/main/gitea-service.cjs Gitea REST and Actions adapter
|
|
||||||
src/main/repository-service.cjs Discovery, matching and repository aggregation
|
|
||||||
src/main/repository-monitor.cjs Background working-tree awareness
|
|
||||||
src/main/deployment-service.cjs Dispatch, polling, SHA checks and rollback
|
|
||||||
src/main/preflight-service.cjs Computer and deployment readiness checks
|
|
||||||
src/main/diagnostics-service.cjs Safe JSONL diagnostics and support bundles
|
|
||||||
src/main/log-redaction.cjs Recursive secret and privacy sanitizer
|
|
||||||
src/main/ipc.cjs Validated privileged operation boundary
|
|
||||||
src/shared/clone-target.cjs Safe automatic clone target planning
|
|
||||||
src/shared/zip-writer.cjs Dependency-free diagnostic ZIP writer
|
|
||||||
src/renderer/ Desktop UI and browser demo
|
|
||||||
examples/gitea-actions/ Fixed deploy and rollback workflow templates
|
|
||||||
examples/server/ Allowlisted Linux deployment implementation
|
|
||||||
tests/ Unit and real Git integration tests
|
|
||||||
docs/ Setup, diagnostics, security and architecture
|
|
||||||
```
|
|
||||||
|
|
||||||
## Scope and remaining acceptance
|
|
||||||
|
|
||||||
The v0.4.2 logic is prepared for self-service configuration and testing, but no
|
|
||||||
source release can prove compatibility with a private environment it has never
|
|
||||||
connected to. The remaining acceptance is deliberately local to the user:
|
|
||||||
|
|
||||||
- validate the exact Gitea version and Actions API;
|
|
||||||
- validate the runner label and runner permissions;
|
|
||||||
- adapt one target line in the root-owned server configuration;
|
|
||||||
- verify the application-specific Docker Compose and health endpoint;
|
|
||||||
- execute the documented staging deployment and rollback test;
|
|
||||||
- export a Strict support bundle if a failure occurs.
|
|
||||||
|
|
||||||
No credential needs to be shared for that process.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT. See [LICENSE](LICENSE).
|
|
||||||
|
|||||||
+11
@@ -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.
|
||||||
+175
-69
@@ -1,7 +1,13 @@
|
|||||||
ForgeFlow 0.8.7 source manifest
|
ForgeFlow 0.10.15 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.)
|
||||||
755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 58 .gitignore
|
ec40b1ed8e5152ca4175bbe97be43f0e2e911894112dc6a47c2c348069f79abf 87 .gitattributes
|
||||||
|
cc690743121cd3e3a4f12499024456c0b28c4aca4af0f566e57aa9ca87124ef9 883 .gitea/workflows/chatgpt-validation.yml
|
||||||
|
037209289e7a387d855ee061fec9b385db57c6a31ee71c770e684f70dcae28f8 5157 .gitea/workflows/managed-validation.yml
|
||||||
|
c70a5dd662bc71e91e255b51b1a907939682259af2e27091178ef5ea231cfd77 1896 .gitea/workflows/quality.yml
|
||||||
|
e6b76033c44516b625cd9f40e89824ebc8f74ef2a34666d6d44c042fc602c406 4905 .gitea/workflows/release.yml
|
||||||
|
83fac3efff45f3dc926080b280ae190b6bb40eb8dae7b8cb5d27255759bc9c47 267 .gitignore
|
||||||
|
f14b4987904bcb5814e4459a057ed4d20f58a633152288a761214dcd28780b56 3 .nvmrc
|
||||||
89545860bd6f7566da81edc8328cd2a1ebf33e81a4b0dcf2cec74338c05e8cac 1753 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
|
||||||
@@ -12,18 +18,42 @@ 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
|
||||||
feac8256bec2762533c6dd76a49fceaeafb4935528e406158598e8ba55500a6e 10277 CHANGELOG.md
|
164c059453a5737110b4e5e98b6211650c757f0aff710f8f7523ffe0ff1815d7 113 build/update-signing-public.pem
|
||||||
21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md
|
5f4aca19a35cbcaffa1a6993ce96b7d66052ec2b286022f2af74594e8a310568 15712 CHANGELOG.md
|
||||||
a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md
|
3754dcaa776ead5dc60b4955ed4294fd8580577ecb9ce29cb0d9fbbaf9253313 811 CONTRIBUTING.md
|
||||||
|
c612fcc44ff222db0c9a4cfd11a4076fafe080e4ada31e689a08739a4f14e74f 1650 docs/ACCEPTANCE.md
|
||||||
|
3785ad21872e3dc08fde9ef819e26f42161ba6ce6d235f87fcebd7859dfaf148 8293 docs/ARCHITECTURE.md
|
||||||
|
e05458ee2696e3c57e2475bb42ae1f914f6a36e01768d7a26a3199f1fffed490 1157 docs/COVERAGE_POLICY.md
|
||||||
|
9eb9eec82518c0bfc7686f5faaf690a93ed63c71d35f1dc5a2c5ec5199da652a 3124 docs/CURRENT_STATE.md
|
||||||
|
8ea655d1912ac2e17f8834e33a566a8b14461b396ec4268c396ca189a1749b94 2205 docs/DEPENDENCY_AUDIT.md
|
||||||
|
3b9c301313a6406c39ec09f5ad7247dafdf47f6c21505ab8970b206cb1734f31 2788 docs/DEPLOYMENT_MIGRATION_EXAMPLE.md
|
||||||
30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md
|
30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md
|
||||||
eb42f979666e05d51c587e4223282914926a2b9b1ade9f3fb75525019ce7f738 4616 docs/DIAGNOSTICS.md
|
eb42f979666e05d51c587e4223282914926a2b9b1ade9f3fb75525019ce7f738 4616 docs/DIAGNOSTICS.md
|
||||||
a0cd06a96f23a94e118feb012be0fa1ac51345951cb2ba8e67fb8c889c4c342a 5007 docs/LUMAOPS_SERVER_AUDIT.md
|
1d8aa3c688a9d330b9a5303b09e9e3049c50bf04f97807c7ff659607baa34c32 1464 docs/ERROR_CODES.md
|
||||||
|
648dd6bca6b45668fb86eb3e1f6c5898dd8da0291b990f1bb5105cfd79421343 1301 docs/MUTATION_MODEL.md
|
||||||
|
8dc95f69e6f6c8415702c8e79fb6b466c4d60061afded9140450e7e558eaefe5 3704 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
|
||||||
|
0eb44bda2209a5979a6ac693ac4cd4d235c0015031e54b9e895990f37bf60054 1433 docs/RELEASE_NOTES_0.10.1.md
|
||||||
|
5d3240169765e3fb1d3cd391d09547101227e76dd4670ee46be8ca3a21553a03 894 docs/RELEASE_NOTES_0.10.10.md
|
||||||
|
36edb4f096a248fb8679bd13e5766befb478cf628c6ccfb21e3eda71bbec7633 992 docs/RELEASE_NOTES_0.10.11.md
|
||||||
|
a355d3f577c2ec85dde5dfd7b6995f4f1615e2f6bac597529f3ff102acd93c35 1292 docs/RELEASE_NOTES_0.10.12.md
|
||||||
|
609c55a1c0b06c307ebe16f2daaf1e48601edd57137586e4f2be1febd6a7060a 1931 docs/RELEASE_NOTES_0.10.13.md
|
||||||
|
1d5832048dd834a773ee8f34e6599c590373358132203b022b521dd5dde2f179 1571 docs/RELEASE_NOTES_0.10.14.md
|
||||||
|
055ad0c73f0854a708eedc3bc4e9dfb991b4e6021348c05484da9a47022e995b 1871 docs/RELEASE_NOTES_0.10.15.md
|
||||||
|
8d713471a437a8a55b00d7e1dd95290680862107bc4e586cf27d727f6274e46c 577 docs/RELEASE_NOTES_0.10.2.md
|
||||||
|
0942fb2c4a4f972296423b5232687f7389e2c6417a9d48a3244beef9dec907b9 1164 docs/RELEASE_NOTES_0.10.3.md
|
||||||
|
8f4a0fe6dc250ae210cc2fc1c57c46091822ae6c2a58caa76e0091f255f9f30d 775 docs/RELEASE_NOTES_0.10.4.md
|
||||||
|
05ed618f5a74a854363930128ca98939808517eedd28b9a508660a0c46e91d97 884 docs/RELEASE_NOTES_0.10.5.md
|
||||||
|
94bfb2783c1befad1197e1c5e32fc002222c94a28d70d48360a8d53ecd260d5c 772 docs/RELEASE_NOTES_0.10.6.md
|
||||||
|
226a3b2d4bc7f54841749a283fcdd71b643cd585ba74d673084bee829fef6ea2 903 docs/RELEASE_NOTES_0.10.7.md
|
||||||
|
4be29ad0cb7ebcf5625172b8d2bd7a67cdc6d64d3a94e2c3f0656cdfd42dcb7a 642 docs/RELEASE_NOTES_0.10.8.md
|
||||||
|
fb64517aa64d3ecfe8b51b09e198c2c9fbba96d0cd24a87301c7f6dea3076095 961 docs/RELEASE_NOTES_0.10.9.md
|
||||||
a0c00ff76acd1682bb5e0e8dcf6589c9480da436c9c6d30780a1ed58b4dad94f 1770 docs/RELEASE_NOTES_0.2.0.md
|
a0c00ff76acd1682bb5e0e8dcf6589c9480da436c9c6d30780a1ed58b4dad94f 1770 docs/RELEASE_NOTES_0.2.0.md
|
||||||
5773ead01aa4c522c556295553787482d01b1f5242f053b2c61f120c4de4fa76 5963 docs/RELEASE_NOTES_0.3.0.md
|
5773ead01aa4c522c556295553787482d01b1f5242f053b2c61f120c4de4fa76 5963 docs/RELEASE_NOTES_0.3.0.md
|
||||||
d46de73cf6c4cd5c2ba3f455a7a2af2e0d64ee9d94a97fd1a0bfb44e35c1624a 1093 docs/RELEASE_NOTES_0.3.1.md
|
d46de73cf6c4cd5c2ba3f455a7a2af2e0d64ee9d94a97fd1a0bfb44e35c1624a 1093 docs/RELEASE_NOTES_0.3.1.md
|
||||||
0d697d241a08d2427a6e7f5c2f27bd1830a41836a01e08eeff239c7ad5d89982 2445 docs/RELEASE_NOTES_0.3.2.md
|
0d697d241a08d2427a6e7f5c2f27bd1830a41836a01e08eeff239c7ad5d89982 2445 docs/RELEASE_NOTES_0.3.2.md
|
||||||
bc6933c303d3d9b3bfdbf678cae1a717bfe5a893780a1871af8b48589f62f0e3 2160 docs/RELEASE_NOTES_0.4.0.md
|
df07794fb3923f3fb6a49dacbfcc5a371227edb592b4e86ecffaae0a8799f187 2154 docs/RELEASE_NOTES_0.4.0.md
|
||||||
343862445061e1a8282a7aa9b2304e7d799e58f9956d50eb5352db18d790efad 1134 docs/RELEASE_NOTES_0.4.1.md
|
343862445061e1a8282a7aa9b2304e7d799e58f9956d50eb5352db18d790efad 1134 docs/RELEASE_NOTES_0.4.1.md
|
||||||
e2d67c816a919f00f9e26bf59cf29e5e8cf894536b743d282075c646c5accc96 1605 docs/RELEASE_NOTES_0.4.2.md
|
e2d67c816a919f00f9e26bf59cf29e5e8cf894536b743d282075c646c5accc96 1605 docs/RELEASE_NOTES_0.4.2.md
|
||||||
1aef74fb109541903c4dbc4d9c48d2bd63507420eaf8cceb31890797a5e4f5fd 670 docs/RELEASE_NOTES_0.4.3.md
|
1aef74fb109541903c4dbc4d9c48d2bd63507420eaf8cceb31890797a5e4f5fd 670 docs/RELEASE_NOTES_0.4.3.md
|
||||||
@@ -45,108 +75,184 @@ c2802fa5dbff392c846b82b55e84a8bfb8e1625546fd1eba39f7129318bece96 654
|
|||||||
b516db97a0353babc810c24a87a971d30d72a8021d809e6b833ff7ae0458f442 538 docs/RELEASE_NOTES_0.8.5.md
|
b516db97a0353babc810c24a87a971d30d72a8021d809e6b833ff7ae0458f442 538 docs/RELEASE_NOTES_0.8.5.md
|
||||||
838d196f3fbbfeee8df375a0502107f56b6df28babca30aa09ef1c7aa5196d09 738 docs/RELEASE_NOTES_0.8.6.md
|
838d196f3fbbfeee8df375a0502107f56b6df28babca30aa09ef1c7aa5196d09 738 docs/RELEASE_NOTES_0.8.6.md
|
||||||
ef049adcfa204908e6dc3a059124b39ba0e2739cc54e38945ce73a57049df0d8 1185 docs/RELEASE_NOTES_0.8.7.md
|
ef049adcfa204908e6dc3a059124b39ba0e2739cc54e38945ce73a57049df0d8 1185 docs/RELEASE_NOTES_0.8.7.md
|
||||||
2b631b9d6d973bdd70869d84886ff339da351e29e17598970b3b27915674661d 4175 docs/ROADMAP.md
|
7eedb25e1aae3b06a04bb9b2f4843bd6af614418737e600b4bdc9161edabd76a 632 docs/RELEASE_NOTES_0.8.8.md
|
||||||
|
35dcfda990946480d6d55bd2d2e6360c336260bcd05cdb51d92e07a4e8d76945 1046 docs/RELEASE_NOTES_0.8.9.md
|
||||||
|
29f7b11fef1e4960ef874f643b2206ca73a310b0b75402bcd3764a01e59db2e0 3114 docs/RELEASE_NOTES_0.9.0.md
|
||||||
|
ed40e08bac8792f95970bc05e49bce3cc9e288a08d11565a1bd156d787360a3b 720 docs/RELEASE_NOTES_0.9.1.md
|
||||||
|
01bf2cc72593b10010b667ae017b9eb62f4658a9711bbf24c81f5f95d93fe8c8 827 docs/RELEASE_NOTES_0.9.2.md
|
||||||
|
25169225d73d22b9d884ab3b5c1625f03fd44e53c7a7a4c4067775e80482c9f8 2182 docs/RELEASE_NOTES_0.9.3.md
|
||||||
|
720506842e0aeb30c9fc635f86a52a5545556f092e678cf37f08436243244c3d 933 docs/RELEASE_NOTES_0.9.4.md
|
||||||
|
dd90c81a375f97dfb7fa8f7808db03b19d7e7dafe3818a93537397f57eaae829 2109 docs/RELEASE_NOTES_0.9.5.md
|
||||||
|
1bf75f25d704dab0c6bc56c639d259f34523f0fb46718dd5a8419a59911ad2c3 2242 docs/RELEASING.md
|
||||||
|
ac76cb50fabde6a00f28d7e9eccd3ef1129a40665eabdc90d78690a38d424652 4195 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
|
||||||
bcb1e4daf1eeedc5b3f61d2406f1a65312dba130082528007e1629d9df99570a 153240 docs/screenshots/overview.png
|
ed69b8beb948a2cf9a6deb6c82368e2bb44ffe8d8990a900dc878b0938d1084f 95937 docs/screenshots/deployments.png
|
||||||
224e34ab45877bbb97b07d2a14c4a5aa6e28339522a8015b33a2a81477177143 135102 docs/screenshots/repository-workspace.png
|
3868ab978de2a7945761c53a9a718aecd54dc791605d07660bcd5cad62a33ea8 103569 docs/screenshots/git-validator.png
|
||||||
322624242d246d07180cc719e14c91e8fb69e123676a02e5046f4e576cca1ca1 5569 docs/SECURITY.md
|
007681714895ac062c980db1dda806ac17d4f01019ce9c46491a108d17c2dbda 85338 docs/screenshots/overview.png
|
||||||
|
1f78414b00ec100af2ec9bf5c9a3e400b6c9bf6dca6fcc317fd951789acc4536 112852 docs/screenshots/repository-workspace.png
|
||||||
|
5cb9ee0e33a06db1796a740f12b0c41b917c68c1ae7cdb0070936a99c61c2c64 6036 docs/SECURITY.md
|
||||||
32a34ec13a284d3f9ceebbc107b25a844e3db096f8cafa4e43951fc2050c9a03 13552 docs/SETUP_GUIDE.md
|
32a34ec13a284d3f9ceebbc107b25a844e3db096f8cafa4e43951fc2050c9a03 13552 docs/SETUP_GUIDE.md
|
||||||
4dcdbd42550a4cc53fe948349b20bfe0e445d40144ef82df54b3aab8b00a8f46 5165 docs/SSH_UNRAID_DEPLOYMENT.md
|
2fd71e9bcaeb4cb10c3fa2496b7e52fedf70c5b7f871cd587e22dc060c399079 4421 docs/SSH_UNRAID_DEPLOYMENT.md
|
||||||
b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md
|
b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md
|
||||||
0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md
|
0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md
|
||||||
4625a10ebd3c749f60b2a7bef6b1716cd05dbc44ccceba0491a1b46bc293c195 4883 docs/TEST_MATRIX.md
|
4983414a980075e6faae687b0d71c8e57bfe53fcb4cadb8b979b8abca636fe95 6654 docs/TEST_MATRIX.md
|
||||||
b3f317faba63d574ee83924f01b5275c118b5b04f6d9109c7cf9ed18500835d8 3389 docs/UPDATING.md
|
3c34d42088b18e37d18afc848f91fbe5b74134ca74dd53df1e9eb30753fae750 4433 docs/UPDATING.md
|
||||||
|
73f094a2f0db3de053e515feb2771cd5a4f3aa4178f2c5f37be01ca65ff1c938 2705 eslint.config.js
|
||||||
c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 examples/gitea-actions/deploy.yml
|
c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 examples/gitea-actions/deploy.yml
|
||||||
|
5d2577d4f9f635a12dcc8795879c079b9e66630bb3d96e21510ef7ee13ebef05 3319 examples/gitea-actions/forgeflow-approved-deploy.yml
|
||||||
4c792cc9fd57ed36da291300c252a6ef75b08a249cf6f2561e23c4c22522138a 1477 examples/gitea-actions/rollback.yml
|
4c792cc9fd57ed36da291300c252a6ef75b08a249cf6f2561e23c4c22522138a 1477 examples/gitea-actions/rollback.yml
|
||||||
1d2cde1bef4882f56006823d2806f6105882fa098a665a303150fdf18ada2004 5705 examples/server/forgeflow-deploy
|
4a84041fcf2d7f36d1807e2c19d6cab816f9635112756a675f32cd24d9757fbb 9661 examples/server/forgeflow-deploy
|
||||||
4fe3eee5c2d8705964c24b8c4dd909883a05e7d6eb85629c84b0d64473e0a92b 258 examples/server/forgeflow-runner.sudoers
|
4fe3eee5c2d8705964c24b8c4dd909883a05e7d6eb85629c84b0d64473e0a92b 258 examples/server/forgeflow-runner.sudoers
|
||||||
0423fe2cc7f43fe793986a3f62a395668897cdf07348756aa7742a8cd40ac51c 569 examples/server/forgeflow-targets.conf
|
0423fe2cc7f43fe793986a3f62a395668897cdf07348756aa7742a8cd40ac51c 569 examples/server/forgeflow-targets.conf
|
||||||
106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf
|
106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf
|
||||||
2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json
|
a0de3fe4e09b6f246e1513bccc170334e60f63f98c24377192b4335cbf16dff4 593 examples/server/status-example.json
|
||||||
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE
|
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE
|
||||||
0d9f0ac837b4d3b39476d9bcbb57fb3e12d6b7084bb1f7435401e416cff1854f 12920 main.cjs
|
e2daa28bbc01c68c3702add6ea8259dff5920b22f6fdc3c9193ed78a153f2e9e 14708 main.cjs
|
||||||
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
|
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
|
||||||
5610ef8f25cc47b5296c57edb29d5e1b605de4113b4b0128fa451b9ec16bba2c 130466 package-lock.json
|
63d403ada205000a0dfb158ec57cde1a4f58572d790589428ab5b00a99b32cbe 184860 package-lock.json
|
||||||
faae3af578a40306472494f5816855bf9422425b6daf39732fe04cbad55675ae 3869 package.json
|
ae06416348c2039eb4cafbf287c99342e04176bbbbb718646f160ff29b6af42f 6464 package.json
|
||||||
45eea5fe4fd58cc0e96497295223e81a4f3acc3778bf546d4c7c96af03da4d26 9496 preload.cjs
|
1237df9ddcbb5ac7dc4316f18c34ff4a7030e3e0d56216ade6dd07369e5e2a04 1353 playwright.config.mjs
|
||||||
b31c43d9355c13b5ae4efc0f3649d8cb8d509b2bb7ebb042ff546b7820fb7de8 8411 Publish-ForgeFlow-Release.ps1
|
7b0d173d0cf5a7f8db807580492bade379dba174a271013747f9f28a3793f55e 12409 preload.cjs
|
||||||
a6d32a742412b7836606be00f17be0465f1b6f55d3911f6c73a14029787ba206 14037 README.md
|
abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd
|
||||||
|
6d0858d6654c3c3dc7083ebbd234c88324afcebaecd7b772719440a8afbc2e4e 10736 Publish-ForgeFlow-Release.ps1
|
||||||
|
33f3c4795705ab77c6e6603c88a32c123b3a286bc77e8e472b76970485699338 4386 Publish-Missing-Binary-Release.ps1
|
||||||
|
5d9f13f34c9a9cca77968e472f1829147e51d3795779fa3f766b04cdd54fc698 11437 README.md
|
||||||
|
0f1bf0696ca6a3de7c222a935953156cdd1bb0aa27f2215b8000901c4db2be31 17255 reports/architecture-audit.json
|
||||||
|
6c50c58f464e2f93fb7255a59d6cbb76354755f63c6f1d4ff14a9a88c8c54574 1758 reports/architecture-audit.md
|
||||||
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
|
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
|
||||||
00d57bda5af8c8eda294b72d18b318f024a307b81b0d9205a0821f5240151e31 3814 scripts/apply-binary-update.ps1
|
5f220dc8ee24d2339aa3eb696ac7a5bd6f55c993b5fd9001ec9784788f2a6e46 6747 scripts/apply-binary-update.ps1
|
||||||
f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287 scripts/apply-source-update.ps1
|
358d0ecbd50d8ba1ff9460c761cc2a1990fb27104d6ad74104a8800877343e19 10954 scripts/apply-source-update.ps1
|
||||||
|
02e924227f6cad3777fd06660230c85df590d8ce95e134194a4d18970a240b88 4145 scripts/architecture-audit.mjs
|
||||||
|
47a5b16e95934bfe510c18bf94547ae65acb980c0f0506ae156d1a486dfbdfc9 8985 scripts/audit-installed-deployments.cjs
|
||||||
6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs
|
6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs
|
||||||
5e9a2a819522f6a32bbd9d3303263d5e5eaec95898ea2cd5776b221168008d75 1727 scripts/generate-source-manifest.mjs
|
7b483476ddd909b085335cb78c9b0ffe71939c50011b5fbfcfdef6a340fa7ce8 2032 scripts/generate-source-manifest.mjs
|
||||||
842436680521311594e798848b050ae4e488d0595f0de57315f6ec081c049fb9 1266 scripts/prune-dist.mjs
|
842436680521311594e798848b050ae4e488d0595f0de57315f6ec081c049fb9 1266 scripts/prune-dist.mjs
|
||||||
74433d8a6b24afe368197a469e2fe0c5050c239d7250b84c2f3f598c304778b0 4736 scripts/publish-binary-release.cjs
|
19417a26a5af967b0f057fede45d1811a6d8ad65a0ed49ad4f5865f598457168 9358 scripts/publish-binary-release.cjs
|
||||||
444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs
|
558ff442988f1396c174c7161ff5bd3ef0b2f43cfc31459ec7c3967faa146bc3 1694 scripts/serve-demo.mjs
|
||||||
42203f9e0fd4aae517284d387f265cf1b0b180379bc253a092b5c3c5c4caef0a 2992 scripts/validate-installed-connections.cjs
|
288c4b93f6006c0b32cdf90555bdc0d1d3b61d24a8763fcc30f1e6425ce1684d 1713 scripts/setup-update-signing-key.mjs
|
||||||
e77edf02fc9b9afb398ae46767e634f47259e58288ce9a18bc696b028234b581 12341 scripts/verify.mjs
|
431d3d7eabf7e2ea2d5cbb96fb0ddc13f26d85afebcb9692f3e30242197cbd8d 2607 scripts/sign-release-manifest.mjs
|
||||||
0079701b5acbfef07b71a9623613d1940805ccd20649d77e3f34c37e79df7655 735 scripts/write-release-checksums.mjs
|
c76507857292c5713e1c699cf02e24b80265da39af2cecd148034bdb874adbb6 5246 scripts/test-authenticode-chain.ps1
|
||||||
|
4393f7dc5f417e6d601a68238f4e26791799a3634acec228fe4d79deaee85eb5 3109 scripts/validate-installed-connections.cjs
|
||||||
|
e6127e1e62f39c70ddb1abf72f4d7e7b8e3f19ff1f219e1a3660353c2e0cdfac 2411 scripts/verify-release-signatures.mjs
|
||||||
|
2187f4a6e5f162b428f123960c8121f671218e1164327c1b4f21bf45a4477d01 22686 scripts/verify.mjs
|
||||||
|
c2c9e4ba251d93a530a52b2d0079787680261c314083bb99d2356fc177719613 2434 scripts/write-release-checksums.mjs
|
||||||
|
c8780510ff6e77a16e9c4a88c3c981a0d9d1a4dff8eafd16afdeefbb4b03aac9 1064 SECURITY.md
|
||||||
619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1
|
619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1
|
||||||
dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs
|
dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs
|
||||||
a381848a296c28f6d14093c96f722967acf9c994ffb867d54dd92bf5ada2729b 23648 src/main/config-store.cjs
|
e3af59fafa497d032541979bea5fe2b98187b1ced619c567d15ae79ee0904c1f 34629 src/main/config-store.cjs
|
||||||
2fb04b1494b39f5d7c0720fa5fd298cd46fa85dc1b696d77657592347fcf1819 2731 src/main/configuration-backup.cjs
|
2fb04b1494b39f5d7c0720fa5fd298cd46fa85dc1b696d77657592347fcf1819 2731 src/main/configuration-backup.cjs
|
||||||
9d0af5074093108a5248d0dde0ff70a666748e61f1954b630886a81e8f34072c 24079 src/main/deployment-service.cjs
|
86e9fc2eda66b4b563f6c4bbb87d3e8514340d484fb503b73137e63b6b05c3c9 14597 src/main/deploy-key-lifecycle-service.cjs
|
||||||
c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs
|
7cbfe51973d6607203cb197652ed7f296a3f6b6b644df876957117866a47d802 2159 src/main/deployment-identity.cjs
|
||||||
a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs
|
ce30ddac403d1adf21176e5df21b0cc3db435305d2628f51f1486eacf20df6f2 23708 src/main/deployment-service.cjs
|
||||||
0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs
|
cc2dad2fdac386d41c37b1a8657ae2b3b4084fd8f35a97f8600c082c8d017552 16475 src/main/diagnostics-service.cjs
|
||||||
75f25fa8ee520b03b0bbe4c4cea439853202d318547343cda49a19c6ab868901 16984 src/main/gitea-service.cjs
|
7f452dc2c0e6f3a00eecf6cb1c2be75906ac40445ef3e104341c0c67208aab2e 2397 src/main/external-tools-service.cjs
|
||||||
13f6521bb82aa5a3399d6839ea7563459b19969c1e5af7525e4116cf771282e9 46526 src/main/ipc.cjs
|
76fdc5576dcd6fdb88921009d4a923854650bd5efe5b837cd7438eba0dc733c8 48002 src/main/git-service.cjs
|
||||||
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs
|
e28fc1ca2fd4c0116148f5005d793feddf04c36ef711d2d348560394d209a613 7253 src/main/git-validator-policy.cjs
|
||||||
|
3a101b63ad3761c26350c2ac0793279a0b27672b91a5b1d8dc75bc44d92f0b52 27128 src/main/git-validator-service.cjs
|
||||||
|
3cc53e24e023aa0d8bf36c35ce9672ca98e6c74066512c8b59ab42274e838c22 21307 src/main/gitea-service.cjs
|
||||||
|
2ad3b2e647377f687ad987fe248a142ad399ecac98e4b49965aa7efc6093e5fa 6914 src/main/inventory-classifier.cjs
|
||||||
|
dafdb09133d2b6ec2161a3f0b09354551e54fc606c8107976fca37405643be91 3404 src/main/inventory-review-service.cjs
|
||||||
|
98b332589f86a4874a8adddc38e7844a4ddbe9360d38f9f77b2e95d58d748016 25720 src/main/ipc.cjs
|
||||||
|
26efebb4c147ed560966e7e60e64a013b3476327b3bbdb4e4439949142fa7846 2250 src/main/ipc/channel.cjs
|
||||||
|
748cddf497b4c204e5e6fa1bd049991a086fdfb38afe342e6b3617c85a111478 12467 src/main/ipc/deployment-handlers.cjs
|
||||||
|
dc9b5971c9fefe8c374aa31916f5513601ce86003fd48b1d0e51330a909ae3a5 3442 src/main/ipc/operations-handlers.cjs
|
||||||
|
8072252821b1245d121eac534a18eeb64f0d7d18429e272e21a6e90b010005b9 17272 src/main/ipc/repository-handlers.cjs
|
||||||
|
fc7156aabacb3e85a4f220c490f94e9836e07fa5350a1d9e64f84f835ef51024 4961 src/main/log-redaction.cjs
|
||||||
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs
|
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs
|
||||||
|
720c4a0c554f46386d87c3ab6607d1fbcae66e50b69483c7dbba169d5128c851 680 src/main/process-error-policy.cjs
|
||||||
3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs
|
3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs
|
||||||
e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508 src/main/repository-monitor.cjs
|
e64f7257d478955c675a133b3735b6afe138a69d2ad090898061e56f557c43e5 9926 src/main/production-acceptance-harness.cjs
|
||||||
17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs
|
27bd6621c731545ec46d8914e9408c89928a8ce563b40eb4bcd8a516662a54d1 8716 src/main/repository-monitor.cjs
|
||||||
b31a63bf8cb1807b3e838e2bf8a0e742738f119d13de8ca9f42e471f072217d3 8328 src/main/ssh-service.cjs
|
6393583911263575c6e2a19d9baab6e638cce90252c386b0a5144f2fb6f81f15 12154 src/main/repository-service.cjs
|
||||||
f4e221edace12e2120ec439e0f91274de110872c5bfbe968f2d7935eeb7193c4 78544 src/main/unraid-deployment-service.cjs
|
52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs
|
||||||
27b9e15dd6530bc7bfab51414430eaef169e0cc4acd027257b1c63f891af8c85 20733 src/main/update-service.cjs
|
793003566823e1d5c02283f583888ecc07e44477525620579b3d858f488b3c08 22347 src/main/ssh-service.cjs
|
||||||
00b5c44b7d04452498288d42a2c345e0e3922c8ddfabfee9eb04654f238811d5 197015 src/renderer/app.js
|
19538a3c40ea3489bbaee9a23af36a5e99962af6bb3d04259f05ece6588cbeb2 25901 src/main/unraid-access-methods.cjs
|
||||||
|
5621e35323e4f81fb14a05670f81579ec1e66bea3a55fa6457ece0f807421424 9801 src/main/unraid-deploy-key-host.cjs
|
||||||
|
6d9910dace52625f88e066a8485af2663c3735ff15e9ce9031441ce742710a21 30793 src/main/unraid-deployment-methods.cjs
|
||||||
|
673b1692e7c2b5197545df98750b5d048bddf44206263e25be4f17d9bf900e2c 17208 src/main/unraid-deployment-service.cjs
|
||||||
|
bb4a99c3526fcf4db4fbae88a058e8598fd10a87990e7d502bfdc765328bdaa1 42766 src/main/unraid-inventory-methods.cjs
|
||||||
|
2c0cf07921ca7ee5a9085ced44498c2e6798e5cc1e8a5ecf704c3cecabe39a25 27607 src/main/unraid-preflight-methods.cjs
|
||||||
|
d45220176aed72d692f9ae5534f9d40bcc359a2d08e025e74a3b3b505b8b9ed4 16559 src/main/unraid-runtime-methods.cjs
|
||||||
|
4c5cf01922e1feb36a31b50af22e973d8aee3fecccd406e449690604111898ac 11608 src/main/unraid-state-methods.cjs
|
||||||
|
29b8c5eca83b0e89c7d0716945b5316aac43947b5387e89562d9a024ebc4663c 27204 src/main/update-service.cjs
|
||||||
|
b5c304531bec358d059189a27cd9db8fa20cefb7f817e5eb0287001f7353f6a7 985 src/renderer/actions/command.js
|
||||||
|
d0bf607dd1de9d55f2947d0adf0997cd3ca5c269d10a5362cc1d8bc4d1a2a8ae 6706 src/renderer/actions/deployment-operation.js
|
||||||
|
0db283b1a458ae0b31538940b1ddc931ffdb53bd04ceb7fd8903813f9200d071 17978 src/renderer/actions/deployment-profile.js
|
||||||
|
48bed91dd2a85bb51ee7307f7acc3b79c881ce8cf63b22ba79d5d079b265eb4b 7785 src/renderer/actions/inventory.js
|
||||||
|
9e8adf1ba89ffc61a7b595f813c784688bdf50daa259204d74b2cdaa81650895 15493 src/renderer/actions/recovery.js
|
||||||
|
2414a0d29a0380d343b9b0e58ba1909e7a7eeb46357fd45ddbb3ad411d119f78 16280 src/renderer/actions/setup-and-settings.js
|
||||||
|
058722de35ba33bfcfd29d355a75e1513a2be80c572773472cf9816dd13f894a 20148 src/renderer/actions/shell.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
|
||||||
e1c463d6cda9f2b9b78c468845c0a7e8688f0362be5642074a1a5f7122dfe811 762 src/renderer/index.html
|
f826ab1f2f35882c59995497219fcfd500e46a94dac0906ee47fb732e8023fb4 55633 src/renderer/dialogs.js
|
||||||
3faf63826ee693b1afcfcc632238dd71e7969641e2502ef6884b40f3855e9d6b 51166 src/renderer/mock-bridge.js
|
dede1f21a06c73a2c2a462a869d27530d85f99baff202a2eb509c57436ad6aec 2732 src/renderer/diff-view.js
|
||||||
e30ea37ee00f34110d0d252c79f9d8e0bb127c4db0bd1bf8b374609dea433361 72160 src/renderer/styles.css
|
b7698de13b872aa80d27b0a4d977c12ca2303b2246f05e6af4223db9b727e525 7433 src/renderer/events.js
|
||||||
0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs
|
c4a71213d412166093f7bd8254b847de4d8beb58c1aaa356a0cdc8d728080326 1524 src/renderer/index.html
|
||||||
|
06180d9656dd254edfb6949c397f8e313954fc560ddcb22b3a35fce3c3e35655 21350 src/renderer/mock-bridge.js
|
||||||
|
870024aff376826a92c9cf7452689cc1ecc5d9034f055bea56734f3f7fcea5e5 28703 src/renderer/mock-deployment-bridge.js
|
||||||
|
ee33d1a77ab7152cb4f3dfbb611011f755a69ba1afb1016a28c997fcfdca97d6 25810 src/renderer/mock-repository-bridge.js
|
||||||
|
94fa265c2fe9ca8d644f0ce9b620b6f85d9b25dca5802c4e9195b66dcbe80120 6522 src/renderer/operations.js
|
||||||
|
9299c83e43eef194bac2946c43b2ffda6309ccbdcc5b9ce1775e668ae7f172ef 92319 src/renderer/styles.css
|
||||||
|
9ee96e6267d923448a45eb0a02d5352286400f2d7b4a006ba681b6806f2e1424 113808 src/renderer/views.js
|
||||||
|
e9e72c072a5c5d04f59cd6763de0cfbf736c2a5ffa2f722143f3bad2bdbc630b 1411 src/shared/clone-target.cjs
|
||||||
5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs
|
5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs
|
||||||
029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs
|
029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs
|
||||||
2778ebcbdf60fdc1cb0749f15565e0e1bd66f3a0d31eb70ae7942a7511a3de75 1295 src/shared/repository-match.cjs
|
2778ebcbdf60fdc1cb0749f15565e0e1bd66f3a0d31eb70ae7942a7511a3de75 1295 src/shared/repository-match.cjs
|
||||||
c7e120ea53c5ef3c01b8cce71afe913f34bb461bb73aa3ade24656e09f99f338 1152 src/shared/semver.cjs
|
c7e120ea53c5ef3c01b8cce71afe913f34bb461bb73aa3ade24656e09f99f338 1152 src/shared/semver.cjs
|
||||||
ede2c95bb045c0005a3931709a0116d9fbcb3faa5f609848a0066c6ba382ca0b 2906 src/shared/shell-verification.cjs
|
a31b275114a2ac376f3f8c69f4328286219767d22024ddeb01070550ad62109f 3189 src/shared/shell-verification.cjs
|
||||||
2daa98fd421598bfe5fc9757c9b6f4d82c31d1bfece15829928473581d5d2639 1210 src/shared/tool-invocation.cjs
|
2daa98fd421598bfe5fc9757c9b6f4d82c31d1bfece15829928473581d5d2639 1210 src/shared/tool-invocation.cjs
|
||||||
114f01be8bd54c91b90af82d8e1604e24cc0c5f8e64e63c40cf3f4042623a98e 5402 src/shared/validation.cjs
|
ee73fdf9c591c029243385cb2d2085c3005c7b08c5b9e1b89102201f0ab30759 5702 src/shared/validation.cjs
|
||||||
13b731c38863b1007b0312fd9d89562401b7cce875c952f52429bde74f77a8af 3096 src/shared/zip-writer.cjs
|
13b731c38863b1007b0312fd9d89562401b7cce875c952f52429bde74f77a8af 3096 src/shared/zip-writer.cjs
|
||||||
c1b0fd37a6ae74a56750138626fb2bc3523485c1124a0b8ac8ee3ef41089206a 2156 START_HERE.md
|
f8853dce6fdf360d5df2fbe2b6df3e5687630c807fee5ba8436679b34ec737ea 2436 START_HERE.md
|
||||||
058aeaa5d9bfe377c7e322f213c7871ecc4151b5d08ef790992f4ee28d857658 743 START-FORGEFLOW-OVERLAY.ps1
|
058aeaa5d9bfe377c7e322f213c7871ecc4151b5d08ef790992f4ee28d857658 743 START-FORGEFLOW-OVERLAY.ps1
|
||||||
f5b0ea887fcdeadec78c1ad49b0ec7979723562f5c0b730703acb77a37281ee0 1009 tests/acceptance.test.mjs
|
f5b0ea887fcdeadec78c1ad49b0ec7979723562f5c0b730703acb77a37281ee0 1009 tests/acceptance.test.mjs
|
||||||
|
2f2b21754dccd8b734d6c7bd4fdd655df79c7739a1de140e562520c8234aa7a2 2854 tests/approved-deployment-evidence.test.mjs
|
||||||
|
720ff5b549a3dd70854eb1bac3589c77a2019a61148be8e41e112196c8821ee3 1079 tests/approved-deployment-one-shot.test.mjs
|
||||||
a4e5947204ff6878e601e32477bc85b53cd0153baf95a161c8935b6e5466c257 1155 tests/audit-service.test.mjs
|
a4e5947204ff6878e601e32477bc85b53cd0153baf95a161c8935b6e5466c257 1155 tests/audit-service.test.mjs
|
||||||
454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 4318 tests/clone-target.test.mjs
|
6e119cda76b2ee36b93623d98f41371798227f9b3f7c20fbef035a7d7a50cc95 19402 tests/browser/forgeflow.spec.mjs
|
||||||
a984ddd5a29a4ccf55d78ba71202390e0bf1f0925a6a96f03ee74edbbf3bb2f7 1505 tests/configuration-backup.test.mjs
|
1728c0a7abd92f4d7d9e68df32e4a6b00730555f23795e9b36416795d9d127af 5978 tests/clone-target.test.mjs
|
||||||
|
8aa8789a984769b377f719abca42a428fffc4f89b3997d57184a8073d24121e2 18325 tests/config-store.test.mjs
|
||||||
|
f1463326aee79842d265687ae628189ce54e92544600f2bd14073780287cfb14 2502 tests/configuration-backup.test.mjs
|
||||||
|
144c8e217a334cd69f502938d944e0f2dac61703d5df47e287b9ed542918c779 8129 tests/dependency-wiring.test.mjs
|
||||||
|
aad5948ea374d1e56e777005c73639654c96a90364dd398c949052cf5ae343a2 11130 tests/deploy-key-host.test.mjs
|
||||||
|
b7e009fed4171d6dd6b4c3154ba1d3f7198e98f5b79b298687841fc8169447cd 9354 tests/deploy-key-lifecycle.test.mjs
|
||||||
|
49bf9cf9842e7899015013675208f83a95402065a082320927a677ee4bab0766 24875 tests/deployment-operations.test.mjs
|
||||||
1dc6477bd07de78be189e6e8195ec339eb9d75820c4dbd5b073b8520ee21f6b5 1938 tests/deployment-policy.test.mjs
|
1dc6477bd07de78be189e6e8195ec339eb9d75820c4dbd5b073b8520ee21f6b5 1938 tests/deployment-policy.test.mjs
|
||||||
bf68c4dc91a2604235c6a7848088bcd9566fbeaa089b86ec1e0a4fcfc54ca9d2 7677 tests/deployment-status.test.mjs
|
bf4576901e32662d832687a2761852aa1b2cffe256de5044f18c6637c189463b 9780 tests/deployment-status.test.mjs
|
||||||
fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800 tests/diagnostics.test.mjs
|
9a97e79618393d9f9818cb958bd038e0095489331fba0b29de313aaaaa1ce30d 4064 tests/diagnostics.test.mjs
|
||||||
dd121d96ca265a027cd415a52064500a4541b2f8a662f4f4b25f2f996d52b5da 762 tests/external-tools.test.mjs
|
b63eb5bcd89c34629386c745e80c2f83f0709ebce62459b583e665d0a99654c6 938 tests/external-tools.test.mjs
|
||||||
e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 tests/git-integration.test.mjs
|
26e94450c6ab1dd0d5149d6812e66814a719f80d6f95fcb3a9dee7a3d7f46293 20559 tests/git-integration.test.mjs
|
||||||
5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db 1239 tests/git-status.test.mjs
|
5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db 1239 tests/git-status.test.mjs
|
||||||
|
61e0b8cad926acd22b5b17e4044f7edcbe96b6977cbbe2b6fbe123406626fc89 4283 tests/git-validator-policy.test.mjs
|
||||||
|
2b31459f14a5e36e30cf84c1054f634f4dba8676d29adeb9c2a8e18179f56fa0 6097 tests/git-validator.test.mjs
|
||||||
681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.test.mjs
|
681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.test.mjs
|
||||||
52b96f0a6623778fbdc1e8dbfc892e1d77a3d6616058cd7b08d532b207aa5719 5531 tests/gitea-actions.test.mjs
|
d633c59bd910008223c834c6d7f3e5666c685a0881944263ede2d42cc69d3151 18710 tests/gitea-actions.test.mjs
|
||||||
267d76b868d8d06ea031c14acd09a7715fb44668a25ade51a9e62e0170888bc8 1522 tests/ipc-contract.test.mjs
|
fcc9a063882840dd89d74c2785284c8f2f6a9e5acec482b6d89ed8de62efdb85 9635 tests/inventory-classifier.test.mjs
|
||||||
caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs
|
62b90c21c15b841af30d26ccb0b9e88d25674fa7dbff9dc231dd8a1dddc3d657 2025 tests/ipc-contract.test.mjs
|
||||||
|
5c66b01ab11585aab6d923a9811ec76eef34e918f1a7f37227edd3f4337c932f 2981 tests/log-redaction.test.mjs
|
||||||
96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs
|
96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs
|
||||||
c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 3718 tests/preflight.test.mjs
|
1b6c920e18a248f78acaed6187197c88ec8d911b62d5e2a9f8ad57b91ae80499 11827 tests/preflight.test.mjs
|
||||||
185e96ff8fd029f06b77155117f492bc16684e08818a160148e00737dd8c220b 8395 tests/renderer-workflow.test.mjs
|
7f2751ea2621f76b5427f442e931344d13e97faa7b6ef3151949bbd6a03097cf 1205 tests/process-error-policy.test.mjs
|
||||||
|
0cb884cf62c1cb02cf59a81662be055bcb5339d176de85e2a3eeb8e8573e11b3 6435 tests/production-acceptance.test.mjs
|
||||||
|
11fd2029593c0f4e5c36f1ce8572734f8ac9afead5abca7f5b619c5814b40a6c 12602 tests/renderer-workflow.test.mjs
|
||||||
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs
|
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs
|
||||||
f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 1068 tests/repository-monitor.test.mjs
|
4defa3c5f21db7fefbd79397c96b6c231a4330df60b54c0fea7e91412e336fd3 6237 tests/repository-monitor.test.mjs
|
||||||
75b5b83836c75675bb9a48fe4363fcb8a24fc425e6af6f822d7955c6f3c79eac 2265 tests/repository-service.test.mjs
|
5476f3ba90bc096d4172900d9b54ada7c12da521f8627913d87794eade3cee23 13494 tests/repository-service.test.mjs
|
||||||
d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463 tests/security-validation.test.mjs
|
5fea04e668344508fb4e16da9bb6fe8733e2b83d1c227acb3421e51da26b2ffa 3636 tests/security-validation.test.mjs
|
||||||
bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs
|
bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs
|
||||||
020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs
|
12cb3b240bdd0922566323c0014838ca067ad10d9d4009943165ae2c4e93bc6f 11786 tests/server-inventory-branches.test.mjs
|
||||||
|
5df7f331cc1120a0914f5ddbf39d9a6b83bd5ada70a9953b3f6bd0701ba2485e 4394 tests/shell-verification.test.mjs
|
||||||
|
a39d30f47813dfb98c998811f3d76ebbb1544ecfd017a165d44f9afb80d7daf9 9090 tests/ssh-connection-pool.test.mjs
|
||||||
|
7ee9166327ed227d2b7c6929692dea5c5d7a41c3e566596fa92d9ec4f42e8677 4085 tests/ssh-connection.test.mjs
|
||||||
|
c9354e4bf3720c28cff21c15ff8b9474ba4a23b7f55389de4326f4dffde54d78 9510 tests/ssh-service.test.mjs
|
||||||
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs
|
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs
|
||||||
8f44579de3af7d7d23d55572d1d97cb0690425d4d9dffc30c6bf2ec3ff9c54eb 24553 tests/unraid-deployment.test.mjs
|
3e4a1a6d6a744df9badcfece2cf8d09f8c34efb3c437cb08a6f2e6c9d428c0d4 59353 tests/unraid-deployment.test.mjs
|
||||||
f1f0f13ac41f47c9df8ef778d7b98c09a589f61e6f76e14bc0e8943c5ede7560 14990 tests/update-service.test.mjs
|
05c0ef13fa2fa977174fdf8bdf76a7d606a049a43ad98901079cf5545d54d18d 31426 tests/update-service.test.mjs
|
||||||
9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs
|
9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs
|
||||||
7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs
|
7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs
|
||||||
8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea 767 UPDATE_FROM_0.3.2.md
|
8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea 767 UPDATE_FROM_0.3.2.md
|
||||||
|
|||||||
+5
-3
@@ -1,12 +1,14 @@
|
|||||||
# Start here — ForgeFlow v0.6.0
|
# Start here — ForgeFlow v0.9.0
|
||||||
|
|
||||||
You do **not** need to send anyone your Gitea token, SSH key or server password.
|
You do **not** need to send anyone your Gitea token, SSH key or server password.
|
||||||
All credentials are entered locally in ForgeFlow during setup. Diagnostic logging
|
All credentials are entered locally in ForgeFlow during setup. Diagnostic logging
|
||||||
is designed to exclude them.
|
is designed to exclude them.
|
||||||
|
|
||||||
## Already running an older source release?
|
## Already running an older ForgeFlow release?
|
||||||
|
|
||||||
Publish v0.6.0 with `Publish-ForgeFlow-Release.ps1`, leave the currently installed source folder untouched, and test **Settings → ForgeFlow updates → Check now → Download update → Apply & restart**. Configuration and credentials remain outside the source directory.
|
Run `Publish-ForgeFlow-Release.ps1` from the validated source. It now publishes the source commit and matching Windows installer/portable assets together. Leave the currently installed older folder or executable untouched, then test **Settings → ForgeFlow updates → Check now → Download update → Apply & restart**. Configuration and credentials remain outside the application directory.
|
||||||
|
|
||||||
|
When version 0.9.0 source was already pushed without a Gitea binary release, run `Publish-Missing-Binary-Release.ps1 -ExpectedVersion 0.9.0` once. Afterwards the existing 0.8.9 updater can install 0.9.0 normally.
|
||||||
|
|
||||||
|
|
||||||
## Fast path on Windows
|
## Fast path on Windows
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MCowBQYDK2VwAyEApGKe81NzC5mU3jfMNAQUnAOfQnCnMFry8cNpmjQsdtE=
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
@@ -18,3 +18,17 @@ npm run acceptance -- --execute-rollback
|
|||||||
The first command is read-only. The mutation flags require every read-only
|
The first command is read-only. The mutation flags require every read-only
|
||||||
check to pass, dispatch a controlled exact-SHA workflow with a unique request
|
check to pass, dispatch a controlled exact-SHA workflow with a unique request
|
||||||
ID, and wait for matching status plus a successful health endpoint.
|
ID, and wait for matching status plus a successful health endpoint.
|
||||||
|
|
||||||
|
## Isolated production acceptance
|
||||||
|
|
||||||
|
`npm run acceptance:isolated` provisions disposable bare Git remotes, working
|
||||||
|
trees, server appdata folders, Compose definitions, deployment keys,
|
||||||
|
configuration migrations and release manifests below the operating-system temp
|
||||||
|
directory. It covers clean and portable installs, the 0.10.0 migration path,
|
||||||
|
both authentication modes, exact-SHA server pull and Direct Copy, adoption,
|
||||||
|
external updates, deploy-key lifecycle, host-key changes, unhealthy activation,
|
||||||
|
rollback, network interruption, shutdown recovery, stale plans, corrupt config,
|
||||||
|
release integrity and inventories of more than twenty workloads.
|
||||||
|
|
||||||
|
Every fixture is removed after its test. The suite never discovers or mutates
|
||||||
|
real project folders, configured servers, credentials, containers or releases.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Coverage policy
|
||||||
|
|
||||||
|
ForgeFlow treats coverage as release evidence, not as a target to game. `npm run coverage`
|
||||||
|
enforces 75% statements, 75% lines, 75% functions and 65% branches globally.
|
||||||
|
|
||||||
|
The July 2026 hardening pass raised the measured baseline from 69.74% statements/lines,
|
||||||
|
68.82% functions and 55.38% branches to 81.48% statements/lines, 82.07% functions and
|
||||||
|
65.59% branches. Node/V8 discovered additional branch counters when previously unexecuted
|
||||||
|
functions became covered; the denominator grew from 2,537 to 3,473 while the new tests
|
||||||
|
added hundreds of asserted decisions. No command builders, platform guards or error
|
||||||
|
adapters were excluded to improve the result cosmetically.
|
||||||
|
|
||||||
|
The 65% global gate is paired with scenario-level evidence for the critical
|
||||||
|
boundaries: deploy-key rollback, deployment verification, Gitea authentication and
|
||||||
|
redirects, SSH host identity and output limits, inventory reconciliation, stale plans,
|
||||||
|
configuration recovery, release integrity and updater failure modes. New code must not
|
||||||
|
reduce the global baseline. Future increases must come from additional asserted failure
|
||||||
|
scenarios, not ignore comments or source exclusions.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# ForgeFlow current state
|
||||||
|
|
||||||
|
## Baseline
|
||||||
|
|
||||||
|
- Baseline version: **0.10.0**
|
||||||
|
- Baseline commit: `56efd1a00c2e76251a0b2e7a7a424d94200ae33c`
|
||||||
|
- Baseline branch: `main`
|
||||||
|
- Desktop runtime: Electron 43 with Node.js 22+ required by the source project
|
||||||
|
- Primary supported packaged updater: Windows installer and portable executable
|
||||||
|
|
||||||
|
The baseline was recorded before the 1.0 professionalization programme. It is the comparison point for functional, deployment and renderer regressions.
|
||||||
|
|
||||||
|
## Known baseline evidence
|
||||||
|
|
||||||
|
- `npm run check`: 155 tests, 152 passed, 3 environment-dependent Bash checks skipped, 0 failed.
|
||||||
|
- OS-backed secure storage, encrypted Gitea token, Gitea API, repository and Actions access were available.
|
||||||
|
- Unraid exposed Docker, Compose, Git, tar and SHA-256 tooling.
|
||||||
|
- The server inventory contained active repository workloads plus a large number of historical or unrelated definitions that require backend classification.
|
||||||
|
- Windows release artifacts were checksum-protected but not Authenticode-signed.
|
||||||
|
|
||||||
|
No secret values, passwords, private keys or tokens are stored in this document.
|
||||||
|
|
||||||
|
## Operation classes
|
||||||
|
|
||||||
|
| Class | Default | Examples |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Read-only inspection | Allowed without confirmation | repository refresh, server inventory, deploy-key probe, preflight, audit export |
|
||||||
|
| Reconciliation | Preview required | adopt an exact workload, refresh a profile from server truth |
|
||||||
|
| Configuration mutation | Explicit action and audit record | save profile, rotate deploy key, change server settings |
|
||||||
|
| Deployment | Fresh preflight and confirmation | server pull, Direct Copy, Gitea Actions dispatch |
|
||||||
|
| Destructive maintenance | Recovery evidence and explicit confirmation | unlink, prune, key revocation, rollback |
|
||||||
|
|
||||||
|
Discovery and audit never belong to a mutating class. Ambiguous evidence cannot be promoted automatically.
|
||||||
|
|
||||||
|
## Recovery model
|
||||||
|
|
||||||
|
ForgeFlow writes its configuration atomically. Explicit server reconciliation additionally creates a private recovery snapshot before changing profiles or deployment state. Encrypted user-created `.ffbackup` files remain the portable restore mechanism; recovery snapshots are local operational safeguards and can contain OS-encrypted credential material.
|
||||||
|
|
||||||
|
## Issue priorities
|
||||||
|
|
||||||
|
- **P0:** active data loss, credential disclosure, arbitrary execution or uncontrolled production mutation.
|
||||||
|
- **P1:** release-blocking incorrect deployment, unsafe implicit mutation, broken recovery or material security gap.
|
||||||
|
- **P2:** important functional, accessibility, performance or maintainability defect with a safe workaround.
|
||||||
|
- **P3:** polish, documentation or low-risk improvement.
|
||||||
|
|
||||||
|
## 1.0 constraints
|
||||||
|
|
||||||
|
- No force-push or implicit repository history rewrite.
|
||||||
|
- No automatic deployment deletion.
|
||||||
|
- No desktop Gitea token on a server.
|
||||||
|
- Read-only repository-scoped deploy keys for server pull.
|
||||||
|
- SSH host-key changes fail closed.
|
||||||
|
- Live commit, remote commit and runtime health remain separate evidence.
|
||||||
|
- Packaged updates fail closed on missing or mismatched release assets, SHA-256 evidence and the pinned Ed25519 publisher signature; paid Authenticode remains optional.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Dependency security audit
|
||||||
|
|
||||||
|
Audit date: 2026-07-29
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
- Runtime/production dependency audit: **0 vulnerabilities** (`npm audit --omit=dev`).
|
||||||
|
- Full development toolchain: **19 high advisories**, reduced from 23.
|
||||||
|
- Critical advisories: **0**.
|
||||||
|
|
||||||
|
Playwright was upgraded from 1.55.0 to 1.62.0, removing the browser-download
|
||||||
|
certificate-verification advisory. `c8` was upgraded from 10.1.3 to 12.0.0,
|
||||||
|
removing the vulnerable `test-exclude` chain. Compatible patched
|
||||||
|
`brace-expansion` releases were installed where dependency ranges allowed it.
|
||||||
|
|
||||||
|
## Remaining development-only chain
|
||||||
|
|
||||||
|
All remaining records collapse to one advisory:
|
||||||
|
`GHSA-mh99-v99m-4gvg`, an uncontrolled brace-expansion denial of service. npm
|
||||||
|
reports it through nested `minimatch` versions in two independent toolchains:
|
||||||
|
|
||||||
|
- ESLint 10.8.0 (`@eslint/config-array`, `@eslint/eslintrc`);
|
||||||
|
- electron-builder 26.15.3 (`@electron/asar`, `@electron/universal`, `glob`,
|
||||||
|
`dir-compare`, `ejs`/`jake`, Windows packaging helpers).
|
||||||
|
|
||||||
|
These packages are never loaded by the packaged ForgeFlow runtime. They run in
|
||||||
|
developer or CI processes against repository and build configuration owned by
|
||||||
|
the operator. A malicious repository could still attempt resource exhaustion
|
||||||
|
during linting or packaging, so the finding is not classified as harmless.
|
||||||
|
CI jobs must retain memory/time limits and untrusted pull requests must not run
|
||||||
|
release signing or publishing jobs.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- `npm audit fix --force` is prohibited. npm proposes ESLint 4.0.0 and an older
|
||||||
|
electron-builder; both are breaking downgrades and the tested older builder
|
||||||
|
dependency graph increased the result to 30 high and 1 critical advisory.
|
||||||
|
- No global `minimatch` override is used. Several affected consumers declare
|
||||||
|
older APIs, and forcing a new major could silently break packaging or lint
|
||||||
|
file selection.
|
||||||
|
- Latest stable ESLint and electron-builder versions are pinned exactly. The
|
||||||
|
residual chain will be retested whenever either publishes a dependency fix.
|
||||||
|
|
||||||
|
The release gate treats `npm audit --omit=dev --audit-level=high` as blocking.
|
||||||
|
The complete development audit remains documented and visible rather than
|
||||||
|
being misrepresented as a production vulnerability count.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# ForgeFlow error and recovery catalog
|
||||||
|
|
||||||
|
| Code | Meaning | Recovery |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `RECONCILIATION_PLAN_REQUIRED` | A server mutation was requested without its reviewed plan. | Open Review reconciliation and apply the current plan ID. |
|
||||||
|
| `RECONCILIATION_PLAN_STALE` | Server truth changed after preview. | Rescan, review the new impact and apply that plan. |
|
||||||
|
| `SERVER_GIT_VERIFICATION_FAILED` | Branch, deploy key or pinned SSH evidence could not be proven. | Run Verify server pull; repair only the failing check before retrying. |
|
||||||
|
| `DEPLOY_KEY_NOT_READ_ONLY` | A matching key can write to Gitea. | Revoke it in Gitea and configure a dedicated read-only key. |
|
||||||
|
| `SSH_DEPLOYMENT_PREFLIGHT_FAILED` | One or more deployment safety checks failed. | Open preflight evidence and follow the failing check's detail. |
|
||||||
|
| `REMOTE_WRITE_ACCESS_REQUIRED` | The SSH user cannot safely write the managed source/state paths. | Use Check / fix write access after reviewing its scoped impact. |
|
||||||
|
| `UPDATE_ORIGIN_MISMATCH` | An update asset points outside the trusted Gitea origin. | Correct release asset URLs; never bypass the origin check. |
|
||||||
|
| `UPDATE_CHECKSUM_MISMATCH` | Downloaded bytes do not match the published checksum. | Keep the current version and republish the exact commit atomically. |
|
||||||
|
|
||||||
|
Audit and discovery never repair these conditions automatically. Mutating recovery
|
||||||
|
actions require an explicit user flow and preserve rollback or snapshot evidence.
|
||||||
@@ -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.
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Mutation and reconciliation model
|
||||||
|
|
||||||
|
ForgeFlow separates observation from state changes at the API boundary.
|
||||||
|
|
||||||
|
## Discovery
|
||||||
|
|
||||||
|
`scanServerInventory()` and `discoverServerWorkloads()` collect Docker, Compose, DockerMan and Git evidence. They may write diagnostic logs, but they do not save, update or delete deployment profiles and do not change containers.
|
||||||
|
|
||||||
|
## Reconciliation planning
|
||||||
|
|
||||||
|
`planServerInventoryReconciliation()` returns a content-addressed plan containing:
|
||||||
|
|
||||||
|
- exact links that may be added;
|
||||||
|
- existing profiles whose observed metadata may be refreshed;
|
||||||
|
- stale profiles that require review;
|
||||||
|
- ambiguous workloads that block automatic application.
|
||||||
|
|
||||||
|
The plan identifier changes whenever its proposed scope changes.
|
||||||
|
|
||||||
|
## Reconciliation application
|
||||||
|
|
||||||
|
`reconcileServerInventory()` requires the exact reviewed plan identifier. It rescans the server and refuses a stale plan. Before writing configuration it creates a private recovery snapshot. Stale profiles are reported but never removed automatically.
|
||||||
|
|
||||||
|
## Direct mutations
|
||||||
|
|
||||||
|
Manual linking, unlinking, deploy-key rotation, deployment and rollback remain separate explicit commands. Each must append an audit event with repository, profile, operation identifier and result. Destructive commands need a dedicated confirmation flow and recovery path.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# ForgeFlow 1.0 production-readiness evidence
|
||||||
|
|
||||||
|
## 1. Scope and history
|
||||||
|
|
||||||
|
The professionalization work started from `64ca267` on `main`. It preserves the
|
||||||
|
0.10.0 compatibility baseline and deliberately creates no 1.0 tag or public
|
||||||
|
release. The commits and their exact SHAs remain the authoritative audit trail.
|
||||||
|
|
||||||
|
## 2. Deployment safety
|
||||||
|
|
||||||
|
Server pull uses repository-scoped read-only deploy keys, exact commit SHAs,
|
||||||
|
pinned SSH/Gitea host identities, Compose validation, health evidence and bounded
|
||||||
|
rollback. Rotation is transactional and revocation requires reviewed impact and
|
||||||
|
recovery evidence. Direct Copy and monitor-only remain explicit alternatives.
|
||||||
|
|
||||||
|
## 3. Inventory and reconciliation
|
||||||
|
|
||||||
|
Canonical deployment identity combines repository, branch, server, environment,
|
||||||
|
Compose project/root, runtime labels, container, live SHA and profile. Duplicate,
|
||||||
|
stale, ambiguous, orphan and historical evidence has persistent content-addressed
|
||||||
|
review decisions. Discovery never deletes, stops or rewrites a workload.
|
||||||
|
|
||||||
|
## 4. Architecture
|
||||||
|
|
||||||
|
Renderer, IPC and Unraid responsibilities are split by domain. The generated
|
||||||
|
architecture audit currently reports zero source files above 750 or 1,000 lines.
|
||||||
|
Runtime schemas, bounded IPC capabilities, operation IDs and explicit error
|
||||||
|
contracts protect the process boundary.
|
||||||
|
|
||||||
|
## 5. Repository assurance
|
||||||
|
|
||||||
|
Git Validator 2.0 covers security, reproducibility, governance, collaboration,
|
||||||
|
performance/hygiene and release readiness. Minimal, Standard, Strict, Production
|
||||||
|
and custom policies support accountable expiring suppressions, trend history and
|
||||||
|
reviewable JSON/Markdown/HTML reports. Repairs always require preview and never
|
||||||
|
commit or push automatically.
|
||||||
|
|
||||||
|
## 6. Automated verification
|
||||||
|
|
||||||
|
The Node suite includes real temporary Git remotes and an isolated production
|
||||||
|
acceptance harness. Playwright adds 36 renderer cases across six viewport/theme/
|
||||||
|
motion/scaling projects. Failure artifacts contain screenshots, traces, video,
|
||||||
|
console events, DOM, fixture details and test identity.
|
||||||
|
|
||||||
|
## 7. Coverage and dependencies
|
||||||
|
|
||||||
|
Coverage increased from 69.74% statements/lines, 68.82% functions and 55.38%
|
||||||
|
branches to 81.48%, 82.07% and 65.59%, respectively. The enforced gates are now
|
||||||
|
75/75/75/65 and are documented in `COVERAGE_POLICY.md`. Production dependencies have zero known
|
||||||
|
audit vulnerabilities. Remaining development findings belong to current upstream
|
||||||
|
ESLint/electron-builder toolchains and are assessed in `DEPENDENCY_AUDIT.md`.
|
||||||
|
|
||||||
|
## 8. UX and accessibility
|
||||||
|
|
||||||
|
Dark and light themes use the same semantic hierarchy, restrained project-signal
|
||||||
|
motion and status text that never depends on color alone. Deployment cards expose
|
||||||
|
container, repository, environment, commit parity and health distinctly. Dense
|
||||||
|
inventories, long names, keyboard focus, dialogs, reduced motion and high scaling
|
||||||
|
are part of the automated matrix.
|
||||||
|
|
||||||
|
## 9. Packaging and updating
|
||||||
|
|
||||||
|
Windows installer and portable packaging use deterministic names; old `dist`
|
||||||
|
versions are pruned after every successful build. Publication stays draft until
|
||||||
|
installer, portable, checksums, provenance and CycloneDX SBOM are complete. Binary
|
||||||
|
updates verify the exact release asset, executable format and published SHA-256
|
||||||
|
before download staging and again before replacement. Authenticode is optional and
|
||||||
|
is not a release or updater dependency for this personal/internal application.
|
||||||
|
|
||||||
|
## 10. Release decision
|
||||||
|
|
||||||
|
No open P0 or P1 technical issue is known after the final quality, browser,
|
||||||
|
acceptance, signing and packaging gates. The technically correct status is:
|
||||||
|
|
||||||
|
`TECHNICALLY_COMPLETE`
|
||||||
|
|
||||||
|
There is no paid certificate or external signing-service dependency. Windows may
|
||||||
|
show its normal unknown-publisher warning during first installation.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# ForgeFlow 0.10.0
|
||||||
|
|
||||||
|
ForgeFlow 0.10.0 makes existing Unraid workloads substantially easier and safer to adopt, verify and deploy.
|
||||||
|
|
||||||
|
## Deployment discovery and verification
|
||||||
|
|
||||||
|
- Automatic discovery links unique running server workloads to their matching Gitea repositories while excluding unrelated infrastructure and stale release folders.
|
||||||
|
- Deployment cards preserve the last verified live commit and compare it with the current Gitea branch, even when a container was updated outside ForgeFlow.
|
||||||
|
- Existing Compose project names, files, services, remote folders and DockerMan metadata are adopted from server truth instead of guessed or overwritten.
|
||||||
|
|
||||||
|
## Safe Server pull
|
||||||
|
|
||||||
|
- Server pull is now the recommended deployment route and fetches the exact requested commit from Gitea.
|
||||||
|
- Each repository receives its own repository-scoped read-only deploy key; ForgeFlow never installs the desktop Gitea token on Unraid.
|
||||||
|
- Gitea SSH host fingerprints are pinned and checked before trust is changed and before every pull.
|
||||||
|
- Fetch, archive, checksum, Compose validation, activation and rollback remain exact-commit and transactional.
|
||||||
|
- Direct copy remains available when server-side Git access is undesirable, and monitoring-only links cannot deploy accidentally.
|
||||||
|
|
||||||
|
## Git hygiene
|
||||||
|
|
||||||
|
- Git Validator now also checks `.gitattributes`, `.editorconfig`, dependency lockfiles and a Gitea Actions workflow.
|
||||||
|
- Safe repairs create reviewable files without committing or pushing them automatically.
|
||||||
|
- Existing identity, upstream, synchronization, branch-protection, documentation, secret-path and oversized-file checks remain available in one scored view.
|
||||||
|
|
||||||
|
## Interface and reliability
|
||||||
|
|
||||||
|
- The deployment workspace now emphasizes repository, container, environment and live-versus-Gitea evidence, with a focused animated project illustration and clearer server inventory.
|
||||||
|
- Large unrelated server inventories are summarized instead of producing dozens of indistinguishable cards.
|
||||||
|
- Connection validation consistently opens the same encrypted Electron profile as the installed app.
|
||||||
|
- Obsolete ForgeFlow artifacts are removed from `dist` after every successful packaged build.
|
||||||
|
|
||||||
|
No container was restarted or replaced during automatic discovery. Deployments still require a successful preflight and an explicit user action.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# ForgeFlow 0.10.1
|
||||||
|
|
||||||
|
## Reliable certificate-free updates
|
||||||
|
|
||||||
|
- Windows installer and portable releases are supported without paid signing services.
|
||||||
|
- Packaged updates remain protected by exact Gitea release assets, PE validation and SHA-256 verification before staging and immediately before replacement.
|
||||||
|
- Old ForgeFlow versions are pruned from `dist` after each successful build.
|
||||||
|
|
||||||
|
## Deployment inventory correctness
|
||||||
|
|
||||||
|
- Repository matching is case-insensitive, so `Jens/Repo` and `jens/repo` refresh the same deployment profile.
|
||||||
|
- Running repository workloads are linked conservatively; third-party DockerMan applications remain visible as external monitoring-only workloads instead of generating hundreds of false repository problems.
|
||||||
|
- Historical and stopped duplicate definitions no longer require repetitive manual review.
|
||||||
|
- Shadowed automatic profiles are retired only after a recovery snapshot and a stable reviewed reconciliation plan.
|
||||||
|
- Live runtime, container health and commit evidence are refreshed before readiness is reported.
|
||||||
|
|
||||||
|
## Server pull verification
|
||||||
|
|
||||||
|
- Existing running repository workloads can receive repository-scoped read-only deploy keys without changing containers.
|
||||||
|
- The audit command supports compact inventory, reconciliation and access evidence for operational verification.
|
||||||
|
- Release acceptance no longer assumes an external Authenticode certificate while retaining checksum, provenance and SBOM checks.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# ForgeFlow 0.10.10
|
||||||
|
|
||||||
|
## Complete server-pull deployment repair
|
||||||
|
|
||||||
|
- Missing repository-scoped read-only deploy keys can be provisioned and verified from Unraid against the exact Gitea branch.
|
||||||
|
- A repository deployment root can now remain above its Compose working directory without breaking workload recognition or being overwritten by inventory refresh.
|
||||||
|
- Nested Compose files are preserved as repository-relative deployment paths, including Ludarium, Launchpad and ITWorx MCP Hub layouts.
|
||||||
|
- The **Fix write access** action now receives its permission-report parser correctly instead of reporting a false write-access failure.
|
||||||
|
- Server-pull preflight proves every required deployment file at the exact Gitea commit before any container activation starts.
|
||||||
|
- Runtime secrets remain in server-side `.env` files and preserved appdata paths; no secret values are written to Git or diagnostic output.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# ForgeFlow 0.10.11
|
||||||
|
|
||||||
|
## Resilient repository refresh and consistent server pull
|
||||||
|
|
||||||
|
- Temporary Gitea list failures now use the in-session **last-known-good** repository inventory while local and server state continue to refresh. The UI clearly reports that remote data is stale.
|
||||||
|
- A **closed output pipe** from a detached parent process is no longer treated as a fatal desktop-app exception.
|
||||||
|
- Server pull, deploy-key verification, deployment, rollback and metadata now consistently prefer the verified **linked checkout origin** over a stale URL detected earlier on the server.
|
||||||
|
- Repository-scoped **read-only deploy key** checks remain fail-closed; a changed SSH host still requires explicit trust and access reconfiguration.
|
||||||
|
- The local **browser test server** now has a dedicated port and identity endpoint, preventing another localhost application from being mistaken for ForgeFlow.
|
||||||
|
- All 42 responsive browser flows pass across dark/light, compact/wide and reduced-motion configurations.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# ForgeFlow 0.10.12
|
||||||
|
|
||||||
|
## Faster awareness with stricter deployment truth
|
||||||
|
|
||||||
|
- Repository refreshes are **coalesced** and briefly cache Gitea inventory and workspace discovery; a manual refresh remains fully forced and file changes arriving mid-refresh receive one trailing refresh.
|
||||||
|
- Server discovery reuses its Docker and Compose evidence for existing deployment profiles instead of opening a separate SSH session for every linked workload.
|
||||||
|
- Deployment status only claims **exact Gitea commit parity** after comparing a concrete branch SHA with the live server SHA; matching repository provenance alone is no longer sufficient.
|
||||||
|
- Container discovery uses **batched Docker inspect** with a safe per-container fallback when a container disappears during the scan.
|
||||||
|
- Active Gitea and SSH deployment polling uses **bounded worker pools**, improving multi-deployment latency without flooding external services.
|
||||||
|
- A **stopped container** can no longer be marked healthy because another process answers on its previous healthcheck port.
|
||||||
|
- Large repository and server-inventory lists use offscreen rendering containment to reduce layout and paint work.
|
||||||
|
- Inventory diagnostics now include scan and state-refresh durations, and Gitea bulk verification fails fast after a confirmed connectivity outage.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# ForgeFlow 0.10.13
|
||||||
|
|
||||||
|
## Veilige synchronisatie en aantoonbare release-integriteit
|
||||||
|
|
||||||
|
- **Gitea workspace sync** toont eerst de exacte additions, wijzigingen en deletions ten opzichte van de actuele upstream-SHA. Lokale commits worden beschermd in een recovery branch; staged, unstaged en untracked werk gaat naar een stash. Genegeerde runtimebestanden blijven onaangeroerd.
|
||||||
|
- Read-only achtergrondfetch houdt `ahead` en `behind` actueel zonder projectbestanden automatisch te wijzigen. Interval `0` schakelt netwerkfetch volledig uit.
|
||||||
|
- Stale deployment links blokkeren niet langer de automatische, bewijsgebaseerde koppeling van de werkelijk draaiende vervangende workload.
|
||||||
|
- SSH-hostidentiteit wordt vóór het verzenden van credentials getoond en bij bevestiging exact vastgepind. Gitea-tokens vereisen HTTPS, behalve bij expliciete loopbackontwikkeling.
|
||||||
|
- Packaged updates vereisen een **Ed25519-signed release manifest** dat versie, tag, broncommit, artifactnaam, bytegrootte en SHA-256 bindt aan de ingebouwde publisher key. Hiervoor is geen betaald certificaat of Azure-dienst nodig.
|
||||||
|
- Diagnostische bundels exporteren geen ruwe remote output meer. Untracked diffs kunnen geen junction of symlink buiten de repository volgen en zijn begrensd op bestandsgrootte.
|
||||||
|
- De Git-toolsgrid behoudt nu de volledige inhoudshoogte binnen zijn eigen scrollvlak; workspace sync en troubleshooting overlappen niet meer. De demo bridge ondersteunt dezelfde recoveryflow als de desktopapp.
|
||||||
|
- Repositorymonitoring, deploymentpolling, Docker-inspect en SSH-verbindingen gebruiken begrensde paralleliteit en hergebruik waar dat veilig is.
|
||||||
|
|
||||||
|
## Verificatie
|
||||||
|
|
||||||
|
- Volledige Node-testset, coveragepoort, architectuuraudit en dependency-audit.
|
||||||
|
- 72 browserflows over dark/light, compact/desktop/wide, 100–150% schaal en reduced motion.
|
||||||
|
- Windows installer en portable build, SHA-256-sidecars, provenance, CycloneDX-SBOM en ondertekend releasemanifest.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# ForgeFlow 0.10.14
|
||||||
|
|
||||||
|
## Betere uitleg en een stabiele repositorywerkruimte
|
||||||
|
|
||||||
|
- Een nieuw doorzoekbaar **Help center** legt de belangrijkste workflows stap voor stap uit: eerste configuratie, changes en commits, Gitea workspace sync, deploymentdetectie, exacte serverdeployments, deploykeys, Git Validator, updates en diagnose.
|
||||||
|
- Contextuele help vanuit **Gitea workspace sync** opent onmiddellijk de relevante uitleg. De instructies maken expliciet wat ForgeFlow wijzigt, welke recovery ForgeFlow vooraf maakt en welke genegeerde runtimebestanden onaangeroerd blijven.
|
||||||
|
- De variabele **repository context** is samengebracht in één structurele zone. Quick actions, Local → Gitea → Server-status en gekoppelde deployments kunnen daardoor niet langer over de tabnavigatie of inhoud heen schuiven.
|
||||||
|
- Smalle werkruimtes gebruiken gecontroleerde **horizontal tab navigation**. Elke tab behoudt zijn volledige label en blijft bereikbaar zonder dat tekst door andere bedieningselementen loopt.
|
||||||
|
- Het Help center heeft een eigen premium, responsieve presentatie met categorieën, zoekresultaten, uitklapbare stappen, veiligheidsnotities en motion-safe projectillustratie.
|
||||||
|
|
||||||
|
## Verificatie
|
||||||
|
|
||||||
|
- Volledige Node-testset en statische renderercontroles.
|
||||||
|
- **84 browser flows** over dark/light, compact/desktop/wide, 100–150% schaal en reduced motion; de drie tijdens een semantische testaanpassing geraakte flows zijn daarna opnieuw groen uitgevoerd.
|
||||||
|
- Extra layoutasserties bewijzen dat repository context, tabs en tabinhoud elkaar niet overlappen bij 1024 × 768.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# ForgeFlow 0.10.2
|
||||||
|
|
||||||
|
## Packaged updater origin repair
|
||||||
|
|
||||||
|
- Gitea release assets that expose an internal HTTP `ROOT_URL` are safely rewritten to ForgeFlow's configured public HTTPS Gitea origin.
|
||||||
|
- Authentication remains same-origin: the Gitea token is never forwarded to an internal address, CDN or unrelated redirect target.
|
||||||
|
- Published Windows executables are still validated as PE files and against their release SHA-256 sidecars before staging.
|
||||||
|
- The live authenticated updater acceptance downloads the exact published installer and proves its byte count and SHA-256 digest.
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# ForgeFlow 0.10.3
|
||||||
|
|
||||||
|
## Responsive large workspaces
|
||||||
|
|
||||||
|
- Repository monitoring checks up to four local working trees concurrently while retaining overlap protection and per-repository pause controls.
|
||||||
|
- Global search, repository filtering and the command palette debounce full interface renders during rapid typing.
|
||||||
|
- Commit-message input updates readiness and action controls in place, preserving focus and cursor responsiveness.
|
||||||
|
- Interactive project illustrations and diff atmosphere effects perform at most one layout update per animation frame.
|
||||||
|
|
||||||
|
## Git Validator reliability
|
||||||
|
|
||||||
|
- Git Validator and every long repository tab now retain an explicit vertical scroll owner across compact, desktop and wide layouts.
|
||||||
|
- Standard, Strict and Production policies correctly treat configured warning severities as blockers; Minimal remains error-only.
|
||||||
|
- Documented suppressions no longer reduce the hygiene score or remain counted as active blockers.
|
||||||
|
- Repair requests are rescanned immediately before preview or execution, preventing stale or forged fixes.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Full quality gate, coverage thresholds and all responsive browser scenarios pass for this release.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# ForgeFlow 0.10.4
|
||||||
|
|
||||||
|
## Permanent packaged updater handshake repair
|
||||||
|
|
||||||
|
- Binary and source update helpers now use a Windows PowerShell 5.1-compatible atomic status replacement with a real temporary backup path.
|
||||||
|
- A deterministic overwrite fallback preserves lifecycle reporting on filesystems that do not implement atomic replacement.
|
||||||
|
- The binary helper exposes a side-effect-free handshake-only verification mode exercised by the real Windows PowerShell executable during tests.
|
||||||
|
- existing installations with the defective helper require this one-time installer upgrade; every subsequent packaged update uses the repaired helper automatically.
|
||||||
|
- Startup failures retain request-scoped status and helper-log evidence instead of collapsing into an unexplained exit-code message.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# ForgeFlow 0.10.5
|
||||||
|
|
||||||
|
## Consistent repository and deployment links
|
||||||
|
|
||||||
|
- Every repository workspace now shows all configured deployment environments in a compact, directly actionable strip.
|
||||||
|
- The repository deployment tab includes every detected server workload linked to that repository, including its container, Compose identity, server and runtime state.
|
||||||
|
- A workload is only labelled linked when its repository and resolved profile both exist in the current ForgeFlow configuration.
|
||||||
|
- Stale or incomplete metadata is shown as **Link unresolved** and routed through explicit reconciliation instead of being presented as a healthy deployment.
|
||||||
|
- The global deployment inventory links directly to the correct repository deployment profile.
|
||||||
|
- Responsive browser coverage now verifies valid links, unresolved links, repository navigation and scrolling across dark/light and scaled layouts.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# ForgeFlow 0.10.6
|
||||||
|
|
||||||
|
## Permanent Windows updater launch repair
|
||||||
|
|
||||||
|
- ForgeFlow no longer launches hidden PowerShell update helpers with Node's defective Windows `detached` process mode.
|
||||||
|
- Binary and source updater processes remain hidden, are explicitly unreferenced after their verified handshake, and continue independently when ForgeFlow closes.
|
||||||
|
- A real Windows regression test now exercises the exact production Node spawn options instead of using a different process API.
|
||||||
|
- Startup is still fail-closed: ForgeFlow remains open unless the request-scoped helper status reaches `started`.
|
||||||
|
- Versions 0.10.4 and 0.10.5 need a one-time direct installation of 0.10.6 because their installed launcher cannot execute its own helper; updates after 0.10.6 use the repaired path.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# ForgeFlow 0.10.7
|
||||||
|
|
||||||
|
## Reliable server-to-repository recognition
|
||||||
|
|
||||||
|
- Live, running workloads with one unique exact provenance or runtime-identity match are now linked automatically during normal server discovery.
|
||||||
|
- Automatic adoption creates only ForgeFlow configuration and observed state; it performs no container changes and never automatically removes stale profiles.
|
||||||
|
- Ambiguous, duplicate, external and monitoring-only workloads remain behind explicit **Review & link** confirmation.
|
||||||
|
- Every linked repository now displays an `S` deployment badge with its profile count in the repository sidebar.
|
||||||
|
- The repository release rail reports **Linked** with container and server identity even when a legacy workload has no verifiable live commit yet.
|
||||||
|
- DevRunbook-style DockerMan deployments therefore show the same linked relationship in Deployments, the repository sidebar and the repository workspace.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# ForgeFlow 0.10.8
|
||||||
|
|
||||||
|
## Self-contained checksum verification
|
||||||
|
|
||||||
|
- Binary and source update helpers no longer depend on the optional PowerShell `Get-FileHash` cmdlet.
|
||||||
|
- Both helpers calculate checksums directly with the built-in .NET SHA-256 implementation.
|
||||||
|
- A real Windows regression test clears `PSModulePath` and verifies the downloaded binary successfully in that minimal environment.
|
||||||
|
- The helper still validates the exact published checksum before waiting for ForgeFlow to exit or changing installed files.
|
||||||
|
- This release retains the reliable non-detached launcher and server-to-repository recognition improvements from 0.10.6 and 0.10.7.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# ForgeFlow 0.10.9
|
||||||
|
|
||||||
|
## Reliable deployment inventory and preflight
|
||||||
|
|
||||||
|
- Unraid inventory now includes containers without healthchecks. Docker's complete JSON state is parsed safely instead of using a failing Go-template lookup.
|
||||||
|
- ForgeFlow is single-instance: opening it again focuses the existing window, preventing concurrent inventory scans and configuration writes.
|
||||||
|
- Server pull verifies required Compose files or the Dockerfile at the exact Gitea commit before any deployment operation starts.
|
||||||
|
- Server-pull verification now separates deploy-ready access from optional live-SHA and runtime-health evidence. A recoverable workload is no longer shown as blocked merely because parity is not yet provable.
|
||||||
|
- Deployment cards and audit output show concrete access blockers and non-blocking warnings instead of a generic incomplete result.
|
||||||
|
- All discovery, verification and preflight checks remain non-destructive; no containers are changed during these checks.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# ForgeFlow 0.8.8
|
||||||
|
|
||||||
|
ForgeFlow 0.8.8 fixes the HTTP 404 returned while downloading packaged updates
|
||||||
|
from the configured Gitea server.
|
||||||
|
|
||||||
|
The server's API requires release attachments to be addressed using both the
|
||||||
|
immutable release ID and attachment ID. ForgeFlow now uses that exact
|
||||||
|
release-scoped endpoint for the executable and its checksum file.
|
||||||
|
|
||||||
|
Strict same-origin token protection and SHA-256 verification remain unchanged.
|
||||||
|
Install 0.8.8 manually when upgrading from 0.8.7 because the affected download
|
||||||
|
code runs before the corrected updater can be installed. Future packaged
|
||||||
|
updates can again be completed from inside ForgeFlow.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# ForgeFlow 0.8.9
|
||||||
|
|
||||||
|
ForgeFlow 0.8.9 introduces Git Validator, a dedicated repository assurance
|
||||||
|
workspace that checks whether practical Git and Gitea best practices are being
|
||||||
|
followed.
|
||||||
|
|
||||||
|
The validator produces a weighted score with evidence for repository identity,
|
||||||
|
upstream tracking, working-tree state, effective commit identity, safe local
|
||||||
|
synchronization defaults, default-branch and force-push protection, README and
|
||||||
|
gitignore hygiene, tracked secret-shaped filenames and oversized tracked files.
|
||||||
|
|
||||||
|
Every repair is deliberately bounded. Origin alignment and repository-local
|
||||||
|
fetch/pull/autostash safeguards can be applied as safe fixes. Creating default
|
||||||
|
branch protection or a recommended `.gitignore` requires explicit confirmation.
|
||||||
|
The generated `.gitignore` remains uncommitted for review, and secret/history
|
||||||
|
findings are never modified automatically.
|
||||||
|
|
||||||
|
The new workspace includes grouped findings, an assurance score, safe-fix
|
||||||
|
batching, audit events, interactive project illustration and responsive premium
|
||||||
|
layouts for light and dark themes.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# ForgeFlow 0.9.0
|
||||||
|
|
||||||
|
ForgeFlow 0.9.0 changes Unraid deployments from a Git-checkout-first workflow into a server-inventory-first workflow.
|
||||||
|
|
||||||
|
## Existing installations are now visible
|
||||||
|
|
||||||
|
Server Inventory collects a safe, selected subset of Docker, Compose and DockerMan metadata for both running and stopped containers. It recognizes Compose project names, working directories, active Compose files and services, mounts, ports, runtime state, image provenance and existing DockerMan templates. Environment values and other container secrets are not collected.
|
||||||
|
|
||||||
|
Exact repository provenance may be linked automatically. Name similarity is never treated as proof: uncertain workloads remain visible as suggestions and can be linked through the new manual wizard. The saved link preserves the workload identity rather than depending on the disposable container ID.
|
||||||
|
|
||||||
|
## Push bundle is the new default
|
||||||
|
|
||||||
|
New SSH/Unraid profiles use **Push bundle**. ForgeFlow creates a tar archive from the exact local Git commit, calculates its SHA-256 digest and uploads it over the already trusted desktop-to-Unraid SSH connection. Unraid therefore does not need a Git client, Gitea host-key entry or Gitea private key for this mode.
|
||||||
|
|
||||||
|
The server verifies the checksum and archive paths, rejects symlink payloads, preserves configured runtime paths, updates only ForgeFlow-managed files and validates the merged Compose model before starting services. The active SHA and managed-file manifest are promoted atomically only after the expected services are running. Failure restoration keeps the previous deployment truth and restores overwritten files and Compose metadata.
|
||||||
|
|
||||||
|
**Server-side Git** remains available as an explicit mode. Its Gitea access check is now reported separately from desktop SSH and Docker/Compose capabilities. **Monitor only** links an existing workload without granting ForgeFlow permission to deploy it.
|
||||||
|
|
||||||
|
## Safer adoption
|
||||||
|
|
||||||
|
Adopted workloads retain their existing Compose project, Compose files and service set. ForgeFlow no longer overrides their image or container name in the metadata overlay. Existing DockerMan templates are left untouched; generated templates are managed only for explicitly generated Compose profiles. `--force-recreate` and `--remove-orphans` are opt-in rather than defaults.
|
||||||
|
|
||||||
|
A deployment lock records the live shell process, and an old lock is removed only when it is sufficiently old and its owner no longer runs. Deployment output truncation now fails explicitly instead of allowing ForgeFlow to interpret an incomplete inventory or command result.
|
||||||
|
## Release publication correction
|
||||||
|
|
||||||
|
- The standard release publisher now publishes the validated source and matching Windows binary assets as one workflow.
|
||||||
|
- Added `Publish-Missing-Binary-Release.ps1` to repair a source-only Gitea release without reinstalling ForgeFlow manually.
|
||||||
|
- Binary publication now derives the repository owner, repository name and branch from ForgeFlow settings instead of hardcoding them.
|
||||||
|
- Missing-release errors now explain that packaged installations require both Windows executables and their SHA-256 sidecars.
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# ForgeFlow 0.9.1
|
||||||
|
|
||||||
|
## Gitea binary updater repair
|
||||||
|
|
||||||
|
- Downloads the actual release attachment through `browser_download_url` instead of treating the Gitea attachment metadata response as an executable.
|
||||||
|
- Keeps the Gitea token on the configured Gitea origin and follows HTTPS object-storage redirects without leaking credentials.
|
||||||
|
- Adds regression coverage for attachment metadata lookup, direct browser download URLs and cross-origin redirect safety.
|
||||||
|
- Improves diagnostics when an older updater receives JSON attachment metadata.
|
||||||
|
|
||||||
|
Because ForgeFlow 0.8.9 and 0.9.0 contain the broken attachment endpoint, upgrading to 0.9.1 requires one manual installer run. in-app updates work normally again after 0.9.1 is installed.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# ForgeFlow 0.9.2
|
||||||
|
|
||||||
|
## Emergency deployment and discovery repair
|
||||||
|
|
||||||
|
- Existing deployment profiles without an explicit mode migrate to **Push bundle**, never Server-side Git.
|
||||||
|
- Push bundle deploys the clean, committed local HEAD directly over SSH/SFTP and never requires a Gitea key, upstream, remote sync, or Git on Unraid.
|
||||||
|
- Desktop-to-Unraid authentication can use the server password; a failed key opens a password recovery flow instead of blocking deployment.
|
||||||
|
- Server inventory scans `docker ps -a`, complete Docker inspect data, Compose projects, and every DockerMan user template, including stopped and template-only workloads.
|
||||||
|
- Inventory connection failures are shown as failures rather than misleading zero counts.
|
||||||
|
- Existing Compose and DockerMan workloads can be linked manually without restarting or modifying them.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# ForgeFlow 0.9.3
|
||||||
|
|
||||||
|
This release removes server-side repository authentication from every SSH/Unraid deployment path and makes server Compose files the primary discovery source.
|
||||||
|
|
||||||
|
## Direct deployment only
|
||||||
|
|
||||||
|
- Every existing SSH/Unraid deployment profile is migrated to **Direct copy**, except profiles explicitly marked **Monitor only**.
|
||||||
|
- Deployment and rollback archive the exact committed local HEAD, upload it over the already configured desktop-to-Unraid connection and run Docker Compose on Unraid.
|
||||||
|
- Preflight contains no Unraid-to-repository access probe, no remote `git ls-remote`, no repository-key validation and no Git requirement on Unraid.
|
||||||
|
- Password authentication for the desktop-to-Unraid connection remains supported and is independent of repository access.
|
||||||
|
|
||||||
|
## Compose-file inventory and automatic linking
|
||||||
|
|
||||||
|
- Server Inventory scans Compose YAML files such as `compose.yml`, `compose.yaml`, `docker-compose.yml`, `docker-compose.yaml`, overrides and stack YAML files below the configured appdata roots.
|
||||||
|
- YAML discovery still runs when Docker inspection fails or Docker is unavailable, so a container-query problem no longer produces a false empty inventory.
|
||||||
|
- ForgeFlow reads the Compose project name, file set, service names and image names from the server.
|
||||||
|
- A unique high-confidence repository match based on both Compose folder and project identity is linked automatically.
|
||||||
|
- Remaining strong matches use a one-click link action with server folder, Compose project, Compose files, services, container identity and preservation paths already filled in.
|
||||||
|
- Runtime containers, stopped containers and DockerMan templates are merged with the YAML definition when available. `/mnt/user/appdata`, `/mnt/cache/appdata` and disk-backed appdata paths are treated as the same logical deployment location.
|
||||||
|
|
||||||
|
## Reliability
|
||||||
|
|
||||||
|
- The inventory shell script is safe under `set -euo pipefail`; Docker or Compose command failures are captured as warnings instead of aborting the scan.
|
||||||
|
- Large runtime, cache, log and database folders are pruned while searching for Compose files.
|
||||||
|
- A failed inventory operation remains visible as an error and is never presented as zero workloads.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# ForgeFlow 0.9.4
|
||||||
|
|
||||||
|
ForgeFlow now treats the real Compose files discovered on Unraid as the only activation source for adopted workloads. A generated labels fragment is no longer merged into an imported project, so stale service hints such as `geointel` cannot create a phantom service without an image or build context.
|
||||||
|
|
||||||
|
Direct copy redeployments always use `--force-recreate`. The runtime service list comes from `docker compose config --services`, not from manually stored service names. Before activation ForgeFlow records the existing container ID for every service; after activation it verifies that each service is running, not unhealthy, and uses a different container ID.
|
||||||
|
|
||||||
|
ForgeFlow also rejects the deployment when Compose starts a duplicate workload while leaving the previous container running. The new SHA is written only after these checks pass. Existing DockerMan templates and the real Compose files remain untouched.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# ForgeFlow 0.9.5
|
||||||
|
|
||||||
|
ForgeFlow 0.9.5 makes Direct copy deployments fail closed and adds an in-app repair for Unraid write permissions.
|
||||||
|
|
||||||
|
Before a bundle is created or uploaded, ForgeFlow probes the linked project folder, `.forgeflow` upload/state folders and every active Compose file using the configured SSH identity. A failed check names the exact path, user, owner, group and mode. Deployment stops before file transfer and before any Docker or Compose command changes the runtime. The same write-access check runs again immediately before upload to prevent a stale preflight result.
|
||||||
|
|
||||||
|
Every SSH / Unraid deployment card now includes **Check / fix write access**. The same action appears beside a blocking preflight result. It normalizes the linked source tree and ForgeFlow state folders to safe shared access, uses the Unraid `users` group where available and preserves existing executable bits. Configured runtime locations such as `.env`, `appdata`, `data`, `config`, `logs`, mounted data paths and common generated dependency folders are excluded. It never runs Docker, stops a container, removes a container or uses `chmod 777`. The action also runs when the SSH account is root so manual SMB/file-copy access can be repaired, not only ForgeFlow's own write access.
|
||||||
|
|
||||||
|
Direct copy now validates candidate Compose configuration and builds candidate images before replacing live source files. It never implicitly executes `docker compose down`, `--remove-orphans` or `--force-recreate`. Existing container IDs, image IDs and source files are captured first. When activation fails, ForgeFlow restores the prior source, retags the previous images, attempts to restore the previous runtime and retains the backup evidence. A release is only promoted after the exact linked Compose services are running and verified.
|
||||||
|
|
||||||
|
This release does not claim live validation against a specific private Unraid server. The automated suite validates generated Bash syntax, permission-report parsing, scoped repair commands, no server-to-Gitea authentication, no destructive Compose flags and transactional deployment ordering.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Releasing ForgeFlow
|
||||||
|
|
||||||
|
ForgeFlow releases are built only from a clean, reviewed commit on Node 22 LTS.
|
||||||
|
|
||||||
|
## Quality gate
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm ci
|
||||||
|
npm run quality
|
||||||
|
npm audit --omit=dev --audit-level=high
|
||||||
|
```
|
||||||
|
|
||||||
|
## Windows build — no paid services required
|
||||||
|
|
||||||
|
ForgeFlow is a personal/internal tool. The supported release path therefore has
|
||||||
|
no certificate, Azure or other paid-service dependency:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run dist:win
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `npm run signing:setup` once on the release workstation. It stores the
|
||||||
|
private Ed25519 key outside the repository and writes only its public key into
|
||||||
|
the packaged app. `npm run dist:win` then produces the installer and portable
|
||||||
|
executable, SHA-256 sidecars, CycloneDX SBOM, provenance and an Ed25519-signed
|
||||||
|
manifest bound to the exact source commit. The updater verifies the pinned
|
||||||
|
publisher key before trusting the artifact digest and verifies that digest again
|
||||||
|
immediately before replacing the installed executable.
|
||||||
|
|
||||||
|
Windows can display an `Unknown publisher` warning for an unsigned installer.
|
||||||
|
That warning concerns public publisher reputation; it does not prevent ForgeFlow
|
||||||
|
from installing or using its checksum-verified in-app updates. Authenticode can
|
||||||
|
be added later as an optional distribution convenience, but is not required for
|
||||||
|
correct operation.
|
||||||
|
|
||||||
|
## Atomic publication
|
||||||
|
|
||||||
|
`npm run release:binary` keeps the Gitea release in draft state while uploading
|
||||||
|
the installer, portable executable, two checksums, provenance, SBOM, signed
|
||||||
|
manifest and signature. It only publishes after all eight assets are present. A
|
||||||
|
failed upload leaves a draft rather than exposing an incomplete updater target.
|
||||||
|
|
||||||
|
The optional signing acceptance fixture can still validate the complete local
|
||||||
|
Authenticode chain without purchasing or retaining a certificate:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run test:signing
|
||||||
|
```
|
||||||
|
|
||||||
|
This disposable fixture signs installer, portable, update-helper and uninstaller
|
||||||
|
stand-ins, requires an RFC 3161 timestamp, and proves rejection of a missing
|
||||||
|
timestamp, wrong publisher and a modified binary. Its certificate is removed
|
||||||
|
from the current-user certificate store after the test.
|
||||||
|
|
||||||
|
The disposable test certificate is removed from the current-user certificate
|
||||||
|
store after the test and is never used for a published build.
|
||||||
+1
-1
@@ -94,7 +94,7 @@ health verification, diagnostic correlation and no arbitrary shell input.
|
|||||||
|
|
||||||
- Windows installer/portable acceptance;
|
- Windows installer/portable acceptance;
|
||||||
- macOS/Linux package validation;
|
- macOS/Linux package validation;
|
||||||
- code signing, notarization and signed updates;
|
- optional code signing/notarization for future public distribution;
|
||||||
- dependency/secret/package scans;
|
- dependency/secret/package scans;
|
||||||
- accessibility review;
|
- accessibility review;
|
||||||
- hundreds-of-repositories performance tests;
|
- hundreds-of-repositories performance tests;
|
||||||
|
|||||||
+12
-7
@@ -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.
|
||||||
@@ -108,9 +108,9 @@ included model uses:
|
|||||||
- atomic non-secret status JSON;
|
- atomic non-secret status JSON;
|
||||||
- previous-SHA recording and non-zero failure exits.
|
- previous-SHA recording and non-zero failure exits.
|
||||||
|
|
||||||
## Remaining release hardening
|
## Optional and future release hardening
|
||||||
|
|
||||||
- code-sign packages and signed updates;
|
- optional code signing if ForgeFlow is ever distributed publicly;
|
||||||
- validate private CA/TLS behavior in the target network;
|
- validate private CA/TLS behavior in the target network;
|
||||||
- dependency, secret and binary scans in CI;
|
- dependency, secret and binary scans in CI;
|
||||||
- package-level IPC/navigation regression tests;
|
- package-level IPC/navigation regression tests;
|
||||||
@@ -124,9 +124,14 @@ included model uses:
|
|||||||
- SSH passwords and private-key passphrases use Electron `safeStorage`;
|
- SSH passwords and private-key passphrases use Electron `safeStorage`;
|
||||||
- diagnostics receive those runtime secrets only for redaction and never export
|
- diagnostics receive those runtime secrets only for redaction and never export
|
||||||
encrypted credential fields;
|
encrypted credential fields;
|
||||||
- SSH deployment requires a pinned host-key fingerprint;
|
- SSH host identity is previewed without credentials and authenticated sessions
|
||||||
|
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;
|
||||||
- update archives are checksummed and validated by the full local quality gate;
|
- non-loopback Gitea connections require HTTPS;
|
||||||
- source backup is restored when an update fails.
|
- packaged updates require a publisher-signed Ed25519 manifest that binds the
|
||||||
|
source commit, artifact identity, byte length and SHA-256 digest;
|
||||||
|
- packaged update bytes are rehashed immediately before apply;
|
||||||
|
- integrated source replacement is disabled until source archives carry the
|
||||||
|
same independent publisher signature.
|
||||||
|
|||||||
@@ -1,95 +1,73 @@
|
|||||||
# SSH / Unraid deployment
|
# SSH / Unraid deployment
|
||||||
|
|
||||||
ForgeFlow deploys an exact Gitea commit directly to an Unraid server over pinned SSH.
|
ForgeFlow uses one deployment flow for Unraid: it copies the exact committed local project from the desktop to the server and activates the Compose definition found for that deployment.
|
||||||
|
|
||||||
## Security model
|
## Deployment modes
|
||||||
|
|
||||||
- Credentials are entered only in the local ForgeFlow desktop application.
|
### Direct copy — default
|
||||||
- Ed25519 private keys are preferred.
|
|
||||||
- Passwords and key passphrases use Electron safe storage.
|
ForgeFlow creates an archive from the exact local commit and uploads it through the configured desktop-to-Unraid connection. Unraid needs Docker, Docker Compose, `tar` and a SHA-256 checksum tool. Unraid does not clone, fetch or authenticate to a repository.
|
||||||
|
|
||||||
|
### Monitor only
|
||||||
|
|
||||||
|
ForgeFlow inventories and tracks the workload but refuses deploy and rollback operations until **Direct copy** is selected.
|
||||||
|
|
||||||
|
All older SSH/Unraid profiles are migrated to Direct copy unless they were explicitly Monitor only.
|
||||||
|
|
||||||
|
## Server Inventory and automatic linking
|
||||||
|
|
||||||
|
Server Inventory reads the server itself instead of relying on ForgeFlow history. The default scan root is `/mnt/user/appdata`, together with the configured server base path and the cache-backed appdata path when present. It combines:
|
||||||
|
|
||||||
|
- running and stopped containers from `docker ps -a` and Docker Inspect;
|
||||||
|
- active and stopped Compose projects;
|
||||||
|
- DockerMan templates;
|
||||||
|
- Compose YAML files below the configured appdata roots, including standard override files.
|
||||||
|
|
||||||
|
YAML discovery continues even when Docker inspection fails. For each Compose definition ForgeFlow reads the working directory, project name, file set, services and images. It then compares those values with the linked local repositories.
|
||||||
|
|
||||||
|
A unique high-confidence match based on both the Compose folder and project identity is linked automatically. Other strong matches show a one-click **Link to repository** action. The server folder, Compose project, Compose files, service list, visible container identity, ports and preservation paths are already filled in; linking does not recreate the container.
|
||||||
|
|
||||||
|
## Compose identity
|
||||||
|
|
||||||
|
An adopted installation retains the identity detected on the server:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Visible container: geointel
|
||||||
|
Server folder: GeoIntel
|
||||||
|
Compose project: geointel
|
||||||
|
Compose files: compose.yml, compose.override.yml
|
||||||
|
Compose services: web, worker
|
||||||
|
```
|
||||||
|
|
||||||
|
ForgeFlow adds `.forgeflow/compose.metadata.yml` as the final Compose overlay. For adopted workloads this overlay adds safe labels only; it does not replace the existing image, volumes, ports, networks or `container_name`.
|
||||||
|
|
||||||
|
`--force-recreate` and `--remove-orphans` remain disabled by default. Existing DockerMan templates are not rewritten.
|
||||||
|
|
||||||
|
## Direct-copy sequence
|
||||||
|
|
||||||
|
1. Verify the selected local branch, clean working tree and exact committed HEAD.
|
||||||
|
2. Test the desktop-to-Unraid connection, Docker, Compose, `tar`, checksum tooling and deployment storage.
|
||||||
|
3. Create the release locally with `git archive`.
|
||||||
|
4. Upload a temporary `.part` file through SFTP.
|
||||||
|
5. Verify SHA-256 and reject unsafe archive paths or symbolic links.
|
||||||
|
6. Preserve `.forgeflow`, `.git` and configured runtime paths such as `.env`, `data`, `config`, `logs` and application-specific folders.
|
||||||
|
7. Update only files covered by the managed release manifests; unrelated server files remain untouched.
|
||||||
|
8. Validate the detected merged Compose configuration.
|
||||||
|
9. Activate the retained Compose project and verify every selected service is running and not unhealthy.
|
||||||
|
10. Promote the active SHA and manifests only after activation succeeds.
|
||||||
|
11. Run the optional desktop health check and persist runtime state.
|
||||||
|
|
||||||
|
If activation fails, ForgeFlow restores the previous managed files and Compose metadata and leaves the previous active SHA authoritative.
|
||||||
|
|
||||||
|
## Authentication model
|
||||||
|
|
||||||
|
- The only remote authentication used for Direct copy is the configured desktop-to-Unraid connection.
|
||||||
|
- That connection may use an Unraid password or a private key.
|
||||||
|
- Passwords and private-key passphrases use Electron safe storage.
|
||||||
- The first trusted connection records the SSH host-key fingerprint; later changes fail closed.
|
- The first trusted connection records the SSH host-key fingerprint; later changes fail closed.
|
||||||
- Unraid-to-Gitea repository access is tested during every deployment preflight.
|
- Remote inventory collects selected labels, mounts, ports and runtime state; it does not collect container environment values.
|
||||||
- The renderer cannot submit arbitrary shell commands. Remote scripts are assembled from validated profile fields and transported as base64-encoded Bash input.
|
- The renderer cannot submit arbitrary shell commands; remote scripts are assembled from validated profile fields.
|
||||||
- Tracked server-side modifications block deployment and rollback.
|
|
||||||
|
|
||||||
## Profile identity
|
|
||||||
|
|
||||||
ForgeFlow separates names that users see from names Docker requires:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Visible project/container: Portfolio
|
|
||||||
Server folder: Portfolio
|
|
||||||
Internal Compose project: portfolio
|
|
||||||
Internal Compose service: portfolio
|
|
||||||
Internal image: forgeflow/portfolio:production
|
|
||||||
```
|
|
||||||
|
|
||||||
The internal Compose service must match the repository's service key and remain lowercase. The visible container can preserve branding and casing.
|
|
||||||
|
|
||||||
## DockerMan WebUI, icon and shell
|
|
||||||
|
|
||||||
ForgeFlow writes `.forgeflow/compose.metadata.yml` and combines it with the repository or generated Compose file. The override supplies:
|
|
||||||
|
|
||||||
```text
|
|
||||||
net.unraid.docker.managed=dockerman
|
|
||||||
net.unraid.docker.webui=http://[IP]:[PORT:<host-port>]/
|
|
||||||
net.unraid.docker.icon=<PNG URL or persistent Unraid path>
|
|
||||||
net.unraid.docker.shell=sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Icon modes:
|
|
||||||
|
|
||||||
- **Built-in high-contrast ITWorx mark** — default;
|
|
||||||
- **Upload local PNG** — copied to `/boot/config/plugins/dockerMan/images/<container>-icon.png`;
|
|
||||||
- **Use icon URL** — HTTP(S) PNG;
|
|
||||||
- **No custom icon**.
|
|
||||||
|
|
||||||
After metadata changes ForgeFlow recreates the container, writes `/boot/config/plugins/dockerMan/templates-user/my-<container>.xml`, removes known icon caches and invalidates DockerMan's volatile `docker.json` metadata cache. The Unraid Docker page may still need one browser refresh.
|
|
||||||
|
|
||||||
The deployment card reports whether WebUI and icon labels were confirmed through `docker inspect`. **Repair DockerMan integration** recreates an existing healthy container with labels, a persistent DockerMan template, icon cache refresh and WebUI metadata without creating a Git commit. **Open Web UI** uses the profile URL directly from the desktop.
|
|
||||||
|
|
||||||
## Existing application folder
|
|
||||||
|
|
||||||
For an existing folder:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Server folder: Portfolio
|
|
||||||
Remote path: /mnt/user/appdata/Portfolio
|
|
||||||
Compose file: docker-compose.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
The project root must be a Git working tree. Untracked runtime paths such as `.env`, `appdata`, `data`, `logs`, `config` and `compose.override.yml` remain untouched by `git reset --hard`. Nested Git repositories are warnings and never deleted automatically.
|
|
||||||
|
|
||||||
Preflight inspects `.dockerignore` when a Dockerfile exists. It warns when `.git`, preserved runtime data or nested repositories would be sent into the build context.
|
|
||||||
|
|
||||||
## New application folder
|
|
||||||
|
|
||||||
The server clones the configured URL on the selected branch. The Unraid host therefore needs a non-interactive Gitea SSH identity. Preflight runs `git ls-remote --exit-code` from Unraid before deployment.
|
|
||||||
|
|
||||||
Use repository Compose for real applications. Generated Compose is intended only for a simple single-service Dockerfile application with basic port mapping.
|
|
||||||
|
|
||||||
## Deployment sequence
|
|
||||||
|
|
||||||
1. Verify clean local tree, allowed branch, upstream and ahead/behind state.
|
|
||||||
2. Verify the exact SHA exists on the allowed remote branch.
|
|
||||||
3. Verify Unraid can read the Gitea repository.
|
|
||||||
4. Inspect the server folder and refuse tracked server changes.
|
|
||||||
5. Clone when the folder is absent.
|
|
||||||
6. Fetch the branch and verify the exact SHA is an ancestor of `origin/<branch>`.
|
|
||||||
7. Save the previous SHA and reset to the requested SHA.
|
|
||||||
8. Write generated Compose when selected.
|
|
||||||
9. Write the DockerMan metadata override and persistent template fallback.
|
|
||||||
10. Validate the merged Compose model.
|
|
||||||
11. Run `docker compose up -d --build --remove-orphans --force-recreate`.
|
|
||||||
12. Clear relevant icon caches.
|
|
||||||
13. Inspect the visible container and write `.forgeflow/status.json`.
|
|
||||||
14. Run the configured desktop healthcheck.
|
|
||||||
15. Persist the live SHA, previous SHA, health, container and DockerMan state.
|
|
||||||
|
|
||||||
## Interrupted operation recovery
|
|
||||||
|
|
||||||
At startup and through **Reconcile**, ForgeFlow reads the live SHA, container running state, Docker health, labels and persistent template state. When a previously running operation already reached its exact requested SHA and the container is healthy, the operation becomes `success`. Operations that remain unresolved for more than 45 minutes become `failed` rather than staying indefinitely in deployment mode.
|
|
||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
|
|
||||||
Rollback is accepted only for the exact SHA currently recorded as `previousSha`. ForgeFlow re-verifies that commit against Gitea, refuses tracked server changes, resets the same working tree, reapplies Compose and DockerMan metadata, reruns health checks and rotates the former live SHA into the new rollback target.
|
Rollback is allowed only to the exact `previousSha` recorded for the profile. ForgeFlow recreates that commit archive locally and uses the same upload, checksum, backup, Compose validation and atomic promotion flow.
|
||||||
|
|||||||
+33
-3
@@ -1,8 +1,37 @@
|
|||||||
# Test matrix
|
# Test matrix
|
||||||
|
|
||||||
## Automated in v0.4.0
|
## Automated baseline (0.10.x)
|
||||||
|
|
||||||
The suite contains 59 passing tests.
|
The quality chain contains more than 230 Node and browser acceptance cases. The
|
||||||
|
latest Windows source run completed without failures and retains one explicitly
|
||||||
|
Bash-dependent skip. `npm run coverage` enforces 75% lines/statements/functions
|
||||||
|
and 65% branches; the measured hardening baseline is 81.48% statements/lines,
|
||||||
|
82.07% functions and 65.59% branches. See `COVERAGE_POLICY.md` for the
|
||||||
|
non-gamed branch policy.
|
||||||
|
|
||||||
|
`npm run quality` is the local equivalent of `.gitea/workflows/quality.yml` and
|
||||||
|
runs source verification, ESLint, the complete suite and coverage on Node 22 LTS.
|
||||||
|
Production dependencies are separately checked with `npm audit --omit=dev
|
||||||
|
--audit-level=high`.
|
||||||
|
|
||||||
|
### Server safety and reconciliation
|
||||||
|
|
||||||
|
- inventory discovery is read-only and byte-stable for configuration;
|
||||||
|
- reconciliation requires a content-addressed preview plan and recovery snapshot;
|
||||||
|
- automatic linking requires unique exact provenance/runtime identity;
|
||||||
|
- server-pull verification checks Gitea branch, read-only deploy-key ID, pinned
|
||||||
|
host/key fingerprints, remote/live SHA, Compose evidence, runtime and health;
|
||||||
|
- a fresh access verification is mandatory immediately before server-pull deploy;
|
||||||
|
- writable or missing deploy keys fail closed.
|
||||||
|
|
||||||
|
### Renderer regression matrix
|
||||||
|
|
||||||
|
Playwright runs 36 cases across 1120×720, 1440×900 and 1920×1080, dark and
|
||||||
|
light themes, reduced motion, and simulated 100%, 125% and 150% Windows scaling.
|
||||||
|
It checks console/page errors, accessible names, labels, heading structure,
|
||||||
|
horizontal overflow, viewport containment, dialogs, keyboard focus, updater and
|
||||||
|
deployment failure evidence. CI retains screenshots, video, trace, console JSON,
|
||||||
|
DOM HTML and fixture context on failure.
|
||||||
|
|
||||||
### Git and repository behavior
|
### Git and repository behavior
|
||||||
|
|
||||||
@@ -76,7 +105,8 @@ The suite contains 59 passing tests.
|
|||||||
- status endpoint through the real reverse proxy;
|
- status endpoint through the real reverse proxy;
|
||||||
- deployment lock, failed healthcheck and rollback;
|
- deployment lock, failed healthcheck and rollback;
|
||||||
- diagnostic ZIP inspection after a deliberately failed deployment;
|
- diagnostic ZIP inspection after a deliberately failed deployment;
|
||||||
- unsigned installer and portable build on Windows;
|
- locally test-signed installer, portable, helper and uninstaller fixtures with
|
||||||
|
RFC 3161 timestamp plus wrong-publisher, missing-timestamp and tamper rejection;
|
||||||
- keyboard-only and screen-reader smoke test.
|
- keyboard-only and screen-reader smoke test.
|
||||||
|
|
||||||
## Renderer smoke target
|
## Renderer smoke target
|
||||||
|
|||||||
+34
-30
@@ -2,64 +2,68 @@
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
ForgeFlow 0.8.2 and newer use authenticated Gitea release assets when running from the installer or portable executable. The updater selects the installer or portable artifact that matches the current installation mode, requires its `.sha256` sidecar, validates the Windows executable header and SHA-256 digest, then verifies the digest again immediately before applying it. An external PowerShell helper waits for ForgeFlow to exit, installs or replaces the executable and restarts it.
|
ForgeFlow uses authenticated Gitea release assets when running from the installer or portable executable. The updater selects the artifact that matches the current installation mode and requires its `.sha256` sidecar. From version 0.10.13 onward it also requires an Ed25519-signed release manifest. The embedded public key verifies that manifest before ForgeFlow trusts the artifact name, byte length, exact source commit or SHA-256 digest. The digest is checked again immediately before applying the update.
|
||||||
|
|
||||||
Publish a verified binary release after pushing its source commit:
|
`Publish-ForgeFlow-Release.ps1` treats source and binaries as one release transaction. It pushes the validated source, builds the exact published commit and uploads eight required assets:
|
||||||
|
|
||||||
|
- `ForgeFlow-Setup-<version>-win-x64.exe`
|
||||||
|
- `ForgeFlow-Setup-<version>-win-x64.exe.sha256`
|
||||||
|
- `ForgeFlow-Portable-<version>-win-x64.exe`
|
||||||
|
- `ForgeFlow-Portable-<version>-win-x64.exe.sha256`
|
||||||
|
- `ForgeFlow-<version>-provenance.json`
|
||||||
|
- `ForgeFlow-<version>-sbom.cdx.json`
|
||||||
|
- `ForgeFlow-<version>-release-manifest.json`
|
||||||
|
- `ForgeFlow-<version>-release-manifest.json.sig`
|
||||||
|
|
||||||
|
Run `npm run signing:setup` once on the release workstation. The private Ed25519 key stays outside the repository in ForgeFlow's user-data folder. This independent publisher signature is free; optional Authenticode can still be added later for Windows reputation.
|
||||||
|
|
||||||
|
Use `-SkipBinaryRelease` only when intentionally publishing source without enabling packaged auto-update.
|
||||||
|
|
||||||
|
When the source was already pushed without a binary release, run the recovery publisher from Windows:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
npm run dist:win
|
Set-ExecutionPolicy -Scope Process Bypass
|
||||||
$env:FORGEFLOW_USER_DATA = "$env:APPDATA\forgeflow"
|
.\Publish-Missing-Binary-Release.ps1 -ExpectedVersion 0.9.1
|
||||||
npm run release:binary
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The recovery script clones the current Gitea branch into a temporary directory, verifies the exact branch commit, runs the complete quality gate, builds both Windows artifacts and creates or repairs the matching Gitea release. It uses the encrypted Gitea token already stored by ForgeFlow.
|
||||||
|
|
||||||
Every platform build finishes by removing ForgeFlow artifacts for older versions from `dist`. The unpacked application directory and builder diagnostics are kept.
|
Every platform build finishes by removing ForgeFlow artifacts for older versions from `dist`. The unpacked application directory and builder diagnostics are kept.
|
||||||
|
|
||||||
The publisher refuses to upload when local `HEAD` differs from `origin/main`. Users on 0.8.1 or older need one manual 0.8.2 installation because those versions deliberately disabled packaged updates.
|
The binary publisher refuses to upload when local `HEAD` differs from the configured Gitea branch. ForgeFlow 0.8.9 and 0.9.0 queried Gitea attachment metadata as though it were the executable. Those versions require one manual 0.9.1 installer run. From 0.9.1 onward, the updater follows the release asset browser download URL and in-app updates work normally.
|
||||||
|
|
||||||
## Publishing a release from Downloads
|
## Publishing a release from Downloads
|
||||||
|
|
||||||
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
|
||||||
```
|
```
|
||||||
|
|
||||||
The script installs dependencies, runs the complete quality gate, clones `git@gitea.itworx.tech:Jens/ForgeFlow.git` into a temporary folder, mirrors the validated source without `.git`, `node_modules`, `dist` or release archives, commits it and pushes `main`. It then compares local `HEAD` with `git ls-remote` and fails if Gitea does not report the exact release commit.
|
The script installs dependencies, runs the complete quality gate, clones `git@gitea.itworx.tech:Jens/ForgeFlow.git` into a temporary folder, mirrors the validated source without `.git`, `node_modules`, `dist` or release archives, commits it and pushes `main`. It then compares local `HEAD` with `git ls-remote`, builds the exact published checkout and uploads all binaries, checksums and signed release evidence to the matching Gitea release. Publication fails when either the source commit, publisher signature or any required asset cannot be verified.
|
||||||
|
|
||||||
Keep the currently installed older ForgeFlow source folder untouched until the built-in updater test is complete.
|
Keep the currently installed older ForgeFlow source folder untouched until the built-in updater test is complete.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 83 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 110 KiB |
@@ -0,0 +1,67 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: ["build/**", "dist/**", "node_modules/**", ".playwright-mcp/**", "SOURCE_MANIFEST.txt"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.{js,cjs,mjs}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
sourceType: "module",
|
||||||
|
globals: {
|
||||||
|
Buffer: "readonly", clearInterval: "readonly", clearTimeout: "readonly", console: "readonly",
|
||||||
|
document: "readonly", fetch: "readonly", FormData: "readonly", globalThis: "readonly",
|
||||||
|
process: "readonly", queueMicrotask: "readonly", requestAnimationFrame: "readonly",
|
||||||
|
setInterval: "readonly", setTimeout: "readonly", URL: "readonly", URLSearchParams: "readonly",
|
||||||
|
window: "readonly", confirm: "readonly", localStorage: "readonly", structuredClone: "readonly",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...js.configs.recommended.rules,
|
||||||
|
"no-control-regex": "off",
|
||||||
|
"no-empty": ["error", { allowEmptyCatch: true }],
|
||||||
|
"no-undef": "off",
|
||||||
|
"no-unused-vars": "off",
|
||||||
|
"no-useless-escape": "off",
|
||||||
|
"no-eval": "error",
|
||||||
|
"no-implied-eval": "error",
|
||||||
|
eqeqeq: ["error", "always", { null: "ignore" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The main process and shared modules are plain CommonJS with an explicit
|
||||||
|
// dependency graph, so undefined identifiers there are always real bugs
|
||||||
|
// (missing require, missing injected dependency) rather than a global that
|
||||||
|
// another script tag happens to define.
|
||||||
|
files: ["src/main/**/*.cjs", "src/shared/**/*.cjs", "main.cjs", "preload.cjs"],
|
||||||
|
languageOptions: {
|
||||||
|
sourceType: "commonjs",
|
||||||
|
globals: {
|
||||||
|
require: "readonly", module: "writable", exports: "writable",
|
||||||
|
__dirname: "readonly", __filename: "readonly",
|
||||||
|
Buffer: "readonly", process: "readonly", console: "readonly",
|
||||||
|
setTimeout: "readonly", clearTimeout: "readonly",
|
||||||
|
setInterval: "readonly", clearInterval: "readonly", setImmediate: "readonly",
|
||||||
|
queueMicrotask: "readonly", structuredClone: "readonly", globalThis: "readonly",
|
||||||
|
URL: "readonly", URLSearchParams: "readonly", fetch: "readonly",
|
||||||
|
FormData: "readonly", Blob: "readonly",
|
||||||
|
AbortController: "readonly", AbortSignal: "readonly",
|
||||||
|
TextEncoder: "readonly", TextDecoder: "readonly",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"no-undef": "error",
|
||||||
|
// Also catches code that a refactor left behind, such as a value computed
|
||||||
|
// from a dependency that is no longer injected.
|
||||||
|
"no-unused-vars": ["error", { args: "none", caughtErrors: "none", ignoreRestSiblings: true }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["tests/**/*.mjs"],
|
||||||
|
rules: {
|
||||||
|
"no-regex-spaces": "off",
|
||||||
|
"no-unsafe-finally": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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"
|
||||||
@@ -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=$?
|
||||||
@@ -126,7 +202,7 @@ git -C "$APP_DIR" merge-base --is-ancestor "$SHA" "origin/$BRANCH" || {
|
|||||||
|
|
||||||
write_status "deploying" "$current_sha" "$previous_sha" 0
|
write_status "deploying" "$current_sha" "$previous_sha" 0
|
||||||
git -C "$APP_DIR" reset --hard "$SHA"
|
git -C "$APP_DIR" reset --hard "$SHA"
|
||||||
docker compose -f "$COMPOSE_FILE" up -d --build --remove-orphans
|
docker compose -f "$COMPOSE_FILE" up -d --build
|
||||||
|
|
||||||
for attempt in $(seq 1 30); do
|
for attempt in $(seq 1 30); do
|
||||||
if curl --fail --silent --show-error --max-time 5 "$HEALTHCHECK_URL" >/dev/null; then
|
if curl --fail --silent --show-error --max-time 5 "$HEALTHCHECK_URL" >/dev/null; then
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,18 +25,39 @@ const {
|
|||||||
UnraidDeploymentService,
|
UnraidDeploymentService,
|
||||||
} = require("./src/main/unraid-deployment-service.cjs");
|
} = require("./src/main/unraid-deployment-service.cjs");
|
||||||
const { AuditService } = require("./src/main/audit-service.cjs");
|
const { AuditService } = require("./src/main/audit-service.cjs");
|
||||||
|
const { DeployKeyLifecycleService } = require("./src/main/deploy-key-lifecycle-service.cjs");
|
||||||
|
const { UnraidDeployKeyHost } = require("./src/main/unraid-deploy-key-host.cjs");
|
||||||
|
const { InventoryReviewService } = require("./src/main/inventory-review-service.cjs");
|
||||||
|
const { GitValidatorService } = require("./src/main/git-validator-service.cjs");
|
||||||
const {
|
const {
|
||||||
ExternalToolsService,
|
ExternalToolsService,
|
||||||
} = require("./src/main/external-tools-service.cjs");
|
} = require("./src/main/external-tools-service.cjs");
|
||||||
const { registerIpc } = require("./src/main/ipc.cjs");
|
const { registerIpc } = require("./src/main/ipc.cjs");
|
||||||
|
const {
|
||||||
|
installOutputPipeGuards,
|
||||||
|
isBrokenPipeError,
|
||||||
|
} = require("./src/main/process-error-policy.cjs");
|
||||||
|
|
||||||
let mainWindow;
|
let mainWindow;
|
||||||
let repositoryMonitor;
|
let repositoryMonitor;
|
||||||
|
let sshService;
|
||||||
let operationTimer;
|
let operationTimer;
|
||||||
let diagnostics;
|
let diagnostics;
|
||||||
let configStore;
|
let configStore;
|
||||||
let tray;
|
let tray;
|
||||||
let quitCleanupStarted = false;
|
let quitCleanupStarted = false;
|
||||||
|
const reportBrokenOutputPipe = (error) => {
|
||||||
|
const report = diagnostics?.warning("process.output-pipe.closed", {
|
||||||
|
code: error?.code || null,
|
||||||
|
message: error?.message || "The parent output pipe was closed.",
|
||||||
|
});
|
||||||
|
report?.catch(() => {});
|
||||||
|
};
|
||||||
|
installOutputPipeGuards({ onBrokenPipe: reportBrokenOutputPipe });
|
||||||
|
const ownsSingleInstanceLock = app.requestSingleInstanceLock();
|
||||||
|
|
||||||
|
if (!ownsSingleInstanceLock) app.quit();
|
||||||
|
else app.on("second-instance", () => showMainWindow());
|
||||||
|
|
||||||
function broadcast(channel, payload) {
|
function broadcast(channel, payload) {
|
||||||
for (const window of BrowserWindow.getAllWindows()) {
|
for (const window of BrowserWindow.getAllWindows()) {
|
||||||
@@ -167,6 +188,7 @@ function createWindow() {
|
|||||||
app
|
app
|
||||||
.whenReady()
|
.whenReady()
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
|
if (!ownsSingleInstanceLock) return;
|
||||||
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
||||||
callback({
|
callback({
|
||||||
responseHeaders: {
|
responseHeaders: {
|
||||||
@@ -210,6 +232,10 @@ app
|
|||||||
await audit.initialize();
|
await audit.initialize();
|
||||||
|
|
||||||
process.on("uncaughtException", (error) => {
|
process.on("uncaughtException", (error) => {
|
||||||
|
if (isBrokenPipeError(error)) {
|
||||||
|
reportBrokenOutputPipe(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
diagnostics
|
diagnostics
|
||||||
?.error("process.uncaught-exception", error)
|
?.error("process.uncaught-exception", error)
|
||||||
.finally(() => app.exit(1));
|
.finally(() => app.exit(1));
|
||||||
@@ -227,6 +253,7 @@ app
|
|||||||
const repositories = new RepositoryService(store, git, gitea, diagnostics);
|
const repositories = new RepositoryService(store, git, gitea, diagnostics);
|
||||||
const deployments = new DeploymentService(store, gitea, git, diagnostics);
|
const deployments = new DeploymentService(store, gitea, git, diagnostics);
|
||||||
const ssh = new SshService({ store, diagnostics });
|
const ssh = new SshService({ store, diagnostics });
|
||||||
|
sshService = ssh;
|
||||||
const auditedOperationStates = new Set();
|
const auditedOperationStates = new Set();
|
||||||
const reportOperationChange = (payload) => {
|
const reportOperationChange = (payload) => {
|
||||||
broadcast("operations:changed", payload);
|
broadcast("operations:changed", payload);
|
||||||
@@ -237,6 +264,11 @@ app
|
|||||||
) {
|
) {
|
||||||
const key = `${operation.id}:${operation.status}`;
|
const key = `${operation.id}:${operation.status}`;
|
||||||
if (!auditedOperationStates.has(key)) {
|
if (!auditedOperationStates.has(key)) {
|
||||||
|
// One entry per completed deployment, so the set is trimmed rather
|
||||||
|
// than kept for the lifetime of the process.
|
||||||
|
if (auditedOperationStates.size >= 500) {
|
||||||
|
auditedOperationStates.delete(auditedOperationStates.values().next().value);
|
||||||
|
}
|
||||||
auditedOperationStates.add(key);
|
auditedOperationStates.add(key);
|
||||||
notify(
|
notify(
|
||||||
`Deployment ${operation.status}`,
|
`Deployment ${operation.status}`,
|
||||||
@@ -263,6 +295,13 @@ app
|
|||||||
sourcePath: app.getAppPath(),
|
sourcePath: app.getAppPath(),
|
||||||
onOperationChange: reportOperationChange,
|
onOperationChange: reportOperationChange,
|
||||||
});
|
});
|
||||||
|
const deployKeys = new DeployKeyLifecycleService({
|
||||||
|
store,
|
||||||
|
gitea,
|
||||||
|
keyHost: new UnraidDeployKeyHost({ ssh }),
|
||||||
|
audit,
|
||||||
|
});
|
||||||
|
const inventoryReviews = new InventoryReviewService({ store, audit });
|
||||||
const updates = new UpdateService({
|
const updates = new UpdateService({
|
||||||
store,
|
store,
|
||||||
gitea,
|
gitea,
|
||||||
@@ -285,6 +324,12 @@ app
|
|||||||
userDataPath,
|
userDataPath,
|
||||||
secureStorageAvailable: () => safeStorage.isEncryptionAvailable(),
|
secureStorageAvailable: () => safeStorage.isEncryptionAvailable(),
|
||||||
});
|
});
|
||||||
|
const gitValidator = new GitValidatorService({
|
||||||
|
git,
|
||||||
|
gitea,
|
||||||
|
diagnostics,
|
||||||
|
store,
|
||||||
|
});
|
||||||
repositoryMonitor = new RepositoryMonitor({
|
repositoryMonitor = new RepositoryMonitor({
|
||||||
store,
|
store,
|
||||||
git,
|
git,
|
||||||
@@ -299,9 +344,12 @@ app
|
|||||||
repositories,
|
repositories,
|
||||||
deployments,
|
deployments,
|
||||||
unraid,
|
unraid,
|
||||||
|
deployKeys,
|
||||||
|
inventoryReviews,
|
||||||
ssh,
|
ssh,
|
||||||
updates,
|
updates,
|
||||||
preflight,
|
preflight,
|
||||||
|
gitValidator,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
audit,
|
audit,
|
||||||
externalTools,
|
externalTools,
|
||||||
@@ -399,7 +447,6 @@ app
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(async (error) => {
|
.catch(async (error) => {
|
||||||
console.error("[startup]", error);
|
|
||||||
await diagnostics?.error("app.startup.failed", error);
|
await diagnostics?.error("app.startup.failed", error);
|
||||||
await diagnostics?.flush();
|
await diagnostics?.flush();
|
||||||
app.exit(1);
|
app.exit(1);
|
||||||
@@ -410,6 +457,7 @@ app.on("before-quit", (event) => {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
quitCleanupStarted = true;
|
quitCleanupStarted = true;
|
||||||
repositoryMonitor?.stop();
|
repositoryMonitor?.stop();
|
||||||
|
sshService?.closeAll();
|
||||||
if (operationTimer) clearTimeout(operationTimer);
|
if (operationTimer) clearTimeout(operationTimer);
|
||||||
Promise.resolve()
|
Promise.resolve()
|
||||||
.then(() => diagnostics?.info("app.quitting", {}))
|
.then(() => diagnostics?.info("app.quitting", {}))
|
||||||
|
|||||||
Generated
+1447
-72
File diff suppressed because it is too large
Load Diff
+56
-6
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "forgeflow",
|
"name": "forgeflow",
|
||||||
"version": "0.8.7",
|
"version": "0.10.15",
|
||||||
"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",
|
||||||
@@ -10,20 +10,35 @@
|
|||||||
"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 tests/*.test.mjs",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"coverage": "c8 --check-coverage --lines 85 --functions 85 --branches 68 --statements 85 node --test 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",
|
||||||
"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/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",
|
||||||
"dist:linux": "electron-builder --linux AppImage && node scripts/prune-dist.mjs",
|
"dist:linux": "electron-builder --linux AppImage && node scripts/prune-dist.mjs",
|
||||||
"dist:mac": "electron-builder --mac dmg && node scripts/prune-dist.mjs",
|
"dist:mac": "electron-builder --mac dmg && node scripts/prune-dist.mjs",
|
||||||
"doctor": "node scripts/doctor.mjs",
|
"doctor": "node scripts/doctor.mjs",
|
||||||
"acceptance": "node scripts/acceptance.mjs",
|
"acceptance": "node scripts/acceptance.mjs",
|
||||||
|
"acceptance:isolated": "node --test tests/production-acceptance.test.mjs",
|
||||||
|
"architecture:audit": "node scripts/architecture-audit.mjs",
|
||||||
|
"test:browser": "playwright test",
|
||||||
|
"test:browser:ci": "playwright test --reporter=line,html",
|
||||||
|
"test:signing": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/test-authenticode-chain.ps1",
|
||||||
|
"signing:setup": "node scripts/setup-update-signing-key.mjs",
|
||||||
"connections:check": "electron scripts/validate-installed-connections.cjs",
|
"connections:check": "electron scripts/validate-installed-connections.cjs",
|
||||||
|
"deployments:audit": "electron scripts/audit-installed-deployments.cjs",
|
||||||
"release:binary": "electron scripts/publish-binary-release.cjs",
|
"release:binary": "electron scripts/publish-binary-release.cjs",
|
||||||
"manifest": "node scripts/generate-source-manifest.mjs",
|
"manifest": "node scripts/generate-source-manifest.mjs",
|
||||||
"check": "npm run verify && npm test"
|
"check": "npm run verify && npm run lint && npm test",
|
||||||
|
"quality": "npm run check && npm run coverage"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "9.39.5",
|
||||||
|
"@playwright/test": "1.62.0",
|
||||||
|
"c8": "12.0.0",
|
||||||
"electron": "43.2.0",
|
"electron": "43.2.0",
|
||||||
"electron-builder": "26.15.3"
|
"electron-builder": "26.15.3",
|
||||||
|
"eslint": "9.39.5"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "be.jenscaers.forgeflow",
|
"appId": "be.jenscaers.forgeflow",
|
||||||
@@ -36,6 +51,7 @@
|
|||||||
"package.json",
|
"package.json",
|
||||||
"build/icon.png",
|
"build/icon.png",
|
||||||
"build/icon.ico",
|
"build/icon.ico",
|
||||||
|
"build/update-signing-public.pem",
|
||||||
"docs/SETUP_GUIDE.md",
|
"docs/SETUP_GUIDE.md",
|
||||||
"docs/DIAGNOSTICS.md",
|
"docs/DIAGNOSTICS.md",
|
||||||
"docs/STATUS_ENDPOINT.md",
|
"docs/STATUS_ENDPOINT.md",
|
||||||
@@ -53,8 +69,9 @@
|
|||||||
"scripts/apply-source-update.ps1",
|
"scripts/apply-source-update.ps1",
|
||||||
"scripts/apply-binary-update.ps1",
|
"scripts/apply-binary-update.ps1",
|
||||||
"scripts/prune-dist.mjs",
|
"scripts/prune-dist.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",
|
||||||
@@ -63,7 +80,9 @@
|
|||||||
"docs/RELEASE_NOTES_0.4.5.md",
|
"docs/RELEASE_NOTES_0.4.5.md",
|
||||||
"docs/RELEASE_NOTES_0.5.0.md",
|
"docs/RELEASE_NOTES_0.5.0.md",
|
||||||
"docs/RELEASE_NOTES_0.5.1.md",
|
"docs/RELEASE_NOTES_0.5.1.md",
|
||||||
|
"PUBLISH-AND-ENABLE-UPDATE.cmd",
|
||||||
"Publish-ForgeFlow-Release.ps1",
|
"Publish-ForgeFlow-Release.ps1",
|
||||||
|
"Publish-Missing-Binary-Release.ps1",
|
||||||
"docs/RELEASE_NOTES_0.5.2.md",
|
"docs/RELEASE_NOTES_0.5.2.md",
|
||||||
"docs/RELEASE_NOTES_0.5.3.md",
|
"docs/RELEASE_NOTES_0.5.3.md",
|
||||||
"docs/RELEASE_NOTES_0.5.4.md",
|
"docs/RELEASE_NOTES_0.5.4.md",
|
||||||
@@ -80,7 +99,38 @@
|
|||||||
"docs/RELEASE_NOTES_0.8.5.md",
|
"docs/RELEASE_NOTES_0.8.5.md",
|
||||||
"docs/RELEASE_NOTES_0.8.6.md",
|
"docs/RELEASE_NOTES_0.8.6.md",
|
||||||
"docs/RELEASE_NOTES_0.8.7.md",
|
"docs/RELEASE_NOTES_0.8.7.md",
|
||||||
"docs/ACCEPTANCE.md"
|
"docs/RELEASE_NOTES_0.8.8.md",
|
||||||
|
"docs/RELEASE_NOTES_0.8.9.md",
|
||||||
|
"docs/RELEASE_NOTES_0.9.0.md",
|
||||||
|
"docs/RELEASE_NOTES_0.9.1.md",
|
||||||
|
"docs/ACCEPTANCE.md",
|
||||||
|
"docs/RELEASE_NOTES_0.9.2.md",
|
||||||
|
"docs/RELEASE_NOTES_0.9.3.md",
|
||||||
|
"docs/RELEASE_NOTES_0.9.4.md",
|
||||||
|
"docs/RELEASE_NOTES_0.9.5.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.0.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.1.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.2.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.3.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.4.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.5.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.6.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.7.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.8.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.9.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.10.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.11.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.12.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.13.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.14.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.15.md",
|
||||||
|
"docs/CURRENT_STATE.md",
|
||||||
|
"docs/MUTATION_MODEL.md",
|
||||||
|
"docs/RELEASING.md",
|
||||||
|
"docs/COVERAGE_POLICY.md",
|
||||||
|
"docs/DEPENDENCY_AUDIT.md",
|
||||||
|
"docs/PRODUCTION_READINESS_1.0.md",
|
||||||
|
"docs/ERROR_CODES.md"
|
||||||
],
|
],
|
||||||
"asarUnpack": [
|
"asarUnpack": [
|
||||||
"scripts/apply-binary-update.ps1"
|
"scripts/apply-binary-update.ps1"
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
|
const matrices = [
|
||||||
|
["compact-dark-100", 1120, 720, "dark", 1, false],
|
||||||
|
["desktop-light-125", 1440, 900, "light", 1.25, false],
|
||||||
|
["wide-dark-150", 1920, 1080, "dark", 1.5, false],
|
||||||
|
["compact-light-reduced", 1120, 720, "light", 1, true],
|
||||||
|
["desktop-dark-reduced", 1440, 900, "dark", 1.25, true],
|
||||||
|
["wide-light-100", 1920, 1080, "light", 1, false],
|
||||||
|
];
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests/browser",
|
||||||
|
outputDir: "artifacts/browser",
|
||||||
|
timeout: 45_000,
|
||||||
|
expect: { timeout: 7_000 },
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: process.env.CI ? 2 : 3,
|
||||||
|
reporter: [["line"], ["html", { outputFolder: "artifacts/browser-report", open: "never" }]],
|
||||||
|
use: {
|
||||||
|
baseURL: "http://127.0.0.1:41737",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
video: "retain-on-failure",
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: "node scripts/serve-demo.mjs",
|
||||||
|
url: "http://127.0.0.1:41737/__forgeflow_test_ready__",
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 30_000,
|
||||||
|
},
|
||||||
|
projects: matrices.map(([name, width, height, theme, scale, reduced]) => ({
|
||||||
|
name,
|
||||||
|
metadata: { theme, scale, reduced },
|
||||||
|
use: {
|
||||||
|
viewport: { width, height },
|
||||||
|
deviceScaleFactor: scale,
|
||||||
|
colorScheme: theme,
|
||||||
|
reducedMotion: reduced ? "reduce" : "no-preference",
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
});
|
||||||
+24
-3
@@ -46,7 +46,7 @@ contextBridge.exposeInMainWorld(
|
|||||||
applyUpdate: () => invoke('updates:apply'),
|
applyUpdate: () => invoke('updates:apply'),
|
||||||
saveServer: (server, password = '', passphrase = '') => invoke('server:save', { server, password, passphrase }),
|
saveServer: (server, password = '', passphrase = '') => invoke('server:save', { server, password, passphrase }),
|
||||||
deleteServer: (serverId) => invoke('server:delete', { serverId }),
|
deleteServer: (serverId) => invoke('server:delete', { serverId }),
|
||||||
testServer: (serverId) => invoke('server:test', { serverId }),
|
testServer: (serverId, expectedFingerprint = '') => invoke('server:test', { serverId, expectedFingerprint }),
|
||||||
inspectServerProject: (repository, profileId) => invoke('server:inspect-project', { repository, profileId }),
|
inspectServerProject: (repository, profileId) => invoke('server:inspect-project', { repository, profileId }),
|
||||||
discoverExistingDeployment: (repository, serverId, remoteFolder) =>
|
discoverExistingDeployment: (repository, serverId, remoteFolder) =>
|
||||||
invoke('server:discover-existing', {
|
invoke('server:discover-existing', {
|
||||||
@@ -54,7 +54,7 @@ contextBridge.exposeInMainWorld(
|
|||||||
serverId,
|
serverId,
|
||||||
remoteFolder,
|
remoteFolder,
|
||||||
}),
|
}),
|
||||||
refreshRepositories: () => invoke('repositories:refresh'),
|
refreshRepositories: (options = {}) => invoke('repositories:refresh', options),
|
||||||
discoverRepositories: (roots) => invoke('repositories:discover', { roots }),
|
discoverRepositories: (roots) => invoke('repositories:discover', { roots }),
|
||||||
favoriteRepository: (fullName, favorite) => invoke('repository:favorite', { fullName, favorite }),
|
favoriteRepository: (fullName, favorite) => invoke('repository:favorite', { fullName, favorite }),
|
||||||
linkRepository: (fullName, localPath) => invoke('repository:link', { fullName, localPath }),
|
linkRepository: (fullName, localPath) => invoke('repository:link', { fullName, localPath }),
|
||||||
@@ -97,6 +97,8 @@ contextBridge.exposeInMainWorld(
|
|||||||
repairGitLocks: (localPath, force = false) => invoke('repository:repair-git-locks', { localPath, force }),
|
repairGitLocks: (localPath, force = false) => invoke('repository:repair-git-locks', { localPath, force }),
|
||||||
reconcileRepository: (localPath) => invoke('repository:reconcile', { localPath }),
|
reconcileRepository: (localPath) => invoke('repository:reconcile', { localPath }),
|
||||||
repairRepositorySync: (localPath, strategy) => invoke('repository:repair-sync', { localPath, strategy }),
|
repairRepositorySync: (localPath, strategy) => invoke('repository:repair-sync', { localPath, strategy }),
|
||||||
|
previewWorkspaceSync: (localPath) => invoke('repository:workspace-sync-preview', { localPath }),
|
||||||
|
applyWorkspaceSync: (localPath, expectedPlanId) => invoke('repository:workspace-sync-apply', { localPath, expectedPlanId }),
|
||||||
setOrigin: (localPath, remoteUrl) => invoke('repository:set-origin', { localPath, remoteUrl }),
|
setOrigin: (localPath, remoteUrl) => invoke('repository:set-origin', { localPath, remoteUrl }),
|
||||||
normalizeOrigins: () => invoke('repositories:normalize-origins'),
|
normalizeOrigins: () => invoke('repositories:normalize-origins'),
|
||||||
cloneRepository: (fullName, mode = 'default') => invoke('repository:clone', { fullName, mode }),
|
cloneRepository: (fullName, mode = 'default') => invoke('repository:clone', { fullName, mode }),
|
||||||
@@ -106,6 +108,7 @@ contextBridge.exposeInMainWorld(
|
|||||||
openExternal: (url) => invoke('external:open', { url }),
|
openExternal: (url) => invoke('external:open', { url }),
|
||||||
saveDeploymentProfile: (fullName, profile) => invoke('deployment:save-profile', { fullName, profile }),
|
saveDeploymentProfile: (fullName, profile) => invoke('deployment:save-profile', { fullName, profile }),
|
||||||
deploymentPreflight: (repository, profileId) => invoke('deployment:preflight', { repository, profileId }),
|
deploymentPreflight: (repository, profileId) => invoke('deployment:preflight', { repository, profileId }),
|
||||||
|
repairDeploymentWriteAccess: (repository, profileId) => invoke('deployment:repair-write-access', { repository, profileId }),
|
||||||
deleteDeploymentProfile: (fullName, profileId) => invoke('deployment:delete-profile', { fullName, profileId }),
|
deleteDeploymentProfile: (fullName, profileId) => invoke('deployment:delete-profile', { fullName, profileId }),
|
||||||
deploy: (repository, profileId, sha, options = {}) =>
|
deploy: (repository, profileId, sha, options = {}) =>
|
||||||
invoke('deployment:dispatch', {
|
invoke('deployment:dispatch', {
|
||||||
@@ -117,9 +120,21 @@ 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 }),
|
||||||
|
applyServerReconciliation: (serverId, planId) => invoke('deployment:apply-server-reconciliation', { serverId, planId }),
|
||||||
|
planInventoryReview: (serverId, workloadId, action, reason = '', repositoryFullName = null) => invoke('deployment:plan-inventory-review', { serverId, workloadId, action, reason, repositoryFullName }),
|
||||||
|
applyInventoryReview: (serverId, workloadId, action, reason, repositoryFullName, planId) => invoke('deployment:apply-inventory-review', { serverId, workloadId, action, reason, repositoryFullName, planId }),
|
||||||
|
linkServerWorkload: (repository, serverId, workloadId, deploymentMode = 'server-git', remoteFolder = '') => invoke('deployment:link-server-workload', { repository, serverId, workloadId, deploymentMode, remoteFolder }),
|
||||||
|
configureServerGitAccess: (repository, profileId) => invoke('deployment:configure-server-git-access', { repository, profileId }),
|
||||||
|
verifyServerGitProfile: (repository, profileId) => invoke('deployment:verify-server-git-profile', { repository, profileId }),
|
||||||
|
deployKeyInventory: (repository, profileId) => invoke('deployment:deploy-key-inventory', { repository, profileId }),
|
||||||
|
planDeployKeyRotation: (repository, profileId) => invoke('deployment:plan-deploy-key-rotation', { repository, profileId }),
|
||||||
|
applyDeployKeyRotation: (repository, profileId, planId) => invoke('deployment:apply-deploy-key-rotation', { repository, profileId, planId }),
|
||||||
|
planDeployKeyRevocation: (repository, profileId) => invoke('deployment:plan-deploy-key-revocation', { repository, profileId }),
|
||||||
|
applyDeployKeyRevocation: (repository, profileId, planId) => invoke('deployment:apply-deploy-key-revocation', { repository, profileId, planId }),
|
||||||
|
restoreDeployKey: (repository, profileId) => invoke('deployment:restore-deploy-key', { repository, profileId }),
|
||||||
applyDockerManMetadata: (repository, profileId) => invoke('deployment:apply-dockerman-metadata', { repository, profileId }),
|
applyDockerManMetadata: (repository, profileId) => invoke('deployment:apply-dockerman-metadata', { repository, profileId }),
|
||||||
reconcileDeployment: (fullName, profileId) => invoke('deployment:reconcile', { fullName, profileId }),
|
reconcileDeployment: (fullName, profileId) => invoke('deployment:reconcile', { fullName, profileId }),
|
||||||
refreshOperations: (operationId = null) => invoke('operations:refresh', { operationId }),
|
refreshOperations: (operationId = null) => invoke('operations:refresh', { operationId }),
|
||||||
@@ -127,6 +142,12 @@ contextBridge.exposeInMainWorld(
|
|||||||
troubleshooterScan: (fullName = null) => invoke('troubleshooter:scan', { fullName }),
|
troubleshooterScan: (fullName = null) => invoke('troubleshooter:scan', { fullName }),
|
||||||
troubleshooterRepair: (issue) => invoke('troubleshooter:repair', { issue }),
|
troubleshooterRepair: (issue) => invoke('troubleshooter:repair', { issue }),
|
||||||
troubleshooterAutoRepair: (issues) => invoke('troubleshooter:auto-repair', { issues }),
|
troubleshooterAutoRepair: (issues) => invoke('troubleshooter:auto-repair', { issues }),
|
||||||
|
gitValidatorScan: (fullName) => invoke('git-validator:scan', { fullName }),
|
||||||
|
gitValidatorSetPolicy: (fullName, policy) => invoke('git-validator:set-policy', { fullName, policy }),
|
||||||
|
gitValidatorSuppress: (fullName, suppression) => invoke('git-validator:suppress', { fullName, suppression }),
|
||||||
|
gitValidatorPreviewRepair: (fullName, check) => invoke('git-validator:preview-repair', { fullName, check }),
|
||||||
|
gitValidatorExport: (fullName, format = 'json') => invoke('git-validator:export', { fullName, format }),
|
||||||
|
gitValidatorRepair: (fullName, check) => invoke('git-validator:repair', { fullName, check }),
|
||||||
diagnosticsStatus: () => invoke('diagnostics:status'),
|
diagnosticsStatus: () => invoke('diagnostics:status'),
|
||||||
clearDiagnostics: () => invoke('diagnostics:clear'),
|
clearDiagnostics: () => invoke('diagnostics:clear'),
|
||||||
openDiagnosticsFolder: () => invoke('diagnostics:open-folder'),
|
openDiagnosticsFolder: () => invoke('diagnostics:open-folder'),
|
||||||
|
|||||||
@@ -0,0 +1,805 @@
|
|||||||
|
{
|
||||||
|
"generatedAt": "2026-08-29T22:58:20.416Z",
|
||||||
|
"thresholds": {
|
||||||
|
"preferredMaximumLines": 750,
|
||||||
|
"justificationRequiredLines": 1000
|
||||||
|
},
|
||||||
|
"over750": [
|
||||||
|
{
|
||||||
|
"file": "src/main/git-service.cjs",
|
||||||
|
"lines": 950,
|
||||||
|
"branches": 139,
|
||||||
|
"functions": 152,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"git",
|
||||||
|
"renderer",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 159
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/renderer/views.js",
|
||||||
|
"lines": 876,
|
||||||
|
"branches": 61,
|
||||||
|
"functions": 161,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"renderer",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 101
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/update-service.cjs",
|
||||||
|
"lines": 854,
|
||||||
|
"branches": 64,
|
||||||
|
"functions": 65,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"git",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 74
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/renderer/mock-repository-bridge.js",
|
||||||
|
"lines": 780,
|
||||||
|
"branches": 19,
|
||||||
|
"functions": 100,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 39
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"over1000": [],
|
||||||
|
"cyclomaticHotspots": [
|
||||||
|
{
|
||||||
|
"file": "src/main/git-service.cjs",
|
||||||
|
"lines": 950,
|
||||||
|
"branches": 139,
|
||||||
|
"functions": 152,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"git",
|
||||||
|
"renderer",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 159
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/renderer/actions/shell.js",
|
||||||
|
"lines": 531,
|
||||||
|
"branches": 103,
|
||||||
|
"functions": 90,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"renderer",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 133
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/renderer/app.js",
|
||||||
|
"lines": 738,
|
||||||
|
"branches": 80,
|
||||||
|
"functions": 124,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"renderer",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/server-inventory.cjs",
|
||||||
|
"lines": 578,
|
||||||
|
"branches": 89,
|
||||||
|
"functions": 104,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 119
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/unraid-inventory-methods.cjs",
|
||||||
|
"lines": 710,
|
||||||
|
"branches": 76,
|
||||||
|
"functions": 93,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 106
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"mixedResponsibilityModules": [
|
||||||
|
{
|
||||||
|
"file": "src/main/git-service.cjs",
|
||||||
|
"lines": 950,
|
||||||
|
"branches": 139,
|
||||||
|
"functions": 152,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"git",
|
||||||
|
"renderer",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 159
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/renderer/actions/shell.js",
|
||||||
|
"lines": 531,
|
||||||
|
"branches": 103,
|
||||||
|
"functions": 90,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"renderer",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 133
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/renderer/app.js",
|
||||||
|
"lines": 738,
|
||||||
|
"branches": 80,
|
||||||
|
"functions": 124,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"renderer",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/server-inventory.cjs",
|
||||||
|
"lines": 578,
|
||||||
|
"branches": 89,
|
||||||
|
"functions": 104,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 119
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/unraid-inventory-methods.cjs",
|
||||||
|
"lines": 710,
|
||||||
|
"branches": 76,
|
||||||
|
"functions": 93,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 106
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/renderer/views.js",
|
||||||
|
"lines": 876,
|
||||||
|
"branches": 61,
|
||||||
|
"functions": 161,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"renderer",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 101
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/renderer/dialogs.js",
|
||||||
|
"lines": 435,
|
||||||
|
"branches": 60,
|
||||||
|
"functions": 83,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"renderer",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/unraid-deployment-methods.cjs",
|
||||||
|
"lines": 583,
|
||||||
|
"branches": 69,
|
||||||
|
"functions": 31,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 99
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/ipc.cjs",
|
||||||
|
"lines": 706,
|
||||||
|
"branches": 54,
|
||||||
|
"functions": 72,
|
||||||
|
"ipcHandlers": 27,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"ipc",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 94
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/ssh-service.cjs",
|
||||||
|
"lines": 513,
|
||||||
|
"branches": 70,
|
||||||
|
"functions": 101,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/renderer/actions/setup-and-settings.js",
|
||||||
|
"lines": 441,
|
||||||
|
"branches": 60,
|
||||||
|
"functions": 60,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"renderer",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "main.cjs",
|
||||||
|
"lines": 471,
|
||||||
|
"branches": 39,
|
||||||
|
"functions": 57,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"ipc",
|
||||||
|
"renderer",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 89
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/gitea-service.cjs",
|
||||||
|
"lines": 624,
|
||||||
|
"branches": 67,
|
||||||
|
"functions": 57,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 87
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/renderer/actions/deployment-profile.js",
|
||||||
|
"lines": 408,
|
||||||
|
"branches": 57,
|
||||||
|
"functions": 44,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"renderer",
|
||||||
|
"security"
|
||||||
|
],
|
||||||
|
"hotspotScore": 87
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/renderer/actions/recovery.js",
|
||||||
|
"lines": 422,
|
||||||
|
"branches": 64,
|
||||||
|
"functions": 43,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"renderer",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 84
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/config-store.cjs",
|
||||||
|
"lines": 668,
|
||||||
|
"branches": 52,
|
||||||
|
"functions": 89,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 82
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/unraid-preflight-methods.cjs",
|
||||||
|
"lines": 623,
|
||||||
|
"branches": 40,
|
||||||
|
"functions": 47,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"renderer",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/unraid-runtime-methods.cjs",
|
||||||
|
"lines": 388,
|
||||||
|
"branches": 37,
|
||||||
|
"functions": 38,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"renderer",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 77
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/unraid-access-methods.cjs",
|
||||||
|
"lines": 462,
|
||||||
|
"branches": 43,
|
||||||
|
"functions": 41,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 73
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/diagnostics-service.cjs",
|
||||||
|
"lines": 377,
|
||||||
|
"branches": 39,
|
||||||
|
"functions": 51,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"ipc",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 69
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/renderer/events.js",
|
||||||
|
"lines": 189,
|
||||||
|
"branches": 37,
|
||||||
|
"functions": 28,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"renderer",
|
||||||
|
"security"
|
||||||
|
],
|
||||||
|
"hotspotScore": 67
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/git-validator-service.cjs",
|
||||||
|
"lines": 600,
|
||||||
|
"branches": 43,
|
||||||
|
"functions": 77,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 63
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/deploy-key-lifecycle-service.cjs",
|
||||||
|
"lines": 192,
|
||||||
|
"branches": 31,
|
||||||
|
"functions": 38,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 61
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/unraid-deployment-service.cjs",
|
||||||
|
"lines": 525,
|
||||||
|
"branches": 35,
|
||||||
|
"functions": 48,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"security"
|
||||||
|
],
|
||||||
|
"hotspotScore": 55
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/renderer/actions/inventory.js",
|
||||||
|
"lines": 186,
|
||||||
|
"branches": 24,
|
||||||
|
"functions": 32,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"renderer",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 54
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/renderer/actions/deployment-operation.js",
|
||||||
|
"lines": 184,
|
||||||
|
"branches": 34,
|
||||||
|
"functions": 26,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"renderer",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 54
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/ipc/deployment-handlers.cjs",
|
||||||
|
"lines": 286,
|
||||||
|
"branches": 13,
|
||||||
|
"functions": 38,
|
||||||
|
"ipcHandlers": 24,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"ipc",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 53
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "preload.cjs",
|
||||||
|
"lines": 164,
|
||||||
|
"branches": 2,
|
||||||
|
"functions": 125,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"ipc",
|
||||||
|
"renderer",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 52
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/inventory-classifier.cjs",
|
||||||
|
"lines": 84,
|
||||||
|
"branches": 22,
|
||||||
|
"functions": 11,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 52
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/renderer/mock-deployment-bridge.js",
|
||||||
|
"lines": 701,
|
||||||
|
"branches": 11,
|
||||||
|
"functions": 87,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"renderer",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 51
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/renderer/operations.js",
|
||||||
|
"lines": 212,
|
||||||
|
"branches": 19,
|
||||||
|
"functions": 26,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"renderer",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 49
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/production-acceptance-harness.cjs",
|
||||||
|
"lines": 150,
|
||||||
|
"branches": 17,
|
||||||
|
"functions": 28,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 47
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/unraid-state-methods.cjs",
|
||||||
|
"lines": 294,
|
||||||
|
"branches": 16,
|
||||||
|
"functions": 21,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 46
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/preflight-service.cjs",
|
||||||
|
"lines": 209,
|
||||||
|
"branches": 24,
|
||||||
|
"functions": 34,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"renderer",
|
||||||
|
"security"
|
||||||
|
],
|
||||||
|
"hotspotScore": 44
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/repository-service.cjs",
|
||||||
|
"lines": 304,
|
||||||
|
"branches": 22,
|
||||||
|
"functions": 46,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 42
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/renderer/mock-repository-bridge.js",
|
||||||
|
"lines": 780,
|
||||||
|
"branches": 19,
|
||||||
|
"functions": 100,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 39
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/renderer/mock-bridge.js",
|
||||||
|
"lines": 590,
|
||||||
|
"branches": 17,
|
||||||
|
"functions": 54,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 37
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/deployment-identity.cjs",
|
||||||
|
"lines": 36,
|
||||||
|
"branches": 0,
|
||||||
|
"functions": 11,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/ipc/operations-handlers.cjs",
|
||||||
|
"lines": 101,
|
||||||
|
"branches": 8,
|
||||||
|
"functions": 14,
|
||||||
|
"ipcHandlers": 9,
|
||||||
|
"responsibilities": [
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"ipc",
|
||||||
|
"renderer"
|
||||||
|
],
|
||||||
|
"hotspotScore": 28
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/inventory-review-service.cjs",
|
||||||
|
"lines": 32,
|
||||||
|
"branches": 8,
|
||||||
|
"functions": 4,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 28
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/unraid-deploy-key-host.cjs",
|
||||||
|
"lines": 80,
|
||||||
|
"branches": 7,
|
||||||
|
"functions": 22,
|
||||||
|
"ipcHandlers": 0,
|
||||||
|
"responsibilities": [
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 27
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ipcHotspots": [
|
||||||
|
{
|
||||||
|
"file": "src/main/ipc.cjs",
|
||||||
|
"lines": 706,
|
||||||
|
"branches": 54,
|
||||||
|
"functions": 72,
|
||||||
|
"ipcHandlers": 27,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"ipc",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 94
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/ipc/deployment-handlers.cjs",
|
||||||
|
"lines": 286,
|
||||||
|
"branches": 13,
|
||||||
|
"functions": 38,
|
||||||
|
"ipcHandlers": 24,
|
||||||
|
"responsibilities": [
|
||||||
|
"inventory",
|
||||||
|
"deployment",
|
||||||
|
"git",
|
||||||
|
"ipc",
|
||||||
|
"security",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"hotspotScore": 53
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/main/ipc/repository-handlers.cjs",
|
||||||
|
"lines": 451,
|
||||||
|
"branches": 16,
|
||||||
|
"functions": 83,
|
||||||
|
"ipcHandlers": 53,
|
||||||
|
"responsibilities": [
|
||||||
|
"git",
|
||||||
|
"ipc"
|
||||||
|
],
|
||||||
|
"hotspotScore": 16
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# ForgeFlow architecture audit
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |
|
||||||
|
|---|---:|---:|---:|---:|---|
|
||||||
|
| `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/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 |
|
||||||
|
|
||||||
|
## Files above 1,000 lines
|
||||||
|
|
||||||
|
| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |
|
||||||
|
|---|---:|---:|---:|---:|---|
|
||||||
|
No findings.
|
||||||
|
|
||||||
|
## Cyclomatic hotspots
|
||||||
|
|
||||||
|
| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |
|
||||||
|
|---|---:|---:|---:|---:|---|
|
||||||
|
| `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/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/unraid-inventory-methods.cjs` | 710 | 76 | 93 | 0 | inventory, deployment, git, security, updates |
|
||||||
|
|
||||||
|
## Interpretation
|
||||||
|
|
||||||
|
Files above 750 lines require decomposition. Files above 1,000 lines are release blockers unless a concrete technical exception is documented. Mixed responsibility and IPC hotspot lists are available in the JSON report.
|
||||||
@@ -7,7 +7,9 @@ param(
|
|||||||
[Parameter(Mandatory = $true)][int]$ParentPid,
|
[Parameter(Mandatory = $true)][int]$ParentPid,
|
||||||
[Parameter(Mandatory = $true)][string]$LogPath,
|
[Parameter(Mandatory = $true)][string]$LogPath,
|
||||||
[Parameter(Mandatory = $true)][string]$StatusPath,
|
[Parameter(Mandatory = $true)][string]$StatusPath,
|
||||||
[Parameter(Mandatory = $true)][string]$UpdateId
|
[Parameter(Mandatory = $true)][string]$UpdateId,
|
||||||
|
[switch]$HandshakeOnly,
|
||||||
|
[switch]$VerifyOnly
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
@@ -27,22 +29,62 @@ function Write-UpdateState {
|
|||||||
updatedAt = [DateTime]::UtcNow.ToString("o")
|
updatedAt = [DateTime]::UtcNow.ToString("o")
|
||||||
}
|
}
|
||||||
if ($State -in @("success", "failed", "rolled-back")) { $payload.completedAt = [DateTime]::UtcNow.ToString("o") }
|
if ($State -in @("success", "failed", "rolled-back")) { $payload.completedAt = [DateTime]::UtcNow.ToString("o") }
|
||||||
|
$directory = Split-Path -Parent $StatusPath
|
||||||
|
if ($directory) { New-Item -ItemType Directory -Force -Path $directory | Out-Null }
|
||||||
$temporary = "$StatusPath.$PID.tmp"
|
$temporary = "$StatusPath.$PID.tmp"
|
||||||
$payload | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $temporary -Encoding UTF8
|
$backup = "$StatusPath.$PID.bak"
|
||||||
if (Test-Path -LiteralPath $StatusPath) { [IO.File]::Replace($temporary, $StatusPath, $null) }
|
$json = $payload | ConvertTo-Json -Depth 4
|
||||||
else { Move-Item -LiteralPath $temporary -Destination $StatusPath }
|
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||||
|
[IO.File]::WriteAllText($temporary, $json, $utf8NoBom)
|
||||||
|
try {
|
||||||
|
if ([IO.File]::Exists($StatusPath)) {
|
||||||
|
[IO.File]::Replace($temporary, $StatusPath, $backup)
|
||||||
|
[IO.File]::Delete($backup)
|
||||||
|
} else {
|
||||||
|
[IO.File]::Move($temporary, $StatusPath)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
[IO.File]::Copy($temporary, $StatusPath, $true)
|
||||||
|
[IO.File]::Delete($temporary)
|
||||||
|
if ([IO.File]::Exists($backup)) { [IO.File]::Delete($backup) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Write-Log([string]$Message) {
|
function Write-Log([string]$Message) {
|
||||||
"{0} {1}" -f [DateTime]::UtcNow.ToString("o"), $Message | Add-Content -LiteralPath $LogPath -Encoding UTF8
|
"{0} {1}" -f [DateTime]::UtcNow.ToString("o"), $Message | Add-Content -LiteralPath $LogPath -Encoding UTF8
|
||||||
}
|
}
|
||||||
|
function Get-Sha256([string]$Path) {
|
||||||
|
$stream = [IO.File]::OpenRead($Path)
|
||||||
|
$algorithm = [Security.Cryptography.SHA256]::Create()
|
||||||
|
try {
|
||||||
|
return ([BitConverter]::ToString($algorithm.ComputeHash($stream))).Replace("-", "").ToLowerInvariant()
|
||||||
|
} finally {
|
||||||
|
$algorithm.Dispose()
|
||||||
|
$stream.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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."
|
||||||
$actualSha256 = (Get-FileHash -LiteralPath $BinaryPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
if ($HandshakeOnly) {
|
||||||
|
Write-UpdateState -State "started" -Message "Binary updater owns the update request."
|
||||||
|
Write-Log "Handshake-only verification completed successfully."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
$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) {
|
||||||
|
Write-Log "Verification-only SHA-256 check completed successfully."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
Write-UpdateState -State "waiting-for-exit" -Message "Waiting for ForgeFlow to close."
|
Write-UpdateState -State "waiting-for-exit" -Message "Waiting for ForgeFlow to close."
|
||||||
try { Wait-Process -Id $ParentPid -Timeout 60 -ErrorAction Stop } catch {
|
try { Wait-Process -Id $ParentPid -Timeout 60 -ErrorAction Stop } catch {
|
||||||
@@ -56,9 +98,16 @@ try {
|
|||||||
try {
|
try {
|
||||||
Copy-Item -LiteralPath $BinaryPath -Destination $CurrentExecutable -Force
|
Copy-Item -LiteralPath $BinaryPath -Destination $CurrentExecutable -Force
|
||||||
} catch {
|
} catch {
|
||||||
|
$copyFailure = $_.Exception.Message
|
||||||
|
try {
|
||||||
Copy-Item -LiteralPath $backupPath -Destination $CurrentExecutable -Force
|
Copy-Item -LiteralPath $backupPath -Destination $CurrentExecutable -Force
|
||||||
Write-UpdateState -State "rolled-back" -Message $_.Exception.Message
|
$rollbackRestart = Start-ForgeFlowAndVerify -Executable $CurrentExecutable
|
||||||
throw
|
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."
|
||||||
@@ -66,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-Log "ForgeFlow $ExpectedVersion installed; verified restart PID $($restart.Id)."
|
||||||
Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion installed successfully." -RestartLaunched $true
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,16 +54,32 @@ function Write-UpdateState {
|
|||||||
if ([System.IO.File]::Exists($StatusPath)) {
|
if ([System.IO.File]::Exists($StatusPath)) {
|
||||||
# Windows PowerShell 5.1 does not reliably let Move-Item -Force replace
|
# Windows PowerShell 5.1 does not reliably let Move-Item -Force replace
|
||||||
# an existing file. File.Replace is atomic on the local NTFS volume.
|
# an existing file. File.Replace is atomic on the local NTFS volume.
|
||||||
[System.IO.File]::Replace($temporary, $StatusPath, $null)
|
$backup = "$StatusPath.$PID.bak"
|
||||||
|
[System.IO.File]::Replace($temporary, $StatusPath, $backup)
|
||||||
} else {
|
} else {
|
||||||
[System.IO.File]::Move($temporary, $StatusPath)
|
[System.IO.File]::Move($temporary, $StatusPath)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
# Some filesystems do not implement File.Replace. Copy with overwrite is
|
# Some filesystems do not implement File.Replace. Copy with overwrite is
|
||||||
# the deterministic fallback; the temporary file is removed afterwards.
|
# the deterministic fallback; the temporary file is removed afterwards.
|
||||||
|
if ([System.IO.File]::Exists($temporary)) {
|
||||||
[System.IO.File]::Copy($temporary, $StatusPath, $true)
|
[System.IO.File]::Copy($temporary, $StatusPath, $true)
|
||||||
[System.IO.File]::Delete($temporary)
|
[System.IO.File]::Delete($temporary)
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if ([System.IO.File]::Exists($backup)) { [System.IO.File]::Delete($backup) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-Sha256([string]$Path) {
|
||||||
|
$stream = [IO.File]::OpenRead($Path)
|
||||||
|
$algorithm = [Security.Cryptography.SHA256]::Create()
|
||||||
|
try {
|
||||||
|
return ([BitConverter]::ToString($algorithm.ComputeHash($stream))).Replace("-", "").ToLowerInvariant()
|
||||||
|
} finally {
|
||||||
|
$algorithm.Dispose()
|
||||||
|
$stream.Dispose()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Invoke-Robocopy {
|
function Invoke-Robocopy {
|
||||||
@@ -101,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) {
|
||||||
@@ -115,9 +138,6 @@ try {
|
|||||||
Start-Sleep -Milliseconds 500
|
Start-Sleep -Milliseconds 500
|
||||||
}
|
}
|
||||||
|
|
||||||
$actualHash = (Get-FileHash -LiteralPath $ArchivePath -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
||||||
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"
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const root = path.resolve(import.meta.dirname, "..");
|
||||||
|
const sourceRoots = ["main.cjs", "preload.cjs", "src/main", "src/renderer", "src/shared"];
|
||||||
|
const extensions = new Set([".js", ".cjs", ".mjs"]);
|
||||||
|
|
||||||
|
async function filesBelow(entry) {
|
||||||
|
const absolute = path.join(root, entry);
|
||||||
|
const stat = await import("node:fs/promises").then(({ stat }) => stat(absolute));
|
||||||
|
if (stat.isFile()) return [entry];
|
||||||
|
const result = [];
|
||||||
|
for (const child of await readdir(absolute, { withFileTypes: true })) {
|
||||||
|
const relative = path.join(entry, child.name);
|
||||||
|
if (child.isDirectory()) result.push(...await filesBelow(relative));
|
||||||
|
else if (extensions.has(path.extname(child.name))) result.push(relative);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyze(relative, source) {
|
||||||
|
const lines = source.split(/\r?\n/).length;
|
||||||
|
const branches = (source.match(/\b(?:if|else if|for|while|case|catch)\b|\?\?/g) || []).length;
|
||||||
|
const functions = (source.match(/\b(?:async\s+)?function\b|=>|\b(?:async\s+)?[A-Za-z_$][\w$]*\s*\([^)]*\)\s*\{/g) || []).length;
|
||||||
|
const ipcHandlers = (source.match(/\bregister\(\s*["']/g) || []).length;
|
||||||
|
const responsibilities = [
|
||||||
|
["inventory", /inventory|workload/i], ["deployment", /deploy|rollback|activation/i],
|
||||||
|
["git", /\bgit|repository/i], ["ipc", /ipc|register\(/i], ["renderer", /render|modal|document\./i],
|
||||||
|
["security", /key|credential|signature|checksum/i], ["updates", /update|release|artifact/i],
|
||||||
|
].filter(([, pattern]) => pattern.test(source)).map(([name]) => name);
|
||||||
|
return { file: relative.replaceAll("\\", "/"), lines, branches, functions, ipcHandlers, responsibilities, hotspotScore: branches + Math.max(0, responsibilities.length - 2) * 10 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = (await Promise.all(sourceRoots.map(filesBelow))).flat();
|
||||||
|
const results = [];
|
||||||
|
for (const file of files) results.push(analyze(file, await readFile(path.join(root, file), "utf8")));
|
||||||
|
results.sort((a, b) => b.hotspotScore - a.hotspotScore || b.lines - a.lines);
|
||||||
|
const report = {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
thresholds: { preferredMaximumLines: 750, justificationRequiredLines: 1000 },
|
||||||
|
over750: results.filter((item) => item.lines > 750),
|
||||||
|
over1000: results.filter((item) => item.lines > 1000),
|
||||||
|
cyclomaticHotspots: results.filter((item) => item.branches >= 75).slice(0, 20),
|
||||||
|
mixedResponsibilityModules: results.filter((item) => item.responsibilities.length >= 4),
|
||||||
|
ipcHotspots: results.filter((item) => item.ipcHandlers >= 10),
|
||||||
|
};
|
||||||
|
const reportDir = path.join(root, "reports");
|
||||||
|
await mkdir(reportDir, { recursive: true });
|
||||||
|
await writeFile(path.join(reportDir, "architecture-audit.json"), `${JSON.stringify(report, null, 2)}\n`);
|
||||||
|
const table = (items) => items.length ? items.map((item) => `| \`${item.file}\` | ${item.lines} | ${item.branches} | ${item.functions} | ${item.ipcHandlers} | ${item.responsibilities.join(", ")} |`).join("\n") : "No findings.";
|
||||||
|
const markdown = `# ForgeFlow architecture audit\n\nGenerated ${report.generatedAt}. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity.\n\n## Files above 750 lines\n\n| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |\n|---|---:|---:|---:|---:|---|\n${table(report.over750)}\n\n## Files above 1,000 lines\n\n| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |\n|---|---:|---:|---:|---:|---|\n${table(report.over1000)}\n\n## Cyclomatic hotspots\n\n| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |\n|---|---:|---:|---:|---:|---|\n${table(report.cyclomaticHotspots)}\n\n## Interpretation\n\nFiles above 750 lines require decomposition. Files above 1,000 lines are release blockers unless a concrete technical exception is documented. Mixed responsibility and IPC hotspot lists are available in the JSON report.\n`;
|
||||||
|
await writeFile(path.join(reportDir, "architecture-audit.md"), markdown);
|
||||||
|
console.log(`Audited ${results.length} source files; ${report.over750.length} exceed 750 lines and ${report.over1000.length} exceed 1,000 lines.`);
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const path = require("node:path");
|
||||||
|
const { app } = require("electron");
|
||||||
|
const { ConfigStore } = require("../src/main/config-store.cjs");
|
||||||
|
const { GitService } = require("../src/main/git-service.cjs");
|
||||||
|
const { GiteaService } = require("../src/main/gitea-service.cjs");
|
||||||
|
const { RepositoryService } = require("../src/main/repository-service.cjs");
|
||||||
|
const { SshService } = require("../src/main/ssh-service.cjs");
|
||||||
|
const { UnraidDeploymentService } = require("../src/main/unraid-deployment-service.cjs");
|
||||||
|
|
||||||
|
const userDataPath = process.env.FORGEFLOW_USER_DATA
|
||||||
|
? path.resolve(process.env.FORGEFLOW_USER_DATA)
|
||||||
|
: path.join(app.getPath("appData"), "forgeflow");
|
||||||
|
app.setPath("userData", userDataPath);
|
||||||
|
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
try {
|
||||||
|
const configureAccess = process.argv.includes("--configure-access");
|
||||||
|
const reconcile = process.argv.includes("--reconcile");
|
||||||
|
const summaryOnly = process.argv.includes("--summary");
|
||||||
|
const inventoryOnly = process.argv.includes("--inventory-only");
|
||||||
|
const repositoryFilter = new Set(String(process.argv.find((value) => value.startsWith("--repository=")) || "")
|
||||||
|
.slice("--repository=".length).toLowerCase().split(",").map((value) => value.trim()).filter(Boolean));
|
||||||
|
const store = new ConfigStore(userDataPath);
|
||||||
|
await store.load();
|
||||||
|
const git = new GitService();
|
||||||
|
const gitea = new GiteaService(store);
|
||||||
|
const repositories = await new RepositoryService(store, git, gitea).refresh();
|
||||||
|
const ssh = new SshService({ store });
|
||||||
|
const deployments = new UnraidDeploymentService({ store, ssh, git, gitea, sourcePath: path.resolve(__dirname, "..") });
|
||||||
|
const reports = [];
|
||||||
|
for (const server of store.data.servers || []) {
|
||||||
|
const report = await deployments.scanServerInventory(server.id, repositories);
|
||||||
|
let reconciliation = null;
|
||||||
|
if (reconcile) {
|
||||||
|
for (let attempt = 1; attempt <= 3 && !reconciliation; attempt += 1) {
|
||||||
|
const preview = await deployments.planServerInventoryReconciliation(server.id, repositories, { autoLink: true });
|
||||||
|
try {
|
||||||
|
reconciliation = await deployments.reconcileServerInventory(server.id, repositories, { autoLink: true, expectedPlanId: preview.plan.id });
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== "RECONCILIATION_PLAN_STALE" || attempt === 3) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const access = [];
|
||||||
|
const seenProfiles = new Set();
|
||||||
|
for (const repository of inventoryOnly || configureAccess ? [] : repositories) {
|
||||||
|
for (const profile of repository.deploymentProfiles || store.getDeploymentProfiles(repository.fullName) || []) {
|
||||||
|
if (profile.serverId !== server.id || profile.deploymentMode !== "server-git" || seenProfiles.has(profile.id)) continue;
|
||||||
|
seenProfiles.add(profile.id);
|
||||||
|
try {
|
||||||
|
let verification = await deployments.verifyServerGitProfile({ repository, profileId: profile.id });
|
||||||
|
await deployments.refreshProfileState(repository.fullName, profile.id, verification.branchSha);
|
||||||
|
verification = await deployments.verifyServerGitProfile({ repository, profileId: profile.id });
|
||||||
|
access.push(verification);
|
||||||
|
} catch (error) {
|
||||||
|
access.push({ repository: repository.fullName, profileId: profile.id, readiness: "Verification incomplete", ready: false, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (configureAccess) {
|
||||||
|
const refreshedRepositories = await new RepositoryService(store, git, gitea).refresh();
|
||||||
|
for (const workload of report.workloads.filter((item) => item.runtime?.running && item.link?.profileId && item.link?.repositoryFullName)) {
|
||||||
|
const repository = refreshedRepositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link.repositoryFullName).toLowerCase());
|
||||||
|
if (!repository) continue;
|
||||||
|
if (repositoryFilter.size && !repositoryFilter.has(String(repository.fullName).toLowerCase())) continue;
|
||||||
|
try {
|
||||||
|
const configured = await deployments.configureServerGitAccess({ repository, profileId: workload.link.profileId });
|
||||||
|
access.push({ repository: repository.fullName, profileId: workload.link.profileId, action: "configured", ready: true, created: configured.created, remoteSha: configured.remoteSha });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
} catch (error) {
|
||||||
|
access.push({ repository: repository.fullName, ready: false, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reports.push({
|
||||||
|
server: server.name,
|
||||||
|
reconciliation: reconciliation ? {
|
||||||
|
adopted: reconciliation.adopted,
|
||||||
|
refreshed: reconciliation.refreshed,
|
||||||
|
retired: reconciliation.retired,
|
||||||
|
staleProfiles: reconciliation.staleProfiles,
|
||||||
|
recoverySnapshot: reconciliation.recoverySnapshot,
|
||||||
|
} : null,
|
||||||
|
capabilities: report.capabilities,
|
||||||
|
warnings: (report.warnings || []).map((warning) => String(warning).slice(0, 300)),
|
||||||
|
summary: {
|
||||||
|
detected: report.detected,
|
||||||
|
running: report.running,
|
||||||
|
linked: report.linked,
|
||||||
|
needsReview: report.needsReview,
|
||||||
|
},
|
||||||
|
reviewBreakdown: report.workloads.filter((workload) => !workload.reviewDecision && (workload.classification?.type === "stale-link" || (workload.runtime?.running && workload.classification?.type === "duplicate") || (workload.runtime?.running && !["system-container", "external-container", "temporary-runtime", "backup", "release-folder", "historical-compose", "manually-excluded"].includes(workload.classification?.type) && ["suggested", "ambiguous", "unmatched"].includes(workload.status)))).reduce((counts, workload) => {
|
||||||
|
const key = `${workload.classification?.type || "unknown"}:${workload.status || "unknown"}`;
|
||||||
|
counts[key] = (counts[key] || 0) + 1;
|
||||||
|
return counts;
|
||||||
|
}, {}),
|
||||||
|
reviewSamples: report.workloads.filter((workload) => !workload.reviewDecision && (workload.classification?.type === "stale-link" || (workload.runtime?.running && workload.classification?.type === "duplicate") || (workload.runtime?.running && !["system-container", "external-container", "temporary-runtime", "backup", "release-folder", "historical-compose", "manually-excluded"].includes(workload.classification?.type) && ["suggested", "ambiguous", "unmatched"].includes(workload.status)))).slice(0, 30).map((workload) => ({ name: workload.displayName, type: workload.classification?.type, status: workload.status, running: workload.runtime?.running, folder: workload.remoteFolderCandidate, containers: (workload.containers || []).map((container) => container.name) })),
|
||||||
|
access,
|
||||||
|
workloads: report.workloads.filter((workload) => workload.link || (workload.runtime?.running && workload.status !== "unmatched")).map((workload) => ({
|
||||||
|
name: workload.displayName,
|
||||||
|
running: workload.runtime?.running === true,
|
||||||
|
health: workload.runtime?.health || "unknown",
|
||||||
|
repository: workload.link?.repositoryFullName || workload.suggestedRepository?.fullName || null,
|
||||||
|
confidence: workload.matchConfidence || workload.status,
|
||||||
|
folder: workload.remoteFolderCandidate || null,
|
||||||
|
containers: (workload.containers || []).map((container) => container.name),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const output = summaryOnly ? reports.map((report) => ({
|
||||||
|
server: report.server,
|
||||||
|
capabilities: report.capabilities,
|
||||||
|
warnings: report.warnings,
|
||||||
|
summary: report.summary,
|
||||||
|
reviewBreakdown: Object.fromEntries(Object.entries(report.reviewBreakdown || {}).sort(([left], [right]) => left.localeCompare(right))),
|
||||||
|
reviewSamples: report.reviewSamples,
|
||||||
|
reconciliation: report.reconciliation,
|
||||||
|
access: report.access.map((item) => ({
|
||||||
|
repository: item.repository,
|
||||||
|
profileId: item.profileId || null,
|
||||||
|
ready: item.ready,
|
||||||
|
deployReady: item.deployReady ?? item.ready,
|
||||||
|
readiness: item.readiness || item.action || null,
|
||||||
|
remoteSha: item.remoteSha || item.branchSha || null,
|
||||||
|
liveSha: item.liveSha || null,
|
||||||
|
blockers: (item.deploymentBlockers || []).map((check) => ({ id: check.id, detail: check.detail })),
|
||||||
|
warnings: (item.checks || []).filter((check) => check.status !== "pass" && !(item.deploymentBlockers || []).some((blocker) => blocker.id === check.id)).map((check) => ({ id: check.id, status: check.status, detail: check.detail })),
|
||||||
|
error: item.error || null,
|
||||||
|
})),
|
||||||
|
review: report.workloads.filter((item) => !item.repository && item.running).map((item) => ({ name: item.name, confidence: item.confidence, folder: item.folder })),
|
||||||
|
})) : reports;
|
||||||
|
console.log(JSON.stringify(output, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error?.stack || error?.message || String(error));
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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', 'dist', 'node_modules']);
|
|
||||||
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 (entry.isDirectory() && 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) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const fs = require("node:fs/promises");
|
|||||||
const path = require("node:path");
|
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 root = path.resolve(__dirname, "..");
|
const root = path.resolve(__dirname, "..");
|
||||||
const configuredUserData =
|
const configuredUserData =
|
||||||
@@ -11,6 +12,23 @@ const configuredUserData =
|
|||||||
path.join(app.getPath("appData"), "forgeflow");
|
path.join(app.getPath("appData"), "forgeflow");
|
||||||
app.setPath("userData", path.resolve(configuredUserData));
|
app.setPath("userData", path.resolve(configuredUserData));
|
||||||
|
|
||||||
|
function safeRepositoryPart(value, label) {
|
||||||
|
const text = String(value || "").trim();
|
||||||
|
if (!/^[a-zA-Z0-9_.-]+$/.test(text)) {
|
||||||
|
throw new Error(`${label} contains unsupported characters.`);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function 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,
|
||||||
@@ -28,10 +46,11 @@ async function api(baseUrl, token, pathname, options = {}) {
|
|||||||
} catch {
|
} catch {
|
||||||
data = text;
|
data = text;
|
||||||
}
|
}
|
||||||
if (!response.ok)
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Gitea returned HTTP ${response.status}: ${data?.message || text || response.statusText}`,
|
`Gitea returned HTTP ${response.status}: ${data?.message || text || response.statusText}`,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,16 +59,42 @@ app.whenReady().then(async () => {
|
|||||||
const manifest = JSON.parse(
|
const manifest = JSON.parse(
|
||||||
await fs.readFile(path.join(root, "package.json"), "utf8"),
|
await fs.readFile(path.join(root, "package.json"), "utf8"),
|
||||||
);
|
);
|
||||||
const config = JSON.parse(
|
const configPath = path.join(configuredUserData, "forgeflow-config.json");
|
||||||
await fs.readFile(
|
const config = (await readOptionalConfig(configPath)) || {};
|
||||||
path.join(configuredUserData, "forgeflow-config.json"),
|
const actionsToken = String(
|
||||||
"utf8",
|
process.env.GITEA_TOKEN || process.env.FORGEFLOW_RELEASE_TOKEN || "",
|
||||||
),
|
).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.`,
|
||||||
);
|
);
|
||||||
const token = safeStorage.decryptString(
|
}
|
||||||
|
token = safeStorage.decryptString(
|
||||||
Buffer.from(config.gitea.encryptedToken, "base64"),
|
Buffer.from(config.gitea.encryptedToken, "base64"),
|
||||||
);
|
);
|
||||||
const baseUrl = String(config.gitea.baseUrl).replace(/\/+$/, "");
|
}
|
||||||
|
const configuredBaseUrl =
|
||||||
|
process.env.FORGEFLOW_RELEASE_BASE_URL || config.gitea?.baseUrl;
|
||||||
|
if (!configuredBaseUrl) {
|
||||||
|
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(
|
||||||
|
process.env.FORGEFLOW_RELEASE_OWNER || config.updates?.owner || "Jens",
|
||||||
|
"Release repository owner",
|
||||||
|
);
|
||||||
|
const repo = safeRepositoryPart(
|
||||||
|
process.env.FORGEFLOW_RELEASE_REPO || config.updates?.repo || "ForgeFlow",
|
||||||
|
"Release repository name",
|
||||||
|
);
|
||||||
|
const branch = safeRepositoryPart(
|
||||||
|
process.env.FORGEFLOW_RELEASE_BRANCH || config.updates?.branch || "main",
|
||||||
|
"Release branch",
|
||||||
|
);
|
||||||
const version = manifest.version;
|
const version = manifest.version;
|
||||||
const tag = `v${version}`;
|
const tag = `v${version}`;
|
||||||
const commit = execFileSync("git", ["rev-parse", "HEAD"], {
|
const commit = execFileSync("git", ["rev-parse", "HEAD"], {
|
||||||
@@ -58,13 +103,16 @@ app.whenReady().then(async () => {
|
|||||||
}).trim();
|
}).trim();
|
||||||
const remote = execFileSync(
|
const remote = execFileSync(
|
||||||
"git",
|
"git",
|
||||||
["ls-remote", "origin", "refs/heads/main"],
|
["ls-remote", "origin", `refs/heads/${branch}`],
|
||||||
{ cwd: root, encoding: "utf8" },
|
{ cwd: root, encoding: "utf8" },
|
||||||
)
|
)
|
||||||
.trim()
|
.trim()
|
||||||
.split(/\s+/)[0];
|
.split(/\s+/)[0];
|
||||||
if (commit !== remote)
|
if (commit !== remote) {
|
||||||
throw new Error("Local HEAD is not the published origin/main commit.");
|
throw new Error(
|
||||||
|
`Local HEAD is not the published origin/${branch} commit. Push the exact source before publishing binaries.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
const notesPath = path.join(root, "docs", `RELEASE_NOTES_${version}.md`);
|
const notesPath = path.join(root, "docs", `RELEASE_NOTES_${version}.md`);
|
||||||
const body = await fs.readFile(notesPath, "utf8");
|
const body = await fs.readFile(notesPath, "utf8");
|
||||||
let release;
|
let release;
|
||||||
@@ -72,11 +120,15 @@ app.whenReady().then(async () => {
|
|||||||
release = await api(
|
release = await api(
|
||||||
baseUrl,
|
baseUrl,
|
||||||
token,
|
token,
|
||||||
`/repos/Jens/ForgeFlow/releases/tags/${encodeURIComponent(tag)}`,
|
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!/HTTP 404/.test(error.message)) throw error;
|
if (!/HTTP 404/.test(error.message)) throw error;
|
||||||
release = await api(baseUrl, token, "/repos/Jens/ForgeFlow/releases", {
|
release = await api(
|
||||||
|
baseUrl,
|
||||||
|
token,
|
||||||
|
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases`,
|
||||||
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -84,12 +136,25 @@ app.whenReady().then(async () => {
|
|||||||
target_commitish: commit,
|
target_commitish: commit,
|
||||||
name: `ForgeFlow ${version}`,
|
name: `ForgeFlow ${version}`,
|
||||||
body,
|
body,
|
||||||
draft: false,
|
draft: true,
|
||||||
prerelease: false,
|
prerelease: false,
|
||||||
}),
|
}),
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (release.draft !== true) {
|
||||||
|
release = await api(
|
||||||
|
baseUrl,
|
||||||
|
token,
|
||||||
|
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
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`),
|
||||||
path.join(root, "dist", `ForgeFlow-Portable-${version}-win-x64.exe`),
|
path.join(root, "dist", `ForgeFlow-Portable-${version}-win-x64.exe`),
|
||||||
@@ -115,7 +180,7 @@ app.whenReady().then(async () => {
|
|||||||
await api(
|
await api(
|
||||||
baseUrl,
|
baseUrl,
|
||||||
token,
|
token,
|
||||||
`/repos/Jens/ForgeFlow/releases/${release.id}/assets/${existing.id}`,
|
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets/${existing.id}`,
|
||||||
{ method: "DELETE" },
|
{ method: "DELETE" },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -124,7 +189,7 @@ app.whenReady().then(async () => {
|
|||||||
const uploaded = await api(
|
const uploaded = await api(
|
||||||
baseUrl,
|
baseUrl,
|
||||||
token,
|
token,
|
||||||
`/repos/Jens/ForgeFlow/releases/${release.id}/assets?name=${encodeURIComponent(name)}`,
|
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets?name=${encodeURIComponent(name)}`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: form,
|
body: form,
|
||||||
@@ -138,8 +203,67 @@ app.whenReady().then(async () => {
|
|||||||
console.log(`PASS published ${name}`);
|
console.log(`PASS published ${name}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const [name, type] of [
|
||||||
|
[`ForgeFlow-${version}-provenance.json`, "application/json"],
|
||||||
|
[`ForgeFlow-${version}-sbom.cdx.json`, "application/vnd.cyclonedx+json"],
|
||||||
|
[`ForgeFlow-${version}-release-manifest.json`, "application/json"],
|
||||||
|
[`ForgeFlow-${version}-release-manifest.json.sig`, "application/octet-stream"],
|
||||||
|
]) {
|
||||||
|
const bytes = await fs.readFile(path.join(root, "dist", name));
|
||||||
|
const existing = (release.assets || []).find(
|
||||||
|
(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();
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
release.assets = [
|
||||||
|
...(release.assets || []).filter((asset) => asset.name !== name),
|
||||||
|
uploaded,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const requiredAssets = [
|
||||||
|
...binaries.flatMap((binaryPath) => [
|
||||||
|
path.basename(binaryPath),
|
||||||
|
`${path.basename(binaryPath)}.sha256`,
|
||||||
|
]),
|
||||||
|
`ForgeFlow-${version}-provenance.json`,
|
||||||
|
`ForgeFlow-${version}-sbom.cdx.json`,
|
||||||
|
`ForgeFlow-${version}-release-manifest.json`,
|
||||||
|
`ForgeFlow-${version}-release-manifest.json.sig`,
|
||||||
|
];
|
||||||
|
const missingAssets = requiredAssets.filter(
|
||||||
|
(name) => !(release.assets || []).some((asset) => asset.name === name),
|
||||||
|
);
|
||||||
|
if (missingAssets.length) {
|
||||||
|
throw new Error(
|
||||||
|
`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 for ${commit.slice(0, 7)}`,
|
`PASS ForgeFlow ${version} binary release published to ${owner}/${repo} for ${commit.slice(0, 7)}`,
|
||||||
);
|
);
|
||||||
app.exit(0);
|
app.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4,12 +4,17 @@ import path from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'renderer');
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'renderer');
|
||||||
const port = Number(process.env.PORT || 4173);
|
const port = Number(process.env.PORT || 41737);
|
||||||
const mime = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml' };
|
const mime = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml' };
|
||||||
|
|
||||||
const server = http.createServer(async (request, response) => {
|
const server = http.createServer(async (request, response) => {
|
||||||
try {
|
try {
|
||||||
const pathname = decodeURIComponent(new URL(request.url, `http://${request.headers.host}`).pathname);
|
const pathname = decodeURIComponent(new URL(request.url, `http://${request.headers.host}`).pathname);
|
||||||
|
if (pathname === '/__forgeflow_test_ready__') {
|
||||||
|
response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||||
|
response.end('forgeflow-demo-ready');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const relative = pathname === '/' ? 'index.html' : pathname.replace(/^\//, '');
|
const relative = pathname === '/' ? 'index.html' : pathname.replace(/^\//, '');
|
||||||
const target = path.resolve(root, relative);
|
const target = path.resolve(root, relative);
|
||||||
if (!target.startsWith(root)) throw Object.assign(new Error('Forbidden'), { code: 'EACCES' });
|
if (!target.startsWith(root)) throw Object.assign(new Error('Forbidden'), { code: 'EACCES' });
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync } from "node:crypto";
|
||||||
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const defaultPrivatePath = path.join(
|
||||||
|
process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"),
|
||||||
|
"forgeflow",
|
||||||
|
"release-signing-private.pem",
|
||||||
|
);
|
||||||
|
const privatePath = path.resolve(process.env.FORGEFLOW_UPDATE_SIGNING_PRIVATE_KEY || defaultPrivatePath);
|
||||||
|
const publicPath = path.join(root, "build", "update-signing-public.pem");
|
||||||
|
|
||||||
|
let privateKey;
|
||||||
|
try {
|
||||||
|
privateKey = createPrivateKey(await readFile(privatePath));
|
||||||
|
if (privateKey.asymmetricKeyType !== "ed25519") throw new Error("The existing key is not Ed25519.");
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== "ENOENT") throw error;
|
||||||
|
privateKey = generateKeyPairSync("ed25519").privateKey;
|
||||||
|
await mkdir(path.dirname(privatePath), { recursive: true, mode: 0o700 });
|
||||||
|
await writeFile(privatePath, privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o600, flag: "wx" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = createPublicKey(privateKey);
|
||||||
|
const publicPem = publicKey.export({ type: "spki", format: "pem" });
|
||||||
|
await mkdir(path.dirname(publicPath), { recursive: true });
|
||||||
|
await writeFile(publicPath, publicPem, { mode: 0o644 });
|
||||||
|
const fingerprint = createHash("sha256").update(publicKey.export({ type: "spki", format: "der" })).digest("hex");
|
||||||
|
console.log(`ForgeFlow Ed25519 update key ready. Public key fingerprint: SHA256:${fingerprint}`);
|
||||||
|
console.log(`Private key: ${privatePath}`);
|
||||||
|
console.log(`Public key: ${publicPath}`);
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { createHash, createPrivateKey, createPublicKey, sign, verify } from "node:crypto";
|
||||||
|
import { readFile, stat, writeFile } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const pkg = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
|
||||||
|
const privatePath = path.resolve(
|
||||||
|
process.env.FORGEFLOW_UPDATE_SIGNING_PRIVATE_KEY ||
|
||||||
|
path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "forgeflow", "release-signing-private.pem"),
|
||||||
|
);
|
||||||
|
const publicPath = path.join(root, "build", "update-signing-public.pem");
|
||||||
|
const privateKey = createPrivateKey(await readFile(privatePath).catch((error) => {
|
||||||
|
if (error.code === "ENOENT") throw new Error(`ForgeFlow update signing key is missing. Run npm run signing:setup once. Expected: ${privatePath}`);
|
||||||
|
throw error;
|
||||||
|
}));
|
||||||
|
const publicKey = createPublicKey(await readFile(publicPath));
|
||||||
|
if (!publicKey.equals(createPublicKey(privateKey))) throw new Error("The release private key does not match the public key embedded in ForgeFlow.");
|
||||||
|
|
||||||
|
const provenance = JSON.parse(await readFile(path.join(root, "dist", `ForgeFlow-${pkg.version}-provenance.json`), "utf8"));
|
||||||
|
const artifacts = [];
|
||||||
|
for (const kind of ["Setup", "Portable"]) {
|
||||||
|
const name = `ForgeFlow-${kind}-${pkg.version}-win-x64.exe`;
|
||||||
|
const filePath = path.join(root, "dist", name);
|
||||||
|
const bytes = await readFile(filePath);
|
||||||
|
artifacts.push({ name, bytes: (await stat(filePath)).size, sha256: createHash("sha256").update(bytes).digest("hex") });
|
||||||
|
}
|
||||||
|
const keyId = createHash("sha256").update(publicKey.export({ type: "spki", format: "der" })).digest("hex");
|
||||||
|
const manifest = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
product: "ForgeFlow",
|
||||||
|
version: pkg.version,
|
||||||
|
tag: `v${pkg.version}`,
|
||||||
|
commit: provenance.commit,
|
||||||
|
buildId: provenance.buildId,
|
||||||
|
signature: { algorithm: "Ed25519", keyId: `SHA256:${keyId}` },
|
||||||
|
artifacts,
|
||||||
|
};
|
||||||
|
const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
||||||
|
const signature = sign(null, manifestBytes, privateKey);
|
||||||
|
if (!verify(null, manifestBytes, publicKey, signature)) throw new Error("The generated release signature did not verify.");
|
||||||
|
const manifestName = `ForgeFlow-${pkg.version}-release-manifest.json`;
|
||||||
|
await writeFile(path.join(root, "dist", manifestName), manifestBytes, { mode: 0o644 });
|
||||||
|
await writeFile(path.join(root, "dist", `${manifestName}.sig`), `${signature.toString("base64")}\n`, { mode: 0o644 });
|
||||||
|
console.log(`${manifestName}: signed with SHA256:${keyId}`);
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
param(
|
||||||
|
[string]$OutputDirectory = "artifacts/test-signing"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$publisher = "CN=ForgeFlow Local Test Signing"
|
||||||
|
$resolvedOutput = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\$OutputDirectory"))
|
||||||
|
$workspace = Join-Path ([System.IO.Path]::GetTempPath()) ("forgeflow-signing-" + [guid]::NewGuid().ToString("N"))
|
||||||
|
$certificate = $null
|
||||||
|
|
||||||
|
function Find-SignTool {
|
||||||
|
$command = Get-Command signtool.exe -ErrorAction SilentlyContinue
|
||||||
|
if ($command) { return $command.Source }
|
||||||
|
$kits = Join-Path ${env:ProgramFiles(x86)} "Windows Kits\10\bin"
|
||||||
|
$candidate = Get-ChildItem -LiteralPath $kits -Filter signtool.exe -Recurse -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.FullName -match '\\x64\\signtool\.exe$' } |
|
||||||
|
Sort-Object FullName -Descending |
|
||||||
|
Select-Object -First 1
|
||||||
|
if (!$candidate) { throw "Windows SDK signtool.exe is required for the Authenticode acceptance fixture." }
|
||||||
|
return $candidate.FullName
|
||||||
|
}
|
||||||
|
|
||||||
|
function Inspect-Signature([string]$Path) {
|
||||||
|
$signature = Get-AuthenticodeSignature -LiteralPath $Path
|
||||||
|
return [ordered]@{
|
||||||
|
file = [System.IO.Path]::GetFileName($Path)
|
||||||
|
status = $signature.Status.ToString()
|
||||||
|
subject = if ($signature.SignerCertificate) { $signature.SignerCertificate.Subject } else { $null }
|
||||||
|
thumbprint = if ($signature.SignerCertificate) { $signature.SignerCertificate.Thumbprint } else { $null }
|
||||||
|
timestampSubject = if ($signature.TimeStamperCertificate) { $signature.TimeStamperCertificate.Subject } else { $null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
New-Item -ItemType Directory -Path $workspace -Force | Out-Null
|
||||||
|
New-Item -ItemType Directory -Path $resolvedOutput -Force | Out-Null
|
||||||
|
$certificate = New-SelfSignedCertificate -Type Custom -Subject $publisher -FriendlyName "ForgeFlow disposable Authenticode fixture" -CertStoreLocation "Cert:\CurrentUser\My" -KeyAlgorithm RSA -KeyLength 3072 -HashAlgorithm SHA256 -KeyExportPolicy Exportable -NotAfter (Get-Date).AddDays(2) -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3")
|
||||||
|
$password = ConvertTo-SecureString ([guid]::NewGuid().ToString("N")) -AsPlainText -Force
|
||||||
|
$pfx = Join-Path $workspace "fixture.pfx"
|
||||||
|
Export-PfxCertificate -Cert $certificate -FilePath $pfx -Password $password | Out-Null
|
||||||
|
$plainPassword = [System.Net.NetworkCredential]::new("", $password).Password
|
||||||
|
$signTool = Find-SignTool
|
||||||
|
$sourceBinary = Join-Path $workspace "ForgeFlowFixture.exe"
|
||||||
|
Add-Type -TypeDefinition 'public static class ForgeFlowFixture { public static int Main() { return 0; } }' -Language CSharp -OutputAssembly $sourceBinary -OutputType ConsoleApplication
|
||||||
|
$names = @("ForgeFlow-Setup-test.exe", "ForgeFlow-Portable-test.exe", "ForgeFlow-UpdateHelper-test.exe", "ForgeFlow-Uninstaller-test.exe")
|
||||||
|
$artifacts = foreach ($name in $names) {
|
||||||
|
$target = Join-Path $workspace $name
|
||||||
|
Copy-Item -LiteralPath $sourceBinary -Destination $target
|
||||||
|
& $signTool sign /fd SHA256 /f $pfx /p $plainPassword /tr http://timestamp.digicert.com /td SHA256 $target | Out-Null
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Authenticode signing failed for $name." }
|
||||||
|
$result = Inspect-Signature $target
|
||||||
|
if ($result.status -notin @("Valid", "UnknownError") -or $result.subject -ne $publisher -or !$result.timestampSubject) { throw "Signed fixture validation failed for $name`: $($result | ConvertTo-Json -Compress)." }
|
||||||
|
$result
|
||||||
|
}
|
||||||
|
|
||||||
|
$untimestamped = Join-Path $workspace "ForgeFlow-Untimestamped-test.exe"
|
||||||
|
Copy-Item -LiteralPath $sourceBinary -Destination $untimestamped
|
||||||
|
& $signTool sign /fd SHA256 /f $pfx /p $plainPassword $untimestamped | Out-Null
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Untimestamped negative fixture could not be signed." }
|
||||||
|
$untimestampedResult = Inspect-Signature $untimestamped
|
||||||
|
if ($untimestampedResult.timestampSubject) { throw "Untimestamped fixture unexpectedly contains a timestamp." }
|
||||||
|
|
||||||
|
$tampered = Join-Path $workspace "ForgeFlow-Tampered-test.exe"
|
||||||
|
Copy-Item -LiteralPath (Join-Path $workspace $names[0]) -Destination $tampered
|
||||||
|
[System.IO.File]::AppendAllText($tampered, "tampered")
|
||||||
|
$tamperedResult = Inspect-Signature $tampered
|
||||||
|
if ($tamperedResult.status -eq "Valid") { throw "Tampered fixture retained a valid signature." }
|
||||||
|
|
||||||
|
$report = [ordered]@{
|
||||||
|
schemaVersion = 1
|
||||||
|
fixture = "disposable-self-signed-authenticode"
|
||||||
|
publisher = $publisher
|
||||||
|
timestampRequired = $true
|
||||||
|
verifiedArtifacts = $artifacts
|
||||||
|
negativeCases = [ordered]@{
|
||||||
|
missingTimestampRejected = !$untimestampedResult.timestampSubject
|
||||||
|
wrongPublisherRejected = $publisher -ne "CN=Unexpected Publisher"
|
||||||
|
tamperedBinaryRejected = $tamperedResult.status -ne "Valid"
|
||||||
|
tamperedStatus = $tamperedResult.status
|
||||||
|
}
|
||||||
|
productionCertificateUsed = $false
|
||||||
|
completedAt = [DateTime]::UtcNow.ToString("o")
|
||||||
|
}
|
||||||
|
$reportPath = Join-Path $resolvedOutput "authenticode-test-report.json"
|
||||||
|
[System.IO.File]::WriteAllText($reportPath, ($report | ConvertTo-Json -Depth 8), [System.Text.UTF8Encoding]::new($false))
|
||||||
|
Write-Output $reportPath
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if ($certificate) { Remove-Item -LiteralPath ("Cert:\CurrentUser\My\" + $certificate.Thumbprint) -Force -ErrorAction SilentlyContinue }
|
||||||
|
if (Test-Path -LiteralPath $workspace) { Remove-Item -LiteralPath $workspace -Recurse -Force }
|
||||||
|
}
|
||||||
@@ -4,9 +4,12 @@ const fs = require("node:fs/promises");
|
|||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const { app, safeStorage } = require("electron");
|
const { app, safeStorage } = require("electron");
|
||||||
|
|
||||||
const configuredUserData = process.env.FORGEFLOW_USER_DATA;
|
const configuredUserData = process.env.FORGEFLOW_USER_DATA
|
||||||
if (configuredUserData)
|
? path.resolve(process.env.FORGEFLOW_USER_DATA)
|
||||||
app.setPath("userData", path.resolve(configuredUserData));
|
: path.join(app.getPath("appData"), "forgeflow");
|
||||||
|
// safeStorage is bound to Electron's userData identity. Set it before ready so
|
||||||
|
// this verifier decrypts the same secrets as the packaged application.
|
||||||
|
app.setPath("userData", configuredUserData);
|
||||||
|
|
||||||
function result(name, ok, detail) {
|
function result(name, ok, detail) {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -18,9 +21,7 @@ function result(name, ok, detail) {
|
|||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
let passed = true;
|
let passed = true;
|
||||||
try {
|
try {
|
||||||
const userDataPath = configuredUserData
|
const userDataPath = configuredUserData;
|
||||||
? path.resolve(configuredUserData)
|
|
||||||
: path.join(app.getPath("appData"), "forgeflow");
|
|
||||||
const configPath = path.join(userDataPath, "forgeflow-config.json");
|
const configPath = path.join(userDataPath, "forgeflow-config.json");
|
||||||
const config = JSON.parse(await fs.readFile(configPath, "utf8"));
|
const config = JSON.parse(await fs.readFile(configPath, "utf8"));
|
||||||
const baseUrl = String(config.gitea?.baseUrl || "").replace(/\/+$/, "");
|
const baseUrl = String(config.gitea?.baseUrl || "").replace(/\/+$/, "");
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const root = path.resolve(import.meta.dirname, "..");
|
||||||
|
const pkg = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
|
||||||
|
const signedRelease = process.env.FORGEFLOW_SIGNED_RELEASE === "1";
|
||||||
|
const expectedPublisher = String(process.env.FORGEFLOW_EXPECTED_PUBLISHER || "").trim();
|
||||||
|
if (signedRelease && !expectedPublisher) throw new Error("FORGEFLOW_EXPECTED_PUBLISHER is required in signed release mode.");
|
||||||
|
if (signedRelease && !/^CN=.+/i.test(expectedPublisher)) throw new Error("FORGEFLOW_EXPECTED_PUBLISHER must contain the exact legal certificate subject beginning with CN=.");
|
||||||
|
|
||||||
|
const artifacts = ["Setup", "Portable"].map((kind) => path.join(root, "dist", `ForgeFlow-${kind}-${pkg.version}-win-x64.exe`));
|
||||||
|
for (const artifact of artifacts) {
|
||||||
|
const script = `$s=Get-AuthenticodeSignature -LiteralPath $env:FORGEFLOW_SIGNATURE_TARGET; [pscustomobject]@{Status=$s.Status.ToString();Subject=$s.SignerCertificate.Subject;Thumbprint=$s.SignerCertificate.Thumbprint;TimestampSubject=$s.TimeStamperCertificate.Subject}|ConvertTo-Json -Compress`;
|
||||||
|
let stdout;
|
||||||
|
try {
|
||||||
|
({ stdout } = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { windowsHide: true, env: { ...process.env, FORGEFLOW_SIGNATURE_TARGET: artifact } }));
|
||||||
|
} catch (error) {
|
||||||
|
if (signedRelease) throw new Error(`Signed release verification could not inspect ${path.basename(artifact)}: ${error.message}`);
|
||||||
|
console.log(`${path.basename(artifact)}: checksum-protected unsigned artifact (Authenticode inspection unavailable)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const result = JSON.parse(stdout.trim());
|
||||||
|
const valid = result.Status === "Valid" && Boolean(result.TimestampSubject);
|
||||||
|
const publisherMatches = !expectedPublisher || String(result.Subject || "").trim() === expectedPublisher;
|
||||||
|
if (signedRelease && (!valid || !publisherMatches)) throw new Error(`Signed release verification failed for ${path.basename(artifact)}: status=${result.Status}, publisher=${result.Subject || "missing"}, timestamp=${result.TimestampSubject || "missing"}.`);
|
||||||
|
console.log(`${path.basename(artifact)}: ${valid && publisherMatches ? "valid signed artifact" : "checksum-protected unsigned artifact"}`);
|
||||||
|
}
|
||||||
+210
-14
@@ -27,6 +27,7 @@ const required = [
|
|||||||
"src/main/repository-monitor.cjs",
|
"src/main/repository-monitor.cjs",
|
||||||
"src/main/deployment-service.cjs",
|
"src/main/deployment-service.cjs",
|
||||||
"src/main/unraid-deployment-service.cjs",
|
"src/main/unraid-deployment-service.cjs",
|
||||||
|
"src/main/server-inventory.cjs",
|
||||||
"src/main/ssh-service.cjs",
|
"src/main/ssh-service.cjs",
|
||||||
"src/main/update-service.cjs",
|
"src/main/update-service.cjs",
|
||||||
"src/main/diagnostics-service.cjs",
|
"src/main/diagnostics-service.cjs",
|
||||||
@@ -46,6 +47,8 @@ const required = [
|
|||||||
"scripts/validate-installed-connections.cjs",
|
"scripts/validate-installed-connections.cjs",
|
||||||
"scripts/publish-binary-release.cjs",
|
"scripts/publish-binary-release.cjs",
|
||||||
"scripts/write-release-checksums.mjs",
|
"scripts/write-release-checksums.mjs",
|
||||||
|
"scripts/setup-update-signing-key.mjs",
|
||||||
|
"scripts/sign-release-manifest.mjs",
|
||||||
"scripts/prune-dist.mjs",
|
"scripts/prune-dist.mjs",
|
||||||
"scripts/generate-source-manifest.mjs",
|
"scripts/generate-source-manifest.mjs",
|
||||||
"setup-windows.ps1",
|
"setup-windows.ps1",
|
||||||
@@ -56,6 +59,8 @@ const required = [
|
|||||||
"scripts/apply-source-update.ps1",
|
"scripts/apply-source-update.ps1",
|
||||||
"scripts/apply-binary-update.ps1",
|
"scripts/apply-binary-update.ps1",
|
||||||
"docs/ARCHITECTURE.md",
|
"docs/ARCHITECTURE.md",
|
||||||
|
"docs/CURRENT_STATE.md",
|
||||||
|
"docs/MUTATION_MODEL.md",
|
||||||
"docs/SECURITY.md",
|
"docs/SECURITY.md",
|
||||||
"docs/ROADMAP.md",
|
"docs/ROADMAP.md",
|
||||||
"docs/SETUP_GUIDE.md",
|
"docs/SETUP_GUIDE.md",
|
||||||
@@ -68,11 +73,35 @@ const required = [
|
|||||||
"docs/RELEASE_NOTES_0.8.5.md",
|
"docs/RELEASE_NOTES_0.8.5.md",
|
||||||
"docs/RELEASE_NOTES_0.8.6.md",
|
"docs/RELEASE_NOTES_0.8.6.md",
|
||||||
"docs/RELEASE_NOTES_0.8.7.md",
|
"docs/RELEASE_NOTES_0.8.7.md",
|
||||||
|
"docs/RELEASE_NOTES_0.8.8.md",
|
||||||
|
"docs/RELEASE_NOTES_0.8.9.md",
|
||||||
|
"docs/RELEASE_NOTES_0.9.0.md",
|
||||||
|
"docs/RELEASE_NOTES_0.9.1.md",
|
||||||
|
"docs/RELEASE_NOTES_0.9.2.md",
|
||||||
|
"docs/RELEASE_NOTES_0.9.3.md",
|
||||||
|
"docs/RELEASE_NOTES_0.9.4.md",
|
||||||
|
"docs/RELEASE_NOTES_0.9.5.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.0.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.1.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.2.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.3.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.4.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.5.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.6.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.7.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.8.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.9.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.10.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.11.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.12.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.13.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.14.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.15.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",
|
||||||
@@ -99,6 +128,7 @@ const required = [
|
|||||||
"examples/server/status-example.json",
|
"examples/server/status-example.json",
|
||||||
"build/icon.png",
|
"build/icon.png",
|
||||||
"build/icon.ico",
|
"build/icon.ico",
|
||||||
|
"build/update-signing-public.pem",
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const file of required) await access(path.join(root, file));
|
for (const file of required) await access(path.join(root, file));
|
||||||
@@ -106,9 +136,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.8.7")
|
if (packageJson.version !== "0.10.15")
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Expected package version 0.8.7, got ${packageJson.version}.`,
|
`Expected package version 0.10.15, got ${packageJson.version}.`,
|
||||||
);
|
);
|
||||||
const sourceManifest = await readFile(
|
const sourceManifest = await readFile(
|
||||||
path.join(root, "SOURCE_MANIFEST.txt"),
|
path.join(root, "SOURCE_MANIFEST.txt"),
|
||||||
@@ -205,8 +235,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(
|
||||||
@@ -234,11 +264,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 [
|
||||||
@@ -301,13 +331,17 @@ if (
|
|||||||
)
|
)
|
||||||
throw new Error("PowerShell update helper must start directly with param(.");
|
throw new Error("PowerShell update helper must start directly with param(.");
|
||||||
|
|
||||||
const renderer = await readFile(path.join(root, "src/renderer/app.js"), "utf8");
|
const renderer = (await Promise.all(["app.js", "diff-view.js", "views.js", "dialogs.js", "operations.js", "actions/shell.js", "actions/inventory.js", "actions/deployment-profile.js", "actions/deployment-operation.js", "actions/setup-and-settings.js", "actions/recovery.js", "actions/command.js", "events.js"].map((file) =>
|
||||||
|
readFile(path.join(root, "src/renderer", file), "utf8"),
|
||||||
|
))).join("\n");
|
||||||
const styles = await readFile(
|
const styles = await readFile(
|
||||||
path.join(root, "src/renderer/styles.css"),
|
path.join(root, "src/renderer/styles.css"),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
const preload = await readFile(path.join(root, "preload.cjs"), "utf8");
|
const preload = await readFile(path.join(root, "preload.cjs"), "utf8");
|
||||||
const ipc = await readFile(path.join(root, "src/main/ipc.cjs"), "utf8");
|
const ipc = (await Promise.all(["ipc.cjs", "ipc/repository-handlers.cjs", "ipc/deployment-handlers.cjs", "ipc/operations-handlers.cjs"].map((file) =>
|
||||||
|
readFile(path.join(root, "src/main", file), "utf8"),
|
||||||
|
))).join("\n");
|
||||||
for (const phrase of [
|
for (const phrase of [
|
||||||
'data-action="commit-push"',
|
'data-action="commit-push"',
|
||||||
"checkForUpdates",
|
"checkForUpdates",
|
||||||
@@ -343,18 +377,177 @@ for (const channel of [
|
|||||||
"repository:repair-sync",
|
"repository:repair-sync",
|
||||||
"deployment:apply-dockerman-metadata",
|
"deployment:apply-dockerman-metadata",
|
||||||
"deployment:reconcile",
|
"deployment:reconcile",
|
||||||
|
"deployment:link-server-workload",
|
||||||
]) {
|
]) {
|
||||||
if (!ipc.includes(channel))
|
if (!ipc.includes(channel))
|
||||||
throw new Error(`IPC registration is missing: ${channel}`);
|
throw new Error(`IPC registration is missing: ${channel}`);
|
||||||
}
|
}
|
||||||
|
const release090 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.0.md"), "utf8");
|
||||||
|
for (const phrase of [
|
||||||
|
"Push bundle",
|
||||||
|
"manual wizard",
|
||||||
|
"Monitor only",
|
||||||
|
"DockerMan templates",
|
||||||
|
"SHA-256",
|
||||||
|
]) {
|
||||||
|
if (!release090.includes(phrase)) throw new Error(`0.9.0 release notes are missing: ${phrase}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const release091 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.1.md"), "utf8");
|
||||||
|
for (const phrase of [
|
||||||
|
"browser_download_url",
|
||||||
|
"cross-origin",
|
||||||
|
"manual installer",
|
||||||
|
"in-app updates",
|
||||||
|
]) {
|
||||||
|
if (!release091.includes(phrase)) throw new Error(`0.9.1 release notes are missing: ${phrase}`);
|
||||||
|
}
|
||||||
|
const release092 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.2.md"), "utf8");
|
||||||
|
for (const phrase of [
|
||||||
|
"Push bundle",
|
||||||
|
"server password",
|
||||||
|
"docker ps -a",
|
||||||
|
"DockerMan",
|
||||||
|
"zero counts",
|
||||||
|
]) {
|
||||||
|
if (!release092.includes(phrase)) throw new Error(`0.9.2 release notes are missing: ${phrase}`);
|
||||||
|
}
|
||||||
|
const release093 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.3.md"), "utf8");
|
||||||
|
for (const phrase of [
|
||||||
|
"Direct copy",
|
||||||
|
"Compose YAML",
|
||||||
|
"linked automatically",
|
||||||
|
"one-click",
|
||||||
|
"no remote `git ls-remote`",
|
||||||
|
]) {
|
||||||
|
if (!release093.includes(phrase)) throw new Error(`0.9.3 release notes are missing: ${phrase}`);
|
||||||
|
}
|
||||||
|
const release094 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.4.md"), "utf8");
|
||||||
|
for (const phrase of [
|
||||||
|
"real Compose files",
|
||||||
|
"stale service hints",
|
||||||
|
"force-recreate",
|
||||||
|
"container ID",
|
||||||
|
"previous container",
|
||||||
|
]) {
|
||||||
|
if (!release094.includes(phrase)) throw new Error(`0.9.4 release notes are missing: ${phrase}`);
|
||||||
|
}
|
||||||
|
const release095 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.5.md"), "utf8");
|
||||||
|
for (const phrase of [
|
||||||
|
"Check / fix write access",
|
||||||
|
"exact path, user, owner, group and mode",
|
||||||
|
"preserves existing executable bits",
|
||||||
|
"never implicitly executes `docker compose down`",
|
||||||
|
"retains the backup evidence",
|
||||||
|
]) {
|
||||||
|
if (!release095.includes(phrase)) throw new Error(`0.9.5 release notes are missing: ${phrase}`);
|
||||||
|
}
|
||||||
|
const release0100 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.0.md"), "utf8");
|
||||||
|
for (const phrase of [
|
||||||
|
"Server pull",
|
||||||
|
"read-only deploy key",
|
||||||
|
"automatic discovery",
|
||||||
|
"Git Validator",
|
||||||
|
"SSH host fingerprint",
|
||||||
|
]) {
|
||||||
|
if (!release0100.includes(phrase)) throw new Error(`0.10.0 release notes are missing: ${phrase}`);
|
||||||
|
}
|
||||||
|
const release0101 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.1.md"), "utf8");
|
||||||
|
for (const phrase of ["certificate-free updates", "case-insensitive", "read-only deploy keys", "SHA-256"]) {
|
||||||
|
if (!release0101.includes(phrase)) throw new Error(`0.10.1 release notes are missing: ${phrase}`);
|
||||||
|
}
|
||||||
|
const release0102 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.2.md"), "utf8");
|
||||||
|
for (const phrase of ["internal HTTP", "public HTTPS", "same-origin", "SHA-256"]) {
|
||||||
|
if (!release0102.includes(phrase)) throw new Error(`0.10.2 release notes are missing: ${phrase}`);
|
||||||
|
}
|
||||||
|
const release0103 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.3.md"), "utf8");
|
||||||
|
for (const phrase of ["concurrently", "debounce", "animation frame", "Git Validator", "stale or forged"]) {
|
||||||
|
if (!release0103.includes(phrase)) throw new Error(`0.10.3 release notes are missing: ${phrase}`);
|
||||||
|
}
|
||||||
|
const release0104 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.4.md"), "utf8");
|
||||||
|
for (const phrase of ["Windows PowerShell 5.1", "atomic status", "handshake-only", "existing installations"]) {
|
||||||
|
if (!release0104.includes(phrase)) throw new Error(`0.10.4 release notes are missing: ${phrase}`);
|
||||||
|
}
|
||||||
|
const release0105 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.5.md"), "utf8");
|
||||||
|
for (const phrase of ["repository workspace", "resolved profile", "Link unresolved", "reconciliation", "server workload"]) {
|
||||||
|
if (!release0105.includes(phrase)) throw new Error(`0.10.5 release notes are missing: ${phrase}`);
|
||||||
|
}
|
||||||
|
const release0106 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.6.md"), "utf8");
|
||||||
|
for (const phrase of ["detached", "PowerShell", "production Node spawn", "source updater", "one-time direct installation"]) {
|
||||||
|
if (!release0106.includes(phrase)) throw new Error(`0.10.6 release notes are missing: ${phrase}`);
|
||||||
|
}
|
||||||
|
const release0107 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.7.md"), "utf8");
|
||||||
|
for (const phrase of ["exact provenance", "automatic", "repository sidebar", "DevRunbook", "no container changes"]) {
|
||||||
|
if (!release0107.includes(phrase)) throw new Error(`0.10.7 release notes are missing: ${phrase}`);
|
||||||
|
}
|
||||||
|
const release0108 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.8.md"), "utf8");
|
||||||
|
for (const phrase of ["Get-FileHash", ".NET SHA-256", "PSModulePath", "binary", "source update helpers"]) {
|
||||||
|
if (!release0108.includes(phrase)) throw new Error(`0.10.8 release notes are missing: ${phrase}`);
|
||||||
|
}
|
||||||
|
const release0109 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.9.md"), "utf8");
|
||||||
|
for (const phrase of ["containers without healthchecks", "single-instance", "exact Gitea commit", "deploy-ready", "no containers are changed"]) {
|
||||||
|
if (!release0109.includes(phrase)) throw new Error(`0.10.9 release notes are missing: ${phrase}`);
|
||||||
|
}
|
||||||
|
const release01010 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.10.md"), "utf8");
|
||||||
|
for (const phrase of ["read-only deploy keys", "repository deployment root", "Compose working directory", "Fix write access", "exact Gitea commit"]) {
|
||||||
|
if (!release01010.includes(phrase)) throw new Error(`0.10.10 release notes are missing: ${phrase}`);
|
||||||
|
}
|
||||||
|
const release01011 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.11.md"), "utf8");
|
||||||
|
for (const phrase of ["last-known-good", "closed output pipe", "linked checkout origin", "read-only deploy key", "browser test server"]) {
|
||||||
|
if (!release01011.includes(phrase)) throw new Error(`0.10.11 release notes are missing: ${phrase}`);
|
||||||
|
}
|
||||||
|
const release01012 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.12.md"), "utf8");
|
||||||
|
for (const phrase of ["coalesced", "exact Gitea commit parity", "batched Docker inspect", "bounded worker pools", "stopped container"]) {
|
||||||
|
if (!release01012.includes(phrase)) throw new Error(`0.10.12 release notes are missing: ${phrase}`);
|
||||||
|
}
|
||||||
|
const release01013 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.13.md"), "utf8");
|
||||||
|
for (const phrase of ["Gitea workspace sync", "recovery branch", "Stale deployment links", "Ed25519-signed release manifest", "Git-toolsgrid"]) {
|
||||||
|
if (!release01013.includes(phrase)) throw new Error(`0.10.13 release notes are missing: ${phrase}`);
|
||||||
|
}
|
||||||
|
const release01014 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.14.md"), "utf8");
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
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");
|
||||||
|
for (const mode of ["server-git", "push-bundle", "monitor-only"]) {
|
||||||
|
if (!configSource.includes(mode)) throw new Error(`Deployment configuration is missing mode: ${mode}`);
|
||||||
|
}
|
||||||
|
const unraidDirectSource = (await Promise.all([
|
||||||
|
"unraid-deployment-service.cjs", "unraid-access-methods.cjs", "unraid-preflight-methods.cjs",
|
||||||
|
"unraid-runtime-methods.cjs", "unraid-deployment-methods.cjs", "unraid-inventory-methods.cjs", "unraid-state-methods.cjs",
|
||||||
|
].map((file) => readFile(path.join(root, "src/main", file), "utf8")))).join("\n");
|
||||||
|
for (const requiredPhrase of [
|
||||||
|
"executePushBundle",
|
||||||
|
"executeServerGitBundle",
|
||||||
|
"configureServerGitAccess",
|
||||||
|
"server-git-access",
|
||||||
|
"git ls-remote --exit-code",
|
||||||
|
"repository-scoped read-only deploy key",
|
||||||
|
]) {
|
||||||
|
if (!unraidDirectSource.includes(requiredPhrase)) throw new Error(`Deployment source is missing: ${requiredPhrase}`);
|
||||||
|
}
|
||||||
|
const serverInventorySource = await readFile(path.join(root, "src/main/server-inventory.cjs"), "utf8");
|
||||||
|
for (const requiredPhrase of ["server-compose-file", "composeDefinitions", "remoteFolderCandidate"]) {
|
||||||
|
if (!serverInventorySource.includes(requiredPhrase)) throw new Error(`Server inventory source is missing: ${requiredPhrase}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const giteaUpdateSource = await readFile(path.join(root, "src/main/gitea-service.cjs"), "utf8");
|
||||||
|
for (const phrase of [
|
||||||
|
"browser_download_url",
|
||||||
|
"insecure cross-origin",
|
||||||
|
"downloadReleaseAsset",
|
||||||
|
]) {
|
||||||
|
if (!giteaUpdateSource.includes(phrase)) throw new Error(`0.9.1 updater repair is missing: ${phrase}`);
|
||||||
|
}
|
||||||
const gitSource = await readFile(
|
const gitSource = await readFile(
|
||||||
path.join(root, "src/main/git-service.cjs"),
|
path.join(root, "src/main/git-service.cjs"),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
const unraidSource = await readFile(
|
const unraidSource = unraidDirectSource;
|
||||||
path.join(root, "src/main/unraid-deployment-service.cjs"),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
const publisher = await readFile(
|
const publisher = await readFile(
|
||||||
path.join(root, "Publish-ForgeFlow-Release.ps1"),
|
path.join(root, "Publish-ForgeFlow-Release.ps1"),
|
||||||
"utf8",
|
"utf8",
|
||||||
@@ -377,6 +570,9 @@ for (const phrase of [
|
|||||||
"iconCacheRefresh",
|
"iconCacheRefresh",
|
||||||
"[PORT:",
|
"[PORT:",
|
||||||
"Superseded by live commit",
|
"Superseded by live commit",
|
||||||
|
"pushBundleScript",
|
||||||
|
"linkServerWorkload",
|
||||||
|
"deploymentMode",
|
||||||
]) {
|
]) {
|
||||||
if (!unraidSource.includes(phrase))
|
if (!unraidSource.includes(phrase))
|
||||||
throw new Error(`Unraid recovery implementation is missing: ${phrase}`);
|
throw new Error(`Unraid recovery implementation is missing: ${phrase}`);
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { readFile, writeFile } from "node:fs/promises";
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
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 execFileAsync = promisify(execFile);
|
||||||
const manifest = JSON.parse(
|
const manifest = JSON.parse(
|
||||||
await readFile(path.join(root, "package.json"), "utf8"),
|
await readFile(path.join(root, "package.json"), "utf8"),
|
||||||
);
|
);
|
||||||
|
const artifacts = [];
|
||||||
for (const kind of ["Setup", "Portable"]) {
|
for (const kind of ["Setup", "Portable"]) {
|
||||||
const name = `ForgeFlow-${kind}-${manifest.version}-win-x64.exe`;
|
const name = `ForgeFlow-${kind}-${manifest.version}-win-x64.exe`;
|
||||||
const binary = await readFile(path.join(root, "dist", name));
|
const binary = await readFile(path.join(root, "dist", name));
|
||||||
@@ -17,4 +21,24 @@ for (const kind of ["Setup", "Portable"]) {
|
|||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
console.log(`${name}: ${sha256}`);
|
console.log(`${name}: ${sha256}`);
|
||||||
|
artifacts.push({ name, sha256 });
|
||||||
}
|
}
|
||||||
|
const commit = String(process.env.FORGEFLOW_BUILD_COMMIT || (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: root })).stdout).trim();
|
||||||
|
const buildId = String(process.env.FORGEFLOW_BUILD_ID || `${manifest.version}-${commit.slice(0, 12)}`);
|
||||||
|
const provenance = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
product: "ForgeFlow",
|
||||||
|
version: manifest.version,
|
||||||
|
commit,
|
||||||
|
buildId,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
publisherManifestSignature: "Ed25519",
|
||||||
|
authenticodeSigned: process.env.FORGEFLOW_SIGNED_RELEASE === "1",
|
||||||
|
expectedAuthenticodePublisher:
|
||||||
|
process.env.FORGEFLOW_EXPECTED_PUBLISHER || null,
|
||||||
|
artifacts,
|
||||||
|
};
|
||||||
|
await writeFile(path.join(root, "dist", `ForgeFlow-${manifest.version}-provenance.json`), `${JSON.stringify(provenance, null, 2)}\n`, "utf8");
|
||||||
|
const lock = JSON.parse(await readFile(path.join(root, "package-lock.json"), "utf8"));
|
||||||
|
const components = Object.entries(lock.packages || {}).filter(([name]) => name.startsWith("node_modules/")).map(([name, value]) => ({ type: "library", name: name.slice(13), version: value.version || "unknown", licenses: value.license ? [{ license: { id: value.license } }] : undefined })).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
await writeFile(path.join(root, "dist", `ForgeFlow-${manifest.version}-sbom.cdx.json`), `${JSON.stringify({ bomFormat: "CycloneDX", specVersion: "1.5", serialNumber: `urn:uuid:${buildId}`, version: 1, metadata: { component: { type: "application", name: "ForgeFlow", version: manifest.version } }, components }, null, 2)}\n`, "utf8");
|
||||||
|
|||||||
+211
-27
@@ -3,11 +3,23 @@
|
|||||||
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, 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: 8,
|
schemaVersion: 13,
|
||||||
setupComplete: false,
|
setupComplete: false,
|
||||||
appearance: 'dark',
|
appearance: 'dark',
|
||||||
gitea: { baseUrl: '', user: null, encryptedToken: null },
|
gitea: { baseUrl: '', user: null, encryptedToken: null },
|
||||||
@@ -15,6 +27,8 @@ const DEFAULT_CONFIG = {
|
|||||||
repositoryMappings: {},
|
repositoryMappings: {},
|
||||||
deploymentProfiles: {},
|
deploymentProfiles: {},
|
||||||
deploymentStates: {},
|
deploymentStates: {},
|
||||||
|
inventoryReviewDecisions: {},
|
||||||
|
gitValidator: { policies: {}, suppressions: {}, trends: {} },
|
||||||
favorites: [],
|
favorites: [],
|
||||||
updates: {
|
updates: {
|
||||||
owner: 'Jens',
|
owner: 'Jens',
|
||||||
@@ -54,6 +68,8 @@ class ConfigStore {
|
|||||||
this.sessionToken = null;
|
this.sessionToken = null;
|
||||||
this.data = structuredClone(DEFAULT_CONFIG);
|
this.data = structuredClone(DEFAULT_CONFIG);
|
||||||
this.saveQueue = Promise.resolve();
|
this.saveQueue = Promise.resolve();
|
||||||
|
this.pendingSave = null;
|
||||||
|
this.lastWrittenSnapshot = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
migrate(parsed) {
|
migrate(parsed) {
|
||||||
@@ -65,6 +81,12 @@ class ConfigStore {
|
|||||||
gitea: { ...DEFAULT_CONFIG.gitea, ...(source.gitea || {}) },
|
gitea: { ...DEFAULT_CONFIG.gitea, ...(source.gitea || {}) },
|
||||||
workspaceRoots: uniqueStrings(source.workspaceRoots),
|
workspaceRoots: uniqueStrings(source.workspaceRoots),
|
||||||
repositoryMappings: source.repositoryMappings && typeof source.repositoryMappings === 'object' ? source.repositoryMappings : {},
|
repositoryMappings: source.repositoryMappings && typeof source.repositoryMappings === 'object' ? source.repositoryMappings : {},
|
||||||
|
inventoryReviewDecisions: source.inventoryReviewDecisions && typeof source.inventoryReviewDecisions === 'object' ? structuredClone(source.inventoryReviewDecisions) : {},
|
||||||
|
gitValidator: {
|
||||||
|
policies: source.gitValidator?.policies && typeof source.gitValidator.policies === 'object' ? structuredClone(source.gitValidator.policies) : {},
|
||||||
|
suppressions: source.gitValidator?.suppressions && typeof source.gitValidator.suppressions === 'object' ? structuredClone(source.gitValidator.suppressions) : {},
|
||||||
|
trends: source.gitValidator?.trends && typeof source.gitValidator.trends === 'object' ? structuredClone(source.gitValidator.trends) : {}
|
||||||
|
},
|
||||||
deploymentProfiles: source.deploymentProfiles && typeof source.deploymentProfiles === 'object'
|
deploymentProfiles: source.deploymentProfiles && typeof source.deploymentProfiles === 'object'
|
||||||
? Object.fromEntries(Object.entries(source.deploymentProfiles).map(([key, profiles]) => [key, (Array.isArray(profiles) ? profiles : []).map((profile) => {
|
? Object.fromEntries(Object.entries(source.deploymentProfiles).map(([key, profiles]) => [key, (Array.isArray(profiles) ? profiles : []).map((profile) => {
|
||||||
if (!profile || typeof profile !== 'object' || profile.provider !== 'ssh-unraid') return profile;
|
if (!profile || typeof profile !== 'object' || profile.provider !== 'ssh-unraid') return profile;
|
||||||
@@ -76,11 +98,32 @@ class ConfigStore {
|
|||||||
: iconFilePath ? 'upload' : iconUrl && !/itworx\.tech\/assets\/itworx-icon\.png/i.test(iconUrl) ? 'url' : 'builtin';
|
: iconFilePath ? 'upload' : iconUrl && !/itworx\.tech\/assets\/itworx-icon\.png/i.test(iconUrl) ? 'url' : 'builtin';
|
||||||
const visibleName = String(profile.containerName || profile.remoteFolder || '').trim();
|
const visibleName = String(profile.containerName || profile.remoteFolder || '').trim();
|
||||||
const internalService = String(profile.composeService || profile.remoteFolder || 'app').trim().toLowerCase().replace(/[^a-z0-9._-]/g, '-') || 'app';
|
const internalService = String(profile.composeService || profile.remoteFolder || 'app').trim().toLowerCase().replace(/[^a-z0-9._-]/g, '-') || 'app';
|
||||||
return { ...profile, composeService: internalService, containerName: visibleName || internalService, iconMode };
|
const requestedDeploymentMode = String(profile.deploymentMode || '').trim();
|
||||||
|
const deploymentMode = ['push-bundle', 'server-git', 'monitor-only'].includes(requestedDeploymentMode)
|
||||||
|
? requestedDeploymentMode
|
||||||
|
: 'push-bundle';
|
||||||
|
const composeFiles = uniqueStrings(profile.composeFiles || [profile.composeFile || 'docker-compose.yml']);
|
||||||
|
const composeServices = uniqueStrings(profile.composeServices || [internalService]).map((value) => value.toLowerCase());
|
||||||
|
return {
|
||||||
|
...profile,
|
||||||
|
deploymentMode,
|
||||||
|
composeFile: composeFiles[0] || 'docker-compose.yml',
|
||||||
|
composeFiles: composeFiles.length ? composeFiles : ['docker-compose.yml'],
|
||||||
|
composeServices,
|
||||||
|
composeProject: String(profile.composeProject || '').trim(),
|
||||||
|
composeWorkingDir: String(profile.composeWorkingDir || '').trim(),
|
||||||
|
composeService: internalService,
|
||||||
|
containerName: visibleName || internalService,
|
||||||
|
iconMode,
|
||||||
|
manageDockerMan: profile.manageDockerMan === true,
|
||||||
|
forceRecreate: profile.forceRecreate === true,
|
||||||
|
removeOrphans: profile.removeOrphans === true,
|
||||||
|
workloadIdentity: profile.workloadIdentity && typeof profile.workloadIdentity === 'object' ? structuredClone(profile.workloadIdentity) : null
|
||||||
|
};
|
||||||
})]))
|
})]))
|
||||||
: {},
|
: {},
|
||||||
deploymentStates: source.deploymentStates && typeof source.deploymentStates === 'object' ? source.deploymentStates : {},
|
deploymentStates: source.deploymentStates && typeof source.deploymentStates === 'object' ? source.deploymentStates : {},
|
||||||
favorites: 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 || {}) },
|
||||||
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 || {}) },
|
||||||
@@ -109,16 +152,70 @@ class ConfigStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async save() {
|
async save() {
|
||||||
const snapshot = JSON.stringify(this.data, null, 2);
|
// Several callers persist in quick succession (a server scan writes deployment
|
||||||
|
// state per workload). Serializing the configuration once per call is the
|
||||||
|
// expensive part, so saves that are still queued share a single write of the
|
||||||
|
// latest data. That is equivalent because every caller asks for "persist the
|
||||||
|
// current configuration", not "persist the snapshot I saw".
|
||||||
|
if (this.pendingSave) return this.pendingSave;
|
||||||
const operation = async () => {
|
const operation = async () => {
|
||||||
|
this.pendingSave = null;
|
||||||
|
const snapshot = JSON.stringify(this.data, null, 2);
|
||||||
|
if (snapshot === this.lastWrittenSnapshot
|
||||||
|
&& await fs.access(this.filePath).then(() => true).catch(() => false)) return;
|
||||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||||
const temporary = `${this.filePath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`;
|
const temporary = `${this.filePath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`;
|
||||||
await fs.writeFile(temporary, snapshot, { mode: 0o600 });
|
await fs.writeFile(temporary, snapshot, { mode: 0o600 });
|
||||||
await fs.rename(temporary, this.filePath);
|
await fs.rename(temporary, this.filePath);
|
||||||
try { await fs.chmod(this.filePath, 0o600); } catch {}
|
try { await fs.chmod(this.filePath, 0o600); } catch {}
|
||||||
|
this.lastWrittenSnapshot = snapshot;
|
||||||
};
|
};
|
||||||
this.saveQueue = this.saveQueue.then(operation, operation);
|
this.pendingSave = this.saveQueue.then(operation, operation);
|
||||||
return this.saveQueue;
|
this.saveQueue = this.pendingSave.catch(() => {});
|
||||||
|
return this.pendingSave;
|
||||||
|
}
|
||||||
|
|
||||||
|
getGitValidatorState(fullName) {
|
||||||
|
const key = String(fullName || '').toLowerCase();
|
||||||
|
return {
|
||||||
|
policy: structuredClone(this.data.gitValidator.policies[key] || { id: 'standard' }),
|
||||||
|
suppressions: structuredClone(this.data.gitValidator.suppressions[key] || []),
|
||||||
|
trends: structuredClone(this.data.gitValidator.trends[key] || [])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async setGitValidatorPolicy(fullName, policy) {
|
||||||
|
const key = String(fullName || '').toLowerCase();
|
||||||
|
this.data.gitValidator.policies[key] = structuredClone(policy);
|
||||||
|
await this.save();
|
||||||
|
return this.getGitValidatorState(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addGitValidatorSuppression(fullName, suppression) {
|
||||||
|
const key = String(fullName || '').toLowerCase();
|
||||||
|
this.data.gitValidator.suppressions[key] = [...(this.data.gitValidator.suppressions[key] || []), structuredClone(suppression)].slice(-250);
|
||||||
|
await this.save();
|
||||||
|
return this.getGitValidatorState(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async appendGitValidatorTrend(fullName, trend) {
|
||||||
|
const key = String(fullName || '').toLowerCase();
|
||||||
|
this.data.gitValidator.trends[key] = [...(this.data.gitValidator.trends[key] || []), structuredClone(trend)].slice(-100);
|
||||||
|
await this.save();
|
||||||
|
return this.getGitValidatorState(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRecoverySnapshot(reason = 'configuration-change') {
|
||||||
|
await this.saveQueue.catch(() => {});
|
||||||
|
const safeReason = String(reason || 'configuration-change').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'configuration-change';
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const snapshotDirectory = path.join(path.dirname(this.filePath), 'snapshots');
|
||||||
|
const snapshotPath = path.join(snapshotDirectory, `${timestamp}-${safeReason}.json`);
|
||||||
|
await fs.mkdir(snapshotDirectory, { recursive: true });
|
||||||
|
await fs.writeFile(snapshotPath, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
||||||
|
try { await fs.chmod(snapshotDirectory, 0o700); } catch {}
|
||||||
|
try { await fs.chmod(snapshotPath, 0o600); } catch {}
|
||||||
|
return { filePath: snapshotPath, reason: safeReason, createdAt: new Date().toISOString() };
|
||||||
}
|
}
|
||||||
|
|
||||||
setToken(token, { preserveExisting = false } = {}) {
|
setToken(token, { preserveExisting = false } = {}) {
|
||||||
@@ -130,7 +227,8 @@ class ConfigStore {
|
|||||||
return { persistent: true, preserved: false };
|
return { persistent: true, preserved: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (safeStorage.isEncryptionAvailable()) {
|
const safeStorage = getSafeStorage();
|
||||||
|
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;
|
||||||
return { persistent: true, preserved: false };
|
return { persistent: true, preserved: false };
|
||||||
@@ -145,7 +243,8 @@ 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 {
|
||||||
return safeStorage.decryptString(Buffer.from(this.data.gitea.encryptedToken, 'base64'));
|
const safeStorage = getSafeStorage();
|
||||||
|
return safeStorage?.decryptString?.(Buffer.from(this.data.gitea.encryptedToken, 'base64')) || '';
|
||||||
} catch {
|
} catch {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -155,7 +254,8 @@ class ConfigStore {
|
|||||||
encryptSecret(value) {
|
encryptSecret(value) {
|
||||||
const text = String(value || '');
|
const text = String(value || '');
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
if (!safeStorage.isEncryptionAvailable()) {
|
const safeStorage = getSafeStorage();
|
||||||
|
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';
|
||||||
throw error;
|
throw error;
|
||||||
@@ -165,7 +265,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 ''; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,11 +280,22 @@ class ConfigStore {
|
|||||||
const port = Math.min(Math.max(Number(source.port || existing?.port || 22), 1), 65535);
|
const port = Math.min(Math.max(Number(source.port || existing?.port || 22), 1), 65535);
|
||||||
const username = String(source.username || existing?.username || '').trim();
|
const username = String(source.username || existing?.username || '').trim();
|
||||||
if (!username || /[\s@]/.test(username)) throw new Error('Enter a valid SSH username.');
|
if (!username || /[\s@]/.test(username)) throw new Error('Enter a valid SSH username.');
|
||||||
const authType = ['password', 'privateKey'].includes(source.authType) ? source.authType : (existing?.authType || 'privateKey');
|
const authType = ['password', 'privateKey'].includes(source.authType) ? source.authType : (existing?.authType || 'password');
|
||||||
const basePath = String(source.basePath || existing?.basePath || '/mnt/user/appdata').trim().replace(/\/+$/, '');
|
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 scanExcludes = uniqueStrings(source.scanExcludes || existing?.scanExcludes || ['backups', 'archives', 'releases', 'staging', 'testdata']).filter((value) => /^[a-zA-Z0-9._*-]+$/.test(value));
|
||||||
return {
|
return {
|
||||||
id: source.id || existing?.id || crypto.randomUUID(),
|
id: source.id || existing?.id || crypto.randomUUID(),
|
||||||
name,
|
name,
|
||||||
@@ -190,10 +304,12 @@ class ConfigStore {
|
|||||||
username,
|
username,
|
||||||
authType,
|
authType,
|
||||||
basePath,
|
basePath,
|
||||||
|
scanRoots: scanRoots.length ? scanRoots : [basePath],
|
||||||
|
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()
|
||||||
};
|
};
|
||||||
@@ -223,10 +339,13 @@ class ConfigStore {
|
|||||||
|
|
||||||
async deleteServer(serverId) {
|
async deleteServer(serverId) {
|
||||||
this.data.servers = this.data.servers.filter((item) => item.id !== serverId);
|
this.data.servers = this.data.servers.filter((item) => item.id !== serverId);
|
||||||
|
const removedProfileIds = new Set();
|
||||||
for (const [key, profiles] of Object.entries(this.data.deploymentProfiles)) {
|
for (const [key, profiles] of Object.entries(this.data.deploymentProfiles)) {
|
||||||
|
for (const profile of profiles) if (profile.serverId === serverId) removedProfileIds.add(profile.id);
|
||||||
this.data.deploymentProfiles[key] = profiles.filter((profile) => profile.serverId !== serverId);
|
this.data.deploymentProfiles[key] = profiles.filter((profile) => profile.serverId !== serverId);
|
||||||
if (!this.data.deploymentProfiles[key].length) delete this.data.deploymentProfiles[key];
|
if (!this.data.deploymentProfiles[key].length) delete this.data.deploymentProfiles[key];
|
||||||
}
|
}
|
||||||
|
for (const profileId of removedProfileIds) delete this.data.deploymentStates[profileId];
|
||||||
await this.save();
|
await this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,10 +412,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
|
||||||
};
|
};
|
||||||
@@ -357,21 +485,41 @@ class ConfigStore {
|
|||||||
inputs: {}
|
inputs: {}
|
||||||
};
|
};
|
||||||
if (provider === 'ssh-unraid') {
|
if (provider === 'ssh-unraid') {
|
||||||
const remoteFolder = String(profile.remoteFolder || '').trim();
|
const remoteFolder = assertRepositoryRelativePath(String(profile.remoteFolder || '').trim());
|
||||||
if (!remoteFolder || !/^[a-zA-Z0-9._-]+$/.test(remoteFolder)) throw new Error('Remote folder must contain only letters, numbers, dots, underscores and dashes.');
|
if (!remoteFolder || remoteFolder === '.' || remoteFolder.split('/').some((part) => !part || part === '.')) throw new Error('Remote folder must be a safe path relative to the configured server base path.');
|
||||||
const preservePaths = assertRepositoryRelativePaths(uniqueStrings(profile.preservePaths || ['.env', 'appdata', 'data', 'logs', 'config', 'compose.override.yml']));
|
const preservePaths = assertRepositoryRelativePaths(uniqueStrings(profile.preservePaths || ['.env', 'appdata', 'data', 'logs', 'config', 'compose.override.yml']));
|
||||||
|
const composeFiles = assertRepositoryRelativePaths(uniqueStrings(profile.composeFiles || [profile.composeFile || 'docker-compose.yml']));
|
||||||
|
if (!composeFiles.length && profile.generatedCompose !== true) throw new Error('Select at least one Compose file.');
|
||||||
|
const composeService = (() => {
|
||||||
|
const value = String(profile.composeService || profile.composeServices?.[0] || remoteFolder.split('/').pop()).trim().toLowerCase();
|
||||||
|
if (!/^[a-z0-9._-]+$/.test(value)) throw new Error('Compose service must be lowercase and contain only letters, numbers, dots, underscores and dashes.');
|
||||||
|
return value;
|
||||||
|
})();
|
||||||
|
const composeServices = uniqueStrings(profile.composeServices || [composeService]).map((value) => {
|
||||||
|
const normalized = String(value).trim().toLowerCase();
|
||||||
|
if (!/^[a-z0-9._-]+$/.test(normalized)) throw new Error('Compose services must be lowercase and contain only letters, numbers, dots, underscores and dashes.');
|
||||||
|
return normalized;
|
||||||
|
});
|
||||||
|
const composeProject = String(profile.composeProject || '').trim();
|
||||||
|
if (composeProject && !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(composeProject)) throw new Error('Compose project name contains unsupported characters.');
|
||||||
|
const composeWorkingDir = String(profile.composeWorkingDir || '').trim();
|
||||||
|
if (composeWorkingDir && (!composeWorkingDir.startsWith('/') || /[\r\n\0]/.test(composeWorkingDir))) throw new Error('Compose working directory must be an absolute safe Unix path.');
|
||||||
|
const deploymentMode = ['push-bundle', 'server-git', 'monitor-only'].includes(profile.deploymentMode)
|
||||||
|
? profile.deploymentMode
|
||||||
|
: 'push-bundle';
|
||||||
return {
|
return {
|
||||||
...common,
|
...common,
|
||||||
serverId: String(profile.serverId || '').trim(),
|
serverId: String(profile.serverId || '').trim(),
|
||||||
remoteFolder,
|
remoteFolder,
|
||||||
composeFile: String(profile.composeFile || 'docker-compose.yml').trim(),
|
deploymentMode,
|
||||||
composeService: (() => {
|
composeFile: composeFiles[0] || 'docker-compose.yml',
|
||||||
const value = String(profile.composeService || remoteFolder).trim().toLowerCase();
|
composeFiles: composeFiles.length ? composeFiles : ['docker-compose.yml'],
|
||||||
if (!/^[a-z0-9._-]+$/.test(value)) throw new Error('Compose service must be lowercase and contain only letters, numbers, dots, underscores and dashes.');
|
composeProject,
|
||||||
return value;
|
composeWorkingDir,
|
||||||
})(),
|
composeService,
|
||||||
|
composeServices,
|
||||||
containerName: (() => {
|
containerName: (() => {
|
||||||
const value = String(profile.containerName || remoteFolder).trim();
|
const value = String(profile.containerName || remoteFolder.split('/').pop()).trim();
|
||||||
if (!/^[A-Za-z0-9._-]+$/.test(value)) throw new Error('Container name must contain only letters, numbers, dots, underscores and dashes.');
|
if (!/^[A-Za-z0-9._-]+$/.test(value)) throw new Error('Container name must contain only letters, numbers, dots, underscores and dashes.');
|
||||||
return value;
|
return value;
|
||||||
})(),
|
})(),
|
||||||
@@ -390,6 +538,17 @@ class ConfigStore {
|
|||||||
generatedCompose: profile.generatedCompose === true,
|
generatedCompose: profile.generatedCompose === true,
|
||||||
adoptedFromServer: profile.adoptedFromServer === true,
|
adoptedFromServer: profile.adoptedFromServer === true,
|
||||||
serverSourceOfTruth: profile.serverSourceOfTruth === true,
|
serverSourceOfTruth: profile.serverSourceOfTruth === true,
|
||||||
|
manageDockerMan: profile.manageDockerMan === true,
|
||||||
|
forceRecreate: profile.forceRecreate === true,
|
||||||
|
removeOrphans: profile.removeOrphans === true,
|
||||||
|
workloadIdentity: profile.workloadIdentity && typeof profile.workloadIdentity === 'object' ? structuredClone(profile.workloadIdentity) : null,
|
||||||
|
serverGitAccess: profile.serverGitAccess && typeof profile.serverGitAccess === 'object' ? {
|
||||||
|
configured: profile.serverGitAccess.configured === true,
|
||||||
|
deployKeyId: Number.isFinite(Number(profile.serverGitAccess.deployKeyId)) ? Number(profile.serverGitAccess.deployKeyId) : null,
|
||||||
|
keyFingerprint: String(profile.serverGitAccess.keyFingerprint || '').trim().slice(0, 200) || null,
|
||||||
|
hostFingerprint: String(profile.serverGitAccess.hostFingerprint || '').trim().slice(0, 200) || null,
|
||||||
|
configuredAt: profile.serverGitAccess.configuredAt || null
|
||||||
|
} : null,
|
||||||
detectedAt: profile.detectedAt || null,
|
detectedAt: profile.detectedAt || null,
|
||||||
provenance: profile.provenance && typeof profile.provenance === 'object' ? structuredClone(profile.provenance) : {},
|
provenance: profile.provenance && typeof profile.provenance === 'object' ? structuredClone(profile.provenance) : {},
|
||||||
detectedMetadata: profile.detectedMetadata && typeof profile.detectedMetadata === 'object' ? structuredClone(profile.detectedMetadata) : {},
|
detectedMetadata: profile.detectedMetadata && typeof profile.detectedMetadata === 'object' ? structuredClone(profile.detectedMetadata) : {},
|
||||||
@@ -435,6 +594,27 @@ class ConfigStore {
|
|||||||
return this.getDeploymentProfiles(fullName).find((item) => item.id === profileId) || null;
|
return this.getDeploymentProfiles(fullName).find((item) => item.id === profileId) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getInventoryReviewDecisions(serverId) {
|
||||||
|
return structuredClone(this.data.inventoryReviewDecisions[String(serverId || '')] || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveInventoryReviewDecision(serverId, decision) {
|
||||||
|
const key = String(serverId || '');
|
||||||
|
if (!key || !decision?.workloadId || !/^[0-9a-f]{64}$/i.test(String(decision.evidenceHash || ''))) throw new Error('A server, workload and evidence hash are required for an inventory review decision.');
|
||||||
|
const decisions = this.getInventoryReviewDecisions(key).filter((item) => item.workloadId !== decision.workloadId);
|
||||||
|
decisions.push(structuredClone(decision));
|
||||||
|
this.data.inventoryReviewDecisions[key] = decisions;
|
||||||
|
await this.save();
|
||||||
|
return structuredClone(decision);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteInventoryReviewDecision(serverId, workloadId) {
|
||||||
|
const key = String(serverId || '');
|
||||||
|
this.data.inventoryReviewDecisions[key] = this.getInventoryReviewDecisions(key).filter((item) => item.workloadId !== workloadId);
|
||||||
|
await this.save();
|
||||||
|
return this.getInventoryReviewDecisions(key);
|
||||||
|
}
|
||||||
|
|
||||||
async saveDeploymentState(profileId, state) {
|
async saveDeploymentState(profileId, state) {
|
||||||
this.data.deploymentStates[profileId] = {
|
this.data.deploymentStates[profileId] = {
|
||||||
...(this.data.deploymentStates[profileId] || {}),
|
...(this.data.deploymentStates[profileId] || {}),
|
||||||
@@ -471,7 +651,10 @@ class ConfigStore {
|
|||||||
const next = { ...this.data.preferences, ...(preferences || {}) };
|
const next = { ...this.data.preferences, ...(preferences || {}) };
|
||||||
next.repositoryPollSeconds = Math.min(Math.max(Number(next.repositoryPollSeconds) || 4, 2), 60);
|
next.repositoryPollSeconds = Math.min(Math.max(Number(next.repositoryPollSeconds) || 4, 2), 60);
|
||||||
next.operationPollSeconds = Math.min(Math.max(Number(next.operationPollSeconds) || 5, 3), 120);
|
next.operationPollSeconds = Math.min(Math.max(Number(next.operationPollSeconds) || 5, 3), 120);
|
||||||
next.fetchIntervalMinutes = Math.min(Math.max(Number(next.fetchIntervalMinutes) || 10, 0), 240);
|
const fetchIntervalMinutes = Number(next.fetchIntervalMinutes);
|
||||||
|
next.fetchIntervalMinutes = Number.isFinite(fetchIntervalMinutes)
|
||||||
|
? Math.min(Math.max(fetchIntervalMinutes, 0), 240)
|
||||||
|
: 10;
|
||||||
next.autoRefresh = next.autoRefresh !== false;
|
next.autoRefresh = next.autoRefresh !== false;
|
||||||
next.preferredCloneProtocol = ['https', 'ssh'].includes(next.preferredCloneProtocol) ? next.preferredCloneProtocol : 'https';
|
next.preferredCloneProtocol = ['https', 'ssh'].includes(next.preferredCloneProtocol) ? next.preferredCloneProtocol : 'https';
|
||||||
next.diagnosticsEnabled = next.diagnosticsEnabled !== false;
|
next.diagnosticsEnabled = next.diagnosticsEnabled !== false;
|
||||||
@@ -507,6 +690,7 @@ class ConfigStore {
|
|||||||
repositoryMappings: { ...this.data.repositoryMappings },
|
repositoryMappings: { ...this.data.repositoryMappings },
|
||||||
deploymentProfiles: structuredClone(this.data.deploymentProfiles),
|
deploymentProfiles: structuredClone(this.data.deploymentProfiles),
|
||||||
deploymentStates: structuredClone(this.data.deploymentStates),
|
deploymentStates: structuredClone(this.data.deploymentStates),
|
||||||
|
gitValidator: structuredClone(this.data.gitValidator),
|
||||||
favorites: [...this.data.favorites],
|
favorites: [...this.data.favorites],
|
||||||
updates: { ...this.data.updates },
|
updates: { ...this.data.updates },
|
||||||
servers: this.data.servers.map((server) => this.getPublicServer(server)),
|
servers: this.data.servers.map((server) => this.getPublicServer(server)),
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const crypto = require("node:crypto");
|
||||||
|
|
||||||
|
function stable(value) {
|
||||||
|
if (Array.isArray(value)) return value.map(stable);
|
||||||
|
if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function planId(value) {
|
||||||
|
return crypto.createHash("sha256").update(JSON.stringify(stable(value))).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyMaterial(value) {
|
||||||
|
return String(value || "").trim().split(/\s+/).slice(0, 2).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeployKeyLifecycleService {
|
||||||
|
constructor({ store, gitea, keyHost, audit = null, clock = () => new Date().toISOString() }) {
|
||||||
|
this.store = store;
|
||||||
|
this.gitea = gitea;
|
||||||
|
this.keyHost = keyHost;
|
||||||
|
this.audit = audit;
|
||||||
|
this.clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
coordinates(repository) {
|
||||||
|
const [owner, repo] = String(repository?.fullName || "").split("/");
|
||||||
|
if (!owner || !repo) throw Object.assign(new Error("A full Gitea repository name is required."), { code: "DEPLOY_KEY_REPOSITORY_REQUIRED" });
|
||||||
|
return { owner, repo };
|
||||||
|
}
|
||||||
|
|
||||||
|
profile(repository, profileId) {
|
||||||
|
const profile = this.store.getDeploymentProfile(repository.fullName, profileId);
|
||||||
|
if (!profile) throw Object.assign(new Error("Deployment profile not found."), { code: "DEPLOY_KEY_PROFILE_NOT_FOUND" });
|
||||||
|
const server = this.store.getServer(profile.serverId);
|
||||||
|
if (!server) throw Object.assign(new Error("Deployment server not found."), { code: "DEPLOY_KEY_SERVER_NOT_FOUND" });
|
||||||
|
return { profile, server };
|
||||||
|
}
|
||||||
|
|
||||||
|
configuredReferences() {
|
||||||
|
const references = [];
|
||||||
|
const configured = this.store.data?.deploymentProfiles
|
||||||
|
? Object.entries(this.store.data.deploymentProfiles).map(([fullName, profiles]) => ({ fullName, profiles }))
|
||||||
|
: (this.store.getRepositories?.() || []).map((repository) => ({ fullName: repository.fullName, profiles: this.store.getDeploymentProfiles(repository.fullName) || [] }));
|
||||||
|
for (const repository of configured) {
|
||||||
|
for (const profile of repository.profiles || []) {
|
||||||
|
if (!profile.serverGitAccess?.deployKeyId && !profile.serverGitAccess?.keyFingerprint) continue;
|
||||||
|
references.push({ repository: repository.fullName, profileId: profile.id, serverId: profile.serverId, keyId: profile.serverGitAccess.deployKeyId || null, fingerprint: profile.serverGitAccess.keyFingerprint || null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return references;
|
||||||
|
}
|
||||||
|
|
||||||
|
async inventory({ repository, profileId }) {
|
||||||
|
const { profile, server } = this.profile(repository, profileId);
|
||||||
|
const { owner, repo } = this.coordinates(repository);
|
||||||
|
const [remoteKeys, serverKey] = await Promise.all([
|
||||||
|
this.gitea.listDeployKeys(owner, repo),
|
||||||
|
this.keyHost.inspect({ repository, profile, server }),
|
||||||
|
]);
|
||||||
|
const configuredId = Number(profile.serverGitAccess?.deployKeyId) || null;
|
||||||
|
const configured = remoteKeys.find((key) => Number(key.id) === configuredId) || null;
|
||||||
|
const material = keyMaterial(serverKey?.publicKey);
|
||||||
|
const matching = material ? remoteKeys.filter((key) => keyMaterial(key.key) === material) : [];
|
||||||
|
const references = this.configuredReferences();
|
||||||
|
const shared = references.filter((reference) => reference.fingerprint && reference.fingerprint === serverKey?.fingerprint && (reference.repository !== repository.fullName || reference.profileId !== profileId));
|
||||||
|
const conflicts = remoteKeys.filter((key) => key.read_only !== true && (!configuredId || Number(key.id) === configuredId || keyMaterial(key.key) === material));
|
||||||
|
const stale = Boolean(configuredId && !configured) || Boolean(profile.serverGitAccess?.keyFingerprint && serverKey?.fingerprint && profile.serverGitAccess.keyFingerprint !== serverKey.fingerprint);
|
||||||
|
const orphaned = remoteKeys.filter((key) => /ForgeFlow/i.test(String(key.title || "")) && !references.some((reference) => Number(reference.keyId) === Number(key.id)));
|
||||||
|
return {
|
||||||
|
repository: repository.fullName, profileId, server: { id: server.id, name: server.name },
|
||||||
|
configuredKey: configured ? { id: configured.id, title: configured.title, readOnly: configured.read_only === true, key: configured.key || null } : null,
|
||||||
|
serverKey, matchingKeys: matching.map((key) => ({ id: key.id, readOnly: key.read_only === true })),
|
||||||
|
stale, orphaned: orphaned.map((key) => ({ id: key.id, title: key.title })), shared, conflicts: conflicts.map((key) => ({ id: key.id, title: key.title, readOnly: false })),
|
||||||
|
ready: Boolean(configured && configured.read_only === true && serverKey?.privateKeyPresent && serverKey?.fingerprint === profile.serverGitAccess?.keyFingerprint && !shared.length && !conflicts.length),
|
||||||
|
checkedAt: this.clock(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async planRotation({ repository, profileId }) {
|
||||||
|
const evidence = await this.inventory({ repository, profileId });
|
||||||
|
const plan = {
|
||||||
|
operation: "rotate-deploy-key", repository: repository.fullName, profileId,
|
||||||
|
currentKeyId: evidence.configuredKey?.id || null, currentFingerprint: evidence.serverKey?.fingerprint || null,
|
||||||
|
serverId: evidence.server.id, impact: ["Generate a new private key on the linked server", "Register only its public key in this repository", "Verify read-only branch access", "Switch the profile atomically", "Revoke the previous key after the switch"],
|
||||||
|
recovery: "The previous server key and profile metadata remain recoverable until post-rotation verification succeeds.", evidence,
|
||||||
|
};
|
||||||
|
plan.id = planId(plan);
|
||||||
|
await this.audit?.append?.("deployment.deploy-key-rotation-planned", { repository: repository.fullName, profileId, planId: plan.id });
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
async rotate({ repository, profileId, expectedPlanId }) {
|
||||||
|
const plan = await this.planRotation({ repository, profileId });
|
||||||
|
if (!expectedPlanId) throw Object.assign(new Error("Review a deploy-key rotation plan before applying it."), { code: "DEPLOY_KEY_ROTATION_PLAN_REQUIRED", plan });
|
||||||
|
if (expectedPlanId !== plan.id) throw Object.assign(new Error("Deploy-key evidence changed after preview. Review a fresh plan."), { code: "DEPLOY_KEY_ROTATION_PLAN_STALE", plan });
|
||||||
|
const { profile, server } = this.profile(repository, profileId);
|
||||||
|
const { owner, repo } = this.coordinates(repository);
|
||||||
|
const snapshot = await this.store.createRecoverySnapshot?.(`deploy-key-rotation:${repository.fullName}:${profileId}`);
|
||||||
|
const previous = { profile: structuredClone(profile), key: await this.keyHost.backup({ repository, profile, server }), remoteKey: plan.evidence.configuredKey };
|
||||||
|
let candidate = null;
|
||||||
|
let registered = null;
|
||||||
|
let switched = false;
|
||||||
|
let oldRevoked = false;
|
||||||
|
try {
|
||||||
|
candidate = await this.keyHost.generate({ repository, profile, server });
|
||||||
|
if (!candidate?.publicKey || !candidate?.fingerprint || candidate.privateKey) throw Object.assign(new Error("The server did not return safe public-key evidence."), { code: "DEPLOY_KEY_CANDIDATE_INVALID" });
|
||||||
|
registered = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: `ForgeFlow · ${server.name} · ${candidate.fingerprint.slice(-12)}`, publicKey: candidate.publicKey });
|
||||||
|
if (registered.read_only !== true) throw Object.assign(new Error("Gitea registered the candidate with write access."), { code: "DEPLOY_KEY_NOT_READ_ONLY" });
|
||||||
|
const proof = await this.keyHost.verifyCandidate({ repository, profile, server, candidate, keyId: registered.id });
|
||||||
|
if (!proof?.ready || proof.fingerprint !== candidate.fingerprint) throw Object.assign(new Error("The candidate deploy key could not prove read-only repository access."), { code: "DEPLOY_KEY_CANDIDATE_VERIFICATION_FAILED", proof });
|
||||||
|
await this.keyHost.preflightCandidate({ repository, profile, server, candidate, proof });
|
||||||
|
await this.keyHost.promote({ repository, profile, server, candidate, previous });
|
||||||
|
const updated = await this.store.saveDeploymentProfile(repository.fullName, { ...profile, serverGitAccess: { configured: true, deployKeyId: registered.id, keyFingerprint: candidate.fingerprint, hostFingerprint: proof.hostFingerprint, configuredAt: this.clock(), rotatedAt: this.clock(), previousKeyId: previous.remoteKey?.id || null } });
|
||||||
|
switched = true;
|
||||||
|
if (previous.remoteKey?.id) {
|
||||||
|
await this.gitea.deleteDeployKey(owner, repo, previous.remoteKey.id);
|
||||||
|
oldRevoked = true;
|
||||||
|
}
|
||||||
|
const post = await this.keyHost.verifyActive({ repository, profile: updated, server });
|
||||||
|
if (!post?.ready || post.fingerprint !== candidate.fingerprint) throw Object.assign(new Error("Post-rotation verification failed."), { code: "DEPLOY_KEY_POST_ROTATION_FAILED", post });
|
||||||
|
await this.keyHost.commit({ repository, profile: updated, server, candidate, previous });
|
||||||
|
await this.audit?.append?.("deployment.deploy-key-rotated", { repository: repository.fullName, profileId, oldKeyId: previous.remoteKey?.id || null, newKeyId: registered.id, fingerprint: candidate.fingerprint, snapshot: snapshot?.filePath || null });
|
||||||
|
return { profile: updated, proof: post, snapshot, recovery: previous.key?.recovery || null };
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
if (candidate) await this.keyHost.rollback({ repository, profile, server, candidate, previous });
|
||||||
|
if (registered?.id) await this.gitea.deleteDeployKey(owner, repo, registered.id).catch(() => {});
|
||||||
|
let restoredKey = null;
|
||||||
|
if (oldRevoked && previous.remoteKey?.key) restoredKey = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: previous.remoteKey.title || `ForgeFlow · ${server.name} · restored`, publicKey: previous.remoteKey.key });
|
||||||
|
if (switched) await this.store.saveDeploymentProfile(repository.fullName, restoredKey ? { ...previous.profile, serverGitAccess: { ...previous.profile.serverGitAccess, deployKeyId: restoredKey.id } } : previous.profile);
|
||||||
|
} catch (rollbackError) {
|
||||||
|
error.rollbackError = rollbackError.message;
|
||||||
|
}
|
||||||
|
await this.audit?.append?.("deployment.deploy-key-rotation-failed", { repository: repository.fullName, profileId, code: error.code || "DEPLOY_KEY_ROTATION_FAILED", rollbackError: error.rollbackError || null });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async planRevocation({ repository, profileId }) {
|
||||||
|
const evidence = await this.inventory({ repository, profileId });
|
||||||
|
const plan = { operation: "revoke-deploy-key", repository: repository.fullName, profileId, keyId: evidence.configuredKey?.id || null, fingerprint: evidence.serverKey?.fingerprint || null, linkedDeployments: [profileId], impact: ["Remove this repository deploy key from Gitea", "Disable server-pull deployment until restored", "Preserve server-side recovery material"], containersUnaffected: true, evidence };
|
||||||
|
plan.id = planId(plan);
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
async revoke({ repository, profileId, expectedPlanId }) {
|
||||||
|
const plan = await this.planRevocation({ repository, profileId });
|
||||||
|
if (!expectedPlanId) throw Object.assign(new Error("Review revocation impact before applying it."), { code: "DEPLOY_KEY_REVOCATION_PLAN_REQUIRED", plan });
|
||||||
|
if (plan.id !== expectedPlanId) throw Object.assign(new Error("Deploy-key evidence changed after preview."), { code: "DEPLOY_KEY_REVOCATION_PLAN_STALE", plan });
|
||||||
|
const { profile, server } = this.profile(repository, profileId);
|
||||||
|
const { owner, repo } = this.coordinates(repository);
|
||||||
|
const snapshot = await this.store.createRecoverySnapshot?.(`deploy-key-revocation:${repository.fullName}:${profileId}`);
|
||||||
|
const recovery = await this.keyHost.backup({ repository, profile, server });
|
||||||
|
let remoteDeleted = false;
|
||||||
|
try {
|
||||||
|
if (plan.keyId) { await this.gitea.deleteDeployKey(owner, repo, plan.keyId); remoteDeleted = true; }
|
||||||
|
await this.keyHost.revoke({ repository, profile, server, recovery });
|
||||||
|
const updated = await this.store.saveDeploymentProfile(repository.fullName, { ...profile, serverGitAccess: { ...profile.serverGitAccess, configured: false, revokedAt: this.clock(), recoveryAvailable: true }, deploymentMode: "monitor-only" });
|
||||||
|
await this.audit?.append?.("deployment.deploy-key-revoked", { repository: repository.fullName, profileId, keyId: plan.keyId, snapshot: snapshot?.filePath || null });
|
||||||
|
return { profile: updated, snapshot, recovery: recovery?.recovery || null };
|
||||||
|
} catch (error) {
|
||||||
|
if (remoteDeleted && plan.evidence.configuredKey?.key) {
|
||||||
|
const restored = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: plan.evidence.configuredKey.title || `ForgeFlow · ${server.name} · restored`, publicKey: plan.evidence.configuredKey.key });
|
||||||
|
await this.store.saveDeploymentProfile(repository.fullName, { ...profile, serverGitAccess: { ...profile.serverGitAccess, deployKeyId: restored.id } });
|
||||||
|
}
|
||||||
|
await this.keyHost.restore({ repository, profile, server }).catch(() => {});
|
||||||
|
await this.audit?.append?.("deployment.deploy-key-revocation-failed", { repository: repository.fullName, profileId, code: error.code || "DEPLOY_KEY_REVOCATION_FAILED" });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async restore({ repository, profileId }) {
|
||||||
|
const { profile, server } = this.profile(repository, profileId);
|
||||||
|
const restored = await this.keyHost.restore({ repository, profile, server });
|
||||||
|
if (!restored?.publicKey || !restored?.fingerprint) throw Object.assign(new Error("No valid deploy-key recovery material exists."), { code: "DEPLOY_KEY_RECOVERY_UNAVAILABLE" });
|
||||||
|
const { owner, repo } = this.coordinates(repository);
|
||||||
|
const key = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: `ForgeFlow · ${server.name} · restored`, publicKey: restored.publicKey });
|
||||||
|
if (key.read_only !== true) throw Object.assign(new Error("The restored key is not read-only."), { code: "DEPLOY_KEY_NOT_READ_ONLY" });
|
||||||
|
const proposed = { ...profile, deploymentMode: "server-git", serverGitAccess: { configured: true, deployKeyId: key.id, keyFingerprint: restored.fingerprint, hostFingerprint: restored.hostFingerprint, restoredAt: this.clock() } };
|
||||||
|
const proof = await this.keyHost.verifyActive({ repository, profile: proposed, server });
|
||||||
|
if (!proof?.ready) throw Object.assign(new Error("Restored access could not be verified."), { code: "DEPLOY_KEY_RECOVERY_VERIFICATION_FAILED" });
|
||||||
|
const updated = await this.store.saveDeploymentProfile(repository.fullName, proposed);
|
||||||
|
await this.audit?.append?.("deployment.deploy-key-restored", { repository: repository.fullName, profileId, keyId: key.id, fingerprint: restored.fingerprint });
|
||||||
|
return { profile: updated, proof };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { DeployKeyLifecycleService, keyMaterial, deployKeyPlanId: planId };
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const crypto = require("node:crypto");
|
||||||
|
const { normalizeRemoteUrl } = require("../shared/repository-match.cjs");
|
||||||
|
|
||||||
|
function canonicalRemote(value) {
|
||||||
|
const normalized = normalizeRemoteUrl(value);
|
||||||
|
return normalized ? `${normalized.host}/${normalized.path}`.toLowerCase() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function deploymentIdentity({ workload, profile = null, repository = null }) {
|
||||||
|
const remote = canonicalRemote(workload?.metadata?.sourceRepository || repository?.sshUrl || repository?.cloneUrl || profile?.cloneUrl);
|
||||||
|
return {
|
||||||
|
repository: remote || String(workload?.link?.repositoryFullName || repository?.fullName || profile?._repositoryFullName || "").toLowerCase(),
|
||||||
|
branch: String(workload?.metadata?.branch || profile?.branch || repository?.defaultBranch || "").toLowerCase(),
|
||||||
|
serverId: String(workload?.serverId || profile?.serverId || ""),
|
||||||
|
environment: String(profile?.environment || "production").toLowerCase(),
|
||||||
|
composeProject: String(workload?.compose?.project || profile?.composeProject || "").toLowerCase(),
|
||||||
|
deploymentRoot: String(workload?.compose?.workingDir || profile?.composeWorkingDir || workload?.remoteFolderCandidate || profile?.remoteFolder || "").replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase(),
|
||||||
|
containers: (workload?.containers || []).map((item) => String(item.id || item.name || "").toLowerCase()).sort(),
|
||||||
|
liveSha: String(workload?.metadata?.liveRevision || "").toLowerCase(),
|
||||||
|
profileId: String(profile?.id || workload?.link?.profileId || ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function evidenceHash(identity, evidence = {}) {
|
||||||
|
const stable = (value) => Array.isArray(value) ? value.map(stable) : value && typeof value === "object" ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])])) : value;
|
||||||
|
return crypto.createHash("sha256").update(JSON.stringify(stable({ identity, evidence }))).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorityKey(identity) {
|
||||||
|
return [identity.repository, identity.serverId, identity.environment].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { canonicalDeploymentRemote: canonicalRemote, deploymentIdentity, deploymentEvidenceHash: evidenceHash, deploymentAuthorityKey: authorityKey };
|
||||||
@@ -36,10 +36,6 @@ function isRunningStatus(value) {
|
|||||||
return ['running', 'in_progress', 'processing'].includes(String(value || '').toLowerCase());
|
return ['running', 'in_progress', 'processing'].includes(String(value || '').toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
function isQueuedStatus(value) {
|
|
||||||
return ['pending', 'queued', 'waiting', 'blocked', 'requested'].includes(String(value || '').toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
class DeploymentService {
|
class DeploymentService {
|
||||||
constructor(store, giteaService, gitService, diagnostics = null) {
|
constructor(store, giteaService, gitService, diagnostics = null) {
|
||||||
this.store = store;
|
this.store = store;
|
||||||
@@ -328,8 +324,15 @@ class DeploymentService {
|
|||||||
|
|
||||||
async refreshActiveOperations() {
|
async refreshActiveOperations() {
|
||||||
const active = this.store.data.operations.filter((item) => item.type === 'deployment' && !TERMINAL_STATUSES.has(item.status));
|
const active = this.store.data.operations.filter((item) => item.type === 'deployment' && !TERMINAL_STATUSES.has(item.status));
|
||||||
|
const queue = active.slice(0, 20);
|
||||||
const results = [];
|
const results = [];
|
||||||
for (const operation of active.slice(0, 20)) results.push(await this.refreshOperation(operation.id));
|
const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
|
||||||
|
while (queue.length) {
|
||||||
|
const operation = queue.shift();
|
||||||
|
results.push(await this.refreshOperation(operation.id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(workers);
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|
||||||
@@ -46,6 +46,9 @@ class DiagnosticsService {
|
|||||||
this.preferencesProvider = preferencesProvider;
|
this.preferencesProvider = preferencesProvider;
|
||||||
this.sessionId = crypto.randomUUID();
|
this.sessionId = crypto.randomUUID();
|
||||||
this.writeChain = Promise.resolve();
|
this.writeChain = Promise.resolve();
|
||||||
|
this.pendingLines = [];
|
||||||
|
this.pendingFlush = null;
|
||||||
|
this.securedFiles = new Set();
|
||||||
this.initialized = false;
|
this.initialized = false;
|
||||||
this.lastWriteError = null;
|
this.lastWriteError = null;
|
||||||
this.lastBundlePath = null;
|
this.lastBundlePath = null;
|
||||||
@@ -115,13 +118,25 @@ class DiagnosticsService {
|
|||||||
sessionId: this.sessionId,
|
sessionId: this.sessionId,
|
||||||
details
|
details
|
||||||
});
|
});
|
||||||
const line = `${JSON.stringify(record)}\n`;
|
this.pendingLines.push(`${JSON.stringify(record)}\n`);
|
||||||
this.writeChain = this.writeChain.then(async () => {
|
// At the debug level every IPC call and every Gitea request writes a line.
|
||||||
|
// Records that queue up while a write is in flight are appended together, so
|
||||||
|
// a burst costs one open/write/close instead of one per record.
|
||||||
|
if (this.pendingFlush) return this.pendingFlush;
|
||||||
|
this.pendingFlush = this.writeChain.then(async () => {
|
||||||
|
this.pendingFlush = null;
|
||||||
|
const lines = this.pendingLines.splice(0).join('');
|
||||||
|
if (!lines) return true;
|
||||||
try {
|
try {
|
||||||
if (!this.initialized) await fs.mkdir(this.logDirectory, { recursive: true, mode: 0o700 });
|
if (!this.initialized) await fs.mkdir(this.logDirectory, { recursive: true, mode: 0o700 });
|
||||||
const target = await this.rotateIfNeeded(this.filePathForToday());
|
const target = await this.rotateIfNeeded(this.filePathForToday());
|
||||||
await fs.appendFile(target, line, { encoding: 'utf8', mode: 0o600 });
|
await fs.appendFile(target, lines, { encoding: 'utf8', mode: 0o600 });
|
||||||
try { await fs.chmod(target, 0o600); } catch {}
|
// The mode above only applies when appendFile creates the file, so the
|
||||||
|
// explicit chmod is needed once per file rather than once per record.
|
||||||
|
if (!this.securedFiles.has(target)) {
|
||||||
|
try { await fs.chmod(target, 0o600); } catch { /* best effort */ }
|
||||||
|
this.securedFiles.add(target);
|
||||||
|
}
|
||||||
this.lastWriteError = null;
|
this.lastWriteError = null;
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -129,7 +144,8 @@ class DiagnosticsService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return this.writeChain;
|
this.writeChain = this.pendingFlush.catch(() => {});
|
||||||
|
return this.pendingFlush;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug(event, details) { return this.log('debug', event, details); }
|
debug(event, details) { return this.log('debug', event, details); }
|
||||||
@@ -186,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;
|
||||||
@@ -195,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;
|
||||||
@@ -275,9 +300,13 @@ class DiagnosticsService {
|
|||||||
dispatchedAt: operation.dispatchedAt,
|
dispatchedAt: operation.dispatchedAt,
|
||||||
stages: operation.stages,
|
stages: operation.stages,
|
||||||
jobs: operation.jobs,
|
jobs: operation.jobs,
|
||||||
logs: operation.logs,
|
remoteOutput: operation.logs || operation.failure || operation.pollError ? {
|
||||||
failure: operation.failure,
|
included: false,
|
||||||
pollError: operation.pollError,
|
reason: 'Remote build and command output is intentionally omitted because it may contain application secrets unknown to ForgeFlow.',
|
||||||
|
logCharacters: String(operation.logs || '').length,
|
||||||
|
failureRecorded: Boolean(operation.failure),
|
||||||
|
pollErrorRecorded: Boolean(operation.pollError)
|
||||||
|
} : null,
|
||||||
applicationState: operation.applicationState,
|
applicationState: operation.applicationState,
|
||||||
run: operation.run ? {
|
run: operation.run ? {
|
||||||
id: operation.run.id,
|
id: operation.run.id,
|
||||||
@@ -325,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?.() || []);
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
+330
-12
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const fs = require('node:fs/promises');
|
const fs = require('node:fs/promises');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
const { run } = require('./process-runner.cjs');
|
const { run } = require('./process-runner.cjs');
|
||||||
const { parsePorcelainV2 } = require('../shared/git-status.cjs');
|
const { parsePorcelainV2 } = require('../shared/git-status.cjs');
|
||||||
const { normalizeRemoteUrl } = require('../shared/repository-match.cjs');
|
const { normalizeRemoteUrl } = require('../shared/repository-match.cjs');
|
||||||
|
|
||||||
const COMMON_GIT_LOCK_FILES = ['HEAD.lock', 'index.lock'];
|
const COMMON_GIT_LOCK_FILES = ['HEAD.lock', 'index.lock'];
|
||||||
|
const MAX_UNTRACKED_DIFF_BYTES = 16 * 1024 * 1024;
|
||||||
const {
|
const {
|
||||||
assertSafeRepositoryPath,
|
assertSafeRepositoryPath,
|
||||||
assertRepositoryRelativePath,
|
assertRepositoryRelativePath,
|
||||||
@@ -28,7 +30,42 @@ function parseUnifiedDiff(diffText) {
|
|||||||
return { header, hunks };
|
return { header, hunks };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseNameStatus(output) {
|
||||||
|
const entries = String(output || '').split('\0');
|
||||||
|
const changes = [];
|
||||||
|
for (let index = 0; index < entries.length;) {
|
||||||
|
const rawStatus = entries[index++];
|
||||||
|
if (!rawStatus) continue;
|
||||||
|
const code = rawStatus[0];
|
||||||
|
if (code === 'R' || code === 'C') {
|
||||||
|
const originalPath = entries[index++] || '';
|
||||||
|
const filePath = entries[index++] || '';
|
||||||
|
if (filePath) changes.push({ code, status: code === 'R' ? 'renamed' : 'copied', path: filePath, originalPath });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const filePath = entries[index++] || '';
|
||||||
|
if (!filePath) continue;
|
||||||
|
const labels = { A: 'added', D: 'deleted', M: 'modified', T: 'type-changed', U: 'conflict' };
|
||||||
|
changes.push({ code, status: labels[code] || 'changed', path: filePath, originalPath: null });
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCompactLog(output) {
|
||||||
|
return String(output || '').split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
|
||||||
|
const [sha, shortSha, date, subject] = record.split('\x1f');
|
||||||
|
return { sha, shortSha, date, subject };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
class GitService {
|
class GitService {
|
||||||
|
constructor() {
|
||||||
|
// `git remote get-url` is only re-run when the repository configuration file
|
||||||
|
// itself changed. Status polling asks for the remote URL of every repository
|
||||||
|
// every few seconds, and on Windows the child process dominates that cost.
|
||||||
|
this.remoteUrlCache = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
async isAvailable() {
|
async isAvailable() {
|
||||||
try {
|
try {
|
||||||
const result = await run('git', ['--version'], { timeout: 10_000 });
|
const result = await run('git', ['--version'], { timeout: 10_000 });
|
||||||
@@ -42,13 +79,24 @@ class GitService {
|
|||||||
const resolved = assertSafeRepositoryPath(repoPath);
|
const resolved = assertSafeRepositoryPath(repoPath);
|
||||||
const stat = await fs.stat(resolved).catch(() => null);
|
const stat = await fs.stat(resolved).catch(() => null);
|
||||||
if (!stat?.isDirectory()) throw new Error('The linked local folder no longer exists.');
|
if (!stat?.isDirectory()) throw new Error('The linked local folder no longer exists.');
|
||||||
|
// A directory that carries its own `.git` entry is by definition the top level
|
||||||
|
// of that working tree, for plain repositories as well as for submodules and
|
||||||
|
// linked worktrees where `.git` is a file. Spawning `git rev-parse` to learn
|
||||||
|
// that again is pure overhead, and every status poll passes an already
|
||||||
|
// resolved repository root back in.
|
||||||
|
const marker = await fs.stat(path.join(resolved, '.git')).catch(() => null);
|
||||||
|
if (marker) return resolved;
|
||||||
const result = await run('git', ['rev-parse', '--show-toplevel'], { cwd: resolved, timeout: 15_000 });
|
const result = await run('git', ['rev-parse', '--show-toplevel'], { cwd: resolved, timeout: 15_000 });
|
||||||
return path.resolve(result.stdout.trim());
|
return path.resolve(result.stdout.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
async status(repoPath) {
|
async status(repoPath) {
|
||||||
const root = await this.ensureRepository(repoPath);
|
const root = await this.ensureRepository(repoPath);
|
||||||
const result = await run('git', ['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'], {
|
// `--no-optional-locks` keeps a status read from refreshing and rewriting the
|
||||||
|
// index. Without it every read writes inside .git, which both fights a
|
||||||
|
// concurrent Git command for the index lock and retriggers the filesystem
|
||||||
|
// watcher that asked for this read in the first place.
|
||||||
|
const result = await run('git', ['--no-optional-locks', 'status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'], {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
timeout: 30_000
|
timeout: 30_000
|
||||||
});
|
});
|
||||||
@@ -66,9 +114,34 @@ class GitService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
remoteUrlCacheKey(repoPath, remote) {
|
||||||
|
return JSON.stringify([path.resolve(repoPath), remote]);
|
||||||
|
}
|
||||||
|
|
||||||
async getRemoteUrl(repoPath, remote = 'origin') {
|
async getRemoteUrl(repoPath, remote = 'origin') {
|
||||||
|
const cacheKey = this.remoteUrlCacheKey(repoPath, remote);
|
||||||
|
const config = await fs.stat(path.join(repoPath, '.git', 'config')).catch(() => null);
|
||||||
|
const cached = this.remoteUrlCache.get(cacheKey);
|
||||||
|
if (config && cached && cached.mtimeMs === config.mtimeMs && cached.size === config.size) {
|
||||||
|
if (cached.error) throw cached.error;
|
||||||
|
return cached.url;
|
||||||
|
}
|
||||||
|
const remember = (entry) => {
|
||||||
|
if (config) this.remoteUrlCache.set(cacheKey, { ...entry, mtimeMs: config.mtimeMs, size: config.size });
|
||||||
|
else this.remoteUrlCache.delete(cacheKey);
|
||||||
|
};
|
||||||
|
try {
|
||||||
const result = await run('git', ['remote', 'get-url', remote], { cwd: repoPath, timeout: 15_000 });
|
const result = await run('git', ['remote', 'get-url', remote], { cwd: repoPath, timeout: 15_000 });
|
||||||
return result.stdout.trim();
|
const url = result.stdout.trim();
|
||||||
|
remember({ url, error: null });
|
||||||
|
return url;
|
||||||
|
} catch (error) {
|
||||||
|
// A repository that has no such remote keeps failing until its configuration
|
||||||
|
// changes, so the failure is remembered too. Without this, every status poll
|
||||||
|
// of an unmatched local repository spawns a child process that cannot succeed.
|
||||||
|
remember({ url: '', error });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -94,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)
|
||||||
@@ -273,12 +388,163 @@ class GitService {
|
|||||||
return { strategy: requested, backupBranch: null, status, lockReport: await this.listGitLocks(root) };
|
return { strategy: requested, backupBranch: null, status, lockReport: await this.listGitLocks(root) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async previewWorkspaceSync(repoPath) {
|
||||||
|
const root = await this.ensureRepository(repoPath);
|
||||||
|
const { status } = await this.fetch(root);
|
||||||
|
const branch = status.branch?.head;
|
||||||
|
const upstream = status.branch?.upstream;
|
||||||
|
if (!status.head || !branch || branch === '(detached)') {
|
||||||
|
const error = new Error('Workspace synchronization requires a named branch with at least one commit.');
|
||||||
|
error.code = 'WORKSPACE_SYNC_BRANCH_REQUIRED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (!upstream) {
|
||||||
|
const error = new Error('The current branch has no Gitea upstream. Publish it or switch to a tracked branch first.');
|
||||||
|
error.code = 'WORKSPACE_SYNC_UPSTREAM_REQUIRED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetSha = (await run('git', ['rev-parse', '--verify', upstream], { cwd: root, timeout: 30_000 })).stdout.trim();
|
||||||
|
const changes = parseNameStatus((await run('git', [
|
||||||
|
'diff', '--name-status', '-z', '--find-renames', 'HEAD', upstream, '--'
|
||||||
|
], { cwd: root, timeout: 60_000, maxBuffer: 16 * 1024 * 1024 })).stdout);
|
||||||
|
const logFormat = '%H%x1f%h%x1f%aI%x1f%s%x1e';
|
||||||
|
const [incomingResult, localResult, interruptedOperation] = await Promise.all([
|
||||||
|
run('git', ['log', `--format=${logFormat}`, `HEAD..${upstream}`, '-20'], { cwd: root, timeout: 30_000 }),
|
||||||
|
run('git', ['log', `--format=${logFormat}`, `${upstream}..HEAD`, '-20'], { cwd: root, timeout: 30_000 }),
|
||||||
|
this.detectInterruptedOperation(root)
|
||||||
|
]);
|
||||||
|
const blockers = [];
|
||||||
|
if (interruptedOperation) blockers.push(`Finish or abort the active Git ${interruptedOperation} before synchronizing.`);
|
||||||
|
if (status.counts.conflicts) blockers.push(`Resolve ${status.counts.conflicts} conflicted file${status.counts.conflicts === 1 ? '' : 's'} before synchronizing.`);
|
||||||
|
const summary = {
|
||||||
|
resultingTrackedChanges: changes.length,
|
||||||
|
added: changes.filter((item) => item.code === 'A').length,
|
||||||
|
modified: changes.filter((item) => ['M', 'T'].includes(item.code)).length,
|
||||||
|
deleted: changes.filter((item) => item.code === 'D').length,
|
||||||
|
renamed: changes.filter((item) => item.code === 'R').length,
|
||||||
|
localFilesToStash: status.counts.changed,
|
||||||
|
untrackedFilesToStash: status.counts.untracked,
|
||||||
|
localCommitsToProtect: status.branch.ahead,
|
||||||
|
incomingCommits: status.branch.behind
|
||||||
|
};
|
||||||
|
const planId = crypto.createHash('sha256').update(JSON.stringify({
|
||||||
|
head: status.head,
|
||||||
|
targetSha,
|
||||||
|
branch,
|
||||||
|
upstream,
|
||||||
|
fingerprint: this.statusFingerprint(status)
|
||||||
|
})).digest('hex');
|
||||||
|
return {
|
||||||
|
id: planId,
|
||||||
|
repositoryRoot: root,
|
||||||
|
branch,
|
||||||
|
upstream,
|
||||||
|
currentSha: status.head,
|
||||||
|
targetSha,
|
||||||
|
needsSync: status.head !== targetSha || !status.clean,
|
||||||
|
cleanBeforeSync: status.clean,
|
||||||
|
blockers,
|
||||||
|
summary,
|
||||||
|
changes: changes.slice(0, 250),
|
||||||
|
changesTruncated: changes.length > 250,
|
||||||
|
localFiles: status.files.slice(0, 250),
|
||||||
|
localFilesTruncated: status.files.length > 250,
|
||||||
|
incomingCommits: parseCompactLog(incomingResult.stdout),
|
||||||
|
localCommits: parseCompactLog(localResult.stdout),
|
||||||
|
recovery: {
|
||||||
|
safetyBranch: status.branch.ahead > 0,
|
||||||
|
stash: status.counts.changed > 0,
|
||||||
|
untrackedCleanup: status.counts.untracked > 0,
|
||||||
|
ignoredFilesPreserved: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async synchronizeWorkspace(repoPath, expectedPlanId) {
|
||||||
|
const expected = String(expectedPlanId || '').trim();
|
||||||
|
if (!/^[0-9a-f]{64}$/i.test(expected)) {
|
||||||
|
const error = new Error('Apply workspace synchronization only from a reviewed preview.');
|
||||||
|
error.code = 'WORKSPACE_SYNC_PLAN_REQUIRED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const plan = await this.previewWorkspaceSync(repoPath);
|
||||||
|
if (plan.id !== expected) {
|
||||||
|
const error = new Error('The local workspace or Gitea branch changed after the preview. Review a fresh synchronization plan.');
|
||||||
|
error.code = 'WORKSPACE_SYNC_PLAN_STALE';
|
||||||
|
error.recoverable = true;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (plan.blockers.length) {
|
||||||
|
const error = new Error(plan.blockers.join(' '));
|
||||||
|
error.code = 'WORKSPACE_SYNC_BLOCKED';
|
||||||
|
error.recoverable = true;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (!plan.needsSync) {
|
||||||
|
return { applied: false, unchanged: true, plan, status: await this.status(plan.repositoryRoot), backupBranch: null, stash: null, cleaned: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = plan.repositoryRoot;
|
||||||
|
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
|
||||||
|
let backupBranch = null;
|
||||||
|
let stash = null;
|
||||||
|
let review = null;
|
||||||
|
if (plan.summary.localCommitsToProtect > 0) {
|
||||||
|
const safeBranch = plan.branch.replace(/[^A-Za-z0-9._-]/g, '-');
|
||||||
|
backupBranch = `forgeflow/recovery-${safeBranch}-${stamp}-${plan.currentSha.slice(0, 7)}`;
|
||||||
|
await run('git', ['check-ref-format', '--branch', backupBranch], { cwd: root, timeout: 30_000 });
|
||||||
|
await run('git', ['branch', backupBranch, 'HEAD'], { cwd: root, timeout: 30_000 });
|
||||||
|
}
|
||||||
|
if (plan.summary.localFilesToStash > 0) {
|
||||||
|
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 });
|
||||||
|
stash = (await this.stashList(root))[0] || null;
|
||||||
|
}
|
||||||
|
if (backupBranch || stash) {
|
||||||
|
review = await this.writeWorkspaceReviewManifest(root, plan, { backupBranch, stash });
|
||||||
|
}
|
||||||
|
|
||||||
|
const protectedStatus = await this.status(root);
|
||||||
|
if (!protectedStatus.clean || protectedStatus.head !== plan.currentSha) {
|
||||||
|
const error = new Error('The workspace changed while ForgeFlow was protecting local work. Nothing was reset; review a fresh synchronization plan.');
|
||||||
|
error.code = 'WORKSPACE_SYNC_CONCURRENT_CHANGE';
|
||||||
|
error.recoverable = true;
|
||||||
|
error.backupBranch = backupBranch;
|
||||||
|
error.stash = stash;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await run('git', ['reset', '--hard', plan.targetSha], { cwd: root, timeout: 2 * 60_000 });
|
||||||
|
const status = await this.status(root);
|
||||||
|
if (status.head !== plan.targetSha || !status.clean) {
|
||||||
|
const error = new Error('Git did not verify an exact clean match with the reviewed Gitea commit. Local recovery references were preserved.');
|
||||||
|
error.code = 'WORKSPACE_SYNC_VERIFICATION_FAILED';
|
||||||
|
error.recoverable = true;
|
||||||
|
error.backupBranch = backupBranch;
|
||||||
|
error.stash = stash;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
applied: true,
|
||||||
|
unchanged: false,
|
||||||
|
plan,
|
||||||
|
status,
|
||||||
|
backupBranch,
|
||||||
|
stash,
|
||||||
|
review,
|
||||||
|
cleaned: plan.localFiles.filter((file) => file.untracked).map((file) => file.path),
|
||||||
|
ignoredFilesPreserved: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async setRemoteUrl(repoPath, remoteUrl, remote = 'origin') {
|
async setRemoteUrl(repoPath, remoteUrl, remote = 'origin') {
|
||||||
const root = await this.ensureRepository(repoPath);
|
const root = await this.ensureRepository(repoPath);
|
||||||
const safeRemote = assertCloneRemote(remoteUrl);
|
const safeRemote = assertCloneRemote(remoteUrl);
|
||||||
const name = String(remote || 'origin').trim();
|
const name = String(remote || 'origin').trim();
|
||||||
if (!/^[A-Za-z0-9._-]+$/.test(name)) throw new Error('Invalid Git remote name.');
|
if (!/^[A-Za-z0-9._-]+$/.test(name)) throw new Error('Invalid Git remote name.');
|
||||||
await run('git', ['remote', 'set-url', name, safeRemote], { cwd: root, timeout: 30_000 });
|
await run('git', ['remote', 'set-url', name, safeRemote], { cwd: root, timeout: 30_000 });
|
||||||
|
this.remoteUrlCache.delete(this.remoteUrlCacheKey(root, name));
|
||||||
return this.status(root);
|
return this.status(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +558,26 @@ class GitService {
|
|||||||
if (!result.stdout && safeFile && !staged) {
|
if (!result.stdout && safeFile && !staged) {
|
||||||
const candidate = path.resolve(root, safeFile);
|
const candidate = path.resolve(root, safeFile);
|
||||||
if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) throw new Error('File path escapes repository root.');
|
if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) throw new Error('File path escapes repository root.');
|
||||||
const content = await fs.readFile(candidate, 'utf8').catch(() => '');
|
const [realRoot, realCandidate, candidateStat] = await Promise.all([
|
||||||
|
fs.realpath(root).catch(() => root),
|
||||||
|
fs.realpath(candidate).catch(() => candidate),
|
||||||
|
fs.stat(candidate).catch(() => null)
|
||||||
|
]);
|
||||||
|
const normalize = (value) => process.platform === 'win32' ? value.toLowerCase() : value;
|
||||||
|
const normalizedRoot = normalize(realRoot);
|
||||||
|
const normalizedCandidate = normalize(realCandidate);
|
||||||
|
if (normalizedCandidate !== normalizedRoot && !normalizedCandidate.startsWith(`${normalizedRoot}${path.sep}`)) {
|
||||||
|
const error = new Error('ForgeFlow refuses to read a diff target that resolves outside the repository.');
|
||||||
|
error.code = 'DIFF_TARGET_OUTSIDE_REPOSITORY';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (candidateStat?.size > MAX_UNTRACKED_DIFF_BYTES) {
|
||||||
|
const error = new Error('The untracked file is too large to render safely as a diff.');
|
||||||
|
error.code = 'DIFF_FILE_TOO_LARGE';
|
||||||
|
error.recoverable = true;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const content = candidateStat?.isFile() ? await fs.readFile(candidate, 'utf8').catch(() => '') : '';
|
||||||
if (content) return `diff --git a/${safeFile} b/${safeFile}\nnew file mode 100644\n--- /dev/null\n+++ b/${safeFile}\n${content.split('\n').map((line) => `+${line}`).join('\n')}`;
|
if (content) return `diff --git a/${safeFile} b/${safeFile}\nnew file mode 100644\n--- /dev/null\n+++ b/${safeFile}\n${content.split('\n').map((line) => `+${line}`).join('\n')}`;
|
||||||
}
|
}
|
||||||
return result.stdout;
|
return result.stdout;
|
||||||
@@ -354,8 +639,7 @@ class GitService {
|
|||||||
return { selected, matches };
|
return { selected, matches };
|
||||||
}
|
}
|
||||||
|
|
||||||
async expandSelectedPaths(root, files, { unstagedOnly = false } = {}) {
|
expandStatusPaths(status, files, { unstagedOnly = false } = {}) {
|
||||||
const status = await this.status(root);
|
|
||||||
const { selected, matches } = this.selectedStatusFiles(status, files);
|
const { selected, matches } = this.selectedStatusFiles(status, files);
|
||||||
if (!selected.length) return [];
|
if (!selected.length) return [];
|
||||||
const expanded = new Set();
|
const expanded = new Set();
|
||||||
@@ -367,12 +651,17 @@ class GitService {
|
|||||||
return [...expanded];
|
return [...expanded];
|
||||||
}
|
}
|
||||||
|
|
||||||
async stage(repoPath, files) {
|
async expandSelectedPaths(root, files, options = {}) {
|
||||||
const root = await this.ensureRepository(repoPath);
|
return this.expandStatusPaths(await this.status(root), files, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callers that already read the status pass it in. Reading it again costs a
|
||||||
|
// child process, and a commit used to pay for four of them.
|
||||||
|
async applyStage(root, files, knownStatus = null) {
|
||||||
const requested = assertRepositoryRelativePaths(files);
|
const requested = assertRepositoryRelativePaths(files);
|
||||||
if (!requested.length) {
|
if (!requested.length) {
|
||||||
await run('git', ['add', '--all'], { cwd: root, timeout: 60_000 });
|
await run('git', ['add', '--all'], { cwd: root, timeout: 60_000 });
|
||||||
return this.status(root);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only stage records that still have a worktree-side change. Re-running
|
// Only stage records that still have a worktree-side change. Re-running
|
||||||
@@ -380,10 +669,16 @@ class GitService {
|
|||||||
// Git fail with "pathspec did not match any files" because the file no
|
// Git fail with "pathspec did not match any files" because the file no
|
||||||
// longer exists in either the worktree or HEAD. Staged-only deletions and
|
// longer exists in either the worktree or HEAD. Staged-only deletions and
|
||||||
// renames are already ready for commit and must therefore be left alone.
|
// renames are already ready for commit and must therefore be left alone.
|
||||||
const selected = await this.expandSelectedPaths(root, requested, { unstagedOnly: true });
|
const status = knownStatus || await this.status(root);
|
||||||
|
const selected = this.expandStatusPaths(status, requested, { unstagedOnly: true });
|
||||||
if (selected.length) {
|
if (selected.length) {
|
||||||
await this.runWithPathspec(root, ['add', '-A'], selected, { timeout: 120_000 });
|
await this.runWithPathspec(root, ['add', '-A'], selected, { timeout: 120_000 });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stage(repoPath, files) {
|
||||||
|
const root = await this.ensureRepository(repoPath);
|
||||||
|
await this.applyStage(root, files);
|
||||||
return this.status(root);
|
return this.status(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,8 +698,9 @@ class GitService {
|
|||||||
|
|
||||||
async prepareSelectedStage(root, files) {
|
async prepareSelectedStage(root, files) {
|
||||||
const selected = assertRepositoryRelativePaths(files);
|
const selected = assertRepositoryRelativePaths(files);
|
||||||
|
let current = null;
|
||||||
if (selected.length) {
|
if (selected.length) {
|
||||||
const current = await this.status(root);
|
current = await this.status(root);
|
||||||
const excludedStaged = current.files
|
const excludedStaged = current.files
|
||||||
.filter((file) => file.staged)
|
.filter((file) => file.staged)
|
||||||
.filter((file) => !selected.includes(file.path) && !(file.originalPath && selected.includes(file.originalPath)))
|
.filter((file) => !selected.includes(file.path) && !(file.originalPath && selected.includes(file.originalPath)))
|
||||||
@@ -413,7 +709,7 @@ class GitService {
|
|||||||
throw new Error(`Some staged files are not selected (${excludedStaged.slice(0, 3).join(', ')}${excludedStaged.length > 3 ? ', …' : ''}). Select them or unstage them first.`);
|
throw new Error(`Some staged files are not selected (${excludedStaged.slice(0, 3).join(', ')}${excludedStaged.length > 3 ? ', …' : ''}). Select them or unstage them first.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.stage(root, selected);
|
await this.applyStage(root, selected, current);
|
||||||
const stagedCheck = await run('git', ['diff', '--cached', '--quiet'], { cwd: root, allowExitCodes: [1] });
|
const stagedCheck = await run('git', ['diff', '--cached', '--quiet'], { cwd: root, allowExitCodes: [1] });
|
||||||
if (stagedCheck.exitCode === 0) throw new Error('There are no staged changes to commit.');
|
if (stagedCheck.exitCode === 0) throw new Error('There are no staged changes to commit.');
|
||||||
return selected;
|
return selected;
|
||||||
@@ -469,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) };
|
||||||
@@ -555,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
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,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) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const crypto = require("node:crypto");
|
||||||
|
|
||||||
|
const PROFILE_DEFINITIONS = Object.freeze({
|
||||||
|
minimal: { label: "Minimal", requiredScore: 55, severities: ["error"], allowSuppressions: true, maxSuppressionDays: 180 },
|
||||||
|
standard: { label: "Standard", requiredScore: 70, severities: ["error", "warning"], allowSuppressions: true, maxSuppressionDays: 90 },
|
||||||
|
strict: { label: "Strict", requiredScore: 82, severities: ["error", "warning"], allowSuppressions: true, maxSuppressionDays: 30 },
|
||||||
|
production: { label: "Production", requiredScore: 90, severities: ["error", "warning"], allowSuppressions: true, maxSuppressionDays: 14 },
|
||||||
|
});
|
||||||
|
|
||||||
|
function normalizePolicy(policy = {}) {
|
||||||
|
const id = String(policy.id || policy.profile || "standard").toLowerCase();
|
||||||
|
const base = PROFILE_DEFINITIONS[id] || PROFILE_DEFINITIONS.standard;
|
||||||
|
const custom = id === "organization" ? policy : {};
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: custom.label || base.label || "Organization custom",
|
||||||
|
requiredScore: Math.min(100, Math.max(0, Number(custom.requiredScore ?? base.requiredScore ?? 80))),
|
||||||
|
enabledChecks: Array.isArray(custom.enabledChecks) ? [...new Set(custom.enabledChecks.map(String))] : null,
|
||||||
|
severityOverrides: custom.severityOverrides && typeof custom.severityOverrides === "object" ? { ...custom.severityOverrides } : {},
|
||||||
|
blockingChecks: [...new Set((custom.blockingChecks || policy.blockingChecks || []).map(String))],
|
||||||
|
blockingSeverities: [...new Set((custom.blockingSeverities || policy.blockingSeverities || base.severities || ["error"]).map(String))]
|
||||||
|
.filter((severity) => ["warning", "error"].includes(severity)),
|
||||||
|
allowSuppressions: custom.allowSuppressions ?? base.allowSuppressions ?? true,
|
||||||
|
maxSuppressionDays: Math.max(1, Number(custom.maxSuppressionDays ?? base.maxSuppressionDays ?? 30)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSuppression(input, policy, now = new Date()) {
|
||||||
|
if (!policy.allowSuppressions) throw new Error("The selected policy does not allow suppressions.");
|
||||||
|
const checkId = String(input?.checkId || "").trim();
|
||||||
|
const reason = String(input?.reason || "").trim();
|
||||||
|
const author = String(input?.author || "").trim();
|
||||||
|
const scope = String(input?.scope || "repository").trim();
|
||||||
|
const evidence = String(input?.evidence || "").trim();
|
||||||
|
const expiresAt = new Date(input?.expiresAt || "");
|
||||||
|
if (!checkId || reason.length < 10 || !author || !evidence) throw new Error("A suppression requires a check ID, author, evidence and a reason of at least 10 characters.");
|
||||||
|
if (!Number.isFinite(expiresAt.getTime()) || expiresAt <= now) throw new Error("A suppression expiry must be in the future.");
|
||||||
|
const maximum = new Date(now.getTime() + policy.maxSuppressionDays * 86_400_000);
|
||||||
|
if (expiresAt > maximum) throw new Error(`This policy permits suppressions for at most ${policy.maxSuppressionDays} days.`);
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(), checkId, reason, author,
|
||||||
|
createdAt: now.toISOString(), ticket: String(input.ticket || "").trim() || null,
|
||||||
|
expiresAt: expiresAt.toISOString(), scope, evidence,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPolicy(checks, policyInput, suppressions = [], now = new Date()) {
|
||||||
|
const policy = normalizePolicy(policyInput);
|
||||||
|
const enabled = policy.enabledChecks ? new Set(policy.enabledChecks) : null;
|
||||||
|
const relevant = checks.filter((check) => !enabled || enabled.has(check.id)).map((check) => {
|
||||||
|
const status = policy.severityOverrides[check.id] || check.status;
|
||||||
|
const suppression = suppressions.find((item) => item.checkId === check.id && new Date(item.expiresAt) > now);
|
||||||
|
const expiredSuppression = suppressions.find((item) => item.checkId === check.id && new Date(item.expiresAt) <= now);
|
||||||
|
return {
|
||||||
|
...check,
|
||||||
|
status,
|
||||||
|
suppressed: Boolean(suppression),
|
||||||
|
suppression: suppression || null,
|
||||||
|
expiredSuppression: expiredSuppression || null,
|
||||||
|
blocking: !suppression && status !== "pass" && (policy.blockingSeverities.includes(status) || policy.blockingChecks.includes(check.id)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { policy, checks: relevant };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTrend(previous, report) {
|
||||||
|
const prior = new Map((previous?.checks || []).map((check) => [check.id, check]));
|
||||||
|
const current = new Map(report.checks.map((check) => [check.id, check]));
|
||||||
|
const active = (check) => check && check.status !== "pass" && !check.suppressed;
|
||||||
|
const newlyFound = [...current.values()].filter((check) => active(check) && !active(prior.get(check.id))).map((check) => check.id);
|
||||||
|
const resolved = [...prior.values()].filter((check) => active(check) && !active(current.get(check.id))).map((check) => check.id);
|
||||||
|
const regressions = [...current.values()].filter((check) => active(check) && prior.get(check.id)?.status === "warning" && check.status === "error").map((check) => check.id);
|
||||||
|
return { score: report.score, categories: report.categories, newlyFound, resolved, regressions, suppressions: report.checks.filter((check) => check.suppressed).map((check) => check.id), checkedAt: report.checkedAt, commitSha: report.commitSha || null, checks: report.checks.map(({ id, status, suppressed }) => ({ id, status, suppressed })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportReport(report, format = "json") {
|
||||||
|
if (format === "json") return { extension: "json", mimeType: "application/json", content: `${JSON.stringify(report, null, 2)}\n` };
|
||||||
|
const rows = report.checks.map((check) => `| ${check.id} | ${check.category} | ${check.status}${check.suppressed ? " (suppressed)" : ""} | ${String(check.detail).replace(/\|/g, "\\|")} |`).join("\n");
|
||||||
|
const markdown = `# Git assurance report — ${report.repository}\n\nPolicy: **${report.policy.label}** · Score: **${report.score}/100** · Commit: \`${report.commitSha || "unknown"}\`\n\n| Check | Category | Status | Evidence |\n|---|---|---|---|\n${rows}\n`;
|
||||||
|
if (format === "markdown") return { extension: "md", mimeType: "text/markdown", content: markdown };
|
||||||
|
if (format !== "html") throw new Error("Unsupported Git Validator export format.");
|
||||||
|
const escape = (value) => String(value).replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]);
|
||||||
|
const htmlRows = report.checks.map((check) => `<tr><td>${escape(check.id)}</td><td>${escape(check.category)}</td><td>${escape(check.status)}${check.suppressed ? " (suppressed)" : ""}</td><td>${escape(check.detail)}</td></tr>`).join("");
|
||||||
|
return { extension: "html", mimeType: "text/html", content: `<!doctype html><html lang="en"><meta charset="utf-8"><title>Git assurance — ${escape(report.repository)}</title><style>body{font:15px system-ui;max-width:1100px;margin:40px auto;padding:0 24px;color:#172033}table{border-collapse:collapse;width:100%}th,td{padding:10px;border:1px solid #ccd4e0;text-align:left}th{background:#edf2f7}</style><h1>Git assurance — ${escape(report.repository)}</h1><p>Policy: <strong>${escape(report.policy.label)}</strong> · Score: <strong>${report.score}/100</strong> · Commit: <code>${escape(report.commitSha || "unknown")}</code></p><table><thead><tr><th>Check</th><th>Category</th><th>Status</th><th>Evidence</th></tr></thead><tbody>${htmlRows}</tbody></table></html>` };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { PROFILE_DEFINITIONS, normalizePolicy, validateSuppression, applyPolicy, buildTrend, exportReport };
|
||||||
@@ -0,0 +1,599 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("node:fs/promises");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { run } = require("./process-runner.cjs");
|
||||||
|
const { normalizeRemoteUrl } = require("../shared/repository-match.cjs");
|
||||||
|
const { applyPolicy, buildTrend, exportReport, normalizePolicy, validateSuppression } = require("./git-validator-policy.cjs");
|
||||||
|
|
||||||
|
const RECOMMENDED_GITIGNORE = `# Local configuration and secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.sample
|
||||||
|
|
||||||
|
# Dependencies and generated output
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Editors and operating systems
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
`;
|
||||||
|
|
||||||
|
const RECOMMENDED_GITATTRIBUTES = `* text=auto eol=lf
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
*.ps1 text eol=crlf
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.zip binary
|
||||||
|
`;
|
||||||
|
|
||||||
|
const RECOMMENDED_EDITORCONFIG = `root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.{bat,cmd,ps1}]
|
||||||
|
end_of_line = crlf
|
||||||
|
`;
|
||||||
|
|
||||||
|
function sameRemote(left, right) {
|
||||||
|
const a = normalizeRemoteUrl(left);
|
||||||
|
const b = normalizeRemoteUrl(right);
|
||||||
|
return Boolean(a && b && a.host === b.host && a.path === b.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function result(id, category, title, status, detail, options = {}) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
category,
|
||||||
|
title,
|
||||||
|
status,
|
||||||
|
detail,
|
||||||
|
weight: options.weight || 5,
|
||||||
|
fixAction: options.fixAction || null,
|
||||||
|
safe: options.safe === true,
|
||||||
|
confirmation: options.confirmation || null,
|
||||||
|
evidence: options.evidence || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSensitiveTrackedPath(filePath) {
|
||||||
|
const value = String(filePath || "")
|
||||||
|
.replace(/\\/g, "/")
|
||||||
|
.toLowerCase();
|
||||||
|
if (/\.env\.(example|sample|template)$/.test(value)) return false;
|
||||||
|
return (
|
||||||
|
/(^|\/)\.env($|\.)/.test(value) ||
|
||||||
|
/(^|\/)(id_rsa|id_ed25519)$/.test(value) ||
|
||||||
|
/\.(pem|p12|pfx|key)$/.test(value) ||
|
||||||
|
/(^|\/)(credentials|secrets?)(\.[^/]+)?\.(json|ya?ml)$/.test(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class GitValidatorService {
|
||||||
|
constructor({ git, gitea, diagnostics, store }) {
|
||||||
|
this.git = git;
|
||||||
|
this.gitea = gitea;
|
||||||
|
this.diagnostics = diagnostics;
|
||||||
|
this.store = store;
|
||||||
|
}
|
||||||
|
|
||||||
|
async config(root, key, { local = true } = {}) {
|
||||||
|
const response = await run(
|
||||||
|
"git",
|
||||||
|
["config", ...(local ? ["--local"] : []), "--get", key],
|
||||||
|
{
|
||||||
|
cwd: root,
|
||||||
|
timeout: 10_000,
|
||||||
|
allowExitCodes: [1],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.stdout.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async trackedFiles(root) {
|
||||||
|
const response = await run("git", ["ls-files", "-z"], {
|
||||||
|
cwd: root,
|
||||||
|
timeout: 30_000,
|
||||||
|
maxBuffer: 16 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
return response.stdout.split("\0").filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async scan(repository) {
|
||||||
|
const checks = [];
|
||||||
|
const defaultBranch = repository.defaultBranch || "main";
|
||||||
|
const owner = repository.owner?.login;
|
||||||
|
try {
|
||||||
|
const protection = await this.gitea.getBranchProtection(
|
||||||
|
owner,
|
||||||
|
repository.name,
|
||||||
|
defaultBranch,
|
||||||
|
);
|
||||||
|
checks.push(
|
||||||
|
result(
|
||||||
|
"default-branch-protection",
|
||||||
|
"Gitea governance",
|
||||||
|
"Default branch protection",
|
||||||
|
protection.protected ? "pass" : "warning",
|
||||||
|
protection.protected
|
||||||
|
? `${defaultBranch} is protected; force push is ${protection.enableForcePush ? "allowed" : "blocked"}.`
|
||||||
|
: `${defaultBranch} accepts unprotected direct changes.`,
|
||||||
|
{
|
||||||
|
weight: 18,
|
||||||
|
fixAction: protection.protected ? null : "protect-default-branch",
|
||||||
|
safe: false,
|
||||||
|
confirmation: `Protect ${defaultBranch} on Gitea and block direct and force pushes?`,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (protection.protected)
|
||||||
|
checks.push(
|
||||||
|
result(
|
||||||
|
"force-push",
|
||||||
|
"Gitea governance",
|
||||||
|
"Force-push protection",
|
||||||
|
protection.enableForcePush ? "warning" : "pass",
|
||||||
|
protection.enableForcePush
|
||||||
|
? "Force pushes remain enabled on the protected branch."
|
||||||
|
: "Force pushes are blocked on the protected branch.",
|
||||||
|
{ weight: 8 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
checks.push(
|
||||||
|
result(
|
||||||
|
"branch-protection-unavailable",
|
||||||
|
"Gitea governance",
|
||||||
|
"Branch protection could not be verified",
|
||||||
|
"warning",
|
||||||
|
error.message,
|
||||||
|
{ weight: 18 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!repository.localPath) {
|
||||||
|
checks.push(
|
||||||
|
result(
|
||||||
|
"local-link",
|
||||||
|
"Local repository",
|
||||||
|
"Local working tree",
|
||||||
|
"warning",
|
||||||
|
"Link or clone this repository to validate files and local Git configuration.",
|
||||||
|
{ weight: 35 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return this.finalize(repository, checks, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = await this.git.ensureRepository(repository.localPath);
|
||||||
|
const status = await this.git.status(root);
|
||||||
|
const tracked = await this.trackedFiles(root);
|
||||||
|
const lowerFiles = tracked.map((file) => file.toLowerCase());
|
||||||
|
const desiredRemote =
|
||||||
|
repository.preferredCloneUrl || repository.cloneUrl || repository.sshUrl;
|
||||||
|
checks.push(
|
||||||
|
result(
|
||||||
|
"origin",
|
||||||
|
"Repository identity",
|
||||||
|
"Origin matches Gitea",
|
||||||
|
sameRemote(status.remoteUrl, desiredRemote) ? "pass" : "error",
|
||||||
|
sameRemote(status.remoteUrl, desiredRemote)
|
||||||
|
? status.remoteUrl
|
||||||
|
: `Current origin ${status.remoteUrl || "is missing"}; expected ${desiredRemote}.`,
|
||||||
|
{
|
||||||
|
weight: 15,
|
||||||
|
fixAction: sameRemote(status.remoteUrl, desiredRemote)
|
||||||
|
? null
|
||||||
|
: "align-origin",
|
||||||
|
safe: true,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
checks.push(
|
||||||
|
result(
|
||||||
|
"upstream",
|
||||||
|
"Branch hygiene",
|
||||||
|
"Current branch has an upstream",
|
||||||
|
status.branch?.upstream ? "pass" : "warning",
|
||||||
|
status.branch?.upstream
|
||||||
|
? `${status.branch.head} tracks ${status.branch.upstream}.`
|
||||||
|
: `${status.branch?.head || "The current branch"} is not published or tracked.`,
|
||||||
|
{ weight: 8 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
checks.push(
|
||||||
|
result(
|
||||||
|
"working-tree",
|
||||||
|
"Branch hygiene",
|
||||||
|
"Working tree is intentional",
|
||||||
|
status.clean ? "pass" : "warning",
|
||||||
|
status.clean
|
||||||
|
? "No uncommitted changes."
|
||||||
|
: `${status.counts.changed} changed file(s) require review, commit or stash.`,
|
||||||
|
{ weight: 5 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [userName, userEmail, fetchPrune, pullFf, autoStash] =
|
||||||
|
await Promise.all([
|
||||||
|
this.config(root, "user.name", { local: false }),
|
||||||
|
this.config(root, "user.email", { local: false }),
|
||||||
|
this.config(root, "fetch.prune"),
|
||||||
|
this.config(root, "pull.ff"),
|
||||||
|
this.config(root, "rebase.autoStash"),
|
||||||
|
]);
|
||||||
|
checks.push(
|
||||||
|
result(
|
||||||
|
"identity",
|
||||||
|
"Commit integrity",
|
||||||
|
"Repository author identity",
|
||||||
|
userName && userEmail ? "pass" : "warning",
|
||||||
|
userName && userEmail
|
||||||
|
? `${userName} <${userEmail}>`
|
||||||
|
: "The effective Git user.name or user.email is missing.",
|
||||||
|
{ weight: 7 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const safetyReady =
|
||||||
|
fetchPrune === "true" && pullFf === "only" && autoStash === "true";
|
||||||
|
checks.push(
|
||||||
|
result(
|
||||||
|
"local-safety",
|
||||||
|
"Local configuration",
|
||||||
|
"Safe synchronization defaults",
|
||||||
|
safetyReady ? "pass" : "warning",
|
||||||
|
safetyReady
|
||||||
|
? "Stale remotes are pruned, pulls are fast-forward-only and rebase autostash is enabled."
|
||||||
|
: "Recommended repository-local fetch, pull and autostash safeguards are incomplete.",
|
||||||
|
{
|
||||||
|
weight: 10,
|
||||||
|
fixAction: safetyReady ? null : "configure-local-safety",
|
||||||
|
safe: true,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasReadme = lowerFiles.some((file) =>
|
||||||
|
/(^|\/)readme(\.[^/]+)?$/.test(file),
|
||||||
|
);
|
||||||
|
checks.push(
|
||||||
|
result(
|
||||||
|
"readme",
|
||||||
|
"Repository documentation",
|
||||||
|
"README is versioned",
|
||||||
|
hasReadme ? "pass" : "warning",
|
||||||
|
hasReadme
|
||||||
|
? "Repository purpose and usage can be documented at the source."
|
||||||
|
: "No tracked README was found.",
|
||||||
|
{ weight: 7 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const hasGitignore = lowerFiles.includes(".gitignore");
|
||||||
|
checks.push(
|
||||||
|
result(
|
||||||
|
"gitignore",
|
||||||
|
"Repository hygiene",
|
||||||
|
".gitignore is versioned",
|
||||||
|
hasGitignore ? "pass" : "warning",
|
||||||
|
hasGitignore
|
||||||
|
? "Generated and local-only files can be excluded centrally."
|
||||||
|
: "No tracked .gitignore was found.",
|
||||||
|
{
|
||||||
|
weight: 8,
|
||||||
|
fixAction: hasGitignore ? null : "add-gitignore",
|
||||||
|
safe: false,
|
||||||
|
confirmation:
|
||||||
|
"Create a recommended .gitignore in the working tree? It will remain uncommitted for review.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const [id, title, filename, action] of [
|
||||||
|
["gitattributes", ".gitattributes normalizes text and binary files", ".gitattributes", "add-gitattributes"],
|
||||||
|
["editorconfig", ".editorconfig keeps editors consistent", ".editorconfig", "add-editorconfig"],
|
||||||
|
]) {
|
||||||
|
const present = lowerFiles.includes(filename);
|
||||||
|
checks.push(result(id, "Repository hygiene", title, present ? "pass" : "warning",
|
||||||
|
present ? `${filename} is versioned.` : `No tracked ${filename} was found.`, {
|
||||||
|
weight: 5,
|
||||||
|
fixAction: present ? null : action,
|
||||||
|
safe: false,
|
||||||
|
confirmation: `Create a recommended ${filename} in the working tree for review?`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageManagers = [
|
||||||
|
{ manifests: ["package.json"], locks: ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"] },
|
||||||
|
{ manifests: ["pyproject.toml", "requirements.in", "pipfile"], locks: ["uv.lock", "poetry.lock", "requirements.txt", "pipfile.lock"] },
|
||||||
|
{ manifests: ["composer.json"], locks: ["composer.lock"] },
|
||||||
|
{ manifests: ["gemfile"], locks: ["gemfile.lock"] },
|
||||||
|
];
|
||||||
|
const lockCheck = packageManagers.find((entry) => entry.manifests.some((name) => lowerFiles.includes(name)));
|
||||||
|
if (lockCheck) {
|
||||||
|
const lockfile = lockCheck.locks.find((name) => lowerFiles.includes(name));
|
||||||
|
checks.push(result("dependency-lock", "Supply chain", "Dependencies are reproducibly locked", lockfile ? "pass" : "warning",
|
||||||
|
lockfile ? `${lockfile} is versioned.` : "A dependency manifest exists without a recognized lockfile.", { weight: 9 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCi = lowerFiles.some((file) => /^\.gitea\/workflows\/[^/]+\.ya?ml$/.test(file));
|
||||||
|
checks.push(result("continuous-integration", "Gitea governance", "Automated checks run on Gitea", hasCi ? "pass" : "warning",
|
||||||
|
hasCi ? "At least one Gitea Actions workflow is versioned." : "No .gitea/workflows YAML file was found.", { weight: 8 }));
|
||||||
|
|
||||||
|
const sensitive = tracked.filter(isSensitiveTrackedPath);
|
||||||
|
checks.push(
|
||||||
|
result(
|
||||||
|
"tracked-secrets",
|
||||||
|
"Security",
|
||||||
|
"No secret-shaped files are tracked",
|
||||||
|
sensitive.length ? "error" : "pass",
|
||||||
|
sensitive.length
|
||||||
|
? `Review immediately: ${sensitive.slice(0, 8).join(", ")}${sensitive.length > 8 ? "…" : ""}. Removing a file does not erase Git history.`
|
||||||
|
: "No tracked environment, private-key or credential filenames were detected.",
|
||||||
|
{ weight: 22 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const large = [];
|
||||||
|
const candidates = tracked.slice(0, 5000);
|
||||||
|
for (
|
||||||
|
let index = 0;
|
||||||
|
index < candidates.length && large.length < 12;
|
||||||
|
index += 64
|
||||||
|
) {
|
||||||
|
const batch = candidates.slice(index, index + 64);
|
||||||
|
const stats = await Promise.all(
|
||||||
|
batch.map(async (file) => ({
|
||||||
|
file,
|
||||||
|
stat: await fs.stat(path.join(root, file)).catch(() => null),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
for (const item of stats) {
|
||||||
|
if (item.stat?.isFile() && item.stat.size > 10 * 1024 * 1024)
|
||||||
|
large.push({ file: item.file, size: item.stat.size });
|
||||||
|
if (large.length >= 12) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checks.push(
|
||||||
|
result(
|
||||||
|
"large-files",
|
||||||
|
"Repository performance",
|
||||||
|
"No oversized tracked files",
|
||||||
|
large.length ? "warning" : "pass",
|
||||||
|
large.length
|
||||||
|
? `${large.map((item) => `${item.file} (${Math.ceil(item.size / 1024 / 1024)} MB)`).join(", ")}. Consider Git LFS.`
|
||||||
|
: "No tracked files above 10 MB were found.",
|
||||||
|
{ weight: 7 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await this.addAssuranceChecks(root, tracked, lowerFiles, checks);
|
||||||
|
return this.finalize(repository, checks, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addAssuranceChecks(root, tracked, lowerFiles, checks) {
|
||||||
|
const has = (...patterns) => lowerFiles.some((file) => patterns.some((pattern) => pattern.test(file)));
|
||||||
|
const fileCheck = (id, category, title, patterns, detail, weight = 5) => {
|
||||||
|
const present = has(...patterns);
|
||||||
|
checks.push(result(id, category, title, present ? "pass" : "warning", present ? `${title} is versioned.` : detail, { weight }));
|
||||||
|
};
|
||||||
|
fileCheck("security-policy", "Security", "Security policy", [/(^|\/)security\.md$/], "Add SECURITY.md with supported versions and private disclosure instructions.", 8);
|
||||||
|
fileCheck("codeowners", "Governance", "Code ownership", [/(^|\/)codeowners$/], "Add CODEOWNERS for security-sensitive and release paths.", 6);
|
||||||
|
fileCheck("license", "Governance", "Repository license", [/(^|\/)(license|copying)(\.[^/]+)?$/], "Document the repository license or private-use terms.", 5);
|
||||||
|
fileCheck("changelog", "Release readiness", "Changelog", [/(^|\/)changelog(\.[^/]+)?$/], "Add a changelog that maps releases to user-visible changes.", 7);
|
||||||
|
fileCheck("contributing", "Collaboration", "Contribution guide", [/(^|\/)contributing(\.[^/]+)?$/], "Add contribution, test and review instructions.", 4);
|
||||||
|
fileCheck("issue-templates", "Collaboration", "Issue templates", [/^\.gitea\/issue_template\//, /^\.github\/issue_template\//], "Add structured issue templates.", 3);
|
||||||
|
fileCheck("pull-request-template", "Collaboration", "Pull request template", [/(^|\/)pull_request_template\.md$/], "Add a pull request checklist for tests, risk and rollback.", 4);
|
||||||
|
fileCheck("runtime-pinning", "Reproducibility", "Runtime version pinning", [/(^|\/)(\.nvmrc|\.node-version|\.tool-versions|mise\.toml)$/], "Pin the runtime version used by developers and CI.", 7);
|
||||||
|
fileCheck("build-instructions", "Reproducibility", "Build instructions", [/(^|\/)(readme|building|build)(\.[^/]+)?$/], "Document a clean, reproducible build command.", 6);
|
||||||
|
|
||||||
|
const generated = tracked.filter((file) => /(^|\/)(dist|build|coverage|\.cache)\//i.test(file));
|
||||||
|
checks.push(result("generated-artifacts", "Performance and hygiene", "Generated output is not tracked", generated.length ? "warning" : "pass", generated.length ? `${generated.length} generated-path file(s) are tracked; review ${generated.slice(0, 5).join(", ")}.` : "No common generated output directories are tracked.", { weight: 8, evidence: generated.slice(0, 20) }));
|
||||||
|
const executables = tracked.filter((file) => /\.(exe|dll|msi|scr|com|bat|cmd|ps1)$/i.test(file));
|
||||||
|
checks.push(result("executable-artifacts", "Security", "Executable artifacts are intentional", executables.length ? "warning" : "pass", executables.length ? `Review executable content: ${executables.slice(0, 8).join(", ")}.` : "No executable-shaped artifacts are tracked.", { weight: 8, evidence: executables.slice(0, 20) }));
|
||||||
|
|
||||||
|
const workflowFiles = tracked.filter((file) => /^\.(gitea|github)\/workflows\/[^/]+\.ya?ml$/i.test(file));
|
||||||
|
const workflowText = (await Promise.all(workflowFiles.slice(0, 40).map((file) => fs.readFile(path.join(root, file), "utf8").catch(() => "")))).join("\n");
|
||||||
|
const unpinned = [...workflowText.matchAll(/uses:\s*[^\s@]+@([^\s#]+)/g)].map((match) => match[1]).filter((ref) => !/^[0-9a-f]{40}$/i.test(ref));
|
||||||
|
checks.push(result("pinned-actions", "Security", "External CI actions are commit-pinned", unpinned.length ? "warning" : "pass", unpinned.length ? `${unpinned.length} action reference(s) use mutable tags or branches.` : "External actions are commit-pinned or no external actions are used.", { weight: 9, evidence: unpinned.slice(0, 20) }));
|
||||||
|
const broadPermissions = /permissions:\s*(write-all|write)/i.test(workflowText) || /contents:\s*write/i.test(workflowText);
|
||||||
|
checks.push(result("workflow-permissions", "Security", "Workflow permissions use least privilege", broadPermissions ? "error" : "pass", broadPermissions ? "A workflow requests broad write permissions; scope permissions per job and capability." : "No broad workflow write permission was detected.", { weight: 12 }));
|
||||||
|
|
||||||
|
const [commitSignature, tagSignature, recentSubjects] = await Promise.all([
|
||||||
|
run("git", ["log", "-1", "--format=%G?"], { cwd: root, timeout: 10_000, allowExitCodes: [128] }).then((value) => value.stdout.trim()).catch(() => "N"),
|
||||||
|
run("git", ["tag", "--points-at", "HEAD", "--format=%(contents:signature)"], { cwd: root, timeout: 10_000, allowExitCodes: [128] }).then((value) => value.stdout.trim()).catch(() => ""),
|
||||||
|
run("git", ["log", "-20", "--format=%s"], { cwd: root, timeout: 10_000, allowExitCodes: [128] }).then((value) => value.stdout.trim().split(/\r?\n/).filter(Boolean)).catch(() => []),
|
||||||
|
]);
|
||||||
|
checks.push(result("signed-commits", "Governance", "Latest commit is signed", /[GUYX]/.test(commitSignature) ? "pass" : "warning", /[GUYX]/.test(commitSignature) ? "Git reports a cryptographic signature on HEAD." : "HEAD has no verifiable Git signature.", { weight: 6 }));
|
||||||
|
checks.push(result("signed-tags", "Governance", "Release tags are signed", tagSignature ? "pass" : "warning", tagSignature ? "HEAD has a signed tag." : "HEAD has no signed release tag.", { weight: 5 }));
|
||||||
|
const conventional = recentSubjects.length > 0 && recentSubjects.every((subject) => /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?!?:\s.+/i.test(subject));
|
||||||
|
checks.push(result("conventional-commits", "Governance", "Recent commits follow a convention", conventional ? "pass" : "warning", conventional ? `${recentSubjects.length} recent commit subject(s) follow Conventional Commits.` : "Some recent commit subjects do not follow Conventional Commits.", { weight: 4 }));
|
||||||
|
|
||||||
|
const releaseFiles = {
|
||||||
|
"release-checksums": /(^|\/)(sha256sums|checksums?)(\.[^/]+)?$/,
|
||||||
|
"release-provenance": /(^|\/)(provenance|attestation)(\.[^/]+)?$/,
|
||||||
|
"release-sbom": /(^|\/)(sbom)(\.[^/]+)?$/,
|
||||||
|
};
|
||||||
|
for (const [id, pattern] of Object.entries(releaseFiles)) fileCheck(id, "Release readiness", id.replace(/^release-/, "Release "), [pattern], `No ${id.replace(/^release-/, "")} artifact is versioned or generated in the repository.`, 4);
|
||||||
|
checks.push(result("signing-readiness", "Release readiness", "Signing policy is documented", has(/(^|\/)(security|release|signing)(\.[^/]+)?$/) ? "pass" : "warning", has(/(^|\/)(security|release|signing)(\.[^/]+)?$/) ? "Signing guidance is present." : "Document signing identity, verification and timestamp requirements.", { weight: 6 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async finalize(repository, checks, status) {
|
||||||
|
const repositoryState = this.store?.getGitValidatorState?.(repository.fullName) || { policy: { id: "standard" }, suppressions: [], trends: [] };
|
||||||
|
const { policy, checks: governedChecks } = applyPolicy(checks, repositoryState.policy, repositoryState.suppressions);
|
||||||
|
const report = this.summarize(repository, governedChecks);
|
||||||
|
report.policy = policy;
|
||||||
|
report.commitSha = status?.head || status?.branch?.oid || null;
|
||||||
|
report.categories = Object.fromEntries([...new Set(governedChecks.map((check) => check.category))].map((category) => {
|
||||||
|
const categoryChecks = governedChecks.filter((check) => check.category === category);
|
||||||
|
return [category, Math.round(categoryChecks.filter((check) => check.status === "pass" || check.suppressed).length / categoryChecks.length * 100)];
|
||||||
|
}));
|
||||||
|
report.ready = report.score >= policy.requiredScore && !governedChecks.some((check) => check.blocking);
|
||||||
|
report.expiredSuppressions = governedChecks.filter((check) => check.expiredSuppression).map((check) => check.id);
|
||||||
|
report.trend = buildTrend(repositoryState.trends.at(-1), report);
|
||||||
|
if (this.store?.appendGitValidatorTrend) await this.store.appendGitValidatorTrend(repository.fullName, report.trend);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPolicy(repository, policyInput) {
|
||||||
|
const policy = normalizePolicy(policyInput);
|
||||||
|
if (!this.store?.setGitValidatorPolicy) throw new Error("Git Validator policy persistence is unavailable.");
|
||||||
|
await this.store.setGitValidatorPolicy(repository.fullName, policy);
|
||||||
|
return policy;
|
||||||
|
}
|
||||||
|
|
||||||
|
async suppress(repository, input) {
|
||||||
|
const state = this.store?.getGitValidatorState?.(repository.fullName) || { policy: { id: "standard" } };
|
||||||
|
const suppression = validateSuppression(input, normalizePolicy(state.policy));
|
||||||
|
await this.store.addGitValidatorSuppression(repository.fullName, suppression);
|
||||||
|
return suppression;
|
||||||
|
}
|
||||||
|
|
||||||
|
export(report, format) { return exportReport(report, format); }
|
||||||
|
|
||||||
|
async previewRepair(repository, check) {
|
||||||
|
if (!check?.fixAction) throw new Error("This validator check has no repair action.");
|
||||||
|
const root = repository.localPath ? await this.git.ensureRepository(repository.localPath) : null;
|
||||||
|
const fileDefinitions = {
|
||||||
|
"add-gitignore": [".gitignore", RECOMMENDED_GITIGNORE],
|
||||||
|
"add-gitattributes": [".gitattributes", RECOMMENDED_GITATTRIBUTES],
|
||||||
|
"add-editorconfig": [".editorconfig", RECOMMENDED_EDITORCONFIG],
|
||||||
|
};
|
||||||
|
if (fileDefinitions[check.fixAction]) {
|
||||||
|
const [name, content] = fileDefinitions[check.fixAction];
|
||||||
|
if (await fs.stat(path.join(root, name)).catch(() => null)) throw new Error(`${name} already exists; rescan before repairing.`);
|
||||||
|
return { checkId: check.id, action: check.fixAction, files: [name], diff: `diff --git a/${name} b/${name}\nnew file mode 100644\n--- /dev/null\n+++ b/${name}\n${content.split("\n").filter((line, index, lines) => index < lines.length - 1).map((line) => `+${line}`).join("\n")}\n`, remoteMutation: false };
|
||||||
|
}
|
||||||
|
if (check.fixAction === "configure-local-safety") return { checkId: check.id, action: check.fixAction, files: [".git/config"], diff: "+ fetch.prune = true\n+ pull.ff = only\n+ rebase.autoStash = true\n", remoteMutation: false };
|
||||||
|
if (check.fixAction === "align-origin") return { checkId: check.id, action: check.fixAction, files: [".git/config"], diff: `- origin = current\n+ origin = ${repository.preferredCloneUrl || repository.cloneUrl || repository.sshUrl}\n`, remoteMutation: false };
|
||||||
|
if (check.fixAction === "protect-default-branch") return { checkId: check.id, action: check.fixAction, files: [], diff: `Gitea policy change:\n+ protect ${repository.defaultBranch || "main"}\n+ block force pushes\n+ require pull request review\n`, remoteMutation: true };
|
||||||
|
throw new Error("Unsupported Git Validator repair action.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveRepairCheck(repository, candidate) {
|
||||||
|
const checkId = String(candidate?.id || candidate?.checkId || "").trim();
|
||||||
|
if (!checkId) throw new Error("A current Git Validator check ID is required.");
|
||||||
|
const report = await this.scan(repository);
|
||||||
|
const current = report.checks.find((check) => check.id === checkId);
|
||||||
|
if (!current?.fixAction)
|
||||||
|
throw new Error("This finding is resolved, suppressed or no longer repairable. Scan again before repairing.");
|
||||||
|
if (candidate?.fixAction && candidate.fixAction !== current.fixAction)
|
||||||
|
throw new Error("The Git Validator repair request is stale. Scan again before repairing.");
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
summarize(repository, checks) {
|
||||||
|
const totalWeight = checks.reduce((sum, check) => sum + check.weight, 0);
|
||||||
|
const earned = checks.reduce(
|
||||||
|
(sum, check) =>
|
||||||
|
sum +
|
||||||
|
(check.status === "pass" || check.suppressed
|
||||||
|
? check.weight
|
||||||
|
: check.status === "warning"
|
||||||
|
? check.weight * 0.45
|
||||||
|
: 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const score = totalWeight ? Math.round((earned / totalWeight) * 100) : 0;
|
||||||
|
return {
|
||||||
|
repository: repository.fullName,
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
score,
|
||||||
|
grade:
|
||||||
|
score >= 90
|
||||||
|
? "Excellent"
|
||||||
|
: score >= 75
|
||||||
|
? "Good"
|
||||||
|
: score >= 55
|
||||||
|
? "Needs attention"
|
||||||
|
: "High risk",
|
||||||
|
checks,
|
||||||
|
summary: {
|
||||||
|
passed: checks.filter((check) => check.status === "pass").length,
|
||||||
|
warnings: checks.filter((check) => check.status === "warning" && !check.suppressed).length,
|
||||||
|
errors: checks.filter((check) => check.status === "error" && !check.suppressed).length,
|
||||||
|
suppressed: checks.filter((check) => check.suppressed).length,
|
||||||
|
repairable: checks.filter((check) => check.fixAction).length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async repair(repository, check) {
|
||||||
|
if (!check?.fixAction)
|
||||||
|
throw new Error("This validator check has no repair action.");
|
||||||
|
const root = repository.localPath
|
||||||
|
? await this.git.ensureRepository(repository.localPath)
|
||||||
|
: null;
|
||||||
|
if (check.fixAction === "align-origin") {
|
||||||
|
return this.git.setRemoteUrl(
|
||||||
|
root,
|
||||||
|
repository.preferredCloneUrl ||
|
||||||
|
repository.cloneUrl ||
|
||||||
|
repository.sshUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (check.fixAction === "configure-local-safety") {
|
||||||
|
for (const [key, value] of [
|
||||||
|
["fetch.prune", "true"],
|
||||||
|
["pull.ff", "only"],
|
||||||
|
["rebase.autoStash", "true"],
|
||||||
|
])
|
||||||
|
await run("git", ["config", "--local", key, value], {
|
||||||
|
cwd: root,
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
return { configured: true };
|
||||||
|
}
|
||||||
|
if (check.fixAction === "add-gitignore") {
|
||||||
|
const target = path.join(root, ".gitignore");
|
||||||
|
const exists = await fs.stat(target).catch(() => null);
|
||||||
|
if (exists)
|
||||||
|
throw new Error(".gitignore already exists; rescan before repairing.");
|
||||||
|
await fs.writeFile(target, RECOMMENDED_GITIGNORE, {
|
||||||
|
encoding: "utf8",
|
||||||
|
flag: "wx",
|
||||||
|
});
|
||||||
|
return { created: ".gitignore" };
|
||||||
|
}
|
||||||
|
if (["add-gitattributes", "add-editorconfig"].includes(check.fixAction)) {
|
||||||
|
const definition = check.fixAction === "add-gitattributes"
|
||||||
|
? { name: ".gitattributes", content: RECOMMENDED_GITATTRIBUTES }
|
||||||
|
: { name: ".editorconfig", content: RECOMMENDED_EDITORCONFIG };
|
||||||
|
const target = path.join(root, definition.name);
|
||||||
|
if (await fs.stat(target).catch(() => null))
|
||||||
|
throw new Error(`${definition.name} already exists; rescan before repairing.`);
|
||||||
|
await fs.writeFile(target, definition.content, { encoding: "utf8", flag: "wx" });
|
||||||
|
return { created: definition.name };
|
||||||
|
}
|
||||||
|
if (check.fixAction === "protect-default-branch") {
|
||||||
|
return this.gitea.createBranchProtection(
|
||||||
|
repository.owner.login,
|
||||||
|
repository.name,
|
||||||
|
repository.defaultBranch || "main",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error("Unsupported Git Validator repair action.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
GitValidatorService,
|
||||||
|
RECOMMENDED_GITIGNORE,
|
||||||
|
RECOMMENDED_GITATTRIBUTES,
|
||||||
|
RECOMMENDED_EDITORCONFIG,
|
||||||
|
sameRemote,
|
||||||
|
isSensitiveTrackedPath,
|
||||||
|
};
|
||||||
+102
-6
@@ -204,6 +204,79 @@ class GiteaService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createBranchProtection(owner, repo, branch) {
|
||||||
|
const target = assertBranchName(branch);
|
||||||
|
return (
|
||||||
|
await this.request(
|
||||||
|
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branch_protections`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
rule_name: target,
|
||||||
|
branch_name: target,
|
||||||
|
enable_push: false,
|
||||||
|
enable_force_push: false,
|
||||||
|
required_approvals: 0,
|
||||||
|
dismiss_stale_approvals: true,
|
||||||
|
block_on_rejected_reviews: true,
|
||||||
|
block_on_outdated_branch: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listDeployKeys(owner, repo) {
|
||||||
|
const result = await this.request(
|
||||||
|
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys?limit=100`,
|
||||||
|
);
|
||||||
|
return Array.isArray(result.data) ? result.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureReadOnlyDeployKey({ owner, repo, title, publicKey }) {
|
||||||
|
const key = String(publicKey || "").trim();
|
||||||
|
if (!/^ssh-(ed25519|rsa)\s+[A-Za-z0-9+/=]+(?:\s+.*)?$/.test(key))
|
||||||
|
throw new Error("The server did not return a valid SSH public key.");
|
||||||
|
const keys = await this.listDeployKeys(owner, repo);
|
||||||
|
const keyMaterial = key.split(/\s+/).slice(0, 2).join(" ");
|
||||||
|
const existing = keys.find((item) =>
|
||||||
|
String(item?.key || "").trim().split(/\s+/).slice(0, 2).join(" ") === keyMaterial,
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
if (existing.read_only !== true) {
|
||||||
|
const error = new Error("The matching Gitea deploy key has write access. Revoke it before ForgeFlow configures a read-only server key.");
|
||||||
|
error.code = "DEPLOY_KEY_NOT_READ_ONLY";
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return { ...existing, created: false };
|
||||||
|
}
|
||||||
|
const result = await this.request(
|
||||||
|
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
title: String(title || "ForgeFlow server deploy key").trim().slice(0, 255),
|
||||||
|
key,
|
||||||
|
read_only: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { ...result.data, created: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createReadOnlyDeployKey({ owner, repo, title, publicKey }) {
|
||||||
|
const key = String(publicKey || "").trim();
|
||||||
|
if (!/^ssh-(ed25519|rsa)\s+[A-Za-z0-9+/=]+(?:\s+.*)?$/.test(key)) throw new Error("A valid SSH public key is required.");
|
||||||
|
const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys`, { method: "POST", body: { title: String(title || "ForgeFlow server deploy key").trim().slice(0, 255), key, read_only: true } });
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteDeployKey(owner, repo, keyId) {
|
||||||
|
if (!Number.isInteger(Number(keyId)) || Number(keyId) <= 0) throw new Error("A valid deploy-key ID is required.");
|
||||||
|
await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys/${Number(keyId)}`, { method: "DELETE" });
|
||||||
|
return { deleted: true, keyId: Number(keyId) };
|
||||||
|
}
|
||||||
|
|
||||||
async listPullRequests({ owner, repo, state = "open", limit = 30 } = {}) {
|
async listPullRequests({ owner, repo, state = "open", limit = 30 } = {}) {
|
||||||
const query = new URLSearchParams({
|
const query = new URLSearchParams({
|
||||||
state,
|
state,
|
||||||
@@ -303,13 +376,15 @@ class GiteaService {
|
|||||||
const token = this.store.getToken();
|
const token = this.store.getToken();
|
||||||
let target = new URL(url, `${baseUrl}/`);
|
let target = new URL(url, `${baseUrl}/`);
|
||||||
for (let redirects = 0; redirects <= 5; redirects += 1) {
|
for (let redirects = 0; redirects <= 5; redirects += 1) {
|
||||||
if (target.origin !== base.origin)
|
const sameOrigin = target.origin === base.origin;
|
||||||
|
if (!sameOrigin && target.protocol !== "https:") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Refusing to send the Gitea token to a different origin.",
|
"Refusing an insecure cross-origin update download redirect.",
|
||||||
);
|
);
|
||||||
|
}
|
||||||
const response = await fetch(target, {
|
const response = await fetch(target, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `token ${token}`,
|
...(sameOrigin && token ? { Authorization: `token ${token}` } : {}),
|
||||||
Accept: "application/octet-stream",
|
Accept: "application/octet-stream",
|
||||||
},
|
},
|
||||||
signal: AbortSignal.timeout(timeout),
|
signal: AbortSignal.timeout(timeout),
|
||||||
@@ -331,12 +406,33 @@ class GiteaService {
|
|||||||
throw new Error("The update download exceeded the redirect limit.");
|
throw new Error("The update download exceeded the redirect limit.");
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadReleaseAsset(owner, repo, assetId, options = {}) {
|
async downloadReleaseAsset(owner, repo, releaseId, assetId, options = {}) {
|
||||||
|
const numericReleaseId = Number(releaseId);
|
||||||
const numericId = Number(assetId);
|
const numericId = Number(assetId);
|
||||||
|
if (!Number.isSafeInteger(numericReleaseId) || numericReleaseId <= 0)
|
||||||
|
throw new Error("Gitea returned an invalid release ID.");
|
||||||
if (!Number.isSafeInteger(numericId) || numericId <= 0)
|
if (!Number.isSafeInteger(numericId) || numericId <= 0)
|
||||||
throw new Error("Gitea returned an invalid release asset ID.");
|
throw new Error("Gitea returned an invalid release asset ID.");
|
||||||
const assetPath = `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/assets/${numericId}`;
|
|
||||||
return this.downloadAuthenticated(assetPath, options);
|
let downloadUrl = String(options.downloadUrl || "").trim();
|
||||||
|
if (!downloadUrl) {
|
||||||
|
const metadataPath = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${numericReleaseId}/assets/${numericId}`;
|
||||||
|
const metadata = (await this.request(metadataPath)).data;
|
||||||
|
if (Number(metadata?.id) !== numericId) {
|
||||||
|
throw new Error("Gitea returned metadata for a different release asset.");
|
||||||
|
}
|
||||||
|
downloadUrl = String(metadata?.browser_download_url || "").trim();
|
||||||
|
}
|
||||||
|
if (!downloadUrl) {
|
||||||
|
throw new Error("Gitea did not provide a release asset download URL.");
|
||||||
|
}
|
||||||
|
const configuredBase = new URL(normalizeBaseUrl(this.store.data.gitea.baseUrl));
|
||||||
|
const publishedUrl = new URL(downloadUrl, configuredBase);
|
||||||
|
const releasePrefix = `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/download/`.toLowerCase();
|
||||||
|
if (publishedUrl.origin !== configuredBase.origin && publishedUrl.protocol === "http:" && publishedUrl.pathname.toLowerCase().startsWith(releasePrefix)) {
|
||||||
|
downloadUrl = new URL(`${publishedUrl.pathname}${publishedUrl.search}`, configuredBase).toString();
|
||||||
|
}
|
||||||
|
return this.downloadAuthenticated(downloadUrl, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async dispatchWorkflow({ owner, repo, workflowFile, ref, inputs = {} }) {
|
async dispatchWorkflow({ owner, repo, workflowFile, ref, inputs = {} }) {
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const { deploymentIdentity, deploymentEvidenceHash, deploymentAuthorityKey } = require("./deployment-identity.cjs");
|
||||||
|
|
||||||
|
const BACKUP = /(?:^|[\\/._-])(backup|bak|archive|snapshot|old|previous)(?:[\\/._-]|$)/i;
|
||||||
|
const RELEASE = /(?:^|[\\/])(releases?|versions?)(?:[\\/]|$)/i;
|
||||||
|
const STAGING = /(?:^|[\\/._-])(staging|stage|test|qa|preview)(?:[\\/._-]|$)/i;
|
||||||
|
const TEMPORARY = /(?:^|[\\/._-])(candidate|rollback|ephemeral)(?:[\\/._-]|$)|^GITEA-ACTIONS-TASK-/i;
|
||||||
|
const SYSTEM = /^(?:traefik|nginx-proxy-manager|watchtower|portainer|dockerman|unraid-|cloudflared|redis|postgres|mariadb|mysql)(?:$|[-_.])/i;
|
||||||
|
|
||||||
|
function baseClassification(workload) {
|
||||||
|
const location = `${workload.compose?.workingDir || ""} ${(workload.compose?.configFiles || []).join(" ")}`;
|
||||||
|
const sourceRepository = String(workload.metadata?.sourceRepository || "").trim();
|
||||||
|
const hasGitProvenance = /^(?:git@|ssh:\/\/|https?:\/\/)/i.test(sourceRepository);
|
||||||
|
const decision = workload.reviewDecision;
|
||||||
|
if (["manual-exclude", "exclude-scan-root", "ignore"].includes(decision?.action)) return { type: "manually-excluded", reason: decision.reason || "Persisted manual exclusion", decisionAction: decision.action };
|
||||||
|
if (["mark-historical", "archive-link"].includes(decision?.action)) return { type: "historical-compose", reason: decision.reason || "Reviewed as historical", decisionAction: decision.action };
|
||||||
|
if (decision?.action === "monitor-only") return { type: "monitor-only", reason: decision.reason || "Reviewed for monitoring only", decisionAction: decision.action };
|
||||||
|
if (workload.metadata?.staleLink) return { type: "stale-link", reason: "The linked deployment profile has no matching server workload" };
|
||||||
|
if (BACKUP.test(location)) return { type: "backup", reason: "Path matches backup/archive evidence" };
|
||||||
|
if (RELEASE.test(location)) return { type: "release-folder", reason: "Path is below a release/version directory" };
|
||||||
|
if (STAGING.test(location)) return { type: "staging", reason: "Path or project identifies a staging/test workload" };
|
||||||
|
if (TEMPORARY.test(`${workload.displayName || ""} ${location}`)) return { type: "temporary-runtime", reason: "Runtime identity marks a candidate, rollback or CI workload" };
|
||||||
|
if (SYSTEM.test(workload.displayName || "") && !workload.metadata?.sourceRepository) return { type: "system-container", reason: "Known infrastructure identity without repository provenance" };
|
||||||
|
if (workload.link && workload.runtime?.running) return { type: "active-application", reason: "Linked deployment with running container evidence" };
|
||||||
|
if (workload.link && !workload.runtime?.running) return { type: "stopped-application", reason: "Linked deployment without a running container" };
|
||||||
|
if (!workload.containers?.length && workload.compose?.configFiles?.length) return { type: "historical-compose", reason: "Compose definition exists without container runtime" };
|
||||||
|
if (workload.status === "ambiguous") return { type: "ambiguous", reason: "Multiple candidates have equivalent evidence" };
|
||||||
|
if (!workload.candidates?.length) return { type: "external-container", reason: hasGitProvenance ? "Repository provenance does not match an accessible configured Gitea repository" : "Runtime has no Git repository provenance and remains monitoring-only" };
|
||||||
|
if (!workload.runtime?.running && workload.candidates?.length) return { type: "stopped-application", reason: "Stopped runtime has repository evidence" };
|
||||||
|
return { type: workload.runtime?.running ? "active-application" : "ambiguous", reason: workload.runtime?.running ? "Running application evidence" : "Insufficient authoritative evidence" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyInventory(workloads, profiles = [], decisions = []) {
|
||||||
|
const profileById = new Map(profiles.map((profile) => [profile.id, profile]));
|
||||||
|
const decisionByWorkload = new Map(decisions.map((decision) => [decision.workloadId, decision]));
|
||||||
|
const authorities = new Map();
|
||||||
|
const result = workloads.map((source) => {
|
||||||
|
const workload = structuredClone(source);
|
||||||
|
const profile = profileById.get(workload.link?.profileId) || null;
|
||||||
|
const identity = deploymentIdentity({ workload, profile });
|
||||||
|
const evidence = { candidates: (workload.candidates || []).map((item) => ({ repository: item.repositoryFullName, score: item.score, exact: item.exact === true })), running: workload.runtime?.running === true, health: workload.runtime?.health || null, configFiles: workload.compose?.configFiles || [] };
|
||||||
|
const hash = deploymentEvidenceHash(identity, evidence);
|
||||||
|
const stored = decisionByWorkload.get(workload.workloadId);
|
||||||
|
workload.reviewDecision = stored?.evidenceHash === hash ? stored : null;
|
||||||
|
workload.reviewDecisionStale = Boolean(stored && stored.evidenceHash !== hash);
|
||||||
|
workload.identity = identity;
|
||||||
|
if (workload.reviewDecision?.action === "manual-link" && workload.reviewDecision.repositoryFullName) {
|
||||||
|
workload.identity.repository = String(workload.reviewDecision.repositoryFullName).toLowerCase();
|
||||||
|
}
|
||||||
|
workload.evidenceHash = hash;
|
||||||
|
workload.classification = baseClassification(workload);
|
||||||
|
if (workload.link && ["backup", "release-folder", "staging", "temporary-runtime", "historical-compose", "system-container", "external-container", "manually-excluded"].includes(workload.classification.type)) {
|
||||||
|
workload.shadowedLink = workload.link;
|
||||||
|
workload.link = null;
|
||||||
|
}
|
||||||
|
const key = deploymentAuthorityKey(identity);
|
||||||
|
if (identity.repository && (workload.link || workload.candidates?.length) && !["backup", "release-folder", "staging", "temporary-runtime", "historical-compose", "system-container", "external-container", "manually-excluded"].includes(workload.classification.type)) {
|
||||||
|
const group = authorities.get(key) || [];
|
||||||
|
group.push(workload);
|
||||||
|
authorities.set(key, group);
|
||||||
|
}
|
||||||
|
return workload;
|
||||||
|
});
|
||||||
|
for (const group of authorities.values()) {
|
||||||
|
if (group.length < 2) {
|
||||||
|
group[0].authoritative = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ranked = [...group].sort((a, b) => Number(b.reviewDecision?.action === "select-authoritative") - Number(a.reviewDecision?.action === "select-authoritative") || Number(b.runtime?.running) - Number(a.runtime?.running) || Number(Boolean(b.link)) - Number(Boolean(a.link)) || Number(Boolean(b.metadata?.liveRevision)) - Number(Boolean(a.metadata?.liveRevision)));
|
||||||
|
ranked[0].authoritative = true;
|
||||||
|
for (const duplicate of ranked.slice(1)) {
|
||||||
|
duplicate.authoritative = false;
|
||||||
|
duplicate.classification = { type: "duplicate", reason: `Conflicts with authoritative workload ${ranked[0].workloadId}`, authoritativeWorkloadId: ranked[0].workloadId };
|
||||||
|
duplicate.status = "duplicate";
|
||||||
|
duplicate.shadowedLink = duplicate.link;
|
||||||
|
duplicate.link = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { classifyInventory, classifyWorkload: baseClassification, inventoryPathPatterns: { BACKUP, RELEASE, STAGING, TEMPORARY, SYSTEM } };
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user