7 Commits
36 changed files with 4365 additions and 1125 deletions
+40
View File
@@ -1,5 +1,45 @@
# Changelog # Changelog
## 0.9.0 - 2026-07-27
- replaced Git-checkout-only discovery with a complete Unraid workload inventory, including stopped, Compose, DockerMan and standalone containers;
- added persistent manual workload linking with repository, deployment mode, Compose project, files and service identity;
- made checksum-verified exact-commit push bundles the default for new SSH/Unraid profiles, so Unraid no longer needs a Gitea key;
- separated desktop-to-Unraid authentication, Docker/Compose capabilities and optional Unraid-to-Gitea access in diagnostics;
- preserved adopted DockerMan templates and disabled aggressive recreate/orphan flags by default;
- promoted deployment state only after Compose validation and service verification, with atomic manifests, rollback restoration and live lock ownership;
- retained server-side Git and monitor-only modes for explicit use cases.
## 0.8.9 - 2026-07-26
- added a per-repository Git Validator with a weighted assurance score and evidence-backed checks;
- validates Gitea branch governance, repository identity, upstream tracking, effective author identity, safe synchronization defaults, README and gitignore hygiene, tracked secret-shaped files and oversized files;
- provides audited one-click repairs for origin alignment and repository-local safety configuration;
- offers confirmed repairs for default-branch protection and a reviewable uncommitted `.gitignore`;
- introduced a premium, theme-aware and container-responsive Validator workspace with safe-fix batching.
## 0.8.8 - 2026-07-26
- fixed binary update downloads on Gitea servers that require release-scoped attachment routes;
- sends both the immutable release ID and attachment ID when downloading update assets;
- preserves strict same-origin token handling and SHA-256 verification.
## 0.8.7 - 2026-07-26
- automatically inventories running workloads across configured Unraid servers;
- links server Git checkouts to Gitea repositories through exact normalized origins and strong container evidence;
- supports image-only discovery through OCI and ForgeFlow repository/commit labels;
- adopts uniquely matched workloads into Deployments without requiring a prior ForgeFlow release operation;
- verifies every SSH deployment refresh against the current full Gitea branch SHA;
- treats matching commits as in order only while the container is running and healthy, and rejects ambiguous matches.
## 0.8.6 - 2026-07-26
- added contextual animated code maps to unused diff-canvas space;
- made illustrations respond to file type, diff size and pointer depth without obscuring code;
- enriched changed-file rows with clearer state chips, active hierarchy and premium interaction feedback;
- added responsive and reduced-motion safeguards for focused, accessible workspaces.
## 0.8.5 - 2026-07-26 ## 0.8.5 - 2026-07-26
- fixed packaged update downloads when Gitea reports an asset URL with a different public origin; - fixed packaged update downloads when Gitea reports an asset URL with a different public origin;
+114 -308
View File
@@ -1,363 +1,169 @@
# ForgeFlow # ForgeFlow
ForgeFlow 0.8 adds partial-hunk commits, guided conflict recovery, Gitea pull **Van lokale wijziging naar aantoonbaar juiste serverversie — zonder de Git- en deploymentcontext over verschillende tools te verspreiden.**
requests and protected-branch awareness, configurable editor/terminal actions,
deployment policies and release notes, append-only audit export, encrypted
configuration backup, native tray/notifications and a guarded end-to-end
acceptance harness.
## Publish this release to the built-in updater repository ForgeFlow is een desktopapp voor teams die met Git, Gitea en eigen servers werken. De app toont wat lokaal gewijzigd is, wat al op Gitea staat en welke exacte commit op de server draait. Daarna begeleidt ForgeFlow je door review, commit, push, deployment en verificatie.
Extract the full source ZIP to a folder under Downloads and run: > Huidige release: **0.9.0** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest)
![ForgeFlow release-overzicht](docs/screenshots/overview.png)
## Wat ForgeFlow voor je oplost
- **Eén duidelijke actielijst:** zie meteen welke repository aandacht nodig heeft en waarom.
- **Veilige Git-flow:** review wijzigingen, stage volledige bestanden of afzonderlijke hunks, commit, push en herstel conflicten zonder contextwissel.
- **Deployment op een exacte commit:** ForgeFlow gebruikt volledige commit-SHA's en toont lokaal, Gitea en server naast elkaar.
- **Volledige serverinventaris:** zie ook gestopte, DockerMan- en niet-Git-installaties, koppel twijfelgevallen handmatig en behoud hun bestaande Compose-identiteit.
- **Push-deployment zonder server-Gitea-key:** ForgeFlow verstuurt standaard een checksum-gecontroleerde bundle van de exacte lokale commit via de vertrouwde SSH-verbinding.
- **Ingebouwde Git Validator:** controleer repository-identiteit, branch protection, synchronisatie-instellingen, documentatie, geheimen en grote bestanden; veilige verbeteringen kunnen gericht worden toegepast.
- **Lokale controle:** configuratie en credentials blijven op het toestel en diagnostische exports worden lokaal geredigeerd.
## Snel starten
### Aanbevolen: de Windows-app installeren
1. Open de [laatste ForgeFlow-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest).
2. Download de Windows-installer of portable executable.
3. Start ForgeFlow en doorloop de setupwizard.
4. Voeg je Gitea-server, token en lokale projectmappen toe.
5. Configureer optioneel een serververbinding en één of meer deploymentprofielen.
Na installatie kun je nieuwe packaged releases vanuit **Settings → Updates** ophalen. Downloads worden tegen de gepubliceerde SHA-256-checksums gecontroleerd. Zie [UPDATING.md](docs/UPDATING.md) wanneer een oudere of source-only build nog niet binair kan updaten.
### Eerst vrijblijvend bekijken
De interactieve demomodus gebruikt uitsluitend representatieve voorbeelddata en maakt geen verbinding met Git, Gitea of een server:
```powershell ```powershell
Set-ExecutionPolicy -Scope Process Bypass npm install
.\Publish-ForgeFlow-Release.ps1 npm run demo
``` ```
The script runs the complete quality gate, clones `git@gitea.itworx.tech:Jens/ForgeFlow.git` into a temporary folder, mirrors the verified source without `.git`, `node_modules` or release ZIPs, commits it on `main` and pushes it. A running older ForgeFlow source installation can then update through **Settings → Updates**. Open daarna `http://127.0.0.1:4173`.
## De dagelijkse workflow
### 1. Begin bij wat aandacht vraagt
ForgeFlow is a desktop release cockpit that turns the complete path from a local Het release-overzicht vertaalt technische status naar concrete acties: wijzigingen reviewen, commits pushen, synchroniseren, deployen of een ongezonde omgeving onderzoeken. De repositorylijst blijft beschikbaar zodat je snel van context kunt wisselen.
code change to a verified server deployment into one guided flow:
```text ### 2. Review en publiceer code
Local working tree -> Gitea repository -> exact deployed server version
```
It is not an IDE and it does not replace Gitea Actions. ForgeFlow coordinates ![Repositorywerkruimte met diff en volgende actie](docs/screenshots/repository-workspace.png)
local Git, a private Gitea instance and fixed deployment workflows while keeping
credentials on the user's own computer.
![ForgeFlow overview](docs/screenshots/overview.png) In de repositorywerkruimte zie je de volledige keten **Local → Gitea → Server**. Je kunt wijzigingen selecteren, diffs bekijken, gedeeltelijke hunk-staging behouden, branch protection controleren en een pull request openen. Destructieve of publicerende acties vereisen altijd expliciete bevestiging.
## Current status: v0.6.0 self-healing Git and Unraid operations ### 3. Deploy en verifieer de live versie
### Git recovery and repository truth ![Deploymentsoverzicht met herkenbare containerkaarten](docs/screenshots/deployments.png)
- serializes every mutating Git action per repository; 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.
- waits through a short grace period and automatically retries after safely removing a proven stale lock;
- detects `HEAD.lock`, `index.lock`, ref locks and worktree locks while skipping object storage;
- provides repository-specific actions for fetch, fast-forward, push and backed-up divergence reset;
- creates a `forgeflow/backup-*` safety branch before any reset to upstream.
### SSH / Unraid and DockerMan Bij server discovery probeert ForgeFlow bestaande containers aan Gitea-repositories te koppelen. Een exacte overeenkomst tussen de volledige live SHA en de actuele Gitea-SHA wordt als gelijklopende versie weergegeven; een runtime-healthcheck blijft een afzonderlijke voorwaarde voor een gezonde deployment.
- reconciles interrupted deployments against the live SHA, container state and health; ### 4. Verbeter de repository met Git Validator
- applies DockerMan WebUI, icon and shell labels through an override and writes a persistent XML template fallback;
- keeps the visible container name such as `Portfolio` while enforcing lowercase internal service/image identities;
- includes a built-in high-contrast ITWorx icon, local PNG upload, persistent DockerMan image storage and cache refresh;
- exposes **Open Web UI**, **Repair DockerMan integration** and **Reconcile** directly on deployment cards.
### Update reliability ![Git Validator met assurance score en veilige fixes](docs/screenshots/git-validator.png)
- confirms the external updater handshake before closing ForgeFlow; Git Validator groepeert bevindingen per onderwerp en maakt onderscheid tussen geslaagde controles, aanbevelingen en kritieke problemen. Alleen fixes die ForgeFlow veilig en voorspelbaar kan uitvoeren worden als automatische actie aangeboden; governancewijzigingen zoals branch protection blijven zichtbaar en expliciet.
- rejects malformed PowerShell update helpers before publication;
- validates the replacement source and keeps rollback/success status for the next launch;
- verifies that Gitea reports the exact published release commit.
Read [SSH / Unraid deployment](docs/SSH_UNRAID_DEPLOYMENT.md) and the ## Wanneer is een release werkelijk in orde?
[LumaOps audit](docs/LUMAOPS_SERVER_AUDIT.md).
### v0.4.2 automatic clone workflow ForgeFlow houdt drie soorten waarheid bewust apart:
**Clone from Gitea** now uses the first configured project root and creates the | Controle | Betekenis |
repository-named subfolder automatically. The native folder picker is reserved | --- | --- |
for the explicit **Choose another location** action. Existing matching checkouts | **Local ↔ Gitea** | De lokale branch volgt de juiste upstream en is niet onverwacht ahead, behind of divergent. |
are linked; conflicting folders are never overwritten. | **Gitea ↔ Server** | De volledige commit-SHA op de server is exact gelijk aan de relevante commit op Gitea. |
| **Runtime health** | De container of applicatie draait en de geconfigureerde healthcheck slaagt. |
The Windows environment doctor retains the v0.3.1 npm command-shim correction. Een gelijke commit bewijst welke code draait; een geslaagde healthcheck bewijst dat die versie ook functioneert. ForgeFlow combineert beide signalen zonder het ene voor het andere te laten doorgaan.
Version 0.3.2 is designed so the user does not need to give a developer any ## Belangrijkste functies
Gitea token, SSH key, server password or other credential. The application now
includes:
- a five-step setup wizard; ### Git en Gitea
- computer, Gitea and deployment preflights;
- protected local token entry;
- structured local diagnostics with aggressive secret redaction;
- standard and strict privacy support bundles;
- fail-closed bundle safety auditing;
- request IDs that correlate desktop, Actions and server events;
- root-owned allowlisted server target configuration;
- a complete setup guide from fresh checkout to first rollback test.
Start with [START_HERE.md](START_HERE.md). The full installation and acceptance - repositories ontdekken, favorieten beheren en ontbrekende lokale clones koppelen;
procedure is in [docs/SETUP_GUIDE.md](docs/SETUP_GUIDE.md). - status, diff, staging, partial hunks, commit, push, fetch, pull, stash en conflict recovery;
- branches maken, wisselen, vergelijken en opruimen;
- branch protection controleren en pull requests openen;
- Git Validator met assurance score, bewijs per controle en gerichte veilige fixes.
## What ForgeFlow can do ### Deployments
### Repository workflow - deploymentprofielen per repository en omgeving;
- Gitea Actions en SSH/Unraid als gecontroleerde uitvoeringsroutes;
- serverinventaris van draaiende en gestopte Docker-, Compose- en DockerMan-workloads;
- automatische koppeling op exact bewijs en een handmatige koppelwizard voor twijfelgevallen;
- push-bundle deployment als standaard, zonder Git- of Gitea-sleutel op Unraid;
- verificatie op volledige SHA, runtime health en recente serverwaarheid;
- preflight, live logs, deploymenthistoriek en rollback naar de vorige bekende versie.
- Connect to a private Gitea instance and validate the account locally. ### Veiligheid en beheer
- Discover Git working trees beneath one or more project roots.
- Use the first project root as the automatic clone destination.
- Match local folders to Gitea repositories using normalized `origin` identity.
- Link existing repositories or clone directly into the default project root.
- Read real Git status through porcelain v2 and NUL-delimited output.
- Inspect diffs and select files.
- Stage and unstage changes.
- Commit locally or commit and push in one action.
- Push, fetch and use fast-forward-only pull.
- Review recent history.
- Create, switch and publish branches.
- Stash, list and restore local work.
- Mark favorites and prioritize repositories requiring attention.
- Refresh repository state automatically while the application is open.
### Deployment workflow - credentials versleuteld via de beveiligde opslag van het besturingssysteem;
- origin-checks voorkomen dat een Gitea-token naar een andere host wordt gestuurd;
- updatepakketten worden alleen vanaf de geconfigureerde Gitea-origin gedownload en met checksums geverifieerd;
- lokale redactie van tokens, wachtwoorden en gevoelige diagnostische data;
- versleutelde configuratieback-up, herstelvoorbeeld en lokale audittrail;
- packaged builds als Windows-installer en portable executable.
- Configure multiple environments per repository. Meer achtergrond staat in [SECURITY.md](docs/SECURITY.md) en [ARCHITECTURE.md](docs/ARCHITECTURE.md).
- Bind each environment to a fixed branch and fixed Gitea Actions workflows.
- Run a visible deployment preflight before confirmation.
- Re-run mandatory checks in the privileged backend before dispatch.
- Verify a clean tree, correct branch, upstream and ahead/behind state.
- Verify that the exact full SHA exists on the allowed remote branch.
- Verify local and remote workflow files and Gitea Actions availability.
- Dispatch a unique request ID with the exact commit SHA.
- Poll Gitea Actions runs and jobs.
- Correlate the workflow with the server status endpoint.
- Verify that the requested SHA is actually live.
- Run an independent application healthcheck.
- Roll back through a separate fixed workflow to the previous exact SHA.
- Preserve a bounded local operation history.
### Safe diagnostics ## Eerste configuratie
ForgeFlow stores structured JSONL diagnostics in the application-data folder. Voor normaal gebruik heb je nodig:
They are intended to make development and troubleshooting possible without
requesting credentials.
The logger records useful operational facts such as: - Windows 10 of 11;
- Git op het toestel;
- toegang tot een Gitea-account en een token met de benodigde repositoryrechten;
- minstens één lokale hoofdmap waarin ForgeFlow projecten mag ontdekken.
- timestamps, stable event names and duration; Voor serverdetectie en SSH-deployments heb je daarnaast een bereikbare Docker- of Unraid-host en een werkende SSH-configuratie nodig. Begin bij:
- Git action outcomes and repository state;
- Gitea endpoint paths and HTTP status, but not authorization headers;
- deployment request IDs, workflow stages and health states;
- preflight results;
- sanitized error names, codes, messages and stacks.
It excludes or redacts: - [SETUP_GUIDE.md](docs/SETUP_GUIDE.md) — Gitea, projectmappen en eerste ingebruikname;
- [DEPLOYMENT_SETUP.md](docs/DEPLOYMENT_SETUP.md) — deploymentprofielen en verificatie;
- [SSH_UNRAID_DEPLOYMENT.md](docs/SSH_UNRAID_DEPLOYMENT.md) — SSH- en Unraid-vereisten;
- [DIAGNOSTICS.md](docs/DIAGNOSTICS.md) — veilige controles en supportbundels.
- Gitea tokens and encrypted token blobs; ## Ontwikkelen vanuit de broncode
- passwords, authorization headers and credential-bearing URLs;
- private keys and common token patterns;
- arbitrary environment-variable dumps;
- local source contents and Git diffs;
- raw Gitea runner logs from support bundles;
- user-home paths.
Support bundles are audited before writing. If a known runtime secret, private Vereisten: Node.js 22, npm en Git.
key marker or URL credential remains, ForgeFlow aborts the export instead of
creating the ZIP. Strict privacy mode also hashes repository and user
identifiers while keeping related events correlatable.
Read [docs/DIAGNOSTICS.md](docs/DIAGNOSTICS.md) for the exact policy and its
limitations.
## Security model
- Electron main/preload/renderer separation.
- Sandboxed renderer with context isolation and no direct Node.js access.
- Narrow, frozen preload API.
- Trusted renderer-origin checks on privileged IPC.
- No arbitrary shell command field in the interface.
- Gitea token stored using Electron `safeStorage` when available.
- Blank token updates preserve the existing protected token.
- Atomic local configuration writes and schema migration.
- Repository-relative path validation for file actions.
- Supported-protocol and credential-in-URL validation.
- Fixed workflow, branch and environment allowlists.
- Root-owned server target configuration.
- Exact-SHA deployment and rollback.
- Server-side locking, health validation and status records.
- Local diagnostic retention, rotation and clear/export controls.
More detail is available in [docs/SECURITY.md](docs/SECURITY.md).
## Prerequisites
For a Windows source setup:
- Windows 10 or 11;
- Node.js 22 or newer;
- npm;
- Git on `PATH`;
- a normal signed-in desktop session;
- access to your own Gitea instance.
For deployment:
- Gitea Actions enabled;
- a trusted Gitea runner with an environment-specific label;
- a Linux target server reachable by that runner;
- Git, Docker Compose, `curl` and `flock` on the target server;
- an existing non-interactive server checkout of the application.
## Fastest Windows start
Extract the release, open PowerShell in the folder and run:
```powershell ```powershell
Set-ExecutionPolicy -Scope Process Bypass npm ci
.\setup-windows.ps1
```
The script:
1. checks Node.js, npm and Git;
2. installs the exact dependency versions from `package-lock.json` when present;
3. runs the environment doctor;
4. validates the source tree;
5. runs all automated tests;
6. starts ForgeFlow.
No Gitea or server credential is requested by the PowerShell script. Tokens are
entered later in the local desktop wizard.
Manual equivalent:
```bash
npm install --no-audit --no-fund
npm run doctor
npm run check npm run check
npm start npm start
``` ```
## Setup and first acceptance test Handige opdrachten:
Follow these documents in order: | Opdracht | Doel |
| --- | --- |
| `npm run dev` | Start Electron in ontwikkelmodus. |
| `npm run demo` | Start de browserdemo met voorbeelddata. |
| `npm run check` | Voert bronverificatie en de volledige testset uit. |
| `npm run doctor` | Controleert de lokale ontwikkelomgeving. |
| `npm run acceptance` | Voert de release-acceptatiecontroles uit. |
| `npm run dist:win` | Bouwt Windows installer + portable package, schrijft checksums en ruimt oude dist-artifacts op. |
1. [START_HERE.md](START_HERE.md) De belangrijkste onderdelen zijn:
2. [docs/SETUP_GUIDE.md](docs/SETUP_GUIDE.md)
3. [docs/UPDATING.md](docs/UPDATING.md)
4. [docs/DEPLOYMENT_SETUP.md](docs/DEPLOYMENT_SETUP.md)
5. [docs/STATUS_ENDPOINT.md](docs/STATUS_ENDPOINT.md)
6. [docs/DIAGNOSTICS.md](docs/DIAGNOSTICS.md)
The recommended first test uses a non-critical staging deployment and validates:
```text ```text
preflight -> commit -> push -> deploy -> Actions -> server SHA -> healthcheck -> rollback electron/ beveiligde desktopintegraties en IPC
src/ renderer, gebruikersflows en visuele componenten
scripts/ build-, release-, demo- en verificatiehulpmiddelen
tests/ unit- en integratietests
docs/ setup, deployment, beveiliging en release-informatie
``` ```
## Updating an existing source installation Aanvullende kwaliteitsdocumentatie:
Close ForgeFlow, copy the v0.4.2 update overlay over the existing source folder - [TEST_MATRIX.md](docs/TEST_MATRIX.md)
and run: - [ACCEPTANCE.md](docs/ACCEPTANCE.md)
- [STATUS_ENDPOINT.md](docs/STATUS_ENDPOINT.md)
- [ROADMAP.md](docs/ROADMAP.md)
```powershell ## Licentie
Set-ExecutionPolicy -Scope Process Bypass
.\update-windows.ps1
```
Application configuration and protected credentials are stored outside the ForgeFlow is beschikbaar onder de [MIT-licentie](LICENSE).
source folder and are not reset. See [docs/UPDATING.md](docs/UPDATING.md).
## Browser demo
The standalone visual demo requires no Gitea connection or credentials:
```bash
npm run demo
```
Open the printed local URL. The demo uses `src/renderer/mock-bridge.js`; the
packaged desktop application uses the privileged `preload.cjs` bridge.
## Quality checks
```bash
npm run verify
npm test
npm run check
npm run doctor -- --json
```
Version 0.3.2 contains 45 automated tests. Two integration suites create real
temporary bare Git remotes and exercise commit, push, branch, stash and exact
remote-SHA behavior. Detailed coverage is listed in
[docs/TEST_MATRIX.md](docs/TEST_MATRIX.md).
## Development packages
```bash
npm run dist:win
npm run dist:linux
npm run dist:mac
```
Windows users can run:
```powershell
.\build-windows.ps1
```
These commands create unsigned development packages. Public distribution still
requires platform code signing and platform-specific release acceptance.
## Server-side deployment model
ForgeFlow never sends a free-form deployment command. It dispatches a fixed
workflow with controlled inputs:
```json
{
"ref": "main",
"inputs": {
"environment": "staging",
"commit_sha": "0123456789abcdef0123456789abcdef01234567",
"request_id": "generated-by-forgeflow"
}
}
```
The workflow calls a root-owned allowlisted server script. Deployment targets
are defined in `/etc/forgeflow/targets.conf`, not supplied by the desktop app.
The server checks repository, environment, paths, branch, exact remote ancestry,
locking, Docker Compose result and health before it updates the public status
JSON.
## Project structure
```text
main.cjs Electron lifecycle and service composition
preload.cjs Whitelisted renderer API and event bridge
src/main/config-store.cjs State, migration and protected token storage
src/main/git-service.cjs Real Git CLI adapter
src/main/gitea-service.cjs Gitea REST and Actions adapter
src/main/repository-service.cjs Discovery, matching and repository aggregation
src/main/repository-monitor.cjs Background working-tree awareness
src/main/deployment-service.cjs Dispatch, polling, SHA checks and rollback
src/main/preflight-service.cjs Computer and deployment readiness checks
src/main/diagnostics-service.cjs Safe JSONL diagnostics and support bundles
src/main/log-redaction.cjs Recursive secret and privacy sanitizer
src/main/ipc.cjs Validated privileged operation boundary
src/shared/clone-target.cjs Safe automatic clone target planning
src/shared/zip-writer.cjs Dependency-free diagnostic ZIP writer
src/renderer/ Desktop UI and browser demo
examples/gitea-actions/ Fixed deploy and rollback workflow templates
examples/server/ Allowlisted Linux deployment implementation
tests/ Unit and real Git integration tests
docs/ Setup, diagnostics, security and architecture
```
## Scope and remaining acceptance
The v0.4.2 logic is prepared for self-service configuration and testing, but no
source release can prove compatibility with a private environment it has never
connected to. The remaining acceptance is deliberately local to the user:
- validate the exact Gitea version and Actions API;
- validate the runner label and runner permissions;
- adapt one target line in the root-owned server configuration;
- verify the application-specific Docker Compose and health endpoint;
- execute the documented staging deployment and rollback test;
- export a Strict support bundle if a failure occurs.
No credential needs to be shared for that process.
## License
MIT. See [LICENSE](LICENSE).
+36 -25
View File
@@ -1,4 +1,4 @@
ForgeFlow 0.8.5 source manifest ForgeFlow 0.9.0 source manifest
SHA-256 BYTES PATH SHA-256 BYTES PATH
(The manifest excludes itself, dependencies and generated release artifacts.) (The manifest excludes itself, dependencies and generated release artifacts.)
755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 58 .gitignore 755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 58 .gitignore
@@ -12,7 +12,7 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263
4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183 3364 build/icon-64.png 4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183 3364 build/icon-64.png
25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico 25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png
ddd7faeae0a1700c3459b48ea086da1103c41efaf87f05a50e349389169f5fed 9288 CHANGELOG.md e3fd7c60e47b04aab79518bfc80b747bd76b299d20e7caca570abaf10b57cdca 12075 CHANGELOG.md
21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md 21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md
a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md
30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md 30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md
@@ -43,15 +43,22 @@ d7bdc61d9b617ad5acf0b2d468eda547fd7509d4af08f33d2661393f25bdcb5a 576
c2802fa5dbff392c846b82b55e84a8bfb8e1625546fd1eba39f7129318bece96 654 docs/RELEASE_NOTES_0.8.3.md c2802fa5dbff392c846b82b55e84a8bfb8e1625546fd1eba39f7129318bece96 654 docs/RELEASE_NOTES_0.8.3.md
8c13279987672314332f648889f52338bcdcb243249f9e1c20fb09d85d7808f5 505 docs/RELEASE_NOTES_0.8.4.md 8c13279987672314332f648889f52338bcdcb243249f9e1c20fb09d85d7808f5 505 docs/RELEASE_NOTES_0.8.4.md
b516db97a0353babc810c24a87a971d30d72a8021d809e6b833ff7ae0458f442 538 docs/RELEASE_NOTES_0.8.5.md b516db97a0353babc810c24a87a971d30d72a8021d809e6b833ff7ae0458f442 538 docs/RELEASE_NOTES_0.8.5.md
838d196f3fbbfeee8df375a0502107f56b6df28babca30aa09ef1c7aa5196d09 738 docs/RELEASE_NOTES_0.8.6.md
ef049adcfa204908e6dc3a059124b39ba0e2739cc54e38945ce73a57049df0d8 1185 docs/RELEASE_NOTES_0.8.7.md
7eedb25e1aae3b06a04bb9b2f4843bd6af614418737e600b4bdc9161edabd76a 632 docs/RELEASE_NOTES_0.8.8.md
35dcfda990946480d6d55bd2d2e6360c336260bcd05cdb51d92e07a4e8d76945 1046 docs/RELEASE_NOTES_0.8.9.md
0f5d64a752424bb8e2f5aa8a01fa31ea3bce3db8fd70e6b3c2ea29c41dd954f5 2574 docs/RELEASE_NOTES_0.9.0.md
2b631b9d6d973bdd70869d84886ff339da351e29e17598970b3b27915674661d 4175 docs/ROADMAP.md 2b631b9d6d973bdd70869d84886ff339da351e29e17598970b3b27915674661d 4175 docs/ROADMAP.md
1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png 1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png
b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png
070e6700bdae8c628c907ba181bbf0dde0bbbbb4208f7a875503f933ff1b882e 118819 docs/screenshots/deployment-success.png 070e6700bdae8c628c907ba181bbf0dde0bbbbb4208f7a875503f933ff1b882e 118819 docs/screenshots/deployment-success.png
bcb1e4daf1eeedc5b3f61d2406f1a65312dba130082528007e1629d9df99570a 153240 docs/screenshots/overview.png 495c55c37a42e7f8ab302d838ed73c232190d33e4e04eea3e3fa4ad1fe795dbc 105224 docs/screenshots/deployments.png
224e34ab45877bbb97b07d2a14c4a5aa6e28339522a8015b33a2a81477177143 135102 docs/screenshots/repository-workspace.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 322624242d246d07180cc719e14c91e8fb69e123676a02e5046f4e576cca1ca1 5569 docs/SECURITY.md
32a34ec13a284d3f9ceebbc107b25a844e3db096f8cafa4e43951fc2050c9a03 13552 docs/SETUP_GUIDE.md 32a34ec13a284d3f9ceebbc107b25a844e3db096f8cafa4e43951fc2050c9a03 13552 docs/SETUP_GUIDE.md
4dcdbd42550a4cc53fe948349b20bfe0e445d40144ef82df54b3aab8b00a8f46 5165 docs/SSH_UNRAID_DEPLOYMENT.md 6d053a73500eda1f48abd633bc8936d4397c7b46e95bc6c3a02aa698ec12f2a8 6056 docs/SSH_UNRAID_DEPLOYMENT.md
b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md
0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md 0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md
4625a10ebd3c749f60b2a7bef6b1716cd05dbc44ccceba0491a1b46bc293c195 4883 docs/TEST_MATRIX.md 4625a10ebd3c749f60b2a7bef6b1716cd05dbc44ccceba0491a1b46bc293c195 4883 docs/TEST_MATRIX.md
@@ -64,13 +71,13 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532
106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf 106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf
2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json 2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE 4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE
6765015bdf27b288a250192272750b243c3cb8d1326b752d056d1e43317b6344 12935 main.cjs 3b16a087c73b600415394dff8b8e34e7f7519e48fde1cf443007b2e11ca77b27 13123 main.cjs
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md 91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
3ffef38b3c056134c2ed8394e7bdcbb1c73e388024aa4f04a668e791060c600a 130466 package-lock.json bf0ccaa096bb7db57eda09c74769ba5f798ccf72654bafe6d04efea6446d0418 130466 package-lock.json
ca3f27afd345ca2880f33f619e01bb161272d99f289f19f94831f4625f286006 3795 package.json 2a6d2668296f9f0e3fea2ef3ce5afebf5b8e6d60fbcbf6939c827e69087dfde7 3980 package.json
3d2ac366a13e9418e3ec6d13ce95b611f30f0228eb3a80ef9e7a936ce9578e24 9080 preload.cjs 6b66a5aef158de35d5cdd0f205ceeec53b6a375937a67c5e9d00e483999ff840 9898 preload.cjs
b31c43d9355c13b5ae4efc0f3649d8cb8d509b2bb7ebb042ff546b7820fb7de8 8411 Publish-ForgeFlow-Release.ps1 b31c43d9355c13b5ae4efc0f3649d8cb8d509b2bb7ebb042ff546b7820fb7de8 8411 Publish-ForgeFlow-Release.ps1
a6d32a742412b7836606be00f17be0465f1b6f55d3911f6c73a14029787ba206 14037 README.md d929f074b9c24619f0e750753b8721b212ed37cedad44565df9c52754eb71a23 8802 README.md
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs 509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
00d57bda5af8c8eda294b72d18b318f024a307b81b0d9205a0821f5240151e31 3814 scripts/apply-binary-update.ps1 00d57bda5af8c8eda294b72d18b318f024a307b81b0d9205a0821f5240151e31 3814 scripts/apply-binary-update.ps1
f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287 scripts/apply-source-update.ps1 f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287 scripts/apply-source-update.ps1
@@ -80,34 +87,36 @@ f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287
74433d8a6b24afe368197a469e2fe0c5050c239d7250b84c2f3f598c304778b0 4736 scripts/publish-binary-release.cjs 74433d8a6b24afe368197a469e2fe0c5050c239d7250b84c2f3f598c304778b0 4736 scripts/publish-binary-release.cjs
444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs 444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs
42203f9e0fd4aae517284d387f265cf1b0b180379bc253a092b5c3c5c4caef0a 2992 scripts/validate-installed-connections.cjs 42203f9e0fd4aae517284d387f265cf1b0b180379bc253a092b5c3c5c4caef0a 2992 scripts/validate-installed-connections.cjs
c73c8bed917ee7dbf5f7fa57923456a4fcf43b1b60b3a67da7cfe9b5e2c223f5 12275 scripts/verify.mjs 1b269fa25947324120cab42612aaef806c3f72c761eacb4649edd58396a0de6f 12890 scripts/verify.mjs
0079701b5acbfef07b71a9623613d1940805ccd20649d77e3f34c37e79df7655 735 scripts/write-release-checksums.mjs 0079701b5acbfef07b71a9623613d1940805ccd20649d77e3f34c37e79df7655 735 scripts/write-release-checksums.mjs
619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1 619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1
dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs
a381848a296c28f6d14093c96f722967acf9c994ffb867d54dd92bf5ada2729b 23648 src/main/config-store.cjs e3e3839042dad88bba6971535d34ba2531dfd129d963b14ab9df887f68179cbf 27126 src/main/config-store.cjs
2fb04b1494b39f5d7c0720fa5fd298cd46fa85dc1b696d77657592347fcf1819 2731 src/main/configuration-backup.cjs 2fb04b1494b39f5d7c0720fa5fd298cd46fa85dc1b696d77657592347fcf1819 2731 src/main/configuration-backup.cjs
9d0af5074093108a5248d0dde0ff70a666748e61f1954b630886a81e8f34072c 24079 src/main/deployment-service.cjs 9d0af5074093108a5248d0dde0ff70a666748e61f1954b630886a81e8f34072c 24079 src/main/deployment-service.cjs
c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs
a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs
0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs 0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs
75f25fa8ee520b03b0bbe4c4cea439853202d318547343cda49a19c6ab868901 16984 src/main/gitea-service.cjs 857f270a2204b743421eea619a6a88595f749e4c24c1794cb092ace7987d25dc 12553 src/main/git-validator-service.cjs
79593a28b8f40f48a73028be34464f94be2e4c67a027a3f5da78a33bcadc76eb 45539 src/main/ipc.cjs 1c0a1c1b7f20734c646c874e07c89550e601951351c04770874c1ed3496f0833 17837 src/main/gitea-service.cjs
703547b6d8f5837801b953cb4c049e41139343da131088e0cd5bb7382c05e772 48373 src/main/ipc.cjs
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs 62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs 958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs
3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs 3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs
e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508 src/main/repository-monitor.cjs e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508 src/main/repository-monitor.cjs
17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs 17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs
b31a63bf8cb1807b3e838e2bf8a0e742738f119d13de8ca9f42e471f072217d3 8328 src/main/ssh-service.cjs cc4d5e06119d0315e4240f0776d68aacf72f2f36907f40830fa86e7bb0876490 18250 src/main/server-inventory.cjs
8cf5013de91737dd9345b121586ceec38a6fc8518648975d60593d81fc225c4b 67376 src/main/unraid-deployment-service.cjs afef3841a3948b2121f8fba809aae4ea3da71bd2fda86973ba50200a5b1f89b2 14894 src/main/ssh-service.cjs
27b9e15dd6530bc7bfab51414430eaef169e0cc4acd027257b1c63f891af8c85 20733 src/main/update-service.cjs 51bfc677fcdaec75ac6abe1a55ff531bc081787053b49ee56d8bfffcab666aa4 99239 src/main/unraid-deployment-service.cjs
ef029189791024421d78b3ac92981f03b20f2086fc4846eaa95d509f65e096a9 194049 src/renderer/app.js 45e65564e1e8b9db487dc6dda03a752c51130189f23ec3b1260dc62e3621a925 20806 src/main/update-service.cjs
704306badd4a1a7080c3d2f4407c8dc1d8ec5f807dd165cd22a37e48d6022435 220714 src/renderer/app.js
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png
094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png 094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png
e1c463d6cda9f2b9b78c468845c0a7e8688f0362be5642074a1a5f7122dfe811 762 src/renderer/index.html e1c463d6cda9f2b9b78c468845c0a7e8688f0362be5642074a1a5f7122dfe811 762 src/renderer/index.html
34dd57cd916b3b2faabaf73d3063d5e6ed734393f13db795b51bff150f4bdc2d 50922 src/renderer/mock-bridge.js d102db2f63d92d8b6af85fc798453dc3bd78cfbe5542cbf0af740781d059fa0f 58577 src/renderer/mock-bridge.js
ebdd804b55c0b13cbf57e51c6d51a60dd838d67bb4c4eaf17ca34c86938a6865 67440 src/renderer/styles.css 607b2592c6aee37de91126a80704c1f7d8575cb2e5ff889305cd86a37e6170c4 76937 src/renderer/styles.css
0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs 0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs
5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs 5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs
029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs 029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs
@@ -129,22 +138,24 @@ fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800
dd121d96ca265a027cd415a52064500a4541b2f8a662f4f4b25f2f996d52b5da 762 tests/external-tools.test.mjs dd121d96ca265a027cd415a52064500a4541b2f8a662f4f4b25f2f996d52b5da 762 tests/external-tools.test.mjs
e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 tests/git-integration.test.mjs e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 tests/git-integration.test.mjs
5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db 1239 tests/git-status.test.mjs 5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db 1239 tests/git-status.test.mjs
c98cbe50a783e2a1cfecf9052f558aabe656add6a463899532dd59d743b720b2 3645 tests/git-validator.test.mjs
681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.test.mjs 681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.test.mjs
52b96f0a6623778fbdc1e8dbfc892e1d77a3d6616058cd7b08d532b207aa5719 5531 tests/gitea-actions.test.mjs 8df055080e7166a52d36a6fdc0bab40c09b054d579c6848fcc076245de1573c4 6797 tests/gitea-actions.test.mjs
267d76b868d8d06ea031c14acd09a7715fb44668a25ade51a9e62e0170888bc8 1522 tests/ipc-contract.test.mjs 48bca4711e7c193d19c78a0cb45ea1c83179b3c23640195f66058268e8a11b52 1520 tests/ipc-contract.test.mjs
caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs
96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs 96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs
c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 3718 tests/preflight.test.mjs c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 3718 tests/preflight.test.mjs
bc29c4e0c08305b2d00bf59ae9d486cad323c94879cf2a35d596a6b58fc17a0b 7784 tests/renderer-workflow.test.mjs 9b2534196f2f6f61fd1c8c5dc44634375bb65ad3000096904d6b10c07d221b13 9158 tests/renderer-workflow.test.mjs
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs 2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs
f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 1068 tests/repository-monitor.test.mjs f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 1068 tests/repository-monitor.test.mjs
75b5b83836c75675bb9a48fe4363fcb8a24fc425e6af6f822d7955c6f3c79eac 2265 tests/repository-service.test.mjs 75b5b83836c75675bb9a48fe4363fcb8a24fc425e6af6f822d7955c6f3c79eac 2265 tests/repository-service.test.mjs
d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463 tests/security-validation.test.mjs d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463 tests/security-validation.test.mjs
bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs
020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs 020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs
2571128f0b8e650071df17755baa09c4dfc441af0c20a7a4e9aa445b59e87d11 1654 tests/ssh-service.test.mjs
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs 8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs
05c791ea262aef85a7c79874ed900c5d792a78778c577cb803b88a433cd6e2b6 21413 tests/unraid-deployment.test.mjs 936412de29be7fbccb71a32460e222077361090deb1373fd56c904d70a0af718 37436 tests/unraid-deployment.test.mjs
f1f0f13ac41f47c9df8ef778d7b98c09a589f61e6f76e14bc0e8943c5ede7560 14990 tests/update-service.test.mjs 11d6e6329f775617a1ce3657d0454cc97d7bcf3d9759f9a5a623c9d18e13d03e 15118 tests/update-service.test.mjs
9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs 9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs
7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs 7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs
8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea 767 UPDATE_FROM_0.3.2.md 8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea 767 UPDATE_FROM_0.3.2.md
+14
View File
@@ -0,0 +1,14 @@
# ForgeFlow 0.8.6
ForgeFlow 0.8.6 gives the changes workspace a richer, more purposeful visual
identity. Unused diff-canvas space now presents a contextual animated code map
that reflects the selected file type and its additions and removals.
Subtle travelling signals, floating status nodes and pointer-responsive depth
bring the canvas to life while keeping every diff line fully readable. Dense
diffs automatically reduce the illustration's presence, and narrow panes hide
it entirely when there is no useful room.
The changed-file list also gains clearer state chips, stronger active-file
hierarchy and refined hover feedback. All effects support light and dark themes
and respect the operating system's reduced-motion preference.
+22
View File
@@ -0,0 +1,22 @@
# ForgeFlow 0.8.7
ForgeFlow 0.8.7 automatically discovers applications already running on every
configured and trusted Unraid server. It inventories server-side Git checkouts,
Docker Compose metadata, bind mounts, container identity and image provenance,
then links each workload to a Gitea repository only when the evidence produces
one unambiguous match.
Uniquely matched workloads are added to Deployments automatically, even when
they were originally deployed outside ForgeFlow. Every refresh resolves the
configured branch directly on Gitea and compares its full commit SHA with the
live server version. A deployment is reported as in order when the SHAs match,
the container is running and Docker health is not failing.
Containers without a server-side Git checkout can also be discovered when the
image exposes standard OCI source/revision labels or ForgeFlow provenance
labels. New ForgeFlow deployments now write repository, branch and exact commit
labels so future discovery remains deterministic.
Ambiguous or weak matches are intentionally left unlinked for manual review.
Server inventory is read-only; automatic adoption changes only ForgeFlow's local
configuration.
+13
View File
@@ -0,0 +1,13 @@
# ForgeFlow 0.8.8
ForgeFlow 0.8.8 fixes the HTTP 404 returned while downloading packaged updates
from the configured Gitea server.
The server's API requires release attachments to be addressed using both the
immutable release ID and attachment ID. ForgeFlow now uses that exact
release-scoped endpoint for the executable and its checksum file.
Strict same-origin token protection and SHA-256 verification remain unchanged.
Install 0.8.8 manually when upgrading from 0.8.7 because the affected download
code runs before the corrected updater can be installed. Future packaged
updates can again be completed from inside ForgeFlow.
+20
View File
@@ -0,0 +1,20 @@
# ForgeFlow 0.8.9
ForgeFlow 0.8.9 introduces Git Validator, a dedicated repository assurance
workspace that checks whether practical Git and Gitea best practices are being
followed.
The validator produces a weighted score with evidence for repository identity,
upstream tracking, working-tree state, effective commit identity, safe local
synchronization defaults, default-branch and force-push protection, README and
gitignore hygiene, tracked secret-shaped filenames and oversized tracked files.
Every repair is deliberately bounded. Origin alignment and repository-local
fetch/pull/autostash safeguards can be applied as safe fixes. Creating default
branch protection or a recommended `.gitignore` requires explicit confirmation.
The generated `.gitignore` remains uncommitted for review, and secret/history
findings are never modified automatically.
The new workspace includes grouped findings, an assurance score, safe-fix
batching, audit events, interactive project illustration and responsive premium
layouts for light and dark themes.
+23
View File
@@ -0,0 +1,23 @@
# ForgeFlow 0.9.0
ForgeFlow 0.9.0 changes Unraid deployments from a Git-checkout-first workflow into a server-inventory-first workflow.
## Existing installations are now visible
Server Inventory collects a safe, selected subset of Docker, Compose and DockerMan metadata for both running and stopped containers. It recognizes Compose project names, working directories, active Compose files and services, mounts, ports, runtime state, image provenance and existing DockerMan templates. Environment values and other container secrets are not collected.
Exact repository provenance may be linked automatically. Name similarity is never treated as proof: uncertain workloads remain visible as suggestions and can be linked through the new manual wizard. The saved link preserves the workload identity rather than depending on the disposable container ID.
## Push bundle is the new default
New SSH/Unraid profiles use **Push bundle**. ForgeFlow creates a tar archive from the exact local Git commit, calculates its SHA-256 digest and uploads it over the already trusted desktop-to-Unraid SSH connection. Unraid therefore does not need a Git client, Gitea host-key entry or Gitea private key for this mode.
The server verifies the checksum and archive paths, rejects symlink payloads, preserves configured runtime paths, updates only ForgeFlow-managed files and validates the merged Compose model before starting services. The active SHA and managed-file manifest are promoted atomically only after the expected services are running. Failure restoration keeps the previous deployment truth and restores overwritten files and Compose metadata.
**Server-side Git** remains available as an explicit mode. Its Gitea access check is now reported separately from desktop SSH and Docker/Compose capabilities. **Monitor only** links an existing workload without granting ForgeFlow permission to deploy it.
## Safer adoption
Adopted workloads retain their existing Compose project, Compose files and service set. ForgeFlow no longer overrides their image or container name in the metadata overlay. Existing DockerMan templates are left untouched; generated templates are managed only for explicitly generated Compose profiles. `--force-recreate` and `--remove-orphans` are opt-in rather than defaults.
A deployment lock records the live shell process, and an old lock is removed only when it is sufficiently old and its owner no longer runs. Deployment output truncation now fails explicitly instead of allowing ForgeFlow to interpret an incomplete inventory or command result.
+66 -64
View File
@@ -1,6 +1,20 @@
# SSH / Unraid deployment # SSH / Unraid deployment
ForgeFlow deploys an exact Gitea commit directly to an Unraid server over pinned SSH. ForgeFlow can inventory existing Unraid workloads and deploy an exact Git commit through one of three explicit modes.
## Deployment modes
### Push bundle — default
ForgeFlow creates an archive from the exact local commit and uploads it through the trusted desktop-to-Unraid SSH connection. The Unraid server needs Docker, Docker Compose, `tar` and a SHA-256 checksum tool. It does **not** need Git access or a Gitea key.
### Server-side Git — optional
Unraid fetches the exact commit from Gitea. This mode additionally requires Git, a trusted Gitea host key and a non-interactive Unraid-to-Gitea identity with repository access.
### Monitor only
ForgeFlow inventories and tracks the workload but refuses deploy and rollback operations until another mode is selected.
## Security model ## Security model
@@ -8,88 +22,76 @@ ForgeFlow deploys an exact Gitea commit directly to an Unraid server over pinned
- Ed25519 private keys are preferred. - Ed25519 private keys are preferred.
- Passwords and key passphrases use Electron safe storage. - Passwords and key passphrases use Electron safe storage.
- The first trusted connection records the SSH host-key fingerprint; later changes fail closed. - The first trusted connection records the SSH host-key fingerprint; later changes fail closed.
- Unraid-to-Gitea repository access is tested during every deployment preflight. - ForgeFlow validates the selected private key before saving the server configuration.
- Desktop-to-Unraid authentication, Docker access, Compose availability and optional Unraid-to-Gitea access are reported as separate checks.
- Remote inventory collects selected labels, mounts, ports and runtime state; it does not collect container environment values.
- The renderer cannot submit arbitrary shell commands. Remote scripts are assembled from validated profile fields and transported as base64-encoded Bash input. - The renderer cannot submit arbitrary shell commands. Remote scripts are assembled from validated profile fields and transported as base64-encoded Bash input.
- Tracked server-side modifications block deployment and rollback. - Incomplete SSH output is rejected rather than interpreted.
## Profile identity ## Server Inventory and manual linking
ForgeFlow separates names that users see from names Docker requires: **Server Inventory** includes running and stopped containers, Compose projects, DockerMan installations and standalone containers even when `/mnt/user/appdata/<app>` is not a Git checkout.
An exact repository remote or trusted provenance label may be linked automatically. Similar names remain suggestions. For an uncertain workload, use **Link workload** and choose:
1. the repository;
2. Push bundle, Server-side Git or Monitor only;
3. the folder below the configured server base path.
The resulting profile preserves the detected Compose project name, Compose files, service set, container metadata and stable workload selector. Linking itself does not recreate a container or rewrite a DockerMan template.
## Compose identity
An adopted installation keeps the identity reported by Docker:
```text ```text
Visible project/container: Portfolio Visible container: omniroute
Server folder: Portfolio Server folder: OmniRoute
Internal Compose project: portfolio Compose project: omniroute-production
Internal Compose service: portfolio Compose files: compose.yml, compose.unraid.yml
Internal image: forgeflow/portfolio:production Compose services: api, worker
``` ```
The internal Compose service must match the repository's service key and remain lowercase. The visible container can preserve branding and casing. ForgeFlow adds `.forgeflow/compose.metadata.yml` as the final Compose overlay. For adopted workloads this overlay adds labels only; it does not replace the existing image or `container_name`.
## DockerMan WebUI, icon and shell `--force-recreate` and `--remove-orphans` are disabled by default and can be enabled explicitly per profile. Multiple Compose files and services are retained and validated.
ForgeFlow writes `.forgeflow/compose.metadata.yml` and combines it with the repository or generated Compose file. The override supplies: ## DockerMan safety
```text Existing DockerMan templates under `/boot/config/plugins/dockerMan/templates-user` are never rewritten for adopted workloads. ForgeFlow manages a persistent DockerMan template only when all of the following are true:
net.unraid.docker.managed=dockerman
net.unraid.docker.webui=http://[IP]:[PORT:<host-port>]/
net.unraid.docker.icon=<PNG URL or persistent Unraid path>
net.unraid.docker.shell=sh
```
Icon modes: - the profile uses ForgeFlow-generated Compose;
- **Manage DockerMan template** is enabled;
- the workload was not adopted from the server.
- **Built-in high-contrast ITWorx mark** — default; Metadata repair for an adopted application therefore updates the Compose metadata labels only and leaves the original Unraid template intact.
- **Upload local PNG** — copied to `/boot/config/plugins/dockerMan/images/<container>-icon.png`;
- **Use icon URL** — HTTP(S) PNG;
- **No custom icon**.
After metadata changes ForgeFlow recreates the container, writes `/boot/config/plugins/dockerMan/templates-user/my-<container>.xml`, removes known icon caches and invalidates DockerMan's volatile `docker.json` metadata cache. The Unraid Docker page may still need one browser refresh. ## Push bundle sequence
The deployment card reports whether WebUI and icon labels were confirmed through `docker inspect`. **Repair DockerMan integration** recreates an existing healthy container with labels, a persistent DockerMan template, icon cache refresh and WebUI metadata without creating a Git commit. **Open Web UI** uses the profile URL directly from the desktop. 1. Verify the clean local working tree, selected branch, upstream state and exact commit.
2. Verify the exact commit exists on the configured remote branch.
3. Test desktop-to-Unraid SSH, Docker, Compose, `tar`, checksum tooling and deployment storage.
4. Create the release with `git archive` from the exact commit.
5. Upload a temporary `.part` file through SFTP.
6. Verify the SHA-256 digest and reject unsafe paths or symbolic links.
7. Store a retained release manifest and back up files that ForgeFlow is about to manage.
8. Preserve `.forgeflow`, `.git` and configured runtime paths such as `.env`, `data`, `config`, `logs` and application-specific folders.
9. Update only files in the old or new ForgeFlow managed-file manifests; unrelated server files remain untouched.
10. Validate the merged Compose configuration.
11. Start the configured Compose project and verify every selected service has a running container and is not unhealthy.
12. Promote `current-sha`, status and managed-file manifests atomically.
13. Run the optional desktop healthcheck and persist runtime state.
## Existing application folder If activation fails, ForgeFlow restores the backed-up files and previous Compose metadata, restarts the previous Compose state where possible and leaves the previous active SHA untouched.
For an existing folder: ## Server-side Git sequence
```text Server-side Git retains the exact-SHA flow for installations that deliberately keep a checkout on Unraid. Preflight runs `git ls-remote` from Unraid, rejects tracked server edits, verifies the configured origin and checks that the selected SHA belongs to the allowed remote branch. The active SHA is written only after Compose validation and service verification.
Server folder: Portfolio
Remote path: /mnt/user/appdata/Portfolio
Compose file: docker-compose.yml
```
The project root must be a Git working tree. Untracked runtime paths such as `.env`, `appdata`, `data`, `logs`, `config` and `compose.override.yml` remain untouched by `git reset --hard`. Nested Git repositories are warnings and never deleted automatically. ## Interrupted operations and locks
Preflight inspects `.dockerignore` when a Dockerfile exists. It warns when `.git`, preserved runtime data or nested repositories would be sent into the build context. A push deployment creates `.forgeflow/deploy.lock` with a request ID, timestamp and shell PID. A lock older than two hours is removed only when its recorded process no longer exists. Startup and **Reconcile** compare the requested SHA with server state and container health; unresolved operations eventually become failed rather than remaining active indefinitely.
## New application folder
The server clones the configured URL on the selected branch. The Unraid host therefore needs a non-interactive Gitea SSH identity. Preflight runs `git ls-remote --exit-code` from Unraid before deployment.
Use repository Compose for real applications. Generated Compose is intended only for a simple single-service Dockerfile application with basic port mapping.
## Deployment sequence
1. Verify clean local tree, allowed branch, upstream and ahead/behind state.
2. Verify the exact SHA exists on the allowed remote branch.
3. Verify Unraid can read the Gitea repository.
4. Inspect the server folder and refuse tracked server changes.
5. Clone when the folder is absent.
6. Fetch the branch and verify the exact SHA is an ancestor of `origin/<branch>`.
7. Save the previous SHA and reset to the requested SHA.
8. Write generated Compose when selected.
9. Write the DockerMan metadata override and persistent template fallback.
10. Validate the merged Compose model.
11. Run `docker compose up -d --build --remove-orphans --force-recreate`.
12. Clear relevant icon caches.
13. Inspect the visible container and write `.forgeflow/status.json`.
14. Run the configured desktop healthcheck.
15. Persist the live SHA, previous SHA, health, container and DockerMan state.
## Interrupted operation recovery
At startup and through **Reconcile**, ForgeFlow reads the live SHA, container running state, Docker health, labels and persistent template state. When a previously running operation already reached its exact requested SHA and the container is healthy, the operation becomes `success`. Operations that remain unresolved for more than 45 minutes become `failed` rather than staying indefinitely in deployment mode.
## Rollback ## Rollback
Rollback is accepted only for the exact SHA currently recorded as `previousSha`. ForgeFlow re-verifies that commit against Gitea, refuses tracked server changes, resets the same working tree, reapplies Compose and DockerMan metadata, reruns health checks and rotates the former live SHA into the new rollback target. Rollback is accepted only for the exact `previousSha` recorded for the profile. Push bundle mode recreates that commit archive locally and uses the same checksum, backup, Compose validation and atomic promotion flow. Server-side Git mode verifies and checks out the exact previous commit through its Git workflow.
Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 110 KiB

+9 -3
View File
@@ -25,6 +25,7 @@ const {
UnraidDeploymentService, UnraidDeploymentService,
} = require("./src/main/unraid-deployment-service.cjs"); } = require("./src/main/unraid-deployment-service.cjs");
const { AuditService } = require("./src/main/audit-service.cjs"); const { AuditService } = require("./src/main/audit-service.cjs");
const { GitValidatorService } = require("./src/main/git-validator-service.cjs");
const { const {
ExternalToolsService, ExternalToolsService,
} = require("./src/main/external-tools-service.cjs"); } = require("./src/main/external-tools-service.cjs");
@@ -141,9 +142,7 @@ function createWindow() {
); );
mainWindow.webContents.setWindowOpenHandler(({ url }) => { mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (/^https?:\/\//i.test(url)) if (/^https?:\/\//i.test(url))
shell shell.openExternal(url).catch((error) =>
.openExternal(url)
.catch((error) =>
diagnostics?.warning("external-link.open.failed", { diagnostics?.warning("external-link.open.failed", {
url, url,
message: error.message, message: error.message,
@@ -260,6 +259,7 @@ app
store, store,
ssh, ssh,
git, git,
gitea,
diagnostics, diagnostics,
sourcePath: app.getAppPath(), sourcePath: app.getAppPath(),
onOperationChange: reportOperationChange, onOperationChange: reportOperationChange,
@@ -286,6 +286,11 @@ app
userDataPath, userDataPath,
secureStorageAvailable: () => safeStorage.isEncryptionAvailable(), secureStorageAvailable: () => safeStorage.isEncryptionAvailable(),
}); });
const gitValidator = new GitValidatorService({
git,
gitea,
diagnostics,
});
repositoryMonitor = new RepositoryMonitor({ repositoryMonitor = new RepositoryMonitor({
store, store,
git, git,
@@ -303,6 +308,7 @@ app
ssh, ssh,
updates, updates,
preflight, preflight,
gitValidator,
diagnostics, diagnostics,
audit, audit,
externalTools, externalTools,
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "forgeflow", "name": "forgeflow",
"version": "0.8.5", "version": "0.9.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "forgeflow", "name": "forgeflow",
"version": "0.8.5", "version": "0.9.0",
"dependencies": { "dependencies": {
"ssh2": "1.17.0" "ssh2": "1.17.0"
}, },
+6 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "forgeflow", "name": "forgeflow",
"version": "0.8.5", "version": "0.9.0",
"private": true, "private": true,
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.", "description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
"main": "main.cjs", "main": "main.cjs",
@@ -78,6 +78,11 @@
"docs/RELEASE_NOTES_0.8.3.md", "docs/RELEASE_NOTES_0.8.3.md",
"docs/RELEASE_NOTES_0.8.4.md", "docs/RELEASE_NOTES_0.8.4.md",
"docs/RELEASE_NOTES_0.8.5.md", "docs/RELEASE_NOTES_0.8.5.md",
"docs/RELEASE_NOTES_0.8.6.md",
"docs/RELEASE_NOTES_0.8.7.md",
"docs/RELEASE_NOTES_0.8.8.md",
"docs/RELEASE_NOTES_0.8.9.md",
"docs/RELEASE_NOTES_0.9.0.md",
"docs/ACCEPTANCE.md" "docs/ACCEPTANCE.md"
], ],
"asarUnpack": [ "asarUnpack": [
+31 -6
View File
@@ -22,7 +22,9 @@ function subscribe(channel, listener) {
return () => ipcRenderer.removeListener(channel, handler); return () => ipcRenderer.removeListener(channel, handler);
} }
contextBridge.exposeInMainWorld('forgeflow', Object.freeze({ contextBridge.exposeInMainWorld(
'forgeflow',
Object.freeze({
bootstrap: () => invoke('app:bootstrap'), bootstrap: () => invoke('app:bootstrap'),
selectDirectory: (payload) => invoke('dialog:select-directory', payload), selectDirectory: (payload) => invoke('dialog:select-directory', payload),
selectKeyFile: (payload) => invoke('dialog:select-key-file', payload), selectKeyFile: (payload) => invoke('dialog:select-key-file', payload),
@@ -46,7 +48,12 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({
deleteServer: (serverId) => invoke('server:delete', { serverId }), deleteServer: (serverId) => invoke('server:delete', { serverId }),
testServer: (serverId) => invoke('server:test', { serverId }), testServer: (serverId) => invoke('server:test', { serverId }),
inspectServerProject: (repository, profileId) => invoke('server:inspect-project', { repository, profileId }), inspectServerProject: (repository, profileId) => invoke('server:inspect-project', { repository, profileId }),
discoverExistingDeployment: (repository, serverId, remoteFolder) => invoke('server:discover-existing', { repository, serverId, remoteFolder }), discoverExistingDeployment: (repository, serverId, remoteFolder) =>
invoke('server:discover-existing', {
repository,
serverId,
remoteFolder,
}),
refreshRepositories: () => invoke('repositories:refresh'), refreshRepositories: () => invoke('repositories:refresh'),
discoverRepositories: (roots) => invoke('repositories:discover', { roots }), discoverRepositories: (roots) => invoke('repositories:discover', { roots }),
favoriteRepository: (fullName, favorite) => invoke('repository:favorite', { fullName, favorite }), favoriteRepository: (fullName, favorite) => invoke('repository:favorite', { fullName, favorite }),
@@ -57,7 +64,12 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({
repositoryDiffHunks: (localPath, filePath) => invoke('repository:diff-hunks', { localPath, filePath }), repositoryDiffHunks: (localPath, filePath) => invoke('repository:diff-hunks', { localPath, filePath }),
stageHunks: (localPath, filePath, hunkIndexes) => invoke('repository:stage-hunks', { localPath, filePath, hunkIndexes }), stageHunks: (localPath, filePath, hunkIndexes) => invoke('repository:stage-hunks', { localPath, filePath, hunkIndexes }),
conflictState: (localPath) => invoke('repository:conflicts', { localPath }), conflictState: (localPath) => invoke('repository:conflicts', { localPath }),
resolveConflict: (localPath, filePath, resolution) => invoke('repository:resolve-conflict', { localPath, filePath, resolution }), resolveConflict: (localPath, filePath, resolution) =>
invoke('repository:resolve-conflict', {
localPath,
filePath,
resolution,
}),
continueGitOperation: (localPath) => invoke('repository:continue-operation', { localPath }), continueGitOperation: (localPath) => invoke('repository:continue-operation', { localPath }),
abortGitOperation: (localPath) => invoke('repository:abort-operation', { localPath }), abortGitOperation: (localPath) => invoke('repository:abort-operation', { localPath }),
stageFiles: (localPath, files) => invoke('repository:stage', { localPath, files }), stageFiles: (localPath, files) => invoke('repository:stage', { localPath, files }),
@@ -95,10 +107,20 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({
saveDeploymentProfile: (fullName, profile) => invoke('deployment:save-profile', { fullName, profile }), saveDeploymentProfile: (fullName, profile) => invoke('deployment:save-profile', { fullName, profile }),
deploymentPreflight: (repository, profileId) => invoke('deployment:preflight', { repository, profileId }), deploymentPreflight: (repository, profileId) => invoke('deployment:preflight', { repository, profileId }),
deleteDeploymentProfile: (fullName, profileId) => invoke('deployment:delete-profile', { fullName, profileId }), deleteDeploymentProfile: (fullName, profileId) => invoke('deployment:delete-profile', { fullName, profileId }),
deploy: (repository, profileId, sha, options = {}) => invoke('deployment:dispatch', { repository, profileId, sha, note: options.note || '', override: options.override === true, overrideReason: options.overrideReason || '' }), deploy: (repository, profileId, sha, options = {}) =>
invoke('deployment:dispatch', {
repository,
profileId,
sha,
note: options.note || '',
override: options.override === true,
overrideReason: options.overrideReason || '',
}),
rollback: (repository, profileId, targetSha) => invoke('deployment:rollback', { repository, profileId, targetSha }), rollback: (repository, profileId, targetSha) => invoke('deployment:rollback', { repository, profileId, targetSha }),
healthcheck: (url) => invoke('deployment:health', { url }), healthcheck: (url) => invoke('deployment:health', { url }),
refreshProfileState: (fullName, profileId) => invoke('deployment:profile-state', { fullName, profileId }), refreshProfileState: (fullName, profileId) => invoke('deployment:profile-state', { fullName, profileId }),
discoverServerDeployments: () => invoke('deployment:discover-server-workloads'),
linkServerWorkload: (repository, serverId, workloadId, deploymentMode = 'push-bundle', remoteFolder = '') => invoke('deployment:link-server-workload', { repository, serverId, workloadId, deploymentMode, remoteFolder }),
applyDockerManMetadata: (repository, profileId) => invoke('deployment:apply-dockerman-metadata', { repository, profileId }), applyDockerManMetadata: (repository, profileId) => invoke('deployment:apply-dockerman-metadata', { repository, profileId }),
reconcileDeployment: (fullName, profileId) => invoke('deployment:reconcile', { fullName, profileId }), reconcileDeployment: (fullName, profileId) => invoke('deployment:reconcile', { fullName, profileId }),
refreshOperations: (operationId = null) => invoke('operations:refresh', { operationId }), refreshOperations: (operationId = null) => invoke('operations:refresh', { operationId }),
@@ -106,6 +128,8 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({
troubleshooterScan: (fullName = null) => invoke('troubleshooter:scan', { fullName }), troubleshooterScan: (fullName = null) => invoke('troubleshooter:scan', { fullName }),
troubleshooterRepair: (issue) => invoke('troubleshooter:repair', { issue }), troubleshooterRepair: (issue) => invoke('troubleshooter:repair', { issue }),
troubleshooterAutoRepair: (issues) => invoke('troubleshooter:auto-repair', { issues }), troubleshooterAutoRepair: (issues) => invoke('troubleshooter:auto-repair', { issues }),
gitValidatorScan: (fullName) => invoke('git-validator:scan', { fullName }),
gitValidatorRepair: (fullName, check) => invoke('git-validator:repair', { fullName, check }),
diagnosticsStatus: () => invoke('diagnostics:status'), diagnosticsStatus: () => invoke('diagnostics:status'),
clearDiagnostics: () => invoke('diagnostics:clear'), clearDiagnostics: () => invoke('diagnostics:clear'),
openDiagnosticsFolder: () => invoke('diagnostics:open-folder'), openDiagnosticsFolder: () => invoke('diagnostics:open-folder'),
@@ -115,5 +139,6 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({
onRepositoriesChanged: (listener) => subscribe('repositories:changed', listener), onRepositoriesChanged: (listener) => subscribe('repositories:changed', listener),
onOperationsChanged: (listener) => subscribe('operations:changed', listener), onOperationsChanged: (listener) => subscribe('operations:changed', listener),
onUpdatesChanged: (listener) => subscribe('updates:changed', listener), onUpdatesChanged: (listener) => subscribe('updates:changed', listener),
reset: () => invoke('app:reset') reset: () => invoke('app:reset'),
})); }),
);
+22 -2
View File
@@ -27,6 +27,7 @@ const required = [
"src/main/repository-monitor.cjs", "src/main/repository-monitor.cjs",
"src/main/deployment-service.cjs", "src/main/deployment-service.cjs",
"src/main/unraid-deployment-service.cjs", "src/main/unraid-deployment-service.cjs",
"src/main/server-inventory.cjs",
"src/main/ssh-service.cjs", "src/main/ssh-service.cjs",
"src/main/update-service.cjs", "src/main/update-service.cjs",
"src/main/diagnostics-service.cjs", "src/main/diagnostics-service.cjs",
@@ -66,6 +67,11 @@ const required = [
"docs/RELEASE_NOTES_0.8.3.md", "docs/RELEASE_NOTES_0.8.3.md",
"docs/RELEASE_NOTES_0.8.4.md", "docs/RELEASE_NOTES_0.8.4.md",
"docs/RELEASE_NOTES_0.8.5.md", "docs/RELEASE_NOTES_0.8.5.md",
"docs/RELEASE_NOTES_0.8.6.md",
"docs/RELEASE_NOTES_0.8.7.md",
"docs/RELEASE_NOTES_0.8.8.md",
"docs/RELEASE_NOTES_0.8.9.md",
"docs/RELEASE_NOTES_0.9.0.md",
"docs/UPDATING.md", "docs/UPDATING.md",
"docs/DIAGNOSTICS.md", "docs/DIAGNOSTICS.md",
"docs/DEPLOYMENT_SETUP.md", "docs/DEPLOYMENT_SETUP.md",
@@ -104,9 +110,9 @@ for (const file of required) await access(path.join(root, file));
const packageJson = JSON.parse( const packageJson = JSON.parse(
await readFile(path.join(root, "package.json"), "utf8"), await readFile(path.join(root, "package.json"), "utf8"),
); );
if (packageJson.version !== "0.8.5") if (packageJson.version !== "0.9.0")
throw new Error( throw new Error(
`Expected package version 0.8.5, got ${packageJson.version}.`, `Expected package version 0.9.0, got ${packageJson.version}.`,
); );
const sourceManifest = await readFile( const sourceManifest = await readFile(
path.join(root, "SOURCE_MANIFEST.txt"), path.join(root, "SOURCE_MANIFEST.txt"),
@@ -341,10 +347,21 @@ for (const channel of [
"repository:repair-sync", "repository:repair-sync",
"deployment:apply-dockerman-metadata", "deployment:apply-dockerman-metadata",
"deployment:reconcile", "deployment:reconcile",
"deployment:link-server-workload",
]) { ]) {
if (!ipc.includes(channel)) if (!ipc.includes(channel))
throw new Error(`IPC registration is missing: ${channel}`); throw new Error(`IPC registration is missing: ${channel}`);
} }
const release090 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.0.md"), "utf8");
for (const phrase of [
"Push bundle",
"manual wizard",
"Monitor only",
"DockerMan templates",
"SHA-256",
]) {
if (!release090.includes(phrase)) throw new Error(`0.9.0 release notes are missing: ${phrase}`);
}
const gitSource = await readFile( const gitSource = await readFile(
path.join(root, "src/main/git-service.cjs"), path.join(root, "src/main/git-service.cjs"),
"utf8", "utf8",
@@ -375,6 +392,9 @@ for (const phrase of [
"iconCacheRefresh", "iconCacheRefresh",
"[PORT:", "[PORT:",
"Superseded by live commit", "Superseded by live commit",
"pushBundleScript",
"linkServerWorkload",
"deploymentMode",
]) { ]) {
if (!unraidSource.includes(phrase)) if (!unraidSource.includes(phrase))
throw new Error(`Unraid recovery implementation is missing: ${phrase}`); throw new Error(`Unraid recovery implementation is missing: ${phrase}`);
+58 -12
View File
@@ -4,10 +4,10 @@ const fs = require('node:fs/promises');
const path = require('node:path'); const path = require('node:path');
const crypto = require('node:crypto'); const crypto = require('node:crypto');
const { safeStorage } = require('electron'); const { safeStorage } = require('electron');
const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePaths } = require('../shared/validation.cjs'); const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePath, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
const DEFAULT_CONFIG = { const DEFAULT_CONFIG = {
schemaVersion: 8, schemaVersion: 9,
setupComplete: false, setupComplete: false,
appearance: 'dark', appearance: 'dark',
gitea: { baseUrl: '', user: null, encryptedToken: null }, gitea: { baseUrl: '', user: null, encryptedToken: null },
@@ -76,7 +76,28 @@ class ConfigStore {
: iconFilePath ? 'upload' : iconUrl && !/itworx\.tech\/assets\/itworx-icon\.png/i.test(iconUrl) ? 'url' : 'builtin'; : iconFilePath ? 'upload' : iconUrl && !/itworx\.tech\/assets\/itworx-icon\.png/i.test(iconUrl) ? 'url' : 'builtin';
const visibleName = String(profile.containerName || profile.remoteFolder || '').trim(); const visibleName = String(profile.containerName || profile.remoteFolder || '').trim();
const internalService = String(profile.composeService || profile.remoteFolder || 'app').trim().toLowerCase().replace(/[^a-z0-9._-]/g, '-') || 'app'; const internalService = String(profile.composeService || profile.remoteFolder || 'app').trim().toLowerCase().replace(/[^a-z0-9._-]/g, '-') || 'app';
return { ...profile, composeService: internalService, containerName: visibleName || internalService, iconMode }; const requestedDeploymentMode = String(profile.deploymentMode || '').trim();
const deploymentMode = ['push-bundle', 'server-git', 'monitor-only'].includes(requestedDeploymentMode)
? requestedDeploymentMode
: 'server-git';
const composeFiles = uniqueStrings(profile.composeFiles || [profile.composeFile || 'docker-compose.yml']);
const composeServices = uniqueStrings(profile.composeServices || [internalService]).map((value) => value.toLowerCase());
return {
...profile,
deploymentMode,
composeFile: composeFiles[0] || 'docker-compose.yml',
composeFiles: composeFiles.length ? composeFiles : ['docker-compose.yml'],
composeServices,
composeProject: String(profile.composeProject || '').trim(),
composeWorkingDir: String(profile.composeWorkingDir || '').trim(),
composeService: internalService,
containerName: visibleName || internalService,
iconMode,
manageDockerMan: profile.manageDockerMan === true,
forceRecreate: profile.forceRecreate === true,
removeOrphans: profile.removeOrphans === true,
workloadIdentity: profile.workloadIdentity && typeof profile.workloadIdentity === 'object' ? structuredClone(profile.workloadIdentity) : null
};
})])) })]))
: {}, : {},
deploymentStates: source.deploymentStates && typeof source.deploymentStates === 'object' ? source.deploymentStates : {}, deploymentStates: source.deploymentStates && typeof source.deploymentStates === 'object' ? source.deploymentStates : {},
@@ -223,10 +244,13 @@ class ConfigStore {
async deleteServer(serverId) { async deleteServer(serverId) {
this.data.servers = this.data.servers.filter((item) => item.id !== serverId); this.data.servers = this.data.servers.filter((item) => item.id !== serverId);
const removedProfileIds = new Set();
for (const [key, profiles] of Object.entries(this.data.deploymentProfiles)) { for (const [key, profiles] of Object.entries(this.data.deploymentProfiles)) {
for (const profile of profiles) if (profile.serverId === serverId) removedProfileIds.add(profile.id);
this.data.deploymentProfiles[key] = profiles.filter((profile) => profile.serverId !== serverId); this.data.deploymentProfiles[key] = profiles.filter((profile) => profile.serverId !== serverId);
if (!this.data.deploymentProfiles[key].length) delete this.data.deploymentProfiles[key]; if (!this.data.deploymentProfiles[key].length) delete this.data.deploymentProfiles[key];
} }
for (const profileId of removedProfileIds) delete this.data.deploymentStates[profileId];
await this.save(); await this.save();
} }
@@ -357,21 +381,39 @@ class ConfigStore {
inputs: {} inputs: {}
}; };
if (provider === 'ssh-unraid') { if (provider === 'ssh-unraid') {
const remoteFolder = String(profile.remoteFolder || '').trim(); const remoteFolder = assertRepositoryRelativePath(String(profile.remoteFolder || '').trim());
if (!remoteFolder || !/^[a-zA-Z0-9._-]+$/.test(remoteFolder)) throw new Error('Remote folder must contain only letters, numbers, dots, underscores and dashes.'); if (!remoteFolder || remoteFolder === '.' || remoteFolder.split('/').some((part) => !part || part === '.')) throw new Error('Remote folder must be a safe path relative to the configured server base path.');
const preservePaths = assertRepositoryRelativePaths(uniqueStrings(profile.preservePaths || ['.env', 'appdata', 'data', 'logs', 'config', 'compose.override.yml'])); const preservePaths = assertRepositoryRelativePaths(uniqueStrings(profile.preservePaths || ['.env', 'appdata', 'data', 'logs', 'config', 'compose.override.yml']));
const composeFiles = assertRepositoryRelativePaths(uniqueStrings(profile.composeFiles || [profile.composeFile || 'docker-compose.yml']));
if (!composeFiles.length && profile.generatedCompose !== true) throw new Error('Select at least one Compose file.');
const composeService = (() => {
const value = String(profile.composeService || profile.composeServices?.[0] || remoteFolder.split('/').pop()).trim().toLowerCase();
if (!/^[a-z0-9._-]+$/.test(value)) throw new Error('Compose service must be lowercase and contain only letters, numbers, dots, underscores and dashes.');
return value;
})();
const composeServices = uniqueStrings(profile.composeServices || [composeService]).map((value) => {
const normalized = String(value).trim().toLowerCase();
if (!/^[a-z0-9._-]+$/.test(normalized)) throw new Error('Compose services must be lowercase and contain only letters, numbers, dots, underscores and dashes.');
return normalized;
});
const composeProject = String(profile.composeProject || '').trim();
if (composeProject && !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(composeProject)) throw new Error('Compose project name contains unsupported characters.');
const composeWorkingDir = String(profile.composeWorkingDir || '').trim();
if (composeWorkingDir && (!composeWorkingDir.startsWith('/') || /[\r\n\0]/.test(composeWorkingDir))) throw new Error('Compose working directory must be an absolute safe Unix path.');
const deploymentMode = ['push-bundle', 'server-git', 'monitor-only'].includes(profile.deploymentMode) ? profile.deploymentMode : 'push-bundle';
return { return {
...common, ...common,
serverId: String(profile.serverId || '').trim(), serverId: String(profile.serverId || '').trim(),
remoteFolder, remoteFolder,
composeFile: String(profile.composeFile || 'docker-compose.yml').trim(), deploymentMode,
composeService: (() => { composeFile: composeFiles[0] || 'docker-compose.yml',
const value = String(profile.composeService || remoteFolder).trim().toLowerCase(); composeFiles: composeFiles.length ? composeFiles : ['docker-compose.yml'],
if (!/^[a-z0-9._-]+$/.test(value)) throw new Error('Compose service must be lowercase and contain only letters, numbers, dots, underscores and dashes.'); composeProject,
return value; composeWorkingDir,
})(), composeService,
composeServices,
containerName: (() => { containerName: (() => {
const value = String(profile.containerName || remoteFolder).trim(); const value = String(profile.containerName || remoteFolder.split('/').pop()).trim();
if (!/^[A-Za-z0-9._-]+$/.test(value)) throw new Error('Container name must contain only letters, numbers, dots, underscores and dashes.'); if (!/^[A-Za-z0-9._-]+$/.test(value)) throw new Error('Container name must contain only letters, numbers, dots, underscores and dashes.');
return value; return value;
})(), })(),
@@ -390,6 +432,10 @@ class ConfigStore {
generatedCompose: profile.generatedCompose === true, generatedCompose: profile.generatedCompose === true,
adoptedFromServer: profile.adoptedFromServer === true, adoptedFromServer: profile.adoptedFromServer === true,
serverSourceOfTruth: profile.serverSourceOfTruth === true, serverSourceOfTruth: profile.serverSourceOfTruth === true,
manageDockerMan: profile.manageDockerMan === true,
forceRecreate: profile.forceRecreate === true,
removeOrphans: profile.removeOrphans === true,
workloadIdentity: profile.workloadIdentity && typeof profile.workloadIdentity === 'object' ? structuredClone(profile.workloadIdentity) : null,
detectedAt: profile.detectedAt || null, detectedAt: profile.detectedAt || null,
provenance: profile.provenance && typeof profile.provenance === 'object' ? structuredClone(profile.provenance) : {}, provenance: profile.provenance && typeof profile.provenance === 'object' ? structuredClone(profile.provenance) : {},
detectedMetadata: profile.detectedMetadata && typeof profile.detectedMetadata === 'object' ? structuredClone(profile.detectedMetadata) : {}, detectedMetadata: profile.detectedMetadata && typeof profile.detectedMetadata === 'object' ? structuredClone(profile.detectedMetadata) : {},
+416
View File
@@ -0,0 +1,416 @@
"use strict";
const fs = require("node:fs/promises");
const path = require("node:path");
const { run } = require("./process-runner.cjs");
const { normalizeRemoteUrl } = require("../shared/repository-match.cjs");
const RECOMMENDED_GITIGNORE = `# Local configuration and secrets
.env
.env.*
!.env.example
!.env.sample
# Dependencies and generated output
node_modules/
dist/
build/
coverage/
# Editors and operating systems
.idea/
.vscode/
.DS_Store
Thumbs.db
`;
function sameRemote(left, right) {
const a = normalizeRemoteUrl(left);
const b = normalizeRemoteUrl(right);
return Boolean(a && b && a.host === b.host && a.path === b.path);
}
function result(id, category, title, status, detail, options = {}) {
return {
id,
category,
title,
status,
detail,
weight: options.weight || 5,
fixAction: options.fixAction || null,
safe: options.safe === true,
confirmation: options.confirmation || null,
};
}
function isSensitiveTrackedPath(filePath) {
const value = String(filePath || "")
.replace(/\\/g, "/")
.toLowerCase();
if (/\.env\.(example|sample|template)$/.test(value)) return false;
return (
/(^|\/)\.env($|\.)/.test(value) ||
/(^|\/)(id_rsa|id_ed25519)$/.test(value) ||
/\.(pem|p12|pfx|key)$/.test(value) ||
/(^|\/)(credentials|secrets?)(\.[^/]+)?\.(json|ya?ml)$/.test(value)
);
}
class GitValidatorService {
constructor({ git, gitea, diagnostics }) {
this.git = git;
this.gitea = gitea;
this.diagnostics = diagnostics;
}
async config(root, key, { local = true } = {}) {
const response = await run(
"git",
["config", ...(local ? ["--local"] : []), "--get", key],
{
cwd: root,
timeout: 10_000,
allowExitCodes: [1],
},
);
return response.stdout.trim();
}
async trackedFiles(root) {
const response = await run("git", ["ls-files", "-z"], {
cwd: root,
timeout: 30_000,
maxBuffer: 16 * 1024 * 1024,
});
return response.stdout.split("\0").filter(Boolean);
}
async scan(repository) {
const checks = [];
const defaultBranch = repository.defaultBranch || "main";
const owner = repository.owner?.login;
try {
const protection = await this.gitea.getBranchProtection(
owner,
repository.name,
defaultBranch,
);
checks.push(
result(
"default-branch-protection",
"Gitea governance",
"Default branch protection",
protection.protected ? "pass" : "warning",
protection.protected
? `${defaultBranch} is protected; force push is ${protection.enableForcePush ? "allowed" : "blocked"}.`
: `${defaultBranch} accepts unprotected direct changes.`,
{
weight: 18,
fixAction: protection.protected ? null : "protect-default-branch",
safe: false,
confirmation: `Protect ${defaultBranch} on Gitea and block direct and force pushes?`,
},
),
);
if (protection.protected)
checks.push(
result(
"force-push",
"Gitea governance",
"Force-push protection",
protection.enableForcePush ? "warning" : "pass",
protection.enableForcePush
? "Force pushes remain enabled on the protected branch."
: "Force pushes are blocked on the protected branch.",
{ weight: 8 },
),
);
} catch (error) {
checks.push(
result(
"branch-protection-unavailable",
"Gitea governance",
"Branch protection could not be verified",
"warning",
error.message,
{ weight: 18 },
),
);
}
if (!repository.localPath) {
checks.push(
result(
"local-link",
"Local repository",
"Local working tree",
"warning",
"Link or clone this repository to validate files and local Git configuration.",
{ weight: 35 },
),
);
return this.summarize(repository, checks);
}
const root = await this.git.ensureRepository(repository.localPath);
const status = await this.git.status(root);
const tracked = await this.trackedFiles(root);
const lowerFiles = tracked.map((file) => file.toLowerCase());
const desiredRemote =
repository.preferredCloneUrl || repository.cloneUrl || repository.sshUrl;
checks.push(
result(
"origin",
"Repository identity",
"Origin matches Gitea",
sameRemote(status.remoteUrl, desiredRemote) ? "pass" : "error",
sameRemote(status.remoteUrl, desiredRemote)
? status.remoteUrl
: `Current origin ${status.remoteUrl || "is missing"}; expected ${desiredRemote}.`,
{
weight: 15,
fixAction: sameRemote(status.remoteUrl, desiredRemote)
? null
: "align-origin",
safe: true,
},
),
);
checks.push(
result(
"upstream",
"Branch hygiene",
"Current branch has an upstream",
status.branch?.upstream ? "pass" : "warning",
status.branch?.upstream
? `${status.branch.head} tracks ${status.branch.upstream}.`
: `${status.branch?.head || "The current branch"} is not published or tracked.`,
{ weight: 8 },
),
);
checks.push(
result(
"working-tree",
"Branch hygiene",
"Working tree is intentional",
status.clean ? "pass" : "warning",
status.clean
? "No uncommitted changes."
: `${status.counts.changed} changed file(s) require review, commit or stash.`,
{ weight: 5 },
),
);
const [userName, userEmail, fetchPrune, pullFf, autoStash] =
await Promise.all([
this.config(root, "user.name", { local: false }),
this.config(root, "user.email", { local: false }),
this.config(root, "fetch.prune"),
this.config(root, "pull.ff"),
this.config(root, "rebase.autoStash"),
]);
checks.push(
result(
"identity",
"Commit integrity",
"Repository author identity",
userName && userEmail ? "pass" : "warning",
userName && userEmail
? `${userName} <${userEmail}>`
: "The effective Git user.name or user.email is missing.",
{ weight: 7 },
),
);
const safetyReady =
fetchPrune === "true" && pullFf === "only" && autoStash === "true";
checks.push(
result(
"local-safety",
"Local configuration",
"Safe synchronization defaults",
safetyReady ? "pass" : "warning",
safetyReady
? "Stale remotes are pruned, pulls are fast-forward-only and rebase autostash is enabled."
: "Recommended repository-local fetch, pull and autostash safeguards are incomplete.",
{
weight: 10,
fixAction: safetyReady ? null : "configure-local-safety",
safe: true,
},
),
);
const hasReadme = lowerFiles.some((file) =>
/(^|\/)readme(\.[^/]+)?$/.test(file),
);
checks.push(
result(
"readme",
"Repository documentation",
"README is versioned",
hasReadme ? "pass" : "warning",
hasReadme
? "Repository purpose and usage can be documented at the source."
: "No tracked README was found.",
{ weight: 7 },
),
);
const hasGitignore = lowerFiles.includes(".gitignore");
checks.push(
result(
"gitignore",
"Repository hygiene",
".gitignore is versioned",
hasGitignore ? "pass" : "warning",
hasGitignore
? "Generated and local-only files can be excluded centrally."
: "No tracked .gitignore was found.",
{
weight: 8,
fixAction: hasGitignore ? null : "add-gitignore",
safe: false,
confirmation:
"Create a recommended .gitignore in the working tree? It will remain uncommitted for review.",
},
),
);
const sensitive = tracked.filter(isSensitiveTrackedPath);
checks.push(
result(
"tracked-secrets",
"Security",
"No secret-shaped files are tracked",
sensitive.length ? "error" : "pass",
sensitive.length
? `Review immediately: ${sensitive.slice(0, 8).join(", ")}${sensitive.length > 8 ? "…" : ""}. Removing a file does not erase Git history.`
: "No tracked environment, private-key or credential filenames were detected.",
{ weight: 22 },
),
);
const large = [];
const candidates = tracked.slice(0, 5000);
for (
let index = 0;
index < candidates.length && large.length < 12;
index += 64
) {
const batch = candidates.slice(index, index + 64);
const stats = await Promise.all(
batch.map(async (file) => ({
file,
stat: await fs.stat(path.join(root, file)).catch(() => null),
})),
);
for (const item of stats) {
if (item.stat?.isFile() && item.stat.size > 10 * 1024 * 1024)
large.push({ file: item.file, size: item.stat.size });
if (large.length >= 12) break;
}
}
checks.push(
result(
"large-files",
"Repository performance",
"No oversized tracked files",
large.length ? "warning" : "pass",
large.length
? `${large.map((item) => `${item.file} (${Math.ceil(item.size / 1024 / 1024)} MB)`).join(", ")}. Consider Git LFS.`
: "No tracked files above 10 MB were found.",
{ weight: 7 },
),
);
return this.summarize(repository, checks);
}
summarize(repository, checks) {
const totalWeight = checks.reduce((sum, check) => sum + check.weight, 0);
const earned = checks.reduce(
(sum, check) =>
sum +
(check.status === "pass"
? check.weight
: check.status === "warning"
? check.weight * 0.45
: 0),
0,
);
const score = totalWeight ? Math.round((earned / totalWeight) * 100) : 0;
return {
repository: repository.fullName,
checkedAt: new Date().toISOString(),
score,
grade:
score >= 90
? "Excellent"
: score >= 75
? "Good"
: score >= 55
? "Needs attention"
: "High risk",
checks,
summary: {
passed: checks.filter((check) => check.status === "pass").length,
warnings: checks.filter((check) => check.status === "warning").length,
errors: checks.filter((check) => check.status === "error").length,
repairable: checks.filter((check) => check.fixAction).length,
},
};
}
async repair(repository, check) {
if (!check?.fixAction)
throw new Error("This validator check has no repair action.");
const root = repository.localPath
? await this.git.ensureRepository(repository.localPath)
: null;
if (check.fixAction === "align-origin") {
return this.git.setRemoteUrl(
root,
repository.preferredCloneUrl ||
repository.cloneUrl ||
repository.sshUrl,
);
}
if (check.fixAction === "configure-local-safety") {
for (const [key, value] of [
["fetch.prune", "true"],
["pull.ff", "only"],
["rebase.autoStash", "true"],
])
await run("git", ["config", "--local", key, value], {
cwd: root,
timeout: 10_000,
});
return { configured: true };
}
if (check.fixAction === "add-gitignore") {
const target = path.join(root, ".gitignore");
const exists = await fs.stat(target).catch(() => null);
if (exists)
throw new Error(".gitignore already exists; rescan before repairing.");
await fs.writeFile(target, RECOMMENDED_GITIGNORE, {
encoding: "utf8",
flag: "wx",
});
return { created: ".gitignore" };
}
if (check.fixAction === "protect-default-branch") {
return this.gitea.createBranchProtection(
repository.owner.login,
repository.name,
repository.defaultBranch || "main",
);
}
throw new Error("Unsupported Git Validator repair action.");
}
}
module.exports = {
GitValidatorService,
RECOMMENDED_GITIGNORE,
sameRemote,
isSensitiveTrackedPath,
};
+27 -2
View File
@@ -204,6 +204,28 @@ class GiteaService {
}; };
} }
async createBranchProtection(owner, repo, branch) {
const target = assertBranchName(branch);
return (
await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branch_protections`,
{
method: "POST",
body: {
rule_name: target,
branch_name: target,
enable_push: false,
enable_force_push: false,
required_approvals: 0,
dismiss_stale_approvals: true,
block_on_rejected_reviews: true,
block_on_outdated_branch: true,
},
},
)
).data;
}
async listPullRequests({ owner, repo, state = "open", limit = 30 } = {}) { async listPullRequests({ owner, repo, state = "open", limit = 30 } = {}) {
const query = new URLSearchParams({ const query = new URLSearchParams({
state, state,
@@ -331,11 +353,14 @@ class GiteaService {
throw new Error("The update download exceeded the redirect limit."); throw new Error("The update download exceeded the redirect limit.");
} }
async downloadReleaseAsset(owner, repo, assetId, options = {}) { async downloadReleaseAsset(owner, repo, releaseId, assetId, options = {}) {
const numericReleaseId = Number(releaseId);
const numericId = Number(assetId); const numericId = Number(assetId);
if (!Number.isSafeInteger(numericReleaseId) || numericReleaseId <= 0)
throw new Error("Gitea returned an invalid release ID.");
if (!Number.isSafeInteger(numericId) || numericId <= 0) if (!Number.isSafeInteger(numericId) || numericId <= 0)
throw new Error("Gitea returned an invalid release asset ID."); throw new Error("Gitea returned an invalid release asset ID.");
const assetPath = `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/assets/${numericId}`; const assetPath = `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${numericReleaseId}/assets/${numericId}`;
return this.downloadAuthenticated(assetPath, options); return this.downloadAuthenticated(assetPath, options);
} }
+87 -3
View File
@@ -87,6 +87,7 @@ function registerIpc({
ssh, ssh,
updates, updates,
preflight, preflight,
gitValidator,
diagnostics, diagnostics,
audit, audit,
externalTools, externalTools,
@@ -449,6 +450,7 @@ function registerIpc({
register( register(
"server:save", "server:save",
async ({ server, password = "", passphrase = "" }) => { async ({ server, password = "", passphrase = "" }) => {
await ssh.validateServerConfiguration(server, { password, passphrase });
const saved = await store.saveServer(server, { password, passphrase }); const saved = await store.saveServer(server, { password, passphrase });
await diagnostics.info("server.saved", { await diagnostics.info("server.saved", {
serverId: saved.id, serverId: saved.id,
@@ -841,6 +843,40 @@ function registerIpc({
return true; 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",
"protect-default-branch",
]);
if (!allowed.has(check?.fixAction))
throw new Error("Unsupported Git Validator repair request.");
const result = await gitValidator.repair(repository, check);
await audit.append("git-validator.repair", {
repository: repository.fullName,
checkId: check.id,
action: check.fixAction,
});
await diagnostics.info("git-validator.repair.completed", {
repository: repository.fullName,
checkId: check.id,
action: check.fixAction,
});
return result;
});
register("troubleshooter:scan", async ({ fullName = null }) => { register("troubleshooter:scan", async ({ fullName = null }) => {
const currentRepositories = await repositories.refresh(); const currentRepositories = await repositories.refresh();
const candidates = fullName const candidates = fullName
@@ -1140,10 +1176,58 @@ function registerIpc({
}, },
); );
register("deployment:health", ({ url }) => deployments.checkHealth(url)); register("deployment:health", ({ url }) => deployments.checkHealth(url));
register("deployment:profile-state", ({ fullName, profileId }) => { register("deployment:link-server-workload", async ({ repository, serverId, workloadId, deploymentMode = "push-bundle", remoteFolder = "" }) => {
const current = await resolveRepository(repository);
const result = await unraid.linkServerWorkload({
repository: current,
serverId,
workloadId,
deploymentMode,
remoteFolder,
});
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); const profile = store.getDeploymentProfile(fullName, profileId);
if (profile?.provider === "ssh-unraid") if (profile?.provider === "ssh-unraid") {
return unraid.refreshProfileState(fullName, profileId); 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); return deployments.refreshProfileState(fullName, profileId);
}); });
register( register(
+403
View File
@@ -0,0 +1,403 @@
'use strict';
const crypto = require('node:crypto');
const path = require('node:path').posix;
const { normalizeRemoteUrl } = require('../shared/repository-match.cjs');
function decodeBase64(value) {
try { return Buffer.from(String(value || ''), 'base64').toString('utf8'); }
catch { return ''; }
}
function remoteIdentity(value) {
const normalized = normalizeRemoteUrl(value);
return normalized ? `${normalized.host}/${normalized.path}` : '';
}
function normalizedName(value) {
return String(value || '').toLowerCase().replace(/\.git$/i, '').replace(/[^a-z0-9]/g, '');
}
function safeJson(value, fallback) {
try { return JSON.parse(value); }
catch { return fallback; }
}
function sanitizeLegacyContainer(container) {
const labels = container?.Config?.Labels || {};
return {
id: container?.Id || '',
name: String(container?.Name || '').replace(/^\//, ''),
image: container?.Config?.Image || '',
imageId: container?.Image || '',
running: container?.State?.Running === true,
status: container?.State?.Status || '',
health: container?.State?.Health?.Status || null,
labels: {
'com.docker.compose.project': labels['com.docker.compose.project'] || '',
'com.docker.compose.project.working_dir': labels['com.docker.compose.project.working_dir'] || '',
'com.docker.compose.project.config_files': labels['com.docker.compose.project.config_files'] || '',
'com.docker.compose.service': labels['com.docker.compose.service'] || '',
'org.opencontainers.image.source': labels['org.opencontainers.image.source'] || '',
'org.opencontainers.image.revision': labels['org.opencontainers.image.revision'] || '',
'tech.itworx.forgeflow.repository': labels['tech.itworx.forgeflow.repository'] || '',
'tech.itworx.forgeflow.commit': labels['tech.itworx.forgeflow.commit'] || '',
'tech.itworx.forgeflow.branch': labels['tech.itworx.forgeflow.branch'] || '',
'net.unraid.docker.webui': labels['net.unraid.docker.webui'] || '',
'net.unraid.docker.icon': labels['net.unraid.docker.icon'] || '',
'net.unraid.docker.shell': labels['net.unraid.docker.shell'] || '',
'net.unraid.docker.managed': labels['net.unraid.docker.managed'] || '',
},
ports: container?.NetworkSettings?.Ports || {},
mounts: Array.isArray(container?.Mounts) ? container.Mounts : [],
networks: container?.NetworkSettings?.Networks || {},
restartPolicy: container?.HostConfig?.RestartPolicy?.Name || '',
};
}
function parseServerInventory(output) {
const marker = '__FORGEFLOW_INVENTORY__';
const index = String(output || '').lastIndexOf(marker);
if (index < 0) throw new Error('The server did not return a ForgeFlow workload inventory.');
const inventory = {
capabilities: {},
checkouts: [],
containers: [],
dockerMan: [],
warnings: [],
};
for (const line of String(output).slice(index + marker.length).trim().split(/\r?\n/)) {
if (!line) continue;
const [kind, ...parts] = line.split('\t');
if (kind === 'H') {
inventory.capabilities = {
docker: parts[0] === 'true',
compose: parts[1] === 'true',
git: parts[2] === 'true',
tar: parts[3] === 'true',
checksum: parts[4] === 'true',
baseWritable: parts[5] === 'true',
composeVersion: decodeBase64(parts[6]),
platform: decodeBase64(parts[7]),
};
} else if (kind === 'R' && parts.length >= 4) {
inventory.checkouts.push({
root: decodeBase64(parts[0]),
remote: decodeBase64(parts[1]),
liveSha: parts[2] || '',
branch: decodeBase64(parts[3]),
});
} else if (kind === 'C' && parts[0]) {
const parsed = safeJson(decodeBase64(parts[0]), null);
if (!parsed) continue;
if (Array.isArray(parsed)) {
if (parsed[0]) inventory.containers.push(sanitizeLegacyContainer(parsed[0]));
} else if (parsed.Config || parsed.State) inventory.containers.push(sanitizeLegacyContainer(parsed));
else inventory.containers.push({
...parsed,
name: String(parsed.name || '').replace(/^\//, ''),
labels: parsed.labels && typeof parsed.labels === 'object' ? parsed.labels : {},
mounts: Array.isArray(parsed.mounts) ? parsed.mounts : [],
ports: parsed.ports && typeof parsed.ports === 'object' ? parsed.ports : {},
networks: parsed.networks && typeof parsed.networks === 'object' ? parsed.networks : {},
});
} else if (kind === 'D' && parts[0]) {
inventory.dockerMan.push({
name: decodeBase64(parts[0]),
templatePath: decodeBase64(parts[1]),
webUiUrl: decodeBase64(parts[2]),
iconUrl: decodeBase64(parts[3]),
shell: decodeBase64(parts[4]),
repository: decodeBase64(parts[5]),
network: decodeBase64(parts[6]),
});
} else if (kind === 'W') inventory.warnings.push(decodeBase64(parts[0]));
}
return inventory;
}
function configFilesFor(container) {
return String(container?.labels?.['com.docker.compose.project.config_files'] || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
function containerPorts(container) {
const ports = [];
for (const [containerKey, bindings] of Object.entries(container?.ports || {})) {
const [containerPortText, protocol = 'tcp'] = containerKey.split('/');
const containerPort = Number(containerPortText) || null;
if (Array.isArray(bindings) && bindings.length) {
for (const binding of bindings) ports.push({
hostIp: binding?.HostIp || '',
hostPort: Number(binding?.HostPort) || null,
containerPort,
protocol,
});
} else ports.push({ hostIp: '', hostPort: null, containerPort, protocol });
}
return ports;
}
function safeRelativeToBase(basePath, candidate) {
const base = String(basePath || '').replace(/\/+$/, '');
const value = String(candidate || '').replace(/\/+$/, '');
if (!base || !value || !value.startsWith(`${base}/`)) return '';
const relative = value.slice(base.length + 1).replace(/^\/+|\/+$/g, '');
if (!relative || relative.split('/').some((part) => !part || part === '.' || part === '..')) return '';
return relative;
}
function topLevelRelativeToBase(basePath, candidate) {
const relative = safeRelativeToBase(basePath, candidate);
return relative ? relative.split('/')[0] : '';
}
function workloadSelector(group) {
if (group.composeProject) return {
kind: 'compose',
composeProject: group.composeProject,
workingDir: group.workingDir || '',
configFiles: group.configFiles,
};
const dockerMan = group.dockerMan || null;
if (dockerMan?.templatePath) return {
kind: 'dockerman-container',
templatePath: dockerMan.templatePath,
containerName: group.containers[0]?.name || '',
};
return { kind: 'docker-container', containerName: group.containers[0]?.name || '' };
}
function stableWorkloadId(serverId, selector) {
return `workload-${crypto.createHash('sha256').update(`${serverId}:${JSON.stringify(selector)}`).digest('hex').slice(0, 24)}`;
}
function profileMatchesWorkload(profile, workload) {
if (!profile || profile.provider !== 'ssh-unraid' || profile.serverId !== workload.serverId) return false;
const identity = profile.workloadIdentity || {};
if (identity.workloadId && identity.workloadId === workload.workloadId) return true;
if (identity.selector && JSON.stringify(identity.selector) === JSON.stringify(workload.selector)) return true;
if (profile.composeProject && workload.compose?.project && profile.composeProject === workload.compose.project) {
if (!profile.composeWorkingDir || !workload.compose.workingDir || profile.composeWorkingDir === workload.compose.workingDir) return true;
}
return workload.containers.some((container) => container.name === profile.containerName);
}
function repositoryRemoteMap(repositories) {
const map = new Map();
for (const repository of repositories || []) {
for (const value of [repository.cloneUrl, repository.sshUrl, repository.htmlUrl, repository.preferredCloneUrl]) {
const id = remoteIdentity(value);
if (id) map.set(id, repository);
}
}
return map;
}
function candidateRepositories(workload, repositories, checkouts) {
const candidates = new Map();
const add = (repository, points, reason, exact = false) => {
if (!repository?.fullName) return;
const current = candidates.get(repository.fullName) || { repositoryFullName: repository.fullName, repositoryName: repository.name, score: 0, exact: false, reasons: [] };
current.score += points;
current.exact ||= exact;
if (reason && !current.reasons.includes(reason)) current.reasons.push(reason);
candidates.set(repository.fullName, current);
};
const remotes = repositoryRemoteMap(repositories);
const exactRemoteHints = new Set();
for (const container of workload.containers) {
const labels = container.labels || {};
for (const value of [labels['tech.itworx.forgeflow.repository'], labels['org.opencontainers.image.source']]) {
const id = remoteIdentity(value);
if (id) exactRemoteHints.add(id);
}
}
for (const checkout of checkouts || []) {
const root = String(checkout.root || '').replace(/\/+$/, '');
const matchesPath = root && (root === workload.compose.workingDir || workload.containers.some((container) => (container.mounts || []).some((mount) => {
const source = String(mount?.Source || '').replace(/\/+$/, '');
return source === root || source.startsWith(`${root}/`);
})));
if (matchesPath) {
const id = remoteIdentity(checkout.remote);
if (id) exactRemoteHints.add(id);
}
}
for (const id of exactRemoteHints) {
const repository = remotes.get(id);
if (repository) add(repository, 100, 'Exact repository provenance from container or server checkout', true);
}
const names = new Set([
workload.compose.project,
path.basename(workload.compose.workingDir || ''),
...workload.containers.map((container) => container.name),
...workload.containers.map((container) => String(container.image || '').split('/').pop()?.split(':')[0]),
].filter(Boolean).map(normalizedName));
for (const repository of repositories || []) {
const repoName = normalizedName(repository.name);
if (!repoName || !names.has(repoName)) continue;
add(repository, workload.compose.project && normalizedName(workload.compose.project) === repoName ? 35 : 20, 'Name similarity only; manual confirmation required');
}
return [...candidates.values()].sort((a, b) => b.score - a.score || a.repositoryFullName.localeCompare(b.repositoryFullName)).map((candidate) => ({
...candidate,
confidence: candidate.exact ? 'exact' : candidate.score >= 35 ? 'strong' : 'weak',
}));
}
function buildWorkloadInventory({ inventory, server, repositories = [], profiles = [] }) {
const dockerManByName = new Map((inventory.dockerMan || []).map((item) => [item.name, item]));
const groups = new Map();
for (const container of inventory.containers || []) {
const labels = container.labels || {};
const composeProject = String(labels['com.docker.compose.project'] || '').trim();
const workingDir = String(labels['com.docker.compose.project.working_dir'] || '').replace(/\/+$/, '');
const configFiles = configFilesFor(container);
const key = composeProject
? `compose:${composeProject}:${workingDir}:${configFiles.join('|')}`
: `container:${container.name}`;
const group = groups.get(key) || {
composeProject,
workingDir,
configFiles,
services: [],
containers: [],
dockerMan: null,
};
group.containers.push(container);
const service = String(labels['com.docker.compose.service'] || '').trim();
if (service && !group.services.includes(service)) group.services.push(service);
group.dockerMan ||= dockerManByName.get(container.name) || null;
groups.set(key, group);
}
const workloads = [];
for (const group of groups.values()) {
const selector = workloadSelector(group);
const workloadId = stableWorkloadId(server.id, selector);
const primary = group.containers.find((item) => item.running) || group.containers[0];
const ports = group.containers.flatMap(containerPorts);
const mounts = group.containers.flatMap((container) => container.mounts || []);
const remoteFolderCandidate = safeRelativeToBase(server.basePath, group.workingDir)
|| mounts.map((mount) => topLevelRelativeToBase(server.basePath, mount?.Source)).find(Boolean)
|| '';
const workload = {
workloadId,
serverId: server.id,
serverName: server.name,
kind: selector.kind,
selector,
displayName: group.composeProject || primary?.name || 'Unnamed workload',
compose: {
project: group.composeProject,
workingDir: group.workingDir,
configFiles: group.configFiles,
services: group.services,
},
containers: group.containers.map((container) => ({
id: container.id,
name: container.name,
image: container.image,
imageId: container.imageId,
running: container.running === true,
status: container.status || '',
health: container.health || null,
service: container.labels?.['com.docker.compose.service'] || '',
ports: containerPorts(container),
mounts: (container.mounts || []).map((mount) => ({
type: mount?.Type || '',
source: mount?.Source || '',
target: mount?.Destination || '',
readOnly: mount?.RW === false,
})),
networks: Object.keys(container.networks || {}),
restartPolicy: container.restartPolicy || '',
})),
dockerMan: group.dockerMan,
metadata: {
webUiUrl: primary?.labels?.['net.unraid.docker.webui'] || group.dockerMan?.webUiUrl || '',
iconUrl: primary?.labels?.['net.unraid.docker.icon'] || group.dockerMan?.iconUrl || '',
shell: primary?.labels?.['net.unraid.docker.shell'] || group.dockerMan?.shell || '/bin/sh',
sourceRepository: primary?.labels?.['tech.itworx.forgeflow.repository'] || primary?.labels?.['org.opencontainers.image.source'] || '',
liveRevision: primary?.labels?.['tech.itworx.forgeflow.commit'] || primary?.labels?.['org.opencontainers.image.revision'] || '',
branch: primary?.labels?.['tech.itworx.forgeflow.branch'] || '',
},
runtime: {
running: group.containers.some((container) => container.running === true),
allRunning: group.containers.every((container) => container.running === true),
health: group.containers.some((container) => container.health === 'unhealthy')
? 'unhealthy'
: group.containers.length && group.containers.every((container) => container.health === 'healthy')
? 'healthy'
: 'unverified',
ports,
},
remoteFolderCandidate,
observedAt: new Date().toISOString(),
};
const matchingCheckout = (inventory.checkouts || []).find((checkout) => {
const root = String(checkout.root || '').replace(/\/+$/, '');
if (!root) return false;
if (root === workload.compose.workingDir) return true;
return mounts.some((mount) => {
const source = String(mount?.Source || '').replace(/\/+$/, '');
return source === root || source.startsWith(`${root}/`);
});
});
if (matchingCheckout) {
workload.metadata.sourceRepository ||= matchingCheckout.remote || '';
workload.metadata.liveRevision ||= matchingCheckout.liveSha || '';
workload.metadata.branch ||= matchingCheckout.branch || '';
}
workload.candidates = candidateRepositories(workload, repositories, inventory.checkouts || []);
const linked = profiles.find((profile) => profileMatchesWorkload(profile, workload));
if (linked) {
workload.link = {
status: 'linked',
profileId: linked.id,
repositoryFullName: linked.repositoryFullName || linked._repositoryFullName || '',
source: linked.workloadIdentity?.linkSource || (linked.adoptedFromServer ? 'automatic' : 'manual'),
};
workload.status = 'linked';
} else if (workload.candidates.length === 1 && workload.candidates[0].exact) workload.status = 'exact-match';
else if (workload.candidates.length) workload.status = workload.candidates[1]?.score === workload.candidates[0]?.score ? 'ambiguous' : 'suggested';
else workload.status = 'unmatched';
workloads.push(workload);
}
workloads.sort((a, b) => Number(b.runtime.running) - Number(a.runtime.running) || a.displayName.localeCompare(b.displayName));
return workloads;
}
function inventoryContainerMatch(checkout, repository, container) {
const safe = container?.Config || container?.State ? sanitizeLegacyContainer(container) : container;
if (!safe?.running) return 0;
const labels = safe.labels || {};
const workingDir = String(labels['com.docker.compose.project.working_dir'] || '').replace(/\/$/, '');
const source = remoteIdentity(labels['org.opencontainers.image.source'] || labels['tech.itworx.forgeflow.repository'] || '');
const mounts = Array.isArray(safe.mounts) ? safe.mounts : [];
const root = String(checkout.root || '').replace(/\/$/, '');
const name = String(safe.name || '').replace(/^\//, '');
const project = String(labels['com.docker.compose.project'] || '');
const expectedNames = new Set([repository.name, root.split('/').pop()].filter(Boolean).map(normalizedName));
if (workingDir && workingDir === root) return 100;
if (mounts.some((mount) => {
const mountSource = String(mount.Source || '').replace(/\/$/, '');
return mountSource === root || mountSource.startsWith(`${root}/`);
})) return 90;
if (source && source === remoteIdentity(checkout.remote)) return 85;
if (expectedNames.has(normalizedName(project))) return 70;
if (expectedNames.has(normalizedName(name))) return 60;
return 0;
}
module.exports = {
parseServerInventory,
buildWorkloadInventory,
inventoryContainerMatch,
remoteIdentity,
stableWorkloadId,
profileMatchesWorkload,
sanitizeLegacyContainer,
safeRelativeToBase,
};
+185 -57
View File
@@ -1,11 +1,12 @@
'use strict'; 'use strict';
const fs = require('node:fs/promises'); const fs = require('node:fs');
const fsp = require('node:fs/promises');
const crypto = require('node:crypto'); const crypto = require('node:crypto');
const path = require('node:path').posix; const path = require('node:path').posix;
function loadSshClient() { function loadSshModule() {
try { return require('ssh2').Client; } try { return require('ssh2'); }
catch { catch {
const error = new Error('The ssh2 dependency is not installed. Run npm install before configuring SSH deployments.'); const error = new Error('The ssh2 dependency is not installed. Run npm install before configuring SSH deployments.');
error.code = 'SSH2_NOT_INSTALLED'; error.code = 'SSH2_NOT_INSTALLED';
@@ -13,6 +14,10 @@ function loadSshClient() {
} }
} }
function loadSshClient() {
return loadSshModule().Client;
}
function fingerprintKey(key) { function fingerprintKey(key) {
const buffer = Buffer.isBuffer(key) ? key : Buffer.from(key); const buffer = Buffer.isBuffer(key) ? key : Buffer.from(key);
return `SHA256:${crypto.createHash('sha256').update(buffer).digest('base64').replace(/=+$/, '')}`; return `SHA256:${crypto.createHash('sha256').update(buffer).digest('base64').replace(/=+$/, '')}`;
@@ -22,12 +27,65 @@ function shellQuote(value) {
return `'${String(value ?? '').replace(/'/g, `'\\''`)}'`; return `'${String(value ?? '').replace(/'/g, `'\\''`)}'`;
} }
function parseCapabilityOutput(output) {
const marker = '__FORGEFLOW_SERVER_TEST__';
const index = String(output || '').lastIndexOf(marker);
if (index < 0) return { platform: String(output || '').trim(), docker: false, dockerReady: false, compose: false, git: false, tar: false, checksum: false };
const fields = {};
for (const line of String(output).slice(index + marker.length).trim().split(/\r?\n/)) {
const separator = line.indexOf('=');
if (separator > 0) fields[line.slice(0, separator)] = line.slice(separator + 1);
}
const decode = (value) => {
try { return value ? Buffer.from(value, 'base64').toString('utf8') : ''; }
catch { return ''; }
};
return {
platform: decode(fields.platform),
docker: fields.docker === 'true',
dockerReady: fields.dockerReady === 'true',
compose: fields.compose === 'true',
composeVersion: decode(fields.composeVersion),
git: fields.git === 'true',
tar: fields.tar === 'true',
checksum: fields.checksum === 'true',
baseWritable: fields.baseWritable === 'true',
};
}
class SshService { class SshService {
constructor({ store, diagnostics }) { constructor({ store, diagnostics }) {
this.store = store; this.store = store;
this.diagnostics = diagnostics; this.diagnostics = diagnostics;
} }
async validateServerConfiguration(server, secrets = {}) {
if (server?.authType !== 'privateKey') return { valid: true, method: 'password' };
const privateKeyPath = String(server.privateKeyPath || '').trim();
if (!privateKeyPath) throw new Error('Select a private key file.');
const stat = await fsp.stat(privateKeyPath).catch(() => null);
if (!stat?.isFile()) {
const error = new Error(`The SSH private key file was not found: ${privateKeyPath}`);
error.code = 'SSH_PRIVATE_KEY_NOT_FOUND';
throw error;
}
const existing = server.id ? this.store.getServer(server.id) : null;
const sameKey = existing && String(existing.privateKeyPath || '') === privateKeyPath;
const storedPassphrase = sameKey ? this.store.getServerCredentials(existing.id).passphrase : '';
const passphrase = Object.prototype.hasOwnProperty.call(secrets, 'passphrase') && String(secrets.passphrase || '')
? String(secrets.passphrase)
: storedPassphrase;
const key = await fsp.readFile(privateKeyPath);
const parsed = loadSshModule().utils.parseKey(key, passphrase || undefined);
const errorResult = Array.isArray(parsed) ? parsed.find((item) => item instanceof Error) : parsed instanceof Error ? parsed : null;
if (errorResult) {
const error = new Error(`The selected file is not a usable SSH private key${passphrase ? ' with the supplied passphrase' : ''}: ${errorResult.message}`);
error.code = /encrypted|passphrase|decrypt/i.test(errorResult.message) ? 'SSH_PRIVATE_KEY_PASSPHRASE_INVALID' : 'SSH_PRIVATE_KEY_INVALID';
throw error;
}
return { valid: true, method: 'privateKey', encrypted: Boolean(passphrase), privateKeyPath };
}
async connectionOptions(server, { trustOnFirstUse = false } = {}) { async connectionOptions(server, { trustOnFirstUse = false } = {}) {
const credentials = this.store.getServerCredentials(server.id); const credentials = this.store.getServerCredentials(server.id);
let observedFingerprint = null; let observedFingerprint = null;
@@ -41,12 +99,16 @@ class SshService {
hostVerifier: (key) => { hostVerifier: (key) => {
observedFingerprint = fingerprintKey(key); observedFingerprint = fingerprintKey(key);
return trustOnFirstUse || Boolean(server.hostFingerprint && observedFingerprint === server.hostFingerprint); return trustOnFirstUse || Boolean(server.hostFingerprint && observedFingerprint === server.hostFingerprint);
} },
}; };
if (server.authType === 'password') { if (server.authType === 'password') options.password = credentials.password;
options.password = credentials.password; else {
} else { try { options.privateKey = await fsp.readFile(server.privateKeyPath); }
options.privateKey = await fs.readFile(server.privateKeyPath); catch (error) {
const wrapped = new Error(`Could not read SSH private key ${server.privateKeyPath}: ${error.message}`);
wrapped.code = 'SSH_PRIVATE_KEY_READ_FAILED';
throw wrapped;
}
if (credentials.passphrase) options.passphrase = credentials.passphrase; if (credentials.passphrase) options.passphrase = credentials.passphrase;
} }
return { options, getObservedFingerprint: () => observedFingerprint }; return { options, getObservedFingerprint: () => observedFingerprint };
@@ -70,24 +132,20 @@ class SshService {
client.once('ready', async () => { client.once('ready', async () => {
try { try {
const data = await action(client, server, connection.getObservedFingerprint()); const data = await action(client, server, connection.getObservedFingerprint());
await this.diagnostics?.debug('ssh.connection.completed', { await this.diagnostics?.debug('ssh.connection.completed', { serverId, host: server.host, durationMs: Date.now() - started });
serverId,
host: server.host,
durationMs: Date.now() - started
});
finish(resolve, data); finish(resolve, data);
} catch (error) { finish(reject, error); } } catch (error) { finish(reject, error); }
}); });
client.once('error', async (error) => { client.once('error', async (error) => {
const wrapped = new Error(`SSH connection failed: ${error.message}`); const observed = connection.getObservedFingerprint();
wrapped.code = error.code || 'SSH_CONNECTION_FAILED'; const mismatch = Boolean(server.hostFingerprint && observed && server.hostFingerprint !== observed);
await this.diagnostics?.warning('ssh.connection.failed', { const wrapped = new Error(mismatch
serverId, ? `SSH host identity changed. Expected ${server.hostFingerprint}, but the server presented ${observed}.`
host: server.host, : `SSH connection failed: ${error.message}`);
durationMs: Date.now() - started, wrapped.code = mismatch ? 'SSH_HOST_KEY_MISMATCH' : (error.code || 'SSH_CONNECTION_FAILED');
code: wrapped.code, wrapped.expectedFingerprint = mismatch ? server.hostFingerprint : undefined;
message: wrapped.message wrapped.observedFingerprint = mismatch ? observed : undefined;
}); await this.diagnostics?.warning('ssh.connection.failed', { serverId, host: server.host, durationMs: Date.now() - started, code: wrapped.code, message: wrapped.message });
finish(reject, wrapped); finish(reject, wrapped);
}); });
client.connect(connection.options); client.connect(connection.options);
@@ -96,96 +154,166 @@ class SshService {
execClient(client, command, { timeout = 15 * 60_000, maxOutput = 2 * 1024 * 1024 } = {}) { execClient(client, command, { timeout = 15 * 60_000, maxOutput = 2 * 1024 * 1024 } = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('The SSH command timed out.')), timeout); let completed = false;
const timer = setTimeout(() => {
if (completed) return;
completed = true;
reject(new Error('The SSH command timed out.'));
}, timeout);
client.exec(command, (error, stream) => { client.exec(command, (error, stream) => {
if (error) { if (error) {
clearTimeout(timer); clearTimeout(timer);
completed = true;
reject(error); reject(error);
return; return;
} }
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
stream.on('data', (chunk) => { if (stdout.length < maxOutput) stdout += chunk.toString(); }); let stdoutBytes = 0;
stream.stderr.on('data', (chunk) => { if (stderr.length < maxOutput) stderr += chunk.toString(); }); let stderrBytes = 0;
let truncated = false;
const append = (target, chunk) => {
const text = chunk.toString();
const bytes = Buffer.byteLength(text);
if (target === 'stdout') {
if (stdoutBytes + bytes <= maxOutput) stdout += text;
else truncated = true;
stdoutBytes += bytes;
} else {
if (stderrBytes + bytes <= maxOutput) stderr += text;
else truncated = true;
stderrBytes += bytes;
}
};
stream.on('data', (chunk) => append('stdout', chunk));
stream.stderr.on('data', (chunk) => append('stderr', chunk));
stream.on('close', (code, signal) => { stream.on('close', (code, signal) => {
if (completed) return;
completed = true;
clearTimeout(timer); clearTimeout(timer);
if (code !== 0) { if (truncated) {
const failure = new Error(`Remote command output exceeded the ${maxOutput}-byte safety limit. ForgeFlow refused to use an incomplete result.`);
failure.code = 'SSH_OUTPUT_TRUNCATED';
failure.stdoutBytes = stdoutBytes;
failure.stderrBytes = stderrBytes;
reject(failure);
} else if (code !== 0) {
const failure = new Error(`Remote command failed with exit code ${code}: ${(stderr || stdout).trim().slice(-4000)}`); const failure = new Error(`Remote command failed with exit code ${code}: ${(stderr || stdout).trim().slice(-4000)}`);
failure.code = 'SSH_COMMAND_FAILED'; failure.code = 'SSH_COMMAND_FAILED';
failure.exitCode = code; failure.exitCode = code;
failure.signal = signal; failure.signal = signal;
reject(failure); reject(failure);
} else resolve({ stdout, stderr, exitCode: code }); } else resolve({ stdout, stderr, exitCode: code, truncated: false });
}); });
}); });
}); });
} }
async uploadBuffer(serverId, remotePath, content, { mode = 0o600 } = {}) { ensureUploadTarget(target) {
const normalized = String(target || '').replace(/\\/g, '/');
if (!normalized.startsWith('/') || normalized.includes('\0') || normalized.split('/').includes('..')) throw new Error('Remote upload path must be an absolute safe Unix path.');
return normalized;
}
async withSftp(serverId, remotePath, action) {
const server = this.store.getServer(serverId); const server = this.store.getServer(serverId);
if (!server?.hostFingerprint) { if (!server?.hostFingerprint) {
const error = new Error('Test and trust the SSH server fingerprint before uploading deployment assets.'); const error = new Error('Test and trust the SSH server fingerprint before uploading deployment assets.');
error.code = 'SSH_HOST_NOT_TRUSTED'; error.code = 'SSH_HOST_NOT_TRUSTED';
throw error; throw error;
} }
const target = String(remotePath || '').replace(/\\/g, '/'); const target = this.ensureUploadTarget(remotePath);
if (!target.startsWith('/') || target.includes('\0') || target.split('/').includes('..')) throw new Error('Remote upload path must be an absolute safe Unix path.');
const data = Buffer.isBuffer(content) ? content : Buffer.from(content);
return this.withClient(serverId, (client) => new Promise((resolve, reject) => { return this.withClient(serverId, (client) => new Promise((resolve, reject) => {
client.sftp((sftpError, sftp) => { client.sftp((sftpError, sftp) => {
if (sftpError) { reject(sftpError); return; } if (sftpError) { reject(sftpError); return; }
const directory = path.dirname(target); const parts = path.dirname(target).split('/').filter(Boolean);
const mkdirParts = directory.split('/').filter(Boolean);
let current = ''; let current = '';
const makeNext = (index) => { const ensureNext = (index) => {
if (index >= mkdirParts.length) { if (index >= parts.length) {
const stream = sftp.createWriteStream(target, { mode }); Promise.resolve(action(sftp, target)).then(resolve, reject);
stream.once('error', reject);
stream.once('close', () => resolve({ remotePath: target, size: data.length }));
stream.end(data);
return; return;
} }
current += `/${mkdirParts[index]}`; current += `/${parts[index]}`;
const ensureDirectory = () => {
sftp.stat(current, (statError, attributes) => { sftp.stat(current, (statError, attributes) => {
if (!statError) { if (!statError) {
if (typeof attributes?.isDirectory === 'function' && !attributes.isDirectory()) { if (typeof attributes?.isDirectory === 'function' && !attributes.isDirectory()) { reject(new Error(`Remote upload parent exists but is not a directory: ${current}`)); return; }
reject(new Error(`Remote upload parent exists but is not a directory: ${current}`)); ensureNext(index + 1);
return;
}
makeNext(index + 1);
return; return;
} }
if (![2, 'ENOENT'].includes(statError.code)) { reject(statError); return; } if (![2, 'ENOENT'].includes(statError.code)) { reject(statError); return; }
sftp.mkdir(current, { mode: 0o755 }, (mkdirError) => { sftp.mkdir(current, { mode: 0o755 }, (mkdirError) => {
if (!mkdirError) { makeNext(index + 1); return; } if (!mkdirError) { ensureNext(index + 1); return; }
sftp.stat(current, (retryError, retryAttributes) => { sftp.stat(current, (retryError, retryAttributes) => {
if (!retryError && (typeof retryAttributes?.isDirectory !== 'function' || retryAttributes.isDirectory())) makeNext(index + 1); if (!retryError && (typeof retryAttributes?.isDirectory !== 'function' || retryAttributes.isDirectory())) ensureNext(index + 1);
else reject(mkdirError); else reject(mkdirError);
}); });
}); });
}); });
}; };
ensureDirectory(); ensureNext(0);
};
makeNext(0);
}); });
}), { trustOnFirstUse: false }); }), { trustOnFirstUse: false });
} }
async uploadFile(serverId, localPath, remotePath, options = {}) { async uploadBuffer(serverId, remotePath, content, { mode = 0o600 } = {}) {
const data = await fs.readFile(localPath); const data = Buffer.isBuffer(content) ? content : Buffer.from(content);
return this.uploadBuffer(serverId, remotePath, data, options); return this.withSftp(serverId, remotePath, (sftp, target) => new Promise((resolve, reject) => {
const stream = sftp.createWriteStream(target, { mode });
stream.once('error', reject);
stream.once('close', () => resolve({ remotePath: target, size: data.length }));
stream.end(data);
}));
}
async uploadFile(serverId, localPath, remotePath, { mode = 0o600, onProgress = null } = {}) {
const stat = await fsp.stat(localPath);
if (!stat.isFile()) throw new Error(`Local upload source is not a file: ${localPath}`);
return this.withSftp(serverId, remotePath, (sftp, target) => new Promise((resolve, reject) => {
const options = {
mode,
step: (totalTransferred, _chunk, total) => onProgress?.({ transferred: totalTransferred, total: total || stat.size }),
};
sftp.fastPut(localPath, target, options, (error) => {
if (error) { reject(error); return; }
resolve({ remotePath: target, size: stat.size });
});
}));
} }
async test(serverId, { trustOnFirstUse = true } = {}) { async test(serverId, { trustOnFirstUse = true } = {}) {
return this.withClient(serverId, async (client, server, fingerprint) => { return this.withClient(serverId, async (client, server, fingerprint) => {
const result = await this.execClient(client, 'uname -srm && command -v git && (docker compose version || docker-compose version)', { timeout: 30_000 }); const script = `
platform=$(uname -srm 2>/dev/null || true)
docker=false; docker_ready=false; compose=false; compose_version=''; git=false; tar_ok=false; checksum=false; base_writable=false
command -v docker >/dev/null 2>&1 && docker=true
[ "$docker" = true ] && docker info >/dev/null 2>&1 && docker_ready=true
if [ "$docker" = true ]; then
if docker compose version >/dev/null 2>&1; then compose=true; compose_version=$(docker compose version 2>/dev/null | head -n1); elif command -v docker-compose >/dev/null 2>&1; then compose=true; compose_version=$(docker-compose version 2>/dev/null | head -n1); fi
fi
command -v git >/dev/null 2>&1 && git=true
command -v tar >/dev/null 2>&1 && tar_ok=true
(command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1) && checksum=true
base=${shellQuote(server.basePath)}
if [ -d "$base" ]; then [ -w "$base" ] && base_writable=true; else parent=$(dirname "$base"); [ -d "$parent" ] && [ -w "$parent" ] && base_writable=true; fi
printf '__FORGEFLOW_SERVER_TEST__\\n'
printf 'platform=%s\\n' "$(printf '%s' "$platform" | base64 | tr -d '\\r\\n')"
printf 'docker=%s\\n' "$docker"
printf 'dockerReady=%s\\n' "$docker_ready"
printf 'compose=%s\\n' "$compose"
printf 'composeVersion=%s\\n' "$(printf '%s' "$compose_version" | base64 | tr -d '\\r\\n')"
printf 'git=%s\\n' "$git"
printf 'tar=%s\\n' "$tar_ok"
printf 'checksum=%s\\n' "$checksum"
printf 'baseWritable=%s\\n' "$base_writable"
`;
const result = await this.execClient(client, script, { timeout: 30_000, maxOutput: 256 * 1024 });
const capabilities = parseCapabilityOutput(result.stdout);
return { return {
connected: true, connected: true,
fingerprint, fingerprint,
server: { id: server.id, name: server.name, host: server.host, basePath: server.basePath }, server: { id: server.id, name: server.name, host: server.host, basePath: server.basePath },
output: result.stdout.trim() capabilities,
output: [capabilities.platform, capabilities.composeVersion].filter(Boolean).join('\n'),
}; };
}, { trustOnFirstUse }); }, { trustOnFirstUse });
} }
@@ -201,4 +329,4 @@ class SshService {
} }
} }
module.exports = { SshService, shellQuote, fingerprintKey }; module.exports = { SshService, shellQuote, fingerprintKey, parseCapabilityOutput };
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -300,10 +300,16 @@ class UpdateService {
} }
const [binary, checksumBytes] = await Promise.all([ const [binary, checksumBytes] = await Promise.all([
this.gitea.downloadReleaseAsset(update.owner, update.repo, asset.id),
this.gitea.downloadReleaseAsset( this.gitea.downloadReleaseAsset(
update.owner, update.owner,
update.repo, update.repo,
release.id,
asset.id,
),
this.gitea.downloadReleaseAsset(
update.owner,
update.repo,
release.id,
checksumAsset.id, checksumAsset.id,
), ),
]); ]);
+419 -60
View File
@@ -159,6 +159,7 @@ const ui = {
diagnosticsStatus: null, diagnosticsStatus: null,
troubleshooter: null, troubleshooter: null,
deploymentDiscovery: null, deploymentDiscovery: null,
serverDiscovery: [],
lastDiagnosticBundle: null, lastDiagnosticBundle: null,
setupDraft: { setupDraft: {
baseUrl: "https://", baseUrl: "https://",
@@ -179,6 +180,7 @@ const ui = {
servers: [], servers: [],
serverInspection: null, serverInspection: null,
gitRecovery: null, gitRecovery: null,
gitValidation: null,
diffHunks: null, diffHunks: null,
conflictState: null, conflictState: null,
branchProtection: null, branchProtection: null,
@@ -415,13 +417,33 @@ async function refreshActiveOperations(showErrors = true) {
} }
async function refreshDeploymentTruth(showErrors = false) { async function refreshDeploymentTruth(showErrors = false) {
let discovery = [];
try {
discovery = (await window.forgeflow.discoverServerDeployments?.()) || [];
ui.serverDiscovery = discovery;
const adopted = discovery.reduce(
(total, server) => total + Number(server.adopted || 0),
0,
);
if (adopted > 0) {
await refreshRepositories(false, true);
showToast(
"Server workloads discovered",
`${adopted} workload${adopted === 1 ? " was" : "s were"} linked automatically from exact repository provenance.`,
"success",
);
}
} catch (error) {
if (showErrors)
showToast("Server discovery unavailable", error.message, "error");
}
const targets = ui.repositories.flatMap((repository) => const targets = ui.repositories.flatMap((repository) =>
(repository.deploymentProfiles || []).map((profile) => ({ (repository.deploymentProfiles || []).map((profile) => ({
repository, repository,
profile, profile,
})), })),
); );
if (!targets.length) return { checked: 0, failed: 0 }; if (!targets.length) return { checked: 0, failed: 0, discovery };
const failures = []; const failures = [];
const queue = [...targets]; const queue = [...targets];
@@ -454,7 +476,7 @@ async function refreshDeploymentTruth(showErrors = false) {
"error", "error",
); );
} }
return { checked: targets.length, failed: failures.length }; return { checked: targets.length, failed: failures.length, discovery };
} }
function selectRepository(id, shouldRender = true) { function selectRepository(id, shouldRender = true) {
@@ -466,6 +488,7 @@ function selectRepository(id, shouldRender = true) {
ui.branches = []; ui.branches = [];
ui.stashes = []; ui.stashes = [];
ui.gitRecovery = null; ui.gitRecovery = null;
ui.gitValidation = null;
ui.branchProtection = null; ui.branchProtection = null;
const repository = selectedRepository(); const repository = selectedRepository();
ui.selectedProfileId = selectedProfile(repository)?.id || null; ui.selectedProfileId = selectedProfile(repository)?.id || null;
@@ -773,10 +796,25 @@ function releaseNode(label, value, description, tone = "") {
return `<div class="release-node"><div class="release-label">${label}</div><div class="release-value"><span class="state-dot ${tone}"></span><strong>${escapeHtml(value)}</strong><span>${escapeHtml(description)}</span></div></div>`; return `<div class="release-node"><div class="release-label">${label}</div><div class="release-value"><span class="state-dot ${tone}"></span><strong>${escapeHtml(value)}</strong><span>${escapeHtml(description)}</span></div></div>`;
} }
function diffAtmosphere(diff) {
if (!ui.selectedFile) return "";
const lines = String(diff || "").split("\n");
const additions = lines.filter(
(line) => line.startsWith("+") && !line.startsWith("+++"),
).length;
const removals = lines.filter(
(line) => line.startsWith("-") && !line.startsWith("---"),
).length;
const extension =
String(ui.selectedFile).split(".").pop()?.slice(0, 8).toUpperCase() ||
"FILE";
return `<div class="diff-atmosphere ${lines.length > 34 ? "dense" : ""}" data-diff-atmosphere aria-hidden="true"><svg viewBox="0 0 360 260" role="presentation"><path class="code-route route-a" d="M38 195 C92 84 178 214 318 74"/><path class="code-route route-b" d="M52 74 C132 8 230 34 310 156"/><g class="code-card"><rect x="110" y="75" width="142" height="106" rx="18"/><path d="M136 108h90M136 128h58M136 148h76"/></g><g class="code-node node-one"><circle cx="48" cy="190" r="15"/><path d="m41 190 5 5 9-12"/></g><g class="code-node node-two"><circle cx="315" cy="76" r="13"/><path d="M308 76h14M315 69v14"/></g><circle class="code-packet packet-one" cx="0" cy="0" r="5"/><circle class="code-packet packet-two" cx="0" cy="0" r="4"/></svg><div class="diff-atmosphere-caption"><span>${escapeHtml(extension)} change map</span><strong><i>+${additions}</i><i>${removals}</i></strong></div></div>`;
}
function renderDiff(diff) { function renderDiff(diff) {
if (!diff) if (!diff)
return '<div class="empty-state"><div class="empty-icon">↔</div><h3>No textual diff</h3><p>Select another file or open the project folder for binary changes.</p></div>'; return '<div class="empty-state"><div class="empty-icon">↔</div><h3>No textual diff</h3><p>Select another file or open the project folder for binary changes.</p></div>';
return escapeHtml(diff) const rendered = escapeHtml(diff)
.split("\n") .split("\n")
.map((line) => { .map((line) => {
const type = const type =
@@ -790,6 +828,7 @@ function renderDiff(diff) {
return `<span class="diff-line ${type}">${line || " "}</span>`; return `<span class="diff-line ${type}">${line || " "}</span>`;
}) })
.join(""); .join("");
return `${rendered}${diffAtmosphere(diff)}`;
} }
function fileStatusCode(file) { function fileStatusCode(file) {
if (file.conflict) return "U"; if (file.conflict) return "U";
@@ -835,11 +874,9 @@ function environmentState(profile) {
const state = profile.state || {}; const state = profile.state || {};
if (state.healthy === false) return { label: "Unhealthy", tone: "danger" }; if (state.healthy === false) return { label: "Unhealthy", tone: "danger" };
if (state.healthy === true) return { label: "Healthy", tone: "success" }; if (state.healthy === true) return { label: "Healthy", tone: "success" };
if ( if (state.containerRunning === true) return { label: "Running · unverified", tone: "warning" };
profile.provider === "ssh-unraid" || if (state.containerRunning === false) return { label: "Stopped", tone: "danger" };
state.statusConfigured || if (profile.provider === "ssh-unraid" || state.statusConfigured || state.healthConfigured)
state.healthConfigured
)
return { label: "Not checked", tone: "" }; return { label: "Not checked", tone: "" };
return { label: "Status not configured", tone: "" }; return { label: "Status not configured", tone: "" };
} }
@@ -882,28 +919,41 @@ function deploymentIdentity(profile, repository) {
function renderProfileCard(repository, profile, compact = false) { function renderProfileCard(repository, profile, compact = false) {
const state = profile.state || {}; const state = profile.state || {};
const health = environmentState(profile); const health = environmentState(profile);
const isSsh = profile.provider === "ssh-unraid";
const mode = isSsh ? profile.deploymentMode || "server-git" : "gitea-actions";
const ready = const ready =
mode !== "monitor-only" &&
repository.readyToDeploy && repository.readyToDeploy &&
repository.localStatus?.branch.head === profile.branch; repository.localStatus?.branch.head === profile.branch;
const isSsh = profile.provider === "ssh-unraid"; const modeLabel = {
"push-bundle": "Push bundle",
"server-git": "Server-side Git",
"monitor-only": "Monitor only",
}[mode] || mode;
const providerDetail = isSsh const providerDetail = isSsh
? `SSH / Unraid · ${profile.remoteFolder || repository.name} · ${profile.branch}` ? `SSH / Unraid · ${modeLabel} · ${profile.remoteFolder || repository.name} · ${profile.branch}${profile.adoptedFromServer ? " · server-linked" : ""}`
: `${profile.workflowFile} · ${profile.branch}`; : `${profile.workflowFile} · ${profile.branch}`;
const rollbackConfigured = isSsh || Boolean(profile.rollbackWorkflowFile); const rollbackConfigured = (isSsh && mode !== "monitor-only") || Boolean(profile.rollbackWorkflowFile);
const dockerMan = dockerManIntegration(profile); const dockerMan = dockerManIntegration(profile);
const { templateReady, webUiReady, iconReady } = dockerMan; const { templateReady, webUiReady, iconReady } = dockerMan;
const dockerManReady = dockerMan.ready; const dockerManReady = dockerMan.ready;
const webUi = const managesDockerMan = isSsh && profile.manageDockerMan === true;
profile.webUiUrl || state.webUiUrl || state.dockerMan?.webUi || ""; const webUi = profile.webUiUrl || state.webUiUrl || state.dockerMan?.webUi || "";
const identity = deploymentIdentity(profile, repository); const identity = deploymentIdentity(profile, repository);
const syncLabel = state.matchesGitea const syncLabel = state.matchesGitea
? `<span class="sync-proof success">${icon("check")}Live = Gitea · ${shortSha(state.liveSha)}</span>` ? `<span class="sync-proof success">${icon("check")}Live = Gitea · ${shortSha(state.liveSha)}</span>`
: state.giteaSha && state.liveSha : state.giteaSha && state.liveSha
? `<span class="sync-proof warning">Live ${shortSha(state.liveSha)} · Gitea ${shortSha(state.giteaSha)}</span>` ? `<span class="sync-proof warning">Live ${shortSha(state.liveSha)} · Gitea ${shortSha(state.giteaSha)}</span>`
: ""; : "";
return `<article class="deploy-card accent-${identity.accent} ${compact ? "compact-card" : ""}"><div class="container-identity"><span class="container-avatar">${escapeHtml(identity.initial)}</span><div><span>Container</span><strong>${escapeHtml(identity.name)}</strong><small>${escapeHtml(repository.fullName)} · ${escapeHtml(profile.environment)}</small></div>${syncLabel}</div><div class="deploy-card-header"><div><div class="eyebrow">${escapeHtml(isSsh ? "SSH / UNRAID" : "GITEA ACTIONS")}</div><h3>${escapeHtml(profile.name)}</h3><p>${escapeHtml(providerDetail)}</p></div><span class="status-pill ${health.tone}"><span class="state-dot ${health.tone}"></span>${health.label}</span></div><div class="deploy-card-body"><div class="deploy-metadata"><span>Live commit</span><strong>${state.liveSha ? shortSha(state.liveSha) : "Unknown"}</strong><span>Gitea commit</span><strong>${state.giteaSha ? shortSha(state.giteaSha) : "Refresh to compare"}</strong><span>Previous version</span><strong>${state.previousSha ? shortSha(state.previousSha) : "Unknown"}</strong><span>Last checked</span><strong>${state.checkedAt ? formatDate(state.checkedAt) : "Never"}</strong>${isSsh ? `<span>Runtime</span><strong>${state.containerRunning === false ? "Stopped" : state.containerRunning ? "Running" : "Unknown"}</strong><span>DockerMan</span><strong class="${dockerManReady ? "text-success" : "text-warning"}">${dockerManReady ? (templateReady ? "Labels/template active" : "WebUI/icon labels active") : `WebUI ${webUiReady ? "ready" : "missing"} · icon ${iconReady ? "ready" : "missing"}`}</strong>` : ""}<span>Rollback</span><strong>${rollbackConfigured ? "Available after first deploy" : "Not configured"}</strong></div><div class="card-actions"><button class="button" data-action="run-deployment-preflight" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Preflight</button><button class="button" data-action="reconcile-deployment" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("refresh")}Refresh truth</button>${webUi ? `<button class="button" data-action="open-profile-webui" data-url="${attr(webUi)}">${icon("external")}Open Web UI</button>` : ""}${isSsh ? `<button class="button ${dockerManReady ? "ghost" : ""}" data-action="apply-dockerman-metadata" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}${dockerManReady ? "Reapply DockerMan metadata" : "Repair DockerMan integration"}</button>` : ""}${ready ? `<button class="button primary" data-action="deploy-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("rocket")}Deploy ${escapeHtml(repository.localStatus.shortHead)}</button>` : ""}<button class="button ghost" data-action="edit-deployment-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">Edit</button>${state.previousSha && rollbackConfigured ? `<button class="button danger" data-action="rollback-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("undo")}Rollback</button>` : ""}</div></div></article>`; const dockerManLabel = managesDockerMan
? dockerManReady
? templateReady
? "Managed labels/template active"
: "Managed labels active"
: `Managed · WebUI ${webUiReady ? "ready" : "missing"} · icon ${iconReady ? "ready" : "missing"}`
: "Existing DockerMan template preserved";
return `<article class="deploy-card accent-${identity.accent} ${compact ? "compact-card" : ""}"><div class="container-identity"><span class="container-avatar">${escapeHtml(identity.initial)}</span><div><span>Container</span><strong>${escapeHtml(identity.name)}</strong><small>${escapeHtml(repository.fullName)} · ${escapeHtml(profile.environment)}</small></div>${syncLabel}</div><div class="deploy-card-header"><div><div class="eyebrow">${escapeHtml(isSsh ? "SSH / UNRAID" : "GITEA ACTIONS")}</div><h3>${escapeHtml(profile.name)}</h3><p>${escapeHtml(providerDetail)}</p></div><span class="status-pill ${health.tone}"><span class="state-dot ${health.tone}"></span>${health.label}</span></div><div class="deploy-card-body"><div class="deploy-metadata"><span>Live commit</span><strong>${state.liveSha ? shortSha(state.liveSha) : "Unknown"}</strong><span>Gitea commit</span><strong>${state.giteaSha ? shortSha(state.giteaSha) : "Refresh to compare"}</strong><span>Previous version</span><strong>${state.previousSha ? shortSha(state.previousSha) : "Unknown"}</strong><span>Last checked</span><strong>${state.checkedAt ? formatDate(state.checkedAt) : "Never"}</strong>${isSsh ? `<span>Deployment mode</span><strong>${escapeHtml(modeLabel)}</strong><span>Compose project</span><strong>${escapeHtml(profile.composeProject || "ForgeFlow-generated identity")}</strong><span>Runtime</span><strong>${state.containerRunning === false ? "Stopped" : state.containerRunning ? state.runtimeVerification === "running-unverified" ? "Running · unverified" : "Running" : "Unknown"}</strong><span>DockerMan</span><strong class="${managesDockerMan && !dockerManReady ? "text-warning" : "text-success"}">${escapeHtml(dockerManLabel)}</strong>` : ""}<span>Rollback</span><strong>${rollbackConfigured ? "Available after first deploy" : "Not configured"}</strong></div><div class="card-actions"><button class="button" data-action="run-deployment-preflight" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Preflight</button><button class="button" data-action="reconcile-deployment" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("refresh")}Refresh truth</button>${webUi ? `<button class="button" data-action="open-profile-webui" data-url="${attr(webUi)}">${icon("external")}Open Web UI</button>` : ""}${managesDockerMan ? `<button class="button ${dockerManReady ? "ghost" : ""}" data-action="apply-dockerman-metadata" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}${dockerManReady ? "Reapply DockerMan integration" : "Repair DockerMan integration"}</button>` : ""}${ready ? `<button class="button primary" data-action="deploy-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("rocket")}Deploy ${escapeHtml(repository.localStatus.shortHead)}</button>` : ""}<button class="button ghost" data-action="edit-deployment-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">Edit</button>${state.previousSha && rollbackConfigured ? `<button class="button danger" data-action="rollback-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("undo")}Rollback</button>` : ""}</div></div></article>`;
} }
function renderRepositoryDeployments(repository) { function renderRepositoryDeployments(repository) {
const profiles = repository.deploymentProfiles || []; const profiles = repository.deploymentProfiles || [];
const repoOps = repositoryOperations(repository).slice(0, 10); const repoOps = repositoryOperations(repository).slice(0, 10);
@@ -931,6 +981,34 @@ function renderRepositorySettings(repository) {
return `<div class="tab-page"><section class="settings-group"><h2>Repository identity</h2><div class="form-grid"><div class="field full"><label>Gitea repository</label><input class="input" value="${attr(repository.fullName)}" readonly/></div><div class="field full"><label>Local working tree</label><input class="input mono" value="${attr(repository.localPath || automaticTarget || "Not linked")}" readonly/></div><div class="field full"><label>Current origin</label><input class="input mono" value="${attr(currentOrigin)}" readonly/></div>${desiredOrigin ? `<div class="field full"><label>Current Gitea SSH origin</label><input class="input mono" value="${attr(desiredOrigin)}" readonly/></div>` : ""}</div><div class="card-actions"><button class="button" data-action="${repository.localPath ? "open-path" : "link-repo"}">${icon("folder")}${repository.localPath ? "Open project folder" : "Link local folder"}</button>${originNeedsRepair ? `<button class="button primary" data-action="repair-origin">${icon("link")}Use current Gitea origin</button>` : ""}${repository.localPath ? `<button class="button" data-action="scan-git-recovery">${icon("pulse")}Scan Git health</button><button class="button danger" data-action="unlink-repo">${icon("link")}Remove link</button>` : `<button class="button primary" data-action="clone-repo">${icon("cloud")}${escapeHtml(clonePrimaryLabel(repository))}</button><button class="button ghost" data-action="clone-repo-custom">Choose another location</button>`}</div></section><section class="settings-group"><div class="section-heading"><div><h2>Open pull requests</h2><span class="meta">Live from Gitea</span></div><button class="button" data-action="load-pull-requests">${icon("refresh")}Refresh</button></div>${pullRequests.length ? `<div class="tool-list">${pullRequests.map((pull) => `<div class="tool-row"><div><strong>#${pull.number} · ${escapeHtml(pull.title)}</strong><span>${escapeHtml(pull.head?.ref || pull.head?.label || "source")}${escapeHtml(pull.base?.ref || pull.base?.label || "target")} · ${formatDate(pull.updated_at || pull.created_at)}</span></div><button class="button" data-action="open-pull-request-url" data-url="${attr(pull.html_url || "")}">Open</button></div>`).join("")}</div>` : '<div class="empty-state compact"><p>No open pull requests.</p></div>'}</section><section class="settings-group"><h2>Repository behavior</h2><div class="notice">${icon("shield")}Origin repair changes only the Git remote URL. Git health scans the actual Git directory, repairs only proven stale lock files and never changes source files or commits.</div></section></div>`; return `<div class="tab-page"><section class="settings-group"><h2>Repository identity</h2><div class="form-grid"><div class="field full"><label>Gitea repository</label><input class="input" value="${attr(repository.fullName)}" readonly/></div><div class="field full"><label>Local working tree</label><input class="input mono" value="${attr(repository.localPath || automaticTarget || "Not linked")}" readonly/></div><div class="field full"><label>Current origin</label><input class="input mono" value="${attr(currentOrigin)}" readonly/></div>${desiredOrigin ? `<div class="field full"><label>Current Gitea SSH origin</label><input class="input mono" value="${attr(desiredOrigin)}" readonly/></div>` : ""}</div><div class="card-actions"><button class="button" data-action="${repository.localPath ? "open-path" : "link-repo"}">${icon("folder")}${repository.localPath ? "Open project folder" : "Link local folder"}</button>${originNeedsRepair ? `<button class="button primary" data-action="repair-origin">${icon("link")}Use current Gitea origin</button>` : ""}${repository.localPath ? `<button class="button" data-action="scan-git-recovery">${icon("pulse")}Scan Git health</button><button class="button danger" data-action="unlink-repo">${icon("link")}Remove link</button>` : `<button class="button primary" data-action="clone-repo">${icon("cloud")}${escapeHtml(clonePrimaryLabel(repository))}</button><button class="button ghost" data-action="clone-repo-custom">Choose another location</button>`}</div></section><section class="settings-group"><div class="section-heading"><div><h2>Open pull requests</h2><span class="meta">Live from Gitea</span></div><button class="button" data-action="load-pull-requests">${icon("refresh")}Refresh</button></div>${pullRequests.length ? `<div class="tool-list">${pullRequests.map((pull) => `<div class="tool-row"><div><strong>#${pull.number} · ${escapeHtml(pull.title)}</strong><span>${escapeHtml(pull.head?.ref || pull.head?.label || "source")}${escapeHtml(pull.base?.ref || pull.base?.label || "target")} · ${formatDate(pull.updated_at || pull.created_at)}</span></div><button class="button" data-action="open-pull-request-url" data-url="${attr(pull.html_url || "")}">Open</button></div>`).join("")}</div>` : '<div class="empty-state compact"><p>No open pull requests.</p></div>'}</section><section class="settings-group"><h2>Repository behavior</h2><div class="notice">${icon("shield")}Origin repair changes only the Git remote URL. Git health scans the actual Git directory, repairs only proven stale lock files and never changes source files or commits.</div></section></div>`;
} }
function renderGitValidator(repository) {
const report = ui.gitValidation;
if (!report)
return `<div class="validator-empty panel">${projectIllustration("diagnostics")}<div><div class="eyebrow">Repository assurance</div><h2>Validate Git best practices</h2><p>Inspect repository identity, branch governance, tracked secrets, file hygiene and safe local synchronization settings.</p><button class="button primary" data-action="git-validator-scan">${icon("shield")}Run Git Validator</button></div></div>`;
const tone =
report.score >= 90 ? "success" : report.score >= 70 ? "warning" : "danger";
const safeFixes = report.checks.filter(
(check) => check.fixAction && check.safe,
);
const groups = report.checks.reduce((grouped, check) => {
(grouped[check.category] ||= []).push(check);
return grouped;
}, {});
return `<div class="validator-page"><section class="validator-hero panel ${tone}"><div class="validator-score"><strong>${report.score}</strong><span>/ 100</span></div><div><div class="eyebrow">Git assurance score</div><h2>${escapeHtml(report.grade)}</h2><p>${report.summary.passed} passed · ${report.summary.warnings} recommendations · ${report.summary.errors} critical</p></div>${projectIllustration("diagnostics")}<div class="validator-actions"><button class="button" data-action="git-validator-scan">${icon("refresh")}Scan again</button>${safeFixes.length ? `<button class="button primary" data-action="git-validator-repair-safe">${icon("wrench")}Apply ${safeFixes.length} safe fix${safeFixes.length === 1 ? "" : "es"}</button>` : ""}</div></section><div class="validator-groups">${Object.entries(
groups,
)
.map(
([category, checks]) =>
`<section class="panel validator-group"><div class="panel-header"><h3>${escapeHtml(category)}</h3><span class="meta">${checks.filter((check) => check.status === "pass").length}/${checks.length} passed</span></div><div class="validator-checks">${checks
.map((check) => {
const checkIndex = report.checks.indexOf(check);
return `<article class="validator-check ${check.status}"><span class="validator-check-icon">${icon(check.status === "pass" ? "check" : check.status === "error" ? "error" : "warning")}</span><div><strong>${escapeHtml(check.title)}</strong><p>${escapeHtml(check.detail)}</p></div>${check.fixAction ? `<button class="button ${check.safe ? "" : "primary"}" data-action="git-validator-repair" data-check-index="${checkIndex}">${icon("wrench")}${check.safe ? "Fix safely" : "Review & fix"}</button>` : `<span class="status-pill ${check.status === "pass" ? "success" : check.status === "error" ? "danger" : "warning"}">${check.status === "pass" ? "Best practice" : "Review"}</span>`}</article>`;
})
.join("")}</div></section>`,
)
.join("")}</div></div>`;
}
function renderRepositoryWorkspace(repository) { function renderRepositoryWorkspace(repository) {
const status = repository.localStatus; const status = repository.localStatus;
const profile = selectedProfile(repository); const profile = selectedProfile(repository);
@@ -961,6 +1039,7 @@ function renderRepositoryWorkspace(repository) {
history: renderHistory, history: renderHistory,
deployments: renderRepositoryDeployments, deployments: renderRepositoryDeployments,
gittools: renderGitTools, gittools: renderGitTools,
validator: renderGitValidator,
settings: renderRepositorySettings, settings: renderRepositorySettings,
}[ui.repositoryTab] || renderChanges }[ui.repositoryTab] || renderChanges
)(repository); )(repository);
@@ -972,6 +1051,7 @@ function renderRepositoryWorkspace(repository) {
["history", "History"], ["history", "History"],
["deployments", "Deployments"], ["deployments", "Deployments"],
["gittools", "Git tools"], ["gittools", "Git tools"],
["validator", "Git Validator"],
["settings", "Project settings"], ["settings", "Project settings"],
] ]
.map( .map(
@@ -1024,34 +1104,44 @@ function renderActionPanel(repository) {
return `<aside class="action-panel"><div class="action-panel-head"><div class="eyebrow">Next action</div><h2>${escapeHtml(action.title)}</h2><p>${escapeHtml(action.detail)}</p></div><div class="action-panel-body">${body}</div>${repository.localPath ? `<div class="action-panel-footer"><button class="button ghost" data-action="open-path">${icon("folder")}Open folder</button><button class="button ghost" data-action="load-git-tools">${icon("branch")}Git tools</button></div>` : ""}</aside>`; return `<aside class="action-panel"><div class="action-panel-head"><div class="eyebrow">Next action</div><h2>${escapeHtml(action.title)}</h2><p>${escapeHtml(action.detail)}</p></div><div class="action-panel-body">${body}</div>${repository.localPath ? `<div class="action-panel-footer"><button class="button ghost" data-action="open-path">${icon("folder")}Open folder</button><button class="button ghost" data-action="load-git-tools">${icon("branch")}Git tools</button></div>` : ""}</aside>`;
} }
function renderServerInventory() {
const servers = ui.serverDiscovery || [];
const workloadCount = servers.reduce((total, server) => total + (server.workloads?.length || 0), 0);
const reviewCount = servers.reduce((total, server) => total + Number(server.needsReview || 0), 0);
return `<section class="section-block"><div class="section-heading"><div><h2>Server inventory</h2><span class="meta">Running and stopped Docker workloads, including installations without a Git checkout</span></div><button class="button ${reviewCount ? "primary" : ""}" data-action="scan-server-inventory">${icon("refresh")}Scan servers</button></div>${servers.length ? `<div class="stack">${servers.map((server) => {
const capabilities = server.capabilities || {};
const capabilityText = [capabilities.docker ? "Docker" : "Docker missing", capabilities.compose ? "Compose" : "Compose missing", capabilities.git ? "Git" : "Git optional/missing", capabilities.tar && capabilities.checksum ? "Push ready" : "Push tools incomplete"].join(" · ");
return `<section class="panel"><div class="panel-header"><div><h3>${escapeHtml(server.serverName || server.server?.name || server.serverId)}</h3><span class="meta">${server.detected || 0} workloads · ${server.running || 0} running · ${server.linked || 0} linked · ${server.needsReview || 0} review</span></div><span class="status-pill ${server.error ? "danger" : capabilities.docker && capabilities.compose ? "success" : "warning"}">${server.error ? "Scan failed" : escapeHtml(capabilityText)}</span></div><div class="panel-body">${server.error ? `<div class="notice danger">${icon("error")}${escapeHtml(server.error)}</div>` : ""}${(server.warnings || []).map((warning) => `<div class="notice warning">${icon("warning")}${escapeHtml(warning)}</div>`).join("")}<div class="tool-list">${(server.workloads || []).length ? server.workloads.map((workload) => {
const containers = (workload.containers || []).map((container) => container.name).join(", ");
const topCandidate = workload.candidates?.[0];
const linked = workload.status === "linked" || Boolean(workload.link);
const statusTone = linked ? "success" : workload.status === "ambiguous" ? "danger" : workload.status === "unmatched" ? "warning" : "warning";
const detail = workload.compose?.project
? `Compose ${workload.compose.project} · ${(workload.compose.services || []).join(", ") || "services unknown"}`
: `Container installation · ${containers || "unnamed"}`;
const candidate = linked
? `Linked to ${workload.link?.repositoryFullName || "repository"}`
: topCandidate
? `${topCandidate.repositoryFullName} suggested · ${topCandidate.confidence}`
: "No repository candidate; select one manually";
return `<div class="tool-row"><div><strong>${escapeHtml(workload.displayName)}</strong><span>${escapeHtml(detail)} · ${workload.runtime?.running ? "running" : "stopped"}</span><span>${escapeHtml(candidate)}</span></div><div class="stack horizontal compact"><span class="status-pill ${statusTone}">${escapeHtml(linked ? "Linked" : workload.status || "Review")}</span>${linked ? `<button class="button ghost" data-action="edit-deployment-profile" data-profile-id="${attr(workload.link?.profileId || "")}">Open link</button>` : `<button class="button primary" data-action="link-server-workload" data-server-id="${attr(server.serverId)}" data-workload-id="${attr(workload.workloadId)}">${icon("link")}Link deployment</button>`}</div></div>`;
}).join("") : '<div class="empty-state compact"><p>No Docker workloads were returned by this server.</p></div>'}</div></div></section>`;
}).join("")}</div>` : `<div class="empty-state panel"><h3>Server inventory not scanned</h3><p>Scan the configured servers to detect existing DockerMan, Docker and Compose installations.</p><button class="button primary" data-action="scan-server-inventory">Scan servers</button></div>`}<div class="notice" style="margin-top:12px">${icon("shield")}Only exact repository provenance is linked automatically. Name similarity remains a manual decision. Push bundle deployments reuse the desktop Unraid SSH connection and do not require a Gitea key on Unraid.</div></section>`;
}
function renderDeployments() { function renderDeployments() {
const cards = ui.repositories.flatMap((repository) => const cards = ui.repositories.flatMap((repository) =>
(repository.deploymentProfiles || []).map((profile) => ({ (repository.deploymentProfiles || []).map((profile) => ({ repository, profile })),
repository,
profile,
})),
); );
const active = operations().filter( const active = operations().filter((operation) => !isTerminalOperation(operation.status));
(operation) => !isTerminalOperation(operation.status), const missingDockerMan = cards.filter(({ profile }) =>
);
const missingDockerMan = cards.filter(
({ profile }) =>
profile.provider === "ssh-unraid" && profile.provider === "ssh-unraid" &&
profile.manageDockerMan === true &&
profile.state?.containerRunning && profile.state?.containerRunning &&
!dockerManIntegration(profile).ready, !dockerManIntegration(profile).ready,
); );
return `<div class="page"><div class="page-header"><div><div class="eyebrow">Server releases</div><h1>Deployments</h1><p>Exact commits, live container truth, DockerMan integration and controlled release recovery.</p></div><div class="stack horizontal compact"><button class="button" data-action="refresh-operations">${icon("refresh")}Refresh runs & servers</button>${missingDockerMan.length ? `<button class="button primary" data-action="repair-missing-dockerman">${icon("wrench")}Repair ${missingDockerMan.length} missing integration${missingDockerMan.length === 1 ? "" : "s"}</button>` : ""}</div></div>${active.length ? `<div class="notice warning">${icon("pulse")} ${active.length} deployment operation${active.length === 1 ? " is" : "s are"} still active. ForgeFlow reconciles these against the live server automatically.</div>` : ""}<div class="deploy-card-grid">${cards.length ? cards.map(({ repository, profile }) => renderProfileCard(repository, profile, true)).join("") : '<div class="empty-state panel"><h3>No deployment environments configured</h3><p>Open a repository and add an environment.</p></div>'}</div><section class="section-block"><div class="section-heading"><h2>All operations</h2><span class="meta">Newest first</span></div><div class="panel">${ return `<div class="page"><div class="page-header"><div><div class="eyebrow">Server releases</div><h1>Deployments</h1><p>Discover existing installations, link uncertain workloads and deploy exact commit bundles without server-side Gitea credentials.</p></div><div class="stack horizontal compact"><button class="button" data-action="refresh-operations">${icon("refresh")}Refresh runs & servers</button>${missingDockerMan.length ? `<button class="button primary" data-action="repair-missing-dockerman">${icon("wrench")}Repair ${missingDockerMan.length} managed integration${missingDockerMan.length === 1 ? "" : "s"}</button>` : ""}</div></div>${active.length ? `<div class="notice warning">${icon("pulse")} ${active.length} deployment operation${active.length === 1 ? " is" : "s are"} still active. ForgeFlow reconciles these against the live server automatically.</div>` : ""}${renderServerInventory()}<section class="section-block"><div class="section-heading"><div><h2>Linked deployment environments</h2><span class="meta">Manual links remain stable across container recreations through Compose/workload identity</span></div></div><div class="deploy-card-grid">${cards.length ? cards.map(({ repository, profile }) => renderProfileCard(repository, profile, true)).join("") : '<div class="empty-state panel"><h3>No deployment environments configured</h3><p>Scan a server and link an existing workload, or open a repository and add an environment.</p></div>'}</div></section><section class="section-block"><div class="section-heading"><h2>All operations</h2><span class="meta">Newest first</span></div><div class="panel">${operations().length ? `<table class="data-table"><thead><tr><th>Repository</th><th>Action</th><th>Environment</th><th>Commit</th><th>Status</th><th>Updated</th><th></th></tr></thead><tbody>${operations().map((operation) => `<tr><td>${escapeHtml(operation.repository)}</td><td>${escapeHtml(operation.action || "deploy")}</td><td>${escapeHtml(operation.environment || "—")}</td><td class="mono">${escapeHtml(operation.shortSha || shortSha(operation.sha))}</td><td><span class="status-pill ${toneForStatus(operation.status)}">${escapeHtml(operation.status)}</span></td><td>${formatDate(operation.updatedAt || operation.createdAt)}</td><td><button class="button ghost" data-action="open-operation" data-operation-id="${attr(operation.id)}">Open</button></td></tr>`).join("")}</tbody></table>` : '<div class="empty-state compact"><p>No operations recorded.</p></div>'}</div></section></div>`;
operations().length
? `<table class="data-table"><thead><tr><th>Repository</th><th>Action</th><th>Environment</th><th>Commit</th><th>Status</th><th>Updated</th><th></th></tr></thead><tbody>${operations()
.map(
(operation) =>
`<tr><td>${escapeHtml(operation.repository)}</td><td>${escapeHtml(operation.action || "deploy")}</td><td>${escapeHtml(operation.environment || "—")}</td><td class="mono">${escapeHtml(operation.shortSha || shortSha(operation.sha))}</td><td><span class="status-pill ${toneForStatus(operation.status)}">${escapeHtml(operation.status)}</span></td><td>${formatDate(operation.updatedAt || operation.createdAt)}</td><td><button class="button ghost" data-action="open-operation" data-operation-id="${attr(operation.id)}">Open</button></td></tr>`,
)
.join("")}</tbody></table>`
: '<div class="empty-state compact"><p>No operations recorded.</p></div>'
}</div></section></div>`;
} }
function renderSettings() { function renderSettings() {
const state = ui.boot.state; const state = ui.boot.state;
const prefs = state.preferences || {}; const prefs = state.preferences || {};
@@ -1177,6 +1267,45 @@ function renderModal() {
ui.repositories.find( ui.repositories.find(
(repo) => repo.fullName === ui.modal.repositoryFullName, (repo) => repo.fullName === ui.modal.repositoryFullName,
); );
if (ui.modal.type === "workload-link") {
const serverResult = (ui.serverDiscovery || []).find(
(item) => item.serverId === ui.modal.serverId,
);
const workload = serverResult?.workloads?.find(
(item) => item.workloadId === ui.modal.workloadId,
);
if (!workload) {
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Link server workload</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="notice danger">${icon("error")}This workload is no longer present in the latest server inventory. Scan the servers again.</div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Close</button></footer></section></div>`;
}
const availableRepositories = ui.repositories.filter((item) => item.fullName);
const suggestedRepository =
ui.modal.repositoryFullName ||
workload.candidates?.[0]?.repositoryFullName ||
selectedRepository()?.fullName ||
availableRepositories[0]?.fullName ||
"";
const selectedLinkRepository = availableRepositories.find(
(item) => item.fullName === suggestedRepository,
);
const remoteFolder =
ui.modal.remoteFolder ||
workload.remoteFolderCandidate ||
safeCloneFolderName(selectedLinkRepository);
const candidateSummary = workload.candidates?.length
? workload.candidates
.slice(0, 4)
.map(
(candidate) =>
`<div class="context-row"><span>${escapeHtml(candidate.repositoryFullName)}</span><strong>${escapeHtml(candidate.exact ? "Exact provenance" : `${candidate.score} confidence`)} · ${escapeHtml((candidate.reasons || []).join(", ") || "name similarity")}</strong></div>`,
)
.join("")
: '<div class="context-row"><span>Repository candidates</span><strong>No confident match; choose manually.</strong></div>';
const containerNames = (workload.containers || [])
.map((container) => container.name)
.filter(Boolean)
.join(", ");
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Link existing server workload</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero">${icon("link")}<div><strong>${escapeHtml(workload.displayName)}</strong><span>${escapeHtml(serverResult?.serverName || serverResult?.server?.name || ui.modal.serverId)} · ${workload.runtime?.running ? "running" : "stopped"}</span></div></div><div class="context-summary"><div class="context-row"><span>Containers</span><strong>${escapeHtml(containerNames || "Unknown")}</strong></div><div class="context-row"><span>Compose identity</span><strong>${escapeHtml(workload.compose?.project || "DockerMan / standalone container")} ${workload.compose?.services?.length ? `· ${escapeHtml(workload.compose.services.join(", "))}` : ""}</strong></div><div class="context-row"><span>Detected folder</span><strong class="mono">${escapeHtml(workload.compose?.workingDir || workload.dockerMan?.templatePath || "No Git checkout required")}</strong></div>${candidateSummary}</div><div class="form-grid" style="margin-top:14px"><div class="field full"><label>Repository to link</label><select id="workload-repository" class="select">${availableRepositories.map((item) => `<option value="${attr(item.fullName)}" ${item.fullName === suggestedRepository ? "selected" : ""}>${escapeHtml(item.fullName)}</option>`).join("") || '<option value="">No Gitea repositories available</option>'}</select></div><div class="field"><label>Deployment mode</label><select id="workload-deployment-mode" class="select"><option value="push-bundle" selected>Push bundle · recommended</option><option value="monitor-only">Monitor only</option><option value="server-git">Server-side Git · advanced</option></select></div><div class="field"><label>Managed server folder</label><input id="workload-remote-folder" class="input" value="${attr(remoteFolder)}"/></div></div><div class="notice success" style="margin-top:12px">${icon("shield")}Push bundle uploads the exact local Git commit over the already configured desktop → Unraid SSH connection. No Gitea SSH key is needed on Unraid. Linking does not recreate containers or rewrite an existing DockerMan template.</div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="confirm-link-server-workload" data-server-id="${attr(ui.modal.serverId)}" data-workload-id="${attr(ui.modal.workloadId)}" ${availableRepositories.length ? "" : "disabled"}>Link workload</button></footer></section></div>`;
}
if (ui.modal.type === "deployment-config") { if (ui.modal.type === "deployment-config") {
const storedProfile = const storedProfile =
repository?.deploymentProfiles?.find( repository?.deploymentProfiles?.find(
@@ -1196,30 +1325,35 @@ function renderModal() {
const ssh = provider === "ssh-unraid"; const ssh = provider === "ssh-unraid";
const remoteFolder = const remoteFolder =
existing.remoteFolder || safeCloneFolderName(repository); existing.remoteFolder || safeCloneFolderName(repository);
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>${existing.id ? "Edit" : "Add"} deployment environment</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="form-grid"><div class="field full"><label>Deployment provider</label><select id="profile-provider" class="select"><option value="ssh-unraid" ${ssh ? "selected" : ""}>SSH / Unraid · direct controlled deployment</option><option value="gitea-actions" ${!ssh ? "selected" : ""}>Gitea Actions · runner workflow</option></select></div>${ssh ? `<div class="field full"><div class="notice ${discovery ? "success" : ""}">${icon(discovery ? "check" : "server")}<div><strong>${discovery ? "Existing deployment imported from server" : "Use the server as source of truth"}</strong><p>${discovery ? `${escapeHtml(discovery.runtime?.containers || 0)} container(s), ${escapeHtml(discovery.runtime?.services || 0)} service(s) and ${escapeHtml(discovery.runtime?.ports?.length || 0)} port mapping(s) detected. Every imported value remains editable as an explicit override.` : "Select the server and folder, then let ForgeFlow read Git, Compose, Docker and DockerMan instead of guessing values."}</p><button type="button" class="button ${discovery ? "" : "primary"}" data-action="discover-existing-deployment">${icon("refresh")}${discovery ? "Rescan server" : "Import existing deployment"}</button></div></div></div>` : ""}<div class="field"><label>Profile name</label><input id="profile-name" class="input" value="${attr(existing.name || "Production")}" /></div><div class="field"><label>Environment</label><input id="profile-environment" class="input" value="${attr(existing.environment || "production")}" /></div><div class="field"><label>Allowed branch</label><input id="profile-branch" class="input" value="${attr(existing.branch || repository?.defaultBranch || "main")}" /></div>${ return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>${existing.id ? "Edit" : "Add"} deployment environment</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="form-grid"><div class="field full"><label>Deployment provider</label><select id="profile-provider" class="select"><option value="ssh-unraid" ${ssh ? "selected" : ""}>SSH / Unraid · direct controlled deployment</option><option value="gitea-actions" ${!ssh ? "selected" : ""}>Gitea Actions · runner workflow</option></select></div>${ssh ? `<div class="field full"><div class="notice ${discovery ? "success" : ""}">${icon(discovery ? "check" : "server")}<div><strong>${discovery ? "Existing deployment imported from server" : "Import by folder or use Server inventory"}</strong><p>${discovery ? `${escapeHtml(discovery.runtime?.containers || 0)} container(s), ${escapeHtml(discovery.runtime?.services || 0)} service(s) and ${escapeHtml(discovery.runtime?.ports?.length || 0)} port mapping(s) detected. Every imported value remains editable as an explicit override.` : "For a known folder, ForgeFlow can read Docker, Compose and DockerMan metadata. For uncertain matches, use Server inventory and select the actual running workload."}</p><button type="button" class="button ${discovery ? "" : "primary"}" data-action="discover-existing-deployment">${icon("refresh")}${discovery ? "Rescan folder" : "Import known folder"}</button></div></div></div>` : ""}<div class="field"><label>Profile name</label><input id="profile-name" class="input" value="${attr(existing.name || "Production")}" /></div><div class="field"><label>Environment</label><input id="profile-environment" class="input" value="${attr(existing.environment || "production")}" /></div><div class="field"><label>Allowed branch</label><input id="profile-branch" class="input" value="${attr(existing.branch || repository?.defaultBranch || "main")}" /></div>${
ssh ssh
? ` ? `
<div class="field"><label>Unraid server</label><select id="profile-server" class="select">${servers.length ? servers.map((server) => `<option value="${attr(server.id)}" ${server.id === existing.serverId ? "selected" : ""}>${escapeHtml(server.name)} · ${escapeHtml(server.host)}</option>`).join("") : '<option value="">Configure a server first</option>'}</select></div> <div class="field"><label>Unraid server</label><select id="profile-server" class="select">${servers.length ? servers.map((server) => `<option value="${attr(server.id)}" ${server.id === existing.serverId ? "selected" : ""}>${escapeHtml(server.name)} · ${escapeHtml(server.host)}</option>`).join("") : '<option value="">Configure a server first</option>'}</select></div>
<div class="field"><label>Server folder name</label><input id="profile-remote-folder" class="input" value="${attr(remoteFolder)}"/></div> <div class="field"><label>Server folder name</label><input id="profile-remote-folder" class="input" value="${attr(remoteFolder)}"/></div>
<div class="field"><label>Git clone URL used by Unraid</label><input id="profile-clone-url" class="input" value="${attr(existing.cloneUrl || repository?.sshUrl || "")}" placeholder="ssh://git@gitea:222/Jens/project.git"/></div> <div class="field"><label>Deployment mode</label><select id="profile-deployment-mode" class="select"><option value="push-bundle" ${(existing.deploymentMode || "push-bundle") === "push-bundle" ? "selected" : ""}>Push bundle · recommended</option><option value="monitor-only" ${existing.deploymentMode === "monitor-only" ? "selected" : ""}>Monitor only</option><option value="server-git" ${existing.deploymentMode === "server-git" ? "selected" : ""}>Server-side Git · advanced</option></select><small>Push bundle uses the desktop → Unraid key and does not require Gitea credentials on Unraid.</small></div>
<label class="check-field"><input id="profile-align-remote" type="checkbox" ${existing.alignRemote === true ? "checked" : ""}/><span>Align an existing server origin to this URL</span></label> <div class="field"><label>Git clone URL · Server-side Git only</label><input id="profile-clone-url" class="input" value="${attr(existing.cloneUrl || repository?.sshUrl || "")}" placeholder="ssh://git@gitea:222/Jens/project.git"/></div>
<div class="field"><label>Compose mode</label><select id="profile-generated-compose" class="select"><option value="false" ${existing.generatedCompose !== true ? "selected" : ""}>Use Compose file from repository/server</option><option value="true" ${existing.generatedCompose === true ? "selected" : ""}>Generate a basic ForgeFlow Compose file</option></select></div> <label class="check-field"><input id="profile-align-remote" type="checkbox" ${existing.alignRemote === true ? "checked" : ""}/><span>Align an existing server origin in Server-side Git mode</span></label>
<div class="field"><label>Compose file</label><input id="profile-compose-file" class="input" value="${attr(existing.composeFile || "docker-compose.yml")}"/></div> <div class="field"><label>Compose mode</label><select id="profile-generated-compose" class="select"><option value="false" ${existing.generatedCompose !== true ? "selected" : ""}>Use existing Compose definition</option><option value="true" ${existing.generatedCompose === true ? "selected" : ""}>Generate a basic ForgeFlow Compose file</option></select></div>
<div class="field"><label>Compose service (internal)</label><input id="profile-compose-service" class="input" value="${attr(existing.composeService || safeCloneFolderName(repository).toLowerCase())}"/><small>Must match the Compose service key and remain lowercase.</small></div><div class="field"><label>Visible container name</label><input id="profile-container-name" class="input" value="${attr(existing.containerName || remoteFolder)}"/><small>May remain Portfolio while internal image/service names are lowercase.</small></div> <div class="field"><label>Compose project identity</label><input id="profile-compose-project" class="input" value="${attr(existing.composeProject || "")}" placeholder="Existing docker compose project name"/><small>Kept stable to update the existing containers instead of creating duplicates.</small></div>
<div class="field full"><label>Compose files</label><input id="profile-compose-files" class="input" value="${attr((existing.composeFiles?.length ? existing.composeFiles : [existing.composeFile || "docker-compose.yml"]).join(", "))}"/><small>Comma-separated, in the same order used by the existing deployment. ForgeFlow adds its metadata overlay last.</small></div>
<div class="field full"><label>Compose services to verify</label><input id="profile-compose-services" class="input" value="${attr((existing.composeServices?.length ? existing.composeServices : [existing.composeService || safeCloneFolderName(repository).toLowerCase()]).join(", "))}"/><small>Compose service (internal) keys, comma-separated. All listed services must be running after deployment.</small></div><div class="field"><label>Visible container name</label><input id="profile-container-name" class="input" value="${attr(existing.containerName || remoteFolder)}"/><small>Used as an inventory hint; adopted Compose identity remains authoritative.</small></div>
<div class="field"><label>Host port</label><input id="profile-host-port" class="input" type="number" min="1" max="65535" value="${attr(existing.hostPort || "")}" placeholder="1223"/></div> <div class="field"><label>Host port</label><input id="profile-host-port" class="input" type="number" min="1" max="65535" value="${attr(existing.hostPort || "")}" placeholder="1223"/></div>
<div class="field"><label>Container port</label><input id="profile-container-port" class="input" type="number" min="1" max="65535" value="${attr(existing.containerPort || "")}" placeholder="8080"/></div> <div class="field"><label>Container port</label><input id="profile-container-port" class="input" type="number" min="1" max="65535" value="${attr(existing.containerPort || "")}" placeholder="8080"/></div>
<div class="field full"><label>Unraid Web UI URL (optional)</label><input id="profile-web-ui" class="input" value="${attr(existing.webUiUrl || "")}" placeholder="http://[IP]:[PORT:1223]/"/></div> <div class="field full"><label>Unraid Web UI URL (optional)</label><input id="profile-web-ui" class="input" value="${attr(existing.webUiUrl || "")}" placeholder="http://[IP]:[PORT:1223]/"/></div>
<div class="field"><label>DockerMan icon source</label><select id="profile-icon-mode" class="select"><option value="builtin" ${(existing.iconMode || (!existing.iconUrl && !existing.iconFilePath ? "builtin" : existing.iconFilePath ? "upload" : "url")) === "builtin" ? "selected" : ""}>Built-in high-contrast ITWorx mark</option><option value="upload" ${existing.iconMode === "upload" || (!existing.iconMode && existing.iconFilePath) ? "selected" : ""}>Upload local PNG</option><option value="url" ${existing.iconMode === "url" || (!existing.iconMode && existing.iconUrl) ? "selected" : ""}>Use icon URL</option><option value="none" ${existing.iconMode === "none" ? "selected" : ""}>No custom icon</option></select></div><div class="field"><label>Container shell</label><select id="profile-docker-shell" class="select"><option value="/bin/sh" ${(existing.dockerShell || "/bin/sh") === "/bin/sh" ? "selected" : ""}>/bin/sh</option><option value="/bin/bash" ${existing.dockerShell === "/bin/bash" ? "selected" : ""}>/bin/bash</option></select></div> <div class="field"><label>DockerMan icon source</label><select id="profile-icon-mode" class="select"><option value="builtin" ${(existing.iconMode || (!existing.iconUrl && !existing.iconFilePath ? "builtin" : existing.iconFilePath ? "upload" : "url")) === "builtin" ? "selected" : ""}>Built-in high-contrast ITWorx mark</option><option value="upload" ${existing.iconMode === "upload" || (!existing.iconMode && existing.iconFilePath) ? "selected" : ""}>Upload local PNG</option><option value="url" ${existing.iconMode === "url" || (!existing.iconMode && existing.iconUrl) ? "selected" : ""}>Use icon URL</option><option value="none" ${existing.iconMode === "none" ? "selected" : ""}>No custom icon</option></select></div><div class="field"><label>Container shell</label><select id="profile-docker-shell" class="select"><option value="/bin/sh" ${(existing.dockerShell || "/bin/sh") === "/bin/sh" ? "selected" : ""}>/bin/sh</option><option value="/bin/bash" ${existing.dockerShell === "/bin/bash" ? "selected" : ""}>/bin/bash</option></select></div>
<div class="field full"><label>DockerMan icon URL</label><input id="profile-icon-url" class="input" value="${attr(existing.iconUrl || "")}" placeholder="https://…/icon.png"/></div><div class="field full"><label>Local PNG</label><div class="inline-form"><input id="profile-icon-file" class="input mono" value="${attr(existing.iconFilePath || "")}" placeholder="Select a local transparent PNG" readonly/><button class="button" data-action="select-profile-icon">${icon("folder")}Browse</button><button class="button ghost" data-action="clear-profile-icon">Clear</button></div><small>Built-in or uploaded PNGs are copied to DockerMan's persistent image folder and referenced through a file:/// URL. ForgeFlow also refreshes the relevant Unraid icon cache after recreating the container.</small></div> <div class="field full"><label>DockerMan icon URL</label><input id="profile-icon-url" class="input" value="${attr(existing.iconUrl || "")}" placeholder="https://…/icon.png"/></div><div class="field full"><label>Local PNG</label><div class="inline-form"><input id="profile-icon-file" class="input mono" value="${attr(existing.iconFilePath || "")}" placeholder="Select a local transparent PNG" readonly/><button class="button" data-action="select-profile-icon">${icon("folder")}Browse</button><button class="button ghost" data-action="clear-profile-icon">Clear</button></div><small>Built-in or uploaded PNGs are copied to DockerMan's persistent image folder and referenced through a file:/// URL. ForgeFlow also refreshes the relevant Unraid icon cache after recreating the container.</small></div>
<div class="field full"><label>Healthcheck URL from this desktop (optional)</label><input id="profile-healthcheck" class="input" value="${attr(existing.healthcheckUrl || "")}" placeholder="http://unraid:1223/health"/></div> <div class="field full"><label>Healthcheck URL from this desktop (optional)</label><input id="profile-healthcheck" class="input" value="${attr(existing.healthcheckUrl || "")}" placeholder="http://unraid:1223/health"/></div>
<div class="field full"><label>Preserve server-only paths</label><input id="profile-preserve-paths" class="input" value="${attr((existing.preservePaths || [".env", "appdata", "data", "logs", "config", "compose.override.yml"]).join(", "))}"/><small>These untracked runtime paths remain untouched by Git deployments.</small></div> <div class="field full"><label>Preserve server-only paths</label><input id="profile-preserve-paths" class="input" value="${attr((existing.preservePaths || [".env", "appdata", "data", "logs", "config", "compose.override.yml"]).join(", "))}"/><small>Push bundle never replaces these paths and only removes files previously managed by ForgeFlow.</small></div>
<label class="check-field"><input id="profile-manage-dockerman" type="checkbox" ${existing.manageDockerMan === true ? "checked" : ""}/><span>Manage a generated DockerMan template</span><small>Existing/imported DockerMan templates are always preserved. This applies only to ForgeFlow-generated Compose deployments.</small></label>
<label class="check-field"><input id="profile-force-recreate" type="checkbox" ${existing.forceRecreate === true ? "checked" : ""}/><span>Force-recreate containers</span><small>Off by default for safely adopted deployments.</small></label>
<label class="check-field"><input id="profile-remove-orphans" type="checkbox" ${existing.removeOrphans === true ? "checked" : ""}/><span>Remove Compose orphans</span><small>Enable only after reviewing the existing Compose project.</small></label>
` `
: ` : `
<div class="field"><label>Deploy workflow file</label><input id="profile-workflow" class="input" value="${attr(existing.workflowFile || "deploy.yml")}" /></div> <div class="field"><label>Deploy workflow file</label><input id="profile-workflow" class="input" value="${attr(existing.workflowFile || "deploy.yml")}" /></div>
<div class="field full"><label>Rollback workflow file (optional)</label><input id="profile-rollback-workflow" class="input" value="${attr(existing.rollbackWorkflowFile || "")}" placeholder="rollback.yml" /></div> <div class="field full"><label>Rollback workflow file (optional)</label><input id="profile-rollback-workflow" class="input" value="${attr(existing.rollbackWorkflowFile || "")}" placeholder="rollback.yml" /></div>
<div class="field full"><label>Application status URL</label><input id="profile-status-url" class="input" value="${attr(existing.statusUrl || "")}" required placeholder="https://app.example.com/.well-known/forgeflow" /></div> <div class="field full"><label>Application status URL</label><input id="profile-status-url" class="input" value="${attr(existing.statusUrl || "")}" required placeholder="https://app.example.com/.well-known/forgeflow" /></div>
<div class="field full"><label>Healthcheck URL (optional)</label><input id="profile-healthcheck" class="input" value="${attr(existing.healthcheckUrl || "")}" placeholder="https://app.example.com/health" /></div>` <div class="field full"><label>Healthcheck URL (optional)</label><input id="profile-healthcheck" class="input" value="${attr(existing.healthcheckUrl || "")}" placeholder="https://app.example.com/health" /></div>`
}<label class="check-field full"><input id="profile-confirmation" type="checkbox" ${existing.confirmationRequired !== false ? "checked" : ""}/><span>Require an explicit confirmation before deployment</span></label></div><div class="notice" style="margin-top:13px">${icon("shield")}${ssh ? "ForgeFlow connects over pinned SSH, refuses tracked server-side changes, deploys the exact Git SHA and preserves untracked runtime data." : "ForgeFlow sends only controlled workflow inputs: environment, exact SHA and a unique request ID."}</div></div><footer class="modal-footer">${existing.id ? `<button class="button danger" data-action="delete-deployment-profile" data-profile-id="${attr(existing.id)}">Delete</button>` : ""}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-deployment-profile" data-profile-id="${attr(existing.id || "")}" ${ssh && !servers.length ? "disabled" : ""}>Save environment</button></footer></section></div>`; }<label class="check-field full"><input id="profile-confirmation" type="checkbox" ${existing.confirmationRequired !== false ? "checked" : ""}/><span>Require an explicit confirmation before deployment</span></label></div><div class="notice" style="margin-top:13px">${icon("shield")}${ssh ? "Push bundle is the safe default: ForgeFlow archives the exact local commit, uploads it over pinned SSH, validates Compose, verifies every selected service and only then promotes the live release. Server-side Git remains optional." : "ForgeFlow sends only controlled workflow inputs: environment, exact SHA and a unique request ID."}</div></div><footer class="modal-footer">${existing.id ? `<button class="button danger" data-action="delete-deployment-profile" data-profile-id="${attr(existing.id)}">Delete</button>` : ""}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-deployment-profile" data-profile-id="${attr(existing.id || "")}" ${ssh && !servers.length ? "disabled" : ""}>Save environment</button></footer></section></div>`;
} }
if (ui.modal.type === "deployment-preflight") { if (ui.modal.type === "deployment-preflight") {
const profile = const profile =
@@ -1249,7 +1383,7 @@ function renderModal() {
(item) => item.id === ui.modal.serverId, (item) => item.id === ui.modal.serverId,
) || {}; ) || {};
const authType = ui.modal.authType || server.authType || "privateKey"; const authType = ui.modal.authType || server.authType || "privateKey";
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>${server.id ? "Edit" : "Add"} SSH / Unraid server</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="form-grid"><div class="field"><label>Name</label><input id="server-name" class="input" value="${attr(server.name || "Unraid")}"/></div><div class="field"><label>Host or IP</label><input id="server-host" class="input" value="${attr(server.host || "")}" placeholder="192.168.1.10"/></div><div class="field"><label>SSH port</label><input id="server-port" class="input" type="number" min="1" max="65535" value="${attr(server.port || 22)}"/></div><div class="field"><label>Username</label><input id="server-username" class="input" value="${attr(server.username || "root")}"/></div><div class="field"><label>Authentication</label><select id="server-auth-type" class="select"><option value="privateKey" ${authType === "privateKey" ? "selected" : ""}>Private key · recommended</option><option value="password" ${authType === "password" ? "selected" : ""}>Password</option></select></div><div class="field"><label>Appdata base path</label><input id="server-base-path" class="input" value="${attr(server.basePath || "/mnt/user/appdata")}"/></div>${authType === "privateKey" ? `<div class="field full"><label>Private key file</label><div class="input-action"><input id="server-private-key" class="input" value="${attr(server.privateKeyPath || "")}" placeholder="C:\\Users\\Jens\\.ssh\\id_ed25519"/><button class="button" data-action="select-private-key">Browse</button></div></div><div class="field full"><label>Private key passphrase</label><input id="server-passphrase" class="input" type="password" placeholder="${server.hasPassphrase ? "Leave empty to keep stored passphrase" : "Only when the key is encrypted"}"/></div>` : `<div class="field full"><label>SSH password</label><input id="server-password" class="input" type="password" placeholder="${server.hasPassword ? "Leave empty to keep stored password" : "Password"}"/></div>`}<div class="field full"><label>Trusted host fingerprint</label><input id="server-fingerprint" class="input mono" value="${attr(server.hostFingerprint || "")}" readonly placeholder="Filled automatically after Test & trust"/></div></div><div class="notice warning" style="margin-top:12px">${icon("key")}The first connection records the SSH host-key fingerprint. Later deployments fail closed when the server presents a different key.</div></div><footer class="modal-footer">${server.id ? `<button class="button danger" data-action="delete-server" data-server-id="${attr(server.id)}">Delete</button>` : ""}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-server" data-server-id="${attr(server.id || "")}">Save server</button></footer></section></div>`; return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>${server.id ? "Edit" : "Add"} SSH / Unraid server</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="form-grid"><div class="field"><label>Name</label><input id="server-name" class="input" value="${attr(server.name || "Unraid")}"/></div><div class="field"><label>Host or IP</label><input id="server-host" class="input" value="${attr(server.host || "")}" placeholder="192.168.1.10"/></div><div class="field"><label>SSH port</label><input id="server-port" class="input" type="number" min="1" max="65535" value="${attr(server.port || 22)}"/></div><div class="field"><label>Username</label><input id="server-username" class="input" value="${attr(server.username || "root")}"/></div><div class="field"><label>Authentication</label><select id="server-auth-type" class="select"><option value="privateKey" ${authType === "privateKey" ? "selected" : ""}>Private key · recommended</option><option value="password" ${authType === "password" ? "selected" : ""}>Password</option></select></div><div class="field"><label>Appdata base path</label><input id="server-base-path" class="input" value="${attr(server.basePath || "/mnt/user/appdata")}"/></div>${authType === "privateKey" ? `<div class="field full"><label>Private key file</label><div class="input-action"><input id="server-private-key" class="input" value="${attr(server.privateKeyPath || "")}" placeholder="C:\\Users\\Jens\\.ssh\\id_ed25519"/><button class="button" data-action="select-private-key">Browse</button></div></div><div class="field full"><label>Private key passphrase</label><input id="server-passphrase" class="input" type="password" placeholder="${server.hasPassphrase ? "Leave empty to keep stored passphrase" : "Only when the key is encrypted"}"/></div>` : `<div class="field full"><label>SSH password</label><input id="server-password" class="input" type="password" placeholder="${server.hasPassword ? "Leave empty to keep stored password" : "Password"}"/></div>`}<div class="field full"><label>Trusted host fingerprint</label><input id="server-fingerprint" class="input mono" value="${attr(server.hostFingerprint || "")}" readonly placeholder="Filled automatically after Test & trust"/></div></div><div class="notice warning" style="margin-top:12px">${icon("key")}This identity is used only for the desktop → Unraid connection. Push bundle deployments reuse it to upload files and do not require a separate Gitea key on Unraid. The first connection records the server host-key fingerprint; later connections fail closed if it changes.</div></div><footer class="modal-footer">${server.id ? `<button class="button danger" data-action="delete-server" data-server-id="${attr(server.id)}">Delete</button>` : ""}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-server" data-server-id="${attr(server.id || "")}">Save server</button></footer></section></div>`;
} }
if (ui.modal.type === "hunk-staging") { if (ui.modal.type === "hunk-staging") {
const hunks = ui.diffHunks?.hunks || []; const hunks = ui.diffHunks?.hunks || [];
@@ -1632,7 +1766,19 @@ app.addEventListener("click", async (event) => {
ui.repositoryTab = target.dataset.tab; ui.repositoryTab = target.dataset.tab;
if (ui.repositoryTab === "gittools" && !ui.branches.length) if (ui.repositoryTab === "gittools" && !ui.branches.length)
await loadGitTools(repository); await loadGitTools(repository);
else if (ui.repositoryTab === "settings") { else if (ui.repositoryTab === "validator" && !ui.gitValidation) {
setLoading(true, "Validating Git and Gitea best practices…");
try {
ui.gitValidation = await window.forgeflow.gitValidatorScan(
repository.fullName,
);
} catch (error) {
showToast("Git Validator failed", error.message, "error");
} finally {
setLoading(false);
}
render();
} else if (ui.repositoryTab === "settings") {
try { try {
ui.pullRequests = await window.forgeflow.pullRequests( ui.pullRequests = await window.forgeflow.pullRequests(
repository.fullName, repository.fullName,
@@ -1644,6 +1790,61 @@ app.addEventListener("click", async (event) => {
} }
render(); render();
} else render(); } else render();
} else if (action === "git-validator-scan") {
setLoading(true, "Validating Git and Gitea best practices…");
try {
ui.gitValidation = await window.forgeflow.gitValidatorScan(
repository.fullName,
);
showToast(
"Git validation complete",
`${ui.gitValidation.score}/100 · ${ui.gitValidation.grade}`,
ui.gitValidation.summary.errors ? "error" : "success",
);
} catch (error) {
showToast("Git Validator failed", error.message, "error");
} finally {
setLoading(false);
}
} else if (action === "git-validator-repair") {
const check = ui.gitValidation?.checks?.[Number(target.dataset.checkIndex)];
if (!check?.fixAction) return;
if (!check.safe && !confirm(check.confirmation || `Apply ${check.title}?`))
return;
setLoading(true, `Repairing ${check.title}`);
try {
await window.forgeflow.gitValidatorRepair(repository.fullName, check);
await refreshRepositories(false, true);
ui.gitValidation = await window.forgeflow.gitValidatorScan(
repository.fullName,
);
showToast("Git best practice repaired", check.title, "success");
} catch (error) {
showToast("Repair failed", error.message, "error");
} finally {
setLoading(false);
}
} else if (action === "git-validator-repair-safe") {
const checks = (ui.gitValidation?.checks || []).filter(
(check) => check.fixAction && check.safe,
);
setLoading(true, `Applying ${checks.length} safe Git fixes…`);
let repaired = 0;
try {
for (const check of checks) {
await window.forgeflow.gitValidatorRepair(repository.fullName, check);
repaired += 1;
}
await refreshRepositories(false, true);
ui.gitValidation = await window.forgeflow.gitValidatorScan(
repository.fullName,
);
showToast("Safe Git fixes applied", `${repaired} repaired.`, "success");
} catch (error) {
showToast("Safe repair stopped", error.message, "error");
} finally {
setLoading(false);
}
} else if (action === "toggle-favorite") { } else if (action === "toggle-favorite") {
ui.boot.state = await window.forgeflow.favoriteRepository( ui.boot.state = await window.forgeflow.favoriteRepository(
repository.fullName, repository.fullName,
@@ -2007,6 +2208,98 @@ app.addEventListener("click", async (event) => {
"success", "success",
); );
} }
} else if (action === "scan-server-inventory") {
setLoading(true, "Scanning Docker, Compose and DockerMan workloads…");
try {
await refreshDeploymentTruth(true);
const detected = (ui.serverDiscovery || []).reduce(
(total, item) => total + Number(item.detected || 0),
0,
);
const review = (ui.serverDiscovery || []).reduce(
(total, item) => total + Number(item.needsReview || 0),
0,
);
showToast(
"Server inventory updated",
`${detected} workload${detected === 1 ? "" : "s"} detected; ${review} require manual review.`,
review ? "info" : "success",
);
} catch (error) {
showToast("Server scan failed", error.message, "error");
}
setLoading(false);
} else if (action === "link-server-workload") {
const serverResult = (ui.serverDiscovery || []).find(
(item) => item.serverId === target.dataset.serverId,
);
const workload = serverResult?.workloads?.find(
(item) => item.workloadId === target.dataset.workloadId,
);
if (!workload) {
showToast(
"Workload unavailable",
"Scan the server inventory again before linking this workload.",
"error",
);
return;
}
ui.modal = {
type: "workload-link",
serverId: target.dataset.serverId,
workloadId: target.dataset.workloadId,
repositoryFullName:
workload.candidates?.[0]?.repositoryFullName ||
repository?.fullName ||
ui.repositories[0]?.fullName ||
"",
remoteFolder: workload.remoteFolderCandidate || "",
};
render();
} else if (action === "confirm-link-server-workload") {
const repositoryFullName = document
.querySelector("#workload-repository")
?.value.trim();
const deploymentMode =
document.querySelector("#workload-deployment-mode")?.value ||
"push-bundle";
const remoteFolder = document
.querySelector("#workload-remote-folder")
?.value.trim();
const linkedRepository = ui.repositories.find(
(item) => item.fullName === repositoryFullName,
);
if (!linkedRepository) {
showToast(
"Choose a repository",
"The workload must be linked to a Gitea repository.",
"error",
);
return;
}
setLoading(true, "Saving the permanent server workload link…");
try {
const result = await window.forgeflow.linkServerWorkload(
linkedRepository,
target.dataset.serverId,
target.dataset.workloadId,
deploymentMode,
remoteFolder,
);
if (result.state) ui.boot.state = result.state;
ui.modal = null;
ui.selectedProfileId = result.profile?.id || null;
await refreshRepositories(false, true);
await refreshDeploymentTruth(false);
showToast(
"Workload linked",
`${linkedRepository.fullName} now uses ${deploymentMode === "push-bundle" ? "safe bundle upload" : deploymentMode === "monitor-only" ? "monitor-only mode" : "server-side Git"}.`,
"success",
);
} catch (error) {
showToast("Could not link workload", error.message, "error");
}
setLoading(false);
} else if (action === "configure-deployment") { } else if (action === "configure-deployment") {
ui.deploymentDiscovery = null; ui.deploymentDiscovery = null;
ui.modal = { ui.modal = {
@@ -2019,7 +2312,7 @@ app.addEventListener("click", async (event) => {
render(); render();
} else if (action === "edit-deployment-profile") { } else if (action === "edit-deployment-profile") {
ui.deploymentDiscovery = null; ui.deploymentDiscovery = null;
if (!repository) repository = profileRepository(target.dataset.profileId); repository = profileRepository(target.dataset.profileId) || repository;
if (repository && String(repository.id) !== String(ui.selectedRepoId)) if (repository && String(repository.id) !== String(ui.selectedRepoId))
selectRepository(repository.id, false); selectRepository(repository.id, false);
ui.modal = { ui.modal = {
@@ -2112,6 +2405,20 @@ app.addEventListener("click", async (event) => {
showToast("Could not save profile", error.message, "error"); showToast("Could not save profile", error.message, "error");
return; return;
} }
const composeFiles =
provider === "ssh-unraid"
? (document.querySelector("#profile-compose-files")?.value || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean)
: [];
const composeServices =
provider === "ssh-unraid"
? (document.querySelector("#profile-compose-services")?.value || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean)
: [];
const profile = { const profile = {
id: target.dataset.profileId || undefined, id: target.dataset.profileId || undefined,
provider, provider,
@@ -2139,18 +2446,27 @@ app.addEventListener("click", async (event) => {
remoteFolder: document remoteFolder: document
.querySelector("#profile-remote-folder") .querySelector("#profile-remote-folder")
.value.trim(), .value.trim(),
deploymentMode:
document.querySelector("#profile-deployment-mode")?.value ||
"push-bundle",
cloneUrl: document.querySelector("#profile-clone-url").value.trim(), cloneUrl: document.querySelector("#profile-clone-url").value.trim(),
alignRemote: document.querySelector("#profile-align-remote") alignRemote: document.querySelector("#profile-align-remote")
.checked, .checked,
generatedCompose: generatedCompose:
document.querySelector("#profile-generated-compose").value === document.querySelector("#profile-generated-compose").value ===
"true", "true",
composeFile: document composeProject:
.querySelector("#profile-compose-file") document.querySelector("#profile-compose-project")?.value.trim() ||
.value.trim(), previousProfile.composeProject ||
composeService: document "",
.querySelector("#profile-compose-service") composeWorkingDir: previousProfile.composeWorkingDir || "",
.value.trim(), composeFiles: composeFiles.length ? composeFiles : ["docker-compose.yml"],
composeFile: composeFiles[0] || "docker-compose.yml",
composeServices: composeServices.length
? composeServices
: [safeCloneFolderName(repository).toLowerCase()],
composeService:
composeServices[0] || safeCloneFolderName(repository).toLowerCase(),
containerName: document containerName: document
.querySelector("#profile-container-name") .querySelector("#profile-container-name")
.value.trim(), .value.trim(),
@@ -2172,8 +2488,25 @@ app.addEventListener("click", async (event) => {
.value.split(",") .value.split(",")
.map((item) => item.trim()) .map((item) => item.trim())
.filter(Boolean), .filter(Boolean),
adoptedFromServer: Boolean(ui.deploymentDiscovery), manageDockerMan:
serverSourceOfTruth: Boolean(ui.deploymentDiscovery), document.querySelector("#profile-manage-dockerman")?.checked ===
true,
forceRecreate:
document.querySelector("#profile-force-recreate")?.checked ===
true,
removeOrphans:
document.querySelector("#profile-remove-orphans")?.checked ===
true,
adoptedFromServer: Boolean(
ui.deploymentDiscovery || previousProfile.adoptedFromServer,
),
serverSourceOfTruth: Boolean(
ui.deploymentDiscovery || previousProfile.serverSourceOfTruth,
),
workloadIdentity:
ui.deploymentDiscovery?.profile?.workloadIdentity ||
previousProfile.workloadIdentity ||
null,
detectedAt: detectedAt:
ui.deploymentDiscovery?.profile?.detectedAt || ui.deploymentDiscovery?.profile?.detectedAt ||
previousProfile.detectedAt || previousProfile.detectedAt ||
@@ -2623,15 +2956,18 @@ app.addEventListener("click", async (event) => {
} else if (action === "test-server") { } else if (action === "test-server") {
setLoading( setLoading(
true, true,
"Connecting to Unraid and checking Git and Docker Compose…", "Checking SSH identity, Docker, Compose and optional Git capabilities…",
); );
try { try {
const result = await window.forgeflow.testServer(target.dataset.serverId); const result = await window.forgeflow.testServer(target.dataset.serverId);
ui.boot.state = result.state; ui.boot.state = result.state;
const capabilities = result.capabilities || {};
const deploymentReady =
capabilities.docker && capabilities.dockerReady && capabilities.compose;
showToast( showToast(
"SSH server ready", deploymentReady ? "SSH server ready" : "SSH connected with missing tools",
`${result.server.name} presented ${result.fingerprint}.`, `${result.server.name} presented ${result.fingerprint}. Docker ${capabilities.dockerReady ? "ready" : "unavailable"}; Compose ${capabilities.compose ? "ready" : "missing"}; server-side Git ${capabilities.git ? "available" : "not installed (optional)"}.`,
"success", deploymentReady ? "success" : "info",
); );
} catch (error) { } catch (error) {
showToast("SSH test failed", error.message, "error"); showToast("SSH test failed", error.message, "error");
@@ -3259,7 +3595,7 @@ document.addEventListener("keydown", (event) => {
document.addEventListener("pointermove", (event) => { document.addEventListener("pointermove", (event) => {
const illustration = event.target.closest?.("[data-project-illustration]"); const illustration = event.target.closest?.("[data-project-illustration]");
if (!illustration) return; if (illustration) {
const bounds = illustration.getBoundingClientRect(); const bounds = illustration.getBoundingClientRect();
illustration.style.setProperty( illustration.style.setProperty(
"--tilt-x", "--tilt-x",
@@ -3269,13 +3605,36 @@ document.addEventListener("pointermove", (event) => {
"--tilt-y", "--tilt-y",
`${((event.clientX - bounds.left) / bounds.width - 0.5) * 9}deg`, `${((event.clientX - bounds.left) / bounds.width - 0.5) * 9}deg`,
); );
}
const diffPanel = event.target.closest?.(".diff-panel");
const atmosphere = diffPanel?.querySelector("[data-diff-atmosphere]");
if (atmosphere) {
const bounds = diffPanel.getBoundingClientRect();
atmosphere.style.setProperty(
"--diff-tilt-x",
`${((event.clientY - bounds.top) / bounds.height - 0.5) * -3}deg`,
);
atmosphere.style.setProperty(
"--diff-tilt-y",
`${((event.clientX - bounds.left) / bounds.width - 0.5) * 4}deg`,
);
}
}); });
document.addEventListener("pointerout", (event) => { document.addEventListener("pointerout", (event) => {
const illustration = event.target.closest?.("[data-project-illustration]"); const illustration = event.target.closest?.("[data-project-illustration]");
if (!illustration || illustration.contains(event.relatedTarget)) return; if (illustration && !illustration.contains(event.relatedTarget)) {
illustration.style.removeProperty("--tilt-x"); illustration.style.removeProperty("--tilt-x");
illustration.style.removeProperty("--tilt-y"); illustration.style.removeProperty("--tilt-y");
}
const diffPanel = event.target.closest?.(".diff-panel");
if (diffPanel && !diffPanel.contains(event.relatedTarget)) {
const atmosphere = diffPanel.querySelector("[data-diff-atmosphere]");
atmosphere?.style.removeProperty("--diff-tilt-x");
atmosphere?.style.removeProperty("--diff-tilt-y");
}
}); });
window.addEventListener("error", (event) => { window.addEventListener("error", (event) => {
+225 -2
View File
@@ -564,7 +564,7 @@
await wait(80); await wait(80);
snapshot(); snapshot();
return { return {
appVersion: "0.8.5-demo", appVersion: "0.9.0-demo",
platform: "win32", platform: "win32",
state: clone(state), state: clone(state),
git: { available: true, version: "git version 2.47.3" }, git: { available: true, version: "git version 2.47.3" },
@@ -1204,7 +1204,7 @@
)), )),
...input, ...input,
id: input.id || `profile-${Date.now()}`, id: input.id || `profile-${Date.now()}`,
provider: "gitea-actions", provider: input.provider || existing?.provider || "gitea-actions",
inputs: existing?.inputs || {}, inputs: existing?.inputs || {},
state: existing?.state || { state: existing?.state || {
liveSha: null, liveSha: null,
@@ -1421,6 +1421,118 @@
syncState(); syncState();
return clone(target.state); return clone(target.state);
}, },
async discoverServerDeployments() {
await wait(80);
return [
{
serverId: "server-unraid",
serverName: "Unraid",
detected: 2,
adopted: 0,
verified: 1,
linked: 1,
unmatched: 0,
needsReview: 1,
running: 2,
stopped: 0,
capabilities: {
docker: true,
dockerReady: true,
compose: true,
git: false,
tar: true,
checksum: true,
},
warnings: [],
workloads: [
{
workloadId: "workload-demo-linked",
displayName: "Portfolio",
status: "linked",
runtime: { running: true, health: "healthy" },
compose: {
project: "portfolio",
workingDir: "/mnt/user/appdata/portfolio",
configFiles: ["/mnt/user/appdata/portfolio/docker-compose.yml"],
services: ["web"],
},
containers: [{ name: "Portfolio", running: true }],
candidates: [],
link: {
profileId: "profile-portfolio",
repositoryFullName: "jens/portfolio",
source: "manual",
},
},
{
workloadId: "workload-demo-review",
displayName: "OmniRoute",
status: "suggested",
runtime: { running: true, health: "unverified" },
compose: {
project: "omniroute",
workingDir: "/mnt/user/appdata/OmniRoute",
configFiles: ["/mnt/user/appdata/OmniRoute/docker-compose.yml"],
services: ["omniroute"],
},
containers: [{ name: "omniroute", running: true }],
remoteFolderCandidate: "OmniRoute",
candidates: repositories.slice(0, 1).map((repository) => ({
repositoryFullName: repository.fullName,
repositoryName: repository.name,
score: 55,
exact: false,
reasons: ["container and repository names are similar"],
})),
},
],
},
];
},
async linkServerWorkload(repository, serverId, workloadId, deploymentMode = "push-bundle", remoteFolder = "") {
await wait(120);
const repo = repositories.find((item) => item.fullName === repository.fullName);
if (!repo) throw new Error("Repository not found.");
const id = `profile-${workloadId}`;
const saved = {
id,
name: `Unraid · ${remoteFolder || repo.name}`,
environment: "production",
provider: "ssh-unraid",
branch: repo.defaultBranch || "main",
serverId,
remoteFolder: remoteFolder || repo.name,
deploymentMode,
composeFile: "docker-compose.yml",
composeFiles: ["docker-compose.yml"],
composeProject: String(remoteFolder || repo.name).toLowerCase(),
composeService: String(remoteFolder || repo.name).toLowerCase(),
composeServices: [String(remoteFolder || repo.name).toLowerCase()],
containerName: remoteFolder || repo.name,
preservePaths: [".env", "appdata", "data", "logs", "config"],
generatedCompose: false,
adoptedFromServer: true,
serverSourceOfTruth: true,
manageDockerMan: false,
forceRecreate: false,
removeOrphans: false,
workloadIdentity: { workloadId, linkSource: "manual", linkedAt: iso() },
confirmationRequired: true,
state: {
liveSha: null,
healthy: null,
containerRunning: true,
runtimeVerification: "running-unverified",
checkedAt: iso(),
},
};
repo.deploymentProfiles = [
...repo.deploymentProfiles.filter((item) => item.id !== id),
saved,
];
syncState();
return { profile: clone(saved), state: clone(state) };
},
async refreshOperations(operationId = null) { async refreshOperations(operationId = null) {
await wait(300); await wait(300);
if (operationId) { if (operationId) {
@@ -1449,6 +1561,117 @@
state.operations.find((item) => item.id === operationId) || null, state.operations.find((item) => item.id === operationId) || null,
); );
}, },
async gitValidatorScan(fullName) {
await wait(260);
return {
repository: fullName,
checkedAt: iso(),
score: 78,
grade: "Good",
summary: { passed: 7, warnings: 3, errors: 0, repairable: 2 },
checks: [
{
id: "origin",
category: "Repository identity",
title: "Origin matches Gitea",
status: "pass",
detail: "The local origin resolves to this Gitea repository.",
weight: 15,
},
{
id: "default-branch-protection",
category: "Gitea governance",
title: "Default branch protection",
status: "warning",
detail: "main accepts unprotected direct changes.",
weight: 18,
fixAction: "protect-default-branch",
safe: false,
confirmation:
"Protect main on Gitea and block direct and force pushes?",
},
{
id: "force-push",
category: "Gitea governance",
title: "Force-push protection",
status: "pass",
detail: "Force pushes are blocked.",
weight: 8,
},
{
id: "upstream",
category: "Branch hygiene",
title: "Current branch has an upstream",
status: "pass",
detail: "main tracks origin/main.",
weight: 8,
},
{
id: "working-tree",
category: "Branch hygiene",
title: "Working tree is intentional",
status: "warning",
detail: "3 changed files require review, commit or stash.",
weight: 5,
},
{
id: "identity",
category: "Commit integrity",
title: "Repository author identity",
status: "pass",
detail: "Jens <jens@example.test>",
weight: 7,
},
{
id: "local-safety",
category: "Local configuration",
title: "Safe synchronization defaults",
status: "warning",
detail: "Recommended repository-local safeguards are incomplete.",
weight: 10,
fixAction: "configure-local-safety",
safe: true,
},
{
id: "readme",
category: "Repository documentation",
title: "README is versioned",
status: "pass",
detail: "Repository documentation is tracked.",
weight: 7,
},
{
id: "gitignore",
category: "Repository hygiene",
title: ".gitignore is versioned",
status: "pass",
detail: "Generated files are excluded centrally.",
weight: 8,
},
{
id: "tracked-secrets",
category: "Security",
title: "No secret-shaped files are tracked",
status: "pass",
detail:
"No tracked environment, key or credential filenames detected.",
weight: 22,
},
{
id: "large-files",
category: "Repository performance",
title: "No oversized tracked files",
status: "pass",
detail: "No tracked files above 10 MB were found.",
weight: 7,
},
],
};
},
async gitValidatorRepair() {
await wait(180);
return { repaired: true };
},
async diagnosticsStatus() { async diagnosticsStatus() {
return { return {
enabled: state.preferences.diagnosticsEnabled !== false, enabled: state.preferences.diagnosticsEnabled !== false,
+433 -4
View File
@@ -970,27 +970,56 @@ select:focus-visible {
scrollbar-gutter: stable; scrollbar-gutter: stable;
} }
.file-row { .file-row {
position: relative;
width: 100%; width: 100%;
min-height: 34px; min-height: 38px;
display: grid; display: grid;
grid-template-columns: 17px 17px minmax(0, 1fr) 16px; grid-template-columns: 17px 17px minmax(0, 1fr) 16px;
gap: 7px; gap: 7px;
align-items: center; align-items: center;
padding: 3px 6px; padding: 4px 8px;
border-radius: 4px; border: 1px solid transparent;
border-radius: 7px;
background: transparent; background: transparent;
color: var(--text-muted); color: var(--text-muted);
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
transition:
transform 160ms ease,
border-color 160ms ease,
background 160ms ease,
box-shadow 160ms ease;
} }
.file-row:hover { .file-row:hover {
background: var(--surface-hover); background: var(--surface-hover);
color: var(--text); color: var(--text);
border-color: color-mix(in srgb, var(--primary) 18%, transparent);
transform: translateX(2px);
} }
.file-row.active { .file-row.active {
background: var(--primary-soft); background:
linear-gradient(90deg, var(--primary-soft), transparent 110%),
var(--surface-1);
border-color: color-mix(in srgb, var(--primary) 36%, var(--line));
box-shadow:
inset 3px 0 0 var(--primary),
0 7px 22px rgba(0, 0, 0, 0.12);
color: var(--text); color: var(--text);
} }
.file-row > span:last-child {
color: var(--text-faint);
filter: drop-shadow(0 0 5px transparent);
transition:
color 160ms ease,
filter 160ms ease;
}
.file-row:hover > span:last-child,
.file-row.active > span:last-child {
color: var(--primary);
filter: drop-shadow(
0 0 5px color-mix(in srgb, var(--primary) 45%, transparent)
);
}
.file-row input { .file-row input {
margin: 0; margin: 0;
accent-color: var(--primary-strong); accent-color: var(--primary-strong);
@@ -1003,6 +1032,12 @@ select:focus-visible {
font-size: 11px; font-size: 11px;
} }
.file-status { .file-status {
display: inline-grid;
place-items: center;
width: 17px;
height: 17px;
border-radius: 5px;
background: color-mix(in srgb, currentColor 11%, transparent);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
font-weight: 750; font-weight: 750;
@@ -1017,6 +1052,7 @@ select:focus-visible {
color: var(--danger); color: var(--danger);
} }
.diff-panel { .diff-panel {
container-type: inline-size;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
display: grid; display: grid;
@@ -1041,6 +1077,8 @@ select:focus-visible {
color: var(--text-muted); color: var(--text-muted);
} }
.diff-view { .diff-view {
position: relative;
isolation: isolate;
overflow: auto; overflow: auto;
padding: 8px 0 36px; padding: 8px 0 36px;
font-family: var(--font-mono); font-family: var(--font-mono);
@@ -1048,12 +1086,179 @@ select:focus-visible {
line-height: 19px; line-height: 19px;
white-space: pre; white-space: pre;
tab-size: 2; tab-size: 2;
background:
radial-gradient(
circle at 84% 72%,
color-mix(in srgb, var(--primary) 8%, transparent),
transparent 28%
),
radial-gradient(
circle at 72% 88%,
color-mix(in srgb, var(--success) 5%, transparent),
transparent 24%
);
} }
.diff-line { .diff-line {
position: relative;
z-index: 2;
display: block; display: block;
min-height: 19px; min-height: 19px;
padding: 0 14px; padding: 0 14px;
} }
.diff-atmosphere {
--diff-tilt-x: 0deg;
--diff-tilt-y: 0deg;
position: absolute;
right: clamp(24px, 7vw, 110px);
bottom: clamp(28px, 8vh, 90px);
z-index: 0;
width: min(360px, 34vw);
color: var(--primary);
opacity: 0.38;
pointer-events: none;
transform: perspective(850px) rotateX(var(--diff-tilt-x))
rotateY(var(--diff-tilt-y));
transform-style: preserve-3d;
transition:
transform 220ms ease-out,
opacity 180ms ease;
}
.diff-atmosphere.dense {
opacity: 0.14;
}
.diff-atmosphere svg {
display: block;
width: 100%;
overflow: visible;
}
.code-route {
fill: none;
stroke: currentColor;
stroke-width: 1.2;
stroke-dasharray: 4 8;
opacity: 0.42;
}
.code-route.route-b {
color: var(--success);
}
.code-card rect {
fill: color-mix(in srgb, var(--surface-2) 72%, transparent);
stroke: color-mix(in srgb, var(--primary) 60%, var(--line));
stroke-width: 1.2;
filter: drop-shadow(0 18px 28px rgba(0, 0, 0, 0.24));
}
.code-card path {
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-width: 4;
opacity: 0.62;
}
.code-node circle {
fill: var(--surface-2);
stroke: currentColor;
stroke-width: 1.5;
}
.code-node path {
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
}
.node-one {
color: var(--success);
animation: code-node-float 5s ease-in-out infinite;
}
.node-two {
animation: code-node-float 5s ease-in-out -2.5s infinite;
}
.code-packet {
fill: var(--primary);
filter: drop-shadow(0 0 8px currentColor);
offset-path: path("M38 195 C92 84 178 214 318 74");
animation: code-packet-travel 5.4s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
.code-packet.packet-two {
fill: var(--success);
offset-path: path("M52 74 C132 8 230 34 310 156");
animation-delay: -2.7s;
}
.diff-atmosphere-caption {
display: flex;
align-items: center;
justify-content: space-between;
margin: -10px 42px 0;
padding-top: 10px;
border-top: 1px solid color-mix(in srgb, var(--primary) 32%, transparent);
color: var(--text-muted);
font: 700 9px/1 var(--font-mono);
letter-spacing: 0.11em;
text-transform: uppercase;
}
.diff-atmosphere-caption strong {
display: flex;
gap: 8px;
}
.diff-atmosphere-caption i {
color: var(--success);
font-style: normal;
}
.diff-atmosphere-caption i + i {
color: var(--danger);
}
@keyframes code-packet-travel {
0% {
offset-distance: 0%;
opacity: 0;
}
12%,
82% {
opacity: 1;
}
100% {
offset-distance: 100%;
opacity: 0;
}
}
@keyframes code-node-float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-6px);
}
}
html[data-theme="light"] .diff-view {
background:
radial-gradient(
circle at 84% 72%,
rgba(72, 92, 220, 0.12),
transparent 30%
),
radial-gradient(
circle at 72% 88%,
rgba(15, 148, 108, 0.08),
transparent 25%
),
linear-gradient(
135deg,
rgba(248, 251, 255, 0.88),
rgba(239, 245, 255, 0.62)
);
}
html[data-theme="light"] .diff-atmosphere {
opacity: 0.46;
}
html[data-theme="light"] .diff-atmosphere.dense {
opacity: 0.18;
}
@container (max-width: 560px) {
.diff-atmosphere {
display: none;
}
}
.diff-line.add { .diff-line.add {
background: rgba(38, 166, 115, 0.14); background: rgba(38, 166, 115, 0.14);
color: #8ef0c6; color: #8ef0c6;
@@ -1075,6 +1280,227 @@ html[data-theme="light"] .diff-line.remove {
color: #caa7ff; color: #caa7ff;
background: rgba(148, 97, 214, 0.08); background: rgba(148, 97, 214, 0.08);
} }
.validator-page {
container-type: inline-size;
padding: 18px;
display: grid;
gap: 14px;
overflow: auto;
}
.validator-empty {
min-height: 360px;
margin: 18px;
padding: 38px;
display: flex;
align-items: center;
justify-content: center;
gap: 36px;
text-align: left;
overflow: hidden;
}
.validator-empty > div:last-child {
max-width: 520px;
}
.validator-empty h2 {
margin: 5px 0 8px;
font-size: 24px;
}
.validator-empty p {
margin: 0 0 18px;
color: var(--text-muted);
line-height: 1.65;
}
.validator-hero {
position: relative;
min-height: 160px;
padding: 24px;
display: grid;
grid-template-columns: auto minmax(220px, 1fr) minmax(180px, 260px) auto;
align-items: center;
gap: 22px;
overflow: hidden;
background:
radial-gradient(
circle at 68% 16%,
color-mix(in srgb, var(--primary) 16%, transparent),
transparent 28%
),
linear-gradient(
120deg,
var(--surface-1),
color-mix(in srgb, var(--surface-2) 84%, var(--primary-soft))
);
}
.validator-hero.success {
--validator-accent: var(--success);
}
.validator-hero.warning {
--validator-accent: var(--warning);
}
.validator-hero.danger {
--validator-accent: var(--danger);
}
.validator-score {
width: 116px;
height: 116px;
border-radius: 32px;
display: grid;
place-content: center;
text-align: center;
background: color-mix(in srgb, var(--validator-accent) 10%, var(--surface-2));
border: 1px solid color-mix(in srgb, var(--validator-accent) 42%, var(--line));
box-shadow:
inset 0 0 34px color-mix(in srgb, var(--validator-accent) 10%, transparent),
0 18px 38px rgba(0, 0, 0, 0.15);
}
.validator-score strong {
color: var(--validator-accent);
font-size: 42px;
line-height: 0.9;
letter-spacing: -0.05em;
}
.validator-score span {
margin-top: 7px;
color: var(--text-muted);
font: 700 10px/1 var(--font-mono);
}
.validator-hero h2 {
margin: 4px 0 6px;
font-size: 24px;
}
.validator-hero p {
margin: 0;
color: var(--text-muted);
}
.validator-hero .project-illustration {
width: 220px;
opacity: 0.82;
}
.validator-actions {
display: grid;
gap: 8px;
min-width: 150px;
}
.validator-groups {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
align-items: start;
}
.validator-group {
overflow: hidden;
}
.validator-checks {
display: grid;
}
.validator-check {
min-height: 78px;
padding: 13px 14px;
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
gap: 11px;
align-items: center;
border-top: 1px solid var(--line-soft);
transition:
background 160ms ease,
transform 160ms ease;
}
.validator-check:hover {
background: var(--surface-hover);
}
.validator-check-icon {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: 9px;
color: var(--text-muted);
background: var(--surface-2);
}
.validator-check.pass .validator-check-icon {
color: var(--success);
background: color-mix(in srgb, var(--success) 12%, transparent);
}
.validator-check.warning .validator-check-icon {
color: var(--warning);
background: color-mix(in srgb, var(--warning) 12%, transparent);
}
.validator-check.error .validator-check-icon {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 12%, transparent);
}
.validator-check strong {
display: block;
font-size: 12px;
}
.validator-check p {
margin: 4px 0 0;
color: var(--text-muted);
font-size: 11px;
line-height: 1.45;
overflow-wrap: anywhere;
}
html[data-theme="light"] .validator-hero {
background:
radial-gradient(
circle at 68% 16%,
rgba(66, 91, 220, 0.18),
transparent 30%
),
linear-gradient(
120deg,
rgba(255, 255, 255, 0.98),
rgba(236, 243, 255, 0.96)
);
}
@media (max-width: 1180px) {
.validator-hero {
grid-template-columns: auto 1fr auto;
}
.validator-hero .project-illustration {
display: none;
}
.validator-groups {
grid-template-columns: 1fr;
}
}
@container (max-width: 900px) {
.validator-hero {
grid-template-columns: auto minmax(0, 1fr);
}
.validator-hero .project-illustration {
display: none;
}
.validator-actions {
grid-column: 1 / -1;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.validator-groups {
grid-template-columns: 1fr;
}
}
@container (max-width: 520px) {
.validator-hero {
grid-template-columns: 1fr;
}
.validator-score {
width: 92px;
height: 92px;
border-radius: 25px;
}
.validator-actions {
grid-template-columns: 1fr;
}
.validator-check {
grid-template-columns: 30px minmax(0, 1fr);
}
.validator-check > .button,
.validator-check > .status-pill {
grid-column: 2;
justify-self: start;
}
}
.empty-state { .empty-state {
height: 100%; height: 100%;
min-height: 260px; min-height: 260px;
@@ -3408,6 +3834,9 @@ html[data-theme="light"] .visual-page-header {
.project-illustration svg { .project-illustration svg {
transform: none !important; transform: none !important;
} }
.diff-atmosphere {
transform: none !important;
}
} }
@media (max-width: 1180px) { @media (max-width: 1180px) {
+101
View File
@@ -0,0 +1,101 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { createRequire } from "node:module";
const exec = promisify(execFile);
const require = createRequire(import.meta.url);
const { GitService } = require("../src/main/git-service.cjs");
const {
GitValidatorService,
isSensitiveTrackedPath,
sameRemote,
} = require("../src/main/git-validator-service.cjs");
async function git(args, cwd) {
return exec("git", args, { cwd, encoding: "utf8" });
}
test("Git Validator scores repository hygiene and offers bounded safe repairs", async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), "forgeflow-validator-"));
t.after(() => rm(root, { recursive: true, force: true }));
await git(["init", "-b", "main"], root);
await git(["config", "user.name", "ForgeFlow Test"], root);
await git(["config", "user.email", "forgeflow@example.invalid"], root);
await git(
["remote", "add", "origin", "https://gitea.example.test/jens/app.git"],
root,
);
await writeFile(path.join(root, "README.md"), "# App\n", "utf8");
await writeFile(path.join(root, ".gitignore"), ".env\n", "utf8");
await git(["add", "."], root);
await git(["commit", "-m", "Initial"], root);
const validator = new GitValidatorService({
git: new GitService(),
gitea: {
getBranchProtection: async () => ({
protected: false,
enableForcePush: false,
}),
},
});
const repository = {
fullName: "jens/app",
name: "app",
owner: { login: "jens" },
defaultBranch: "main",
localPath: root,
cloneUrl: "https://gitea.example.test/jens/app.git",
sshUrl: "git@gitea.example.test:jens/app.git",
};
const report = await validator.scan(repository);
assert.ok(report.score > 60);
assert.equal(
report.checks.find((check) => check.id === "origin").status,
"pass",
);
assert.equal(
report.checks.find((check) => check.id === "default-branch-protection")
.fixAction,
"protect-default-branch",
);
const safety = report.checks.find((check) => check.id === "local-safety");
assert.equal(safety.safe, true);
await validator.repair(repository, safety);
const rescanned = await validator.scan(repository);
assert.equal(
rescanned.checks.find((check) => check.id === "local-safety").status,
"pass",
);
});
test("Git Validator creates a reviewable gitignore without committing it", async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), "forgeflow-ignore-"));
t.after(() => rm(root, { recursive: true, force: true }));
await git(["init", "-b", "main"], root);
const validator = new GitValidatorService({ git: new GitService() });
const repository = { localPath: root };
await validator.repair(repository, { fixAction: "add-gitignore" });
const content = await readFile(path.join(root, ".gitignore"), "utf8");
assert.match(content, /\.env/);
const status = await git(["status", "--short"], root);
assert.match(status.stdout, /\?\? \.gitignore/);
});
test("Git Validator recognizes remote aliases and secret-shaped tracked paths", () => {
assert.equal(
sameRemote(
"git@gitea.example.test:jens/app.git",
"https://gitea.example.test/jens/app",
),
true,
);
assert.equal(isSensitiveTrackedPath(".env.production"), true);
assert.equal(isSensitiveTrackedPath("config/private.pem"), true);
assert.equal(isSensitiveTrackedPath(".env.example"), false);
});
+34
View File
@@ -107,3 +107,37 @@ test('creates controlled pull requests and reads branch protection', async () =>
assert.deepEqual(create.options.body, { head: 'feature', base: 'main', title: 'Release feature', body: 'Summary' }); assert.deepEqual(create.options.body, { head: 'feature', base: 'main', title: 'Release feature', body: 'Summary' });
await assert.rejects(() => service.createPullRequest({ owner: 'owner', repo: 'app', head: 'main', base: 'main', title: 'Invalid' }), /different/); await assert.rejects(() => service.createPullRequest({ owner: 'owner', repo: 'app', head: 'main', base: 'main', title: 'Invalid' }), /different/);
}); });
test('downloads release assets through the release-scoped Gitea endpoint', async () => {
const service = new GiteaService(makeStore());
let requested = '';
service.downloadAuthenticated = async (pathname) => {
requested = pathname;
return Buffer.from('asset');
};
const asset = await service.downloadReleaseAsset('Jens', 'ForgeFlow', 107, 412);
assert.equal(asset.toString(), 'asset');
assert.equal(
requested,
'/api/v1/repos/Jens/ForgeFlow/releases/107/assets/412',
);
await assert.rejects(
() => service.downloadReleaseAsset('Jens', 'ForgeFlow', null, 412),
/invalid release ID/,
);
});
test('creates conservative default branch protection rules', async () => {
const service = new GiteaService(makeStore());
let request = null;
service.request = async (pathname, options) => {
request = { pathname, options };
return { data: { rule_name: 'main' } };
};
const result = await service.createBranchProtection('jens', 'app', 'main');
assert.equal(result.rule_name, 'main');
assert.equal(request.options.method, 'POST');
assert.equal(request.options.body.enable_push, false);
assert.equal(request.options.body.enable_force_push, false);
assert.equal(request.options.body.rule_name, 'main');
});
+1 -1
View File
@@ -41,7 +41,7 @@ test("every renderer bridge call is exposed by the preload contract", async () =
), ),
); );
const exposed = new Set( const exposed = new Set(
[...preload.matchAll(/^\s{2}([A-Za-z0-9_]+):/gm)].map((match) => match[1]), [...preload.matchAll(/^\s+([A-Za-z0-9_]+):/gm)].map((match) => match[1]),
); );
assert.ok(calls.size > 40, "expected the complete renderer bridge surface"); assert.ok(calls.size > 40, "expected the complete renderer bridge surface");
assert.deepEqual( assert.deepEqual(
+39
View File
@@ -226,3 +226,42 @@ test("interactive project illustrations are semantic, responsive and motion-safe
assert.match(styles, /prefers-reduced-motion/); assert.match(styles, /prefers-reduced-motion/);
assert.match(styles, /transform: none !important/); assert.match(styles, /transform: none !important/);
}); });
test("the diff canvas uses a contextual and motion-safe code illustration", async () => {
const renderer = await readFile(
new URL("../src/renderer/app.js", import.meta.url),
"utf8",
);
const styles = await readFile(
new URL("../src/renderer/styles.css", import.meta.url),
"utf8",
);
assert.match(renderer, /function diffAtmosphere/);
assert.match(renderer, /data-diff-atmosphere/);
assert.match(renderer, /--diff-tilt-x/);
assert.match(styles, /\.diff-atmosphere/);
assert.match(styles, /@keyframes code-packet-travel/);
assert.match(styles, /prefers-reduced-motion/);
});
test("Git Validator exposes scored best-practice checks and bounded repairs", async () => {
const renderer = await readFile(
new URL("../src/renderer/app.js", import.meta.url),
"utf8",
);
const styles = await readFile(
new URL("../src/renderer/styles.css", import.meta.url),
"utf8",
);
const preload = await readFile(
new URL("../preload.cjs", import.meta.url),
"utf8",
);
assert.match(renderer, /function renderGitValidator/);
assert.match(renderer, /git-validator-repair-safe/);
assert.match(renderer, /check\.safe/);
assert.match(styles, /\.validator-score/);
assert.match(styles, /@container \(max-width: 900px\)/);
assert.match(preload, /gitValidatorScan/);
assert.match(preload, /gitValidatorRepair/);
});
+37
View File
@@ -0,0 +1,37 @@
import test from "node:test";
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { SshService, parseCapabilityOutput } = require("../src/main/ssh-service.cjs");
test("SSH capability parsing keeps Git optional and reports deployment prerequisites separately", () => {
const b64 = (value) => Buffer.from(value).toString("base64");
const parsed = parseCapabilityOutput(`noise\n__FORGEFLOW_SERVER_TEST__\nplatform=${b64("Linux Unraid")}\ndocker=true\ndockerReady=true\ncompose=true\ncomposeVersion=${b64("Docker Compose version v2.40.0")}\ngit=false\ntar=true\nchecksum=true\nbaseWritable=true\n`);
assert.equal(parsed.dockerReady, true);
assert.equal(parsed.compose, true);
assert.equal(parsed.git, false);
assert.equal(parsed.tar, true);
assert.equal(parsed.checksum, true);
assert.equal(parsed.baseWritable, true);
});
test("SSH execution rejects truncated output instead of using an incomplete inventory", async () => {
const service = new SshService({ store: {}, diagnostics: null });
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
const client = {
exec(_command, callback) {
callback(null, stream);
queueMicrotask(() => {
stream.emit("data", Buffer.from("x".repeat(64)));
stream.emit("close", 0, null);
});
},
};
await assert.rejects(
service.execClient(client, "inventory", { maxOutput: 16, timeout: 1_000 }),
(error) => error?.code === "SSH_OUTPUT_TRUNCATED" && /incomplete result/.test(error.message),
);
});
+466 -2
View File
@@ -9,10 +9,14 @@ const {
parseInspection, parseInspection,
dockerIgnoreHasPath, dockerIgnoreHasPath,
checksSummary, checksSummary,
parseServerInventory,
inventoryContainerMatch,
remoteIdentity,
xmlEscape, xmlEscape,
bash, bash,
} = require("../src/main/unraid-deployment-service.cjs"); } = require("../src/main/unraid-deployment-service.cjs");
const { fingerprintKey, shellQuote } = require("../src/main/ssh-service.cjs"); const { fingerprintKey, shellQuote } = require("../src/main/ssh-service.cjs");
const { buildWorkloadInventory } = require("../src/main/server-inventory.cjs");
test("Unraid remote paths cannot escape appdata project folder", () => { test("Unraid remote paths cannot escape appdata project folder", () => {
assert.equal(safeRemoteFolder("lumaops"), "lumaops"); assert.equal(safeRemoteFolder("lumaops"), "lumaops");
@@ -37,6 +41,255 @@ test("server inspection key-value payload is decoded safely", () => {
assert.deepEqual(parsed.existingPreservePaths, ["data", "logs"]); assert.deepEqual(parsed.existingPreservePaths, ["data", "logs"]);
}); });
test("server workload inventory links running containers to exact Gitea checkouts", () => {
const b64 = (value) => Buffer.from(value).toString("base64");
const inspect = JSON.stringify([
{
Name: "/Portfolio",
State: { Running: true, Health: { Status: "healthy" } },
Config: {
Labels: {
"com.docker.compose.project.working_dir":
"/mnt/user/appdata/Portfolio",
},
},
Mounts: [],
},
]);
const inventory = parseServerInventory(
`noise\n__FORGEFLOW_INVENTORY__\nR\t${b64("/mnt/user/appdata/Portfolio")}\t${b64("git@gitea.itworx.tech:Jens/Portfolio.git")}\t${"a".repeat(40)}\t${b64("main")}\nC\t${b64(inspect)}\n`,
);
assert.equal(inventory.checkouts.length, 1);
assert.equal(inventory.containers.length, 1);
assert.equal(
remoteIdentity("git@gitea.itworx.tech:Jens/Portfolio.git"),
remoteIdentity("https://gitea.itworx.tech/Jens/Portfolio"),
);
assert.equal(
inventoryContainerMatch(
inventory.checkouts[0],
{ name: "Portfolio" },
inventory.containers[0],
),
100,
);
});
test("automatic server discovery adopts and verifies a running Gitea deployment", async () => {
const b64 = (value) => Buffer.from(value).toString("base64");
const sha = "b".repeat(40);
const container = {
Name: "/Portfolio",
State: { Running: true, Health: { Status: "healthy" } },
Config: {
Labels: {
"com.docker.compose.project.working_dir": "/mnt/user/appdata/Portfolio",
"com.docker.compose.service": "portfolio",
},
},
Mounts: [],
NetworkSettings: { Ports: { "3000/tcp": [{ HostPort: "8080" }] } },
};
const profiles = [];
const states = new Map();
const service = new UnraidDeploymentService({
store: {
getServer: () => ({
id: "unraid",
name: "Unraid",
basePath: "/mnt/user/appdata",
}),
getDeploymentProfiles: () => profiles,
saveDeploymentProfile: async (_fullName, profile) => {
profiles.push(profile);
return profile;
},
saveDeploymentState: async (id, state) => {
states.set(id, state);
return state;
},
},
ssh: {
exec: async () => ({
stdout: `__FORGEFLOW_INVENTORY__\nR\t${b64("/mnt/user/appdata/Portfolio")}\t${b64("git@gitea.itworx.tech:Jens/Portfolio.git")}\t${sha}\t${b64("main")}\nC\t${b64(JSON.stringify([container]))}\n`,
}),
},
gitea: { getBranch: async () => ({ commit: { id: sha } }) },
});
const result = await service.discoverServerWorkloads("unraid", [
{
fullName: "Jens/Portfolio",
name: "Portfolio",
defaultBranch: "main",
cloneUrl: "https://gitea.itworx.tech/Jens/Portfolio.git",
sshUrl: "git@gitea.itworx.tech:Jens/Portfolio.git",
},
]);
assert.equal(result.adopted, 1);
assert.equal(result.verified, 1);
assert.equal(profiles[0].containerName, "Portfolio");
assert.equal(profiles[0].adoptedFromServer, true);
assert.equal(states.get(profiles[0].id).matchesGitea, true);
});
test("server inventory includes stopped DockerMan containers without Git and keeps name matches manual", () => {
const workloads = buildWorkloadInventory({
inventory: {
checkouts: [],
dockerMan: [
{
name: "omniroute",
templatePath: "/boot/config/plugins/dockerMan/templates-user/my-omniroute.xml",
webUiUrl: "http://[IP]:[PORT:20128]/",
iconUrl: "",
shell: "sh",
repository: "diegosouzapw/omniroute:latest",
network: "bridge",
},
],
containers: [
{
id: "container-1",
name: "omniroute",
image: "ghcr.io/diegosouzapw/omniroute:latest",
imageId: "sha256:image",
running: false,
status: "exited",
health: null,
labels: {},
ports: { "3000/tcp": [{ HostPort: "20128", HostIp: "0.0.0.0" }] },
mounts: [
{
Type: "bind",
Source: "/mnt/user/appdata/OmniRoute/config",
Destination: "/app/config",
RW: true,
},
],
networks: { bridge: {} },
restartPolicy: "unless-stopped",
},
],
warnings: [],
capabilities: { docker: true, compose: true },
},
server: {
id: "unraid",
name: "Unraid",
basePath: "/mnt/user/appdata",
},
repositories: [
{
fullName: "Jens/OmniRoute",
name: "OmniRoute",
cloneUrl: "https://gitea.itworx.tech/Jens/OmniRoute.git",
},
],
profiles: [],
});
assert.equal(workloads.length, 1);
assert.equal(workloads[0].runtime.running, false);
assert.equal(workloads[0].kind, "dockerman-container");
assert.equal(workloads[0].remoteFolderCandidate, "OmniRoute");
assert.equal(workloads[0].status, "suggested");
assert.equal(workloads[0].candidates[0].exact, false);
assert.match(workloads[0].candidates[0].reasons.join(" "), /manual confirmation/i);
});
test("server inventory groups multi-service Compose projects and preserves their identity", () => {
const baseContainer = {
image: "example/app:latest",
imageId: "sha256:image",
running: true,
status: "running",
health: null,
ports: {},
mounts: [],
networks: { appnet: {} },
restartPolicy: "unless-stopped",
};
const labels = {
"com.docker.compose.project": "forgeflow",
"com.docker.compose.project.working_dir": "/mnt/user/appdata/ForgeFlow",
"com.docker.compose.project.config_files": "/mnt/user/appdata/ForgeFlow/compose.yml,/mnt/user/appdata/ForgeFlow/compose.prod.yml",
};
const workloads = buildWorkloadInventory({
inventory: {
checkouts: [],
dockerMan: [],
warnings: [],
capabilities: {},
containers: [
{
...baseContainer,
id: "web",
name: "forgeflow-web-1",
labels: { ...labels, "com.docker.compose.service": "web" },
},
{
...baseContainer,
id: "worker",
name: "forgeflow-worker-1",
labels: { ...labels, "com.docker.compose.service": "worker" },
},
],
},
server: { id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata" },
repositories: [],
profiles: [],
});
assert.equal(workloads.length, 1);
assert.deepEqual(workloads[0].compose.services.sort(), ["web", "worker"]);
assert.deepEqual(workloads[0].compose.configFiles, [
"/mnt/user/appdata/ForgeFlow/compose.yml",
"/mnt/user/appdata/ForgeFlow/compose.prod.yml",
]);
assert.equal(workloads[0].compose.project, "forgeflow");
assert.equal(workloads[0].remoteFolderCandidate, "ForgeFlow");
});
test("manual workload linking does not claim Gitea parity for unrelated provenance", async () => {
let savedState = null;
const service = new UnraidDeploymentService({
store: {
saveDeploymentState: async (_id, state) => {
savedState = state;
return state;
},
},
ssh: {},
git: {},
diagnostics: null,
});
await service.saveWorkloadState(
{
id: "profile",
containerName: "app",
remoteFolder: "app",
cloneUrl: "https://gitea.itworx.tech/Jens/Expected.git",
},
{
workloadId: "workload",
observedAt: new Date().toISOString(),
metadata: {
sourceRepository: "https://gitea.itworx.tech/Jens/Other.git",
liveRevision: "a".repeat(40),
},
runtime: { running: true, health: "healthy" },
containers: [{ name: "app", running: true, health: "healthy" }],
compose: { project: "app" },
},
{ basePath: "/mnt/user/appdata" },
);
assert.equal(savedState.liveSha, "a".repeat(40));
assert.equal(savedState.matchesGitea, false);
assert.equal(savedState.giteaSha, null);
});
test("Docker ignore checks identify exact runtime and Git context exclusions", () => { test("Docker ignore checks identify exact runtime and Git context exclusions", () => {
const rules = "# build context\n.git\ndata/\nlogs/**\n!logs/keep.txt\n"; const rules = "# build context\n.git\ndata/\nlogs/**\n!logs/keep.txt\n";
assert.equal(dockerIgnoreHasPath(rules, ".git"), true); assert.equal(dockerIgnoreHasPath(rules, ".git"), true);
@@ -113,6 +366,9 @@ test("SSH helpers produce pinned fingerprints and quoted commands", () => {
const decoded = Buffer.from(encoded, "base64").toString("utf8"); const decoded = Buffer.from(encoded, "base64").toString("utf8");
assert.match(decoded, /GIT_TERMINAL_PROMPT=0/); assert.match(decoded, /GIT_TERMINAL_PROMPT=0/);
assert.match(decoded, /BatchMode=yes/); assert.match(decoded, /BatchMode=yes/);
assert.match(decoded, /forgeflow_compose\(\)/);
assert.match(decoded, /docker compose "\$@"/);
assert.match(decoded, /docker-compose "\$@"/);
assert.match(decoded, /git fetch origin main/); assert.match(decoded, /git fetch origin main/);
}); });
@@ -334,8 +590,8 @@ test("DockerMan metadata uses dockerman labels, a template WebUI and lowercase-s
"file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png", "file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png",
); );
assert.match(metadata, / portfolio:/); assert.match(metadata, / portfolio:/);
assert.match(metadata, /image: forgeflow\/portfolio:production/); assert.doesNotMatch(metadata, /image: forgeflow\/portfolio:production/);
assert.match(metadata, /container_name: Portfolio/); assert.doesNotMatch(metadata, /container_name: Portfolio/);
assert.match(metadata, /net\.unraid\.docker\.managed.*dockerman/); assert.match(metadata, /net\.unraid\.docker\.managed.*dockerman/);
assert.match( assert.match(
metadata, metadata,
@@ -362,6 +618,8 @@ test("DockerMan integration writes a persistent template fallback and invalidate
hostPort: 5150, hostPort: 5150,
webUiUrl: "http://192.168.10.150:5150/", webUiUrl: "http://192.168.10.150:5150/",
dockerShell: "/bin/sh", dockerShell: "/bin/sh",
manageDockerMan: true,
generatedCompose: true,
}; };
const repository = { name: "Portfolio" }; const repository = { name: "Portfolio" };
const icon = const icon =
@@ -388,6 +646,32 @@ test("DockerMan integration writes a persistent template fallback and invalidate
assert.equal(xmlEscape('A&B<"x">'), "A&amp;B&lt;&quot;x&quot;&gt;"); assert.equal(xmlEscape('A&B<"x">'), "A&amp;B&lt;&quot;x&quot;&gt;");
}); });
test("adopted DockerMan templates are never rewritten", () => {
const service = new UnraidDeploymentService({
store: {},
ssh: {},
git: {},
diagnostics: null,
});
const refresh = service.dockerManRefreshScript(
{
composeService: "omniroute",
containerName: "omniroute",
remoteFolder: "OmniRoute",
environment: "production",
manageDockerMan: true,
generatedCompose: false,
adoptedFromServer: true,
},
{ name: "OmniRoute" },
"",
);
assert.match(refresh, /left the existing DockerMan template unchanged/);
assert.doesNotMatch(refresh, /templates-user/);
});
test("built-in ITWorx DockerMan icon is uploaded to persistent Unraid storage", async (t) => { test("built-in ITWorx DockerMan icon is uploaded to persistent Unraid storage", async (t) => {
const { mkdtemp, mkdir, writeFile, rm } = await import("node:fs/promises"); const { mkdtemp, mkdir, writeFile, rm } = await import("node:fs/promises");
const os = await import("node:os"); const os = await import("node:os");
@@ -665,3 +949,183 @@ test("a failed deployment is marked superseded when Gitea and Unraid agree on a
assert.equal(operations[0].status, "cancelled"); assert.equal(operations[0].status, "cancelled");
assert.match(operations[0].error, /Superseded/); assert.match(operations[0].error, /Superseded/);
}); });
test("linked Compose deployments retain the existing project, files and service set", () => {
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {} });
const repository = { name: "OmniRoute", fullName: "Jens/OmniRoute" };
const profile = {
composeProject: "omniroute-production",
composeFiles: ["compose.yml", "compose.unraid.yml"],
composeServices: ["api", "worker"],
generatedCompose: false,
};
const invocation = service.composeInvocation(profile, repository);
assert.match(invocation, /-p 'omniroute-production'/);
assert.ok(invocation.indexOf("-f 'compose.yml'") < invocation.indexOf("-f 'compose.unraid.yml'"));
assert.ok(invocation.indexOf("-f 'compose.unraid.yml'") < invocation.indexOf("-f '.forgeflow\/compose.metadata.yml'"));
assert.deepEqual(service.deploymentServices(profile, repository), ["api", "worker"]);
});
test("push bundle activation validates Compose and services before promoting current SHA", () => {
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {} });
const repository = { name: "OmniRoute", fullName: "Jens/OmniRoute" };
const profile = {
environment: "production",
deploymentMode: "push-bundle",
composeProject: "omniroute",
composeFiles: ["compose.yml", "compose.unraid.yml"],
composeServices: ["api", "worker"],
preservePaths: ["data", "config"],
generatedCompose: false,
adoptedFromServer: true,
manageDockerMan: false,
};
const script = service.pushBundleScript({
repository,
profile,
remotePath: "/mnt/user/appdata/OmniRoute",
targetSha: "a".repeat(40),
requestId: "request-1",
remotePart: "/mnt/user/appdata/.forgeflow/incoming/request-1.tar.part",
digest: "b".repeat(64),
metadata: "services:\n api:\n labels: {}\n worker:\n labels: {}\n",
generated: "",
iconReference: "",
});
const configIndex = script.indexOf("config >/dev/null");
const upIndex = script.indexOf("up -d --build");
const serviceCheckIndex = script.indexOf("Compose service $service did not create a container");
const promoteIndex = script.indexOf('current-sha.pending');
assert.ok(configIndex >= 0 && configIndex < upIndex);
assert.ok(upIndex < serviceCheckIndex);
assert.ok(serviceCheckIndex < promoteIndex);
assert.match(script, /mmin \+120/);
assert.match(script, /grep -E '\(\^\/\|\(\^\|\/\)\\\.\\\.\(\/\|\$\)\)'/);
assert.match(script, /kill -0 "\$lock_pid"/);
assert.match(script, /is_preserved "\$rel" && continue/);
assert.doesNotMatch(script, /git clone|git -C "\$root" fetch/);
assert.match(script, /ForgeFlow left the existing DockerMan template unchanged/);
});
test("generated push-bundle command passes Bash syntax validation", { skip: process.platform === "win32" }, async () => {
const { spawnSync } = await import("node:child_process");
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {} });
const repository = { name: "Demo", fullName: "Jens/Demo" };
const profile = {
environment: "production",
deploymentMode: "push-bundle",
composeProject: "demo",
composeFiles: ["compose.yml"],
composeServices: ["app"],
preservePaths: ["data"],
generatedCompose: false,
adoptedFromServer: true,
manageDockerMan: false,
};
const generated = service.pushBundleScript({
repository,
profile,
remotePath: "/mnt/user/appdata/Demo",
targetSha: "d".repeat(40),
requestId: "syntax-test",
remotePart: "/mnt/user/appdata/Demo/.forgeflow/incoming/syntax-test.tar.part",
digest: "e".repeat(64),
metadata: "services:\n app:\n labels: {}\n",
generated: "",
iconReference: "",
});
const wrapped = bash(generated);
const encoded = wrapped.match(/printf '%s' '([A-Za-z0-9+/=]+)'/)[1];
const script = Buffer.from(encoded, "base64").toString("utf8");
const result = spawnSync("bash", ["-n"], { input: script, encoding: "utf8" });
assert.equal(result.status, 0, result.stderr || result.stdout);
});
test("push bundle preflight does not require Git or Gitea credentials on Unraid", async (context) => {
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const localPath = await mkdtemp(join(tmpdir(), "forgeflow-push-preflight-"));
context.after(() => rm(localPath, { recursive: true, force: true }));
await writeFile(join(localPath, "compose.yml"), "services:\n app:\n image: example/app:latest\n");
const sha = "c".repeat(40);
const profile = {
id: "production",
name: "Production",
environment: "production",
provider: "ssh-unraid",
branch: "main",
serverId: "unraid",
remoteFolder: "OmniRoute",
deploymentMode: "push-bundle",
composeFile: "compose.yml",
composeFiles: ["compose.yml"],
composeService: "app",
composeServices: ["app"],
iconMode: "none",
generatedCompose: false,
preservePaths: ["data"],
};
let remoteGitProbeCount = 0;
const service = new UnraidDeploymentService({
store: {
getDeploymentProfile: () => profile,
getServer: () => ({
id: "unraid",
name: "Unraid",
host: "192.168.10.150",
port: 22,
username: "root",
basePath: "/mnt/user/appdata",
hostFingerprint: "SHA256:test",
}),
},
git: {
status: async () => ({
root: localPath,
head: sha,
clean: true,
counts: { changed: 0 },
branch: { head: "main", upstream: "origin/main", ahead: 0, behind: 0 },
}),
verifyCommitOnRemoteBranch: async () => true,
},
ssh: {
test: async () => ({
capabilities: {
docker: true,
dockerReady: true,
compose: true,
composeVersion: "Docker Compose version v2",
git: false,
tar: true,
checksum: true,
baseWritable: true,
},
}),
exec: async (_serverId, command) => {
if (String(command).includes("git ls-remote")) remoteGitProbeCount += 1;
const encodedFiles = Buffer.from("").toString("base64");
return {
stdout: `__FORGEFLOW_KV__\nexists=false\nrootGit=false\nhead=\nbranch=\nremote=\ntrackedChanges=\ncomposeFiles=${encodedFiles}\nnestedGit=\ndockerfile=false\ndockerignoreContent=\nexistingPreservePaths=\n`,
};
},
},
sourcePath: new URL("..", import.meta.url).pathname,
});
const result = await service.preflight({
repository: {
fullName: "Jens/OmniRoute",
name: "OmniRoute",
localPath,
localStatus: { head: sha },
},
profileId: "production",
sha,
});
assert.equal(remoteGitProbeCount, 0);
assert.equal(result.checks.find((item) => item.id === "server-git-access")?.status, "pass");
assert.match(result.checks.find((item) => item.id === "server-git-access")?.detail || "", /Not required/);
assert.equal(result.checks.some((item) => item.id === "server-git-command"), false);
assert.equal(result.summary.ready, true);
});
+6 -2
View File
@@ -336,6 +336,7 @@ test("packaged updater downloads only a published checksum-matched Windows asset
async getReleaseByTag(_owner, _repo, tag) { async getReleaseByTag(_owner, _repo, tag) {
if (tag !== "v0.8.2") return null; if (tag !== "v0.8.2") return null;
return { return {
id: 82,
tag_name: tag, tag_name: tag,
draft: false, draft: false,
prerelease: false, prerelease: false,
@@ -353,7 +354,8 @@ test("packaged updater downloads only a published checksum-matched Windows asset
], ],
}; };
}, },
async downloadReleaseAsset(_owner, _repo, assetId) { async downloadReleaseAsset(_owner, _repo, releaseId, assetId) {
assert.equal(releaseId, 82);
return assetId === 42 ? Buffer.from(`${sha256} ${assetName}\n`) : binary; return assetId === 42 ? Buffer.from(`${sha256} ${assetName}\n`) : binary;
}, },
}; };
@@ -398,6 +400,7 @@ test("packaged updater rejects a binary whose checksum does not match", async ()
gitea: { gitea: {
async getReleaseByTag() { async getReleaseByTag() {
return { return {
id: 83,
tag_name: "v0.8.2", tag_name: "v0.8.2",
assets: [ assets: [
{ {
@@ -413,7 +416,8 @@ test("packaged updater rejects a binary whose checksum does not match", async ()
], ],
}; };
}, },
async downloadReleaseAsset(_owner, _repo, assetId) { async downloadReleaseAsset(_owner, _repo, releaseId, assetId) {
assert.equal(releaseId, 83);
return assetId === 52 return assetId === 52
? Buffer.from(`${"0".repeat(64)} ${assetName}`) ? Buffer.from(`${"0".repeat(64)} ${assetName}`)
: binary; : binary;