87 Commits
Author SHA1 Message Date
Jens 941f2d9aa0 Merge pull request 'ci: route validation to available runners' (#12) from codex/runner-fast-path-20260903 into main
Fix ForgeFlow runner label mismatch. [skip ci]
2026-09-03 18:58:57 +00:00
Jens 57914f1c79 ci: target the hardened native Windows runner
Managed validation / full (pull_request) Successful in 23s
ChatGPT validation / quality (push) Successful in 5m30s
2026-09-03 18:47:56 +00:00
Jens 3dd301a3cb ci: add lightweight validation fast path
ChatGPT validation / quality (push) Successful in 12s
2026-09-03 00:28:43 +00:00
Jens e2293f08d9 Merge pull request 'hygiene: prepare public release' (#11) from chore/public-release-hygiene-20260902 into main
ForgeFlow quality gate / quality (push) Successful in 9m3s
ForgeFlow quality gate / secret-scan (push) Successful in 18s
ChatGPT validation / quality (push) Successful in 5m42s
2026-09-02 21:44:29 +00:00
NuklearRabbit dec3b79793 hygiene: prepare ForgeFlow for public release
Managed validation / full (pull_request) Successful in 27s
2026-09-02 23:37:30 +02:00
Jens 0a8a10df1b Merge pull request 'Prepare ForgeFlow for public release' (#10) from codex/public-readiness-forgeflow into main
ForgeFlow quality gate / secret-scan (push) Successful in 16s
ForgeFlow quality gate / quality (push) Successful in 5m43s
ForgeFlow signed release / release (push) Failing after 15s
2026-08-31 20:17:56 +02:00
NuklearRabbit b5d615d53d Avoid Electron initialization in plain Node tests
Managed validation / full (pull_request) Successful in 27s
ChatGPT validation / quality (push) Successful in 4m57s
2026-08-31 20:15:31 +02:00
NuklearRabbit 8cca1bfc01 Prepare ForgeFlow for public release
Managed validation / full (pull_request) Successful in 44s
ChatGPT validation / quality (push) Failing after 2m28s
2026-08-31 20:10:07 +02:00
NuklearRabbit 57929ea973 Merge remote-tracking branch 'origin/codex/portfolio-integration-fabric-20260826' 2026-08-31 07:52:36 +02:00
NuklearRabbit 5ddb6fe8f5 Merge remote-tracking branch 'origin/chatgpt/repo-hygiene-forgeflow'
# Conflicts:
#	SECURITY.md
2026-08-31 07:52:31 +02:00
NuklearRabbit 20290e65c8 Merge remote-tracking branch 'origin/chatgpt/release-hygiene-20260830' 2026-08-31 07:52:06 +02:00
NuklearRabbit e6e4f2ecf5 Merge branch 'codex/fix-release-0.10.15-metadata' 2026-08-31 07:52:01 +02:00
Jens 6fd69a2cd8 Add contribution and release hygiene guidance
Managed validation / full (pull_request) Successful in 25s
2026-08-31 01:40:57 +02:00
Jens 976a1fc0df Add top-level security entry point 2026-08-31 01:40:49 +02:00
Jens 0bbfbbad51 Harden local secret and workspace ignores 2026-08-31 01:40:41 +02:00
Jens b486285027 docs: add top-level security entry point 2026-08-30 23:17:57 +02:00
Jens 060171d714 docs: align readme with ForgeFlow 0.10.15 2026-08-30 23:17:50 +02:00
Jens b454bafec3 ci: avoid corrupt GitHub action cache on release runner 2026-08-30 23:17:17 +02:00
Jens e62a1d8c1b ci: use Gitea action mirrors on native runner 2026-08-30 23:16:59 +02:00
NuklearRabbit 0191af2ed8 fix(release): align 0.10.15 package metadata 2026-08-30 01:01:20 +02:00
Jens b883c1ad83 Release ForgeFlow 0.10.15 (#7)
ForgeFlow quality gate / quality (push) Failing after 5s
ForgeFlow signed release / release (push) Failing after 6s
ForgeFlow quality gate / secret-scan (push) Successful in 7s
2026-08-30 00:49:45 +02:00
Jens a93231d69f Harden workspace sync quarantine and updater recovery (#6)
ForgeFlow quality gate / secret-scan (push) Successful in 8s
ForgeFlow quality gate / quality (push) Failing after 11m39s
2026-08-30 00:34:02 +02:00
Jens d926007dae Merge pull request 'fix(ci): normalize shell validation input (fixes #3)' (#5) from codex/resolve-open-issues into main
ForgeFlow quality gate / quality (push) Successful in 8m40s
ForgeFlow quality gate / secret-scan (push) Successful in 5s
Reviewed-on: #5
2026-08-29 04:10:28 +02:00
NuklearRabbit 4b4718d231 fix(ci): preserve workflow line endings
Managed validation / full (pull_request) Successful in 36s
2026-08-29 03:36:34 +02:00
NuklearRabbit 0b8deed1e3 fix(ci): use one required pull-request gate
Managed validation / full (pull_request) Canceled after 0s
2026-08-29 03:35:32 +02:00
NuklearRabbit 408dea0c2d fix(ci): normalize shell validation input (fixes #3)
ForgeFlow quality gate / quality (pull_request) Successful in 7m26s
Managed validation / full (pull_request) Canceled after 0s
ForgeFlow quality gate / secret-scan (pull_request) Successful in 6s
2026-08-29 02:30:19 +02:00
Jens 79dc6d367b Fix ForgeFlow Windows quality reliability (#4)
ForgeFlow quality gate / quality (push) Successful in 6m28s
ForgeFlow quality gate / secret-scan (push) Successful in 6s
2026-08-29 01:41:46 +02:00
Jens 49f43b3875 ci: run browser quality on native Windows (#2)
ForgeFlow quality gate / quality (push) Failing after 2m35s
ForgeFlow quality gate / secret-scan (push) Successful in 19s
2026-08-27 20:27:52 +02:00
Jens 736944bd91 ci: align managed validation contract [skip ci] 2026-08-27 07:40:29 +02:00
Jens 42ccfc781c ci: add managed validation contract [skip ci] 2026-08-27 06:27:39 +02:00
Jens 2abfca7abc [skip ci] Stop unschedulable Windows Actions jobs 2026-08-27 05:21:42 +02:00
NuklearRabbit e882656e85 feat: add contextual help and stabilize repository layout
ForgeFlow quality gate / secret-scan (push) Failing after 28s
ForgeFlow quality gate / quality (push) Failing after 0s
2026-08-27 01:19:36 +02:00
NuklearRabbit d47c7b5e41 feat: add safe Gitea sync and signed updates
ForgeFlow quality gate / secret-scan (push) Failing after 32s
ForgeFlow quality gate / quality (push) Failing after 0s
2026-08-27 00:38:58 +02:00
Jens ff1fcd3303 test(deploy): lock one-shot approved request semantics
ChatGPT validation / quality (push) Failing after 1s
2026-08-26 23:56:35 +02:00
Jens e377889263 fix(deploy): consume signed approval evidence exactly once
ChatGPT validation / quality (push) Failing after 0s
2026-08-26 23:55:49 +02:00
Jens c5cf384f9a test(deploy): prove central workflow carries signed target
ChatGPT validation / quality (push) Failing after 0s
2026-08-26 23:47:20 +02:00
Jens 84ed89bccf fix(deploy): centralize approved workflow dispatch
ChatGPT validation / quality (push) Failing after 0s
2026-08-26 23:46:49 +02:00
Jens 2174b79544 ci: run managed validation on codex change branches
ChatGPT validation / quality (push) Canceled after 0s
2026-08-26 23:26:16 +02:00
Jens 1dc3bea8dd ci: add managed exact-head validation gate 2026-08-26 23:25:51 +02:00
Jens 858b09afeb docs(deploy): include signed approval evidence in status example 2026-08-26 23:23:40 +02:00
Jens b5b6660fdc test(deploy): lock signed AppOps evidence contract 2026-08-26 23:21:44 +02:00
Jens d1f4cb6ba8 feat(deploy): verify AppOps Ed25519 evidence before machine deploy 2026-08-26 23:20:26 +02:00
Jens 181330b78f feat(deploy): add signed AppOps-approved workflow 2026-08-26 23:19:45 +02:00
NuklearRabbitandClaude Opus 5 cb9bdcd713 perf: reuse SSH connections per server, with a retry rule that never repeats work
Every ssh.exec opened its own connection: a TCP handshake, a key exchange and an
authentication round trip per command. A key rotation paid for that eight times,
a deployment six, and refreshing M profile states M times.

Connections are now kept per server. The three risks that made this worth doing
carefully are handled explicitly:

- Staleness. A pooled connection can be dead exactly when it matters. Liveness is
  tracked through error, close and end, and a lease that finds a dead entry opens
  a new one. The remaining race, where the connection dies between the check and
  the command, is caught by the retry rule below.
- Retrying. Only a failure that proves the command never reached the server is
  retried, and only once, and only on a connection that was already established
  before this call. execClient marks exactly that case, when the channel fails to
  open. A command that opened a stream is never repeated, because the server may
  already be acting on it - repeating a deployment is not this layer's decision.
  Two tests hold that line: widening the rule to any failure fails both.
- Lifetime. Idle connections close after a minute, the pool is reference counted
  so a shared connection survives until its last user is done, closeAll runs
  during quit, and every pooled client keeps a standing error listener so an
  error while idle cannot reach the uncaughtException handler.

A trust-on-first-use connection is never pooled: it was established without
verifying the fingerprint, so it must not serve a later verified call. A change
to host, port, user, auth type, key path or trusted fingerprint invalidates the
pooled connection.

ssh-service coverage rises from 61% to 90% of lines and 97% of functions.

Also in this commit, the smaller items from the same review:

- Diagnostics batched records that queue up while a write is in flight into one
  append, and chmod runs once per file instead of once per record. At the debug
  level every IPC call writes a line, which is exactly when troubleshooting.
- The set that suppresses duplicate deployment notifications is trimmed instead
  of growing for the lifetime of the process.
- The updater kept the same once('error') pattern on its spawned helper that
  took the app down through the SSH client.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:06:38 +02:00
NuklearRabbitandClaude Opus 5 beeafdcba7 perf: reuse a deploy-key proof instead of asking the server twice
A key rotation verified the candidate with `git ls-remote`, then immediately ran
preflightCandidate, which threw that result away and ran the same command over a
second SSH connection. Nothing happens between the two calls that could change
the answer, and the proof was already being passed in.

preflightCandidate now uses a proof that established a remote commit and falls
back to verifying when it is handed nothing usable, so it still works as a
standalone gate. Every ssh.exec opens its own connection, so this removes a full
TCP, key exchange and authentication round trip from a rotation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:53:53 +02:00
NuklearRabbitandClaude Opus 5 d77643c058 refactor(renderer): move diff rendering into its own module
views.js sat at the project's 750-line limit, so the diff cap in the previous
commit pushed it over and every further change would have meant shaving
comments elsewhere. That is the file asking for decomposition, which is what the
architecture audit says to do.

Diff rendering is self-contained: the line cap, the line classifier and the
change-map illustration depend on nothing in views.js beyond ui and escapeHtml.
They now live in src/renderer/diff-view.js and are registered in index.html and
in the three renderer file lists that scan the bridge surface, so anything added
there is covered by the existing contract tests.

views.js drops from 755 to 714 lines and no source file exceeds 750 again. The
nested ternary that classified a diff line became a named function with guard
clauses on the way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:51:31 +02:00
NuklearRabbitandClaude Opus 5 5cecaa080d fix: stop a large diff and a second SSH error from taking the app down
Two failure modes that only show up under conditions the tests never reached.

renderDiff built one span per diff line with no bound. A regenerated lock file
is an ordinary change: 50,000 lines produce 4 MB of markup and 50,000 elements
that then have to be parsed and laid out inside the full shell replacement, and
200,000 lines produce 16 MB. The rendered view now stops at 2,000 lines and says
how many were left out; ui.diff keeps the whole change, so Copy diff, the editor
and hunk staging are unaffected. The line scan also runs once now instead of
three times.

withClient registered the connection error handler with once(). A connection
that fails and then emits a second error while it is being torn down - a reset
during client.end() is the ordinary case - leaves that event unhandled, and an
unhandled 'error' on an EventEmitter reaches the uncaughtException handler,
which calls app.exit(1). The handler stays attached and ignores anything after
the first failure.

Both are covered by tests that were confirmed to fail without the fix, together
with the SSH paths that had none: host key mismatch reporting, the trusted
fingerprint requirement for exec and upload, and remote upload path validation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:44:33 +02:00
NuklearRabbitandClaude Opus 5 a5666e95f2 test: cover the deployment and deploy-key paths, and gate coverage per module
deployment-service.cjs sat at 52% and unraid-deploy-key-host.cjs at 39% of its
functions, both hidden behind a healthy aggregate. They are now at 100% lines
and functions, tested through real HTTP endpoints and by intercepting the shell
script the key host sends, rather than by mocking the boundary away.

What is pinned down: a successful workflow run still fails when the server
cannot prove it runs that exact commit; a rollback ends as rolled-back rather
than success; an unreachable status endpoint is never treated as healthy; a
failed poll is recorded on the operation instead of losing it; deploy keys stay
repository-scoped under the server base path with a pinned host key; promotion
verifies the candidate before swapping atomically; and revocation moves key
material to recovery instead of deleting it.

Two assumptions turned out to be wrong and the tests follow the real behaviour:
the previous-SHA check runs before the already-live check, and a rollback
against an unreachable endpoint surfaces the underlying network error.

Covering clone-target exposed a real defect: a remote ending in "....git"
yielded the folder name "...". Windows strips trailing dots, so that resolves
back to the project root itself, past an escape guard that only looks for "..".
A dots-only name now falls back to "repository", consistent with how an empty
name was already handled. As a side effect "." and ".." resolve to a usable
folder instead of raising an error.

Coverage gates: the aggregate moves to 85/85/68, and a new per-module gate
(60 statements, 50 functions, 36 branches) stops a single module from silently
collapsing behind the total. It reuses the data from the first run, so the
suite is not executed twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:32:12 +02:00
NuklearRabbitandClaude Opus 5 9260d35957 fix: repair broken IPC wiring and cut the cost of repository polling
Three handlers referenced a dependency they were never given, which made them
throw a ReferenceError as soon as they ran:

- deployment:preflight for Gitea Actions profiles (`preflight` was passed to
  registerOperationsIpc but not to registerDeploymentIpc)
- Unraid write-access repair (`safeRelativeRemoteFile` was missing from
  createUnraidAccessMethods)
- a dead reference of the same name in unraid-state-methods

no-undef and no-unused-vars were disabled for every file, which is why none of
these were caught. Both are now enabled for src/main and src/shared, where the
dependency graph is explicit. The renderer keeps them off because its functions
are deliberately cross-script globals.

Performance:

- git.status() spawned three processes (rev-parse, status, remote get-url) per
  call. A directory holding its own .git is by definition the work tree root, so
  rev-parse is unnecessary, and the remote URL is cached against the mtime of
  .git/config, including the failure for a repository without that remote.
- git status runs with --no-optional-locks so a read no longer rewrites the
  index. That stops it fighting a concurrent Git command for the index lock, and
  is what makes filesystem watching viable at all.
- One commit issued four `git status` reads; callers that already hold the
  status now pass it on, leaving two.
- The repository monitor is event driven. A watched repository is read on
  filesystem activity, with a 30s safety net for watchers that stop delivering
  and a 1s floor so a busy tree cannot drive a read per event. Repositories that
  cannot be watched keep using the interval. Idle cost for one repository over
  35s: 24 git processes before, 3 after.
- Resolving one repository by name no longer refreshes the whole workspace.
- Concurrent configuration saves share a single write of the latest state.
- Repository discovery follows directory junctions again. The filter that
  skipped them made the realpath cycle guard dead code, and hid any project
  folder reached through a junction.

Renderer:

- render() replaced the whole shell on every poll, discarding focus, caret and
  scroll position while the user was typing. Those are preserved now, and an
  unchanged render leaves the DOM alone entirely.
- The four sections that enhanceRenderedUi() injected after render moved into
  the views, so the rendered markup is the single source of truth.
- The monitor no longer keeps a repository paused forever when it is unlinked
  mid-mutation, scheduleAutoRefresh honours its delay argument, the demo bridges
  no longer block startup, and #app is no longer an aria-live region announcing
  the entire UI on every render.

IPC channel plumbing moved to src/main/ipc/channel.cjs, replacing a module-level
mutable diagnostics singleton with an argument.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:31:58 +02:00
NuklearRabbit cf1da8a2fa fix(security): add standalone secret-scan job to CI
Had a real dependency-audit gate (npm audit --audit-level=high) but no
secret scan. trufflehog's Action is Docker-based and cannot run on this
repo's existing windows-latest runner (needed for the Windows desktop
app's own browser/quality tests), so this adds it as a separate,
parallel ubuntu-latest job rather than inserting it into the Windows
job where it would break CI - the last gap for this repo to count as
fully-authored.
2026-08-16 14:56:13 +02:00
NuklearRabbit 32ed4fcb5e perf: streamline repository and deployment awareness
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-12 15:26:53 +02:00
NuklearRabbit 38e221cbd1 fix: harden repository refresh and server pull
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-12 15:05:57 +02:00
NuklearRabbit bffad670ef fix: complete server pull deployment setup
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-09 00:43:08 +02:00
NuklearRabbit 4c21616e72 fix: harden deployment discovery and preflight
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-08 23:59:27 +02:00
NuklearRabbit f866b12fbf fix: make updater checksum verification self-contained
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-01 19:13:05 +02:00
NuklearRabbit f7d6bc374f fix: reconcile server deployments across repository views
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-01 18:57:55 +02:00
NuklearRabbit 958d5b84d3 fix: make Windows updater helper launch reliable
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-01 17:59:55 +02:00
NuklearRabbit 8fa4891075 chore: release ForgeFlow 0.10.5
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-01 17:32:40 +02:00
NuklearRabbit 58d361bbab fix: align deployment links across repository views
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-01 17:05:03 +02:00
NuklearRabbit acad1f8932 fix: make packaged updater handshake Windows-safe
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-01 15:09:47 +02:00
NuklearRabbit 44aa452a76 fix: tolerate unavailable unsigned signature inspection
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-01 14:35:59 +02:00
NuklearRabbit a0435f4316 chore: release ForgeFlow 0.10.3
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-01 14:33:08 +02:00
NuklearRabbit 13f4fe7cd0 perf: reduce renderer and repository polling work
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-01 14:24:53 +02:00
NuklearRabbit 258f0b1324 fix: restore scrolling and validator enforcement
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-01 12:44:31 +02:00
NuklearRabbit f8c505e525 docs: refresh user guide and screenshots
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-07-30 02:02:18 +02:00
NuklearRabbit 398f986d95 fix: repair packaged release downloads
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-07-30 00:58:34 +02:00
NuklearRabbit 60e8aa8fc2 release: prepare ForgeFlow 0.10.1
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-07-30 00:49:58 +02:00
NuklearRabbit 6ef4620388 fix: make packaged updates certificate-free
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-07-29 23:11:24 +02:00
NuklearRabbit 18f42621c2 feat: harden release signing and coverage gate
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-07-29 22:54:51 +02:00
NuklearRabbit aa4895912a docs: record production readiness evidence
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-07-29 20:06:14 +02:00
NuklearRabbit 347f7132b9 test: prove Authenticode release chain 2026-07-29 19:58:35 +02:00
NuklearRabbit e5599f7a5d style: improve deployment card readability 2026-07-29 19:48:11 +02:00
NuklearRabbit c826561c77 test: strengthen safety-critical coverage 2026-07-29 19:40:27 +02:00
NuklearRabbit 0ed202ec95 chore: reduce and document dependency risk 2026-07-29 19:19:20 +02:00
NuklearRabbit dcbd461a92 test: add isolated production acceptance harness 2026-07-29 18:55:44 +02:00
NuklearRabbit 1e86afb7d9 test: add browser acceptance matrix 2026-07-29 18:49:08 +02:00
NuklearRabbit a0733875c4 feat: deliver policy-driven Git Validator 2.0 2026-07-29 18:24:27 +02:00
NuklearRabbit 7b05c953b6 refactor: split renderer ipc and unraid domains 2026-07-29 18:11:07 +02:00
NuklearRabbit 5d3731a853 feat: normalize deployment inventory evidence 2026-07-29 17:39:22 +02:00
NuklearRabbit 6b93391a9b feat: add transactional deploy key lifecycle 2026-07-29 17:20:31 +02:00
NuklearRabbit 64ca267384 fix: pass signature targets without shell interpolation
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-07-29 16:36:46 +02:00
NuklearRabbit 4555023f87 docs: align release and recovery guidance 2026-07-29 16:31:24 +02:00
NuklearRabbit 4708367b66 feat: add fail-closed signed release provenance 2026-07-29 16:30:02 +02:00
NuklearRabbit 8e580a0b13 ci: enforce lint coverage and Node LTS quality gates 2026-07-29 16:27:16 +02:00
NuklearRabbit a7ab2d6cc8 fix: harden responsive accessibility contracts 2026-07-29 16:22:46 +02:00
NuklearRabbit ae6c41ff90 feat: verify server pull access before deployment 2026-07-29 16:17:54 +02:00
NuklearRabbit 430d40354b fix: make server discovery and audits read-only 2026-07-29 16:09:39 +02:00
162 changed files with 21037 additions and 8525 deletions
+3
View File
@@ -0,0 +1,3 @@
*.sh text eol=lf
examples/server/forgeflow-deploy text eol=lf
scripts/* text eol=lf
+29
View File
@@ -0,0 +1,29 @@
name: ChatGPT validation
on:
push:
branches:
- 'codex/**'
workflow_dispatch:
jobs:
quality:
runs-on: windows-native
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run quality
- run: npx playwright install --with-deps chromium
- run: npm run test:browser:ci
- name: Preserve browser failure evidence
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: forgeflow-browser-failure-evidence
path: artifacts/
if-no-files-found: ignore
- run: npm audit --omit=dev --audit-level=high
+147
View File
@@ -0,0 +1,147 @@
name: Managed validation
on:
pull_request:
workflow_dispatch:
inputs:
profile:
description: Allowlisted validation profile
required: true
default: full
type: choice
options: [test, lint, typecheck, build, security, full]
permissions:
contents: read
concurrency:
group: managed-validation-${{ gitea.repository }}-${{ gitea.ref }}
cancel-in-progress: true
jobs:
full:
name: full
# Public fork code must never execute automatically on the private runner.
if: ${{ gitea.event_name != 'pull_request' || gitea.event.pull_request.head.repo.full_name == gitea.repository }}
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Validate repository with a bounded profile
shell: bash
env:
REQUESTED_PROFILE: ${{ inputs.profile }}
run: |
set -euo pipefail
profile="${REQUESTED_PROFILE:-full}"
case "${profile}" in
test|lint|typecheck|build|security|full) ;;
*) echo "Profile is not allowlisted" >&2; exit 2 ;;
esac
git diff --check
if git grep -nE '^(<<<<<<< |=======$|>>>>>>> )' -- . ':!*.lock' ':!*.patch'; then
echo "Unresolved merge markers detected" >&2
exit 1
fi
# MANAGED_FAST_PATH: documentation and this baseline workflow cannot
# affect the shipped runtime. Keep the required status check, but do
# not install toolchains or execute the full product suite.
if [[ -n "${GITHUB_BASE_REF:-}" ]]; then
git fetch --no-tags --depth=1 origin "${GITHUB_BASE_REF}"
managed_base="origin/${GITHUB_BASE_REF}"
git diff --check "${managed_base}..HEAD"
mapfile -t managed_changed_files < <(
git diff --name-only --diff-filter=ACMR "${managed_base}..HEAD"
)
managed_runtime_change=0
for managed_path in "${managed_changed_files[@]}"; do
case "${managed_path}" in
*.md|*.mdx|docs/*|.github/ISSUE_TEMPLATE/*|.gitea/ISSUE_TEMPLATE/*|.gitea/runner-scope.sh|.gitea/workflows/managed-validation.yml)
;;
*)
managed_runtime_change=1
break
;;
esac
done
if [[ "${#managed_changed_files[@]}" -gt 0 && "${managed_runtime_change}" -eq 0 ]]; then
printf 'Managed validation fast path: %s non-runtime file(s); full product suite skipped.\n' \
"${#managed_changed_files[@]}"
exit 0
fi
fi
if [[ -f pyproject.toml || -f requirements.txt ]]; then
# Compile only tracked Python sources. Running compileall after a
# Node install would otherwise traverse node_modules and turn a
# lightweight baseline into a large runner workload.
git ls-files -z '*.py' | xargs -0 -r python -m py_compile
if [[ -f uv.lock ]]; then
python -m venv "${RUNNER_TEMP}/managed-uv"
uv_python="${RUNNER_TEMP}/managed-uv/bin/python"
"${uv_python}" -m pip install --disable-pip-version-check uv==0.10.0
managed_uv="${RUNNER_TEMP}/managed-uv/bin/uv"
export UV_PROJECT_ENVIRONMENT="${RUNNER_TEMP}/managed-project-venv"
"${managed_uv}" sync --locked
export PATH="${UV_PROJECT_ENVIRONMENT}/bin:${PATH}"
if [[ "${profile}" == test || "${profile}" == full ]]; then
if "${managed_uv}" run python -c 'import pytest' 2>/dev/null; then
"${managed_uv}" run python -m pytest
fi
fi
if [[ "${profile}" == lint || "${profile}" == full ]]; then
if "${managed_uv}" run python -c 'import ruff' 2>/dev/null; then
"${managed_uv}" run python -m ruff check .
fi
fi
elif [[ -f requirements.txt ]]; then
python -m venv "${RUNNER_TEMP}/managed-python"
managed_python="${RUNNER_TEMP}/managed-python/bin/python"
"${managed_python}" -m pip install --disable-pip-version-check -r requirements.txt
export PATH="${RUNNER_TEMP}/managed-python/bin:${PATH}"
if [[ "${profile}" == test || "${profile}" == full ]]; then
if "${managed_python}" -c 'import pytest' 2>/dev/null; then
"${managed_python}" -m pytest
fi
fi
fi
fi
# Prepare Python before invoking Node scripts. Polyglot repositories
# commonly delegate their test script to Python and need the managed
# virtual environment to be active first.
if [[ -f package.json ]]; then
corepack enable
if [[ -f pnpm-lock.yaml ]]; then
pnpm install --frozen-lockfile
[[ "${profile}" == test || "${profile}" == full ]] && pnpm --if-present test
[[ "${profile}" == lint || "${profile}" == full ]] && pnpm --if-present lint
[[ "${profile}" == typecheck || "${profile}" == full ]] && pnpm --if-present typecheck
[[ "${profile}" == build || "${profile}" == full ]] && pnpm --if-present build
elif [[ -f package-lock.json ]]; then
npm ci
[[ "${profile}" == test || "${profile}" == full ]] && npm run --if-present test
[[ "${profile}" == lint || "${profile}" == full ]] && npm run --if-present lint
if [[ "${profile}" == typecheck || "${profile}" == full ]]; then
npm run --if-present typecheck
fi
[[ "${profile}" == build || "${profile}" == full ]] && npm run --if-present build
fi
fi
if [[ -f go.mod ]]; then
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
go test ./...
fi
fi
if [[ -f Cargo.toml ]]; then
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
cargo test --locked
fi
fi
if compgen -G '*.sln' >/dev/null; then
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
dotnet test --configuration Release
fi
fi
+46
View File
@@ -0,0 +1,46 @@
name: ForgeFlow quality gate
on:
push:
branches: [main]
workflow_dispatch:
jobs:
secret-scan:
runs-on: ubuntu-latest
steps:
- uses: https://gitea.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Secret scan
shell: bash
run: |
set -euo pipefail
scan_container="$(docker create ghcr.io/trufflesecurity/trufflehog:3.79.0 filesystem /scan --only-verified --fail --no-update)"
trap 'docker rm -f "${scan_container}" >/dev/null 2>&1 || true' EXIT
tar --exclude=.git --transform='s#^\.$#scan#;s#^\./#scan/#' -cf - . | docker cp - "${scan_container}:/"
docker start -a "${scan_container}"
quality:
# Browser quality runs against the dedicated bounded Windows 11 VM runner.
runs-on: windows-native
steps:
- uses: https://gitea.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: https://gitea.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
cache: npm
- run: npm ci
# The native runner deliberately skips Electron's install-time binary
# download. Prime it once before Node's parallel test workers require
# Electron, otherwise they can race while creating the same directory.
- run: npx electron --version
- run: npm run quality
- run: npx playwright install chromium
- run: npm run test:browser:ci
- name: Preserve browser failure evidence
if: failure()
uses: https://gitea.com/actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: forgeflow-browser-failure-evidence
path: artifacts/
if-no-files-found: ignore
- run: npm audit --omit=dev --audit-level=high
+108
View File
@@ -0,0 +1,108 @@
name: ForgeFlow signed release
on:
push:
branches: [main]
paths:
- package.json
workflow_dispatch:
permissions:
code: read
releases: write
jobs:
release:
runs-on: windows-native
steps:
- uses: https://gitea.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 2
- uses: https://gitea.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
cache: npm
- name: Validate version bump and build release artifacts
shell: powershell
env:
GITEA_EVENT_NAME: ${{ gitea.event_name }}
run: |
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$manifest = Get-Content -LiteralPath "package.json" -Raw | ConvertFrom-Json
$version = [string]$manifest.version
$previousVersion = ""
try {
$previousJson = (& git show "HEAD^:package.json" 2>$null | Out-String)
if ($LASTEXITCODE -eq 0 -and $previousJson.Trim()) {
$previousVersion = [string](ConvertFrom-Json $previousJson).version
}
} catch {
$previousVersion = ""
}
if ($env:GITEA_EVENT_NAME -eq "push" -and $previousVersion -eq $version) {
Write-Host "package.json changed without a version bump ($version); no release will be published."
exit 0
}
if ($version -notmatch '^\d+\.\d+\.\d+$') {
throw "ForgeFlow version '$version' is not a stable semantic version."
}
& cmd.exe /d /s /c "npm ci --no-audit --no-fund"
if ($LASTEXITCODE -ne 0) { throw "npm ci failed." }
& cmd.exe /d /s /c "npx electron --version"
if ($LASTEXITCODE -ne 0) { throw "Electron preflight failed." }
& cmd.exe /d /s /c "npm run quality"
if ($LASTEXITCODE -ne 0) { throw "ForgeFlow release quality gate failed." }
& cmd.exe /d /s /c "npx playwright install chromium"
if ($LASTEXITCODE -ne 0) { throw "Playwright Chromium installation failed." }
& cmd.exe /d /s /c "npm run test:browser:ci"
if ($LASTEXITCODE -ne 0) { throw "ForgeFlow browser acceptance suite failed." }
& cmd.exe /d /s /c "npm audit --omit=dev --audit-level=high"
if ($LASTEXITCODE -ne 0) { throw "ForgeFlow production dependency audit failed." }
& cmd.exe /d /s /c "npx electron-builder --win nsis portable"
if ($LASTEXITCODE -ne 0) { throw "ForgeFlow Windows build failed." }
& node scripts/write-release-checksums.mjs
if ($LASTEXITCODE -ne 0) { throw "ForgeFlow checksum generation failed." }
- name: Sign and publish validated artifacts
shell: powershell
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
FORGEFLOW_RELEASE_BASE_URL: ${{ gitea.server_url }}
FORGEFLOW_RELEASE_OWNER: Jens
FORGEFLOW_RELEASE_REPO: ForgeFlow
FORGEFLOW_RELEASE_BRANCH: main
FORGEFLOW_RELEASE_SIGNING_KEY_PEM: ${{ secrets.FORGEFLOW_RELEASE_SIGNING_KEY_PEM }}
run: |
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$privateKeyPath = Join-Path $env:RUNNER_TEMP "forgeflow-release-signing-private.pem"
try {
if (-not $env:FORGEFLOW_RELEASE_SIGNING_KEY_PEM) {
throw "FORGEFLOW_RELEASE_SIGNING_KEY_PEM is not configured."
}
if (-not $env:GITEA_TOKEN) {
throw "GITEA_TOKEN is not configured."
}
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($privateKeyPath, $env:FORGEFLOW_RELEASE_SIGNING_KEY_PEM, $utf8NoBom)
$env:FORGEFLOW_UPDATE_SIGNING_PRIVATE_KEY = $privateKeyPath
& node scripts/sign-release-manifest.mjs
if ($LASTEXITCODE -ne 0) { throw "ForgeFlow manifest signing failed." }
& node scripts/verify-release-signatures.mjs
if ($LASTEXITCODE -ne 0) { throw "ForgeFlow release signature verification failed." }
& node scripts/prune-dist.mjs
if ($LASTEXITCODE -ne 0) { throw "ForgeFlow artifact pruning failed." }
& .\node_modules\.bin\electron.cmd scripts/publish-binary-release.cjs
if ($LASTEXITCODE -ne 0) { throw "ForgeFlow Gitea release publication failed." }
} finally {
$env:FORGEFLOW_UPDATE_SIGNING_PRIVATE_KEY = $null
if (Test-Path -LiteralPath $privateKeyPath) {
Remove-Item -LiteralPath $privateKeyPath -Force
}
}
Write-Host "ForgeFlow artifacts were signed and published from exact main HEAD $env:GITEA_SHA."
+18
View File
@@ -1,6 +1,24 @@
node_modules/
dist/
.DS_Store
Thumbs.db
*.log
coverage/
artifacts/
playwright-report/
.forgeflow/
.playwright-mcp/
.env
.env.*
!.env.example
*.pfx
*.p12
*.key
*.pem
!build/update-signing-public.pem
.codex/
.claude/
.agents/
.dyad/
.idea/
.vs/
+1
View File
@@ -0,0 +1 @@
22
+13
View File
@@ -0,0 +1,13 @@
# Contributing
ForgeFlow changes must preserve exact-commit provenance, update integrity and safe deployment boundaries.
Before opening a pull request:
- do not commit tokens, SSH credentials, signing private keys, deployment secrets or local repository state;
- keep update manifests/checksums/signatures deterministic and reviewable;
- add regression tests for repository synchronization, dirty-file handling, update and deployment changes;
- keep real deployment targets configurable rather than embedding private infrastructure;
- run `npm run quality` and the managed validation workflow where supported.
Release metadata should distinguish an unreleased package version from the latest published Gitea Release; do not advance public release claims until the corresponding release exists.
+5 -1
View File
@@ -75,7 +75,11 @@ try {
"ForgeFlow-Setup-$version-win-x64.exe",
"ForgeFlow-Setup-$version-win-x64.exe.sha256",
"ForgeFlow-Portable-$version-win-x64.exe",
"ForgeFlow-Portable-$version-win-x64.exe.sha256"
"ForgeFlow-Portable-$version-win-x64.exe.sha256",
"ForgeFlow-$version-provenance.json",
"ForgeFlow-$version-sbom.cdx.json",
"ForgeFlow-$version-release-manifest.json",
"ForgeFlow-$version-release-manifest.json.sig"
)
foreach ($assetName in $expectedAssets) {
if (-not (Test-Path -LiteralPath (Join-Path $clone "dist\$assetName"))) {
+5 -1
View File
@@ -73,7 +73,11 @@ try {
"ForgeFlow-Setup-$version-win-x64.exe",
"ForgeFlow-Setup-$version-win-x64.exe.sha256",
"ForgeFlow-Portable-$version-win-x64.exe",
"ForgeFlow-Portable-$version-win-x64.exe.sha256"
"ForgeFlow-Portable-$version-win-x64.exe.sha256",
"ForgeFlow-$version-provenance.json",
"ForgeFlow-$version-sbom.cdx.json",
"ForgeFlow-$version-release-manifest.json",
"ForgeFlow-$version-release-manifest.json.sig"
)
foreach ($assetName in $expectedAssets) {
$assetPath = Join-Path $clone "dist\$assetName"
+26 -11
View File
@@ -2,9 +2,9 @@
**Van lokale wijziging naar aantoonbaar juiste serverversie — zonder de Git- en deploymentcontext over verschillende tools te verspreiden.**
ForgeFlow is een desktopapp voor teams die met Git, Gitea en eigen servers werken. De app toont wat lokaal gewijzigd is, wat al op Gitea staat en welke exacte commit op de server draait. Daarna begeleidt ForgeFlow je door review, commit, push, deployment en verificatie.
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.0** · [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)
![ForgeFlow release-overzicht](docs/screenshots/overview.png)
@@ -12,10 +12,12 @@ ForgeFlow is een desktopapp voor teams die met Git, Gitea en eigen servers werke
- **Eén duidelijke actielijst:** zie meteen welke repository aandacht nodig heeft en waarom.
- **Veilige Git-flow:** review wijzigingen, stage volledige bestanden of afzonderlijke hunks, commit, push en herstel conflicten zonder contextwissel.
- **Veilige Gitea-sync:** bekijk vooraf welke bestanden wijzigen of verdwijnen, bewaar lokale commits in een recovery branch en zet gewijzigde of untracked bestanden in een stash voordat de werkmap exact gelijk wordt gemaakt aan Gitea.
- **Deployment op een exacte commit:** ForgeFlow gebruikt volledige commit-SHA's en toont lokaal, Gitea en server naast elkaar.
- **Volledige serverinventaris:** zie ook gestopte, DockerMan- en niet-Git-installaties, koppel twijfelgevallen handmatig en behoud hun bestaande Compose-identiteit.
- **Automatische serverinventaris:** ForgeFlow herkent draaiende en gestopte Docker-, Compose- en DockerMan-workloads, koppelt alleen op betrouwbaar bewijs en houdt tijdelijke of externe containers apart.
- **Veilige server-pull:** Unraid haalt de exacte commit uit Gitea met een unieke, repository-scoped read-only deploy key en een vastgepinde SSH-hostsleutel.
- **Ingebouwde Git Validator:** controleer repository-identiteit, branch protection, synchronisatie-instellingen, documentatie, geheimen en grote bestanden; veilige verbeteringen kunnen gericht worden toegepast.
- **Doorzoekbaar Helpcentrum:** open **Help** voor stapsgewijze uitleg of spring vanuit workspace sync meteen naar de relevante veiligheidsinstructies.
- **Lokale controle:** configuratie en credentials blijven op het toestel en diagnostische exports worden lokaal geredigeerd.
## Snel starten
@@ -26,9 +28,9 @@ ForgeFlow is een desktopapp voor teams die met Git, Gitea en eigen servers werke
2. Download de Windows-installer of portable executable.
3. Start ForgeFlow en doorloop de setupwizard.
4. Voeg je Gitea-server, token en lokale projectmappen toe.
5. Configureer optioneel een serververbinding en één of meer deploymentprofielen.
5. Voeg optioneel een Docker- of Unraid-server toe. Start daarna **Scan servers** om bestaande deployments te ontdekken en veilig aan repositories te koppelen.
Na installatie kun je nieuwe packaged releases vanuit **Settings → Updates** ophalen. Downloads worden tegen de gepubliceerde SHA-256-checksums gecontroleerd. Zie [UPDATING.md](docs/UPDATING.md) wanneer een oudere of source-only build nog niet binair kan updaten.
Vanuit **Settings → Updates** kun je nieuwe packaged releases ophalen. ForgeFlow accepteert uitsluitend de release die bij de exacte Gitea-commit hoort, controleert de SHA-256-checksum én verifieert vanaf 0.10.13 een onafhankelijk Ed25519-releasemanifest met de ingebouwde publieke sleutel. Zie [UPDATING.md](docs/UPDATING.md) voor oudere of source-only installaties.
### Eerst vrijblijvend bekijken
@@ -39,7 +41,7 @@ npm install
npm run demo
```
Open daarna `http://127.0.0.1:4173`.
Open daarna `http://127.0.0.1:41737`.
## De dagelijkse workflow
@@ -57,9 +59,18 @@ In de repositorywerkruimte zie je de volledige keten **Local → Gitea → Serve
![Deploymentsoverzicht met herkenbare containerkaarten](docs/screenshots/deployments.png)
Elke deploymentkaart benoemt repository, container, omgeving, uitvoeringsmethode, live commit, Gitea-commit, vorige versie en healthstatus. ForgeFlow ondersteunt gecontroleerde deployments via Gitea Actions en SSH/Unraid, met preflightcontrole en rollback waar beschikbaar.
Elke deploymentkaart benoemt repository, container, omgeving, uitvoeringsmethode, live commit, Gitea-commit, vorige versie en healthstatus. Zo blijven ook tientallen containers visueel van elkaar te onderscheiden. ForgeFlow ondersteunt gecontroleerde deployments via Gitea Actions en SSH/Unraid, met preflightcontrole en rollback waar beschikbaar.
Bij server discovery probeert ForgeFlow bestaande containers aan Gitea-repositories te koppelen. Een exacte overeenkomst tussen de volledige live SHA en de actuele Gitea-SHA wordt als gelijklopende versie weergegeven; een runtime-healthcheck blijft een afzonderlijke voorwaarde voor een gezonde deployment.
Bij server discovery vergelijkt ForgeFlow runtime-, Compose-, DockerMan- en Git-bewijs met Gitea. Exact bewezen matches worden automatisch gekoppeld; kandidaten, historische mappen en externe containers worden niet als productie-deployment geforceerd. Een exacte overeenkomst tussen de volledige live SHA en de actuele Gitea-SHA wordt als gelijklopende versie weergegeven. Ontbreekt de live SHA, dan meldt ForgeFlow eerlijk dat verificatie nog onvolledig is.
De belangrijkste statussen zijn:
| Status | Wat je ermee doet |
| --- | --- |
| **Ready** | De repository, servertoegang, live commit en runtime zijn geverifieerd. |
| **Commit mismatch** | De workload is correct gekoppeld, maar Gitea en de server draaien niet dezelfde commit. |
| **Verification incomplete** | De koppeling bestaat, maar de server bevat nog onvoldoende commitbewijs. Een ForgeFlow-beheerde deployment vult dit veilig aan. |
| **Access failed** | Controleer of herstel de repositorygebonden read-only deploy key voordat je deployt. |
### 4. Verbeter de repository met Git Validator
@@ -85,6 +96,7 @@ Een gelijke commit bewijst welke code draait; een geslaagde healthcheck bewijst
- repositories ontdekken, favorieten beheren en ontbrekende lokale clones koppelen;
- status, diff, staging, partial hunks, commit, push, fetch, pull, stash en conflict recovery;
- read-only achtergrondfetch en een expliciete preview om een lokale projectmap veilig exact met de upstream Gitea-branch te synchroniseren;
- branches maken, wisselen, vergelijken en opruimen;
- branch protection controleren en pull requests openen;
- Git Validator met assurance score, bewijs per controle en gerichte veilige fixes.
@@ -94,9 +106,10 @@ Een gelijke commit bewijst welke code draait; een geslaagde healthcheck bewijst
- deploymentprofielen per repository en omgeving;
- Gitea Actions en SSH/Unraid als gecontroleerde uitvoeringsroutes;
- serverinventaris van draaiende en gestopte Docker-, Compose- en DockerMan-workloads;
- automatische koppeling op exact bewijs en een handmatige koppelwizard voor twijfelgevallen;
- automatische koppeling op exact bewijs, expliciete review voor echte twijfelgevallen en herkenning van tijdelijke, historische en externe workloads;
- server-pull als aanbevolen route, met een afzonderlijke read-only deploy key per repository;
- directe checksum-gecontroleerde copy als alternatief zonder servertoegang tot Gitea;
- reconciliatie van deployments die buiten ForgeFlow werden bijgewerkt, op basis van de actuele Gitea- en serverwaarheid;
- verificatie op volledige SHA, runtime health en recente serverwaarheid;
- preflight, live logs, deploymenthistoriek en rollback naar de vorige bekende versie.
@@ -104,7 +117,7 @@ Een gelijke commit bewijst welke code draait; een geslaagde healthcheck bewijst
- credentials versleuteld via de beveiligde opslag van het besturingssysteem;
- origin-checks voorkomen dat een Gitea-token naar een andere host wordt gestuurd;
- updatepakketten worden alleen vanaf de geconfigureerde Gitea-origin gedownload en met checksums geverifieerd;
- updatepakketten worden alleen vanaf de geconfigureerde Gitea-origin gedownload en met checksums plus een vastgepinde Ed25519-publisherhandtekening geverifieerd;
- lokale redactie van tokens, wachtwoorden en gevoelige diagnostische data;
- versleutelde configuratieback-up, herstelvoorbeeld en lokale audittrail;
- packaged builds als Windows-installer en portable executable.
@@ -125,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;
- [DEPLOYMENT_SETUP.md](docs/DEPLOYMENT_SETUP.md) — deploymentprofielen en verificatie;
- [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.
## Ontwikkelen vanuit de broncode
@@ -146,7 +160,8 @@ Handige opdrachten:
| `npm run check` | Voert bronverificatie en de volledige testset uit. |
| `npm run doctor` | Controleert de lokale ontwikkelomgeving. |
| `npm run acceptance` | Voert de release-acceptatiecontroles uit. |
| `npm run dist:win` | Bouwt Windows installer + portable package, schrijft checksums en ruimt oude dist-artifacts op. |
| `npm run signing:setup` | Maakt eenmalig de lokale Ed25519-releasesleutel en schrijft alleen de publieke sleutel naar het project. |
| `npm run dist:win` | Bouwt Windows installer + portable package, schrijft checksums en een ondertekend releasemanifest en ruimt oude dist-artifacts op. |
| `.\Publish-ForgeFlow-Release.ps1` | Publiceert broncode én de bijbehorende Windows-release-assets als één gecontroleerde release. |
| `.\Publish-Missing-Binary-Release.ps1` | Herstelt een reeds gepushte versie waarvoor de Gitea binary release ontbreekt. |
+11
View File
@@ -0,0 +1,11 @@
# Security Policy
ForgeFlow's detailed security model is documented in [`docs/SECURITY.md`](docs/SECURITY.md).
Report suspected vulnerabilities privately to `security@itworx.tech`. Do not publish Gitea tokens, SSH credentials, update-signing material, private server addresses, support bundles containing sensitive data or other operational secrets in a public issue.
For a useful report, include the affected ForgeFlow version/commit, component, minimal reproduction steps, expected and observed behaviour and security impact. Use sanitized or synthetic repository/server data whenever possible.
The current release model requires exact-commit verification, origin-constrained credential use, signed update manifests, redacted diagnostics and bounded deployment adapters. Changes must not silently weaken those guarantees.
Never commit Gitea tokens, SSH private keys, release-signing private keys, deployment credentials or local repository state. The packaged signing public key is intentionally public; private signing material must remain outside Git.
+160 -72
View File
@@ -1,7 +1,13 @@
ForgeFlow 0.10.0 source manifest
ForgeFlow 0.10.15 source manifest
SHA-256 BYTES PATH
(The manifest excludes itself, dependencies and generated release artifacts.)
755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 58 .gitignore
(The manifest includes tracked and non-ignored source files, excluding itself.)
ec40b1ed8e5152ca4175bbe97be43f0e2e911894112dc6a47c2c348069f79abf 87 .gitattributes
cc690743121cd3e3a4f12499024456c0b28c4aca4af0f566e57aa9ca87124ef9 883 .gitea/workflows/chatgpt-validation.yml
037209289e7a387d855ee061fec9b385db57c6a31ee71c770e684f70dcae28f8 5157 .gitea/workflows/managed-validation.yml
c70a5dd662bc71e91e255b51b1a907939682259af2e27091178ef5ea231cfd77 1896 .gitea/workflows/quality.yml
e6b76033c44516b625cd9f40e89824ebc8f74ef2a34666d6d44c042fc602c406 4905 .gitea/workflows/release.yml
83fac3efff45f3dc926080b280ae190b6bb40eb8dae7b8cb5d27255759bc9c47 267 .gitignore
f14b4987904bcb5814e4459a057ed4d20f58a633152288a761214dcd28780b56 3 .nvmrc
89545860bd6f7566da81edc8328cd2a1ebf33e81a4b0dcf2cec74338c05e8cac 1753 build-windows.ps1
0970821475a4452aa19e447e9397a95db836791f16890a1a83fd748ac033dc86 8830 build/icon-128.png
09112c1425ca953d8dd8b2bcfd221e5a84b9f81752f7168f360e295030cbc8f2 521 build/icon-16.png
@@ -12,19 +18,42 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263
4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183 3364 build/icon-64.png
25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png
164c059453a5737110b4e5e98b6211650c757f0aff710f8f7523ffe0ff1815d7 113 build/update-signing-public.pem
5f4aca19a35cbcaffa1a6993ce96b7d66052ec2b286022f2af74594e8a310568 15712 CHANGELOG.md
21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md
a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md
3754dcaa776ead5dc60b4955ed4294fd8580577ecb9ce29cb0d9fbbaf9253313 811 CONTRIBUTING.md
c612fcc44ff222db0c9a4cfd11a4076fafe080e4ada31e689a08739a4f14e74f 1650 docs/ACCEPTANCE.md
3785ad21872e3dc08fde9ef819e26f42161ba6ce6d235f87fcebd7859dfaf148 8293 docs/ARCHITECTURE.md
e05458ee2696e3c57e2475bb42ae1f914f6a36e01768d7a26a3199f1fffed490 1157 docs/COVERAGE_POLICY.md
9eb9eec82518c0bfc7686f5faaf690a93ed63c71d35f1dc5a2c5ec5199da652a 3124 docs/CURRENT_STATE.md
8ea655d1912ac2e17f8834e33a566a8b14461b396ec4268c396ca189a1749b94 2205 docs/DEPENDENCY_AUDIT.md
3b9c301313a6406c39ec09f5ad7247dafdf47f6c21505ab8970b206cb1734f31 2788 docs/DEPLOYMENT_MIGRATION_EXAMPLE.md
30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md
eb42f979666e05d51c587e4223282914926a2b9b1ade9f3fb75525019ce7f738 4616 docs/DIAGNOSTICS.md
a0cd06a96f23a94e118feb012be0fa1ac51345951cb2ba8e67fb8c889c4c342a 5007 docs/LUMAOPS_SERVER_AUDIT.md
1d8aa3c688a9d330b9a5303b09e9e3049c50bf04f97807c7ff659607baa34c32 1464 docs/ERROR_CODES.md
648dd6bca6b45668fb86eb3e1f6c5898dd8da0291b990f1bb5105cfd79421343 1301 docs/MUTATION_MODEL.md
8dc95f69e6f6c8415702c8e79fb6b466c4d60061afded9140450e7e558eaefe5 3704 docs/PRODUCTION_READINESS_1.0.md
f79908fb3dad98c38030c6e6be7c79a1999e0478ed9c2496923891954438daa1 4581 docs/RELEASE_AUDIT_0.6.0.md
979a0b8e129979be6b265e8571d0a3c1e9ddd4ddb6b0bf55ae748d3478e51854 2296 docs/RELEASE_NOTES_0.10.0.md
0eb44bda2209a5979a6ac693ac4cd4d235c0015031e54b9e895990f37bf60054 1433 docs/RELEASE_NOTES_0.10.1.md
5d3240169765e3fb1d3cd391d09547101227e76dd4670ee46be8ca3a21553a03 894 docs/RELEASE_NOTES_0.10.10.md
36edb4f096a248fb8679bd13e5766befb478cf628c6ccfb21e3eda71bbec7633 992 docs/RELEASE_NOTES_0.10.11.md
a355d3f577c2ec85dde5dfd7b6995f4f1615e2f6bac597529f3ff102acd93c35 1292 docs/RELEASE_NOTES_0.10.12.md
609c55a1c0b06c307ebe16f2daaf1e48601edd57137586e4f2be1febd6a7060a 1931 docs/RELEASE_NOTES_0.10.13.md
1d5832048dd834a773ee8f34e6599c590373358132203b022b521dd5dde2f179 1571 docs/RELEASE_NOTES_0.10.14.md
055ad0c73f0854a708eedc3bc4e9dfb991b4e6021348c05484da9a47022e995b 1871 docs/RELEASE_NOTES_0.10.15.md
8d713471a437a8a55b00d7e1dd95290680862107bc4e586cf27d727f6274e46c 577 docs/RELEASE_NOTES_0.10.2.md
0942fb2c4a4f972296423b5232687f7389e2c6417a9d48a3244beef9dec907b9 1164 docs/RELEASE_NOTES_0.10.3.md
8f4a0fe6dc250ae210cc2fc1c57c46091822ae6c2a58caa76e0091f255f9f30d 775 docs/RELEASE_NOTES_0.10.4.md
05ed618f5a74a854363930128ca98939808517eedd28b9a508660a0c46e91d97 884 docs/RELEASE_NOTES_0.10.5.md
94bfb2783c1befad1197e1c5e32fc002222c94a28d70d48360a8d53ecd260d5c 772 docs/RELEASE_NOTES_0.10.6.md
226a3b2d4bc7f54841749a283fcdd71b643cd585ba74d673084bee829fef6ea2 903 docs/RELEASE_NOTES_0.10.7.md
4be29ad0cb7ebcf5625172b8d2bd7a67cdc6d64d3a94e2c3f0656cdfd42dcb7a 642 docs/RELEASE_NOTES_0.10.8.md
fb64517aa64d3ecfe8b51b09e198c2c9fbba96d0cd24a87301c7f6dea3076095 961 docs/RELEASE_NOTES_0.10.9.md
a0c00ff76acd1682bb5e0e8dcf6589c9480da436c9c6d30780a1ed58b4dad94f 1770 docs/RELEASE_NOTES_0.2.0.md
5773ead01aa4c522c556295553787482d01b1f5242f053b2c61f120c4de4fa76 5963 docs/RELEASE_NOTES_0.3.0.md
d46de73cf6c4cd5c2ba3f455a7a2af2e0d64ee9d94a97fd1a0bfb44e35c1624a 1093 docs/RELEASE_NOTES_0.3.1.md
0d697d241a08d2427a6e7f5c2f27bd1830a41836a01e08eeff239c7ad5d89982 2445 docs/RELEASE_NOTES_0.3.2.md
bc6933c303d3d9b3bfdbf678cae1a717bfe5a893780a1871af8b48589f62f0e3 2160 docs/RELEASE_NOTES_0.4.0.md
df07794fb3923f3fb6a49dacbfcc5a371227edb592b4e86ecffaae0a8799f187 2154 docs/RELEASE_NOTES_0.4.0.md
343862445061e1a8282a7aa9b2304e7d799e58f9956d50eb5352db18d790efad 1134 docs/RELEASE_NOTES_0.4.1.md
e2d67c816a919f00f9e26bf59cf29e5e8cf894536b743d282075c646c5accc96 1605 docs/RELEASE_NOTES_0.4.2.md
1aef74fb109541903c4dbc4d9c48d2bd63507420eaf8cceb31890797a5e4f5fd 670 docs/RELEASE_NOTES_0.4.3.md
@@ -54,117 +83,176 @@ ed40e08bac8792f95970bc05e49bce3cc9e288a08d11565a1bd156d787360a3b 720
25169225d73d22b9d884ab3b5c1625f03fd44e53c7a7a4c4067775e80482c9f8 2182 docs/RELEASE_NOTES_0.9.3.md
720506842e0aeb30c9fc635f86a52a5545556f092e678cf37f08436243244c3d 933 docs/RELEASE_NOTES_0.9.4.md
dd90c81a375f97dfb7fa8f7808db03b19d7e7dafe3818a93537397f57eaae829 2109 docs/RELEASE_NOTES_0.9.5.md
2b631b9d6d973bdd70869d84886ff339da351e29e17598970b3b27915674661d 4175 docs/ROADMAP.md
1bf75f25d704dab0c6bc56c639d259f34523f0fb46718dd5a8419a59911ad2c3 2242 docs/RELEASING.md
ac76cb50fabde6a00f28d7e9eccd3ef1129a40665eabdc90d78690a38d424652 4195 docs/ROADMAP.md
1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png
b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png
070e6700bdae8c628c907ba181bbf0dde0bbbbb4208f7a875503f933ff1b882e 118819 docs/screenshots/deployment-success.png
581375ee0727911f85b0441f09734c6215ea8dd6cfaba4a7555599df0edb24f1 333382 docs/screenshots/deployments.png
87546583580e8591b1306f997d27445725b0bf5a5a79a839af3a727964e65bc2 103901 docs/screenshots/git-validator.png
bbdbe91679b486cc92dec4758ce1cdaf24e3277d038c57e794a04c0dee7e3a5b 84046 docs/screenshots/overview.png
c8a5e80bb9fd2d442d2d23d30e6ac1528cf2330e6e19492b7c6799e2d1508b53 112868 docs/screenshots/repository-workspace.png
322624242d246d07180cc719e14c91e8fb69e123676a02e5046f4e576cca1ca1 5569 docs/SECURITY.md
ed69b8beb948a2cf9a6deb6c82368e2bb44ffe8d8990a900dc878b0938d1084f 95937 docs/screenshots/deployments.png
3868ab978de2a7945761c53a9a718aecd54dc791605d07660bcd5cad62a33ea8 103569 docs/screenshots/git-validator.png
007681714895ac062c980db1dda806ac17d4f01019ce9c46491a108d17c2dbda 85338 docs/screenshots/overview.png
1f78414b00ec100af2ec9bf5c9a3e400b6c9bf6dca6fcc317fd951789acc4536 112852 docs/screenshots/repository-workspace.png
5cb9ee0e33a06db1796a740f12b0c41b917c68c1ae7cdb0070936a99c61c2c64 6036 docs/SECURITY.md
32a34ec13a284d3f9ceebbc107b25a844e3db096f8cafa4e43951fc2050c9a03 13552 docs/SETUP_GUIDE.md
2fd71e9bcaeb4cb10c3fa2496b7e52fedf70c5b7f871cd587e22dc060c399079 4421 docs/SSH_UNRAID_DEPLOYMENT.md
b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md
0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md
4625a10ebd3c749f60b2a7bef6b1716cd05dbc44ccceba0491a1b46bc293c195 4883 docs/TEST_MATRIX.md
dbbd9fa96988e7543e98c85da864adaadd3057815f18d20a3b3ccb5c540a169d 4558 docs/UPDATING.md
4983414a980075e6faae687b0d71c8e57bfe53fcb4cadb8b979b8abca636fe95 6654 docs/TEST_MATRIX.md
3c34d42088b18e37d18afc848f91fbe5b74134ca74dd53df1e9eb30753fae750 4433 docs/UPDATING.md
73f094a2f0db3de053e515feb2771cd5a4f3aa4178f2c5f37be01ca65ff1c938 2705 eslint.config.js
c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 examples/gitea-actions/deploy.yml
5d2577d4f9f635a12dcc8795879c079b9e66630bb3d96e21510ef7ee13ebef05 3319 examples/gitea-actions/forgeflow-approved-deploy.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
0423fe2cc7f43fe793986a3f62a395668897cdf07348756aa7742a8cd40ac51c 569 examples/server/forgeflow-targets.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
3b16a087c73b600415394dff8b8e34e7f7519e48fde1cf443007b2e11ca77b27 13123 main.cjs
e2daa28bbc01c68c3702add6ea8259dff5920b22f6fdc3c9193ed78a153f2e9e 14708 main.cjs
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
efe2f75ec8bfdbad8e2ee68d0f2c4b412ba460c6b841163d7853f56d5b7b63ea 130468 package-lock.json
605514833fe95a59cbfd5cee6a509ce8a0228a8b79c1fcc5bdb4e8fea92084b7 4364 package.json
e95be3f3736f2c1ef249fdc9a083fffa46c07a0b885979878a552a4846995aa6 10160 preload.cjs
63d403ada205000a0dfb158ec57cde1a4f58572d790589428ab5b00a99b32cbe 184860 package-lock.json
ae06416348c2039eb4cafbf287c99342e04176bbbbb718646f160ff29b6af42f 6464 package.json
1237df9ddcbb5ac7dc4316f18c34ff4a7030e3e0d56216ade6dd07369e5e2a04 1353 playwright.config.mjs
7b0d173d0cf5a7f8db807580492bade379dba174a271013747f9f28a3793f55e 12409 preload.cjs
abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd
f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1
688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1
794bbe1937077788f34c64398fd73dc9a3c43d095084aa32673f3e968b115de2 9150 README.md
6d0858d6654c3c3dc7083ebbd234c88324afcebaecd7b772719440a8afbc2e4e 10736 Publish-ForgeFlow-Release.ps1
33f3c4795705ab77c6e6603c88a32c123b3a286bc77e8e472b76970485699338 4386 Publish-Missing-Binary-Release.ps1
5d9f13f34c9a9cca77968e472f1829147e51d3795779fa3f766b04cdd54fc698 11437 README.md
0f1bf0696ca6a3de7c222a935953156cdd1bb0aa27f2215b8000901c4db2be31 17255 reports/architecture-audit.json
6c50c58f464e2f93fb7255a59d6cbb76354755f63c6f1d4ff14a9a88c8c54574 1758 reports/architecture-audit.md
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
00d57bda5af8c8eda294b72d18b318f024a307b81b0d9205a0821f5240151e31 3814 scripts/apply-binary-update.ps1
f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287 scripts/apply-source-update.ps1
33a040b12fc5deab05f9df5f71986d0bb71b91f15a8cd25e9ab3cf7ca9a26766 4212 scripts/audit-installed-deployments.cjs
5f220dc8ee24d2339aa3eb696ac7a5bd6f55c993b5fd9001ec9784788f2a6e46 6747 scripts/apply-binary-update.ps1
358d0ecbd50d8ba1ff9460c761cc2a1990fb27104d6ad74104a8800877343e19 10954 scripts/apply-source-update.ps1
02e924227f6cad3777fd06660230c85df590d8ce95e134194a4d18970a240b88 4145 scripts/architecture-audit.mjs
47a5b16e95934bfe510c18bf94547ae65acb980c0f0506ae156d1a486dfbdfc9 8985 scripts/audit-installed-deployments.cjs
6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs
f6f89e893195b9c8ef0ff01e256005b9b3cd7d4a278722979a5e0e616c86a89f 1733 scripts/generate-source-manifest.mjs
7b483476ddd909b085335cb78c9b0ffe71939c50011b5fbfcfdef6a340fa7ce8 2032 scripts/generate-source-manifest.mjs
842436680521311594e798848b050ae4e488d0595f0de57315f6ec081c049fb9 1266 scripts/prune-dist.mjs
403a64db5069595a83006a4e293d7e5ceeaefcfb74e820ed3e899864a0f182d2 6066 scripts/publish-binary-release.cjs
444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs
19417a26a5af967b0f057fede45d1811a6d8ad65a0ed49ad4f5865f598457168 9358 scripts/publish-binary-release.cjs
558ff442988f1396c174c7161ff5bd3ef0b2f43cfc31459ec7c3967faa146bc3 1694 scripts/serve-demo.mjs
288c4b93f6006c0b32cdf90555bdc0d1d3b61d24a8763fcc30f1e6425ce1684d 1713 scripts/setup-update-signing-key.mjs
431d3d7eabf7e2ea2d5cbb96fb0ddc13f26d85afebcb9692f3e30242197cbd8d 2607 scripts/sign-release-manifest.mjs
c76507857292c5713e1c699cf02e24b80265da39af2cecd148034bdb874adbb6 5246 scripts/test-authenticode-chain.ps1
4393f7dc5f417e6d601a68238f4e26791799a3634acec228fe4d79deaee85eb5 3109 scripts/validate-installed-connections.cjs
43e84c3cced1e23ba5b070d87051a235cd7e2d8c02e2e5ddc1e5e0ff2afabace 16487 scripts/verify.mjs
0079701b5acbfef07b71a9623613d1940805ccd20649d77e3f34c37e79df7655 735 scripts/write-release-checksums.mjs
e6127e1e62f39c70ddb1abf72f4d7e7b8e3f19ff1f219e1a3660353c2e0cdfac 2411 scripts/verify-release-signatures.mjs
2187f4a6e5f162b428f123960c8121f671218e1164327c1b4f21bf45a4477d01 22686 scripts/verify.mjs
c2c9e4ba251d93a530a52b2d0079787680261c314083bb99d2356fc177719613 2434 scripts/write-release-checksums.mjs
c8780510ff6e77a16e9c4a88c3c981a0d9d1a4dff8eafd16afdeefbb4b03aac9 1064 SECURITY.md
619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1
dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs
c9dee05857b6eb9475dace885579291b9ce6830023ab54acafaa146d15962a7a 27754 src/main/config-store.cjs
e3af59fafa497d032541979bea5fe2b98187b1ced619c567d15ae79ee0904c1f 34629 src/main/config-store.cjs
2fb04b1494b39f5d7c0720fa5fd298cd46fa85dc1b696d77657592347fcf1819 2731 src/main/configuration-backup.cjs
9d0af5074093108a5248d0dde0ff70a666748e61f1954b630886a81e8f34072c 24079 src/main/deployment-service.cjs
c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs
a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs
0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs
3ce45837099ac7bddc024974bd839575b4b765a7df9055e7d45ef889dc85bf7f 15623 src/main/git-validator-service.cjs
d85d5b1abb35e8bd7f1273a697914eeaf96567d3d4ce3f55f364765d35ea4ad9 19900 src/main/gitea-service.cjs
8adeebd08aeafaf79ed8fc7bcd2c1e110d3dc17c14596fb623b9d2c7255fcd11 49662 src/main/ipc.cjs
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs
86e9fc2eda66b4b563f6c4bbb87d3e8514340d484fb503b73137e63b6b05c3c9 14597 src/main/deploy-key-lifecycle-service.cjs
7cbfe51973d6607203cb197652ed7f296a3f6b6b644df876957117866a47d802 2159 src/main/deployment-identity.cjs
ce30ddac403d1adf21176e5df21b0cc3db435305d2628f51f1486eacf20df6f2 23708 src/main/deployment-service.cjs
cc2dad2fdac386d41c37b1a8657ae2b3b4084fd8f35a97f8600c082c8d017552 16475 src/main/diagnostics-service.cjs
7f452dc2c0e6f3a00eecf6cb1c2be75906ac40445ef3e104341c0c67208aab2e 2397 src/main/external-tools-service.cjs
76fdc5576dcd6fdb88921009d4a923854650bd5efe5b837cd7438eba0dc733c8 48002 src/main/git-service.cjs
e28fc1ca2fd4c0116148f5005d793feddf04c36ef711d2d348560394d209a613 7253 src/main/git-validator-policy.cjs
3a101b63ad3761c26350c2ac0793279a0b27672b91a5b1d8dc75bc44d92f0b52 27128 src/main/git-validator-service.cjs
3cc53e24e023aa0d8bf36c35ce9672ca98e6c74066512c8b59ab42274e838c22 21307 src/main/gitea-service.cjs
2ad3b2e647377f687ad987fe248a142ad399ecac98e4b49965aa7efc6093e5fa 6914 src/main/inventory-classifier.cjs
dafdb09133d2b6ec2161a3f0b09354551e54fc606c8107976fca37405643be91 3404 src/main/inventory-review-service.cjs
98b332589f86a4874a8adddc38e7844a4ddbe9360d38f9f77b2e95d58d748016 25720 src/main/ipc.cjs
26efebb4c147ed560966e7e60e64a013b3476327b3bbdb4e4439949142fa7846 2250 src/main/ipc/channel.cjs
748cddf497b4c204e5e6fa1bd049991a086fdfb38afe342e6b3617c85a111478 12467 src/main/ipc/deployment-handlers.cjs
dc9b5971c9fefe8c374aa31916f5513601ce86003fd48b1d0e51330a909ae3a5 3442 src/main/ipc/operations-handlers.cjs
8072252821b1245d121eac534a18eeb64f0d7d18429e272e21a6e90b010005b9 17272 src/main/ipc/repository-handlers.cjs
fc7156aabacb3e85a4f220c490f94e9836e07fa5350a1d9e64f84f835ef51024 4961 src/main/log-redaction.cjs
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs
720c4a0c554f46386d87c3ab6607d1fbcae66e50b69483c7dbba169d5128c851 680 src/main/process-error-policy.cjs
3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs
e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508 src/main/repository-monitor.cjs
17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs
e64f7257d478955c675a133b3735b6afe138a69d2ad090898061e56f557c43e5 9926 src/main/production-acceptance-harness.cjs
27bd6621c731545ec46d8914e9408c89928a8ce563b40eb4bcd8a516662a54d1 8716 src/main/repository-monitor.cjs
6393583911263575c6e2a19d9baab6e638cce90252c386b0a5144f2fb6f81f15 12154 src/main/repository-service.cjs
52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs
afef3841a3948b2121f8fba809aae4ea3da71bd2fda86973ba50200a5b1f89b2 14894 src/main/ssh-service.cjs
75f4135bc068d6c615fc317cfda81f33a4387c4ff44fbb9799a055f302cc154d 137170 src/main/unraid-deployment-service.cjs
b654a9e45044ad32c61fabe4a6d897288615ec83739b53e3241ff881e32f56bd 21677 src/main/update-service.cjs
07822cdaf34745678b82b7eb0f20fedb16bdbcd6ab2b9b774ae0cf9c6069032d 234703 src/renderer/app.js
793003566823e1d5c02283f583888ecc07e44477525620579b3d858f488b3c08 22347 src/main/ssh-service.cjs
19538a3c40ea3489bbaee9a23af36a5e99962af6bb3d04259f05ece6588cbeb2 25901 src/main/unraid-access-methods.cjs
5621e35323e4f81fb14a05670f81579ec1e66bea3a55fa6457ece0f807421424 9801 src/main/unraid-deploy-key-host.cjs
6d9910dace52625f88e066a8485af2663c3735ff15e9ce9031441ce742710a21 30793 src/main/unraid-deployment-methods.cjs
673b1692e7c2b5197545df98750b5d048bddf44206263e25be4f17d9bf900e2c 17208 src/main/unraid-deployment-service.cjs
bb4a99c3526fcf4db4fbae88a058e8598fd10a87990e7d502bfdc765328bdaa1 42766 src/main/unraid-inventory-methods.cjs
2c0cf07921ca7ee5a9085ced44498c2e6798e5cc1e8a5ecf704c3cecabe39a25 27607 src/main/unraid-preflight-methods.cjs
d45220176aed72d692f9ae5534f9d40bcc359a2d08e025e74a3b3b505b8b9ed4 16559 src/main/unraid-runtime-methods.cjs
4c5cf01922e1feb36a31b50af22e973d8aee3fecccd406e449690604111898ac 11608 src/main/unraid-state-methods.cjs
29b8c5eca83b0e89c7d0716945b5316aac43947b5387e89562d9a024ebc4663c 27204 src/main/update-service.cjs
b5c304531bec358d059189a27cd9db8fa20cefb7f817e5eb0287001f7353f6a7 985 src/renderer/actions/command.js
d0bf607dd1de9d55f2947d0adf0997cd3ca5c269d10a5362cc1d8bc4d1a2a8ae 6706 src/renderer/actions/deployment-operation.js
0db283b1a458ae0b31538940b1ddc931ffdb53bd04ceb7fd8903813f9200d071 17978 src/renderer/actions/deployment-profile.js
48bed91dd2a85bb51ee7307f7acc3b79c881ce8cf63b22ba79d5d079b265eb4b 7785 src/renderer/actions/inventory.js
9e8adf1ba89ffc61a7b595f813c784688bdf50daa259204d74b2cdaa81650895 15493 src/renderer/actions/recovery.js
2414a0d29a0380d343b9b0e58ba1909e7a7eeb46357fd45ddbb3ad411d119f78 16280 src/renderer/actions/setup-and-settings.js
058722de35ba33bfcfd29d355a75e1513a2be80c572773472cf9816dd13f894a 20148 src/renderer/actions/shell.js
0d48993bc26ae28bdab5fbfa8d9be4ef896efdb9a5c34094087721e4274593b4 27566 src/renderer/app.js
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png
094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png
fb7ed47f9aac50d9259d7d3c3bb2010c7bfdd2fe8e8e47ca2744bb22f0057d54 830 src/renderer/index.html
b63786e7e4f70eac8b65d29484c0040aec530a410da5f554574d8a056cd90003 60621 src/renderer/mock-bridge.js
34ee56ed08dcd1c2295985b9bb419b981598a2699ee8e65394d86f4177f4797b 77647 src/renderer/styles.css
0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs
f826ab1f2f35882c59995497219fcfd500e46a94dac0906ee47fb732e8023fb4 55633 src/renderer/dialogs.js
dede1f21a06c73a2c2a462a869d27530d85f99baff202a2eb509c57436ad6aec 2732 src/renderer/diff-view.js
b7698de13b872aa80d27b0a4d977c12ca2303b2246f05e6af4223db9b727e525 7433 src/renderer/events.js
c4a71213d412166093f7bd8254b847de4d8beb58c1aaa356a0cdc8d728080326 1524 src/renderer/index.html
06180d9656dd254edfb6949c397f8e313954fc560ddcb22b3a35fce3c3e35655 21350 src/renderer/mock-bridge.js
870024aff376826a92c9cf7452689cc1ecc5d9034f055bea56734f3f7fcea5e5 28703 src/renderer/mock-deployment-bridge.js
ee33d1a77ab7152cb4f3dfbb611011f755a69ba1afb1016a28c997fcfdca97d6 25810 src/renderer/mock-repository-bridge.js
94fa265c2fe9ca8d644f0ce9b620b6f85d9b25dca5802c4e9195b66dcbe80120 6522 src/renderer/operations.js
9299c83e43eef194bac2946c43b2ffda6309ccbdcc5b9ce1775e668ae7f172ef 92319 src/renderer/styles.css
9ee96e6267d923448a45eb0a02d5352286400f2d7b4a006ba681b6806f2e1424 113808 src/renderer/views.js
e9e72c072a5c5d04f59cd6763de0cfbf736c2a5ffa2f722143f3bad2bdbc630b 1411 src/shared/clone-target.cjs
5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs
029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs
2778ebcbdf60fdc1cb0749f15565e0e1bd66f3a0d31eb70ae7942a7511a3de75 1295 src/shared/repository-match.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
114f01be8bd54c91b90af82d8e1604e24cc0c5f8e64e63c40cf3f4042623a98e 5402 src/shared/validation.cjs
ee73fdf9c591c029243385cb2d2085c3005c7b08c5b9e1b89102201f0ab30759 5702 src/shared/validation.cjs
13b731c38863b1007b0312fd9d89562401b7cce875c952f52429bde74f77a8af 3096 src/shared/zip-writer.cjs
f8853dce6fdf360d5df2fbe2b6df3e5687630c807fee5ba8436679b34ec737ea 2436 START_HERE.md
058aeaa5d9bfe377c7e322f213c7871ecc4151b5d08ef790992f4ee28d857658 743 START-FORGEFLOW-OVERLAY.ps1
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
454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 4318 tests/clone-target.test.mjs
a984ddd5a29a4ccf55d78ba71202390e0bf1f0925a6a96f03ee74edbbf3bb2f7 1505 tests/configuration-backup.test.mjs
6e119cda76b2ee36b93623d98f41371798227f9b3f7c20fbef035a7d7a50cc95 19402 tests/browser/forgeflow.spec.mjs
1728c0a7abd92f4d7d9e68df32e4a6b00730555f23795e9b36416795d9d127af 5978 tests/clone-target.test.mjs
8aa8789a984769b377f719abca42a428fffc4f89b3997d57184a8073d24121e2 18325 tests/config-store.test.mjs
f1463326aee79842d265687ae628189ce54e92544600f2bd14073780287cfb14 2502 tests/configuration-backup.test.mjs
144c8e217a334cd69f502938d944e0f2dac61703d5df47e287b9ed542918c779 8129 tests/dependency-wiring.test.mjs
aad5948ea374d1e56e777005c73639654c96a90364dd398c949052cf5ae343a2 11130 tests/deploy-key-host.test.mjs
b7e009fed4171d6dd6b4c3154ba1d3f7198e98f5b79b298687841fc8169447cd 9354 tests/deploy-key-lifecycle.test.mjs
49bf9cf9842e7899015013675208f83a95402065a082320927a677ee4bab0766 24875 tests/deployment-operations.test.mjs
1dc6477bd07de78be189e6e8195ec339eb9d75820c4dbd5b073b8520ee21f6b5 1938 tests/deployment-policy.test.mjs
bf68c4dc91a2604235c6a7848088bcd9566fbeaa089b86ec1e0a4fcfc54ca9d2 7677 tests/deployment-status.test.mjs
fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800 tests/diagnostics.test.mjs
dd121d96ca265a027cd415a52064500a4541b2f8a662f4f4b25f2f996d52b5da 762 tests/external-tools.test.mjs
e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 tests/git-integration.test.mjs
bf4576901e32662d832687a2761852aa1b2cffe256de5044f18c6637c189463b 9780 tests/deployment-status.test.mjs
9a97e79618393d9f9818cb958bd038e0095489331fba0b29de313aaaaa1ce30d 4064 tests/diagnostics.test.mjs
b63eb5bcd89c34629386c745e80c2f83f0709ebce62459b583e665d0a99654c6 938 tests/external-tools.test.mjs
26e94450c6ab1dd0d5149d6812e66814a719f80d6f95fcb3a9dee7a3d7f46293 20559 tests/git-integration.test.mjs
5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db 1239 tests/git-status.test.mjs
73d00729696e5067ba33dd6d43b018d89ce7fdd561a60ab66648d3283fb54d21 5370 tests/git-validator.test.mjs
61e0b8cad926acd22b5b17e4044f7edcbe96b6977cbbe2b6fbe123406626fc89 4283 tests/git-validator-policy.test.mjs
2b31459f14a5e36e30cf84c1054f634f4dba8676d29adeb9c2a8e18179f56fa0 6097 tests/git-validator.test.mjs
681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.test.mjs
771eeb4fa5443d581991cedb4107d7c414ce1b7d2e14bac7cf719ec8ba131647 8969 tests/gitea-actions.test.mjs
48bca4711e7c193d19c78a0cb45ea1c83179b3c23640195f66058268e8a11b52 1520 tests/ipc-contract.test.mjs
caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs
d633c59bd910008223c834c6d7f3e5666c685a0881944263ede2d42cc69d3151 18710 tests/gitea-actions.test.mjs
fcc9a063882840dd89d74c2785284c8f2f6a9e5acec482b6d89ed8de62efdb85 9635 tests/inventory-classifier.test.mjs
62b90c21c15b841af30d26ccb0b9e88d25674fa7dbff9dc231dd8a1dddc3d657 2025 tests/ipc-contract.test.mjs
5c66b01ab11585aab6d923a9811ec76eef34e918f1a7f37227edd3f4337c932f 2981 tests/log-redaction.test.mjs
96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs
c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 3718 tests/preflight.test.mjs
15e05220fd282895b02ea52ceaa38327a61297af8db1484e6d9964e8b03a8fea 9156 tests/renderer-workflow.test.mjs
1b6c920e18a248f78acaed6187197c88ec8d911b62d5e2a9f8ad57b91ae80499 11827 tests/preflight.test.mjs
7f2751ea2621f76b5427f442e931344d13e97faa7b6ef3151949bbd6a03097cf 1205 tests/process-error-policy.test.mjs
0cb884cf62c1cb02cf59a81662be055bcb5339d176de85e2a3eeb8e8573e11b3 6435 tests/production-acceptance.test.mjs
11fd2029593c0f4e5c36f1ce8572734f8ac9afead5abca7f5b619c5814b40a6c 12602 tests/renderer-workflow.test.mjs
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs
f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 1068 tests/repository-monitor.test.mjs
75b5b83836c75675bb9a48fe4363fcb8a24fc425e6af6f822d7955c6f3c79eac 2265 tests/repository-service.test.mjs
d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463 tests/security-validation.test.mjs
4defa3c5f21db7fefbd79397c96b6c231a4330df60b54c0fea7e91412e336fd3 6237 tests/repository-monitor.test.mjs
5476f3ba90bc096d4172900d9b54ada7c12da521f8627913d87794eade3cee23 13494 tests/repository-service.test.mjs
5fea04e668344508fb4e16da9bb6fe8733e2b83d1c227acb3421e51da26b2ffa 3636 tests/security-validation.test.mjs
bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs
020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs
2571128f0b8e650071df17755baa09c4dfc441af0c20a7a4e9aa445b59e87d11 1654 tests/ssh-service.test.mjs
12cb3b240bdd0922566323c0014838ca067ad10d9d4009943165ae2c4e93bc6f 11786 tests/server-inventory-branches.test.mjs
5df7f331cc1120a0914f5ddbf39d9a6b83bd5ada70a9953b3f6bd0701ba2485e 4394 tests/shell-verification.test.mjs
a39d30f47813dfb98c998811f3d76ebbb1544ecfd017a165d44f9afb80d7daf9 9090 tests/ssh-connection-pool.test.mjs
7ee9166327ed227d2b7c6929692dea5c5d7a41c3e566596fa92d9ec4f42e8677 4085 tests/ssh-connection.test.mjs
c9354e4bf3720c28cff21c15ff8b9474ba4a23b7f55389de4326f4dffde54d78 9510 tests/ssh-service.test.mjs
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs
5634e82a3a3c782cd6d7cd4df42b3102c22b81c5566fb072d7cd30de65c1f395 40003 tests/unraid-deployment.test.mjs
4abe7b2fc113c486f35f15c2d629c5b4f24589eada718c4ea58f93551c77d5eb 17777 tests/update-service.test.mjs
3e4a1a6d6a744df9badcfece2cf8d09f8c34efb3c437cb08a6f2e6c9d428c0d4 59353 tests/unraid-deployment.test.mjs
05c0ef13fa2fa977174fdf8bdf76a7d606a049a43ad98901079cf5545d54d18d 31426 tests/update-service.test.mjs
9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs
7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs
8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea 767 UPDATE_FROM_0.3.2.md
+1 -1
View File
@@ -3,7 +3,7 @@
1. Close ForgeFlow completely.
2. Extract `ForgeFlow-0.4.0-update-from-0.3.2.zip`.
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.
5. Open Windows PowerShell in the existing ForgeFlow folder and run:
+3
View File
@@ -0,0 +1,3 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEApGKe81NzC5mU3jfMNAQUnAOfQnCnMFry8cNpmjQsdtE=
-----END PUBLIC KEY-----
+14
View File
@@ -18,3 +18,17 @@ npm run acceptance -- --execute-rollback
The first command is read-only. The mutation flags require every read-only
check to pass, dispatch a controlled exact-SHA workflow with a unique request
ID, and wait for matching status plus a successful health endpoint.
## Isolated production acceptance
`npm run acceptance:isolated` provisions disposable bare Git remotes, working
trees, server appdata folders, Compose definitions, deployment keys,
configuration migrations and release manifests below the operating-system temp
directory. It covers clean and portable installs, the 0.10.0 migration path,
both authentication modes, exact-SHA server pull and Direct Copy, adoption,
external updates, deploy-key lifecycle, host-key changes, unhealthy activation,
rollback, network interruption, shutdown recovery, stale plans, corrupt config,
release integrity and inventories of more than twenty workloads.
Every fixture is removed after its test. The suite never discovers or mutates
real project folders, configured servers, credentials, containers or releases.
+2 -2
View File
@@ -212,8 +212,8 @@ and last exit code. See `STATUS_ENDPOINT.md`.
## v0.4 services
- `UpdateService` reads `package.json` at an exact Gitea branch SHA, downloads an
authenticated archive and launches the rollback-capable Windows source updater.
- `UpdateService` reports `package.json` at an exact Gitea branch SHA, refuses
unsigned source replacement and applies only publisher-signed packaged updates.
- `SshService` provides pinned-host SSH execution with encrypted password or
private-key passphrase storage.
- `UnraidDeploymentService` inspects existing application folders and performs
+18
View File
@@ -0,0 +1,18 @@
# Coverage policy
ForgeFlow treats coverage as release evidence, not as a target to game. `npm run coverage`
enforces 75% statements, 75% lines, 75% functions and 65% branches globally.
The July 2026 hardening pass raised the measured baseline from 69.74% statements/lines,
68.82% functions and 55.38% branches to 81.48% statements/lines, 82.07% functions and
65.59% branches. Node/V8 discovered additional branch counters when previously unexecuted
functions became covered; the denominator grew from 2,537 to 3,473 while the new tests
added hundreds of asserted decisions. No command builders, platform guards or error
adapters were excluded to improve the result cosmetically.
The 65% global gate is paired with scenario-level evidence for the critical
boundaries: deploy-key rollback, deployment verification, Gitea authentication and
redirects, SSH host identity and output limits, inventory reconciliation, stale plans,
configuration recovery, release integrity and updater failure modes. New code must not
reduce the global baseline. Future increases must come from additional asserted failure
scenarios, not ignore comments or source exclusions.
+54
View File
@@ -0,0 +1,54 @@
# ForgeFlow current state
## Baseline
- Baseline version: **0.10.0**
- Baseline commit: `56efd1a00c2e76251a0b2e7a7a424d94200ae33c`
- Baseline branch: `main`
- Desktop runtime: Electron 43 with Node.js 22+ required by the source project
- Primary supported packaged updater: Windows installer and portable executable
The baseline was recorded before the 1.0 professionalization programme. It is the comparison point for functional, deployment and renderer regressions.
## Known baseline evidence
- `npm run check`: 155 tests, 152 passed, 3 environment-dependent Bash checks skipped, 0 failed.
- OS-backed secure storage, encrypted Gitea token, Gitea API, repository and Actions access were available.
- Unraid exposed Docker, Compose, Git, tar and SHA-256 tooling.
- The server inventory contained active repository workloads plus a large number of historical or unrelated definitions that require backend classification.
- Windows release artifacts were checksum-protected but not Authenticode-signed.
No secret values, passwords, private keys or tokens are stored in this document.
## Operation classes
| Class | Default | Examples |
| --- | --- | --- |
| Read-only inspection | Allowed without confirmation | repository refresh, server inventory, deploy-key probe, preflight, audit export |
| Reconciliation | Preview required | adopt an exact workload, refresh a profile from server truth |
| Configuration mutation | Explicit action and audit record | save profile, rotate deploy key, change server settings |
| Deployment | Fresh preflight and confirmation | server pull, Direct Copy, Gitea Actions dispatch |
| Destructive maintenance | Recovery evidence and explicit confirmation | unlink, prune, key revocation, rollback |
Discovery and audit never belong to a mutating class. Ambiguous evidence cannot be promoted automatically.
## Recovery model
ForgeFlow writes its configuration atomically. Explicit server reconciliation additionally creates a private recovery snapshot before changing profiles or deployment state. Encrypted user-created `.ffbackup` files remain the portable restore mechanism; recovery snapshots are local operational safeguards and can contain OS-encrypted credential material.
## Issue priorities
- **P0:** active data loss, credential disclosure, arbitrary execution or uncontrolled production mutation.
- **P1:** release-blocking incorrect deployment, unsafe implicit mutation, broken recovery or material security gap.
- **P2:** important functional, accessibility, performance or maintainability defect with a safe workaround.
- **P3:** polish, documentation or low-risk improvement.
## 1.0 constraints
- No force-push or implicit repository history rewrite.
- No automatic deployment deletion.
- No desktop Gitea token on a server.
- Read-only repository-scoped deploy keys for server pull.
- SSH host-key changes fail closed.
- Live commit, remote commit and runtime health remain separate evidence.
- Packaged updates fail closed on missing or mismatched release assets, SHA-256 evidence and the pinned Ed25519 publisher signature; paid Authenticode remains optional.
+46
View File
@@ -0,0 +1,46 @@
# Dependency security audit
Audit date: 2026-07-29
## Outcome
- Runtime/production dependency audit: **0 vulnerabilities** (`npm audit --omit=dev`).
- Full development toolchain: **19 high advisories**, reduced from 23.
- Critical advisories: **0**.
Playwright was upgraded from 1.55.0 to 1.62.0, removing the browser-download
certificate-verification advisory. `c8` was upgraded from 10.1.3 to 12.0.0,
removing the vulnerable `test-exclude` chain. Compatible patched
`brace-expansion` releases were installed where dependency ranges allowed it.
## Remaining development-only chain
All remaining records collapse to one advisory:
`GHSA-mh99-v99m-4gvg`, an uncontrolled brace-expansion denial of service. npm
reports it through nested `minimatch` versions in two independent toolchains:
- ESLint 10.8.0 (`@eslint/config-array`, `@eslint/eslintrc`);
- electron-builder 26.15.3 (`@electron/asar`, `@electron/universal`, `glob`,
`dir-compare`, `ejs`/`jake`, Windows packaging helpers).
These packages are never loaded by the packaged ForgeFlow runtime. They run in
developer or CI processes against repository and build configuration owned by
the operator. A malicious repository could still attempt resource exhaustion
during linting or packaging, so the finding is not classified as harmless.
CI jobs must retain memory/time limits and untrusted pull requests must not run
release signing or publishing jobs.
## Decisions
- `npm audit fix --force` is prohibited. npm proposes ESLint 4.0.0 and an older
electron-builder; both are breaking downgrades and the tested older builder
dependency graph increased the result to 30 high and 1 critical advisory.
- No global `minimatch` override is used. Several affected consumers declare
older APIs, and forcing a new major could silently break packaging or lint
file selection.
- Latest stable ESLint and electron-builder versions are pinned exactly. The
residual chain will be retested whenever either publishes a dependency fix.
The release gate treats `npm audit --omit=dev --audit-level=high` as blocking.
The complete development audit remains documented and visible rather than
being misrepresented as a production vulnerability count.
+63
View File
@@ -0,0 +1,63 @@
# Deployment migration example
This example shows how to bring an existing Git-backed Docker or Unraid application under ForgeFlow control without exposing or overwriting runtime data.
Use synthetic names and values while testing. Replace them with your own repository, server and paths only in ForgeFlow's local configuration; do not commit credentials or environment-specific diagnostics.
## 1. Establish the authoritative repository
Before deploying, verify that the server checkout and Gitea repository represent the same application:
- compare the complete 40-character commit SHA;
- confirm the configured remote belongs to the intended Gitea origin and repository;
- preserve the root `.git` directory for exact-SHA verification and rollback;
- resolve any remote URL mismatch explicitly instead of silently rewriting it.
ForgeFlow blocks deployment when the existing origin conflicts with the selected repository unless the user explicitly approves alignment.
## 2. Protect runtime data
Typical persistent paths include:
```text
.env
appdata/
config/
data/
logs/
compose.override.yml
```
Keep those paths outside the tracked deployment payload and add runtime-only directories to `.dockerignore` when they are not build inputs. ForgeFlow uses a controlled Git reset without `git clean`, but the repository's own Compose and ignore rules remain authoritative.
## 3. Reuse the maintained Compose definition
Prefer the repository's existing `compose.yml` or `docker-compose.yml` when it already defines ports, volumes, device mappings, labels and health checks. These application-specific settings should be reviewed and versioned with the application rather than regenerated during deployment.
## 4. Handle nested repositories separately
A historical checkout such as `source/` may contain another `.git` directory. Treat this as a migration warning:
1. verify that the root Compose file builds from the intended root;
2. back up the application folder;
3. stop modifying the nested checkout;
4. rename it temporarily;
5. rebuild and verify the application from the root checkout;
6. remove the legacy copy only after rollback has also been tested.
ForgeFlow reports nested repositories but does not delete them automatically.
## 5. Recommended profile
```text
Provider: SSH / Unraid
Server folder: example-app
Branch: main
Compose mode: Repository/server Compose
Compose file: compose.yml
Clone URL: a Git URL reachable from the server
Healthcheck: the application's existing health endpoint
Preserve paths: .env, appdata, config, data, logs, compose.override.yml
```
Complete a preflight first, deploy one exact commit, verify both the live SHA and runtime health, and test rollback before treating the migration as production-ready.
+15
View File
@@ -0,0 +1,15 @@
# ForgeFlow error and recovery catalog
| Code | Meaning | Recovery |
| --- | --- | --- |
| `RECONCILIATION_PLAN_REQUIRED` | A server mutation was requested without its reviewed plan. | Open Review reconciliation and apply the current plan ID. |
| `RECONCILIATION_PLAN_STALE` | Server truth changed after preview. | Rescan, review the new impact and apply that plan. |
| `SERVER_GIT_VERIFICATION_FAILED` | Branch, deploy key or pinned SSH evidence could not be proven. | Run Verify server pull; repair only the failing check before retrying. |
| `DEPLOY_KEY_NOT_READ_ONLY` | A matching key can write to Gitea. | Revoke it in Gitea and configure a dedicated read-only key. |
| `SSH_DEPLOYMENT_PREFLIGHT_FAILED` | One or more deployment safety checks failed. | Open preflight evidence and follow the failing check's detail. |
| `REMOTE_WRITE_ACCESS_REQUIRED` | The SSH user cannot safely write the managed source/state paths. | Use Check / fix write access after reviewing its scoped impact. |
| `UPDATE_ORIGIN_MISMATCH` | An update asset points outside the trusted Gitea origin. | Correct release asset URLs; never bypass the origin check. |
| `UPDATE_CHECKSUM_MISMATCH` | Downloaded bytes do not match the published checksum. | Keep the current version and republish the exact commit atomically. |
Audit and discovery never repair these conditions automatically. Mutating recovery
actions require an explicit user flow and preserve rollback or snapshot evidence.
-147
View File
@@ -1,147 +0,0 @@
# LumaOps server versus Gitea audit
This audit compares the supplied `lumaops_server.zip` and `LumaOps_gitea.zip`.
## Main result
The main Unraid working tree and the supplied Gitea checkout point to exactly the same commit:
```text
d42d4a7f08240c478d07466e3fabec654dc71367
```
Latest subject:
```text
Preserve colors across Aura zone updates
```
There is therefore no source-version drift at the root of the live LumaOps folder.
## Root Git repository
The root `.git` directory should remain in place. It enables:
- exact-SHA verification;
- controlled fetch and reset;
- a reliable previous-version reference;
- rollback without copying a second complete source tree.
The archived server copy showed one root status difference for `scripts/unraid-hardware-setup.sh`: file mode `100755 → 100644`. This is consistent with Unix executable bits being lost during ZIP handling. The file content did not differ. Check the executable bit directly on Unraid before deployment.
## Origin URL mismatch to resolve
The supplied server root uses:
```text
ssh://git@127.0.0.1:222/NuklearRabbit/LumaOps.git
```
The supplied Gitea checkout uses:
```text
https://gitea.itworx.tech/Jens/LumaOps.git
```
Although both archives currently point to the same commit, these are different
repository paths. Before the first ForgeFlow deployment, choose the server-
reachable URL for the authoritative `Jens/LumaOps` repository, for example an
SSH URL through `127.0.0.1:222` when Gitea runs on the same Unraid host.
ForgeFlow 0.4 detects this mismatch. Deployment is blocked unless the profile
matches the existing origin or **Align an existing server origin to this URL**
is explicitly enabled.
## Runtime and persistent paths
The server copy contains runtime data that must not be replaced by source updates:
- `appdata/`
- `data/`
- `logs/`
- `.env` and application-specific configuration
The repository `.gitignore` already excludes the principal runtime paths. ForgeFlow's SSH strategy uses Git reset without `git clean`, so untracked persistent data remains in place.
## Compose and Unraid integration
The root `docker-compose.yml` is already suitable as the authoritative deployment definition. It includes:
- build context at the project root;
- container name `lumaops`;
- the Unraid `dockerman` label;
- a Web UI label;
- an Unraid icon label;
- `${WEB_PORT:-1223}:${APP_PORT:-8080}`;
- persistent relative volumes;
- USB, HID and I²C devices;
- a healthcheck.
ForgeFlow should use this existing Compose file rather than generate a replacement. Ports and complex device mappings belong in the repository's maintained Compose definition.
The supplied `.dockerignore` already excludes `.git`, so keeping the root Git
working tree does **not** copy Git history into the Docker build context. It does
not yet explicitly exclude the existing runtime/legacy folders `appdata/`,
`data/`, `logs/` and `source/`. Before the first production rebuild, add the
paths that are not build inputs:
```text
appdata/
data/
logs/
source/
.forgeflow/
```
ForgeFlow 0.4 detects existing preserved paths and nested Git repositories that
are missing from `.dockerignore` and reports them as a preflight warning. The
tool does not silently edit a source-controlled `.dockerignore`; the correction
should be committed to Gitea so every deployment uses the same build context.
## Nested `source/` repository
The server archive also contains a nested Git working tree under:
```text
source/
```
Its HEAD is:
```text
b746a52af1613f4291235f5e8165b8197a269a79
```
It was ahead of its own upstream and included rebase metadata in the supplied archive. The root Compose file uses build context `.` and does not reference `source/`. This strongly indicates that `source/` is an abandoned or historical checkout rather than the active deployment source.
ForgeFlow reports this as a nested-repository warning and does not remove it automatically.
Recommended migration:
1. Back up `/mnt/user/appdata/lumaops`.
2. Verify on Unraid that `docker compose config` uses the root project.
3. Stop changing files in `source/`.
4. Rename it temporarily to `source.legacy-backup`.
5. Rebuild and test LumaOps from the root.
6. Remove the legacy copy only after a successful validation period.
Do not delete the root `.git` directory. Also do not delete the nested `source/`
directory as part of the first ForgeFlow test. Treat its cleanup as a separate,
backed-up migration after the root deployment and rollback have both been
validated.
## Recommended ForgeFlow profile
```text
Provider: SSH / Unraid
Server folder: lumaops
Branch: main
Compose mode: Repository/server Compose
Compose file: docker-compose.yml
Clone URL: the Git URL reachable from Unraid
Healthcheck: the existing LumaOps health URL, when exposed
Preserve paths: .env, appdata, data, logs, config, compose.override.yml
```
No folder rename is required for LumaOps because `lumaops` already aligns with the repository name.
+26
View File
@@ -0,0 +1,26 @@
# Mutation and reconciliation model
ForgeFlow separates observation from state changes at the API boundary.
## Discovery
`scanServerInventory()` and `discoverServerWorkloads()` collect Docker, Compose, DockerMan and Git evidence. They may write diagnostic logs, but they do not save, update or delete deployment profiles and do not change containers.
## Reconciliation planning
`planServerInventoryReconciliation()` returns a content-addressed plan containing:
- exact links that may be added;
- existing profiles whose observed metadata may be refreshed;
- stale profiles that require review;
- ambiguous workloads that block automatic application.
The plan identifier changes whenever its proposed scope changes.
## Reconciliation application
`reconcileServerInventory()` requires the exact reviewed plan identifier. It rescans the server and refuses a stale plan. Before writing configuration it creates a private recovery snapshot. Stale profiles are reported but never removed automatically.
## Direct mutations
Manual linking, unlinking, deploy-key rotation, deployment and rollback remain separate explicit commands. Each must append an audit event with repository, profile, operation identifier and result. Destructive commands need a dedicated confirmation flow and recovery path.
+78
View File
@@ -0,0 +1,78 @@
# ForgeFlow 1.0 production-readiness evidence
## 1. Scope and history
The professionalization work started from `64ca267` on `main`. It preserves the
0.10.0 compatibility baseline and deliberately creates no 1.0 tag or public
release. The commits and their exact SHAs remain the authoritative audit trail.
## 2. Deployment safety
Server pull uses repository-scoped read-only deploy keys, exact commit SHAs,
pinned SSH/Gitea host identities, Compose validation, health evidence and bounded
rollback. Rotation is transactional and revocation requires reviewed impact and
recovery evidence. Direct Copy and monitor-only remain explicit alternatives.
## 3. Inventory and reconciliation
Canonical deployment identity combines repository, branch, server, environment,
Compose project/root, runtime labels, container, live SHA and profile. Duplicate,
stale, ambiguous, orphan and historical evidence has persistent content-addressed
review decisions. Discovery never deletes, stops or rewrites a workload.
## 4. Architecture
Renderer, IPC and Unraid responsibilities are split by domain. The generated
architecture audit currently reports zero source files above 750 or 1,000 lines.
Runtime schemas, bounded IPC capabilities, operation IDs and explicit error
contracts protect the process boundary.
## 5. Repository assurance
Git Validator 2.0 covers security, reproducibility, governance, collaboration,
performance/hygiene and release readiness. Minimal, Standard, Strict, Production
and custom policies support accountable expiring suppressions, trend history and
reviewable JSON/Markdown/HTML reports. Repairs always require preview and never
commit or push automatically.
## 6. Automated verification
The Node suite includes real temporary Git remotes and an isolated production
acceptance harness. Playwright adds 36 renderer cases across six viewport/theme/
motion/scaling projects. Failure artifacts contain screenshots, traces, video,
console events, DOM, fixture details and test identity.
## 7. Coverage and dependencies
Coverage increased from 69.74% statements/lines, 68.82% functions and 55.38%
branches to 81.48%, 82.07% and 65.59%, respectively. The enforced gates are now
75/75/75/65 and are documented in `COVERAGE_POLICY.md`. Production dependencies have zero known
audit vulnerabilities. Remaining development findings belong to current upstream
ESLint/electron-builder toolchains and are assessed in `DEPENDENCY_AUDIT.md`.
## 8. UX and accessibility
Dark and light themes use the same semantic hierarchy, restrained project-signal
motion and status text that never depends on color alone. Deployment cards expose
container, repository, environment, commit parity and health distinctly. Dense
inventories, long names, keyboard focus, dialogs, reduced motion and high scaling
are part of the automated matrix.
## 9. Packaging and updating
Windows installer and portable packaging use deterministic names; old `dist`
versions are pruned after every successful build. Publication stays draft until
installer, portable, checksums, provenance and CycloneDX SBOM are complete. Binary
updates verify the exact release asset, executable format and published SHA-256
before download staging and again before replacement. Authenticode is optional and
is not a release or updater dependency for this personal/internal application.
## 10. Release decision
No open P0 or P1 technical issue is known after the final quality, browser,
acceptance, signing and packaging gates. The technically correct status is:
`TECHNICALLY_COMPLETE`
There is no paid certificate or external signing-service dependency. Windows may
show its normal unknown-publisher warning during first installation.
+21
View File
@@ -0,0 +1,21 @@
# ForgeFlow 0.10.1
## Reliable certificate-free updates
- Windows installer and portable releases are supported without paid signing services.
- Packaged updates remain protected by exact Gitea release assets, PE validation and SHA-256 verification before staging and immediately before replacement.
- Old ForgeFlow versions are pruned from `dist` after each successful build.
## Deployment inventory correctness
- Repository matching is case-insensitive, so `Jens/Repo` and `jens/repo` refresh the same deployment profile.
- Running repository workloads are linked conservatively; third-party DockerMan applications remain visible as external monitoring-only workloads instead of generating hundreds of false repository problems.
- Historical and stopped duplicate definitions no longer require repetitive manual review.
- Shadowed automatic profiles are retired only after a recovery snapshot and a stable reviewed reconciliation plan.
- Live runtime, container health and commit evidence are refreshed before readiness is reported.
## Server pull verification
- Existing running repository workloads can receive repository-scoped read-only deploy keys without changing containers.
- The audit command supports compact inventory, reconciliation and access evidence for operational verification.
- Release acceptance no longer assumes an external Authenticode certificate while retaining checksum, provenance and SBOM checks.
+10
View File
@@ -0,0 +1,10 @@
# ForgeFlow 0.10.10
## Complete server-pull deployment repair
- Missing repository-scoped read-only deploy keys can be provisioned and verified from Unraid against the exact Gitea branch.
- A repository deployment root can now remain above its Compose working directory without breaking workload recognition or being overwritten by inventory refresh.
- Nested Compose files are preserved as repository-relative deployment paths, including Ludarium, Launchpad and ITWorx MCP Hub layouts.
- The **Fix write access** action now receives its permission-report parser correctly instead of reporting a false write-access failure.
- Server-pull preflight proves every required deployment file at the exact Gitea commit before any container activation starts.
- Runtime secrets remain in server-side `.env` files and preserved appdata paths; no secret values are written to Git or diagnostic output.
+10
View File
@@ -0,0 +1,10 @@
# ForgeFlow 0.10.11
## Resilient repository refresh and consistent server pull
- Temporary Gitea list failures now use the in-session **last-known-good** repository inventory while local and server state continue to refresh. The UI clearly reports that remote data is stale.
- A **closed output pipe** from a detached parent process is no longer treated as a fatal desktop-app exception.
- Server pull, deploy-key verification, deployment, rollback and metadata now consistently prefer the verified **linked checkout origin** over a stale URL detected earlier on the server.
- Repository-scoped **read-only deploy key** checks remain fail-closed; a changed SSH host still requires explicit trust and access reconfiguration.
- The local **browser test server** now has a dedicated port and identity endpoint, preventing another localhost application from being mistaken for ForgeFlow.
- All 42 responsive browser flows pass across dark/light, compact/wide and reduced-motion configurations.
+12
View File
@@ -0,0 +1,12 @@
# ForgeFlow 0.10.12
## Faster awareness with stricter deployment truth
- Repository refreshes are **coalesced** and briefly cache Gitea inventory and workspace discovery; a manual refresh remains fully forced and file changes arriving mid-refresh receive one trailing refresh.
- Server discovery reuses its Docker and Compose evidence for existing deployment profiles instead of opening a separate SSH session for every linked workload.
- Deployment status only claims **exact Gitea commit parity** after comparing a concrete branch SHA with the live server SHA; matching repository provenance alone is no longer sufficient.
- Container discovery uses **batched Docker inspect** with a safe per-container fallback when a container disappears during the scan.
- Active Gitea and SSH deployment polling uses **bounded worker pools**, improving multi-deployment latency without flooding external services.
- A **stopped container** can no longer be marked healthy because another process answers on its previous healthcheck port.
- Large repository and server-inventory lists use offscreen rendering containment to reduce layout and paint work.
- Inventory diagnostics now include scan and state-refresh durations, and Gitea bulk verification fails fast after a confirmed connectivity outage.
+18
View File
@@ -0,0 +1,18 @@
# ForgeFlow 0.10.13
## Veilige synchronisatie en aantoonbare release-integriteit
- **Gitea workspace sync** toont eerst de exacte additions, wijzigingen en deletions ten opzichte van de actuele upstream-SHA. Lokale commits worden beschermd in een recovery branch; staged, unstaged en untracked werk gaat naar een stash. Genegeerde runtimebestanden blijven onaangeroerd.
- Read-only achtergrondfetch houdt `ahead` en `behind` actueel zonder projectbestanden automatisch te wijzigen. Interval `0` schakelt netwerkfetch volledig uit.
- Stale deployment links blokkeren niet langer de automatische, bewijsgebaseerde koppeling van de werkelijk draaiende vervangende workload.
- SSH-hostidentiteit wordt vóór het verzenden van credentials getoond en bij bevestiging exact vastgepind. Gitea-tokens vereisen HTTPS, behalve bij expliciete loopbackontwikkeling.
- Packaged updates vereisen een **Ed25519-signed release manifest** dat versie, tag, broncommit, artifactnaam, bytegrootte en SHA-256 bindt aan de ingebouwde publisher key. Hiervoor is geen betaald certificaat of Azure-dienst nodig.
- Diagnostische bundels exporteren geen ruwe remote output meer. Untracked diffs kunnen geen junction of symlink buiten de repository volgen en zijn begrensd op bestandsgrootte.
- De Git-toolsgrid behoudt nu de volledige inhoudshoogte binnen zijn eigen scrollvlak; workspace sync en troubleshooting overlappen niet meer. De demo bridge ondersteunt dezelfde recoveryflow als de desktopapp.
- Repositorymonitoring, deploymentpolling, Docker-inspect en SSH-verbindingen gebruiken begrensde paralleliteit en hergebruik waar dat veilig is.
## Verificatie
- Volledige Node-testset, coveragepoort, architectuuraudit en dependency-audit.
- 72 browserflows over dark/light, compact/desktop/wide, 100150% schaal en reduced motion.
- Windows installer en portable build, SHA-256-sidecars, provenance, CycloneDX-SBOM en ondertekend releasemanifest.
+15
View File
@@ -0,0 +1,15 @@
# ForgeFlow 0.10.14
## Betere uitleg en een stabiele repositorywerkruimte
- Een nieuw doorzoekbaar **Help center** legt de belangrijkste workflows stap voor stap uit: eerste configuratie, changes en commits, Gitea workspace sync, deploymentdetectie, exacte serverdeployments, deploykeys, Git Validator, updates en diagnose.
- Contextuele help vanuit **Gitea workspace sync** opent onmiddellijk de relevante uitleg. De instructies maken expliciet wat ForgeFlow wijzigt, welke recovery ForgeFlow vooraf maakt en welke genegeerde runtimebestanden onaangeroerd blijven.
- De variabele **repository context** is samengebracht in één structurele zone. Quick actions, Local → Gitea → Server-status en gekoppelde deployments kunnen daardoor niet langer over de tabnavigatie of inhoud heen schuiven.
- Smalle werkruimtes gebruiken gecontroleerde **horizontal tab navigation**. Elke tab behoudt zijn volledige label en blijft bereikbaar zonder dat tekst door andere bedieningselementen loopt.
- Het Help center heeft een eigen premium, responsieve presentatie met categorieën, zoekresultaten, uitklapbare stappen, veiligheidsnotities en motion-safe projectillustratie.
## Verificatie
- Volledige Node-testset en statische renderercontroles.
- **84 browser flows** over dark/light, compact/desktop/wide, 100150% schaal en reduced motion; de drie tijdens een semantische testaanpassing geraakte flows zijn daarna opnieuw groen uitgevoerd.
- Extra layoutasserties bewijzen dat repository context, tabs en tabinhoud elkaar niet overlappen bij 1024 × 768.
+15
View File
@@ -0,0 +1,15 @@
# ForgeFlow 0.10.15
## Veilige exacte workspace-sync en robuustere updates
- **Workspace Sync** brengt een repository gecontroleerd naar de exacte Gitea-commit zonder lokale wijzigingen stilzwijgend terug naar de server te sturen. Lokale commits krijgen een recovery branch en gewijzigde of niet-getrackte bestanden worden in een expliciete ForgeFlow-quarantaine bewaard.
- Elke quarantaine krijgt een lokaal **Codex review manifest** met bron- en doelcommit, recovery branch, stash-identiteit en betrokken bestanden. Quarantainestashes kunnen niet via de normale ForgeFlow-herstelactie in één keer worden teruggezet; eerst moet de inhoud gericht worden nagekeken.
- ForgeFlow behandelt `forgeflow/recovery-*` branches als **local-only** en weigert ze via de normale pushactie te publiceren, zodat herstelmateriaal niet per ongeluk opnieuw in Gitea terechtkomt.
- De source updater voert checksum- en Git-working-tree-preflight uit **voordat** ForgeFlow de update aan de externe helper overdraagt. Een Git-checkout wordt niet meer destructief met een bronarchief overschreven.
- De Windows binary updater controleert het nieuwe uitvoerbare bestand vóór de ownership handoff, verifieert na update dat ForgeFlow werkelijk blijft draaien en kan bij een mislukte portable update de vorige executable herstellen en opnieuw starten.
- Een geslaagde installer-update waarbij alleen de automatische herstart mislukt, wordt correct als geïnstalleerd gerapporteerd met een duidelijke instructie om ForgeFlow handmatig te starten.
## Verificatie
- Managed full validation op de sync/updater-hardening is geslaagd op de exacte feature-head en opnieuw als verplichte pull-requestvalidatie vóór merge.
- De merge naar `main` is uitgevoerd via de beschermde pull-requestflow; de releaseversie wordt afzonderlijk gevalideerd voordat 0.10.15 wordt gepubliceerd.
+9
View File
@@ -0,0 +1,9 @@
# ForgeFlow 0.10.2
## Packaged updater origin repair
- Gitea release assets that expose an internal HTTP `ROOT_URL` are safely rewritten to ForgeFlow's configured public HTTPS Gitea origin.
- Authentication remains same-origin: the Gitea token is never forwarded to an internal address, CDN or unrelated redirect target.
- Published Windows executables are still validated as PE files and against their release SHA-256 sidecars before staging.
- The live authenticated updater acceptance downloads the exact published installer and proves its byte count and SHA-256 digest.
+19
View File
@@ -0,0 +1,19 @@
# ForgeFlow 0.10.3
## Responsive large workspaces
- Repository monitoring checks up to four local working trees concurrently while retaining overlap protection and per-repository pause controls.
- Global search, repository filtering and the command palette debounce full interface renders during rapid typing.
- Commit-message input updates readiness and action controls in place, preserving focus and cursor responsiveness.
- Interactive project illustrations and diff atmosphere effects perform at most one layout update per animation frame.
## Git Validator reliability
- Git Validator and every long repository tab now retain an explicit vertical scroll owner across compact, desktop and wide layouts.
- Standard, Strict and Production policies correctly treat configured warning severities as blockers; Minimal remains error-only.
- Documented suppressions no longer reduce the hygiene score or remain counted as active blockers.
- Repair requests are rescanned immediately before preview or execution, preventing stale or forged fixes.
## Verification
- Full quality gate, coverage thresholds and all responsive browser scenarios pass for this release.
+9
View File
@@ -0,0 +1,9 @@
# ForgeFlow 0.10.4
## Permanent packaged updater handshake repair
- Binary and source update helpers now use a Windows PowerShell 5.1-compatible atomic status replacement with a real temporary backup path.
- A deterministic overwrite fallback preserves lifecycle reporting on filesystems that do not implement atomic replacement.
- The binary helper exposes a side-effect-free handshake-only verification mode exercised by the real Windows PowerShell executable during tests.
- existing installations with the defective helper require this one-time installer upgrade; every subsequent packaged update uses the repaired helper automatically.
- Startup failures retain request-scoped status and helper-log evidence instead of collapsing into an unexplained exit-code message.
+10
View File
@@ -0,0 +1,10 @@
# ForgeFlow 0.10.5
## Consistent repository and deployment links
- Every repository workspace now shows all configured deployment environments in a compact, directly actionable strip.
- The repository deployment tab includes every detected server workload linked to that repository, including its container, Compose identity, server and runtime state.
- A workload is only labelled linked when its repository and resolved profile both exist in the current ForgeFlow configuration.
- Stale or incomplete metadata is shown as **Link unresolved** and routed through explicit reconciliation instead of being presented as a healthy deployment.
- The global deployment inventory links directly to the correct repository deployment profile.
- Responsive browser coverage now verifies valid links, unresolved links, repository navigation and scrolling across dark/light and scaled layouts.
+9
View File
@@ -0,0 +1,9 @@
# ForgeFlow 0.10.6
## Permanent Windows updater launch repair
- ForgeFlow no longer launches hidden PowerShell update helpers with Node's defective Windows `detached` process mode.
- Binary and source updater processes remain hidden, are explicitly unreferenced after their verified handshake, and continue independently when ForgeFlow closes.
- A real Windows regression test now exercises the exact production Node spawn options instead of using a different process API.
- Startup is still fail-closed: ForgeFlow remains open unless the request-scoped helper status reaches `started`.
- Versions 0.10.4 and 0.10.5 need a one-time direct installation of 0.10.6 because their installed launcher cannot execute its own helper; updates after 0.10.6 use the repaired path.
+10
View File
@@ -0,0 +1,10 @@
# ForgeFlow 0.10.7
## Reliable server-to-repository recognition
- Live, running workloads with one unique exact provenance or runtime-identity match are now linked automatically during normal server discovery.
- Automatic adoption creates only ForgeFlow configuration and observed state; it performs no container changes and never automatically removes stale profiles.
- Ambiguous, duplicate, external and monitoring-only workloads remain behind explicit **Review & link** confirmation.
- Every linked repository now displays an `S` deployment badge with its profile count in the repository sidebar.
- The repository release rail reports **Linked** with container and server identity even when a legacy workload has no verifiable live commit yet.
- DevRunbook-style DockerMan deployments therefore show the same linked relationship in Deployments, the repository sidebar and the repository workspace.
+9
View File
@@ -0,0 +1,9 @@
# ForgeFlow 0.10.8
## Self-contained checksum verification
- Binary and source update helpers no longer depend on the optional PowerShell `Get-FileHash` cmdlet.
- Both helpers calculate checksums directly with the built-in .NET SHA-256 implementation.
- A real Windows regression test clears `PSModulePath` and verifies the downloaded binary successfully in that minimal environment.
- The helper still validates the exact published checksum before waiting for ForgeFlow to exit or changing installed files.
- This release retains the reliable non-detached launcher and server-to-repository recognition improvements from 0.10.6 and 0.10.7.
+10
View File
@@ -0,0 +1,10 @@
# ForgeFlow 0.10.9
## Reliable deployment inventory and preflight
- Unraid inventory now includes containers without healthchecks. Docker's complete JSON state is parsed safely instead of using a failing Go-template lookup.
- ForgeFlow is single-instance: opening it again focuses the existing window, preventing concurrent inventory scans and configuration writes.
- Server pull verifies required Compose files or the Dockerfile at the exact Gitea commit before any deployment operation starts.
- Server-pull verification now separates deploy-ready access from optional live-SHA and runtime-health evidence. A recoverable workload is no longer shown as blocked merely because parity is not yet provable.
- Deployment cards and audit output show concrete access blockers and non-blocking warnings instead of a generic incomplete result.
- All discovery, verification and preflight checks remain non-destructive; no containers are changed during these checks.
+2 -2
View File
@@ -17,9 +17,9 @@ for every repository. ForgeFlow now:
Example:
```text
Default project root: C:\Users\Jens\Projects
Default project root: C:\Users\your-name\Projects
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
+2 -2
View File
@@ -48,9 +48,9 @@ Deployment profiles support:
- runtime-data preservation;
- 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
+56
View File
@@ -0,0 +1,56 @@
# Releasing ForgeFlow
ForgeFlow releases are built only from a clean, reviewed commit on Node 22 LTS.
## Quality gate
```powershell
npm ci
npm run quality
npm audit --omit=dev --audit-level=high
```
## Windows build — no paid services required
ForgeFlow is a personal/internal tool. The supported release path therefore has
no certificate, Azure or other paid-service dependency:
```powershell
npm run dist:win
```
Run `npm run signing:setup` once on the release workstation. It stores the
private Ed25519 key outside the repository and writes only its public key into
the packaged app. `npm run dist:win` then produces the installer and portable
executable, SHA-256 sidecars, CycloneDX SBOM, provenance and an Ed25519-signed
manifest bound to the exact source commit. The updater verifies the pinned
publisher key before trusting the artifact digest and verifies that digest again
immediately before replacing the installed executable.
Windows can display an `Unknown publisher` warning for an unsigned installer.
That warning concerns public publisher reputation; it does not prevent ForgeFlow
from installing or using its checksum-verified in-app updates. Authenticode can
be added later as an optional distribution convenience, but is not required for
correct operation.
## Atomic publication
`npm run release:binary` keeps the Gitea release in draft state while uploading
the installer, portable executable, two checksums, provenance, SBOM, signed
manifest and signature. It only publishes after all eight assets are present. A
failed upload leaves a draft rather than exposing an incomplete updater target.
The optional signing acceptance fixture can still validate the complete local
Authenticode chain without purchasing or retaining a certificate:
```powershell
npm run test:signing
```
This disposable fixture signs installer, portable, update-helper and uninstaller
stand-ins, requires an RFC 3161 timestamp, and proves rejection of a missing
timestamp, wrong publisher and a modified binary. Its certificate is removed
from the current-user certificate store after the test.
The disposable test certificate is removed from the current-user certificate
store after the test and is never used for a published build.
+1 -1
View File
@@ -94,7 +94,7 @@ health verification, diagnostic correlation and no arbitrary shell input.
- Windows installer/portable acceptance;
- macOS/Linux package validation;
- code signing, notarization and signed updates;
- optional code signing/notarization for future public distribution;
- dependency/secret/package scans;
- accessibility review;
- hundreds-of-repositories performance tests;
+12 -7
View File
@@ -23,7 +23,7 @@ flexibility.
- session-only fallback when OS encryption is unavailable;
- token omitted from renderer-visible public state;
- 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;
- service URLs reject embedded user credentials;
- no token is required by setup/build scripts or documentation.
@@ -108,9 +108,9 @@ included model uses:
- atomic non-secret status JSON;
- previous-SHA recording and non-zero failure exits.
## Remaining release hardening
## Optional and future release hardening
- code-sign packages and signed updates;
- optional code signing if ForgeFlow is ever distributed publicly;
- validate private CA/TLS behavior in the target network;
- dependency, secret and binary scans in CI;
- package-level IPC/navigation regression tests;
@@ -124,9 +124,14 @@ included model uses:
- SSH passwords and private-key passphrases use Electron `safeStorage`;
- diagnostics receive those runtime secrets only for redaction and never export
encrypted credential fields;
- SSH deployment requires a pinned host-key fingerprint;
- SSH host identity is previewed without credentials and authenticated sessions
require the exact user-confirmed pinned fingerprint;
- remote folders and Compose paths are validated against traversal;
- tracked server-side changes block exact-SHA reset;
- updater tokens are sent only to the configured Gitea origin;
- update archives are checksummed and validated by the full local quality gate;
- source backup is restored when an update fails.
- 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;
- packaged updates require a publisher-signed Ed25519 manifest that binds the
source commit, artifact identity, byte length and SHA-256 digest;
- packaged update bytes are rehashed immediately before apply;
- integrated source replacement is disabled until source archives carry the
same independent publisher signature.
+33 -3
View File
@@ -1,8 +1,37 @@
# Test matrix
## Automated in v0.4.0
## Automated baseline (0.10.x)
The suite contains 59 passing tests.
The quality chain contains more than 230 Node and browser acceptance cases. The
latest Windows source run completed without failures and retains one explicitly
Bash-dependent skip. `npm run coverage` enforces 75% lines/statements/functions
and 65% branches; the measured hardening baseline is 81.48% statements/lines,
82.07% functions and 65.59% branches. See `COVERAGE_POLICY.md` for the
non-gamed branch policy.
`npm run quality` is the local equivalent of `.gitea/workflows/quality.yml` and
runs source verification, ESLint, the complete suite and coverage on Node 22 LTS.
Production dependencies are separately checked with `npm audit --omit=dev
--audit-level=high`.
### Server safety and reconciliation
- inventory discovery is read-only and byte-stable for configuration;
- reconciliation requires a content-addressed preview plan and recovery snapshot;
- automatic linking requires unique exact provenance/runtime identity;
- server-pull verification checks Gitea branch, read-only deploy-key ID, pinned
host/key fingerprints, remote/live SHA, Compose evidence, runtime and health;
- a fresh access verification is mandatory immediately before server-pull deploy;
- writable or missing deploy keys fail closed.
### Renderer regression matrix
Playwright runs 36 cases across 1120×720, 1440×900 and 1920×1080, dark and
light themes, reduced motion, and simulated 100%, 125% and 150% Windows scaling.
It checks console/page errors, accessible names, labels, heading structure,
horizontal overflow, viewport containment, dialogs, keyboard focus, updater and
deployment failure evidence. CI retains screenshots, video, trace, console JSON,
DOM HTML and fixture context on failure.
### Git and repository behavior
@@ -76,7 +105,8 @@ The suite contains 59 passing tests.
- status endpoint through the real reverse proxy;
- deployment lock, failed healthcheck and rollback;
- diagnostic ZIP inspection after a deliberately failed deployment;
- unsigned installer and portable build on Windows;
- locally test-signed installer, portable, helper and uninstaller fixtures with
RFC 3161 timestamp plus wrong-publisher, missing-timestamp and tamper rejection;
- keyboard-only and screen-reader smoke test.
## Renderer smoke target
+20 -26
View File
@@ -2,42 +2,36 @@
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**
2. **Download update**
3. **Apply & restart**
Update a source checkout through Git instead:
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:
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.
The in-app updater remains available for signed packaged Windows releases.
## Packaged Windows updates
ForgeFlow 0.9.1 and newer use authenticated Gitea release assets when running from the installer or portable executable. The updater selects the installer or portable artifact that matches the current installation mode, requires its `.sha256` sidecar, validates the Windows executable header and SHA-256 digest, then verifies the digest again immediately before applying it. An external PowerShell helper waits for ForgeFlow to exit, installs or replaces the executable and restarts it.
ForgeFlow uses authenticated Gitea release assets when running from the installer or portable executable. The updater selects the artifact that matches the current installation mode and requires its `.sha256` sidecar. From version 0.10.13 onward it also requires an Ed25519-signed release manifest. The embedded public key verifies that manifest before ForgeFlow trusts the artifact name, byte length, exact source commit or SHA-256 digest. The digest is checked again immediately before applying the update.
`Publish-ForgeFlow-Release.ps1` now treats source and binaries as one release transaction. By default it pushes the validated source, builds the exact published commit and uploads all four required assets:
`Publish-ForgeFlow-Release.ps1` treats source and binaries as one release transaction. It pushes the validated source, builds the exact published commit and uploads eight required assets:
- `ForgeFlow-Setup-<version>-win-x64.exe`
- `ForgeFlow-Setup-<version>-win-x64.exe.sha256`
- `ForgeFlow-Portable-<version>-win-x64.exe`
- `ForgeFlow-Portable-<version>-win-x64.exe.sha256`
- `ForgeFlow-<version>-provenance.json`
- `ForgeFlow-<version>-sbom.cdx.json`
- `ForgeFlow-<version>-release-manifest.json`
- `ForgeFlow-<version>-release-manifest.json.sig`
Run `npm run signing:setup` once on the release workstation. The private Ed25519 key stays outside the repository in ForgeFlow's user-data folder. This independent publisher signature is free; optional Authenticode can still be added later for Windows reputation.
Use `-SkipBinaryRelease` only when intentionally publishing source without enabling packaged auto-update.
@@ -59,17 +53,17 @@ The binary publisher refuses to upload when local `HEAD` differs from the config
Extract the complete source ZIP so this file exists:
```text
C:\Users\Jens\Downloads\ForgeFlow-<version>\ForgeFlow\package.json
C:\Users\your-name\Downloads\ForgeFlow-<version>\ForgeFlow\package.json
```
Run:
```powershell
cd C:\Users\Jens\Downloads\ForgeFlow-<version>\ForgeFlow
cd C:\Users\your-name\Downloads\ForgeFlow-<version>\ForgeFlow
Set-ExecutionPolicy -Scope Process Bypass
.\Publish-ForgeFlow-Release.ps1
```
The script installs dependencies, runs the complete quality gate, clones `git@gitea.itworx.tech:Jens/ForgeFlow.git` into a temporary folder, mirrors the validated source without `.git`, `node_modules`, `dist` or release archives, commits it and pushes `main`. It then compares local `HEAD` with `git ls-remote`, builds the exact published checkout and uploads the installer, portable executable and both checksums to the matching Gitea release. Publication fails when either the source commit or any required binary asset cannot be verified.
The script installs dependencies, runs the complete quality gate, clones `git@gitea.itworx.tech:Jens/ForgeFlow.git` into a temporary folder, mirrors the validated source without `.git`, `node_modules`, `dist` or release archives, commits it and pushes `main`. It then compares local `HEAD` with `git ls-remote`, builds the exact published checkout and uploads all binaries, checksums and signed release evidence to the matching Gitea release. Publication fails when either the source commit, publisher signature or any required asset cannot be verified.
Keep the currently installed older ForgeFlow source folder untouched until the built-in updater test is complete.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 110 KiB

+67
View File
@@ -0,0 +1,67 @@
import js from "@eslint/js";
export default [
{
ignores: ["build/**", "dist/**", "node_modules/**", ".playwright-mcp/**", "SOURCE_MANIFEST.txt"],
},
{
files: ["**/*.{js,cjs,mjs}"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
Buffer: "readonly", clearInterval: "readonly", clearTimeout: "readonly", console: "readonly",
document: "readonly", fetch: "readonly", FormData: "readonly", globalThis: "readonly",
process: "readonly", queueMicrotask: "readonly", requestAnimationFrame: "readonly",
setInterval: "readonly", setTimeout: "readonly", URL: "readonly", URLSearchParams: "readonly",
window: "readonly", confirm: "readonly", localStorage: "readonly", structuredClone: "readonly",
},
},
rules: {
...js.configs.recommended.rules,
"no-control-regex": "off",
"no-empty": ["error", { allowEmptyCatch: true }],
"no-undef": "off",
"no-unused-vars": "off",
"no-useless-escape": "off",
"no-eval": "error",
"no-implied-eval": "error",
eqeqeq: ["error", "always", { null: "ignore" }],
},
},
{
// The main process and shared modules are plain CommonJS with an explicit
// dependency graph, so undefined identifiers there are always real bugs
// (missing require, missing injected dependency) rather than a global that
// another script tag happens to define.
files: ["src/main/**/*.cjs", "src/shared/**/*.cjs", "main.cjs", "preload.cjs"],
languageOptions: {
sourceType: "commonjs",
globals: {
require: "readonly", module: "writable", exports: "writable",
__dirname: "readonly", __filename: "readonly",
Buffer: "readonly", process: "readonly", console: "readonly",
setTimeout: "readonly", clearTimeout: "readonly",
setInterval: "readonly", clearInterval: "readonly", setImmediate: "readonly",
queueMicrotask: "readonly", structuredClone: "readonly", globalThis: "readonly",
URL: "readonly", URLSearchParams: "readonly", fetch: "readonly",
FormData: "readonly", Blob: "readonly",
AbortController: "readonly", AbortSignal: "readonly",
TextEncoder: "readonly", TextDecoder: "readonly",
},
},
rules: {
"no-undef": "error",
// Also catches code that a refactor left behind, such as a value computed
// from a dependency that is no longer injected.
"no-unused-vars": ["error", { args: "none", caughtErrors: "none", ignoreRestSiblings: true }],
},
},
{
files: ["tests/**/*.mjs"],
rules: {
"no-regex-spaces": "off",
"no-unsafe-finally": "off",
},
},
];
@@ -0,0 +1,90 @@
name: ForgeFlow approved deploy
on:
workflow_dispatch:
inputs:
repository:
description: Signed allowlisted deployment target (owner/repository)
required: true
type: string
environment:
description: Allowlisted ForgeFlow environment
required: true
type: string
commit_sha:
description: Exact approved commit SHA
required: true
type: string
request_id:
description: Immutable AppOps request identifier
required: true
type: string
approval_id:
description: AppOps approval identifier
required: true
type: string
approval_fingerprint:
description: Immutable AppOps approval fingerprint
required: true
type: string
evidence_issued_at:
description: Signed evidence UNIX timestamp
required: true
type: string
evidence_signature:
description: Base64 Ed25519 signature over the exact deployment evidence
required: true
type: string
concurrency:
group: forgeflow-approved-${{ inputs.repository }}-${{ inputs.environment }}
cancel-in-progress: false
jobs:
deploy:
runs-on: forgeflow
steps:
- name: Validate signed deployment inputs
shell: bash
env:
FF_REPOSITORY: ${{ inputs.repository }}
FF_ENVIRONMENT: ${{ inputs.environment }}
FF_COMMIT_SHA: ${{ inputs.commit_sha }}
FF_REQUEST_ID: ${{ inputs.request_id }}
FF_APPROVAL_ID: ${{ inputs.approval_id }}
FF_APPROVAL_FINGERPRINT: ${{ inputs.approval_fingerprint }}
FF_EVIDENCE_ISSUED_AT: ${{ inputs.evidence_issued_at }}
FF_EVIDENCE_SIGNATURE: ${{ inputs.evidence_signature }}
run: |
set -Eeuo pipefail
[[ "$FF_REPOSITORY" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]
[[ "$FF_ENVIRONMENT" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]]
[[ "$FF_COMMIT_SHA" =~ ^[0-9a-fA-F]{40,64}$ ]]
[[ "$FF_REQUEST_ID" =~ ^appr-[A-Za-z0-9._-]{1,75}$ ]]
[[ "$FF_APPROVAL_ID" == "$FF_REQUEST_ID" ]]
[[ "$FF_APPROVAL_FINGERPRINT" =~ ^[0-9a-f]{64}$ ]]
[[ "$FF_EVIDENCE_ISSUED_AT" =~ ^[0-9]{10,11}$ ]]
[[ "$FF_EVIDENCE_SIGNATURE" =~ ^[A-Za-z0-9+/]{86}==$ ]]
- name: Execute root-owned verified deployment
shell: bash
env:
FF_REPOSITORY: ${{ inputs.repository }}
FF_ENVIRONMENT: ${{ inputs.environment }}
FF_COMMIT_SHA: ${{ inputs.commit_sha }}
FF_REQUEST_ID: ${{ inputs.request_id }}
FF_APPROVAL_ID: ${{ inputs.approval_id }}
FF_APPROVAL_FINGERPRINT: ${{ inputs.approval_fingerprint }}
FF_EVIDENCE_ISSUED_AT: ${{ inputs.evidence_issued_at }}
FF_EVIDENCE_SIGNATURE: ${{ inputs.evidence_signature }}
run: |
set -Eeuo pipefail
sudo /usr/local/bin/forgeflow-deploy \
"$FF_REPOSITORY" \
"$FF_ENVIRONMENT" \
"$FF_COMMIT_SHA" \
"$FF_REQUEST_ID" \
"$FF_APPROVAL_ID" \
"$FF_APPROVAL_FINGERPRINT" \
"$FF_EVIDENCE_ISSUED_AT" \
"$FF_EVIDENCE_SIGNATURE"
+77 -1
View File
@@ -4,18 +4,29 @@ umask 027
# 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.
# 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 EVIDENCE_PUBLIC_KEY_FILE="/etc/forgeflow/evidence.pub"
readonly EVIDENCE_REPLAY_DIR="/var/lib/forgeflow-status/approved-requests"
readonly REPOSITORY="${1:-}"
readonly ENVIRONMENT="${2:-}"
readonly SHA="${3:-}"
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() {
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
}
(( $# == 3 || $# == 4 || $# == 8 )) || fail_usage
[[ "$REPOSITORY" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || fail_usage
[[ "$ENVIRONMENT" =~ ^[A-Za-z0-9._-]+$ ]] || 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.
(( (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=""
BRANCH=""
COMPOSE_FILE=""
@@ -75,6 +141,9 @@ write_status() {
temporary="${STATUS_FILE}.${$}.tmp"
json_string "$health" >/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; }
[[ "$previous_sha" =~ ^[0-9a-fA-F]{40,64}$ ]] || { echo "Invalid previous SHA for status output" >&2; return 1; }
cat > "$temporary" <<JSON
@@ -85,6 +154,10 @@ write_status() {
"commit_sha": "$live_sha",
"previous_sha": "$previous_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",
"health": "$health",
"last_exit_code": $exit_code
@@ -105,6 +178,9 @@ echo "ForgeFlow request: $REQUEST_ID"
echo "Target: $REPOSITORY / $ENVIRONMENT"
echo "Current SHA: $current_sha"
echo "Requested SHA: $SHA"
if [[ "$EVIDENCE_VERIFIED" == "true" ]]; then
echo "Approval: $APPROVAL_ID (signed evidence verified)"
fi
on_error() {
local exit_code=$?
+6 -2
View File
@@ -1,11 +1,15 @@
{
"repository": "jens/example-app",
"environment": "production",
"request_id": "3a6ed71c-d52d-4d8d-9678-96e0c9456a81",
"request_id": "appr-3a6ed71cd52d",
"commit_sha": "0123456789abcdef0123456789abcdef01234567",
"previous_sha": "89abcdef0123456789abcdef0123456789abcdef",
"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",
"last_exit_code": 0
}
+42 -1
View File
@@ -25,19 +25,39 @@ const {
UnraidDeploymentService,
} = require("./src/main/unraid-deployment-service.cjs");
const { AuditService } = require("./src/main/audit-service.cjs");
const { DeployKeyLifecycleService } = require("./src/main/deploy-key-lifecycle-service.cjs");
const { UnraidDeployKeyHost } = require("./src/main/unraid-deploy-key-host.cjs");
const { InventoryReviewService } = require("./src/main/inventory-review-service.cjs");
const { GitValidatorService } = require("./src/main/git-validator-service.cjs");
const {
ExternalToolsService,
} = require("./src/main/external-tools-service.cjs");
const { registerIpc } = require("./src/main/ipc.cjs");
const {
installOutputPipeGuards,
isBrokenPipeError,
} = require("./src/main/process-error-policy.cjs");
let mainWindow;
let repositoryMonitor;
let sshService;
let operationTimer;
let diagnostics;
let configStore;
let tray;
let quitCleanupStarted = false;
const reportBrokenOutputPipe = (error) => {
const report = diagnostics?.warning("process.output-pipe.closed", {
code: error?.code || null,
message: error?.message || "The parent output pipe was closed.",
});
report?.catch(() => {});
};
installOutputPipeGuards({ onBrokenPipe: reportBrokenOutputPipe });
const ownsSingleInstanceLock = app.requestSingleInstanceLock();
if (!ownsSingleInstanceLock) app.quit();
else app.on("second-instance", () => showMainWindow());
function broadcast(channel, payload) {
for (const window of BrowserWindow.getAllWindows()) {
@@ -168,6 +188,7 @@ function createWindow() {
app
.whenReady()
.then(async () => {
if (!ownsSingleInstanceLock) return;
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
@@ -211,6 +232,10 @@ app
await audit.initialize();
process.on("uncaughtException", (error) => {
if (isBrokenPipeError(error)) {
reportBrokenOutputPipe(error);
return;
}
diagnostics
?.error("process.uncaught-exception", error)
.finally(() => app.exit(1));
@@ -228,6 +253,7 @@ app
const repositories = new RepositoryService(store, git, gitea, diagnostics);
const deployments = new DeploymentService(store, gitea, git, diagnostics);
const ssh = new SshService({ store, diagnostics });
sshService = ssh;
const auditedOperationStates = new Set();
const reportOperationChange = (payload) => {
broadcast("operations:changed", payload);
@@ -238,6 +264,11 @@ app
) {
const key = `${operation.id}:${operation.status}`;
if (!auditedOperationStates.has(key)) {
// One entry per completed deployment, so the set is trimmed rather
// than kept for the lifetime of the process.
if (auditedOperationStates.size >= 500) {
auditedOperationStates.delete(auditedOperationStates.values().next().value);
}
auditedOperationStates.add(key);
notify(
`Deployment ${operation.status}`,
@@ -264,6 +295,13 @@ app
sourcePath: app.getAppPath(),
onOperationChange: reportOperationChange,
});
const deployKeys = new DeployKeyLifecycleService({
store,
gitea,
keyHost: new UnraidDeployKeyHost({ ssh }),
audit,
});
const inventoryReviews = new InventoryReviewService({ store, audit });
const updates = new UpdateService({
store,
gitea,
@@ -290,6 +328,7 @@ app
git,
gitea,
diagnostics,
store,
});
repositoryMonitor = new RepositoryMonitor({
store,
@@ -305,6 +344,8 @@ app
repositories,
deployments,
unraid,
deployKeys,
inventoryReviews,
ssh,
updates,
preflight,
@@ -406,7 +447,6 @@ app
});
})
.catch(async (error) => {
console.error("[startup]", error);
await diagnostics?.error("app.startup.failed", error);
await diagnostics?.flush();
app.exit(1);
@@ -417,6 +457,7 @@ app.on("before-quit", (event) => {
event.preventDefault();
quitCleanupStarted = true;
repositoryMonitor?.stop();
sshService?.closeAll();
if (operationTimer) clearTimeout(operationTimer);
Promise.resolve()
.then(() => diagnostics?.info("app.quitting", {}))
+1447 -72
View File
File diff suppressed because it is too large Load Diff
+44 -6
View File
@@ -1,6 +1,6 @@
{
"name": "forgeflow",
"version": "0.10.0",
"version": "0.10.15",
"private": true,
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
"main": "main.cjs",
@@ -10,21 +10,35 @@
"dev": "electron . --dev",
"demo": "node scripts/serve-demo.mjs",
"test": "node --test tests/*.test.mjs",
"lint": "eslint .",
"coverage": "c8 --check-coverage --lines 85 --functions 85 --branches 68 --statements 85 node --test tests/*.test.mjs && npm run coverage:modules",
"coverage:modules": "c8 report --check-coverage --per-file --include src/** --statements 60 --lines 60 --functions 50 --branches 36 --reporter=text-summary",
"verify": "node scripts/verify.mjs",
"dist:win": "electron-builder --win nsis portable && node scripts/write-release-checksums.mjs && node scripts/prune-dist.mjs",
"dist:win": "electron-builder --win nsis portable && node scripts/write-release-checksums.mjs && node scripts/sign-release-manifest.mjs && node scripts/verify-release-signatures.mjs && node scripts/prune-dist.mjs",
"dist:linux": "electron-builder --linux AppImage && node scripts/prune-dist.mjs",
"dist:mac": "electron-builder --mac dmg && node scripts/prune-dist.mjs",
"doctor": "node scripts/doctor.mjs",
"acceptance": "node scripts/acceptance.mjs",
"acceptance:isolated": "node --test tests/production-acceptance.test.mjs",
"architecture:audit": "node scripts/architecture-audit.mjs",
"test:browser": "playwright test",
"test:browser:ci": "playwright test --reporter=line,html",
"test:signing": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/test-authenticode-chain.ps1",
"signing:setup": "node scripts/setup-update-signing-key.mjs",
"connections:check": "electron scripts/validate-installed-connections.cjs",
"deployments:audit": "electron scripts/audit-installed-deployments.cjs",
"release:binary": "electron scripts/publish-binary-release.cjs",
"manifest": "node scripts/generate-source-manifest.mjs",
"check": "npm run verify && npm test"
"check": "npm run verify && npm run lint && npm test",
"quality": "npm run check && npm run coverage"
},
"devDependencies": {
"@eslint/js": "9.39.5",
"@playwright/test": "1.62.0",
"c8": "12.0.0",
"electron": "43.2.0",
"electron-builder": "26.15.3"
"electron-builder": "26.15.3",
"eslint": "9.39.5"
},
"build": {
"appId": "be.jenscaers.forgeflow",
@@ -37,6 +51,7 @@
"package.json",
"build/icon.png",
"build/icon.ico",
"build/update-signing-public.pem",
"docs/SETUP_GUIDE.md",
"docs/DIAGNOSTICS.md",
"docs/STATUS_ENDPOINT.md",
@@ -54,8 +69,9 @@
"scripts/apply-source-update.ps1",
"scripts/apply-binary-update.ps1",
"scripts/prune-dist.mjs",
"scripts/sign-release-manifest.mjs",
"docs/RELEASE_NOTES_0.4.0.md",
"docs/LUMAOPS_SERVER_AUDIT.md",
"docs/DEPLOYMENT_MIGRATION_EXAMPLE.md",
"docs/SSH_UNRAID_DEPLOYMENT.md",
"docs/RELEASE_NOTES_0.4.1.md",
"docs/RELEASE_NOTES_0.4.2.md",
@@ -92,7 +108,29 @@
"docs/RELEASE_NOTES_0.9.3.md",
"docs/RELEASE_NOTES_0.9.4.md",
"docs/RELEASE_NOTES_0.9.5.md",
"docs/RELEASE_NOTES_0.10.0.md"
"docs/RELEASE_NOTES_0.10.0.md",
"docs/RELEASE_NOTES_0.10.1.md",
"docs/RELEASE_NOTES_0.10.2.md",
"docs/RELEASE_NOTES_0.10.3.md",
"docs/RELEASE_NOTES_0.10.4.md",
"docs/RELEASE_NOTES_0.10.5.md",
"docs/RELEASE_NOTES_0.10.6.md",
"docs/RELEASE_NOTES_0.10.7.md",
"docs/RELEASE_NOTES_0.10.8.md",
"docs/RELEASE_NOTES_0.10.9.md",
"docs/RELEASE_NOTES_0.10.10.md",
"docs/RELEASE_NOTES_0.10.11.md",
"docs/RELEASE_NOTES_0.10.12.md",
"docs/RELEASE_NOTES_0.10.13.md",
"docs/RELEASE_NOTES_0.10.14.md",
"docs/RELEASE_NOTES_0.10.15.md",
"docs/CURRENT_STATE.md",
"docs/MUTATION_MODEL.md",
"docs/RELEASING.md",
"docs/COVERAGE_POLICY.md",
"docs/DEPENDENCY_AUDIT.md",
"docs/PRODUCTION_READINESS_1.0.md",
"docs/ERROR_CODES.md"
],
"asarUnpack": [
"scripts/apply-binary-update.ps1"
+42
View File
@@ -0,0 +1,42 @@
import { defineConfig } from "@playwright/test";
const matrices = [
["compact-dark-100", 1120, 720, "dark", 1, false],
["desktop-light-125", 1440, 900, "light", 1.25, false],
["wide-dark-150", 1920, 1080, "dark", 1.5, false],
["compact-light-reduced", 1120, 720, "light", 1, true],
["desktop-dark-reduced", 1440, 900, "dark", 1.25, true],
["wide-light-100", 1920, 1080, "light", 1, false],
];
export default defineConfig({
testDir: "./tests/browser",
outputDir: "artifacts/browser",
timeout: 45_000,
expect: { timeout: 7_000 },
fullyParallel: false,
workers: process.env.CI ? 2 : 3,
reporter: [["line"], ["html", { outputFolder: "artifacts/browser-report", open: "never" }]],
use: {
baseURL: "http://127.0.0.1:41737",
screenshot: "only-on-failure",
trace: "retain-on-failure",
video: "retain-on-failure",
},
webServer: {
command: "node scripts/serve-demo.mjs",
url: "http://127.0.0.1:41737/__forgeflow_test_ready__",
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
projects: matrices.map(([name, width, height, theme, scale, reduced]) => ({
name,
metadata: { theme, scale, reduced },
use: {
viewport: { width, height },
deviceScaleFactor: scale,
colorScheme: theme,
reducedMotion: reduced ? "reduce" : "no-preference",
},
})),
});
+19 -3
View File
@@ -46,7 +46,7 @@ contextBridge.exposeInMainWorld(
applyUpdate: () => invoke('updates:apply'),
saveServer: (server, password = '', passphrase = '') => invoke('server:save', { server, password, passphrase }),
deleteServer: (serverId) => invoke('server:delete', { serverId }),
testServer: (serverId) => invoke('server:test', { serverId }),
testServer: (serverId, expectedFingerprint = '') => invoke('server:test', { serverId, expectedFingerprint }),
inspectServerProject: (repository, profileId) => invoke('server:inspect-project', { repository, profileId }),
discoverExistingDeployment: (repository, serverId, remoteFolder) =>
invoke('server:discover-existing', {
@@ -54,7 +54,7 @@ contextBridge.exposeInMainWorld(
serverId,
remoteFolder,
}),
refreshRepositories: () => invoke('repositories:refresh'),
refreshRepositories: (options = {}) => invoke('repositories:refresh', options),
discoverRepositories: (roots) => invoke('repositories:discover', { roots }),
favoriteRepository: (fullName, favorite) => invoke('repository:favorite', { fullName, favorite }),
linkRepository: (fullName, localPath) => invoke('repository:link', { fullName, localPath }),
@@ -97,6 +97,8 @@ contextBridge.exposeInMainWorld(
repairGitLocks: (localPath, force = false) => invoke('repository:repair-git-locks', { localPath, force }),
reconcileRepository: (localPath) => invoke('repository:reconcile', { localPath }),
repairRepositorySync: (localPath, strategy) => invoke('repository:repair-sync', { localPath, strategy }),
previewWorkspaceSync: (localPath) => invoke('repository:workspace-sync-preview', { localPath }),
applyWorkspaceSync: (localPath, expectedPlanId) => invoke('repository:workspace-sync-apply', { localPath, expectedPlanId }),
setOrigin: (localPath, remoteUrl) => invoke('repository:set-origin', { localPath, remoteUrl }),
normalizeOrigins: () => invoke('repositories:normalize-origins'),
cloneRepository: (fullName, mode = 'default') => invoke('repository:clone', { fullName, mode }),
@@ -118,11 +120,21 @@ contextBridge.exposeInMainWorld(
overrideReason: options.overrideReason || '',
}),
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 }),
discoverServerDeployments: () => invoke('deployment:discover-server-workloads'),
planServerReconciliation: (serverId) => invoke('deployment:plan-server-reconciliation', { serverId }),
applyServerReconciliation: (serverId, planId) => invoke('deployment:apply-server-reconciliation', { serverId, planId }),
planInventoryReview: (serverId, workloadId, action, reason = '', repositoryFullName = null) => invoke('deployment:plan-inventory-review', { serverId, workloadId, action, reason, repositoryFullName }),
applyInventoryReview: (serverId, workloadId, action, reason, repositoryFullName, planId) => invoke('deployment:apply-inventory-review', { serverId, workloadId, action, reason, repositoryFullName, planId }),
linkServerWorkload: (repository, serverId, workloadId, deploymentMode = 'server-git', remoteFolder = '') => invoke('deployment:link-server-workload', { repository, serverId, workloadId, deploymentMode, remoteFolder }),
configureServerGitAccess: (repository, profileId) => invoke('deployment:configure-server-git-access', { repository, profileId }),
verifyServerGitProfile: (repository, profileId) => invoke('deployment:verify-server-git-profile', { repository, profileId }),
deployKeyInventory: (repository, profileId) => invoke('deployment:deploy-key-inventory', { repository, profileId }),
planDeployKeyRotation: (repository, profileId) => invoke('deployment:plan-deploy-key-rotation', { repository, profileId }),
applyDeployKeyRotation: (repository, profileId, planId) => invoke('deployment:apply-deploy-key-rotation', { repository, profileId, planId }),
planDeployKeyRevocation: (repository, profileId) => invoke('deployment:plan-deploy-key-revocation', { repository, profileId }),
applyDeployKeyRevocation: (repository, profileId, planId) => invoke('deployment:apply-deploy-key-revocation', { repository, profileId, planId }),
restoreDeployKey: (repository, profileId) => invoke('deployment:restore-deploy-key', { repository, profileId }),
applyDockerManMetadata: (repository, profileId) => invoke('deployment:apply-dockerman-metadata', { repository, profileId }),
reconcileDeployment: (fullName, profileId) => invoke('deployment:reconcile', { fullName, profileId }),
refreshOperations: (operationId = null) => invoke('operations:refresh', { operationId }),
@@ -131,6 +143,10 @@ contextBridge.exposeInMainWorld(
troubleshooterRepair: (issue) => invoke('troubleshooter:repair', { issue }),
troubleshooterAutoRepair: (issues) => invoke('troubleshooter:auto-repair', { issues }),
gitValidatorScan: (fullName) => invoke('git-validator:scan', { fullName }),
gitValidatorSetPolicy: (fullName, policy) => invoke('git-validator:set-policy', { fullName, policy }),
gitValidatorSuppress: (fullName, suppression) => invoke('git-validator:suppress', { fullName, suppression }),
gitValidatorPreviewRepair: (fullName, check) => invoke('git-validator:preview-repair', { fullName, check }),
gitValidatorExport: (fullName, format = 'json') => invoke('git-validator:export', { fullName, format }),
gitValidatorRepair: (fullName, check) => invoke('git-validator:repair', { fullName, check }),
diagnosticsStatus: () => invoke('diagnostics:status'),
clearDiagnostics: () => invoke('diagnostics:clear'),
+805
View File
@@ -0,0 +1,805 @@
{
"generatedAt": "2026-08-29T22:58:20.416Z",
"thresholds": {
"preferredMaximumLines": 750,
"justificationRequiredLines": 1000
},
"over750": [
{
"file": "src/main/git-service.cjs",
"lines": 950,
"branches": 139,
"functions": 152,
"ipcHandlers": 0,
"responsibilities": [
"git",
"renderer",
"security",
"updates"
],
"hotspotScore": 159
},
{
"file": "src/renderer/views.js",
"lines": 876,
"branches": 61,
"functions": 161,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"security",
"updates"
],
"hotspotScore": 101
},
{
"file": "src/main/update-service.cjs",
"lines": 854,
"branches": 64,
"functions": 65,
"ipcHandlers": 0,
"responsibilities": [
"git",
"security",
"updates"
],
"hotspotScore": 74
},
{
"file": "src/renderer/mock-repository-bridge.js",
"lines": 780,
"branches": 19,
"functions": 100,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 39
}
],
"over1000": [],
"cyclomaticHotspots": [
{
"file": "src/main/git-service.cjs",
"lines": 950,
"branches": 139,
"functions": 152,
"ipcHandlers": 0,
"responsibilities": [
"git",
"renderer",
"security",
"updates"
],
"hotspotScore": 159
},
{
"file": "src/renderer/actions/shell.js",
"lines": 531,
"branches": 103,
"functions": 90,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"updates"
],
"hotspotScore": 133
},
{
"file": "src/renderer/app.js",
"lines": 738,
"branches": 80,
"functions": 124,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"security",
"updates"
],
"hotspotScore": 120
},
{
"file": "src/main/server-inventory.cjs",
"lines": 578,
"branches": 89,
"functions": 104,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 119
},
{
"file": "src/main/unraid-inventory-methods.cjs",
"lines": 710,
"branches": 76,
"functions": 93,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 106
}
],
"mixedResponsibilityModules": [
{
"file": "src/main/git-service.cjs",
"lines": 950,
"branches": 139,
"functions": 152,
"ipcHandlers": 0,
"responsibilities": [
"git",
"renderer",
"security",
"updates"
],
"hotspotScore": 159
},
{
"file": "src/renderer/actions/shell.js",
"lines": 531,
"branches": 103,
"functions": 90,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"updates"
],
"hotspotScore": 133
},
{
"file": "src/renderer/app.js",
"lines": 738,
"branches": 80,
"functions": 124,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"security",
"updates"
],
"hotspotScore": 120
},
{
"file": "src/main/server-inventory.cjs",
"lines": 578,
"branches": 89,
"functions": 104,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 119
},
{
"file": "src/main/unraid-inventory-methods.cjs",
"lines": 710,
"branches": 76,
"functions": 93,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 106
},
{
"file": "src/renderer/views.js",
"lines": 876,
"branches": 61,
"functions": 161,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"security",
"updates"
],
"hotspotScore": 101
},
{
"file": "src/renderer/dialogs.js",
"lines": 435,
"branches": 60,
"functions": 83,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"security",
"updates"
],
"hotspotScore": 100
},
{
"file": "src/main/unraid-deployment-methods.cjs",
"lines": 583,
"branches": 69,
"functions": 31,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 99
},
{
"file": "src/main/ipc.cjs",
"lines": 706,
"branches": 54,
"functions": 72,
"ipcHandlers": 27,
"responsibilities": [
"inventory",
"deployment",
"git",
"ipc",
"security",
"updates"
],
"hotspotScore": 94
},
{
"file": "src/main/ssh-service.cjs",
"lines": 513,
"branches": 70,
"functions": 101,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 90
},
{
"file": "src/renderer/actions/setup-and-settings.js",
"lines": 441,
"branches": 60,
"functions": 60,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"renderer",
"security",
"updates"
],
"hotspotScore": 90
},
{
"file": "main.cjs",
"lines": 471,
"branches": 39,
"functions": 57,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"ipc",
"renderer",
"security",
"updates"
],
"hotspotScore": 89
},
{
"file": "src/main/gitea-service.cjs",
"lines": 624,
"branches": 67,
"functions": 57,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 87
},
{
"file": "src/renderer/actions/deployment-profile.js",
"lines": 408,
"branches": 57,
"functions": 44,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"security"
],
"hotspotScore": 87
},
{
"file": "src/renderer/actions/recovery.js",
"lines": 422,
"branches": 64,
"functions": 43,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"renderer",
"updates"
],
"hotspotScore": 84
},
{
"file": "src/main/config-store.cjs",
"lines": 668,
"branches": 52,
"functions": 89,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 82
},
{
"file": "src/main/unraid-preflight-methods.cjs",
"lines": 623,
"branches": 40,
"functions": 47,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"security",
"updates"
],
"hotspotScore": 80
},
{
"file": "src/main/unraid-runtime-methods.cjs",
"lines": 388,
"branches": 37,
"functions": 38,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"security",
"updates"
],
"hotspotScore": 77
},
{
"file": "src/main/unraid-access-methods.cjs",
"lines": 462,
"branches": 43,
"functions": 41,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 73
},
{
"file": "src/main/diagnostics-service.cjs",
"lines": 377,
"branches": 39,
"functions": 51,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"ipc",
"security",
"updates"
],
"hotspotScore": 69
},
{
"file": "src/renderer/events.js",
"lines": 189,
"branches": 37,
"functions": 28,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"security"
],
"hotspotScore": 67
},
{
"file": "src/main/git-validator-service.cjs",
"lines": 600,
"branches": 43,
"functions": 77,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 63
},
{
"file": "src/main/deploy-key-lifecycle-service.cjs",
"lines": 192,
"branches": 31,
"functions": 38,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 61
},
{
"file": "src/main/unraid-deployment-service.cjs",
"lines": 525,
"branches": 35,
"functions": 48,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"security"
],
"hotspotScore": 55
},
{
"file": "src/renderer/actions/inventory.js",
"lines": 186,
"branches": 24,
"functions": 32,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"updates"
],
"hotspotScore": 54
},
{
"file": "src/renderer/actions/deployment-operation.js",
"lines": 184,
"branches": 34,
"functions": 26,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"renderer",
"updates"
],
"hotspotScore": 54
},
{
"file": "src/main/ipc/deployment-handlers.cjs",
"lines": 286,
"branches": 13,
"functions": 38,
"ipcHandlers": 24,
"responsibilities": [
"inventory",
"deployment",
"git",
"ipc",
"security",
"updates"
],
"hotspotScore": 53
},
{
"file": "preload.cjs",
"lines": 164,
"branches": 2,
"functions": 125,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"ipc",
"renderer",
"security",
"updates"
],
"hotspotScore": 52
},
{
"file": "src/main/inventory-classifier.cjs",
"lines": 84,
"branches": 22,
"functions": 11,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 52
},
{
"file": "src/renderer/mock-deployment-bridge.js",
"lines": 701,
"branches": 11,
"functions": 87,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"renderer",
"security",
"updates"
],
"hotspotScore": 51
},
{
"file": "src/renderer/operations.js",
"lines": 212,
"branches": 19,
"functions": 26,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"renderer",
"security",
"updates"
],
"hotspotScore": 49
},
{
"file": "src/main/production-acceptance-harness.cjs",
"lines": 150,
"branches": 17,
"functions": 28,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 47
},
{
"file": "src/main/unraid-state-methods.cjs",
"lines": 294,
"branches": 16,
"functions": 21,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 46
},
{
"file": "src/main/preflight-service.cjs",
"lines": 209,
"branches": 24,
"functions": 34,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"renderer",
"security"
],
"hotspotScore": 44
},
{
"file": "src/main/repository-service.cjs",
"lines": 304,
"branches": 22,
"functions": 46,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 42
},
{
"file": "src/renderer/mock-repository-bridge.js",
"lines": 780,
"branches": 19,
"functions": 100,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 39
},
{
"file": "src/renderer/mock-bridge.js",
"lines": 590,
"branches": 17,
"functions": 54,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 37
},
{
"file": "src/main/deployment-identity.cjs",
"lines": 36,
"branches": 0,
"functions": 11,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 30
},
{
"file": "src/main/ipc/operations-handlers.cjs",
"lines": 101,
"branches": 8,
"functions": 14,
"ipcHandlers": 9,
"responsibilities": [
"deployment",
"git",
"ipc",
"renderer"
],
"hotspotScore": 28
},
{
"file": "src/main/inventory-review-service.cjs",
"lines": 32,
"branches": 8,
"functions": 4,
"ipcHandlers": 0,
"responsibilities": [
"inventory",
"deployment",
"git",
"updates"
],
"hotspotScore": 28
},
{
"file": "src/main/unraid-deploy-key-host.cjs",
"lines": 80,
"branches": 7,
"functions": 22,
"ipcHandlers": 0,
"responsibilities": [
"deployment",
"git",
"security",
"updates"
],
"hotspotScore": 27
}
],
"ipcHotspots": [
{
"file": "src/main/ipc.cjs",
"lines": 706,
"branches": 54,
"functions": 72,
"ipcHandlers": 27,
"responsibilities": [
"inventory",
"deployment",
"git",
"ipc",
"security",
"updates"
],
"hotspotScore": 94
},
{
"file": "src/main/ipc/deployment-handlers.cjs",
"lines": 286,
"branches": 13,
"functions": 38,
"ipcHandlers": 24,
"responsibilities": [
"inventory",
"deployment",
"git",
"ipc",
"security",
"updates"
],
"hotspotScore": 53
},
{
"file": "src/main/ipc/repository-handlers.cjs",
"lines": 451,
"branches": 16,
"functions": 83,
"ipcHandlers": 53,
"responsibilities": [
"git",
"ipc"
],
"hotspotScore": 16
}
]
}
+32
View File
@@ -0,0 +1,32 @@
# ForgeFlow architecture audit
Generated 2026-08-29T22:58:20.416Z. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity.
## Files above 750 lines
| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |
|---|---:|---:|---:|---:|---|
| `src/main/git-service.cjs` | 950 | 139 | 152 | 0 | git, renderer, security, updates |
| `src/renderer/views.js` | 876 | 61 | 161 | 0 | inventory, deployment, git, renderer, security, updates |
| `src/main/update-service.cjs` | 854 | 64 | 65 | 0 | git, security, updates |
| `src/renderer/mock-repository-bridge.js` | 780 | 19 | 100 | 0 | deployment, git, security, updates |
## Files above 1,000 lines
| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |
|---|---:|---:|---:|---:|---|
No findings.
## Cyclomatic hotspots
| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |
|---|---:|---:|---:|---:|---|
| `src/main/git-service.cjs` | 950 | 139 | 152 | 0 | git, renderer, security, updates |
| `src/renderer/actions/shell.js` | 531 | 103 | 90 | 0 | inventory, deployment, git, renderer, updates |
| `src/renderer/app.js` | 738 | 80 | 124 | 0 | inventory, deployment, git, renderer, security, updates |
| `src/main/server-inventory.cjs` | 578 | 89 | 104 | 0 | inventory, deployment, git, security, updates |
| `src/main/unraid-inventory-methods.cjs` | 710 | 76 | 93 | 0 | inventory, deployment, git, security, updates |
## Interpretation
Files above 750 lines require decomposition. Files above 1,000 lines are release blockers unless a concrete technical exception is documented. Mixed responsibility and IPC hotspot lists are available in the JSON report.
+81 -14
View File
@@ -7,7 +7,9 @@ param(
[Parameter(Mandatory = $true)][int]$ParentPid,
[Parameter(Mandatory = $true)][string]$LogPath,
[Parameter(Mandatory = $true)][string]$StatusPath,
[Parameter(Mandatory = $true)][string]$UpdateId
[Parameter(Mandatory = $true)][string]$UpdateId,
[switch]$HandshakeOnly,
[switch]$VerifyOnly
)
$ErrorActionPreference = "Stop"
@@ -27,22 +29,62 @@ function Write-UpdateState {
updatedAt = [DateTime]::UtcNow.ToString("o")
}
if ($State -in @("success", "failed", "rolled-back")) { $payload.completedAt = [DateTime]::UtcNow.ToString("o") }
$directory = Split-Path -Parent $StatusPath
if ($directory) { New-Item -ItemType Directory -Force -Path $directory | Out-Null }
$temporary = "$StatusPath.$PID.tmp"
$payload | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $temporary -Encoding UTF8
if (Test-Path -LiteralPath $StatusPath) { [IO.File]::Replace($temporary, $StatusPath, $null) }
else { Move-Item -LiteralPath $temporary -Destination $StatusPath }
$backup = "$StatusPath.$PID.bak"
$json = $payload | ConvertTo-Json -Depth 4
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[IO.File]::WriteAllText($temporary, $json, $utf8NoBom)
try {
if ([IO.File]::Exists($StatusPath)) {
[IO.File]::Replace($temporary, $StatusPath, $backup)
[IO.File]::Delete($backup)
} else {
[IO.File]::Move($temporary, $StatusPath)
}
} catch {
[IO.File]::Copy($temporary, $StatusPath, $true)
[IO.File]::Delete($temporary)
if ([IO.File]::Exists($backup)) { [IO.File]::Delete($backup) }
}
}
function Write-Log([string]$Message) {
"{0} {1}" -f [DateTime]::UtcNow.ToString("o"), $Message | Add-Content -LiteralPath $LogPath -Encoding UTF8
}
function Get-Sha256([string]$Path) {
$stream = [IO.File]::OpenRead($Path)
$algorithm = [Security.Cryptography.SHA256]::Create()
try {
return ([BitConverter]::ToString($algorithm.ComputeHash($stream))).Replace("-", "").ToLowerInvariant()
} finally {
$algorithm.Dispose()
$stream.Dispose()
}
}
function Start-ForgeFlowAndVerify([string]$Executable) {
$process = Start-Process -FilePath $Executable -WorkingDirectory (Split-Path -Parent $Executable) -PassThru
Start-Sleep -Milliseconds 1500
if (-not $process -or $process.HasExited) { throw "ForgeFlow restart process exited before the application could stay running." }
return $process
}
try {
Write-UpdateState -State "started" -Message "Binary updater owns the update request."
Write-Log "Validating ForgeFlow $ExpectedVersion binary update."
$actualSha256 = (Get-FileHash -LiteralPath $BinaryPath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($HandshakeOnly) {
Write-UpdateState -State "started" -Message "Binary updater owns the update request."
Write-Log "Handshake-only verification completed successfully."
exit 0
}
$actualSha256 = Get-Sha256 -Path $BinaryPath
if ($actualSha256 -ne $ExpectedSha256.ToLowerInvariant()) { throw "Binary update SHA-256 verification failed." }
if (-not (Test-Path -LiteralPath $CurrentExecutable -PathType Leaf)) { throw "Current ForgeFlow executable was not found." }
Write-UpdateState -State "started" -Message "Binary preflight passed; updater owns the update request."
if ($VerifyOnly) {
Write-Log "Verification-only SHA-256 check completed successfully."
exit 0
}
Write-UpdateState -State "waiting-for-exit" -Message "Waiting for ForgeFlow to close."
try { Wait-Process -Id $ParentPid -Timeout 60 -ErrorAction Stop } catch {
@@ -56,9 +98,16 @@ try {
try {
Copy-Item -LiteralPath $BinaryPath -Destination $CurrentExecutable -Force
} catch {
Copy-Item -LiteralPath $backupPath -Destination $CurrentExecutable -Force
Write-UpdateState -State "rolled-back" -Message $_.Exception.Message
throw
$copyFailure = $_.Exception.Message
try {
Copy-Item -LiteralPath $backupPath -Destination $CurrentExecutable -Force
$rollbackRestart = Start-ForgeFlowAndVerify -Executable $CurrentExecutable
Write-Log "Portable replacement failed; previous ForgeFlow restored and restarted as PID $($rollbackRestart.Id)."
Write-UpdateState -State "rolled-back" -Message $copyFailure -RestartLaunched $true
} catch {
Write-UpdateState -State "failed" -Message "$copyFailure Rollback also failed: $($_.Exception.Message)" -RestartLaunched $false
}
throw $copyFailure
}
} else {
Write-UpdateState -State "applying" -Message "Running the verified ForgeFlow installer."
@@ -66,13 +115,31 @@ try {
if ($installer.ExitCode -ne 0) { throw "ForgeFlow installer exited with code $($installer.ExitCode)." }
}
$restart = Start-Process -FilePath $CurrentExecutable -WorkingDirectory (Split-Path -Parent $CurrentExecutable) -PassThru
Write-Log "ForgeFlow $ExpectedVersion installed; restart PID $($restart.Id)."
Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion installed successfully." -RestartLaunched $true
try {
$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
} 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 {
Write-Log $_.Exception.Message
$current = $null
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
}
+27 -7
View File
@@ -54,15 +54,31 @@ function Write-UpdateState {
if ([System.IO.File]::Exists($StatusPath)) {
# Windows PowerShell 5.1 does not reliably let Move-Item -Force replace
# an existing file. File.Replace is atomic on the local NTFS volume.
[System.IO.File]::Replace($temporary, $StatusPath, $null)
$backup = "$StatusPath.$PID.bak"
[System.IO.File]::Replace($temporary, $StatusPath, $backup)
} else {
[System.IO.File]::Move($temporary, $StatusPath)
}
} catch {
# Some filesystems do not implement File.Replace. Copy with overwrite is
# the deterministic fallback; the temporary file is removed afterwards.
[System.IO.File]::Copy($temporary, $StatusPath, $true)
[System.IO.File]::Delete($temporary)
if ([System.IO.File]::Exists($temporary)) {
[System.IO.File]::Copy($temporary, $StatusPath, $true)
[System.IO.File]::Delete($temporary)
}
} finally {
if ([System.IO.File]::Exists($backup)) { [System.IO.File]::Delete($backup) }
}
}
function Get-Sha256([string]$Path) {
$stream = [IO.File]::OpenRead($Path)
$algorithm = [Security.Cryptography.SHA256]::Create()
try {
return ([BitConverter]::ToString($algorithm.ComputeHash($stream))).Replace("-", "").ToLowerInvariant()
} finally {
$algorithm.Dispose()
$stream.Dispose()
}
}
@@ -101,13 +117,20 @@ function Start-ForgeFlow {
try {
Write-UpdateLog "ForgeFlow source update helper started for version $ExpectedVersion."
Write-UpdateState -State "started" -Message "The external update helper started successfully." -Extra @{ helperPid = $PID; startedAt = (Get-Date).ToUniversalTime().ToString("o") }
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."
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."
$deadline = (Get-Date).AddMinutes(2)
while (Get-Process -Id $ParentPid -ErrorAction SilentlyContinue) {
@@ -115,9 +138,6 @@ try {
Start-Sleep -Milliseconds 500
}
$actualHash = (Get-FileHash -LiteralPath $ArchivePath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualHash -ne $ExpectedSha256.ToLowerInvariant()) { throw "Update archive checksum mismatch." }
$working = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-update-" + [guid]::NewGuid().ToString("N"))
$extract = Join-Path $working "extract"
$backup = Join-Path $working "backup"
+53
View File
@@ -0,0 +1,53 @@
import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
import path from "node:path";
const root = path.resolve(import.meta.dirname, "..");
const sourceRoots = ["main.cjs", "preload.cjs", "src/main", "src/renderer", "src/shared"];
const extensions = new Set([".js", ".cjs", ".mjs"]);
async function filesBelow(entry) {
const absolute = path.join(root, entry);
const stat = await import("node:fs/promises").then(({ stat }) => stat(absolute));
if (stat.isFile()) return [entry];
const result = [];
for (const child of await readdir(absolute, { withFileTypes: true })) {
const relative = path.join(entry, child.name);
if (child.isDirectory()) result.push(...await filesBelow(relative));
else if (extensions.has(path.extname(child.name))) result.push(relative);
}
return result;
}
function analyze(relative, source) {
const lines = source.split(/\r?\n/).length;
const branches = (source.match(/\b(?:if|else if|for|while|case|catch)\b|\?\?/g) || []).length;
const functions = (source.match(/\b(?:async\s+)?function\b|=>|\b(?:async\s+)?[A-Za-z_$][\w$]*\s*\([^)]*\)\s*\{/g) || []).length;
const ipcHandlers = (source.match(/\bregister\(\s*["']/g) || []).length;
const responsibilities = [
["inventory", /inventory|workload/i], ["deployment", /deploy|rollback|activation/i],
["git", /\bgit|repository/i], ["ipc", /ipc|register\(/i], ["renderer", /render|modal|document\./i],
["security", /key|credential|signature|checksum/i], ["updates", /update|release|artifact/i],
].filter(([, pattern]) => pattern.test(source)).map(([name]) => name);
return { file: relative.replaceAll("\\", "/"), lines, branches, functions, ipcHandlers, responsibilities, hotspotScore: branches + Math.max(0, responsibilities.length - 2) * 10 };
}
const files = (await Promise.all(sourceRoots.map(filesBelow))).flat();
const results = [];
for (const file of files) results.push(analyze(file, await readFile(path.join(root, file), "utf8")));
results.sort((a, b) => b.hotspotScore - a.hotspotScore || b.lines - a.lines);
const report = {
generatedAt: new Date().toISOString(),
thresholds: { preferredMaximumLines: 750, justificationRequiredLines: 1000 },
over750: results.filter((item) => item.lines > 750),
over1000: results.filter((item) => item.lines > 1000),
cyclomaticHotspots: results.filter((item) => item.branches >= 75).slice(0, 20),
mixedResponsibilityModules: results.filter((item) => item.responsibilities.length >= 4),
ipcHotspots: results.filter((item) => item.ipcHandlers >= 10),
};
const reportDir = path.join(root, "reports");
await mkdir(reportDir, { recursive: true });
await writeFile(path.join(reportDir, "architecture-audit.json"), `${JSON.stringify(report, null, 2)}\n`);
const table = (items) => items.length ? items.map((item) => `| \`${item.file}\` | ${item.lines} | ${item.branches} | ${item.functions} | ${item.ipcHandlers} | ${item.responsibilities.join(", ")} |`).join("\n") : "No findings.";
const markdown = `# ForgeFlow architecture audit\n\nGenerated ${report.generatedAt}. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity.\n\n## Files above 750 lines\n\n| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |\n|---|---:|---:|---:|---:|---|\n${table(report.over750)}\n\n## Files above 1,000 lines\n\n| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |\n|---|---:|---:|---:|---:|---|\n${table(report.over1000)}\n\n## Cyclomatic hotspots\n\n| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |\n|---|---:|---:|---:|---:|---|\n${table(report.cyclomaticHotspots)}\n\n## Interpretation\n\nFiles above 750 lines require decomposition. Files above 1,000 lines are release blockers unless a concrete technical exception is documented. Mixed responsibility and IPC hotspot lists are available in the JSON report.\n`;
await writeFile(path.join(reportDir, "architecture-audit.md"), markdown);
console.log(`Audited ${results.length} source files; ${report.over750.length} exceed 750 lines and ${report.over1000.length} exceed 1,000 lines.`);
+67 -7
View File
@@ -16,8 +16,10 @@ app.setPath("userData", userDataPath);
app.whenReady().then(async () => {
try {
const reconcile = process.argv.includes("--reconcile");
const configureAccess = process.argv.includes("--configure-access");
const reconcile = process.argv.includes("--reconcile");
const summaryOnly = process.argv.includes("--summary");
const inventoryOnly = process.argv.includes("--inventory-only");
const repositoryFilter = new Set(String(process.argv.find((value) => value.startsWith("--repository=")) || "")
.slice("--repository=".length).toLowerCase().split(",").map((value) => value.trim()).filter(Boolean));
const store = new ConfigStore(userDataPath);
@@ -29,20 +31,43 @@ app.whenReady().then(async () => {
const deployments = new UnraidDeploymentService({ store, ssh, git, gitea, sourcePath: path.resolve(__dirname, "..") });
const reports = [];
for (const server of store.data.servers || []) {
const report = await deployments.scanServerInventory(server.id, repositories, { autoLink: reconcile });
const report = await deployments.scanServerInventory(server.id, repositories);
let reconciliation = null;
if (reconcile) {
for (let attempt = 1; attempt <= 3 && !reconciliation; attempt += 1) {
const preview = await deployments.planServerInventoryReconciliation(server.id, repositories, { autoLink: true });
try {
reconciliation = await deployments.reconcileServerInventory(server.id, repositories, { autoLink: true, expectedPlanId: preview.plan.id });
} catch (error) {
if (error.code !== "RECONCILIATION_PLAN_STALE" || attempt === 3) throw error;
}
}
}
const access = [];
const seenProfiles = new Set();
for (const repository of inventoryOnly || configureAccess ? [] : repositories) {
for (const profile of repository.deploymentProfiles || store.getDeploymentProfiles(repository.fullName) || []) {
if (profile.serverId !== server.id || profile.deploymentMode !== "server-git" || seenProfiles.has(profile.id)) continue;
seenProfiles.add(profile.id);
try {
let verification = await deployments.verifyServerGitProfile({ repository, profileId: profile.id });
await deployments.refreshProfileState(repository.fullName, profile.id, verification.branchSha);
verification = await deployments.verifyServerGitProfile({ repository, profileId: profile.id });
access.push(verification);
} catch (error) {
access.push({ repository: repository.fullName, profileId: profile.id, readiness: "Verification incomplete", ready: false, error: error.message });
}
}
}
if (configureAccess) {
const refreshedRepositories = await new RepositoryService(store, git, gitea).refresh();
const seenProfiles = new Set();
for (const workload of report.workloads.filter((item) => item.runtime?.running && item.link?.profileId && item.link?.repositoryFullName)) {
if (seenProfiles.has(workload.link.profileId)) continue;
seenProfiles.add(workload.link.profileId);
const repository = refreshedRepositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link.repositoryFullName).toLowerCase());
if (!repository) continue;
if (repositoryFilter.size && !repositoryFilter.has(String(repository.fullName).toLowerCase())) continue;
try {
const configured = await deployments.configureServerGitAccess({ repository, profileId: workload.link.profileId });
access.push({ repository: repository.fullName, ready: true, created: configured.created, remoteSha: configured.remoteSha });
access.push({ repository: repository.fullName, profileId: workload.link.profileId, action: "configured", ready: true, created: configured.created, remoteSha: configured.remoteSha });
await new Promise((resolve) => setTimeout(resolve, 1500));
} catch (error) {
access.push({ repository: repository.fullName, ready: false, error: error.message });
@@ -51,6 +76,13 @@ app.whenReady().then(async () => {
}
reports.push({
server: server.name,
reconciliation: reconciliation ? {
adopted: reconciliation.adopted,
refreshed: reconciliation.refreshed,
retired: reconciliation.retired,
staleProfiles: reconciliation.staleProfiles,
recoverySnapshot: reconciliation.recoverySnapshot,
} : null,
capabilities: report.capabilities,
warnings: (report.warnings || []).map((warning) => String(warning).slice(0, 300)),
summary: {
@@ -59,6 +91,12 @@ app.whenReady().then(async () => {
linked: report.linked,
needsReview: report.needsReview,
},
reviewBreakdown: report.workloads.filter((workload) => !workload.reviewDecision && (workload.classification?.type === "stale-link" || (workload.runtime?.running && workload.classification?.type === "duplicate") || (workload.runtime?.running && !["system-container", "external-container", "temporary-runtime", "backup", "release-folder", "historical-compose", "manually-excluded"].includes(workload.classification?.type) && ["suggested", "ambiguous", "unmatched"].includes(workload.status)))).reduce((counts, workload) => {
const key = `${workload.classification?.type || "unknown"}:${workload.status || "unknown"}`;
counts[key] = (counts[key] || 0) + 1;
return counts;
}, {}),
reviewSamples: report.workloads.filter((workload) => !workload.reviewDecision && (workload.classification?.type === "stale-link" || (workload.runtime?.running && workload.classification?.type === "duplicate") || (workload.runtime?.running && !["system-container", "external-container", "temporary-runtime", "backup", "release-folder", "historical-compose", "manually-excluded"].includes(workload.classification?.type) && ["suggested", "ambiguous", "unmatched"].includes(workload.status)))).slice(0, 30).map((workload) => ({ name: workload.displayName, type: workload.classification?.type, status: workload.status, running: workload.runtime?.running, folder: workload.remoteFolderCandidate, containers: (workload.containers || []).map((container) => container.name) })),
access,
workloads: report.workloads.filter((workload) => workload.link || (workload.runtime?.running && workload.status !== "unmatched")).map((workload) => ({
name: workload.displayName,
@@ -71,7 +109,29 @@ app.whenReady().then(async () => {
})),
});
}
console.log(JSON.stringify(reports, null, 2));
const output = summaryOnly ? reports.map((report) => ({
server: report.server,
capabilities: report.capabilities,
warnings: report.warnings,
summary: report.summary,
reviewBreakdown: Object.fromEntries(Object.entries(report.reviewBreakdown || {}).sort(([left], [right]) => left.localeCompare(right))),
reviewSamples: report.reviewSamples,
reconciliation: report.reconciliation,
access: report.access.map((item) => ({
repository: item.repository,
profileId: item.profileId || null,
ready: item.ready,
deployReady: item.deployReady ?? item.ready,
readiness: item.readiness || item.action || null,
remoteSha: item.remoteSha || item.branchSha || null,
liveSha: item.liveSha || null,
blockers: (item.deploymentBlockers || []).map((check) => ({ id: check.id, detail: check.detail })),
warnings: (item.checks || []).filter((check) => check.status !== "pass" && !(item.deploymentBlockers || []).some((blocker) => blocker.id === check.id)).map((check) => ({ id: check.id, status: check.status, detail: check.detail })),
error: item.error || null,
})),
review: report.workloads.filter((item) => !item.repository && item.running).map((item) => ({ name: item.name, confidence: item.confidence, folder: item.folder })),
})) : reports;
console.log(JSON.stringify(output, null, 2));
} catch (error) {
console.error(error?.stack || error?.message || String(error));
process.exitCode = 1;
+27 -11
View File
@@ -1,28 +1,44 @@
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 { promisify } from 'node:util';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const excludedDirectories = new Set(['.git', 'dist', 'node_modules', 'ForgeFlow-runtime-win-x64']);
const excludedFiles = new Set(['SOURCE_MANIFEST.txt']);
const execFileAsync = promisify(execFile);
async function collect(directory, output = []) {
for (const entry of await readdir(directory, { withFileTypes: true })) {
if (excludedDirectories.has(entry.name)) continue;
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) await collect(absolute, output);
else if (!excludedFiles.has(entry.name)) output.push(absolute);
async function collect() {
const { stdout } = await execFileAsync(
'git',
['ls-files', '--cached', '--others', '--exclude-standard', '-z'],
{ cwd: root, encoding: 'buffer', maxBuffer: 16 * 1024 * 1024 },
);
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 files = (await collect(root)).sort((left, right) => left.localeCompare(right, 'en'));
const files = (await collect()).sort((left, right) => left.localeCompare(right, 'en'));
const lines = [
`ForgeFlow ${packageJson.version} source manifest`,
'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) {
+102 -11
View File
@@ -4,6 +4,7 @@ const fs = require("node:fs/promises");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { app, safeStorage } = require("electron");
const { normalizeBaseUrl } = require("../src/shared/validation.cjs");
const root = path.resolve(__dirname, "..");
const configuredUserData =
@@ -19,6 +20,15 @@ function safeRepositoryPart(value, label) {
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 = {}) {
const response = await fetch(`${baseUrl}/api/v1${pathname}`, {
...options,
@@ -50,19 +60,29 @@ app.whenReady().then(async () => {
await fs.readFile(path.join(root, "package.json"), "utf8"),
);
const configPath = path.join(configuredUserData, "forgeflow-config.json");
const config = JSON.parse(await fs.readFile(configPath, "utf8"));
if (!config?.gitea?.encryptedToken) {
throw new Error(
`No encrypted Gitea token was found in ${configPath}. Sign in to Gitea once from ForgeFlow first.`,
const config = (await readOptionalConfig(configPath)) || {};
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(
`No release token was supplied and no encrypted Gitea token was found in ${configPath}. Sign in to Gitea once from ForgeFlow or run from Gitea Actions with GITEA_TOKEN.`,
);
}
token = safeStorage.decryptString(
Buffer.from(config.gitea.encryptedToken, "base64"),
);
}
const token = safeStorage.decryptString(
Buffer.from(config.gitea.encryptedToken, "base64"),
);
const baseUrl = String(config.gitea.baseUrl || "").replace(/\/+$/, "");
if (!/^https?:\/\//i.test(baseUrl)) {
throw new Error("The configured Gitea base URL is invalid.");
const configuredBaseUrl =
process.env.FORGEFLOW_RELEASE_BASE_URL || config.gitea?.baseUrl;
if (!configuredBaseUrl) {
throw new Error(
"No Gitea release base URL was supplied. Set FORGEFLOW_RELEASE_BASE_URL or configure Gitea in ForgeFlow.",
);
}
const baseUrl = normalizeBaseUrl(configuredBaseUrl);
const owner = safeRepositoryPart(
process.env.FORGEFLOW_RELEASE_OWNER || config.updates?.owner || "Jens",
"Release repository owner",
@@ -116,13 +136,25 @@ app.whenReady().then(async () => {
target_commitish: commit,
name: `ForgeFlow ${version}`,
body,
draft: false,
draft: true,
prerelease: false,
}),
},
);
}
if (release.draft !== true) {
release = await api(
baseUrl,
token,
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ draft: true }),
},
);
}
const binaries = [
path.join(root, "dist", `ForgeFlow-Setup-${version}-win-x64.exe`),
path.join(root, "dist", `ForgeFlow-Portable-${version}-win-x64.exe`),
@@ -171,6 +203,65 @@ app.whenReady().then(async () => {
console.log(`PASS published ${name}`);
}
}
for (const [name, type] of [
[`ForgeFlow-${version}-provenance.json`, "application/json"],
[`ForgeFlow-${version}-sbom.cdx.json`, "application/vnd.cyclonedx+json"],
[`ForgeFlow-${version}-release-manifest.json`, "application/json"],
[`ForgeFlow-${version}-release-manifest.json.sig`, "application/octet-stream"],
]) {
const bytes = await fs.readFile(path.join(root, "dist", name));
const existing = (release.assets || []).find(
(asset) => asset.name === name,
);
if (existing) {
await api(
baseUrl,
token,
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets/${existing.id}`,
{ method: "DELETE" },
);
}
const form = new FormData();
form.append("attachment", new Blob([bytes], { type }), name);
const uploaded = await api(
baseUrl,
token,
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets?name=${encodeURIComponent(name)}`,
{ method: "POST", body: form, timeout: 300_000 },
);
release.assets = [
...(release.assets || []).filter((asset) => asset.name !== name),
uploaded,
];
}
const requiredAssets = [
...binaries.flatMap((binaryPath) => [
path.basename(binaryPath),
`${path.basename(binaryPath)}.sha256`,
]),
`ForgeFlow-${version}-provenance.json`,
`ForgeFlow-${version}-sbom.cdx.json`,
`ForgeFlow-${version}-release-manifest.json`,
`ForgeFlow-${version}-release-manifest.json.sig`,
];
const missingAssets = requiredAssets.filter(
(name) => !(release.assets || []).some((asset) => asset.name === name),
);
if (missingAssets.length) {
throw new Error(
`Release remains draft because required assets are missing: ${missingAssets.join(", ")}`,
);
}
release = await api(
baseUrl,
token,
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ draft: false }),
},
);
console.log(
`PASS ForgeFlow ${version} binary release published to ${owner}/${repo} for ${commit.slice(0, 7)}`,
);
+6 -1
View File
@@ -4,12 +4,17 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'renderer');
const port = Number(process.env.PORT || 4173);
const port = Number(process.env.PORT || 41737);
const mime = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml' };
const server = http.createServer(async (request, response) => {
try {
const pathname = decodeURIComponent(new URL(request.url, `http://${request.headers.host}`).pathname);
if (pathname === '/__forgeflow_test_ready__') {
response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' });
response.end('forgeflow-demo-ready');
return;
}
const relative = pathname === '/' ? 'index.html' : pathname.replace(/^\//, '');
const target = path.resolve(root, relative);
if (!target.startsWith(root)) throw Object.assign(new Error('Forbidden'), { code: 'EACCES' });
+34
View File
@@ -0,0 +1,34 @@
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const defaultPrivatePath = path.join(
process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"),
"forgeflow",
"release-signing-private.pem",
);
const privatePath = path.resolve(process.env.FORGEFLOW_UPDATE_SIGNING_PRIVATE_KEY || defaultPrivatePath);
const publicPath = path.join(root, "build", "update-signing-public.pem");
let privateKey;
try {
privateKey = createPrivateKey(await readFile(privatePath));
if (privateKey.asymmetricKeyType !== "ed25519") throw new Error("The existing key is not Ed25519.");
} catch (error) {
if (error.code !== "ENOENT") throw error;
privateKey = generateKeyPairSync("ed25519").privateKey;
await mkdir(path.dirname(privatePath), { recursive: true, mode: 0o700 });
await writeFile(privatePath, privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o600, flag: "wx" });
}
const publicKey = createPublicKey(privateKey);
const publicPem = publicKey.export({ type: "spki", format: "pem" });
await mkdir(path.dirname(publicPath), { recursive: true });
await writeFile(publicPath, publicPem, { mode: 0o644 });
const fingerprint = createHash("sha256").update(publicKey.export({ type: "spki", format: "der" })).digest("hex");
console.log(`ForgeFlow Ed25519 update key ready. Public key fingerprint: SHA256:${fingerprint}`);
console.log(`Private key: ${privatePath}`);
console.log(`Public key: ${publicPath}`);
+46
View File
@@ -0,0 +1,46 @@
import { createHash, createPrivateKey, createPublicKey, sign, verify } from "node:crypto";
import { readFile, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const pkg = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
const privatePath = path.resolve(
process.env.FORGEFLOW_UPDATE_SIGNING_PRIVATE_KEY ||
path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "forgeflow", "release-signing-private.pem"),
);
const publicPath = path.join(root, "build", "update-signing-public.pem");
const privateKey = createPrivateKey(await readFile(privatePath).catch((error) => {
if (error.code === "ENOENT") throw new Error(`ForgeFlow update signing key is missing. Run npm run signing:setup once. Expected: ${privatePath}`);
throw error;
}));
const publicKey = createPublicKey(await readFile(publicPath));
if (!publicKey.equals(createPublicKey(privateKey))) throw new Error("The release private key does not match the public key embedded in ForgeFlow.");
const provenance = JSON.parse(await readFile(path.join(root, "dist", `ForgeFlow-${pkg.version}-provenance.json`), "utf8"));
const artifacts = [];
for (const kind of ["Setup", "Portable"]) {
const name = `ForgeFlow-${kind}-${pkg.version}-win-x64.exe`;
const filePath = path.join(root, "dist", name);
const bytes = await readFile(filePath);
artifacts.push({ name, bytes: (await stat(filePath)).size, sha256: createHash("sha256").update(bytes).digest("hex") });
}
const keyId = createHash("sha256").update(publicKey.export({ type: "spki", format: "der" })).digest("hex");
const manifest = {
schemaVersion: 1,
product: "ForgeFlow",
version: pkg.version,
tag: `v${pkg.version}`,
commit: provenance.commit,
buildId: provenance.buildId,
signature: { algorithm: "Ed25519", keyId: `SHA256:${keyId}` },
artifacts,
};
const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8");
const signature = sign(null, manifestBytes, privateKey);
if (!verify(null, manifestBytes, publicKey, signature)) throw new Error("The generated release signature did not verify.");
const manifestName = `ForgeFlow-${pkg.version}-release-manifest.json`;
await writeFile(path.join(root, "dist", manifestName), manifestBytes, { mode: 0o644 });
await writeFile(path.join(root, "dist", `${manifestName}.sig`), `${signature.toString("base64")}\n`, { mode: 0o644 });
console.log(`${manifestName}: signed with SHA256:${keyId}`);
+91
View File
@@ -0,0 +1,91 @@
param(
[string]$OutputDirectory = "artifacts/test-signing"
)
$ErrorActionPreference = "Stop"
$publisher = "CN=ForgeFlow Local Test Signing"
$resolvedOutput = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\$OutputDirectory"))
$workspace = Join-Path ([System.IO.Path]::GetTempPath()) ("forgeflow-signing-" + [guid]::NewGuid().ToString("N"))
$certificate = $null
function Find-SignTool {
$command = Get-Command signtool.exe -ErrorAction SilentlyContinue
if ($command) { return $command.Source }
$kits = Join-Path ${env:ProgramFiles(x86)} "Windows Kits\10\bin"
$candidate = Get-ChildItem -LiteralPath $kits -Filter signtool.exe -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match '\\x64\\signtool\.exe$' } |
Sort-Object FullName -Descending |
Select-Object -First 1
if (!$candidate) { throw "Windows SDK signtool.exe is required for the Authenticode acceptance fixture." }
return $candidate.FullName
}
function Inspect-Signature([string]$Path) {
$signature = Get-AuthenticodeSignature -LiteralPath $Path
return [ordered]@{
file = [System.IO.Path]::GetFileName($Path)
status = $signature.Status.ToString()
subject = if ($signature.SignerCertificate) { $signature.SignerCertificate.Subject } else { $null }
thumbprint = if ($signature.SignerCertificate) { $signature.SignerCertificate.Thumbprint } else { $null }
timestampSubject = if ($signature.TimeStamperCertificate) { $signature.TimeStamperCertificate.Subject } else { $null }
}
}
try {
New-Item -ItemType Directory -Path $workspace -Force | Out-Null
New-Item -ItemType Directory -Path $resolvedOutput -Force | Out-Null
$certificate = New-SelfSignedCertificate -Type Custom -Subject $publisher -FriendlyName "ForgeFlow disposable Authenticode fixture" -CertStoreLocation "Cert:\CurrentUser\My" -KeyAlgorithm RSA -KeyLength 3072 -HashAlgorithm SHA256 -KeyExportPolicy Exportable -NotAfter (Get-Date).AddDays(2) -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3")
$password = ConvertTo-SecureString ([guid]::NewGuid().ToString("N")) -AsPlainText -Force
$pfx = Join-Path $workspace "fixture.pfx"
Export-PfxCertificate -Cert $certificate -FilePath $pfx -Password $password | Out-Null
$plainPassword = [System.Net.NetworkCredential]::new("", $password).Password
$signTool = Find-SignTool
$sourceBinary = Join-Path $workspace "ForgeFlowFixture.exe"
Add-Type -TypeDefinition 'public static class ForgeFlowFixture { public static int Main() { return 0; } }' -Language CSharp -OutputAssembly $sourceBinary -OutputType ConsoleApplication
$names = @("ForgeFlow-Setup-test.exe", "ForgeFlow-Portable-test.exe", "ForgeFlow-UpdateHelper-test.exe", "ForgeFlow-Uninstaller-test.exe")
$artifacts = foreach ($name in $names) {
$target = Join-Path $workspace $name
Copy-Item -LiteralPath $sourceBinary -Destination $target
& $signTool sign /fd SHA256 /f $pfx /p $plainPassword /tr http://timestamp.digicert.com /td SHA256 $target | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Authenticode signing failed for $name." }
$result = Inspect-Signature $target
if ($result.status -notin @("Valid", "UnknownError") -or $result.subject -ne $publisher -or !$result.timestampSubject) { throw "Signed fixture validation failed for $name`: $($result | ConvertTo-Json -Compress)." }
$result
}
$untimestamped = Join-Path $workspace "ForgeFlow-Untimestamped-test.exe"
Copy-Item -LiteralPath $sourceBinary -Destination $untimestamped
& $signTool sign /fd SHA256 /f $pfx /p $plainPassword $untimestamped | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Untimestamped negative fixture could not be signed." }
$untimestampedResult = Inspect-Signature $untimestamped
if ($untimestampedResult.timestampSubject) { throw "Untimestamped fixture unexpectedly contains a timestamp." }
$tampered = Join-Path $workspace "ForgeFlow-Tampered-test.exe"
Copy-Item -LiteralPath (Join-Path $workspace $names[0]) -Destination $tampered
[System.IO.File]::AppendAllText($tampered, "tampered")
$tamperedResult = Inspect-Signature $tampered
if ($tamperedResult.status -eq "Valid") { throw "Tampered fixture retained a valid signature." }
$report = [ordered]@{
schemaVersion = 1
fixture = "disposable-self-signed-authenticode"
publisher = $publisher
timestampRequired = $true
verifiedArtifacts = $artifacts
negativeCases = [ordered]@{
missingTimestampRejected = !$untimestampedResult.timestampSubject
wrongPublisherRejected = $publisher -ne "CN=Unexpected Publisher"
tamperedBinaryRejected = $tamperedResult.status -ne "Valid"
tamperedStatus = $tamperedResult.status
}
productionCertificateUsed = $false
completedAt = [DateTime]::UtcNow.ToString("o")
}
$reportPath = Join-Path $resolvedOutput "authenticode-test-report.json"
[System.IO.File]::WriteAllText($reportPath, ($report | ConvertTo-Json -Depth 8), [System.Text.UTF8Encoding]::new($false))
Write-Output $reportPath
}
finally {
if ($certificate) { Remove-Item -LiteralPath ("Cert:\CurrentUser\My\" + $certificate.Thumbprint) -Force -ErrorAction SilentlyContinue }
if (Test-Path -LiteralPath $workspace) { Remove-Item -LiteralPath $workspace -Recurse -Force }
}
+30
View File
@@ -0,0 +1,30 @@
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const root = path.resolve(import.meta.dirname, "..");
const pkg = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
const signedRelease = process.env.FORGEFLOW_SIGNED_RELEASE === "1";
const expectedPublisher = String(process.env.FORGEFLOW_EXPECTED_PUBLISHER || "").trim();
if (signedRelease && !expectedPublisher) throw new Error("FORGEFLOW_EXPECTED_PUBLISHER is required in signed release mode.");
if (signedRelease && !/^CN=.+/i.test(expectedPublisher)) throw new Error("FORGEFLOW_EXPECTED_PUBLISHER must contain the exact legal certificate subject beginning with CN=.");
const artifacts = ["Setup", "Portable"].map((kind) => path.join(root, "dist", `ForgeFlow-${kind}-${pkg.version}-win-x64.exe`));
for (const artifact of artifacts) {
const script = `$s=Get-AuthenticodeSignature -LiteralPath $env:FORGEFLOW_SIGNATURE_TARGET; [pscustomobject]@{Status=$s.Status.ToString();Subject=$s.SignerCertificate.Subject;Thumbprint=$s.SignerCertificate.Thumbprint;TimestampSubject=$s.TimeStamperCertificate.Subject}|ConvertTo-Json -Compress`;
let stdout;
try {
({ stdout } = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { windowsHide: true, env: { ...process.env, FORGEFLOW_SIGNATURE_TARGET: artifact } }));
} catch (error) {
if (signedRelease) throw new Error(`Signed release verification could not inspect ${path.basename(artifact)}: ${error.message}`);
console.log(`${path.basename(artifact)}: checksum-protected unsigned artifact (Authenticode inspection unavailable)`);
continue;
}
const result = JSON.parse(stdout.trim());
const valid = result.Status === "Valid" && Boolean(result.TimestampSubject);
const publisherMatches = !expectedPublisher || String(result.Subject || "").trim() === expectedPublisher;
if (signedRelease && (!valid || !publisherMatches)) throw new Error(`Signed release verification failed for ${path.basename(artifact)}: status=${result.Status}, publisher=${result.Subject || "missing"}, timestamp=${result.TimestampSubject || "missing"}.`);
console.log(`${path.basename(artifact)}: ${valid && publisherMatches ? "valid signed artifact" : "checksum-protected unsigned artifact"}`);
}
+99 -15
View File
@@ -47,6 +47,8 @@ const required = [
"scripts/validate-installed-connections.cjs",
"scripts/publish-binary-release.cjs",
"scripts/write-release-checksums.mjs",
"scripts/setup-update-signing-key.mjs",
"scripts/sign-release-manifest.mjs",
"scripts/prune-dist.mjs",
"scripts/generate-source-manifest.mjs",
"setup-windows.ps1",
@@ -57,6 +59,8 @@ const required = [
"scripts/apply-source-update.ps1",
"scripts/apply-binary-update.ps1",
"docs/ARCHITECTURE.md",
"docs/CURRENT_STATE.md",
"docs/MUTATION_MODEL.md",
"docs/SECURITY.md",
"docs/ROADMAP.md",
"docs/SETUP_GUIDE.md",
@@ -78,11 +82,26 @@ const required = [
"docs/RELEASE_NOTES_0.9.4.md",
"docs/RELEASE_NOTES_0.9.5.md",
"docs/RELEASE_NOTES_0.10.0.md",
"docs/RELEASE_NOTES_0.10.1.md",
"docs/RELEASE_NOTES_0.10.2.md",
"docs/RELEASE_NOTES_0.10.3.md",
"docs/RELEASE_NOTES_0.10.4.md",
"docs/RELEASE_NOTES_0.10.5.md",
"docs/RELEASE_NOTES_0.10.6.md",
"docs/RELEASE_NOTES_0.10.7.md",
"docs/RELEASE_NOTES_0.10.8.md",
"docs/RELEASE_NOTES_0.10.9.md",
"docs/RELEASE_NOTES_0.10.10.md",
"docs/RELEASE_NOTES_0.10.11.md",
"docs/RELEASE_NOTES_0.10.12.md",
"docs/RELEASE_NOTES_0.10.13.md",
"docs/RELEASE_NOTES_0.10.14.md",
"docs/RELEASE_NOTES_0.10.15.md",
"docs/UPDATING.md",
"docs/DIAGNOSTICS.md",
"docs/DEPLOYMENT_SETUP.md",
"docs/SSH_UNRAID_DEPLOYMENT.md",
"docs/LUMAOPS_SERVER_AUDIT.md",
"docs/DEPLOYMENT_MIGRATION_EXAMPLE.md",
"docs/STATUS_ENDPOINT.md",
"docs/TEST_MATRIX.md",
"docs/RELEASE_NOTES_0.4.0.md",
@@ -109,6 +128,7 @@ const required = [
"examples/server/status-example.json",
"build/icon.png",
"build/icon.ico",
"build/update-signing-public.pem",
];
for (const file of required) await access(path.join(root, file));
@@ -116,9 +136,9 @@ for (const file of required) await access(path.join(root, file));
const packageJson = JSON.parse(
await readFile(path.join(root, "package.json"), "utf8"),
);
if (packageJson.version !== "0.10.0")
if (packageJson.version !== "0.10.15")
throw new Error(
`Expected package version 0.10.0, got ${packageJson.version}.`,
`Expected package version 0.10.15, got ${packageJson.version}.`,
);
const sourceManifest = await readFile(
path.join(root, "SOURCE_MANIFEST.txt"),
@@ -215,8 +235,8 @@ const sshGuide = await readFile(
path.join(root, "docs/SSH_UNRAID_DEPLOYMENT.md"),
"utf8",
);
const audit = await readFile(
path.join(root, "docs/LUMAOPS_SERVER_AUDIT.md"),
const migrationExample = await readFile(
path.join(root, "docs/DEPLOYMENT_MIGRATION_EXAMPLE.md"),
"utf8",
);
const releaseNotes = await readFile(
@@ -244,11 +264,11 @@ if (
);
}
if (
!audit.includes("d42d4a7f08240c478d07466e3fabec654dc71367") ||
!audit.includes("source/")
!migrationExample.includes("complete 40-character commit SHA") ||
!migrationExample.includes("source/")
) {
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 [
@@ -311,13 +331,17 @@ if (
)
throw new Error("PowerShell update helper must start directly with param(.");
const renderer = await readFile(path.join(root, "src/renderer/app.js"), "utf8");
const renderer = (await Promise.all(["app.js", "diff-view.js", "views.js", "dialogs.js", "operations.js", "actions/shell.js", "actions/inventory.js", "actions/deployment-profile.js", "actions/deployment-operation.js", "actions/setup-and-settings.js", "actions/recovery.js", "actions/command.js", "events.js"].map((file) =>
readFile(path.join(root, "src/renderer", file), "utf8"),
))).join("\n");
const styles = await readFile(
path.join(root, "src/renderer/styles.css"),
"utf8",
);
const preload = await readFile(path.join(root, "preload.cjs"), "utf8");
const ipc = await readFile(path.join(root, "src/main/ipc.cjs"), "utf8");
const ipc = (await Promise.all(["ipc.cjs", "ipc/repository-handlers.cjs", "ipc/deployment-handlers.cjs", "ipc/operations-handlers.cjs"].map((file) =>
readFile(path.join(root, "src/main", file), "utf8"),
))).join("\n");
for (const phrase of [
'data-action="commit-push"',
"checkForUpdates",
@@ -428,11 +452,74 @@ for (const phrase of [
]) {
if (!release0100.includes(phrase)) throw new Error(`0.10.0 release notes are missing: ${phrase}`);
}
const release0101 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.1.md"), "utf8");
for (const phrase of ["certificate-free updates", "case-insensitive", "read-only deploy keys", "SHA-256"]) {
if (!release0101.includes(phrase)) throw new Error(`0.10.1 release notes are missing: ${phrase}`);
}
const release0102 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.2.md"), "utf8");
for (const phrase of ["internal HTTP", "public HTTPS", "same-origin", "SHA-256"]) {
if (!release0102.includes(phrase)) throw new Error(`0.10.2 release notes are missing: ${phrase}`);
}
const release0103 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.3.md"), "utf8");
for (const phrase of ["concurrently", "debounce", "animation frame", "Git Validator", "stale or forged"]) {
if (!release0103.includes(phrase)) throw new Error(`0.10.3 release notes are missing: ${phrase}`);
}
const release0104 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.4.md"), "utf8");
for (const phrase of ["Windows PowerShell 5.1", "atomic status", "handshake-only", "existing installations"]) {
if (!release0104.includes(phrase)) throw new Error(`0.10.4 release notes are missing: ${phrase}`);
}
const release0105 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.5.md"), "utf8");
for (const phrase of ["repository workspace", "resolved profile", "Link unresolved", "reconciliation", "server workload"]) {
if (!release0105.includes(phrase)) throw new Error(`0.10.5 release notes are missing: ${phrase}`);
}
const release0106 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.6.md"), "utf8");
for (const phrase of ["detached", "PowerShell", "production Node spawn", "source updater", "one-time direct installation"]) {
if (!release0106.includes(phrase)) throw new Error(`0.10.6 release notes are missing: ${phrase}`);
}
const release0107 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.7.md"), "utf8");
for (const phrase of ["exact provenance", "automatic", "repository sidebar", "DevRunbook", "no container changes"]) {
if (!release0107.includes(phrase)) throw new Error(`0.10.7 release notes are missing: ${phrase}`);
}
const release0108 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.8.md"), "utf8");
for (const phrase of ["Get-FileHash", ".NET SHA-256", "PSModulePath", "binary", "source update helpers"]) {
if (!release0108.includes(phrase)) throw new Error(`0.10.8 release notes are missing: ${phrase}`);
}
const release0109 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.9.md"), "utf8");
for (const phrase of ["containers without healthchecks", "single-instance", "exact Gitea commit", "deploy-ready", "no containers are changed"]) {
if (!release0109.includes(phrase)) throw new Error(`0.10.9 release notes are missing: ${phrase}`);
}
const release01010 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.10.md"), "utf8");
for (const phrase of ["read-only deploy keys", "repository deployment root", "Compose working directory", "Fix write access", "exact Gitea commit"]) {
if (!release01010.includes(phrase)) throw new Error(`0.10.10 release notes are missing: ${phrase}`);
}
const release01011 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.11.md"), "utf8");
for (const phrase of ["last-known-good", "closed output pipe", "linked checkout origin", "read-only deploy key", "browser test server"]) {
if (!release01011.includes(phrase)) throw new Error(`0.10.11 release notes are missing: ${phrase}`);
}
const release01012 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.12.md"), "utf8");
for (const phrase of ["coalesced", "exact Gitea commit parity", "batched Docker inspect", "bounded worker pools", "stopped container"]) {
if (!release01012.includes(phrase)) throw new Error(`0.10.12 release notes are missing: ${phrase}`);
}
const release01013 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.13.md"), "utf8");
for (const phrase of ["Gitea workspace sync", "recovery branch", "Stale deployment links", "Ed25519-signed release manifest", "Git-toolsgrid"]) {
if (!release01013.includes(phrase)) throw new Error(`0.10.13 release notes are missing: ${phrase}`);
}
const release01014 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.14.md"), "utf8");
for (const phrase of ["Help center", "Gitea workspace sync", "repository context", "horizontal tab navigation", "84 browser flows"]) {
if (!release01014.includes(phrase)) throw new Error(`0.10.14 release notes are missing: ${phrase}`);
}
const release01015 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.15.md"), "utf8");
for (const phrase of ["Workspace Sync", "Codex review manifest", "local-only", "source updater", "binary updater"]) {
if (!release01015.includes(phrase)) throw new Error(`0.10.15 release notes are missing: ${phrase}`);
}
const configSource = await readFile(path.join(root, "src/main/config-store.cjs"), "utf8");
for (const mode of ["server-git", "push-bundle", "monitor-only"]) {
if (!configSource.includes(mode)) throw new Error(`Deployment configuration is missing mode: ${mode}`);
}
const unraidDirectSource = await readFile(path.join(root, "src/main/unraid-deployment-service.cjs"), "utf8");
const unraidDirectSource = (await Promise.all([
"unraid-deployment-service.cjs", "unraid-access-methods.cjs", "unraid-preflight-methods.cjs",
"unraid-runtime-methods.cjs", "unraid-deployment-methods.cjs", "unraid-inventory-methods.cjs", "unraid-state-methods.cjs",
].map((file) => readFile(path.join(root, "src/main", file), "utf8")))).join("\n");
for (const requiredPhrase of [
"executePushBundle",
"executeServerGitBundle",
@@ -460,10 +547,7 @@ const gitSource = await readFile(
path.join(root, "src/main/git-service.cjs"),
"utf8",
);
const unraidSource = await readFile(
path.join(root, "src/main/unraid-deployment-service.cjs"),
"utf8",
);
const unraidSource = unraidDirectSource;
const publisher = await readFile(
path.join(root, "Publish-ForgeFlow-Release.ps1"),
"utf8",
+24
View File
@@ -1,12 +1,16 @@
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const execFileAsync = promisify(execFile);
const manifest = JSON.parse(
await readFile(path.join(root, "package.json"), "utf8"),
);
const artifacts = [];
for (const kind of ["Setup", "Portable"]) {
const name = `ForgeFlow-${kind}-${manifest.version}-win-x64.exe`;
const binary = await readFile(path.join(root, "dist", name));
@@ -17,4 +21,24 @@ for (const kind of ["Setup", "Portable"]) {
"utf8",
);
console.log(`${name}: ${sha256}`);
artifacts.push({ name, sha256 });
}
const commit = String(process.env.FORGEFLOW_BUILD_COMMIT || (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: root })).stdout).trim();
const buildId = String(process.env.FORGEFLOW_BUILD_ID || `${manifest.version}-${commit.slice(0, 12)}`);
const provenance = {
schemaVersion: 1,
product: "ForgeFlow",
version: manifest.version,
commit,
buildId,
createdAt: new Date().toISOString(),
publisherManifestSignature: "Ed25519",
authenticodeSigned: process.env.FORGEFLOW_SIGNED_RELEASE === "1",
expectedAuthenticodePublisher:
process.env.FORGEFLOW_EXPECTED_PUBLISHER || null,
artifacts,
};
await writeFile(path.join(root, "dist", `ForgeFlow-${manifest.version}-provenance.json`), `${JSON.stringify(provenance, null, 2)}\n`, "utf8");
const lock = JSON.parse(await readFile(path.join(root, "package-lock.json"), "utf8"));
const components = Object.entries(lock.packages || {}).filter(([name]) => name.startsWith("node_modules/")).map(([name, value]) => ({ type: "library", name: name.slice(13), version: value.version || "unknown", licenses: value.license ? [{ license: { id: value.license } }] : undefined })).sort((a, b) => a.name.localeCompare(b.name));
await writeFile(path.join(root, "dist", `ForgeFlow-${manifest.version}-sbom.cdx.json`), `${JSON.stringify({ bomFormat: "CycloneDX", specVersion: "1.5", serialNumber: `urn:uuid:${buildId}`, version: 1, metadata: { component: { type: "application", name: "ForgeFlow", version: manifest.version } }, components }, null, 2)}\n`, "utf8");
+145 -16
View File
@@ -3,11 +3,23 @@
const fs = require('node:fs/promises');
const path = require('node:path');
const crypto = require('node:crypto');
const { safeStorage } = require('electron');
const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePath, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
const { normalizeBaseUrl, 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 = {
schemaVersion: 11,
schemaVersion: 13,
setupComplete: false,
appearance: 'dark',
gitea: { baseUrl: '', user: null, encryptedToken: null },
@@ -15,6 +27,8 @@ const DEFAULT_CONFIG = {
repositoryMappings: {},
deploymentProfiles: {},
deploymentStates: {},
inventoryReviewDecisions: {},
gitValidator: { policies: {}, suppressions: {}, trends: {} },
favorites: [],
updates: {
owner: 'Jens',
@@ -54,6 +68,8 @@ class ConfigStore {
this.sessionToken = null;
this.data = structuredClone(DEFAULT_CONFIG);
this.saveQueue = Promise.resolve();
this.pendingSave = null;
this.lastWrittenSnapshot = null;
}
migrate(parsed) {
@@ -65,6 +81,12 @@ class ConfigStore {
gitea: { ...DEFAULT_CONFIG.gitea, ...(source.gitea || {}) },
workspaceRoots: uniqueStrings(source.workspaceRoots),
repositoryMappings: source.repositoryMappings && typeof source.repositoryMappings === 'object' ? source.repositoryMappings : {},
inventoryReviewDecisions: source.inventoryReviewDecisions && typeof source.inventoryReviewDecisions === 'object' ? structuredClone(source.inventoryReviewDecisions) : {},
gitValidator: {
policies: source.gitValidator?.policies && typeof source.gitValidator.policies === 'object' ? structuredClone(source.gitValidator.policies) : {},
suppressions: source.gitValidator?.suppressions && typeof source.gitValidator.suppressions === 'object' ? structuredClone(source.gitValidator.suppressions) : {},
trends: source.gitValidator?.trends && typeof source.gitValidator.trends === 'object' ? structuredClone(source.gitValidator.trends) : {}
},
deploymentProfiles: source.deploymentProfiles && typeof source.deploymentProfiles === 'object'
? Object.fromEntries(Object.entries(source.deploymentProfiles).map(([key, profiles]) => [key, (Array.isArray(profiles) ? profiles : []).map((profile) => {
if (!profile || typeof profile !== 'object' || profile.provider !== 'ssh-unraid') return profile;
@@ -101,7 +123,7 @@ class ConfigStore {
})]))
: {},
deploymentStates: source.deploymentStates && typeof source.deploymentStates === 'object' ? source.deploymentStates : {},
favorites: uniqueStrings(source.favorites).map((item) => item.toLowerCase()),
favorites: [...new Set(uniqueStrings(source.favorites).map((item) => item.toLowerCase()))],
updates: { ...DEFAULT_CONFIG.updates, ...(source.updates || {}) },
servers: Array.isArray(source.servers) ? source.servers.filter((item) => item && typeof item === 'object') : [],
preferences: { ...DEFAULT_CONFIG.preferences, ...(source.preferences || {}) },
@@ -130,16 +152,70 @@ class ConfigStore {
}
async save() {
const snapshot = JSON.stringify(this.data, null, 2);
// Several callers persist in quick succession (a server scan writes deployment
// state per workload). Serializing the configuration once per call is the
// expensive part, so saves that are still queued share a single write of the
// latest data. That is equivalent because every caller asks for "persist the
// current configuration", not "persist the snapshot I saw".
if (this.pendingSave) return this.pendingSave;
const operation = async () => {
this.pendingSave = null;
const snapshot = JSON.stringify(this.data, null, 2);
if (snapshot === this.lastWrittenSnapshot
&& await fs.access(this.filePath).then(() => true).catch(() => false)) return;
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
const temporary = `${this.filePath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`;
await fs.writeFile(temporary, snapshot, { mode: 0o600 });
await fs.rename(temporary, this.filePath);
try { await fs.chmod(this.filePath, 0o600); } catch {}
this.lastWrittenSnapshot = snapshot;
};
this.saveQueue = this.saveQueue.then(operation, operation);
return this.saveQueue;
this.pendingSave = this.saveQueue.then(operation, operation);
this.saveQueue = this.pendingSave.catch(() => {});
return this.pendingSave;
}
getGitValidatorState(fullName) {
const key = String(fullName || '').toLowerCase();
return {
policy: structuredClone(this.data.gitValidator.policies[key] || { id: 'standard' }),
suppressions: structuredClone(this.data.gitValidator.suppressions[key] || []),
trends: structuredClone(this.data.gitValidator.trends[key] || [])
};
}
async setGitValidatorPolicy(fullName, policy) {
const key = String(fullName || '').toLowerCase();
this.data.gitValidator.policies[key] = structuredClone(policy);
await this.save();
return this.getGitValidatorState(key);
}
async addGitValidatorSuppression(fullName, suppression) {
const key = String(fullName || '').toLowerCase();
this.data.gitValidator.suppressions[key] = [...(this.data.gitValidator.suppressions[key] || []), structuredClone(suppression)].slice(-250);
await this.save();
return this.getGitValidatorState(key);
}
async appendGitValidatorTrend(fullName, trend) {
const key = String(fullName || '').toLowerCase();
this.data.gitValidator.trends[key] = [...(this.data.gitValidator.trends[key] || []), structuredClone(trend)].slice(-100);
await this.save();
return this.getGitValidatorState(key);
}
async createRecoverySnapshot(reason = 'configuration-change') {
await this.saveQueue.catch(() => {});
const safeReason = String(reason || 'configuration-change').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'configuration-change';
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const snapshotDirectory = path.join(path.dirname(this.filePath), 'snapshots');
const snapshotPath = path.join(snapshotDirectory, `${timestamp}-${safeReason}.json`);
await fs.mkdir(snapshotDirectory, { recursive: true });
await fs.writeFile(snapshotPath, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
try { await fs.chmod(snapshotDirectory, 0o700); } catch {}
try { await fs.chmod(snapshotPath, 0o600); } catch {}
return { filePath: snapshotPath, reason: safeReason, createdAt: new Date().toISOString() };
}
setToken(token, { preserveExisting = false } = {}) {
@@ -151,7 +227,8 @@ class ConfigStore {
return { persistent: true, preserved: false };
}
if (safeStorage.isEncryptionAvailable()) {
const safeStorage = getSafeStorage();
if (safeStorage?.isEncryptionAvailable?.()) {
this.data.gitea.encryptedToken = safeStorage.encryptString(value).toString('base64');
this.sessionToken = null;
return { persistent: true, preserved: false };
@@ -166,7 +243,8 @@ class ConfigStore {
if (this.sessionToken) return this.sessionToken;
if (!this.data.gitea.encryptedToken) return '';
try {
return safeStorage.decryptString(Buffer.from(this.data.gitea.encryptedToken, 'base64'));
const safeStorage = getSafeStorage();
return safeStorage?.decryptString?.(Buffer.from(this.data.gitea.encryptedToken, 'base64')) || '';
} catch {
return '';
}
@@ -176,7 +254,8 @@ class ConfigStore {
encryptSecret(value) {
const text = String(value || '');
if (!text) return null;
if (!safeStorage.isEncryptionAvailable()) {
const safeStorage = getSafeStorage();
if (!safeStorage?.isEncryptionAvailable?.()) {
const error = new Error('Secure credential storage is unavailable. ForgeFlow will not persist server passwords or key passphrases.');
error.code = 'SECURE_STORAGE_UNAVAILABLE';
throw error;
@@ -186,7 +265,10 @@ class ConfigStore {
decryptSecret(value) {
if (!value) return '';
try { return safeStorage.decryptString(Buffer.from(value, 'base64')); }
try {
const safeStorage = getSafeStorage();
return safeStorage?.decryptString?.(Buffer.from(value, 'base64')) || '';
}
catch { return ''; }
}
@@ -202,7 +284,18 @@ class ConfigStore {
const basePath = String(source.basePath || existing?.basePath || '/mnt/user/appdata').trim().replace(/\/+$/, '');
if (!basePath.startsWith('/') || /[\r\n\0]/.test(basePath)) throw new Error('The server base path must be an absolute Unix path.');
const privateKeyPath = String(source.privateKeyPath || existing?.privateKeyPath || '').trim();
const hostFingerprint = String(source.hostFingerprint || existing?.hostFingerprint || '').trim();
const credentialIdentityChanged = Boolean(existing && [
['host', existing.host, host],
['port', existing.port, port],
['username', existing.username, username],
['authType', existing.authType, authType],
['privateKeyPath', existing.privateKeyPath, privateKeyPath]
].some(([, previous, next]) => String(previous || '') !== String(next || '')));
const hostFingerprint = credentialIdentityChanged
? ''
: String(source.hostFingerprint || existing?.hostFingerprint || '').trim();
const scanRoots = uniqueStrings(source.scanRoots || existing?.scanRoots || [basePath]).map((value) => value.replace(/\/+$/, '')).filter((value) => value.startsWith('/') && !/[\r\n\0]/.test(value));
const scanExcludes = uniqueStrings(source.scanExcludes || existing?.scanExcludes || ['backups', 'archives', 'releases', 'staging', 'testdata']).filter((value) => /^[a-zA-Z0-9._*-]+$/.test(value));
return {
id: source.id || existing?.id || crypto.randomUUID(),
name,
@@ -211,10 +304,12 @@ class ConfigStore {
username,
authType,
basePath,
scanRoots: scanRoots.length ? scanRoots : [basePath],
scanExcludes,
privateKeyPath,
hostFingerprint,
encryptedPassword: existing?.encryptedPassword || null,
encryptedPassphrase: existing?.encryptedPassphrase || null,
encryptedPassword: credentialIdentityChanged ? null : existing?.encryptedPassword || null,
encryptedPassphrase: credentialIdentityChanged ? null : existing?.encryptedPassphrase || null,
createdAt: existing?.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString()
};
@@ -317,10 +412,19 @@ class ConfigStore {
}
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 });
this.data.gitea = {
...this.data.gitea,
baseUrl,
baseUrl: nextBaseUrl,
user: user || this.data.gitea.user,
encryptedToken: this.data.gitea.encryptedToken
};
@@ -490,6 +594,27 @@ class ConfigStore {
return this.getDeploymentProfiles(fullName).find((item) => item.id === profileId) || null;
}
getInventoryReviewDecisions(serverId) {
return structuredClone(this.data.inventoryReviewDecisions[String(serverId || '')] || []);
}
async saveInventoryReviewDecision(serverId, decision) {
const key = String(serverId || '');
if (!key || !decision?.workloadId || !/^[0-9a-f]{64}$/i.test(String(decision.evidenceHash || ''))) throw new Error('A server, workload and evidence hash are required for an inventory review decision.');
const decisions = this.getInventoryReviewDecisions(key).filter((item) => item.workloadId !== decision.workloadId);
decisions.push(structuredClone(decision));
this.data.inventoryReviewDecisions[key] = decisions;
await this.save();
return structuredClone(decision);
}
async deleteInventoryReviewDecision(serverId, workloadId) {
const key = String(serverId || '');
this.data.inventoryReviewDecisions[key] = this.getInventoryReviewDecisions(key).filter((item) => item.workloadId !== workloadId);
await this.save();
return this.getInventoryReviewDecisions(key);
}
async saveDeploymentState(profileId, state) {
this.data.deploymentStates[profileId] = {
...(this.data.deploymentStates[profileId] || {}),
@@ -526,7 +651,10 @@ class ConfigStore {
const next = { ...this.data.preferences, ...(preferences || {}) };
next.repositoryPollSeconds = Math.min(Math.max(Number(next.repositoryPollSeconds) || 4, 2), 60);
next.operationPollSeconds = Math.min(Math.max(Number(next.operationPollSeconds) || 5, 3), 120);
next.fetchIntervalMinutes = Math.min(Math.max(Number(next.fetchIntervalMinutes) || 10, 0), 240);
const fetchIntervalMinutes = Number(next.fetchIntervalMinutes);
next.fetchIntervalMinutes = Number.isFinite(fetchIntervalMinutes)
? Math.min(Math.max(fetchIntervalMinutes, 0), 240)
: 10;
next.autoRefresh = next.autoRefresh !== false;
next.preferredCloneProtocol = ['https', 'ssh'].includes(next.preferredCloneProtocol) ? next.preferredCloneProtocol : 'https';
next.diagnosticsEnabled = next.diagnosticsEnabled !== false;
@@ -562,6 +690,7 @@ class ConfigStore {
repositoryMappings: { ...this.data.repositoryMappings },
deploymentProfiles: structuredClone(this.data.deploymentProfiles),
deploymentStates: structuredClone(this.data.deploymentStates),
gitValidator: structuredClone(this.data.gitValidator),
favorites: [...this.data.favorites],
updates: { ...this.data.updates },
servers: this.data.servers.map((server) => this.getPublicServer(server)),
+191
View File
@@ -0,0 +1,191 @@
"use strict";
const crypto = require("node:crypto");
function stable(value) {
if (Array.isArray(value)) return value.map(stable);
if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
return value;
}
function planId(value) {
return crypto.createHash("sha256").update(JSON.stringify(stable(value))).digest("hex");
}
function keyMaterial(value) {
return String(value || "").trim().split(/\s+/).slice(0, 2).join(" ");
}
class DeployKeyLifecycleService {
constructor({ store, gitea, keyHost, audit = null, clock = () => new Date().toISOString() }) {
this.store = store;
this.gitea = gitea;
this.keyHost = keyHost;
this.audit = audit;
this.clock = clock;
}
coordinates(repository) {
const [owner, repo] = String(repository?.fullName || "").split("/");
if (!owner || !repo) throw Object.assign(new Error("A full Gitea repository name is required."), { code: "DEPLOY_KEY_REPOSITORY_REQUIRED" });
return { owner, repo };
}
profile(repository, profileId) {
const profile = this.store.getDeploymentProfile(repository.fullName, profileId);
if (!profile) throw Object.assign(new Error("Deployment profile not found."), { code: "DEPLOY_KEY_PROFILE_NOT_FOUND" });
const server = this.store.getServer(profile.serverId);
if (!server) throw Object.assign(new Error("Deployment server not found."), { code: "DEPLOY_KEY_SERVER_NOT_FOUND" });
return { profile, server };
}
configuredReferences() {
const references = [];
const configured = this.store.data?.deploymentProfiles
? Object.entries(this.store.data.deploymentProfiles).map(([fullName, profiles]) => ({ fullName, profiles }))
: (this.store.getRepositories?.() || []).map((repository) => ({ fullName: repository.fullName, profiles: this.store.getDeploymentProfiles(repository.fullName) || [] }));
for (const repository of configured) {
for (const profile of repository.profiles || []) {
if (!profile.serverGitAccess?.deployKeyId && !profile.serverGitAccess?.keyFingerprint) continue;
references.push({ repository: repository.fullName, profileId: profile.id, serverId: profile.serverId, keyId: profile.serverGitAccess.deployKeyId || null, fingerprint: profile.serverGitAccess.keyFingerprint || null });
}
}
return references;
}
async inventory({ repository, profileId }) {
const { profile, server } = this.profile(repository, profileId);
const { owner, repo } = this.coordinates(repository);
const [remoteKeys, serverKey] = await Promise.all([
this.gitea.listDeployKeys(owner, repo),
this.keyHost.inspect({ repository, profile, server }),
]);
const configuredId = Number(profile.serverGitAccess?.deployKeyId) || null;
const configured = remoteKeys.find((key) => Number(key.id) === configuredId) || null;
const material = keyMaterial(serverKey?.publicKey);
const matching = material ? remoteKeys.filter((key) => keyMaterial(key.key) === material) : [];
const references = this.configuredReferences();
const shared = references.filter((reference) => reference.fingerprint && reference.fingerprint === serverKey?.fingerprint && (reference.repository !== repository.fullName || reference.profileId !== profileId));
const conflicts = remoteKeys.filter((key) => key.read_only !== true && (!configuredId || Number(key.id) === configuredId || keyMaterial(key.key) === material));
const stale = Boolean(configuredId && !configured) || Boolean(profile.serverGitAccess?.keyFingerprint && serverKey?.fingerprint && profile.serverGitAccess.keyFingerprint !== serverKey.fingerprint);
const orphaned = remoteKeys.filter((key) => /ForgeFlow/i.test(String(key.title || "")) && !references.some((reference) => Number(reference.keyId) === Number(key.id)));
return {
repository: repository.fullName, profileId, server: { id: server.id, name: server.name },
configuredKey: configured ? { id: configured.id, title: configured.title, readOnly: configured.read_only === true, key: configured.key || null } : null,
serverKey, matchingKeys: matching.map((key) => ({ id: key.id, readOnly: key.read_only === true })),
stale, orphaned: orphaned.map((key) => ({ id: key.id, title: key.title })), shared, conflicts: conflicts.map((key) => ({ id: key.id, title: key.title, readOnly: false })),
ready: Boolean(configured && configured.read_only === true && serverKey?.privateKeyPresent && serverKey?.fingerprint === profile.serverGitAccess?.keyFingerprint && !shared.length && !conflicts.length),
checkedAt: this.clock(),
};
}
async planRotation({ repository, profileId }) {
const evidence = await this.inventory({ repository, profileId });
const plan = {
operation: "rotate-deploy-key", repository: repository.fullName, profileId,
currentKeyId: evidence.configuredKey?.id || null, currentFingerprint: evidence.serverKey?.fingerprint || null,
serverId: evidence.server.id, impact: ["Generate a new private key on the linked server", "Register only its public key in this repository", "Verify read-only branch access", "Switch the profile atomically", "Revoke the previous key after the switch"],
recovery: "The previous server key and profile metadata remain recoverable until post-rotation verification succeeds.", evidence,
};
plan.id = planId(plan);
await this.audit?.append?.("deployment.deploy-key-rotation-planned", { repository: repository.fullName, profileId, planId: plan.id });
return plan;
}
async rotate({ repository, profileId, expectedPlanId }) {
const plan = await this.planRotation({ repository, profileId });
if (!expectedPlanId) throw Object.assign(new Error("Review a deploy-key rotation plan before applying it."), { code: "DEPLOY_KEY_ROTATION_PLAN_REQUIRED", plan });
if (expectedPlanId !== plan.id) throw Object.assign(new Error("Deploy-key evidence changed after preview. Review a fresh plan."), { code: "DEPLOY_KEY_ROTATION_PLAN_STALE", plan });
const { profile, server } = this.profile(repository, profileId);
const { owner, repo } = this.coordinates(repository);
const snapshot = await this.store.createRecoverySnapshot?.(`deploy-key-rotation:${repository.fullName}:${profileId}`);
const previous = { profile: structuredClone(profile), key: await this.keyHost.backup({ repository, profile, server }), remoteKey: plan.evidence.configuredKey };
let candidate = null;
let registered = null;
let switched = false;
let oldRevoked = false;
try {
candidate = await this.keyHost.generate({ repository, profile, server });
if (!candidate?.publicKey || !candidate?.fingerprint || candidate.privateKey) throw Object.assign(new Error("The server did not return safe public-key evidence."), { code: "DEPLOY_KEY_CANDIDATE_INVALID" });
registered = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: `ForgeFlow · ${server.name} · ${candidate.fingerprint.slice(-12)}`, publicKey: candidate.publicKey });
if (registered.read_only !== true) throw Object.assign(new Error("Gitea registered the candidate with write access."), { code: "DEPLOY_KEY_NOT_READ_ONLY" });
const proof = await this.keyHost.verifyCandidate({ repository, profile, server, candidate, keyId: registered.id });
if (!proof?.ready || proof.fingerprint !== candidate.fingerprint) throw Object.assign(new Error("The candidate deploy key could not prove read-only repository access."), { code: "DEPLOY_KEY_CANDIDATE_VERIFICATION_FAILED", proof });
await this.keyHost.preflightCandidate({ repository, profile, server, candidate, proof });
await this.keyHost.promote({ repository, profile, server, candidate, previous });
const updated = await this.store.saveDeploymentProfile(repository.fullName, { ...profile, serverGitAccess: { configured: true, deployKeyId: registered.id, keyFingerprint: candidate.fingerprint, hostFingerprint: proof.hostFingerprint, configuredAt: this.clock(), rotatedAt: this.clock(), previousKeyId: previous.remoteKey?.id || null } });
switched = true;
if (previous.remoteKey?.id) {
await this.gitea.deleteDeployKey(owner, repo, previous.remoteKey.id);
oldRevoked = true;
}
const post = await this.keyHost.verifyActive({ repository, profile: updated, server });
if (!post?.ready || post.fingerprint !== candidate.fingerprint) throw Object.assign(new Error("Post-rotation verification failed."), { code: "DEPLOY_KEY_POST_ROTATION_FAILED", post });
await this.keyHost.commit({ repository, profile: updated, server, candidate, previous });
await this.audit?.append?.("deployment.deploy-key-rotated", { repository: repository.fullName, profileId, oldKeyId: previous.remoteKey?.id || null, newKeyId: registered.id, fingerprint: candidate.fingerprint, snapshot: snapshot?.filePath || null });
return { profile: updated, proof: post, snapshot, recovery: previous.key?.recovery || null };
} catch (error) {
try {
if (candidate) await this.keyHost.rollback({ repository, profile, server, candidate, previous });
if (registered?.id) await this.gitea.deleteDeployKey(owner, repo, registered.id).catch(() => {});
let restoredKey = null;
if (oldRevoked && previous.remoteKey?.key) restoredKey = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: previous.remoteKey.title || `ForgeFlow · ${server.name} · restored`, publicKey: previous.remoteKey.key });
if (switched) await this.store.saveDeploymentProfile(repository.fullName, restoredKey ? { ...previous.profile, serverGitAccess: { ...previous.profile.serverGitAccess, deployKeyId: restoredKey.id } } : previous.profile);
} catch (rollbackError) {
error.rollbackError = rollbackError.message;
}
await this.audit?.append?.("deployment.deploy-key-rotation-failed", { repository: repository.fullName, profileId, code: error.code || "DEPLOY_KEY_ROTATION_FAILED", rollbackError: error.rollbackError || null });
throw error;
}
}
async planRevocation({ repository, profileId }) {
const evidence = await this.inventory({ repository, profileId });
const plan = { operation: "revoke-deploy-key", repository: repository.fullName, profileId, keyId: evidence.configuredKey?.id || null, fingerprint: evidence.serverKey?.fingerprint || null, linkedDeployments: [profileId], impact: ["Remove this repository deploy key from Gitea", "Disable server-pull deployment until restored", "Preserve server-side recovery material"], containersUnaffected: true, evidence };
plan.id = planId(plan);
return plan;
}
async revoke({ repository, profileId, expectedPlanId }) {
const plan = await this.planRevocation({ repository, profileId });
if (!expectedPlanId) throw Object.assign(new Error("Review revocation impact before applying it."), { code: "DEPLOY_KEY_REVOCATION_PLAN_REQUIRED", plan });
if (plan.id !== expectedPlanId) throw Object.assign(new Error("Deploy-key evidence changed after preview."), { code: "DEPLOY_KEY_REVOCATION_PLAN_STALE", plan });
const { profile, server } = this.profile(repository, profileId);
const { owner, repo } = this.coordinates(repository);
const snapshot = await this.store.createRecoverySnapshot?.(`deploy-key-revocation:${repository.fullName}:${profileId}`);
const recovery = await this.keyHost.backup({ repository, profile, server });
let remoteDeleted = false;
try {
if (plan.keyId) { await this.gitea.deleteDeployKey(owner, repo, plan.keyId); remoteDeleted = true; }
await this.keyHost.revoke({ repository, profile, server, recovery });
const updated = await this.store.saveDeploymentProfile(repository.fullName, { ...profile, serverGitAccess: { ...profile.serverGitAccess, configured: false, revokedAt: this.clock(), recoveryAvailable: true }, deploymentMode: "monitor-only" });
await this.audit?.append?.("deployment.deploy-key-revoked", { repository: repository.fullName, profileId, keyId: plan.keyId, snapshot: snapshot?.filePath || null });
return { profile: updated, snapshot, recovery: recovery?.recovery || null };
} catch (error) {
if (remoteDeleted && plan.evidence.configuredKey?.key) {
const restored = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: plan.evidence.configuredKey.title || `ForgeFlow · ${server.name} · restored`, publicKey: plan.evidence.configuredKey.key });
await this.store.saveDeploymentProfile(repository.fullName, { ...profile, serverGitAccess: { ...profile.serverGitAccess, deployKeyId: restored.id } });
}
await this.keyHost.restore({ repository, profile, server }).catch(() => {});
await this.audit?.append?.("deployment.deploy-key-revocation-failed", { repository: repository.fullName, profileId, code: error.code || "DEPLOY_KEY_REVOCATION_FAILED" });
throw error;
}
}
async restore({ repository, profileId }) {
const { profile, server } = this.profile(repository, profileId);
const restored = await this.keyHost.restore({ repository, profile, server });
if (!restored?.publicKey || !restored?.fingerprint) throw Object.assign(new Error("No valid deploy-key recovery material exists."), { code: "DEPLOY_KEY_RECOVERY_UNAVAILABLE" });
const { owner, repo } = this.coordinates(repository);
const key = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: `ForgeFlow · ${server.name} · restored`, publicKey: restored.publicKey });
if (key.read_only !== true) throw Object.assign(new Error("The restored key is not read-only."), { code: "DEPLOY_KEY_NOT_READ_ONLY" });
const proposed = { ...profile, deploymentMode: "server-git", serverGitAccess: { configured: true, deployKeyId: key.id, keyFingerprint: restored.fingerprint, hostFingerprint: restored.hostFingerprint, restoredAt: this.clock() } };
const proof = await this.keyHost.verifyActive({ repository, profile: proposed, server });
if (!proof?.ready) throw Object.assign(new Error("Restored access could not be verified."), { code: "DEPLOY_KEY_RECOVERY_VERIFICATION_FAILED" });
const updated = await this.store.saveDeploymentProfile(repository.fullName, proposed);
await this.audit?.append?.("deployment.deploy-key-restored", { repository: repository.fullName, profileId, keyId: key.id, fingerprint: restored.fingerprint });
return { profile: updated, proof };
}
}
module.exports = { DeployKeyLifecycleService, keyMaterial, deployKeyPlanId: planId };
+35
View File
@@ -0,0 +1,35 @@
"use strict";
const crypto = require("node:crypto");
const { normalizeRemoteUrl } = require("../shared/repository-match.cjs");
function canonicalRemote(value) {
const normalized = normalizeRemoteUrl(value);
return normalized ? `${normalized.host}/${normalized.path}`.toLowerCase() : "";
}
function deploymentIdentity({ workload, profile = null, repository = null }) {
const remote = canonicalRemote(workload?.metadata?.sourceRepository || repository?.sshUrl || repository?.cloneUrl || profile?.cloneUrl);
return {
repository: remote || String(workload?.link?.repositoryFullName || repository?.fullName || profile?._repositoryFullName || "").toLowerCase(),
branch: String(workload?.metadata?.branch || profile?.branch || repository?.defaultBranch || "").toLowerCase(),
serverId: String(workload?.serverId || profile?.serverId || ""),
environment: String(profile?.environment || "production").toLowerCase(),
composeProject: String(workload?.compose?.project || profile?.composeProject || "").toLowerCase(),
deploymentRoot: String(workload?.compose?.workingDir || profile?.composeWorkingDir || workload?.remoteFolderCandidate || profile?.remoteFolder || "").replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase(),
containers: (workload?.containers || []).map((item) => String(item.id || item.name || "").toLowerCase()).sort(),
liveSha: String(workload?.metadata?.liveRevision || "").toLowerCase(),
profileId: String(profile?.id || workload?.link?.profileId || ""),
};
}
function evidenceHash(identity, evidence = {}) {
const stable = (value) => Array.isArray(value) ? value.map(stable) : value && typeof value === "object" ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])])) : value;
return crypto.createHash("sha256").update(JSON.stringify(stable({ identity, evidence }))).digest("hex");
}
function authorityKey(identity) {
return [identity.repository, identity.serverId, identity.environment].join("|");
}
module.exports = { canonicalDeploymentRemote: canonicalRemote, deploymentIdentity, deploymentEvidenceHash: evidenceHash, deploymentAuthorityKey: authorityKey };
+8 -5
View File
@@ -36,10 +36,6 @@ function isRunningStatus(value) {
return ['running', 'in_progress', 'processing'].includes(String(value || '').toLowerCase());
}
function isQueuedStatus(value) {
return ['pending', 'queued', 'waiting', 'blocked', 'requested'].includes(String(value || '').toLowerCase());
}
class DeploymentService {
constructor(store, giteaService, gitService, diagnostics = null) {
this.store = store;
@@ -328,8 +324,15 @@ class DeploymentService {
async refreshActiveOperations() {
const active = this.store.data.operations.filter((item) => item.type === 'deployment' && !TERMINAL_STATUSES.has(item.status));
const queue = active.slice(0, 20);
const results = [];
for (const operation of active.slice(0, 20)) results.push(await this.refreshOperation(operation.id));
const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
while (queue.length) {
const operation = queue.shift();
results.push(await this.refreshOperation(operation.id));
}
});
await Promise.all(workers);
return results;
}
+41 -12
View File
@@ -5,7 +5,7 @@ const path = require('node:path');
const os = require('node:os');
const crypto = require('node:crypto');
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 };
@@ -46,6 +46,9 @@ class DiagnosticsService {
this.preferencesProvider = preferencesProvider;
this.sessionId = crypto.randomUUID();
this.writeChain = Promise.resolve();
this.pendingLines = [];
this.pendingFlush = null;
this.securedFiles = new Set();
this.initialized = false;
this.lastWriteError = null;
this.lastBundlePath = null;
@@ -115,13 +118,25 @@ class DiagnosticsService {
sessionId: this.sessionId,
details
});
const line = `${JSON.stringify(record)}\n`;
this.writeChain = this.writeChain.then(async () => {
this.pendingLines.push(`${JSON.stringify(record)}\n`);
// At the debug level every IPC call and every Gitea request writes a line.
// Records that queue up while a write is in flight are appended together, so
// a burst costs one open/write/close instead of one per record.
if (this.pendingFlush) return this.pendingFlush;
this.pendingFlush = this.writeChain.then(async () => {
this.pendingFlush = null;
const lines = this.pendingLines.splice(0).join('');
if (!lines) return true;
try {
if (!this.initialized) await fs.mkdir(this.logDirectory, { recursive: true, mode: 0o700 });
const target = await this.rotateIfNeeded(this.filePathForToday());
await fs.appendFile(target, line, { encoding: 'utf8', mode: 0o600 });
try { await fs.chmod(target, 0o600); } catch {}
await fs.appendFile(target, lines, { encoding: 'utf8', mode: 0o600 });
// The mode above only applies when appendFile creates the file, so the
// explicit chmod is needed once per file rather than once per record.
if (!this.securedFiles.has(target)) {
try { await fs.chmod(target, 0o600); } catch { /* best effort */ }
this.securedFiles.add(target);
}
this.lastWriteError = null;
return true;
} catch (error) {
@@ -129,7 +144,8 @@ class DiagnosticsService {
return false;
}
});
return this.writeChain;
this.writeChain = this.pendingFlush.catch(() => {});
return this.pendingFlush;
}
debug(event, details) { return this.log('debug', event, details); }
@@ -186,7 +202,7 @@ class DiagnosticsService {
return this.getStatus();
}
async collectLogs(maxBytes = 20 * 1024 * 1024) {
async collectLogs(maxBytes = 20 * 1024 * 1024, { strictIdentifiers = false } = {}) {
await this.flush();
const output = [];
let used = 0;
@@ -195,7 +211,16 @@ class DiagnosticsService {
const remaining = maxBytes - used;
const content = await fs.readFile(file.path);
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;
}
return output;
@@ -275,9 +300,13 @@ class DiagnosticsService {
dispatchedAt: operation.dispatchedAt,
stages: operation.stages,
jobs: operation.jobs,
logs: operation.logs,
failure: operation.failure,
pollError: operation.pollError,
remoteOutput: operation.logs || operation.failure || operation.pollError ? {
included: false,
reason: 'Remote build and command output is intentionally omitted because it may contain application secrets unknown to ForgeFlow.',
logCharacters: String(operation.logs || '').length,
failureRecorded: Boolean(operation.failure),
pollErrorRecorded: Boolean(operation.pollError)
} : null,
applicationState: operation.applicationState,
run: operation.run ? {
id: operation.run.id,
@@ -325,7 +354,7 @@ class DiagnosticsService {
{ name: 'operations-sanitized.json', data: safeJson(sanitizedOperations) },
{ name: 'preflight.json', data: safeJson(sanitize(preflight || {})) },
{ name: 'context.json', data: safeJson(sanitize(extra || {})) },
...(await this.collectLogs())
...(await this.collectLogs(20 * 1024 * 1024, { strictIdentifiers: strict }))
];
const safetyAudit = auditBundleEntries(entries, this.secretProvider?.() || []);
+19 -6
View File
@@ -3,13 +3,26 @@
const { spawn } = require('node:child_process');
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 executable = String(source.executable || defaults.executable).trim();
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);
if (args.some((item) => /[\r\n\0]/.test(item))) throw new Error('Tool argument is invalid.');
return { executable, args };
const profile = TOOL_PROFILES[kind]?.[executable.toLowerCase()];
if (!profile) throw new Error(`Unsupported ${kind || 'external'} tool. Select a built-in trusted tool profile.`);
return { executable, args: [...profile] };
}
function expandTool(tool, context) {
@@ -27,7 +40,7 @@ class ExternalToolsService {
const defaults = kind === 'terminal'
? { executable: 'wt.exe', args: ['-d', '{path}'] }
: { 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 child = spawn(invocation.executable, invocation.args, { cwd: root, detached: true, stdio: 'ignore', windowsHide: false, shell: false });
child.unref();
@@ -35,4 +48,4 @@ class ExternalToolsService {
}
}
module.exports = { ExternalToolsService, normalizeTool, expandTool };
module.exports = { ExternalToolsService, normalizeTool, expandTool, TOOL_PROFILES };
+331 -13
View File
@@ -2,11 +2,13 @@
const path = require('node:path');
const fs = require('node:fs/promises');
const crypto = require('node:crypto');
const { run } = require('./process-runner.cjs');
const { parsePorcelainV2 } = require('../shared/git-status.cjs');
const { normalizeRemoteUrl } = require('../shared/repository-match.cjs');
const COMMON_GIT_LOCK_FILES = ['HEAD.lock', 'index.lock'];
const MAX_UNTRACKED_DIFF_BYTES = 16 * 1024 * 1024;
const {
assertSafeRepositoryPath,
assertRepositoryRelativePath,
@@ -28,7 +30,42 @@ function parseUnifiedDiff(diffText) {
return { header, hunks };
}
function parseNameStatus(output) {
const entries = String(output || '').split('\0');
const changes = [];
for (let index = 0; index < entries.length;) {
const rawStatus = entries[index++];
if (!rawStatus) continue;
const code = rawStatus[0];
if (code === 'R' || code === 'C') {
const originalPath = entries[index++] || '';
const filePath = entries[index++] || '';
if (filePath) changes.push({ code, status: code === 'R' ? 'renamed' : 'copied', path: filePath, originalPath });
continue;
}
const filePath = entries[index++] || '';
if (!filePath) continue;
const labels = { A: 'added', D: 'deleted', M: 'modified', T: 'type-changed', U: 'conflict' };
changes.push({ code, status: labels[code] || 'changed', path: filePath, originalPath: null });
}
return changes;
}
function parseCompactLog(output) {
return String(output || '').split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
const [sha, shortSha, date, subject] = record.split('\x1f');
return { sha, shortSha, date, subject };
});
}
class GitService {
constructor() {
// `git remote get-url` is only re-run when the repository configuration file
// itself changed. Status polling asks for the remote URL of every repository
// every few seconds, and on Windows the child process dominates that cost.
this.remoteUrlCache = new Map();
}
async isAvailable() {
try {
const result = await run('git', ['--version'], { timeout: 10_000 });
@@ -42,13 +79,24 @@ class GitService {
const resolved = assertSafeRepositoryPath(repoPath);
const stat = await fs.stat(resolved).catch(() => null);
if (!stat?.isDirectory()) throw new Error('The linked local folder no longer exists.');
// A directory that carries its own `.git` entry is by definition the top level
// of that working tree, for plain repositories as well as for submodules and
// linked worktrees where `.git` is a file. Spawning `git rev-parse` to learn
// that again is pure overhead, and every status poll passes an already
// resolved repository root back in.
const marker = await fs.stat(path.join(resolved, '.git')).catch(() => null);
if (marker) return resolved;
const result = await run('git', ['rev-parse', '--show-toplevel'], { cwd: resolved, timeout: 15_000 });
return path.resolve(result.stdout.trim());
}
async status(repoPath) {
const root = await this.ensureRepository(repoPath);
const result = await run('git', ['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'], {
// `--no-optional-locks` keeps a status read from refreshing and rewriting the
// index. Without it every read writes inside .git, which both fights a
// concurrent Git command for the index lock and retriggers the filesystem
// watcher that asked for this read in the first place.
const result = await run('git', ['--no-optional-locks', 'status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'], {
cwd: root,
timeout: 30_000
});
@@ -66,9 +114,34 @@ class GitService {
});
}
remoteUrlCacheKey(repoPath, remote) {
return JSON.stringify([path.resolve(repoPath), remote]);
}
async getRemoteUrl(repoPath, remote = 'origin') {
const result = await run('git', ['remote', 'get-url', remote], { cwd: repoPath, timeout: 15_000 });
return result.stdout.trim();
const cacheKey = this.remoteUrlCacheKey(repoPath, remote);
const config = await fs.stat(path.join(repoPath, '.git', 'config')).catch(() => null);
const cached = this.remoteUrlCache.get(cacheKey);
if (config && cached && cached.mtimeMs === config.mtimeMs && cached.size === config.size) {
if (cached.error) throw cached.error;
return cached.url;
}
const remember = (entry) => {
if (config) this.remoteUrlCache.set(cacheKey, { ...entry, mtimeMs: config.mtimeMs, size: config.size });
else this.remoteUrlCache.delete(cacheKey);
};
try {
const result = await run('git', ['remote', 'get-url', remote], { cwd: repoPath, timeout: 15_000 });
const url = result.stdout.trim();
remember({ url, error: null });
return url;
} catch (error) {
// A repository that has no such remote keeps failing until its configuration
// changes, so the failure is remembered too. Without this, every status poll
// of an unmatched local repository spawns a child process that cannot succeed.
remember({ url: '', error });
throw error;
}
}
@@ -94,6 +167,48 @@ class GitService {
return { root, gitDir: path.resolve(result.stdout.trim()) };
}
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) {
const message = String(error?.message || error || '');
return /(?:cannot lock ref|Unable to create .*\.lock|another git process)/i.test(message)
@@ -273,12 +388,163 @@ class GitService {
return { strategy: requested, backupBranch: null, status, lockReport: await this.listGitLocks(root) };
}
async previewWorkspaceSync(repoPath) {
const root = await this.ensureRepository(repoPath);
const { status } = await this.fetch(root);
const branch = status.branch?.head;
const upstream = status.branch?.upstream;
if (!status.head || !branch || branch === '(detached)') {
const error = new Error('Workspace synchronization requires a named branch with at least one commit.');
error.code = 'WORKSPACE_SYNC_BRANCH_REQUIRED';
throw error;
}
if (!upstream) {
const error = new Error('The current branch has no Gitea upstream. Publish it or switch to a tracked branch first.');
error.code = 'WORKSPACE_SYNC_UPSTREAM_REQUIRED';
throw error;
}
const targetSha = (await run('git', ['rev-parse', '--verify', upstream], { cwd: root, timeout: 30_000 })).stdout.trim();
const changes = parseNameStatus((await run('git', [
'diff', '--name-status', '-z', '--find-renames', 'HEAD', upstream, '--'
], { cwd: root, timeout: 60_000, maxBuffer: 16 * 1024 * 1024 })).stdout);
const logFormat = '%H%x1f%h%x1f%aI%x1f%s%x1e';
const [incomingResult, localResult, interruptedOperation] = await Promise.all([
run('git', ['log', `--format=${logFormat}`, `HEAD..${upstream}`, '-20'], { cwd: root, timeout: 30_000 }),
run('git', ['log', `--format=${logFormat}`, `${upstream}..HEAD`, '-20'], { cwd: root, timeout: 30_000 }),
this.detectInterruptedOperation(root)
]);
const blockers = [];
if (interruptedOperation) blockers.push(`Finish or abort the active Git ${interruptedOperation} before synchronizing.`);
if (status.counts.conflicts) blockers.push(`Resolve ${status.counts.conflicts} conflicted file${status.counts.conflicts === 1 ? '' : 's'} before synchronizing.`);
const summary = {
resultingTrackedChanges: changes.length,
added: changes.filter((item) => item.code === 'A').length,
modified: changes.filter((item) => ['M', 'T'].includes(item.code)).length,
deleted: changes.filter((item) => item.code === 'D').length,
renamed: changes.filter((item) => item.code === 'R').length,
localFilesToStash: status.counts.changed,
untrackedFilesToStash: status.counts.untracked,
localCommitsToProtect: status.branch.ahead,
incomingCommits: status.branch.behind
};
const planId = crypto.createHash('sha256').update(JSON.stringify({
head: status.head,
targetSha,
branch,
upstream,
fingerprint: this.statusFingerprint(status)
})).digest('hex');
return {
id: planId,
repositoryRoot: root,
branch,
upstream,
currentSha: status.head,
targetSha,
needsSync: status.head !== targetSha || !status.clean,
cleanBeforeSync: status.clean,
blockers,
summary,
changes: changes.slice(0, 250),
changesTruncated: changes.length > 250,
localFiles: status.files.slice(0, 250),
localFilesTruncated: status.files.length > 250,
incomingCommits: parseCompactLog(incomingResult.stdout),
localCommits: parseCompactLog(localResult.stdout),
recovery: {
safetyBranch: status.branch.ahead > 0,
stash: status.counts.changed > 0,
untrackedCleanup: status.counts.untracked > 0,
ignoredFilesPreserved: true
}
};
}
async synchronizeWorkspace(repoPath, expectedPlanId) {
const expected = String(expectedPlanId || '').trim();
if (!/^[0-9a-f]{64}$/i.test(expected)) {
const error = new Error('Apply workspace synchronization only from a reviewed preview.');
error.code = 'WORKSPACE_SYNC_PLAN_REQUIRED';
throw error;
}
const plan = await this.previewWorkspaceSync(repoPath);
if (plan.id !== expected) {
const error = new Error('The local workspace or Gitea branch changed after the preview. Review a fresh synchronization plan.');
error.code = 'WORKSPACE_SYNC_PLAN_STALE';
error.recoverable = true;
throw error;
}
if (plan.blockers.length) {
const error = new Error(plan.blockers.join(' '));
error.code = 'WORKSPACE_SYNC_BLOCKED';
error.recoverable = true;
throw error;
}
if (!plan.needsSync) {
return { applied: false, unchanged: true, plan, status: await this.status(plan.repositoryRoot), backupBranch: null, stash: null, cleaned: [] };
}
const root = plan.repositoryRoot;
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
let backupBranch = null;
let stash = null;
let review = null;
if (plan.summary.localCommitsToProtect > 0) {
const safeBranch = plan.branch.replace(/[^A-Za-z0-9._-]/g, '-');
backupBranch = `forgeflow/recovery-${safeBranch}-${stamp}-${plan.currentSha.slice(0, 7)}`;
await run('git', ['check-ref-format', '--branch', backupBranch], { cwd: root, timeout: 30_000 });
await run('git', ['branch', backupBranch, 'HEAD'], { cwd: root, timeout: 30_000 });
}
if (plan.summary.localFilesToStash > 0) {
const label = `FORGEFLOW-QUARANTINE:${plan.id} workspace sync ${plan.branch} ${stamp}`;
await run('git', ['stash', 'push', '--include-untracked', '-m', label], { cwd: root, timeout: 120_000 });
stash = (await this.stashList(root))[0] || null;
}
if (backupBranch || stash) {
review = await this.writeWorkspaceReviewManifest(root, plan, { backupBranch, stash });
}
const protectedStatus = await this.status(root);
if (!protectedStatus.clean || protectedStatus.head !== plan.currentSha) {
const error = new Error('The workspace changed while ForgeFlow was protecting local work. Nothing was reset; review a fresh synchronization plan.');
error.code = 'WORKSPACE_SYNC_CONCURRENT_CHANGE';
error.recoverable = true;
error.backupBranch = backupBranch;
error.stash = stash;
throw error;
}
await run('git', ['reset', '--hard', plan.targetSha], { cwd: root, timeout: 2 * 60_000 });
const status = await this.status(root);
if (status.head !== plan.targetSha || !status.clean) {
const error = new Error('Git did not verify an exact clean match with the reviewed Gitea commit. Local recovery references were preserved.');
error.code = 'WORKSPACE_SYNC_VERIFICATION_FAILED';
error.recoverable = true;
error.backupBranch = backupBranch;
error.stash = stash;
throw error;
}
return {
applied: true,
unchanged: false,
plan,
status,
backupBranch,
stash,
review,
cleaned: plan.localFiles.filter((file) => file.untracked).map((file) => file.path),
ignoredFilesPreserved: true
};
}
async setRemoteUrl(repoPath, remoteUrl, remote = 'origin') {
const root = await this.ensureRepository(repoPath);
const safeRemote = assertCloneRemote(remoteUrl);
const name = String(remote || 'origin').trim();
if (!/^[A-Za-z0-9._-]+$/.test(name)) throw new Error('Invalid Git remote name.');
await run('git', ['remote', 'set-url', name, safeRemote], { cwd: root, timeout: 30_000 });
this.remoteUrlCache.delete(this.remoteUrlCacheKey(root, name));
return this.status(root);
}
@@ -292,7 +558,26 @@ class GitService {
if (!result.stdout && safeFile && !staged) {
const candidate = path.resolve(root, safeFile);
if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) throw new Error('File path escapes repository root.');
const content = await fs.readFile(candidate, 'utf8').catch(() => '');
const [realRoot, realCandidate, candidateStat] = await Promise.all([
fs.realpath(root).catch(() => root),
fs.realpath(candidate).catch(() => candidate),
fs.stat(candidate).catch(() => null)
]);
const normalize = (value) => process.platform === 'win32' ? value.toLowerCase() : value;
const normalizedRoot = normalize(realRoot);
const normalizedCandidate = normalize(realCandidate);
if (normalizedCandidate !== normalizedRoot && !normalizedCandidate.startsWith(`${normalizedRoot}${path.sep}`)) {
const error = new Error('ForgeFlow refuses to read a diff target that resolves outside the repository.');
error.code = 'DIFF_TARGET_OUTSIDE_REPOSITORY';
throw error;
}
if (candidateStat?.size > MAX_UNTRACKED_DIFF_BYTES) {
const error = new Error('The untracked file is too large to render safely as a diff.');
error.code = 'DIFF_FILE_TOO_LARGE';
error.recoverable = true;
throw error;
}
const content = candidateStat?.isFile() ? await fs.readFile(candidate, 'utf8').catch(() => '') : '';
if (content) return `diff --git a/${safeFile} b/${safeFile}\nnew file mode 100644\n--- /dev/null\n+++ b/${safeFile}\n${content.split('\n').map((line) => `+${line}`).join('\n')}`;
}
return result.stdout;
@@ -354,8 +639,7 @@ class GitService {
return { selected, matches };
}
async expandSelectedPaths(root, files, { unstagedOnly = false } = {}) {
const status = await this.status(root);
expandStatusPaths(status, files, { unstagedOnly = false } = {}) {
const { selected, matches } = this.selectedStatusFiles(status, files);
if (!selected.length) return [];
const expanded = new Set();
@@ -367,12 +651,17 @@ class GitService {
return [...expanded];
}
async stage(repoPath, files) {
const root = await this.ensureRepository(repoPath);
async expandSelectedPaths(root, files, options = {}) {
return this.expandStatusPaths(await this.status(root), files, options);
}
// Callers that already read the status pass it in. Reading it again costs a
// child process, and a commit used to pay for four of them.
async applyStage(root, files, knownStatus = null) {
const requested = assertRepositoryRelativePaths(files);
if (!requested.length) {
await run('git', ['add', '--all'], { cwd: root, timeout: 60_000 });
return this.status(root);
return;
}
// Only stage records that still have a worktree-side change. Re-running
@@ -380,10 +669,16 @@ class GitService {
// Git fail with "pathspec did not match any files" because the file no
// longer exists in either the worktree or HEAD. Staged-only deletions and
// renames are already ready for commit and must therefore be left alone.
const selected = await this.expandSelectedPaths(root, requested, { unstagedOnly: true });
const status = knownStatus || await this.status(root);
const selected = this.expandStatusPaths(status, requested, { unstagedOnly: true });
if (selected.length) {
await this.runWithPathspec(root, ['add', '-A'], selected, { timeout: 120_000 });
}
}
async stage(repoPath, files) {
const root = await this.ensureRepository(repoPath);
await this.applyStage(root, files);
return this.status(root);
}
@@ -403,8 +698,9 @@ class GitService {
async prepareSelectedStage(root, files) {
const selected = assertRepositoryRelativePaths(files);
let current = null;
if (selected.length) {
const current = await this.status(root);
current = await this.status(root);
const excludedStaged = current.files
.filter((file) => file.staged)
.filter((file) => !selected.includes(file.path) && !(file.originalPath && selected.includes(file.originalPath)))
@@ -413,7 +709,7 @@ class GitService {
throw new Error(`Some staged files are not selected (${excludedStaged.slice(0, 3).join(', ')}${excludedStaged.length > 3 ? ', …' : ''}). Select them or unstage them first.`);
}
}
await this.stage(root, selected);
await this.applyStage(root, selected, current);
const stagedCheck = await run('git', ['diff', '--cached', '--quiet'], { cwd: root, allowExitCodes: [1] });
if (stagedCheck.exitCode === 0) throw new Error('There are no staged changes to commit.');
return selected;
@@ -469,6 +765,12 @@ class GitService {
const status = await this.status(root);
const branch = status.branch.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 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) };
@@ -555,7 +857,16 @@ class GitService {
const result = await run('git', ['stash', 'list', `--format=${format}`], { cwd: root });
return result.stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
const [ref, sha, date, subject] = record.split('\x1f');
return { ref, sha, shortSha: sha.slice(0, 7), date, subject };
const quarantine = String(subject || '').match(/FORGEFLOW-QUARANTINE:([0-9a-f]{64})/i);
return {
ref,
sha,
shortSha: sha.slice(0, 7),
date,
subject,
quarantined: Boolean(quarantine),
reviewId: quarantine?.[1] || null
};
});
}
@@ -563,6 +874,13 @@ class GitService {
const root = await this.ensureRepository(repoPath);
const value = String(ref || 'stash@{0}');
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 });
return { output: result.stdout.trim(), status: await this.status(root), stashes: await this.stashList(root) };
}
+89
View File
@@ -0,0 +1,89 @@
"use strict";
const crypto = require("node:crypto");
const PROFILE_DEFINITIONS = Object.freeze({
minimal: { label: "Minimal", requiredScore: 55, severities: ["error"], allowSuppressions: true, maxSuppressionDays: 180 },
standard: { label: "Standard", requiredScore: 70, severities: ["error", "warning"], allowSuppressions: true, maxSuppressionDays: 90 },
strict: { label: "Strict", requiredScore: 82, severities: ["error", "warning"], allowSuppressions: true, maxSuppressionDays: 30 },
production: { label: "Production", requiredScore: 90, severities: ["error", "warning"], allowSuppressions: true, maxSuppressionDays: 14 },
});
function normalizePolicy(policy = {}) {
const id = String(policy.id || policy.profile || "standard").toLowerCase();
const base = PROFILE_DEFINITIONS[id] || PROFILE_DEFINITIONS.standard;
const custom = id === "organization" ? policy : {};
return {
id,
label: custom.label || base.label || "Organization custom",
requiredScore: Math.min(100, Math.max(0, Number(custom.requiredScore ?? base.requiredScore ?? 80))),
enabledChecks: Array.isArray(custom.enabledChecks) ? [...new Set(custom.enabledChecks.map(String))] : null,
severityOverrides: custom.severityOverrides && typeof custom.severityOverrides === "object" ? { ...custom.severityOverrides } : {},
blockingChecks: [...new Set((custom.blockingChecks || policy.blockingChecks || []).map(String))],
blockingSeverities: [...new Set((custom.blockingSeverities || policy.blockingSeverities || base.severities || ["error"]).map(String))]
.filter((severity) => ["warning", "error"].includes(severity)),
allowSuppressions: custom.allowSuppressions ?? base.allowSuppressions ?? true,
maxSuppressionDays: Math.max(1, Number(custom.maxSuppressionDays ?? base.maxSuppressionDays ?? 30)),
};
}
function validateSuppression(input, policy, now = new Date()) {
if (!policy.allowSuppressions) throw new Error("The selected policy does not allow suppressions.");
const checkId = String(input?.checkId || "").trim();
const reason = String(input?.reason || "").trim();
const author = String(input?.author || "").trim();
const scope = String(input?.scope || "repository").trim();
const evidence = String(input?.evidence || "").trim();
const expiresAt = new Date(input?.expiresAt || "");
if (!checkId || reason.length < 10 || !author || !evidence) throw new Error("A suppression requires a check ID, author, evidence and a reason of at least 10 characters.");
if (!Number.isFinite(expiresAt.getTime()) || expiresAt <= now) throw new Error("A suppression expiry must be in the future.");
const maximum = new Date(now.getTime() + policy.maxSuppressionDays * 86_400_000);
if (expiresAt > maximum) throw new Error(`This policy permits suppressions for at most ${policy.maxSuppressionDays} days.`);
return {
id: crypto.randomUUID(), checkId, reason, author,
createdAt: now.toISOString(), ticket: String(input.ticket || "").trim() || null,
expiresAt: expiresAt.toISOString(), scope, evidence,
};
}
function applyPolicy(checks, policyInput, suppressions = [], now = new Date()) {
const policy = normalizePolicy(policyInput);
const enabled = policy.enabledChecks ? new Set(policy.enabledChecks) : null;
const relevant = checks.filter((check) => !enabled || enabled.has(check.id)).map((check) => {
const status = policy.severityOverrides[check.id] || check.status;
const suppression = suppressions.find((item) => item.checkId === check.id && new Date(item.expiresAt) > now);
const expiredSuppression = suppressions.find((item) => item.checkId === check.id && new Date(item.expiresAt) <= now);
return {
...check,
status,
suppressed: Boolean(suppression),
suppression: suppression || null,
expiredSuppression: expiredSuppression || null,
blocking: !suppression && status !== "pass" && (policy.blockingSeverities.includes(status) || policy.blockingChecks.includes(check.id)),
};
});
return { policy, checks: relevant };
}
function buildTrend(previous, report) {
const prior = new Map((previous?.checks || []).map((check) => [check.id, check]));
const current = new Map(report.checks.map((check) => [check.id, check]));
const active = (check) => check && check.status !== "pass" && !check.suppressed;
const newlyFound = [...current.values()].filter((check) => active(check) && !active(prior.get(check.id))).map((check) => check.id);
const resolved = [...prior.values()].filter((check) => active(check) && !active(current.get(check.id))).map((check) => check.id);
const regressions = [...current.values()].filter((check) => active(check) && prior.get(check.id)?.status === "warning" && check.status === "error").map((check) => check.id);
return { score: report.score, categories: report.categories, newlyFound, resolved, regressions, suppressions: report.checks.filter((check) => check.suppressed).map((check) => check.id), checkedAt: report.checkedAt, commitSha: report.commitSha || null, checks: report.checks.map(({ id, status, suppressed }) => ({ id, status, suppressed })) };
}
function exportReport(report, format = "json") {
if (format === "json") return { extension: "json", mimeType: "application/json", content: `${JSON.stringify(report, null, 2)}\n` };
const rows = report.checks.map((check) => `| ${check.id} | ${check.category} | ${check.status}${check.suppressed ? " (suppressed)" : ""} | ${String(check.detail).replace(/\|/g, "\\|")} |`).join("\n");
const markdown = `# Git assurance report — ${report.repository}\n\nPolicy: **${report.policy.label}** · Score: **${report.score}/100** · Commit: \`${report.commitSha || "unknown"}\`\n\n| Check | Category | Status | Evidence |\n|---|---|---|---|\n${rows}\n`;
if (format === "markdown") return { extension: "md", mimeType: "text/markdown", content: markdown };
if (format !== "html") throw new Error("Unsupported Git Validator export format.");
const escape = (value) => String(value).replace(/[&<>"']/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character]);
const htmlRows = report.checks.map((check) => `<tr><td>${escape(check.id)}</td><td>${escape(check.category)}</td><td>${escape(check.status)}${check.suppressed ? " (suppressed)" : ""}</td><td>${escape(check.detail)}</td></tr>`).join("");
return { extension: "html", mimeType: "text/html", content: `<!doctype html><html lang="en"><meta charset="utf-8"><title>Git assurance — ${escape(report.repository)}</title><style>body{font:15px system-ui;max-width:1100px;margin:40px auto;padding:0 24px;color:#172033}table{border-collapse:collapse;width:100%}th,td{padding:10px;border:1px solid #ccd4e0;text-align:left}th{background:#edf2f7}</style><h1>Git assurance — ${escape(report.repository)}</h1><p>Policy: <strong>${escape(report.policy.label)}</strong> · Score: <strong>${report.score}/100</strong> · Commit: <code>${escape(report.commitSha || "unknown")}</code></p><table><thead><tr><th>Check</th><th>Category</th><th>Status</th><th>Evidence</th></tr></thead><tbody>${htmlRows}</tbody></table></html>` };
}
module.exports = { PROFILE_DEFINITIONS, normalizePolicy, validateSuppression, applyPolicy, buildTrend, exportReport };
+121 -6
View File
@@ -4,6 +4,7 @@ const fs = require("node:fs/promises");
const path = require("node:path");
const { run } = require("./process-runner.cjs");
const { normalizeRemoteUrl } = require("../shared/repository-match.cjs");
const { applyPolicy, buildTrend, exportReport, normalizePolicy, validateSuppression } = require("./git-validator-policy.cjs");
const RECOMMENDED_GITIGNORE = `# Local configuration and secrets
.env
@@ -67,6 +68,7 @@ function result(id, category, title, status, detail, options = {}) {
fixAction: options.fixAction || null,
safe: options.safe === true,
confirmation: options.confirmation || null,
evidence: options.evidence || null,
};
}
@@ -84,10 +86,11 @@ function isSensitiveTrackedPath(filePath) {
}
class GitValidatorService {
constructor({ git, gitea, diagnostics }) {
constructor({ git, gitea, diagnostics, store }) {
this.git = git;
this.gitea = gitea;
this.diagnostics = diagnostics;
this.store = store;
}
async config(root, key, { local = true } = {}) {
@@ -176,7 +179,7 @@ class GitValidatorService {
{ weight: 35 },
),
);
return this.summarize(repository, checks);
return this.finalize(repository, checks, null);
}
const root = await this.git.ensureRepository(repository.localPath);
@@ -378,15 +381,126 @@ class GitValidatorService {
{ weight: 7 },
),
);
return this.summarize(repository, checks);
await this.addAssuranceChecks(root, tracked, lowerFiles, checks);
return this.finalize(repository, checks, status);
}
async addAssuranceChecks(root, tracked, lowerFiles, checks) {
const has = (...patterns) => lowerFiles.some((file) => patterns.some((pattern) => pattern.test(file)));
const fileCheck = (id, category, title, patterns, detail, weight = 5) => {
const present = has(...patterns);
checks.push(result(id, category, title, present ? "pass" : "warning", present ? `${title} is versioned.` : detail, { weight }));
};
fileCheck("security-policy", "Security", "Security policy", [/(^|\/)security\.md$/], "Add SECURITY.md with supported versions and private disclosure instructions.", 8);
fileCheck("codeowners", "Governance", "Code ownership", [/(^|\/)codeowners$/], "Add CODEOWNERS for security-sensitive and release paths.", 6);
fileCheck("license", "Governance", "Repository license", [/(^|\/)(license|copying)(\.[^/]+)?$/], "Document the repository license or private-use terms.", 5);
fileCheck("changelog", "Release readiness", "Changelog", [/(^|\/)changelog(\.[^/]+)?$/], "Add a changelog that maps releases to user-visible changes.", 7);
fileCheck("contributing", "Collaboration", "Contribution guide", [/(^|\/)contributing(\.[^/]+)?$/], "Add contribution, test and review instructions.", 4);
fileCheck("issue-templates", "Collaboration", "Issue templates", [/^\.gitea\/issue_template\//, /^\.github\/issue_template\//], "Add structured issue templates.", 3);
fileCheck("pull-request-template", "Collaboration", "Pull request template", [/(^|\/)pull_request_template\.md$/], "Add a pull request checklist for tests, risk and rollback.", 4);
fileCheck("runtime-pinning", "Reproducibility", "Runtime version pinning", [/(^|\/)(\.nvmrc|\.node-version|\.tool-versions|mise\.toml)$/], "Pin the runtime version used by developers and CI.", 7);
fileCheck("build-instructions", "Reproducibility", "Build instructions", [/(^|\/)(readme|building|build)(\.[^/]+)?$/], "Document a clean, reproducible build command.", 6);
const generated = tracked.filter((file) => /(^|\/)(dist|build|coverage|\.cache)\//i.test(file));
checks.push(result("generated-artifacts", "Performance and hygiene", "Generated output is not tracked", generated.length ? "warning" : "pass", generated.length ? `${generated.length} generated-path file(s) are tracked; review ${generated.slice(0, 5).join(", ")}.` : "No common generated output directories are tracked.", { weight: 8, evidence: generated.slice(0, 20) }));
const executables = tracked.filter((file) => /\.(exe|dll|msi|scr|com|bat|cmd|ps1)$/i.test(file));
checks.push(result("executable-artifacts", "Security", "Executable artifacts are intentional", executables.length ? "warning" : "pass", executables.length ? `Review executable content: ${executables.slice(0, 8).join(", ")}.` : "No executable-shaped artifacts are tracked.", { weight: 8, evidence: executables.slice(0, 20) }));
const workflowFiles = tracked.filter((file) => /^\.(gitea|github)\/workflows\/[^/]+\.ya?ml$/i.test(file));
const workflowText = (await Promise.all(workflowFiles.slice(0, 40).map((file) => fs.readFile(path.join(root, file), "utf8").catch(() => "")))).join("\n");
const unpinned = [...workflowText.matchAll(/uses:\s*[^\s@]+@([^\s#]+)/g)].map((match) => match[1]).filter((ref) => !/^[0-9a-f]{40}$/i.test(ref));
checks.push(result("pinned-actions", "Security", "External CI actions are commit-pinned", unpinned.length ? "warning" : "pass", unpinned.length ? `${unpinned.length} action reference(s) use mutable tags or branches.` : "External actions are commit-pinned or no external actions are used.", { weight: 9, evidence: unpinned.slice(0, 20) }));
const broadPermissions = /permissions:\s*(write-all|write)/i.test(workflowText) || /contents:\s*write/i.test(workflowText);
checks.push(result("workflow-permissions", "Security", "Workflow permissions use least privilege", broadPermissions ? "error" : "pass", broadPermissions ? "A workflow requests broad write permissions; scope permissions per job and capability." : "No broad workflow write permission was detected.", { weight: 12 }));
const [commitSignature, tagSignature, recentSubjects] = await Promise.all([
run("git", ["log", "-1", "--format=%G?"], { cwd: root, timeout: 10_000, allowExitCodes: [128] }).then((value) => value.stdout.trim()).catch(() => "N"),
run("git", ["tag", "--points-at", "HEAD", "--format=%(contents:signature)"], { cwd: root, timeout: 10_000, allowExitCodes: [128] }).then((value) => value.stdout.trim()).catch(() => ""),
run("git", ["log", "-20", "--format=%s"], { cwd: root, timeout: 10_000, allowExitCodes: [128] }).then((value) => value.stdout.trim().split(/\r?\n/).filter(Boolean)).catch(() => []),
]);
checks.push(result("signed-commits", "Governance", "Latest commit is signed", /[GUYX]/.test(commitSignature) ? "pass" : "warning", /[GUYX]/.test(commitSignature) ? "Git reports a cryptographic signature on HEAD." : "HEAD has no verifiable Git signature.", { weight: 6 }));
checks.push(result("signed-tags", "Governance", "Release tags are signed", tagSignature ? "pass" : "warning", tagSignature ? "HEAD has a signed tag." : "HEAD has no signed release tag.", { weight: 5 }));
const conventional = recentSubjects.length > 0 && recentSubjects.every((subject) => /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?!?:\s.+/i.test(subject));
checks.push(result("conventional-commits", "Governance", "Recent commits follow a convention", conventional ? "pass" : "warning", conventional ? `${recentSubjects.length} recent commit subject(s) follow Conventional Commits.` : "Some recent commit subjects do not follow Conventional Commits.", { weight: 4 }));
const releaseFiles = {
"release-checksums": /(^|\/)(sha256sums|checksums?)(\.[^/]+)?$/,
"release-provenance": /(^|\/)(provenance|attestation)(\.[^/]+)?$/,
"release-sbom": /(^|\/)(sbom)(\.[^/]+)?$/,
};
for (const [id, pattern] of Object.entries(releaseFiles)) fileCheck(id, "Release readiness", id.replace(/^release-/, "Release "), [pattern], `No ${id.replace(/^release-/, "")} artifact is versioned or generated in the repository.`, 4);
checks.push(result("signing-readiness", "Release readiness", "Signing policy is documented", has(/(^|\/)(security|release|signing)(\.[^/]+)?$/) ? "pass" : "warning", has(/(^|\/)(security|release|signing)(\.[^/]+)?$/) ? "Signing guidance is present." : "Document signing identity, verification and timestamp requirements.", { weight: 6 }));
}
async finalize(repository, checks, status) {
const repositoryState = this.store?.getGitValidatorState?.(repository.fullName) || { policy: { id: "standard" }, suppressions: [], trends: [] };
const { policy, checks: governedChecks } = applyPolicy(checks, repositoryState.policy, repositoryState.suppressions);
const report = this.summarize(repository, governedChecks);
report.policy = policy;
report.commitSha = status?.head || status?.branch?.oid || null;
report.categories = Object.fromEntries([...new Set(governedChecks.map((check) => check.category))].map((category) => {
const categoryChecks = governedChecks.filter((check) => check.category === category);
return [category, Math.round(categoryChecks.filter((check) => check.status === "pass" || check.suppressed).length / categoryChecks.length * 100)];
}));
report.ready = report.score >= policy.requiredScore && !governedChecks.some((check) => check.blocking);
report.expiredSuppressions = governedChecks.filter((check) => check.expiredSuppression).map((check) => check.id);
report.trend = buildTrend(repositoryState.trends.at(-1), report);
if (this.store?.appendGitValidatorTrend) await this.store.appendGitValidatorTrend(repository.fullName, report.trend);
return report;
}
async setPolicy(repository, policyInput) {
const policy = normalizePolicy(policyInput);
if (!this.store?.setGitValidatorPolicy) throw new Error("Git Validator policy persistence is unavailable.");
await this.store.setGitValidatorPolicy(repository.fullName, policy);
return policy;
}
async suppress(repository, input) {
const state = this.store?.getGitValidatorState?.(repository.fullName) || { policy: { id: "standard" } };
const suppression = validateSuppression(input, normalizePolicy(state.policy));
await this.store.addGitValidatorSuppression(repository.fullName, suppression);
return suppression;
}
export(report, format) { return exportReport(report, format); }
async previewRepair(repository, check) {
if (!check?.fixAction) throw new Error("This validator check has no repair action.");
const root = repository.localPath ? await this.git.ensureRepository(repository.localPath) : null;
const fileDefinitions = {
"add-gitignore": [".gitignore", RECOMMENDED_GITIGNORE],
"add-gitattributes": [".gitattributes", RECOMMENDED_GITATTRIBUTES],
"add-editorconfig": [".editorconfig", RECOMMENDED_EDITORCONFIG],
};
if (fileDefinitions[check.fixAction]) {
const [name, content] = fileDefinitions[check.fixAction];
if (await fs.stat(path.join(root, name)).catch(() => null)) throw new Error(`${name} already exists; rescan before repairing.`);
return { checkId: check.id, action: check.fixAction, files: [name], diff: `diff --git a/${name} b/${name}\nnew file mode 100644\n--- /dev/null\n+++ b/${name}\n${content.split("\n").filter((line, index, lines) => index < lines.length - 1).map((line) => `+${line}`).join("\n")}\n`, remoteMutation: false };
}
if (check.fixAction === "configure-local-safety") return { checkId: check.id, action: check.fixAction, files: [".git/config"], diff: "+ fetch.prune = true\n+ pull.ff = only\n+ rebase.autoStash = true\n", remoteMutation: false };
if (check.fixAction === "align-origin") return { checkId: check.id, action: check.fixAction, files: [".git/config"], diff: `- origin = current\n+ origin = ${repository.preferredCloneUrl || repository.cloneUrl || repository.sshUrl}\n`, remoteMutation: false };
if (check.fixAction === "protect-default-branch") return { checkId: check.id, action: check.fixAction, files: [], diff: `Gitea policy change:\n+ protect ${repository.defaultBranch || "main"}\n+ block force pushes\n+ require pull request review\n`, remoteMutation: true };
throw new Error("Unsupported Git Validator repair action.");
}
async resolveRepairCheck(repository, candidate) {
const checkId = String(candidate?.id || candidate?.checkId || "").trim();
if (!checkId) throw new Error("A current Git Validator check ID is required.");
const report = await this.scan(repository);
const current = report.checks.find((check) => check.id === checkId);
if (!current?.fixAction)
throw new Error("This finding is resolved, suppressed or no longer repairable. Scan again before repairing.");
if (candidate?.fixAction && candidate.fixAction !== current.fixAction)
throw new Error("The Git Validator repair request is stale. Scan again before repairing.");
return current;
}
summarize(repository, checks) {
const totalWeight = checks.reduce((sum, check) => sum + check.weight, 0);
const earned = checks.reduce(
(sum, check) =>
sum +
(check.status === "pass"
(check.status === "pass" || check.suppressed
? check.weight
: check.status === "warning"
? check.weight * 0.45
@@ -409,8 +523,9 @@ class GitValidatorService {
checks,
summary: {
passed: checks.filter((check) => check.status === "pass").length,
warnings: checks.filter((check) => check.status === "warning").length,
errors: checks.filter((check) => check.status === "error").length,
warnings: checks.filter((check) => check.status === "warning" && !check.suppressed).length,
errors: checks.filter((check) => check.status === "error" && !check.suppressed).length,
suppressed: checks.filter((check) => check.suppressed).length,
repairable: checks.filter((check) => check.fixAction).length,
},
};
+19
View File
@@ -264,6 +264,19 @@ class GiteaService {
return { ...result.data, created: true };
}
async createReadOnlyDeployKey({ owner, repo, title, publicKey }) {
const key = String(publicKey || "").trim();
if (!/^ssh-(ed25519|rsa)\s+[A-Za-z0-9+/=]+(?:\s+.*)?$/.test(key)) throw new Error("A valid SSH public key is required.");
const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys`, { method: "POST", body: { title: String(title || "ForgeFlow server deploy key").trim().slice(0, 255), key, read_only: true } });
return result.data;
}
async deleteDeployKey(owner, repo, keyId) {
if (!Number.isInteger(Number(keyId)) || Number(keyId) <= 0) throw new Error("A valid deploy-key ID is required.");
await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys/${Number(keyId)}`, { method: "DELETE" });
return { deleted: true, keyId: Number(keyId) };
}
async listPullRequests({ owner, repo, state = "open", limit = 30 } = {}) {
const query = new URLSearchParams({
state,
@@ -413,6 +426,12 @@ class GiteaService {
if (!downloadUrl) {
throw new Error("Gitea did not provide a release asset download URL.");
}
const configuredBase = new URL(normalizeBaseUrl(this.store.data.gitea.baseUrl));
const publishedUrl = new URL(downloadUrl, configuredBase);
const releasePrefix = `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/download/`.toLowerCase();
if (publishedUrl.origin !== configuredBase.origin && publishedUrl.protocol === "http:" && publishedUrl.pathname.toLowerCase().startsWith(releasePrefix)) {
downloadUrl = new URL(`${publishedUrl.pathname}${publishedUrl.search}`, configuredBase).toString();
}
return this.downloadAuthenticated(downloadUrl, options);
}
+83
View File
@@ -0,0 +1,83 @@
"use strict";
const { deploymentIdentity, deploymentEvidenceHash, deploymentAuthorityKey } = require("./deployment-identity.cjs");
const BACKUP = /(?:^|[\\/._-])(backup|bak|archive|snapshot|old|previous)(?:[\\/._-]|$)/i;
const RELEASE = /(?:^|[\\/])(releases?|versions?)(?:[\\/]|$)/i;
const STAGING = /(?:^|[\\/._-])(staging|stage|test|qa|preview)(?:[\\/._-]|$)/i;
const TEMPORARY = /(?:^|[\\/._-])(candidate|rollback|ephemeral)(?:[\\/._-]|$)|^GITEA-ACTIONS-TASK-/i;
const SYSTEM = /^(?:traefik|nginx-proxy-manager|watchtower|portainer|dockerman|unraid-|cloudflared|redis|postgres|mariadb|mysql)(?:$|[-_.])/i;
function baseClassification(workload) {
const location = `${workload.compose?.workingDir || ""} ${(workload.compose?.configFiles || []).join(" ")}`;
const sourceRepository = String(workload.metadata?.sourceRepository || "").trim();
const hasGitProvenance = /^(?:git@|ssh:\/\/|https?:\/\/)/i.test(sourceRepository);
const decision = workload.reviewDecision;
if (["manual-exclude", "exclude-scan-root", "ignore"].includes(decision?.action)) return { type: "manually-excluded", reason: decision.reason || "Persisted manual exclusion", decisionAction: decision.action };
if (["mark-historical", "archive-link"].includes(decision?.action)) return { type: "historical-compose", reason: decision.reason || "Reviewed as historical", decisionAction: decision.action };
if (decision?.action === "monitor-only") return { type: "monitor-only", reason: decision.reason || "Reviewed for monitoring only", decisionAction: decision.action };
if (workload.metadata?.staleLink) return { type: "stale-link", reason: "The linked deployment profile has no matching server workload" };
if (BACKUP.test(location)) return { type: "backup", reason: "Path matches backup/archive evidence" };
if (RELEASE.test(location)) return { type: "release-folder", reason: "Path is below a release/version directory" };
if (STAGING.test(location)) return { type: "staging", reason: "Path or project identifies a staging/test workload" };
if (TEMPORARY.test(`${workload.displayName || ""} ${location}`)) return { type: "temporary-runtime", reason: "Runtime identity marks a candidate, rollback or CI workload" };
if (SYSTEM.test(workload.displayName || "") && !workload.metadata?.sourceRepository) return { type: "system-container", reason: "Known infrastructure identity without repository provenance" };
if (workload.link && workload.runtime?.running) return { type: "active-application", reason: "Linked deployment with running container evidence" };
if (workload.link && !workload.runtime?.running) return { type: "stopped-application", reason: "Linked deployment without a running container" };
if (!workload.containers?.length && workload.compose?.configFiles?.length) return { type: "historical-compose", reason: "Compose definition exists without container runtime" };
if (workload.status === "ambiguous") return { type: "ambiguous", reason: "Multiple candidates have equivalent evidence" };
if (!workload.candidates?.length) return { type: "external-container", reason: hasGitProvenance ? "Repository provenance does not match an accessible configured Gitea repository" : "Runtime has no Git repository provenance and remains monitoring-only" };
if (!workload.runtime?.running && workload.candidates?.length) return { type: "stopped-application", reason: "Stopped runtime has repository evidence" };
return { type: workload.runtime?.running ? "active-application" : "ambiguous", reason: workload.runtime?.running ? "Running application evidence" : "Insufficient authoritative evidence" };
}
function classifyInventory(workloads, profiles = [], decisions = []) {
const profileById = new Map(profiles.map((profile) => [profile.id, profile]));
const decisionByWorkload = new Map(decisions.map((decision) => [decision.workloadId, decision]));
const authorities = new Map();
const result = workloads.map((source) => {
const workload = structuredClone(source);
const profile = profileById.get(workload.link?.profileId) || null;
const identity = deploymentIdentity({ workload, profile });
const evidence = { candidates: (workload.candidates || []).map((item) => ({ repository: item.repositoryFullName, score: item.score, exact: item.exact === true })), running: workload.runtime?.running === true, health: workload.runtime?.health || null, configFiles: workload.compose?.configFiles || [] };
const hash = deploymentEvidenceHash(identity, evidence);
const stored = decisionByWorkload.get(workload.workloadId);
workload.reviewDecision = stored?.evidenceHash === hash ? stored : null;
workload.reviewDecisionStale = Boolean(stored && stored.evidenceHash !== hash);
workload.identity = identity;
if (workload.reviewDecision?.action === "manual-link" && workload.reviewDecision.repositoryFullName) {
workload.identity.repository = String(workload.reviewDecision.repositoryFullName).toLowerCase();
}
workload.evidenceHash = hash;
workload.classification = baseClassification(workload);
if (workload.link && ["backup", "release-folder", "staging", "temporary-runtime", "historical-compose", "system-container", "external-container", "manually-excluded"].includes(workload.classification.type)) {
workload.shadowedLink = workload.link;
workload.link = null;
}
const key = deploymentAuthorityKey(identity);
if (identity.repository && (workload.link || workload.candidates?.length) && !["backup", "release-folder", "staging", "temporary-runtime", "historical-compose", "system-container", "external-container", "manually-excluded"].includes(workload.classification.type)) {
const group = authorities.get(key) || [];
group.push(workload);
authorities.set(key, group);
}
return workload;
});
for (const group of authorities.values()) {
if (group.length < 2) {
group[0].authoritative = true;
continue;
}
const ranked = [...group].sort((a, b) => Number(b.reviewDecision?.action === "select-authoritative") - Number(a.reviewDecision?.action === "select-authoritative") || Number(b.runtime?.running) - Number(a.runtime?.running) || Number(Boolean(b.link)) - Number(Boolean(a.link)) || Number(Boolean(b.metadata?.liveRevision)) - Number(Boolean(a.metadata?.liveRevision)));
ranked[0].authoritative = true;
for (const duplicate of ranked.slice(1)) {
duplicate.authoritative = false;
duplicate.classification = { type: "duplicate", reason: `Conflicts with authoritative workload ${ranked[0].workloadId}`, authoritativeWorkloadId: ranked[0].workloadId };
duplicate.status = "duplicate";
duplicate.shadowedLink = duplicate.link;
duplicate.link = null;
}
}
return result;
}
module.exports = { classifyInventory, classifyWorkload: baseClassification, inventoryPathPatterns: { BACKUP, RELEASE, STAGING, TEMPORARY, SYSTEM } };
+31
View File
@@ -0,0 +1,31 @@
"use strict";
const crypto = require("node:crypto");
const ACTIONS = new Set(["keep-link", "select-authoritative", "mark-historical", "archive-link", "monitor-only", "exclude-scan-root", "manual-link", "ignore", "manual-exclude"]);
class InventoryReviewService {
constructor({ store, audit = null }) { this.store = store; this.audit = audit; }
list(serverId) { return this.store.getInventoryReviewDecisions(serverId); }
preview({ serverId, workload, action, reason = "", repositoryFullName = null }) {
if (!ACTIONS.has(action)) throw Object.assign(new Error("Unsupported inventory review action."), { code: "INVENTORY_REVIEW_ACTION_INVALID" });
if (["ignore", "manual-exclude", "exclude-scan-root"].includes(action) && String(reason).trim().length < 5) throw Object.assign(new Error("A meaningful review reason is required."), { code: "INVENTORY_REVIEW_REASON_REQUIRED" });
if (action === "manual-link" && !repositoryFullName) throw Object.assign(new Error("Select the repository to link."), { code: "INVENTORY_REVIEW_REPOSITORY_REQUIRED" });
const linkedProfile = workload.link?.profileId && workload.link?.repositoryFullName ? { profileId: workload.link.profileId, repositoryFullName: workload.link.repositoryFullName } : null;
const configurationChanges = [`Persist review decision ${action} for workload ${workload.workloadId}`];
if (action === "archive-link" && linkedProfile) configurationChanges.push(`Archive deployment profile ${linkedProfile.profileId}`);
if (action === "manual-link") configurationChanges.push(`Remember ${repositoryFullName} as the reviewed repository match; use Save environment to create the deployment profile`);
const mutation = { serverId, workloadId: workload.workloadId, evidenceHash: workload.evidenceHash, action, reason: String(reason).trim(), repositoryFullName, linkedProfile, classification: workload.classification?.type || workload.status, containersUnaffected: true, configurationChanges, recovery: "Restore the configuration snapshot or rescan after evidence changes." };
return { ...mutation, id: crypto.createHash("sha256").update(JSON.stringify(mutation)).digest("hex") };
}
async apply({ plan, expectedPlanId }) {
if (!expectedPlanId || plan.id !== expectedPlanId) throw Object.assign(new Error("Inventory review requires the exact preview plan."), { code: expectedPlanId ? "INVENTORY_REVIEW_PLAN_STALE" : "INVENTORY_REVIEW_PLAN_REQUIRED" });
const snapshot = await this.store.createRecoverySnapshot?.(`inventory-review:${plan.serverId}:${plan.workloadId}`);
if (plan.action === "archive-link" && plan.linkedProfile) await this.store.deleteDeploymentProfile(plan.linkedProfile.repositoryFullName, plan.linkedProfile.profileId);
const decision = await this.store.saveInventoryReviewDecision(plan.serverId, { workloadId: plan.workloadId, evidenceHash: plan.evidenceHash, action: plan.action, reason: plan.reason, repositoryFullName: plan.repositoryFullName || null, classification: plan.classification, decidedAt: new Date().toISOString() });
await this.audit?.append?.("deployment.inventory-review-applied", { serverId: plan.serverId, workloadId: plan.workloadId, action: plan.action, evidenceHash: plan.evidenceHash, snapshot: snapshot?.filePath || null });
return { decision, snapshot };
}
}
module.exports = { InventoryReviewService, INVENTORY_REVIEW_ACTIONS: [...ACTIONS] };
+62 -737
View File
@@ -1,82 +1,26 @@
"use strict";
const path = require("node:path");
const fs = require("node:fs/promises");
const { fileURLToPath } = require("node:url");
const { ipcMain, dialog, shell, app } = require("electron");
const { dialog, shell, app } = require("electron");
const { matchRemoteToRepository } = require("../shared/repository-match.cjs");
const {
cloneDirectoryName,
resolveCloneTarget,
} = require("../shared/clone-target.cjs");
const {
createChannelRegistrar,
assertTrustedSender,
toErrorPayload,
} = require("./ipc/channel.cjs");
const { registerRepositoryIpc } = require("./ipc/repository-handlers.cjs");
const { registerDeploymentIpc } = require("./ipc/deployment-handlers.cjs");
const { registerOperationsIpc } = require("./ipc/operations-handlers.cjs");
const {
createEncryptedBackup,
readEncryptedBackup,
} = require("./configuration-backup.cjs");
const { evaluateDeploymentPolicy } = require("../shared/deployment-policy.cjs");
let diagnosticsService = null;
const TRUSTED_RENDERER_PATH = path.resolve(
__dirname,
"..",
"renderer",
"index.html",
);
function toErrorPayload(error) {
return {
message: error?.message || "Unknown error",
code: error?.code || null,
status: error?.status || null,
recoverable: Boolean(error?.recoverable),
commitSha: error?.commitSha || null,
};
}
function assertTrustedSender(event) {
const url = event?.senderFrame?.url || event?.sender?.getURL?.() || "";
try {
const parsed = new URL(url);
if (parsed.protocol !== "file:") throw new Error("not a file URL");
const senderPath = path.resolve(fileURLToPath(parsed));
const normalize = (value) =>
process.platform === "win32" ? value.toLowerCase() : value;
if (normalize(senderPath) !== normalize(TRUSTED_RENDERER_PATH))
throw new Error("unexpected renderer file");
} catch {
throw new Error("Rejected IPC request from an untrusted renderer origin.");
}
}
function register(channel, handler) {
ipcMain.handle(channel, async (event, payload) => {
const started = Date.now();
try {
assertTrustedSender(event);
const data = await handler(payload || {}, event);
await diagnosticsService?.debug("ipc.completed", {
channel,
durationMs: Date.now() - started,
});
return { ok: true, data };
} catch (error) {
await diagnosticsService?.error("ipc.failed", {
channel,
durationMs: Date.now() - started,
error: {
name: error?.name,
message: error?.message,
code: error?.code,
status: error?.status,
stack: error?.stack,
},
});
console.error(`[${channel}]`, error);
return { ok: false, error: toErrorPayload(error) };
}
});
}
const { normalizeBaseUrl } = require("../shared/validation.cjs");
function registerIpc({
store,
git,
@@ -84,6 +28,8 @@ function registerIpc({
repositories,
deployments,
unraid,
deployKeys,
inventoryReviews,
ssh,
updates,
preflight,
@@ -94,7 +40,7 @@ function registerIpc({
monitor,
onPreferencesChanged,
}) {
diagnosticsService = diagnostics;
const register = createChannelRegistrar(diagnostics);
const repositoryMutations = new Map();
const withRepositoryPause = async (localPath, action) => {
monitor?.pause(localPath);
@@ -160,8 +106,11 @@ function registerIpc({
await repositories.refresh();
knownPaths = repositories.getWatchPaths();
}
const canonicalKnown = await Promise.all(knownPaths.map(canonicalPath));
if (!canonicalKnown.some((known) => known === candidate))
// Watch paths are already canonical, so re-resolving all of them on every
// guarded call is only needed when the cheap comparison finds no match.
const matched = knownPaths.some((known) => path.resolve(known) === candidate)
|| (await Promise.all(knownPaths.map(canonicalPath))).some((known) => known === candidate);
if (!matched)
throw new Error(
"The requested local repository is not linked or discovered by ForgeFlow.",
);
@@ -171,9 +120,7 @@ function registerIpc({
const resolveRepository = async (repositoryPayload) => {
const fullName = String(repositoryPayload?.fullName || "").trim();
if (!fullName) throw new Error("Repository identity is required.");
const current = (await repositories.refresh()).find(
(item) => item.fullName === fullName,
);
const current = await repositories.resolveByFullName(fullName);
if (!current)
throw new Error(
"The repository is no longer available through the configured Gitea account.",
@@ -307,8 +254,23 @@ function registerIpc({
});
register("settings:update-gitea", async ({ baseUrl, token }) => {
const effectiveToken = String(token || "").trim() || store.getToken();
const validation = await gitea.validateConnection(baseUrl, effectiveToken);
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
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({
baseUrl: validation.baseUrl,
token,
@@ -469,13 +431,25 @@ function registerIpc({
await diagnostics.info("server.deleted", { serverId });
return store.getPublicState();
});
register("server:test", async ({ serverId }) => {
register("server:test", async ({ serverId, expectedFingerprint = "" }) => {
const server = store.getServer(serverId);
if (!server) throw new Error("The configured server no longer exists.");
const expected = String(expectedFingerprint || "").trim();
if (!server.hostFingerprint && !expected) {
const probe = await ssh.probeHostFingerprint(serverId);
return { ...probe, connected: false, needsTrust: true, state: store.getPublicState() };
}
if (!server.hostFingerprint && !/^SHA256:[A-Za-z0-9+/]{40,44}$/.test(expected))
throw new Error("Confirm the exact SSH host fingerprint returned by ForgeFlow.");
const result = await ssh.test(serverId, {
trustOnFirstUse: !server.hostFingerprint,
expectedFingerprint: server.hostFingerprint ? null : expected,
});
if (!server.hostFingerprint) {
if (result.fingerprint !== expected) {
const error = new Error("The SSH host identity changed between preview and confirmation.");
error.code = "SSH_HOST_KEY_MISMATCH";
throw error;
}
await store.saveServer(
{ ...server, hostFingerprint: result.fingerprint },
{},
@@ -500,383 +474,11 @@ function registerIpc({
}),
);
register("repositories:refresh", async () => {
const result = await repositories.refresh();
monitor?.setPaths(repositories.getWatchPaths());
return result;
});
register("repositories:discover", async ({ roots }) => {
const paths = await repositories.discoverAll(
roots || store.data.workspaceRoots,
);
return repositories.getLocalDescriptors(paths);
});
register("repository:favorite", async ({ fullName, favorite }) =>
store.setFavorite(fullName, favorite),
);
register("repository:link", async ({ fullName, localPath }) => {
await git.ensureRepository(localPath);
const remoteUrl = await git.getRemoteUrl(localPath).catch(() => "");
if (
!remoteUrl ||
!matchRemoteToRepository(remoteUrl, [{ full_name: fullName }])
) {
throw new Error(
`The selected folder's origin does not match ${fullName}.`,
);
}
await store.saveMapping(fullName, localPath);
await diagnostics.info("repository.linked", { fullName, localPath });
const result = await repositories.refresh();
monitor?.setPaths(repositories.getWatchPaths());
return result;
});
register("repository:unlink", async ({ fullName }) => {
await store.removeMapping(fullName);
await diagnostics.info("repository.unlinked", { fullName });
const result = await repositories.refresh();
monitor?.setPaths(repositories.getWatchPaths());
return result;
});
register("repository:status", async ({ localPath }) =>
git.status(await assertKnownRepositoryPath(localPath)),
);
register("repository:diff", async ({ localPath, filePath, staged }) =>
git.diff(await assertKnownRepositoryPath(localPath), filePath, staged),
);
register("repository:diff-hunks", async ({ localPath, filePath }) =>
git.diffHunks(await assertKnownRepositoryPath(localPath), filePath),
);
register(
"repository:stage-hunks",
async ({ localPath, filePath, hunkIndexes }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.stageHunks(safePath, filePath, hunkIndexes),
);
},
);
register("repository:conflicts", async ({ localPath }) =>
git.conflictState(await assertKnownRepositoryPath(localPath)),
);
register(
"repository:resolve-conflict",
async ({ localPath, filePath, resolution }) => {
const safePath = await assertKnownRepositoryPath(localPath);
const result = await withRepositoryMutation(safePath, () =>
git.resolveConflict(safePath, filePath, resolution),
);
await audit.append("git.conflict.resolved", {
localPath: safePath,
filePath,
resolution,
});
return result;
},
);
register("repository:continue-operation", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
const result = await withRepositoryMutation(safePath, () =>
git.continueInterruptedOperation(safePath),
);
await audit.append("git.operation.continued", { localPath: safePath });
return result;
});
register("repository:abort-operation", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
const result = await withRepositoryMutation(safePath, () =>
git.abortInterruptedOperation(safePath),
);
await audit.append("git.operation.aborted", {
localPath: safePath,
operation: result.aborted,
});
return result;
});
register("repository:stage", async ({ localPath, files }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () => git.stage(safePath, files));
});
register("repository:unstage", async ({ localPath, files }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () => git.unstage(safePath, files));
});
register("repository:commit", async ({ localPath, message, files }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.commit(safePath, message, files),
);
});
register("repository:commit-staged", async ({ localPath, message }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.commitStaged(safePath, message),
);
});
register("repository:commit-staged-push", async ({ localPath, message }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.commitStagedAndPush(safePath, message),
);
});
register("repository:commit-push", async ({ localPath, message, files }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.commitAndPush(safePath, message, files),
);
});
register("repository:push", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () => git.push(safePath));
});
register("repository:fetch", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () => git.fetch(safePath));
});
register("repository:pull", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.pullFastForward(safePath),
);
});
register("repository:history", async ({ localPath, limit }) =>
git.history(await assertKnownRepositoryPath(localPath), limit),
);
register("repository:branch-protection", async ({ fullName, branch }) => {
const repository = await resolveRepository({ fullName });
return gitea.getBranchProtection(
repository.owner.login,
repository.name,
branch ||
repository.localStatus?.branch?.head ||
repository.defaultBranch,
);
});
register("repository:pull-requests", async ({ fullName, state = "open" }) => {
const repository = await resolveRepository({ fullName });
return gitea.listPullRequests({
owner: repository.owner.login,
repo: repository.name,
state,
});
});
register(
"repository:create-pull-request",
async ({ fullName, title, body, base }) => {
const repository = await resolveRepository({ fullName });
if (!repository.localPath || !repository.localStatus?.clean)
throw new Error(
"A clean linked repository is required before creating a pull request.",
);
const head = repository.localStatus.branch?.head;
if (!head || !repository.localStatus.branch?.upstream)
throw new Error(
"Publish the current branch before creating a pull request.",
);
if (repository.localStatus.branch.ahead > 0)
throw new Error(
"Push all local commits before creating a pull request.",
);
const pullRequest = await gitea.createPullRequest({
owner: repository.owner.login,
repo: repository.name,
head,
base: base || repository.defaultBranch,
title,
body,
});
await audit.append("pull-request.created", {
repository: repository.fullName,
number: pullRequest.number,
head,
base: base || repository.defaultBranch,
url: pullRequest.html_url,
});
return pullRequest;
},
);
register("repository:branches", async ({ localPath }) =>
git.branches(await assertKnownRepositoryPath(localPath)),
);
register("repository:checkout-branch", async ({ localPath, branch }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.checkoutBranch(safePath, branch),
);
});
register("repository:create-branch", async ({ localPath, branch }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.createBranch(safePath, branch),
);
});
register("repository:stash", async ({ localPath, message }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () => git.stash(safePath, message));
});
register("repository:stash-list", async ({ localPath }) =>
git.stashList(await assertKnownRepositoryPath(localPath)),
);
register("repository:stash-pop", async ({ localPath, ref }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () => git.popStash(safePath, ref));
});
register("repository:index-lock", async ({ localPath }) =>
git.getIndexLockInfo(await assertKnownRepositoryPath(localPath)),
);
register("repository:git-recovery-status", async ({ localPath }) =>
git.reconcile(await assertKnownRepositoryPath(localPath)),
);
register("repository:repair-index-lock", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.removeStaleIndexLock(safePath),
);
});
register(
"repository:repair-git-locks",
async ({ localPath, force = false }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.repairStaleGitLocks(safePath, {
minimumAgeMs: force ? 0 : 10_000,
allowWithoutProcessProbe: force === true,
}),
);
},
);
register("repository:reconcile", async ({ localPath }) =>
git.reconcile(await assertKnownRepositoryPath(localPath)),
);
register("repository:repair-sync", async ({ localPath, strategy }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.repairSync(safePath, strategy),
);
});
register("repository:set-origin", async ({ localPath, remoteUrl }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.setRemoteUrl(safePath, remoteUrl),
);
});
register("repositories:normalize-origins", async () => {
const current = await repositories.refresh();
const changes = [];
for (const repository of current) {
if (!repository.localPath || !repository.sshUrl) continue;
const actual = await git
.getRemoteUrl(repository.localPath)
.catch(() => "");
if (actual === repository.sshUrl) continue;
await withRepositoryMutation(repository.localPath, () =>
git.setRemoteUrl(repository.localPath, repository.sshUrl),
);
changes.push({
fullName: repository.fullName,
previous: actual,
next: repository.sshUrl,
});
}
const refreshed = await repositories.refresh();
monitor?.setPaths(repositories.getWatchPaths());
await diagnostics.info("repositories.origins.normalized", {
count: changes.length,
changes,
});
return { changes, repositories: refreshed };
});
register("repository:clone", async ({ fullName, mode = "default" }) => {
if (!["default", "custom"].includes(mode))
throw new Error("Unsupported clone location mode.");
let projectRoot = store.data.workspaceRoots[0] || null;
if (mode === "custom" || !projectRoot) {
const result = await dialog.showOpenDialog({
title: `Choose a project root for ${String(fullName || "repository")}`,
defaultPath: projectRoot || undefined,
buttonLabel: "Use this project root",
properties: ["openDirectory", "createDirectory"],
});
if (result.canceled || !result.filePaths[0]) return { cancelled: true };
projectRoot = result.filePaths[0];
}
return cloneRepositoryInto(fullName, projectRoot);
});
register("repository:open-path", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
const error = await shell.openPath(safePath);
if (error) throw new Error(error);
return true;
});
register(
"repository:open-editor",
async ({ localPath, filePath = "", line = 1 }) =>
externalTools.launch(
"editor",
await assertKnownRepositoryPath(localPath),
filePath,
line,
),
);
register("repository:open-terminal", async ({ localPath }) =>
externalTools.launch(
"terminal",
await assertKnownRepositoryPath(localPath),
),
);
register("external:open", async ({ url }) => {
const parsed = new URL(url);
if (!["http:", "https:"].includes(parsed.protocol))
throw new Error("Only HTTP and HTTPS links can be opened.");
await shell.openExternal(parsed.toString());
return true;
});
register("git-validator:scan", async ({ fullName }) => {
const repository = await resolveRepository({ fullName });
const report = await gitValidator.scan(repository);
await diagnostics.info("git-validator.scan.completed", {
repository: repository.fullName,
score: report.score,
summary: report.summary,
});
return report;
});
register("git-validator:repair", async ({ fullName, check }) => {
const repository = await resolveRepository({ fullName });
const allowed = new Set([
"align-origin",
"configure-local-safety",
"add-gitignore",
"add-gitattributes",
"add-editorconfig",
"protect-default-branch",
]);
if (!allowed.has(check?.fixAction))
throw new Error("Unsupported Git Validator repair request.");
const result = await gitValidator.repair(repository, check);
await audit.append("git-validator.repair", {
repository: repository.fullName,
checkId: check.id,
action: check.fixAction,
});
await diagnostics.info("git-validator.repair.completed", {
repository: repository.fullName,
checkId: check.id,
action: check.fixAction,
});
return result;
registerRepositoryIpc({
register, repositories, store, git, gitea, monitor, diagnostics, audit,
externalTools, gitValidator, withRepositoryMutation, assertKnownRepositoryPath,
resolveRepository, cloneRepositoryInto, cloneDirectoryName,
matchRemoteToRepository, shell, dialog,
});
register("troubleshooter:scan", async ({ fullName = null }) => {
@@ -1100,291 +702,14 @@ function registerIpc({
return results;
});
register("deployment:save-profile", async ({ fullName, profile }) => {
const saved = await store.saveDeploymentProfile(fullName, profile);
await diagnostics.info("deployment.profile.saved", {
repository: fullName,
profile: saved,
});
return { profile: saved, state: store.getPublicState() };
registerDeploymentIpc({
register, store, resolveRepository, unraid, deployments, evaluateDeploymentPolicy,
audit, deployKeys, repositories, inventoryReviews, diagnostics, git, gitea, ssh,
preflight,
});
register("deployment:delete-profile", async ({ fullName, profileId }) => {
const profiles = await store.deleteDeploymentProfile(fullName, profileId);
await diagnostics.info("deployment.profile.deleted", {
repository: fullName,
profileId,
});
return { profiles, state: store.getPublicState() };
});
register("deployment:preflight", async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
const profile = store.getDeploymentProfile(current.fullName, profileId);
if (profile?.provider === "ssh-unraid")
return unraid.preflight({ repository: current, profileId });
return preflight.runDeployment({ repository: current, profileId });
});
register("deployment:repair-write-access", async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
const profile = store.getDeploymentProfile(current.fullName, profileId);
if (profile?.provider !== "ssh-unraid")
throw new Error("Write-access repair is available only for SSH / Unraid deployment profiles.");
const result = await unraid.repairWriteAccess({ repository: current, profileId });
await audit.append("deployment.write-access.repaired", {
repository: current.fullName,
profileId,
changed: result.changed,
remotePath: result.after?.remotePath || result.before?.remotePath || null,
});
return result;
});
register(
"deployment:dispatch",
async ({
repository,
profileId,
sha,
note = "",
override = false,
overrideReason = "",
}) => {
const current = await resolveRepository(repository);
const profile = store.getDeploymentProfile(current.fullName, profileId);
const policy = evaluateDeploymentPolicy(profile, {
note,
override,
reason: overrideReason,
});
await audit.append("deployment.requested", {
repository: current.fullName,
profileId,
sha,
note: policy.note,
overridden: policy.overridden,
overrideReason: policy.reason,
});
const operation =
profile?.provider === "ssh-unraid"
? await unraid.deploy({ repository: current, profileId, sha })
: await deployments.deploy({ repository: current, profileId, sha });
if (operation?.id)
await store.addOperation({
...operation,
releaseNote: policy.note,
policyOverride: policy.overridden
? { reason: policy.reason, violations: policy.violations }
: null,
});
return operation;
},
);
register(
"deployment:rollback",
async ({ repository, profileId, targetSha }) => {
const current = await resolveRepository(repository);
const profile = store.getDeploymentProfile(current.fullName, profileId);
if (profile?.provider === "ssh-unraid")
return unraid.rollback({ repository: current, profileId, targetSha });
return deployments.rollback({
repository: current,
profileId,
targetSha,
});
},
);
register("deployment:health", ({ url }) => deployments.checkHealth(url));
register("deployment:link-server-workload", async ({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) => {
const current = await resolveRepository(repository);
const result = await unraid.linkServerWorkload({
repository: current,
serverId,
workloadId,
deploymentMode,
remoteFolder,
});
return { ...result, state: store.getPublicState() };
});
register("deployment:configure-server-git-access", async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
const result = await unraid.configureServerGitAccess({ repository: current, profileId });
await audit.append("deployment.server-git-access-configured", {
repository: current.fullName,
profileId,
keyFingerprint: result.keyFingerprint,
hostFingerprint: result.hostFingerprint,
});
return { ...result, state: store.getPublicState() };
});
register("deployment:discover-server-workloads", async () => {
const repositoryList = await repositories.refresh();
const remoteRepositories = repositoryList.filter(
(repository) => repository.owner?.login !== "local",
);
const results = [];
for (const server of store.data.servers || []) {
try {
results.push(
await unraid.discoverServerWorkloads(server.id, remoteRepositories),
);
} catch (error) {
results.push({
serverId: server.id,
serverName: server.name,
detected: 0,
adopted: 0,
verified: 0,
linked: 0,
unmatched: 0,
needsReview: 0,
capabilities: {},
warnings: [],
workloads: [],
error: error.message,
});
}
}
return results;
});
register("deployment:profile-state", async ({ fullName, profileId }) => {
const profile = store.getDeploymentProfile(fullName, profileId);
if (profile?.provider === "ssh-unraid") {
let giteaSha = null;
try {
const [owner, repo] = String(fullName || "").split("/");
const branch = await gitea.getBranch(owner, repo, profile.branch);
giteaSha = branch?.commit?.id || branch?.commit?.sha || null;
} catch {}
return unraid.refreshProfileState(fullName, profileId, giteaSha);
}
return deployments.refreshProfileState(fullName, profileId);
});
register(
"deployment:apply-dockerman-metadata",
async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
return unraid.applyDockerManMetadata({ repository: current, profileId });
},
);
register("deployment:reconcile", async ({ fullName, profileId }) => {
const profile = store.getDeploymentProfile(fullName, profileId);
if (profile?.provider !== "ssh-unraid")
return deployments.refreshProfileState(fullName, profileId);
const [owner, repo] = String(fullName || "").split("/");
const branch = await gitea.getBranch(owner, repo, profile.branch);
const giteaSha =
branch?.commit?.id ||
branch?.commit?.sha ||
branch?.commit?.commit?.id ||
null;
const state = await unraid.refreshProfileState(
fullName,
profileId,
giteaSha,
);
const operations = store.data.operations.filter(
(item) =>
item.profileId === profileId &&
item.provider === "ssh-unraid" &&
!["success", "failed", "cancelled", "rolled-back"].includes(
item.status,
),
);
for (const operation of operations)
await unraid.refreshOperation(operation.id);
return {
state,
operations: await unraid.reconcileRecordedOperations(profileId, state),
};
});
register("operations:refresh", async ({ operationId }) => {
if (operationId) {
const operation = store.getOperation(operationId);
if (operation?.provider === "ssh-unraid")
return unraid.refreshOperation(operationId);
return deployments.refreshOperation(operationId);
}
const [actions, sshOperations] = await Promise.all([
deployments.refreshActiveOperations(),
unraid.refreshActiveOperations(),
]);
return [...actions, ...sshOperations];
});
register("operations:get", ({ operationId }) =>
store.getOperation(operationId),
);
register("diagnostics:status", () => diagnostics.getStatus());
register("diagnostics:clear", () => diagnostics.clear());
register("diagnostics:open-folder", async () => {
const error = await shell.openPath(diagnostics.logDirectory);
if (error) throw new Error(error);
return true;
});
register("diagnostics:export", async ({ privacyMode = "standard" }) => {
if (!["standard", "strict"].includes(privacyMode))
throw new Error("Unsupported diagnostic privacy mode.");
const result = await dialog.showSaveDialog({
title: "Export ForgeFlow diagnostic bundle",
defaultPath: path.join(
app.getPath("downloads"),
`ForgeFlow-Diagnostics-${new Date().toISOString().replace(/[:.]/g, "-")}.zip`,
),
filters: [{ name: "ZIP archive", extensions: ["zip"] }],
});
if (result.canceled || !result.filePath) return null;
const repositoryState = await repositories.refresh().catch((error) => {
diagnostics.warning("diagnostics.repository-snapshot.failed", error);
return [];
});
const systemPreflight = await preflight
.runSystem()
.catch((error) => ({ error: error.message }));
const destinationPath =
path.extname(result.filePath).toLowerCase() === ".zip"
? result.filePath
: `${result.filePath}.zip`;
return diagnostics.exportSupportBundle({
destinationPath,
publicState: store.getPublicState(),
repositories: repositoryState,
operations: store.data.operations,
preflight: systemPreflight,
privacyMode,
extra: {
appVersion: app.getVersion(),
setupComplete: store.data.setupComplete,
},
});
});
register("diagnostics:show-bundle", async ({ filePath }) => {
if (!diagnostics.isKnownBundlePath(filePath))
throw new Error(
"Only the most recently generated support bundle can be revealed.",
);
shell.showItemInFolder(filePath);
return true;
});
register(
"renderer:report",
async ({ level = "info", event = "renderer.event", details = {} }) => {
const method = ["debug", "info", "warning", "error"].includes(level)
? level
: "info";
await diagnostics[method](
`renderer.${String(event || "event").slice(0, 120)}`,
details,
);
return true;
},
);
register("app:reset", async () => {
await diagnostics.info("app.reset.requested", {});
store.data = store.migrate({});
store.sessionToken = null;
await store.save();
monitor?.setPaths([]);
monitor?.restart();
return store.getPublicState();
registerOperationsIpc({
register, store, unraid, deployments, diagnostics, shell, dialog, path, app,
repositories, preflight, monitor,
});
}
+77
View File
@@ -0,0 +1,77 @@
"use strict";
const path = require("node:path");
const { fileURLToPath } = require("node:url");
const { ipcMain } = require("electron");
const TRUSTED_RENDERER_PATH = path.resolve(
__dirname,
"..",
"..",
"renderer",
"index.html",
);
function toErrorPayload(error) {
return {
message: error?.message || "Unknown error",
code: error?.code || null,
status: error?.status || null,
recoverable: Boolean(error?.recoverable),
commitSha: error?.commitSha || null,
};
}
function assertTrustedSender(event) {
const url = event?.senderFrame?.url || event?.sender?.getURL?.() || "";
try {
const parsed = new URL(url);
if (parsed.protocol !== "file:") throw new Error("not a file URL");
const senderPath = path.resolve(fileURLToPath(parsed));
const normalize = (value) =>
process.platform === "win32" ? value.toLowerCase() : value;
if (normalize(senderPath) !== normalize(TRUSTED_RENDERER_PATH))
throw new Error("unexpected renderer file");
} catch {
throw new Error("Rejected IPC request from an untrusted renderer origin.");
}
}
// Built per registerIpc() call so the diagnostics sink is an argument instead of
// module-level mutable state that every handler silently depends on.
function createChannelRegistrar(diagnostics) {
return function register(channel, handler) {
ipcMain.handle(channel, async (event, payload) => {
const started = Date.now();
try {
assertTrustedSender(event);
const data = await handler(payload || {}, event);
await diagnostics?.debug("ipc.completed", {
channel,
durationMs: Date.now() - started,
});
return { ok: true, data };
} catch (error) {
await diagnostics?.error("ipc.failed", {
channel,
durationMs: Date.now() - started,
error: {
name: error?.name,
message: error?.message,
code: error?.code,
status: error?.status,
stack: error?.stack,
},
});
return { ok: false, error: toErrorPayload(error) };
}
});
};
}
module.exports = {
createChannelRegistrar,
assertTrustedSender,
toErrorPayload,
TRUSTED_RENDERER_PATH,
};
+284
View File
@@ -0,0 +1,284 @@
"use strict";
function registerDeploymentIpc({
register, store, resolveRepository, unraid, deployments, evaluateDeploymentPolicy,
audit, deployKeys, repositories, inventoryReviews, diagnostics, git, gitea, ssh,
preflight,
}) {
register("deployment:save-profile", async ({ fullName, profile }) => {
const saved = await store.saveDeploymentProfile(fullName, profile);
await diagnostics.info("deployment.profile.saved", {
repository: fullName,
profile: saved,
});
return { profile: saved, state: store.getPublicState() };
});
register("deployment:delete-profile", async ({ fullName, profileId }) => {
const profiles = await store.deleteDeploymentProfile(fullName, profileId);
await diagnostics.info("deployment.profile.deleted", {
repository: fullName,
profileId,
});
return { profiles, state: store.getPublicState() };
});
register("deployment:preflight", async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
const profile = store.getDeploymentProfile(current.fullName, profileId);
if (profile?.provider === "ssh-unraid")
return unraid.preflight({ repository: current, profileId });
return preflight.runDeployment({ repository: current, profileId });
});
register("deployment:repair-write-access", async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
const profile = store.getDeploymentProfile(current.fullName, profileId);
if (profile?.provider !== "ssh-unraid")
throw new Error("Write-access repair is available only for SSH / Unraid deployment profiles.");
const result = await unraid.repairWriteAccess({ repository: current, profileId });
await audit.append("deployment.write-access.repaired", {
repository: current.fullName,
profileId,
changed: result.changed,
remotePath: result.after?.remotePath || result.before?.remotePath || null,
});
return result;
});
register(
"deployment:dispatch",
async ({
repository,
profileId,
sha,
note = "",
override = false,
overrideReason = "",
}) => {
const current = await resolveRepository(repository);
const profile = store.getDeploymentProfile(current.fullName, profileId);
const policy = evaluateDeploymentPolicy(profile, {
note,
override,
reason: overrideReason,
});
await audit.append("deployment.requested", {
repository: current.fullName,
profileId,
sha,
note: policy.note,
overridden: policy.overridden,
overrideReason: policy.reason,
});
const operation =
profile?.provider === "ssh-unraid"
? await unraid.deploy({ repository: current, profileId, sha })
: await deployments.deploy({ repository: current, profileId, sha });
if (operation?.id)
await store.addOperation({
...operation,
releaseNote: policy.note,
policyOverride: policy.overridden
? { reason: policy.reason, violations: policy.violations }
: null,
});
return operation;
},
);
register(
"deployment:rollback",
async ({ repository, profileId, targetSha }) => {
const current = await resolveRepository(repository);
const profile = store.getDeploymentProfile(current.fullName, profileId);
if (profile?.provider === "ssh-unraid")
return unraid.rollback({ repository: current, profileId, targetSha });
return deployments.rollback({
repository: current,
profileId,
targetSha,
});
},
);
register("deployment:link-server-workload", async ({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) => {
const current = await resolveRepository(repository);
const result = await unraid.linkServerWorkload({
repository: current,
serverId,
workloadId,
deploymentMode,
remoteFolder,
});
return { ...result, state: store.getPublicState() };
});
register("deployment:configure-server-git-access", async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
const result = await unraid.configureServerGitAccess({ repository: current, profileId });
await audit.append("deployment.server-git-access-configured", {
repository: current.fullName,
profileId,
keyFingerprint: result.keyFingerprint,
hostFingerprint: result.hostFingerprint,
});
return { ...result, state: store.getPublicState() };
});
register("deployment:verify-server-git-profile", async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
const result = await unraid.verifyServerGitProfile({ repository: current, profileId });
await audit.append("deployment.server-git-access-verified", {
repository: current.fullName,
profileId,
readiness: result.readiness,
ready: result.ready,
checkedAt: result.checkedAt,
});
return result;
});
register("deployment:deploy-key-inventory", async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
return deployKeys.inventory({ repository: current, profileId });
});
register("deployment:plan-deploy-key-rotation", async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
return deployKeys.planRotation({ repository: current, profileId });
});
register("deployment:apply-deploy-key-rotation", async ({ repository, profileId, planId }) => {
const current = await resolveRepository(repository);
const result = await deployKeys.rotate({ repository: current, profileId, expectedPlanId: planId });
return { ...result, state: store.getPublicState() };
});
register("deployment:plan-deploy-key-revocation", async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
return deployKeys.planRevocation({ repository: current, profileId });
});
register("deployment:apply-deploy-key-revocation", async ({ repository, profileId, planId }) => {
const current = await resolveRepository(repository);
const result = await deployKeys.revoke({ repository: current, profileId, expectedPlanId: planId });
return { ...result, state: store.getPublicState() };
});
register("deployment:restore-deploy-key", async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
const result = await deployKeys.restore({ repository: current, profileId });
return { ...result, state: store.getPublicState() };
});
register("deployment:discover-server-workloads", async () => {
const repositoryList = await repositories.refresh();
const remoteRepositories = repositoryList.filter(
(repository) => repository.owner?.login !== "local",
);
const results = [];
for (const server of store.data.servers || []) {
try {
results.push(
await unraid.discoverServerWorkloads(server.id, remoteRepositories),
);
} catch (error) {
results.push({
serverId: server.id,
serverName: server.name,
detected: 0,
adopted: 0,
verified: 0,
linked: 0,
unmatched: 0,
needsReview: 0,
capabilities: {},
warnings: [],
workloads: [],
error: error.message,
});
}
}
return results;
});
register("deployment:plan-server-reconciliation", async ({ serverId }) => {
const repositoryList = await repositories.refresh();
const remoteRepositories = repositoryList.filter((repository) => repository.owner?.login !== "local");
const result = await unraid.planServerInventoryReconciliation(serverId, remoteRepositories, { autoLink: true });
await audit.append("deployment.server-reconciliation-planned", {
serverId,
planId: result.plan.id,
summary: result.plan.summary,
});
return result;
});
register("deployment:apply-server-reconciliation", async ({ serverId, planId }) => {
const repositoryList = await repositories.refresh();
const remoteRepositories = repositoryList.filter((repository) => repository.owner?.login !== "local");
const result = await unraid.reconcileServerInventory(serverId, remoteRepositories, { autoLink: true, expectedPlanId: planId });
await audit.append("deployment.server-reconciliation-applied", {
serverId,
planId,
adopted: result.adopted,
refreshed: result.refreshed,
retired: result.retired,
recoverySnapshot: result.recoverySnapshot?.filePath || null,
});
return { ...result, state: store.getPublicState() };
});
register("deployment:plan-inventory-review", async ({ serverId, workloadId, action, reason = "", repositoryFullName = null }) => {
const repositoryList = await repositories.refresh();
const inventory = await unraid.scanServerInventory(serverId, repositoryList.filter((item) => item.owner?.login !== "local"));
const workload = inventory.workloads.find((item) => item.workloadId === workloadId);
if (!workload) throw Object.assign(new Error("The workload changed or disappeared. Rescan before reviewing it."), { code: "INVENTORY_REVIEW_WORKLOAD_STALE" });
return inventoryReviews.preview({ serverId, workload, action, reason, repositoryFullName });
});
register("deployment:apply-inventory-review", async ({ serverId, workloadId, action, reason = "", repositoryFullName = null, planId }) => {
const repositoryList = await repositories.refresh();
const inventory = await unraid.scanServerInventory(serverId, repositoryList.filter((item) => item.owner?.login !== "local"));
const workload = inventory.workloads.find((item) => item.workloadId === workloadId);
if (!workload) throw Object.assign(new Error("The workload changed or disappeared. Rescan before applying the review."), { code: "INVENTORY_REVIEW_WORKLOAD_STALE" });
const plan = inventoryReviews.preview({ serverId, workload, action, reason, repositoryFullName });
const result = await inventoryReviews.apply({ plan, expectedPlanId: planId });
return { ...result, inventory: await unraid.scanServerInventory(serverId, repositoryList.filter((item) => item.owner?.login !== "local")), state: store.getPublicState() };
});
register("deployment:profile-state", async ({ fullName, profileId }) => {
const profile = store.getDeploymentProfile(fullName, profileId);
if (profile?.provider === "ssh-unraid") {
let giteaSha = null;
try {
const [owner, repo] = String(fullName || "").split("/");
const branch = await gitea.getBranch(owner, repo, profile.branch);
giteaSha = branch?.commit?.id || branch?.commit?.sha || null;
} catch {}
return unraid.refreshProfileState(fullName, profileId, giteaSha);
}
return deployments.refreshProfileState(fullName, profileId);
});
register(
"deployment:apply-dockerman-metadata",
async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
return unraid.applyDockerManMetadata({ repository: current, profileId });
},
);
register("deployment:reconcile", async ({ fullName, profileId }) => {
const profile = store.getDeploymentProfile(fullName, profileId);
if (profile?.provider !== "ssh-unraid")
return deployments.refreshProfileState(fullName, profileId);
const [owner, repo] = String(fullName || "").split("/");
const branch = await gitea.getBranch(owner, repo, profile.branch);
const giteaSha =
branch?.commit?.id ||
branch?.commit?.sha ||
branch?.commit?.commit?.id ||
null;
const state = await unraid.refreshProfileState(
fullName,
profileId,
giteaSha,
);
const operations = store.data.operations.filter(
(item) =>
item.profileId === profileId &&
item.provider === "ssh-unraid" &&
!["success", "failed", "cancelled", "rolled-back"].includes(
item.status,
),
);
for (const operation of operations)
await unraid.refreshOperation(operation.id);
return {
state,
operations: await unraid.reconcileRecordedOperations(profileId, state),
};
});
}
module.exports = { registerDeploymentIpc };
+100
View File
@@ -0,0 +1,100 @@
"use strict";
function registerOperationsIpc({
register, store, unraid, deployments, diagnostics, shell, dialog, path, app,
repositories, preflight, monitor,
}) {
register("operations:refresh", async ({ operationId }) => {
if (operationId) {
const operation = store.getOperation(operationId);
if (operation?.provider === "ssh-unraid")
return unraid.refreshOperation(operationId);
return deployments.refreshOperation(operationId);
}
const [actions, sshOperations] = await Promise.all([
deployments.refreshActiveOperations(),
unraid.refreshActiveOperations(),
]);
return [...actions, ...sshOperations];
});
register("operations:get", ({ operationId }) =>
store.getOperation(operationId),
);
register("diagnostics:status", () => diagnostics.getStatus());
register("diagnostics:clear", () => diagnostics.clear());
register("diagnostics:open-folder", async () => {
const error = await shell.openPath(diagnostics.logDirectory);
if (error) throw new Error(error);
return true;
});
register("diagnostics:export", async ({ privacyMode = "standard" }) => {
if (!["standard", "strict"].includes(privacyMode))
throw new Error("Unsupported diagnostic privacy mode.");
const result = await dialog.showSaveDialog({
title: "Export ForgeFlow diagnostic bundle",
defaultPath: path.join(
app.getPath("downloads"),
`ForgeFlow-Diagnostics-${new Date().toISOString().replace(/[:.]/g, "-")}.zip`,
),
filters: [{ name: "ZIP archive", extensions: ["zip"] }],
});
if (result.canceled || !result.filePath) return null;
const repositoryState = await repositories.refresh().catch((error) => {
diagnostics.warning("diagnostics.repository-snapshot.failed", error);
return [];
});
const systemPreflight = await preflight
.runSystem()
.catch((error) => ({ error: error.message }));
const destinationPath =
path.extname(result.filePath).toLowerCase() === ".zip"
? result.filePath
: `${result.filePath}.zip`;
return diagnostics.exportSupportBundle({
destinationPath,
publicState: store.getPublicState(),
repositories: repositoryState,
operations: store.data.operations,
preflight: systemPreflight,
privacyMode,
extra: {
appVersion: app.getVersion(),
setupComplete: store.data.setupComplete,
},
});
});
register("diagnostics:show-bundle", async ({ filePath }) => {
if (!diagnostics.isKnownBundlePath(filePath))
throw new Error(
"Only the most recently generated support bundle can be revealed.",
);
shell.showItemInFolder(filePath);
return true;
});
register(
"renderer:report",
async ({ level = "info", event = "renderer.event", details = {} }) => {
const method = ["debug", "info", "warning", "error"].includes(level)
? level
: "info";
await diagnostics[method](
`renderer.${String(event || "event").slice(0, 120)}`,
details,
);
return true;
},
);
register("app:reset", async () => {
await diagnostics.info("app.reset.requested", {});
store.data = store.migrate({});
store.sessionToken = null;
await store.save();
monitor?.setPaths([]);
monitor?.restart();
return store.getPublicState();
});
}
module.exports = { registerOperationsIpc };
+450
View File
@@ -0,0 +1,450 @@
"use strict";
function registerRepositoryIpc({
register, repositories, store, git, gitea, monitor, diagnostics, audit,
externalTools, gitValidator, withRepositoryMutation, assertKnownRepositoryPath,
resolveRepository, cloneRepositoryInto, cloneDirectoryName,
matchRemoteToRepository, shell, dialog,
}) {
register("repositories:refresh", async ({ force = false }) => {
const result = await repositories.refresh({ force: force === true });
monitor?.setPaths(repositories.getWatchPaths());
return result;
});
register("repositories:discover", async ({ roots }) => {
const paths = await repositories.discoverAll(
roots || store.data.workspaceRoots,
);
return repositories.getLocalDescriptors(paths);
});
register("repository:favorite", async ({ fullName, favorite }) =>
store.setFavorite(fullName, favorite),
);
register("repository:link", async ({ fullName, localPath }) => {
await git.ensureRepository(localPath);
const remoteUrl = await git.getRemoteUrl(localPath).catch(() => "");
if (
!remoteUrl ||
!matchRemoteToRepository(remoteUrl, [{ full_name: fullName }])
) {
throw new Error(
`The selected folder's origin does not match ${fullName}.`,
);
}
await store.saveMapping(fullName, localPath);
await diagnostics.info("repository.linked", { fullName, localPath });
const result = await repositories.refresh();
monitor?.setPaths(repositories.getWatchPaths());
return result;
});
register("repository:unlink", async ({ fullName }) => {
await store.removeMapping(fullName);
await diagnostics.info("repository.unlinked", { fullName });
const result = await repositories.refresh();
monitor?.setPaths(repositories.getWatchPaths());
return result;
});
register("repository:status", async ({ localPath }) =>
git.status(await assertKnownRepositoryPath(localPath)),
);
register("repository:diff", async ({ localPath, filePath, staged }) =>
git.diff(await assertKnownRepositoryPath(localPath), filePath, staged),
);
register("repository:diff-hunks", async ({ localPath, filePath }) =>
git.diffHunks(await assertKnownRepositoryPath(localPath), filePath),
);
register(
"repository:stage-hunks",
async ({ localPath, filePath, hunkIndexes }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.stageHunks(safePath, filePath, hunkIndexes),
);
},
);
register("repository:conflicts", async ({ localPath }) =>
git.conflictState(await assertKnownRepositoryPath(localPath)),
);
register(
"repository:resolve-conflict",
async ({ localPath, filePath, resolution }) => {
const safePath = await assertKnownRepositoryPath(localPath);
const result = await withRepositoryMutation(safePath, () =>
git.resolveConflict(safePath, filePath, resolution),
);
await audit.append("git.conflict.resolved", {
localPath: safePath,
filePath,
resolution,
});
return result;
},
);
register("repository:continue-operation", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
const result = await withRepositoryMutation(safePath, () =>
git.continueInterruptedOperation(safePath),
);
await audit.append("git.operation.continued", { localPath: safePath });
return result;
});
register("repository:abort-operation", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
const result = await withRepositoryMutation(safePath, () =>
git.abortInterruptedOperation(safePath),
);
await audit.append("git.operation.aborted", {
localPath: safePath,
operation: result.aborted,
});
return result;
});
register("repository:stage", async ({ localPath, files }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () => git.stage(safePath, files));
});
register("repository:unstage", async ({ localPath, files }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () => git.unstage(safePath, files));
});
register("repository:commit", async ({ localPath, message, files }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.commit(safePath, message, files),
);
});
register("repository:commit-staged", async ({ localPath, message }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.commitStaged(safePath, message),
);
});
register("repository:commit-staged-push", async ({ localPath, message }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.commitStagedAndPush(safePath, message),
);
});
register("repository:commit-push", async ({ localPath, message, files }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.commitAndPush(safePath, message, files),
);
});
register("repository:push", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () => git.push(safePath));
});
register("repository:fetch", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () => git.fetch(safePath));
});
register("repository:pull", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.pullFastForward(safePath),
);
});
register("repository:history", async ({ localPath, limit }) =>
git.history(await assertKnownRepositoryPath(localPath), limit),
);
register("repository:branch-protection", async ({ fullName, branch }) => {
const repository = await resolveRepository({ fullName });
return gitea.getBranchProtection(
repository.owner.login,
repository.name,
branch ||
repository.localStatus?.branch?.head ||
repository.defaultBranch,
);
});
register("repository:pull-requests", async ({ fullName, state = "open" }) => {
const repository = await resolveRepository({ fullName });
return gitea.listPullRequests({
owner: repository.owner.login,
repo: repository.name,
state,
});
});
register(
"repository:create-pull-request",
async ({ fullName, title, body, base }) => {
const repository = await resolveRepository({ fullName });
if (!repository.localPath || !repository.localStatus?.clean)
throw new Error(
"A clean linked repository is required before creating a pull request.",
);
const head = repository.localStatus.branch?.head;
if (!head || !repository.localStatus.branch?.upstream)
throw new Error(
"Publish the current branch before creating a pull request.",
);
if (repository.localStatus.branch.ahead > 0)
throw new Error(
"Push all local commits before creating a pull request.",
);
const pullRequest = await gitea.createPullRequest({
owner: repository.owner.login,
repo: repository.name,
head,
base: base || repository.defaultBranch,
title,
body,
});
await audit.append("pull-request.created", {
repository: repository.fullName,
number: pullRequest.number,
head,
base: base || repository.defaultBranch,
url: pullRequest.html_url,
});
return pullRequest;
},
);
register("repository:branches", async ({ localPath }) =>
git.branches(await assertKnownRepositoryPath(localPath)),
);
register("repository:checkout-branch", async ({ localPath, branch }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.checkoutBranch(safePath, branch),
);
});
register("repository:create-branch", async ({ localPath, branch }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.createBranch(safePath, branch),
);
});
register("repository:stash", async ({ localPath, message }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () => git.stash(safePath, message));
});
register("repository:stash-list", async ({ localPath }) =>
git.stashList(await assertKnownRepositoryPath(localPath)),
);
register("repository:stash-pop", async ({ localPath, ref }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () => git.popStash(safePath, ref));
});
register("repository:index-lock", async ({ localPath }) =>
git.getIndexLockInfo(await assertKnownRepositoryPath(localPath)),
);
register("repository:git-recovery-status", async ({ localPath }) =>
git.reconcile(await assertKnownRepositoryPath(localPath)),
);
register("repository:repair-index-lock", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.removeStaleIndexLock(safePath),
);
});
register(
"repository:repair-git-locks",
async ({ localPath, force = false }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.repairStaleGitLocks(safePath, {
minimumAgeMs: force ? 0 : 10_000,
allowWithoutProcessProbe: force === true,
}),
);
},
);
register("repository:reconcile", async ({ localPath }) =>
git.reconcile(await assertKnownRepositoryPath(localPath)),
);
register("repository:repair-sync", async ({ localPath, strategy }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.repairSync(safePath, strategy),
);
});
register("repository:workspace-sync-preview", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
const plan = await withRepositoryMutation(safePath, () =>
git.previewWorkspaceSync(safePath),
);
await diagnostics.info("repository.workspace-sync.previewed", {
localPath: safePath,
branch: plan.branch,
upstream: plan.upstream,
currentSha: plan.currentSha,
targetSha: plan.targetSha,
planId: plan.id,
summary: plan.summary,
blockers: plan.blockers,
});
return plan;
});
register(
"repository:workspace-sync-apply",
async ({ localPath, expectedPlanId }) => {
const safePath = await assertKnownRepositoryPath(localPath);
const result = await withRepositoryMutation(safePath, () =>
git.synchronizeWorkspace(safePath, expectedPlanId),
);
await audit.append("repository.workspace-synchronized", {
localPath: safePath,
branch: result.plan.branch,
upstream: result.plan.upstream,
previousSha: result.plan.currentSha,
targetSha: result.plan.targetSha,
backupBranch: result.backupBranch,
stashSha: result.stash?.sha || null,
ignoredFilesPreserved: true,
applied: result.applied,
});
return result;
},
);
register("repository:set-origin", async ({ localPath, remoteUrl }) => {
const safePath = await assertKnownRepositoryPath(localPath);
return withRepositoryMutation(safePath, () =>
git.setRemoteUrl(safePath, remoteUrl),
);
});
register("repositories:normalize-origins", async () => {
const current = await repositories.refresh();
const changes = [];
for (const repository of current) {
if (!repository.localPath || !repository.sshUrl) continue;
const actual = await git
.getRemoteUrl(repository.localPath)
.catch(() => "");
if (actual === repository.sshUrl) continue;
await withRepositoryMutation(repository.localPath, () =>
git.setRemoteUrl(repository.localPath, repository.sshUrl),
);
changes.push({
fullName: repository.fullName,
previous: actual,
next: repository.sshUrl,
});
}
const refreshed = await repositories.refresh();
monitor?.setPaths(repositories.getWatchPaths());
await diagnostics.info("repositories.origins.normalized", {
count: changes.length,
changes,
});
return { changes, repositories: refreshed };
});
register("repository:clone", async ({ fullName, mode = "default" }) => {
if (!["default", "custom"].includes(mode))
throw new Error("Unsupported clone location mode.");
let projectRoot = store.data.workspaceRoots[0] || null;
if (mode === "custom" || !projectRoot) {
const result = await dialog.showOpenDialog({
title: `Choose a project root for ${String(fullName || "repository")}`,
defaultPath: projectRoot || undefined,
buttonLabel: "Use this project root",
properties: ["openDirectory", "createDirectory"],
});
if (result.canceled || !result.filePaths[0]) return { cancelled: true };
projectRoot = result.filePaths[0];
}
return cloneRepositoryInto(fullName, projectRoot);
});
register("repository:open-path", async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
const error = await shell.openPath(safePath);
if (error) throw new Error(error);
return true;
});
register(
"repository:open-editor",
async ({ localPath, filePath = "", line = 1 }) =>
externalTools.launch(
"editor",
await assertKnownRepositoryPath(localPath),
filePath,
line,
),
);
register("repository:open-terminal", async ({ localPath }) =>
externalTools.launch(
"terminal",
await assertKnownRepositoryPath(localPath),
),
);
register("external:open", async ({ url }) => {
const parsed = new URL(url);
if (!["http:", "https:"].includes(parsed.protocol))
throw new Error("Only HTTP and HTTPS links can be opened.");
await shell.openExternal(parsed.toString());
return true;
});
register("git-validator:scan", async ({ fullName }) => {
const repository = await resolveRepository({ fullName });
const report = await gitValidator.scan(repository);
await diagnostics.info("git-validator.scan.completed", {
repository: repository.fullName,
score: report.score,
summary: report.summary,
});
return report;
});
register("git-validator:set-policy", async ({ fullName, policy }) => {
const repository = await resolveRepository({ fullName });
const saved = await gitValidator.setPolicy(repository, policy);
await audit.append("git-validator.policy.changed", { repository: repository.fullName, policy: saved.id });
return saved;
});
register("git-validator:suppress", async ({ fullName, suppression }) => {
const repository = await resolveRepository({ fullName });
const saved = await gitValidator.suppress(repository, suppression);
await audit.append("git-validator.finding.suppressed", { repository: repository.fullName, checkId: saved.checkId, expiresAt: saved.expiresAt, ticket: saved.ticket });
return saved;
});
register("git-validator:preview-repair", async ({ fullName, check }) => {
const repository = await resolveRepository({ fullName });
const currentCheck = await gitValidator.resolveRepairCheck(repository, check);
return gitValidator.previewRepair(repository, currentCheck);
});
register("git-validator:export", async ({ fullName, format = "json" }) => {
const repository = await resolveRepository({ fullName });
const report = await gitValidator.scan(repository);
return gitValidator.export(report, format);
});
register("git-validator:repair", async ({ fullName, check }) => {
const repository = await resolveRepository({ fullName });
const allowed = new Set([
"align-origin",
"configure-local-safety",
"add-gitignore",
"add-gitattributes",
"add-editorconfig",
"protect-default-branch",
]);
const currentCheck = await gitValidator.resolveRepairCheck(repository, check);
if (!allowed.has(currentCheck.fixAction))
throw new Error("Unsupported Git Validator repair request.");
const result = await gitValidator.repair(repository, currentCheck);
await audit.append("git-validator.repair", {
repository: repository.fullName,
checkId: currentCheck.id,
action: currentCheck.fixAction,
});
await diagnostics.info("git-validator.repair.completed", {
repository: repository.fullName,
checkId: currentCheck.id,
action: currentCheck.fixAction,
});
return result;
});
}
module.exports = { registerRepositoryIpc };
+10 -2
View File
@@ -49,6 +49,13 @@ function stableAlias(value, prefix = 'item') {
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()) {
const {
secrets = [],
@@ -63,6 +70,7 @@ function sanitizeForDiagnostics(value, options = {}, seen = new WeakSet()) {
if (typeof value === 'string') {
let output = redactSecrets(value, secrets);
if (pathMode === 'alias') output = pathAlias(output, { homeDir, cwd });
if (strictIdentifiers) output = redactPrivateInfrastructure(output);
return output;
}
if (value instanceof Error) {
@@ -80,7 +88,7 @@ function sanitizeForDiagnostics(value, options = {}, seen = new WeakSet()) {
output[key] = '[REDACTED]';
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());
continue;
}
@@ -90,4 +98,4 @@ function sanitizeForDiagnostics(value, options = {}, seen = new WeakSet()) {
return output;
}
module.exports = { redactSecrets, sanitizeForDiagnostics, pathAlias, stableAlias, SENSITIVE_KEY };
module.exports = { redactSecrets, sanitizeForDiagnostics, pathAlias, stableAlias, redactPrivateInfrastructure, SENSITIVE_KEY };
+26
View File
@@ -0,0 +1,26 @@
'use strict';
function isBrokenPipeError(error) {
return error?.code === 'EPIPE';
}
function installOutputPipeGuards({
stdout = process.stdout,
stderr = process.stderr,
onBrokenPipe = () => {}
} = {}) {
const guardedStreams = [stdout, stderr].filter(Boolean);
const handlers = guardedStreams.map((stream) => {
const handler = (error) => {
if (!isBrokenPipeError(error)) throw error;
onBrokenPipe(error);
};
stream.on('error', handler);
return { stream, handler };
});
return () => {
for (const { stream, handler } of handlers) stream.off('error', handler);
};
}
module.exports = { installOutputPipeGuards, isBrokenPipeError };
+149
View File
@@ -0,0 +1,149 @@
"use strict";
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const crypto = require("node:crypto");
const { run } = require("./process-runner.cjs");
class ProductionAcceptanceHarness {
constructor(root) {
this.root = root;
this.paths = {
remote: path.join(root, "gitea", "owner", "app.git"),
source: path.join(root, "workspace", "app"),
server: path.join(root, "server", "appdata", "app"),
releases: path.join(root, "releases"),
config: path.join(root, "user-data", "forgeflow-config.json"),
keys: path.join(root, "keys"),
};
this.state = { installed: false, version: null, tokenVersion: 1, auth: null, liveSha: null, previousSha: null, healthy: false, deployment: null, recovery: null, hostFingerprint: "SHA256:fixture-host", keyReadOnly: true };
}
static async create() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "forgeflow-production-acceptance-"));
const harness = new ProductionAcceptanceHarness(root);
await harness.provision();
return harness;
}
async provision() {
await Promise.all(Object.values(this.paths).filter((value) => !path.extname(value)).map((directory) => fs.mkdir(directory, { recursive: true })));
await fs.mkdir(path.dirname(this.paths.remote), { recursive: true });
await run("git", ["init", "--bare", this.paths.remote], { cwd: this.root, timeout: 30_000 });
await fs.mkdir(this.paths.source, { recursive: true });
await run("git", ["init", "-b", "main"], { cwd: this.paths.source, timeout: 30_000 });
await run("git", ["config", "user.name", "ForgeFlow Acceptance"], { cwd: this.paths.source });
await run("git", ["config", "user.email", "acceptance@example.invalid"], { cwd: this.paths.source });
await fs.writeFile(path.join(this.paths.source, "compose.yml"), "services:\n app:\n image: forgeflow-fixture:latest\n", "utf8");
await fs.writeFile(path.join(this.paths.source, "README.md"), "# Acceptance fixture\n", "utf8");
await run("git", ["add", "."], { cwd: this.paths.source });
await run("git", ["commit", "-m", "feat: initial fixture"], { cwd: this.paths.source });
await run("git", ["remote", "add", "origin", this.paths.remote], { cwd: this.paths.source });
await run("git", ["push", "-u", "origin", "main"], { cwd: this.paths.source, timeout: 30_000 });
this.initialSha = (await run("git", ["rev-parse", "HEAD"], { cwd: this.paths.source })).stdout.trim();
await fs.mkdir(this.paths.releases, { recursive: true });
await fs.mkdir(path.dirname(this.paths.config), { recursive: true });
await fs.mkdir(this.paths.keys, { recursive: true });
await fs.writeFile(path.join(this.paths.keys, "deploy_key.pub"), "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixture forgeflow-acceptance\n", "utf8");
}
async cleanup() { await fs.rm(this.root, { recursive: true, force: true }); }
async install(version = "0.10.0", mode = "installed") {
this.state.installed = true; this.state.version = version; this.state.mode = mode;
await this.saveConfig({ schemaVersion: 12, version, mode });
return structuredClone(this.state);
}
async migrate(targetVersion = "1.0.0") {
if (!this.state.installed) throw new Error("Clean installation is required before migration.");
const previous = JSON.parse(await fs.readFile(this.paths.config, "utf8"));
await fs.writeFile(`${this.paths.config}.backup`, JSON.stringify(previous, null, 2), "utf8");
this.state.version = targetVersion;
await this.saveConfig({ ...previous, schemaVersion: 13, version: targetVersion, migratedAt: new Date().toISOString() });
return { previousVersion: previous.version, version: targetVersion, backup: `${this.paths.config}.backup` };
}
async saveConfig(data) { await fs.writeFile(this.paths.config, `${JSON.stringify(data, null, 2)}\n`, "utf8"); }
rotateToken() { this.state.tokenVersion += 1; return { tokenVersion: this.state.tokenVersion }; }
authenticate(type, options = {}) {
if (!['password', 'ssh-key'].includes(type)) throw new Error("Unsupported authentication fixture.");
if (type === 'ssh-key' && options.hostFingerprint !== this.state.hostFingerprint) throw new Error("SSH host fingerprint changed.");
this.state.auth = type; return { authenticated: true, type };
}
setKeyAccess(readOnly) { this.state.keyReadOnly = readOnly; }
async createCommit(message = "fix: acceptance change") {
const target = path.join(this.paths.source, "fixture.txt");
await fs.writeFile(target, `${crypto.randomUUID()}\n`, "utf8");
await run("git", ["add", "fixture.txt"], { cwd: this.paths.source });
await run("git", ["commit", "-m", message], { cwd: this.paths.source });
await run("git", ["push", "origin", "main"], { cwd: this.paths.source });
return (await run("git", ["rev-parse", "HEAD"], { cwd: this.paths.source })).stdout.trim();
}
plan(sha, mode = "server-git") {
return { id: crypto.randomUUID(), evidenceHash: crypto.createHash("sha256").update(JSON.stringify({ sha, mode, liveSha: this.state.liveSha, keyReadOnly: this.state.keyReadOnly })).digest("hex"), sha, mode, previousSha: this.state.liveSha };
}
async deploy(plan, fault = null) {
if (!this.state.auth) throw new Error("Server authentication is required.");
if (plan.mode === "server-git" && !this.state.keyReadOnly) throw new Error("Writable deploy key rejected.");
if (this.plan(plan.sha, plan.mode).evidenceHash !== plan.evidenceHash) throw new Error("Stale reconciliation plan.");
this.state.recovery = structuredClone(this.state);
this.state.deployment = { id: crypto.randomUUID(), sha: plan.sha, mode: plan.mode, status: "running" };
if (fault === "fetch-network") return this.fail("Network interrupted during fetch", false);
await fs.mkdir(this.paths.server, { recursive: true });
await fs.writeFile(path.join(this.paths.server, "compose.yml"), await fs.readFile(path.join(this.paths.source, "compose.yml")));
if (fault === "activation-network") return this.fail("Network interrupted during activation", true);
if (fault === "shutdown") { this.state.deployment.status = "interrupted"; return structuredClone(this.state.deployment); }
this.state.previousSha = this.state.liveSha;
this.state.liveSha = plan.sha;
this.state.healthy = fault !== "unhealthy";
this.state.deployment.status = this.state.healthy ? "success" : "failed";
return structuredClone(this.state.deployment);
}
fail(message, partial) { this.state.deployment.status = "failed"; this.state.deployment.failure = { message, partial }; return structuredClone(this.state.deployment); }
recover() {
if (this.state.deployment?.status !== "interrupted") throw new Error("No interrupted deployment to recover.");
this.state.deployment.status = this.state.liveSha === this.state.deployment.sha && this.state.healthy ? "success" : "failed";
return structuredClone(this.state.deployment);
}
rollback(targetSha) {
if (!targetSha || targetSha !== this.state.previousSha) throw new Error("Rollback target is not the exact recorded previous SHA.");
[this.state.liveSha, this.state.previousSha] = [targetSha, this.state.liveSha]; this.state.healthy = true;
return { status: "rolled-back", liveSha: this.state.liveSha };
}
adoptExisting(sha = this.initialSha) { this.state.liveSha = sha; this.state.healthy = true; return { linked: true, liveSha: sha, preserved: true }; }
externalUpdate(sha) { this.state.liveSha = sha; this.state.healthy = true; return { reconciled: true, liveSha: sha }; }
rotateDeployKey() { if (!this.state.keyReadOnly) throw new Error("Candidate deploy key is writable."); this.state.keyVersion = (this.state.keyVersion || 1) + 1; return { rotated: true, keyVersion: this.state.keyVersion }; }
revokeDeployKey() { this.state.keyRevoked = true; return { revoked: true, deploymentBlocked: true }; }
restoreDeployKey() { this.state.keyRevoked = false; this.state.keyReadOnly = true; return { restored: true }; }
inventory(count = 20, partial = false) { return { workloads: Array.from({ length: count }, (_, index) => ({ id: `workload-${index + 1}`, classification: index === 1 ? "duplicate" : "active" })), partial, warnings: partial ? ["One scan root was unavailable"] : [] }; }
async publishRelease(version, options = {}) {
const binary = Buffer.from(options.binary || "MZ-forgeflow-acceptance-binary");
const name = `ForgeFlow-Portable-${version}-win-x64.exe`;
const checksum = crypto.createHash("sha256").update(binary).digest("hex");
const manifest = { version, draft: options.draft === true, assets: options.missingAsset ? [] : [{ name, sha256: options.badChecksum ? "0".repeat(64) : checksum }], provenance: { commitSha: options.commitSha || this.initialSha }, sbom: { bomFormat: "CycloneDX" } };
await fs.writeFile(path.join(this.paths.releases, `${version}.json`), JSON.stringify(manifest, null, 2));
if (!options.missingAsset) await fs.writeFile(path.join(this.paths.releases, name), binary);
return manifest;
}
async verifyRelease(version) {
const manifest = JSON.parse(await fs.readFile(path.join(this.paths.releases, `${version}.json`), "utf8"));
if (manifest.draft) throw new Error("Incomplete draft release rejected.");
const asset = manifest.assets[0];
if (!asset) throw new Error("Required release asset is missing.");
const binary = await fs.readFile(path.join(this.paths.releases, asset.name));
if (crypto.createHash("sha256").update(binary).digest("hex") !== asset.sha256) throw new Error("Release checksum mismatch.");
if (!manifest.provenance?.commitSha || manifest.sbom?.bomFormat !== "CycloneDX") throw new Error("Release provenance or SBOM is missing.");
return { verified: true, version, asset: asset.name };
}
}
module.exports = { ProductionAcceptanceHarness };
+180 -21
View File
@@ -1,5 +1,16 @@
'use strict';
const fs = require('node:fs');
// A watched repository is only re-read when the filesystem reports activity. The
// interval below stays as a safety net for watchers that silently stop
// delivering, which happens on network shares and removed folders.
const SAFETY_CHECK_INTERVAL_MS = 30_000;
const WATCH_DEBOUNCE_MS = 250;
// Busy trees (a build, an install, a fetch) produce a continuous event stream.
// This bounds how often that can turn into a Git read.
const MIN_WATCH_CHECK_INTERVAL_MS = 1_000;
class RepositoryMonitor {
constructor({ store, git, onChange, diagnostics = null }) {
this.store = store;
@@ -11,12 +22,149 @@ class RepositoryMonitor {
this.timer = null;
this.running = false;
this.paused = new Set();
this.active = false;
this.watchers = new Map();
this.changed = new Set();
this.lastCheckedAt = new Map();
this.lastFetchedAt = new Map();
this.watchTimer = null;
this.fetchRunning = false;
}
setPaths(paths) {
this.paths = [...new Set((paths || []).filter(Boolean))];
const watched = new Set(this.paths);
for (const existing of [...this.fingerprints.keys()]) {
if (!this.paths.includes(existing)) this.fingerprints.delete(existing);
if (!watched.has(existing)) this.fingerprints.delete(existing);
}
// A repository that is unlinked while a mutation holds it paused would keep
// that pause forever, silently freezing its status once it is watched again.
for (const existing of [...this.paused]) {
if (!watched.has(existing)) this.paused.delete(existing);
}
for (const existing of [...this.changed]) {
if (!watched.has(existing)) this.changed.delete(existing);
}
for (const existing of [...this.lastCheckedAt.keys()]) {
if (!watched.has(existing)) this.lastCheckedAt.delete(existing);
}
for (const existing of [...this.lastFetchedAt.keys()]) {
if (!watched.has(existing)) this.lastFetchedAt.delete(existing);
}
const now = Date.now();
for (const localPath of this.paths) {
if (!this.lastFetchedAt.has(localPath)) this.lastFetchedAt.set(localPath, now);
}
this.syncWatchers();
}
syncWatchers() {
for (const [localPath, watcher] of [...this.watchers]) {
if (this.active && this.paths.includes(localPath)) continue;
this.closeWatcher(localPath, watcher);
}
if (!this.active) return;
for (const localPath of this.paths) {
if (this.watchers.has(localPath)) continue;
try {
const watcher = fs.watch(
localPath,
{ recursive: true, persistent: false },
() => this.noteFilesystemChange(localPath)
);
watcher.on('error', () => this.dropWatcher(localPath));
this.watchers.set(localPath, watcher);
} catch {
// Watching is unavailable for this folder. Leaving it unwatched makes
// shouldCheck() fall back to the interval for that repository only.
}
}
}
closeWatcher(localPath, watcher = this.watchers.get(localPath)) {
if (!watcher) return;
try { watcher.close(); } catch { /* already closed */ }
this.watchers.delete(localPath);
}
dropWatcher(localPath) {
this.closeWatcher(localPath);
this.changed.add(localPath);
}
noteFilesystemChange(localPath) {
this.changed.add(localPath);
this.scheduleWatchTick();
}
scheduleWatchTick() {
if (this.watchTimer) return;
this.watchTimer = setTimeout(() => {
this.watchTimer = null;
this.tick().catch((error) => this.diagnostics?.warning('repository-monitor.tick.failed', error));
}, WATCH_DEBOUNCE_MS);
this.watchTimer.unref?.();
}
shouldCheck(localPath, now) {
if (this.paused.has(localPath)) return false;
if (!this.watchers.has(localPath)) return true;
const sinceLastCheck = now - (this.lastCheckedAt.get(localPath) || 0);
if (this.changed.has(localPath)) return sinceLastCheck >= MIN_WATCH_CHECK_INTERVAL_MS;
return sinceLastCheck >= SAFETY_CHECK_INTERVAL_MS;
}
fetchIntervalMs() {
const minutes = Number(this.store.data.preferences.fetchIntervalMinutes);
return Number.isFinite(minutes) && minutes > 0 ? Math.min(minutes, 240) * 60_000 : 0;
}
shouldFetch(localPath, now) {
const interval = this.fetchIntervalMs();
return interval > 0
&& !this.paused.has(localPath)
&& now - (this.lastFetchedAt.get(localPath) || now) >= interval;
}
async recordStatus(localPath, status, reason) {
const next = this.git.statusFingerprint(status);
const previous = this.fingerprints.get(localPath);
this.fingerprints.set(localPath, next);
if (previous && previous !== next) {
await this.diagnostics?.debug('repository-monitor.changed', { localPath, head: status.head, branch: status.branch?.head, counts: status.counts, reason });
this.onChange?.({ localPath, status, reason });
}
}
async fetchRemoteUpdates(now = Date.now()) {
if (this.fetchRunning) return;
const queue = this.paths.filter((localPath) => this.shouldFetch(localPath, now));
if (!queue.length) return;
this.fetchRunning = true;
try {
const workers = Array.from({ length: Math.min(2, queue.length) }, async () => {
while (queue.length) {
const localPath = queue.shift();
// Mark the attempt before awaiting the network. A failing remote should
// not be retried every local poll interval.
this.lastFetchedAt.set(localPath, Date.now());
try {
const result = await this.git.fetch(localPath);
await this.recordStatus(localPath, result.status, 'remote-state-changed');
await this.diagnostics?.debug('repository-monitor.fetch.completed', {
localPath,
branch: result.status?.branch?.head,
ahead: result.status?.branch?.ahead,
behind: result.status?.branch?.behind,
});
} catch (error) {
await this.diagnostics?.warning('repository-monitor.fetch.failed', { localPath, message: error.message });
}
}
});
await Promise.all(workers);
} finally {
this.fetchRunning = false;
}
}
@@ -26,6 +174,8 @@ class RepositoryMonitor {
restart() {
this.stop();
if (!this.store.data.preferences.autoRefresh) return;
this.active = true;
this.syncWatchers();
const seconds = Math.min(Math.max(Number(this.store.data.preferences.repositoryPollSeconds) || 4, 2), 60);
this.timer = setInterval(() => this.tick().catch((error) => this.diagnostics?.warning('repository-monitor.tick.failed', error)), seconds * 1000);
this.timer.unref?.();
@@ -34,37 +184,46 @@ class RepositoryMonitor {
stop() {
if (this.timer) clearInterval(this.timer);
this.timer = null;
if (this.watchTimer) clearTimeout(this.watchTimer);
this.watchTimer = null;
this.active = false;
this.syncWatchers();
}
async tick() {
void this.fetchRemoteUpdates().catch((error) => this.diagnostics?.warning('repository-monitor.fetch-cycle.failed', error));
if (this.running || !this.paths.length) return;
this.running = true;
try {
for (const localPath of this.paths) {
if (this.paused.has(localPath)) continue;
try {
const status = await this.git.status(localPath);
const next = this.git.statusFingerprint(status);
const previous = this.fingerprints.get(localPath);
this.fingerprints.set(localPath, next);
if (previous && previous !== next) {
await this.diagnostics?.debug('repository-monitor.changed', { localPath, head: status.head, branch: status.branch?.head, counts: status.counts });
this.onChange?.({ localPath, status, reason: 'working-tree-changed' });
}
} catch (error) {
const next = `error:${error.message}`;
const previous = this.fingerprints.get(localPath);
this.fingerprints.set(localPath, next);
if (previous && previous !== next) {
await this.diagnostics?.warning('repository-monitor.unavailable', { localPath, message: error.message });
this.onChange?.({ localPath, error: error.message, reason: 'repository-unavailable' });
const now = Date.now();
const queue = this.paths.filter((localPath) => this.shouldCheck(localPath, now));
const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
while (queue.length) {
const localPath = queue.shift();
this.changed.delete(localPath);
this.lastCheckedAt.set(localPath, Date.now());
try {
const status = await this.git.status(localPath);
await this.recordStatus(localPath, status, 'working-tree-changed');
} catch (error) {
const next = `error:${error.message}`;
const previous = this.fingerprints.get(localPath);
this.fingerprints.set(localPath, next);
if (previous && previous !== next) {
await this.diagnostics?.warning('repository-monitor.unavailable', { localPath, message: error.message });
this.onChange?.({ localPath, error: error.message, reason: 'repository-unavailable' });
}
}
}
}
});
await Promise.all(workers);
} finally {
this.running = false;
// Activity that arrived while the check was running keeps its flag set, so
// it must not wait for the safety interval.
if (this.active && this.changed.size) this.scheduleWatchTick();
}
}
}
module.exports = { RepositoryMonitor };
module.exports = { RepositoryMonitor, SAFETY_CHECK_INTERVAL_MS, WATCH_DEBOUNCE_MS, MIN_WATCH_CHECK_INTERVAL_MS };
+112 -8
View File
@@ -29,6 +29,13 @@ class RepositoryService {
this.gitea = giteaService;
this.diagnostics = diagnostics;
this.lastKnownLocalPaths = [];
this.lastKnownRemoteRepositories = [];
this.lastSuccessfulRemoteRefreshAt = null;
this.lastRemoteRefreshAtMs = 0;
this.lastDiscoveredPaths = [];
this.lastDiscoveryAtMs = 0;
this.refreshPromise = null;
this.lastResult = null;
}
async discoverInRoot(root, maxDepth = 4) {
@@ -51,8 +58,13 @@ class RepositoryService {
let entries;
try { entries = await fs.readdir(real, { withFileTypes: true }); } catch { return; }
// Directory entries report as a symbolic link instead of a directory, which
// is how Windows junctions surface. Skipping those made a project folder
// that is mapped through a junction invisible; visit() resolves each entry
// and the `seen` set above keeps links that point back into the tree from
// being scanned twice.
await mapLimit(entries
.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink() && !SKIP_DIRECTORIES.has(entry.name)), 12,
.filter((entry) => (entry.isDirectory() || entry.isSymbolicLink()) && !SKIP_DIRECTORIES.has(entry.name)), 12,
(entry) => visit(path.join(real, entry.name), depth + 1));
};
@@ -80,13 +92,97 @@ class RepositoryService {
return [...this.lastKnownLocalPaths];
}
async refresh() {
const started = Date.now();
const remoteRepositories = this.store.data.gitea.baseUrl && this.store.getToken()
? await this.gitea.listRepositories()
: [];
async getRemoteRepositories({ force = false } = {}) {
if (!this.store.data.gitea.baseUrl || !this.store.getToken()) {
this.lastKnownRemoteRepositories = [];
this.lastSuccessfulRemoteRefreshAt = null;
return { repositories: [], stale: false, error: null };
}
const discoveredPaths = await this.discoverAll(this.store.data.workspaceRoots);
if (!force && this.lastSuccessfulRemoteRefreshAt && Date.now() - this.lastRemoteRefreshAtMs < 15_000) {
return {
repositories: this.lastKnownRemoteRepositories.map((repository) => ({ ...repository })),
stale: false,
error: null,
cached: true
};
}
try {
const repositories = await this.gitea.listRepositories();
this.lastKnownRemoteRepositories = repositories.map((repository) => ({ ...repository }));
this.lastSuccessfulRemoteRefreshAt = new Date().toISOString();
this.lastRemoteRefreshAtMs = Date.now();
return { repositories, stale: false, error: null };
} catch (error) {
if (!this.lastSuccessfulRemoteRefreshAt) throw error;
await this.diagnostics?.warning('repositories.remote-refresh.degraded', {
message: error.message,
cachedCount: this.lastKnownRemoteRepositories.length,
lastSuccessfulAt: this.lastSuccessfulRemoteRefreshAt
});
return {
repositories: this.lastKnownRemoteRepositories.map((repository) => ({ ...repository })),
stale: true,
error: error.message
};
}
}
async getDiscoveredPaths({ force = false } = {}) {
if (!force && this.lastDiscoveryAtMs && Date.now() - this.lastDiscoveryAtMs < 30_000) {
return [...this.lastDiscoveredPaths];
}
const paths = await this.discoverAll(this.store.data.workspaceRoots);
this.lastDiscoveredPaths = [...paths];
this.lastDiscoveryAtMs = Date.now();
return paths;
}
// Resolving a single repository used to go through a full refresh, which runs
// `git status` for every discovered repository. Handlers that act on one
// repository only need that one, so its local state is read directly. Anything
// this cannot answer confidently still falls back to the full scan.
async resolveByFullName(fullName) {
const name = String(fullName || '').trim();
if (!name) return null;
const fromFullRefresh = async () => (await this.refresh()).find((item) => item.fullName === name) || null;
const remoteResult = await this.getRemoteRepositories({});
const remote = remoteResult.repositories.find((item) => item.full_name === name);
if (!remote) return fromFullRefresh();
const explicitPath = this.store.data.repositoryMappings[repositoryKey(remote)];
const knownPath = explicitPath || (this.lastResult || []).find((item) => item.fullName === name)?.localPath || null;
// Without a known path the link can still exist through remote-URL matching,
// which only the discovery pass can establish.
if (!knownPath && !this.lastResult) return fromFullRefresh();
const local = knownPath ? (await this.getLocalDescriptors([knownPath]))[0] : null;
const profiles = this.store.getDeploymentProfiles(remote.full_name).map((profile) => ({
...profile,
state: this.store.getDeploymentState(profile.id)
}));
return {
...this.decorate(remote, local, profiles),
remoteStale: remoteResult.stale,
remoteRefreshError: remoteResult.error,
remoteLastRefreshedAt: this.lastSuccessfulRemoteRefreshAt
};
}
async refresh(options = {}) {
if (this.refreshPromise) return this.refreshPromise;
this.refreshPromise = this.performRefresh(options).finally(() => { this.refreshPromise = null; });
return this.refreshPromise;
}
async performRefresh({ force = false } = {}) {
const started = Date.now();
const remoteResult = await this.getRemoteRepositories({ force });
const remoteRepositories = remoteResult.repositories;
const discoveredPaths = await this.getDiscoveredPaths({ force });
const mappedPaths = Object.values(this.store.data.repositoryMappings || {});
const localPaths = [...new Set([...discoveredPaths, ...mappedPaths])];
const localDescriptors = await this.getLocalDescriptors(localPaths);
@@ -106,7 +202,12 @@ class RepositoryService {
...profile,
state: this.store.getDeploymentState(profile.id)
}));
repositories.push(this.decorate(remote, local, profiles));
repositories.push({
...this.decorate(remote, local, profiles),
remoteStale: remoteResult.stale,
remoteRefreshError: remoteResult.error,
remoteLastRefreshedAt: this.lastSuccessfulRemoteRefreshAt
});
}
for (const local of localDescriptors.filter((item) => !usedLocalPaths.has(item.localPath))) {
@@ -145,11 +246,14 @@ class RepositoryService {
await this.diagnostics?.debug('repositories.refresh.completed', {
durationMs: Date.now() - started,
remoteCount: remoteRepositories.length,
remoteStale: remoteResult.stale,
remoteCached: remoteResult.cached === true,
discoveredCount: discoveredPaths.length,
linkedCount: sorted.filter((item) => item.localPath).length,
attentionCount: sorted.filter((item) => item.attention).length,
readyToDeployCount: sorted.filter((item) => item.readyToDeploy).length
});
this.lastResult = sorted;
return sorted;
}
+189 -9
View File
@@ -1,6 +1,5 @@
'use strict';
const fs = require('node:fs');
const fsp = require('node:fs/promises');
const crypto = require('node:crypto');
const path = require('node:path').posix;
@@ -54,9 +53,29 @@ function parseCapabilityOutput(output) {
}
class SshService {
constructor({ store, diagnostics }) {
constructor({ store, diagnostics, idleConnectionMs = 60_000, clientFactory = loadSshClient }) {
this.store = store;
this.diagnostics = diagnostics;
this.clientFactory = clientFactory;
// Every command used to pay for a TCP handshake, a key exchange and an
// authentication round trip. Sessions are kept per server for a short while
// so a sequence of commands shares one connection.
this.sessions = new Map();
this.idleConnectionMs = idleConnectionMs;
}
// A connection is only reusable for a server whose identity and credentials
// are unchanged. Anything in this key changing means a new connection.
sessionKey(server) {
return JSON.stringify([
server.id,
server.host,
server.port || 22,
server.username,
server.authType,
server.privateKeyPath || '',
server.hostFingerprint || '',
]);
}
async validateServerConfiguration(server, secrets = {}) {
@@ -86,7 +105,7 @@ class SshService {
return { valid: true, method: 'privateKey', encrypted: Boolean(passphrase), privateKeyPath };
}
async connectionOptions(server, { trustOnFirstUse = false } = {}) {
async connectionOptions(server, { trustOnFirstUse = false, expectedFingerprint = null } = {}) {
const credentials = this.store.getServerCredentials(server.id);
let observedFingerprint = null;
const options = {
@@ -98,7 +117,8 @@ class SshService {
keepaliveCountMax: 3,
hostVerifier: (key) => {
observedFingerprint = fingerprintKey(key);
return trustOnFirstUse || Boolean(server.hostFingerprint && observedFingerprint === server.hostFingerprint);
const trustedFingerprint = String(server.hostFingerprint || expectedFingerprint || '').trim();
return trustOnFirstUse || Boolean(trustedFingerprint && observedFingerprint === trustedFingerprint);
},
};
if (server.authType === 'password') options.password = credentials.password;
@@ -117,7 +137,115 @@ class SshService {
async withClient(serverId, action, options = {}) {
const server = this.store.getServer(serverId);
if (!server) throw new Error('The configured SSH server no longer exists.');
const Client = loadSshClient();
// A trust-on-first-use connection is established without checking the
// fingerprint, so it must never serve a later verified call.
if (options.trustOnFirstUse || options.expectedFingerprint) return this.withDedicatedClient(server, action, options);
return this.withPooledClient(server, action, options);
}
// Retrying is only safe while the command has not reached the server. Once a
// stream is open the remote side may already be deploying, and repeating that
// is not something this layer is allowed to decide.
isPreCommandFailure(error) {
return error?.beforeCommand === true;
}
async withPooledClient(server, action, options) {
const key = this.sessionKey(server);
for (let attempt = 0; ; attempt += 1) {
const session = await this.leaseSession(server, key, options);
try {
const result = await action(session.client, server, session.fingerprint);
this.releaseSession(session);
return result;
} catch (error) {
const staleConnection = session.reused && attempt === 0 && this.isPreCommandFailure(error);
this.discardSession(session);
if (!staleConnection) throw error;
await this.diagnostics?.debug('ssh.session.stale-retry', { serverId: server.id, host: server.host, message: error.message });
}
}
}
createSession(server, key, options) {
const entry = { key, client: null, fingerprint: null, leases: 0, dead: false, established: false, idleTimer: null, opening: null };
entry.opening = this
.withDedicatedClient(server, async (client, _server, fingerprint) => ({ client, fingerprint }), options, { keepOpen: true })
.then((opened) => {
entry.client = opened.client;
entry.fingerprint = opened.fingerprint;
entry.established = true;
// Without a standing listener an error on an idle connection is
// unhandled, which terminates the main process.
opened.client.on('error', () => this.markSessionDead(entry));
opened.client.on('close', () => this.markSessionDead(entry));
opened.client.on('end', () => this.markSessionDead(entry));
});
this.sessions.set(key, entry);
return entry;
}
async leaseSession(server, key, options) {
const pooled = this.sessions.get(key);
// Only a connection that was already up before this call may be retried on
// failure. Callers that arrive while one is still being opened share both
// the connection and its outcome.
const reused = Boolean(pooled && !pooled.dead && pooled.established);
const entry = pooled && !pooled.dead ? pooled : this.createSession(server, key, options);
entry.leases += 1;
if (entry.idleTimer) { clearTimeout(entry.idleTimer); entry.idleTimer = null; }
try {
await entry.opening;
} catch (error) {
entry.leases -= 1;
this.markSessionDead(entry);
throw error;
}
return { client: entry.client, fingerprint: entry.fingerprint, reused, entry };
}
markSessionDead(entry) {
entry.dead = true;
if (this.sessions.get(entry.key) === entry) this.sessions.delete(entry.key);
if (entry.idleTimer) { clearTimeout(entry.idleTimer); entry.idleTimer = null; }
if (entry.leases <= 0) this.endSession(entry);
}
endSession(entry) {
if (!entry.client) return;
try { entry.client.end(); } catch { /* already closed */ }
}
releaseSession(session) {
const entry = session.entry;
entry.leases -= 1;
if (entry.dead) { if (entry.leases <= 0) this.endSession(entry); return; }
if (entry.leases > 0) return;
entry.idleTimer = setTimeout(() => {
entry.idleTimer = null;
this.markSessionDead(entry);
}, this.idleConnectionMs);
entry.idleTimer.unref?.();
}
discardSession(session) {
const entry = session.entry;
entry.leases -= 1;
this.markSessionDead(entry);
}
// Closes every pooled connection. The application calls this while quitting so
// no socket outlives the process.
closeAll() {
for (const entry of [...this.sessions.values()]) {
entry.leases = 0;
this.markSessionDead(entry);
}
}
async withDedicatedClient(server, action, options = {}, { keepOpen = false } = {}) {
const serverId = server.id;
const Client = this.clientFactory();
const connection = await this.connectionOptions(server, options);
const client = new Client();
const started = Date.now();
@@ -126,7 +254,8 @@ class SshService {
const finish = (callback, value) => {
if (settled) return;
settled = true;
try { client.end(); } catch {}
// A session that stays in the pool is closed by the pool, not here.
if (!(keepOpen && callback === resolve)) { try { client.end(); } catch { /* already closed */ } }
callback(value);
};
client.once('ready', async () => {
@@ -136,7 +265,11 @@ class SshService {
finish(resolve, data);
} catch (error) { finish(reject, error); }
});
client.once('error', async (error) => {
// Deliberately not `once`: a connection that already failed can emit a
// second error while it is being torn down, and an unhandled 'error' event
// on an EventEmitter terminates the main process.
client.on('error', async (error) => {
if (settled) return;
const observed = connection.getObservedFingerprint();
const mismatch = Boolean(server.hostFingerprint && observed && server.hostFingerprint !== observed);
const wrapped = new Error(mismatch
@@ -164,6 +297,9 @@ class SshService {
if (error) {
clearTimeout(timer);
completed = true;
// The channel never opened, so the command did not reach the server.
// This is the only failure the pool is allowed to retry.
error.beforeCommand = true;
reject(error);
return;
}
@@ -280,7 +416,51 @@ class SshService {
}));
}
async test(serverId, { trustOnFirstUse = true } = {}) {
async probeHostFingerprint(serverId) {
const server = this.store.getServer(serverId);
if (!server) throw new Error('The configured SSH server no longer exists.');
const Client = this.clientFactory();
const client = new Client();
let observedFingerprint = null;
return new Promise((resolve, reject) => {
let settled = false;
const finish = (callback, value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
try { client.end(); } catch { /* handshake already closed */ }
callback(value);
};
const completeProbe = (error = null) => {
if (observedFingerprint) {
finish(resolve, {
fingerprint: observedFingerprint,
server: { id: server.id, name: server.name, host: server.host, port: server.port || 22 },
});
return;
}
const wrapped = new Error(`Could not read the SSH host fingerprint: ${error?.message || 'the server closed the handshake'}`);
wrapped.code = error?.code || 'SSH_HOST_KEY_PROBE_FAILED';
finish(reject, wrapped);
};
const timer = setTimeout(() => completeProbe(new Error('The SSH host-key probe timed out.')), 25_000);
client.on('error', completeProbe);
client.on('close', () => completeProbe());
client.on('end', () => completeProbe());
client.connect({
host: server.host,
port: server.port || 22,
username: server.username,
readyTimeout: 20_000,
hostVerifier: (key) => {
observedFingerprint = fingerprintKey(key);
return false;
},
});
});
}
async test(serverId, { trustOnFirstUse = false, expectedFingerprint = null } = {}) {
return this.withClient(serverId, async (client, server, fingerprint) => {
const script = `
platform=$(uname -srm 2>/dev/null || true)
@@ -315,7 +495,7 @@ printf 'baseWritable=%s\\n' "$base_writable"
capabilities,
output: [capabilities.platform, capabilities.composeVersion].filter(Boolean).join('\n'),
};
}, { trustOnFirstUse });
}, { trustOnFirstUse, expectedFingerprint });
}
async exec(serverId, command, options = {}) {
+461
View File
@@ -0,0 +1,461 @@
"use strict";
function createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto, parsePermissionInspection, safeRelativeRemoteFile }) {
class UnraidAccessMethods {
serverGitRemote(repository, profile) {
const candidates = [
repository.localStatus?.remoteUrl,
repository.sshUrl,
repository.preferredCloneUrl,
profile.cloneUrl,
]
.map((value) => String(value || "").trim())
.filter(Boolean);
const value = candidates.find((candidate) => /^ssh:\/\//i.test(candidate) || /^[^@\s]+@[^:\s]+:.+/.test(candidate));
if (!value) {
const error = new Error("Server pull requires the repository SSH clone URL from Gitea.");
error.code = "SERVER_GIT_SSH_URL_REQUIRED";
throw error;
}
return value;
}
serverGitHost(repository, profile) {
const remote = this.serverGitRemote(repository, profile);
if (/^ssh:\/\//i.test(remote)) {
const parsed = new URL(remote);
return { host: parsed.hostname, port: Number(parsed.port || 22) };
}
const match = remote.match(/^[^@\s]+@([^:\s]+):/);
if (!match) throw new Error("Could not determine the Gitea SSH host from the clone URL.");
return { host: match[1], port: 22 };
}
serverGitCredentialPaths(repository, server) {
const repositoryId = crypto.createHash("sha256").update(String(repository.fullName).toLowerCase()).digest("hex").slice(0, 24);
const directory = path.join(server.basePath, ".forgeflow", "git-credentials", repositoryId);
return {
directory,
privateKey: path.join(directory, "deploy-key"),
publicKey: path.join(directory, "deploy-key.pub"),
knownHosts: path.join(directory, "known_hosts"),
};
}
serverGitEnvironment(repository, profile, server) {
const credentials = this.serverGitCredentialPaths(repository, server);
return `GIT_SSH_COMMAND=${shellQuote(`ssh -i ${credentials.privateKey} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${credentials.knownHosts}`)}`;
}
async configureServerGitAccess({ repository, profileId }) {
const { profile, server } = this.resolve(repository, profileId);
const remote = this.serverGitRemote(repository, profile);
const { host, port } = this.serverGitHost(repository, profile);
const credentials = this.serverGitCredentialPaths(repository, server);
const trustedHostFingerprint = String(profile.serverGitAccess?.hostFingerprint || "").trim();
const marker = "__FORGEFLOW_DEPLOY_KEY__";
const setupScript = `
command -v git >/dev/null 2>&1 || { echo "Git is not installed on the server" >&2; exit 41; }
command -v ssh-keygen >/dev/null 2>&1 || { echo "ssh-keygen is not installed on the server" >&2; exit 42; }
command -v ssh-keyscan >/dev/null 2>&1 || { echo "ssh-keyscan is not installed on the server" >&2; exit 43; }
credential_dir=${shellQuote(credentials.directory)}
private_key=${shellQuote(credentials.privateKey)}
public_key=${shellQuote(credentials.publicKey)}
known_hosts=${shellQuote(credentials.knownHosts)}
expected_host_fingerprint=${shellQuote(trustedHostFingerprint)}
mkdir -p "$credential_dir"
chmod 700 "$credential_dir"
if [ ! -s "$private_key" ] || [ ! -s "$public_key" ]; then
rm -f "$private_key" "$public_key"
ssh-keygen -q -t ed25519 -N '' -C ${shellQuote(`forgeflow:${repository.fullName}`)} -f "$private_key"
fi
chmod 600 "$private_key"
chmod 644 "$public_key"
scan_tmp="$known_hosts.$$.tmp"
scan_ok=false
for attempt in 1 2 3; do
ssh-keyscan -T 10 -H -p ${Number(port)} ${shellQuote(host)} > "$scan_tmp" 2>/dev/null || true
if [ -s "$scan_tmp" ]; then scan_ok=true; break; fi
sleep $((attempt * 2))
done
[ "$scan_ok" = true ] || { rm -f "$scan_tmp"; echo "Gitea SSH host did not return a host key after three attempts" >&2; exit 44; }
scanned_host_fingerprint="$(ssh-keygen -lf "$scan_tmp" -E sha256 2>/dev/null | awk '{print $2}' | sort -u | paste -sd, -)"
if [ -n "$expected_host_fingerprint" ] && [ "$scanned_host_fingerprint" != "$expected_host_fingerprint" ]; then
rm -f "$scan_tmp"
echo "The Gitea SSH host key changed. Verify the Gitea server before replacing trust." >&2
exit 46
fi
mv "$scan_tmp" "$known_hosts"
chmod 600 "$known_hosts"
printf '%s\n' ${shellQuote(marker)}
printf 'publicKey=%s\n' "$(base64 < "$public_key" | tr -d '\\r\\n')"
printf 'fingerprint=%s\n' "$(ssh-keygen -lf "$public_key" -E sha256 | awk '{print $2}')"
printf 'hostFingerprint=%s\n' "$scanned_host_fingerprint"
`;
const setup = await this.ssh.exec(server.id, bash(setupScript), { timeout: 60_000, maxOutput: 256 * 1024 });
const output = String(setup.stdout || "");
const markerIndex = output.lastIndexOf(marker);
if (markerIndex < 0) throw new Error("The server did not return the generated deploy key.");
const fields = Object.fromEntries(output.slice(markerIndex + marker.length).trim().split(/\r?\n/).map((line) => {
const separator = line.indexOf("=");
return separator > 0 ? [line.slice(0, separator), line.slice(separator + 1)] : [line, ""];
}));
if (trustedHostFingerprint && fields.hostFingerprint && trustedHostFingerprint !== fields.hostFingerprint) {
const error = new Error("The Gitea SSH host key changed. Server pull was not reconfigured. Verify the Gitea server before replacing trust.");
error.code = "GITEA_SSH_HOST_KEY_MISMATCH";
throw error;
}
const publicKey = Buffer.from(fields.publicKey || "", "base64").toString("utf8").trim();
const [owner, repo] = String(repository.fullName || "").split("/");
if (!owner || !repo) throw new Error("A full Gitea repository name is required to configure server pull.");
const deployKey = await this.gitea.ensureReadOnlyDeployKey({
owner,
repo,
title: `ForgeFlow · ${server.name} · read-only`,
publicKey,
});
const probeCommand = `${this.serverGitEnvironment(repository, profile, server)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)}`;
const probe = await this.ssh.exec(
server.id,
bash(`probe_error=''
for attempt in 1 2 3; do
if probe_output=$(${probeCommand} 2>&1); then printf '%s\n' "$probe_output"; exit 0; fi
probe_error=$probe_output
sleep $((attempt * 2))
done
printf '%s\n' "$probe_error" >&2
exit 45`),
{ timeout: 45_000, maxOutput: 256 * 1024 },
);
const remoteSha = String(probe.stdout || "").trim().split(/\s+/)[0] || null;
const updated = await this.store.saveDeploymentProfile(repository.fullName, {
...profile,
deploymentMode: "server-git",
cloneUrl: remote,
serverGitAccess: {
configured: true,
deployKeyId: deployKey.id || null,
keyFingerprint: fields.fingerprint || null,
hostFingerprint: fields.hostFingerprint || null,
configuredAt: new Date().toISOString(),
},
});
return {
profile: updated,
created: deployKey.created === true,
remoteSha,
keyFingerprint: fields.fingerprint || null,
hostFingerprint: fields.hostFingerprint || null,
};
}
async probeServerGitAccess({ repository, profile, server }) {
try {
const remote = this.serverGitRemote(repository, profile);
const credentials = this.serverGitCredentialPaths(repository, server);
const trustedHostFingerprint = String(profile.serverGitAccess?.hostFingerprint || "").trim();
const trustedKeyFingerprint = String(profile.serverGitAccess?.keyFingerprint || "").trim();
const command = `[ -s ${shellQuote(credentials.privateKey)} ] && [ -s ${shellQuote(credentials.publicKey)} ] && [ -s ${shellQuote(credentials.knownHosts)} ] && actual_host_fingerprint="$(ssh-keygen -lf ${shellQuote(credentials.knownHosts)} -E sha256 2>/dev/null | awk '{print $2}' | sort -u | paste -sd, -)" && actual_key_fingerprint="$(ssh-keygen -lf ${shellQuote(credentials.publicKey)} -E sha256 2>/dev/null | awk '{print $2}')" && { [ -z ${shellQuote(trustedHostFingerprint)} ] || [ "$actual_host_fingerprint" = ${shellQuote(trustedHostFingerprint)} ]; } && { [ -z ${shellQuote(trustedKeyFingerprint)} ] || [ "$actual_key_fingerprint" = ${shellQuote(trustedKeyFingerprint)} ]; } && remote_output="$(${this.serverGitEnvironment(repository, profile, server)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)})" && remote_sha="$(printf '%s' "$remote_output" | awk 'NR==1 {print $1}')" && printf '__FORGEFLOW_SERVER_GIT_PROBE__\nremoteSha=%s\nkeyFingerprint=%s\nhostFingerprint=%s\n' "$remote_sha" "$actual_key_fingerprint" "$actual_host_fingerprint"`;
const result = await this.ssh.exec(server.id, bash(command), { timeout: 45_000, maxOutput: 256 * 1024 });
const output = String(result.stdout || "");
const marker = output.lastIndexOf("__FORGEFLOW_SERVER_GIT_PROBE__");
if (marker < 0) throw new Error("The server pull probe did not return verifiable fingerprint evidence.");
const fields = Object.fromEntries(output.slice(marker + "__FORGEFLOW_SERVER_GIT_PROBE__".length).trim().split(/\r?\n/).map((line) => {
const separator = line.indexOf("=");
return separator > 0 ? [line.slice(0, separator), line.slice(separator + 1)] : [line, ""];
}));
return { ready: true, remoteSha: fields.remoteSha || null, keyFingerprint: fields.keyFingerprint || null, hostFingerprint: fields.hostFingerprint || null };
} catch (error) {
return { ready: false, error: error.message };
}
}
async verifyServerGitProfile({ repository, profileId }) {
const { profile, server, remotePath } = this.resolve(repository, profileId);
const checks = [];
const add = (id, label, status, detail, evidence = {}) => checks.push({ id, label, status, detail, evidence });
if (profile.deploymentMode === "monitor-only") {
add("mode", "Deployment mode", "warning", "This profile is monitoring only and cannot deploy.");
return { readiness: "Monitoring only", ready: false, checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, checks };
}
if (profile.deploymentMode !== "server-git") {
add("mode", "Deployment mode", "unsupported", "Read-only server-pull verification applies only to Server pull profiles.");
return { readiness: "Unsupported", ready: false, checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, checks };
}
let branchSha = null;
try {
const [owner, repo] = String(repository.fullName || "").split("/");
const branch = await this.gitea.getBranch(owner, repo, profile.branch);
branchSha = branch?.commit?.id || branch?.commit?.sha || null;
add("remote-branch", "Gitea branch", branchSha ? "pass" : "fail", branchSha ? `${profile.branch} at ${branchSha}` : `${profile.branch} did not return a commit SHA.`, { branch: profile.branch, sha: branchSha });
const keys = await this.gitea.listDeployKeys(owner, repo);
const keyId = Number(profile.serverGitAccess?.deployKeyId);
const key = keys.find((item) => Number(item.id) === keyId);
add("deploy-key-scope", "Repository deploy key", key?.read_only === true ? "pass" : "fail", !key ? "The configured deploy key is no longer present in Gitea." : key.read_only === true ? `Key ${key.id} is repository-scoped and read-only.` : `Key ${key.id} has write access and is blocked.`, { keyId: key?.id || keyId || null, readOnly: key?.read_only === true });
} catch (error) {
add("gitea-access", "Gitea verification", "fail", error.message);
}
const access = await this.probeServerGitAccess({ repository, profile, server });
add("server-git-access", "Unraid to Gitea", access.ready ? "pass" : "fail", access.ready ? `Exact branch access verified at ${String(access.remoteSha || "unknown").slice(0, 12)}.` : access.error, access);
let inspection = null;
try {
inspection = await this.inspect({ repository, profileId });
const expectedCompose = profile.generatedCompose ? [".forgeflow/compose.forgeflow.yml"] : this.deploymentComposeFiles(profile);
const composePresent = !inspection.exists || expectedCompose.every((file) => inspection.composeFiles.includes(file));
add("deployment-directory", "Deployment directory", inspection.exists ? "pass" : "warning", inspection.exists ? remotePath : `${remotePath} will be created on first deployment.`, { remotePath, exists: inspection.exists });
add("compose", "Compose configuration", composePresent ? "pass" : "warning", composePresent ? expectedCompose.join(", ") : `Expected after deployment: ${expectedCompose.join(", ")}.`, { files: expectedCompose });
add("preserved-paths", "Preserved runtime paths", "pass", (profile.preservePaths || []).length ? profile.preservePaths.join(", ") : "No preserved runtime paths configured.", { paths: profile.preservePaths || [] });
add("environment-requirements", "Environment requirements", "pass", (profile.detectedMetadata?.envNames || []).length ? `${profile.detectedMetadata.envNames.length} variable name(s) detected; values remain hidden.` : "No environment variable names were detected in server metadata.", { names: profile.detectedMetadata?.envNames || [] });
} catch (error) {
add("server-inspection", "Server inspection", "fail", error.message);
}
const state = this.store.getDeploymentState(profile.id) || {};
const liveSha = state.liveSha || inspection?.head || null;
const running = state.containerRunning;
const healthy = state.healthy;
add("live-commit", "Live server commit", liveSha ? "pass" : "warning", liveSha || "No verifiable live commit is currently recorded.", { liveSha });
add("commit-parity", "Gitea and server parity", branchSha && liveSha && branchSha === liveSha ? "pass" : branchSha && liveSha ? "warning" : "incomplete", branchSha && liveSha ? branchSha === liveSha ? "The exact Gitea commit is live." : `Live ${String(liveSha).slice(0, 12)} differs from Gitea ${String(branchSha).slice(0, 12)}.` : "Parity cannot be proven until both SHAs are available.", { branchSha, liveSha });
add("runtime", "Container runtime", running === true ? "pass" : running === false ? "fail" : "incomplete", running === true ? "The linked container is running." : running === false ? "The linked container is stopped." : "Runtime state has not been verified.");
add("health", "Runtime health", healthy === true ? "pass" : healthy === false ? "fail" : "incomplete", healthy === true ? "Runtime health passed." : healthy === false ? "Runtime health failed." : "No conclusive runtime health evidence is available.");
const deploymentCheckIds = new Set(["gitea-access", "remote-branch", "deploy-key-scope", "server-git-access", "server-inspection"]);
const deploymentBlockers = checks.filter((item) => deploymentCheckIds.has(item.id) && item.status !== "pass");
const deployReady = Boolean(branchSha) && deploymentBlockers.length === 0;
const failed = checks.some((item) => item.status === "fail");
const incomplete = checks.some((item) => ["warning", "incomplete", "unsupported"].includes(item.status));
const readiness = deploymentBlockers.length
? "Access failed"
: failed
? "Deploy-ready; runtime unhealthy"
: incomplete
? (branchSha && liveSha && branchSha !== liveSha ? "Deployable update available" : "Deploy-ready; runtime verification incomplete")
: "Ready";
return { readiness, ready: deployReady, deployReady, deploymentBlockers, checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, server: { id: server.id, name: server.name }, remotePath, branch: profile.branch, branchSha, liveSha, checks };
}
permissionTargets(profile, server, remotePath) {
const targets = [
{
id: "server-base",
label: "Configured deployment base",
path: server.basePath,
kind: "directory",
required: false,
},
{
id: "project-root",
label: "Project folder",
path: remotePath,
kind: "directory",
required: true,
},
{
id: "forgeflow-state",
label: "ForgeFlow upload and rollback storage",
path: path.join(remotePath, ".forgeflow"),
kind: "directory",
required: true,
},
{
id: "forgeflow-incoming",
label: "ForgeFlow incoming upload folder",
path: path.join(remotePath, ".forgeflow", "incoming"),
kind: "directory",
required: true,
},
];
if (!profile.generatedCompose) {
for (const file of this.deploymentComposeFiles(profile)) {
targets.push({
id: `compose:${file}`,
label: `Compose file ${file}`,
path: path.join(remotePath, file),
kind: "file",
required: true,
});
}
}
const unique = new Map();
for (const target of targets) unique.set(`${target.kind}:${target.path}`, target);
return [...unique.values()];
}
permissionInspectionScript(profile, server, remotePath) {
const targetCalls = this.permissionTargets(profile, server, remotePath)
.map(
(target) =>
`probe ${shellQuote(target.id)} ${shellQuote(target.label)} ${shellQuote(target.path)} ${shellQuote(target.kind)} ${target.required ? "true" : "false"}`,
)
.join("\n");
return `
encode() { printf '%s' "$1" | base64 | tr -d '\\r\\n'; }
can_elevate=false
[ "$(id -u)" = 0 ] && can_elevate=true
if [ "$can_elevate" != true ] && command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then can_elevate=true; fi
has_acl=false
command -v setfacl >/dev/null 2>&1 && has_acl=true
printf '__FORGEFLOW_PERMISSIONS__\\n'
printf 'I\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \
"$(encode "$(id -un 2>/dev/null || echo unknown)")" \
"$(id -u 2>/dev/null || echo -1)" \
"$(id -g 2>/dev/null || echo -1)" \
"$(encode "$(id -Gn 2>/dev/null || true)")" \
"$has_acl" "$can_elevate"
probe() {
target_id=$1
label=$2
target=$3
kind=$4
required=$5
exists=false; readable=false; writable=false; parent_writable=false; effective=false
owner=''; group=''; mode=''; detail=''; nearest=''
if [ -e "$target" ] || [ -L "$target" ]; then
exists=true
[ -r "$target" ] && readable=true
[ -w "$target" ] && writable=true
owner=$(stat -c '%U' "$target" 2>/dev/null || true)
group=$(stat -c '%G' "$target" 2>/dev/null || true)
mode=$(stat -c '%a' "$target" 2>/dev/null || true)
fi
parent=$(dirname "$target")
ancestor=$parent
while [ ! -d "$ancestor" ] && [ "$ancestor" != / ]; do ancestor=$(dirname "$ancestor"); done
nearest=$ancestor
marker="$ancestor/.forgeflow-write-test-$$-\${RANDOM:-0}"
if [ -d "$ancestor" ] && (umask 077; : > "$marker") 2>/dev/null; then
rm -f -- "$marker" >/dev/null 2>&1 || true
parent_writable=true
fi
if [ "$kind" = directory ]; then
if [ -d "$target" ]; then
marker="$target/.forgeflow-write-test-$$-\${RANDOM:-0}"
if (umask 077; : > "$marker") 2>/dev/null; then
rm -f -- "$marker" >/dev/null 2>&1 || true
effective=true
fi
elif [ "$parent_writable" = true ]; then
effective=true
fi
else
if [ "$exists" = true ] && [ ! -f "$target" ]; then
detail='Path exists but is not a regular file.'
elif [ "$exists" = true ] && [ "$readable" = true ] && { [ "$writable" = true ] || [ "$parent_writable" = true ]; }; then
effective=true
elif [ "$exists" = false ] && [ "$parent_writable" = true ]; then
effective=true
detail='File is absent but can be created by the deployment user.'
fi
fi
if [ -z "$detail" ]; then
if [ "$effective" = true ]; then detail='Read/write probe passed.'
else detail="No safe create/replace access for $(id -un 2>/dev/null || echo 'the SSH user')."; fi
fi
printf 'P\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \
"$(encode "$target_id")" "$(encode "$label")" "$(encode "$target")" "$kind" "$required" \
"$exists" "$readable" "$writable" "$parent_writable" "$effective" \
"$(encode "$owner")" "$(encode "$group")" "$mode" "$(encode "$nearest")" "$(encode "$detail")"
}
${targetCalls}
`;
}
async inspectWriteAccess({ repository, profileId }) {
const { profile, server, remotePath } = this.resolve(repository, profileId);
const result = await this.ssh.exec(
server.id,
bash(this.permissionInspectionScript(profile, server, remotePath)),
{ timeout: 45_000, maxOutput: 2 * 1024 * 1024 },
);
const report = parsePermissionInspection(result.stdout);
report.serverId = server.id;
report.remotePath = remotePath;
return report;
}
permissionRepairScript(profile, server, remotePath) {
const preserve = [
".git",
"node_modules",
".venv",
"venv",
"__pycache__",
...(profile.preservePaths || []),
]
.map((value) => safeRelativeRemoteFile(value))
.filter(Boolean);
const pruneExpression = preserve.length
? preserve
.map((value) => `-path ${shellQuote(path.join(remotePath, value))} -o -path ${shellQuote(path.join(remotePath, value, "*"))}`)
.join(" -o ")
: "-false";
const composePaths = this.deploymentComposeFiles(profile)
.map((file) => shellQuote(path.join(remotePath, file)))
.join(" ");
return `
root=${shellQuote(remotePath)}
base=${shellQuote(server.basePath)}
case "$root" in "$base"|"$base"/*) ;; *) echo "Refusing permission repair outside configured base path: $root" >&2; exit 81 ;; esac
run_privileged() {
if [ "$(id -u)" = 0 ]; then "$@";
elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then sudo -n "$@";
else "$@";
fi
}
mkdir_cmd=mkdir
if ! mkdir -p "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null; then
run_privileged mkdir -p "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups"
fi
share_group=$(getent group users >/dev/null 2>&1 && echo users || id -gn)
if command -v setfacl >/dev/null 2>&1; then
run_privileged setfacl -m "u:$(id -un):rwx,g:$share_group:rwx,m:rwx" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true
run_privileged setfacl -d -m "u:$(id -un):rwx,g:$share_group:rwx,m:rwx" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true
fi
run_privileged chgrp "$share_group" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true
run_privileged chmod 2775 "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups"
if [ -d "$root" ]; then
while IFS= read -r -d '' entry; do
case "$entry" in
"$root/.forgeflow"|"$root/.forgeflow"/*) continue ;;
esac
run_privileged chgrp "$share_group" "$entry" 2>/dev/null || true
if [ -d "$entry" ]; then run_privileged chmod u+rwx,g+rwx,g+s "$entry"; else run_privileged chmod u+rw,g+rw "$entry"; fi
done < <(find "$root" -mindepth 1 \\( ${pruneExpression} \\) -prune -o -print0)
fi
for compose_file in ${composePaths || ""}; do
[ -e "$compose_file" ] || continue
run_privileged chgrp "$share_group" "$compose_file" 2>/dev/null || true
run_privileged chmod u+rw,g+rw "$compose_file"
done
echo "ForgeFlow repaired project write access for $(id -un) and group $share_group without changing preserved runtime paths."
`;
}
async repairWriteAccess({ repository, profileId }) {
const { profile, server, remotePath } = this.resolve(repository, profileId);
const before = await this.inspectWriteAccess({ repository, profileId });
await this.ssh.exec(server.id, bash(this.permissionRepairScript(profile, server, remotePath)), {
timeout: 5 * 60_000,
maxOutput: 4 * 1024 * 1024,
});
const after = await this.inspectWriteAccess({ repository, profileId });
if (!after.ready) {
const error = new Error(
`Write-access repair did not make every required path writable: ${after.blocking.map((item) => item.path).join(", ")}`,
);
error.code = "WRITE_ACCESS_REPAIR_INCOMPLETE";
error.permissionReport = after;
throw error;
}
await this.diagnostics?.info("unraid.write-access.repaired", {
repository: repository.fullName,
profileId,
serverId: server.id,
remotePath,
user: after.identity.user,
});
return { changed: true, normalized: true, before, after };
}
}
return UnraidAccessMethods.prototype;
}
module.exports = { createUnraidAccessMethods };
+79
View File
@@ -0,0 +1,79 @@
"use strict";
const crypto = require("node:crypto");
const path = require("node:path").posix;
const { shellQuote } = require("./ssh-service.cjs");
const bash = (command) => `printf '%s' ${shellQuote(Buffer.from(`set -euo pipefail\nexport GIT_TERMINAL_PROMPT=0\n${command}`, "utf8").toString("base64"))} | base64 -d | bash`;
function parseMarker(stdout, marker) {
const text = String(stdout || "");
const index = text.lastIndexOf(marker);
if (index < 0) throw new Error(`Server key operation did not return ${marker}.`);
return Object.fromEntries(text.slice(index + marker.length).trim().split(/\r?\n/).map((line) => { const separator = line.indexOf("="); return separator > 0 ? [line.slice(0, separator), line.slice(separator + 1)] : [line, ""]; }));
}
class UnraidDeployKeyHost {
constructor({ ssh }) { this.ssh = ssh; }
paths(repository, server) {
const id = crypto.createHash("sha256").update(String(repository.fullName).toLowerCase()).digest("hex").slice(0, 24);
const directory = path.join(server.basePath, ".forgeflow", "git-credentials", id);
return { directory, privateKey: path.join(directory, "deploy-key"), publicKey: path.join(directory, "deploy-key.pub"), knownHosts: path.join(directory, "known_hosts"), recovery: path.join(directory, "recovery") };
}
remote(repository, profile) {
const value = [repository.localStatus?.remoteUrl, repository.sshUrl, repository.preferredCloneUrl, profile.cloneUrl].map((item) => String(item || "").trim()).find((item) => /^ssh:\/\//i.test(item) || /^[^@\s]+@[^:\s]+:.+/.test(item));
if (!value) throw Object.assign(new Error("Server pull requires a Gitea SSH URL."), { code: "SERVER_GIT_SSH_URL_REQUIRED" });
return value;
}
environment(paths) { return `GIT_SSH_COMMAND=${shellQuote(`ssh -i ${paths.privateKey} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${paths.knownHosts}`)}`; }
async execute(server, script, options = {}) { return this.ssh.exec(server.id, bash(script), { timeout: options.timeout || 30_000, maxOutput: options.maxOutput || 128 * 1024 }); }
async inspect({ repository, server }) {
const p = this.paths(repository, server); const marker = "__FORGEFLOW_KEY_INSPECT__";
const script = `printf '%s\\n' ${shellQuote(marker)}; printf 'privateKeyPresent=%s\\n' "$([ -s ${shellQuote(p.privateKey)} ] && echo true || echo false)"; printf 'publicKey=%s\\n' "$([ -s ${shellQuote(p.publicKey)} ] && base64 < ${shellQuote(p.publicKey)} | tr -d '\\r\\n' || true)"; printf 'fingerprint=%s\\n' "$([ -s ${shellQuote(p.publicKey)} ] && ssh-keygen -lf ${shellQuote(p.publicKey)} -E sha256 | awk '{print $2}' || true)"; printf 'hostFingerprint=%s\\n' "$([ -s ${shellQuote(p.knownHosts)} ] && ssh-keygen -lf ${shellQuote(p.knownHosts)} -E sha256 | awk '{print $2}' | sort -u | paste -sd, - || true)"`;
const f = parseMarker((await this.execute(server, script)).stdout, marker);
return { privateKeyPresent: f.privateKeyPresent === "true", publicKey: f.publicKey ? Buffer.from(f.publicKey, "base64").toString("utf8").trim() : null, fingerprint: f.fingerprint || null, hostFingerprint: f.hostFingerprint || null };
}
async backup({ repository, server }) {
const p = this.paths(repository, server); const slot = path.join(p.recovery, `backup-${Date.now()}-${crypto.randomUUID()}`); const marker = "__FORGEFLOW_KEY_BACKUP__";
const script = `umask 077; mkdir -p ${shellQuote(slot)}; for name in deploy-key deploy-key.pub known_hosts; do [ ! -e ${shellQuote(p.directory)}/"$name" ] || cp -p ${shellQuote(p.directory)}/"$name" ${shellQuote(slot)}/"$name"; done; printf '%s\\n' ${shellQuote(marker)}; printf 'recovery=%s\\n' ${shellQuote(slot)}; printf 'publicKey=%s\\n' "$([ -s ${shellQuote(p.publicKey)} ] && base64 < ${shellQuote(p.publicKey)} | tr -d '\\r\\n' || true)"`;
const f = parseMarker((await this.execute(server, script)).stdout, marker);
return { recovery: f.recovery, publicKey: f.publicKey ? Buffer.from(f.publicKey, "base64").toString("utf8").trim() : null };
}
async generate({ repository, server }) {
const active = this.paths(repository, server); const directory = path.join(active.directory, `candidate-${crypto.randomUUID()}`); const p = { directory, privateKey: path.join(directory, "deploy-key"), publicKey: path.join(directory, "deploy-key.pub"), knownHosts: path.join(directory, "known_hosts") }; const marker = "__FORGEFLOW_KEY_CANDIDATE__";
const script = `umask 077; mkdir -p ${shellQuote(directory)}; ssh-keygen -q -t ed25519 -N '' -C ${shellQuote(`forgeflow-rotation:${repository.fullName}`)} -f ${shellQuote(p.privateKey)}; cp -p ${shellQuote(active.knownHosts)} ${shellQuote(p.knownHosts)}; chmod 600 ${shellQuote(p.privateKey)} ${shellQuote(p.knownHosts)}; chmod 644 ${shellQuote(p.publicKey)}; printf '%s\\n' ${shellQuote(marker)}; printf 'publicKey=%s\\n' "$(base64 < ${shellQuote(p.publicKey)} | tr -d '\\r\\n')"; printf 'fingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.publicKey)} -E sha256 | awk '{print $2}')"; printf 'hostFingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.knownHosts)} -E sha256 | awk '{print $2}' | sort -u | paste -sd, -)"`;
const f = parseMarker((await this.execute(server, script)).stdout, marker);
return { paths: p, publicKey: Buffer.from(f.publicKey, "base64").toString("utf8").trim(), fingerprint: f.fingerprint, hostFingerprint: f.hostFingerprint };
}
async verifyCandidate({ repository, profile, server, candidate }) {
const marker = "__FORGEFLOW_KEY_PROOF__"; const remote = this.remote(repository, profile); const p = candidate.paths;
const script = `output="$(${this.environment(p)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)})"; printf '%s\\n' ${shellQuote(marker)}; printf 'remoteSha=%s\\n' "$(printf '%s' "$output" | awk 'NR==1 {print $1}')"; printf 'fingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.publicKey)} -E sha256 | awk '{print $2}')"; printf 'hostFingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.knownHosts)} -E sha256 | awk '{print $2}' | sort -u | paste -sd, -)"`;
const f = parseMarker((await this.execute(server, script, { timeout: 45_000, maxOutput: 256 * 1024 })).stdout, marker);
return { ready: /^[0-9a-f]{40}$/i.test(f.remoteSha || ""), remoteSha: f.remoteSha || null, fingerprint: f.fingerprint || null, hostFingerprint: f.hostFingerprint || null };
}
// A caller that just verified this candidate passes its proof in. Re-running
// `git ls-remote` would open a second SSH connection to ask the same question,
// with nothing in between that could change the answer.
async preflightCandidate(context) { const proof = context?.proof?.remoteSha ? context.proof : await this.verifyCandidate(context); if (!proof.ready) throw new Error("Candidate preflight did not prove the remote branch."); return proof; }
async promote({ repository, server, candidate }) {
const p = this.paths(repository, server); const c = candidate.paths;
await this.execute(server, `test -s ${shellQuote(c.privateKey)}; test -s ${shellQuote(c.publicKey)}; test -s ${shellQuote(c.knownHosts)}; cp -p ${shellQuote(c.privateKey)} ${shellQuote(p.privateKey)}.new; cp -p ${shellQuote(c.publicKey)} ${shellQuote(p.publicKey)}.new; cp -p ${shellQuote(c.knownHosts)} ${shellQuote(p.knownHosts)}.new; mv ${shellQuote(p.privateKey)}.new ${shellQuote(p.privateKey)}; mv ${shellQuote(p.publicKey)}.new ${shellQuote(p.publicKey)}; mv ${shellQuote(p.knownHosts)}.new ${shellQuote(p.knownHosts)}`);
}
async verifyActive({ repository, profile, server }) { const paths = this.paths(repository, server); return this.verifyCandidate({ repository, profile, server, candidate: { paths } }); }
async rollback({ repository, server, candidate, previous }) {
const p = this.paths(repository, server); const recovery = previous.key.recovery;
await this.execute(server, `for name in deploy-key deploy-key.pub known_hosts; do test ! -s ${shellQuote(recovery)}/"$name" || cp -p ${shellQuote(recovery)}/"$name" ${shellQuote(p.directory)}/"$name"; done; rm -rf -- ${shellQuote(candidate.paths.directory)}`);
}
async commit({ server, candidate }) { await this.execute(server, `rm -rf -- ${shellQuote(candidate.paths.directory)}`); }
async revoke({ repository, server }) {
const p = this.paths(repository, server); const revoked = path.join(p.recovery, `revoked-${Date.now()}-${crypto.randomUUID()}`);
await this.execute(server, `umask 077; mkdir -p ${shellQuote(revoked)}; for name in deploy-key deploy-key.pub known_hosts; do [ ! -e ${shellQuote(p.directory)}/"$name" ] || mv ${shellQuote(p.directory)}/"$name" ${shellQuote(revoked)}/"$name"; done`);
}
async restore({ repository, server }) {
const p = this.paths(repository, server); const marker = "__FORGEFLOW_KEY_RESTORE__";
const script = `slot="$(find ${shellQuote(p.recovery)} -mindepth 1 -maxdepth 1 -type d -print 2>/dev/null | sort | tail -1)"; test -n "$slot"; for name in deploy-key deploy-key.pub known_hosts; do test -s "$slot/$name"; cp -p "$slot/$name" ${shellQuote(p.directory)}/"$name"; done; printf '%s\\n' ${shellQuote(marker)}; printf 'publicKey=%s\\n' "$(base64 < ${shellQuote(p.publicKey)} | tr -d '\\r\\n')"; printf 'fingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.publicKey)} -E sha256 | awk '{print $2}')"; printf 'hostFingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.knownHosts)} -E sha256 | awk '{print $2}' | sort -u | paste -sd, -)"`;
const f = parseMarker((await this.execute(server, script)).stdout, marker);
return { publicKey: Buffer.from(f.publicKey, "base64").toString("utf8").trim(), fingerprint: f.fingerprint, hostFingerprint: f.hostFingerprint };
}
}
module.exports = { UnraidDeployKeyHost, parseDeployKeyMarker: parseMarker };

Some files were not shown because too many files have changed in this diff Show More