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 | ||
|
|
ff1fcd3303 | ||
|
|
e377889263 | ||
|
|
c5cf384f9a | ||
|
|
84ed89bccf | ||
|
|
2174b79544 | ||
|
|
1dc3bea8dd | ||
|
|
858b09afeb | ||
|
|
b5b6660fdc | ||
|
|
d1f4cb6ba8 | ||
|
|
181330b78f |
@@ -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
|
||||||
@@ -3,34 +3,42 @@ name: ForgeFlow quality gate
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
pull_request:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
secret-scan:
|
secret-scan:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: https://gitea.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
- name: Secret scan
|
- name: Secret scan
|
||||||
uses: trufflesecurity/trufflehog@v3.79.0
|
shell: bash
|
||||||
with:
|
run: |
|
||||||
path: ./
|
set -euo pipefail
|
||||||
extra_args: --only-verified
|
scan_container="$(docker create ghcr.io/trufflesecurity/trufflehog:3.79.0 filesystem /scan --only-verified --fail --no-update)"
|
||||||
|
trap 'docker rm -f "${scan_container}" >/dev/null 2>&1 || true' EXIT
|
||||||
|
tar --exclude=.git --transform='s#^\.$#scan#;s#^\./#scan/#' -cf - . | docker cp - "${scan_container}:/"
|
||||||
|
docker start -a "${scan_container}"
|
||||||
|
|
||||||
quality:
|
quality:
|
||||||
runs-on: windows-latest
|
# Browser quality runs against the dedicated bounded Windows 11 VM runner.
|
||||||
|
runs-on: windows-native
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: https://gitea.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
- uses: actions/setup-node@v4
|
- uses: https://gitea.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
# The native runner deliberately skips Electron's install-time binary
|
||||||
|
# download. Prime it once before Node's parallel test workers require
|
||||||
|
# Electron, otherwise they can race while creating the same directory.
|
||||||
|
- run: npx electron --version
|
||||||
- run: npm run quality
|
- run: npm run quality
|
||||||
- run: npx playwright install --with-deps chromium
|
- run: npx playwright install chromium
|
||||||
- run: npm run test:browser:ci
|
- run: npm run test:browser:ci
|
||||||
- name: Preserve browser failure evidence
|
- name: Preserve browser failure evidence
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v4
|
uses: https://gitea.com/actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
with:
|
with:
|
||||||
name: forgeflow-browser-failure-evidence
|
name: forgeflow-browser-failure-evidence
|
||||||
path: artifacts/
|
path: artifacts/
|
||||||
|
|||||||
@@ -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."
|
||||||
+15
@@ -1,9 +1,24 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
*.log
|
*.log
|
||||||
coverage/
|
coverage/
|
||||||
artifacts/
|
artifacts/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
.forgeflow/
|
.forgeflow/
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.pfx
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
!build/update-signing-public.pem
|
||||||
|
.codex/
|
||||||
|
.claude/
|
||||||
|
.agents/
|
||||||
|
.dyad/
|
||||||
|
.idea/
|
||||||
|
.vs/
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
ForgeFlow is een Windows-desktopapp voor wie Git, Gitea en eigen Docker- of Unraid-servers gebruikt. Je ziet in één werkruimte wat lokaal gewijzigd is, wat op Gitea staat en welke exacte commit op de server draait. ForgeFlow begeleidt je daarna veilig door review, commit, push, deployment en verificatie.
|
ForgeFlow is 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.
|
||||||
|
|
||||||
> Huidige release: **0.10.14** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest)
|
> Huidige release: **0.10.15** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -138,6 +138,7 @@ Voor serverdetectie en SSH-deployments heb je daarnaast een bereikbare Docker- o
|
|||||||
- [SETUP_GUIDE.md](docs/SETUP_GUIDE.md) — Gitea, projectmappen en eerste ingebruikname;
|
- [SETUP_GUIDE.md](docs/SETUP_GUIDE.md) — Gitea, projectmappen en eerste ingebruikname;
|
||||||
- [DEPLOYMENT_SETUP.md](docs/DEPLOYMENT_SETUP.md) — deploymentprofielen en verificatie;
|
- [DEPLOYMENT_SETUP.md](docs/DEPLOYMENT_SETUP.md) — deploymentprofielen en verificatie;
|
||||||
- [SSH_UNRAID_DEPLOYMENT.md](docs/SSH_UNRAID_DEPLOYMENT.md) — SSH- en Unraid-vereisten;
|
- [SSH_UNRAID_DEPLOYMENT.md](docs/SSH_UNRAID_DEPLOYMENT.md) — SSH- en Unraid-vereisten;
|
||||||
|
- [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.
|
- [DIAGNOSTICS.md](docs/DIAGNOSTICS.md) — veilige controles en supportbundels.
|
||||||
|
|
||||||
## Ontwikkelen vanuit de broncode
|
## Ontwikkelen vanuit de broncode
|
||||||
|
|||||||
+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.
|
||||||
+60
-55
@@ -1,14 +1,13 @@
|
|||||||
ForgeFlow 0.10.14 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.)
|
||||||
61f37822ae5502219a38b2eaf23fdcb611875f0e675efb4abe6157c9f072c0cc 937 .gitea/workflows/quality.yml
|
ec40b1ed8e5152ca4175bbe97be43f0e2e911894112dc6a47c2c348069f79abf 87 .gitattributes
|
||||||
4a9e8a955ad8c9fa7ba3f8f89cf9920ac1d28c6e5b344782e12d02c3b0fab1ee 105 .gitignore
|
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
|
f14b4987904bcb5814e4459a057ed4d20f58a633152288a761214dcd28780b56 3 .nvmrc
|
||||||
d0b1bd421359311871224f9fa1cff5a802000933668017d9e42e5190f8d2d8e5 152 .playwright-mcp/page-2026-07-29T17-41-03-014Z.yml
|
|
||||||
528fe408ad4b49c621dd57dd831cecf7ec00b8865069f6ed3b80fefc2e0b7823 8267 .playwright-mcp/page-2026-07-29T17-41-26-269Z.yml
|
|
||||||
804c6dac953c5094671784919ff35ebda756306a04ef34fe01cd7cf7cd50b2dc 16909 .playwright-mcp/page-2026-07-29T17-41-46-590Z.yml
|
|
||||||
0f17fdea98e15ebcf7f3ed356d31b0fc89be62f7bc1d25b26c8a41e4b5deca68 160 .playwright-mcp/page-2026-07-29T17-47-10-112Z.yml
|
|
||||||
5f348bcf74baa845958884bd2258e1ce4121d386c945777b0e80912602e5b5f6 17227 .playwright-mcp/page-2026-07-29T17-47-19-366Z.yml
|
|
||||||
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
|
||||||
@@ -21,15 +20,16 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263
|
|||||||
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png
|
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png
|
||||||
164c059453a5737110b4e5e98b6211650c757f0aff710f8f7523ffe0ff1815d7 113 build/update-signing-public.pem
|
164c059453a5737110b4e5e98b6211650c757f0aff710f8f7523ffe0ff1815d7 113 build/update-signing-public.pem
|
||||||
5f4aca19a35cbcaffa1a6993ce96b7d66052ec2b286022f2af74594e8a310568 15712 CHANGELOG.md
|
5f4aca19a35cbcaffa1a6993ce96b7d66052ec2b286022f2af74594e8a310568 15712 CHANGELOG.md
|
||||||
|
3754dcaa776ead5dc60b4955ed4294fd8580577ecb9ce29cb0d9fbbaf9253313 811 CONTRIBUTING.md
|
||||||
c612fcc44ff222db0c9a4cfd11a4076fafe080e4ada31e689a08739a4f14e74f 1650 docs/ACCEPTANCE.md
|
c612fcc44ff222db0c9a4cfd11a4076fafe080e4ada31e689a08739a4f14e74f 1650 docs/ACCEPTANCE.md
|
||||||
a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md
|
3785ad21872e3dc08fde9ef819e26f42161ba6ce6d235f87fcebd7859dfaf148 8293 docs/ARCHITECTURE.md
|
||||||
e05458ee2696e3c57e2475bb42ae1f914f6a36e01768d7a26a3199f1fffed490 1157 docs/COVERAGE_POLICY.md
|
e05458ee2696e3c57e2475bb42ae1f914f6a36e01768d7a26a3199f1fffed490 1157 docs/COVERAGE_POLICY.md
|
||||||
9eb9eec82518c0bfc7686f5faaf690a93ed63c71d35f1dc5a2c5ec5199da652a 3124 docs/CURRENT_STATE.md
|
9eb9eec82518c0bfc7686f5faaf690a93ed63c71d35f1dc5a2c5ec5199da652a 3124 docs/CURRENT_STATE.md
|
||||||
8ea655d1912ac2e17f8834e33a566a8b14461b396ec4268c396ca189a1749b94 2205 docs/DEPENDENCY_AUDIT.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
|
||||||
1d8aa3c688a9d330b9a5303b09e9e3049c50bf04f97807c7ff659607baa34c32 1464 docs/ERROR_CODES.md
|
1d8aa3c688a9d330b9a5303b09e9e3049c50bf04f97807c7ff659607baa34c32 1464 docs/ERROR_CODES.md
|
||||||
a0cd06a96f23a94e118feb012be0fa1ac51345951cb2ba8e67fb8c889c4c342a 5007 docs/LUMAOPS_SERVER_AUDIT.md
|
|
||||||
648dd6bca6b45668fb86eb3e1f6c5898dd8da0291b990f1bb5105cfd79421343 1301 docs/MUTATION_MODEL.md
|
648dd6bca6b45668fb86eb3e1f6c5898dd8da0291b990f1bb5105cfd79421343 1301 docs/MUTATION_MODEL.md
|
||||||
8dc95f69e6f6c8415702c8e79fb6b466c4d60061afded9140450e7e558eaefe5 3704 docs/PRODUCTION_READINESS_1.0.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
|
||||||
@@ -39,7 +39,8 @@ f79908fb3dad98c38030c6e6be7c79a1999e0478ed9c2496923891954438daa1 4581
|
|||||||
36edb4f096a248fb8679bd13e5766befb478cf628c6ccfb21e3eda71bbec7633 992 docs/RELEASE_NOTES_0.10.11.md
|
36edb4f096a248fb8679bd13e5766befb478cf628c6ccfb21e3eda71bbec7633 992 docs/RELEASE_NOTES_0.10.11.md
|
||||||
a355d3f577c2ec85dde5dfd7b6995f4f1615e2f6bac597529f3ff102acd93c35 1292 docs/RELEASE_NOTES_0.10.12.md
|
a355d3f577c2ec85dde5dfd7b6995f4f1615e2f6bac597529f3ff102acd93c35 1292 docs/RELEASE_NOTES_0.10.12.md
|
||||||
609c55a1c0b06c307ebe16f2daaf1e48601edd57137586e4f2be1febd6a7060a 1931 docs/RELEASE_NOTES_0.10.13.md
|
609c55a1c0b06c307ebe16f2daaf1e48601edd57137586e4f2be1febd6a7060a 1931 docs/RELEASE_NOTES_0.10.13.md
|
||||||
09eaf3f7671fbc5c25d5ca444415616ccf6dd8e6351855291d392b0aeac20bce 1556 docs/RELEASE_NOTES_0.10.14.md
|
1d5832048dd834a773ee8f34e6599c590373358132203b022b521dd5dde2f179 1571 docs/RELEASE_NOTES_0.10.14.md
|
||||||
|
055ad0c73f0854a708eedc3bc4e9dfb991b4e6021348c05484da9a47022e995b 1871 docs/RELEASE_NOTES_0.10.15.md
|
||||||
8d713471a437a8a55b00d7e1dd95290680862107bc4e586cf27d727f6274e46c 577 docs/RELEASE_NOTES_0.10.2.md
|
8d713471a437a8a55b00d7e1dd95290680862107bc4e586cf27d727f6274e46c 577 docs/RELEASE_NOTES_0.10.2.md
|
||||||
0942fb2c4a4f972296423b5232687f7389e2c6417a9d48a3244beef9dec907b9 1164 docs/RELEASE_NOTES_0.10.3.md
|
0942fb2c4a4f972296423b5232687f7389e2c6417a9d48a3244beef9dec907b9 1164 docs/RELEASE_NOTES_0.10.3.md
|
||||||
8f4a0fe6dc250ae210cc2fc1c57c46091822ae6c2a58caa76e0091f255f9f30d 775 docs/RELEASE_NOTES_0.10.4.md
|
8f4a0fe6dc250ae210cc2fc1c57c46091822ae6c2a58caa76e0091f255f9f30d 775 docs/RELEASE_NOTES_0.10.4.md
|
||||||
@@ -52,7 +53,7 @@ a0c00ff76acd1682bb5e0e8dcf6589c9480da436c9c6d30780a1ed58b4dad94f 1770
|
|||||||
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
|
||||||
@@ -91,72 +92,74 @@ ed69b8beb948a2cf9a6deb6c82368e2bb44ffe8d8990a900dc878b0938d1084f 95937
|
|||||||
3868ab978de2a7945761c53a9a718aecd54dc791605d07660bcd5cad62a33ea8 103569 docs/screenshots/git-validator.png
|
3868ab978de2a7945761c53a9a718aecd54dc791605d07660bcd5cad62a33ea8 103569 docs/screenshots/git-validator.png
|
||||||
007681714895ac062c980db1dda806ac17d4f01019ce9c46491a108d17c2dbda 85338 docs/screenshots/overview.png
|
007681714895ac062c980db1dda806ac17d4f01019ce9c46491a108d17c2dbda 85338 docs/screenshots/overview.png
|
||||||
1f78414b00ec100af2ec9bf5c9a3e400b6c9bf6dca6fcc317fd951789acc4536 112852 docs/screenshots/repository-workspace.png
|
1f78414b00ec100af2ec9bf5c9a3e400b6c9bf6dca6fcc317fd951789acc4536 112852 docs/screenshots/repository-workspace.png
|
||||||
735950c1e77bd4a1cf5ee986a7a307600741e5fdb90918adb0687bd29a89aaec 5877 docs/SECURITY.md
|
5cb9ee0e33a06db1796a740f12b0c41b917c68c1ae7cdb0070936a99c61c2c64 6036 docs/SECURITY.md
|
||||||
32a34ec13a284d3f9ceebbc107b25a844e3db096f8cafa4e43951fc2050c9a03 13552 docs/SETUP_GUIDE.md
|
32a34ec13a284d3f9ceebbc107b25a844e3db096f8cafa4e43951fc2050c9a03 13552 docs/SETUP_GUIDE.md
|
||||||
2fd71e9bcaeb4cb10c3fa2496b7e52fedf70c5b7f871cd587e22dc060c399079 4421 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
|
||||||
4983414a980075e6faae687b0d71c8e57bfe53fcb4cadb8b979b8abca636fe95 6654 docs/TEST_MATRIX.md
|
4983414a980075e6faae687b0d71c8e57bfe53fcb4cadb8b979b8abca636fe95 6654 docs/TEST_MATRIX.md
|
||||||
03fb2fe52a863b9d3d536f3c8abe23e47b9851be7fd7ccbfb106554b9c595385 5013 docs/UPDATING.md
|
3c34d42088b18e37d18afc848f91fbe5b74134ca74dd53df1e9eb30753fae750 4433 docs/UPDATING.md
|
||||||
73f094a2f0db3de053e515feb2771cd5a4f3aa4178f2c5f37be01ca65ff1c938 2705 eslint.config.js
|
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
|
||||||
577f3fa2131a3baa84549a6523f5816ef9da94f5bac6bc274d4588b6e7ab6594 5688 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
|
||||||
e2daa28bbc01c68c3702add6ea8259dff5920b22f6fdc3c9193ed78a153f2e9e 14708 main.cjs
|
e2daa28bbc01c68c3702add6ea8259dff5920b22f6fdc3c9193ed78a153f2e9e 14708 main.cjs
|
||||||
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
|
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
|
||||||
65c548b9072c90530ee32686c34959e940e694684af243b17d5cbdb6bd47cb61 179808 package-lock.json
|
63d403ada205000a0dfb158ec57cde1a4f58572d790589428ab5b00a99b32cbe 184860 package-lock.json
|
||||||
fe6f311914aa4513e476267c795ca22311ab78d1bb4388d5a3e50f687d5e193f 6415 package.json
|
ae06416348c2039eb4cafbf287c99342e04176bbbbb718646f160ff29b6af42f 6464 package.json
|
||||||
1237df9ddcbb5ac7dc4316f18c34ff4a7030e3e0d56216ade6dd07369e5e2a04 1353 playwright.config.mjs
|
1237df9ddcbb5ac7dc4316f18c34ff4a7030e3e0d56216ade6dd07369e5e2a04 1353 playwright.config.mjs
|
||||||
e8f678b26a1b651ee0e06e499b0538d8a193d0565687e9f750b58f801e4fabd8 12473 preload.cjs
|
7b0d173d0cf5a7f8db807580492bade379dba174a271013747f9f28a3793f55e 12409 preload.cjs
|
||||||
abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd
|
abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd
|
||||||
6d0858d6654c3c3dc7083ebbd234c88324afcebaecd7b772719440a8afbc2e4e 10736 Publish-ForgeFlow-Release.ps1
|
6d0858d6654c3c3dc7083ebbd234c88324afcebaecd7b772719440a8afbc2e4e 10736 Publish-ForgeFlow-Release.ps1
|
||||||
33f3c4795705ab77c6e6603c88a32c123b3a286bc77e8e472b76970485699338 4386 Publish-Missing-Binary-Release.ps1
|
33f3c4795705ab77c6e6603c88a32c123b3a286bc77e8e472b76970485699338 4386 Publish-Missing-Binary-Release.ps1
|
||||||
521356cc921145e8229751f85d57652fa50e0267f0d953a9f86cdcfd33eb5838 11117 README.md
|
5d9f13f34c9a9cca77968e472f1829147e51d3795779fa3f766b04cdd54fc698 11437 README.md
|
||||||
d75cfae88987ff6e8b92a53c988e59cf6dcffb74cd7cf8d27b417001f68f6bc6 16450 reports/architecture-audit.json
|
0f1bf0696ca6a3de7c222a935953156cdd1bb0aa27f2215b8000901c4db2be31 17255 reports/architecture-audit.json
|
||||||
654fea47bb851d5865908debcdc2140593c8bd78d03c7a5bfb6573518de08919 1726 reports/architecture-audit.md
|
6c50c58f464e2f93fb7255a59d6cbb76354755f63c6f1d4ff14a9a88c8c54574 1758 reports/architecture-audit.md
|
||||||
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
|
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
|
||||||
d0745072321aca2c80f44460974a7926715a9f429164aaf7660dced40b52c736 4790 scripts/apply-binary-update.ps1
|
5f220dc8ee24d2339aa3eb696ac7a5bd6f55c993b5fd9001ec9784788f2a6e46 6747 scripts/apply-binary-update.ps1
|
||||||
404863bcbe7292355662e3a326455df864d7279badc29f90866a3b837420df54 10745 scripts/apply-source-update.ps1
|
358d0ecbd50d8ba1ff9460c761cc2a1990fb27104d6ad74104a8800877343e19 10954 scripts/apply-source-update.ps1
|
||||||
02e924227f6cad3777fd06660230c85df590d8ce95e134194a4d18970a240b88 4145 scripts/architecture-audit.mjs
|
02e924227f6cad3777fd06660230c85df590d8ce95e134194a4d18970a240b88 4145 scripts/architecture-audit.mjs
|
||||||
47a5b16e95934bfe510c18bf94547ae65acb980c0f0506ae156d1a486dfbdfc9 8985 scripts/audit-installed-deployments.cjs
|
47a5b16e95934bfe510c18bf94547ae65acb980c0f0506ae156d1a486dfbdfc9 8985 scripts/audit-installed-deployments.cjs
|
||||||
6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs
|
6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs
|
||||||
0244d42896b8c44f734d0bb6cdcb29b5981342be2f070ce89f8d9eaf3e4d49e6 1793 scripts/generate-source-manifest.mjs
|
7b483476ddd909b085335cb78c9b0ffe71939c50011b5fbfcfdef6a340fa7ce8 2032 scripts/generate-source-manifest.mjs
|
||||||
842436680521311594e798848b050ae4e488d0595f0de57315f6ec081c049fb9 1266 scripts/prune-dist.mjs
|
842436680521311594e798848b050ae4e488d0595f0de57315f6ec081c049fb9 1266 scripts/prune-dist.mjs
|
||||||
d0e6fd6ce67b553a3654acd4393e5b9c3be825c45d03d957fee36fb2a3c56a85 8308 scripts/publish-binary-release.cjs
|
19417a26a5af967b0f057fede45d1811a6d8ad65a0ed49ad4f5865f598457168 9358 scripts/publish-binary-release.cjs
|
||||||
558ff442988f1396c174c7161ff5bd3ef0b2f43cfc31459ec7c3967faa146bc3 1694 scripts/serve-demo.mjs
|
558ff442988f1396c174c7161ff5bd3ef0b2f43cfc31459ec7c3967faa146bc3 1694 scripts/serve-demo.mjs
|
||||||
288c4b93f6006c0b32cdf90555bdc0d1d3b61d24a8763fcc30f1e6425ce1684d 1713 scripts/setup-update-signing-key.mjs
|
288c4b93f6006c0b32cdf90555bdc0d1d3b61d24a8763fcc30f1e6425ce1684d 1713 scripts/setup-update-signing-key.mjs
|
||||||
431d3d7eabf7e2ea2d5cbb96fb0ddc13f26d85afebcb9692f3e30242197cbd8d 2607 scripts/sign-release-manifest.mjs
|
431d3d7eabf7e2ea2d5cbb96fb0ddc13f26d85afebcb9692f3e30242197cbd8d 2607 scripts/sign-release-manifest.mjs
|
||||||
c76507857292c5713e1c699cf02e24b80265da39af2cecd148034bdb874adbb6 5246 scripts/test-authenticode-chain.ps1
|
c76507857292c5713e1c699cf02e24b80265da39af2cecd148034bdb874adbb6 5246 scripts/test-authenticode-chain.ps1
|
||||||
4393f7dc5f417e6d601a68238f4e26791799a3634acec228fe4d79deaee85eb5 3109 scripts/validate-installed-connections.cjs
|
4393f7dc5f417e6d601a68238f4e26791799a3634acec228fe4d79deaee85eb5 3109 scripts/validate-installed-connections.cjs
|
||||||
e6127e1e62f39c70ddb1abf72f4d7e7b8e3f19ff1f219e1a3660353c2e0cdfac 2411 scripts/verify-release-signatures.mjs
|
e6127e1e62f39c70ddb1abf72f4d7e7b8e3f19ff1f219e1a3660353c2e0cdfac 2411 scripts/verify-release-signatures.mjs
|
||||||
de14527e9fddfb904dd73ffe4e7836d8bb65abfdf7bd0c350bb9eb911b1d5de3 22289 scripts/verify.mjs
|
2187f4a6e5f162b428f123960c8121f671218e1164327c1b4f21bf45a4477d01 22686 scripts/verify.mjs
|
||||||
c2c9e4ba251d93a530a52b2d0079787680261c314083bb99d2356fc177719613 2434 scripts/write-release-checksums.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
|
||||||
5506ac1e5e49006ffd028a29c89bd0b95485ea3f2abc22db4f2b9fedd959852f 33194 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
|
||||||
86e9fc2eda66b4b563f6c4bbb87d3e8514340d484fb503b73137e63b6b05c3c9 14597 src/main/deploy-key-lifecycle-service.cjs
|
86e9fc2eda66b4b563f6c4bbb87d3e8514340d484fb503b73137e63b6b05c3c9 14597 src/main/deploy-key-lifecycle-service.cjs
|
||||||
7cbfe51973d6607203cb197652ed7f296a3f6b6b644df876957117866a47d802 2159 src/main/deployment-identity.cjs
|
7cbfe51973d6607203cb197652ed7f296a3f6b6b644df876957117866a47d802 2159 src/main/deployment-identity.cjs
|
||||||
ce30ddac403d1adf21176e5df21b0cc3db435305d2628f51f1486eacf20df6f2 23708 src/main/deployment-service.cjs
|
ce30ddac403d1adf21176e5df21b0cc3db435305d2628f51f1486eacf20df6f2 23708 src/main/deployment-service.cjs
|
||||||
f22348297291199e858656248cec70f94f002144ea7ea0807bd844cc5016baaf 16277 src/main/diagnostics-service.cjs
|
cc2dad2fdac386d41c37b1a8657ae2b3b4084fd8f35a97f8600c082c8d017552 16475 src/main/diagnostics-service.cjs
|
||||||
a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs
|
7f452dc2c0e6f3a00eecf6cb1c2be75906ac40445ef3e104341c0c67208aab2e 2397 src/main/external-tools-service.cjs
|
||||||
3d19a328eec427329fd9123b24fe79b5ab6670c0b33ac68397948e8df636a99d 43961 src/main/git-service.cjs
|
76fdc5576dcd6fdb88921009d4a923854650bd5efe5b837cd7438eba0dc733c8 48002 src/main/git-service.cjs
|
||||||
e28fc1ca2fd4c0116148f5005d793feddf04c36ef711d2d348560394d209a613 7253 src/main/git-validator-policy.cjs
|
e28fc1ca2fd4c0116148f5005d793feddf04c36ef711d2d348560394d209a613 7253 src/main/git-validator-policy.cjs
|
||||||
3a101b63ad3761c26350c2ac0793279a0b27672b91a5b1d8dc75bc44d92f0b52 27128 src/main/git-validator-service.cjs
|
3a101b63ad3761c26350c2ac0793279a0b27672b91a5b1d8dc75bc44d92f0b52 27128 src/main/git-validator-service.cjs
|
||||||
3cc53e24e023aa0d8bf36c35ce9672ca98e6c74066512c8b59ab42274e838c22 21307 src/main/gitea-service.cjs
|
3cc53e24e023aa0d8bf36c35ce9672ca98e6c74066512c8b59ab42274e838c22 21307 src/main/gitea-service.cjs
|
||||||
2ad3b2e647377f687ad987fe248a142ad399ecac98e4b49965aa7efc6093e5fa 6914 src/main/inventory-classifier.cjs
|
2ad3b2e647377f687ad987fe248a142ad399ecac98e4b49965aa7efc6093e5fa 6914 src/main/inventory-classifier.cjs
|
||||||
dafdb09133d2b6ec2161a3f0b09354551e54fc606c8107976fca37405643be91 3404 src/main/inventory-review-service.cjs
|
dafdb09133d2b6ec2161a3f0b09354551e54fc606c8107976fca37405643be91 3404 src/main/inventory-review-service.cjs
|
||||||
5e10cf3759bbf9294909866ed16f206d394789f0564549fc0fe23cdd89118ca6 25110 src/main/ipc.cjs
|
98b332589f86a4874a8adddc38e7844a4ddbe9360d38f9f77b2e95d58d748016 25720 src/main/ipc.cjs
|
||||||
26efebb4c147ed560966e7e60e64a013b3476327b3bbdb4e4439949142fa7846 2250 src/main/ipc/channel.cjs
|
26efebb4c147ed560966e7e60e64a013b3476327b3bbdb4e4439949142fa7846 2250 src/main/ipc/channel.cjs
|
||||||
b80357dd1f0aa18022d92db85b6cc8f29bc691ef11f9a0e90b4886ae5e19c763 12543 src/main/ipc/deployment-handlers.cjs
|
748cddf497b4c204e5e6fa1bd049991a086fdfb38afe342e6b3617c85a111478 12467 src/main/ipc/deployment-handlers.cjs
|
||||||
dc9b5971c9fefe8c374aa31916f5513601ce86003fd48b1d0e51330a909ae3a5 3442 src/main/ipc/operations-handlers.cjs
|
dc9b5971c9fefe8c374aa31916f5513601ce86003fd48b1d0e51330a909ae3a5 3442 src/main/ipc/operations-handlers.cjs
|
||||||
8072252821b1245d121eac534a18eeb64f0d7d18429e272e21a6e90b010005b9 17272 src/main/ipc/repository-handlers.cjs
|
8072252821b1245d121eac534a18eeb64f0d7d18429e272e21a6e90b010005b9 17272 src/main/ipc/repository-handlers.cjs
|
||||||
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.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
|
720c4a0c554f46386d87c3ab6607d1fbcae66e50b69483c7dbba169d5128c851 680 src/main/process-error-policy.cjs
|
||||||
3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs
|
3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs
|
||||||
@@ -173,45 +176,47 @@ bb4a99c3526fcf4db4fbae88a058e8598fd10a87990e7d502bfdc765328bdaa1 42766
|
|||||||
2c0cf07921ca7ee5a9085ced44498c2e6798e5cc1e8a5ecf704c3cecabe39a25 27607 src/main/unraid-preflight-methods.cjs
|
2c0cf07921ca7ee5a9085ced44498c2e6798e5cc1e8a5ecf704c3cecabe39a25 27607 src/main/unraid-preflight-methods.cjs
|
||||||
d45220176aed72d692f9ae5534f9d40bcc359a2d08e025e74a3b3b505b8b9ed4 16559 src/main/unraid-runtime-methods.cjs
|
d45220176aed72d692f9ae5534f9d40bcc359a2d08e025e74a3b3b505b8b9ed4 16559 src/main/unraid-runtime-methods.cjs
|
||||||
4c5cf01922e1feb36a31b50af22e973d8aee3fecccd406e449690604111898ac 11608 src/main/unraid-state-methods.cjs
|
4c5cf01922e1feb36a31b50af22e973d8aee3fecccd406e449690604111898ac 11608 src/main/unraid-state-methods.cjs
|
||||||
0b25c3729c5fffe3cd412c2325616bb86a6c916ae248eeb39d837378bb78c144 26420 src/main/update-service.cjs
|
29b8c5eca83b0e89c7d0716945b5316aac43947b5387e89562d9a024ebc4663c 27204 src/main/update-service.cjs
|
||||||
b5c304531bec358d059189a27cd9db8fa20cefb7f817e5eb0287001f7353f6a7 985 src/renderer/actions/command.js
|
b5c304531bec358d059189a27cd9db8fa20cefb7f817e5eb0287001f7353f6a7 985 src/renderer/actions/command.js
|
||||||
d0bf607dd1de9d55f2947d0adf0997cd3ca5c269d10a5362cc1d8bc4d1a2a8ae 6706 src/renderer/actions/deployment-operation.js
|
d0bf607dd1de9d55f2947d0adf0997cd3ca5c269d10a5362cc1d8bc4d1a2a8ae 6706 src/renderer/actions/deployment-operation.js
|
||||||
0db283b1a458ae0b31538940b1ddc931ffdb53bd04ceb7fd8903813f9200d071 17978 src/renderer/actions/deployment-profile.js
|
0db283b1a458ae0b31538940b1ddc931ffdb53bd04ceb7fd8903813f9200d071 17978 src/renderer/actions/deployment-profile.js
|
||||||
48bed91dd2a85bb51ee7307f7acc3b79c881ce8cf63b22ba79d5d079b265eb4b 7785 src/renderer/actions/inventory.js
|
48bed91dd2a85bb51ee7307f7acc3b79c881ce8cf63b22ba79d5d079b265eb4b 7785 src/renderer/actions/inventory.js
|
||||||
13b8611b5389625deeec59ff2a6cfebcfc93bd7972902439715be371d1f9f573 14936 src/renderer/actions/recovery.js
|
9e8adf1ba89ffc61a7b595f813c784688bdf50daa259204d74b2cdaa81650895 15493 src/renderer/actions/recovery.js
|
||||||
2414a0d29a0380d343b9b0e58ba1909e7a7eeb46357fd45ddbb3ad411d119f78 16280 src/renderer/actions/setup-and-settings.js
|
2414a0d29a0380d343b9b0e58ba1909e7a7eeb46357fd45ddbb3ad411d119f78 16280 src/renderer/actions/setup-and-settings.js
|
||||||
6900075c7cbcf6638b071735a3ce34954339d3d0ad324373c2bf5afcbd3cfe02 19618 src/renderer/actions/shell.js
|
058722de35ba33bfcfd29d355a75e1513a2be80c572773472cf9816dd13f894a 20148 src/renderer/actions/shell.js
|
||||||
d8eee8bc10b23d877919560790c311924bc3c339775dfe225b5b460e040f25de 26829 src/renderer/app.js
|
0d48993bc26ae28bdab5fbfa8d9be4ef896efdb9a5c34094087721e4274593b4 27566 src/renderer/app.js
|
||||||
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png
|
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png
|
||||||
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png
|
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png
|
||||||
094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png
|
094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png
|
||||||
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png
|
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png
|
||||||
a9dfda1adb8910bb882428c237907b56163ee4901992a54a6e50c74f6037786a 55199 src/renderer/dialogs.js
|
f826ab1f2f35882c59995497219fcfd500e46a94dac0906ee47fb732e8023fb4 55633 src/renderer/dialogs.js
|
||||||
dede1f21a06c73a2c2a462a869d27530d85f99baff202a2eb509c57436ad6aec 2732 src/renderer/diff-view.js
|
dede1f21a06c73a2c2a462a869d27530d85f99baff202a2eb509c57436ad6aec 2732 src/renderer/diff-view.js
|
||||||
e0ea09d8d3ab1033452a77a9dfa4557b29ad5428c9050713661fe4699e007bda 7245 src/renderer/events.js
|
b7698de13b872aa80d27b0a4d977c12ca2303b2246f05e6af4223db9b727e525 7433 src/renderer/events.js
|
||||||
c4a71213d412166093f7bd8254b847de4d8beb58c1aaa356a0cdc8d728080326 1524 src/renderer/index.html
|
c4a71213d412166093f7bd8254b847de4d8beb58c1aaa356a0cdc8d728080326 1524 src/renderer/index.html
|
||||||
06180d9656dd254edfb6949c397f8e313954fc560ddcb22b3a35fce3c3e35655 21350 src/renderer/mock-bridge.js
|
06180d9656dd254edfb6949c397f8e313954fc560ddcb22b3a35fce3c3e35655 21350 src/renderer/mock-bridge.js
|
||||||
870024aff376826a92c9cf7452689cc1ecc5d9034f055bea56734f3f7fcea5e5 28703 src/renderer/mock-deployment-bridge.js
|
870024aff376826a92c9cf7452689cc1ecc5d9034f055bea56734f3f7fcea5e5 28703 src/renderer/mock-deployment-bridge.js
|
||||||
2e7ed10ac9555470f989e6e8d721fcf0db979a03c9508844e028fd9028e31465 25031 src/renderer/mock-repository-bridge.js
|
ee33d1a77ab7152cb4f3dfbb611011f755a69ba1afb1016a28c997fcfdca97d6 25810 src/renderer/mock-repository-bridge.js
|
||||||
94fa265c2fe9ca8d644f0ce9b620b6f85d9b25dca5802c4e9195b66dcbe80120 6522 src/renderer/operations.js
|
94fa265c2fe9ca8d644f0ce9b620b6f85d9b25dca5802c4e9195b66dcbe80120 6522 src/renderer/operations.js
|
||||||
b541b5173d9b2a0063825243e87be4b8709d34d4f82a96d3a91d17471b3a60b7 87983 src/renderer/styles.css
|
9299c83e43eef194bac2946c43b2ffda6309ccbdcc5b9ce1775e668ae7f172ef 92319 src/renderer/styles.css
|
||||||
17a568f0844d6bbdac2c4e9e8d9e564b06c0557d9c2ddcb2a8eb513962306360 112565 src/renderer/views.js
|
9ee96e6267d923448a45eb0a02d5352286400f2d7b4a006ba681b6806f2e1424 113808 src/renderer/views.js
|
||||||
e9e72c072a5c5d04f59cd6763de0cfbf736c2a5ffa2f722143f3bad2bdbc630b 1411 src/shared/clone-target.cjs
|
e9e72c072a5c5d04f59cd6763de0cfbf736c2a5ffa2f722143f3bad2bdbc630b 1411 src/shared/clone-target.cjs
|
||||||
5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs
|
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
|
||||||
8791d3813e6cf285ee6aa49f76e75fc1f3af76fd98c76bcb3c92ee18e9cb699f 2889 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
|
||||||
ee73fdf9c591c029243385cb2d2085c3005c7b08c5b9e1b89102201f0ab30759 5702 src/shared/validation.cjs
|
ee73fdf9c591c029243385cb2d2085c3005c7b08c5b9e1b89102201f0ab30759 5702 src/shared/validation.cjs
|
||||||
13b731c38863b1007b0312fd9d89562401b7cce875c952f52429bde74f77a8af 3096 src/shared/zip-writer.cjs
|
13b731c38863b1007b0312fd9d89562401b7cce875c952f52429bde74f77a8af 3096 src/shared/zip-writer.cjs
|
||||||
f8853dce6fdf360d5df2fbe2b6df3e5687630c807fee5ba8436679b34ec737ea 2436 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
|
||||||
8eb8023bfd1366f4cc8c1867fe16fa0bed8d6e9d29219100ae1336aa06d10110 19040 tests/browser/forgeflow.spec.mjs
|
6e119cda76b2ee36b93623d98f41371798227f9b3f7c20fbef035a7d7a50cc95 19402 tests/browser/forgeflow.spec.mjs
|
||||||
1728c0a7abd92f4d7d9e68df32e4a6b00730555f23795e9b36416795d9d127af 5978 tests/clone-target.test.mjs
|
1728c0a7abd92f4d7d9e68df32e4a6b00730555f23795e9b36416795d9d127af 5978 tests/clone-target.test.mjs
|
||||||
aa2ae0e5a12bc47f8024e0d7408148e3af797e3f3f0ecb2525fb1cc54cf4e1f0 17378 tests/config-store.test.mjs
|
8aa8789a984769b377f719abca42a428fffc4f89b3997d57184a8073d24121e2 18325 tests/config-store.test.mjs
|
||||||
f1463326aee79842d265687ae628189ce54e92544600f2bd14073780287cfb14 2502 tests/configuration-backup.test.mjs
|
f1463326aee79842d265687ae628189ce54e92544600f2bd14073780287cfb14 2502 tests/configuration-backup.test.mjs
|
||||||
144c8e217a334cd69f502938d944e0f2dac61703d5df47e287b9ed542918c779 8129 tests/dependency-wiring.test.mjs
|
144c8e217a334cd69f502938d944e0f2dac61703d5df47e287b9ed542918c779 8129 tests/dependency-wiring.test.mjs
|
||||||
aad5948ea374d1e56e777005c73639654c96a90364dd398c949052cf5ae343a2 11130 tests/deploy-key-host.test.mjs
|
aad5948ea374d1e56e777005c73639654c96a90364dd398c949052cf5ae343a2 11130 tests/deploy-key-host.test.mjs
|
||||||
@@ -219,9 +224,9 @@ b7e009fed4171d6dd6b4c3154ba1d3f7198e98f5b79b298687841fc8169447cd 9354
|
|||||||
49bf9cf9842e7899015013675208f83a95402065a082320927a677ee4bab0766 24875 tests/deployment-operations.test.mjs
|
49bf9cf9842e7899015013675208f83a95402065a082320927a677ee4bab0766 24875 tests/deployment-operations.test.mjs
|
||||||
1dc6477bd07de78be189e6e8195ec339eb9d75820c4dbd5b073b8520ee21f6b5 1938 tests/deployment-policy.test.mjs
|
1dc6477bd07de78be189e6e8195ec339eb9d75820c4dbd5b073b8520ee21f6b5 1938 tests/deployment-policy.test.mjs
|
||||||
bf4576901e32662d832687a2761852aa1b2cffe256de5044f18c6637c189463b 9780 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
|
||||||
1da4abd9355183ee04410b3d89403cc36094eb5df622ecfe6736e8807135bd28 19532 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
|
61e0b8cad926acd22b5b17e4044f7edcbe96b6977cbbe2b6fbe123406626fc89 4283 tests/git-validator-policy.test.mjs
|
||||||
2b31459f14a5e36e30cf84c1054f634f4dba8676d29adeb9c2a8e18179f56fa0 6097 tests/git-validator.test.mjs
|
2b31459f14a5e36e30cf84c1054f634f4dba8676d29adeb9c2a8e18179f56fa0 6097 tests/git-validator.test.mjs
|
||||||
@@ -229,25 +234,25 @@ dd121d96ca265a027cd415a52064500a4541b2f8a662f4f4b25f2f996d52b5da 762
|
|||||||
d633c59bd910008223c834c6d7f3e5666c685a0881944263ede2d42cc69d3151 18710 tests/gitea-actions.test.mjs
|
d633c59bd910008223c834c6d7f3e5666c685a0881944263ede2d42cc69d3151 18710 tests/gitea-actions.test.mjs
|
||||||
fcc9a063882840dd89d74c2785284c8f2f6a9e5acec482b6d89ed8de62efdb85 9635 tests/inventory-classifier.test.mjs
|
fcc9a063882840dd89d74c2785284c8f2f6a9e5acec482b6d89ed8de62efdb85 9635 tests/inventory-classifier.test.mjs
|
||||||
62b90c21c15b841af30d26ccb0b9e88d25674fa7dbff9dc231dd8a1dddc3d657 2025 tests/ipc-contract.test.mjs
|
62b90c21c15b841af30d26ccb0b9e88d25674fa7dbff9dc231dd8a1dddc3d657 2025 tests/ipc-contract.test.mjs
|
||||||
caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs
|
5c66b01ab11585aab6d923a9811ec76eef34e918f1a7f37227edd3f4337c932f 2981 tests/log-redaction.test.mjs
|
||||||
96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs
|
96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs
|
||||||
1b6c920e18a248f78acaed6187197c88ec8d911b62d5e2a9f8ad57b91ae80499 11827 tests/preflight.test.mjs
|
1b6c920e18a248f78acaed6187197c88ec8d911b62d5e2a9f8ad57b91ae80499 11827 tests/preflight.test.mjs
|
||||||
7f2751ea2621f76b5427f442e931344d13e97faa7b6ef3151949bbd6a03097cf 1205 tests/process-error-policy.test.mjs
|
7f2751ea2621f76b5427f442e931344d13e97faa7b6ef3151949bbd6a03097cf 1205 tests/process-error-policy.test.mjs
|
||||||
0cb884cf62c1cb02cf59a81662be055bcb5339d176de85e2a3eeb8e8573e11b3 6435 tests/production-acceptance.test.mjs
|
0cb884cf62c1cb02cf59a81662be055bcb5339d176de85e2a3eeb8e8573e11b3 6435 tests/production-acceptance.test.mjs
|
||||||
1635efed857c776e677a064175a01b0f8b21bf7ead0b02c08956286077b8a37e 12294 tests/renderer-workflow.test.mjs
|
11fd2029593c0f4e5c36f1ce8572734f8ac9afead5abca7f5b619c5814b40a6c 12602 tests/renderer-workflow.test.mjs
|
||||||
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs
|
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs
|
||||||
76712a5d26f2598b00b83b925c9c84a90ab81c0eb1760895e9d6a2bd2f6eb425 5828 tests/repository-monitor.test.mjs
|
4defa3c5f21db7fefbd79397c96b6c231a4330df60b54c0fea7e91412e336fd3 6237 tests/repository-monitor.test.mjs
|
||||||
5476f3ba90bc096d4172900d9b54ada7c12da521f8627913d87794eade3cee23 13494 tests/repository-service.test.mjs
|
5476f3ba90bc096d4172900d9b54ada7c12da521f8627913d87794eade3cee23 13494 tests/repository-service.test.mjs
|
||||||
5fea04e668344508fb4e16da9bb6fe8733e2b83d1c227acb3421e51da26b2ffa 3636 tests/security-validation.test.mjs
|
5fea04e668344508fb4e16da9bb6fe8733e2b83d1c227acb3421e51da26b2ffa 3636 tests/security-validation.test.mjs
|
||||||
bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs
|
bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs
|
||||||
12cb3b240bdd0922566323c0014838ca067ad10d9d4009943165ae2c4e93bc6f 11786 tests/server-inventory-branches.test.mjs
|
12cb3b240bdd0922566323c0014838ca067ad10d9d4009943165ae2c4e93bc6f 11786 tests/server-inventory-branches.test.mjs
|
||||||
020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs
|
5df7f331cc1120a0914f5ddbf39d9a6b83bd5ada70a9953b3f6bd0701ba2485e 4394 tests/shell-verification.test.mjs
|
||||||
a39d30f47813dfb98c998811f3d76ebbb1544ecfd017a165d44f9afb80d7daf9 9090 tests/ssh-connection-pool.test.mjs
|
a39d30f47813dfb98c998811f3d76ebbb1544ecfd017a165d44f9afb80d7daf9 9090 tests/ssh-connection-pool.test.mjs
|
||||||
7ee9166327ed227d2b7c6929692dea5c5d7a41c3e566596fa92d9ec4f42e8677 4085 tests/ssh-connection.test.mjs
|
7ee9166327ed227d2b7c6929692dea5c5d7a41c3e566596fa92d9ec4f42e8677 4085 tests/ssh-connection.test.mjs
|
||||||
c9354e4bf3720c28cff21c15ff8b9474ba4a23b7f55389de4326f4dffde54d78 9510 tests/ssh-service.test.mjs
|
c9354e4bf3720c28cff21c15ff8b9474ba4a23b7f55389de4326f4dffde54d78 9510 tests/ssh-service.test.mjs
|
||||||
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs
|
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs
|
||||||
3e4a1a6d6a744df9badcfece2cf8d09f8c34efb3c437cb08a6f2e6c9d428c0d4 59353 tests/unraid-deployment.test.mjs
|
3e4a1a6d6a744df9badcfece2cf8d09f8c34efb3c437cb08a6f2e6c9d428c0d4 59353 tests/unraid-deployment.test.mjs
|
||||||
3bd3247ed821ba261ad7c02d649c26979e3591df456afd1bda04e351b2296fa1 29168 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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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,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.
|
||||||
@@ -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,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.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+5
-4
@@ -23,7 +23,7 @@ flexibility.
|
|||||||
- session-only fallback when OS encryption is unavailable;
|
- session-only fallback when OS encryption is unavailable;
|
||||||
- token omitted from renderer-visible public state;
|
- token omitted from renderer-visible public state;
|
||||||
- encrypted token blob excluded from diagnostic bundles;
|
- encrypted token blob excluded from diagnostic bundles;
|
||||||
- blank settings token field preserves the existing token;
|
- a blank settings token field preserves the existing token only when the normalized Gitea origin is unchanged;
|
||||||
- atomic config replacement and restrictive permissions where supported;
|
- atomic config replacement and restrictive permissions where supported;
|
||||||
- service URLs reject embedded user credentials;
|
- service URLs reject embedded user credentials;
|
||||||
- no token is required by setup/build scripts or documentation.
|
- no token is required by setup/build scripts or documentation.
|
||||||
@@ -128,9 +128,10 @@ included model uses:
|
|||||||
require the exact user-confirmed pinned fingerprint;
|
require the exact user-confirmed pinned fingerprint;
|
||||||
- remote folders and Compose paths are validated against traversal;
|
- remote folders and Compose paths are validated against traversal;
|
||||||
- tracked server-side changes block exact-SHA reset;
|
- tracked server-side changes block exact-SHA reset;
|
||||||
- updater tokens are sent only to the configured Gitea origin;
|
- updater tokens are sent only to the configured Gitea origin, and changing that origin requires a newly entered token;
|
||||||
- non-loopback Gitea connections require HTTPS;
|
- non-loopback Gitea connections require HTTPS;
|
||||||
- packaged updates require a publisher-signed Ed25519 manifest that binds the
|
- packaged updates require a publisher-signed Ed25519 manifest that binds the
|
||||||
source commit, artifact identity, byte length and SHA-256 digest;
|
source commit, artifact identity, byte length and SHA-256 digest;
|
||||||
- update archives are checksummed and validated by the full local quality gate;
|
- packaged update bytes are rehashed immediately before apply;
|
||||||
- source backup is restored when an update fails.
|
- integrated source replacement is disabled until source archives carry the
|
||||||
|
same independent publisher signature.
|
||||||
|
|||||||
+11
-23
@@ -2,31 +2,19 @@
|
|||||||
|
|
||||||
ForgeFlow stores credentials, repository mappings, preferences, deployment profiles, diagnostics and operation history outside the source directory.
|
ForgeFlow stores credentials, repository mappings, preferences, deployment profiles, diagnostics and operation history outside the source directory.
|
||||||
|
|
||||||
## Built-in source update
|
## Source checkouts
|
||||||
|
|
||||||
Open **Settings → ForgeFlow updates** and choose:
|
Integrated source replacement is disabled until source archives are covered by the same independent publisher signature as packaged releases. A server-provided commit SHA and a checksum calculated from the downloaded archive do not independently authenticate its publisher, while dependency installation can execute package lifecycle scripts.
|
||||||
|
|
||||||
1. **Check now**
|
Update a source checkout through Git instead:
|
||||||
2. **Download update**
|
|
||||||
3. **Apply & restart**
|
|
||||||
|
|
||||||
The default update source is the configured Gitea instance, repository `Jens/ForgeFlow`, branch `main`.
|
1. fetch the configured upstream;
|
||||||
|
2. review the exact commit and release notes;
|
||||||
|
3. switch to the intended release commit or tag;
|
||||||
|
4. run `npm ci --ignore-scripts` and review the dependency lifecycle allowlist;
|
||||||
|
5. run `npm run check` before starting ForgeFlow.
|
||||||
|
|
||||||
The updater pins the download to the exact remote commit, checks the archive SHA-256, starts an external PowerShell helper and waits for a structured `started` marker. ForgeFlow closes only after that marker exists. The helper then:
|
The in-app updater remains available for signed packaged Windows releases.
|
||||||
|
|
||||||
1. waits for the old process to exit;
|
|
||||||
2. backs up the current source;
|
|
||||||
3. extracts and validates the requested semantic version;
|
|
||||||
4. mirrors the incoming source;
|
|
||||||
5. runs `npm ci --no-audit --no-fund` when the published release contains `package-lock.json`, otherwise a pinned direct-dependency `npm install`;
|
|
||||||
6. runs `npm run check`;
|
|
||||||
7. writes the successful installation result before restart;
|
|
||||||
8. launches the installed Electron executable directly;
|
|
||||||
9. persists `success`, `failed` or `rolled-back` state for the next launch.
|
|
||||||
|
|
||||||
A failed validation restores the previous source. A successful installation is not rolled back merely because automatic restart fails; start ForgeFlow manually and the persisted result is shown.
|
|
||||||
|
|
||||||
Update logs and status files are stored beneath ForgeFlow's local user-data `updates` folder and exclude the Gitea token.
|
|
||||||
|
|
||||||
## Packaged Windows updates
|
## Packaged Windows updates
|
||||||
|
|
||||||
@@ -65,13 +53,13 @@ The binary publisher refuses to upload when local `HEAD` differs from the config
|
|||||||
Extract the complete source ZIP so this file exists:
|
Extract the complete source ZIP so this file exists:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
C:\Users\Jens\Downloads\ForgeFlow-<version>\ForgeFlow\package.json
|
C:\Users\your-name\Downloads\ForgeFlow-<version>\ForgeFlow\package.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd C:\Users\Jens\Downloads\ForgeFlow-<version>\ForgeFlow
|
cd C:\Users\your-name\Downloads\ForgeFlow-<version>\ForgeFlow
|
||||||
Set-ExecutionPolicy -Scope Process Bypass
|
Set-ExecutionPolicy -Scope Process Bypass
|
||||||
.\Publish-ForgeFlow-Release.ps1
|
.\Publish-ForgeFlow-Release.ps1
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
name: ForgeFlow approved deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
repository:
|
||||||
|
description: Signed allowlisted deployment target (owner/repository)
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
environment:
|
||||||
|
description: Allowlisted ForgeFlow environment
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
commit_sha:
|
||||||
|
description: Exact approved commit SHA
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
request_id:
|
||||||
|
description: Immutable AppOps request identifier
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
approval_id:
|
||||||
|
description: AppOps approval identifier
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
approval_fingerprint:
|
||||||
|
description: Immutable AppOps approval fingerprint
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
evidence_issued_at:
|
||||||
|
description: Signed evidence UNIX timestamp
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
evidence_signature:
|
||||||
|
description: Base64 Ed25519 signature over the exact deployment evidence
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: forgeflow-approved-${{ inputs.repository }}-${{ inputs.environment }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: forgeflow
|
||||||
|
steps:
|
||||||
|
- name: Validate signed deployment inputs
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
FF_REPOSITORY: ${{ inputs.repository }}
|
||||||
|
FF_ENVIRONMENT: ${{ inputs.environment }}
|
||||||
|
FF_COMMIT_SHA: ${{ inputs.commit_sha }}
|
||||||
|
FF_REQUEST_ID: ${{ inputs.request_id }}
|
||||||
|
FF_APPROVAL_ID: ${{ inputs.approval_id }}
|
||||||
|
FF_APPROVAL_FINGERPRINT: ${{ inputs.approval_fingerprint }}
|
||||||
|
FF_EVIDENCE_ISSUED_AT: ${{ inputs.evidence_issued_at }}
|
||||||
|
FF_EVIDENCE_SIGNATURE: ${{ inputs.evidence_signature }}
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
[[ "$FF_REPOSITORY" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]
|
||||||
|
[[ "$FF_ENVIRONMENT" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]]
|
||||||
|
[[ "$FF_COMMIT_SHA" =~ ^[0-9a-fA-F]{40,64}$ ]]
|
||||||
|
[[ "$FF_REQUEST_ID" =~ ^appr-[A-Za-z0-9._-]{1,75}$ ]]
|
||||||
|
[[ "$FF_APPROVAL_ID" == "$FF_REQUEST_ID" ]]
|
||||||
|
[[ "$FF_APPROVAL_FINGERPRINT" =~ ^[0-9a-f]{64}$ ]]
|
||||||
|
[[ "$FF_EVIDENCE_ISSUED_AT" =~ ^[0-9]{10,11}$ ]]
|
||||||
|
[[ "$FF_EVIDENCE_SIGNATURE" =~ ^[A-Za-z0-9+/]{86}==$ ]]
|
||||||
|
|
||||||
|
- name: Execute root-owned verified deployment
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
FF_REPOSITORY: ${{ inputs.repository }}
|
||||||
|
FF_ENVIRONMENT: ${{ inputs.environment }}
|
||||||
|
FF_COMMIT_SHA: ${{ inputs.commit_sha }}
|
||||||
|
FF_REQUEST_ID: ${{ inputs.request_id }}
|
||||||
|
FF_APPROVAL_ID: ${{ inputs.approval_id }}
|
||||||
|
FF_APPROVAL_FINGERPRINT: ${{ inputs.approval_fingerprint }}
|
||||||
|
FF_EVIDENCE_ISSUED_AT: ${{ inputs.evidence_issued_at }}
|
||||||
|
FF_EVIDENCE_SIGNATURE: ${{ inputs.evidence_signature }}
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
sudo /usr/local/bin/forgeflow-deploy \
|
||||||
|
"$FF_REPOSITORY" \
|
||||||
|
"$FF_ENVIRONMENT" \
|
||||||
|
"$FF_COMMIT_SHA" \
|
||||||
|
"$FF_REQUEST_ID" \
|
||||||
|
"$FF_APPROVAL_ID" \
|
||||||
|
"$FF_APPROVAL_FINGERPRINT" \
|
||||||
|
"$FF_EVIDENCE_ISSUED_AT" \
|
||||||
|
"$FF_EVIDENCE_SIGNATURE"
|
||||||
@@ -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=$?
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+35
-35
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "forgeflow",
|
"name": "forgeflow",
|
||||||
"version": "0.10.14",
|
"version": "0.10.15",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "forgeflow",
|
"name": "forgeflow",
|
||||||
"version": "0.10.14",
|
"version": "0.10.15",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ssh2": "1.17.0"
|
"ssh2": "1.17.0"
|
||||||
},
|
},
|
||||||
@@ -68,9 +68,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@electron/asar/node_modules/brace-expansion": {
|
"node_modules/@electron/asar/node_modules/brace-expansion": {
|
||||||
"version": "1.1.17",
|
"version": "1.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||||
"integrity": "sha512-w+aeW/mkgM4PyRMOJCgi3fOrTm5Q8QY1OSfn2TO2iuDj3ezIHqejmuxbjfPrqUkgqRew1iqkyAn0tr0ZwHD9+w==",
|
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -257,9 +257,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@electron/universal/node_modules/brace-expansion": {
|
"node_modules/@electron/universal/node_modules/brace-expansion": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||||
"integrity": "sha512-DRdx5neNsG/QXbniLFWi2YmC/68oeOOmKz6zOjVk6ZS1ZLXgLIKqVEc6hWsmkjBbgii0SwaBTcJ5XKj5gzY/4A==",
|
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -401,9 +401,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
|
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
|
||||||
"version": "1.1.17",
|
"version": "1.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||||
"integrity": "sha512-w+aeW/mkgM4PyRMOJCgi3fOrTm5Q8QY1OSfn2TO2iuDj3ezIHqejmuxbjfPrqUkgqRew1iqkyAn0tr0ZwHD9+w==",
|
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -482,9 +482,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
|
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
|
||||||
"version": "1.1.17",
|
"version": "1.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||||
"integrity": "sha512-w+aeW/mkgM4PyRMOJCgi3fOrTm5Q8QY1OSfn2TO2iuDj3ezIHqejmuxbjfPrqUkgqRew1iqkyAn0tr0ZwHD9+w==",
|
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1357,9 +1357,9 @@
|
|||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||||
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2014,9 +2014,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/dir-compare/node_modules/brace-expansion": {
|
"node_modules/dir-compare/node_modules/brace-expansion": {
|
||||||
"version": "1.1.17",
|
"version": "1.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||||
"integrity": "sha512-w+aeW/mkgM4PyRMOJCgi3fOrTm5Q8QY1OSfn2TO2iuDj3ezIHqejmuxbjfPrqUkgqRew1iqkyAn0tr0ZwHD9+w==",
|
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2471,9 +2471,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/eslint/node_modules/brace-expansion": {
|
"node_modules/eslint/node_modules/brace-expansion": {
|
||||||
"version": "1.1.17",
|
"version": "1.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||||
"integrity": "sha512-w+aeW/mkgM4PyRMOJCgi3fOrTm5Q8QY1OSfn2TO2iuDj3ezIHqejmuxbjfPrqUkgqRew1iqkyAn0tr0ZwHD9+w==",
|
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2587,9 +2587,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.4",
|
"version": "3.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz",
|
||||||
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
|
"integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2652,9 +2652,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/filelist/node_modules/brace-expansion": {
|
"node_modules/filelist/node_modules/brace-expansion": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||||
"integrity": "sha512-DRdx5neNsG/QXbniLFWi2YmC/68oeOOmKz6zOjVk6ZS1ZLXgLIKqVEc6hWsmkjBbgii0SwaBTcJ5XKj5gzY/4A==",
|
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2927,9 +2927,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/glob/node_modules/brace-expansion": {
|
"node_modules/glob/node_modules/brace-expansion": {
|
||||||
"version": "1.1.17",
|
"version": "1.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||||
"integrity": "sha512-w+aeW/mkgM4PyRMOJCgi3fOrTm5Q8QY1OSfn2TO2iuDj3ezIHqejmuxbjfPrqUkgqRew1iqkyAn0tr0ZwHD9+w==",
|
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3365,9 +3365,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "forgeflow",
|
"name": "forgeflow",
|
||||||
"version": "0.10.14",
|
"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",
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
"scripts/prune-dist.mjs",
|
"scripts/prune-dist.mjs",
|
||||||
"scripts/sign-release-manifest.mjs",
|
"scripts/sign-release-manifest.mjs",
|
||||||
"docs/RELEASE_NOTES_0.4.0.md",
|
"docs/RELEASE_NOTES_0.4.0.md",
|
||||||
"docs/LUMAOPS_SERVER_AUDIT.md",
|
"docs/DEPLOYMENT_MIGRATION_EXAMPLE.md",
|
||||||
"docs/SSH_UNRAID_DEPLOYMENT.md",
|
"docs/SSH_UNRAID_DEPLOYMENT.md",
|
||||||
"docs/RELEASE_NOTES_0.4.1.md",
|
"docs/RELEASE_NOTES_0.4.1.md",
|
||||||
"docs/RELEASE_NOTES_0.4.2.md",
|
"docs/RELEASE_NOTES_0.4.2.md",
|
||||||
@@ -123,6 +123,7 @@
|
|||||||
"docs/RELEASE_NOTES_0.10.12.md",
|
"docs/RELEASE_NOTES_0.10.12.md",
|
||||||
"docs/RELEASE_NOTES_0.10.13.md",
|
"docs/RELEASE_NOTES_0.10.13.md",
|
||||||
"docs/RELEASE_NOTES_0.10.14.md",
|
"docs/RELEASE_NOTES_0.10.14.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.15.md",
|
||||||
"docs/CURRENT_STATE.md",
|
"docs/CURRENT_STATE.md",
|
||||||
"docs/MUTATION_MODEL.md",
|
"docs/MUTATION_MODEL.md",
|
||||||
"docs/RELEASING.md",
|
"docs/RELEASING.md",
|
||||||
|
|||||||
@@ -120,7 +120,6 @@ contextBridge.exposeInMainWorld(
|
|||||||
overrideReason: options.overrideReason || '',
|
overrideReason: options.overrideReason || '',
|
||||||
}),
|
}),
|
||||||
rollback: (repository, profileId, targetSha) => invoke('deployment:rollback', { repository, profileId, targetSha }),
|
rollback: (repository, profileId, targetSha) => invoke('deployment:rollback', { repository, profileId, targetSha }),
|
||||||
healthcheck: (url) => invoke('deployment:health', { url }),
|
|
||||||
refreshProfileState: (fullName, profileId) => invoke('deployment:profile-state', { fullName, profileId }),
|
refreshProfileState: (fullName, profileId) => invoke('deployment:profile-state', { fullName, profileId }),
|
||||||
discoverServerDeployments: () => invoke('deployment:discover-server-workloads'),
|
discoverServerDeployments: () => invoke('deployment:discover-server-workloads'),
|
||||||
planServerReconciliation: (serverId) => invoke('deployment:plan-server-reconciliation', { serverId }),
|
planServerReconciliation: (serverId) => invoke('deployment:plan-server-reconciliation', { serverId }),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"generatedAt": "2026-08-26T23:19:01.915Z",
|
"generatedAt": "2026-08-29T22:58:20.416Z",
|
||||||
"thresholds": {
|
"thresholds": {
|
||||||
"preferredMaximumLines": 750,
|
"preferredMaximumLines": 750,
|
||||||
"justificationRequiredLines": 1000
|
"justificationRequiredLines": 1000
|
||||||
@@ -7,9 +7,9 @@
|
|||||||
"over750": [
|
"over750": [
|
||||||
{
|
{
|
||||||
"file": "src/main/git-service.cjs",
|
"file": "src/main/git-service.cjs",
|
||||||
"lines": 881,
|
"lines": 950,
|
||||||
"branches": 134,
|
"branches": 139,
|
||||||
"functions": 147,
|
"functions": 152,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 0,
|
||||||
"responsibilities": [
|
"responsibilities": [
|
||||||
"git",
|
"git",
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"security",
|
"security",
|
||||||
"updates"
|
"updates"
|
||||||
],
|
],
|
||||||
"hotspotScore": 154
|
"hotspotScore": 159
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/renderer/views.js",
|
"file": "src/renderer/views.js",
|
||||||
@@ -67,9 +67,9 @@
|
|||||||
"cyclomaticHotspots": [
|
"cyclomaticHotspots": [
|
||||||
{
|
{
|
||||||
"file": "src/main/git-service.cjs",
|
"file": "src/main/git-service.cjs",
|
||||||
"lines": 881,
|
"lines": 950,
|
||||||
"branches": 134,
|
"branches": 139,
|
||||||
"functions": 147,
|
"functions": 152,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 0,
|
||||||
"responsibilities": [
|
"responsibilities": [
|
||||||
"git",
|
"git",
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
"security",
|
"security",
|
||||||
"updates"
|
"updates"
|
||||||
],
|
],
|
||||||
"hotspotScore": 154
|
"hotspotScore": 159
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/renderer/actions/shell.js",
|
"file": "src/renderer/actions/shell.js",
|
||||||
@@ -144,9 +144,9 @@
|
|||||||
"mixedResponsibilityModules": [
|
"mixedResponsibilityModules": [
|
||||||
{
|
{
|
||||||
"file": "src/main/git-service.cjs",
|
"file": "src/main/git-service.cjs",
|
||||||
"lines": 881,
|
"lines": 950,
|
||||||
"branches": 134,
|
"branches": 139,
|
||||||
"functions": 147,
|
"functions": 152,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 0,
|
||||||
"responsibilities": [
|
"responsibilities": [
|
||||||
"git",
|
"git",
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
"security",
|
"security",
|
||||||
"updates"
|
"updates"
|
||||||
],
|
],
|
||||||
"hotspotScore": 154
|
"hotspotScore": 159
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/renderer/actions/shell.js",
|
"file": "src/renderer/actions/shell.js",
|
||||||
@@ -357,7 +357,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "src/renderer/actions/recovery.js",
|
"file": "src/renderer/actions/recovery.js",
|
||||||
"lines": 421,
|
"lines": 422,
|
||||||
"branches": 64,
|
"branches": 64,
|
||||||
"functions": 43,
|
"functions": 43,
|
||||||
"ipcHandlers": 0,
|
"ipcHandlers": 0,
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# ForgeFlow architecture audit
|
# ForgeFlow architecture audit
|
||||||
|
|
||||||
Generated 2026-08-26T23:19:01.915Z. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity.
|
Generated 2026-08-29T22:58:20.416Z. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity.
|
||||||
|
|
||||||
## Files above 750 lines
|
## Files above 750 lines
|
||||||
|
|
||||||
| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |
|
| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |
|
||||||
|---|---:|---:|---:|---:|---|
|
|---|---:|---:|---:|---:|---|
|
||||||
| `src/main/git-service.cjs` | 881 | 134 | 147 | 0 | git, renderer, security, updates |
|
| `src/main/git-service.cjs` | 950 | 139 | 152 | 0 | git, renderer, security, updates |
|
||||||
| `src/renderer/views.js` | 876 | 61 | 161 | 0 | inventory, deployment, git, renderer, security, updates |
|
| `src/renderer/views.js` | 876 | 61 | 161 | 0 | inventory, deployment, git, renderer, security, updates |
|
||||||
| `src/main/update-service.cjs` | 854 | 64 | 65 | 0 | git, security, updates |
|
| `src/main/update-service.cjs` | 854 | 64 | 65 | 0 | git, security, updates |
|
||||||
| `src/renderer/mock-repository-bridge.js` | 780 | 19 | 100 | 0 | deployment, git, security, updates |
|
| `src/renderer/mock-repository-bridge.js` | 780 | 19 | 100 | 0 | deployment, git, security, updates |
|
||||||
@@ -21,7 +21,7 @@ No findings.
|
|||||||
|
|
||||||
| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |
|
| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |
|
||||||
|---|---:|---:|---:|---:|---|
|
|---|---:|---:|---:|---:|---|
|
||||||
| `src/main/git-service.cjs` | 881 | 134 | 147 | 0 | git, renderer, security, updates |
|
| `src/main/git-service.cjs` | 950 | 139 | 152 | 0 | git, renderer, security, updates |
|
||||||
| `src/renderer/actions/shell.js` | 531 | 103 | 90 | 0 | inventory, deployment, git, renderer, updates |
|
| `src/renderer/actions/shell.js` | 531 | 103 | 90 | 0 | inventory, deployment, git, renderer, updates |
|
||||||
| `src/renderer/app.js` | 738 | 80 | 124 | 0 | inventory, deployment, git, renderer, security, updates |
|
| `src/renderer/app.js` | 738 | 80 | 124 | 0 | inventory, deployment, git, renderer, security, updates |
|
||||||
| `src/main/server-inventory.cjs` | 578 | 89 | 104 | 0 | inventory, deployment, git, security, updates |
|
| `src/main/server-inventory.cjs` | 578 | 89 | 104 | 0 | inventory, deployment, git, security, updates |
|
||||||
|
|||||||
@@ -63,16 +63,24 @@ function Get-Sha256([string]$Path) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Start-ForgeFlowAndVerify([string]$Executable) {
|
||||||
|
$process = Start-Process -FilePath $Executable -WorkingDirectory (Split-Path -Parent $Executable) -PassThru
|
||||||
|
Start-Sleep -Milliseconds 1500
|
||||||
|
if (-not $process -or $process.HasExited) { throw "ForgeFlow restart process exited before the application could stay running." }
|
||||||
|
return $process
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Write-UpdateState -State "started" -Message "Binary updater owns the update request."
|
|
||||||
Write-Log "Validating ForgeFlow $ExpectedVersion binary update."
|
Write-Log "Validating ForgeFlow $ExpectedVersion binary update."
|
||||||
if ($HandshakeOnly) {
|
if ($HandshakeOnly) {
|
||||||
|
Write-UpdateState -State "started" -Message "Binary updater owns the update request."
|
||||||
Write-Log "Handshake-only verification completed successfully."
|
Write-Log "Handshake-only verification completed successfully."
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
$actualSha256 = Get-Sha256 -Path $BinaryPath
|
$actualSha256 = Get-Sha256 -Path $BinaryPath
|
||||||
if ($actualSha256 -ne $ExpectedSha256.ToLowerInvariant()) { throw "Binary update SHA-256 verification failed." }
|
if ($actualSha256 -ne $ExpectedSha256.ToLowerInvariant()) { throw "Binary update SHA-256 verification failed." }
|
||||||
if (-not (Test-Path -LiteralPath $CurrentExecutable -PathType Leaf)) { throw "Current ForgeFlow executable was not found." }
|
if (-not (Test-Path -LiteralPath $CurrentExecutable -PathType Leaf)) { throw "Current ForgeFlow executable was not found." }
|
||||||
|
Write-UpdateState -State "started" -Message "Binary preflight passed; updater owns the update request."
|
||||||
if ($VerifyOnly) {
|
if ($VerifyOnly) {
|
||||||
Write-Log "Verification-only SHA-256 check completed successfully."
|
Write-Log "Verification-only SHA-256 check completed successfully."
|
||||||
exit 0
|
exit 0
|
||||||
@@ -90,9 +98,16 @@ try {
|
|||||||
try {
|
try {
|
||||||
Copy-Item -LiteralPath $BinaryPath -Destination $CurrentExecutable -Force
|
Copy-Item -LiteralPath $BinaryPath -Destination $CurrentExecutable -Force
|
||||||
} catch {
|
} catch {
|
||||||
|
$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."
|
||||||
@@ -100,13 +115,31 @@ try {
|
|||||||
if ($installer.ExitCode -ne 0) { throw "ForgeFlow installer exited with code $($installer.ExitCode)." }
|
if ($installer.ExitCode -ne 0) { throw "ForgeFlow installer exited with code $($installer.ExitCode)." }
|
||||||
}
|
}
|
||||||
|
|
||||||
$restart = Start-Process -FilePath $CurrentExecutable -WorkingDirectory (Split-Path -Parent $CurrentExecutable) -PassThru
|
try {
|
||||||
Write-Log "ForgeFlow $ExpectedVersion installed; restart PID $($restart.Id)."
|
$restart = Start-ForgeFlowAndVerify -Executable $CurrentExecutable
|
||||||
|
Write-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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,13 +117,20 @@ function Start-ForgeFlow {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
Write-UpdateLog "ForgeFlow source update helper started for version $ExpectedVersion."
|
Write-UpdateLog "ForgeFlow source update helper started for version $ExpectedVersion."
|
||||||
Write-UpdateState -State "started" -Message "The external update helper started successfully." -Extra @{ helperPid = $PID; startedAt = (Get-Date).ToUniversalTime().ToString("o") }
|
|
||||||
|
|
||||||
if ($HandshakeOnly) {
|
if ($HandshakeOnly) {
|
||||||
|
Write-UpdateState -State "started" -Message "The external update helper started successfully." -Extra @{ helperPid = $PID; startedAt = (Get-Date).ToUniversalTime().ToString("o") }
|
||||||
Write-UpdateLog "Handshake-only verification completed successfully."
|
Write-UpdateLog "Handshake-only verification completed successfully."
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Test-Path -LiteralPath (Join-Path $SourcePath ".git")) {
|
||||||
|
throw "Integrated source update refuses to overwrite a Git working tree. Use normal Git/ForgeFlow workspace sync so local commits and dirty files remain reviewable."
|
||||||
|
}
|
||||||
|
$actualHash = Get-Sha256 -Path $ArchivePath
|
||||||
|
if ($actualHash -ne $ExpectedSha256.ToLowerInvariant()) { throw "Update archive checksum mismatch." }
|
||||||
|
Write-UpdateState -State "started" -Message "Source update preflight passed; the external helper owns the request." -Extra @{ helperPid = $PID; startedAt = (Get-Date).ToUniversalTime().ToString("o") }
|
||||||
|
|
||||||
Write-UpdateState -State "waiting-for-exit" -Message "Waiting for the running ForgeFlow process to exit."
|
Write-UpdateState -State "waiting-for-exit" -Message "Waiting for the running ForgeFlow process to exit."
|
||||||
$deadline = (Get-Date).AddMinutes(2)
|
$deadline = (Get-Date).AddMinutes(2)
|
||||||
while (Get-Process -Id $ParentPid -ErrorAction SilentlyContinue) {
|
while (Get-Process -Id $ParentPid -ErrorAction SilentlyContinue) {
|
||||||
@@ -131,9 +138,6 @@ try {
|
|||||||
Start-Sleep -Milliseconds 500
|
Start-Sleep -Milliseconds 500
|
||||||
}
|
}
|
||||||
|
|
||||||
$actualHash = Get-Sha256 -Path $ArchivePath
|
|
||||||
if ($actualHash -ne $ExpectedSha256.ToLowerInvariant()) { throw "Update archive checksum mismatch." }
|
|
||||||
|
|
||||||
$working = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-update-" + [guid]::NewGuid().ToString("N"))
|
$working = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-update-" + [guid]::NewGuid().ToString("N"))
|
||||||
$extract = Join-Path $working "extract"
|
$extract = Join-Path $working "extract"
|
||||||
$backup = Join-Path $working "backup"
|
$backup = Join-Path $working "backup"
|
||||||
|
|||||||
@@ -1,28 +1,44 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
import { execFile } from 'node:child_process';
|
||||||
|
import { readFile, stat, writeFile } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const excludedDirectories = new Set(['.git', '.forgeflow', 'artifacts', 'coverage', 'dist', 'node_modules', 'playwright-report', 'ForgeFlow-runtime-win-x64']);
|
|
||||||
const excludedFiles = new Set(['SOURCE_MANIFEST.txt']);
|
const excludedFiles = new Set(['SOURCE_MANIFEST.txt']);
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
async function collect(directory, output = []) {
|
async function collect() {
|
||||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
const { stdout } = await execFileAsync(
|
||||||
if (excludedDirectories.has(entry.name)) continue;
|
'git',
|
||||||
const absolute = path.join(directory, entry.name);
|
['ls-files', '--cached', '--others', '--exclude-standard', '-z'],
|
||||||
if (entry.isDirectory()) await collect(absolute, output);
|
{ cwd: root, encoding: 'buffer', maxBuffer: 16 * 1024 * 1024 },
|
||||||
else if (!excludedFiles.has(entry.name)) output.push(absolute);
|
);
|
||||||
|
const relativePaths = stdout
|
||||||
|
.toString('utf8')
|
||||||
|
.split('\0')
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((relative) => !excludedFiles.has(relative));
|
||||||
|
|
||||||
|
const existing = [];
|
||||||
|
for (const relative of relativePaths) {
|
||||||
|
const absolute = path.resolve(root, relative);
|
||||||
|
try {
|
||||||
|
if ((await stat(absolute)).isFile()) existing.push(absolute);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code !== 'ENOENT') throw error;
|
||||||
}
|
}
|
||||||
return output;
|
}
|
||||||
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
|
const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
|
||||||
const files = (await collect(root)).sort((left, right) => left.localeCompare(right, 'en'));
|
const files = (await collect()).sort((left, right) => left.localeCompare(right, 'en'));
|
||||||
const lines = [
|
const lines = [
|
||||||
`ForgeFlow ${packageJson.version} source manifest`,
|
`ForgeFlow ${packageJson.version} source manifest`,
|
||||||
'SHA-256 BYTES PATH',
|
'SHA-256 BYTES PATH',
|
||||||
'(The manifest excludes itself, dependencies and generated release artifacts.)'
|
'(The manifest includes tracked and non-ignored source files, excluding itself.)'
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const absolute of files) {
|
for (const absolute of files) {
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ function safeRepositoryPart(value, label) {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readOptionalConfig(configPath) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(await fs.readFile(configPath, "utf8"));
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === "ENOENT") return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function api(baseUrl, token, pathname, options = {}) {
|
async function api(baseUrl, token, pathname, options = {}) {
|
||||||
const response = await fetch(`${baseUrl}/api/v1${pathname}`, {
|
const response = await fetch(`${baseUrl}/api/v1${pathname}`, {
|
||||||
...options,
|
...options,
|
||||||
@@ -51,16 +60,29 @@ app.whenReady().then(async () => {
|
|||||||
await fs.readFile(path.join(root, "package.json"), "utf8"),
|
await fs.readFile(path.join(root, "package.json"), "utf8"),
|
||||||
);
|
);
|
||||||
const configPath = path.join(configuredUserData, "forgeflow-config.json");
|
const configPath = path.join(configuredUserData, "forgeflow-config.json");
|
||||||
const config = JSON.parse(await fs.readFile(configPath, "utf8"));
|
const config = (await readOptionalConfig(configPath)) || {};
|
||||||
if (!config?.gitea?.encryptedToken) {
|
const actionsToken = String(
|
||||||
|
process.env.GITEA_TOKEN || process.env.FORGEFLOW_RELEASE_TOKEN || "",
|
||||||
|
).trim();
|
||||||
|
let token = actionsToken;
|
||||||
|
if (!token) {
|
||||||
|
if (!config.gitea?.encryptedToken) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`No encrypted Gitea token was found in ${configPath}. Sign in to Gitea once from ForgeFlow first.`,
|
`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 = normalizeBaseUrl(config.gitea.baseUrl);
|
}
|
||||||
|
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(
|
const owner = safeRepositoryPart(
|
||||||
process.env.FORGEFLOW_RELEASE_OWNER || config.updates?.owner || "Jens",
|
process.env.FORGEFLOW_RELEASE_OWNER || config.updates?.owner || "Jens",
|
||||||
"Release repository owner",
|
"Release repository owner",
|
||||||
@@ -122,11 +144,16 @@ app.whenReady().then(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (release.draft !== true) {
|
if (release.draft !== true) {
|
||||||
release = await api(baseUrl, token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}`, {
|
release = await api(
|
||||||
|
baseUrl,
|
||||||
|
token,
|
||||||
|
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}`,
|
||||||
|
{
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ draft: true }),
|
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`),
|
||||||
@@ -183,27 +210,58 @@ app.whenReady().then(async () => {
|
|||||||
[`ForgeFlow-${version}-release-manifest.json.sig`, "application/octet-stream"],
|
[`ForgeFlow-${version}-release-manifest.json.sig`, "application/octet-stream"],
|
||||||
]) {
|
]) {
|
||||||
const bytes = await fs.readFile(path.join(root, "dist", name));
|
const bytes = await fs.readFile(path.join(root, "dist", name));
|
||||||
const existing = (release.assets || []).find((asset) => asset.name === name);
|
const existing = (release.assets || []).find(
|
||||||
if (existing) await api(baseUrl, token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets/${existing.id}`, { method: "DELETE" });
|
(asset) => asset.name === name,
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
await api(
|
||||||
|
baseUrl,
|
||||||
|
token,
|
||||||
|
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets/${existing.id}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
);
|
||||||
|
}
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("attachment", new Blob([bytes], { type }), name);
|
form.append("attachment", new Blob([bytes], { type }), name);
|
||||||
const uploaded = await api(baseUrl, token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets?name=${encodeURIComponent(name)}`, { method: "POST", body: form, timeout: 300_000 });
|
const uploaded = await api(
|
||||||
release.assets = [...(release.assets || []).filter((asset) => asset.name !== name), uploaded];
|
baseUrl,
|
||||||
|
token,
|
||||||
|
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets?name=${encodeURIComponent(name)}`,
|
||||||
|
{ method: "POST", body: form, timeout: 300_000 },
|
||||||
|
);
|
||||||
|
release.assets = [
|
||||||
|
...(release.assets || []).filter((asset) => asset.name !== name),
|
||||||
|
uploaded,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
const requiredAssets = [
|
const requiredAssets = [
|
||||||
...binaries.flatMap((binaryPath) => [path.basename(binaryPath), `${path.basename(binaryPath)}.sha256`]),
|
...binaries.flatMap((binaryPath) => [
|
||||||
|
path.basename(binaryPath),
|
||||||
|
`${path.basename(binaryPath)}.sha256`,
|
||||||
|
]),
|
||||||
`ForgeFlow-${version}-provenance.json`,
|
`ForgeFlow-${version}-provenance.json`,
|
||||||
`ForgeFlow-${version}-sbom.cdx.json`,
|
`ForgeFlow-${version}-sbom.cdx.json`,
|
||||||
`ForgeFlow-${version}-release-manifest.json`,
|
`ForgeFlow-${version}-release-manifest.json`,
|
||||||
`ForgeFlow-${version}-release-manifest.json.sig`,
|
`ForgeFlow-${version}-release-manifest.json.sig`,
|
||||||
];
|
];
|
||||||
const missingAssets = requiredAssets.filter((name) => !(release.assets || []).some((asset) => asset.name === name));
|
const missingAssets = requiredAssets.filter(
|
||||||
if (missingAssets.length) throw new Error(`Release remains draft because required assets are missing: ${missingAssets.join(", ")}`);
|
(name) => !(release.assets || []).some((asset) => asset.name === name),
|
||||||
release = await api(baseUrl, token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}`, {
|
);
|
||||||
|
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",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ draft: false }),
|
body: JSON.stringify({ draft: false }),
|
||||||
});
|
},
|
||||||
|
);
|
||||||
console.log(
|
console.log(
|
||||||
`PASS ForgeFlow ${version} binary release published to ${owner}/${repo} for ${commit.slice(0, 7)}`,
|
`PASS ForgeFlow ${version} binary release published to ${owner}/${repo} for ${commit.slice(0, 7)}`,
|
||||||
);
|
);
|
||||||
|
|||||||
+13
-8
@@ -96,11 +96,12 @@ const required = [
|
|||||||
"docs/RELEASE_NOTES_0.10.12.md",
|
"docs/RELEASE_NOTES_0.10.12.md",
|
||||||
"docs/RELEASE_NOTES_0.10.13.md",
|
"docs/RELEASE_NOTES_0.10.13.md",
|
||||||
"docs/RELEASE_NOTES_0.10.14.md",
|
"docs/RELEASE_NOTES_0.10.14.md",
|
||||||
|
"docs/RELEASE_NOTES_0.10.15.md",
|
||||||
"docs/UPDATING.md",
|
"docs/UPDATING.md",
|
||||||
"docs/DIAGNOSTICS.md",
|
"docs/DIAGNOSTICS.md",
|
||||||
"docs/DEPLOYMENT_SETUP.md",
|
"docs/DEPLOYMENT_SETUP.md",
|
||||||
"docs/SSH_UNRAID_DEPLOYMENT.md",
|
"docs/SSH_UNRAID_DEPLOYMENT.md",
|
||||||
"docs/LUMAOPS_SERVER_AUDIT.md",
|
"docs/DEPLOYMENT_MIGRATION_EXAMPLE.md",
|
||||||
"docs/STATUS_ENDPOINT.md",
|
"docs/STATUS_ENDPOINT.md",
|
||||||
"docs/TEST_MATRIX.md",
|
"docs/TEST_MATRIX.md",
|
||||||
"docs/RELEASE_NOTES_0.4.0.md",
|
"docs/RELEASE_NOTES_0.4.0.md",
|
||||||
@@ -135,9 +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.10.14")
|
if (packageJson.version !== "0.10.15")
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Expected package version 0.10.14, 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"),
|
||||||
@@ -234,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(
|
||||||
@@ -263,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 [
|
||||||
@@ -507,6 +508,10 @@ const release01014 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.14.
|
|||||||
for (const phrase of ["Help center", "Gitea workspace sync", "repository context", "horizontal tab navigation", "84 browser flows"]) {
|
for (const phrase of ["Help center", "Gitea workspace sync", "repository context", "horizontal tab navigation", "84 browser flows"]) {
|
||||||
if (!release01014.includes(phrase)) throw new Error(`0.10.14 release notes are missing: ${phrase}`);
|
if (!release01014.includes(phrase)) throw new Error(`0.10.14 release notes are missing: ${phrase}`);
|
||||||
}
|
}
|
||||||
|
const release01015 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.15.md"), "utf8");
|
||||||
|
for (const phrase of ["Workspace Sync", "Codex review manifest", "local-only", "source updater", "binary updater"]) {
|
||||||
|
if (!release01015.includes(phrase)) throw new Error(`0.10.15 release notes are missing: ${phrase}`);
|
||||||
|
}
|
||||||
const configSource = await readFile(path.join(root, "src/main/config-store.cjs"), "utf8");
|
const configSource = await readFile(path.join(root, "src/main/config-store.cjs"), "utf8");
|
||||||
for (const mode of ["server-git", "push-bundle", "monitor-only"]) {
|
for (const mode of ["server-git", "push-bundle", "monitor-only"]) {
|
||||||
if (!configSource.includes(mode)) throw new Error(`Deployment configuration is missing mode: ${mode}`);
|
if (!configSource.includes(mode)) throw new Error(`Deployment configuration is missing mode: ${mode}`);
|
||||||
|
|||||||
@@ -3,8 +3,20 @@
|
|||||||
const fs = require('node:fs/promises');
|
const fs = require('node:fs/promises');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const crypto = require('node:crypto');
|
const crypto = require('node:crypto');
|
||||||
const { safeStorage } = require('electron');
|
const { normalizeBaseUrl, assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePath, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
|
||||||
const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePath, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
|
|
||||||
|
let cachedSafeStorage;
|
||||||
|
|
||||||
|
function getSafeStorage() {
|
||||||
|
if (cachedSafeStorage !== undefined) return cachedSafeStorage;
|
||||||
|
try {
|
||||||
|
const electron = require('electron');
|
||||||
|
cachedSafeStorage = electron && typeof electron === 'object' ? electron.safeStorage || null : null;
|
||||||
|
} catch {
|
||||||
|
cachedSafeStorage = null;
|
||||||
|
}
|
||||||
|
return cachedSafeStorage;
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG = {
|
const DEFAULT_CONFIG = {
|
||||||
schemaVersion: 13,
|
schemaVersion: 13,
|
||||||
@@ -215,6 +227,7 @@ class ConfigStore {
|
|||||||
return { persistent: true, preserved: false };
|
return { persistent: true, preserved: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const safeStorage = getSafeStorage();
|
||||||
if (safeStorage?.isEncryptionAvailable?.()) {
|
if (safeStorage?.isEncryptionAvailable?.()) {
|
||||||
this.data.gitea.encryptedToken = safeStorage.encryptString(value).toString('base64');
|
this.data.gitea.encryptedToken = safeStorage.encryptString(value).toString('base64');
|
||||||
this.sessionToken = null;
|
this.sessionToken = null;
|
||||||
@@ -230,6 +243,7 @@ class ConfigStore {
|
|||||||
if (this.sessionToken) return this.sessionToken;
|
if (this.sessionToken) return this.sessionToken;
|
||||||
if (!this.data.gitea.encryptedToken) return '';
|
if (!this.data.gitea.encryptedToken) return '';
|
||||||
try {
|
try {
|
||||||
|
const safeStorage = getSafeStorage();
|
||||||
return safeStorage?.decryptString?.(Buffer.from(this.data.gitea.encryptedToken, 'base64')) || '';
|
return safeStorage?.decryptString?.(Buffer.from(this.data.gitea.encryptedToken, 'base64')) || '';
|
||||||
} catch {
|
} catch {
|
||||||
return '';
|
return '';
|
||||||
@@ -240,6 +254,7 @@ class ConfigStore {
|
|||||||
encryptSecret(value) {
|
encryptSecret(value) {
|
||||||
const text = String(value || '');
|
const text = String(value || '');
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
|
const safeStorage = getSafeStorage();
|
||||||
if (!safeStorage?.isEncryptionAvailable?.()) {
|
if (!safeStorage?.isEncryptionAvailable?.()) {
|
||||||
const error = new Error('Secure credential storage is unavailable. ForgeFlow will not persist server passwords or key passphrases.');
|
const error = new Error('Secure credential storage is unavailable. ForgeFlow will not persist server passwords or key passphrases.');
|
||||||
error.code = 'SECURE_STORAGE_UNAVAILABLE';
|
error.code = 'SECURE_STORAGE_UNAVAILABLE';
|
||||||
@@ -250,7 +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 ''; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,7 +284,16 @@ class ConfigStore {
|
|||||||
const basePath = String(source.basePath || existing?.basePath || '/mnt/user/appdata').trim().replace(/\/+$/, '');
|
const basePath = String(source.basePath || existing?.basePath || '/mnt/user/appdata').trim().replace(/\/+$/, '');
|
||||||
if (!basePath.startsWith('/') || /[\r\n\0]/.test(basePath)) throw new Error('The server base path must be an absolute Unix path.');
|
if (!basePath.startsWith('/') || /[\r\n\0]/.test(basePath)) throw new Error('The server base path must be an absolute Unix path.');
|
||||||
const privateKeyPath = String(source.privateKeyPath || existing?.privateKeyPath || '').trim();
|
const privateKeyPath = String(source.privateKeyPath || existing?.privateKeyPath || '').trim();
|
||||||
const hostFingerprint = String(source.hostFingerprint || existing?.hostFingerprint || '').trim();
|
const credentialIdentityChanged = Boolean(existing && [
|
||||||
|
['host', existing.host, host],
|
||||||
|
['port', existing.port, port],
|
||||||
|
['username', existing.username, username],
|
||||||
|
['authType', existing.authType, authType],
|
||||||
|
['privateKeyPath', existing.privateKeyPath, privateKeyPath]
|
||||||
|
].some(([, previous, next]) => String(previous || '') !== String(next || '')));
|
||||||
|
const hostFingerprint = credentialIdentityChanged
|
||||||
|
? ''
|
||||||
|
: String(source.hostFingerprint || existing?.hostFingerprint || '').trim();
|
||||||
const scanRoots = uniqueStrings(source.scanRoots || existing?.scanRoots || [basePath]).map((value) => value.replace(/\/+$/, '')).filter((value) => value.startsWith('/') && !/[\r\n\0]/.test(value));
|
const scanRoots = uniqueStrings(source.scanRoots || existing?.scanRoots || [basePath]).map((value) => value.replace(/\/+$/, '')).filter((value) => value.startsWith('/') && !/[\r\n\0]/.test(value));
|
||||||
const scanExcludes = uniqueStrings(source.scanExcludes || existing?.scanExcludes || ['backups', 'archives', 'releases', 'staging', 'testdata']).filter((value) => /^[a-zA-Z0-9._*-]+$/.test(value));
|
const scanExcludes = uniqueStrings(source.scanExcludes || existing?.scanExcludes || ['backups', 'archives', 'releases', 'staging', 'testdata']).filter((value) => /^[a-zA-Z0-9._*-]+$/.test(value));
|
||||||
return {
|
return {
|
||||||
@@ -281,8 +308,8 @@ class ConfigStore {
|
|||||||
scanExcludes,
|
scanExcludes,
|
||||||
privateKeyPath,
|
privateKeyPath,
|
||||||
hostFingerprint,
|
hostFingerprint,
|
||||||
encryptedPassword: existing?.encryptedPassword || null,
|
encryptedPassword: credentialIdentityChanged ? null : existing?.encryptedPassword || null,
|
||||||
encryptedPassphrase: existing?.encryptedPassphrase || null,
|
encryptedPassphrase: credentialIdentityChanged ? null : existing?.encryptedPassphrase || null,
|
||||||
createdAt: existing?.createdAt || new Date().toISOString(),
|
createdAt: existing?.createdAt || new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString()
|
updatedAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
@@ -385,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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const path = require('node:path');
|
|||||||
const os = require('node:os');
|
const os = require('node:os');
|
||||||
const crypto = require('node:crypto');
|
const crypto = require('node:crypto');
|
||||||
const { createZip } = require('../shared/zip-writer.cjs');
|
const { createZip } = require('../shared/zip-writer.cjs');
|
||||||
const { sanitizeForDiagnostics, redactSecrets } = require('./log-redaction.cjs');
|
const { sanitizeForDiagnostics } = require('./log-redaction.cjs');
|
||||||
|
|
||||||
const LEVELS = { debug: 10, info: 20, warning: 30, error: 40 };
|
const LEVELS = { debug: 10, info: 20, warning: 30, error: 40 };
|
||||||
|
|
||||||
@@ -202,7 +202,7 @@ class DiagnosticsService {
|
|||||||
return this.getStatus();
|
return this.getStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async collectLogs(maxBytes = 20 * 1024 * 1024) {
|
async collectLogs(maxBytes = 20 * 1024 * 1024, { strictIdentifiers = false } = {}) {
|
||||||
await this.flush();
|
await this.flush();
|
||||||
const output = [];
|
const output = [];
|
||||||
let used = 0;
|
let used = 0;
|
||||||
@@ -211,7 +211,16 @@ class DiagnosticsService {
|
|||||||
const remaining = maxBytes - used;
|
const remaining = maxBytes - used;
|
||||||
const content = await fs.readFile(file.path);
|
const content = await fs.readFile(file.path);
|
||||||
const slice = content.length > remaining ? content.subarray(content.length - remaining) : content;
|
const slice = content.length > remaining ? content.subarray(content.length - remaining) : content;
|
||||||
output.push({ name: `logs/${file.name}`, data: Buffer.from(redactSecrets(slice.toString('utf8'), this.secretProvider?.() || []), 'utf8') });
|
output.push({
|
||||||
|
name: `logs/${file.name}`,
|
||||||
|
data: Buffer.from(
|
||||||
|
sanitizeForDiagnostics(slice.toString('utf8'), {
|
||||||
|
secrets: this.secretProvider?.() || [],
|
||||||
|
strictIdentifiers,
|
||||||
|
}),
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
});
|
||||||
used += slice.length;
|
used += slice.length;
|
||||||
}
|
}
|
||||||
return output;
|
return output;
|
||||||
@@ -345,7 +354,7 @@ class DiagnosticsService {
|
|||||||
{ name: 'operations-sanitized.json', data: safeJson(sanitizedOperations) },
|
{ name: 'operations-sanitized.json', data: safeJson(sanitizedOperations) },
|
||||||
{ name: 'preflight.json', data: safeJson(sanitize(preflight || {})) },
|
{ name: 'preflight.json', data: safeJson(sanitize(preflight || {})) },
|
||||||
{ name: 'context.json', data: safeJson(sanitize(extra || {})) },
|
{ name: 'context.json', data: safeJson(sanitize(extra || {})) },
|
||||||
...(await this.collectLogs())
|
...(await this.collectLogs(20 * 1024 * 1024, { strictIdentifiers: strict }))
|
||||||
];
|
];
|
||||||
|
|
||||||
const safetyAudit = auditBundleEntries(entries, this.secretProvider?.() || []);
|
const safetyAudit = auditBundleEntries(entries, this.secretProvider?.() || []);
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -167,6 +167,48 @@ class GitService {
|
|||||||
return { root, gitDir: path.resolve(result.stdout.trim()) };
|
return { root, gitDir: path.resolve(result.stdout.trim()) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async writeWorkspaceReviewManifest(repoPath, plan, { backupBranch = null, stash = null } = {}) {
|
||||||
|
const { root, gitDir } = await this.gitDirectory(repoPath);
|
||||||
|
const reviewId = String(plan?.id || '').trim();
|
||||||
|
if (!/^[0-9a-f]{64}$/i.test(reviewId)) throw new Error('Workspace review manifest requires a valid synchronization plan.');
|
||||||
|
const reviewDirectory = path.join(gitDir, 'forgeflow', 'workspace-reviews');
|
||||||
|
await fs.mkdir(reviewDirectory, { recursive: true });
|
||||||
|
const manifestPath = path.join(reviewDirectory, `${reviewId}.json`);
|
||||||
|
const payload = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
kind: 'workspace-sync-quarantine',
|
||||||
|
id: reviewId,
|
||||||
|
status: 'pending-codex-review',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
repositoryRoot: root,
|
||||||
|
branch: plan.branch,
|
||||||
|
upstream: plan.upstream,
|
||||||
|
sourceSha: plan.currentSha,
|
||||||
|
targetSha: plan.targetSha,
|
||||||
|
recoveryBranch: backupBranch,
|
||||||
|
stashRef: stash?.ref || null,
|
||||||
|
stashSha: stash?.sha || null,
|
||||||
|
files: (plan.localFiles || []).map((file) => ({
|
||||||
|
path: file.path,
|
||||||
|
originalPath: file.originalPath || null,
|
||||||
|
status: file.status,
|
||||||
|
staged: Boolean(file.staged),
|
||||||
|
unstaged: Boolean(file.unstaged),
|
||||||
|
untracked: Boolean(file.untracked)
|
||||||
|
})),
|
||||||
|
instructions: [
|
||||||
|
'Review the recovery branch and quarantine stash with Codex before restoring anything.',
|
||||||
|
'ForgeFlow recovery branches are local-only and cannot be pushed to Gitea.',
|
||||||
|
'Restore only files that are still useful; obsolete files can be dropped after review.'
|
||||||
|
],
|
||||||
|
manifestPath
|
||||||
|
};
|
||||||
|
const temporaryPath = `${manifestPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||||||
|
await fs.writeFile(temporaryPath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
|
||||||
|
await fs.rename(temporaryPath, manifestPath);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
isGitLockError(error) {
|
isGitLockError(error) {
|
||||||
const message = String(error?.message || error || '');
|
const message = String(error?.message || error || '');
|
||||||
return /(?:cannot lock ref|Unable to create .*\.lock|another git process)/i.test(message)
|
return /(?:cannot lock ref|Unable to create .*\.lock|another git process)/i.test(message)
|
||||||
@@ -447,6 +489,7 @@ class GitService {
|
|||||||
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
|
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
|
||||||
let backupBranch = null;
|
let backupBranch = null;
|
||||||
let stash = null;
|
let stash = null;
|
||||||
|
let review = null;
|
||||||
if (plan.summary.localCommitsToProtect > 0) {
|
if (plan.summary.localCommitsToProtect > 0) {
|
||||||
const safeBranch = plan.branch.replace(/[^A-Za-z0-9._-]/g, '-');
|
const safeBranch = plan.branch.replace(/[^A-Za-z0-9._-]/g, '-');
|
||||||
backupBranch = `forgeflow/recovery-${safeBranch}-${stamp}-${plan.currentSha.slice(0, 7)}`;
|
backupBranch = `forgeflow/recovery-${safeBranch}-${stamp}-${plan.currentSha.slice(0, 7)}`;
|
||||||
@@ -454,10 +497,13 @@ class GitService {
|
|||||||
await run('git', ['branch', backupBranch, 'HEAD'], { cwd: root, timeout: 30_000 });
|
await run('git', ['branch', backupBranch, 'HEAD'], { cwd: root, timeout: 30_000 });
|
||||||
}
|
}
|
||||||
if (plan.summary.localFilesToStash > 0) {
|
if (plan.summary.localFilesToStash > 0) {
|
||||||
const label = `ForgeFlow workspace sync ${plan.branch} ${stamp}`;
|
const label = `FORGEFLOW-QUARANTINE:${plan.id} workspace sync ${plan.branch} ${stamp}`;
|
||||||
await run('git', ['stash', 'push', '--include-untracked', '-m', label], { cwd: root, timeout: 120_000 });
|
await run('git', ['stash', 'push', '--include-untracked', '-m', label], { cwd: root, timeout: 120_000 });
|
||||||
stash = (await this.stashList(root))[0] || null;
|
stash = (await this.stashList(root))[0] || null;
|
||||||
}
|
}
|
||||||
|
if (backupBranch || stash) {
|
||||||
|
review = await this.writeWorkspaceReviewManifest(root, plan, { backupBranch, stash });
|
||||||
|
}
|
||||||
|
|
||||||
const protectedStatus = await this.status(root);
|
const protectedStatus = await this.status(root);
|
||||||
if (!protectedStatus.clean || protectedStatus.head !== plan.currentSha) {
|
if (!protectedStatus.clean || protectedStatus.head !== plan.currentSha) {
|
||||||
@@ -486,6 +532,7 @@ class GitService {
|
|||||||
status,
|
status,
|
||||||
backupBranch,
|
backupBranch,
|
||||||
stash,
|
stash,
|
||||||
|
review,
|
||||||
cleaned: plan.localFiles.filter((file) => file.untracked).map((file) => file.path),
|
cleaned: plan.localFiles.filter((file) => file.untracked).map((file) => file.path),
|
||||||
ignoredFilesPreserved: true
|
ignoredFilesPreserved: true
|
||||||
};
|
};
|
||||||
@@ -718,6 +765,12 @@ class GitService {
|
|||||||
const status = await this.status(root);
|
const status = await this.status(root);
|
||||||
const branch = status.branch.head;
|
const branch = status.branch.head;
|
||||||
if (!branch || branch === '(detached)') throw new Error('Cannot push from a detached HEAD.');
|
if (!branch || branch === '(detached)') throw new Error('Cannot push from a detached HEAD.');
|
||||||
|
if (/^forgeflow\/recovery-/.test(branch)) {
|
||||||
|
const error = new Error('ForgeFlow recovery branches are local quarantine references and cannot be pushed to Gitea. Review them with Codex and move only approved work onto a normal branch.');
|
||||||
|
error.code = 'WORKSPACE_RECOVERY_BRANCH_LOCAL_ONLY';
|
||||||
|
error.recoverable = true;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
const args = status.branch.upstream ? ['push', '--porcelain'] : ['push', '--porcelain', '--set-upstream', 'origin', branch];
|
const args = status.branch.upstream ? ['push', '--porcelain'] : ['push', '--porcelain', '--set-upstream', 'origin', branch];
|
||||||
const result = await run('git', args, { cwd: root, timeout: 180_000, maxBuffer: 16 * 1024 * 1024 });
|
const result = await run('git', args, { cwd: root, timeout: 180_000, maxBuffer: 16 * 1024 * 1024 });
|
||||||
return { output: `${result.stdout}\n${result.stderr}`.trim(), status: await this.status(root) };
|
return { output: `${result.stdout}\n${result.stderr}`.trim(), status: await this.status(root) };
|
||||||
@@ -804,7 +857,16 @@ class GitService {
|
|||||||
const result = await run('git', ['stash', 'list', `--format=${format}`], { cwd: root });
|
const result = await run('git', ['stash', 'list', `--format=${format}`], { cwd: root });
|
||||||
return result.stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
|
return result.stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
|
||||||
const [ref, sha, date, subject] = record.split('\x1f');
|
const [ref, sha, date, subject] = record.split('\x1f');
|
||||||
return { ref, sha, shortSha: sha.slice(0, 7), date, subject };
|
const quarantine = String(subject || '').match(/FORGEFLOW-QUARANTINE:([0-9a-f]{64})/i);
|
||||||
|
return {
|
||||||
|
ref,
|
||||||
|
sha,
|
||||||
|
shortSha: sha.slice(0, 7),
|
||||||
|
date,
|
||||||
|
subject,
|
||||||
|
quarantined: Boolean(quarantine),
|
||||||
|
reviewId: quarantine?.[1] || null
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -812,6 +874,13 @@ class GitService {
|
|||||||
const root = await this.ensureRepository(repoPath);
|
const root = await this.ensureRepository(repoPath);
|
||||||
const value = String(ref || 'stash@{0}');
|
const value = String(ref || 'stash@{0}');
|
||||||
if (!/^stash@\{\d+\}$/.test(value)) throw new Error('Invalid stash reference.');
|
if (!/^stash@\{\d+\}$/.test(value)) throw new Error('Invalid stash reference.');
|
||||||
|
const candidate = (await this.stashList(root)).find((item) => item.ref === value);
|
||||||
|
if (candidate?.quarantined) {
|
||||||
|
const error = new Error(`This stash is quarantined for Codex review (${candidate.reviewId}). ForgeFlow will not apply and drop it wholesale; restore only reviewed files manually.`);
|
||||||
|
error.code = 'WORKSPACE_QUARANTINE_REVIEW_REQUIRED';
|
||||||
|
error.recoverable = true;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
const result = await run('git', ['stash', 'pop', value], { cwd: root, timeout: 120_000 });
|
const result = await run('git', ['stash', 'pop', value], { cwd: root, timeout: 120_000 });
|
||||||
return { output: result.stdout.trim(), status: await this.status(root), stashes: await this.stashList(root) };
|
return { output: result.stdout.trim(), status: await this.status(root), stashes: await this.stashList(root) };
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-2
@@ -20,6 +20,7 @@ const {
|
|||||||
readEncryptedBackup,
|
readEncryptedBackup,
|
||||||
} = require("./configuration-backup.cjs");
|
} = require("./configuration-backup.cjs");
|
||||||
const { evaluateDeploymentPolicy } = require("../shared/deployment-policy.cjs");
|
const { evaluateDeploymentPolicy } = require("../shared/deployment-policy.cjs");
|
||||||
|
const { normalizeBaseUrl } = require("../shared/validation.cjs");
|
||||||
function registerIpc({
|
function registerIpc({
|
||||||
store,
|
store,
|
||||||
git,
|
git,
|
||||||
@@ -253,8 +254,23 @@ function registerIpc({
|
|||||||
});
|
});
|
||||||
|
|
||||||
register("settings:update-gitea", async ({ baseUrl, token }) => {
|
register("settings:update-gitea", async ({ baseUrl, token }) => {
|
||||||
const effectiveToken = String(token || "").trim() || store.getToken();
|
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
||||||
const validation = await gitea.validateConnection(baseUrl, effectiveToken);
|
const currentBaseUrl = store.data.gitea.baseUrl
|
||||||
|
? normalizeBaseUrl(store.data.gitea.baseUrl)
|
||||||
|
: "";
|
||||||
|
const submittedToken = String(token || "").trim();
|
||||||
|
if (!submittedToken && normalizedBaseUrl !== currentBaseUrl) {
|
||||||
|
const error = new Error(
|
||||||
|
"Enter a new Gitea token when changing the server address. Stored tokens are bound to their original origin.",
|
||||||
|
);
|
||||||
|
error.code = "GITEA_TOKEN_ORIGIN_CHANGED";
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const effectiveToken = submittedToken || store.getToken();
|
||||||
|
const validation = await gitea.validateConnection(
|
||||||
|
normalizedBaseUrl,
|
||||||
|
effectiveToken,
|
||||||
|
);
|
||||||
const tokenState = await store.updateGitea({
|
const tokenState = await store.updateGitea({
|
||||||
baseUrl: validation.baseUrl,
|
baseUrl: validation.baseUrl,
|
||||||
token,
|
token,
|
||||||
|
|||||||
@@ -96,7 +96,6 @@ function registerDeploymentIpc({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
register("deployment:health", ({ url }) => deployments.checkHealth(url));
|
|
||||||
register("deployment:link-server-workload", async ({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) => {
|
register("deployment:link-server-workload", async ({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) => {
|
||||||
const current = await resolveRepository(repository);
|
const current = await resolveRepository(repository);
|
||||||
const result = await unraid.linkServerWorkload({
|
const result = await unraid.linkServerWorkload({
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ function stableAlias(value, prefix = 'item') {
|
|||||||
return `${prefix}-${hash}`;
|
return `${prefix}-${hash}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function redactPrivateInfrastructure(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replace(/\b(?:10(?:\.\d{1,3}){3}|127(?:\.\d{1,3}){3}|169\.254(?:\.\d{1,3}){2}|172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}|192\.168(?:\.\d{1,3}){2})\b/g, '<PRIVATE_ADDRESS>')
|
||||||
|
.replace(/\b(?:https?|ssh):\/\/[^\s"'<>]+/gi, '<PRIVATE_URL>')
|
||||||
|
.replace(/\/(?:mnt|srv|opt|var\/lib)\/[^\s"'<>]*/g, '<SERVER_PATH>');
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeForDiagnostics(value, options = {}, seen = new WeakSet()) {
|
function sanitizeForDiagnostics(value, options = {}, seen = new WeakSet()) {
|
||||||
const {
|
const {
|
||||||
secrets = [],
|
secrets = [],
|
||||||
@@ -63,6 +70,7 @@ function sanitizeForDiagnostics(value, options = {}, seen = new WeakSet()) {
|
|||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
let output = redactSecrets(value, secrets);
|
let output = redactSecrets(value, secrets);
|
||||||
if (pathMode === 'alias') output = pathAlias(output, { homeDir, cwd });
|
if (pathMode === 'alias') output = pathAlias(output, { homeDir, cwd });
|
||||||
|
if (strictIdentifiers) output = redactPrivateInfrastructure(output);
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
if (value instanceof Error) {
|
if (value instanceof Error) {
|
||||||
@@ -80,7 +88,7 @@ function sanitizeForDiagnostics(value, options = {}, seen = new WeakSet()) {
|
|||||||
output[key] = '[REDACTED]';
|
output[key] = '[REDACTED]';
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (strictIdentifiers && ['fullName', 'repository', 'owner', 'user', 'login', 'email'].includes(key)) {
|
if (strictIdentifiers && ['full_name', 'repository', 'owner', 'user', 'login', 'email', 'host', 'hostname', 'username', 'base_path', 'private_key_path', 'local_path', 'remote_folder', 'remote_url', 'clone_url', 'status_url', 'healthcheck_url', 'web_ui_url', 'workspace_roots', 'scan_roots'].includes(normalizedKey.toLowerCase())) {
|
||||||
output[key] = stableAlias(typeof item === 'object' ? JSON.stringify(item) : item, key.toLowerCase());
|
output[key] = stableAlias(typeof item === 'object' ? JSON.stringify(item) : item, key.toLowerCase());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -90,4 +98,4 @@ function sanitizeForDiagnostics(value, options = {}, seen = new WeakSet()) {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { redactSecrets, sanitizeForDiagnostics, pathAlias, stableAlias, SENSITIVE_KEY };
|
module.exports = { redactSecrets, sanitizeForDiagnostics, pathAlias, stableAlias, redactPrivateInfrastructure, SENSITIVE_KEY };
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ function delay(ms) {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requireSignedSourceUpdate(message) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.code = "SIGNED_SOURCE_UPDATE_REQUIRED";
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveWindowsPowerShellPath(environment = process.env) {
|
function resolveWindowsPowerShellPath(environment = process.env) {
|
||||||
const windowsRoot = environment.SystemRoot || environment.WINDIR;
|
const windowsRoot = environment.SystemRoot || environment.WINDIR;
|
||||||
if (windowsRoot) {
|
if (windowsRoot) {
|
||||||
@@ -325,6 +331,11 @@ class UpdateService {
|
|||||||
return this.downloadPackaged(update);
|
return this.downloadPackaged(update);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
requireSignedSourceUpdate(
|
||||||
|
"Integrated source updates are disabled because source archives do not yet carry an independently signed publisher manifest. Update a source checkout with Git after reviewing the exact commit.",
|
||||||
|
);
|
||||||
|
|
||||||
|
/* c8 ignore start -- retained for a future signed source-archive implementation */
|
||||||
await fs.mkdir(this.updateDirectory, { recursive: true });
|
await fs.mkdir(this.updateDirectory, { recursive: true });
|
||||||
const archiveUrl = `${this.store.data.gitea.baseUrl.replace(/\/+$/, "")}/${encodeURIComponent(update.owner)}/${encodeURIComponent(update.repo)}/archive/${update.remoteSha}.zip`;
|
const archiveUrl = `${this.store.data.gitea.baseUrl.replace(/\/+$/, "")}/${encodeURIComponent(update.owner)}/${encodeURIComponent(update.repo)}/archive/${update.remoteSha}.zip`;
|
||||||
const archive = await this.gitea.downloadAuthenticated(archiveUrl);
|
const archive = await this.gitea.downloadAuthenticated(archiveUrl);
|
||||||
@@ -354,6 +365,7 @@ class UpdateService {
|
|||||||
sha256,
|
sha256,
|
||||||
});
|
});
|
||||||
return { ...metadata, downloaded: true };
|
return { ...metadata, downloaded: true };
|
||||||
|
/* c8 ignore stop */
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadPackaged(update) {
|
async downloadPackaged(update) {
|
||||||
@@ -528,6 +540,10 @@ class UpdateService {
|
|||||||
"The integrated updater currently supports Windows only.",
|
"The integrated updater currently supports Windows only.",
|
||||||
);
|
);
|
||||||
if (update.kind === "binary") return this.applyPackaged(update);
|
if (update.kind === "binary") return this.applyPackaged(update);
|
||||||
|
requireSignedSourceUpdate(
|
||||||
|
"This source archive cannot be applied because it has no independently signed publisher manifest.",
|
||||||
|
);
|
||||||
|
/* c8 ignore start -- legacy helper retained only for migration compatibility */
|
||||||
const stat = await fs.stat(update.archivePath).catch(() => null);
|
const stat = await fs.stat(update.archivePath).catch(() => null);
|
||||||
if (!stat?.isFile())
|
if (!stat?.isFile())
|
||||||
throw new Error("The staged update archive is no longer available.");
|
throw new Error("The staged update archive is no longer available.");
|
||||||
@@ -662,6 +678,7 @@ class UpdateService {
|
|||||||
logPath,
|
logPath,
|
||||||
statusPath,
|
statusPath,
|
||||||
};
|
};
|
||||||
|
/* c8 ignore stop */
|
||||||
}
|
}
|
||||||
|
|
||||||
async applyPackaged(update) {
|
async applyPackaged(update) {
|
||||||
@@ -850,4 +867,5 @@ module.exports = {
|
|||||||
waitForUpdaterStarted,
|
waitForUpdaterStarted,
|
||||||
readJsonFile,
|
readJsonFile,
|
||||||
readLogTail,
|
readLogTail,
|
||||||
|
requireSignedSourceUpdate,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -161,12 +161,13 @@ Force repair after you have closed all Git tools for this repository?`)
|
|||||||
]);
|
]);
|
||||||
const recovery = [
|
const recovery = [
|
||||||
result.backupBranch ? `recovery branch ${result.backupBranch}` : null,
|
result.backupBranch ? `recovery branch ${result.backupBranch}` : null,
|
||||||
result.stash ? `stash ${result.stash.ref}` : null,
|
result.stash ? `quarantine stash ${result.stash.ref}` : null,
|
||||||
|
result.review ? `Codex review manifest ${result.review.manifestPath}` : null,
|
||||||
].filter(Boolean).join(" and ");
|
].filter(Boolean).join(" and ");
|
||||||
showToast(
|
showToast(
|
||||||
"Workspace synchronized with Gitea",
|
"Workspace synchronized with Gitea",
|
||||||
recovery
|
recovery
|
||||||
? `Local work is preserved in ${recovery}. Ignored runtime files were retained.`
|
? `Local work is quarantined in ${recovery}. Review it before restoring anything; ignored runtime files were retained.`
|
||||||
: `Tracked files now match ${result.plan.upstream}; ignored runtime files were retained.`,
|
: `Tracked files now match ${result.plan.upstream}; ignored runtime files were retained.`,
|
||||||
"success",
|
"success",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -176,7 +176,7 @@
|
|||||||
username: "root",
|
username: "root",
|
||||||
authType: "privateKey",
|
authType: "privateKey",
|
||||||
basePath: "/mnt/user/appdata",
|
basePath: "/mnt/user/appdata",
|
||||||
privateKeyPath: "C:\\Users\\Jens\\.ssh\\id_ed25519",
|
privateKeyPath: "C:\\Users\\your-name\\.ssh\\id_ed25519",
|
||||||
hostFingerprint: "SHA256:demo",
|
hostFingerprint: "SHA256:demo",
|
||||||
hasPassword: false,
|
hasPassword: false,
|
||||||
hasPassphrase: false,
|
hasPassphrase: false,
|
||||||
|
|||||||
@@ -665,7 +665,7 @@ function createMockDeploymentBridge(context) {
|
|||||||
async exportDiagnostics(privacyMode = "standard") {
|
async exportDiagnostics(privacyMode = "standard") {
|
||||||
await wait(500);
|
await wait(500);
|
||||||
return {
|
return {
|
||||||
path: `C:\Users\Jens\Downloads\ForgeFlow-Diagnostics-demo.zip`,
|
path: `C:\Users\your-name\Downloads\ForgeFlow-Diagnostics-demo.zip`,
|
||||||
bytes: 38221,
|
bytes: 38221,
|
||||||
size: "37.3 KB",
|
size: "37.3 KB",
|
||||||
sha256: "b".repeat(64),
|
sha256: "b".repeat(64),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ function createMockRepositoryBridge(context) {
|
|||||||
await wait(80);
|
await wait(80);
|
||||||
snapshot();
|
snapshot();
|
||||||
return {
|
return {
|
||||||
appVersion: "0.10.14-demo",
|
appVersion: "0.10.15-demo",
|
||||||
platform: "win32",
|
platform: "win32",
|
||||||
state: clone(state),
|
state: clone(state),
|
||||||
git: { available: true, version: "git version 2.47.3" },
|
git: { available: true, version: "git version 2.47.3" },
|
||||||
@@ -29,7 +29,7 @@ function createMockRepositoryBridge(context) {
|
|||||||
},
|
},
|
||||||
async selectKeyFile() {
|
async selectKeyFile() {
|
||||||
await wait();
|
await wait();
|
||||||
return "C:\\Users\\Jens\\.ssh\\id_ed25519";
|
return "C:\\Users\\your-name\\.ssh\\id_ed25519";
|
||||||
},
|
},
|
||||||
async setupPreflight({ baseUrl, token, roots = [] }) {
|
async setupPreflight({ baseUrl, token, roots = [] }) {
|
||||||
await wait(240);
|
await wait(240);
|
||||||
|
|||||||
@@ -367,7 +367,7 @@ function renderGitTools(repository) {
|
|||||||
? ui.branches.map((branch) => `<div class="tool-row"><div><strong>${escapeHtml(branch.name)}</strong><span>${escapeHtml(branch.shortSha)}${branch.upstream ? ` · ${escapeHtml(branch.upstream)}` : " · unpublished"}</span></div>${branch.current ? '<span class="status-pill success">Current</span>' : `<button class="button" data-action="checkout-branch" data-branch="${attr(branch.name)}">Switch</button>`}</div>`).join("")
|
? ui.branches.map((branch) => `<div class="tool-row"><div><strong>${escapeHtml(branch.name)}</strong><span>${escapeHtml(branch.shortSha)}${branch.upstream ? ` · ${escapeHtml(branch.upstream)}` : " · unpublished"}</span></div>${branch.current ? '<span class="status-pill success">Current</span>' : `<button class="button" data-action="checkout-branch" data-branch="${attr(branch.name)}">Switch</button>`}</div>`).join("")
|
||||||
: '<div class="empty-state compact"><p>Load branch information.</p></div>';
|
: '<div class="empty-state compact"><p>Load branch information.</p></div>';
|
||||||
const stashRows = ui.stashes.length
|
const stashRows = ui.stashes.length
|
||||||
? ui.stashes.map((stash) => `<div class="tool-row"><div><strong>${escapeHtml(stash.ref)}</strong><span>${escapeHtml(stash.subject)} · ${formatDate(stash.date)}</span></div><button class="button" data-action="pop-stash" data-stash-ref="${attr(stash.ref)}">Apply & drop</button></div>`).join("")
|
? ui.stashes.map((stash) => `<div class="tool-row"><div><strong>${escapeHtml(stash.ref)}</strong><span>${escapeHtml(stash.subject)} · ${formatDate(stash.date)}</span></div>${stash.quarantined ? `<span class="status-pill warning" title="Workspace review ${attr(stash.reviewId || "")}">Codex review required</span>` : `<button class="button" data-action="pop-stash" data-stash-ref="${attr(stash.ref)}">Apply & drop</button>`}</div>`).join("")
|
||||||
: '<div class="empty-state compact"><p>No stashes, or Git tools have not been loaded.</p></div>';
|
: '<div class="empty-state compact"><p>No stashes, or Git tools have not been loaded.</p></div>';
|
||||||
const recoveryBody = recovery
|
const recoveryBody = recovery
|
||||||
? `<div class="troubleshooting-summary"><span class="status-pill ${locks.length ? "warning" : "success"}">${locks.length ? `${locks.length} lock${locks.length === 1 ? "" : "s"}` : "No Git locks"}</span><span>${activeProcesses.length ? `${activeProcesses.length} active Git process(es)` : "No matching active Git process detected"}</span></div>${locks.length ? `<div class="tool-list">${locks.map((lock) => `<div class="tool-row"><div><strong>${escapeHtml(lock.name)}</strong><span>${Math.round(lock.ageMs / 1000)}s old · ${escapeHtml(lock.modifiedAt)}</span></div></div>`).join("")}</div>` : ""}${recommendations.length ? `<div class="tool-list recovery-actions">${recommendations.map((item) => `<div class="tool-row"><div><strong>${escapeHtml(item.label)}</strong><span>${item.safe ? "Safe automated action" : item.action ? "Creates a safety branch before changing history" : "Review required"}</span></div>${item.action ? `<button class="button ${item.safe ? "" : "danger"}" data-action="repair-repository-sync" data-strategy="${attr(item.action)}">Run</button>` : ""}</div>`).join("")}</div>` : ""}`
|
? `<div class="troubleshooting-summary"><span class="status-pill ${locks.length ? "warning" : "success"}">${locks.length ? `${locks.length} lock${locks.length === 1 ? "" : "s"}` : "No Git locks"}</span><span>${activeProcesses.length ? `${activeProcesses.length} active Git process(es)` : "No matching active Git process detected"}</span></div>${locks.length ? `<div class="tool-list">${locks.map((lock) => `<div class="tool-row"><div><strong>${escapeHtml(lock.name)}</strong><span>${Math.round(lock.ageMs / 1000)}s old · ${escapeHtml(lock.modifiedAt)}</span></div></div>`).join("")}</div>` : ""}${recommendations.length ? `<div class="tool-list recovery-actions">${recommendations.map((item) => `<div class="tool-row"><div><strong>${escapeHtml(item.label)}</strong><span>${item.safe ? "Safe automated action" : item.action ? "Creates a safety branch before changing history" : "Review required"}</span></div>${item.action ? `<button class="button ${item.safe ? "" : "danger"}" data-action="repair-repository-sync" data-strategy="${attr(item.action)}">Run</button>` : ""}</div>`).join("")}</div>` : ""}`
|
||||||
@@ -761,8 +761,8 @@ function renderSettings() {
|
|||||||
const update = ui.updateStatus;
|
const update = ui.updateStatus;
|
||||||
const servers = state.servers || [];
|
const servers = state.servers || [];
|
||||||
return `<div class="settings-layout"><aside class="settings-nav"><button class="nav-button active">${icon("settings")}<span>General</span></button><button class="nav-button" data-action="check-updates">${icon("update")}<span>Updates</span></button><button class="nav-button" data-action="open-add-server">${icon("server")}<span>Servers</span></button><button class="nav-button" data-action="reset-app">${icon("trash")}<span>Reset setup</span></button></aside><div class="settings-content"><div class="page-header"><div><div class="eyebrow">Application</div><h1>Settings</h1><p>Connections, project discovery, secure SSH servers and application updates.</p></div></div>
|
return `<div class="settings-layout"><aside class="settings-nav"><button class="nav-button active">${icon("settings")}<span>General</span></button><button class="nav-button" data-action="check-updates">${icon("update")}<span>Updates</span></button><button class="nav-button" data-action="open-add-server">${icon("server")}<span>Servers</span></button><button class="nav-button" data-action="reset-app">${icon("trash")}<span>Reset setup</span></button></aside><div class="settings-content"><div class="page-header"><div><div class="eyebrow">Application</div><h1>Settings</h1><p>Connections, project discovery, secure SSH servers and application updates.</p></div></div>
|
||||||
<section class="settings-group"><h2>Gitea connection</h2><div class="form-grid"><div class="field full"><label for="settings-gitea-url">Instance URL</label><input id="settings-gitea-url" class="input" value="${attr(state.gitea.baseUrl)}" placeholder="https://gitea.example.com" /></div><div class="field full"><label for="settings-gitea-token">New access token</label><input id="settings-gitea-token" class="input" type="password" placeholder="Leave empty to keep the existing token" /></div></div><div class="connection-card" style="margin-top:10px"><div><strong>${state.gitea.hasToken ? `Connected as ${escapeHtml(state.gitea.user?.login || "user")}` : "Not connected"}</strong><div class="queue-sub">${escapeHtml(state.gitea.baseUrl || "No Gitea instance configured")}</div></div><button class="button primary" data-action="save-gitea-settings">Validate & save</button></div></section>
|
<section class="settings-group"><h2>Gitea connection</h2><div class="form-grid"><div class="field full"><label for="settings-gitea-url">Instance URL</label><input id="settings-gitea-url" class="input" value="${attr(state.gitea.baseUrl)}" placeholder="https://gitea.example.com" /></div><div class="field full"><label for="settings-gitea-token">New access token</label><input id="settings-gitea-token" class="input" type="password" placeholder="Leave empty only when keeping the same server" /></div></div><div class="connection-card" style="margin-top:10px"><div><strong>${state.gitea.hasToken ? `Connected as ${escapeHtml(state.gitea.user?.login || "user")}` : "Not connected"}</strong><div class="queue-sub">${escapeHtml(state.gitea.baseUrl || "No Gitea instance configured")}</div></div><button class="button primary" data-action="save-gitea-settings">Validate & save</button></div></section>
|
||||||
<section class="settings-group"><div class="section-heading"><div><h2>ForgeFlow updates</h2><span class="meta">Secure source update from ${escapeHtml(state.updates?.owner || "Jens")}/${escapeHtml(state.updates?.repo || "ForgeFlow")}</span></div><button class="button" data-action="check-updates" ${ui.updateChecking ? "disabled" : ""}>${icon("update")}${ui.updateChecking ? "Checking…" : "Check now"}</button></div><div class="form-grid"><div class="field"><label>Repository owner</label><input id="update-owner" class="input" value="${attr(state.updates?.owner || "Jens")}"/></div><div class="field"><label>Repository name</label><input id="update-repo" class="input" value="${attr(state.updates?.repo || "ForgeFlow")}"/></div><div class="field"><label>Release branch</label><input id="update-branch" class="input" value="${attr(state.updates?.branch || "main")}"/></div><div class="field"><label>Automatic startup check</label><select id="update-auto-check" class="select"><option value="true" ${state.updates?.autoCheck !== false ? "selected" : ""}>Enabled</option><option value="false" ${state.updates?.autoCheck === false ? "selected" : ""}>Disabled</option></select></div></div><div class="update-card ${update?.available ? "available" : ""}"><div>${icon(update?.available ? "download" : "check")}<span><strong>${update ? (update.available ? `ForgeFlow ${escapeHtml(update.remoteVersion)} is available` : `ForgeFlow ${escapeHtml(update.currentVersion)} is up to date`) : `Current version ${escapeHtml(ui.boot.appVersion)}`}</strong><small>${update ? `Branch ${escapeHtml(update.branch)} · commit ${escapeHtml(update.shortSha)} · checked ${formatDate(update.checkedAt)}` : "No update check in this session."}</small></span></div><div class="stack horizontal compact">${update?.available && !update.downloaded ? `<button class="button primary" data-action="download-update">${icon("download")}Download update</button>` : ""}${update?.downloaded ? `<button class="button success" data-action="apply-update">${icon("update")}Apply & restart</button>` : ""}<button class="button" data-action="save-update-settings">Save update settings</button></div></div><div class="notice" style="margin-top:10px">${icon("shield")}The updater downloads an authenticated ZIP for the exact remote commit, verifies its SHA-256 checksum, runs the complete quality gate and restores the previous source version if validation fails.</div></section>
|
<section class="settings-group"><div class="section-heading"><div><h2>ForgeFlow updates</h2><span class="meta">Signed packaged updates from ${escapeHtml(state.updates?.owner || "Jens")}/${escapeHtml(state.updates?.repo || "ForgeFlow")}</span></div><button class="button" data-action="check-updates" ${ui.updateChecking ? "disabled" : ""}>${icon("update")}${ui.updateChecking ? "Checking…" : "Check now"}</button></div><div class="form-grid"><div class="field"><label>Repository owner</label><input id="update-owner" class="input" value="${attr(state.updates?.owner || "Jens")}"/></div><div class="field"><label>Repository name</label><input id="update-repo" class="input" value="${attr(state.updates?.repo || "ForgeFlow")}"/></div><div class="field"><label>Release branch</label><input id="update-branch" class="input" value="${attr(state.updates?.branch || "main")}"/></div><div class="field"><label>Automatic startup check</label><select id="update-auto-check" class="select"><option value="true" ${state.updates?.autoCheck !== false ? "selected" : ""}>Enabled</option><option value="false" ${state.updates?.autoCheck === false ? "selected" : ""}>Disabled</option></select></div></div><div class="update-card ${update?.available ? "available" : ""}"><div>${icon(update?.available ? "download" : "check")}<span><strong>${update ? (update.available ? `ForgeFlow ${escapeHtml(update.remoteVersion)} is available${update.packaged ? "" : " in the source repository"}` : `ForgeFlow ${escapeHtml(update.currentVersion)} is up to date`) : `Current version ${escapeHtml(ui.boot.appVersion)}`}</strong><small>${update ? `Branch ${escapeHtml(update.branch)} · commit ${escapeHtml(update.shortSha)} · checked ${formatDate(update.checkedAt)}` : "No update check in this session."}</small></span></div><div class="stack horizontal compact">${update?.available && update.packaged && !update.downloaded ? `<button class="button primary" data-action="download-update">${icon("download")}Download signed update</button>` : ""}${update?.downloaded ? `<button class="button success" data-action="apply-update">${icon("update")}Apply & restart</button>` : ""}<button class="button" data-action="save-update-settings">Save update settings</button></div></div><div class="notice" style="margin-top:10px">${icon("shield")}${update && !update.packaged ? "Source checkouts must be updated with Git after reviewing the exact commit. Integrated source replacement remains disabled until source archives are publisher-signed." : "Packaged updates require an Ed25519 publisher signature that binds the exact commit, artifact name, size and SHA-256 digest."}</div></section>
|
||||||
<section class="settings-group"><div class="section-heading"><div><h2>SSH / Unraid servers</h2><span class="meta">Credentials are encrypted locally; a new host fingerprint is shown before authentication.</span></div><button class="button primary" data-action="open-add-server">${icon("plus")}Add server</button></div>${servers.length ? `<div class="server-list">${servers.map((server) => `<article class="server-card"><div class="server-card-main">${icon("server")}<div><strong>${escapeHtml(server.name)}</strong><span>${escapeHtml(server.username)}@${escapeHtml(server.host)}:${escapeHtml(server.port)} · ${escapeHtml(server.basePath)}</span><small>${server.hostFingerprint ? `Trusted ${escapeHtml(server.hostFingerprint)}` : "Host identity not trusted yet"}</small></div></div><div class="stack horizontal compact"><button class="button" data-action="test-server" data-server-id="${attr(server.id)}">${server.hostFingerprint ? "Test connection" : "Preview & trust fingerprint"}</button><button class="button" data-action="edit-server" data-server-id="${attr(server.id)}">Edit</button><button class="icon-button danger" data-action="delete-server" data-server-id="${attr(server.id)}" title="Delete server">${icon("trash")}</button></div></article>`).join("")}</div>` : '<div class="empty-state compact"><p>No SSH server configured. Add your Unraid server before creating an SSH deployment profile.</p></div>'}</section>
|
<section class="settings-group"><div class="section-heading"><div><h2>SSH / Unraid servers</h2><span class="meta">Credentials are encrypted locally; a new host fingerprint is shown before authentication.</span></div><button class="button primary" data-action="open-add-server">${icon("plus")}Add server</button></div>${servers.length ? `<div class="server-list">${servers.map((server) => `<article class="server-card"><div class="server-card-main">${icon("server")}<div><strong>${escapeHtml(server.name)}</strong><span>${escapeHtml(server.username)}@${escapeHtml(server.host)}:${escapeHtml(server.port)} · ${escapeHtml(server.basePath)}</span><small>${server.hostFingerprint ? `Trusted ${escapeHtml(server.hostFingerprint)}` : "Host identity not trusted yet"}</small></div></div><div class="stack horizontal compact"><button class="button" data-action="test-server" data-server-id="${attr(server.id)}">${server.hostFingerprint ? "Test connection" : "Preview & trust fingerprint"}</button><button class="button" data-action="edit-server" data-server-id="${attr(server.id)}">Edit</button><button class="icon-button danger" data-action="delete-server" data-server-id="${attr(server.id)}" title="Delete server">${icon("trash")}</button></div></article>`).join("")}</div>` : '<div class="empty-state compact"><p>No SSH server configured. Add your Unraid server before creating an SSH deployment profile.</p></div>'}</section>
|
||||||
<section class="settings-group"><div class="section-heading"><div><h2>Git remote maintenance</h2><span class="meta">Standardize linked repositories to the current Gitea SSH URLs.</span></div><button class="button" data-action="normalize-origins">${icon("link")}Normalize all origins</button></div><p>This replaces legacy aliases and renamed owners only after an explicit click. Local commits and files are not changed.</p></section>
|
<section class="settings-group"><div class="section-heading"><div><h2>Git remote maintenance</h2><span class="meta">Standardize linked repositories to the current Gitea SSH URLs.</span></div><button class="button" data-action="normalize-origins">${icon("link")}Normalize all origins</button></div><p>This replaces legacy aliases and renamed owners only after an explicit click. Local commits and files are not changed.</p></section>
|
||||||
<section class="settings-group"><h2>Project roots</h2><p>The first folder is the default clone destination. ForgeFlow automatically creates one subfolder per repository.</p><div class="stack">${state.workspaceRoots.map((root, index) => `<div class="root-row">${index === 0 ? '<span class="status-pill success">Default</span>' : ""}<input class="input" data-root-index="${index}" value="${attr(root)}" aria-label="Project root ${index + 1}"/><button class="icon-button" data-action="remove-root" data-index="${index}" title="Remove">${icon("trash")}</button></div>`).join("")}<button class="button" data-action="add-root">${icon("plus")}Add project root</button><button class="button primary" data-action="save-roots">Save folders & rescan</button></div></section>
|
<section class="settings-group"><h2>Project roots</h2><p>The first folder is the default clone destination. ForgeFlow automatically creates one subfolder per repository.</p><div class="stack">${state.workspaceRoots.map((root, index) => `<div class="root-row">${index === 0 ? '<span class="status-pill success">Default</span>' : ""}<input class="input" data-root-index="${index}" value="${attr(root)}" aria-label="Project root ${index + 1}"/><button class="icon-button" data-action="remove-root" data-index="${index}" title="Remove">${icon("trash")}</button></div>`).join("")}<button class="button" data-action="add-root">${icon("plus")}Add project root</button><button class="button primary" data-action="save-roots">Save folders & rescan</button></div></section>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
|
||||||
function normalizeRelativePosixPath(value) {
|
function normalizeRelativePosixPath(value) {
|
||||||
@@ -19,11 +20,14 @@ function bashSyntaxCheckInvocation(root, scriptPath = 'examples/server/forgeflow
|
|||||||
if (typeof root !== 'string' || !root.trim()) {
|
if (typeof root !== 'string' || !root.trim()) {
|
||||||
throw new Error('Project root is required for shell validation.');
|
throw new Error('Project root is required for shell validation.');
|
||||||
}
|
}
|
||||||
|
const relativeScriptPath = normalizeRelativePosixPath(scriptPath);
|
||||||
|
const scriptText = fs.readFileSync(path.join(root, ...relativeScriptPath.split('/')), 'utf8');
|
||||||
return {
|
return {
|
||||||
command: 'bash',
|
command: 'bash',
|
||||||
args: ['-n', normalizeRelativePosixPath(scriptPath)],
|
args: ['-n'],
|
||||||
options: {
|
options: {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
|
input: scriptText.replace(/\r\n?/g, '\n'),
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
windowsHide: true
|
windowsHide: true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const workflowUrl = new URL('../examples/gitea-actions/forgeflow-approved-deploy.yml', import.meta.url);
|
||||||
|
const deployUrl = new URL('../examples/server/forgeflow-deploy', import.meta.url);
|
||||||
|
|
||||||
|
|
||||||
|
test('central approved workflow transports signed target evidence only to the root-owned deploy wrapper', async () => {
|
||||||
|
const workflow = await readFile(workflowUrl, 'utf8');
|
||||||
|
|
||||||
|
for (const input of [
|
||||||
|
'repository',
|
||||||
|
'environment',
|
||||||
|
'commit_sha',
|
||||||
|
'request_id',
|
||||||
|
'approval_id',
|
||||||
|
'approval_fingerprint',
|
||||||
|
'evidence_issued_at',
|
||||||
|
'evidence_signature',
|
||||||
|
]) {
|
||||||
|
assert.match(workflow, new RegExp(`\\b${input}:`));
|
||||||
|
}
|
||||||
|
assert.match(workflow, /\$\{\{ inputs\.repository \}\}/);
|
||||||
|
assert.doesNotMatch(workflow, /\$\{\{ gitea\.repository \}\}/);
|
||||||
|
assert.match(workflow, /FF_APPROVAL_ID.*FF_REQUEST_ID/s);
|
||||||
|
assert.match(workflow, /sudo \/usr\/local\/bin\/forgeflow-deploy/);
|
||||||
|
assert.doesNotMatch(workflow, /actions\/checkout/);
|
||||||
|
assert.doesNotMatch(workflow, /docker compose/);
|
||||||
|
assert.doesNotMatch(workflow, /git\s+-C/);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('server wrapper verifies Ed25519 evidence before any live git or compose mutation', async () => {
|
||||||
|
const script = await readFile(deployUrl, 'utf8');
|
||||||
|
const verifyIndex = script.indexOf('openssl pkeyutl -verify');
|
||||||
|
const resetIndex = script.indexOf('git -C "$APP_DIR" reset --hard "$SHA"');
|
||||||
|
const composeIndex = script.indexOf('docker compose -f "$COMPOSE_FILE" up -d --build');
|
||||||
|
|
||||||
|
assert.ok(verifyIndex > 0, 'expected cryptographic verification');
|
||||||
|
assert.ok(resetIndex > verifyIndex, 'git reset must happen after evidence verification');
|
||||||
|
assert.ok(composeIndex > verifyIndex, 'compose mutation must happen after evidence verification');
|
||||||
|
assert.match(script, /EVIDENCE_PUBLIC_KEY_FILE="\/etc\/forgeflow\/evidence\.pub"/);
|
||||||
|
assert.match(script, /evidence_owner.*root/s);
|
||||||
|
assert.match(script, /8#022/);
|
||||||
|
assert.match(script, /EVIDENCE_ISSUED_AT >= now_epoch - 1800/);
|
||||||
|
assert.match(script, /"evidence_verified": \$EVIDENCE_VERIFIED/);
|
||||||
|
assert.match(script, /\(\( \$# == 3 \|\| \$# == 4 \|\| \$# == 8 \)\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('signed message fields match the AppOps evidence v1 contract and exclude runner-chosen workflow/ref', async () => {
|
||||||
|
const script = await readFile(deployUrl, 'utf8');
|
||||||
|
const marker = "printf 'forgeflow-evidence-v1\\n%s\\n%s\\n%s\\n%s\\n%s\\n%s\\n%s\\n'";
|
||||||
|
assert.ok(script.includes(marker));
|
||||||
|
assert.match(
|
||||||
|
script,
|
||||||
|
/"\$APPROVAL_ID"[\s\\]+"\$APPROVAL_FINGERPRINT"[\s\\]+"\$REPOSITORY"[\s\\]+"\$ENVIRONMENT"[\s\\]+"\$\{SHA,,\}"[\s\\]+"\$REQUEST_ID"[\s\\]+"\$EVIDENCE_ISSUED_AT"/s,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(script.slice(0, script.indexOf('APP_DIR=""')), /WORKFLOW|workflow|ref=/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const deployUrl = new URL('../examples/server/forgeflow-deploy', import.meta.url);
|
||||||
|
|
||||||
|
test('a signed approved request is consumed once before target selection or mutation', async () => {
|
||||||
|
const script = await readFile(deployUrl, 'utf8');
|
||||||
|
const verifyIndex = script.indexOf('openssl pkeyutl -verify');
|
||||||
|
const consumeIndex = script.indexOf('mkdir -m 0700 "$EVIDENCE_REPLAY_DIR/$APPROVAL_ID"');
|
||||||
|
const targetIndex = script.indexOf('APP_DIR=""');
|
||||||
|
const resetIndex = script.indexOf('git -C "$APP_DIR" reset --hard "$SHA"');
|
||||||
|
|
||||||
|
assert.ok(verifyIndex > 0);
|
||||||
|
assert.ok(consumeIndex > verifyIndex);
|
||||||
|
assert.ok(targetIndex > consumeIndex);
|
||||||
|
assert.ok(resetIndex > consumeIndex);
|
||||||
|
assert.match(script, /EVIDENCE_REPLAY_DIR="\/var\/lib\/forgeflow-status\/approved-requests"/);
|
||||||
|
assert.match(script, /install -d -o root -g root -m 0700 "\$EVIDENCE_REPLAY_DIR"/);
|
||||||
|
assert.match(script, /Approved deployment evidence was already consumed/);
|
||||||
|
});
|
||||||
@@ -193,7 +193,11 @@ test("setup, Gitea updates and generic patches retain normalized public state",
|
|||||||
assert.equal(completed.state.setupComplete, true);
|
assert.equal(completed.state.setupComplete, true);
|
||||||
assert.equal(completed.state.gitea.hasToken, true);
|
assert.equal(completed.state.gitea.hasToken, true);
|
||||||
assert.deepEqual(completed.state.workspaceRoots, ["C:/Projects"]);
|
assert.deepEqual(completed.state.workspaceRoots, ["C:/Projects"]);
|
||||||
const update = await store.updateGitea({ baseUrl: "https://new.test", token: "", user: null });
|
await assert.rejects(
|
||||||
|
store.updateGitea({ baseUrl: "https://new.test", token: "", user: null }),
|
||||||
|
(error) => error.code === "GITEA_TOKEN_ORIGIN_CHANGED"
|
||||||
|
);
|
||||||
|
const update = await store.updateGitea({ baseUrl: "https://gitea.test", token: "", user: null });
|
||||||
assert.equal(update.preserved, true);
|
assert.equal(update.preserved, true);
|
||||||
assert.equal(store.data.gitea.user.login, "jens");
|
assert.equal(store.data.gitea.user.login, "jens");
|
||||||
const patched = await store.patch({ appearance: "light", workspaceRoots: ["D:/Code", "D:/Code"] });
|
const patched = await store.patch({ appearance: "light", workspaceRoots: ["D:/Code", "D:/Code"] });
|
||||||
@@ -215,6 +219,20 @@ test("server saves reject absent credentials before mutating configuration", asy
|
|||||||
assert.deepEqual(store.data.servers, []);
|
assert.deepEqual(store.data.servers, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("server credentials and trust are cleared when the connection identity changes", async (t) => {
|
||||||
|
const { store } = await storeFixture(t);
|
||||||
|
store.encryptSecret = (value) => `encrypted:${value}`;
|
||||||
|
const saved = await store.saveServer({ host: "server-one", username: "deploy", authType: "password", basePath: "/mnt/apps", hostFingerprint: "SHA256:trusted" }, { password: "test-password" });
|
||||||
|
await assert.rejects(
|
||||||
|
store.saveServer({ ...saved, host: "server-two" }, {}),
|
||||||
|
/password is required/i
|
||||||
|
);
|
||||||
|
assert.equal(store.data.servers[0].host, "server-one");
|
||||||
|
const changed = await store.saveServer({ ...saved, host: "server-two" }, { password: "replacement-password" });
|
||||||
|
assert.equal(changed.hostFingerprint, "");
|
||||||
|
assert.equal(changed.hasPassword, true);
|
||||||
|
});
|
||||||
|
|
||||||
test("deployment profile normalization covers safe defaults and every optional Unraid control", async (t) => {
|
test("deployment profile normalization covers safe defaults and every optional Unraid control", async (t) => {
|
||||||
const { store } = await storeFixture(t);
|
const { store } = await storeFixture(t);
|
||||||
const actions = store.normalizeDeploymentProfile({ environment: "qa", statusUrl: "https://app.test/status" });
|
const actions = store.normalizeDeploymentProfile({ environment: "qa", statusUrl: "https://app.test/status" });
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ test('writes structured local diagnostics and exports a secret-free support bund
|
|||||||
authorization: `token ${secret}`,
|
authorization: `token ${secret}`,
|
||||||
password: 'unsafe-password',
|
password: 'unsafe-password',
|
||||||
message: `request failed with ${secret}`,
|
message: `request failed with ${secret}`,
|
||||||
path: path.join(os.homedir(), 'private', 'repository')
|
path: path.join(os.homedir(), 'private', 'repository'),
|
||||||
|
host: '192.168.10.20',
|
||||||
|
basePath: '/mnt/user/appdata/private-app'
|
||||||
});
|
});
|
||||||
await service.flush();
|
await service.flush();
|
||||||
|
|
||||||
@@ -57,7 +59,7 @@ test('writes structured local diagnostics and exports a secret-free support bund
|
|||||||
const result = await service.exportSupportBundle({
|
const result = await service.exportSupportBundle({
|
||||||
destinationPath: destination,
|
destinationPath: destination,
|
||||||
privacyMode: 'strict',
|
privacyMode: 'strict',
|
||||||
publicState: { gitea: { baseUrl: 'https://gitea.example.test', hasToken: true, encryptedToken: 'ciphertext' }, preferences: {} },
|
publicState: { gitea: { baseUrl: 'https://gitea.example.test', hasToken: true, encryptedToken: 'ciphertext' }, servers: [{ host: '192.168.10.20', username: 'deploy', basePath: '/mnt/user/appdata/private-app' }], preferences: {} },
|
||||||
repositories: [{ id: 1, fullName: 'jens/private-repo', localPath: path.join(os.homedir(), 'private-repo'), localStatus: { head: 'a'.repeat(40), branch: { head: 'main' }, counts: {}, clean: true } }],
|
repositories: [{ id: 1, fullName: 'jens/private-repo', localPath: path.join(os.homedir(), 'private-repo'), localStatus: { head: 'a'.repeat(40), branch: { head: 'main' }, counts: {}, clean: true } }],
|
||||||
operations: [{ repository: 'jens/private-repo', status: 'failed', runnerLog: `Authorization: token ${secret}` }],
|
operations: [{ repository: 'jens/private-repo', status: 'failed', runnerLog: `Authorization: token ${secret}` }],
|
||||||
preflight: { checks: [] }
|
preflight: { checks: [] }
|
||||||
@@ -67,6 +69,7 @@ test('writes structured local diagnostics and exports a secret-free support bund
|
|||||||
const bundleText = [...entries.values()].map((value) => value.toString('utf8')).join('\n');
|
const bundleText = [...entries.values()].map((value) => value.toString('utf8')).join('\n');
|
||||||
assert.doesNotMatch(bundleText, new RegExp(secret));
|
assert.doesNotMatch(bundleText, new RegExp(secret));
|
||||||
assert.doesNotMatch(bundleText, /ciphertext|unsafe-password|jens\/private-repo/);
|
assert.doesNotMatch(bundleText, /ciphertext|unsafe-password|jens\/private-repo/);
|
||||||
|
assert.doesNotMatch(bundleText, /192\.168\.10\.20|\/mnt\/user\/appdata\/private-app/);
|
||||||
assert.match(entries.get('manifest.json').toString(), /"containsSecrets": false/);
|
assert.match(entries.get('manifest.json').toString(), /"containsSecrets": false/);
|
||||||
assert.match(entries.get('repositories-sanitized.json').toString(), /fullname-[a-f0-9]{12}/);
|
assert.match(entries.get('repositories-sanitized.json').toString(), /fullname-[a-f0-9]{12}/);
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import toolsModule from '../src/main/external-tools-service.cjs';
|
|||||||
const { normalizeTool, expandTool } = toolsModule;
|
const { normalizeTool, expandTool } = toolsModule;
|
||||||
|
|
||||||
test('external tool templates expand as argument arrays without a shell', () => {
|
test('external tool templates expand as argument arrays without a shell', () => {
|
||||||
const tool = normalizeTool({ executable: 'code.exe', args: ['--goto', '{file}:{line}', '{path}'] }, {});
|
const tool = normalizeTool({ executable: 'code.exe', args: ['--malicious', 'ignored'] }, { executable: 'code.exe' }, 'editor');
|
||||||
const invocation = expandTool(tool, { path: 'C:\\Projects\\App', file: 'C:\\Projects\\App\\src\\app.js', line: 12 });
|
const invocation = expandTool(tool, { path: 'C:\\Projects\\App', file: 'C:\\Projects\\App\\src\\app.js', line: 12 });
|
||||||
assert.equal(invocation.executable, 'code.exe');
|
assert.equal(invocation.executable, 'code.exe');
|
||||||
assert.deepEqual(invocation.args, ['--goto', 'C:\\Projects\\App\\src\\app.js:12', 'C:\\Projects\\App']);
|
assert.deepEqual(invocation.args, ['--reuse-window', '--goto', 'C:\\Projects\\App\\src\\app.js:12']);
|
||||||
assert.throws(() => normalizeTool({ executable: 'code.exe\ncalc.exe', args: [] }, {}), /invalid/);
|
assert.throws(() => normalizeTool({ executable: 'code.exe\ncalc.exe', args: [] }, {}, 'editor'), /invalid/);
|
||||||
|
assert.throws(() => normalizeTool({ executable: 'powershell.exe', args: ['-Command', 'calc'] }, {}, 'terminal'), /unsupported terminal tool/i);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -295,6 +295,16 @@ test('previews and safely mirrors a workspace to Gitea while preserving every cl
|
|||||||
assert.equal(result.status.head, reviewedPlan.targetSha);
|
assert.equal(result.status.head, reviewedPlan.targetSha);
|
||||||
assert.match(result.backupBranch, /^forgeflow\/recovery-main-/);
|
assert.match(result.backupBranch, /^forgeflow\/recovery-main-/);
|
||||||
assert.ok(result.stash?.sha);
|
assert.ok(result.stash?.sha);
|
||||||
|
assert.equal(result.stash.quarantined, true);
|
||||||
|
assert.equal(result.review.id, reviewedPlan.id);
|
||||||
|
assert.equal(result.review.status, 'pending-codex-review');
|
||||||
|
const reviewManifest = JSON.parse(await fs.readFile(result.review.manifestPath, 'utf8'));
|
||||||
|
assert.equal(reviewManifest.recoveryBranch, result.backupBranch);
|
||||||
|
assert.equal(reviewManifest.stashSha, result.stash.sha);
|
||||||
|
assert.deepEqual(
|
||||||
|
new Set(reviewManifest.files.map((file) => file.path)),
|
||||||
|
new Set(['README.md', 'local-notes.txt', 'changed-after-preview.txt'])
|
||||||
|
);
|
||||||
assert.equal((await git(['rev-parse', result.backupBranch], working)).stdout.trim(), localHead);
|
assert.equal((await git(['rev-parse', result.backupBranch], working)).stdout.trim(), localHead);
|
||||||
assert.equal((await fs.readFile(path.join(working, 'README.md'), 'utf8')).replace(/\r\n/g, '\n'), 'changed on Gitea\n');
|
assert.equal((await fs.readFile(path.join(working, 'README.md'), 'utf8')).replace(/\r\n/g, '\n'), 'changed on Gitea\n');
|
||||||
assert.equal((await fs.readFile(path.join(working, 'remote-only.txt'), 'utf8')).replace(/\r\n/g, '\n'), 'new on Gitea\n');
|
assert.equal((await fs.readFile(path.join(working, 'remote-only.txt'), 'utf8')).replace(/\r\n/g, '\n'), 'new on Gitea\n');
|
||||||
@@ -306,6 +316,15 @@ test('previews and safely mirrors a workspace to Gitea while preserving every cl
|
|||||||
assert.match(stashedPaths, /README\.md/);
|
assert.match(stashedPaths, /README\.md/);
|
||||||
assert.match(stashedPaths, /local-notes\.txt/);
|
assert.match(stashedPaths, /local-notes\.txt/);
|
||||||
assert.match(stashedPaths, /changed-after-preview\.txt/);
|
assert.match(stashedPaths, /changed-after-preview\.txt/);
|
||||||
|
await assert.rejects(
|
||||||
|
service.popStash(working, result.stash.ref),
|
||||||
|
(error) => error.code === 'WORKSPACE_QUARANTINE_REVIEW_REQUIRED'
|
||||||
|
);
|
||||||
|
await git(['switch', result.backupBranch], working);
|
||||||
|
await assert.rejects(
|
||||||
|
service.push(working),
|
||||||
|
(error) => error.code === 'WORKSPACE_RECOVERY_BRANCH_LOCAL_ONLY'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('repairs a diverged branch by creating a safety branch before resetting to upstream', async (t) => {
|
test('repairs a diverged branch by creating a safety branch before resetting to upstream', async (t) => {
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ test('rewrites Gitea internal HTTP release URLs to the configured public origin'
|
|||||||
return Buffer.from('asset');
|
return Buffer.from('asset');
|
||||||
};
|
};
|
||||||
await service.downloadReleaseAsset('Jens', 'ForgeFlow', 107, 412, {
|
await service.downloadReleaseAsset('Jens', 'ForgeFlow', 107, 412, {
|
||||||
downloadUrl: 'http://192.168.10.150:3000/Jens/ForgeFlow/releases/download/v0.10.1/ForgeFlow.exe',
|
downloadUrl: 'http://192.168.56.10:3000/Jens/ForgeFlow/releases/download/v0.10.1/ForgeFlow.exe',
|
||||||
});
|
});
|
||||||
assert.equal(requested, 'https://gitea.example.test/Jens/ForgeFlow/releases/download/v0.10.1/ForgeFlow.exe');
|
assert.equal(requested, 'https://gitea.example.test/Jens/ForgeFlow/releases/download/v0.10.1/ForgeFlow.exe');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import redaction from '../src/main/log-redaction.cjs';
|
import redaction from '../src/main/log-redaction.cjs';
|
||||||
|
|
||||||
const { redactSecrets, sanitizeForDiagnostics, pathAlias, stableAlias } = redaction;
|
const { redactSecrets, sanitizeForDiagnostics, pathAlias, stableAlias, redactPrivateInfrastructure } = redaction;
|
||||||
|
|
||||||
test('redacts runtime credentials, structured secrets, private keys and URL credentials', () => {
|
test('redacts runtime credentials, structured secrets, private keys and URL credentials', () => {
|
||||||
const token = ['gitea', 'TEST', 'ONLY', 'SecretToken123456'].join('_');
|
const token = ['gitea', 'TEST', 'ONLY', 'SecretToken123456'].join('_');
|
||||||
@@ -20,7 +20,7 @@ test('redacts runtime credentials, structured secrets, private keys and URL cred
|
|||||||
test('sanitizes nested sensitive keys and aliases user paths', () => {
|
test('sanitizes nested sensitive keys and aliases user paths', () => {
|
||||||
const value = {
|
const value = {
|
||||||
accessToken: 'do-not-keep',
|
accessToken: 'do-not-keep',
|
||||||
nested: { password: 'do-not-keep-either', path: 'C:\\Users\\Jens\\Projects\\ForgeFlow' },
|
nested: { password: 'do-not-keep-either', path: 'C:\\Users\\example-user\\Projects\\ForgeFlow' },
|
||||||
home: '/home/jens/projects/forgeflow'
|
home: '/home/jens/projects/forgeflow'
|
||||||
};
|
};
|
||||||
const sanitized = sanitizeForDiagnostics(value, { homeDir: '/home/jens', cwd: '/work/ForgeFlow' });
|
const sanitized = sanitizeForDiagnostics(value, { homeDir: '/home/jens', cwd: '/work/ForgeFlow' });
|
||||||
@@ -31,17 +31,24 @@ test('sanitizes nested sensitive keys and aliases user paths', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('strict privacy mode replaces stable identifiers deterministically', () => {
|
test('strict privacy mode replaces stable identifiers deterministically', () => {
|
||||||
const first = sanitizeForDiagnostics({ fullName: 'jens/private-project', login: 'jens' }, { strictIdentifiers: true });
|
const first = sanitizeForDiagnostics({ fullName: 'jens/private-project', login: 'jens', host: '192.168.10.20', basePath: '/mnt/user/appdata' }, { strictIdentifiers: true });
|
||||||
const second = sanitizeForDiagnostics({ fullName: 'jens/private-project', login: 'jens' }, { strictIdentifiers: true });
|
const second = sanitizeForDiagnostics({ fullName: 'jens/private-project', login: 'jens', host: '192.168.10.20', basePath: '/mnt/user/appdata' }, { strictIdentifiers: true });
|
||||||
assert.equal(first.fullName, second.fullName);
|
assert.equal(first.fullName, second.fullName);
|
||||||
assert.equal(first.login, second.login);
|
assert.equal(first.login, second.login);
|
||||||
assert.notEqual(first.fullName, 'jens/private-project');
|
assert.notEqual(first.fullName, 'jens/private-project');
|
||||||
assert.match(first.fullName, /^fullname-[a-f0-9]{12}$/);
|
assert.match(first.fullName, /^fullname-[a-f0-9]{12}$/);
|
||||||
|
assert.notEqual(first.host, '192.168.10.20');
|
||||||
|
assert.notEqual(first.basePath, '/mnt/user/appdata');
|
||||||
assert.equal(stableAlias('same', 'repo'), stableAlias('same', 'repo'));
|
assert.equal(stableAlias('same', 'repo'), stableAlias('same', 'repo'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('strict privacy redacts private addresses, infrastructure URLs and server paths in log text', () => {
|
||||||
|
const result = redactPrivateInfrastructure('host 192.168.10.20 url https://internal.example.test/status path /mnt/user/appdata/example');
|
||||||
|
assert.doesNotMatch(result, /192\.168\.10\.20|internal\.example\.test|\/mnt\/user\/appdata/);
|
||||||
|
});
|
||||||
|
|
||||||
test('path aliasing handles slash variants', () => {
|
test('path aliasing handles slash variants', () => {
|
||||||
const result = pathAlias('C:\\Users\\Jens\\src and C:/Users/Jens/src', { homeDir: 'C:\\Users\\Jens', cwd: 'D:\\ForgeFlow' });
|
const result = pathAlias('C:\\Users\\example-user\\src and C:/Users/example-user/src', { homeDir: 'C:\\Users\\example-user', cwd: 'D:\\ForgeFlow' });
|
||||||
assert.doesNotMatch(result, /Users[\\/]Jens/);
|
assert.doesNotMatch(result, /Users[\\/]Jens/);
|
||||||
assert.match(result, /<HOME>/);
|
assert.match(result, /<HOME>/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ test('a watched repository is read on filesystem activity instead of on every in
|
|||||||
|
|
||||||
revision = 2;
|
revision = 2;
|
||||||
await writeFile(path.join(root, 'feature.txt'), 'changed\n');
|
await writeFile(path.join(root, 'feature.txt'), 'changed\n');
|
||||||
|
// Exercise the monitor's filesystem-activity boundary deterministically.
|
||||||
|
// Native fs.watch delivery is platform/overlay specific and is covered by
|
||||||
|
// the product's safety interval rather than by this unit test.
|
||||||
|
monitor.noteFilesystemChange(root);
|
||||||
// The watcher debounce and the per-repository cooldown both apply here.
|
// The watcher debounce and the per-repository cooldown both apply here.
|
||||||
const deadline = Date.now() + 5_000;
|
const deadline = Date.now() + 5_000;
|
||||||
while (changes.length === 0 && Date.now() < deadline) {
|
while (changes.length === 0 && Date.now() < deadline) {
|
||||||
|
|||||||
@@ -8,15 +8,6 @@ import shellVerification from '../src/shared/shell-verification.cjs';
|
|||||||
|
|
||||||
const { bashSyntaxCheckInvocation, bashSyntaxCheckFromTextInvocation, normalizeRelativePosixPath, validateShellScriptStructure, shouldRunExternalBash } = shellVerification;
|
const { bashSyntaxCheckInvocation, bashSyntaxCheckFromTextInvocation, normalizeRelativePosixPath, validateShellScriptStructure, shouldRunExternalBash } = shellVerification;
|
||||||
|
|
||||||
test('Bash syntax validation keeps Windows project roots in cwd and passes a relative POSIX path', () => {
|
|
||||||
const invocation = bashSyntaxCheckInvocation('C:\\Projects\\ForgeFlow');
|
|
||||||
assert.equal(invocation.command, 'bash');
|
|
||||||
assert.deepEqual(invocation.args, ['-n', 'examples/server/forgeflow-deploy']);
|
|
||||||
assert.equal(invocation.options.cwd, 'C:\\Projects\\ForgeFlow');
|
|
||||||
assert.equal(invocation.args[1].includes('\\'), false);
|
|
||||||
assert.equal(/^[A-Za-z]:/.test(invocation.args[1]), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Shell validation refuses absolute and escaping script paths', () => {
|
test('Shell validation refuses absolute and escaping script paths', () => {
|
||||||
assert.throws(() => normalizeRelativePosixPath('C:\\Projects\\ForgeFlow\\script.sh'), /must be relative/);
|
assert.throws(() => normalizeRelativePosixPath('C:\\Projects\\ForgeFlow\\script.sh'), /must be relative/);
|
||||||
assert.throws(() => normalizeRelativePosixPath('/tmp/script.sh'), /must be relative/);
|
assert.throws(() => normalizeRelativePosixPath('/tmp/script.sh'), /must be relative/);
|
||||||
@@ -34,11 +25,19 @@ test('Bash syntax validation works from a project root containing spaces', async
|
|||||||
await mkdir(relativeDirectory, { recursive: true });
|
await mkdir(relativeDirectory, { recursive: true });
|
||||||
await copyFile(new URL('../examples/server/forgeflow-deploy', import.meta.url), path.join(relativeDirectory, 'forgeflow-deploy'));
|
await copyFile(new URL('../examples/server/forgeflow-deploy', import.meta.url), path.join(relativeDirectory, 'forgeflow-deploy'));
|
||||||
const invocation = bashSyntaxCheckInvocation(tempBase);
|
const invocation = bashSyntaxCheckInvocation(tempBase);
|
||||||
|
assert.equal(invocation.options.cwd, tempBase);
|
||||||
|
assert.deepEqual(invocation.args, ['-n']);
|
||||||
|
assert.equal(invocation.options.input.includes('\r'), false);
|
||||||
const result = spawnSync(invocation.command, invocation.args, invocation.options);
|
const result = spawnSync(invocation.command, invocation.args, invocation.options);
|
||||||
assert.equal(result.status, 0, result.stderr);
|
assert.equal(result.status, 0, result.stderr);
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
await rm(tempBase, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
|
await rm(tempBase, {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
maxRetries: 20,
|
||||||
|
retryDelay: 100
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Git Bash on Windows can retain a short-lived working-directory handle
|
// Git Bash on Windows can retain a short-lived working-directory handle
|
||||||
// after bash -n exits. Do not fail a successful syntax test solely because
|
// after bash -n exits. Do not fail a successful syntax test solely because
|
||||||
@@ -48,7 +47,6 @@ test('Bash syntax validation works from a project root containing spaces', async
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
test('Bash syntax validation from text does not depend on a Windows working directory', () => {
|
test('Bash syntax validation from text does not depend on a Windows working directory', () => {
|
||||||
const invocation = bashSyntaxCheckFromTextInvocation('#!/usr/bin/env bash\nset -euo pipefail\necho ok\n');
|
const invocation = bashSyntaxCheckFromTextInvocation('#!/usr/bin/env bash\nset -euo pipefail\necho ok\n');
|
||||||
assert.equal(invocation.command, 'bash');
|
assert.equal(invocation.command, 'bash');
|
||||||
@@ -67,7 +65,6 @@ test('Bash syntax validation from text detects malformed scripts', (t) => {
|
|||||||
assert.notEqual(result.status, 0);
|
assert.notEqual(result.status, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
test('portable server-script validation does not require a local Bash executable', () => {
|
test('portable server-script validation does not require a local Bash executable', () => {
|
||||||
const script = `#!/usr/bin/env bash
|
const script = `#!/usr/bin/env bash
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
@@ -89,13 +86,9 @@ write_status "unhealthy"
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('portable server-script validation refuses missing deployment safety markers', () => {
|
test('portable server-script validation refuses missing deployment safety markers', () => {
|
||||||
assert.throws(
|
assert.throws(() => validateShellScriptStructure('#!/usr/bin/env bash\nset -Eeuo pipefail\necho unsafe\n'), /missing required safety marker/);
|
||||||
() => validateShellScriptStructure('#!/usr/bin/env bash\nset -Eeuo pipefail\necho unsafe\n'),
|
|
||||||
/missing required safety marker/
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
test('Windows publication never depends on an external Bash shim', () => {
|
test('Windows publication never depends on an external Bash shim', () => {
|
||||||
assert.equal(shouldRunExternalBash('win32'), false);
|
assert.equal(shouldRunExternalBash('win32'), false);
|
||||||
assert.equal(shouldRunExternalBash('linux'), true);
|
assert.equal(shouldRunExternalBash('linux'), true);
|
||||||
|
|||||||
@@ -121,10 +121,10 @@ test("server pull prefers the linked checkout origin over stale detected SSH end
|
|||||||
const repository = {
|
const repository = {
|
||||||
fullName: "Jens/Portfolio",
|
fullName: "Jens/Portfolio",
|
||||||
localStatus: { remoteUrl: "git@gitea.itworx.tech:Jens/Portfolio.git" },
|
localStatus: { remoteUrl: "git@gitea.itworx.tech:Jens/Portfolio.git" },
|
||||||
sshUrl: "ssh://git@192.168.10.150:222/Jens/Portfolio.git",
|
sshUrl: "ssh://git@192.168.56.10:222/Jens/Portfolio.git",
|
||||||
preferredCloneUrl: "ssh://git@192.168.10.150:222/Jens/Portfolio.git",
|
preferredCloneUrl: "ssh://git@192.168.56.10:222/Jens/Portfolio.git",
|
||||||
};
|
};
|
||||||
const profile = { cloneUrl: "ssh://git@192.168.10.150:222/Jens/Portfolio.git" };
|
const profile = { cloneUrl: "ssh://git@192.168.56.10:222/Jens/Portfolio.git" };
|
||||||
|
|
||||||
assert.equal(service.serverGitRemote(repository, profile), "git@gitea.itworx.tech:Jens/Portfolio.git");
|
assert.equal(service.serverGitRemote(repository, profile), "git@gitea.itworx.tech:Jens/Portfolio.git");
|
||||||
assert.deepEqual(service.serverGitHost(repository, profile), { host: "gitea.itworx.tech", port: 22 });
|
assert.deepEqual(service.serverGitHost(repository, profile), { host: "gitea.itworx.tech", port: 22 });
|
||||||
@@ -894,7 +894,7 @@ test("DockerMan metadata uses dockerman labels, a template WebUI and lowercase-s
|
|||||||
remoteFolder: "Portfolio",
|
remoteFolder: "Portfolio",
|
||||||
environment: "production",
|
environment: "production",
|
||||||
hostPort: 5150,
|
hostPort: 5150,
|
||||||
webUiUrl: "http://192.168.10.150:5150/admin",
|
webUiUrl: "http://192.168.56.10:5150/admin",
|
||||||
dockerShell: "/bin/sh",
|
dockerShell: "/bin/sh",
|
||||||
},
|
},
|
||||||
{ name: "Portfolio" },
|
{ name: "Portfolio" },
|
||||||
@@ -927,7 +927,7 @@ test("DockerMan integration writes a persistent template fallback and invalidate
|
|||||||
remoteFolder: "Portfolio",
|
remoteFolder: "Portfolio",
|
||||||
environment: "production",
|
environment: "production",
|
||||||
hostPort: 5150,
|
hostPort: 5150,
|
||||||
webUiUrl: "http://192.168.10.150:5150/",
|
webUiUrl: "http://192.168.56.10:5150/",
|
||||||
dockerShell: "/bin/sh",
|
dockerShell: "/bin/sh",
|
||||||
manageDockerMan: true,
|
manageDockerMan: true,
|
||||||
generatedCompose: true,
|
generatedCompose: true,
|
||||||
@@ -1124,7 +1124,7 @@ test("existing Unraid deployment discovery derives profile values from Docker, C
|
|||||||
defaultBranch: "main",
|
defaultBranch: "main",
|
||||||
sshUrl: "ssh://git@gitea/Jens/blockpilot-autonomous.git",
|
sshUrl: "ssh://git@gitea/Jens/blockpilot-autonomous.git",
|
||||||
},
|
},
|
||||||
server: { id: "unraid", host: "192.168.10.150" },
|
server: { id: "unraid", host: "192.168.56.10" },
|
||||||
remoteFolder: "blockpilot-autonomous",
|
remoteFolder: "blockpilot-autonomous",
|
||||||
remotePath: "/mnt/user/appdata/blockpilot-autonomous",
|
remotePath: "/mnt/user/appdata/blockpilot-autonomous",
|
||||||
payload: {
|
payload: {
|
||||||
@@ -1399,7 +1399,7 @@ test("push bundle preflight does not require Git or Gitea credentials on Unraid"
|
|||||||
getServer: () => ({
|
getServer: () => ({
|
||||||
id: "unraid",
|
id: "unraid",
|
||||||
name: "Unraid",
|
name: "Unraid",
|
||||||
host: "192.168.10.150",
|
host: "192.168.56.10",
|
||||||
port: 22,
|
port: 22,
|
||||||
username: "root",
|
username: "root",
|
||||||
basePath: "/mnt/user/appdata",
|
basePath: "/mnt/user/appdata",
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ test("update repository parts reject path injection", async () => {
|
|||||||
await rm(temp, { recursive: true, force: true });
|
await rm(temp, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("source updater confirms an external STARTED marker before ForgeFlow may close", async () => {
|
test("source updater refuses an unsigned archive before launching a helper", async () => {
|
||||||
const temp = await mkdtemp(
|
const temp = await mkdtemp(
|
||||||
path.join(os.tmpdir(), "forgeflow-update-handshake-"),
|
path.join(os.tmpdir(), "forgeflow-update-handshake-"),
|
||||||
);
|
);
|
||||||
@@ -181,10 +181,11 @@ test("source updater confirms an external STARTED marker before ForgeFlow may cl
|
|||||||
remoteSha: "a".repeat(40),
|
remoteSha: "a".repeat(40),
|
||||||
sha256: "b".repeat(64),
|
sha256: "b".repeat(64),
|
||||||
};
|
};
|
||||||
const result = await service.apply();
|
await assert.rejects(
|
||||||
assert.equal(result.confirmed, true);
|
service.apply(),
|
||||||
assert.ok(capturedArgs.includes("-StatusPath"));
|
(error) => error.code === "SIGNED_SOURCE_UPDATE_REQUIRED",
|
||||||
assert.ok(capturedArgs.includes("-UpdateId"));
|
);
|
||||||
|
assert.equal(capturedArgs, null);
|
||||||
await rm(temp, { recursive: true, force: true });
|
await rm(temp, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -268,6 +269,15 @@ test("PowerShell update helper starts with param and has no BOM or stray leading
|
|||||||
assert.match(text, /npm ci --no-audit --no-fund/);
|
assert.match(text, /npm ci --no-audit --no-fund/);
|
||||||
assert.match(text, /package-lock\.json/);
|
assert.match(text, /package-lock\.json/);
|
||||||
assert.doesNotMatch(text, /Get-Command npm\.cmd/);
|
assert.doesNotMatch(text, /Get-Command npm\.cmd/);
|
||||||
|
assert.match(text, /Integrated source update refuses to overwrite a Git working tree/);
|
||||||
|
assert.ok(
|
||||||
|
text.indexOf("Handshake-only verification completed successfully") <
|
||||||
|
text.indexOf("Integrated source update refuses to overwrite a Git working tree"),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
text.indexOf("$actualHash = Get-Sha256") <
|
||||||
|
text.indexOf("Source update preflight passed"),
|
||||||
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
text.indexOf('Write-UpdateState -State "success"') <
|
text.indexOf('Write-UpdateState -State "success"') <
|
||||||
text.indexOf("Start-ForgeFlow -WorkingDirectory $SourcePath"),
|
text.indexOf("Start-ForgeFlow -WorkingDirectory $SourcePath"),
|
||||||
@@ -701,11 +711,12 @@ test("release manifest verification rejects a different publisher key", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("Windows release pipeline emits signed provenance, manifest and SBOM evidence", async () => {
|
test("Windows release pipeline emits signed provenance, manifest and SBOM evidence", async () => {
|
||||||
const [pkgSource, signatureSource, checksumSource, manifestSigner] = await Promise.all([
|
const [pkgSource, signatureSource, checksumSource, manifestSigner, releaseWorkflow] = await Promise.all([
|
||||||
readFile(new URL("../package.json", import.meta.url), "utf8"),
|
readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../scripts/verify-release-signatures.mjs", import.meta.url), "utf8"),
|
readFile(new URL("../scripts/verify-release-signatures.mjs", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../scripts/write-release-checksums.mjs", import.meta.url), "utf8"),
|
readFile(new URL("../scripts/write-release-checksums.mjs", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../scripts/sign-release-manifest.mjs", import.meta.url), "utf8"),
|
readFile(new URL("../scripts/sign-release-manifest.mjs", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../.gitea/workflows/release.yml", import.meta.url), "utf8"),
|
||||||
]);
|
]);
|
||||||
assert.match(pkgSource, /verify-release-signatures\.mjs/);
|
assert.match(pkgSource, /verify-release-signatures\.mjs/);
|
||||||
assert.match(signatureSource, /FORGEFLOW_SIGNED_RELEASE/);
|
assert.match(signatureSource, /FORGEFLOW_SIGNED_RELEASE/);
|
||||||
@@ -720,6 +731,16 @@ test("Windows release pipeline emits signed provenance, manifest and SBOM eviden
|
|||||||
assert.match(manifestSigner, /Ed25519/);
|
assert.match(manifestSigner, /Ed25519/);
|
||||||
assert.match(manifestSigner, /release-manifest\.json/);
|
assert.match(manifestSigner, /release-manifest\.json/);
|
||||||
assert.match(pkgSource, /sign-release-manifest\.mjs/);
|
assert.match(pkgSource, /sign-release-manifest\.mjs/);
|
||||||
|
assert.doesNotMatch(releaseWorkflow, /checkout@v\d|setup-node@v\d/);
|
||||||
|
assert.match(releaseWorkflow, /checkout@[a-f0-9]{40}/);
|
||||||
|
assert.match(releaseWorkflow, /setup-node@[a-f0-9]{40}/);
|
||||||
|
assert.ok(
|
||||||
|
releaseWorkflow.indexOf("Validate version bump and build release artifacts") <
|
||||||
|
releaseWorkflow.indexOf("FORGEFLOW_RELEASE_SIGNING_KEY_PEM"),
|
||||||
|
"signing secrets must not be present during dependency installation and quality checks",
|
||||||
|
);
|
||||||
|
assert.match(releaseWorkflow, /finally \{/);
|
||||||
|
assert.match(releaseWorkflow, /Remove-Item -LiteralPath \$privateKeyPath -Force/);
|
||||||
const publisher = await readFile(new URL("../scripts/publish-binary-release.cjs", import.meta.url), "utf8");
|
const publisher = await readFile(new URL("../scripts/publish-binary-release.cjs", import.meta.url), "utf8");
|
||||||
assert.match(publisher, /draft: true/);
|
assert.match(publisher, /draft: true/);
|
||||||
assert.match(publisher, /requiredAssets/);
|
assert.match(publisher, /requiredAssets/);
|
||||||
@@ -764,4 +785,15 @@ test("binary update helper verifies, waits, applies and records restart state",
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
assert.doesNotMatch(helper, /Get-FileHash/);
|
assert.doesNotMatch(helper, /Get-FileHash/);
|
||||||
|
assert.match(helper, /function Start-ForgeFlowAndVerify/);
|
||||||
|
assert.match(helper, /Start-Sleep -Milliseconds 1500/);
|
||||||
|
assert.match(helper, /Updated portable executable failed its restart probe/);
|
||||||
|
assert.ok(
|
||||||
|
helper.indexOf("$actualSha256 = Get-Sha256") <
|
||||||
|
helper.indexOf("Binary preflight passed"),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
helper.indexOf("Binary preflight passed") <
|
||||||
|
helper.indexOf('Write-UpdateState -State "waiting-for-exit"'),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user