This commit is contained in:
NuklearRabbit
2026-07-24 20:29:23 +02:00
commit 66060348da
107 changed files with 14771 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
.DS_Store
*.log
coverage/
.forgeflow/
+66
View File
@@ -0,0 +1,66 @@
## 0.4.3
- Removed the platform-dependent Unraid inspection integration test that mixed Windows temporary paths with remote Linux semantics.
- Added deterministic SSH inspection contract coverage.
- Released as one complete clean source archive.
# Changelog
## 0.4.2
- Replaced nested SSH Bash quoting with a single-line base64 transport.
- Fixed Windows Git Bash inspection failures caused by multiline quote parsing.
- Made temporary test cleanup resilient to short-lived Windows directory locks.
- Corrected remote status-file base64 invocation.
- Added Windows-focused regression coverage and a recovery updater from 0.4.0/0.4.1.
## 0.4.1
- Fixed Windows `bash -n` verification for ForgeFlow paths such as `C:\Projects\ForgeFlow`.
- Bash now receives a relative POSIX script path with the project root supplied through `cwd`.
- Added cross-platform regression tests, including a project root containing spaces.
## 0.4.0
- Made the changed-file panel independently scrollable for large working trees.
- Added explicit commit-message readiness and clarified that commit actions stage selected files automatically.
- Integrated the supplied ITWorx.tech logo into the title bar, setup and desktop icons.
- Added a private-Gitea source updater pinned to an exact update-branch commit.
- Added secure SSH / Unraid server profiles with host-key pinning.
- Added exact-SHA deployment to Git-backed `/mnt/user/appdata/<project>` folders.
- Added existing deployment inspection, tracked-change blocking, origin alignment and nested-Git warnings.
- Added repository Compose adoption and basic generated Compose for simple Dockerfile applications.
- Added exact previous-SHA rollback with repeat health verification.
- Added the LumaOps server-versus-Gitea migration audit.
- Expanded the automated suite to 59 passing tests.
## 0.3.2
- Clone from Gitea now uses the first configured project root automatically.
- Repository-named target folders are created without reopening the folder picker.
- Added an explicit Choose another location action for exceptional clones.
- Existing matching checkouts are linked instead of cloned again.
- Different repositories, non-empty ordinary folders and file conflicts are blocked.
- Clone identity and destination are resolved again in the privileged backend.
- Added a Windows source-update script and upgrade guide.
- Expanded the automated suite from 39 to 45 passing tests.
## 0.3.1
- Fixed the Windows environment doctor failing with `spawn npm ENOENT` after a successful npm installation.
- Added safe npm invocation through `npm_execpath` with a `cmd.exe` fallback.
- Added Windows and cross-platform regression tests for npm discovery.
- Removed the duplicated hard-coded doctor version.
## 0.1.0 — Functional MVP foundation
- Reworked the Stitch static screens into one coherent desktop application.
- Added secure Electron main/preload/renderer architecture.
- Added real Gitea connection and repository loading.
- Added bounded local Git repository discovery and remote matching.
- Added real Git status, diff, staging, commit, push, fetch and fast-forward pull.
- Added deployment profiles and exact-SHA Gitea Actions dispatch.
- Added deployment confirmation, operation history and healthcheck foundation.
- Added dark/light themes, onboarding and browser demo.
- Added unit tests and a real temporary Git remote integration test.
- Added safe workflow and server deployment examples.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jens Caers
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+332
View File
@@ -0,0 +1,332 @@
# ForgeFlow
ForgeFlow is a desktop release cockpit that turns the complete path from a local
code change to a verified server deployment into one guided flow:
```text
Local working tree -> Gitea repository -> exact deployed server version
```
It is not an IDE and it does not replace Gitea Actions. ForgeFlow coordinates
local Git, a private Gitea instance and fixed deployment workflows while keeping
credentials on the user's own computer.
![ForgeFlow overview](docs/screenshots/overview.png)
## Current status: v0.4.2 integrated desktop test release
### v0.4.2 workflow and deployment expansion
- large changed-file sets scroll independently;
- commit actions explain the missing prerequisite and auto-stage selected files;
- ITWorx.tech branding is integrated;
- the app can update itself from `Jens/ForgeFlow` in the configured Gitea instance;
- SSH / Unraid servers can be configured once with pinned host identity;
- exact commits can be deployed to `/mnt/user/appdata/<project>`;
- existing Compose definitions and untracked runtime data are preserved;
- a basic Unraid-compatible Compose file can be generated for simple new projects.
Read [SSH / Unraid deployment](docs/SSH_UNRAID_DEPLOYMENT.md) and the
[LumaOps audit](docs/LUMAOPS_SERVER_AUDIT.md).
### v0.4.2 automatic clone workflow
**Clone from Gitea** now uses the first configured project root and creates the
repository-named subfolder automatically. The native folder picker is reserved
for the explicit **Choose another location** action. Existing matching checkouts
are linked; conflicting folders are never overwritten.
The Windows environment doctor retains the v0.3.1 npm command-shim correction.
Version 0.3.2 is designed so the user does not need to give a developer any
Gitea token, SSH key, server password or other credential. The application now
includes:
- a five-step setup wizard;
- 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
procedure is in [docs/SETUP_GUIDE.md](docs/SETUP_GUIDE.md).
## What ForgeFlow can do
### Repository workflow
- Connect to a private Gitea instance and validate the account locally.
- 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
- Configure multiple environments per repository.
- 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
ForgeFlow stores structured JSONL diagnostics in the application-data folder.
They are intended to make development and troubleshooting possible without
requesting credentials.
The logger records useful operational facts such as:
- timestamps, stable event names and duration;
- 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:
- Gitea tokens and encrypted token blobs;
- 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
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
Set-ExecutionPolicy -Scope Process Bypass
.\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 start
```
## Setup and first acceptance test
Follow these documents in order:
1. [START_HERE.md](START_HERE.md)
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
preflight -> commit -> push -> deploy -> Actions -> server SHA -> healthcheck -> rollback
```
## Updating an existing source installation
Close ForgeFlow, copy the v0.4.2 update overlay over the existing source folder
and run:
```powershell
Set-ExecutionPolicy -Scope Process Bypass
.\update-windows.ps1
```
Application configuration and protected credentials are stored outside the
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).
+105
View File
@@ -0,0 +1,105 @@
755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 .gitignore
4bf1a097cf93b11eb49c09a9d7f92a341eab15c5eafd7014f6087aac352648ca CHANGELOG.md
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db LICENSE
f1a69461c2ea8edec2b0fecc23db196cafccd730a309f0ad84acd5c5110b45ef README.md
e5414be56177664a12d31f8d668d6273628c62ae1a608b9f61580c2f9359bbf1 START_HERE.md
8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea UPDATE_FROM_0.3.2.md
7fbfbba99e6f38029b9a77c8fad8a8e5a91c186306e9e167aae5357c666aab2e build/icon-128.png
2e4519b3fff1f06c2f706b11ab3ee87c5100092df6c6e03763c2e3010dfdca8f build/icon-16.png
5e331c05fc5f76beb229ebc973ca4c88ad6663cef23ed38b67b03d233275371d build/icon-256.png
d6342683ccadb6d5ae4b4013f4b4fa8dfb3c27d2d3449ba23e82c4dd4f68cb46 build/icon-32.png
1a58c1454ec6bf39509f6c56094ee9927ac1fac71d4a52d4e35068ead80dd696 build/icon-48.png
c2b306a28dc374700c857a482a28ebc805845141e8eba1907486eeec9a76aba4 build/icon-512.png
4d2adb598bf4ee3593f7293009a7dd2739742f44234daf4ecebd8b77bc74608f build/icon-64.png
ca6fbcee9cd986155c760953ab4a56cf6eea448ba8adf227412e7069aa4e211f build/icon.ico
c2b306a28dc374700c857a482a28ebc805845141e8eba1907486eeec9a76aba4 build/icon.png
6337a7d0791e8749e3a576d7735257b0031db7a9eff4a2d6818cc251544774d2 build-windows.ps1
2d9836ae6d576bab5494b9f094bc673e5ca4772bf5771006583d1bc46fe46698 docs/ARCHITECTURE.md
30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 docs/DEPLOYMENT_SETUP.md
eb42f979666e05d51c587e4223282914926a2b9b1ade9f3fb75525019ce7f738 docs/DIAGNOSTICS.md
a0cd06a96f23a94e118feb012be0fa1ac51345951cb2ba8e67fb8c889c4c342a docs/LUMAOPS_SERVER_AUDIT.md
a0c00ff76acd1682bb5e0e8dcf6589c9480da436c9c6d30780a1ed58b4dad94f docs/RELEASE_NOTES_0.2.0.md
5773ead01aa4c522c556295553787482d01b1f5242f053b2c61f120c4de4fa76 docs/RELEASE_NOTES_0.3.0.md
d46de73cf6c4cd5c2ba3f455a7a2af2e0d64ee9d94a97fd1a0bfb44e35c1624a docs/RELEASE_NOTES_0.3.1.md
0d697d241a08d2427a6e7f5c2f27bd1830a41836a01e08eeff239c7ad5d89982 docs/RELEASE_NOTES_0.3.2.md
bc6933c303d3d9b3bfdbf678cae1a717bfe5a893780a1871af8b48589f62f0e3 docs/RELEASE_NOTES_0.4.0.md
343862445061e1a8282a7aa9b2304e7d799e58f9956d50eb5352db18d790efad docs/RELEASE_NOTES_0.4.1.md
e2d67c816a919f00f9e26bf59cf29e5e8cf894536b743d282075c646c5accc96 docs/RELEASE_NOTES_0.4.2.md
1aef74fb109541903c4dbc4d9c48d2bd63507420eaf8cceb31890797a5e4f5fd docs/RELEASE_NOTES_0.4.3.md
c465f1a9c4454c9a18f38f68a243037b8897c2c9929077a586604acd4ff26d35 docs/ROADMAP.md
322624242d246d07180cc719e14c91e8fb69e123676a02e5046f4e576cca1ca1 docs/SECURITY.md
c79123aa4c718ac3ab0d79771f2967710c28f939b58fca0094b02e3172f2c024 docs/SETUP_GUIDE.md
b5ba1f7580e47e1f01900964b866d9f15b973a9e9dcccf2650f403595020e949 docs/SSH_UNRAID_DEPLOYMENT.md
b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 docs/STATUS_ENDPOINT.md
0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 docs/STITCH_REVIEW.md
08640f1b5e26048b5ae501909f415d2426b07cc316a0bd2178023f2457aa7a2a docs/TEST_MATRIX.md
a620b634532b3cc13c17a5e7de35efab1eb34673279d64de86df9b369cf7d65b docs/UPDATING.md
1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 docs/screenshots/deploy-confirmation.png
b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa docs/screenshots/deployment-run.png
070e6700bdae8c628c907ba181bbf0dde0bbbbb4208f7a875503f933ff1b882e docs/screenshots/deployment-success.png
bcb1e4daf1eeedc5b3f61d2406f1a65312dba130082528007e1629d9df99570a docs/screenshots/overview.png
224e34ab45877bbb97b07d2a14c4a5aa6e28339522a8015b33a2a81477177143 docs/screenshots/repository-workspace.png
c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 examples/gitea-actions/deploy.yml
4c792cc9fd57ed36da291300c252a6ef75b08a249cf6f2561e23c4c22522138a examples/gitea-actions/rollback.yml
1d2cde1bef4882f56006823d2806f6105882fa098a665a303150fdf18ada2004 examples/server/forgeflow-deploy
4fe3eee5c2d8705964c24b8c4dd909883a05e7d6eb85629c84b0d64473e0a92b examples/server/forgeflow-runner.sudoers
0423fe2cc7f43fe793986a3f62a395668897cdf07348756aa7742a8cd40ac51c examples/server/forgeflow-targets.conf
106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a examples/server/nginx-forgeflow-status.conf
2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a examples/server/status-example.json
6e4ef7ec12358d756d2ee6105420a5a440ba8d207a7ee26a35c873c24661d84d main.cjs
4a3d70ddcb00778d98b38662fa6af27ce33017e95139a2726298e3a20948985e package.json
1e9cfc496c61702a9b083265f08ce451008d5ce8829bb4a447429e6abea3dc89 preload.cjs
a39ab8ac36fc81c37c1718ec620d4e590a1c404d01264e07e3c904a5189bdc27 scripts/apply-source-update.ps1
f427dfcd7b5ee7079de13633c8d7d22a91115e0bbc4f2a9a96246f42176d4880 scripts/doctor.mjs
444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 scripts/serve-demo.mjs
d8c56b85186f36579e8b311f929db2a7c7b77f817cc95ea953fceaba0467a8ed scripts/verify.mjs
92524adae60aced3af23f8afe82c011873ae9f1e53d854e4d12a94e8d1be1aa9 setup-windows.ps1
366c1edbc90a00fcbf660002e55291ba234d896e7afbe24002d9d6db84b9f44c src/main/config-store.cjs
a970ff3f47d1641bf1ab9611e1122349aa65ff8fee4789585e078431368b8c6b src/main/deployment-service.cjs
c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c src/main/diagnostics-service.cjs
620226c8518c8308e0095f17102371238aeb8db92005392fe1ed0163d2e567b5 src/main/git-service.cjs
ab7344b1951c87e982cab5c293891bc45dad48a48a3b4b63991b0e76ef785ba6 src/main/gitea-service.cjs
2af3674ca1faaae24a8858ec6dc2f4ccb271112de1534e5d49c00ff50047e731 src/main/ipc.cjs
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c src/main/log-redaction.cjs
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e src/main/preflight-service.cjs
9e35f867b1be78cb850c7872456b2fc00c837b8552c6244057862b7166c26661 src/main/process-runner.cjs
e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 src/main/repository-monitor.cjs
eca26673564284fce8715bd74201e59fa390800926d968d642d07eec5cc3af66 src/main/repository-service.cjs
65db01a05d842c40bb784c34560870b2e9c3b973c088fcc5ce0db654a585b146 src/main/ssh-service.cjs
db7fa63d85cec92afc9017207495571e7c5ec215aca02cf2aa18e6b8e265ba27 src/main/unraid-deployment-service.cjs
2b39c0c1e84da52026dc95c9962c4976b7f69bc636b186dc8a41f9d4904f208a src/main/update-service.cjs
5fe59bb1a3aa7afea4694891dc32836c7a15d0fa42a3d87bf7217365ee97d95b src/renderer/app.js
2f3448ddaa016105769d20cbe30c461fd7ba5d3bd105865b750aa0ef6b66e1af src/renderer/assets/itworx-mark.png
37f7da5a438b88be731c45c027a0fd88d08bd1af3150afa787836fa7baadbc48 src/renderer/assets/itworx-wordmark.png
0fc26fbc70918e92586098fb0ee5c2f9946758020f930a08a005b270794b5998 src/renderer/index.html
c25dd7ee946fc7973fe3e9431cb39b22ee4ad608f9c5056f778788c7a387c2b9 src/renderer/mock-bridge.js
7fcd281b5307ed9dccbf2fee9b917943d9b02bb0bd3a8577fa607f62ea66d1d5 src/renderer/styles.css
0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d src/shared/clone-target.cjs
029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f src/shared/git-status.cjs
2778ebcbdf60fdc1cb0749f15565e0e1bd66f3a0d31eb70ae7942a7511a3de75 src/shared/repository-match.cjs
7f4d057a3c8e8d22eda9477eea7b144237824ef0f514737831d1881ff8e7f4a4 src/shared/semver.cjs
c3135dea1c0d35ad3b4cda9597eac3335d1f16a556f8fb96694df4f9d3b0bf6f src/shared/shell-verification.cjs
2daa98fd421598bfe5fc9757c9b6f4d82c31d1bfece15829928473581d5d2639 src/shared/tool-invocation.cjs
a97c83b8023d6c0cf49d6f2d5b626ef2341f02670f0de170e840026d28fd1f0e src/shared/validation.cjs
13b731c38863b1007b0312fd9d89562401b7cce875c952f52429bde74f77a8af src/shared/zip-writer.cjs
454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 tests/clone-target.test.mjs
abb65b39f285da518a48be41aff40d89ceb9c5b0e6091772c2bde171f65daf9b tests/deployment-status.test.mjs
fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 tests/diagnostics.test.mjs
7ed195eba8f40ea1e4a57678b0e3dd2defbe888c5c8aab5e931898d506ad638e tests/git-integration.test.mjs
5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db tests/git-status.test.mjs
681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 tests/git-workflows.test.mjs
e914b2bcafbd674c06adfd9bd851ca04e134210691b7f91cd3de26cee37ef5f3 tests/gitea-actions.test.mjs
caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 tests/log-redaction.test.mjs
c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 tests/preflight.test.mjs
035c18b6801313f9fbdb5d8c5a26e12beae37d33b18c8433b96bd6cb65910a36 tests/renderer-workflow.test.mjs
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 tests/repository-matching.test.mjs
f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 tests/repository-monitor.test.mjs
3c71aa5fb30d9c6fbc4b0ccfcf5112f45cbcc2a60cb990e4551a8813f7155505 tests/security-validation.test.mjs
ecfdad2a03c24898c822fcf05abac89c8f8fe452a05b16fdafc0236a64c27a23 tests/semver.test.mjs
60ce18a6e538acb29191b0857f45b78ce71102440655f5e070251325261a8151 tests/shell-verification.test.mjs
0f80df71dda957d18a7dabc8286e117a01b3c0de73dc06458beb6e9fc23af0da tests/tool-invocation.test.mjs
80f748687bc3fb72812388faf34732c8e71cbccd1f9258bc95b7f32c34eb3a84 tests/unraid-deployment.test.mjs
cfc143a618be64456512313f0b244c1310e9c79ce94b7dacb1b627d9de26f750 tests/update-service.test.mjs
4d1f0a4c46190ca72b51fddf79ec6d4d02e65fa6f42ef3755de5d414f7da75bb tests/validation.test.mjs
7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d tests/zip-writer.test.mjs
3ea68269b66f639b3aba50c9605ccbfaa32c22a1d2cc842d8296c4ee59df6212 update-windows.ps1
+60
View File
@@ -0,0 +1,60 @@
# Start here — ForgeFlow v0.4.2
You do **not** need to send anyone your Gitea token, SSH key or server password.
All credentials are entered locally in ForgeFlow during setup. Diagnostic logging
is designed to exclude them.
## Already running v0.3.2?
Use the v0.4.2 update overlay and follow [docs/UPDATING.md](docs/UPDATING.md).
Your Gitea token and ForgeFlow configuration are stored outside the source
folder and are preserved.
## Fast path on Windows
1. Extract the complete ForgeFlow ZIP to a normal local folder.
2. Open PowerShell in that folder.
3. Run:
```powershell
Set-ExecutionPolicy -Scope Process Bypass
.\setup-windows.ps1
```
The script checks Node.js and Git, installs the desktop dependencies, runs the
full source/test quality gate and opens ForgeFlow.
4. Follow the five-step setup wizard in the application.
5. Continue with [`docs/SETUP_GUIDE.md`](docs/SETUP_GUIDE.md) to connect one
repository to a staging or production environment.
## When something fails
Open **Diagnostics** in ForgeFlow and:
1. Run **System preflight** or the environment **Deployment preflight**.
2. Resolve any blocking checks shown in red.
3. Choose **Create diagnostic ZIP**.
4. Prefer **Strict privacy** when the bundle will be shared.
5. Inspect the ZIP before sharing it.
The bundle contains redacted application events, configuration structure,
repository/deployment states, preflight output and a local safety-audit result.
It intentionally excludes access tokens, encrypted token blobs and raw runner
logs.
Detailed diagnostic behavior: [`docs/DIAGNOSTICS.md`](docs/DIAGNOSTICS.md).
## Unraid deployments
After the desktop app opens:
1. Open **Settings → SSH / Unraid servers**.
2. Add the Unraid host and `/mnt/user/appdata`.
3. Save it and run **Test & trust**.
4. Open a repository, go to **Deployments**, and choose **SSH / Unraid**.
5. Run **Preflight** before the first deployment.
See [docs/SSH_UNRAID_DEPLOYMENT.md](docs/SSH_UNRAID_DEPLOYMENT.md).
+19
View File
@@ -0,0 +1,19 @@
# Update ForgeFlow 0.3.2 to 0.4.0
1. Close ForgeFlow completely.
2. Extract `ForgeFlow-0.4.0-update-from-0.3.2.zip`.
3. Copy the contents of the included `ForgeFlow` folder into your existing
`C:\Users\Jens\dyad-apps\ForgeFlow` folder and replace existing files.
4. Do not create a nested `ForgeFlow\ForgeFlow` folder.
5. Open Windows PowerShell in the existing ForgeFlow folder and run:
```powershell
Set-ExecutionPolicy -Scope Process Bypass
.\update-windows.ps1
```
The script installs the pinned SSH dependency, runs the environment doctor,
source verification and all automated tests, then starts ForgeFlow.
Credentials, repository mappings, diagnostics, server definitions and
deployment profiles are stored outside the source folder and remain intact.
+58
View File
@@ -0,0 +1,58 @@
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
Set-Location $PSScriptRoot
function Assert-Command {
param([Parameter(Mandatory = $true)][string]$Name)
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "Required command '$Name' was not found on PATH. Read START_HERE.md for prerequisites."
}
}
function Invoke-Step {
param(
[Parameter(Mandatory = $true)][string]$Title,
[Parameter(Mandatory = $true)][scriptblock]$Action
)
Write-Host "`n$Title" -ForegroundColor Yellow
& $Action
if ($LASTEXITCODE -ne 0) {
throw "$Title failed with exit code $LASTEXITCODE."
}
}
Write-Host "ForgeFlow v0.4.2 Windows development build" -ForegroundColor Cyan
Write-Host "Artifacts are unsigned and intended for local testing." -ForegroundColor DarkGray
Assert-Command node
Assert-Command npm
Assert-Command git
$nodeVersionText = (node --version).Trim()
$nodeMajor = [int]($nodeVersionText.TrimStart('v').Split('.')[0])
if ($nodeMajor -lt 22) {
throw "Node.js 22 or newer is required. Detected: $nodeVersionText"
}
Invoke-Step "Installing exact project dependencies..." {
if (Test-Path ".\package-lock.json") {
npm ci --no-audit --no-fund
} else {
npm install --no-audit --no-fund
}
}
Invoke-Step "Running the environment doctor..." {
npm run doctor
}
Invoke-Step "Running the release quality gate..." {
npm run check
}
Invoke-Step "Building NSIS installer and portable package..." {
npm run dist:win
}
Write-Host "`nBuild complete. Artifacts are available in .\dist" -ForegroundColor Green
Get-ChildItem -Path .\dist -File | Select-Object Name, Length, LastWriteTime
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 483 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+221
View File
@@ -0,0 +1,221 @@
# Architecture
## Process model
```text
+---------------- Electron renderer ----------------+
| Desktop UI |
| No Node.js, filesystem, process or token access |
+-------------------------+---------------------------+
|
frozen preload API
|
+-------------------------v---------------------------+
| Electron main process |
| |
| IPC validation + trusted sender checks |
| ConfigStore -------- schema 5 + protected Gitea/SSH secrets |
| GitService ----------- Git through execFile args |
| GiteaService --------- repositories + Actions API |
| RepositoryService ---- discovery + aggregation |
| RepositoryMonitor ---- local state awareness |
| PreflightService ----- system/deployment readiness |
| DeploymentService ---- dispatch/poll/verify |
| DiagnosticsService --- JSONL/redaction/support ZIP |
+----------------+--------------------+---------------+
| |
local Git Gitea API
| |
working trees repositories/actions
|
trusted runner
|
fixed allowlisted entry point
|
app + independent status JSON
```
## Trust boundaries
### Renderer
The renderer is untrusted input. It can request only methods exposed by
`preload.cjs`. Node integration is disabled, context isolation and sandboxing
are enabled, and IPC requests are accepted only from the packaged file origin.
Renderer crashes and unhandled rejections are reported through a sanitized
one-way diagnostic method.
### Main process
Paths, URLs, file selections, branch names, commit messages, diagnostic export
modes and deployment requests are checked here. The renderer cannot supply a
server command. UI eligibility is advisory; main-process services re-read the
working tree and remote ancestry immediately before privileged actions.
### Gitea
Gitea supplies repository metadata and the Actions control plane. ForgeFlow
handles run-list response variants, retries servers that reject optional query
filters and can fall back to the older tasks listing. Requests log only method,
API path, status and duration—not authorization headers or request bodies.
### Runner and server
The runner consumes only committed trusted workflows. The root-owned server
entry point reads an exact repository/environment target from a root-owned,
non-writable data file. It validates the full SHA again and uses a
per-environment lock. The runner receives no free-form command from ForgeFlow.
## Local state
`forgeflow-config.json` lives below Electron's platform-specific user-data path
and is written atomically. Schema version 3 contains:
- Gitea connection metadata and an OS-encrypted token blob where available;
- workspace roots and explicit repository mappings;
- favorites and application preferences;
- diagnostic retention and level preferences;
- multiple deployment profiles and last server state;
- up to 250 operation records.
Renderer-visible public state never contains the plaintext or encrypted token.
Structured diagnostic JSONL files live in a separate `diagnostics` directory.
They have independent rotation and retention and never block normal app use when
logging itself fails.
## Repository aggregation
1. Fetch accessible Gitea repositories.
2. Scan bounded workspace roots for Git working trees.
3. Normalize HTTPS and SCP-style SSH remotes.
4. Match local `origin` identity to `owner/repository`.
5. Apply explicit mappings where present.
6. Read Git state with bounded concurrency.
7. Attach favorites, deployment profiles and last server state.
8. Derive attention and ready-to-deploy status.
9. Feed linked paths to the repository monitor.
## Repository clone lifecycle
The first configured project root is the default. The renderer submits only the
current Gitea repository identity and either `default` or `custom` location
mode. The main process resolves the repository again, selects a configured root
or a native-dialog result, calculates the repository-named child path and asks
GitService to inspect it.
```text
repository identity
|
current Gitea metadata
|
project root + safe repository folder name
|
missing / empty / matching checkout / conflict
|
clone or reuse -> save mapping -> refresh -> monitor
```
A matching existing checkout is reused. Different repositories and arbitrary
non-empty folders are rejected.
## Git execution
ForgeFlow invokes the installed Git executable through `execFile`; it never
builds shell command strings. File arguments must remain repository-relative
and cannot contain traversal segments.
Core status command:
```bash
git status --porcelain=v2 --branch -z --untracked-files=all
```
Synchronization is intentionally limited to:
```bash
git pull --ff-only
```
Branch switching requires a clean working tree. Stash supports untracked files.
Deployment and rollback verify the full SHA against `origin/<allowed-branch>`
with `merge-base --is-ancestor`.
## Preflight model
### System preflight
Checks Git, author identity, app storage, diagnostic storage, OS credential
protection, configured workspace roots and—when credentials are present—Gitea
connectivity and repository visibility.
### Deployment preflight
Checks repository link, Git working tree, allowed branch, clean state, upstream,
ahead/behind state, exact remote SHA, local and remote workflow presence,
Actions API access, server status endpoint and healthcheck.
Only failed required checks block readiness. The deployment backend repeats
safety-critical Git/SHA validation after the user continues.
## Repository monitor
The current monitor periodically fingerprints Git state. It establishes a
baseline, reports later changes and pauses during mutating operations to avoid
intermediate noise. It is dependency-free rather than a native filesystem
watcher.
## Deployment lifecycle
```text
requested -> queued -> running -> health/version verification -> terminal
```
A deployment operation stores the exact SHA, fixed profile, environment,
workflow and a UUID request ID. Polling then:
1. finds the matching Actions run by SHA, branch, workflow and dispatch time;
2. normalizes run status;
3. retrieves jobs and locally redacted runner output;
4. maps jobs to ForgeFlow stages;
5. reads the independent status endpoint and healthcheck after runner success;
6. verifies live SHA equality;
7. stores success, rolled-back, failed or cancelled.
The request ID is sent to the workflow and server status document, making one
operation correlatable without using a credential as an identifier.
## Diagnostics pipeline
```text
event -> recursive sanitization -> ordered JSONL write
|
support export requested
|
fresh state + preflight + redacted logs
|
strict/standard privacy transformation
|
fail-closed local secret safety audit
|
ZIP + SHA-256 result
```
Raw runner output is deliberately omitted from exported support bundles.
## Status endpoint
The recommended endpoint is a static JSON file served independently from the
application. It reports live, previous and requested SHAs, request ID, health
and last exit code. See `STATUS_ENDPOINT.md`.
## v0.4 services
- `UpdateService` reads `package.json` at an exact Gitea branch SHA, downloads an
authenticated archive and launches the rollback-capable Windows source updater.
- `SshService` provides pinned-host SSH execution with encrypted password or
private-key passphrase storage.
- `UnraidDeploymentService` inspects existing application folders and performs
exact-SHA Git and Docker Compose deployments without deleting untracked
runtime data.
+58
View File
@@ -0,0 +1,58 @@
# Deployment setup guide
This guide connects one Gitea repository to one server environment without giving the desktop arbitrary shell access.
## 1. Add fixed workflows
Copy these examples into the repository:
```text
examples/gitea-actions/deploy.yml -> .gitea/workflows/deploy.yml
examples/gitea-actions/rollback.yml -> .gitea/workflows/rollback.yml
```
Change the runner label to the label registered for the target environment.
## 2. Install the allowlisted server entry point
Copy `examples/server/forgeflow-deploy` to `/usr/local/bin/forgeflow-deploy`, customize its repository/environment allowlist and make it root-owned:
```bash
sudo install -o root -g root -m 0755 forgeflow-deploy /usr/local/bin/forgeflow-deploy
```
Grant the runner account permission to execute only this entry point where elevation is needed. Do not grant unrestricted shell or Docker administration merely for ForgeFlow.
## 3. Expose deployment status
The example script writes an atomic JSON document beneath `/var/lib/forgeflow-status`. Serve the appropriate file at a fixed HTTPS URL, for example with `examples/server/nginx-forgeflow-status.conf`.
See `STATUS_ENDPOINT.md` for the contract.
## 4. Configure the ForgeFlow profile
Open the repository, choose **Deployments** and add an environment with:
- Name: `Production` or `Staging`.
- Environment: the fixed workflow input.
- Branch: usually `main`.
- Workflow file: `deploy.yml`.
- Rollback workflow: `rollback.yml`.
- Status URL: the JSON endpoint.
- Healthcheck URL: the application health endpoint.
- Confirmation: enabled for production.
## 5. Validate the complete path
Test these scenarios before relying on production:
1. Clean commit and push.
2. Successful deployment to the exact SHA.
3. Gitea runner failure.
4. Application healthcheck failure.
5. Server reports the wrong SHA.
6. Second deployment while the lock is held.
7. Rollback to the recorded previous SHA.
8. Token without sufficient permissions.
Keep a manual recovery path documented even after rollback works.
+150
View File
@@ -0,0 +1,150 @@
# Diagnostics, privacy and support bundles
ForgeFlow v0.3.2 records development-oriented diagnostics locally so failures
can be investigated without requesting the user's Gitea token, SSH key or
server password.
## Storage
Diagnostic events are stored beneath the Electron application-data directory in:
```text
diagnostics/forgeflow-YYYY-MM-DD.jsonl
```
The Diagnostics page displays an aliased path such as `<HOME>` rather than the
Windows account name. Files use restrictive permissions where the operating
system supports them.
Defaults:
- enabled;
- minimum level `info`;
- 14-day retention;
- 8 MB maximum per log segment;
- daily filenames with numbered rotation;
- ordered asynchronous writes;
- no application crash when diagnostic storage itself fails.
These values can be changed in **Diagnostics -> Recording policy**.
## What an event can contain
Useful fields include:
- UTC timestamp;
- level and stable event name;
- per-launch session identifier;
- operation or deployment request identifier;
- Git/Gitea operation outcome;
- HTTP status, duration and endpoint path without request headers;
- repository state and branch/SHA metadata;
- preflight result;
- sanitized exception name, code, message and stack;
- renderer crash or unhandled-rejection metadata.
ForgeFlow does not log IPC payloads, request authorization headers or Gitea
response bodies merely because a request was made.
## Redaction
Every event passes through a recursive sanitizer before it is written.
Redaction covers:
- the currently active Gitea token;
- token, password, authorization, credential, API-key, client-secret and
encrypted-token object fields, including camelCase variants;
- bearer/token/basic authorization values in strings;
- common token query parameters;
- credentials embedded in URLs;
- PEM private-key blocks;
- known Gitea/Git hosting token patterns;
- Windows, macOS and Linux home-directory paths;
- application source path aliases;
- circular data structures and oversized strings.
Credential values are replaced with `[REDACTED]`; user paths use aliases such as
`<HOME>`.
## Support bundle
**Create diagnostic ZIP** exports a local archive containing:
```text
manifest.json
safety-audit.json
README.txt
system.json
diagnostics-status.json
configuration-sanitized.json
repositories-sanitized.json
operations-sanitized.json
preflight.json
context.json
logs/*.jsonl
```
The application returns the SHA-256 of the generated archive so a shared file
can be identified exactly.
### Excluded data
The bundle intentionally excludes:
- plaintext Gitea access tokens;
- Electron `safeStorage` encrypted-token blobs;
- request authorization headers;
- passwords and private keys;
- raw Gitea runner logs;
- arbitrary environment-variable dumps;
- full local file contents and Git diffs.
ForgeFlow does not automatically ingest or persist raw runner output. Job names,
statuses and safe operation summaries are stored locally; full runner output remains
available only in the trusted Gitea Actions interface when deeper server-side
investigation is necessary.
## Privacy modes
### Standard
Preserves repository names and user-facing identifiers. Local home paths and
credentials are still redacted. Use when the recipient already knows the
project context.
### Strict
Additionally replaces repository and user identifiers with deterministic
SHA-256-based aliases. Related events remain correlatable without revealing the
original names.
## Fail-closed bundle audit
Immediately before writing the archive, ForgeFlow scans every prepared entry
for:
- the known active runtime secret values;
- private-key begin markers;
- unredacted credentials embedded in HTTP(S) URLs.
The result is stored as `safety-audit.json`. When a finding remains, archive
creation is aborted and the unsafe ZIP is not written.
## Practical limitation
No generic logger can mathematically identify every unknown secret if a third-
party process prints an arbitrary value without a label or recognizable format.
ForgeFlow reduces this risk by not including raw runner logs, not recording IPC
payloads and applying both structured and textual redaction. Always inspect a
support bundle before sharing it, particularly when custom integrations have
been added.
## Development workflow after a failure
1. Reproduce the issue once when safe.
2. Note the approximate time and repository/environment.
3. Run the relevant preflight.
4. Export a Strict diagnostic bundle.
5. Keep the returned SHA-256 with the bug report.
6. Describe the visible action that failed.
7. Share no separate token, key or password.
+147
View File
@@ -0,0 +1,147 @@
# LumaOps server versus Gitea audit
This audit compares the supplied `lumaops_server.zip` and `LumaOps_gitea.zip`.
## Main result
The main Unraid working tree and the supplied Gitea checkout point to exactly the same commit:
```text
d42d4a7f08240c478d07466e3fabec654dc71367
```
Latest subject:
```text
Preserve colors across Aura zone updates
```
There is therefore no source-version drift at the root of the live LumaOps folder.
## Root Git repository
The root `.git` directory should remain in place. It enables:
- exact-SHA verification;
- controlled fetch and reset;
- a reliable previous-version reference;
- rollback without copying a second complete source tree.
The archived server copy showed one root status difference for `scripts/unraid-hardware-setup.sh`: file mode `100755 → 100644`. This is consistent with Unix executable bits being lost during ZIP handling. The file content did not differ. Check the executable bit directly on Unraid before deployment.
## Origin URL mismatch to resolve
The supplied server root uses:
```text
ssh://git@127.0.0.1:222/NuklearRabbit/LumaOps.git
```
The supplied Gitea checkout uses:
```text
https://gitea.itworx.tech/Jens/LumaOps.git
```
Although both archives currently point to the same commit, these are different
repository paths. Before the first ForgeFlow deployment, choose the server-
reachable URL for the authoritative `Jens/LumaOps` repository, for example an
SSH URL through `127.0.0.1:222` when Gitea runs on the same Unraid host.
ForgeFlow 0.4 detects this mismatch. Deployment is blocked unless the profile
matches the existing origin or **Align an existing server origin to this URL**
is explicitly enabled.
## Runtime and persistent paths
The server copy contains runtime data that must not be replaced by source updates:
- `appdata/`
- `data/`
- `logs/`
- `.env` and application-specific configuration
The repository `.gitignore` already excludes the principal runtime paths. ForgeFlow's SSH strategy uses Git reset without `git clean`, so untracked persistent data remains in place.
## Compose and Unraid integration
The root `docker-compose.yml` is already suitable as the authoritative deployment definition. It includes:
- build context at the project root;
- container name `lumaops`;
- the Unraid `dockerman` label;
- a Web UI label;
- an Unraid icon label;
- `${WEB_PORT:-1223}:${APP_PORT:-8080}`;
- persistent relative volumes;
- USB, HID and I²C devices;
- a healthcheck.
ForgeFlow should use this existing Compose file rather than generate a replacement. Ports and complex device mappings belong in the repository's maintained Compose definition.
The supplied `.dockerignore` already excludes `.git`, so keeping the root Git
working tree does **not** copy Git history into the Docker build context. It does
not yet explicitly exclude the existing runtime/legacy folders `appdata/`,
`data/`, `logs/` and `source/`. Before the first production rebuild, add the
paths that are not build inputs:
```text
appdata/
data/
logs/
source/
.forgeflow/
```
ForgeFlow 0.4 detects existing preserved paths and nested Git repositories that
are missing from `.dockerignore` and reports them as a preflight warning. The
tool does not silently edit a source-controlled `.dockerignore`; the correction
should be committed to Gitea so every deployment uses the same build context.
## Nested `source/` repository
The server archive also contains a nested Git working tree under:
```text
source/
```
Its HEAD is:
```text
b746a52af1613f4291235f5e8165b8197a269a79
```
It was ahead of its own upstream and included rebase metadata in the supplied archive. The root Compose file uses build context `.` and does not reference `source/`. This strongly indicates that `source/` is an abandoned or historical checkout rather than the active deployment source.
ForgeFlow reports this as a nested-repository warning and does not remove it automatically.
Recommended migration:
1. Back up `/mnt/user/appdata/lumaops`.
2. Verify on Unraid that `docker compose config` uses the root project.
3. Stop changing files in `source/`.
4. Rename it temporarily to `source.legacy-backup`.
5. Rebuild and test LumaOps from the root.
6. Remove the legacy copy only after a successful validation period.
Do not delete the root `.git` directory. Also do not delete the nested `source/`
directory as part of the first ForgeFlow test. Treat its cleanup as a separate,
backed-up migration after the root deployment and rollback have both been
validated.
## Recommended ForgeFlow profile
```text
Provider: SSH / Unraid
Server folder: lumaops
Branch: main
Compose mode: Repository/server Compose
Compose file: docker-compose.yml
Clone URL: the Git URL reachable from Unraid
Healthcheck: the existing LumaOps health URL, when exposed
Preserve paths: .env, appdata, data, logs, config, compose.override.yml
```
No folder rename is required for LumaOps because `lumaops` already aligns with the repository name.
+32
View File
@@ -0,0 +1,32 @@
# ForgeFlow 0.2.0 release notes
ForgeFlow 0.2.0 turns the original visual prototype into a substantially more operational personal release cockpit.
## Highlights
- Automatic repository status monitoring with safe pause/resume around Git mutations.
- Commit-only and commit-and-push flows with recoverable push failures.
- Branch creation, switching, publication and stash workflows.
- Favorite repositories and action-oriented attention queues.
- Multiple deployment environments per repository.
- Exact remote-branch SHA verification before deploy and rollback.
- Gitea Actions run, job and available log polling.
- Live server version, previous version and health verification.
- Fixed-workflow rollback to a recorded full commit SHA.
- Stronger IPC, URL, path and secret-handling controls.
- Reworked renderer with command palette and live deployment states.
- 21 passing automated tests, including real temporary Git remotes.
- Headless browser smoke coverage at three desktop viewport sizes.
## Upgrade notes
Configuration is migrated automatically to schema version 2. Existing Gitea tokens are preserved when the settings form is saved with an empty token field.
Deployment profiles now support independent branch, workflow, rollback workflow, healthcheck and status endpoint settings. Review existing profiles before using them against production.
## Known limitations
- No signed installer or automatic update channel is included in this source release.
- Partial-hunk staging, conflict resolution and protected-branch awareness are not yet implemented.
- Gitea Actions behavior still needs acceptance testing against the intended Gitea and act_runner versions.
- Native desktop notifications, tray mode and accessibility acceptance remain future work.
+140
View File
@@ -0,0 +1,140 @@
# ForgeFlow 0.3.0 release notes
Release date: 2026-07-24
Release type: self-service test release
## Goal
Version 0.3.0 closes the gap between a functional developer preview and a build
that can be configured and tested by its owner without sharing credentials with
a developer. The release concentrates on setup guidance, deterministic
preflight checks, safe diagnostics and server-side allowlisting.
## Setup and readiness
- Replaced the lightweight onboarding with a five-step setup wizard.
- Added a computer readiness preflight for Git, Git identity, writable app data,
writable diagnostics and OS credential encryption.
- Added a Gitea validation stage before setup completion.
- Added visible repository discovery results.
- Added safe setup diagnostics before Gitea is connected.
- Added a comprehensive start page and end-to-end setup guide.
- Added a JSON-capable command-line doctor for local environment validation.
## Deployment preflight
A deployment now receives a visible preflight before confirmation and a second
mandatory backend validation immediately before dispatch. Checks include:
- linked local Git repository;
- allowed deployment branch;
- clean working tree;
- configured upstream;
- local/remote ahead and behind state;
- exact full SHA on the remote branch;
- local deploy and rollback workflow files;
- remote workflow visibility through Gitea;
- Gitea Actions API availability;
- deployment status endpoint;
- application health endpoint.
Optional environment checks can warn without hiding required failures.
Deployment cannot bypass the mandatory checks through the renderer.
## Diagnostic logging
- Added ordered structured JSONL logging in the Electron app-data directory.
- Added daily files, size rotation and retention pruning.
- Added configurable logging level, retention and file-size policy.
- Added process, renderer, Git, repository, Gitea, IPC, preflight and deployment
diagnostics.
- Added per-launch session IDs and per-operation deployment request IDs.
- Added a no-throw logging design so diagnostic storage does not crash the app.
- Added local clear and open-folder controls.
## Secret and privacy protection
- Added recursive sensitive-key detection, including camelCase variants.
- Added bearer/basic/token/password/API-key/client-secret redaction.
- Added runtime-secret replacement.
- Added URL credential, token query parameter and private-key redaction.
- Added common hosting-token pattern redaction.
- Added user-home and source-root path aliases.
- Added strict privacy mode with deterministic identifier hashing.
- Stopped automatically ingesting or persisting raw runner logs; full output stays in Gitea.
- Added fail-closed bundle auditing before the ZIP is written.
- Added SHA-256 output for every generated support bundle.
No Gitea token, SSH key or server password is needed by the developer to use
these diagnostics.
## Support bundle contents
A support bundle can contain:
- manifest and safety audit;
- system and application version information;
- sanitized public configuration;
- sanitized repository state;
- sanitized operation history;
- latest preflight report;
- safe diagnostic status;
- redacted JSONL logs.
It intentionally excludes protected token blobs, authorization headers,
private keys, source files, Git diffs, environment dumps and raw runner output.
## Server deployment hardening
- Moved target definitions to a root-owned `/etc/forgeflow/targets.conf` file.
- Added exact repository/environment allowlisting.
- Made the server status URL mandatory and require matching SHA plus request ID before success.
- Added configuration ownership and permission checks.
- Added absolute and restricted path validation.
- Added exact remote-SHA and branch ancestry validation.
- Added per-target `flock` locking.
- Added Docker Compose result and health verification.
- Added current, previous, requested SHA, request ID and exit code to server
status output.
- Added a restrictive sudoers template for the runner.
- Added explicit deploy and rollback workflow request-ID inputs.
- Added backend repository re-resolution so renderer-supplied paths and identities
cannot select an arbitrary local folder or Gitea repository.
- Captured pre-dispatch Actions run IDs so polling cannot attach to an older run
with the same commit SHA.
- Required repository, environment, live SHA, requested SHA, request ID, zero
server exit code and explicit health success before marking a release complete.
- Restricted rollback to the exact previous SHA currently reported by the server
status endpoint.
## User interface
- Added a dedicated Diagnostics workspace.
- Added system and deployment preflight presentation.
- Added diagnostic policy controls.
- Added Standard and Strict support-bundle export.
- Added support-bundle checksum and reveal action.
- Added readiness explanations to onboarding.
- Replaced duplicate sidebar navigation with a compact safe-diagnostics state.
## Validation
- 39 required project files validated.
- 35 JavaScript files passed syntax checks.
- 36 of 36 automated tests passed.
- Two real temporary Git remotes remain part of the integration suite.
- New tests cover redaction, diagnostic rotation/export, support ZIP generation,
fail-closed safety auditing, preflight and Gitea workflow-file checks.
## Known boundaries
- The release is not code-signed.
- A platform-native installer is not guaranteed by the source ZIP alone.
- The private Gitea, runner and server environment still requires the documented
local acceptance test.
- Application-specific compose commands and health endpoints remain target
configuration, because they cannot be inferred safely.
- No redactor can mathematically identify an arbitrary unknown secret printed by
custom third-party code; raw runner logs therefore remain only in the trusted
Gitea Actions interface, and exported bundles should still be inspected before
sharing.
+25
View File
@@ -0,0 +1,25 @@
# ForgeFlow 0.3.1 release notes
## Windows environment-doctor hotfix
Version 0.3.1 fixes a Windows-only false negative in the environment doctor.
The setup script could invoke npm successfully, install all dependencies and then
report `spawn npm ENOENT` from Node.js. Windows exposes npm through a command
shim (`npm.cmd`), which cannot always be executed directly through
`child_process.execFile`.
The doctor now:
- uses `npm_execpath` through the active Node executable when launched by npm;
- falls back to `cmd.exe /c npm --version` on Windows;
- continues to invoke npm directly on Linux and macOS;
- reports which safe invocation path succeeded;
- reads its displayed application version from `package.json` instead of a
duplicated hard-coded value.
Three regression tests cover npm-script execution, the Windows command-shim
fallback and the normal non-Windows path.
The npm deprecation messages printed during dependency installation are warnings
from transitive build-tool dependencies. They were not the cause of the setup
failure and do not prevent ForgeFlow from starting.
+72
View File
@@ -0,0 +1,72 @@
# ForgeFlow 0.3.2 release notes
## Automatic clone destinations
The normal **Clone from Gitea** action no longer opens a Windows folder picker
for every repository. ForgeFlow now:
1. uses the first configured project root as the default;
2. derives a safe folder name from the repository clone URL;
3. creates `<project-root>/<repository-name>`;
4. clones into that folder;
5. validates the resulting Git repository;
6. saves the repository mapping;
7. starts monitoring the working tree;
8. opens the linked repository in ForgeFlow.
Example:
```text
Default project root: C:\Users\Jens\Projects
Gitea repository: Jens/Portfolio
Automatic target: C:\Users\Jens\Projects\Portfolio
```
A separate **Choose another location** action remains available for exceptional
cases. That choice selects a parent project root; ForgeFlow still creates the
repository-named subfolder itself.
## Existing-folder safety
The clone backend now inspects the automatic target before running Git:
- a missing target is created through `git clone`;
- an existing empty directory is accepted;
- an existing Git checkout with the same normalized origin is linked;
- a different Git repository is blocked;
- an ordinary non-empty directory is blocked;
- a file at the target path is blocked.
ForgeFlow never silently overwrites a conflicting folder and does not create a
duplicated `Repository\Repository` directory.
## Security and consistency
The renderer no longer supplies a free-form remote URL or clone destination to
the privileged Git operation. It sends the Gitea repository identity and a
location mode. The main process then:
- resolves the current repository again from Gitea;
- selects the configured root or a native-dialog result;
- calculates the destination itself;
- performs conflict checks;
- clones or reuses the checkout;
- persists the mapping atomically.
Clone diagnostics contain repository identity, target, branch and commit state,
but no Gitea token or authorization header.
## Validation
Version 0.3.2 contains 45 passing automated tests. New coverage includes:
- HTTPS and SSH repository folder-name derivation;
- automatic target construction;
- missing and empty target handling;
- same-origin checkout reuse;
- different-repository rejection;
- non-empty ordinary-folder rejection;
- file-at-target rejection.
Existing Git, deployment, diagnostics, redaction, rollback and Windows doctor
tests continue to pass.
+57
View File
@@ -0,0 +1,57 @@
# ForgeFlow 0.4.0 release notes
## Scrollable change list
The changed-file panel now has an independent bounded vertical scroll area. Large commits no longer make lower files unreachable.
## Commit readiness
The action panel now labels the commit message as required and displays the exact reason why commit actions are disabled. Selected files are staged automatically during commit; manual staging remains available as an optional index-review step.
## ITWorx.tech branding
The supplied ITWorx.tech logo is integrated into the title bar, first-run setup and application icons.
## Built-in source updater
ForgeFlow can check the configured private Gitea repository, defaulting to `Jens/ForgeFlow` on `main`.
The updater:
- reads the remote `package.json` at an exact branch commit;
- compares semantic versions;
- downloads an authenticated exact-SHA archive;
- verifies a SHA-256 checksum;
- closes ForgeFlow;
- backs up the current source;
- installs dependencies;
- runs the complete quality gate;
- restores the previous source when validation fails;
- restarts ForgeFlow.
## SSH / Unraid deployment
A server can be configured once with hostname, SSH port, username, encrypted credentials and `/mnt/user/appdata` as base path.
Deployment profiles support:
- existing Git-backed application folders;
- automatic new folder creation;
- exact commit verification;
- pinned SSH host identity;
- existing or generated Compose configuration;
- host and container ports;
- Unraid Web UI and icon labels;
- server-folder mapping;
- tracked-change blocking;
- nested-Git warnings;
- runtime-data preservation;
- rollback to the previous SHA.
## LumaOps audit
The supplied server and Gitea roots both match commit `d42d4a7f08240c478d07466e3fabec654dc71367`. The root Git checkout and Compose file should remain. A stale nested `source/` Git checkout is documented for controlled cleanup.
## Validation
ForgeFlow 0.4.0 has 59 passing automated tests, including real temporary Git remotes, update exact-SHA checks, SSH path safety, renderer workflow contracts, diagnostics redaction, exact previous-SHA rollback enforcement and deployment controls.
+26
View File
@@ -0,0 +1,26 @@
# ForgeFlow 0.4.1 release notes
## Windows Bash path fix
ForgeFlow 0.4.1 fixes the source quality gate on Windows when the project is stored at a path such as `C:\Projects\ForgeFlow`.
The previous verifier passed an absolute Windows path directly to `bash -n`. Bash interpreted the backslashes as escape characters, producing a collapsed path such as `C:ProjectsForgeFlow...` and a false validation failure.
The verifier now starts Bash with the ForgeFlow project root as its working directory and passes the deployment example as a relative POSIX path:
```text
examples/server/forgeflow-deploy
```
This keeps the project root separate from the script argument and works across Windows Git Bash, Linux and macOS.
## Regression coverage
New automated coverage verifies that:
- a Windows project root remains in `cwd`;
- no drive letter or backslash is passed as the Bash script argument;
- absolute and escaping script paths are rejected;
- Bash validation succeeds from a project root containing spaces.
No Gitea token, repository mapping, SSH credential, deployment profile or diagnostic history is changed by this update.
+29
View File
@@ -0,0 +1,29 @@
# ForgeFlow 0.4.2
## Windows Git Bash reliability hotfix
This release fixes the two remaining Windows-only failures, including the temporary-directory lock seen during cleanup while validating the 0.4.1 recovery update.
### Remote shell transport
SSH / Unraid scripts are now sent through a single-line base64 transport and decoded by Bash on the server. This removes nested quote parsing from the transport layer and prevents Git Bash from misreading multiline commands, single quotes, or newline-stripping expressions.
The generated command contains no raw multiline payload. The decoded script still enables strict shell mode, disables interactive Git prompts, and requires batch-mode SSH for server-side Git operations.
### Temporary-directory lock cleanup
The Bash syntax regression test now retries cleanup when Windows briefly retains a working-directory handle after `bash -n` exits. A successful syntax validation is no longer reported as failed solely because of a short-lived `EBUSY`, `EPERM`, or `ENOTEMPTY` cleanup condition.
### Additional correction
The remote status reader now invokes `base64` with the status filename in the correct argument position before stripping CR/LF characters.
## Regression coverage
Coverage verifies:
- Windows Git Bash execution from a project root containing spaces;
- a single-line base64 transport for generated Unraid inspection commands;
- preserved runtime-path inspection without nested quoting failures;
- strict, non-interactive server-side Git settings after decoding;
- safe rollback to ForgeFlow 0.4.0 when an update validation fails.
+9
View File
@@ -0,0 +1,9 @@
# ForgeFlow 0.4.3
## Full clean release
- Replaced the OS-dependent local Bash/Windows-temp-path Unraid inspection test with a platform-independent mocked SSH inspection contract.
- The SSH inspection command is still verified to use Base64 transport and the returned Unraid metadata is parsed and evaluated deterministically.
- Removed the false Windows failure where a local temporary path was interpreted as a remote Linux path.
- Regression coverage confirms preserved runtime paths, nested Git directories, `.dockerignore` handling, and remote target resolution.
- This release is distributed as a complete source package rather than another incremental updater.
+95
View File
@@ -0,0 +1,95 @@
# ForgeFlow roadmap
## Delivered through v0.4
- coherent Local -> Gitea -> Server desktop model;
- protected Gitea credential storage and strict IPC boundary;
- real Git status, diff, stage, commit, push, fetch and fast-forward pull;
- branches, stashes, favorites and automatic local awareness;
- multiple Gitea Actions and SSH / Unraid deployment profiles;
- exact-SHA remote-branch validation, runner polling, request-ID verification, health and rollback;
- five-step readiness/setup wizard;
- system and deployment preflight engine;
- structured rotating diagnostic JSONL logs;
- aggressive credential/path redaction;
- standard/strict support bundles with SHA-256 and fail-closed safety audit;
- no automatic ingestion or persistence of raw runner logs;
- root-owned declarative server target configuration;
- cross-layer request-ID correlation;
- canonical end-to-end setup guide;
- 36 automated tests.
The source is now intended to be locally configured and testable without
sharing credentials. It remains a developer preview until a real environment
acceptance pass is completed.
## Milestone A — Real personal acceptance
- run the canonical setup guide on the target Windows machine;
- connect the actual Gitea instance locally;
- use one non-critical staging repository;
- register a narrowly scoped trusted runner;
- install the target configuration, entry point and status endpoint;
- pass Deployment preflight;
- validate commit -> push -> deploy -> status -> health -> rollback;
- deliberately test stopped runner, wrong branch, missing workflow, failed
health and lock contention;
- export/inspect a strict diagnostic bundle from a failed test;
- capture only non-secret environment-specific adjustments in documentation.
Exit: one real application can be released and restored without code changes to
ForgeFlow itself.
## Milestone B — Git completeness
- partial-hunk staging/discard;
- amend and signing checks;
- richer branch publication/upstream controls;
- conflict helper and editor integration;
- protected-branch awareness;
- pull-request creation;
- submodule/worktree policy.
## Milestone C — Desktop operations
- native notifications and system tray;
- background start preference;
- notification center;
- native menus and expanded keyboard navigation;
- configurable editor/terminal commands;
- repository attention rules and snoozing;
- safer periodic remote fetch scheduling.
## Milestone D — Recovery and audit
- append-only audit export distinct from diagnostics;
- deployment notes and release annotations;
- explicit reconciliation of externally deployed versions;
- per-environment recovery runbook links;
- encrypted configuration backup/restore without token export;
- deployment freeze and maintenance-window policies.
## Milestone E — Additional controlled adapters
- mutually authenticated ForgeFlow server agent;
- Portainer stack deployment;
- systemd adapter;
- Kubernetes adapter.
Every adapter must retain exact version identity, allowlisting, lock control,
health verification, diagnostic correlation and no arbitrary shell input.
## Milestone F — Productization
- Windows installer/portable acceptance;
- macOS/Linux package validation;
- code signing, notarization and signed updates;
- dependency/secret/package scans;
- accessibility review;
- hundreds-of-repositories performance tests;
- opt-in privacy-aware crash reporting;
- documented Gitea/Git/runner support matrix;
- stable configuration migration rollback policy.
- built-in private-Gitea source updater with backup and rollback;
- Unraid server inventory, generated basic Compose and exact-SHA SSH deployment.
+132
View File
@@ -0,0 +1,132 @@
# Security model
ForgeFlow bridges developer credentials, local source trees and production
release controls. The design favors constrained operations over arbitrary
flexibility.
## Desktop boundary
- `nodeIntegration: false`;
- `contextIsolation: true`;
- renderer sandbox enabled;
- Content Security Policy limited to packaged resources;
- narrow frozen preload API;
- IPC rejected unless it originates from the packaged file renderer;
- external navigation restricted to HTTP(S);
- renderer errors reported through sanitized diagnostic IPC;
- support-bundle reveal restricted to the last archive created by the main
process.
## Credentials and persistence
- Gitea token encrypted through Electron `safeStorage` where available;
- session-only fallback when OS encryption is unavailable;
- token omitted from renderer-visible public state;
- encrypted token blob excluded from diagnostic bundles;
- blank settings token field preserves the existing token;
- atomic config replacement and restrictive permissions where supported;
- service URLs reject embedded user credentials;
- no token is required by setup/build scripts or documentation.
## Git operations
- Git executed through `execFile` argument arrays, never shell interpolation;
- local repository and Git root verified;
- file actions accept only repository-relative paths;
- absolute paths, traversal and NUL characters rejected;
- clone remotes restricted to supported Git protocols; embedded passwords
rejected;
- renderer supplies repository identity rather than a free-form remote URL;
- automatic clone targets are calculated in the main process below a selected
project root;
- matching existing origins may be linked, while different repositories and
non-empty ordinary folders are blocked;
- branch names validated by Git;
- fast-forward-only pull;
- branch switching/creation require a clean tree;
- selected-commit flow refuses hidden staged files outside the selection;
- monitor pauses around mutating actions.
## Deployment
- fixed workflow filenames, branch and environment;
- full 4064 character SHA required;
- local state re-read immediately before dispatch;
- clean, published and synchronized branch required;
- deployment SHA must equal local `HEAD`;
- deploy and rollback SHA must belong to the allowed remote branch;
- no free-form server commands over IPC or workflow inputs;
- mandatory deployment preflight in the normal UI flow;
- backend validation repeated after preflight;
- exact target displayed in confirmation;
- independent health and live-SHA checks;
- rollback uses a separate fixed workflow and previous full SHA;
- UUID request ID correlates desktop, workflow and server state.
## Diagnostics and redaction
- structured events are sanitized before writing;
- IPC payloads and HTTP authorization headers are not logged;
- sensitive object keys, including camelCase, are removed;
- known active tokens, authorization strings, query tokens, URL passwords,
private-key blocks and common token formats are redacted;
- home/source paths are aliased;
- strings and collections are bounded;
- logs rotate by day/size and expire by retention policy;
- support bundles offer deterministic strict-privacy aliases;
- raw runner logs, diffs and source file contents are omitted from bundles;
- bundle creation performs a final fail-closed scan for known secrets,
private-key markers and URL credentials;
- bundle SHA-256 is displayed for exact identification.
No generic detector can identify a completely unknown arbitrary secret printed
without context by third-party code. ForgeFlow minimizes that residual risk by
not exporting raw runner output and by requiring user inspection before sharing.
## Runner boundary
Use a production-capable runner only for repositories you trust. Give it a
label unique to the intended environment and the narrowest repository or
organization scope.
Avoid exposing a host Docker socket to untrusted jobs. Treat a runner capable of
host deployment as privileged infrastructure.
## Server entry point
The runner account should not receive unrestricted sudo or SSH access. The
included model uses:
- root-owned `/usr/local/bin/forgeflow-deploy`;
- root-owned `/etc/forgeflow/targets.conf` without group/other write access;
- a sudoers rule for that exact executable only;
- exact repository/environment matching;
- absolute-path and branch validation;
- full-SHA remote ancestry proof;
- per-target `flock` lock;
- fixed Compose and healthcheck configuration;
- atomic non-secret status JSON;
- previous-SHA recording and non-zero failure exits.
## Remaining release hardening
- code-sign packages and signed updates;
- validate private CA/TLS behavior in the target network;
- dependency, secret and binary scans in CI;
- package-level IPC/navigation regression tests;
- OS-specific credential storage and installer acceptance;
- rate/approval policies for team use;
- threat-model every future deployment adapter separately.
## SSH and updater additions
- SSH passwords and private-key passphrases use Electron `safeStorage`;
- diagnostics receive those runtime secrets only for redaction and never export
encrypted credential fields;
- SSH deployment requires a pinned host-key fingerprint;
- remote folders and Compose paths are validated against traversal;
- tracked server-side changes block exact-SHA reset;
- updater tokens are sent only to the configured Gitea origin;
- update archives are checksummed and validated by the full local quality gate;
- source backup is restored when an update fails.
+485
View File
@@ -0,0 +1,485 @@
# ForgeFlow setup and first-test guide
This is the canonical guide for turning the source release into a locally
configured desktop application and testing one complete path:
```text
local change -> commit -> push -> exact-SHA deployment -> healthcheck -> rollback
```
You never need to provide your Gitea token, SSH key or server credentials to a
developer. Enter them only on the computer or server where they belong.
---
## Part 1 — Prepare the Windows desktop
### 1. Extract the release
Extract the complete ForgeFlow ZIP to a normal local directory, for example:
```text
C:\Tools\ForgeFlow
```
Avoid running it directly from inside the ZIP or from a temporary email folder.
### 2. Install the prerequisites
Required:
- Node.js 22 or newer;
- npm, normally installed with Node.js;
- Git for Windows available on `PATH`;
- a normal signed-in Windows desktop session so Electron can use OS credential
encryption.
Optional manual check:
```powershell
node --version
npm --version
git --version
git config --global user.name
git config --global user.email
```
Configure the Git identity when either value is empty:
```powershell
git config --global user.name "YOUR NAME"
git config --global user.email "YOUR EMAIL"
```
### 3. Run the local setup command
Open PowerShell in the extracted folder and run:
```powershell
Set-ExecutionPolicy -Scope Process Bypass
.\setup-windows.ps1
```
This command:
1. checks Node.js, npm and Git;
2. installs the declared project dependency versions;
3. runs source validation and all automated tests;
4. starts the Electron desktop application.
No Gitea or server credential is requested by the PowerShell script.
---
## Part 2 — Complete the ForgeFlow desktop wizard
The first launch uses five explicit steps.
### Step 1. Readiness
Select **Run readiness check**. ForgeFlow verifies:
- Git CLI availability;
- Git author identity;
- writable application storage;
- writable diagnostic storage;
- availability of operating-system credential encryption.
Warnings are informative. Red required checks block completion until resolved.
A safe setup diagnostic ZIP can already be exported at this stage.
### Step 2. Gitea
Create a Gitea access token (personal access token) in your own Gitea account. The exact scope labels
can vary by Gitea version. Give it only the minimum rights needed for:
- reading the repositories you want to show in ForgeFlow;
- reading repository contents and branches;
- reading Actions runs and jobs;
- dispatching the fixed deployment and rollback workflows.
Do not put this token in a Markdown file, `.env`, workflow or chat message.
Enter locally in ForgeFlow:
```text
Instance URL: https://YOUR-GITEA-HOST
Access token: PASTE LOCALLY IN THE PASSWORD FIELD
```
Select **Validate & continue**. ForgeFlow confirms the user identity and
repository access. When OS encryption is available, the token is stored with
Electron `safeStorage`; otherwise it remains session-only and must be entered
again after restarting.
### Step 3. Folders
Choose one or more project roots that contain local repositories, for
example:
```text
C:\Development
D:\Projects
```
Do not select the entire system disk. A focused project root produces faster
and clearer discovery.
The first configured root is also the default clone destination. When cloning
`owner/repository`, ForgeFlow automatically creates:
```text
<first-project-root>\repository
```
The normal **Clone from Gitea** action does not open a folder picker. Use
**Choose another location** only when a repository belongs under a different
parent directory. ForgeFlow still creates the repository-named subfolder.
### Step 4. Discovery
ForgeFlow scans Git metadata and matches each local `origin` to a Gitea
repository. Generated dependency directories are skipped.
### Step 5. Ready
Enter ForgeFlow. Repositories that could not be matched can still be linked or
cloned from their repository screen. A clone is automatically linked and
monitored after Git completes.
---
## Part 3 — Prepare one repository for deployment
Start with a non-critical staging application when possible.
### 1. Verify the local repository
The repository should have:
- a configured `origin` pointing to the same Gitea repository;
- a normal branch such as `main`;
- no unresolved conflicts;
- an upstream branch after the first push.
### 2. Add the fixed Gitea Actions workflows
Copy:
```text
examples/gitea-actions/deploy.yml
examples/gitea-actions/rollback.yml
```
to the target repository as:
```text
.gitea/workflows/deploy.yml
.gitea/workflows/rollback.yml
```
Review the runner label in both files:
```yaml
runs-on: forgeflow-production
```
Replace it with the exact label of the trusted runner that can reach the target
server environment. Commit and push these workflow files before running the
deployment preflight.
The workflows accept only controlled inputs:
```text
environment
commit_sha or target_sha
request_id
```
ForgeFlow creates the `request_id` automatically so desktop diagnostics,
Actions output and server status can be correlated without exposing a secret.
---
## Part 4 — Prepare the server and trusted runner
The example implementation targets a dedicated Git checkout deployed with
Docker Compose. Adapt the allowlisted target values, not the security model.
### 1. Confirm the server prerequisites
On the target server, verify:
```bash
git --version
docker --version
docker compose version
curl --version
flock --version
```
The application checkout must already exist and have a working `origin` that the
server can fetch without interactive prompts.
Example:
```text
/srv/YOUR-APP
/srv/YOUR-APP/compose.yml
```
### 2. Install the target configuration
Copy the template:
```bash
sudo install -d -o root -g root -m 0755 /etc/forgeflow
sudo install -o root -g root -m 0640 \
examples/server/forgeflow-targets.conf \
/etc/forgeflow/targets.conf
```
Edit it as root:
```bash
sudo nano /etc/forgeflow/targets.conf
```
Each active line has seven pipe-separated fields:
```text
repository|environment|app_dir|branch|compose_file|healthcheck_url|status_file
```
Example:
```text
jens/my-app|staging|/srv/my-app-staging|main|/srv/my-app-staging/compose.yml|http://127.0.0.1:18080/health|/var/lib/forgeflow-status/my-app-staging.json
```
Rules:
- repository must exactly match `owner/repository` in Gitea;
- environment must exactly match the ForgeFlow profile value;
- all filesystem paths must be absolute;
- status files must stay under `/var/lib/forgeflow-status/`;
- the configuration must remain root-owned and not group/other writable.
Validate permissions:
```bash
sudo stat -c '%U %G %a %n' /etc/forgeflow/targets.conf
```
Expected owner is `root`; a mode such as `640` is appropriate.
### 3. Install the allowlisted deployment entry point
```bash
sudo install -o root -g root -m 0755 \
examples/server/forgeflow-deploy \
/usr/local/bin/forgeflow-deploy
```
The script:
- accepts only a valid repository, environment, full SHA and request ID;
- resolves the repository/environment through the root-owned target file;
- rejects unsafe paths and branches;
- prevents concurrent deployments with `flock`;
- fetches the allowed branch;
- proves that the requested SHA is an ancestor of the remote branch;
- resets only the dedicated deployment checkout;
- runs the fixed Docker Compose redeploy;
- performs repeated healthchecks;
- writes live, previous and requested SHAs atomically;
- records the correlation request ID and last exit code.
### 4. Restrict runner elevation
Copy and edit the sudoers example:
```bash
sudo install -o root -g root -m 0440 \
examples/server/forgeflow-runner.sudoers \
/etc/sudoers.d/forgeflow-runner
sudo visudo -cf /etc/sudoers.d/forgeflow-runner
```
Replace `act_runner` with the actual trusted runner account. Do not grant that
account unrestricted passwordless `sudo`, shell access or wildcard commands.
### 5. Register and start the Gitea runner
Register a dedicated trusted runner according to your Gitea instance and runner
version. Attach the exact label referenced by the workflow, for example:
```text
forgeflow-production
```
Only repositories you control should be able to schedule jobs on a runner with
production access.
---
## Part 5 — Publish server version status
The server script writes one non-secret JSON status document per environment.
Serve it over HTTPS independently of the application process so it can still
report a failed release.
Copy and adapt:
```text
examples/server/nginx-forgeflow-status.conf
```
Example URL:
```text
https://YOUR-APP-HOST/.well-known/forgeflow
```
Expected response:
```json
{
"repository": "jens/my-app",
"environment": "staging",
"request_id": "00000000-0000-0000-0000-000000000000",
"commit_sha": "0123456789abcdef0123456789abcdef01234567",
"previous_sha": "89abcdef0123456789abcdef0123456789abcdef",
"requested_sha": "0123456789abcdef0123456789abcdef01234567",
"deployed_at": "2026-07-24T12:00:00Z",
"health": "healthy",
"last_exit_code": 0
}
```
Test from the ForgeFlow desktop computer:
```powershell
Invoke-WebRequest "https://YOUR-APP-HOST/.well-known/forgeflow"
Invoke-WebRequest "https://YOUR-APP-HOST/health"
```
See [`STATUS_ENDPOINT.md`](STATUS_ENDPOINT.md) for the accepted contract.
---
## Part 6 — Create the deployment profile in ForgeFlow
Open the linked repository and add an environment.
Fill in:
```text
Profile name: Staging
Environment input: staging
Allowed branch: main
Deploy workflow: deploy.yml
Rollback workflow: rollback.yml
Status URL: https://YOUR-APP-HOST/.well-known/forgeflow
Healthcheck URL: https://YOUR-APP-HOST/health
Confirmation: enabled
```
Save the profile.
---
## Part 7 — Run Deployment preflight
Select **Preflight** on the environment card. ForgeFlow must verify:
1. local repository link;
2. valid Git working tree;
3. allowed current branch;
4. clean working tree;
5. published upstream;
6. zero commits ahead and zero behind;
7. exact local SHA exists on the allowed remote branch;
8. local deploy workflow exists;
9. remote deploy workflow exists on Gitea;
10. Gitea Actions API is readable;
11. a configured server status endpoint;
12. current status-endpoint reachability;
13. application healthcheck result when configured.
The status URL is mandatory because ForgeFlow uses it after the workflow to prove
that the server applied the exact SHA for the exact request ID. An unreachable
status document can be a warning before the very first deployment because the
server script may create it, but the operation cannot finish successfully until
the endpoint returns the requested SHA and request ID. Required failures block
the Continue button and the deployment backend repeats its own Git/SHA checks at
dispatch time.
---
## Part 8 — First safe end-to-end test
Use a staging profile first.
1. Make a harmless visible change.
2. Review the diff in ForgeFlow.
3. Enter a commit message.
4. Select **Commit & push**.
5. Confirm that Local and Gitea show the same SHA.
6. Select **Deploy SHA -> Staging**.
7. Review and continue through Deployment preflight.
8. Confirm the exact SHA.
9. Follow workflow, job and healthcheck progress.
10. Confirm that the server status endpoint reports the same full SHA.
11. Create a second harmless commit and deploy it.
12. Use **Rollback** to restore the recorded previous SHA.
Also test deliberately:
- an uncommitted local change;
- a local commit that was not pushed;
- the wrong branch;
- a missing workflow file;
- a stopped runner;
- a failed healthcheck;
- a second deployment while the lock is held.
ForgeFlow should block unsafe local states and clearly retain failed operation
metadata for diagnostics.
---
## Part 9 — Export a diagnostic bundle without sharing credentials
Open **Diagnostics**.
1. Run **System preflight**.
2. Select **Strict privacy** when sharing externally.
3. Select **Create diagnostic ZIP**.
4. Inspect the ZIP before sending it.
The bundle deliberately contains no encrypted token field and omits raw runner
logs. Before writing the ZIP, ForgeFlow runs a safety audit for known runtime
secrets, private-key markers and unredacted URL credentials. If that audit
fails, no bundle is written.
See [`DIAGNOSTICS.md`](DIAGNOSTICS.md) for the exact contents and limitations.
---
## Part 8 — Configure an Unraid server
Open **Settings → SSH / Unraid servers**. Enter the host, SSH port, username and
`/mnt/user/appdata` as the base path. Prefer a private key. Save, then run
**Test & trust** to record the server host-key fingerprint.
For an existing project, enter its current server folder name. ForgeFlow
inspects the root Git repository, tracked modifications, Compose files and
nested repositories before it permits deployment.
For a new project, use the repository name as server folder. Select the
repository Compose file or enable basic generated Compose and enter host and
container ports.
See `docs/SSH_UNRAID_DEPLOYMENT.md`.
+112
View File
@@ -0,0 +1,112 @@
# SSH / Unraid deployment
ForgeFlow 0.4 can deploy an exact Gitea commit directly to an Unraid server over SSH.
## Security model
- Enter credentials only in the local ForgeFlow desktop window.
- Prefer an Ed25519 private key over a password.
- ForgeFlow stores passwords and private-key passphrases through Electron safe storage.
- The first successful test records the SSH host-key fingerprint.
- Later connections fail closed when that fingerprint changes.
- Diagnostics redact the Gitea token, SSH password and private-key passphrase.
- ForgeFlow never sends arbitrary commands entered through the renderer. Deployment commands are assembled from validated profile fields.
## Configure the server
Open **Settings → SSH / Unraid servers → Add server**.
Typical Unraid values:
```text
Name: Unraid
Host: 192.168.1.10
Port: 22
Username: root
Base path: /mnt/user/appdata
Auth: Private key
```
Save the server, then choose **Test & trust**. ForgeFlow verifies SSH, Git and Docker Compose and records the host-key fingerprint.
## Existing application folder
Create a deployment profile and choose **SSH / Unraid**.
For an existing folder:
```text
Server folder: lumaops
Remote path: /mnt/user/appdata/lumaops
Compose file: docker-compose.yml
```
ForgeFlow inspects the folder before deployment. An existing deployment is adopted only when the project root is a Git working tree. Tracked server-side changes block deployment. Untracked runtime paths such as `.env`, `appdata`, `data`, `logs`, `config` and `compose.override.yml` remain untouched by `git reset --hard`.
Keep the root `.git` directory. It is used to verify the exact commit, update the working tree and roll back to the previous SHA.
Nested Git repositories are reported as warnings and are never removed automatically.
When a Dockerfile is present, preflight also inspects `.dockerignore`. It reports
whether `.git` is excluded and warns when existing preserved runtime folders or
nested repositories would still be sent as Docker build context. Fix those
rules in the repository and commit them rather than changing only the live
server copy.
## New application folder
For a new project, ForgeFlow creates:
```text
/mnt/user/appdata/<repository-name>
```
The Unraid server clones the configured Git URL on the selected branch. The server therefore needs access to that repository, normally through an SSH deploy key or an existing trusted Gitea SSH identity.
Two Compose modes are available:
1. **Use repository Compose file** — recommended for real applications. Keep ports, volumes, devices, networks and Unraid labels version-controlled.
2. **Generate basic ForgeFlow Compose** — suitable for a simple Dockerfile-based application. ForgeFlow asks for host port, container port, service/container name, Web UI URL and icon URL and writes `.forgeflow/compose.forgeflow.yml`.
Generated Compose deliberately stays minimal. Projects requiring USB devices, GPU access, custom networks, secrets or multiple services should provide their own Compose file.
## Deployment sequence
1. Verify that the local repository is clean, on the allowed branch and fully synchronized with Gitea.
2. Verify that the exact requested SHA exists on `origin/<branch>`.
3. Verify the repository Compose file or Dockerfile locally.
4. Connect through pinned SSH.
5. Inspect the target folder.
6. Refuse tracked server-only modifications.
7. Clone when the folder does not exist.
8. Fetch the configured branch without allowing interactive credential prompts.
9. Verify again on the server that the requested full SHA belongs to `origin/<branch>`.
10. Save the current SHA as the rollback target.
11. Reset the working tree to the exact requested SHA.
12. Validate the selected Compose file.
13. Run `docker compose up -d --build --remove-orphans`.
14. Store non-secret state under `.forgeflow/` and run the configured healthcheck.
## Folder names
The default folder is the repository name. Existing deployments can keep another folder name by entering it explicitly in the profile. ForgeFlow does not rename populated application folders automatically because Docker paths, scripts and external integrations may depend on them.
A later controlled migration can align names after a successful backup and downtime window.
## Rollback
After a successful deployment, the previous SHA is stored in:
```text
.forgeflow/previous-sha
```
Rollback is accepted only for the exact SHA currently recorded as the previous
deployment. ForgeFlow rechecks that SHA against the configured Gitea branch,
refuses tracked server-side changes, resets the same working tree, runs Docker
Compose again and repeats the healthcheck. The version that was live before the
rollback becomes the new rollback target.
## Before the first real deployment
Back up the application folder and its persistent data. Run **Preflight** and resolve every failed check. Warnings, such as a nested Git repository, should be reviewed but do not automatically delete or modify anything.
+59
View File
@@ -0,0 +1,59 @@
# Server status endpoint contract
A workflow can report success while the wrong application version is running.
ForgeFlow therefore supports a small server-side endpoint that independently
reports the deployed commit.
## Canonical response
```json
{
"repository": "jens/example-app",
"environment": "production",
"request_id": "3a6ed71c-d52d-4d8d-9678-96e0c9456a81",
"commit_sha": "0123456789abcdef0123456789abcdef01234567",
"previous_sha": "89abcdef0123456789abcdef0123456789abcdef",
"requested_sha": "0123456789abcdef0123456789abcdef01234567",
"deployed_at": "2026-07-24T13:00:00Z",
"health": "healthy",
"last_exit_code": 0
}
```
Required for exact version verification:
- `commit_sha`: full 4064 character hexadecimal commit identity.
Recommended:
- `previous_sha`: previous successful commit used by rollback;
- `requested_sha`: SHA requested by the latest deployment attempt;
- `request_id`: ForgeFlow operation correlation identifier;
- `deployed_at`: ISO-8601 timestamp;
- `health`: `healthy`, `deploying` or `unhealthy`;
- `last_exit_code`: server entry-point result, with `0` for success;
- `repository` and `environment`: useful for human consistency checks.
ForgeFlow also accepts `commitSha`, `sha`, `previousSha`, `requestId` and nested
`version.sha`, but canonical snake-case fields are preferred.
## Isolation
Serve the endpoint independently from the deployed application when practical.
A static JSON file exposed by the reverse proxy remains readable when the
application fails to boot. The included deployment script writes it atomically.
The JSON file is non-secret and can be served read-only. Do not include tokens,
host credentials, environment variables, registry secrets or stack traces.
## Profile configuration
Set both URLs when available:
- **Status URL**: returns this document and exact live SHA;
- **Healthcheck URL**: returns a successful HTTP status only when the application
is operational.
After an Actions run succeeds, ForgeFlow checks both. It marks the operation
failed when the healthcheck is unhealthy or the server reports another SHA than
the requested deployment.
+99
View File
@@ -0,0 +1,99 @@
# Stitch review and design corrections
## What worked well
The Stitch export established a strong visual starting point:
- A restrained graphite theme suitable for long sessions.
- Compact desktop density.
- Clear technical typography.
- Useful deployment progress and failure concepts.
- A credible developer-tool tone without excessive decoration.
- Good use of green, amber and red for operational state.
## What was changed
### 1. From IDE shell to release cockpit
The export contained navigation for Editor, Monitoring and Extensions. Those features would blur the product into an incomplete IDE. ForgeFlow instead complements the user's existing editor and terminal.
The product boundary is now:
```text
Understand repository state -> perform safe Git action -> release exact version
```
### 2. One navigation model
The mock-ups mixed a top product navigation with a broad left application navigation. The implementation uses:
- A compact top bar for global search, identity, refresh and theme.
- A left rail for Overview, Deployments, Settings and repositories.
- Repository tabs only inside the selected project.
### 3. Operational cards instead of generic statistics
CPU, queue or server graphs are not useful unless ForgeFlow becomes a monitoring suite. The overview now answers:
- Which projects have local changes?
- Which commits are not pushed?
- Which repositories are behind or conflicted?
- Which exact commits are ready to deploy?
### 4. Persistent Local -> Gitea -> Server rail
The most important state was made visible at the top of every repository workspace. The user no longer needs to infer synchronization from several unrelated badges.
### 5. Contextual action panel
The right panel now changes with state:
- Link or clone.
- Resolve conflict.
- Commit and push.
- Fast-forward synchronize.
- Push commits.
- Configure deployment.
- Deploy exact SHA.
- Explain the blocking error.
Only one action is visually dominant.
### 6. Diff viewer, not editor
ForgeFlow displays changed files and diffs, but deliberately opens the real project folder for editing. This avoids duplicating editor features and keeps the application technically realistic.
### 7. Safer deployment language
A generic **Deploy** button can hide too much. ForgeFlow displays the exact action:
```text
Deploy b82f91a -> Production
```
The confirmation state shows the repository, branch, full SHA and workflow file.
### 8. Desktop behavior
The implementation adds details that static screens could not provide:
- Native directory selection.
- External-link restrictions.
- Keyboard shortcut for global search.
- Ctrl/Cmd+Enter for commit and push.
- Resizable desktop layout.
- Offline and error handling foundations.
- Secure process boundary between UI and system operations.
## Visual direction retained
The implementation intentionally keeps:
- Deep neutral background and panels.
- Blue primary actions.
- Green synchronization and health.
- Amber pending work.
- Red actual failures and conflicts.
- Compact status badges.
- Monospace only for branches, commits, paths and logs.
- Minimal decorative effects.
+106
View File
@@ -0,0 +1,106 @@
# Test matrix
## Automated in v0.4.0
The suite contains 59 passing tests.
### Git and repository behavior
- porcelain v2 ordinary and rename parsing;
- HTTPS and SCP-style remote matching;
- real temporary bare remote: status, diff, selected commit and push;
- real temporary bare remote: commit-only, branch creation/publication and
remote-SHA ancestry verification;
- real stash creation, listing, pop and untracked-file restoration;
- repository monitor baseline, change detection and pause/resume;
- safe repository folder-name derivation from HTTPS and SSH clone URLs;
- automatic target construction beneath the project root;
- missing, empty and matching-checkout clone target handling;
- different repository, ordinary non-empty folder and file conflict rejection.
### Gitea and deployment behavior
- Gitea URL/credential validation;
- Actions run normalization across payload shapes;
- optional query-filter compatibility retry;
- runs-to-tasks fallback;
- newest matching run selection;
- repository workflow contents lookup and 404 behavior;
- deployment terminal-status mapping;
- controlled dispatch inputs that cannot be overridden by profile data;
- exact post-workflow SHA and request-ID verification;
- rollback input allowlisting and exact current previous-SHA enforcement;
- complete deployment preflight with Git, workflow, Actions, status and health
mocks.
### Security and diagnostics
- repository path traversal and absolute-path rejection;
- workflow filename, branch, environment and full-SHA validation;
- clone protocol and embedded-password rejection;
- runtime token, authorization, query token, URL credential and private-key
redaction;
- camelCase and nested sensitive-key removal;
- home-path aliasing;
- deterministic strict-privacy identifier hashing;
- required versus optional preflight blocking behavior;
- system preflight before credentials are entered;
- structured JSONL diagnostic writes;
- support-bundle strict privacy and secret exclusion;
- ZIP structure, deflate payloads and CRC validation.
- Windows npm command-shim discovery through `npm_execpath` and `cmd.exe`;
- normal direct npm discovery on non-Windows systems.
## Static source quality gate
`npm run verify` checks:
- all required source, documentation and server-template files;
- JavaScript syntax across the project;
- package version and required scripts;
- desktop packaging metadata and icons;
- Bash syntax for the server entry point;
- status JSON parsing;
- required setup-guide sections;
- renderer entry hooks.
## Manual before a real production release
- setup wizard against the installed Gitea version;
- repository discovery on the target Windows system;
- token persistence through Windows credential protection;
- HTTPS and/or SSH Git authentication;
- actual Actions dispatch, run resolution and job visibility;
- runner label and repository trust scope;
- server target-file ownership/mode enforcement;
- status endpoint through the real reverse proxy;
- deployment lock, failed healthcheck and rollback;
- diagnostic ZIP inspection after a deliberately failed deployment;
- unsigned installer and portable build on Windows;
- keyboard-only and screen-reader smoke test.
## Renderer smoke target
The standalone demo should be checked at minimum at:
- 1120 × 720;
- 1440 × 900;
- 1920 × 1080.
Required views now include setup readiness, dashboard, repository workspace,
deployment preflight, active run, success/failure and Diagnostics.
### v0.4 additions
- bounded independently scrollable changed-file layout;
- explicit commit-message and selection readiness contract;
- ITWorx.tech asset integration;
- semantic update-version comparison;
- exact-SHA Gitea update manifest lookup;
- update repository path-injection rejection;
- SSH host-key fingerprint helper;
- remote shell quoting;
- Unraid folder and Compose path escape rejection;
- server inspection payload decoding;
- SSH deployment preflight summary behavior.
+55
View File
@@ -0,0 +1,55 @@
# Updating ForgeFlow on Windows
ForgeFlow stores its local token, server credentials, repository mappings,
preferences, deployment profiles, diagnostics and operation history outside the
source directory.
## Manual update to v0.4.0
1. Close ForgeFlow.
2. Extract the v0.4.0 update package.
3. Copy the contents of its `ForgeFlow` folder over the existing source folder.
4. Do not create `ForgeFlow\ForgeFlow`.
5. Open PowerShell in the existing folder.
6. Run:
```powershell
Set-ExecutionPolicy -Scope Process Bypass
.\update-windows.ps1
```
The script installs the new `ssh2` dependency, runs the environment doctor,
verifies the source and executes all tests before starting ForgeFlow.
## Built-in updates after v0.4.0
Open **Settings → ForgeFlow updates**.
The default source is:
```text
Gitea instance: the instance already configured in ForgeFlow
Repository: Jens/ForgeFlow
Branch: main
```
The repository must contain a newer semantic version in `package.json`.
Choose:
1. **Check now**
2. **Download update**
3. **Apply & restart**
The source updater downloads the exact branch commit, records a SHA-256 checksum,
backs up the installed source, applies the archive, runs `npm install` and
`npm run check`, and restarts ForgeFlow. When validation fails it restores the
previous source and starts that version again.
The update log is written beneath ForgeFlow's local user-data `updates` folder
and does not contain the Gitea token.
The updater deliberately checks the semantic version stored in the remote
`package.json`. Merely pushing a new commit without increasing that version does
not present an update. Publish the complete validated ForgeFlow source to the
configured repository and bump the version for every release.
Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

+51
View File
@@ -0,0 +1,51 @@
name: ForgeFlow deployment
on:
workflow_dispatch:
inputs:
environment:
description: Fixed ForgeFlow deployment environment
required: true
default: production
commit_sha:
description: Exact commit verified by ForgeFlow
required: true
request_id:
description: ForgeFlow correlation identifier
required: true
concurrency:
group: forgeflow-${{ gitea.repository }}-${{ inputs.environment }}
cancel-in-progress: false
jobs:
deploy:
# Register a trusted runner with this label, or replace it with your own.
runs-on: forgeflow-production
steps:
- name: Validate dispatch inputs
shell: bash
run: |
set -euo pipefail
[[ "${{ inputs.environment }}" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]] || {
echo "Invalid environment identifier" >&2
exit 64
}
[[ "${{ inputs.commit_sha }}" =~ ^[0-9a-fA-F]{40,64}$ ]] || {
echo "Invalid commit SHA" >&2
exit 64
}
[[ "${{ inputs.request_id }}" =~ ^[A-Za-z0-9._:-]{1,100}$ ]] || {
echo "Invalid request identifier" >&2
exit 64
}
- name: Deploy exact allowlisted version
shell: bash
run: |
set -euo pipefail
sudo /usr/local/bin/forgeflow-deploy \
"${{ gitea.repository }}" \
"${{ inputs.environment }}" \
"${{ inputs.commit_sha }}" \
"${{ inputs.request_id }}"
+50
View File
@@ -0,0 +1,50 @@
name: ForgeFlow rollback
on:
workflow_dispatch:
inputs:
environment:
description: Fixed ForgeFlow deployment environment
required: true
default: production
target_sha:
description: Exact previously successful commit verified by ForgeFlow
required: true
request_id:
description: ForgeFlow correlation identifier
required: true
concurrency:
group: forgeflow-${{ gitea.repository }}-${{ inputs.environment }}
cancel-in-progress: false
jobs:
rollback:
runs-on: forgeflow-production
steps:
- name: Validate rollback inputs
shell: bash
run: |
set -euo pipefail
[[ "${{ inputs.environment }}" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]] || {
echo "Invalid environment identifier" >&2
exit 64
}
[[ "${{ inputs.target_sha }}" =~ ^[0-9a-fA-F]{40,64}$ ]] || {
echo "Invalid target SHA" >&2
exit 64
}
[[ "${{ inputs.request_id }}" =~ ^[A-Za-z0-9._:-]{1,100}$ ]] || {
echo "Invalid request identifier" >&2
exit 64
}
- name: Restore exact allowlisted version
shell: bash
run: |
set -euo pipefail
sudo /usr/local/bin/forgeflow-deploy \
"${{ gitea.repository }}" \
"${{ inputs.environment }}" \
"${{ inputs.target_sha }}" \
"${{ inputs.request_id }}"
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 027
# Install as /usr/local/bin/forgeflow-deploy, owned by root and not writable by
# the Gitea runner. Targets are read from the root-owned data file below.
readonly CONFIG_FILE="/etc/forgeflow/targets.conf"
readonly REPOSITORY="${1:-}"
readonly ENVIRONMENT="${2:-}"
readonly SHA="${3:-}"
readonly REQUEST_ID="${4:-manual-$(date +%s)}"
fail_usage() {
echo "Usage: forgeflow-deploy <owner/repository> <environment> <full-sha> [request-id]" >&2
exit 64
}
[[ "$REPOSITORY" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || fail_usage
[[ "$ENVIRONMENT" =~ ^[A-Za-z0-9._-]+$ ]] || fail_usage
[[ "$SHA" =~ ^[0-9a-fA-F]{40,64}$ ]] || fail_usage
[[ "$REQUEST_ID" =~ ^[A-Za-z0-9._:-]{1,100}$ ]] || fail_usage
[[ -f "$CONFIG_FILE" ]] || { echo "Missing target configuration: $CONFIG_FILE" >&2; exit 78; }
# The target file is security-sensitive because it controls root-executed paths.
config_owner="$(stat -c '%U' "$CONFIG_FILE")"
config_mode="$(stat -c '%a' "$CONFIG_FILE")"
[[ "$config_owner" == "root" ]] || { echo "Target configuration must be owned by root" >&2; exit 78; }
# Reject group/other write bits. GNU stat returns an octal string such as 640.
(( (8#$config_mode & 8#022) == 0 )) || { echo "Target configuration may not be group/other writable" >&2; exit 78; }
APP_DIR=""
BRANCH=""
COMPOSE_FILE=""
HEALTHCHECK_URL=""
STATUS_FILE=""
while IFS='|' read -r config_repository config_environment config_app_dir config_branch config_compose config_health config_status extra; do
[[ -z "${config_repository// }" || "$config_repository" == \#* ]] && continue
[[ -z "${extra:-}" ]] || { echo "Invalid extra field in $CONFIG_FILE" >&2; exit 78; }
if [[ "$config_repository" == "$REPOSITORY" && "$config_environment" == "$ENVIRONMENT" ]]; then
APP_DIR="$config_app_dir"
BRANCH="$config_branch"
COMPOSE_FILE="$config_compose"
HEALTHCHECK_URL="$config_health"
STATUS_FILE="$config_status"
break
fi
done < "$CONFIG_FILE"
[[ -n "$APP_DIR" ]] || { echo "Repository/environment is not allowlisted" >&2; exit 64; }
[[ "$APP_DIR" == /* && "$COMPOSE_FILE" == /* && "$STATUS_FILE" == /var/lib/forgeflow-status/* ]] || {
echo "Target configuration contains an unsafe path" >&2
exit 78
}
[[ "$BRANCH" =~ ^[A-Za-z0-9._/-]+$ && "$BRANCH" != *..* ]] || { echo "Unsafe branch in target configuration" >&2; exit 78; }
[[ -d "$APP_DIR/.git" ]] || { echo "Application directory is not a Git working tree: $APP_DIR" >&2; exit 72; }
[[ -f "$COMPOSE_FILE" ]] || { echo "Compose file does not exist: $COMPOSE_FILE" >&2; exit 72; }
install -d -o root -g root -m 0755 "$(dirname "$STATUS_FILE")"
json_string() {
# Inputs accepted by this script are deliberately restricted to characters
# that do not need JSON escaping. This guard prevents accidental expansion.
[[ "$1" =~ ^[A-Za-z0-9._:/-]*$ ]] || return 1
printf '%s' "$1"
}
write_status() {
local health="$1"
local live_sha="$2"
local previous_sha="$3"
local exit_code="${4:-0}"
local deployed_at temporary
deployed_at="$(date --utc +%Y-%m-%dT%H:%M:%SZ)"
temporary="${STATUS_FILE}.${$}.tmp"
json_string "$health" >/dev/null
json_string "$REQUEST_ID" >/dev/null
[[ "$live_sha" =~ ^[0-9a-fA-F]{40,64}$ ]] || { echo "Invalid live SHA for status output" >&2; return 1; }
[[ "$previous_sha" =~ ^[0-9a-fA-F]{40,64}$ ]] || { echo "Invalid previous SHA for status output" >&2; return 1; }
cat > "$temporary" <<JSON
{
"repository": "$REPOSITORY",
"environment": "$ENVIRONMENT",
"request_id": "$REQUEST_ID",
"commit_sha": "$live_sha",
"previous_sha": "$previous_sha",
"requested_sha": "$SHA",
"deployed_at": "$deployed_at",
"health": "$health",
"last_exit_code": $exit_code
}
JSON
chmod 0644 "$temporary"
mv -f "$temporary" "$STATUS_FILE"
}
exec 9>"/run/lock/forgeflow-${REPOSITORY//\//-}-${ENVIRONMENT}.lock"
flock -n 9 || { echo "Another deployment is already running" >&2; exit 75; }
current_sha="$(git -C "$APP_DIR" rev-parse HEAD)"
previous_sha="$(cat "$APP_DIR/.forgeflow-live-sha" 2>/dev/null || printf '%s' "$current_sha")"
[[ "$previous_sha" =~ ^[0-9a-fA-F]{40,64}$ ]] || previous_sha="$current_sha"
echo "ForgeFlow request: $REQUEST_ID"
echo "Target: $REPOSITORY / $ENVIRONMENT"
echo "Current SHA: $current_sha"
echo "Requested SHA: $SHA"
on_error() {
local exit_code=$?
local actual_sha
trap - ERR
actual_sha="$(git -C "$APP_DIR" rev-parse HEAD 2>/dev/null || printf '%s' "$current_sha")"
write_status "unhealthy" "$actual_sha" "$previous_sha" "$exit_code" || true
echo "Deployment failed with exit code $exit_code" >&2
exit "$exit_code"
}
trap on_error ERR
git -C "$APP_DIR" fetch --prune origin "$BRANCH"
git -C "$APP_DIR" cat-file -e "$SHA^{commit}"
git -C "$APP_DIR" merge-base --is-ancestor "$SHA" "origin/$BRANCH" || {
echo "Requested SHA is not part of origin/$BRANCH" >&2
exit 65
}
write_status "deploying" "$current_sha" "$previous_sha" 0
git -C "$APP_DIR" reset --hard "$SHA"
docker compose -f "$COMPOSE_FILE" up -d --build --remove-orphans
for attempt in $(seq 1 30); do
if curl --fail --silent --show-error --max-time 5 "$HEALTHCHECK_URL" >/dev/null; then
printf '%s\n' "$SHA" > "$APP_DIR/.forgeflow-live-sha"
printf '%s\n' "$previous_sha" > "$APP_DIR/.forgeflow-previous-sha"
chmod 0640 "$APP_DIR/.forgeflow-live-sha" "$APP_DIR/.forgeflow-previous-sha"
write_status "healthy" "$SHA" "$previous_sha" 0
trap - ERR
echo "Deployment healthy at $SHA"
exit 0
fi
echo "Healthcheck attempt $attempt/30 did not pass yet"
sleep 2
done
trap - ERR
write_status "unhealthy" "$SHA" "$previous_sha" 70
echo "Healthcheck failed after deployment" >&2
exit 70
+4
View File
@@ -0,0 +1,4 @@
# Validate with: sudo visudo -cf /etc/sudoers.d/forgeflow-runner
# Replace "act_runner" with the account that executes your trusted Gitea runner.
# Do not add shell wildcards or other commands.
act_runner ALL=(root) NOPASSWD: /usr/local/bin/forgeflow-deploy
+9
View File
@@ -0,0 +1,9 @@
# ForgeFlow deployment targets
#
# Format (one target per line, no shell syntax):
# repository|environment|app_dir|branch|compose_file|healthcheck_url|status_file
#
# Keep this file root-owned and not writable by the runner account.
jens/example-app|production|/srv/example-app|main|/srv/example-app/compose.yml|http://127.0.0.1:8080/health|/var/lib/forgeflow-status/example-app-production.json
jens/example-app|staging|/srv/example-app-staging|main|/srv/example-app-staging/compose.yml|http://127.0.0.1:18080/health|/var/lib/forgeflow-status/example-app-staging.json
@@ -0,0 +1,8 @@
# Example virtual-host fragment. Keep this endpoint outside the application
# process so it can still report a failed release when the app itself is down.
location = /.well-known/forgeflow {
default_type application/json;
alias /var/lib/forgeflow-status/example-app-production.json;
add_header Cache-Control "no-store" always;
add_header X-Content-Type-Options "nosniff" always;
}
+11
View File
@@ -0,0 +1,11 @@
{
"repository": "jens/example-app",
"environment": "production",
"request_id": "3a6ed71c-d52d-4d8d-9678-96e0c9456a81",
"commit_sha": "0123456789abcdef0123456789abcdef01234567",
"previous_sha": "89abcdef0123456789abcdef0123456789abcdef",
"requested_sha": "0123456789abcdef0123456789abcdef01234567",
"deployed_at": "2026-07-24T13:00:00Z",
"health": "healthy",
"last_exit_code": 0
}
+202
View File
@@ -0,0 +1,202 @@
'use strict';
const path = require('node:path');
const { app, BrowserWindow, shell, session, safeStorage } = require('electron');
const { ConfigStore } = require('./src/main/config-store.cjs');
const { GitService } = require('./src/main/git-service.cjs');
const { GiteaService } = require('./src/main/gitea-service.cjs');
const { RepositoryService } = require('./src/main/repository-service.cjs');
const { DeploymentService } = require('./src/main/deployment-service.cjs');
const { RepositoryMonitor } = require('./src/main/repository-monitor.cjs');
const { DiagnosticsService } = require('./src/main/diagnostics-service.cjs');
const { PreflightService } = require('./src/main/preflight-service.cjs');
const { UpdateService } = require('./src/main/update-service.cjs');
const { SshService } = require('./src/main/ssh-service.cjs');
const { UnraidDeploymentService } = require('./src/main/unraid-deployment-service.cjs');
const { registerIpc } = require('./src/main/ipc.cjs');
let mainWindow;
let repositoryMonitor;
let operationTimer;
let diagnostics;
let quitCleanupStarted = false;
function broadcast(channel, payload) {
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send(channel, payload);
}
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1480,
height: 940,
minWidth: 1120,
minHeight: 720,
show: false,
backgroundColor: '#0b0e14',
title: 'ForgeFlow',
icon: path.join(__dirname, 'build', 'icon.png'),
autoHideMenuBar: true,
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true,
spellcheck: false
}
});
mainWindow.loadFile(path.join(__dirname, 'src', 'renderer', 'index.html'));
mainWindow.once('ready-to-show', () => {
mainWindow.show();
diagnostics?.info('window.ready', { size: mainWindow.getSize() });
});
mainWindow.on('unresponsive', () => diagnostics?.warning('window.unresponsive', {}));
mainWindow.webContents.on('render-process-gone', (_event, details) => diagnostics?.error('renderer.process.gone', details));
mainWindow.webContents.on('did-fail-load', (_event, code, description, validatedUrl) => diagnostics?.error('renderer.load.failed', { code, description, validatedUrl }));
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (/^https?:\/\//i.test(url)) shell.openExternal(url);
return { action: 'deny' };
});
mainWindow.webContents.on('will-navigate', (event, url) => {
if (url !== mainWindow.webContents.getURL()) event.preventDefault();
});
}
app.whenReady().then(async () => {
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
"default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'"
]
}
});
});
const userDataPath = app.getPath('userData');
const store = new ConfigStore(userDataPath);
await store.load();
diagnostics = new DiagnosticsService({
userDataPath,
appInfo: { name: app.getName(), version: app.getVersion(), packaged: app.isPackaged },
secretProvider: () => [
store.getToken(),
...(store.data.servers || []).flatMap((server) => {
try {
const credentials = store.getServerCredentials(server.id);
return [credentials.password, credentials.passphrase];
} catch { return []; }
})
],
preferencesProvider: () => store.data.preferences
});
await diagnostics.initialize();
process.on('uncaughtException', (error) => {
diagnostics?.error('process.uncaught-exception', error).finally(() => app.exit(1));
});
process.on('unhandledRejection', (reason) => diagnostics?.error('process.unhandled-rejection', reason instanceof Error ? reason : { reason }));
const git = new GitService();
const gitea = new GiteaService(store, diagnostics);
const repositories = new RepositoryService(store, git, gitea, diagnostics);
const deployments = new DeploymentService(store, gitea, git, diagnostics);
const ssh = new SshService({ store, diagnostics });
const unraid = new UnraidDeploymentService({ store, ssh, git, diagnostics });
const updates = new UpdateService({
store,
gitea,
diagnostics,
appInfo: { version: app.getVersion(), packaged: app.isPackaged },
sourcePath: app.getAppPath(),
userDataPath
});
const preflight = new PreflightService({
store,
git,
gitea,
deployments,
diagnostics,
userDataPath,
secureStorageAvailable: () => safeStorage.isEncryptionAvailable()
});
repositoryMonitor = new RepositoryMonitor({
store,
git,
diagnostics,
onChange: (payload) => broadcast('repositories:changed', payload)
});
repositoryMonitor.restart();
registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh, updates, preflight, diagnostics, monitor: repositoryMonitor });
createWindow();
if (store.data.setupComplete && store.data.updates?.autoCheck && store.getToken()) {
setTimeout(async () => {
try {
const status = await updates.check();
broadcast('updates:changed', status);
} catch (error) {
await diagnostics.warning('updates.startup-check.failed', { message: error.message, code: error.code });
}
}, 2500).unref?.();
}
const gitAvailability = await git.isAvailable();
await diagnostics.info('app.ready', {
platform: process.platform,
arch: process.arch,
setupComplete: store.data.setupComplete,
git: gitAvailability,
secureStorageAvailable: safeStorage.isEncryptionAvailable()
});
const scheduleOperationPoll = () => {
if (operationTimer) clearTimeout(operationTimer);
const intervalMs = Math.max(3, Number(store.data.preferences.operationPollSeconds) || 5) * 1000;
operationTimer = setTimeout(async () => {
if (store.data.setupComplete && store.getToken()) {
const active = store.data.operations.some((item) => item.type === 'deployment' && !['success', 'failed', 'cancelled', 'rolled-back'].includes(item.status));
if (active) {
const updated = await deployments.refreshActiveOperations().catch(async (error) => {
await diagnostics.error('operation-monitor.failed', error);
return [];
});
if (updated.length) broadcast('operations:changed', { operations: updated });
}
}
scheduleOperationPoll();
}, intervalMs);
operationTimer.unref?.();
};
scheduleOperationPoll();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
}).catch(async (error) => {
console.error('[startup]', error);
await diagnostics?.error('app.startup.failed', error);
await diagnostics?.flush();
app.exit(1);
});
app.on('before-quit', (event) => {
if (quitCleanupStarted) return;
event.preventDefault();
quitCleanupStarted = true;
repositoryMonitor?.stop();
if (operationTimer) clearTimeout(operationTimer);
Promise.resolve()
.then(() => diagnostics?.info('app.quitting', {}))
.then(() => diagnostics?.flush())
.finally(() => app.quit());
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
+3677
View File
File diff suppressed because it is too large Load Diff
+89
View File
@@ -0,0 +1,89 @@
{
"name": "forgeflow",
"version": "0.4.3",
"private": true,
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
"main": "main.cjs",
"type": "module",
"scripts": {
"start": "electron .",
"dev": "electron . --dev",
"demo": "node scripts/serve-demo.mjs",
"test": "node --test tests/*.test.mjs",
"verify": "node scripts/verify.mjs",
"dist:win": "electron-builder --win nsis portable",
"dist:linux": "electron-builder --linux AppImage",
"dist:mac": "electron-builder --mac dmg",
"doctor": "node scripts/doctor.mjs",
"check": "npm run verify && npm test"
},
"devDependencies": {
"electron": "43.2.0",
"electron-builder": "26.15.3"
},
"build": {
"appId": "be.jenscaers.forgeflow",
"productName": "ForgeFlow",
"asar": true,
"files": [
"main.cjs",
"preload.cjs",
"src/**/*",
"package.json",
"build/icon.png",
"docs/SETUP_GUIDE.md",
"docs/DIAGNOSTICS.md",
"docs/STATUS_ENDPOINT.md",
"examples/gitea-actions/**/*",
"examples/server/**/*",
"README.md",
"START_HERE.md",
"docs/DEPLOYMENT_SETUP.md",
"docs/SECURITY.md",
"docs/TEST_MATRIX.md",
"docs/RELEASE_NOTES_0.3.0.md",
"docs/RELEASE_NOTES_0.3.1.md",
"docs/RELEASE_NOTES_0.3.2.md",
"docs/UPDATING.md",
"scripts/apply-source-update.ps1",
"docs/RELEASE_NOTES_0.4.0.md",
"docs/LUMAOPS_SERVER_AUDIT.md",
"docs/SSH_UNRAID_DEPLOYMENT.md",
"docs/RELEASE_NOTES_0.4.1.md",
"docs/RELEASE_NOTES_0.4.2.md",
"docs/RELEASE_NOTES_0.4.3.md"
],
"directories": {
"output": "dist"
},
"win": {
"target": [
"nsis",
"portable"
],
"icon": "build/icon.ico"
},
"linux": {
"target": [
"AppImage"
],
"category": "Development",
"icon": "build/icon.png"
},
"mac": {
"target": [
"dmg"
],
"category": "public.app-category.developer-tools",
"icon": "build/icon.png"
},
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}"
},
"engines": {
"node": ">=22"
},
"author": "Jens",
"dependencies": {
"ssh2": "1.17.0"
}
}
+87
View File
@@ -0,0 +1,87 @@
'use strict';
const { contextBridge, ipcRenderer } = require('electron');
async function invoke(channel, payload) {
const result = await ipcRenderer.invoke(channel, payload);
if (!result?.ok) {
const error = new Error(result?.error?.message || 'ForgeFlow operation failed.');
error.code = result?.error?.code;
error.status = result?.error?.status;
error.recoverable = result?.error?.recoverable;
error.commitSha = result?.error?.commitSha;
throw error;
}
return result.data;
}
function subscribe(channel, listener) {
if (typeof listener !== 'function') return () => {};
const handler = (_event, payload) => listener(payload);
ipcRenderer.on(channel, handler);
return () => ipcRenderer.removeListener(channel, handler);
}
contextBridge.exposeInMainWorld('forgeflow', Object.freeze({
bootstrap: () => invoke('app:bootstrap'),
selectDirectory: (payload) => invoke('dialog:select-directory', payload),
selectKeyFile: (payload) => invoke('dialog:select-key-file', payload),
setupPreflight: (payload) => invoke('setup:preflight', payload),
validateGitea: (payload) => invoke('setup:validate-gitea', payload),
completeSetup: (payload) => invoke('setup:complete', payload),
updateGitea: (payload) => invoke('settings:update-gitea', payload),
setWorkspaceRoots: (roots) => invoke('settings:set-roots', { roots }),
setAppearance: (appearance) => invoke('settings:set-appearance', { appearance }),
setPreferences: (preferences) => invoke('settings:set-preferences', { preferences }),
setUpdatePreferences: (updates) => invoke('updates:preferences', { updates }),
checkForUpdates: () => invoke('updates:check'),
downloadUpdate: () => invoke('updates:download'),
applyUpdate: () => invoke('updates:apply'),
saveServer: (server, password = '', passphrase = '') => invoke('server:save', { server, password, passphrase }),
deleteServer: (serverId) => invoke('server:delete', { serverId }),
testServer: (serverId) => invoke('server:test', { serverId }),
inspectServerProject: (repository, profileId) => invoke('server:inspect-project', { repository, profileId }),
refreshRepositories: () => invoke('repositories:refresh'),
discoverRepositories: (roots) => invoke('repositories:discover', { roots }),
favoriteRepository: (fullName, favorite) => invoke('repository:favorite', { fullName, favorite }),
linkRepository: (fullName, localPath) => invoke('repository:link', { fullName, localPath }),
unlinkRepository: (fullName) => invoke('repository:unlink', { fullName }),
repositoryStatus: (localPath) => invoke('repository:status', { localPath }),
repositoryDiff: (localPath, filePath, staged = false) => invoke('repository:diff', { localPath, filePath, staged }),
stageFiles: (localPath, files) => invoke('repository:stage', { localPath, files }),
unstageFiles: (localPath, files) => invoke('repository:unstage', { localPath, files }),
commit: (localPath, message, files) => invoke('repository:commit', { localPath, message, files }),
commitAndPush: (localPath, message, files) => invoke('repository:commit-push', { localPath, message, files }),
push: (localPath) => invoke('repository:push', { localPath }),
fetch: (localPath) => invoke('repository:fetch', { localPath }),
pull: (localPath) => invoke('repository:pull', { localPath }),
history: (localPath, limit = 20) => invoke('repository:history', { localPath, limit }),
branches: (localPath) => invoke('repository:branches', { localPath }),
checkoutBranch: (localPath, branch) => invoke('repository:checkout-branch', { localPath, branch }),
createBranch: (localPath, branch) => invoke('repository:create-branch', { localPath, branch }),
stash: (localPath, message) => invoke('repository:stash', { localPath, message }),
stashList: (localPath) => invoke('repository:stash-list', { localPath }),
popStash: (localPath, ref) => invoke('repository:stash-pop', { localPath, ref }),
cloneRepository: (fullName, mode = 'default') => invoke('repository:clone', { fullName, mode }),
openPath: (localPath) => invoke('repository:open-path', { localPath }),
openExternal: (url) => invoke('external:open', { url }),
saveDeploymentProfile: (fullName, profile) => invoke('deployment:save-profile', { fullName, profile }),
deploymentPreflight: (repository, profileId) => invoke('deployment:preflight', { repository, profileId }),
deleteDeploymentProfile: (fullName, profileId) => invoke('deployment:delete-profile', { fullName, profileId }),
deploy: (repository, profileId, sha) => invoke('deployment:dispatch', { repository, profileId, sha }),
rollback: (repository, profileId, targetSha) => invoke('deployment:rollback', { repository, profileId, targetSha }),
healthcheck: (url) => invoke('deployment:health', { url }),
refreshProfileState: (fullName, profileId) => invoke('deployment:profile-state', { fullName, profileId }),
refreshOperations: (operationId = null) => invoke('operations:refresh', { operationId }),
getOperation: (operationId) => invoke('operations:get', { operationId }),
diagnosticsStatus: () => invoke('diagnostics:status'),
clearDiagnostics: () => invoke('diagnostics:clear'),
openDiagnosticsFolder: () => invoke('diagnostics:open-folder'),
exportDiagnostics: (privacyMode = 'standard') => invoke('diagnostics:export', { privacyMode }),
showDiagnosticBundle: (filePath) => invoke('diagnostics:show-bundle', { filePath }),
reportRendererEvent: (level, event, details = {}) => invoke('renderer:report', { level, event, details }),
onRepositoriesChanged: (listener) => subscribe('repositories:changed', listener),
onOperationsChanged: (listener) => subscribe('operations:changed', listener),
onUpdatesChanged: (listener) => subscribe('updates:changed', listener),
reset: () => invoke('app:reset')
}));
+92
View File
@@ -0,0 +1,92 @@
param(
[Parameter(Mandatory=$true)][string]$SourcePath,
[Parameter(Mandatory=$true)][string]$ArchivePath,
[Parameter(Mandatory=$true)][string]$ExpectedVersion,
[Parameter(Mandatory=$true)][string]$ExpectedSha256,
[Parameter(Mandatory=$true)][int]$ParentPid,
[Parameter(Mandatory=$true)][string]$LogPath
)
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
function Write-UpdateLog {
param([string]$Message)
$line = "$(Get-Date -Format o) $Message"
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $LogPath) | Out-Null
Add-Content -Path $LogPath -Value $line -Encoding UTF8
}
function Invoke-Robocopy {
param([string]$From, [string]$To)
New-Item -ItemType Directory -Force -Path $To | Out-Null
& robocopy.exe $From $To /MIR /R:2 /W:1 /NFL /NDL /NJH /NJS /NP /XD node_modules .git dist | Out-Null
if ($LASTEXITCODE -gt 7) { throw "robocopy failed with exit code $LASTEXITCODE" }
}
try {
Write-UpdateLog "ForgeFlow source update started for version $ExpectedVersion."
$deadline = (Get-Date).AddMinutes(2)
while (Get-Process -Id $ParentPid -ErrorAction SilentlyContinue) {
if ((Get-Date) -gt $deadline) { throw "ForgeFlow did not exit before the update timeout." }
Start-Sleep -Milliseconds 500
}
$actualHash = (Get-FileHash -Path $ArchivePath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualHash -ne $ExpectedSha256.ToLowerInvariant()) { throw "Update archive checksum mismatch." }
$working = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-update-" + [guid]::NewGuid().ToString("N"))
$extract = Join-Path $working "extract"
$backup = Join-Path $working "backup"
New-Item -ItemType Directory -Force -Path $extract | Out-Null
Write-UpdateLog "Creating source backup."
Invoke-Robocopy -From $SourcePath -To $backup
Write-UpdateLog "Extracting update archive."
Expand-Archive -LiteralPath $ArchivePath -DestinationPath $extract -Force
$manifest = Get-ChildItem -Path $extract -Filter package.json -File -Recurse |
Where-Object {
try {
$json = Get-Content $_.FullName -Raw | ConvertFrom-Json
return $json.name -eq "forgeflow" -and $json.version -eq $ExpectedVersion
} catch { return $false }
} |
Select-Object -First 1
if (-not $manifest) { throw "The update does not contain ForgeFlow version $ExpectedVersion." }
$incoming = Split-Path -Parent $manifest.FullName
Write-UpdateLog "Applying verified source files."
Invoke-Robocopy -From $incoming -To $SourcePath
Push-Location $SourcePath
try {
Write-UpdateLog "Installing exact dependencies."
& cmd.exe /d /s /c "npm install --no-audit --no-fund" *>> $LogPath
if ($LASTEXITCODE -ne 0) { throw "npm install failed with exit code $LASTEXITCODE." }
Write-UpdateLog "Running ForgeFlow quality gate."
& cmd.exe /d /s /c "npm run check" *>> $LogPath
if ($LASTEXITCODE -ne 0) { throw "npm run check failed with exit code $LASTEXITCODE." }
} finally { Pop-Location }
Write-UpdateLog "Update validated successfully. Restarting ForgeFlow."
Start-Process -FilePath "cmd.exe" -WorkingDirectory $SourcePath -ArgumentList "/d", "/s", "/c", "npm start"
Remove-Item -LiteralPath $working -Recurse -Force -ErrorAction SilentlyContinue
exit 0
}
catch {
Write-UpdateLog ("Update failed: " + $_.Exception.Message)
try {
if ($backup -and (Test-Path $backup)) {
Write-UpdateLog "Restoring previous source version."
Invoke-Robocopy -From $backup -To $SourcePath
Push-Location $SourcePath
try {
& cmd.exe /d /s /c "npm install --no-audit --no-fund" *>> $LogPath
} finally { Pop-Location }
Start-Process -FilePath "cmd.exe" -WorkingDirectory $SourcePath -ArgumentList "/d", "/s", "/c", "npm start"
}
} catch {
Write-UpdateLog ("Rollback failed: " + $_.Exception.Message)
}
exit 1
}
+88
View File
@@ -0,0 +1,88 @@
import { execFile } from 'node:child_process';
import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
import toolInvocation from '../src/shared/tool-invocation.cjs';
const exec = promisify(execFile);
const { npmProbeCandidates } = toolInvocation;
const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
const checks = [];
const jsonMode = process.argv.includes('--json');
function add(id, name, ok, detail, help = '') {
checks.push({ id, name, status: ok ? 'pass' : 'fail', ok, detail, help });
}
const major = Number(process.versions.node.split('.')[0]);
add('node', 'Node.js', major >= 22, process.version, 'Install Node.js 22 or newer.');
try {
const failures = [];
let version = '';
let source = '';
for (const candidate of npmProbeCandidates()) {
try {
const { stdout } = await exec(candidate.file, candidate.args, { windowsHide: true });
version = stdout.trim();
source = candidate.source;
if (version) break;
} catch (error) {
failures.push(`${candidate.source}: ${error.message}`);
}
}
if (!version) throw new Error(failures.join(' | ') || 'No npm invocation candidate succeeded.');
add('npm', 'npm', true, `${version} (${source})`);
} catch (error) {
add('npm', 'npm', false, error.message, 'Install npm together with Node.js and ensure npm.cmd is available on PATH.');
}
try {
const { stdout } = await exec('git', ['--version']);
add('git', 'Git', true, stdout.trim());
const [name, email] = await Promise.all([
exec('git', ['config', '--global', '--get', 'user.name']).then((result) => result.stdout.trim()).catch(() => ''),
exec('git', ['config', '--global', '--get', 'user.email']).then((result) => result.stdout.trim()).catch(() => '')
]);
add('git-identity', 'Git identity', Boolean(name && email), name && email ? `${name} <${email}>` : 'user.name or user.email is missing', 'Configure git config --global user.name and user.email.');
} catch (error) {
add('git', 'Git', false, error.message, 'Install Git and ensure git is on PATH.');
}
try {
await access(new URL('../node_modules/electron/package.json', import.meta.url));
add('electron', 'Electron dependency', true, 'installed');
} catch {
add('electron', 'Electron dependency', false, 'not installed', 'Run npm install.');
}
let markerDirectory = null;
try {
markerDirectory = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-doctor-'));
await writeFile(path.join(markerDirectory, 'write-test'), 'ok');
add('temp-storage', 'Local diagnostic storage', true, markerDirectory.replace(os.homedir(), '<HOME>'));
} catch (error) {
add('temp-storage', 'Local diagnostic storage', false, error.message, 'Check local disk permissions and free space.');
} finally {
if (markerDirectory) await rm(markerDirectory, { recursive: true, force: true }).catch(() => {});
}
const report = {
product: 'ForgeFlow',
version: packageJson.version,
generatedAt: new Date().toISOString(),
platform: process.platform,
arch: process.arch,
ready: checks.every((check) => check.ok),
checks
};
if (jsonMode) console.log(JSON.stringify(report, null, 2));
else {
console.log('ForgeFlow doctor\n');
for (const check of checks) console.log(`${check.ok ? 'PASS' : 'FAIL'} ${check.name.padEnd(26)} ${check.detail}`);
console.log(`\n${report.ready ? 'Environment is ready.' : 'Resolve failed checks before starting ForgeFlow.'}`);
}
if (!report.ready) process.exitCode = 1;
+28
View File
@@ -0,0 +1,28 @@
import http from 'node:http';
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'renderer');
const port = Number(process.env.PORT || 4173);
const mime = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml' };
const server = http.createServer(async (request, response) => {
try {
const pathname = decodeURIComponent(new URL(request.url, `http://${request.headers.host}`).pathname);
const relative = pathname === '/' ? 'index.html' : pathname.replace(/^\//, '');
const target = path.resolve(root, relative);
if (!target.startsWith(root)) throw Object.assign(new Error('Forbidden'), { code: 'EACCES' });
const info = await stat(target);
if (!info.isFile()) throw Object.assign(new Error('Not found'), { code: 'ENOENT' });
response.writeHead(200, { 'Content-Type': mime[path.extname(target)] || 'application/octet-stream', 'Cache-Control': 'no-store' });
response.end(await readFile(target));
} catch (error) {
response.writeHead(error.code === 'ENOENT' ? 404 : 403, { 'Content-Type': 'text/plain' });
response.end(error.code === 'ENOENT' ? 'Not found' : 'Forbidden');
}
});
server.listen(port, '127.0.0.1', () => {
console.log(`ForgeFlow demo: http://127.0.0.1:${port}`);
});
+97
View File
@@ -0,0 +1,97 @@
import { access, readFile, readdir } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import shellVerification from '../src/shared/shell-verification.cjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const required = [
'package.json', 'main.cjs', 'preload.cjs',
'src/renderer/index.html', 'src/renderer/styles.css', 'src/renderer/app.js', 'src/renderer/mock-bridge.js',
'src/renderer/assets/itworx-mark.png', 'src/renderer/assets/itworx-wordmark.png',
'src/main/config-store.cjs', 'src/main/git-service.cjs', 'src/main/gitea-service.cjs',
'src/main/repository-service.cjs', 'src/main/repository-monitor.cjs', 'src/main/deployment-service.cjs',
'src/main/unraid-deployment-service.cjs', 'src/main/ssh-service.cjs', 'src/main/update-service.cjs',
'src/main/diagnostics-service.cjs', 'src/main/preflight-service.cjs', 'src/main/log-redaction.cjs', 'src/main/ipc.cjs',
'src/shared/clone-target.cjs', 'src/shared/semver.cjs', 'src/shared/zip-writer.cjs',
'src/shared/tool-invocation.cjs', 'src/shared/shell-verification.cjs', 'START_HERE.md', 'README.md',
'setup-windows.ps1', 'update-windows.ps1', 'build-windows.ps1', 'UPDATE_FROM_0.3.2.md', 'scripts/apply-source-update.ps1',
'docs/ARCHITECTURE.md', 'docs/SECURITY.md', 'docs/ROADMAP.md', 'docs/SETUP_GUIDE.md',
'docs/UPDATING.md', 'docs/DIAGNOSTICS.md', 'docs/DEPLOYMENT_SETUP.md', 'docs/SSH_UNRAID_DEPLOYMENT.md',
'docs/LUMAOPS_SERVER_AUDIT.md', 'docs/STATUS_ENDPOINT.md', 'docs/TEST_MATRIX.md', 'docs/RELEASE_NOTES_0.4.0.md', 'docs/RELEASE_NOTES_0.4.1.md', 'docs/RELEASE_NOTES_0.4.2.md', 'docs/RELEASE_NOTES_0.4.3.md',
'examples/gitea-actions/deploy.yml', 'examples/gitea-actions/rollback.yml',
'examples/server/forgeflow-deploy', 'examples/server/forgeflow-targets.conf',
'examples/server/forgeflow-runner.sudoers', 'examples/server/status-example.json',
'build/icon.png', 'build/icon.ico'
];
for (const file of required) await access(path.join(root, file));
const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
if (packageJson.version !== '0.4.3') throw new Error(`Expected package version 0.4.3, got ${packageJson.version}.`);
for (const group of ['dependencies', 'devDependencies']) {
for (const [name, version] of Object.entries(packageJson[group] || {})) {
if (/^[~^*]/.test(version)) throw new Error(`${group} dependency ${name} must be pinned exactly, got ${version}.`);
}
}
if (packageJson.dependencies?.ssh2 !== '1.17.0') throw new Error('ssh2 must remain pinned to 1.17.0.');
for (const script of ['start', 'demo', 'test', 'verify', 'check']) {
if (!packageJson.scripts?.[script]) throw new Error(`Required npm script is missing: ${script}`);
}
if (!packageJson.build?.win?.icon || !packageJson.build?.linux?.icon || !packageJson.build?.mac?.icon) {
throw new Error('Package icon configuration is incomplete.');
}
async function collect(directory, extensions, output = []) {
for (const entry of await readdir(directory, { withFileTypes: true })) {
if (['node_modules', 'dist'].includes(entry.name)) continue;
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) await collect(absolute, extensions, output);
else if (extensions.has(path.extname(entry.name))) output.push(absolute);
}
return output;
}
const javascriptFiles = await collect(root, new Set(['.js', '.cjs', '.mjs']));
for (const file of javascriptFiles) {
const result = spawnSync(process.execPath, ['--check', file], { encoding: 'utf8' });
if (result.status !== 0) throw new Error(`${path.relative(root, file)} failed syntax validation:\n${result.stderr}`);
}
const bashCheck = shellVerification.bashSyntaxCheckInvocation(root);
const shell = spawnSync(bashCheck.command, bashCheck.args, bashCheck.options);
if (shell.error) throw new Error(`Unable to start Bash for server deployment syntax validation: ${shell.error.message}`);
if (shell.status !== 0) throw new Error(`Server deployment example failed bash syntax validation:\n${shell.stderr}`);
JSON.parse(await readFile(path.join(root, 'examples/server/status-example.json'), 'utf8'));
const setupGuide = await readFile(path.join(root, 'docs/SETUP_GUIDE.md'), 'utf8');
const sshGuide = await readFile(path.join(root, 'docs/SSH_UNRAID_DEPLOYMENT.md'), 'utf8');
const audit = await readFile(path.join(root, 'docs/LUMAOPS_SERVER_AUDIT.md'), 'utf8');
const releaseNotes = await readFile(path.join(root, 'docs/RELEASE_NOTES_0.4.3.md'), 'utf8');
if (!setupGuide.includes('Gitea access token') || !setupGuide.includes('diagnostic bundle')) {
throw new Error('Setup guide is missing required connection or diagnostics instructions.');
}
if (!sshGuide.includes('/mnt/user/appdata') || !sshGuide.includes('host-key fingerprint')) {
throw new Error('SSH / Unraid guide is missing its base path or host identity policy.');
}
if (!audit.includes('d42d4a7f08240c478d07466e3fabec654dc71367') || !audit.includes('source/')) {
throw new Error('LumaOps audit is missing the exact matching SHA or nested repository finding.');
}
for (const phrase of ['platform-independent', 'SSH inspection', 'Windows', 'Regression coverage']) {
if (!releaseNotes.includes(phrase)) throw new Error(`Release notes are missing: ${phrase}`);
}
const renderer = await readFile(path.join(root, 'src/renderer/app.js'), 'utf8');
const styles = await readFile(path.join(root, 'src/renderer/styles.css'), 'utf8');
const preload = await readFile(path.join(root, 'preload.cjs'), 'utf8');
const ipc = await readFile(path.join(root, 'src/main/ipc.cjs'), 'utf8');
for (const phrase of ['Commit selected & push to Gitea', 'checkForUpdates', 'saveServer', 'profile-provider', 'itworx-mark.png']) {
if (!renderer.includes(phrase) && !preload.includes(phrase)) throw new Error(`Frontend integration is missing: ${phrase}`);
}
if (!styles.includes('.file-list { flex: 1 1 auto;') || !styles.includes('.main-canvas.repository-canvas')) {
throw new Error('Changed-file scrolling constraints are missing.');
}
for (const channel of ['updates:check', 'updates:download', 'updates:apply', 'server:save', 'server:test', 'server:inspect-project']) {
if (!ipc.includes(channel)) throw new Error(`IPC registration is missing: ${channel}`);
}
console.log(`Verified ${required.length} required project files and ${javascriptFiles.length} JavaScript files for ForgeFlow ${packageJson.version}.`);
+62
View File
@@ -0,0 +1,62 @@
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
Set-Location $PSScriptRoot
function Assert-Command {
param([Parameter(Mandatory = $true)][string]$Name)
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "Required command '$Name' was not found on PATH. Read START_HERE.md for prerequisites."
}
}
function Invoke-Step {
param(
[Parameter(Mandatory = $true)][string]$Title,
[Parameter(Mandatory = $true)][scriptblock]$Action
)
Write-Host "`n$Title" -ForegroundColor Yellow
& $Action
if ($LASTEXITCODE -ne 0) {
throw "$Title failed with exit code $LASTEXITCODE."
}
}
Write-Host "ForgeFlow v0.4.2 self-service setup" -ForegroundColor Cyan
Write-Host "No Gitea token, SSH key or server password is requested by this script." -ForegroundColor DarkGray
Assert-Command node
Assert-Command npm
Assert-Command git
$nodeVersionText = (node --version).Trim()
$nodeMajor = [int]($nodeVersionText.TrimStart('v').Split('.')[0])
if ($nodeMajor -lt 22) {
throw "Node.js 22 or newer is required. Detected: $nodeVersionText"
}
Write-Host "Node: $nodeVersionText" -ForegroundColor DarkGray
Write-Host "npm: $((npm --version).Trim())" -ForegroundColor DarkGray
Write-Host "Git: $((git --version).Trim())" -ForegroundColor DarkGray
Invoke-Step "Installing project dependencies..." {
if (Test-Path ".\package-lock.json") {
npm ci --no-audit --no-fund
} else {
Write-Host "No package-lock.json is present; installing the pinned top-level dependency versions." -ForegroundColor DarkGray
npm install --no-audit --no-fund
}
}
Invoke-Step "Running the environment doctor..." {
npm run doctor
}
Invoke-Step "Running source verification and automated tests..." {
npm run check
}
Write-Host "`nAll local checks passed." -ForegroundColor Green
Write-Host "ForgeFlow will now open the five-step setup wizard." -ForegroundColor Green
Write-Host "Enter credentials only inside the local ForgeFlow password field." -ForegroundColor DarkGray
npm start
+424
View File
@@ -0,0 +1,424 @@
'use strict';
const fs = require('node:fs/promises');
const path = require('node:path');
const crypto = require('node:crypto');
const { safeStorage } = require('electron');
const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
const DEFAULT_CONFIG = {
schemaVersion: 5,
setupComplete: false,
appearance: 'dark',
gitea: { baseUrl: '', user: null, encryptedToken: null },
workspaceRoots: [],
repositoryMappings: {},
deploymentProfiles: {},
deploymentStates: {},
favorites: [],
updates: {
owner: 'Jens',
repo: 'ForgeFlow',
branch: 'main',
autoCheck: true,
lastCheckedAt: null
},
servers: [],
preferences: {
autoRefresh: true,
repositoryPollSeconds: 4,
operationPollSeconds: 5,
fetchIntervalMinutes: 10,
preferredCloneProtocol: 'https',
diagnosticsEnabled: true,
diagnosticLevel: 'info',
logRetentionDays: 14,
maxLogFileMb: 8
},
operations: []
};
function uniqueStrings(values) {
return [...new Set((Array.isArray(values) ? values : []).map((value) => String(value || '').trim()).filter(Boolean))];
}
class ConfigStore {
constructor(userDataPath) {
this.filePath = path.join(userDataPath, 'forgeflow-config.json');
this.sessionToken = null;
this.data = structuredClone(DEFAULT_CONFIG);
}
migrate(parsed) {
const source = parsed && typeof parsed === 'object' ? parsed : {};
return {
...structuredClone(DEFAULT_CONFIG),
...source,
schemaVersion: DEFAULT_CONFIG.schemaVersion,
gitea: { ...DEFAULT_CONFIG.gitea, ...(source.gitea || {}) },
workspaceRoots: uniqueStrings(source.workspaceRoots),
repositoryMappings: source.repositoryMappings && typeof source.repositoryMappings === 'object' ? source.repositoryMappings : {},
deploymentProfiles: source.deploymentProfiles && typeof source.deploymentProfiles === 'object' ? source.deploymentProfiles : {},
deploymentStates: source.deploymentStates && typeof source.deploymentStates === 'object' ? source.deploymentStates : {},
favorites: uniqueStrings(source.favorites).map((item) => item.toLowerCase()),
updates: { ...DEFAULT_CONFIG.updates, ...(source.updates || {}) },
servers: Array.isArray(source.servers) ? source.servers.filter((item) => item && typeof item === 'object') : [],
preferences: { ...DEFAULT_CONFIG.preferences, ...(source.preferences || {}) },
operations: Array.isArray(source.operations) ? source.operations.slice(0, 250).map((operation) => { const { runnerLog, ...safeOperation } = operation || {}; return safeOperation; }) : []
};
}
async load() {
try {
const raw = await fs.readFile(this.filePath, 'utf8');
this.data = this.migrate(JSON.parse(raw));
await this.save();
} catch (error) {
if (error.code !== 'ENOENT') throw error;
await this.save();
}
return this.getPublicState();
}
async save() {
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
const temporary = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
await fs.writeFile(temporary, JSON.stringify(this.data, null, 2), { mode: 0o600 });
await fs.rename(temporary, this.filePath);
try { await fs.chmod(this.filePath, 0o600); } catch {}
}
setToken(token, { preserveExisting = false } = {}) {
const value = String(token || '').trim();
if (!value && preserveExisting && this.getToken()) return { persistent: Boolean(this.data.gitea.encryptedToken), preserved: true };
if (!value) {
this.data.gitea.encryptedToken = null;
this.sessionToken = null;
return { persistent: true, preserved: false };
}
if (safeStorage.isEncryptionAvailable()) {
this.data.gitea.encryptedToken = safeStorage.encryptString(value).toString('base64');
this.sessionToken = null;
return { persistent: true, preserved: false };
}
this.data.gitea.encryptedToken = null;
this.sessionToken = value;
return { persistent: false, preserved: false };
}
getToken() {
if (this.sessionToken) return this.sessionToken;
if (!this.data.gitea.encryptedToken) return '';
try {
return safeStorage.decryptString(Buffer.from(this.data.gitea.encryptedToken, 'base64'));
} catch {
return '';
}
}
encryptSecret(value) {
const text = String(value || '');
if (!text) return null;
if (!safeStorage.isEncryptionAvailable()) {
const error = new Error('Secure credential storage is unavailable. ForgeFlow will not persist server passwords or key passphrases.');
error.code = 'SECURE_STORAGE_UNAVAILABLE';
throw error;
}
return safeStorage.encryptString(text).toString('base64');
}
decryptSecret(value) {
if (!value) return '';
try { return safeStorage.decryptString(Buffer.from(value, 'base64')); }
catch { return ''; }
}
normalizeServer(server, existing = null) {
const source = server || {};
const name = String(source.name || existing?.name || 'Unraid').trim().slice(0, 100);
const host = String(source.host || existing?.host || '').trim();
if (!host || /[\s/@]/.test(host)) throw new Error('Enter a valid SSH hostname or IP address.');
const port = Math.min(Math.max(Number(source.port || existing?.port || 22), 1), 65535);
const username = String(source.username || existing?.username || '').trim();
if (!username || /[\s@]/.test(username)) throw new Error('Enter a valid SSH username.');
const authType = ['password', 'privateKey'].includes(source.authType) ? source.authType : (existing?.authType || 'privateKey');
const basePath = String(source.basePath || existing?.basePath || '/mnt/user/appdata').trim().replace(/\/+$/, '');
if (!basePath.startsWith('/') || /[\r\n\0]/.test(basePath)) throw new Error('The server base path must be an absolute Unix path.');
const privateKeyPath = String(source.privateKeyPath || existing?.privateKeyPath || '').trim();
const hostFingerprint = String(source.hostFingerprint || existing?.hostFingerprint || '').trim();
return {
id: source.id || existing?.id || crypto.randomUUID(),
name,
host,
port,
username,
authType,
basePath,
privateKeyPath,
hostFingerprint,
encryptedPassword: existing?.encryptedPassword || null,
encryptedPassphrase: existing?.encryptedPassphrase || null,
createdAt: existing?.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString()
};
}
async saveServer(server, secrets = {}) {
const existing = this.data.servers.find((item) => item.id === server?.id) || null;
const normalized = this.normalizeServer(server, existing);
if (Object.prototype.hasOwnProperty.call(secrets, 'password') && String(secrets.password || '')) {
normalized.encryptedPassword = this.encryptSecret(secrets.password);
}
if (Object.prototype.hasOwnProperty.call(secrets, 'passphrase') && String(secrets.passphrase || '')) {
normalized.encryptedPassphrase = this.encryptSecret(secrets.passphrase);
}
if (normalized.authType === 'password') {
normalized.privateKeyPath = '';
normalized.encryptedPassphrase = null;
} else {
normalized.encryptedPassword = null;
}
if (normalized.authType === 'password' && !normalized.encryptedPassword) throw new Error('A password is required for password authentication.');
if (normalized.authType === 'privateKey' && !normalized.privateKeyPath) throw new Error('Select a private key file.');
this.data.servers = [normalized, ...this.data.servers.filter((item) => item.id !== normalized.id)];
await this.save();
return this.getPublicServer(normalized);
}
async deleteServer(serverId) {
this.data.servers = this.data.servers.filter((item) => item.id !== serverId);
for (const [key, profiles] of Object.entries(this.data.deploymentProfiles)) {
this.data.deploymentProfiles[key] = profiles.filter((profile) => profile.serverId !== serverId);
if (!this.data.deploymentProfiles[key].length) delete this.data.deploymentProfiles[key];
}
await this.save();
}
getServer(serverId) {
return this.data.servers.find((item) => item.id === serverId) || null;
}
getServerCredentials(serverId) {
const server = this.getServer(serverId);
if (!server) throw new Error('The configured server no longer exists.');
return {
password: this.decryptSecret(server.encryptedPassword),
passphrase: this.decryptSecret(server.encryptedPassphrase)
};
}
getPublicServer(server) {
if (!server) return null;
const { encryptedPassword, encryptedPassphrase, ...publicServer } = server;
return {
...structuredClone(publicServer),
hasPassword: Boolean(encryptedPassword),
hasPassphrase: Boolean(encryptedPassphrase)
};
}
async setUpdatePreferences(updates) {
const next = { ...this.data.updates, ...(updates || {}) };
next.owner = String(next.owner || 'Jens').trim().slice(0, 100);
next.repo = String(next.repo || 'ForgeFlow').trim().slice(0, 100);
next.branch = assertBranchName(next.branch || 'main');
next.autoCheck = next.autoCheck !== false;
this.data.updates = next;
await this.save();
return this.getPublicState();
}
async patch(patch) {
this.data = this.migrate({ ...this.data, ...patch });
await this.save();
return this.getPublicState();
}
async updateGitea({ baseUrl, token, user }) {
const tokenState = this.setToken(token, { preserveExisting: true });
this.data.gitea = {
...this.data.gitea,
baseUrl,
user: user || this.data.gitea.user,
encryptedToken: this.data.gitea.encryptedToken
};
await this.save();
return tokenState;
}
async completeSetup({ baseUrl, token, user, workspaceRoots }) {
const tokenState = this.setToken(token);
this.data.setupComplete = true;
this.data.gitea = { baseUrl, user, encryptedToken: this.data.gitea.encryptedToken };
this.data.workspaceRoots = uniqueStrings(workspaceRoots);
await this.save();
return { state: this.getPublicState(), tokenState };
}
async saveMapping(fullName, localPath) {
this.data.repositoryMappings[String(fullName).toLowerCase()] = localPath;
await this.save();
}
async removeMapping(fullName) {
delete this.data.repositoryMappings[String(fullName).toLowerCase()];
await this.save();
}
async setFavorite(fullName, favorite) {
const key = String(fullName || '').toLowerCase();
const favorites = new Set(this.data.favorites || []);
if (favorite) favorites.add(key); else favorites.delete(key);
this.data.favorites = [...favorites];
await this.save();
return this.getPublicState();
}
normalizeDeploymentProfile(profile) {
const environment = assertEnvironmentName(profile.environment || 'production');
const provider = ['gitea-actions', 'ssh-unraid'].includes(profile.provider) ? profile.provider : 'gitea-actions';
const healthcheckUrl = assertHttpUrl(profile.healthcheckUrl, { optional: true, label: 'Healthcheck URL' });
const common = {
id: profile.id || crypto.randomUUID(),
name: String(profile.name || environment || 'Production').trim().slice(0, 100),
environment,
provider,
branch: assertBranchName(profile.branch || 'main'),
healthcheckUrl,
confirmationRequired: profile.confirmationRequired !== false,
inputs: {}
};
if (provider === 'ssh-unraid') {
const remoteFolder = 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.');
const preservePaths = assertRepositoryRelativePaths(uniqueStrings(profile.preservePaths || ['.env', 'appdata', 'data', 'logs', 'config', 'compose.override.yml']));
return {
...common,
serverId: String(profile.serverId || '').trim(),
remoteFolder,
composeFile: String(profile.composeFile || 'docker-compose.yml').trim(),
composeService: String(profile.composeService || '').trim(),
cloneUrl: profile.cloneUrl ? assertCloneRemote(profile.cloneUrl) : '',
alignRemote: profile.alignRemote === true,
hostPort: profile.hostPort ? Math.min(Math.max(Number(profile.hostPort), 1), 65535) : null,
containerPort: profile.containerPort ? Math.min(Math.max(Number(profile.containerPort), 1), 65535) : null,
webUiUrl: assertHttpUrl(profile.webUiUrl, { optional: true, label: 'Web UI URL' }),
iconUrl: assertHttpUrl(profile.iconUrl, { optional: true, label: 'Icon URL' }),
preservePaths,
generatedCompose: profile.generatedCompose === true
};
}
const statusUrl = assertHttpUrl(profile.statusUrl, { label: 'Application status URL' });
return {
...common,
workflowFile: assertWorkflowFileName(profile.workflowFile || 'deploy.yml'),
rollbackWorkflowFile: profile.rollbackWorkflowFile ? assertWorkflowFileName(profile.rollbackWorkflowFile) : '',
statusUrl
};
}
async saveDeploymentProfile(fullName, profile) {
const key = String(fullName).toLowerCase();
const profiles = Array.isArray(this.data.deploymentProfiles[key]) ? this.data.deploymentProfiles[key] : [];
const normalized = this.normalizeDeploymentProfile(profile || {});
const next = profiles.filter((item) => item.id !== normalized.id);
next.push(normalized);
this.data.deploymentProfiles[key] = next;
await this.save();
return normalized;
}
async deleteDeploymentProfile(fullName, profileId) {
const key = String(fullName || '').toLowerCase();
const profiles = Array.isArray(this.data.deploymentProfiles[key]) ? this.data.deploymentProfiles[key] : [];
const next = profiles.filter((item) => item.id !== profileId);
if (next.length) this.data.deploymentProfiles[key] = next;
else delete this.data.deploymentProfiles[key];
delete this.data.deploymentStates[profileId];
await this.save();
return next;
}
getDeploymentProfiles(fullName) {
return structuredClone(this.data.deploymentProfiles[String(fullName || '').toLowerCase()] || []);
}
getDeploymentProfile(fullName, profileId) {
return this.getDeploymentProfiles(fullName).find((item) => item.id === profileId) || null;
}
async saveDeploymentState(profileId, state) {
this.data.deploymentStates[profileId] = {
...(this.data.deploymentStates[profileId] || {}),
...state,
checkedAt: state.checkedAt || new Date().toISOString()
};
await this.save();
return structuredClone(this.data.deploymentStates[profileId]);
}
getDeploymentState(profileId) {
return structuredClone(this.data.deploymentStates[profileId] || null);
}
async addOperation(operation) {
const existing = this.data.operations.find((item) => item.id === operation.id);
const normalized = {
id: operation.id || crypto.randomUUID(),
createdAt: existing?.createdAt || operation.createdAt || new Date().toISOString(),
...existing,
...operation,
updatedAt: new Date().toISOString()
};
this.data.operations = [normalized, ...this.data.operations.filter((item) => item.id !== normalized.id)].slice(0, 250);
await this.save();
return structuredClone(normalized);
}
getOperation(operationId) {
return structuredClone(this.data.operations.find((item) => item.id === operationId) || null);
}
async setPreferences(preferences) {
const next = { ...this.data.preferences, ...(preferences || {}) };
next.repositoryPollSeconds = Math.min(Math.max(Number(next.repositoryPollSeconds) || 4, 2), 60);
next.operationPollSeconds = Math.min(Math.max(Number(next.operationPollSeconds) || 5, 3), 120);
next.fetchIntervalMinutes = Math.min(Math.max(Number(next.fetchIntervalMinutes) || 10, 0), 240);
next.autoRefresh = next.autoRefresh !== false;
next.preferredCloneProtocol = ['https', 'ssh'].includes(next.preferredCloneProtocol) ? next.preferredCloneProtocol : 'https';
next.diagnosticsEnabled = next.diagnosticsEnabled !== false;
next.diagnosticLevel = ['debug', 'info', 'warning', 'error'].includes(next.diagnosticLevel) ? next.diagnosticLevel : 'info';
next.logRetentionDays = Math.min(Math.max(Number(next.logRetentionDays) || 14, 1), 90);
next.maxLogFileMb = Math.min(Math.max(Number(next.maxLogFileMb) || 8, 1), 50);
this.data.preferences = next;
await this.save();
return this.getPublicState();
}
getPublicState() {
return {
schemaVersion: this.data.schemaVersion,
setupComplete: this.data.setupComplete,
appearance: this.data.appearance,
gitea: {
baseUrl: this.data.gitea.baseUrl,
user: this.data.gitea.user,
hasToken: Boolean(this.getToken())
},
workspaceRoots: [...this.data.workspaceRoots],
repositoryMappings: { ...this.data.repositoryMappings },
deploymentProfiles: structuredClone(this.data.deploymentProfiles),
deploymentStates: structuredClone(this.data.deploymentStates),
favorites: [...this.data.favorites],
updates: { ...this.data.updates },
servers: this.data.servers.map((server) => this.getPublicServer(server)),
preferences: { ...this.data.preferences },
operations: structuredClone(this.data.operations)
};
}
}
module.exports = { ConfigStore, DEFAULT_CONFIG };
+424
View File
@@ -0,0 +1,424 @@
'use strict';
const crypto = require('node:crypto');
const { assertDeploymentRequest, assertFullCommitSha, assertHttpUrl } = require('../shared/validation.cjs');
const { redactSecrets } = require('./log-redaction.cjs');
const TERMINAL_STATUSES = new Set(['success', 'failed', 'cancelled', 'rolled-back']);
function applicationVerificationFailure(operation, state) {
if (!state?.statusConfigured) return { stage: 'version-verification', message: 'No server status endpoint is configured.' };
if (!state.statusReachable) return { stage: 'version-verification', message: state.error || 'The server status endpoint is not reachable.' };
if (!state.statusRepository) return { stage: 'version-verification', message: 'The server status endpoint did not identify its repository.' };
if (state.statusRepository !== operation.repository) return { stage: 'version-verification', message: `The status endpoint belongs to ${state.statusRepository}, not ${operation.repository}.` };
if (!state.statusEnvironment) return { stage: 'version-verification', message: 'The server status endpoint did not identify its environment.' };
if (state.statusEnvironment !== operation.environment) return { stage: 'version-verification', message: `The status endpoint belongs to ${state.statusEnvironment}, not ${operation.environment}.` };
if (!state.liveSha) return { stage: 'version-verification', message: 'The server status endpoint did not return a valid full commit SHA.' };
if (state.liveSha !== operation.sha) return { stage: 'version-verification', message: `Server reports ${state.liveSha.slice(0, 7)} instead of ${operation.shortSha}.` };
if (!state.requestedSha) return { stage: 'version-verification', message: 'The server status endpoint did not return the requested commit SHA.' };
if (state.requestedSha !== operation.sha) return { stage: 'version-verification', message: 'The server status document was created for a different requested commit.' };
if (!state.requestId) return { stage: 'version-verification', message: 'The server status endpoint did not return the deployment request ID.' };
if (state.requestId !== operation.id) return { stage: 'version-verification', message: 'The server status belongs to a different deployment request.' };
if (state.lastExitCode !== 0) return { stage: 'server-command', message: `The server deployment command reported exit code ${state.lastExitCode ?? 'unknown'}.` };
if (state.healthy !== true) return { stage: 'healthcheck', message: state.error || `The server did not report a healthy application state (${state.healthStatus || 'unknown'}).` };
return null;
}
function terminalRunConclusion(run) {
const value = String(run?.conclusion || run?.status || '').toLowerCase();
if (['success'].includes(value)) return 'success';
if (['failure', 'failed', 'timed_out', 'startup_failure'].includes(value)) return 'failed';
if (['cancelled', 'canceled', 'skipped'].includes(value)) return 'cancelled';
return null;
}
function isRunningStatus(value) {
return ['running', 'in_progress', 'processing'].includes(String(value || '').toLowerCase());
}
function isQueuedStatus(value) {
return ['pending', 'queued', 'waiting', 'blocked', 'requested'].includes(String(value || '').toLowerCase());
}
class DeploymentService {
constructor(store, giteaService, gitService, diagnostics = null) {
this.store = store;
this.gitea = giteaService;
this.git = gitService;
this.diagnostics = diagnostics;
this.refreshLocks = new Set();
}
splitRepository(fullName) {
const [owner, repo, ...unexpected] = String(fullName || '').split('/');
if (!owner || !repo || unexpected.length) throw new Error('Invalid Gitea repository identity.');
return { owner, repo };
}
makeStages() {
return [
{ id: 'requested', label: 'Requested', status: 'complete' },
{ id: 'verified', label: 'Verified', status: 'complete' },
{ id: 'queued', label: 'Workflow queued', status: 'active' },
{ id: 'runner', label: 'Runner execution', status: 'pending' },
{ id: 'healthcheck', label: 'Healthcheck', status: 'pending' },
{ id: 'complete', label: 'Complete', status: 'pending' }
];
}
setStage(operation, id, status) {
const stage = operation.stages?.find((item) => item.id === id);
if (stage) stage.status = status;
}
appendLog(operation, line) {
const clean = redactSecrets(line, [this.store.getToken()]);
operation.logs = Array.isArray(operation.logs) ? operation.logs : [];
if (operation.logs.at(-1) !== clean) operation.logs.push(clean);
operation.logs = operation.logs.slice(-1000);
}
async captureBaselineRunIds(owner, repo, branch, operation) {
try {
const result = await this.gitea.listWorkflowRuns({ owner, repo, branch, limit: 50 });
const ids = (result.runs || []).map((run) => run.id).filter((id) => id !== null && id !== undefined).map(String);
operation.baselineRunIds = [...new Set(ids)].slice(0, 100);
this.appendLog(operation, `[info] Captured ${operation.baselineRunIds.length} existing Actions run identifier(s) before dispatch.`);
} catch (error) {
operation.baselineRunIds = [];
this.appendLog(operation, `[warning] Could not capture the pre-dispatch run baseline: ${error.message}`);
}
}
async validateDeploy(repository, profile, sha) {
assertDeploymentRequest(profile, sha);
const localStatus = await this.git.status(repository.localPath);
if (localStatus.head !== sha) throw new Error('The selected commit no longer matches the local repository. Refresh before deploying.');
if (localStatus.branch.head !== profile.branch) throw new Error(`This profile only allows deployments from ${profile.branch}.`);
if (localStatus.counts.changed) throw new Error('Commit local changes before deploying.');
if (localStatus.branch.ahead) throw new Error('Push all local commits before deploying.');
if (localStatus.branch.behind) throw new Error('Synchronize with Gitea before deploying.');
if (!localStatus.branch.upstream) throw new Error('Publish this branch to Gitea before deploying.');
await this.git.verifyCommitOnRemoteBranch(repository.localPath, sha, profile.branch);
return localStatus;
}
async deploy({ repository, profileId, sha }) {
if (!repository?.fullName || !repository?.localPath) throw new Error('A linked local repository is required for deployment.');
const profile = this.store.getDeploymentProfile(repository.fullName, profileId);
const fullSha = assertFullCommitSha(sha);
await this.validateDeploy(repository, profile, fullSha);
const { owner, repo } = this.splitRepository(repository.fullName);
const operation = {
id: crypto.randomUUID(),
type: 'deployment',
action: 'deploy',
status: 'requested',
repository: repository.fullName,
profileId,
profileName: profile.name,
environment: profile.environment,
workflowFile: profile.workflowFile,
branch: profile.branch,
sha: fullSha,
shortSha: fullSha.slice(0, 7),
dispatchedAt: new Date().toISOString(),
stages: this.makeStages(),
logs: [
`[info] Verified clean ${profile.branch} at ${fullSha}`,
`[info] Dispatching ${profile.workflowFile} for ${repository.fullName}`
]
};
await this.captureBaselineRunIds(owner, repo, profile.branch, operation);
await this.store.addOperation(operation);
await this.diagnostics?.info('deployment.dispatch.requested', { operationId: operation.id, repository: operation.repository, profileId, environment: operation.environment, branch: operation.branch, sha: operation.sha, workflowFile: operation.workflowFile });
try {
await this.gitea.dispatchWorkflow({
owner,
repo,
workflowFile: profile.workflowFile,
ref: profile.branch,
inputs: { environment: profile.environment, commit_sha: fullSha, request_id: operation.id }
});
operation.status = 'queued';
this.appendLog(operation, '[ok] Gitea accepted the workflow dispatch request.');
this.appendLog(operation, '[info] Resolving the corresponding Actions run…');
const saved = await this.store.addOperation(operation);
await this.diagnostics?.info('deployment.dispatch.accepted', { operationId: operation.id, repository: operation.repository, status: operation.status });
return saved;
} catch (error) {
operation.status = 'failed';
this.setStage(operation, 'queued', 'failed');
operation.failure = { stage: 'dispatch', message: error.message };
this.appendLog(operation, `[error] ${error.message}`);
await this.store.addOperation(operation);
await this.diagnostics?.error('deployment.dispatch.failed', { operationId: operation.id, repository: operation.repository, message: error.message, code: error.code, status: error.status });
throw error;
}
}
async rollback({ repository, profileId, targetSha }) {
if (!repository?.fullName || !repository?.localPath) throw new Error('A linked local repository is required for rollback.');
const profile = this.store.getDeploymentProfile(repository.fullName, profileId);
if (!profile) throw new Error('Deployment profile not found.');
if (!profile.rollbackWorkflowFile) throw new Error('No rollback workflow is configured for this profile.');
const fullSha = assertFullCommitSha(targetSha);
assertDeploymentRequest({ ...profile, workflowFile: profile.rollbackWorkflowFile }, fullSha);
const state = await this.refreshProfileState(repository.fullName, profileId);
if (!state.statusReachable) throw new Error(state.error || 'The server status endpoint must be reachable before rollback.');
if (state.statusRepository !== repository.fullName || state.statusEnvironment !== profile.environment) throw new Error('The status endpoint does not match this repository and environment.');
if (!state.previousSha) throw new Error('The server status endpoint does not report a previous version.');
if (state.previousSha !== fullSha) throw new Error('The requested rollback SHA is no longer the previous server version. Refresh the environment state.');
if (state.liveSha === fullSha) throw new Error('The requested rollback version is already live.');
await this.git.verifyCommitOnRemoteBranch(repository.localPath, fullSha, profile.branch);
const { owner, repo } = this.splitRepository(repository.fullName);
const operation = {
id: crypto.randomUUID(),
type: 'deployment',
action: 'rollback',
status: 'requested',
repository: repository.fullName,
profileId,
profileName: profile.name,
environment: profile.environment,
workflowFile: profile.rollbackWorkflowFile,
branch: profile.branch,
sha: fullSha,
shortSha: fullSha.slice(0, 7),
dispatchedAt: new Date().toISOString(),
stages: this.makeStages(),
logs: [
`[warning] Rollback target verified on origin/${profile.branch}: ${fullSha}`,
`[info] Dispatching ${profile.rollbackWorkflowFile}`
]
};
await this.captureBaselineRunIds(owner, repo, profile.branch, operation);
await this.store.addOperation(operation);
await this.diagnostics?.info('deployment.rollback.requested', { operationId: operation.id, repository: operation.repository, profileId, environment: operation.environment, branch: operation.branch, sha: operation.sha, workflowFile: operation.workflowFile });
try {
await this.gitea.dispatchWorkflow({
owner,
repo,
workflowFile: profile.rollbackWorkflowFile,
ref: profile.branch,
inputs: { environment: profile.environment, target_sha: fullSha, request_id: operation.id }
});
operation.status = 'queued';
this.appendLog(operation, '[ok] Gitea accepted the rollback request.');
const saved = await this.store.addOperation(operation);
await this.diagnostics?.info('deployment.rollback.accepted', { operationId: operation.id, repository: operation.repository });
return saved;
} catch (error) {
operation.status = 'failed';
this.setStage(operation, 'queued', 'failed');
operation.failure = { stage: 'dispatch', message: error.message };
this.appendLog(operation, `[error] ${error.message}`);
await this.store.addOperation(operation);
await this.diagnostics?.error('deployment.rollback.failed', { operationId: operation.id, repository: operation.repository, message: error.message, code: error.code, status: error.status });
throw error;
}
}
mapJobsToStages(operation, jobs) {
operation.jobs = jobs;
if (!jobs.length) return;
const running = jobs.some((job) => isRunningStatus(job.status));
const failed = jobs.some((job) => terminalRunConclusion(job) === 'failed');
const allDone = jobs.every((job) => terminalRunConclusion(job));
this.setStage(operation, 'queued', 'complete');
this.setStage(operation, 'runner', failed ? 'failed' : allDone ? 'complete' : running ? 'active' : 'pending');
}
async refreshOperation(operationId) {
if (this.refreshLocks.has(operationId)) return this.store.getOperation(operationId);
const operation = this.store.getOperation(operationId);
if (!operation || operation.type !== 'deployment') throw new Error('Deployment operation not found.');
if (TERMINAL_STATUSES.has(operation.status)) return operation;
this.refreshLocks.add(operationId);
try {
const profile = this.store.getDeploymentProfile(operation.repository, operation.profileId);
if (!profile) throw new Error('The deployment profile used by this operation no longer exists.');
const { owner, repo } = this.splitRepository(operation.repository);
const found = await this.gitea.findWorkflowRun({
owner,
repo,
sha: operation.sha,
branch: operation.branch,
workflowFile: operation.workflowFile,
dispatchedAt: operation.dispatchedAt || operation.createdAt,
excludeRunIds: operation.baselineRunIds || []
});
if (!found.run) {
operation.status = 'queued';
this.setStage(operation, 'queued', 'active');
this.appendLog(operation, '[info] Workflow is queued or not visible through the Actions API yet.');
return await this.store.addOperation(operation);
}
operation.run = { ...found.run, source: found.source };
operation.runUrl = found.run.htmlUrl || `${this.store.data.gitea.baseUrl}/${operation.repository}/actions/runs/${found.run.runNumber}`;
this.setStage(operation, 'queued', 'complete');
const runConclusion = terminalRunConclusion(found.run);
if (!runConclusion) {
operation.status = isRunningStatus(found.run.status) ? 'running' : 'queued';
this.setStage(operation, 'runner', operation.status === 'running' ? 'active' : 'pending');
}
try {
const jobs = await this.gitea.listWorkflowJobs({ owner, repo, runNumber: found.run.runNumber });
this.mapJobsToStages(operation, jobs);
for (const job of jobs) {
const conclusion = job.conclusion || job.status;
this.appendLog(operation, `[job] ${job.name}: ${conclusion}`);
}
// Raw runner output is intentionally not ingested or persisted. Open the trusted Gitea run for full logs.
} catch (error) {
this.appendLog(operation, `[warning] Job details unavailable: ${error.message}`);
}
if (runConclusion === 'success') {
this.setStage(operation, 'runner', 'complete');
this.setStage(operation, 'healthcheck', 'active');
const state = await this.refreshProfileState(operation.repository, operation.profileId, { expectedSha: operation.sha });
operation.applicationState = state;
const verificationFailure = applicationVerificationFailure(operation, state);
if (verificationFailure) {
operation.status = 'failed';
this.setStage(operation, 'healthcheck', 'failed');
this.setStage(operation, 'complete', 'failed');
operation.failure = verificationFailure;
this.appendLog(operation, `[error] ${verificationFailure.message}`);
} else {
operation.status = operation.action === 'rollback' ? 'rolled-back' : 'success';
this.setStage(operation, 'healthcheck', 'complete');
this.setStage(operation, 'complete', 'complete');
this.appendLog(operation, `[ok] ${operation.action === 'rollback' ? 'Rollback' : 'Deployment'} completed successfully.`);
}
} else if (runConclusion === 'failed' || runConclusion === 'cancelled') {
operation.status = runConclusion;
this.setStage(operation, 'runner', runConclusion === 'failed' ? 'failed' : 'cancelled');
this.setStage(operation, 'healthcheck', 'skipped');
this.setStage(operation, 'complete', runConclusion === 'failed' ? 'failed' : 'cancelled');
operation.failure = { stage: 'runner', message: `Gitea Actions finished with ${runConclusion}.` };
this.appendLog(operation, `[error] ${operation.failure.message}`);
}
const saved = await this.store.addOperation(operation);
if (TERMINAL_STATUSES.has(operation.status)) {
await this.diagnostics?.info('deployment.operation.terminal', { operationId: operation.id, repository: operation.repository, status: operation.status, failure: operation.failure || null, applicationState: operation.applicationState || null });
} else {
await this.diagnostics?.debug('deployment.operation.refreshed', { operationId: operation.id, repository: operation.repository, status: operation.status, run: operation.run ? { id: operation.run.id, runNumber: operation.run.runNumber, status: operation.run.status, conclusion: operation.run.conclusion } : null });
}
return saved;
} catch (error) {
operation.pollError = error.message;
this.appendLog(operation, `[warning] Status refresh failed: ${error.message}`);
await this.diagnostics?.warning('deployment.operation.poll-failed', { operationId: operation.id, repository: operation.repository, message: error.message });
return await this.store.addOperation(operation);
} finally {
this.refreshLocks.delete(operationId);
}
}
async refreshActiveOperations() {
const active = this.store.data.operations.filter((item) => item.type === 'deployment' && !TERMINAL_STATUSES.has(item.status));
const results = [];
for (const operation of active.slice(0, 20)) results.push(await this.refreshOperation(operation.id));
return results;
}
async checkHealth(url) {
if (!url) return { configured: false, healthy: null };
const normalized = assertHttpUrl(url, { label: 'Healthcheck URL' });
const started = Date.now();
try {
const response = await fetch(normalized, { signal: AbortSignal.timeout(10_000), redirect: 'follow', headers: { Accept: 'application/json, text/plain, */*' } });
return { configured: true, healthy: response.ok, status: response.status, latencyMs: Date.now() - started };
} catch (error) {
return { configured: true, healthy: false, error: error.message, latencyMs: Date.now() - started };
}
}
async readStatusEndpoint(url) {
if (!url) return { configured: false };
const normalized = assertHttpUrl(url, { label: 'Application status URL' });
const started = Date.now();
try {
const response = await fetch(normalized, { signal: AbortSignal.timeout(10_000), redirect: 'follow', headers: { Accept: 'application/json' } });
if (!response.ok) return { configured: true, reachable: true, ok: false, status: response.status, latencyMs: Date.now() - started };
const payload = await response.json();
const liveSha = payload.commit_sha || payload.commitSha || payload.sha || payload.version?.commit_sha || payload.version?.sha || null;
const previousSha = payload.previous_sha || payload.previousSha || payload.previous?.sha || null;
const requestId = payload.request_id || payload.requestId || null;
const requestedSha = payload.requested_sha || payload.requestedSha || null;
const repository = payload.repository || null;
const environment = payload.environment || null;
const rawExitCode = payload.last_exit_code ?? payload.lastExitCode ?? null;
return {
configured: true,
reachable: true,
ok: true,
status: response.status,
latencyMs: Date.now() - started,
liveSha: /^[a-f0-9]{40,64}$/i.test(String(liveSha || '')) ? String(liveSha).toLowerCase() : null,
previousSha: /^[a-f0-9]{40,64}$/i.test(String(previousSha || '')) ? String(previousSha).toLowerCase() : null,
requestId: typeof requestId === 'string' ? requestId.slice(0, 100) : null,
requestedSha: /^[a-f0-9]{40,64}$/i.test(String(requestedSha || '')) ? String(requestedSha).toLowerCase() : null,
repository: typeof repository === 'string' ? repository.slice(0, 200) : null,
environment: typeof environment === 'string' ? environment.slice(0, 64).toLowerCase() : null,
lastExitCode: rawExitCode !== null && rawExitCode !== '' && Number.isInteger(Number(rawExitCode)) ? Number(rawExitCode) : null,
deployedAt: payload.deployed_at || payload.deployedAt || null,
health: payload.health || payload.status || null,
payload
};
} catch (error) {
return { configured: true, reachable: false, ok: false, error: error.message, latencyMs: Date.now() - started };
}
}
async refreshProfileState(fullName, profileId, { expectedSha = null } = {}) {
const profile = this.store.getDeploymentProfile(fullName, profileId);
if (!profile) throw new Error('Deployment profile not found.');
const [status, health] = await Promise.all([
this.readStatusEndpoint(profile.statusUrl),
this.checkHealth(profile.healthcheckUrl)
]);
const state = {
profileId,
repository: fullName,
environment: profile.environment,
liveSha: status.liveSha || null,
previousSha: status.previousSha || null,
deployedAt: status.deployedAt || null,
statusConfigured: Boolean(status.configured),
statusReachable: status.configured ? Boolean(status.reachable && status.ok) : null,
statusCode: status.status || null,
statusRepository: status.repository || null,
statusEnvironment: status.environment || null,
requestedSha: status.requestedSha || null,
lastExitCode: status.lastExitCode,
healthConfigured: Boolean(health.configured),
healthy: health.configured
? Boolean(health.healthy)
: (['healthy', 'ok', 'success', 'ready'].includes(String(status.health || '').toLowerCase())
? true
: (['unhealthy', 'failed', 'error', 'degraded'].includes(String(status.health || '').toLowerCase()) ? false : null)),
healthStatus: health.status || status.health || null,
latencyMs: health.latencyMs ?? status.latencyMs ?? null,
expectedSha: expectedSha || null,
requestId: status.requestId || null,
versionMatches: expectedSha && status.liveSha ? status.liveSha === expectedSha : null,
error: health.error || status.error || null,
checkedAt: new Date().toISOString()
};
return this.store.saveDeploymentState(profileId, state);
}
}
module.exports = { DeploymentService, TERMINAL_STATUSES, terminalRunConclusion, applicationVerificationFailure };
+356
View File
@@ -0,0 +1,356 @@
'use strict';
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const crypto = require('node:crypto');
const { createZip } = require('../shared/zip-writer.cjs');
const { sanitizeForDiagnostics, redactSecrets } = require('./log-redaction.cjs');
const LEVELS = { debug: 10, info: 20, warning: 30, error: 40 };
function dateKey(value = new Date()) {
return value.toISOString().slice(0, 10);
}
function byteSizeLabel(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function safeJson(value) {
return `${JSON.stringify(value, null, 2)}\n`;
}
function auditBundleEntries(entries, secrets = []) {
const candidates = [...new Set((secrets || []).map((item) => String(item || '').trim()).filter((item) => item.length >= 4))];
const findings = [];
for (const entry of entries) {
const text = Buffer.isBuffer(entry.data) ? entry.data.toString('utf8') : String(entry.data ?? '');
for (const secret of candidates) {
if (text.includes(secret)) findings.push({ file: entry.name, type: 'known-runtime-secret' });
}
if (/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/i.test(text)) findings.push({ file: entry.name, type: 'private-key-marker' });
if (/https?:\/\/[^\s:@/]+:(?!\[REDACTED\])[^@\s/]+@/i.test(text)) findings.push({ file: entry.name, type: 'url-credential' });
}
return { passed: findings.length === 0, checkedFiles: entries.length, knownRuntimeSecretCount: candidates.length, findings };
}
class DiagnosticsService {
constructor({ userDataPath, appInfo = {}, secretProvider = () => [], preferencesProvider = () => ({}) }) {
this.userDataPath = userDataPath;
this.logDirectory = path.join(userDataPath, 'diagnostics');
this.appInfo = appInfo;
this.secretProvider = secretProvider;
this.preferencesProvider = preferencesProvider;
this.sessionId = crypto.randomUUID();
this.writeChain = Promise.resolve();
this.initialized = false;
this.lastWriteError = null;
this.lastBundlePath = null;
}
preferences() {
const source = this.preferencesProvider?.() || {};
return {
enabled: source.diagnosticsEnabled !== false,
level: ['debug', 'info', 'warning', 'error'].includes(source.diagnosticLevel) ? source.diagnosticLevel : 'info',
retentionDays: Math.min(Math.max(Number(source.logRetentionDays) || 14, 1), 90),
maxFileMb: Math.min(Math.max(Number(source.maxLogFileMb) || 8, 1), 50)
};
}
sanitize(value, options = {}) {
return sanitizeForDiagnostics(value, {
secrets: this.secretProvider?.() || [],
homeDir: os.homedir(),
cwd: process.cwd(),
...options
});
}
async initialize() {
await fs.mkdir(this.logDirectory, { recursive: true, mode: 0o700 });
try { await fs.chmod(this.logDirectory, 0o700); } catch {}
this.initialized = true;
await this.prune();
await this.info('diagnostics.session.started', {
sessionId: this.sessionId,
app: this.appInfo,
platform: process.platform,
arch: process.arch,
node: process.versions.node,
electron: process.versions.electron || null
});
}
shouldWrite(level) {
const preferences = this.preferences();
return preferences.enabled && LEVELS[level] >= LEVELS[preferences.level];
}
filePathForToday() {
return path.join(this.logDirectory, `forgeflow-${dateKey()}.jsonl`);
}
async rotateIfNeeded(filePath) {
const limit = this.preferences().maxFileMb * 1024 * 1024;
const stat = await fs.stat(filePath).catch(() => null);
if (!stat || stat.size < limit) return filePath;
for (let index = 1; index < 100; index += 1) {
const candidate = path.join(this.logDirectory, `forgeflow-${dateKey()}-${String(index).padStart(2, '0')}.jsonl`);
const candidateStat = await fs.stat(candidate).catch(() => null);
if (!candidateStat || candidateStat.size < limit) return candidate;
}
return path.join(this.logDirectory, `forgeflow-${dateKey()}-${Date.now()}.jsonl`);
}
log(level, event, details = {}) {
if (!this.shouldWrite(level)) return Promise.resolve(false);
const record = this.sanitize({
timestamp: new Date().toISOString(),
level,
event: String(event || 'diagnostics.event').slice(0, 160),
sessionId: this.sessionId,
details
});
const line = `${JSON.stringify(record)}\n`;
this.writeChain = this.writeChain.then(async () => {
try {
if (!this.initialized) await fs.mkdir(this.logDirectory, { recursive: true, mode: 0o700 });
const target = await this.rotateIfNeeded(this.filePathForToday());
await fs.appendFile(target, line, { encoding: 'utf8', mode: 0o600 });
try { await fs.chmod(target, 0o600); } catch {}
this.lastWriteError = null;
return true;
} catch (error) {
this.lastWriteError = error.message;
return false;
}
});
return this.writeChain;
}
debug(event, details) { return this.log('debug', event, details); }
info(event, details) { return this.log('info', event, details); }
warning(event, details) { return this.log('warning', event, details); }
error(event, details) { return this.log('error', event, details); }
async flush() {
await this.writeChain;
}
async listLogFiles() {
await fs.mkdir(this.logDirectory, { recursive: true, mode: 0o700 });
const entries = await fs.readdir(this.logDirectory, { withFileTypes: true });
const files = [];
for (const entry of entries) {
if (!entry.isFile() || !/^forgeflow-.*\.jsonl$/i.test(entry.name)) continue;
const absolute = path.join(this.logDirectory, entry.name);
const stat = await fs.stat(absolute).catch(() => null);
if (stat) files.push({ name: entry.name, path: absolute, size: stat.size, modifiedAt: stat.mtime.toISOString() });
}
return files.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
}
async prune() {
const cutoff = Date.now() - this.preferences().retentionDays * 24 * 60 * 60 * 1000;
for (const file of await this.listLogFiles()) {
if (new Date(file.modifiedAt).getTime() < cutoff) await fs.rm(file.path, { force: true }).catch(() => {});
}
}
async getStatus() {
await this.flush();
const files = await this.listLogFiles();
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
return {
enabled: this.preferences().enabled,
level: this.preferences().level,
retentionDays: this.preferences().retentionDays,
maxFileMb: this.preferences().maxFileMb,
directory: this.sanitize(this.logDirectory),
fileCount: files.length,
totalBytes,
totalSize: byteSizeLabel(totalBytes),
latestAt: files[0]?.modifiedAt || null,
lastWriteError: this.lastWriteError
};
}
async clear() {
await this.flush();
for (const file of await this.listLogFiles()) await fs.rm(file.path, { force: true });
await this.info('diagnostics.logs.cleared', {});
return this.getStatus();
}
async collectLogs(maxBytes = 20 * 1024 * 1024) {
await this.flush();
const output = [];
let used = 0;
for (const file of await this.listLogFiles()) {
if (used >= maxBytes) break;
const remaining = maxBytes - used;
const content = await fs.readFile(file.path);
const slice = content.length > remaining ? content.subarray(content.length - remaining) : content;
output.push({ name: `logs/${file.name}`, data: Buffer.from(redactSecrets(slice.toString('utf8'), this.secretProvider?.() || []), 'utf8') });
used += slice.length;
}
return output;
}
async exportSupportBundle({ destinationPath, publicState, repositories = [], operations = [], preflight = null, privacyMode = 'standard', extra = {} }) {
if (!destinationPath) throw new Error('No support bundle destination was selected.');
if (!['standard', 'strict'].includes(privacyMode)) throw new Error('Unsupported diagnostic privacy mode.');
if (path.extname(destinationPath).toLowerCase() !== '.zip') throw new Error('Diagnostic bundles must use the .zip extension.');
await this.info('diagnostics.bundle.requested', { privacyMode, repositoryCount: repositories.length, operationCount: operations.length });
const strict = privacyMode === 'strict';
const sanitize = (value) => this.sanitize(value, { strictIdentifiers: strict });
const generatedAt = new Date().toISOString();
const diagnosticsStatus = await this.getStatus();
const system = sanitize({
app: this.appInfo,
generatedAt,
sessionId: this.sessionId,
platform: process.platform,
arch: process.arch,
release: os.release(),
type: os.type(),
cpus: os.cpus()?.map((cpu) => cpu.model).filter((value, index, array) => array.indexOf(value) === index),
cpuCount: os.cpus()?.length || null,
totalMemoryBytes: os.totalmem(),
freeMemoryBytes: os.freemem(),
uptimeSeconds: os.uptime(),
locale: Intl.DateTimeFormat().resolvedOptions().locale,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
versions: process.versions
});
const sanitizedState = sanitize(publicState || {});
if (sanitizedState.gitea) sanitizedState.gitea.hasToken = Boolean(publicState?.gitea?.hasToken);
const sanitizedRepositories = sanitize(repositories.map((repository) => ({
id: repository.id,
fullName: repository.fullName,
linkState: repository.linkState,
localPath: repository.localPath,
attention: repository.attention,
attentionReason: repository.attentionReason,
readyToDeploy: repository.readyToDeploy,
localStatus: repository.localStatus ? {
branch: repository.localStatus.branch,
head: repository.localStatus.head,
counts: repository.localStatus.counts,
clean: repository.localStatus.clean,
remoteUrl: repository.localStatus.remoteUrl
} : null,
deploymentProfiles: repository.deploymentProfiles?.map((profile) => ({
id: profile.id,
name: profile.name,
environment: profile.environment,
branch: profile.branch,
workflowFile: profile.workflowFile,
rollbackWorkflowFile: profile.rollbackWorkflowFile,
healthcheckUrl: profile.healthcheckUrl,
statusUrl: profile.statusUrl,
state: profile.state
})) || []
})));
const sanitizedOperations = sanitize(operations.map((operation) => ({
id: operation.id,
type: operation.type,
action: operation.action,
status: operation.status,
repository: operation.repository,
profileId: operation.profileId,
profileName: operation.profileName,
environment: operation.environment,
workflowFile: operation.workflowFile,
branch: operation.branch,
sha: operation.sha,
shortSha: operation.shortSha,
createdAt: operation.createdAt,
updatedAt: operation.updatedAt,
dispatchedAt: operation.dispatchedAt,
stages: operation.stages,
jobs: operation.jobs,
logs: operation.logs,
failure: operation.failure,
pollError: operation.pollError,
applicationState: operation.applicationState,
run: operation.run ? {
id: operation.run.id,
runNumber: operation.run.runNumber,
name: operation.run.name,
status: operation.run.status,
conclusion: operation.run.conclusion,
headSha: operation.run.headSha,
headBranch: operation.run.headBranch,
workflowPath: operation.run.workflowPath,
createdAt: operation.run.createdAt,
updatedAt: operation.run.updatedAt
} : null,
runnerLog: operation.runnerLog ? {
included: false,
reason: 'Raw runner output is intentionally omitted from diagnostic bundles.',
characters: String(operation.runnerLog).length,
lines: String(operation.runnerLog).split(/\r?\n/).length
} : null
})));
const manifest = {
schemaVersion: 1,
product: 'ForgeFlow Support Bundle',
generatedAt,
privacyMode,
containsSecrets: false,
redaction: {
knownRuntimeSecrets: true,
sensitiveObjectKeys: true,
authorizationHeaders: true,
credentialUrls: true,
privateKeys: true,
userHomePaths: true,
identifiersHashed: strict
},
files: []
};
const entries = [
{ name: 'README.txt', data: `ForgeFlow diagnostic support bundle\nGenerated: ${generatedAt}\nPrivacy mode: ${privacyMode}\n\nThis bundle is generated locally. Access tokens, passwords, authorization headers, embedded URL credentials, encrypted token blobs and private keys are removed. Review the bundle before sharing it.\n` },
{ name: 'system.json', data: safeJson(system) },
{ name: 'diagnostics-status.json', data: safeJson(sanitize(diagnosticsStatus)) },
{ name: 'configuration-sanitized.json', data: safeJson(sanitizedState) },
{ name: 'repositories-sanitized.json', data: safeJson(sanitizedRepositories) },
{ name: 'operations-sanitized.json', data: safeJson(sanitizedOperations) },
{ name: 'preflight.json', data: safeJson(sanitize(preflight || {})) },
{ name: 'context.json', data: safeJson(sanitize(extra || {})) },
...(await this.collectLogs())
];
const safetyAudit = auditBundleEntries(entries, this.secretProvider?.() || []);
if (!safetyAudit.passed) {
await this.error('diagnostics.bundle.safety-check-failed', { findings: safetyAudit.findings });
throw new Error('The diagnostic bundle failed its local secret-safety check and was not written.');
}
entries.push({ name: 'safety-audit.json', data: safeJson(safetyAudit) });
manifest.files = entries.map((entry) => ({ name: entry.name, bytes: Buffer.byteLength(entry.data) }));
entries.unshift({ name: 'manifest.json', data: safeJson(manifest) });
const archive = createZip(entries);
const temporary = `${destinationPath}.${process.pid}.${Date.now()}.tmp`;
await fs.mkdir(path.dirname(destinationPath), { recursive: true });
await fs.writeFile(temporary, archive, { mode: 0o600 });
await fs.rename(temporary, destinationPath);
try { await fs.chmod(destinationPath, 0o600); } catch {}
this.lastBundlePath = path.resolve(destinationPath);
const sha256 = crypto.createHash('sha256').update(archive).digest('hex');
await this.info('diagnostics.bundle.created', { destinationPath, bytes: archive.length, sha256, privacyMode });
return { path: destinationPath, bytes: archive.length, size: byteSizeLabel(archive.length), sha256, privacyMode, generatedAt };
}
isKnownBundlePath(filePath) {
return Boolean(filePath && this.lastBundlePath && path.resolve(filePath) === this.lastBundlePath);
}
}
module.exports = { DiagnosticsService, dateKey, byteSizeLabel, auditBundleEntries, LEVELS };
+298
View File
@@ -0,0 +1,298 @@
'use strict';
const path = require('node:path');
const fs = require('node:fs/promises');
const { run } = require('./process-runner.cjs');
const { parsePorcelainV2 } = require('../shared/git-status.cjs');
const { normalizeRemoteUrl } = require('../shared/repository-match.cjs');
const {
assertSafeRepositoryPath,
assertRepositoryRelativePath,
assertRepositoryRelativePaths,
assertCommitMessage,
assertFullCommitSha,
assertCloneRemote
} = require('../shared/validation.cjs');
class GitService {
async isAvailable() {
try {
const result = await run('git', ['--version'], { timeout: 10_000 });
return { available: true, version: result.stdout.trim() };
} catch (error) {
return { available: false, version: null, error: error.message };
}
}
async ensureRepository(repoPath) {
const resolved = assertSafeRepositoryPath(repoPath);
const stat = await fs.stat(resolved).catch(() => null);
if (!stat?.isDirectory()) throw new Error('The linked local folder no longer exists.');
const result = await run('git', ['rev-parse', '--show-toplevel'], { cwd: resolved, timeout: 15_000 });
return path.resolve(result.stdout.trim());
}
async status(repoPath) {
const root = await this.ensureRepository(repoPath);
const result = await run('git', ['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'], {
cwd: root,
timeout: 30_000
});
const parsed = parsePorcelainV2(result.stdout);
const remoteUrl = await this.getRemoteUrl(root).catch(() => '');
const head = parsed.branch.oid && parsed.branch.oid !== '(initial)' ? parsed.branch.oid : null;
return { ...parsed, root, remoteUrl, head, shortHead: head ? head.slice(0, 7) : null };
}
statusFingerprint(status) {
return JSON.stringify({
head: status?.head || null,
branch: status?.branch || null,
files: (status?.files || []).map((file) => [file.path, file.originalPath, file.indexCode, file.worktreeCode])
});
}
async getRemoteUrl(repoPath, remote = 'origin') {
const result = await run('git', ['remote', 'get-url', remote], { cwd: repoPath, timeout: 15_000 });
return result.stdout.trim();
}
async diff(repoPath, filePath, staged = false) {
const root = await this.ensureRepository(repoPath);
const safeFile = filePath ? assertRepositoryRelativePath(filePath) : '';
const args = ['diff', '--no-ext-diff', '--no-color', '--unified=4'];
if (staged) args.push('--cached');
if (safeFile) args.push('--', safeFile);
const result = await run('git', args, { cwd: root, timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
if (!result.stdout && safeFile && !staged) {
const candidate = path.resolve(root, safeFile);
if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) throw new Error('File path escapes repository root.');
const content = await fs.readFile(candidate, 'utf8').catch(() => '');
if (content) return `diff --git a/${safeFile} b/${safeFile}\nnew file mode 100644\n--- /dev/null\n+++ b/${safeFile}\n${content.split('\n').map((line) => `+${line}`).join('\n')}`;
}
return result.stdout;
}
async stage(repoPath, files) {
const root = await this.ensureRepository(repoPath);
const selected = assertRepositoryRelativePaths(files);
await run('git', selected.length ? ['add', '--', ...selected] : ['add', '--all'], { cwd: root, timeout: 60_000 });
return this.status(root);
}
async unstage(repoPath, files) {
const root = await this.ensureRepository(repoPath);
const selected = assertRepositoryRelativePaths(files);
const hasHead = await run('git', ['rev-parse', '--verify', 'HEAD'], { cwd: root, allowExitCodes: [128] });
if (hasHead.exitCode === 0) {
await run('git', selected.length ? ['restore', '--staged', '--', ...selected] : ['restore', '--staged', '.'], { cwd: root });
} else {
await run('git', selected.length ? ['rm', '--cached', '--', ...selected] : ['rm', '--cached', '-r', '.'], { cwd: root, allowExitCodes: [1] });
}
return this.status(root);
}
async prepareSelectedStage(root, files) {
const selected = assertRepositoryRelativePaths(files);
if (selected.length) {
const stagedBefore = await run('git', ['diff', '--cached', '--name-only', '-z'], { cwd: root });
const alreadyStaged = stagedBefore.stdout.split('\0').filter(Boolean);
const excludedStaged = alreadyStaged.filter((file) => !selected.includes(file));
if (excludedStaged.length) {
throw new Error(`Some staged files are not selected (${excludedStaged.slice(0, 3).join(', ')}${excludedStaged.length > 3 ? ', …' : ''}). Select them or unstage them first.`);
}
}
await this.stage(root, selected);
const stagedCheck = await run('git', ['diff', '--cached', '--quiet'], { cwd: root, allowExitCodes: [1] });
if (stagedCheck.exitCode === 0) throw new Error('There are no staged changes to commit.');
return selected;
}
async commit(repoPath, message, files = []) {
const root = await this.ensureRepository(repoPath);
const commitMessage = assertCommitMessage(message);
await this.prepareSelectedStage(root, files);
const result = await run('git', ['commit', '-m', commitMessage], { cwd: root, timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
const status = await this.status(root);
return { output: result.stdout.trim(), sha: status.head, shortSha: status.shortHead, status };
}
async commitAndPush(repoPath, message, files = []) {
const committed = await this.commit(repoPath, message, files);
try {
const pushed = await this.push(repoPath);
return { commitOutput: committed.output, pushOutput: pushed.output, status: pushed.status, sha: committed.sha };
} catch (error) {
const wrapped = new Error(`Commit ${committed.shortSha} was created locally, but push failed: ${error.message}`);
wrapped.code = 'PUSH_AFTER_COMMIT_FAILED';
wrapped.commitSha = committed.sha;
wrapped.recoverable = true;
throw wrapped;
}
}
async push(repoPath) {
const root = await this.ensureRepository(repoPath);
const status = await this.status(root);
const branch = status.branch.head;
if (!branch || branch === '(detached)') throw new Error('Cannot push from a detached HEAD.');
const args = status.branch.upstream ? ['push', '--porcelain'] : ['push', '--porcelain', '--set-upstream', 'origin', branch];
const result = await run('git', args, { cwd: root, timeout: 180_000, maxBuffer: 16 * 1024 * 1024 });
return { output: `${result.stdout}\n${result.stderr}`.trim(), status: await this.status(root) };
}
async fetch(repoPath) {
const root = await this.ensureRepository(repoPath);
const result = await run('git', ['fetch', '--prune'], { cwd: root, timeout: 180_000 });
return { output: `${result.stdout}\n${result.stderr}`.trim(), status: await this.status(root) };
}
async pullFastForward(repoPath) {
const root = await this.ensureRepository(repoPath);
const status = await this.status(root);
if (!status.clean) throw new Error('Commit or stash local changes before synchronizing.');
if (!status.branch.upstream) throw new Error('This branch has no upstream branch. Publish it first.');
const result = await run('git', ['pull', '--ff-only'], { cwd: root, timeout: 180_000 });
return { output: `${result.stdout}\n${result.stderr}`.trim(), status: await this.status(root) };
}
async history(repoPath, limit = 20) {
const root = await this.ensureRepository(repoPath);
const format = '%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%s%x1e';
const result = await run('git', ['log', `-${Math.min(Math.max(Number(limit) || 20, 1), 100)}`, `--format=${format}`], { cwd: root, allowExitCodes: [128] });
if (result.exitCode === 128) return [];
return result.stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
const [sha, shortSha, author, email, date, subject] = record.split('\x1f');
return { sha, shortSha, author, email, date, subject };
});
}
async branches(repoPath) {
const root = await this.ensureRepository(repoPath);
const format = '%(refname:short)%x1f%(objectname)%x1f%(HEAD)%x1f%(upstream:short)%x1f%(upstream:track)%x1e';
const result = await run('git', ['for-each-ref', `--format=${format}`, 'refs/heads'], { cwd: root });
return result.stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
const [name, sha, current, upstream, track] = record.split('\x1f');
const ahead = Number(track?.match(/ahead (\d+)/)?.[1] || 0);
const behind = Number(track?.match(/behind (\d+)/)?.[1] || 0);
return { name, sha, shortSha: sha?.slice(0, 7), current: current === '*', upstream: upstream || null, ahead, behind };
});
}
assertBranchName(branch) {
const value = String(branch || '').trim();
if (!value) throw new Error('Branch name is required.');
return value;
}
async checkoutBranch(repoPath, branch) {
const root = await this.ensureRepository(repoPath);
const status = await this.status(root);
if (!status.clean) throw new Error('Commit or stash local changes before switching branches.');
const value = this.assertBranchName(branch);
await run('git', ['check-ref-format', '--branch', value], { cwd: root });
await run('git', ['switch', value], { cwd: root, timeout: 60_000 });
return this.status(root);
}
async createBranch(repoPath, branch) {
const root = await this.ensureRepository(repoPath);
const status = await this.status(root);
if (!status.clean) throw new Error('Commit or stash local changes before creating a branch.');
const value = this.assertBranchName(branch);
await run('git', ['check-ref-format', '--branch', value], { cwd: root });
await run('git', ['switch', '-c', value], { cwd: root, timeout: 60_000 });
return this.status(root);
}
async stash(repoPath, message = '') {
const root = await this.ensureRepository(repoPath);
const status = await this.status(root);
if (status.clean) throw new Error('There are no changes to stash.');
const args = ['stash', 'push', '--include-untracked'];
const label = String(message || '').trim();
if (label) args.push('-m', label.slice(0, 200));
const result = await run('git', args, { cwd: root, timeout: 120_000 });
return { output: result.stdout.trim(), status: await this.status(root), stashes: await this.stashList(root) };
}
async stashList(repoPath) {
const root = await this.ensureRepository(repoPath);
const format = '%gd%x1f%H%x1f%aI%x1f%gs%x1e';
const result = await run('git', ['stash', 'list', `--format=${format}`], { cwd: root });
return result.stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
const [ref, sha, date, subject] = record.split('\x1f');
return { ref, sha, shortSha: sha.slice(0, 7), date, subject };
});
}
async popStash(repoPath, ref = 'stash@{0}') {
const root = await this.ensureRepository(repoPath);
const value = String(ref || 'stash@{0}');
if (!/^stash@\{\d+\}$/.test(value)) throw new Error('Invalid stash reference.');
const result = await run('git', ['stash', 'pop', value], { cwd: root, timeout: 120_000 });
return { output: result.stdout.trim(), status: await this.status(root), stashes: await this.stashList(root) };
}
async verifyCommitOnRemoteBranch(repoPath, sha, branch) {
const root = await this.ensureRepository(repoPath);
const fullSha = assertFullCommitSha(sha);
const branchName = this.assertBranchName(branch);
await run('git', ['fetch', '--prune', 'origin', branchName], { cwd: root, timeout: 180_000 });
await run('git', ['cat-file', '-e', `${fullSha}^{commit}`], { cwd: root, timeout: 30_000 });
const ancestor = await run('git', ['merge-base', '--is-ancestor', fullSha, `origin/${branchName}`], { cwd: root, allowExitCodes: [1] });
if (ancestor.exitCode !== 0) throw new Error(`Commit ${fullSha.slice(0, 7)} is not contained in origin/${branchName}.`);
return { valid: true, sha: fullSha, branch: branchName };
}
async inspectCloneTarget(remoteUrl, destination) {
const remote = assertCloneRemote(remoteUrl);
const target = assertSafeRepositoryPath(destination);
const existing = await fs.stat(target).catch(() => null);
if (!existing) return { state: 'missing', remote, target };
if (!existing.isDirectory()) {
const error = new Error('The automatic clone target exists and is not a folder.');
error.code = 'CLONE_TARGET_NOT_DIRECTORY';
throw error;
}
const entries = await fs.readdir(target);
if (!entries.length) return { state: 'empty', remote, target };
const existingRemote = await this.getRemoteUrl(target).catch(() => '');
const expected = normalizeRemoteUrl(remote);
const actual = normalizeRemoteUrl(existingRemote);
const sameRepository = Boolean(
expected && actual
&& expected.host === actual.host
&& expected.path === actual.path
);
if (sameRepository) return { state: 'matching-repository', remote, target };
const error = new Error(existingRemote
? 'The automatic clone target already contains a different Git repository.'
: 'The automatic clone target already contains files. Choose another location or link the existing folder.');
error.code = existingRemote ? 'CLONE_TARGET_DIFFERENT_REPOSITORY' : 'CLONE_TARGET_NOT_EMPTY';
throw error;
}
async clone(remoteUrl, destination) {
const assessment = await this.inspectCloneTarget(remoteUrl, destination);
if (assessment.state === 'matching-repository') {
const status = await this.status(assessment.target);
return { ...status, reused: true };
}
if (assessment.state === 'missing') {
await fs.mkdir(path.dirname(assessment.target), { recursive: true });
}
await run('git', ['clone', '--progress', assessment.remote, assessment.target], { timeout: 15 * 60_000, maxBuffer: 32 * 1024 * 1024 });
const status = await this.status(assessment.target);
return { ...status, reused: false };
}
}
module.exports = { GitService };
+277
View File
@@ -0,0 +1,277 @@
'use strict';
const { normalizeBaseUrl } = require('../shared/validation.cjs');
const { redactSecrets } = require('./log-redaction.cjs');
class GiteaService {
constructor(store, diagnostics = null) {
this.store = store;
this.diagnostics = diagnostics;
}
async request(pathname, options = {}) {
const baseUrl = normalizeBaseUrl(options.baseUrl || this.store.data.gitea.baseUrl);
const token = options.token || this.store.getToken();
if (!token && options.auth !== false) throw new Error('No Gitea access token is available.');
const headers = {
Accept: options.accept || 'application/json',
...(token && options.auth !== false ? { Authorization: `token ${token}` } : {}),
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...(options.headers || {})
};
const started = Date.now();
let response;
try {
response = await fetch(`${baseUrl}/api/v1${pathname}`, {
method: options.method || 'GET',
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
signal: AbortSignal.timeout(options.timeout || 30_000),
redirect: 'follow'
});
} catch (error) {
const wrapped = new Error(`Could not reach Gitea: ${redactSecrets(error.message, [token])}`);
wrapped.code = error.code || 'GITEA_NETWORK_ERROR';
await this.diagnostics?.warning('gitea.request.failed', { method: options.method || 'GET', pathname, durationMs: Date.now() - started, code: wrapped.code, message: wrapped.message });
throw wrapped;
}
let text = '';
let payload = null;
if (options.responseType === 'buffer') {
payload = Buffer.from(await response.arrayBuffer());
} else {
text = await response.text();
if (text) {
if (options.responseType === 'text') payload = text;
else {
try { payload = JSON.parse(text); } catch { payload = text; }
}
}
}
if (!response.ok) {
const detail = typeof payload === 'object' && !Buffer.isBuffer(payload) && payload?.message ? payload.message : text || response.statusText;
const error = new Error(`Gitea returned ${response.status}: ${redactSecrets(detail, [token])}`);
error.status = response.status;
error.payload = payload;
await this.diagnostics?.warning('gitea.request.rejected', { method: options.method || 'GET', pathname, status: response.status, durationMs: Date.now() - started, message: error.message });
throw error;
}
await this.diagnostics?.debug('gitea.request.completed', { method: options.method || 'GET', pathname, status: response.status, durationMs: Date.now() - started });
return { status: response.status, headers: response.headers, data: payload };
}
async validateConnection(baseUrl, token) {
const normalized = normalizeBaseUrl(baseUrl);
const user = await this.request('/user', { baseUrl: normalized, token });
const repositories = await this.listRepositories({ baseUrl: normalized, token, limitPages: 1 });
const version = await this.request('/version', { baseUrl: normalized, token }).then((result) => result.data?.version || null).catch(() => null);
return { baseUrl: normalized, user: user.data, repositoryCount: repositories.length, version };
}
async listRepositories(options = {}) {
const repositories = [];
const pageSize = 50;
const limitPages = options.limitPages || 20;
for (let page = 1; page <= limitPages; page += 1) {
const result = await this.request(`/user/repos?limit=${pageSize}&page=${page}&sort=updated`, options);
const batch = Array.isArray(result.data) ? result.data : [];
repositories.push(...batch);
if (batch.length < pageSize) break;
}
return repositories;
}
async getRepository(owner, repo) {
return (await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`)).data;
}
async repositoryFileExists({ owner, repo, filePath, ref }) {
const encodedPath = String(filePath || '').split('/').map(encodeURIComponent).join('/');
const query = ref ? `?ref=${encodeURIComponent(ref)}` : '';
try {
await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodedPath}${query}`);
return true;
} catch (error) {
if (error.status === 404) return false;
throw error;
}
}
async getBranch(owner, repo, branch) {
return (await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches/${encodeURIComponent(branch)}`)).data;
}
async getRepositoryFile({ owner, repo, filePath, ref }) {
const encodedPath = String(filePath || '').split('/').map(encodeURIComponent).join('/');
const query = ref ? `?ref=${encodeURIComponent(ref)}` : '';
const payload = (await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodedPath}${query}`)).data;
if (!payload || Array.isArray(payload)) throw new Error(`Repository path ${filePath} is not a file.`);
if (payload.encoding === 'base64' && typeof payload.content === 'string') {
return { ...payload, decoded: Buffer.from(payload.content.replace(/\s/g, ''), 'base64').toString('utf8') };
}
if (typeof payload.content === 'string') return { ...payload, decoded: payload.content };
throw new Error(`Gitea did not return readable content for ${filePath}.`);
}
async getLatestRelease(owner, repo) {
try {
return (await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/latest`)).data;
} catch (error) {
if (error.status === 404) return null;
throw error;
}
}
async downloadAuthenticated(url, { timeout = 180_000 } = {}) {
const baseUrl = normalizeBaseUrl(this.store.data.gitea.baseUrl);
const base = new URL(baseUrl);
const token = this.store.getToken();
let target = new URL(url, `${baseUrl}/`);
for (let redirects = 0; redirects <= 5; redirects += 1) {
if (target.origin !== base.origin) throw new Error('Refusing to send the Gitea token to a different origin.');
const response = await fetch(target, {
headers: { Authorization: `token ${token}`, Accept: 'application/octet-stream' },
signal: AbortSignal.timeout(timeout),
redirect: 'manual'
});
if ([301, 302, 303, 307, 308].includes(response.status)) {
const location = response.headers.get('location');
if (!location) throw new Error('The update download redirect did not contain a destination.');
target = new URL(location, target);
continue;
}
if (!response.ok) throw new Error(`Update download failed with HTTP ${response.status}.`);
return Buffer.from(await response.arrayBuffer());
}
throw new Error('The update download exceeded the redirect limit.');
}
async dispatchWorkflow({ owner, repo, workflowFile, ref, inputs = {} }) {
const result = await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/workflows/${encodeURIComponent(workflowFile)}/dispatches`,
{ method: 'POST', body: { ref, inputs }, timeout: 60_000 }
);
return { accepted: [200, 201, 204].includes(result.status), status: result.status };
}
normalizeRun(run) {
if (!run || typeof run !== 'object') return null;
const status = String(run.status || run.conclusion || '').toLowerCase();
const conclusion = String(run.conclusion || '').toLowerCase() || (['success', 'failure', 'cancelled', 'skipped'].includes(status) ? status : null);
return {
id: run.id ?? run.run_id ?? run.task_id ?? null,
runNumber: run.run_number ?? run.index ?? run.id ?? null,
name: run.name || run.workflow_name || run.workflow_id || 'Workflow',
event: run.event || null,
status,
conclusion,
headSha: run.head_sha || run.commit_sha || run.commit?.sha || null,
headBranch: run.head_branch || run.ref || run.branch || null,
workflowPath: run.path || run.workflow_path || run.workflow_file || null,
displayTitle: run.display_title || run.title || run.name || null,
actor: run.actor?.login || run.trigger_user?.login || run.user?.login || null,
createdAt: run.created_at || run.started || run.start_time || null,
updatedAt: run.updated_at || run.stopped || run.end_time || null,
htmlUrl: run.html_url || run.url || null,
raw: run
};
}
async listWorkflowRuns({ owner, repo, sha, branch, limit = 30 } = {}) {
const base = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions`;
const normalizedLimit = String(Math.min(Math.max(limit, 1), 100));
const filtered = new URLSearchParams({ limit: normalizedLimit });
if (sha) filtered.set('head_sha', sha);
if (branch) filtered.set('branch', branch);
const basic = new URLSearchParams({ limit: normalizedLimit });
const tryEndpoint = async (endpoint) => {
try {
return await this.request(`${base}/${endpoint}?${filtered}`);
} catch (error) {
// Action API query support differs across Gitea releases. Retry without
// optional filters and apply SHA/branch matching locally.
if (![400, 422].includes(error.status) || String(filtered) === String(basic)) throw error;
return this.request(`${base}/${endpoint}?${basic}`);
}
};
let result;
let source = 'runs';
try {
result = await tryEndpoint('runs');
} catch (error) {
if (![404, 405].includes(error.status)) throw error;
source = 'tasks';
result = await tryEndpoint('tasks');
}
const data = result.data;
const items = Array.isArray(data) ? data : data?.workflow_runs || data?.runs || data?.tasks || [];
return { source, runs: items.map((item) => this.normalizeRun(item)).filter(Boolean), totalCount: data?.total_count ?? items.length };
}
async findWorkflowRun({ owner, repo, sha, branch, workflowFile, dispatchedAt, excludeRunIds = [] }) {
const { runs, source } = await this.listWorkflowRuns({ owner, repo, sha, branch, limit: 50 });
const earliest = dispatchedAt ? new Date(dispatchedAt).getTime() - 120_000 : 0;
const workflowBase = String(workflowFile || '').split('/').pop();
const excluded = new Set((excludeRunIds || []).map((value) => String(value)));
const candidates = runs.filter((run) => {
if (run.id !== null && run.id !== undefined && excluded.has(String(run.id))) return false;
if (sha && run.headSha && run.headSha.toLowerCase() !== sha.toLowerCase()) return false;
if (branch && run.headBranch && run.headBranch.replace(/^refs\/heads\//, '') !== branch) return false;
if (earliest && run.createdAt && new Date(run.createdAt).getTime() < earliest) return false;
if (workflowBase && run.workflowPath) {
const runBase = String(run.workflowPath).split('/').pop();
if (runBase && runBase !== workflowBase) return false;
}
return true;
});
candidates.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0));
return { source, run: candidates[0] || null };
}
async listWorkflowJobs({ owner, repo, runNumber }) {
if (runNumber === null || runNumber === undefined) return [];
const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/runs/${encodeURIComponent(runNumber)}/jobs?limit=100`);
const data = result.data;
const jobs = Array.isArray(data) ? data : data?.jobs || [];
return jobs.map((job) => ({
id: job.id,
name: job.name || job.job_name || `Job ${job.id}`,
status: String(job.status || '').toLowerCase(),
conclusion: String(job.conclusion || '').toLowerCase() || null,
startedAt: job.started_at || null,
completedAt: job.completed_at || null,
steps: Array.isArray(job.steps) ? job.steps.map((step) => ({
name: step.name,
status: String(step.status || '').toLowerCase(),
conclusion: String(step.conclusion || '').toLowerCase() || null,
number: step.number
})) : []
}));
}
async getJobLogs({ owner, repo, jobId }) {
if (!jobId) return '';
try {
const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/jobs/${encodeURIComponent(jobId)}/logs`, {
accept: 'text/plain, application/octet-stream',
responseType: 'text',
timeout: 60_000
});
return String(result.data || '').slice(-500_000);
} catch (error) {
if ([404, 410].includes(error.status)) return '';
throw error;
}
}
}
module.exports = { GiteaService };
+409
View File
@@ -0,0 +1,409 @@
'use strict';
const path = require('node:path');
const fs = require('node:fs/promises');
const { fileURLToPath } = require('node:url');
const { ipcMain, dialog, shell, app } = require('electron');
const { matchRemoteToRepository } = require('../shared/repository-match.cjs');
const { cloneDirectoryName, resolveCloneTarget } = require('../shared/clone-target.cjs');
let diagnosticsService = null;
const TRUSTED_RENDERER_PATH = path.resolve(__dirname, '..', 'renderer', 'index.html');
function toErrorPayload(error) {
return {
message: error?.message || 'Unknown error',
code: error?.code || null,
status: error?.status || null,
recoverable: Boolean(error?.recoverable),
commitSha: error?.commitSha || null
};
}
function assertTrustedSender(event) {
const url = event?.senderFrame?.url || event?.sender?.getURL?.() || '';
try {
const parsed = new URL(url);
if (parsed.protocol !== 'file:') throw new Error('not a file URL');
const senderPath = path.resolve(fileURLToPath(parsed));
const normalize = (value) => process.platform === 'win32' ? value.toLowerCase() : value;
if (normalize(senderPath) !== normalize(TRUSTED_RENDERER_PATH)) throw new Error('unexpected renderer file');
} catch {
throw new Error('Rejected IPC request from an untrusted renderer origin.');
}
}
function register(channel, handler) {
ipcMain.handle(channel, async (event, payload) => {
const started = Date.now();
try {
assertTrustedSender(event);
const data = await handler(payload || {}, event);
await diagnosticsService?.debug('ipc.completed', { channel, durationMs: Date.now() - started });
return { ok: true, data };
} catch (error) {
await diagnosticsService?.error('ipc.failed', {
channel,
durationMs: Date.now() - started,
error: { name: error?.name, message: error?.message, code: error?.code, status: error?.status, stack: error?.stack }
});
console.error(`[${channel}]`, error);
return { ok: false, error: toErrorPayload(error) };
}
});
}
function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh, updates, preflight, diagnostics, monitor }) {
diagnosticsService = diagnostics;
const withRepositoryPause = async (localPath, action) => {
monitor?.pause(localPath);
try { return await action(); }
finally { monitor?.resume(localPath); }
};
const canonicalPath = async (value) => {
const resolved = path.resolve(String(value || ''));
return fs.realpath(resolved).catch(() => resolved);
};
const assertKnownRepositoryPath = async (localPath) => {
const candidate = await canonicalPath(localPath);
let knownPaths = repositories.getWatchPaths();
if (!knownPaths.length && store.data.setupComplete) {
await repositories.refresh();
knownPaths = repositories.getWatchPaths();
}
const canonicalKnown = await Promise.all(knownPaths.map(canonicalPath));
if (!canonicalKnown.some((known) => known === candidate)) throw new Error('The requested local repository is not linked or discovered by ForgeFlow.');
return candidate;
};
const resolveRepository = async (repositoryPayload) => {
const fullName = String(repositoryPayload?.fullName || '').trim();
if (!fullName) throw new Error('Repository identity is required.');
const current = (await repositories.refresh()).find((item) => item.fullName === fullName);
if (!current) throw new Error('The repository is no longer available through the configured Gitea account.');
return current;
};
const assertProjectRoot = async (rootValue) => {
const root = await canonicalPath(rootValue);
const stat = await fs.stat(root).catch(() => null);
if (!stat?.isDirectory()) throw new Error('The selected project root no longer exists.');
return root;
};
const cloneRepositoryInto = async (fullName, projectRoot) => {
const current = await resolveRepository({ fullName });
if (current.localPath) throw new Error('This repository already has a linked local folder.');
const remoteUrl = current.preferredCloneUrl || current.cloneUrl || current.sshUrl;
if (!remoteUrl) throw new Error('Gitea did not provide a usable clone URL for this repository.');
const root = await assertProjectRoot(projectRoot);
const { target } = resolveCloneTarget(root, remoteUrl);
const status = await git.clone(remoteUrl, target);
await store.saveMapping(current.fullName, target);
const result = await repositories.refresh();
monitor?.setPaths(repositories.getWatchPaths());
await diagnostics.info(status.reused ? 'repository.clone.reused' : 'repository.cloned', {
fullName: current.fullName,
projectRoot: root,
target,
head: status.head,
branch: status.branch?.head
});
return {
target,
status,
reused: Boolean(status.reused),
repositories: result,
state: store.getPublicState()
};
};
register('app:bootstrap', async () => ({
appVersion: app.getVersion(),
platform: process.platform,
state: store.getPublicState(),
git: await git.isAvailable(),
diagnostics: await diagnostics.getStatus()
}));
register('dialog:select-directory', async ({ title = 'Select folder', defaultPath }) => {
const result = await dialog.showOpenDialog({ title, defaultPath, properties: ['openDirectory', 'createDirectory'] });
return result.canceled ? null : result.filePaths[0];
});
register('dialog:select-key-file', async ({ title = 'Select SSH private key', defaultPath }) => {
const result = await dialog.showOpenDialog({
title,
defaultPath,
properties: ['openFile']
});
return result.canceled ? null : result.filePaths[0];
});
register('setup:preflight', ({ baseUrl, token, roots }) => preflight.runSystem({ baseUrl, token, roots }));
register('setup:validate-gitea', ({ baseUrl, token }) => gitea.validateConnection(baseUrl, token));
register('setup:complete', async ({ baseUrl, token, workspaceRoots }) => {
const report = await preflight.runSystem({ baseUrl, token, roots: workspaceRoots });
if (!report.summary.ready || !report.giteaValidation) throw new Error('Setup readiness checks must pass before configuration can be completed.');
const validation = report.giteaValidation;
const result = await store.completeSetup({ baseUrl: validation.baseUrl, token, user: validation.user, workspaceRoots });
await diagnostics.info('setup.completed', { baseUrl: validation.baseUrl, user: validation.user?.login || null, workspaceRootCount: workspaceRoots?.length || 0, tokenPersistent: result.tokenState.persistent });
return result;
});
register('settings:update-gitea', async ({ baseUrl, token }) => {
const effectiveToken = String(token || '').trim() || store.getToken();
const validation = await gitea.validateConnection(baseUrl, effectiveToken);
const tokenState = await store.updateGitea({ baseUrl: validation.baseUrl, token, user: validation.user });
await diagnostics.info('settings.gitea.updated', { baseUrl: validation.baseUrl, user: validation.user?.login || null, tokenPersistent: tokenState.persistent, tokenPreserved: tokenState.preserved });
return { validation, tokenState, state: store.getPublicState() };
});
register('settings:set-roots', async ({ roots }) => {
store.data.workspaceRoots = [...new Set((roots || []).filter(Boolean))];
await store.save();
await diagnostics.info('settings.workspace-roots.updated', { rootCount: store.data.workspaceRoots.length, roots: store.data.workspaceRoots });
return store.getPublicState();
});
register('settings:set-appearance', async ({ appearance }) => {
if (!['dark', 'light', 'system'].includes(appearance)) throw new Error('Unsupported appearance setting.');
store.data.appearance = appearance;
await store.save();
return store.getPublicState();
});
register('settings:set-preferences', async ({ preferences }) => {
const state = await store.setPreferences(preferences);
monitor?.restart();
await diagnostics.info('settings.preferences.updated', { preferences: state.preferences });
return state;
});
register('updates:preferences', ({ updates: next }) => store.setUpdatePreferences(next));
register('updates:check', () => updates.check());
register('updates:download', () => updates.download());
register('updates:apply', async () => {
const result = await updates.apply();
setTimeout(() => app.quit(), 650).unref?.();
return result;
});
register('server:save', async ({ server, password = '', passphrase = '' }) => {
const saved = await store.saveServer(server, { password, passphrase });
await diagnostics.info('server.saved', {
serverId: saved.id,
name: saved.name,
host: saved.host,
port: saved.port,
username: saved.username,
authType: saved.authType,
basePath: saved.basePath
});
return { server: saved, state: store.getPublicState() };
});
register('server:delete', async ({ serverId }) => {
await store.deleteServer(serverId);
await diagnostics.info('server.deleted', { serverId });
return store.getPublicState();
});
register('server:test', async ({ serverId }) => {
const server = store.getServer(serverId);
if (!server) throw new Error('The configured server no longer exists.');
const result = await ssh.test(serverId, { trustOnFirstUse: !server.hostFingerprint });
if (!server.hostFingerprint) {
await store.saveServer({ ...server, hostFingerprint: result.fingerprint }, {});
result.trusted = true;
}
return { ...result, state: store.getPublicState() };
});
register('server:inspect-project', async ({ repository, profileId }) => unraid.inspect({ repository: await resolveRepository(repository), profileId }));
register('repositories:refresh', async () => {
const result = await repositories.refresh();
monitor?.setPaths(repositories.getWatchPaths());
return result;
});
register('repositories:discover', async ({ roots }) => {
const paths = await repositories.discoverAll(roots || store.data.workspaceRoots);
return repositories.getLocalDescriptors(paths);
});
register('repository:favorite', async ({ fullName, favorite }) => store.setFavorite(fullName, favorite));
register('repository:link', async ({ fullName, localPath }) => {
await git.ensureRepository(localPath);
const remoteUrl = await git.getRemoteUrl(localPath).catch(() => '');
if (!remoteUrl || !matchRemoteToRepository(remoteUrl, [{ full_name: fullName }])) {
throw new Error(`The selected folder's origin does not match ${fullName}.`);
}
await store.saveMapping(fullName, localPath);
await diagnostics.info('repository.linked', { fullName, localPath });
const result = await repositories.refresh();
monitor?.setPaths(repositories.getWatchPaths());
return result;
});
register('repository:unlink', async ({ fullName }) => {
await store.removeMapping(fullName);
await diagnostics.info('repository.unlinked', { fullName });
const result = await repositories.refresh();
monitor?.setPaths(repositories.getWatchPaths());
return result;
});
register('repository:status', async ({ localPath }) => git.status(await assertKnownRepositoryPath(localPath)));
register('repository:diff', async ({ localPath, filePath, staged }) => git.diff(await assertKnownRepositoryPath(localPath), filePath, staged));
register('repository:stage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.stage(safePath, files)); });
register('repository:unstage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.unstage(safePath, files)); });
register('repository:commit', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.commit(safePath, message, files)); });
register('repository:commit-push', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.commitAndPush(safePath, message, files)); });
register('repository:push', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.push(safePath)); });
register('repository:fetch', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.fetch(safePath)); });
register('repository:pull', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.pullFastForward(safePath)); });
register('repository:history', async ({ localPath, limit }) => git.history(await assertKnownRepositoryPath(localPath), limit));
register('repository:branches', async ({ localPath }) => git.branches(await assertKnownRepositoryPath(localPath)));
register('repository:checkout-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.checkoutBranch(safePath, branch)); });
register('repository:create-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.createBranch(safePath, branch)); });
register('repository:stash', async ({ localPath, message }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.stash(safePath, message)); });
register('repository:stash-list', async ({ localPath }) => git.stashList(await assertKnownRepositoryPath(localPath)));
register('repository:stash-pop', async ({ localPath, ref }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.popStash(safePath, ref)); });
register('repository:clone', async ({ fullName, mode = 'default' }) => {
if (!['default', 'custom'].includes(mode)) throw new Error('Unsupported clone location mode.');
let projectRoot = store.data.workspaceRoots[0] || null;
if (mode === 'custom' || !projectRoot) {
const result = await dialog.showOpenDialog({
title: `Choose a project root for ${String(fullName || 'repository')}`,
defaultPath: projectRoot || undefined,
buttonLabel: 'Use this project root',
properties: ['openDirectory', 'createDirectory']
});
if (result.canceled || !result.filePaths[0]) return { cancelled: true };
projectRoot = result.filePaths[0];
}
return cloneRepositoryInto(fullName, projectRoot);
});
register('repository:open-path', async ({ localPath }) => {
const safePath = await assertKnownRepositoryPath(localPath);
const error = await shell.openPath(safePath);
if (error) throw new Error(error);
return true;
});
register('external:open', async ({ url }) => {
const parsed = new URL(url);
if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('Only HTTP and HTTPS links can be opened.');
await shell.openExternal(parsed.toString());
return true;
});
register('deployment:save-profile', async ({ fullName, profile }) => {
const saved = await store.saveDeploymentProfile(fullName, profile);
await diagnostics.info('deployment.profile.saved', { repository: fullName, profile: saved });
return { profile: saved, state: store.getPublicState() };
});
register('deployment:delete-profile', async ({ fullName, profileId }) => {
const profiles = await store.deleteDeploymentProfile(fullName, profileId);
await diagnostics.info('deployment.profile.deleted', { repository: fullName, profileId });
return { profiles, state: store.getPublicState() };
});
register('deployment:preflight', async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
const profile = store.getDeploymentProfile(current.fullName, profileId);
if (profile?.provider === 'ssh-unraid') return unraid.preflight({ repository: current, profileId });
return preflight.runDeployment({ repository: current, profileId });
});
register('deployment:dispatch', async ({ repository, profileId, sha }) => {
const current = await resolveRepository(repository);
const profile = store.getDeploymentProfile(current.fullName, profileId);
if (profile?.provider === 'ssh-unraid') return unraid.deploy({ repository: current, profileId, sha });
return deployments.deploy({ repository: current, profileId, sha });
});
register('deployment:rollback', async ({ repository, profileId, targetSha }) => {
const current = await resolveRepository(repository);
const profile = store.getDeploymentProfile(current.fullName, profileId);
if (profile?.provider === 'ssh-unraid') return unraid.rollback({ repository: current, profileId, targetSha });
return deployments.rollback({ repository: current, profileId, targetSha });
});
register('deployment:health', ({ url }) => deployments.checkHealth(url));
register('deployment:profile-state', ({ fullName, profileId }) => {
const profile = store.getDeploymentProfile(fullName, profileId);
if (profile?.provider === 'ssh-unraid') return unraid.refreshProfileState(fullName, profileId);
return deployments.refreshProfileState(fullName, profileId);
});
register('operations:refresh', async ({ operationId }) => {
if (operationId) {
const operation = store.getOperation(operationId);
if (operation?.provider === 'ssh-unraid') return operation;
return deployments.refreshOperation(operationId);
}
return deployments.refreshActiveOperations();
});
register('operations:get', ({ operationId }) => store.getOperation(operationId));
register('diagnostics:status', () => diagnostics.getStatus());
register('diagnostics:clear', () => diagnostics.clear());
register('diagnostics:open-folder', async () => {
const error = await shell.openPath(diagnostics.logDirectory);
if (error) throw new Error(error);
return true;
});
register('diagnostics:export', async ({ privacyMode = 'standard' }) => {
if (!['standard', 'strict'].includes(privacyMode)) throw new Error('Unsupported diagnostic privacy mode.');
const result = await dialog.showSaveDialog({
title: 'Export ForgeFlow diagnostic bundle',
defaultPath: path.join(app.getPath('downloads'), `ForgeFlow-Diagnostics-${new Date().toISOString().replace(/[:.]/g, '-')}.zip`),
filters: [{ name: 'ZIP archive', extensions: ['zip'] }]
});
if (result.canceled || !result.filePath) return null;
const repositoryState = await repositories.refresh().catch((error) => {
diagnostics.warning('diagnostics.repository-snapshot.failed', error);
return [];
});
const systemPreflight = await preflight.runSystem().catch((error) => ({ error: error.message }));
const destinationPath = path.extname(result.filePath).toLowerCase() === '.zip' ? result.filePath : `${result.filePath}.zip`;
return diagnostics.exportSupportBundle({
destinationPath,
publicState: store.getPublicState(),
repositories: repositoryState,
operations: store.data.operations,
preflight: systemPreflight,
privacyMode,
extra: { appVersion: app.getVersion(), setupComplete: store.data.setupComplete }
});
});
register('diagnostics:show-bundle', async ({ filePath }) => {
if (!diagnostics.isKnownBundlePath(filePath)) throw new Error('Only the most recently generated support bundle can be revealed.');
shell.showItemInFolder(filePath);
return true;
});
register('renderer:report', async ({ level = 'info', event = 'renderer.event', details = {} }) => {
const method = ['debug', 'info', 'warning', 'error'].includes(level) ? level : 'info';
await diagnostics[method](`renderer.${String(event || 'event').slice(0, 120)}`, details);
return true;
});
register('app:reset', async () => {
await diagnostics.info('app.reset.requested', {});
store.data = store.migrate({});
store.sessionToken = null;
await store.save();
monitor?.setPaths([]);
monitor?.restart();
return store.getPublicState();
});
}
module.exports = { registerIpc, cloneDirectoryName, assertTrustedSender, toErrorPayload };
+93
View File
@@ -0,0 +1,93 @@
'use strict';
const path = require('node:path');
const os = require('node:os');
const crypto = require('node:crypto');
const SENSITIVE_KEY = /(^|_)(token|password|passwd|authorization|secret|credential|clientsecret|client_secret|apikey|api_key|privatekey|private_key|encryptedtoken|encrypted_token)($|_)/i;
const MAX_DIAGNOSTIC_STRING = 200_000;
function redactSecrets(value, secrets = []) {
let text = String(value ?? '');
const candidates = [...new Set((secrets || []).map((item) => String(item || '').trim()).filter((item) => item.length >= 4))]
.sort((a, b) => b.length - a.length);
for (const secret of candidates) text = text.split(secret).join('[REDACTED]');
text = text
.replace(/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/gi, '[REDACTED PRIVATE KEY]')
.replace(/(authorization\s*[:=]\s*(?:token|bearer|basic)\s+)[^\s,;]+/gi, '$1[REDACTED]')
.replace(/([?&](?:access_token|token|api_key|apikey|key|secret|password)=)[^&#\s]+/gi, '$1[REDACTED]')
.replace(/((?:access_token|token|api_key|apikey|client_secret|password|passwd|secret)\s*[=:]\s*)[^\s,;]+/gi, '$1[REDACTED]')
.replace(/("(?:access_token|token|api_key|apikey|client_secret|password|passwd|secret)"\s*:\s*")[^"]+("?)/gi, '$1[REDACTED]$2')
.replace(/(https?:\/\/[^\s:@/]+:)[^@\s/]+@/gi, '$1[REDACTED]@')
.replace(/\b(?:ghp|github_pat|glpat|gitea)_[A-Za-z0-9_-]{16,}\b/g, '[REDACTED TOKEN]');
return text.length > MAX_DIAGNOSTIC_STRING ? `${text.slice(0, MAX_DIAGNOSTIC_STRING)}\n[TRUNCATED]` : text;
}
function pathAlias(value, { homeDir = os.homedir(), cwd = process.cwd() } = {}) {
let text = String(value ?? '');
const replacements = [
[homeDir, '<HOME>'],
[cwd, '<APP_ROOT>']
].filter(([candidate]) => candidate && candidate.length > 3)
.sort((a, b) => b[0].length - a[0].length);
for (const [candidate, replacement] of replacements) {
const normalized = path.resolve(candidate);
text = text.split(normalized).join(replacement);
text = text.split(normalized.replace(/\\/g, '/')).join(replacement);
text = text.split(normalized.replace(/\//g, '\\')).join(replacement);
}
text = text
.replace(/[A-Za-z]:\\Users\\[^\\\s]+/g, '<HOME>')
.replace(/\/(?:home|Users)\/[^/\s]+/g, '<HOME>');
return text;
}
function stableAlias(value, prefix = 'item') {
const hash = crypto.createHash('sha256').update(String(value || '')).digest('hex').slice(0, 12);
return `${prefix}-${hash}`;
}
function sanitizeForDiagnostics(value, options = {}, seen = new WeakSet()) {
const {
secrets = [],
pathMode = 'alias',
homeDir = os.homedir(),
cwd = process.cwd(),
strictIdentifiers = false
} = options;
if (value === null || value === undefined || typeof value === 'boolean' || typeof value === 'number') return value;
if (typeof value === 'bigint') return value.toString();
if (typeof value === 'string') {
let output = redactSecrets(value, secrets);
if (pathMode === 'alias') output = pathAlias(output, { homeDir, cwd });
return output;
}
if (value instanceof Error) {
return sanitizeForDiagnostics({ name: value.name, message: value.message, code: value.code, stack: value.stack }, options, seen);
}
if (Array.isArray(value)) return value.slice(0, 1000).map((item) => sanitizeForDiagnostics(item, options, seen));
if (typeof value !== 'object') return redactSecrets(String(value), secrets);
if (seen.has(value)) return '[CIRCULAR]';
seen.add(value);
const output = {};
for (const [key, item] of Object.entries(value)) {
const normalizedKey = key.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[-.]/g, '_');
if (SENSITIVE_KEY.test(normalizedKey)) {
output[key] = '[REDACTED]';
continue;
}
if (strictIdentifiers && ['fullName', 'repository', 'owner', 'user', 'login', 'email'].includes(key)) {
output[key] = stableAlias(typeof item === 'object' ? JSON.stringify(item) : item, key.toLowerCase());
continue;
}
output[key] = sanitizeForDiagnostics(item, options, seen);
}
seen.delete(value);
return output;
}
module.exports = { redactSecrets, sanitizeForDiagnostics, pathAlias, stableAlias, SENSITIVE_KEY };
+208
View File
@@ -0,0 +1,208 @@
'use strict';
const fs = require('node:fs/promises');
const path = require('node:path');
const { run } = require('./process-runner.cjs');
function check(id, label, status, detail, { required = false, help = '' } = {}) {
return { id, label, status, detail, required, help };
}
function summarize(checks) {
const counts = checks.reduce((acc, item) => {
acc[item.status] = (acc[item.status] || 0) + 1;
return acc;
}, { pass: 0, warning: 0, fail: 0, skipped: 0 });
const blocking = checks.filter((item) => item.required && item.status === 'fail');
return { counts, blocking: blocking.map((item) => item.id), ready: blocking.length === 0 };
}
class PreflightService {
constructor({ store, git, gitea, deployments, diagnostics, userDataPath, secureStorageAvailable = () => false }) {
this.store = store;
this.git = git;
this.gitea = gitea;
this.deployments = deployments;
this.diagnostics = diagnostics;
this.userDataPath = userDataPath;
this.secureStorageAvailable = secureStorageAvailable;
}
async writableDirectory(directory) {
const marker = path.join(directory, `.forgeflow-write-test-${process.pid}-${Date.now()}`);
await fs.mkdir(directory, { recursive: true });
await fs.writeFile(marker, 'ok', { mode: 0o600 });
await fs.rm(marker, { force: true });
return true;
}
async gitIdentity() {
const [name, email] = await Promise.all([
run('git', ['config', '--global', '--get', 'user.name'], { allowExitCodes: [1], timeout: 10_000 }),
run('git', ['config', '--global', '--get', 'user.email'], { allowExitCodes: [1], timeout: 10_000 })
]);
return { name: name.stdout.trim(), email: email.stdout.trim() };
}
async runSystem({ baseUrl = '', token = '', roots = [] } = {}) {
const startedAt = new Date().toISOString();
const checks = [];
const git = await this.git.isAvailable();
checks.push(check('git.available', 'Git command line', git.available ? 'pass' : 'fail', git.available ? git.version : git.error || 'Git was not found on PATH.', {
required: true,
help: 'Install Git for Windows and ensure git.exe is available on PATH.'
}));
if (git.available) {
try {
const identity = await this.gitIdentity();
checks.push(check('git.identity', 'Git author identity', identity.name && identity.email ? 'pass' : 'warning', identity.name && identity.email ? `${identity.name} <${identity.email}>` : 'Global user.name or user.email is missing.', {
help: 'Set git config --global user.name and user.email before creating commits.'
}));
} catch (error) {
checks.push(check('git.identity', 'Git author identity', 'warning', error.message));
}
}
try {
await this.writableDirectory(this.userDataPath);
checks.push(check('storage.userdata', 'Application data storage', 'pass', 'ForgeFlow can write its local configuration.', { required: true }));
} catch (error) {
checks.push(check('storage.userdata', 'Application data storage', 'fail', error.message, { required: true }));
}
try {
await this.writableDirectory(this.diagnostics.logDirectory);
checks.push(check('storage.diagnostics', 'Diagnostic log storage', 'pass', 'The diagnostic directory is writable.', { required: true }));
} catch (error) {
checks.push(check('storage.diagnostics', 'Diagnostic log storage', 'fail', error.message, { required: true }));
}
checks.push(check('storage.credentials', 'Protected credential storage', this.secureStorageAvailable() ? 'pass' : 'warning', this.secureStorageAvailable()
? 'The operating system can encrypt the Gitea token at rest.'
: 'OS credential encryption is unavailable; the token will remain session-only.', {
help: 'Use a normal signed-in desktop session and make sure the OS credential service is available.'
}));
const normalizedRoots = [...new Set((roots || []).map((item) => String(item || '').trim()).filter(Boolean))];
if (!normalizedRoots.length) {
checks.push(check('workspace.roots', 'Development folders', 'warning', 'No development folder has been selected yet.'));
} else {
for (let index = 0; index < normalizedRoots.length; index += 1) {
const root = normalizedRoots[index];
try {
const stat = await fs.stat(root);
checks.push(check(`workspace.root.${index}`, `Development folder ${index + 1}`, stat.isDirectory() ? 'pass' : 'fail', stat.isDirectory() ? root : 'The selected path is not a directory.', { required: true }));
} catch (error) {
checks.push(check(`workspace.root.${index}`, `Development folder ${index + 1}`, 'fail', error.message, { required: true }));
}
}
}
const effectiveBaseUrl = String(baseUrl || this.store.data.gitea.baseUrl || '').trim();
const effectiveToken = String(token || this.store.getToken() || '').trim();
let giteaValidation = null;
if (!effectiveBaseUrl || !effectiveToken) {
checks.push(check('gitea.connection', 'Gitea connection', 'warning', 'Enter the Gitea URL and a local access token to test the connection.'));
} else {
try {
giteaValidation = await this.gitea.validateConnection(effectiveBaseUrl, effectiveToken);
checks.push(check('gitea.connection', 'Gitea connection', 'pass', `Connected to Gitea ${giteaValidation.version || 'unknown version'} as ${giteaValidation.user?.login || 'user'}.`, { required: true }));
checks.push(check('gitea.repositories', 'Repository access', giteaValidation.repositoryCount >= 0 ? 'pass' : 'warning', `${giteaValidation.repositoryCount} accessible repositories returned.`));
} catch (error) {
checks.push(check('gitea.connection', 'Gitea connection', 'fail', error.message, { required: true }));
}
}
const result = { kind: 'system', startedAt, completedAt: new Date().toISOString(), checks, summary: summarize(checks), giteaValidation };
await this.diagnostics.info('preflight.system.completed', { summary: result.summary, checks });
return result;
}
async fileExists(filePath) {
const stat = await fs.stat(filePath).catch(() => null);
return Boolean(stat?.isFile());
}
async runDeployment({ repository, profileId }) {
const checks = [];
const startedAt = new Date().toISOString();
if (!repository?.fullName) throw new Error('Repository identity is required.');
const profile = this.store.getDeploymentProfile(repository.fullName, profileId);
if (!profile) throw new Error('Deployment profile not found.');
checks.push(check('repository.linked', 'Local repository link', repository.localPath ? 'pass' : 'fail', repository.localPath || 'No local folder is linked.', { required: true }));
if (!repository.localPath) {
const result = { kind: 'deployment', repository: repository.fullName, profileId, startedAt, completedAt: new Date().toISOString(), checks, summary: summarize(checks) };
await this.diagnostics.info('preflight.deployment.completed', result);
return result;
}
let status = null;
try {
status = await this.git.status(repository.localPath);
checks.push(check('git.repository', 'Git working tree', 'pass', status.root, { required: true }));
checks.push(check('git.branch', 'Allowed branch', status.branch.head === profile.branch ? 'pass' : 'fail', `Current: ${status.branch.head || 'detached'}; required: ${profile.branch}.`, { required: true }));
checks.push(check('git.clean', 'Clean working tree', status.clean ? 'pass' : 'fail', status.clean ? 'No uncommitted changes.' : `${status.counts.changed} changed file(s) remain.`, { required: true }));
checks.push(check('git.upstream', 'Published upstream', status.branch.upstream ? 'pass' : 'fail', status.branch.upstream || 'No upstream branch configured.', { required: true }));
checks.push(check('git.sync', 'Local and Gitea synchronized', !status.branch.ahead && !status.branch.behind ? 'pass' : 'fail', `${status.branch.ahead || 0} ahead, ${status.branch.behind || 0} behind.`, { required: true }));
if (status.head) {
try {
await this.git.verifyCommitOnRemoteBranch(repository.localPath, status.head, profile.branch);
checks.push(check('git.remote-sha', 'Exact commit on remote branch', 'pass', `${status.head.slice(0, 7)} exists on origin/${profile.branch}.`, { required: true }));
} catch (error) {
checks.push(check('git.remote-sha', 'Exact commit on remote branch', 'fail', error.message, { required: true }));
}
}
} catch (error) {
checks.push(check('git.repository', 'Git working tree', 'fail', error.message, { required: true }));
}
const workflowPath = path.join(repository.localPath, '.gitea', 'workflows', profile.workflowFile);
checks.push(check('workflow.deploy.local', 'Deploy workflow in local repository', await this.fileExists(workflowPath) ? 'pass' : 'fail', workflowPath, { required: true }));
if (profile.rollbackWorkflowFile) {
const rollbackPath = path.join(repository.localPath, '.gitea', 'workflows', profile.rollbackWorkflowFile);
checks.push(check('workflow.rollback.local', 'Rollback workflow in local repository', await this.fileExists(rollbackPath) ? 'pass' : 'warning', rollbackPath));
}
try {
const [owner, repo] = repository.fullName.split('/');
const remoteWorkflow = await this.gitea.repositoryFileExists({ owner, repo, filePath: `.gitea/workflows/${profile.workflowFile}`, ref: profile.branch });
checks.push(check('workflow.deploy.remote', 'Deploy workflow on Gitea branch', remoteWorkflow ? 'pass' : 'fail', remoteWorkflow ? `${profile.workflowFile} exists on ${profile.branch}.` : `${profile.workflowFile} is not present on ${profile.branch}.`, { required: true }));
try {
await this.gitea.listWorkflowRuns({ owner, repo, branch: profile.branch, limit: 1 });
checks.push(check('gitea.actions', 'Gitea Actions API', 'pass', 'The Actions runs endpoint is accessible.', { required: true }));
} catch (error) {
checks.push(check('gitea.actions', 'Gitea Actions API', 'fail', error.message, { required: true }));
}
} catch (error) {
checks.push(check('workflow.deploy.remote', 'Deploy workflow on Gitea branch', 'fail', error.message, { required: true }));
}
if (profile.statusUrl) {
const state = await this.deployments.readStatusEndpoint(profile.statusUrl);
checks.push(check('server.status.configured', 'Server version endpoint configured', 'pass', profile.statusUrl, { required: true }));
checks.push(check('server.status.reachable', 'Server version endpoint reachable', state.reachable && state.ok ? 'pass' : 'warning', state.reachable && state.ok ? `Endpoint reachable${state.liveSha ? `; live ${state.liveSha.slice(0, 7)}` : '; no live SHA reported yet'}.` : state.error || `HTTP ${state.status || 'unavailable'}.`, { help: 'The first deployment may create the status file. Successful completion still requires the endpoint to return the exact SHA and request ID.' }));
if (state.reachable && state.ok) {
const identityMatches = (!state.repository || state.repository === repository.fullName) && (!state.environment || state.environment === profile.environment);
checks.push(check('server.status.identity', 'Status endpoint target identity', identityMatches ? (state.repository && state.environment ? 'pass' : 'warning') : 'fail', state.repository && state.environment ? `${state.repository} / ${state.environment}` : 'Repository or environment is not present in the current status document.', { required: !identityMatches }));
}
} else checks.push(check('server.status.configured', 'Server version endpoint configured', 'fail', 'A status URL is required for exact post-deployment verification.', { required: true }));
if (profile.healthcheckUrl) {
const health = await this.deployments.checkHealth(profile.healthcheckUrl);
checks.push(check('server.health', 'Application healthcheck', health.healthy ? 'pass' : 'warning', health.healthy ? `HTTP ${health.status} in ${health.latencyMs} ms.` : health.error || `HTTP ${health.status || 'unavailable'}.`));
} else checks.push(check('server.health', 'Application healthcheck', 'warning', 'No healthcheck URL is configured.'));
const result = {
kind: 'deployment', repository: repository.fullName, profileId, profileName: profile.name,
startedAt, completedAt: new Date().toISOString(), checks, summary: summarize(checks),
head: status?.head || null
};
await this.diagnostics.info('preflight.deployment.completed', { repository: repository.fullName, profileId, summary: result.summary, checks });
return result;
}
}
module.exports = { PreflightService, summarize, check };
+37
View File
@@ -0,0 +1,37 @@
'use strict';
const { execFile } = require('node:child_process');
function run(command, args = [], options = {}) {
const {
cwd,
timeout = 60_000,
maxBuffer = 8 * 1024 * 1024,
env,
allowExitCodes = []
} = options;
return new Promise((resolve, reject) => {
execFile(command, args, {
cwd,
timeout,
maxBuffer,
windowsHide: true,
encoding: 'utf8',
env: { ...process.env, ...(env || {}) }
}, (error, stdout, stderr) => {
if (error && !allowExitCodes.includes(error.code)) {
const wrapped = new Error((stderr || stdout || error.message).trim());
wrapped.code = error.code;
wrapped.stdout = stdout;
wrapped.stderr = stderr;
wrapped.command = `${command} ${args.join(' ')}`;
reject(wrapped);
return;
}
resolve({ stdout: stdout || '', stderr: stderr || '', exitCode: error?.code || 0 });
});
});
}
module.exports = { run };
+70
View File
@@ -0,0 +1,70 @@
'use strict';
class RepositoryMonitor {
constructor({ store, git, onChange, diagnostics = null }) {
this.store = store;
this.git = git;
this.onChange = onChange;
this.diagnostics = diagnostics;
this.paths = [];
this.fingerprints = new Map();
this.timer = null;
this.running = false;
this.paused = new Set();
}
setPaths(paths) {
this.paths = [...new Set((paths || []).filter(Boolean))];
for (const existing of [...this.fingerprints.keys()]) {
if (!this.paths.includes(existing)) this.fingerprints.delete(existing);
}
}
pause(localPath) { if (localPath) this.paused.add(localPath); }
resume(localPath) { if (localPath) this.paused.delete(localPath); }
restart() {
this.stop();
if (!this.store.data.preferences.autoRefresh) return;
const seconds = Math.min(Math.max(Number(this.store.data.preferences.repositoryPollSeconds) || 4, 2), 60);
this.timer = setInterval(() => this.tick().catch((error) => this.diagnostics?.warning('repository-monitor.tick.failed', error)), seconds * 1000);
this.timer.unref?.();
}
stop() {
if (this.timer) clearInterval(this.timer);
this.timer = null;
}
async tick() {
if (this.running || !this.paths.length) return;
this.running = true;
try {
for (const localPath of this.paths) {
if (this.paused.has(localPath)) continue;
try {
const status = await this.git.status(localPath);
const next = this.git.statusFingerprint(status);
const previous = this.fingerprints.get(localPath);
this.fingerprints.set(localPath, next);
if (previous && previous !== next) {
await this.diagnostics?.debug('repository-monitor.changed', { localPath, head: status.head, branch: status.branch?.head, counts: status.counts });
this.onChange?.({ localPath, status, reason: 'working-tree-changed' });
}
} catch (error) {
const next = `error:${error.message}`;
const previous = this.fingerprints.get(localPath);
this.fingerprints.set(localPath, next);
if (previous && previous !== next) {
await this.diagnostics?.warning('repository-monitor.unavailable', { localPath, message: error.message });
this.onChange?.({ localPath, error: error.message, reason: 'repository-unavailable' });
}
}
}
} finally {
this.running = false;
}
}
}
module.exports = { RepositoryMonitor };
+193
View File
@@ -0,0 +1,193 @@
'use strict';
const fs = require('node:fs/promises');
const path = require('node:path');
const { matchRemoteToRepository, repositoryKey } = require('../shared/repository-match.cjs');
const SKIP_DIRECTORIES = new Set([
'.git', '.svn', '.hg', 'node_modules', '.next', '.nuxt', 'dist', 'build', 'coverage',
'.cache', '.venv', 'venv', '__pycache__', '$RECYCLE.BIN', 'System Volume Information'
]);
async function mapLimit(items, limit, mapper) {
const output = new Array(items.length);
let cursor = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (cursor < items.length) {
const index = cursor++;
output[index] = await mapper(items[index], index);
}
});
await Promise.all(workers);
return output;
}
class RepositoryService {
constructor(store, gitService, giteaService, diagnostics = null) {
this.store = store;
this.git = gitService;
this.gitea = giteaService;
this.diagnostics = diagnostics;
this.lastKnownLocalPaths = [];
}
async discoverInRoot(root, maxDepth = 4) {
const found = [];
const seen = new Set();
const visit = async (directory, depth) => {
let real;
try { real = await fs.realpath(directory); } catch { return; }
if (seen.has(real)) return;
seen.add(real);
const gitMarker = path.join(directory, '.git');
const marker = await fs.stat(gitMarker).catch(() => null);
if (marker) {
found.push(real);
return;
}
if (depth >= maxDepth) return;
let entries;
try { entries = await fs.readdir(real, { withFileTypes: true }); } catch { return; }
await mapLimit(entries
.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink() && !SKIP_DIRECTORIES.has(entry.name)), 12,
(entry) => visit(path.join(real, entry.name), depth + 1));
};
await visit(root, 0);
return found;
}
async discoverAll(roots) {
const grouped = await mapLimit((roots || []).filter(Boolean), 4, (root) => this.discoverInRoot(root));
return [...new Set(grouped.flat())];
}
async getLocalDescriptors(paths) {
return mapLimit(paths, 5, async (localPath) => {
try {
const status = await this.git.status(localPath);
return { localPath: status.root, remoteUrl: status.remoteUrl, status };
} catch (error) {
return { localPath, remoteUrl: '', status: null, error: error.message };
}
});
}
getWatchPaths() {
return [...this.lastKnownLocalPaths];
}
async refresh() {
const started = Date.now();
const remoteRepositories = this.store.data.gitea.baseUrl && this.store.getToken()
? await this.gitea.listRepositories()
: [];
const discoveredPaths = await this.discoverAll(this.store.data.workspaceRoots);
const mappedPaths = Object.values(this.store.data.repositoryMappings || {});
const localPaths = [...new Set([...discoveredPaths, ...mappedPaths])];
const localDescriptors = await this.getLocalDescriptors(localPaths);
this.lastKnownLocalPaths = localDescriptors.filter((item) => item.status).map((item) => item.status.root);
const usedLocalPaths = new Set();
const repositories = [];
for (const remote of remoteRepositories) {
const key = repositoryKey(remote);
const explicitPath = this.store.data.repositoryMappings[key];
let local = explicitPath ? localDescriptors.find((item) => path.resolve(item.localPath) === path.resolve(explicitPath)) : null;
if (!local) local = localDescriptors.find((item) => !usedLocalPaths.has(item.localPath) && matchRemoteToRepository(item.remoteUrl, [remote]));
if (local) usedLocalPaths.add(local.localPath);
const profiles = this.store.getDeploymentProfiles(remote.full_name).map((profile) => ({
...profile,
state: this.store.getDeploymentState(profile.id)
}));
repositories.push(this.decorate(remote, local, profiles));
}
for (const local of localDescriptors.filter((item) => !usedLocalPaths.has(item.localPath))) {
const name = path.basename(local.localPath);
repositories.push({
id: `local:${local.localPath}`,
name,
fullName: name,
owner: { login: 'local' },
description: 'Local repository not matched to Gitea',
private: true,
defaultBranch: local.status?.branch.head || 'main',
htmlUrl: null,
cloneUrl: null,
sshUrl: null,
preferredCloneUrl: null,
localPath: local.localPath,
localStatus: local.status,
linkState: 'unmatched-local',
deploymentProfiles: [],
readyToDeploy: false,
favorite: false,
attention: Boolean(local.error),
attentionReason: local.error || null
});
}
const sorted = repositories.sort((a, b) => {
const score = (repo) => (repo.attention ? 100 : 0)
+ (repo.localStatus?.counts.changed ? 50 : 0)
+ (repo.localStatus?.branch.ahead ? 30 : 0)
+ (repo.readyToDeploy ? 20 : 0)
+ (repo.favorite ? 5 : 0);
return score(b) - score(a) || a.fullName.localeCompare(b.fullName);
});
await this.diagnostics?.debug('repositories.refresh.completed', {
durationMs: Date.now() - started,
remoteCount: remoteRepositories.length,
discoveredCount: discoveredPaths.length,
linkedCount: sorted.filter((item) => item.localPath).length,
attentionCount: sorted.filter((item) => item.attention).length,
readyToDeployCount: sorted.filter((item) => item.readyToDeploy).length
});
return sorted;
}
decorate(remote, local, profiles) {
const status = local?.status || null;
const hasChanges = Boolean(status?.counts.changed);
const ahead = status?.branch.ahead || 0;
const behind = status?.branch.behind || 0;
const conflict = Boolean(status?.counts.conflicts);
const profileForBranch = profiles.find((profile) => profile.branch === status?.branch.head);
const readyToDeploy = Boolean(profileForBranch && status?.head && status?.branch.upstream && !hasChanges && ahead === 0 && behind === 0);
const key = String(remote.full_name || '').toLowerCase();
const preferredCloneUrl = this.store.data.preferences.preferredCloneProtocol === 'ssh'
? (remote.ssh_url || remote.clone_url)
: (remote.clone_url || remote.ssh_url);
return {
id: remote.id,
name: remote.name,
fullName: remote.full_name,
owner: remote.owner,
description: remote.description || '',
private: remote.private,
defaultBranch: remote.default_branch || 'main',
htmlUrl: remote.html_url,
cloneUrl: remote.clone_url,
sshUrl: remote.ssh_url,
preferredCloneUrl,
updatedAt: remote.updated_at,
localPath: local?.localPath || null,
localStatus: status,
linkState: local ? 'linked' : 'remote-only',
deploymentProfiles: profiles,
readyToDeploy,
favorite: (this.store.data.favorites || []).includes(key),
attention: conflict || behind > 0 || Boolean(local?.error),
attentionReason: conflict ? 'Merge conflict' : behind > 0 ? `${behind} commit${behind === 1 ? '' : 's'} behind remote` : local?.error || null
};
}
}
module.exports = { RepositoryService, SKIP_DIRECTORIES, mapLimit };
+146
View File
@@ -0,0 +1,146 @@
'use strict';
const fs = require('node:fs/promises');
const crypto = require('node:crypto');
function loadSshClient() {
try { return require('ssh2').Client; }
catch {
const error = new Error('The ssh2 dependency is not installed. Run npm install before configuring SSH deployments.');
error.code = 'SSH2_NOT_INSTALLED';
throw error;
}
}
function fingerprintKey(key) {
const buffer = Buffer.isBuffer(key) ? key : Buffer.from(key);
return `SHA256:${crypto.createHash('sha256').update(buffer).digest('base64').replace(/=+$/, '')}`;
}
function shellQuote(value) {
return `'${String(value ?? '').replace(/'/g, `'\\''`)}'`;
}
class SshService {
constructor({ store, diagnostics }) {
this.store = store;
this.diagnostics = diagnostics;
}
async connectionOptions(server, { trustOnFirstUse = false } = {}) {
const credentials = this.store.getServerCredentials(server.id);
let observedFingerprint = null;
const options = {
host: server.host,
port: server.port || 22,
username: server.username,
readyTimeout: 20_000,
keepaliveInterval: 10_000,
keepaliveCountMax: 3,
hostVerifier: (key) => {
observedFingerprint = fingerprintKey(key);
return trustOnFirstUse || Boolean(server.hostFingerprint && observedFingerprint === server.hostFingerprint);
}
};
if (server.authType === 'password') {
options.password = credentials.password;
} else {
options.privateKey = await fs.readFile(server.privateKeyPath);
if (credentials.passphrase) options.passphrase = credentials.passphrase;
}
return { options, getObservedFingerprint: () => observedFingerprint };
}
async withClient(serverId, action, options = {}) {
const server = this.store.getServer(serverId);
if (!server) throw new Error('The configured SSH server no longer exists.');
const Client = loadSshClient();
const connection = await this.connectionOptions(server, options);
const client = new Client();
const started = Date.now();
return new Promise((resolve, reject) => {
let settled = false;
const finish = (callback, value) => {
if (settled) return;
settled = true;
try { client.end(); } catch {}
callback(value);
};
client.once('ready', async () => {
try {
const data = await action(client, server, connection.getObservedFingerprint());
await this.diagnostics?.debug('ssh.connection.completed', {
serverId,
host: server.host,
durationMs: Date.now() - started
});
finish(resolve, data);
} catch (error) { finish(reject, error); }
});
client.once('error', async (error) => {
const wrapped = new Error(`SSH connection failed: ${error.message}`);
wrapped.code = error.code || 'SSH_CONNECTION_FAILED';
await this.diagnostics?.warning('ssh.connection.failed', {
serverId,
host: server.host,
durationMs: Date.now() - started,
code: wrapped.code,
message: wrapped.message
});
finish(reject, wrapped);
});
client.connect(connection.options);
});
}
execClient(client, command, { timeout = 15 * 60_000, maxOutput = 2 * 1024 * 1024 } = {}) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('The SSH command timed out.')), timeout);
client.exec(command, (error, stream) => {
if (error) {
clearTimeout(timer);
reject(error);
return;
}
let stdout = '';
let stderr = '';
stream.on('data', (chunk) => { if (stdout.length < maxOutput) stdout += chunk.toString(); });
stream.stderr.on('data', (chunk) => { if (stderr.length < maxOutput) stderr += chunk.toString(); });
stream.on('close', (code, signal) => {
clearTimeout(timer);
if (code !== 0) {
const failure = new Error(`Remote command failed with exit code ${code}: ${(stderr || stdout).trim().slice(-4000)}`);
failure.code = 'SSH_COMMAND_FAILED';
failure.exitCode = code;
failure.signal = signal;
reject(failure);
} else resolve({ stdout, stderr, exitCode: code });
});
});
});
}
async test(serverId, { trustOnFirstUse = true } = {}) {
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 });
return {
connected: true,
fingerprint,
server: { id: server.id, name: server.name, host: server.host, basePath: server.basePath },
output: result.stdout.trim()
};
}, { trustOnFirstUse });
}
async exec(serverId, command, options = {}) {
const server = this.store.getServer(serverId);
if (!server?.hostFingerprint) {
const error = new Error('Test and trust the SSH server fingerprint before running deployment commands.');
error.code = 'SSH_HOST_NOT_TRUSTED';
throw error;
}
return this.withClient(serverId, (client) => this.execClient(client, command, options), { trustOnFirstUse: false });
}
}
module.exports = { SshService, shellQuote, fingerprintKey };
+595
View File
@@ -0,0 +1,595 @@
'use strict';
const fs = require('node:fs/promises');
const path = require('node:path').posix;
const nativePath = require('node:path');
const crypto = require('node:crypto');
const { shellQuote } = require('./ssh-service.cjs');
const { assertFullCommitSha } = require('../shared/validation.cjs');
const { normalizeRemoteUrl } = require('../shared/repository-match.cjs');
function safeRemoteFolder(value) {
const text = String(value || '').trim();
if (!/^[a-zA-Z0-9._-]+$/.test(text) || text === '.' || text === '..') throw new Error('Remote folder contains unsupported characters.');
return text;
}
function safeRelativeRemoteFile(value, fallback = '') {
const text = String(value || fallback).trim().replace(/\\/g, '/');
if (!text || text.startsWith('/') || text.split('/').some((part) => !part || part === '.' || part === '..')) {
throw new Error('Remote file path must remain inside the project folder.');
}
return text;
}
function bash(command) {
const script = `set -euo pipefail\nexport GIT_TERMINAL_PROMPT=0\nexport GIT_SSH_COMMAND='ssh -o BatchMode=yes'\n${command}`;
const payload = Buffer.from(script, 'utf8').toString('base64');
return `printf '%s' ${shellQuote(payload)} | base64 -d | bash`;
}
function parseInspection(text) {
const jsonMarker = '__FORGEFLOW_JSON__';
const jsonIndex = text.lastIndexOf(jsonMarker);
if (jsonIndex >= 0) return JSON.parse(text.slice(jsonIndex + jsonMarker.length).trim());
const kvMarker = '__FORGEFLOW_KV__';
const kvIndex = text.lastIndexOf(kvMarker);
if (kvIndex < 0) throw new Error('The server inspection did not return a ForgeFlow result.');
const fields = {};
for (const line of text.slice(kvIndex + kvMarker.length).trim().split(/\r?\n/)) {
const separator = line.indexOf('=');
if (separator > 0) fields[line.slice(0, separator)] = line.slice(separator + 1);
}
const decodeLines = (value) => {
try { return value ? Buffer.from(value, 'base64').toString('utf8').split(/\r?\n/).filter(Boolean) : []; }
catch { return []; }
};
const decodeText = (value) => {
try { return value ? Buffer.from(value, 'base64').toString('utf8') : ''; }
catch { return ''; }
};
return {
exists: fields.exists === 'true',
rootGit: fields.rootGit === 'true',
head: fields.head || null,
branch: fields.branch || null,
remote: fields.remote ? Buffer.from(fields.remote, 'base64').toString('utf8') : null,
trackedChanges: decodeLines(fields.trackedChanges),
composeFiles: decodeLines(fields.composeFiles),
nestedGit: decodeLines(fields.nestedGit),
dockerfile: fields.dockerfile === 'true',
dockerignoreContent: decodeText(fields.dockerignoreContent),
existingPreservePaths: decodeLines(fields.existingPreservePaths)
};
}
function dockerIgnoreHasPath(content, value) {
const target = String(value || '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\//, '').replace(/\/$/, '');
if (!target) return false;
return String(content || '').split(/\r?\n/).some((line) => {
let rule = line.trim();
if (!rule || rule.startsWith('#') || rule.startsWith('!')) return false;
rule = rule.replace(/^\.\//, '').replace(/^\//, '').replace(/\/$/, '');
return rule === target || rule === `${target}/**` || rule === `${target}/**/*`;
});
}
function checksSummary(checks) {
const counts = {
pass: checks.filter((item) => item.status === 'pass').length,
warning: checks.filter((item) => item.status === 'warning').length,
fail: checks.filter((item) => item.status === 'fail').length
};
return {
ready: counts.fail === 0,
counts,
blocking: checks.filter((item) => item.status === 'fail').map((item) => item.id)
};
}
class UnraidDeploymentService {
constructor({ store, ssh, git, diagnostics }) {
this.store = store;
this.ssh = ssh;
this.git = git;
this.diagnostics = diagnostics;
}
resolve(repository, profileId) {
const profile = this.store.getDeploymentProfile(repository.fullName, profileId);
if (!profile || profile.provider !== 'ssh-unraid') throw new Error('The SSH / Unraid deployment profile no longer exists.');
const server = this.store.getServer(profile.serverId);
if (!server) throw new Error('The deployment server no longer exists.');
const remoteFolder = safeRemoteFolder(profile.remoteFolder || repository.name);
const remotePath = path.join(server.basePath, remoteFolder);
if (!remotePath.startsWith(`${server.basePath}/`)) throw new Error('Remote project path escapes the configured server base path.');
return { profile, server, remoteFolder, remotePath };
}
async inspect({ repository, profileId }) {
const { profile, server, remotePath } = this.resolve(repository, profileId);
const preserveProbe = (profile.preservePaths || []).map((relativePath) =>
`if [ -e "$root"/${shellQuote(relativePath)} ]; then printf '%s\\n' ${shellQuote(relativePath)}; fi`
).join('\n');
const script = `
root=${shellQuote(remotePath)}
exists=false; root_git=false; head=""; branch=""; remote=""; tracked_changes=""; compose_files=""; nested_git=""; dockerfile=false; dockerignore_content=""; existing_preserve_paths=""
if [ -d "$root" ]; then
exists=true
if [ -d "$root/.git" ]; then
root_git=true
head=$(git -C "$root" rev-parse HEAD 2>/dev/null || true)
branch=$(git -C "$root" branch --show-current 2>/dev/null || true)
remote=$(git -C "$root" remote get-url origin 2>/dev/null || true)
tracked_changes=$(git -C "$root" status --porcelain --untracked-files=no 2>/dev/null | head -n 25 | base64 | tr -d '\\r\\n' || true)
fi
compose_files=$(find "$root" -maxdepth 2 -type f \\( -name 'docker-compose.yml' -o -name 'docker-compose.yaml' -o -name 'compose.yml' -o -name 'compose.yaml' -o -name 'compose.forgeflow.yml' \\) -printf '%P\\n' 2>/dev/null | sort | base64 | tr -d '\\r\\n' || true)
nested_git=$(find "$root" -mindepth 2 -maxdepth 5 -type d -name .git -printf '%h\\n' 2>/dev/null | sed "s#^$root/##" | sort | base64 | tr -d '\\r\\n' || true)
[ -f "$root/Dockerfile" ] && dockerfile=true
[ -f "$root/.dockerignore" ] && dockerignore_content=$(base64 < "$root/.dockerignore" | tr -d '\\r\\n' || true)
existing_preserve_paths=$({ ${preserveProbe || ':'}; } | sort -u | base64 | tr -d '\\r\\n' || true)
fi
printf '__FORGEFLOW_KV__\\n'
printf 'exists=%s\\n' "$exists"
printf 'rootGit=%s\\n' "$root_git"
printf 'head=%s\\n' "$head"
printf 'branch=%s\\n' "$branch"
printf 'remote=%s\\n' "$(printf '%s' "$remote" | base64 | tr -d '\\r\\n')"
printf 'trackedChanges=%s\\n' "$tracked_changes"
printf 'composeFiles=%s\\n' "$compose_files"
printf 'nestedGit=%s\\n' "$nested_git"
printf 'dockerfile=%s\\n' "$dockerfile"
printf 'dockerignoreContent=%s\\n' "$dockerignore_content"
printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths"
`;
const wrapped = bash(script);
const result = await this.ssh.exec(server.id, wrapped, { timeout: 60_000 });
const parsed = parseInspection(result.stdout);
const contextCandidates = [...new Set([...(parsed.existingPreservePaths || []), ...(parsed.nestedGit || [])])];
const inspection = {
...parsed,
dockerignore: Boolean(parsed.dockerignoreContent),
dockerignoreGitExcluded: dockerIgnoreHasPath(parsed.dockerignoreContent, '.git'),
dockerContextExclusionsMissing: parsed.dockerfile
? contextCandidates.filter((item) => !dockerIgnoreHasPath(parsed.dockerignoreContent, item))
: [],
serverId: server.id,
serverName: server.name,
remotePath,
profileId: profile.id
};
await this.diagnostics?.info('unraid.inspected', {
repository: repository.fullName,
serverId: server.id,
remotePath,
exists: inspection.exists,
rootGit: inspection.rootGit,
head: inspection.head,
composeFiles: inspection.composeFiles,
nestedGitCount: inspection.nestedGit.length,
trackedChangeCount: inspection.trackedChanges.length,
dockerContextExclusionsMissing: inspection.dockerContextExclusionsMissing
});
return inspection;
}
async preflight({ repository, profileId, sha = null }) {
const { profile, server, remotePath } = this.resolve(repository, profileId);
const targetSha = assertFullCommitSha(sha || repository.localStatus?.head);
const checks = [];
let inspection = null;
if (!repository.localPath) {
checks.push({ id: 'local-repository', label: 'Local repository', status: 'fail', detail: 'Link or clone the repository locally before deploying.' });
} else {
try {
const localStatus = await this.git.status(repository.localPath);
checks.push({ id: 'local-repository', label: 'Local repository', status: 'pass', detail: localStatus.root });
checks.push({ id: 'local-branch', label: 'Allowed branch', status: localStatus.branch.head === profile.branch ? 'pass' : 'fail', detail: `Current: ${localStatus.branch.head || 'detached'}; required: ${profile.branch}.` });
checks.push({ id: 'local-clean', label: 'Clean local working tree', status: localStatus.clean ? 'pass' : 'fail', detail: localStatus.clean ? 'No uncommitted changes.' : `${localStatus.counts.changed} changed file(s) remain.` });
checks.push({ id: 'local-upstream', label: 'Published upstream', status: localStatus.branch.upstream ? 'pass' : 'fail', detail: localStatus.branch.upstream || 'No upstream branch is configured.' });
checks.push({ id: 'local-sync', label: 'Local and Gitea synchronized', status: !localStatus.branch.ahead && !localStatus.branch.behind ? 'pass' : 'fail', detail: `${localStatus.branch.ahead || 0} ahead, ${localStatus.branch.behind || 0} behind.` });
checks.push({ id: 'local-target-sha', label: 'Selected deployment commit', status: localStatus.head === targetSha ? 'pass' : 'fail', detail: localStatus.head === targetSha ? targetSha : `Local HEAD is ${localStatus.head || 'unknown'}, but deployment requested ${targetSha}.` });
try {
await this.git.verifyCommitOnRemoteBranch(repository.localPath, targetSha, profile.branch);
checks.push({ id: 'remote-target-sha', label: 'Exact commit on Gitea branch', status: 'pass', detail: `${targetSha.slice(0, 7)} exists on origin/${profile.branch}.` });
} catch (error) {
checks.push({ id: 'remote-target-sha', label: 'Exact commit on Gitea branch', status: 'fail', detail: error.message });
}
const localDeploymentFile = profile.generatedCompose
? nativePath.join(repository.localPath, 'Dockerfile')
: nativePath.join(repository.localPath, safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml'));
const localDeploymentFileExists = Boolean((await fs.stat(localDeploymentFile).catch(() => null))?.isFile());
checks.push({
id: 'local-deployment-file',
label: profile.generatedCompose ? 'Dockerfile in repository' : 'Compose file in repository',
status: localDeploymentFileExists ? 'pass' : 'fail',
detail: localDeploymentFileExists ? localDeploymentFile : `${localDeploymentFile} was not found in the exact local checkout.`
});
} catch (error) {
checks.push({ id: 'local-repository', label: 'Local repository', status: 'fail', detail: error.message });
}
}
try {
const connection = await this.ssh.test(server.id, { trustOnFirstUse: false });
checks.push({ id: 'ssh', label: 'SSH connection', status: 'pass', detail: `${server.username}@${server.host}:${server.port}` });
if (!/docker compose|docker-compose/i.test(connection.output)) {
checks.push({ id: 'compose-command', label: 'Docker Compose', status: 'fail', detail: 'Docker Compose was not detected on the server.' });
} else checks.push({ id: 'compose-command', label: 'Docker Compose', status: 'pass', detail: 'Docker Compose is available.' });
} catch (error) {
checks.push({ id: 'ssh', label: 'SSH connection', status: 'fail', detail: error.message });
}
if (!server.hostFingerprint) checks.push({ id: 'host-key', label: 'Server identity', status: 'fail', detail: 'Test and trust the SSH host key first.' });
else checks.push({ id: 'host-key', label: 'Server identity', status: 'pass', detail: server.hostFingerprint });
try {
inspection = await this.inspect({ repository, profileId });
if (!inspection.exists) {
checks.push({ id: 'remote-folder', label: 'Remote project folder', status: 'pass', detail: `${remotePath} will be created.` });
} else if (!inspection.rootGit) {
checks.push({ id: 'remote-folder', label: 'Remote project folder', status: 'fail', detail: `${remotePath} exists but is not a Git working tree. Adopt or migrate it before deployment.` });
} else {
checks.push({ id: 'remote-folder', label: 'Remote Git working tree', status: 'pass', detail: `${remotePath} at ${String(inspection.head || '').slice(0, 7) || 'unknown'}.` });
}
if (inspection.trackedChanges.length) {
checks.push({ id: 'tracked-changes', label: 'Server-side tracked changes', status: 'fail', detail: `${inspection.trackedChanges.length} tracked change(s) would be overwritten. Commit, revert or migrate them first.` });
} else if (inspection.rootGit) checks.push({ id: 'tracked-changes', label: 'Server-side tracked changes', status: 'pass', detail: 'No tracked server-only edits detected.' });
if (inspection.rootGit && profile.cloneUrl && inspection.remote) {
const expectedRemote = normalizeRemoteUrl(profile.cloneUrl);
const currentRemote = normalizeRemoteUrl(inspection.remote);
const matches = Boolean(expectedRemote && currentRemote && expectedRemote.host === currentRemote.host && expectedRemote.path === currentRemote.path);
if (!matches && profile.alignRemote) {
checks.push({ id: 'origin-url', label: 'Server Git origin', status: 'warning', detail: `Origin will be aligned from ${inspection.remote} to the configured clone URL before fetch.` });
} else if (!matches) {
checks.push({ id: 'origin-url', label: 'Server Git origin', status: 'fail', detail: `Current origin ${inspection.remote} does not match the configured clone URL. Enable controlled origin alignment or correct the profile.` });
} else {
checks.push({ id: 'origin-url', label: 'Server Git origin', status: 'pass', detail: inspection.remote });
}
}
if (inspection.nestedGit.length) {
checks.push({ id: 'nested-git', label: 'Nested Git repositories', status: 'warning', detail: `Detected: ${inspection.nestedGit.join(', ')}. ForgeFlow will not delete them automatically.` });
}
if (inspection.dockerfile && !inspection.dockerignore) {
checks.push({ id: 'dockerignore', label: 'Docker build context', status: 'warning', detail: 'A Dockerfile exists but .dockerignore is missing. Add one in the repository before large builds.' });
} else if (inspection.dockerfile && !inspection.dockerignoreGitExcluded) {
checks.push({ id: 'dockerignore-git', label: 'Git metadata excluded from Docker', status: 'warning', detail: '.dockerignore does not explicitly exclude .git.' });
} else if (inspection.dockerfile) {
checks.push({ id: 'dockerignore-git', label: 'Git metadata excluded from Docker', status: 'pass', detail: '.git is excluded from the Docker build context.' });
}
if (inspection.dockerContextExclusionsMissing.length) {
checks.push({ id: 'dockerignore-runtime', label: 'Runtime data excluded from Docker', status: 'warning', detail: `Add these existing runtime or legacy paths to .dockerignore: ${inspection.dockerContextExclusionsMissing.join(', ')}.` });
} else if (inspection.dockerfile && inspection.existingPreservePaths.length) {
checks.push({ id: 'dockerignore-runtime', label: 'Runtime data excluded from Docker', status: 'pass', detail: 'Detected preserved runtime paths are excluded from the Docker build context.' });
}
const composeFile = safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml');
if (inspection.exists && !inspection.composeFiles.includes(composeFile) && !profile.generatedCompose) {
checks.push({ id: 'compose-file', label: 'Compose configuration', status: 'fail', detail: `${composeFile} was not found. Select an existing file or enable generated Compose.` });
} else {
checks.push({ id: 'compose-file', label: 'Compose configuration', status: 'pass', detail: profile.generatedCompose ? 'ForgeFlow will generate an isolated Compose file.' : composeFile });
}
} catch (error) {
checks.push({ id: 'inspection', label: 'Server project inspection', status: 'fail', detail: error.message });
}
checks.push({ id: 'exact-sha', label: 'Exact deployment commit', status: 'pass', detail: targetSha });
return {
provider: 'ssh-unraid',
repository: repository.fullName,
environment: profile.environment,
sha: targetSha,
server: { id: server.id, name: server.name, host: server.host },
remotePath,
inspection,
checks,
summary: checksSummary(checks)
};
}
generatedCompose(profile, repository) {
const service = String(profile.composeService || repository.name || 'app').toLowerCase().replace(/[^a-z0-9_-]/g, '-') || 'app';
if (!profile.hostPort || !profile.containerPort) throw new Error('Host and container ports are required for generated Compose.');
const labels = [
'net.unraid.docker.managed=dockerman',
profile.webUiUrl ? `net.unraid.docker.webui=${profile.webUiUrl}` : '',
profile.iconUrl ? `net.unraid.docker.icon=${profile.iconUrl}` : ''
].filter(Boolean);
return [
'services:',
` ${service}:`,
' build:',
' context: ..',
` container_name: ${service}`,
' restart: unless-stopped',
' ports:',
` - "${profile.hostPort}:${profile.containerPort}"`,
...(labels.length ? [' labels:', ...labels.map((label) => ` - ${JSON.stringify(label)}`)] : [])
].join('\n') + '\n';
}
async checkHealth(url) {
if (!url) return { configured: false, healthy: null, status: null, latencyMs: null };
let last = null;
for (let attempt = 1; attempt <= 5; attempt += 1) {
const started = Date.now();
try {
const response = await fetch(url, { signal: AbortSignal.timeout(8_000), redirect: 'manual' });
last = { configured: true, healthy: response.ok, status: response.status, latencyMs: Date.now() - started };
if (response.ok) return last;
} catch (error) {
last = { configured: true, healthy: false, status: null, latencyMs: Date.now() - started, error: error.message };
}
if (attempt < 5) await new Promise((resolve) => setTimeout(resolve, 3_000));
}
return last;
}
async deploy({ repository, profileId, sha }) {
const targetSha = assertFullCommitSha(sha);
const { profile, server, remotePath } = this.resolve(repository, profileId);
const preflight = await this.preflight({ repository, profileId, sha: targetSha });
if (!preflight.summary.ready) {
const error = new Error(`SSH deployment preflight failed: ${preflight.summary.blocking.join(', ')}`);
error.code = 'SSH_DEPLOYMENT_PREFLIGHT_FAILED';
throw error;
}
const requestId = crypto.randomUUID();
const operation = await this.store.addOperation({
id: requestId,
type: 'deployment',
action: 'deploy',
provider: 'ssh-unraid',
repository: repository.fullName,
environment: profile.environment,
profileId,
serverId: server.id,
remotePath,
sha: targetSha,
shortSha: targetSha.slice(0, 7),
status: 'running',
logs: ['SSH connection verified.', `Deploying exact commit ${targetSha}.`]
});
const cloneUrl = String(profile.cloneUrl || repository.sshUrl || repository.preferredCloneUrl || '').trim();
if (!cloneUrl) throw new Error('No server-usable Git clone URL is configured.');
const composeFile = profile.generatedCompose ? '.forgeflow/compose.forgeflow.yml' : safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml');
const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : '';
const branch = String(profile.branch || 'main');
const statusJson = JSON.stringify({
repository: repository.fullName,
environment: profile.environment,
requested_sha: targetSha,
live_sha: targetSha,
request_id: requestId,
healthy: null,
healthcheck_url_configured: Boolean(profile.healthcheckUrl),
deployed_at: new Date().toISOString()
});
const script = `
root=${shellQuote(remotePath)}
parent=$(dirname "$root")
mkdir -p "$parent"
if [ ! -d "$root" ]; then
git clone --branch ${shellQuote(branch)} --single-branch ${shellQuote(cloneUrl)} "$root"
fi
test -d "$root/.git" || { echo "Existing folder is not a Git working tree" >&2; exit 32; }
${profile.alignRemote ? `git -C "$root" remote set-url origin ${shellQuote(cloneUrl)}` : ''}
changes=$(git -C "$root" status --porcelain --untracked-files=no)
test -z "$changes" || { echo "Tracked server-side changes block deployment" >&2; printf '%s\\n' "$changes" >&2; exit 33; }
git -C "$root" fetch --prune origin ${shellQuote(branch)}
git -C "$root" cat-file -e ${shellQuote(`${targetSha}^{commit}`)}
git -C "$root" merge-base --is-ancestor ${shellQuote(targetSha)} ${shellQuote(`origin/${branch}`)}
previous=$(git -C "$root" rev-parse HEAD 2>/dev/null || true)
git -C "$root" checkout -B ${shellQuote(branch)} ${shellQuote(`origin/${branch}`)}
git -C "$root" reset --hard ${shellQuote(targetSha)}
mkdir -p "$root/.forgeflow"
printf '%s' "$previous" > "$root/.forgeflow/previous-sha"
printf '%s' ${shellQuote(targetSha)} > "$root/.forgeflow/current-sha"
${profile.generatedCompose ? `cat > "$root/.forgeflow/compose.forgeflow.yml" <<'FORGEFLOW_COMPOSE'\n${generated}FORGEFLOW_COMPOSE` : ''}
cd "$root"
docker compose -f ${shellQuote(composeFile)} config >/dev/null
docker compose -f ${shellQuote(composeFile)} up -d --build --remove-orphans
cat > "$root/.forgeflow/status.json" <<'FORGEFLOW_STATUS'
${statusJson}
FORGEFLOW_STATUS
`;
try {
const result = await this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 4 * 1024 * 1024 });
const health = await this.checkHealth(profile.healthcheckUrl);
const finalStatus = health.healthy === false ? 'failed' : 'success';
const finalLogs = [
...operation.logs,
...result.stdout.trim().split('\n').filter(Boolean).slice(-60),
'Docker Compose deployment completed.',
health.configured ? `Healthcheck ${health.healthy ? 'passed' : 'failed'}${health.status ? ` with HTTP ${health.status}` : ''}.` : 'No desktop healthcheck URL configured.'
];
const completed = await this.store.addOperation({
...operation,
status: finalStatus,
previousSha: preflight.inspection?.head || null,
health,
logs: finalLogs,
error: health.healthy === false ? 'The application healthcheck did not pass after deployment.' : null
});
await this.store.saveDeploymentState(profileId, {
liveSha: targetSha,
previousSha: preflight.inspection?.head || null,
healthy: health.healthy,
healthStatus: health.status,
healthLatencyMs: health.latencyMs,
requestId,
remotePath,
provider: 'ssh-unraid'
});
await this.diagnostics?.info('unraid.deployment.completed', {
requestId,
repository: repository.fullName,
serverId: server.id,
remotePath,
sha: targetSha,
healthy: health.healthy,
healthStatus: health.status
});
if (health.healthy === false) {
const error = new Error('Deployment completed, but the configured healthcheck failed. The previous SHA remains available for rollback.');
error.code = 'DEPLOYMENT_HEALTHCHECK_FAILED';
error.operationId = completed.id;
throw error;
}
return completed;
} catch (error) {
if (error.code !== 'DEPLOYMENT_HEALTHCHECK_FAILED') {
await this.store.addOperation({ ...operation, status: 'failed', error: error.message, logs: [...operation.logs, error.message] });
}
await this.diagnostics?.error('unraid.deployment.failed', {
requestId,
repository: repository.fullName,
serverId: server.id,
remotePath,
sha: targetSha,
error
});
throw error;
}
}
async rollback({ repository, profileId, targetSha }) {
const target = assertFullCommitSha(targetSha);
const { profile, server, remotePath } = this.resolve(repository, profileId);
const deploymentState = this.store.getDeploymentState(profileId);
if (!deploymentState?.previousSha || deploymentState.previousSha !== target) {
const error = new Error('Rollback is allowed only to the exact previous SHA reported by ForgeFlow for this deployment profile.');
error.code = 'ROLLBACK_TARGET_NOT_PREVIOUS_SHA';
throw error;
}
if (!repository.localPath) throw new Error('A linked local repository is required for rollback verification.');
await this.git.verifyCommitOnRemoteBranch(repository.localPath, target, profile.branch);
const inspection = await this.inspect({ repository, profileId });
if (!inspection.rootGit) throw new Error('The configured server project is not a root Git working tree.');
if (inspection.trackedChanges.length) throw new Error('Tracked server-side changes block rollback. Commit, revert or migrate them first.');
const composeFile = profile.generatedCompose ? '.forgeflow/compose.forgeflow.yml' : safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml');
const requestId = crypto.randomUUID();
const operation = await this.store.addOperation({
id: requestId,
type: 'deployment',
action: 'rollback',
provider: 'ssh-unraid',
repository: repository.fullName,
environment: profile.environment,
profileId,
serverId: server.id,
remotePath,
sha: target,
shortSha: target.slice(0, 7),
status: 'running',
logs: [`Rolling back to exact commit ${target}.`]
});
const statusJson = JSON.stringify({
repository: repository.fullName,
environment: profile.environment,
requested_sha: target,
live_sha: target,
request_id: requestId,
healthy: null,
healthcheck_url_configured: Boolean(profile.healthcheckUrl),
rollback: true,
deployed_at: new Date().toISOString()
});
const script = `
root=${shellQuote(remotePath)}
test -d "$root/.git"
git -C "$root" fetch --prune origin ${shellQuote(profile.branch)}
git -C "$root" cat-file -e ${shellQuote(`${target}^{commit}`)}
current=$(git -C "$root" rev-parse HEAD)
git -C "$root" reset --hard ${shellQuote(target)}
cd "$root"
docker compose -f ${shellQuote(composeFile)} config >/dev/null
docker compose -f ${shellQuote(composeFile)} up -d --build --remove-orphans
printf '%s' "$current" > "$root/.forgeflow/previous-sha"
printf '%s' ${shellQuote(target)} > "$root/.forgeflow/current-sha"
cat > "$root/.forgeflow/status.json" <<'FORGEFLOW_STATUS'
${statusJson}
FORGEFLOW_STATUS
`;
try {
const result = await this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 4 * 1024 * 1024 });
const health = await this.checkHealth(profile.healthcheckUrl);
const finalStatus = health.healthy === false ? 'failed' : 'rolled-back';
const completed = await this.store.addOperation({
...operation,
status: finalStatus,
previousSha: deploymentState.liveSha || inspection.head || null,
health,
error: health.healthy === false ? 'The application healthcheck did not pass after rollback.' : null,
logs: [
...operation.logs,
...result.stdout.trim().split('\n').filter(Boolean).slice(-60),
'Rollback completed.',
health.configured ? `Healthcheck ${health.healthy ? 'passed' : 'failed'}${health.status ? ` with HTTP ${health.status}` : ''}.` : 'No desktop healthcheck URL configured.'
]
});
await this.store.saveDeploymentState(profileId, {
liveSha: target,
previousSha: deploymentState.liveSha || inspection.head || null,
healthy: health.healthy,
healthStatus: health.status,
healthLatencyMs: health.latencyMs,
requestId,
remotePath,
provider: 'ssh-unraid'
});
if (health.healthy === false) {
const error = new Error('Rollback completed, but the configured healthcheck failed.');
error.code = 'ROLLBACK_HEALTHCHECK_FAILED';
error.operationId = completed.id;
throw error;
}
return completed;
} catch (error) {
if (error.code !== 'ROLLBACK_HEALTHCHECK_FAILED') {
await this.store.addOperation({ ...operation, status: 'failed', error: error.message, logs: [...operation.logs, error.message] });
}
throw error;
}
}
async refreshProfileState(fullName, profileId) {
const repository = { fullName, name: fullName.split('/').pop() };
const { profile, server, remotePath } = this.resolve(repository, profileId);
const script = `
root=${shellQuote(remotePath)}
live=""; previous=""; status=""
[ -f "$root/.forgeflow/current-sha" ] && live=$(cat "$root/.forgeflow/current-sha")
[ -f "$root/.forgeflow/previous-sha" ] && previous=$(cat "$root/.forgeflow/previous-sha")
[ -f "$root/.forgeflow/status.json" ] && status=$(base64 "$root/.forgeflow/status.json" | tr -d '\\r\\n')
printf '__FORGEFLOW_JSON__\\n{"liveSha":"%s","previousSha":"%s","statusBase64":"%s"}\\n' "$live" "$previous" "$status"
`;
const result = await this.ssh.exec(server.id, bash(script), { timeout: 30_000 });
const raw = parseInspection(result.stdout);
let remoteStatus = null;
try { remoteStatus = raw.statusBase64 ? JSON.parse(Buffer.from(raw.statusBase64, 'base64').toString('utf8')) : null; } catch {}
const existing = this.store.getDeploymentState(profile.id) || {};
return this.store.saveDeploymentState(profile.id, {
liveSha: /^[0-9a-f]{40}$/i.test(raw.liveSha || '') ? raw.liveSha : null,
previousSha: /^[0-9a-f]{40}$/i.test(raw.previousSha || '') ? raw.previousSha : null,
healthy: remoteStatus?.healthy ?? existing.healthy ?? null,
healthStatus: existing.healthStatus ?? null,
healthLatencyMs: existing.healthLatencyMs ?? null,
requestId: remoteStatus?.request_id || existing.requestId || null,
remotePath,
provider: 'ssh-unraid'
});
}
}
module.exports = {
UnraidDeploymentService,
safeRemoteFolder,
safeRelativeRemoteFile,
parseInspection,
dockerIgnoreHasPath,
checksSummary,
bash
};
+135
View File
@@ -0,0 +1,135 @@
'use strict';
const fs = require('node:fs/promises');
const path = require('node:path');
const crypto = require('node:crypto');
const { spawn } = require('node:child_process');
const { isNewerVersion } = require('../shared/semver.cjs');
function safeRepositoryPart(value, label) {
const text = String(value || '').trim();
if (!/^[a-zA-Z0-9_.-]+$/.test(text)) throw new Error(`${label} contains unsupported characters.`);
return text;
}
class UpdateService {
constructor({ store, gitea, diagnostics, appInfo, sourcePath, userDataPath }) {
this.store = store;
this.gitea = gitea;
this.diagnostics = diagnostics;
this.appInfo = appInfo;
this.sourcePath = sourcePath;
this.updateDirectory = path.join(userDataPath, 'updates');
this.staged = null;
}
async check() {
const settings = this.store.data.updates || {};
const owner = safeRepositoryPart(settings.owner || 'Jens', 'Update repository owner');
const repo = safeRepositoryPart(settings.repo || 'ForgeFlow', 'Update repository name');
const branchName = String(settings.branch || 'main').trim();
const branch = await this.gitea.getBranch(owner, repo, branchName);
const remoteSha = branch?.commit?.id || branch?.commit?.sha || branch?.commit?.commit?.id;
if (!/^[0-9a-f]{40}$/i.test(String(remoteSha || ''))) throw new Error('Gitea did not return a full commit SHA for the update branch.');
const file = await this.gitea.getRepositoryFile({ owner, repo, filePath: 'package.json', ref: remoteSha });
let manifest;
try { manifest = JSON.parse(file.decoded); }
catch { throw new Error('The remote ForgeFlow package.json is not valid JSON.'); }
if (manifest.name !== 'forgeflow') throw new Error('The configured update repository is not a ForgeFlow source repository.');
const remoteVersion = String(manifest.version || '').trim();
const currentVersion = String(this.appInfo.version || '').trim();
const available = isNewerVersion(remoteVersion, currentVersion);
const result = {
checkedAt: new Date().toISOString(),
owner,
repo,
branch: branchName,
currentVersion,
remoteVersion,
remoteSha,
shortSha: remoteSha.slice(0, 7),
available,
packaged: Boolean(this.appInfo.packaged),
mode: this.appInfo.packaged ? 'packaged' : 'source'
};
this.store.data.updates.lastCheckedAt = result.checkedAt;
await this.store.save();
await this.diagnostics?.info('updates.checked', {
repository: `${owner}/${repo}`,
branch: branchName,
currentVersion,
remoteVersion,
remoteSha,
available,
mode: result.mode
});
return result;
}
async download(expected = null) {
const update = expected?.remoteSha ? expected : await this.check();
if (!update.available) return { ...update, downloaded: false, reason: 'up-to-date' };
if (this.appInfo.packaged) {
const error = new Error('This developer release uses source updates. Install a signed packaged release before using binary auto-update.');
error.code = 'PACKAGED_UPDATE_NOT_CONFIGURED';
throw error;
}
await fs.mkdir(this.updateDirectory, { recursive: true });
const archiveUrl = `${this.store.data.gitea.baseUrl.replace(/\/+$/, '')}/${encodeURIComponent(update.owner)}/${encodeURIComponent(update.repo)}/archive/${update.remoteSha}.zip`;
const archive = await this.gitea.downloadAuthenticated(archiveUrl);
if (archive.length < 1000 || archive[0] !== 0x50 || archive[1] !== 0x4b) throw new Error('The downloaded update is not a valid ZIP archive.');
const sha256 = crypto.createHash('sha256').update(archive).digest('hex');
const archivePath = path.join(this.updateDirectory, `ForgeFlow-${update.remoteVersion}-${update.shortSha}.zip`);
const metadataPath = `${archivePath}.json`;
await fs.writeFile(archivePath, archive, { mode: 0o600 });
const metadata = { ...update, archivePath, sha256, downloadedAt: new Date().toISOString() };
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
this.staged = metadata;
await this.diagnostics?.info('updates.downloaded', {
remoteVersion: update.remoteVersion,
remoteSha: update.remoteSha,
bytes: archive.length,
sha256
});
return { ...metadata, downloaded: true };
}
async apply(staged = null) {
const update = staged?.archivePath ? staged : this.staged;
if (!update?.archivePath) throw new Error('Download an update before applying it.');
if (process.platform !== 'win32') throw new Error('The integrated source updater currently supports Windows only.');
const stat = await fs.stat(update.archivePath).catch(() => null);
if (!stat?.isFile()) throw new Error('The staged update archive is no longer available.');
const scriptPath = path.join(this.sourcePath, 'scripts', 'apply-source-update.ps1');
const scriptStat = await fs.stat(scriptPath).catch(() => null);
if (!scriptStat?.isFile()) throw new Error('The source update helper is missing.');
const logPath = path.join(this.updateDirectory, `apply-${Date.now()}.log`);
const args = [
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath,
'-SourcePath', this.sourcePath,
'-ArchivePath', update.archivePath,
'-ExpectedVersion', update.remoteVersion,
'-ExpectedSha256', update.sha256,
'-ParentPid', String(process.pid),
'-LogPath', logPath
];
const child = spawn('powershell.exe', args, {
detached: true,
stdio: 'ignore',
windowsHide: false,
cwd: this.sourcePath
});
child.unref();
await this.diagnostics?.info('updates.apply-launched', {
remoteVersion: update.remoteVersion,
remoteSha: update.remoteSha,
logPath
});
return { launched: true, version: update.remoteVersion, logPath };
}
}
module.exports = { UpdateService, safeRepositoryPart };
+1055
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark light" />
<title>ForgeFlow</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div id="app" aria-live="polite">
<div class="boot-screen">
<div class="brand-mark">F</div>
<strong>Starting ForgeFlow</strong>
<span>Checking Git and local configuration…</span>
</div>
</div>
<div id="toast-root" class="toast-root" aria-live="assertive"></div>
<script src="mock-bridge.js"></script>
<script defer src="app.js"></script>
</body>
</html>
+350
View File
@@ -0,0 +1,350 @@
(() => {
if (window.forgeflow) return;
const wait = (ms = 180) => new Promise((resolve) => setTimeout(resolve, ms));
const clone = (value) => JSON.parse(JSON.stringify(value));
const iso = (offset = 0) => new Date(Date.now() + offset).toISOString();
const storage = {
get(key) { try { return localStorage.getItem(key); } catch { return null; } },
set(key, value) { try { localStorage.setItem(key, value); } catch {} }
};
const repositoryListeners = new Set();
const operationListeners = new Set();
const updateListeners = new Set();
const emitRepositories = () => repositoryListeners.forEach((listener) => listener({ reason: 'demo-change' }));
const emitOperations = (operations) => operationListeners.forEach((listener) => listener({ operations: clone(operations) }));
const randomSha = () => `${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`.padEnd(40, 'a').slice(0, 40);
const makeStatus = ({ head, branch = 'main', ahead = 0, behind = 0, upstream = `origin/${branch}`, files = [] }) => ({
branch: { oid: head, head: branch, upstream, ahead, behind },
files,
counts: {
changed: files.length,
staged: files.filter((item) => item.staged).length,
unstaged: files.filter((item) => item.unstaged).length,
conflicts: files.filter((item) => item.conflict).length,
untracked: files.filter((item) => item.untracked).length
},
clean: files.length === 0,
root: '',
remoteUrl: '',
head,
shortHead: head.slice(0, 7),
fingerprint: `${head}:${branch}:${ahead}:${behind}:${files.map((item) => `${item.path}:${item.indexCode}${item.worktreeCode}`).join('|')}`
});
const makeFile = (path, status = 'modified', options = {}) => ({
path,
originalPath: options.originalPath || null,
indexCode: options.staged ? (status === 'added' ? 'A' : status === 'deleted' ? 'D' : 'M') : '.',
worktreeCode: options.staged ? '.' : status === 'untracked' ? '?' : status === 'deleted' ? 'D' : status === 'conflict' ? 'U' : 'M',
staged: Boolean(options.staged),
unstaged: !options.staged,
untracked: status === 'untracked',
conflict: status === 'conflict',
status
});
const profile = (id, name, environment, options = {}) => ({
id,
name,
environment,
provider: 'gitea-actions',
branch: options.branch || 'main',
workflowFile: options.workflowFile || 'deploy.yml',
rollbackWorkflowFile: options.rollbackWorkflowFile ?? 'rollback.yml',
healthcheckUrl: options.healthcheckUrl || `https://${environment}.internal/health`,
statusUrl: options.statusUrl || `https://${environment}.internal/.well-known/forgeflow`,
confirmationRequired: options.confirmationRequired !== false,
inputs: {},
state: {
liveSha: options.liveSha || null,
previousSha: options.previousSha || null,
healthy: options.healthy ?? null,
healthConfigured: true,
statusConfigured: true,
healthStatus: options.healthy === false ? 503 : 200,
healthLatencyMs: 42,
checkedAt: options.checkedAt || iso(-120000)
}
});
const now = iso();
const defaultPreferences = {
autoRefresh: true,
repositoryPollSeconds: 4,
operationPollSeconds: 5,
fetchIntervalMinutes: 10,
preferredCloneProtocol: 'https',
diagnosticsEnabled: true,
diagnosticLevel: 'info',
logRetentionDays: 14,
maxLogFileMb: 8
};
let state = {
schemaVersion: 5,
setupComplete: storage.get('forgeflow-demo-setup') !== 'false',
appearance: storage.get('forgeflow-theme') || 'dark',
gitea: { baseUrl: 'https://gitea.internal', user: { login: 'jens', full_name: 'Jens' }, hasToken: true },
workspaceRoots: ['C:\\Development'],
repositoryMappings: {},
deploymentProfiles: {},
deploymentStates: {},
favorites: ['jens/microsoft-cloud-operations-platform', 'jens/unraid-appops-gateway'],
updates: { owner: 'Jens', repo: 'ForgeFlow', branch: 'main', autoCheck: true, lastCheckedAt: null },
servers: [{ id: 'server-unraid', name: 'Unraid', host: '192.168.1.10', port: 22, username: 'root', authType: 'privateKey', basePath: '/mnt/user/appdata', privateKeyPath: 'C:\\Users\\Jens\\.ssh\\id_ed25519', hostFingerprint: 'SHA256:demo', hasPassword: false, hasPassphrase: false }],
preferences: { ...defaultPreferences },
operations: [
{
id: 'op-success', type: 'deployment', action: 'deploy', status: 'success',
repository: 'jens/microsoft-cloud-operations-platform', profileId: 'profile-mcop-prod', profileName: 'Production',
environment: 'production', workflowFile: 'deploy.yml', branch: 'main',
sha: 'b82f91ab0173cd4346ca0f0f7dcc3e8182cc8fd0', shortSha: 'b82f91a', createdAt: now, updatedAt: now,
stages: [
{ id: 'requested', label: 'Requested', status: 'complete' }, { id: 'verified', label: 'Verified', status: 'complete' },
{ id: 'queued', label: 'Workflow queued', status: 'complete' }, { id: 'runner', label: 'Runner execution', status: 'complete' },
{ id: 'healthcheck', label: 'Healthcheck', status: 'complete' }, { id: 'complete', label: 'Complete', status: 'complete' }
],
logs: ['[info] Exact commit verified.', '[job] deploy: success', '[ok] Server reports b82f91a and healthcheck returned 200.'],
run: { id: 48, runNumber: 48, status: 'completed', conclusion: 'success', name: 'ForgeFlow deployment' },
runUrl: 'https://gitea.internal/jens/microsoft-cloud-operations-platform/actions/runs/48'
},
{
id: 'op-failed', type: 'deployment', action: 'deploy', status: 'failed',
repository: 'jens/portfolio', profileId: 'profile-portfolio', profileName: 'Production', environment: 'production',
workflowFile: 'deploy.yml', branch: 'main', sha: 'a7f2e1c1bb6147fc8b6633d2b08500c93402a719', shortSha: 'a7f2e1c',
createdAt: iso(-86400000), updatedAt: iso(-86300000),
failure: { stage: 'healthcheck', message: 'Healthcheck returned 502.' },
stages: [
{ id: 'requested', label: 'Requested', status: 'complete' }, { id: 'verified', label: 'Verified', status: 'complete' },
{ id: 'queued', label: 'Workflow queued', status: 'complete' }, { id: 'runner', label: 'Runner execution', status: 'complete' },
{ id: 'healthcheck', label: 'Healthcheck', status: 'failed' }, { id: 'complete', label: 'Complete', status: 'failed' }
],
logs: ['[job] deploy: success', '[error] Healthcheck returned 502.']
}
]
};
let repositories = [
{
id: 1, name: 'microsoft-cloud-operations-platform', fullName: 'jens/microsoft-cloud-operations-platform', owner: { login: 'jens' },
description: 'Tenant-aware Microsoft cloud operations console.', private: true, defaultBranch: 'main',
htmlUrl: 'https://gitea.internal/jens/microsoft-cloud-operations-platform', cloneUrl: 'https://gitea.internal/jens/microsoft-cloud-operations-platform.git', sshUrl: 'git@gitea.internal:jens/microsoft-cloud-operations-platform.git', updatedAt: now,
localPath: 'C:\\Development\\Microsoft-Cloud-Operations-Platform',
localStatus: makeStatus({ head: 'b82f91ab0173cd4346ca0f0f7dcc3e8182cc8fd0' }), linkState: 'linked',
deploymentProfiles: [
profile('profile-mcop-prod', 'Production', 'production', { liveSha: '72bd10eb0173cd4346ca0f0f7dcc3e8182cc8fd0', previousSha: '6ac991ab0173cd4346ca0f0f7dcc3e8182cc8fd0', healthy: true }),
profile('profile-mcop-stage', 'Staging', 'staging', { liveSha: 'b82f91ab0173cd4346ca0f0f7dcc3e8182cc8fd0', previousSha: '72bd10eb0173cd4346ca0f0f7dcc3e8182cc8fd0', healthy: true, confirmationRequired: false })
]
},
{
id: 2, name: 'vacancyradar', fullName: 'jens/vacancyradar', owner: { login: 'jens' }, description: 'Local-first vacancy intelligence cockpit.', private: true, defaultBranch: 'main',
htmlUrl: 'https://gitea.internal/jens/vacancyradar', cloneUrl: 'https://gitea.internal/jens/vacancyradar.git', sshUrl: 'git@gitea.internal:jens/vacancyradar.git', updatedAt: now,
localPath: 'C:\\Development\\VacancyRadar',
localStatus: makeStatus({ head: 'c9182d0d28318c8cf0af109edc054732426aadf1', branch: 'feature/deployment-api', files: [makeFile('src/api/deploy.ts', 'added', { staged: true }), makeFile('src/main.tsx'), makeFile('src/components/Sidebar.tsx')] }),
linkState: 'linked', deploymentProfiles: [profile('profile-vr', 'Production', 'production', { liveSha: 'c117ab9d28318c8cf0af109edc054732426aadf1', previousSha: 'b1f57aad28318c8cf0af109edc054732426aadf1', healthy: true })]
},
{
id: 3, name: 'unraid-appops-gateway', fullName: 'jens/unraid-appops-gateway', owner: { login: 'jens' }, description: 'Safe operations gateway for Unraid and Portainer.', private: true, defaultBranch: 'main',
htmlUrl: 'https://gitea.internal/jens/unraid-appops-gateway', cloneUrl: 'https://gitea.internal/jens/unraid-appops-gateway.git', sshUrl: 'git@gitea.internal:jens/unraid-appops-gateway.git', updatedAt: now,
localPath: 'C:\\Development\\Unraid-AppOps-Gateway', localStatus: makeStatus({ head: 'f2d1e0a1bb6147fc8b6633d2b08500c93402a719', ahead: 2 }), linkState: 'linked',
deploymentProfiles: [profile('profile-appops', 'Production', 'production', { liveSha: '8ac731b1bb6147fc8b6633d2b08500c93402a719', previousSha: '7bc198a1bb6147fc8b6633d2b08500c93402a719', healthy: true })]
},
{
id: 4, name: 'support-bundle-collector', fullName: 'jens/support-bundle-collector', owner: { login: 'jens' }, description: 'Privacy-aware Windows support bundle collector.', private: true, defaultBranch: 'main',
htmlUrl: 'https://gitea.internal/jens/support-bundle-collector', cloneUrl: 'https://gitea.internal/jens/support-bundle-collector.git', sshUrl: 'git@gitea.internal:jens/support-bundle-collector.git', updatedAt: now,
localPath: null, localStatus: null, linkState: 'remote-only', deploymentProfiles: []
},
{
id: 5, name: 'portfolio', fullName: 'jens/portfolio', owner: { login: 'jens' }, description: 'Professional infrastructure and automation portfolio.', private: false, defaultBranch: 'main',
htmlUrl: 'https://gitea.internal/jens/portfolio', cloneUrl: 'https://gitea.internal/jens/portfolio.git', sshUrl: 'git@gitea.internal:jens/portfolio.git', updatedAt: now,
localPath: 'C:\\Development\\portfolio', localStatus: makeStatus({ head: 'a7f2e1c1bb6147fc8b6633d2b08500c93402a719', behind: 1 }), linkState: 'linked',
deploymentProfiles: [profile('profile-portfolio', 'Production', 'production', { liveSha: '4c20dd11bb6147fc8b6633d2b08500c93402a719', previousSha: '31adfe11bb6147fc8b6633d2b08500c93402a719', healthy: false })]
}
];
const diffs = {
'src/api/deploy.ts': `diff --git a/src/api/deploy.ts b/src/api/deploy.ts\nnew file mode 100644\n--- /dev/null\n+++ b/src/api/deploy.ts\n@@ -0,0 +1,18 @@\n+export interface DeploymentRequest {\n+ environment: 'staging' | 'production';\n+ commitSha: string;\n+}\n+\n+export async function deploy(request: DeploymentRequest) {\n+ return api.post('/deployments', request);\n+}`,
'src/main.tsx': `diff --git a/src/main.tsx b/src/main.tsx\nindex 45ad1a2..939fc17 100644\n--- a/src/main.tsx\n+++ b/src/main.tsx\n@@ -24,8 +24,9 @@ import { Router } from './routes';\n-const API_ENDPOINT = 'http://localhost:3000';\n+const API_ENDPOINT = process.env.VITE_API_URL || '/api';\n+const DEPLOY_VERSION = '1.0.4-rc1';`,
'src/components/Sidebar.tsx': `diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx\nindex a7bbd82..bf21e90 100644\n--- a/src/components/Sidebar.tsx\n+++ b/src/components/Sidebar.tsx\n@@ -31,6 +31,7 @@ export function Sidebar() {\n+ <NavItem to="/deployments">Deployments</NavItem>`
};
const findRepo = (localPath) => repositories.find((item) => item.localPath === localPath);
const findProfileRepo = (profileId) => repositories.find((item) => item.deploymentProfiles.some((entry) => entry.id === profileId));
const syncState = () => {
state.deploymentProfiles = {};
state.deploymentStates = {};
state.repositoryMappings = {};
for (const repository of repositories) {
if (repository.localPath) state.repositoryMappings[repository.fullName.toLowerCase()] = repository.localPath;
state.deploymentProfiles[repository.fullName.toLowerCase()] = repository.deploymentProfiles.map(({ state: profileState, ...entry }) => entry);
for (const entry of repository.deploymentProfiles) if (entry.state) state.deploymentStates[entry.id] = clone(entry.state);
}
};
const recompute = (repository) => {
const status = repository.localStatus;
if (status) {
status.counts = {
changed: status.files.length,
staged: status.files.filter((item) => item.staged).length,
unstaged: status.files.filter((item) => item.unstaged).length,
conflicts: status.files.filter((item) => item.conflict).length,
untracked: status.files.filter((item) => item.untracked).length
};
status.clean = status.files.length === 0;
status.shortHead = status.head.slice(0, 7);
status.branch.oid = status.head;
}
repository.favorite = state.favorites.includes(repository.fullName.toLowerCase());
repository.readyToDeploy = Boolean(repository.localPath && status?.clean && status.branch.upstream && status.branch.ahead === 0 && status.branch.behind === 0 && repository.deploymentProfiles.some((entry) => entry.branch === status.branch.head));
repository.attention = !repository.localPath || Boolean(status?.counts.conflicts || status?.branch.behind || status?.branch.ahead || status?.counts.changed);
repository.attentionReason = !repository.localPath ? 'No local folder linked' : status?.counts.conflicts ? `${status.counts.conflicts} conflict(s)` : status?.counts.changed ? `${status.counts.changed} local change(s)` : status?.branch.behind ? `${status.branch.behind} commit(s) behind remote` : status?.branch.ahead ? `${status.branch.ahead} unpushed commit(s)` : null;
repository.preferredCloneUrl = state.preferences.preferredCloneProtocol === 'ssh' ? repository.sshUrl : repository.cloneUrl;
};
const snapshot = () => { repositories.forEach(recompute); syncState(); return clone(repositories); };
syncState();
const commitHistory = [
{ sha: 'c9182d0d28318c8cf0af109edc054732426aadf1', shortSha: 'c9182d0', author: 'Jens', date: now, subject: 'feat: add deployment provider contract' },
{ sha: '1fa7399d28318c8cf0af109edc054732426aadf1', shortSha: '1fa7399', author: 'Jens', date: iso(-86400000), subject: 'refactor: consolidate repository state' },
{ sha: 'a251a11d28318c8cf0af109edc054732426aadf1', shortSha: 'a251a11', author: 'Jens', date: iso(-172800000), subject: 'docs: define deployment safety gates' }
];
const branchesByRepo = new Map();
const stashesByRepo = new Map();
function updateOperation(operation) {
state.operations = [clone(operation), ...state.operations.filter((item) => item.id !== operation.id)].slice(0, 250);
emitOperations([operation]);
return clone(operation);
}
function advanceOperation(operation) {
if (!operation || ['success', 'failed', 'cancelled', 'rolled-back'].includes(operation.status)) return operation;
operation.demoPolls = (operation.demoPolls || 0) + 1;
if (operation.demoPolls === 1) {
operation.status = 'running';
operation.run = { id: 81, runNumber: 81, status: 'running', conclusion: null, name: operation.action === 'rollback' ? 'ForgeFlow rollback' : 'ForgeFlow deployment' };
operation.runUrl = `https://gitea.internal/${operation.repository}/actions/runs/81`;
operation.stages.find((item) => item.id === 'queued').status = 'complete';
operation.stages.find((item) => item.id === 'runner').status = 'active';
operation.jobs = [{ id: 201, name: operation.action === 'rollback' ? 'rollback' : 'deploy', status: 'running', conclusion: null }];
operation.logs.push(`[job] ${operation.jobs[0].name}: running`);
} else if (operation.demoPolls >= 2) {
operation.status = operation.action === 'rollback' ? 'rolled-back' : 'success';
operation.stages.forEach((item) => { item.status = 'complete'; });
operation.jobs = [{ id: 201, name: operation.action === 'rollback' ? 'rollback' : 'deploy', status: 'completed', conclusion: 'success' }];
operation.logs.push('[ok] Runner completed successfully.', `[ok] Server status endpoint confirms ${operation.shortSha}.`);
const repository = repositories.find((item) => item.fullName === operation.repository);
const targetProfile = repository?.deploymentProfiles.find((item) => item.id === operation.profileId);
if (targetProfile) {
const oldLive = targetProfile.state.liveSha;
targetProfile.state.previousSha = oldLive;
targetProfile.state.liveSha = operation.sha;
targetProfile.state.healthy = true;
targetProfile.state.checkedAt = iso();
}
}
operation.updatedAt = iso();
return operation;
}
window.forgeflow = Object.freeze({
async bootstrap() { await wait(80); snapshot(); return { appVersion: '0.4.0-demo', platform: 'win32', state: clone(state), git: { available: true, version: 'git version 2.47.3' }, diagnostics: { enabled: true, level: state.preferences.diagnosticLevel, retentionDays: state.preferences.logRetentionDays, maxFileMb: state.preferences.maxLogFileMb, directory: '<HOME>/AppData/Roaming/ForgeFlow/diagnostics', fileCount: 2, totalBytes: 18432, totalSize: '18.0 KB', latestAt: iso(-2000), lastWriteError: null } }; },
async selectDirectory() { await wait(); return 'C:\\Development'; },
async selectKeyFile() { await wait(); return 'C:\\Users\\Jens\\.ssh\\id_ed25519'; },
async setupPreflight({ baseUrl, token, roots = [] }) { await wait(240); const checks = [
{ id: 'git.available', label: 'Git command line', status: 'pass', detail: 'git version 2.47.3', required: true },
{ id: 'git.identity', label: 'Git author identity', status: 'pass', detail: 'Jens <jens@example.invalid>', required: false },
{ id: 'storage.userdata', label: 'Application data storage', status: 'pass', detail: 'ForgeFlow can write its local configuration.', required: true },
{ id: 'storage.diagnostics', label: 'Diagnostic log storage', status: 'pass', detail: 'The diagnostic directory is writable.', required: true },
{ id: 'storage.credentials', label: 'Protected credential storage', status: 'pass', detail: 'The operating system can encrypt the Gitea token at rest.', required: false },
{ id: 'workspace.roots', label: 'Development folders', status: roots.length ? 'pass' : 'warning', detail: roots.length ? `${roots.length} folder(s) selected.` : 'No development folder selected yet.', required: false },
{ id: 'gitea.connection', label: 'Gitea connection', status: baseUrl && token ? 'pass' : 'warning', detail: baseUrl && token ? 'Connection parameters are ready for validation.' : 'Enter the Gitea URL and token.', required: false }
]; return { kind: 'system', startedAt: iso(-100), completedAt: iso(), checks, summary: { counts: { pass: checks.filter(i=>i.status==='pass').length, warning: checks.filter(i=>i.status==='warning').length, fail: 0, skipped: 0 }, blocking: [], ready: true } }; },
async validateGitea({ baseUrl, token }) { await wait(320); if (!baseUrl || !token) throw new Error('Enter an instance URL and access token.'); return { baseUrl: baseUrl.replace(/\/$/, ''), user: { login: 'jens', full_name: 'Jens' }, repositoryCount: repositories.length, version: '1.26.0' }; },
async completeSetup(payload) { await wait(300); state.setupComplete = true; state.gitea = { baseUrl: payload.baseUrl, user: payload.user, hasToken: true }; state.workspaceRoots = payload.workspaceRoots; storage.set('forgeflow-demo-setup', 'true'); return { state: clone(state), tokenState: { persistent: true } }; },
async updateGitea(payload) { const validation = await this.validateGitea({ ...payload, token: payload.token || 'preserved-demo-token' }); state.gitea = { baseUrl: validation.baseUrl, user: validation.user, hasToken: true }; return { validation, tokenState: { persistent: true, preserved: !payload.token }, state: clone(state) }; },
async setWorkspaceRoots(roots) { state.workspaceRoots = [...new Set(roots)]; return clone(state); },
async setAppearance(appearance) { state.appearance = appearance; storage.set('forgeflow-theme', appearance); return clone(state); },
async setPreferences(preferences) { state.preferences = { ...state.preferences, ...preferences }; snapshot(); return clone(state); },
async setUpdatePreferences(updates) { state.updates = { ...state.updates, ...updates }; return clone(state); },
async checkForUpdates() { await wait(300); return { checkedAt: iso(), owner: state.updates.owner, repo: state.updates.repo, branch: state.updates.branch, currentVersion: '0.4.0', remoteVersion: '0.4.1', remoteSha: 'a'.repeat(40), shortSha: 'aaaaaaa', available: true, mode: 'source' }; },
async downloadUpdate() { await wait(500); return { ...(await this.checkForUpdates()), downloaded: true, archivePath: 'C:\\Temp\\ForgeFlow-0.4.1.zip', sha256: 'b'.repeat(64) }; },
async applyUpdate() { await wait(200); return { launched: true, version: '0.4.1' }; },
async saveServer(server) { const saved = { ...server, id: server.id || `server-${Date.now()}`, hasPassword: server.authType === 'password', hasPassphrase: false }; state.servers = [saved, ...state.servers.filter((item) => item.id !== saved.id)]; return { server: clone(saved), state: clone(state) }; },
async deleteServer(serverId) { state.servers = state.servers.filter((item) => item.id !== serverId); return clone(state); },
async testServer(serverId) { const server = state.servers.find((item) => item.id === serverId); server.hostFingerprint = server.hostFingerprint || 'SHA256:demo'; return { connected: true, fingerprint: server.hostFingerprint, server: clone(server), output: 'Linux\n/usr/bin/git\nDocker Compose version v2', state: clone(state) }; },
async inspectServerProject() { return { exists: true, rootGit: true, head: 'd42d4a7'.padEnd(40,'0'), branch: 'main', trackedChanges: [], composeFiles: ['docker-compose.yml'], nestedGit: ['source'], dockerfile: true }; },
async refreshRepositories() { await wait(260); return snapshot(); },
async discoverRepositories() { await wait(360); return snapshot().filter((repo) => repo.localPath).map((repo) => ({ localPath: repo.localPath, remoteUrl: repo.cloneUrl, status: repo.localStatus })); },
async favoriteRepository(fullName, favorite) { const key = fullName.toLowerCase(); state.favorites = favorite ? [...new Set([...state.favorites, key])] : state.favorites.filter((item) => item !== key); snapshot(); return clone(state); },
async linkRepository(fullName, localPath) { const repo = repositories.find((item) => item.fullName === fullName); repo.localPath = localPath; repo.linkState = 'linked'; repo.localStatus = makeStatus({ head: randomSha() }); emitRepositories(); return snapshot(); },
async unlinkRepository(fullName) { const repo = repositories.find((item) => item.fullName === fullName); repo.localPath = null; repo.localStatus = null; repo.linkState = 'remote-only'; emitRepositories(); return snapshot(); },
async repositoryStatus(localPath) { return clone(findRepo(localPath)?.localStatus); },
async repositoryDiff(localPath, filePath) { await wait(80); return diffs[filePath] || `diff --git a/${filePath} b/${filePath}\n--- a/${filePath}\n+++ b/${filePath}\n@@ -1 +1 @@\n-old\n+new`; },
async stageFiles(localPath, files) { const repo = findRepo(localPath); repo.localStatus.files.forEach((item) => { if (!files?.length || files.includes(item.path)) { item.staged = true; item.unstaged = false; item.indexCode = item.untracked ? 'A' : 'M'; item.worktreeCode = '.'; } }); recompute(repo); emitRepositories(); return clone(repo.localStatus); },
async unstageFiles(localPath, files) { const repo = findRepo(localPath); repo.localStatus.files.forEach((item) => { if (!files?.length || files.includes(item.path)) { item.staged = false; item.unstaged = true; item.indexCode = '.'; item.worktreeCode = item.untracked ? '?' : 'M'; } }); recompute(repo); emitRepositories(); return clone(repo.localStatus); },
async commit(localPath, message, files) { await wait(520); if (!message?.trim()) throw new Error('Enter a commit message.'); const repo = findRepo(localPath); repo.localStatus.files = repo.localStatus.files.filter((item) => !files?.includes(item.path)); repo.localStatus.head = randomSha(); repo.localStatus.branch.ahead += 1; recompute(repo); emitRepositories(); return { commitOutput: `[${repo.localStatus.branch.head} ${repo.localStatus.shortHead}] ${message}`, commitSha: repo.localStatus.head, status: clone(repo.localStatus) }; },
async commitAndPush(localPath, message, files) { const result = await this.commit(localPath, message, files); const repo = findRepo(localPath); await wait(240); repo.localStatus.branch.ahead = 0; recompute(repo); emitRepositories(); return { ...result, pushOutput: 'Push completed.', status: clone(repo.localStatus) }; },
async push(localPath) { await wait(360); const repo = findRepo(localPath); repo.localStatus.branch.ahead = 0; recompute(repo); emitRepositories(); return { output: 'Push completed.', status: clone(repo.localStatus) }; },
async fetch() { await wait(260); return { output: 'Fetch completed.' }; },
async pull(localPath) { await wait(380); const repo = findRepo(localPath); repo.localStatus.branch.behind = 0; recompute(repo); emitRepositories(); return { output: 'Fast-forwarded.', status: clone(repo.localStatus) }; },
async history() { await wait(100); return clone(commitHistory); },
async branches(localPath) { const repo = findRepo(localPath); if (!branchesByRepo.has(localPath)) branchesByRepo.set(localPath, [{ name: repo.localStatus.branch.head, current: true, sha: repo.localStatus.head, shortSha: repo.localStatus.shortHead, upstream: repo.localStatus.branch.upstream }, { name: 'main', current: repo.localStatus.branch.head === 'main', sha: repo.localStatus.head, shortSha: repo.localStatus.shortHead, upstream: 'origin/main' }]); return clone(branchesByRepo.get(localPath)); },
async checkoutBranch(localPath, branch) { const repo = findRepo(localPath); if (!repo.localStatus.clean) throw new Error('Commit or stash local changes before switching branches.'); const list = await this.branches(localPath); list.forEach((item) => { item.current = item.name === branch; }); branchesByRepo.set(localPath, list); repo.localStatus.branch.head = branch; repo.localStatus.branch.upstream = `origin/${branch}`; recompute(repo); emitRepositories(); return { status: clone(repo.localStatus), branches: clone(list) }; },
async createBranch(localPath, branch) { const repo = findRepo(localPath); const list = await this.branches(localPath); list.forEach((item) => { item.current = false; }); list.unshift({ name: branch, current: true, sha: repo.localStatus.head, shortSha: repo.localStatus.shortHead, upstream: null }); branchesByRepo.set(localPath, list); repo.localStatus.branch.head = branch; repo.localStatus.branch.upstream = null; recompute(repo); emitRepositories(); return { status: clone(repo.localStatus), branches: clone(list) }; },
async stash(localPath, message) { const repo = findRepo(localPath); const list = stashesByRepo.get(localPath) || []; list.unshift({ ref: `stash@{${list.length}}`, subject: message || 'ForgeFlow stash', date: iso() }); stashesByRepo.set(localPath, list); repo.localStatus.files = []; recompute(repo); emitRepositories(); return { output: 'Saved working directory and index state.', status: clone(repo.localStatus), stashes: clone(list) }; },
async stashList(localPath) { return clone(stashesByRepo.get(localPath) || []); },
async popStash(localPath, ref) { const repo = findRepo(localPath); const list = stashesByRepo.get(localPath) || []; const index = list.findIndex((item) => item.ref === ref); if (index < 0) throw new Error('Stash not found.'); list.splice(index, 1); stashesByRepo.set(localPath, list); repo.localStatus.files = [makeFile('src/restored-from-stash.ts')]; recompute(repo); emitRepositories(); return { output: 'Stash applied.', status: clone(repo.localStatus), stashes: clone(list) }; },
async cloneRepository(fullName, mode = 'default') {
await wait(620);
const repository = repositories.find((item) => item.fullName === fullName);
if (!repository) throw new Error('Repository not found.');
if (repository.localPath) throw new Error('This repository already has a linked local folder.');
const root = mode === 'custom' ? 'D:\\OtherProjects' : state.workspaceRoots[0];
if (!root) return { cancelled: true };
const target = `${root.replace(/[\\/]+$/, '')}\\${repository.name}`;
const head = randomSha();
repository.localPath = target;
repository.localStatus = makeStatus({ head, branch: repository.defaultBranch || 'main' });
repository.localStatus.root = target;
repository.localStatus.remoteUrl = repository.preferredCloneUrl || repository.cloneUrl;
repository.linkState = 'linked';
recompute(repository);
const current = snapshot();
emitRepositories();
return { target, status: clone(repository.localStatus), reused: false, repositories: current, state: clone(state) };
},
async openPath() { return true; },
async openExternal() { return true; },
async saveDeploymentProfile(fullName, input) { const repo = repositories.find((item) => item.fullName === fullName); const existing = repo.deploymentProfiles.find((item) => item.id === input.id); const saved = { ...(existing || profile(input.id || `profile-${Date.now()}`, input.name || input.environment, input.environment || 'production')), ...input, id: input.id || `profile-${Date.now()}`, provider: 'gitea-actions', inputs: existing?.inputs || {}, state: existing?.state || { liveSha: null, previousSha: null, healthy: null, healthConfigured: Boolean(input.healthcheckUrl), statusConfigured: Boolean(input.statusUrl), checkedAt: null } }; repo.deploymentProfiles = [...repo.deploymentProfiles.filter((item) => item.id !== saved.id), saved]; snapshot(); return { profile: clone(saved), state: clone(state) }; },
async deleteDeploymentProfile(fullName, profileId) { const repo = repositories.find((item) => item.fullName === fullName); repo.deploymentProfiles = repo.deploymentProfiles.filter((item) => item.id !== profileId); snapshot(); return { profiles: clone(repo.deploymentProfiles), state: clone(state) }; },
async deploymentPreflight(repository, profileId) { await wait(280); const profile = repository.deploymentProfiles.find((item) => item.id === profileId); const status = repository.localStatus; const checks = [
{ id: 'repository.linked', label: 'Local repository link', status: repository.localPath ? 'pass' : 'fail', detail: repository.localPath || 'No local folder linked.', required: true },
{ id: 'git.branch', label: 'Allowed branch', status: status?.branch.head === profile?.branch ? 'pass' : 'fail', detail: `Current: ${status?.branch.head || 'unknown'}; required: ${profile?.branch || 'unknown'}.`, required: true },
{ id: 'git.clean', label: 'Clean working tree', status: status?.clean ? 'pass' : 'fail', detail: status?.clean ? 'No uncommitted changes.' : `${status?.counts.changed || 0} changed file(s).`, required: true },
{ id: 'git.sync', label: 'Local and Gitea synchronized', status: !status?.branch.ahead && !status?.branch.behind ? 'pass' : 'fail', detail: `${status?.branch.ahead || 0} ahead, ${status?.branch.behind || 0} behind.`, required: true },
{ id: 'workflow.deploy.remote', label: 'Deploy workflow on Gitea branch', status: 'pass', detail: `${profile?.workflowFile || 'deploy.yml'} exists on ${profile?.branch || 'main'}.`, required: true },
{ id: 'gitea.actions', label: 'Gitea Actions API', status: 'pass', detail: 'The Actions runs endpoint is accessible.', required: true },
{ id: 'server.status', label: 'Server version endpoint', status: profile?.statusUrl ? 'pass' : 'warning', detail: profile?.statusUrl ? `Endpoint reachable; live ${profile.state?.liveSha?.slice(0,7) || 'unknown'}.` : 'No status URL configured.', required: false },
{ id: 'server.health', label: 'Application healthcheck', status: profile?.healthcheckUrl ? 'pass' : 'warning', detail: profile?.healthcheckUrl ? 'HTTP 200 in 42 ms.' : 'No healthcheck URL configured.', required: false }
]; const blocking = checks.filter(i=>i.required && i.status==='fail').map(i=>i.id); return { kind: 'deployment', repository: repository.fullName, profileId, startedAt: iso(-100), completedAt: iso(), checks, summary: { counts: { pass: checks.filter(i=>i.status==='pass').length, warning: checks.filter(i=>i.status==='warning').length, fail: checks.filter(i=>i.status==='fail').length, skipped: 0 }, blocking, ready: blocking.length===0 }, head: status?.head || null }; },
async deploy(repository, profileId, sha) { await wait(320); const selected = repository.deploymentProfiles.find((item) => item.id === profileId); const operation = { id: `deploy-${Date.now()}`, type: 'deployment', action: 'deploy', status: 'queued', repository: repository.fullName, profileId, profileName: selected.name, environment: selected.environment, workflowFile: selected.workflowFile, branch: selected.branch, sha, shortSha: sha.slice(0, 7), dispatchedAt: iso(), createdAt: iso(), updatedAt: iso(), demoPolls: 0, stages: [{ id: 'requested', label: 'Requested', status: 'complete' }, { id: 'verified', label: 'Verified', status: 'complete' }, { id: 'queued', label: 'Workflow queued', status: 'active' }, { id: 'runner', label: 'Runner execution', status: 'pending' }, { id: 'healthcheck', label: 'Healthcheck', status: 'pending' }, { id: 'complete', label: 'Complete', status: 'pending' }], logs: [`[info] Verified clean ${selected.branch} at ${sha}`, `[ok] Gitea accepted ${selected.workflowFile}.`] }; return updateOperation(operation); },
async rollback(repository, profileId, targetSha) { await wait(320); const selected = repository.deploymentProfiles.find((item) => item.id === profileId); const operation = { id: `rollback-${Date.now()}`, type: 'deployment', action: 'rollback', status: 'queued', repository: repository.fullName, profileId, profileName: selected.name, environment: selected.environment, workflowFile: selected.rollbackWorkflowFile, branch: selected.branch, sha: targetSha, shortSha: targetSha.slice(0, 7), dispatchedAt: iso(), createdAt: iso(), updatedAt: iso(), demoPolls: 0, stages: [{ id: 'requested', label: 'Requested', status: 'complete' }, { id: 'verified', label: 'Verified', status: 'complete' }, { id: 'queued', label: 'Workflow queued', status: 'active' }, { id: 'runner', label: 'Runner execution', status: 'pending' }, { id: 'healthcheck', label: 'Healthcheck', status: 'pending' }, { id: 'complete', label: 'Complete', status: 'pending' }], logs: [`[warning] Rollback target verified: ${targetSha}`, `[ok] Gitea accepted ${selected.rollbackWorkflowFile}.`] }; return updateOperation(operation); },
async healthcheck() { await wait(160); return { configured: true, healthy: true, status: 200, latencyMs: 42 }; },
async refreshProfileState(fullName, profileId) { await wait(240); const repo = repositories.find((item) => item.fullName === fullName) || findProfileRepo(profileId); const target = repo?.deploymentProfiles.find((item) => item.id === profileId); if (!target) throw new Error('Deployment profile not found.'); target.state = { ...target.state, checkedAt: iso(), healthy: target.state.healthy !== false, healthConfigured: Boolean(target.healthcheckUrl), statusConfigured: Boolean(target.statusUrl) }; syncState(); return clone(target.state); },
async refreshOperations(operationId = null) { await wait(300); if (operationId) { const operation = state.operations.find((item) => item.id === operationId); if (!operation) throw new Error('Operation not found.'); return updateOperation(advanceOperation(operation)); } const active = state.operations.filter((item) => !['success', 'failed', 'cancelled', 'rolled-back'].includes(item.status)).map(advanceOperation); if (active.length) emitOperations(active); state.operations = state.operations.map((item) => active.find((entry) => entry.id === item.id) || item); return clone(active); },
async getOperation(operationId) { return clone(state.operations.find((item) => item.id === operationId) || null); },
async diagnosticsStatus() { return { enabled: state.preferences.diagnosticsEnabled !== false, level: state.preferences.diagnosticLevel, retentionDays: state.preferences.logRetentionDays, maxFileMb: state.preferences.maxLogFileMb, directory: '<HOME>/AppData/Roaming/ForgeFlow/diagnostics', fileCount: 2, totalBytes: 18432, totalSize: '18.0 KB', latestAt: iso(-2000), lastWriteError: null }; },
async clearDiagnostics() { return { enabled: true, level: state.preferences.diagnosticLevel, retentionDays: state.preferences.logRetentionDays, maxFileMb: state.preferences.maxLogFileMb, directory: '<HOME>/AppData/Roaming/ForgeFlow/diagnostics', fileCount: 1, totalBytes: 256, totalSize: '256 B', latestAt: iso(), lastWriteError: null }; },
async openDiagnosticsFolder() { return true; },
async exportDiagnostics(privacyMode = 'standard') { await wait(500); return { path: `C:\Users\Jens\Downloads\ForgeFlow-Diagnostics-demo.zip`, bytes: 38221, size: '37.3 KB', sha256: 'b'.repeat(64), privacyMode, generatedAt: iso() }; },
async showDiagnosticBundle() { return true; },
async reportRendererEvent() { return true; },
onRepositoriesChanged(listener) { repositoryListeners.add(listener); return () => repositoryListeners.delete(listener); },
onOperationsChanged(listener) { operationListeners.add(listener); return () => operationListeners.delete(listener); },
onUpdatesChanged(listener) { updateListeners.add(listener); return () => updateListeners.delete(listener); },
async reset() { state.setupComplete = false; storage.set('forgeflow-demo-setup', 'false'); return clone(state); }
});
})();
+535
View File
@@ -0,0 +1,535 @@
:root {
color-scheme: dark;
--bg: #0b0e14;
--surface-0: #0f131b;
--surface-1: #151a24;
--surface-2: #1b2130;
--surface-3: #252c3a;
--surface-hover: #202838;
--line: #30394a;
--line-soft: #222a38;
--text: #e7ebf4;
--text-muted: #9aa4b6;
--text-faint: #6f7a8d;
--primary: #8fb4ff;
--primary-strong: #5b8ff9;
--primary-soft: rgba(91, 143, 249, .15);
--success: #54ddb0;
--success-soft: rgba(84, 221, 176, .12);
--warning: #f2ba63;
--warning-soft: rgba(242, 186, 99, .13);
--danger: #ff817a;
--danger-soft: rgba(255, 129, 122, .13);
--shadow: 0 18px 70px rgba(0,0,0,.32);
--radius: 7px;
--sidebar: 286px;
--action-panel: 352px;
--font-ui: Inter, "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
--font-mono: "Cascadia Code", "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
html[data-theme="light"] {
color-scheme: light;
--bg: #eef2f7;
--surface-0: #f7f9fc;
--surface-1: #ffffff;
--surface-2: #f3f6fa;
--surface-3: #e8edf4;
--surface-hover: #edf2f8;
--line: #cdd5e1;
--line-soft: #e1e6ee;
--text: #172033;
--text-muted: #526078;
--text-faint: #7b879a;
--primary: #295fca;
--primary-strong: #326ee0;
--primary-soft: rgba(50, 110, 224, .10);
--success: #087a57;
--success-soft: rgba(8, 122, 87, .10);
--warning: #9b5b00;
--warning-soft: rgba(155, 91, 0, .10);
--danger: #c73737;
--danger-soft: rgba(199, 55, 55, .10);
--shadow: 0 18px 70px rgba(43, 55, 77, .15);
}
* { box-sizing: border-box; }
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
body { background: var(--bg); color: var(--text); font-family: var(--font-ui); font-size: 13px; }
button, input, textarea, select { font: inherit; color: inherit; }
button { border: 0; }
button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; }
::selection { background: rgba(91, 143, 249, .35); }
::-webkit-scrollbar { width: 9px; height: 9px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--text-faint) 38%, transparent); border: 3px solid transparent; background-clip: padding-box; border-radius: 20px; }
.boot-screen { height: 100vh; display: grid; place-content: center; justify-items: center; gap: 10px; color: var(--text-muted); }
.boot-screen strong { color: var(--text); font-size: 16px; }
.brand-mark { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 10px; background: linear-gradient(145deg, var(--primary), var(--primary-strong)); color: #07152e; font-weight: 800; font-size: 20px; box-shadow: 0 8px 30px rgba(91,143,249,.25); }
.app-shell { height: 100vh; display: grid; grid-template-rows: 48px minmax(0,1fr) 25px; background: var(--bg); }
.titlebar { display: flex; align-items: center; justify-content: space-between; padding: 0 10px 0 14px; border-bottom: 1px solid var(--line); background: var(--surface-1); -webkit-app-region: drag; }
.titlebar-left, .titlebar-right { display: flex; align-items: center; gap: 9px; min-width: 0; }
.titlebar button, .titlebar input { -webkit-app-region: no-drag; }
.wordmark { display: flex; align-items: center; gap: 9px; font-size: 15px; font-weight: 720; letter-spacing: -.02em; }
.wordmark .brand-mark { width: 25px; height: 25px; border-radius: 6px; font-size: 13px; box-shadow: none; }
.brand-logo { width: 32px; height: 25px; object-fit: contain; display: block; }
.wordmark small { color: var(--text-faint); font-size: 9px; font-weight: 620; letter-spacing: .01em; margin-left: -4px; }
.workspace-name { color: var(--text-muted); border-left: 1px solid var(--line); padding-left: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 280px; }
.connection-chip { display: inline-flex; align-items: center; gap: 6px; color: var(--text-muted); font-size: 11px; font-weight: 650; padding: 5px 8px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface-0); }
.connection-chip .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 3px var(--success-soft); }
.global-search { width: clamp(180px, 23vw, 330px); height: 30px; padding: 0 10px 0 31px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-0); color: var(--text); }
.search-wrap { position: relative; }
.search-wrap .icon { position: absolute; left: 9px; top: 7px; color: var(--text-faint); pointer-events: none; }
.app-body { display: grid; grid-template-columns: var(--sidebar) minmax(0,1fr); min-height: 0; }
.sidebar { min-width: 0; display: flex; flex-direction: column; border-right: 1px solid var(--line); background: var(--surface-1); overflow: hidden; }
.primary-nav { padding: 10px 8px 8px; border-bottom: 1px solid var(--line-soft); }
.nav-button { width: 100%; height: 34px; display: flex; align-items: center; gap: 10px; padding: 0 10px; border-radius: 5px; background: transparent; color: var(--text-muted); cursor: pointer; text-align: left; font-weight: 560; }
.nav-button:hover { background: var(--surface-hover); color: var(--text); }
.nav-button.active { color: var(--primary); background: var(--primary-soft); }
.nav-button .nav-count { margin-left: auto; min-width: 20px; text-align: center; font-family: var(--font-mono); color: var(--text-faint); font-size: 10px; }
.sidebar-section { display: flex; align-items: center; justify-content: space-between; padding: 13px 12px 7px; color: var(--text-faint); font-size: 10px; font-weight: 750; letter-spacing: .09em; text-transform: uppercase; }
.sidebar-section button { background: none; color: inherit; cursor: pointer; padding: 2px; }
.repo-filter { margin: 0 9px 8px; width: calc(100% - 18px); height: 29px; border: 1px solid var(--line-soft); border-radius: 5px; background: var(--surface-0); padding: 0 9px; }
.repo-list { min-height: 0; overflow: auto; padding: 0 6px 10px; }
.repo-row { width: 100%; display: grid; grid-template-columns: 18px minmax(0,1fr) auto; gap: 8px; align-items: center; min-height: 44px; padding: 6px 8px; background: transparent; border-radius: 5px; color: var(--text-muted); cursor: pointer; text-align: left; border: 1px solid transparent; }
.repo-row:hover { background: var(--surface-hover); color: var(--text); }
.repo-row.active { background: var(--primary-soft); border-color: color-mix(in srgb, var(--primary) 26%, transparent); color: var(--text); }
.repo-row.attention .repo-icon { color: var(--warning); }
.repo-main { min-width: 0; }
.repo-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 620; }
.repo-sub { display: flex; gap: 6px; margin-top: 3px; color: var(--text-faint); font-family: var(--font-mono); font-size: 10px; overflow: hidden; }
.repo-sub span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.repo-badges { display: flex; gap: 3px; align-items: center; }
.mini-badge { min-width: 18px; height: 18px; display: inline-grid; place-items: center; padding: 0 5px; border-radius: 9px; font-family: var(--font-mono); font-size: 9px; font-weight: 700; color: var(--text-muted); background: var(--surface-3); }
.mini-badge.warning { background: var(--warning-soft); color: var(--warning); }
.mini-badge.success { background: var(--success-soft); color: var(--success); }
.mini-badge.danger { background: var(--danger-soft); color: var(--danger); }
.sidebar-footer { margin-top: auto; border-top: 1px solid var(--line-soft); padding: 10px 11px; }
.sidebar-diagnostic-state { display: grid; grid-template-columns: 9px minmax(0,1fr); gap: 9px; align-items: start; color: var(--text-muted); }
.sidebar-diagnostic-state .state-dot { margin-top: 4px; }
.sidebar-diagnostic-state strong, .sidebar-diagnostic-state span { display: block; }
.sidebar-diagnostic-state strong { color: var(--text); font-size: 10px; font-weight: 650; }
.sidebar-diagnostic-state span { margin-top: 2px; color: var(--text-faint); font-size: 9px; line-height: 1.35; }
.workspace { min-width: 0; min-height: 0; display: grid; background: var(--surface-0); }
.workspace.with-panel { grid-template-columns: minmax(0,1fr) var(--action-panel); }
.main-canvas { min-width: 0; min-height: 0; overflow: auto; }
.main-canvas.repository-canvas { overflow: hidden; height: 100%; }
.action-panel { min-width: 0; border-left: 1px solid var(--line); background: var(--surface-1); overflow: auto; }
.page { min-height: 100%; padding: 22px 24px 40px; }
.page.nopad { padding: 0; }
.page-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; margin-bottom: 22px; }
.page-header h1, .repo-heading h1 { margin: 0; font-size: 20px; line-height: 1.3; letter-spacing: -.025em; }
.page-header p, .repo-heading p { margin: 5px 0 0; color: var(--text-muted); max-width: 720px; }
.eyebrow { color: var(--text-faint); font-size: 10px; font-weight: 760; letter-spacing: .09em; text-transform: uppercase; }
.button { min-height: 32px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; padding: 0 11px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface-2); color: var(--text); cursor: pointer; font-weight: 620; white-space: nowrap; }
.button:hover { background: var(--surface-3); }
.button.primary { background: var(--primary-strong); border-color: var(--primary-strong); color: #fff; }
.button.primary:hover { filter: brightness(1.07); }
.button.success { background: var(--success); border-color: var(--success); color: #06251b; }
.button.danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, var(--line)); background: var(--danger-soft); }
.button.ghost { background: transparent; border-color: transparent; color: var(--text-muted); }
.button.ghost:hover { color: var(--text); background: var(--surface-hover); }
.button.block { width: 100%; min-height: 38px; }
.button:disabled { opacity: .45; cursor: not-allowed; }
.icon-button { width: 30px; height: 30px; display: inline-grid; place-items: center; border-radius: 5px; border: 1px solid transparent; background: transparent; color: var(--text-muted); cursor: pointer; }
.icon-button:hover { color: var(--text); background: var(--surface-hover); border-color: var(--line-soft); }
.icon { width: 16px; height: 16px; display: inline-block; flex: 0 0 auto; }
.icon svg { width: 100%; height: 100%; display: block; stroke: currentColor; fill: none; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.summary-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; background: var(--surface-1); }
.summary-card { min-height: 118px; padding: 15px; border-right: 1px solid var(--line); position: relative; }
.summary-card:last-child { border-right: 0; }
.summary-value { margin-top: 18px; font-size: 28px; font-weight: 720; letter-spacing: -.04em; }
.summary-label { color: var(--text-muted); margin-top: 2px; }
.summary-card .icon { position: absolute; top: 14px; right: 14px; color: var(--text-faint); }
.summary-card.warning .summary-value, .summary-card.warning .icon { color: var(--warning); }
.summary-card.success .summary-value, .summary-card.success .icon { color: var(--success); }
.summary-card.danger .summary-value, .summary-card.danger .icon { color: var(--danger); }
.section-block { margin-top: 24px; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 9px; }
.section-heading h2 { margin: 0; font-size: 13px; letter-spacing: -.01em; }
.section-heading .meta { color: var(--text-faint); font-size: 11px; }
.action-queue { border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; background: var(--surface-1); }
.queue-row { display: grid; grid-template-columns: 28px minmax(160px,1.2fr) minmax(230px,2fr) auto; gap: 12px; align-items: center; min-height: 62px; padding: 10px 13px; border-bottom: 1px solid var(--line-soft); }
.queue-row:last-child { border-bottom: 0; }
.queue-row:hover { background: var(--surface-hover); }
.queue-icon { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 6px; background: var(--surface-2); color: var(--text-muted); }
.queue-icon.warning { background: var(--warning-soft); color: var(--warning); }
.queue-icon.success { background: var(--success-soft); color: var(--success); }
.queue-icon.danger { background: var(--danger-soft); color: var(--danger); }
.queue-title { font-weight: 640; }
.queue-sub { color: var(--text-faint); margin-top: 3px; font-size: 11px; font-family: var(--font-mono); }
.queue-reason { color: var(--text-muted); }
.queue-reason strong { color: var(--text); display: block; font-weight: 600; }
.two-column { display: grid; grid-template-columns: minmax(0,1.5fr) minmax(280px,1fr); gap: 16px; }
.panel { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-1); }
.panel-header { min-height: 42px; display: flex; align-items: center; justify-content: space-between; padding: 0 13px; border-bottom: 1px solid var(--line-soft); }
.panel-header h2, .panel-header h3 { font-size: 12px; margin: 0; }
.panel-body { padding: 14px; }
.activity-list { padding: 3px 0; }
.activity-item { display: grid; grid-template-columns: 10px minmax(0,1fr) auto; gap: 10px; padding: 10px 13px; align-items: start; }
.activity-dot { width: 7px; height: 7px; margin-top: 5px; border-radius: 50%; background: var(--text-faint); }
.activity-dot.success { background: var(--success); }
.activity-dot.warning { background: var(--warning); }
.activity-dot.danger { background: var(--danger); }
.activity-title { font-weight: 590; }
.activity-sub, .activity-time { color: var(--text-faint); font-size: 11px; }
.repo-workspace { height: 100%; min-height: 0; display: grid; grid-template-rows: auto auto 39px minmax(0,1fr); }
.repo-header { padding: 16px 18px 13px; background: var(--surface-1); border-bottom: 1px solid var(--line); display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.repo-heading { min-width: 0; }
.repo-heading h1 { display: flex; align-items: center; gap: 9px; font-size: 17px; }
.repo-heading p { font-family: var(--font-mono); font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.repo-header-actions { display: flex; gap: 7px; }
.release-rail { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); background: var(--surface-0); border-bottom: 1px solid var(--line); }
.release-node { min-width: 0; padding: 10px 16px 11px; border-right: 1px solid var(--line-soft); position: relative; }
.release-node:last-child { border-right: 0; }
.release-node:not(:last-child)::after { content: ''; position: absolute; right: -6px; top: 19px; z-index: 2; width: 12px; height: 12px; display: grid; place-items: center; border-radius: 50%; background: var(--surface-0); color: var(--text-faint); }
.release-label { color: var(--text-faint); font-size: 9px; font-weight: 760; letter-spacing: .08em; text-transform: uppercase; }
.release-value { display: flex; align-items: center; gap: 7px; margin-top: 4px; min-width: 0; }
.release-value strong { font-family: var(--font-mono); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.release-value span { color: var(--text-muted); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.state-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-faint); flex: 0 0 auto; }
.state-dot.success { background: var(--success); box-shadow: 0 0 0 3px var(--success-soft); }
.state-dot.warning { background: var(--warning); box-shadow: 0 0 0 3px var(--warning-soft); }
.state-dot.danger { background: var(--danger); box-shadow: 0 0 0 3px var(--danger-soft); }
.tabs { display: flex; align-items: flex-end; gap: 2px; padding: 0 12px; border-bottom: 1px solid var(--line); background: var(--surface-1); }
.tab { height: 38px; padding: 0 12px; background: transparent; color: var(--text-muted); border-bottom: 2px solid transparent; cursor: pointer; }
.tab:hover { color: var(--text); }
.tab.active { color: var(--primary); border-bottom-color: var(--primary); }
.repo-content { min-height: 0; overflow: hidden; }
.changes-layout { height: 100%; min-height: 0; display: grid; grid-template-columns: 290px minmax(0,1fr); }
.file-panel { min-width: 0; min-height: 0; overflow: hidden; border-right: 1px solid var(--line); background: var(--surface-1); display: flex; flex-direction: column; }
.file-panel-tools { min-height: 38px; padding: 0 9px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line-soft); }
.file-list { flex: 1 1 auto; overflow-x: hidden; overflow-y: auto; min-height: 0; padding: 5px; overscroll-behavior: contain; scrollbar-gutter: stable; }
.file-row { width: 100%; min-height: 34px; display: grid; grid-template-columns: 17px 17px minmax(0,1fr) 16px; gap: 7px; align-items: center; padding: 3px 6px; border-radius: 4px; background: transparent; color: var(--text-muted); cursor: pointer; text-align: left; }
.file-row:hover { background: var(--surface-hover); color: var(--text); }
.file-row.active { background: var(--primary-soft); color: var(--text); }
.file-row input { margin: 0; accent-color: var(--primary-strong); }
.file-path { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: var(--font-mono); font-size: 11px; }
.file-status { font-family: var(--font-mono); font-size: 10px; font-weight: 750; color: var(--warning); }
.file-status.added, .file-status.untracked { color: var(--success); }
.file-status.deleted, .file-status.conflict { color: var(--danger); }
.diff-panel { min-width: 0; min-height: 0; display: grid; grid-template-rows: 38px minmax(0,1fr); background: var(--bg); }
.diff-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 0 11px; border-bottom: 1px solid var(--line-soft); background: var(--surface-0); }
.diff-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-mono); color: var(--text-muted); }
.diff-view { overflow: auto; padding: 8px 0 36px; font-family: var(--font-mono); font-size: 11px; line-height: 19px; white-space: pre; tab-size: 2; }
.diff-line { display: block; min-height: 19px; padding: 0 14px; }
.diff-line.add { background: rgba(38, 166, 115, .14); color: #8ef0c6; }
.diff-line.remove { background: rgba(229, 83, 75, .14); color: #ffaaa5; }
html[data-theme="light"] .diff-line.add { color: #006642; }
html[data-theme="light"] .diff-line.remove { color: #a31f1f; }
.diff-line.meta { color: var(--primary); }
.diff-line.hunk { color: #caa7ff; background: rgba(148, 97, 214, .08); }
.empty-state { height: 100%; min-height: 260px; display: grid; place-content: center; justify-items: center; text-align: center; padding: 30px; color: var(--text-muted); }
.empty-state .large-icon { width: 48px; height: 48px; display: grid; place-items: center; border-radius: 12px; background: var(--surface-2); color: var(--text-faint); margin-bottom: 12px; }
.empty-state h3 { margin: 0 0 6px; color: var(--text); font-size: 14px; }
.empty-state p { margin: 0; max-width: 420px; line-height: 1.55; }
.inspector { padding: 16px; }
.inspector-header { margin-bottom: 16px; }
.inspector-header h2 { margin: 3px 0 0; font-size: 15px; }
.inspector-section { padding: 15px 0; border-top: 1px solid var(--line-soft); }
.inspector-section:first-of-type { border-top: 0; padding-top: 0; }
.inspector-label { color: var(--text-faint); font-size: 9px; font-weight: 760; letter-spacing: .09em; text-transform: uppercase; margin-bottom: 8px; }
.textarea, .input, .select { width: 100%; border: 1px solid var(--line); border-radius: 5px; background: var(--surface-0); color: var(--text); }
.input, .select { height: 33px; padding: 0 9px; }
.textarea { min-height: 98px; resize: vertical; padding: 9px 10px; line-height: 1.45; }
.field-hint { display: flex; justify-content: space-between; gap: 10px; margin-top: 6px; color: var(--text-faint); font-size: 10px; }
.context-summary { padding: 11px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-0); }
.context-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 5px 0; color: var(--text-muted); }
.context-row strong { color: var(--text); font-family: var(--font-mono); font-size: 11px; text-align: right; overflow: hidden; text-overflow: ellipsis; }
.notice { display: flex; align-items: flex-start; gap: 9px; padding: 10px 11px; border-radius: 5px; border: 1px solid var(--line); background: var(--surface-0); color: var(--text-muted); line-height: 1.45; }
.notice.warning { border-color: color-mix(in srgb, var(--warning) 35%, var(--line)); background: var(--warning-soft); color: var(--warning); }
.notice.danger { border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); background: var(--danger-soft); color: var(--danger); }
.notice.success { border-color: color-mix(in srgb, var(--success) 35%, var(--line)); background: var(--success-soft); color: var(--success); }
.stack { display: grid; gap: 8px; }
.divider-text { display: flex; align-items: center; gap: 9px; color: var(--text-faint); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
.divider-text::before, .divider-text::after { content: ''; height: 1px; background: var(--line-soft); flex: 1; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th { height: 34px; padding: 0 11px; color: var(--text-faint); text-align: left; font-size: 9px; letter-spacing: .07em; text-transform: uppercase; border-bottom: 1px solid var(--line); }
.data-table td { padding: 10px 11px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
.data-table tr:last-child td { border-bottom: 0; }
.data-table tbody tr:hover { background: var(--surface-hover); }
.mono { font-family: var(--font-mono); }
.status-pill { display: inline-flex; align-items: center; gap: 6px; min-height: 22px; padding: 0 8px; border-radius: 11px; background: var(--surface-3); color: var(--text-muted); font-size: 10px; font-weight: 680; }
.status-pill.success { background: var(--success-soft); color: var(--success); }
.status-pill.warning { background: var(--warning-soft); color: var(--warning); }
.status-pill.danger { background: var(--danger-soft); color: var(--danger); }
.settings-layout { display: grid; grid-template-columns: 210px minmax(0,1fr); min-height: 100%; }
.settings-nav { padding: 15px 8px; border-right: 1px solid var(--line); background: var(--surface-1); }
.settings-content { padding: 25px 30px 60px; overflow: auto; }
.settings-group { max-width: 860px; margin-bottom: 28px; }
.settings-group > h2 { font-size: 12px; margin: 0 0 12px; }
.form-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 14px; }
.field { display: grid; gap: 6px; }
.field label { color: var(--text-muted); font-size: 11px; font-weight: 650; }
.field.full { grid-column: 1 / -1; }
.connection-card { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 13px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-1); }
.root-row { display: flex; align-items: center; gap: 8px; }
.root-row .input { flex: 1; font-family: var(--font-mono); font-size: 11px; }
.deploy-card-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(310px,1fr)); gap: 12px; }
.deploy-card { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-1); overflow: hidden; }
.deploy-card-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 14px; border-bottom: 1px solid var(--line-soft); }
.deploy-card-body { padding: 13px 14px; }
.deploy-metadata { display: grid; grid-template-columns: 1fr auto; gap: 7px 15px; color: var(--text-muted); }
.deploy-metadata strong { font-family: var(--font-mono); font-size: 11px; color: var(--text); }
.deployment-view { min-height: 100%; padding: 22px; }
.pipeline-card { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-1); overflow: hidden; }
.pipeline-head { display: flex; justify-content: space-between; gap: 15px; padding: 16px; border-bottom: 1px solid var(--line-soft); }
.pipeline-head h2 { margin: 0; font-size: 16px; }
.pipeline-head p { margin: 5px 0 0; color: var(--text-muted); font-family: var(--font-mono); font-size: 11px; }
.pipeline-stages { display: grid; grid-template-columns: repeat(5,1fr); padding: 25px 20px 20px; }
.pipeline-stage { position: relative; display: grid; justify-items: center; gap: 8px; color: var(--text-faint); text-align: center; font-size: 10px; font-weight: 680; }
.pipeline-stage::before { content: ''; position: absolute; height: 2px; left: -50%; right: 50%; top: 15px; background: var(--line); }
.pipeline-stage:first-child::before { display: none; }
.pipeline-stage.complete::before, .pipeline-stage.active::before { background: var(--success); }
.stage-icon { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 9px; background: var(--surface-3); border: 1px solid var(--line); z-index: 1; }
.pipeline-stage.complete { color: var(--success); }
.pipeline-stage.complete .stage-icon { background: var(--success); border-color: var(--success); color: #06251b; }
.pipeline-stage.active { color: var(--primary); }
.pipeline-stage.active .stage-icon { background: var(--primary-strong); border-color: var(--primary); color: #fff; box-shadow: 0 0 0 5px var(--primary-soft); }
.log-view { margin-top: 14px; border: 1px solid var(--line); border-radius: var(--radius); background: #080b11; overflow: hidden; }
.log-toolbar { height: 35px; display: flex; align-items: center; justify-content: space-between; padding: 0 11px; border-bottom: 1px solid #262d3b; color: #9aa4b6; }
.log-lines { min-height: 290px; max-height: 500px; overflow: auto; padding: 12px 14px; font: 11px/19px var(--font-mono); color: #c3cada; white-space: pre-wrap; }
.log-lines .ok { color: #54ddb0; }
.log-lines .warn { color: #f2ba63; }
.log-lines .err { color: #ff817a; }
.setup-backdrop { position: fixed; inset: 0; z-index: 50; display: grid; place-items: center; padding: 25px; background: rgba(4,7,12,.75); backdrop-filter: blur(8px); }
.setup-window { width: min(920px,96vw); min-height: 590px; max-height: 92vh; display: grid; grid-template-columns: 230px minmax(0,1fr); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; background: var(--surface-1); box-shadow: var(--shadow); }
.setup-sidebar { padding: 24px 17px; border-right: 1px solid var(--line); background: var(--surface-0); }
.setup-sidebar h2 { margin: 16px 0 5px; font-size: 18px; }
.setup-sidebar p { margin: 0 0 22px; color: var(--text-muted); line-height: 1.5; }
.setup-step { min-height: 38px; display: flex; align-items: center; gap: 9px; padding: 0 9px; border-radius: 5px; color: var(--text-faint); margin-bottom: 3px; }
.setup-step .step-number { width: 22px; height: 22px; display: grid; place-items: center; border-radius: 50%; border: 1px solid var(--line); font-family: var(--font-mono); font-size: 9px; }
.setup-step.active { background: var(--primary-soft); color: var(--primary); }
.setup-step.complete { color: var(--success); }
.setup-content { min-width: 0; padding: 34px 38px 24px; display: grid; grid-template-rows: minmax(0,1fr) auto; overflow: auto; }
.setup-body h1 { margin: 0; font-size: 22px; }
.setup-body > p { color: var(--text-muted); line-height: 1.55; max-width: 620px; }
.setup-actions { display: flex; justify-content: space-between; gap: 10px; padding-top: 22px; border-top: 1px solid var(--line-soft); }
.discovery-list { border: 1px solid var(--line); border-radius: 6px; overflow: hidden; max-height: 260px; overflow-y: auto; }
.discovery-row { min-height: 45px; display: grid; grid-template-columns: 20px minmax(0,1fr) auto; gap: 10px; align-items: center; padding: 7px 10px; border-bottom: 1px solid var(--line-soft); }
.discovery-row:last-child { border-bottom: 0; }
.discovery-row strong { display: block; font-size: 12px; }
.discovery-row span { display: block; margin-top: 2px; color: var(--text-faint); font: 10px var(--font-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.modal-backdrop { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 20px; background: rgba(4,7,12,.7); backdrop-filter: blur(4px); }
.modal { width: min(520px,94vw); border: 1px solid var(--line); border-radius: 9px; background: var(--surface-1); box-shadow: var(--shadow); overflow: hidden; }
.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 15px 17px; border-bottom: 1px solid var(--line); }
.modal-header h2 { margin: 0; font-size: 15px; }
.modal-body { padding: 17px; }
.modal-footer { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 17px; border-top: 1px solid var(--line-soft); background: var(--surface-0); }
.statusbar { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 0 9px; border-top: 1px solid var(--line); background: var(--surface-1); color: var(--text-faint); font-size: 10px; font-weight: 620; }
.statusbar-left, .statusbar-right { display: flex; align-items: center; gap: 13px; min-width: 0; }
.statusbar-item { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.statusbar .success { color: var(--success); }
.statusbar .warning { color: var(--warning); }
.statusbar .danger { color: var(--danger); }
.toast-root { position: fixed; right: 15px; bottom: 38px; z-index: 90; display: grid; gap: 8px; pointer-events: none; }
.toast { width: min(380px,calc(100vw - 30px)); display: grid; grid-template-columns: 20px minmax(0,1fr); gap: 9px; padding: 11px 12px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface-2); box-shadow: var(--shadow); pointer-events: auto; animation: toast-in .18s ease-out; }
.toast.success { border-color: color-mix(in srgb, var(--success) 35%, var(--line)); }
.toast.error { border-color: color-mix(in srgb, var(--danger) 40%, var(--line)); }
.toast strong { display: block; margin-bottom: 2px; }
.toast span { color: var(--text-muted); line-height: 1.4; }
@keyframes toast-in { from { transform: translateY(8px); opacity: 0; } }
.loading-overlay { position: absolute; inset: 0; z-index: 20; display: grid; place-items: center; background: color-mix(in srgb, var(--surface-0) 72%, transparent); backdrop-filter: blur(2px); }
.spinner { width: 22px; height: 22px; border: 2px solid var(--line); border-top-color: var(--primary); border-radius: 50%; animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 1250px) {
:root { --sidebar: 250px; --action-panel: 320px; }
.summary-grid { grid-template-columns: repeat(2,1fr); }
.summary-card:nth-child(2) { border-right: 0; }
.summary-card:nth-child(-n+2) { border-bottom: 1px solid var(--line); }
.queue-row { grid-template-columns: 28px minmax(130px,1fr) minmax(180px,1.5fr) auto; }
}
@media (max-width: 1120px) {
:root { --sidebar: 220px; --action-panel: 300px; }
.global-search { width: 190px; }
.changes-layout { grid-template-columns: 245px minmax(0,1fr); }
.page { padding-left: 18px; padding-right: 18px; }
}
/* ForgeFlow v0.2 interaction and workflow refinements */
.command-trigger { height: 30px; display: inline-flex; align-items: center; gap: 7px; padding: 0 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-0); color: var(--text-muted); cursor: pointer; -webkit-app-region: no-drag; }
.command-trigger:hover { color: var(--text); background: var(--surface-hover); }
kbd { min-width: 24px; padding: 2px 5px; border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 4px; background: var(--surface-2); color: var(--text-faint); font: 9px var(--font-mono); text-align: center; }
.repo-group-label { padding: 10px 8px 4px; color: var(--text-faint); font-size: 9px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
.favorite-button { width: 25px; height: 25px; display: inline-grid; place-items: center; margin-left: -5px; border-radius: 5px; background: transparent; color: var(--text-faint); cursor: pointer; }
.favorite-button:hover, .favorite-button.active { color: var(--warning); background: var(--warning-soft); }
.favorite-button.active svg { fill: currentColor; }
.repo-row .repo-icon .icon { width: 15px; height: 15px; }
.repo-row .repo-icon:has(svg path[d^="m12 3"]) { color: var(--warning); }
.button.small { min-height: 25px; padding: 0 7px; font-size: 10px; }
.stack.horizontal.compact { gap: 5px; }
.empty-state.compact { min-height: 105px; padding: 16px; }
.empty-state.full { height: 100%; min-height: 320px; }
.readiness-list { display: grid; gap: 3px; }
.readiness-row { display: grid; grid-template-columns: 12px minmax(0,1fr); gap: 9px; align-items: start; padding: 9px 4px; border-bottom: 1px solid var(--line-soft); }
.readiness-row:last-child { border-bottom: 0; }
.readiness-row strong { display: block; font-size: 11px; }
.readiness-row span:not(.state-dot) { display: block; margin-top: 2px; color: var(--text-faint); font-size: 10px; }
.action-panel-head { padding: 18px 17px 14px; border-bottom: 1px solid var(--line); }
.action-panel-head h2 { margin: 5px 0 5px; font-size: 16px; }
.action-panel-head p { margin: 0; color: var(--text-muted); line-height: 1.45; }
.action-panel-body { padding: 15px 16px; }
.action-panel-footer { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; margin: auto 10px 10px; padding-top: 10px; border-top: 1px solid var(--line-soft); }
.action-panel { display: flex; flex-direction: column; }
.panel-callout { display: grid; gap: 9px; }
.panel-callout h2 { margin: 3px 0 0; font-size: 15px; }
.panel-callout p { margin: 0 0 5px; color: var(--text-muted); line-height: 1.5; }
.callout-icon { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 9px; background: var(--primary-soft); color: var(--primary); }
.callout-icon.success { background: var(--success-soft); color: var(--success); }
.callout-icon.warning { background: var(--warning-soft); color: var(--warning); }
.callout-icon.danger { background: var(--danger-soft); color: var(--danger); }
.field-hint { display: flex; justify-content: space-between; gap: 10px; margin-top: 6px; color: var(--text-faint); font-size: 10px; }
.field-label { display: block; margin-top: 8px; color: var(--text-muted); font-size: 10px; font-weight: 650; }
.textarea { width: 100%; min-height: 92px; resize: vertical; padding: 9px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-0); line-height: 1.45; }
.input, .select { width: 100%; min-height: 34px; padding: 0 10px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface-0); color: var(--text); }
.select { cursor: pointer; }
.stack { display: grid; gap: 8px; }
.stack.horizontal { display: flex; flex-wrap: wrap; align-items: center; }
.deploy-proof { display: grid; grid-template-columns: 1fr auto; gap: 7px 12px; margin: 7px 0 5px; padding: 11px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-0); }
.deploy-proof span { color: var(--text-faint); }
.deploy-proof strong { font: 11px var(--font-mono); }
.tab-page { min-height: 100%; padding: 18px 19px 42px; overflow: auto; }
.git-tools-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 15px; align-items: start; }
.inline-form { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 8px; margin-bottom: 13px; }
.tool-list { display: grid; border: 1px solid var(--line-soft); border-radius: 6px; overflow: hidden; }
.tool-row { min-height: 51px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 10px; border-bottom: 1px solid var(--line-soft); }
.tool-row:last-child { border-bottom: 0; }
.tool-row:hover { background: var(--surface-hover); }
.tool-row strong { display: block; }
.tool-row span { display: block; margin-top: 3px; color: var(--text-faint); font: 10px var(--font-mono); }
.deploy-card-header h3 { margin: 4px 0 3px; font-size: 14px; }
.deploy-card-header p { margin: 0; color: var(--text-faint); font: 10px var(--font-mono); }
.card-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line-soft); }
.compact-card .deploy-card-body { padding-bottom: 11px; }
.check-field { display: flex; align-items: center; gap: 9px; padding: 8px 0; color: var(--text-muted); }
.check-field input { accent-color: var(--primary-strong); }
.wide-modal { width: min(650px,95vw); }
.modal-spacer { flex: 1; }
.confirm-hero { display: flex; align-items: center; gap: 12px; padding: 12px; border: 1px solid color-mix(in srgb, var(--success) 30%, var(--line)); border-radius: 7px; background: var(--success-soft); }
.confirm-hero.danger { border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); background: var(--danger-soft); }
.confirm-hero > .icon { width: 27px; height: 27px; color: var(--success); }
.confirm-hero.danger > .icon { color: var(--danger); }
.confirm-hero strong, .confirm-hero span { display: block; }
.confirm-hero span { margin-top: 3px; color: var(--text-muted); }
.confirm-grid { display: grid; grid-template-columns: 120px minmax(0,1fr); gap: 9px 14px; margin-top: 16px; }
.confirm-grid span { color: var(--text-faint); }
.confirm-grid strong { overflow-wrap: anywhere; }
.notice.danger { border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); background: var(--danger-soft); color: var(--danger); }
.notice.warning { border-color: color-mix(in srgb, var(--warning) 35%, var(--line)); background: var(--warning-soft); color: var(--warning); }
.danger-zone { padding: 15px; border: 1px solid color-mix(in srgb, var(--danger) 25%, var(--line)); border-radius: 7px; background: var(--danger-soft); }
.danger-zone p { color: var(--text-muted); line-height: 1.5; }
.pipeline-stages { grid-template-columns: repeat(6,1fr); }
.pipeline-stage.failed { color: var(--danger); }
.pipeline-stage.failed .stage-icon { background: var(--danger); border-color: var(--danger); color: #fff; }
.pipeline-stage.cancelled, .pipeline-stage.skipped { color: var(--text-faint); }
.log-lines { word-break: break-word; }
.palette-backdrop { align-items: start; padding-top: 12vh; }
.command-palette { width: min(650px,94vw); border: 1px solid var(--line); border-radius: 10px; background: var(--surface-1); box-shadow: var(--shadow); overflow: hidden; }
.palette-search { height: 54px; display: grid; grid-template-columns: 20px minmax(0,1fr); gap: 9px; align-items: center; padding: 0 15px; border-bottom: 1px solid var(--line); }
.palette-search input { height: 100%; border: 0; outline: 0; background: transparent; font-size: 15px; }
.palette-list { max-height: 380px; overflow: auto; padding: 6px; }
.palette-row { width: 100%; min-height: 52px; display: grid; grid-template-columns: 22px minmax(0,1fr) auto; gap: 10px; align-items: center; padding: 7px 10px; border-radius: 6px; background: transparent; color: var(--text-muted); text-align: left; cursor: pointer; }
.palette-row:hover, .palette-row:focus-visible { background: var(--primary-soft); color: var(--text); }
.palette-row:disabled { opacity: .4; cursor: not-allowed; }
.palette-row strong, .palette-row small { display: block; }
.palette-row small { margin-top: 3px; color: var(--text-faint); }
.palette-footer { padding: 8px 13px; border-top: 1px solid var(--line-soft); color: var(--text-faint); font-size: 10px; }
.discovery-progress { min-height: 260px; display: grid; place-content: center; justify-items: center; gap: 13px; color: var(--text-muted); }
@media (max-width: 1240px) {
.command-trigger span { display: none; }
.command-trigger kbd { display: none; }
.git-tools-grid { grid-template-columns: 1fr; }
.pipeline-stages { grid-template-columns: repeat(3,1fr); row-gap: 18px; }
.pipeline-stage:nth-child(4)::before { display: none; }
}
/* v0.3 diagnostics and preflight */
.diagnostics-page { display: grid; gap: 18px; padding: 20px 22px 44px; overflow: auto; }
.diagnostic-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; align-items: start; }
.diagnostic-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border: 1px solid var(--line-soft); border-radius: 7px; overflow: hidden; }
.diagnostic-metrics > div { min-height: 68px; display: grid; align-content: center; gap: 5px; padding: 11px 12px; border-right: 1px solid var(--line-soft); border-bottom: 1px solid var(--line-soft); background: var(--surface-0); }
.diagnostic-metrics > div:nth-child(2n) { border-right: 0; }
.diagnostic-metrics > div:nth-last-child(-n + 2) { border-bottom: 0; }
.diagnostic-metrics span { color: var(--text-faint); font-size: 9px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
.diagnostic-metrics strong { overflow-wrap: anywhere; font-size: 12px; }
.preflight-summary { min-height: 48px; display: flex; flex-wrap: wrap; align-items: center; gap: 9px; padding: 11px 13px; border-bottom: 1px solid var(--line-soft); color: var(--text-muted); }
.preflight-list { display: grid; }
.preflight-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; gap: 10px; align-items: start; padding: 12px 13px; border-bottom: 1px solid var(--line-soft); }
.preflight-row:last-child { border-bottom: 0; }
.preflight-row > div { min-width: 0; }
.preflight-row strong { display: block; margin-top: 1px; font-size: 11px; }
.preflight-row span:not(.preflight-state):not(.status-pill), .preflight-row small { display: block; margin-top: 3px; color: var(--text-faint); line-height: 1.4; overflow-wrap: anywhere; }
.preflight-row small { color: var(--text-muted); }
.preflight-state { width: 25px; height: 25px; display: grid; place-items: center; border-radius: 50%; background: var(--surface-2); color: var(--text-faint); }
.preflight-state .icon { width: 14px; height: 14px; }
.preflight-state.success { background: var(--success-soft); color: var(--success); }
.preflight-state.warning { background: var(--warning-soft); color: var(--warning); }
.preflight-state.danger { background: var(--danger-soft); color: var(--danger); }
.setup-preflight { max-height: 330px; margin-top: 17px; border: 1px solid var(--line); border-radius: 8px; overflow: auto; background: var(--surface-0); }
.setup-summary { display: grid; margin: 18px 0; border: 1px solid var(--line); border-radius: 8px; padding: 0 12px; background: var(--surface-0); }
.setup-support-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
.diagnostics-page .panel-body { padding: 14px; }
.diagnostics-page .section-block { margin: 0; }
@media (max-width: 1100px) {
.diagnostic-grid { grid-template-columns: 1fr; }
}
@media (max-width: 760px) {
.diagnostics-page { padding: 15px; }
.diagnostic-metrics { grid-template-columns: 1fr; }
.diagnostic-metrics > div { border-right: 0; }
.diagnostic-metrics > div:nth-last-child(-n + 2) { border-bottom: 1px solid var(--line-soft); }
.diagnostic-metrics > div:last-child { border-bottom: 0; }
.preflight-row { grid-template-columns: 28px minmax(0, 1fr); }
.preflight-row > .status-pill { grid-column: 2; justify-self: start; }
}
.required-mark { color: var(--warning); font-size: 10px; font-weight: 700; text-transform: uppercase; margin-left: 5px; }
.commit-readiness { margin-top: 9px; padding: 8px 9px; display: flex; align-items: flex-start; gap: 7px; border: 1px solid var(--line); border-radius: 5px; color: var(--text-muted); background: var(--surface-0); font-size: 11px; line-height: 1.4; }
.commit-readiness.blocked { border-color: color-mix(in srgb, var(--warning) 35%, var(--line)); background: var(--warning-soft); color: var(--text); }
.commit-readiness.ready { border-color: color-mix(in srgb, var(--success) 35%, var(--line)); background: var(--success-soft); color: var(--text); }
.commit-readiness .icon { flex: 0 0 auto; margin-top: 1px; }
.stage-note { margin-top: 10px; color: var(--text-faint); font-size: 10px; line-height: 1.45; }
.update-card { margin-top: 12px; padding: 12px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface-0); }
.update-card.available { border-color: color-mix(in srgb, var(--primary) 45%, var(--line)); background: var(--primary-soft); }
.update-card > div:first-child { display: flex; align-items: center; gap: 10px; min-width: 0; }
.update-card > div:first-child > span { min-width: 0; display: grid; gap: 2px; }
.update-card small, .server-card small { color: var(--text-faint); }
.server-list { display: grid; gap: 8px; }
.server-card { padding: 11px 12px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface-0); }
.server-card-main { min-width: 0; display: flex; align-items: center; gap: 10px; }
.server-card-main > div { min-width: 0; display: grid; gap: 2px; }
.server-card-main span { color: var(--text-muted); font-family: var(--font-mono); font-size: 10px; overflow: hidden; text-overflow: ellipsis; }
.provider-choice { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; margin-bottom: 14px; }
.provider-note { padding: 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-0); color: var(--text-muted); font-size: 11px; }
.server-inspection { margin-top: 12px; }
@media (max-width: 1240px) {
.wordmark small { display: none; }
.update-card, .server-card { align-items: flex-start; flex-direction: column; }
}
.setup-brand-logo { width: 150px; height: auto; display: block; margin-bottom: 12px; }
+24
View File
@@ -0,0 +1,24 @@
'use strict';
const path = require('node:path');
function cloneDirectoryName(remoteUrl) {
const raw = String(remoteUrl || '').trim().replace(/[?#].*$/, '').replace(/[\\/]+$/, '');
const segment = raw.split(/[\\/:]/).filter(Boolean).at(-1) || 'repository';
return segment.replace(/\.git$/i, '').replace(/[^a-zA-Z0-9._-]/g, '-') || 'repository';
}
function resolveCloneTarget(workspaceRoot, remoteUrl) {
const root = path.resolve(String(workspaceRoot || ''));
if (!String(workspaceRoot || '').trim()) throw new Error('A project root is required.');
const target = path.resolve(root, cloneDirectoryName(remoteUrl));
const normalize = (value) => process.platform === 'win32' ? value.toLowerCase() : value;
const normalizedRoot = normalize(root);
const normalizedTarget = normalize(target);
if (normalizedTarget === normalizedRoot || !normalizedTarget.startsWith(`${normalizedRoot}${path.sep}`)) {
throw new Error('Clone target escapes the selected project root.');
}
return { root, target, directoryName: path.basename(target) };
}
module.exports = { cloneDirectoryName, resolveCloneTarget };
+93
View File
@@ -0,0 +1,93 @@
'use strict';
function parseBranchHeader(line, branch) {
if (line.startsWith('# branch.oid ')) branch.oid = line.slice(13).trim();
if (line.startsWith('# branch.head ')) branch.head = line.slice(14).trim();
if (line.startsWith('# branch.upstream ')) branch.upstream = line.slice(18).trim();
if (line.startsWith('# branch.ab ')) {
const match = line.match(/\+(\d+)\s+-(\d+)/);
if (match) {
branch.ahead = Number(match[1]);
branch.behind = Number(match[2]);
}
}
}
function statusLabel(code) {
const map = {
M: 'modified', A: 'added', D: 'deleted', R: 'renamed', C: 'copied',
U: 'conflict', T: 'type-changed', '?': 'untracked', '!': 'ignored', '.': 'clean', ' ': 'clean'
};
return map[code] || 'changed';
}
function buildFile(path, originalPath, xy, kind) {
const indexCode = xy?.[0] || '.';
const worktreeCode = xy?.[1] || '.';
const conflict = kind === 'u' || indexCode === 'U' || worktreeCode === 'U';
const untracked = kind === '?';
return {
path,
originalPath: originalPath || null,
indexCode,
worktreeCode,
staged: !untracked && indexCode !== '.' && indexCode !== ' ',
unstaged: untracked || (worktreeCode !== '.' && worktreeCode !== ' '),
untracked,
conflict,
status: conflict ? 'conflict' : untracked ? 'untracked' : statusLabel(worktreeCode !== '.' ? worktreeCode : indexCode)
};
}
function parsePorcelainV2(output) {
const branch = { oid: null, head: null, upstream: null, ahead: 0, behind: 0 };
const files = [];
const entries = String(output || '').split('\0');
for (let index = 0; index < entries.length; index += 1) {
const entry = entries[index];
if (!entry) continue;
if (entry.startsWith('# ')) {
parseBranchHeader(entry, branch);
continue;
}
const kind = entry[0];
if (kind === '1') {
const parts = entry.split(' ');
const xy = parts[1];
const path = parts.slice(8).join(' ');
files.push(buildFile(path, null, xy, kind));
} else if (kind === '2') {
const parts = entry.split(' ');
const xy = parts[1];
const path = parts.slice(9).join(' ');
const originalPath = entries[index + 1] || null;
index += 1;
files.push(buildFile(path, originalPath, xy, kind));
} else if (kind === 'u') {
const parts = entry.split(' ');
const xy = parts[1];
const path = parts.slice(10).join(' ');
files.push(buildFile(path, null, xy, kind));
} else if (kind === '?' || kind === '!') {
const path = entry.slice(2);
if (kind === '?') files.push(buildFile(path, null, '??', kind));
}
}
return {
branch,
files,
counts: {
changed: files.length,
staged: files.filter((file) => file.staged).length,
unstaged: files.filter((file) => file.unstaged).length,
conflicts: files.filter((file) => file.conflict).length,
untracked: files.filter((file) => file.untracked).length
},
clean: files.length === 0
};
}
module.exports = { parsePorcelainV2, statusLabel };
+39
View File
@@ -0,0 +1,39 @@
'use strict';
function stripGitSuffix(value) {
return value.replace(/\.git$/i, '').replace(/^\/+|\/+$/g, '');
}
function normalizeRemoteUrl(remote) {
const raw = String(remote || '').trim();
if (!raw) return null;
const scp = raw.match(/^(?:[^@]+@)?([^:]+):(.+)$/);
if (scp && !raw.includes('://') && !/^[a-zA-Z]:[\\/]/.test(raw)) {
return { host: scp[1].toLowerCase(), path: stripGitSuffix(scp[2]).toLowerCase() };
}
try {
const url = new URL(raw);
return { host: url.hostname.toLowerCase(), path: stripGitSuffix(url.pathname).toLowerCase() };
} catch {
return { host: '', path: stripGitSuffix(raw.replace(/\\/g, '/')).toLowerCase() };
}
}
function repositoryKey(repository) {
return String(repository?.full_name || `${repository?.owner?.login || repository?.owner || ''}/${repository?.name || ''}`)
.replace(/^\/+|\/+$/g, '')
.toLowerCase();
}
function matchRemoteToRepository(remote, repositories) {
const normalized = normalizeRemoteUrl(remote);
if (!normalized) return null;
return repositories.find((repository) => {
const key = repositoryKey(repository);
return normalized.path === key || normalized.path.endsWith(`/${key}`);
}) || null;
}
module.exports = { normalizeRemoteUrl, repositoryKey, matchRemoteToRepository };
+32
View File
@@ -0,0 +1,32 @@
'use strict';
function parseVersion(value) {
const match = String(value || '').trim().replace(/^v/i, '').match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
if (!match) return null;
return {
raw: String(value).trim(),
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
prerelease: match[4] || ''
};
}
function compareVersions(leftValue, rightValue) {
const left = parseVersion(leftValue);
const right = parseVersion(rightValue);
if (!left || !right) throw new Error('Both versions must use semantic versioning (for example 1.2.3).');
for (const key of ['major', 'minor', 'patch']) {
if (left[key] !== right[key]) return left[key] > right[key] ? 1 : -1;
}
if (left.prerelease === right.prerelease) return 0;
if (!left.prerelease) return 1;
if (!right.prerelease) return -1;
return left.prerelease.localeCompare(right.prerelease, undefined, { numeric: true }) > 0 ? 1 : -1;
}
function isNewerVersion(candidate, current) {
return compareVersions(candidate, current) > 0;
}
module.exports = { parseVersion, compareVersions, isNewerVersion };
+36
View File
@@ -0,0 +1,36 @@
const path = require('node:path');
function normalizeRelativePosixPath(value) {
if (typeof value !== 'string' || !value.trim()) {
throw new Error('Shell validation path must be a non-empty string.');
}
const trimmed = value.trim();
if (path.isAbsolute(trimmed) || /^[A-Za-z]:[\\/]/.test(trimmed)) {
throw new Error('Shell validation path must be relative to the project root.');
}
const normalized = trimmed.replace(/\\/g, '/').replace(/^\.\//, '');
if (normalized.split('/').some((segment) => segment === '..')) {
throw new Error('Shell validation path may not escape the project root.');
}
return normalized;
}
function bashSyntaxCheckInvocation(root, scriptPath = 'examples/server/forgeflow-deploy') {
if (typeof root !== 'string' || !root.trim()) {
throw new Error('Project root is required for shell validation.');
}
return {
command: 'bash',
args: ['-n', normalizeRelativePosixPath(scriptPath)],
options: {
cwd: root,
encoding: 'utf8',
windowsHide: true
}
};
}
module.exports = {
bashSyntaxCheckInvocation,
normalizeRelativePosixPath
};
+42
View File
@@ -0,0 +1,42 @@
'use strict';
function uniqueCandidates(candidates) {
const seen = new Set();
return candidates.filter((candidate) => {
const key = JSON.stringify([candidate.file, candidate.args]);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function npmProbeCandidates(options = {}) {
const platform = options.platform || process.platform;
const env = options.env || process.env;
const execPath = options.execPath || process.execPath;
const candidates = [];
// npm exposes the exact CLI entry point while running an npm script. Calling
// it through Node avoids Windows' inability to exec .cmd shims directly.
if (env.npm_execpath) {
candidates.push({
file: env.npm_node_execpath || execPath,
args: [env.npm_execpath, '--version'],
source: 'npm_execpath'
});
}
if (platform === 'win32') {
candidates.push({
file: env.ComSpec || env.COMSPEC || 'cmd.exe',
args: ['/d', '/s', '/c', 'npm --version'],
source: 'windows-command-shim'
});
} else {
candidates.push({ file: 'npm', args: ['--version'], source: 'path' });
}
return uniqueCandidates(candidates);
}
module.exports = { npmProbeCandidates };
+125
View File
@@ -0,0 +1,125 @@
'use strict';
const path = require('node:path');
function normalizeBaseUrl(value) {
const raw = String(value || '').trim().replace(/\/+$/, '');
if (!raw) throw new Error('Gitea URL is required.');
const url = new URL(raw);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported.');
if (url.username || url.password) throw new Error('Do not include credentials in the Gitea URL.');
url.hash = '';
url.search = '';
return url.toString().replace(/\/$/, '');
}
function assertSafeRepositoryPath(value) {
if (!value || typeof value !== 'string') throw new Error('A repository path is required.');
if (value.includes('\0')) throw new Error('Invalid repository path.');
return path.resolve(value);
}
function assertRepositoryRelativePath(value) {
const filePath = String(value || '');
if (!filePath || filePath.includes('\0')) throw new Error('A repository-relative file path is required.');
const normalized = filePath.replace(/\\/g, '/');
if (path.posix.isAbsolute(normalized) || /^[a-zA-Z]:\//.test(normalized)) throw new Error('Absolute file paths are not allowed.');
if (normalized.split('/').some((segment) => segment === '..')) throw new Error('File path may not escape the repository.');
return normalized.replace(/^\.\//, '');
}
function assertRepositoryRelativePaths(values) {
if (!Array.isArray(values)) return [];
return [...new Set(values.filter(Boolean).map(assertRepositoryRelativePath))];
}
function assertCommitMessage(value) {
const message = String(value || '').trim();
if (!message) throw new Error('Enter a commit message.');
if (message.length > 5000) throw new Error('Commit message is too long.');
if (message.includes('\0')) throw new Error('Commit message contains an invalid character.');
return message;
}
function assertFullCommitSha(value) {
const sha = String(value || '').trim();
if (!/^[a-f0-9]{40,64}$/i.test(sha)) throw new Error('A full commit SHA is required.');
return sha.toLowerCase();
}
function assertWorkflowFile(value) {
const workflow = assertRepositoryRelativePath(String(value || '').trim());
if (!/^[a-zA-Z0-9._/-]+\.ya?ml$/i.test(workflow)) throw new Error('Workflow file must be a YAML filename.');
return workflow;
}
function assertBranchName(value) {
const branch = String(value || '').trim();
if (!branch) throw new Error('A branch name is required.');
if (branch.length > 255) throw new Error('The branch name is too long.');
if (branch === '@' || branch.startsWith('-') || branch.startsWith('/') || branch.endsWith('/') || branch.endsWith('.')) throw new Error('The branch name is invalid.');
if (branch.includes('..') || branch.includes('@{') || branch.includes('//') || /[\x00-\x20\x7f~^:?*\[\\]/.test(branch)) throw new Error('The branch name is invalid.');
if (branch.split('/').some((part) => !part || part.startsWith('.') || part.endsWith('.lock'))) throw new Error('The branch name is invalid.');
return branch;
}
function assertEnvironmentName(value) {
const environment = String(value || '').trim().toLowerCase();
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(environment)) {
throw new Error('Environment must use 1-64 lowercase letters, numbers, dots, dashes or underscores.');
}
return environment;
}
function assertWorkflowFileName(value) {
const workflow = assertWorkflowFile(value);
if (workflow.includes('/')) throw new Error('Workflow must be a filename from .gitea/workflows, not a path.');
return workflow;
}
function assertDeploymentRequest(profile, sha) {
if (!profile) throw new Error('Deployment profile not found.');
assertFullCommitSha(sha);
assertWorkflowFileName(profile.workflowFile);
assertBranchName(profile.branch);
assertEnvironmentName(profile.environment);
assertHttpUrl(profile.statusUrl, { label: 'Application status URL' });
}
function assertHttpUrl(value, { optional = false, label = 'URL' } = {}) {
const raw = String(value || '').trim();
if (!raw && optional) return '';
if (!raw) throw new Error(`${label} is required.`);
const url = new URL(raw);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error(`${label} must use HTTP or HTTPS.`);
if (url.username || url.password) throw new Error(`${label} may not contain credentials.`);
return url.toString();
}
function assertCloneRemote(value) {
const remote = String(value || '').trim();
if (!remote || remote.includes('\0')) throw new Error('Clone URL is required.');
const scp = /^(?:[^@\s]+@)?[^:\s]+:[^\s]+$/.test(remote) && !remote.includes('://');
if (scp) return remote;
const url = new URL(remote);
if (!['http:', 'https:', 'ssh:', 'git:'].includes(url.protocol)) throw new Error('Unsupported Git remote protocol.');
if (url.password) throw new Error('Do not include a password in the clone URL.');
return remote;
}
module.exports = {
normalizeBaseUrl,
assertSafeRepositoryPath,
assertRepositoryRelativePath,
assertRepositoryRelativePaths,
assertCommitMessage,
assertFullCommitSha,
assertWorkflowFile,
assertWorkflowFileName,
assertBranchName,
assertEnvironmentName,
assertDeploymentRequest,
assertHttpUrl,
assertCloneRemote
};
+91
View File
@@ -0,0 +1,91 @@
'use strict';
const zlib = require('node:zlib');
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let n = 0; n < 256; n += 1) {
let c = n;
for (let k = 0; k < 8; k += 1) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
table[n] = c >>> 0;
}
return table;
})();
function crc32(buffer) {
let crc = 0xffffffff;
for (const byte of buffer) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function dosDateTime(date = new Date()) {
const year = Math.max(1980, date.getFullYear());
const time = (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2);
const day = date.getDate();
const month = date.getMonth() + 1;
const dosDate = ((year - 1980) << 9) | (month << 5) | day;
return { time, date: dosDate };
}
function createZip(entries) {
const localParts = [];
const centralParts = [];
let offset = 0;
const stamp = dosDateTime();
for (const entry of entries) {
const name = Buffer.from(String(entry.name).replace(/\\/g, '/').replace(/^\/+/, ''), 'utf8');
const source = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(String(entry.data ?? ''), 'utf8');
const compressed = zlib.deflateRawSync(source, { level: 6 });
const checksum = crc32(source);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0x0800, 6);
local.writeUInt16LE(8, 8);
local.writeUInt16LE(stamp.time, 10);
local.writeUInt16LE(stamp.date, 12);
local.writeUInt32LE(checksum, 14);
local.writeUInt32LE(compressed.length, 18);
local.writeUInt32LE(source.length, 22);
local.writeUInt16LE(name.length, 26);
local.writeUInt16LE(0, 28);
localParts.push(local, name, compressed);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(20, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(0x0800, 8);
central.writeUInt16LE(8, 10);
central.writeUInt16LE(stamp.time, 12);
central.writeUInt16LE(stamp.date, 14);
central.writeUInt32LE(checksum, 16);
central.writeUInt32LE(compressed.length, 20);
central.writeUInt32LE(source.length, 24);
central.writeUInt16LE(name.length, 28);
central.writeUInt16LE(0, 30);
central.writeUInt16LE(0, 32);
central.writeUInt16LE(0, 34);
central.writeUInt16LE(0, 36);
central.writeUInt32LE(0, 38);
central.writeUInt32LE(offset, 42);
centralParts.push(central, name);
offset += local.length + name.length + compressed.length;
}
const centralDirectory = Buffer.concat(centralParts);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(0, 4);
end.writeUInt16LE(0, 6);
end.writeUInt16LE(entries.length, 8);
end.writeUInt16LE(entries.length, 10);
end.writeUInt32LE(centralDirectory.length, 12);
end.writeUInt32LE(offset, 16);
end.writeUInt16LE(0, 20);
return Buffer.concat([...localParts, centralDirectory, end]);
}
module.exports = { createZip, crc32 };
+91
View File
@@ -0,0 +1,91 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs/promises';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import cloneTargetModule from '../src/shared/clone-target.cjs';
import gitModule from '../src/main/git-service.cjs';
const exec = promisify(execFile);
const { cloneDirectoryName, resolveCloneTarget } = cloneTargetModule;
const { GitService } = gitModule;
test('derives a safe repository folder name from HTTPS and SSH clone URLs', () => {
assert.equal(cloneDirectoryName('https://gitea.example.test/jens/ForgeFlow.git'), 'ForgeFlow');
assert.equal(cloneDirectoryName('git@gitea.example.test:jens/my-app.git'), 'my-app');
assert.equal(cloneDirectoryName('ssh://git@gitea.example.test/jens/app.git?ref=main'), 'app');
});
test('resolves the automatic clone target inside the configured project root', () => {
const root = path.join(os.tmpdir(), 'forgeflow-projects');
const plan = resolveCloneTarget(root, 'https://gitea.example.test/jens/portfolio.git');
assert.equal(plan.root, path.resolve(root));
assert.equal(plan.target, path.join(path.resolve(root), 'portfolio'));
assert.equal(plan.directoryName, 'portfolio');
});
test('clone target inspection accepts missing and empty destinations', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-clone-target-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const service = new GitService();
const remote = 'https://gitea.example.test/jens/app.git';
const missing = await service.inspectCloneTarget(remote, path.join(root, 'missing-app'));
assert.equal(missing.state, 'missing');
const emptyPath = path.join(root, 'empty-app');
await fs.mkdir(emptyPath);
const empty = await service.inspectCloneTarget(remote, emptyPath);
assert.equal(empty.state, 'empty');
});
test('clone target inspection reuses an existing checkout with the same origin', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-clone-reuse-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const target = path.join(root, 'app');
await fs.mkdir(target);
await exec('git', ['init'], { cwd: target, encoding: 'utf8' });
await exec('git', ['remote', 'add', 'origin', 'git@gitea.example.test:jens/app.git'], { cwd: target, encoding: 'utf8' });
const service = new GitService();
const assessment = await service.inspectCloneTarget('https://gitea.example.test/jens/app.git', target);
assert.equal(assessment.state, 'matching-repository');
});
test('clone target inspection blocks a different repository and ordinary files', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-clone-conflict-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const service = new GitService();
const otherRepository = path.join(root, 'repository');
await fs.mkdir(otherRepository);
await exec('git', ['init'], { cwd: otherRepository, encoding: 'utf8' });
await exec('git', ['remote', 'add', 'origin', 'https://gitea.example.test/jens/other.git'], { cwd: otherRepository, encoding: 'utf8' });
await assert.rejects(
service.inspectCloneTarget('https://gitea.example.test/jens/app.git', otherRepository),
(error) => error.code === 'CLONE_TARGET_DIFFERENT_REPOSITORY'
);
const ordinaryFolder = path.join(root, 'ordinary');
await fs.mkdir(ordinaryFolder);
await fs.writeFile(path.join(ordinaryFolder, 'notes.txt'), 'do not overwrite\n');
await assert.rejects(
service.inspectCloneTarget('https://gitea.example.test/jens/app.git', ordinaryFolder),
(error) => error.code === 'CLONE_TARGET_NOT_EMPTY'
);
});
test('clone target inspection blocks a file at the automatic destination', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-clone-file-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const target = path.join(root, 'app');
await fs.writeFile(target, 'not a directory');
const service = new GitService();
await assert.rejects(
service.inspectCloneTarget('https://gitea.example.test/jens/app.git', target),
(error) => error.code === 'CLONE_TARGET_NOT_DIRECTORY'
);
});
+154
View File
@@ -0,0 +1,154 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import deploymentModule from '../src/main/deployment-service.cjs';
const { DeploymentService, terminalRunConclusion, applicationVerificationFailure } = deploymentModule;
test('maps runner conclusions to ForgeFlow terminal states', () => {
assert.equal(terminalRunConclusion({ conclusion: 'success' }), 'success');
assert.equal(terminalRunConclusion({ conclusion: 'failure' }), 'failed');
assert.equal(terminalRunConclusion({ status: 'timed_out' }), 'failed');
assert.equal(terminalRunConclusion({ conclusion: 'cancelled' }), 'cancelled');
assert.equal(terminalRunConclusion({ status: 'running' }), null);
});
test('dispatches only controlled deployment inputs', async () => {
const sha = 'a'.repeat(40);
let dispatched = null;
const operations = new Map();
const profile = {
id: 'staging', name: 'Staging', environment: 'staging', branch: 'main',
workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml',
statusUrl: 'https://app.example.test/.well-known/forgeflow',
inputs: { commit_sha: 'b'.repeat(40), request_id: 'forged', arbitrary: 'ignored' }
};
const store = {
getDeploymentProfile: () => profile,
getToken: () => '',
addOperation: async (operation) => { operations.set(operation.id, structuredClone(operation)); return structuredClone(operation); }
};
const service = new DeploymentService(store, {
dispatchWorkflow: async (payload) => { dispatched = payload; return { accepted: true, status: 204 }; }
}, {
status: async () => ({ head: sha, clean: true, counts: { changed: 0 }, branch: { head: 'main', upstream: 'origin/main', ahead: 0, behind: 0 } }),
verifyCommitOnRemoteBranch: async () => ({ valid: true })
}, { info: async () => {}, error: async () => {} });
const operation = await service.deploy({ repository: { fullName: 'jens/app', localPath: '/repo' }, profileId: profile.id, sha });
assert.deepEqual(Object.keys(dispatched.inputs).sort(), ['commit_sha', 'environment', 'request_id']);
assert.equal(dispatched.inputs.commit_sha, sha);
assert.equal(dispatched.inputs.environment, 'staging');
assert.equal(dispatched.inputs.request_id, operation.id);
assert.equal(dispatched.inputs.arbitrary, undefined);
});
test('requires exact server SHA and matching request ID after a successful workflow', () => {
const operation = { id: 'request-1', repository: 'jens/app', environment: 'staging', sha: 'a'.repeat(40), shortSha: 'aaaaaaa' };
assert.equal(applicationVerificationFailure(operation, {
statusConfigured: true, statusReachable: true, statusRepository: 'jens/app', statusEnvironment: 'staging',
liveSha: operation.sha, requestedSha: operation.sha, requestId: operation.id, lastExitCode: 0, healthy: true
}), null);
assert.match(applicationVerificationFailure(operation, {
statusConfigured: true, statusReachable: true, statusRepository: 'jens/app', statusEnvironment: 'staging',
liveSha: operation.sha, requestedSha: operation.sha, requestId: 'another-request', lastExitCode: 0, healthy: true
}).message, /different deployment request/i);
assert.match(applicationVerificationFailure(operation, {
statusConfigured: true, statusReachable: true, statusRepository: 'jens/app', statusEnvironment: 'staging',
liveSha: 'b'.repeat(40), requestedSha: operation.sha, requestId: operation.id, lastExitCode: 0, healthy: true
}).message, /server reports/i);
assert.match(applicationVerificationFailure(operation, {
statusConfigured: true, statusReachable: false, liveSha: null,
requestId: null, healthy: null
}).message, /not reachable/i);
assert.match(applicationVerificationFailure(operation, {
statusConfigured: true, statusReachable: true, statusRepository: 'other/app', statusEnvironment: 'staging',
liveSha: operation.sha, requestedSha: operation.sha, requestId: operation.id, lastExitCode: 0, healthy: true
}).message, /belongs to other\/app/i);
assert.match(applicationVerificationFailure(operation, {
statusConfigured: true, statusReachable: true, statusRepository: 'jens/app', statusEnvironment: 'staging',
liveSha: operation.sha, requestedSha: operation.sha, requestId: operation.id, lastExitCode: 70, healthy: false
}).message, /exit code 70/i);
});
test('rollback accepts only the currently reported previous SHA and dispatches controlled inputs', async () => {
const liveSha = 'a'.repeat(40);
const previousSha = 'b'.repeat(40);
let dispatched = null;
let verified = null;
const operations = new Map();
const profile = {
id: 'production', name: 'Production', environment: 'production', branch: 'main',
workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml',
statusUrl: 'https://app.example.test/.well-known/forgeflow',
inputs: { target_sha: 'c'.repeat(40), request_id: 'forged', arbitrary: 'ignored' }
};
const store = {
getDeploymentProfile: () => profile,
getToken: () => '',
addOperation: async (operation) => { operations.set(operation.id, structuredClone(operation)); return structuredClone(operation); }
};
const service = new DeploymentService(store, {
listWorkflowRuns: async () => ({ runs: [] }),
dispatchWorkflow: async (payload) => { dispatched = payload; return { accepted: true, status: 204 }; }
}, {
verifyCommitOnRemoteBranch: async (...args) => { verified = args; return { valid: true }; }
}, { info: async () => {}, warning: async () => {}, error: async () => {} });
service.refreshProfileState = async () => ({
statusReachable: true,
statusRepository: 'jens/app',
statusEnvironment: 'production',
liveSha,
previousSha
});
const operation = await service.rollback({
repository: { fullName: 'jens/app', localPath: '/repo' },
profileId: profile.id,
targetSha: previousSha
});
assert.deepEqual(verified, ['/repo', previousSha, 'main']);
assert.deepEqual(Object.keys(dispatched.inputs).sort(), ['environment', 'request_id', 'target_sha']);
assert.equal(dispatched.inputs.environment, 'production');
assert.equal(dispatched.inputs.target_sha, previousSha);
assert.equal(dispatched.inputs.request_id, operation.id);
assert.equal(dispatched.inputs.arbitrary, undefined);
});
test('rollback refuses a stale target that is no longer the server-reported previous SHA', async () => {
const previousSha = 'b'.repeat(40);
let dispatched = false;
const profile = {
id: 'production', name: 'Production', environment: 'production', branch: 'main',
workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml',
statusUrl: 'https://app.example.test/.well-known/forgeflow'
};
const service = new DeploymentService({
getDeploymentProfile: () => profile,
getToken: () => '',
addOperation: async (operation) => operation
}, {
dispatchWorkflow: async () => { dispatched = true; }
}, {
verifyCommitOnRemoteBranch: async () => ({ valid: true })
}, { info: async () => {}, warning: async () => {}, error: async () => {} });
service.refreshProfileState = async () => ({
statusReachable: true,
statusRepository: 'jens/app',
statusEnvironment: 'production',
liveSha: 'a'.repeat(40),
previousSha
});
await assert.rejects(
service.rollback({
repository: { fullName: 'jens/app', localPath: '/repo' },
profileId: profile.id,
targetSha: 'c'.repeat(40)
}),
/no longer the previous server version/i
);
assert.equal(dispatched, false);
});
+75
View File
@@ -0,0 +1,75 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import zlib from 'node:zlib';
import diagnosticsModule from '../src/main/diagnostics-service.cjs';
const { DiagnosticsService } = diagnosticsModule;
function unzipLocalEntries(buffer) {
const entries = new Map();
let offset = 0;
while (offset + 4 <= buffer.length && buffer.readUInt32LE(offset) === 0x04034b50) {
const method = buffer.readUInt16LE(offset + 8);
const compressedSize = buffer.readUInt32LE(offset + 18);
const nameLength = buffer.readUInt16LE(offset + 26);
const extraLength = buffer.readUInt16LE(offset + 28);
const nameStart = offset + 30;
const dataStart = nameStart + nameLength + extraLength;
const name = buffer.subarray(nameStart, nameStart + nameLength).toString('utf8');
const compressed = buffer.subarray(dataStart, dataStart + compressedSize);
entries.set(name, method === 8 ? zlib.inflateRawSync(compressed) : compressed);
offset = dataStart + compressedSize;
}
return entries;
}
test('writes structured local diagnostics and exports a secret-free support bundle', async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-diagnostics-'));
t.after(() => rm(root, { recursive: true, force: true }));
const secret = ['gitea', 'TEST', 'ONLY', 'ULTRA', 'SECRET', '1234567890'].join('_');
const service = new DiagnosticsService({
userDataPath: root,
appInfo: { name: 'ForgeFlow', version: '0.3.0-test' },
secretProvider: () => [secret],
preferencesProvider: () => ({ diagnosticsEnabled: true, diagnosticLevel: 'debug', logRetentionDays: 14, maxLogFileMb: 8 })
});
await service.initialize();
await service.error('test.failure', {
authorization: `token ${secret}`,
password: 'unsafe-password',
message: `request failed with ${secret}`,
path: path.join(os.homedir(), 'private', 'repository')
});
await service.flush();
const status = await service.getStatus();
assert.equal(status.enabled, true);
assert.ok(status.fileCount >= 1);
const raw = (await Promise.all((await service.listLogFiles()).map((file) => readFile(file.path, 'utf8')))).join('\n');
assert.doesNotMatch(raw, new RegExp(secret));
assert.doesNotMatch(raw, /unsafe-password/);
assert.doesNotMatch(raw, new RegExp(os.homedir().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
const destination = path.join(root, 'support.zip');
const result = await service.exportSupportBundle({
destinationPath: destination,
privacyMode: 'strict',
publicState: { gitea: { baseUrl: 'https://gitea.example.test', hasToken: true, encryptedToken: 'ciphertext' }, preferences: {} },
repositories: [{ id: 1, fullName: 'jens/private-repo', localPath: path.join(os.homedir(), 'private-repo'), localStatus: { head: 'a'.repeat(40), branch: { head: 'main' }, counts: {}, clean: true } }],
operations: [{ repository: 'jens/private-repo', status: 'failed', runnerLog: `Authorization: token ${secret}` }],
preflight: { checks: [] }
});
assert.equal(service.isKnownBundlePath(result.path), true);
const entries = unzipLocalEntries(await readFile(destination));
const bundleText = [...entries.values()].map((value) => value.toString('utf8')).join('\n');
assert.doesNotMatch(bundleText, new RegExp(secret));
assert.doesNotMatch(bundleText, /ciphertext|unsafe-password|jens\/private-repo/);
assert.match(entries.get('manifest.json').toString(), /"containsSecrets": false/);
assert.match(entries.get('repositories-sanitized.json').toString(), /fullname-[a-f0-9]{12}/);
const cleared = await service.clear();
assert.ok(cleared.fileCount >= 1, 'clear writes a new safe session marker');
});
+54
View File
@@ -0,0 +1,54 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs/promises';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import gitModule from '../src/main/git-service.cjs';
const exec = promisify(execFile);
const { GitService } = gitModule;
async function git(args, cwd) {
return exec('git', args, { cwd, encoding: 'utf8' });
}
test('GitService reads changes and commits/pushes selected files to a real bare remote', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-git-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const remote = path.join(root, 'remote.git');
const working = path.join(root, 'working');
await git(['init', '--bare', remote], root);
await git(['clone', remote, working], root);
await git(['config', 'user.name', 'ForgeFlow Test'], working);
await git(['config', 'user.email', 'forgeflow@example.invalid'], working);
await fs.writeFile(path.join(working, 'README.md'), '# ForgeFlow\n');
await git(['add', 'README.md'], working);
await git(['commit', '-m', 'Initial commit'], working);
await git(['branch', '-M', 'main'], working);
await git(['push', '-u', 'origin', 'main'], working);
await fs.appendFile(path.join(working, 'README.md'), '\nDesktop release cockpit.\n');
await fs.writeFile(path.join(working, 'feature.txt'), 'new file\n');
const service = new GitService();
const before = await service.status(working);
assert.equal(before.branch.head, 'main');
assert.equal(before.branch.ahead, 0);
assert.equal(before.branch.behind, 0);
assert.equal(before.counts.changed, 2);
assert.deepEqual(new Set(before.files.map((file) => file.path)), new Set(['README.md', 'feature.txt']));
const diff = await service.diff(working, 'README.md');
assert.match(diff, /Desktop release cockpit/);
const result = await service.commitAndPush(working, 'Add desktop cockpit copy', ['README.md', 'feature.txt']);
assert.equal(result.status.clean, true);
assert.equal(result.status.branch.ahead, 0);
assert.match(result.pushOutput, /main/);
const remoteLog = await git(['--git-dir', remote, 'log', '-1', '--pretty=%s', 'refs/heads/main'], root);
assert.equal(remoteLog.stdout.trim(), 'Add desktop cockpit copy');
});
+34
View File
@@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import gitStatus from '../src/shared/git-status.cjs';
const { parsePorcelainV2 } = gitStatus;
test('parses branch metadata and ordinary changes', () => {
const output = [
'# branch.oid 0123456789abcdef',
'# branch.head main',
'# branch.upstream origin/main',
'# branch.ab +2 -1',
'1 .M N... 100644 100644 100644 abc def src/main.js',
'1 M. N... 100644 100644 100644 abc def README.md',
'? new file.txt',
''
].join('\0');
const parsed = parsePorcelainV2(output);
assert.equal(parsed.branch.head, 'main');
assert.equal(parsed.branch.ahead, 2);
assert.equal(parsed.branch.behind, 1);
assert.equal(parsed.counts.changed, 3);
assert.equal(parsed.counts.staged, 1);
assert.equal(parsed.counts.untracked, 1);
assert.equal(parsed.files[0].path, 'src/main.js');
});
test('parses rename records with original path', () => {
const output = '2 R. N... 100644 100644 100644 abc def R100 src/new.js\0src/old.js\0';
const parsed = parsePorcelainV2(output);
assert.equal(parsed.files[0].path, 'src/new.js');
assert.equal(parsed.files[0].originalPath, 'src/old.js');
assert.equal(parsed.files[0].status, 'renamed');
});
+46
View File
@@ -0,0 +1,46 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs/promises';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import gitModule from '../src/main/git-service.cjs';
const exec = promisify(execFile);
const { GitService } = gitModule;
const git = (args, cwd) => exec('git', args, { cwd, encoding: 'utf8' });
test('supports commit-only, branch creation, stash lifecycle and remote SHA verification', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-git-workflow-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const remote = path.join(root, 'remote.git');
const working = path.join(root, 'working');
await git(['init', '--bare', remote], root);
await git(['clone', remote, working], root);
await git(['config', 'user.name', 'ForgeFlow Test'], working);
await git(['config', 'user.email', 'forgeflow@example.invalid'], working);
await fs.writeFile(path.join(working, 'README.md'), '# ForgeFlow\n');
await git(['add', '.'], working);
await git(['commit', '-m', 'Initial'], working);
await git(['branch', '-M', 'main'], working);
await git(['push', '-u', 'origin', 'main'], working);
const service = new GitService();
const branchStatus = await service.createBranch(working, 'feature/release-flow');
assert.equal(branchStatus.branch.head, 'feature/release-flow');
await fs.writeFile(path.join(working, 'release.txt'), 'release cockpit\n');
const committed = await service.commit(working, 'Add release flow', ['release.txt']);
assert.equal(committed.status.branch.ahead, 0, 'unpublished branches have no upstream-based ahead count');
const pushed = await service.push(working);
assert.equal(pushed.status.branch.upstream, 'origin/feature/release-flow');
await service.verifyCommitOnRemoteBranch(working, committed.sha, 'feature/release-flow');
await fs.appendFile(path.join(working, 'release.txt'), 'local draft\n');
await fs.writeFile(path.join(working, 'untracked.txt'), 'draft\n');
const stashed = await service.stash(working, 'Draft release work');
assert.equal(stashed.status.clean, true);
assert.equal(stashed.stashes.length, 1);
const restored = await service.popStash(working, stashed.stashes[0].ref);
assert.equal(restored.status.counts.changed, 2);
});
+90
View File
@@ -0,0 +1,90 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import giteaModule from '../src/main/gitea-service.cjs';
const { GiteaService } = giteaModule;
function makeStore() {
return { data: { gitea: { baseUrl: 'https://gitea.example.test' } }, getToken: () => 'demo-token' };
}
test('normalizes run payloads from different Actions API shapes', () => {
const service = new GiteaService(makeStore());
const run = service.normalizeRun({
task_id: 42,
index: 7,
workflow_name: 'Deploy',
status: 'success',
commit: { sha: 'a'.repeat(40) },
ref: 'refs/heads/main',
workflow_file: '.gitea/workflows/deploy.yml',
start_time: '2026-07-24T12:00:00Z'
});
assert.equal(run.id, 42);
assert.equal(run.runNumber, 7);
assert.equal(run.conclusion, 'success');
assert.equal(run.headSha, 'a'.repeat(40));
assert.equal(run.headBranch, 'refs/heads/main');
});
test('retries Actions runs without optional filters when a server rejects them', async () => {
const service = new GiteaService(makeStore());
const calls = [];
service.request = async (pathname) => {
calls.push(pathname);
if (calls.length === 1) {
const error = new Error('Unsupported query');
error.status = 422;
throw error;
}
return { data: { workflow_runs: [{ id: 11, run_number: 11, status: 'queued', head_sha: 'b'.repeat(40), head_branch: 'main' }] } };
};
const result = await service.listWorkflowRuns({ owner: 'jens', repo: 'app', sha: 'b'.repeat(40), branch: 'main' });
assert.equal(calls.length, 2);
assert.match(calls[0], /head_sha=/);
assert.doesNotMatch(calls[1], /head_sha=/);
assert.equal(result.source, 'runs');
assert.equal(result.runs[0].runNumber, 11);
});
test('falls back to legacy Actions tasks endpoint when runs is unavailable', async () => {
const service = new GiteaService(makeStore());
const calls = [];
service.request = async (pathname) => {
calls.push(pathname);
if (pathname.includes('/runs?')) {
const error = new Error('Not found');
error.status = 404;
throw error;
}
return { data: [{ task_id: 9, index: 3, status: 'running', commit_sha: 'c'.repeat(40), branch: 'main' }] };
};
const result = await service.listWorkflowRuns({ owner: 'jens', repo: 'app' });
assert.equal(result.source, 'tasks');
assert.equal(result.runs[0].id, 9);
assert.ok(calls.some((pathname) => pathname.includes('/tasks?')));
});
test('selects the newest matching workflow run', async () => {
const service = new GiteaService(makeStore());
service.listWorkflowRuns = async () => ({ source: 'runs', runs: [
{ id: 1, headSha: 'd'.repeat(40), headBranch: 'main', workflowPath: 'deploy.yml', createdAt: '2026-07-24T10:00:00Z' },
{ id: 2, headSha: 'd'.repeat(40), headBranch: 'main', workflowPath: '.gitea/workflows/deploy.yml', createdAt: '2026-07-24T11:00:00Z' },
{ id: 3, headSha: 'e'.repeat(40), headBranch: 'main', workflowPath: 'deploy.yml', createdAt: '2026-07-24T12:00:00Z' }
] });
const found = await service.findWorkflowRun({ owner: 'jens', repo: 'app', sha: 'd'.repeat(40), branch: 'main', workflowFile: 'deploy.yml' });
assert.equal(found.run.id, 2);
const excludingNewest = await service.findWorkflowRun({ owner: 'jens', repo: 'app', sha: 'd'.repeat(40), branch: 'main', workflowFile: 'deploy.yml', excludeRunIds: [2] });
assert.equal(excludingNewest.run.id, 1);
});
test('checks repository workflow files through the contents API', async () => {
const service = new GiteaService(makeStore());
const calls = [];
service.request = async (pathname) => { calls.push(pathname); return { data: { type: 'file' } }; };
assert.equal(await service.repositoryFileExists({ owner: 'jens', repo: 'app', filePath: '.gitea/workflows/deploy.yml', ref: 'main' }), true);
assert.match(calls[0], /contents\/\.gitea\/workflows\/deploy\.yml\?ref=main/);
service.request = async () => { const error = new Error('missing'); error.status = 404; throw error; };
assert.equal(await service.repositoryFileExists({ owner: 'jens', repo: 'app', filePath: '.gitea/workflows/missing.yml', ref: 'main' }), false);
});
+47
View File
@@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import redaction from '../src/main/log-redaction.cjs';
const { redactSecrets, sanitizeForDiagnostics, pathAlias, stableAlias } = redaction;
test('redacts runtime credentials, structured secrets, private keys and URL credentials', () => {
const token = ['gitea', 'TEST', 'ONLY', 'SecretToken123456'].join('_');
const input = [
`Authorization: Bearer ${token}`,
`https://${['jens', 'p4ssw0rd'].join(':')}@gitea.example.test/api?access_token=${token}`,
'client_secret=another-secret-value',
['-----BEGIN', 'PRIVATE KEY-----\nsecret-key-material\n-----END PRIVATE KEY-----'].join(' ')
].join('\n');
const output = redactSecrets(input, [token]);
assert.doesNotMatch(output, /ThisIsARealisticSecret|p4ssw0rd|another-secret-value|secret-key-material/);
assert.match(output, /REDACTED/);
});
test('sanitizes nested sensitive keys and aliases user paths', () => {
const value = {
accessToken: 'do-not-keep',
nested: { password: 'do-not-keep-either', path: 'C:\\Users\\Jens\\Projects\\ForgeFlow' },
home: '/home/jens/projects/forgeflow'
};
const sanitized = sanitizeForDiagnostics(value, { homeDir: '/home/jens', cwd: '/work/ForgeFlow' });
assert.equal(sanitized.accessToken, '[REDACTED]');
assert.equal(sanitized.nested.password, '[REDACTED]');
assert.doesNotMatch(JSON.stringify(sanitized), /do-not-keep|Users\\Jens|\/home\/jens/);
assert.match(JSON.stringify(sanitized), /<HOME>/);
});
test('strict privacy mode replaces stable identifiers deterministically', () => {
const first = sanitizeForDiagnostics({ fullName: 'jens/private-project', login: 'jens' }, { strictIdentifiers: true });
const second = sanitizeForDiagnostics({ fullName: 'jens/private-project', login: 'jens' }, { strictIdentifiers: true });
assert.equal(first.fullName, second.fullName);
assert.equal(first.login, second.login);
assert.notEqual(first.fullName, 'jens/private-project');
assert.match(first.fullName, /^fullname-[a-f0-9]{12}$/);
assert.equal(stableAlias('same', 'repo'), stableAlias('same', 'repo'));
});
test('path aliasing handles slash variants', () => {
const result = pathAlias('C:\\Users\\Jens\\src and C:/Users/Jens/src', { homeDir: 'C:\\Users\\Jens', cwd: 'D:\\ForgeFlow' });
assert.doesNotMatch(result, /Users[\\/]Jens/);
assert.match(result, /<HOME>/);
});
+65
View File
@@ -0,0 +1,65 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import preflightModule from '../src/main/preflight-service.cjs';
const { PreflightService, summarize, check } = preflightModule;
test('only required failed checks block readiness', () => {
const summary = summarize([
check('required-pass', 'Required pass', 'pass', 'ok', { required: true }),
check('optional-warning', 'Optional warning', 'warning', 'notice'),
check('optional-fail', 'Optional fail', 'fail', 'not blocking'),
check('required-fail', 'Required fail', 'fail', 'blocked', { required: true })
]);
assert.equal(summary.ready, false);
assert.deepEqual(summary.blocking, ['required-fail']);
assert.equal(summary.counts.warning, 1);
});
test('system preflight can pass before credentials are entered', async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-system-'));
t.after(() => rm(root, { recursive: true, force: true }));
const service = new PreflightService({
store: { data: { gitea: { baseUrl: '' } }, getToken: () => '' },
git: { isAvailable: async () => ({ available: true, version: 'git version test' }) },
gitea: {}, deployments: {},
diagnostics: { logDirectory: path.join(root, 'diagnostics'), info: async () => {} },
userDataPath: path.join(root, 'data'),
secureStorageAvailable: () => true
});
service.gitIdentity = async () => ({ name: 'Jens', email: 'jens@example.test' });
const result = await service.runSystem({ roots: [root] });
assert.equal(result.summary.ready, true);
assert.equal(result.checks.find((item) => item.id === 'gitea.connection').status, 'warning');
assert.equal(result.checks.find((item) => item.id === 'storage.credentials').status, 'pass');
});
test('deployment preflight verifies exact Git, workflow, Actions and server prerequisites', async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-deploy-'));
t.after(() => rm(root, { recursive: true, force: true }));
await mkdir(path.join(root, '.gitea', 'workflows'), { recursive: true });
await writeFile(path.join(root, '.gitea', 'workflows', 'deploy.yml'), 'name: deploy\n');
await writeFile(path.join(root, '.gitea', 'workflows', 'rollback.yml'), 'name: rollback\n');
const sha = 'a'.repeat(40);
const profile = { id: 'production', name: 'Production', environment: 'production', branch: 'main', workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml', statusUrl: 'https://app.example.test/status', healthcheckUrl: 'https://app.example.test/health' };
const service = new PreflightService({
store: { getDeploymentProfile: () => profile },
git: {
status: async () => ({ root, head: sha, clean: true, counts: { changed: 0 }, branch: { head: 'main', upstream: 'origin/main', ahead: 0, behind: 0 } }),
verifyCommitOnRemoteBranch: async () => true
},
gitea: { repositoryFileExists: async () => true, listWorkflowRuns: async () => ({ runs: [] }) },
deployments: {
readStatusEndpoint: async () => ({ configured: true, reachable: true, ok: true, liveSha: sha, status: 200 }),
checkHealth: async () => ({ configured: true, healthy: true, status: 200, latencyMs: 12 })
},
diagnostics: { info: async () => {} }, userDataPath: root
});
const result = await service.runDeployment({ repository: { fullName: 'jens/app', localPath: root }, profileId: profile.id });
assert.equal(result.summary.ready, true);
assert.equal(result.checks.filter((item) => item.status === 'fail').length, 0);
assert.equal(result.head, sha);
});
+24
View File
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
test('changed file list has an independently scrollable bounded layout', async () => {
const css = await readFile(new URL('../src/renderer/styles.css', import.meta.url), 'utf8');
assert.match(css, /\.main-canvas\.repository-canvas\s*\{[^}]*overflow:\s*hidden/);
assert.match(css, /\.file-panel\s*\{[^}]*min-height:\s*0[^}]*overflow:\s*hidden/);
assert.match(css, /\.file-list\s*\{[^}]*flex:\s*1 1 auto[^}]*overflow-y:\s*auto/);
});
test('commit workflow explains every disabled prerequisite', async () => {
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
assert.match(renderer, /Commit message <span class="required-mark">required/);
assert.match(renderer, /Enter a commit message to enable commit and push/);
assert.match(renderer, /ForgeFlow stages the selected files automatically/);
assert.match(renderer, /Commit selected & push to Gitea/);
});
test('ITWorx branding is integrated into titlebar and setup', async () => {
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
assert.match(renderer, /itworx-mark\.png/);
assert.match(renderer, /itworx-wordmark\.png/);
});
+18
View File
@@ -0,0 +1,18 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import matching from '../src/shared/repository-match.cjs';
const { normalizeRemoteUrl, matchRemoteToRepository } = matching;
const repositories = [{ full_name: 'jens/forgeflow', name: 'forgeflow', owner: { login: 'jens' } }];
test('normalizes HTTPS remotes', () => {
assert.deepEqual(normalizeRemoteUrl('https://gitea.internal/jens/forgeflow.git'), { host: 'gitea.internal', path: 'jens/forgeflow' });
});
test('normalizes SCP-style SSH remotes', () => {
assert.deepEqual(normalizeRemoteUrl('git@gitea.internal:jens/forgeflow.git'), { host: 'gitea.internal', path: 'jens/forgeflow' });
});
test('matches local remote to Gitea full name', () => {
assert.equal(matchRemoteToRepository('git@gitea.internal:jens/forgeflow.git', repositories)?.full_name, 'jens/forgeflow');
});
+30
View File
@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import monitorModule from '../src/main/repository-monitor.cjs';
const { RepositoryMonitor } = monitorModule;
test('repository monitor establishes a baseline and emits only on later changes', async () => {
let revision = 1;
const changes = [];
const git = {
status: async (localPath) => ({ localPath, revision }),
statusFingerprint: (status) => String(status.revision)
};
const store = { data: { preferences: { autoRefresh: true, repositoryPollSeconds: 2 } } };
const monitor = new RepositoryMonitor({ store, git, onChange: (change) => changes.push(change) });
monitor.setPaths(['/repo']);
await monitor.tick();
assert.equal(changes.length, 0);
revision = 2;
await monitor.tick();
assert.equal(changes.length, 1);
assert.equal(changes[0].reason, 'working-tree-changed');
monitor.pause('/repo');
revision = 3;
await monitor.tick();
assert.equal(changes.length, 1);
monitor.resume('/repo');
await monitor.tick();
assert.equal(changes.length, 2);
});
+66
View File
@@ -0,0 +1,66 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import validation from '../src/shared/validation.cjs';
import redaction from '../src/main/log-redaction.cjs';
const {
normalizeBaseUrl,
assertRepositoryRelativePath,
assertRepositoryRelativePaths,
assertFullCommitSha,
assertWorkflowFile,
assertWorkflowFileName,
assertBranchName,
assertEnvironmentName,
assertHttpUrl,
assertCloneRemote
} = validation;
const { redactSecrets } = redaction;
test('rejects credentials embedded in service URLs', () => {
assert.throws(() => normalizeBaseUrl(`https://${['jens', 'secret'].join(':')}@gitea.example.test`), /credentials/i);
assert.throws(() => assertHttpUrl(`https://${['user', 'secret'].join(':')}@app.example.test/health`), /credentials/i);
});
test('accepts repository-relative paths but blocks escapes and absolute paths', () => {
assert.equal(assertRepositoryRelativePath('./src/main.ts'), 'src/main.ts');
assert.deepEqual(assertRepositoryRelativePaths(['src/main.ts', 'src/main.ts', 'docs/readme.md']), ['src/main.ts', 'docs/readme.md']);
assert.throws(() => assertRepositoryRelativePath('../secrets.txt'), /escape/i);
assert.throws(() => assertRepositoryRelativePath('/etc/passwd'), /absolute/i);
assert.throws(() => assertRepositoryRelativePath('C:\\Windows\\win.ini'), /absolute/i);
});
test('validates full commit SHAs and workflow filenames', () => {
const sha = 'A'.repeat(40);
assert.equal(assertFullCommitSha(sha), 'a'.repeat(40));
assert.equal(assertWorkflowFile('.gitea/workflows/deploy.yml'), '.gitea/workflows/deploy.yml');
assert.throws(() => assertFullCommitSha('abc1234'), /full commit SHA/i);
assert.throws(() => assertWorkflowFile('../deploy.yml'), /escape/i);
assert.throws(() => assertWorkflowFile('deploy.sh'), /YAML/i);
assert.equal(assertWorkflowFileName('deploy.yml'), 'deploy.yml');
assert.throws(() => assertWorkflowFileName('.gitea/workflows/deploy.yml'), /filename/i);
});
test('allows supported Git remotes and rejects unsafe protocols/passwords', () => {
assert.equal(assertCloneRemote('git@gitea.example.test:jens/app.git'), 'git@gitea.example.test:jens/app.git');
assert.equal(assertCloneRemote('ssh://git@gitea.example.test/jens/app.git'), 'ssh://git@gitea.example.test/jens/app.git');
assert.throws(() => assertCloneRemote('file:///tmp/repo.git'), /unsupported/i);
assert.throws(() => assertCloneRemote(`https://${['jens', 'secret'].join(':')}@gitea.example.test/jens/app.git`), /password/i);
});
test('redacts known tokens, authorization headers, query tokens and URL passwords', () => {
const token = 'super-secret-token';
const source = `Authorization: token ${token}\nhttps://gitea.test/api?access_token=${token}\nhttps://${['jens', 'password'].join(':')}@gitea.test\n${token}`;
const result = redactSecrets(source, [token]);
assert.doesNotMatch(result, /super-secret-token|password/);
assert.match(result, /\[REDACTED\]/);
});
test('validates deployment branch and environment identifiers', () => {
assert.equal(assertBranchName('release/staging'), 'release/staging');
assert.equal(assertEnvironmentName('Production-EU'), 'production-eu');
assert.throws(() => assertBranchName('-dangerous'), /invalid/i);
assert.throws(() => assertBranchName('main..backup'), /invalid/i);
assert.throws(() => assertEnvironmentName('production eu'), /environment/i);
});
+13
View File
@@ -0,0 +1,13 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const { parseVersion, compareVersions, isNewerVersion } = require('../src/shared/semver.cjs');
test('semantic versions are compared without lexical mistakes', () => {
assert.equal(parseVersion('v0.4.0').minor, 4);
assert.equal(compareVersions('0.10.0', '0.9.9'), 1);
assert.equal(compareVersions('1.0.0', '1.0.0'), 0);
assert.equal(isNewerVersion('0.4.1', '0.4.0'), true);
assert.equal(isNewerVersion('0.4.0-beta.1', '0.4.0'), false);
});

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