Release ForgeFlow 0.5.2
This commit is contained in:
+22
-1
@@ -1,4 +1,25 @@
|
||||
## 0.4.4
|
||||
# Changelog
|
||||
|
||||
## 0.5.2
|
||||
|
||||
- Made release verification and built-in update validation independent of Windows Bash shims.
|
||||
- Added portable structural safety validation for the Unraid deployment script.
|
||||
- Kept GNU Bash syntax validation on Linux and other non-Windows systems.
|
||||
|
||||
|
||||
## 0.5.1
|
||||
|
||||
- Fixed Windows publication quality gate by validating Bash syntax through standard input.
|
||||
- Added regression coverage for path-independent shell validation.
|
||||
|
||||
## 0.5.0
|
||||
|
||||
- Viewport-safe scrollable dialogs with persistent actions.
|
||||
- NUL-delimited Git pathspec transport for large selections.
|
||||
- Per-repository mutation serialization and stale lock repair.
|
||||
- Bulk normalization of legacy Gitea origins.
|
||||
- Expanded regression coverage.
|
||||
|
||||
|
||||
- Correctly stage deleted and renamed paths.
|
||||
- Preserve and surface local commits when push fails.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
param(
|
||||
[string]$Remote = "git@gitea.itworx.tech:Jens/ForgeFlow.git",
|
||||
[string]$Branch = "main"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$source = $PSScriptRoot
|
||||
$manifest = Get-Content (Join-Path $source "package.json") -Raw | ConvertFrom-Json
|
||||
if ($manifest.name -ne "forgeflow") { throw "Run this script from an extracted ForgeFlow source release." }
|
||||
$version = [string]$manifest.version
|
||||
$temp = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-publish-" + [guid]::NewGuid().ToString("N"))
|
||||
$clone = Join-Path $temp "ForgeFlow"
|
||||
|
||||
try {
|
||||
Write-Host "Validating ForgeFlow $version before publishing..." -ForegroundColor Cyan
|
||||
Push-Location $source
|
||||
try {
|
||||
& cmd.exe /d /s /c "npm install --no-audit --no-fund"
|
||||
if ($LASTEXITCODE -ne 0) { throw "npm install failed." }
|
||||
& cmd.exe /d /s /c "npm run check"
|
||||
if ($LASTEXITCODE -ne 0) { throw "ForgeFlow quality gate failed." }
|
||||
} finally { Pop-Location }
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $temp | Out-Null
|
||||
Write-Host "Cloning $Remote..." -ForegroundColor Cyan
|
||||
& git clone --branch $Branch --single-branch $Remote $clone
|
||||
if ($LASTEXITCODE -ne 0) { throw "Could not clone the ForgeFlow update repository." }
|
||||
|
||||
& robocopy.exe $source $clone /MIR /R:2 /W:1 /NFL /NDL /NJH /NJS /NP /XD .git node_modules dist /XF *.zip *.sha256
|
||||
if ($LASTEXITCODE -gt 7) { throw "Robocopy failed with exit code $LASTEXITCODE." }
|
||||
|
||||
Push-Location $clone
|
||||
try {
|
||||
& git add -A
|
||||
$changes = & git status --porcelain
|
||||
if (-not $changes) {
|
||||
Write-Host "Gitea already contains ForgeFlow $version; nothing to publish." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
& git commit -m "Release ForgeFlow $version"
|
||||
if ($LASTEXITCODE -ne 0) { throw "Could not create the release commit." }
|
||||
& git push origin $Branch
|
||||
if ($LASTEXITCODE -ne 0) { throw "Could not push ForgeFlow $version to Gitea." }
|
||||
Write-Host "ForgeFlow $version is now available on Gitea for the built-in updater." -ForegroundColor Green
|
||||
} finally { Pop-Location }
|
||||
}
|
||||
finally {
|
||||
Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
@@ -1,5 +1,18 @@
|
||||
# ForgeFlow
|
||||
|
||||
## Publish this release to the built-in updater repository
|
||||
|
||||
Extract the full source ZIP to a folder under Downloads and run:
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy -Scope Process Bypass
|
||||
.\Publish-ForgeFlow-Release.ps1
|
||||
```
|
||||
|
||||
The script runs the complete quality gate, clones `git@gitea.itworx.tech:Jens/ForgeFlow.git` into a temporary folder, mirrors the verified source without `.git`, `node_modules` or release ZIPs, commits it on `main` and pushes it. A running older ForgeFlow source installation can then update through **Settings → Updates**.
|
||||
|
||||
|
||||
|
||||
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:
|
||||
|
||||
@@ -13,9 +26,16 @@ credentials on the user's own computer.
|
||||
|
||||

|
||||
|
||||
## Current status: v0.4.2 integrated desktop test release
|
||||
## Current status: v0.5.2 Windows publication reliability release
|
||||
|
||||
### v0.4.2 workflow and deployment expansion
|
||||
### v0.5.2 publication reliability and v0.5.0 workflow hardening
|
||||
|
||||
- viewport-safe deployment and server dialogs with persistent action buttons;
|
||||
- NUL-delimited Git pathspec transport for thousands of selected files;
|
||||
- per-repository Git mutation queues and explicit stale-lock repair;
|
||||
- one-click normalization of legacy Gitea remotes;
|
||||
- corrected password capture before the loading overlay re-renders the server form;
|
||||
- a verified publishing script for testing the built-in updater.
|
||||
|
||||
- large changed-file sets scroll independently;
|
||||
- commit actions explain the missing prerequisite and auto-stage selected files;
|
||||
|
||||
+21
-17
@@ -1,7 +1,8 @@
|
||||
755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 .gitignore
|
||||
af25ba17d5e51503943c84366c724bf6f32f4b61e4985e4ffd78e1bb54197493 CHANGELOG.md
|
||||
330b5ef8c603a39bc04e2eb5b67351c957d43fb8b60b595e3c055ea5facc2162 CHANGELOG.md
|
||||
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db LICENSE
|
||||
f1a69461c2ea8edec2b0fecc23db196cafccd730a309f0ad84acd5c5110b45ef README.md
|
||||
7769cb56a09533305837c155ce8212c55a5f12a933a9e5f72d6f69853c66e274 Publish-ForgeFlow-Release.ps1
|
||||
2460ec9580231f9786a5f3dfdfdc58e3aecdc57d0b43dfdec3344445275d0350 README.md
|
||||
e5414be56177664a12d31f8d668d6273628c62ae1a608b9f61580c2f9359bbf1 START_HERE.md
|
||||
8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea UPDATE_FROM_0.3.2.md
|
||||
7fbfbba99e6f38029b9a77c8fad8a8e5a91c186306e9e167aae5357c666aab2e build/icon-128.png
|
||||
@@ -28,6 +29,9 @@ e2d67c816a919f00f9e26bf59cf29e5e8cf894536b743d282075c646c5accc96 docs/RELEASE_N
|
||||
1aef74fb109541903c4dbc4d9c48d2bd63507420eaf8cceb31890797a5e4f5fd docs/RELEASE_NOTES_0.4.3.md
|
||||
85fecec65f7687e1382547166eff62777613825a8d81960dfcb4ae16aa15c8be docs/RELEASE_NOTES_0.4.4.md
|
||||
5cd0cffecdce942fb1024a0410568174e7704af9bacbe42f81891693a1817a19 docs/RELEASE_NOTES_0.4.5.md
|
||||
f9554c10f56d41d916330f06175b07f099c9ed1534f00abc9ea7f94be70f4a97 docs/RELEASE_NOTES_0.5.0.md
|
||||
11a932e2c401c53d117175aa312b6d20508918b12192d37c5084bd54a5ea3a72 docs/RELEASE_NOTES_0.5.1.md
|
||||
d7d007e4c2807698db07b2ebe1cb48c36bd162bf4daad77c9d299096c9654d5a docs/RELEASE_NOTES_0.5.2.md
|
||||
c465f1a9c4454c9a18f38f68a243037b8897c2c9929077a586604acd4ff26d35 docs/ROADMAP.md
|
||||
322624242d246d07180cc719e14c91e8fb69e123676a02e5046f4e576cca1ca1 docs/SECURITY.md
|
||||
c79123aa4c718ac3ab0d79771f2967710c28f939b58fca0094b02e3172f2c024 docs/SETUP_GUIDE.md
|
||||
@@ -35,7 +39,7 @@ b5ba1f7580e47e1f01900964b866d9f15b973a9e9dcccf2650f403595020e949 docs/SSH_UNRAI
|
||||
b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 docs/STATUS_ENDPOINT.md
|
||||
0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 docs/STITCH_REVIEW.md
|
||||
08640f1b5e26048b5ae501909f415d2426b07cc316a0bd2178023f2457aa7a2a docs/TEST_MATRIX.md
|
||||
a620b634532b3cc13c17a5e7de35efab1eb34673279d64de86df9b369cf7d65b docs/UPDATING.md
|
||||
6794a12b10ce7f35223862b422c202e5ba0308eabedf03339dea0d54f8f3d191 docs/UPDATING.md
|
||||
1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 docs/screenshots/deploy-confirmation.png
|
||||
b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa docs/screenshots/deployment-run.png
|
||||
070e6700bdae8c628c907ba181bbf0dde0bbbbb4208f7a875503f933ff1b882e docs/screenshots/deployment-success.png
|
||||
@@ -49,57 +53,57 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 examples/gitea
|
||||
106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a examples/server/nginx-forgeflow-status.conf
|
||||
2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a examples/server/status-example.json
|
||||
6e4ef7ec12358d756d2ee6105420a5a440ba8d207a7ee26a35c873c24661d84d main.cjs
|
||||
e5bf62b9bef693dfed707a7771d7a25dab3e781ed52223beadf3322b9c1f53be package.json
|
||||
1e9cfc496c61702a9b083265f08ce451008d5ce8829bb4a447429e6abea3dc89 preload.cjs
|
||||
a8f32272f09ca019c6e9b3c92298b47558c1731ea761ae00d93ae77501b91a7f package.json
|
||||
97af4d20a26dbf4f9643c4ff007bb48840231a245b02dee9c004fcd1fc6d888f preload.cjs
|
||||
a39ab8ac36fc81c37c1718ec620d4e590a1c404d01264e07e3c904a5189bdc27 scripts/apply-source-update.ps1
|
||||
f427dfcd7b5ee7079de13633c8d7d22a91115e0bbc4f2a9a96246f42176d4880 scripts/doctor.mjs
|
||||
444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 scripts/serve-demo.mjs
|
||||
13d27b4dc4217a7e77d69056f23c46382b2bcd6b0642c2f25a743ace8283e5dc scripts/verify.mjs
|
||||
3d6b25c37c92607dbc08b4b6303addb6fb2a28dc2845a5d4eeb9c48deeae7818 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
|
||||
a921c1c3a70ffff78f208b69431c525393bdb524d61ab484e85372f1b7d176dc src/main/git-service.cjs
|
||||
1558fccc76d4eb563940e51b5d483edfe5e0c7987a35ad1dbfcd5f406eb4d44d src/main/git-service.cjs
|
||||
ab7344b1951c87e982cab5c293891bc45dad48a48a3b4b63991b0e76ef785ba6 src/main/gitea-service.cjs
|
||||
2af3674ca1faaae24a8858ec6dc2f4ccb271112de1534e5d49c00ff50047e731 src/main/ipc.cjs
|
||||
2d273442b55d7e3ddf0b1cd4c606a162a4ead03c44028e149976a9e1f4f0470a src/main/ipc.cjs
|
||||
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c src/main/log-redaction.cjs
|
||||
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e src/main/preflight-service.cjs
|
||||
9e35f867b1be78cb850c7872456b2fc00c837b8552c6244057862b7166c26661 src/main/process-runner.cjs
|
||||
1dc0c997bd2d837f3d27dff58a9443888597b7981c8a1dd1eaa4487176ef716c 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
|
||||
63e5b88b5b0a1801ec21d889a7c29ac70767877599c3155e2b06e548f35c4d9e src/renderer/app.js
|
||||
7ec82a1d4f6d74b44f4bf4a9641f66195c07d82937fa3c89c681ba9d751370fb 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
|
||||
37b2dce2a55befd968f589d3ff29b66bb407162c9d0de5e3ffe833ac5c7556ec src/renderer/mock-bridge.js
|
||||
9908a81d4f5d23313eaea042a3593d8107984272698d131ebe4ea246a12f013c 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
|
||||
ede2c95bb045c0005a3931709a0116d9fbcb3faa5f609848a0066c6ba382ca0b 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
|
||||
64f3736e8576536221307a0571ea2769893106a6b6b9887f51f5279312b0a0eb tests/git-integration.test.mjs
|
||||
625d600edf89dadc63e7c7989227d6509bd6f43a298f88804e2ee42afe2036c3 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
|
||||
462fffc71845d6e79f07e1298ef9de7648a8d680087db377ed4901d9ae96a732 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
|
||||
020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 tests/shell-verification.test.mjs
|
||||
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be tests/tool-invocation.test.mjs
|
||||
80f748687bc3fb72812388faf34732c8e71cbccd1f9258bc95b7f32c34eb3a84 tests/unraid-deployment.test.mjs
|
||||
cfc143a618be64456512313f0b244c1310e9c79ce94b7dacb1b627d9de26f750 tests/update-service.test.mjs
|
||||
4d1f0a4c46190ca72b51fddf79ec6d4d02e65fa6f42ef3755de5d414f7da75bb tests/validation.test.mjs
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# ForgeFlow 0.5.0
|
||||
|
||||
## Reliability and viewport release
|
||||
|
||||
- All dialogs are constrained to the visible desktop viewport. Long deployment configuration and preflight content scrolls independently while the action footer remains available.
|
||||
- Large partial selections use Git's NUL-delimited `--pathspec-from-file` interface instead of thousands of command-line arguments. This removes Windows `ENAMETOOLONG` failures.
|
||||
- Mutating Git operations are serialized per repository, preventing ForgeFlow background actions from competing for `.git/index.lock`.
|
||||
- Added explicit stale index-lock inspection and repair APIs.
|
||||
- Added one-click normalization of every linked repository origin to the current Gitea SSH URL, replacing legacy aliases and renamed owners without changing files or commits.
|
||||
- Source updater remains exact-commit pinned and runs the full quality gate before restart.
|
||||
|
||||
## Upgrade test
|
||||
|
||||
Push the extracted source to `Jens/ForgeFlow` with package version `0.5.0`. A running 0.4.5 source installation can then use Settings → Updates → Check now → Download update → Apply & restart.
|
||||
@@ -0,0 +1,16 @@
|
||||
# ForgeFlow 0.5.1
|
||||
|
||||
## Windows publication reliability
|
||||
|
||||
- Validates the server deployment shell script through Bash standard input instead of passing a Windows working directory to Bash.
|
||||
- Removes the Git Bash versus WSL path ambiguity that caused a blank-error quality-gate failure from Downloads.
|
||||
- Adds regression coverage proving shell validation no longer depends on a Windows path or a path containing spaces.
|
||||
- Retains all v0.5.0 viewport, Git batching, remote normalization, repository serialization and password-form fixes.
|
||||
|
||||
Publish the extracted source to `Jens/ForgeFlow` with package version `0.5.1`. A running older source installation can then discover and apply it through the built-in updater.
|
||||
|
||||
## Carried forward from 0.5.0
|
||||
|
||||
- Responsive viewport handling keeps long modals and their actions reachable.
|
||||
- Large Git selections continue to use `--pathspec-from-file` with NUL separation.
|
||||
- Mutating Git work remains serialized per repository.
|
||||
@@ -0,0 +1,11 @@
|
||||
# ForgeFlow 0.5.2
|
||||
|
||||
## Windows publication and update reliability
|
||||
|
||||
- Removes the external Bash executable as a Windows publication/update prerequisite.
|
||||
- Always performs deterministic structural validation of the Linux/Unraid deployment script.
|
||||
- Runs GNU Bash `-n` syntax validation on non-Windows hosts and Linux CI.
|
||||
- Prevents Git Bash, WSL launcher, or another `bash.exe` shim from blocking a valid Windows release.
|
||||
- Keeps all ForgeFlow 0.5.0 and 0.5.1 viewport, Git batching, remote normalization, serialized per repository, lock handling, SSH form, and updater improvements.
|
||||
|
||||
The release retains the viewport fixes, Git `--pathspec-from-file` batching, and Git mutations serialized per repository from 0.5.0/0.5.1.
|
||||
@@ -53,3 +53,8 @@ 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.
|
||||
|
||||
|
||||
## Publishing v0.5.1 from a Downloads folder
|
||||
|
||||
Extract the complete release and run `Publish-ForgeFlow-Release.ps1`. The script validates the source, clones `Jens/ForgeFlow` into a temporary directory, mirrors the verified source, commits it and pushes `main`. Keep the currently running older ForgeFlow source folder untouched; use its **Settings → Updates** screen to test the exact-commit download, validation, rollback and restart path.
|
||||
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "forgeflow",
|
||||
"version": "0.4.5",
|
||||
"version": "0.5.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "forgeflow",
|
||||
"version": "0.4.5",
|
||||
"version": "0.5.2",
|
||||
"dependencies": {
|
||||
"ssh2": "1.17.0"
|
||||
},
|
||||
@@ -3121,9 +3121,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
|
||||
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
|
||||
"integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
|
||||
+9
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "forgeflow",
|
||||
"version": "0.4.5",
|
||||
"version": "0.5.2",
|
||||
"private": true,
|
||||
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
|
||||
"main": "main.cjs",
|
||||
@@ -51,7 +51,13 @@
|
||||
"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"
|
||||
"docs/RELEASE_NOTES_0.4.3.md",
|
||||
"docs/RELEASE_NOTES_0.4.4.md",
|
||||
"docs/RELEASE_NOTES_0.4.5.md",
|
||||
"docs/RELEASE_NOTES_0.5.0.md",
|
||||
"docs/RELEASE_NOTES_0.5.1.md",
|
||||
"Publish-ForgeFlow-Release.ps1",
|
||||
"docs/RELEASE_NOTES_0.5.2.md"
|
||||
],
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
@@ -85,8 +91,5 @@
|
||||
"author": "Jens",
|
||||
"dependencies": {
|
||||
"ssh2": "1.17.0"
|
||||
},
|
||||
"files": [
|
||||
"docs/RELEASE_NOTES_0.4.4.md"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,10 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({
|
||||
stash: (localPath, message) => invoke('repository:stash', { localPath, message }),
|
||||
stashList: (localPath) => invoke('repository:stash-list', { localPath }),
|
||||
popStash: (localPath, ref) => invoke('repository:stash-pop', { localPath, ref }),
|
||||
indexLockInfo: (localPath) => invoke('repository:index-lock', { localPath }),
|
||||
repairIndexLock: (localPath) => invoke('repository:repair-index-lock', { localPath }),
|
||||
setOrigin: (localPath, remoteUrl) => invoke('repository:set-origin', { localPath, remoteUrl }),
|
||||
normalizeOrigins: () => invoke('repositories:normalize-origins'),
|
||||
cloneRepository: (fullName, mode = 'default') => invoke('repository:clone', { fullName, mode }),
|
||||
openPath: (localPath) => invoke('repository:open-path', { localPath }),
|
||||
openExternal: (url) => invoke('external:open', { url }),
|
||||
|
||||
+23
-8
@@ -18,7 +18,9 @@ const required = [
|
||||
'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', 'docs/RELEASE_NOTES_0.4.4.md', 'docs/RELEASE_NOTES_0.4.5.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',
|
||||
'docs/RELEASE_NOTES_0.5.0.md', 'docs/RELEASE_NOTES_0.5.1.md', 'docs/RELEASE_NOTES_0.5.2.md',
|
||||
'Publish-ForgeFlow-Release.ps1', 'docs/RELEASE_NOTES_0.4.4.md', 'docs/RELEASE_NOTES_0.4.5.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',
|
||||
@@ -28,7 +30,7 @@ const required = [
|
||||
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.5') throw new Error(`Expected package version 0.4.5, got ${packageJson.version}.`);
|
||||
if (packageJson.version !== '0.5.2') throw new Error(`Expected package version 0.5.2, 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}.`);
|
||||
@@ -58,16 +60,29 @@ for (const file of javascriptFiles) {
|
||||
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}`);
|
||||
const deploymentScript = await readFile(path.join(root, 'examples/server/forgeflow-deploy'), 'utf8');
|
||||
shellVerification.validateShellScriptStructure(deploymentScript);
|
||||
|
||||
// The server deployment script targets Linux/Unraid. On Windows, different tools may
|
||||
// register themselves as bash.exe (Git Bash, WSL launcher, MSYS), and several of
|
||||
// those cannot reliably accept a script over stdin from Node. Publishing and applying
|
||||
// a desktop update therefore never depend on a Windows Bash shim. Portable structural
|
||||
// validation always runs; GNU Bash syntax validation additionally runs on non-Windows.
|
||||
if (shellVerification.shouldRunExternalBash(process.platform)) {
|
||||
const bashCheck = shellVerification.bashSyntaxCheckFromTextInvocation(deploymentScript);
|
||||
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:
|
||||
${shell.stderr || shell.stdout || 'Bash returned a non-zero status.'}`);
|
||||
} else {
|
||||
console.log('Windows: external Bash syntax validation skipped; portable server-script validation passed.');
|
||||
}
|
||||
|
||||
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.5.md'), 'utf8');
|
||||
const releaseNotes = await readFile(path.join(root, 'docs/RELEASE_NOTES_0.5.2.md'), 'utf8');
|
||||
if (!setupGuide.includes('Gitea access token') || !setupGuide.includes('diagnostic bundle')) {
|
||||
throw new Error('Setup guide is missing required connection or diagnostics instructions.');
|
||||
}
|
||||
@@ -77,7 +92,7 @@ if (!sshGuide.includes('/mnt/user/appdata') || !sshGuide.includes('host-key fing
|
||||
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 ['already staged', 'git add -A', 'silent-zebra-glow.zip']) {
|
||||
for (const phrase of ['viewport', '--pathspec-from-file', 'serialized per repository']) {
|
||||
if (!releaseNotes.includes(phrase)) throw new Error(`Release notes are missing: ${phrase}`);
|
||||
}
|
||||
const renderer = await readFile(path.join(root, 'src/renderer/app.js'), 'utf8');
|
||||
|
||||
@@ -57,6 +57,50 @@ class GitService {
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
|
||||
pathspecInput(paths) {
|
||||
const selected = assertRepositoryRelativePaths(paths);
|
||||
return selected.length ? `${selected.join('\0')}\0` : '';
|
||||
}
|
||||
|
||||
async runWithPathspec(root, args, paths, options = {}) {
|
||||
const selected = assertRepositoryRelativePaths(paths);
|
||||
if (!selected.length) return run('git', args, { cwd: root, ...options });
|
||||
return run('git', [...args, '--pathspec-from-file=-', '--pathspec-file-nul'], {
|
||||
cwd: root,
|
||||
input: this.pathspecInput(selected),
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
async getIndexLockInfo(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const lockPath = path.join(root, '.git', 'index.lock');
|
||||
const stat = await fs.stat(lockPath).catch(() => null);
|
||||
return stat ? { exists: true, lockPath, ageMs: Math.max(0, Date.now() - stat.mtimeMs) } : { exists: false, lockPath, ageMs: 0 };
|
||||
}
|
||||
|
||||
async removeStaleIndexLock(repoPath, minimumAgeMs = 30_000) {
|
||||
const info = await this.getIndexLockInfo(repoPath);
|
||||
if (!info.exists) return { removed: false, reason: 'missing', ...info };
|
||||
if (info.ageMs < minimumAgeMs) {
|
||||
const error = new Error('The Git index lock is recent. Close other Git tools and try again before removing it.');
|
||||
error.code = 'INDEX_LOCK_RECENT';
|
||||
throw error;
|
||||
}
|
||||
await fs.rm(info.lockPath, { force: true });
|
||||
return { removed: true, ...info };
|
||||
}
|
||||
|
||||
async setRemoteUrl(repoPath, remoteUrl, remote = 'origin') {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const safeRemote = assertCloneRemote(remoteUrl);
|
||||
const name = String(remote || 'origin').trim();
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(name)) throw new Error('Invalid Git remote name.');
|
||||
await run('git', ['remote', 'set-url', name, safeRemote], { cwd: root, timeout: 30_000 });
|
||||
return this.status(root);
|
||||
}
|
||||
|
||||
async diff(repoPath, filePath, staged = false) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const safeFile = filePath ? assertRepositoryRelativePath(filePath) : '';
|
||||
@@ -109,7 +153,7 @@ class GitService {
|
||||
// renames are already ready for commit and must therefore be left alone.
|
||||
const selected = await this.expandSelectedPaths(root, requested, { unstagedOnly: true });
|
||||
if (selected.length) {
|
||||
await run('git', ['add', '-A', '--', ...selected], { cwd: root, timeout: 60_000 });
|
||||
await this.runWithPathspec(root, ['add', '-A'], selected, { timeout: 120_000 });
|
||||
}
|
||||
return this.status(root);
|
||||
}
|
||||
@@ -119,9 +163,11 @@ class GitService {
|
||||
const selected = await this.expandSelectedPaths(root, 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 });
|
||||
if (selected.length) await this.runWithPathspec(root, ['restore', '--staged'], selected, { timeout: 120_000 });
|
||||
else await run('git', ['restore', '--staged', '.'], { cwd: root });
|
||||
} else {
|
||||
await run('git', selected.length ? ['rm', '--cached', '--ignore-unmatch', '--', ...selected] : ['rm', '--cached', '-r', '.'], { cwd: root, allowExitCodes: [1] });
|
||||
if (selected.length) await this.runWithPathspec(root, ['rm', '--cached', '--ignore-unmatch'], selected, { timeout: 120_000, allowExitCodes: [1] });
|
||||
else await run('git', ['rm', '--cached', '-r', '.'], { cwd: root, allowExitCodes: [1] });
|
||||
}
|
||||
return this.status(root);
|
||||
}
|
||||
|
||||
+39
-11
@@ -55,11 +55,20 @@ function register(channel, handler) {
|
||||
|
||||
function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh, updates, preflight, diagnostics, monitor }) {
|
||||
diagnosticsService = diagnostics;
|
||||
const repositoryMutations = new Map();
|
||||
const withRepositoryPause = async (localPath, action) => {
|
||||
monitor?.pause(localPath);
|
||||
try { return await action(); }
|
||||
finally { monitor?.resume(localPath); }
|
||||
};
|
||||
const withRepositoryMutation = async (localPath, action) => {
|
||||
const key = path.resolve(localPath);
|
||||
const previous = repositoryMutations.get(key) || Promise.resolve();
|
||||
const current = previous.catch(() => {}).then(() => withRepositoryPause(key, action));
|
||||
repositoryMutations.set(key, current);
|
||||
try { return await current; }
|
||||
finally { if (repositoryMutations.get(key) === current) repositoryMutations.delete(key); }
|
||||
};
|
||||
|
||||
const canonicalPath = async (value) => {
|
||||
const resolved = path.resolve(String(value || ''));
|
||||
@@ -262,20 +271,39 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
|
||||
|
||||
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:stage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.stage(safePath, files)); });
|
||||
register('repository:unstage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.unstage(safePath, files)); });
|
||||
register('repository:commit', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.commit(safePath, message, files)); });
|
||||
register('repository:commit-push', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.commitAndPush(safePath, message, files)); });
|
||||
register('repository:push', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.push(safePath)); });
|
||||
register('repository:fetch', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.fetch(safePath)); });
|
||||
register('repository:pull', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.pullFastForward(safePath)); });
|
||||
register('repository:history', async ({ localPath, limit }) => git.history(await assertKnownRepositoryPath(localPath), limit));
|
||||
register('repository: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:checkout-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.checkoutBranch(safePath, branch)); });
|
||||
register('repository:create-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.createBranch(safePath, branch)); });
|
||||
register('repository:stash', async ({ localPath, message }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.stash(safePath, message)); });
|
||||
register('repository:stash-list', async ({ localPath }) => git.stashList(await assertKnownRepositoryPath(localPath)));
|
||||
register('repository:stash-pop', async ({ localPath, ref }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.popStash(safePath, ref)); });
|
||||
register('repository:stash-pop', async ({ localPath, ref }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.popStash(safePath, ref)); });
|
||||
register('repository:index-lock', async ({ localPath }) => git.getIndexLockInfo(await assertKnownRepositoryPath(localPath)));
|
||||
register('repository:repair-index-lock', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.removeStaleIndexLock(safePath)); });
|
||||
register('repository:set-origin', async ({ localPath, remoteUrl }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.setRemoteUrl(safePath, remoteUrl)); });
|
||||
|
||||
register('repositories:normalize-origins', async () => {
|
||||
const current = await repositories.refresh();
|
||||
const changes = [];
|
||||
for (const repository of current) {
|
||||
if (!repository.localPath || !repository.sshUrl) continue;
|
||||
const actual = await git.getRemoteUrl(repository.localPath).catch(() => '');
|
||||
if (actual === repository.sshUrl) continue;
|
||||
await withRepositoryMutation(repository.localPath, () => git.setRemoteUrl(repository.localPath, repository.sshUrl));
|
||||
changes.push({ fullName: repository.fullName, previous: actual, next: repository.sshUrl });
|
||||
}
|
||||
const refreshed = await repositories.refresh();
|
||||
monitor?.setPaths(repositories.getWatchPaths());
|
||||
await diagnostics.info('repositories.origins.normalized', { count: changes.length, changes });
|
||||
return { changes, repositories: refreshed };
|
||||
});
|
||||
|
||||
register('repository:clone', async ({ fullName, mode = 'default' }) => {
|
||||
if (!['default', 'custom'].includes(mode)) throw new Error('Unsupported clone location mode.');
|
||||
|
||||
@@ -8,11 +8,12 @@ function run(command, args = [], options = {}) {
|
||||
timeout = 60_000,
|
||||
maxBuffer = 8 * 1024 * 1024,
|
||||
env,
|
||||
input = null,
|
||||
allowExitCodes = []
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(command, args, {
|
||||
const child = execFile(command, args, {
|
||||
cwd,
|
||||
timeout,
|
||||
maxBuffer,
|
||||
@@ -21,8 +22,16 @@ function run(command, args = [], options = {}) {
|
||||
env: { ...process.env, ...(env || {}) }
|
||||
}, (error, stdout, stderr) => {
|
||||
if (error && !allowExitCodes.includes(error.code)) {
|
||||
const wrapped = new Error((stderr || stdout || error.message).trim());
|
||||
const message = (stderr || stdout || error.message).trim();
|
||||
const wrapped = new Error(message);
|
||||
wrapped.code = error.code;
|
||||
if (/\.git[\\/]index\.lock[\s\S]*File exists/i.test(message) || /Unable to create .*index\.lock/i.test(message)) {
|
||||
wrapped.code = 'GIT_INDEX_LOCKED';
|
||||
wrapped.recoverable = true;
|
||||
} else if (error.code === 'ENAMETOOLONG') {
|
||||
wrapped.code = 'GIT_ARGUMENT_LIST_TOO_LONG';
|
||||
wrapped.recoverable = true;
|
||||
}
|
||||
wrapped.stdout = stdout;
|
||||
wrapped.stderr = stderr;
|
||||
wrapped.command = `${command} ${args.join(' ')}`;
|
||||
@@ -31,6 +40,10 @@ function run(command, args = [], options = {}) {
|
||||
}
|
||||
resolve({ stdout: stdout || '', stderr: stderr || '', exitCode: error?.code || 0 });
|
||||
});
|
||||
if (input !== null && input !== undefined) {
|
||||
child.stdin.on('error', () => {});
|
||||
child.stdin.end(input);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+38
-7
@@ -453,7 +453,10 @@ function renderGitTools(repository) {
|
||||
|
||||
function renderRepositorySettings(repository) {
|
||||
const automaticTarget = displayCloneTarget(repository);
|
||||
return `<div class="tab-page"><section class="settings-group"><h2>Repository identity</h2><div class="form-grid"><div class="field full"><label>Gitea repository</label><input class="input" value="${attr(repository.fullName)}" readonly/></div><div class="field full"><label>Local working tree</label><input class="input mono" value="${attr(repository.localPath || automaticTarget || 'Not linked')}" readonly/></div></div><div class="card-actions"><button class="button" data-action="${repository.localPath ? 'open-path' : 'link-repo'}">${icon('folder')}${repository.localPath ? 'Open project folder' : 'Link local folder'}</button>${repository.localPath ? `<button class="button danger" data-action="unlink-repo">${icon('link')}Remove link</button>` : `<button class="button primary" data-action="clone-repo">${icon('cloud')}${escapeHtml(clonePrimaryLabel(repository))}</button><button class="button ghost" data-action="clone-repo-custom">Choose another location</button>`}</div></section><section class="settings-group"><h2>Repository behavior</h2><div class="notice">${icon('shield')}ForgeFlow automatically creates a repository-named subfolder and never overwrites a non-empty conflicting folder. Existing matching clones are linked instead.</div></section></div>`;
|
||||
const currentOrigin = repository.localStatus?.remoteUrl || 'Unavailable';
|
||||
const desiredOrigin = repository.sshUrl || repository.preferredCloneUrl || '';
|
||||
const originNeedsRepair = Boolean(repository.localPath && desiredOrigin && currentOrigin !== desiredOrigin);
|
||||
return `<div class="tab-page"><section class="settings-group"><h2>Repository identity</h2><div class="form-grid"><div class="field full"><label>Gitea repository</label><input class="input" value="${attr(repository.fullName)}" readonly/></div><div class="field full"><label>Local working tree</label><input class="input mono" value="${attr(repository.localPath || automaticTarget || 'Not linked')}" readonly/></div><div class="field full"><label>Current origin</label><input class="input mono" value="${attr(currentOrigin)}" readonly/></div>${desiredOrigin ? `<div class="field full"><label>Current Gitea SSH origin</label><input class="input mono" value="${attr(desiredOrigin)}" readonly/></div>` : ''}</div><div class="card-actions"><button class="button" data-action="${repository.localPath ? 'open-path' : 'link-repo'}">${icon('folder')}${repository.localPath ? 'Open project folder' : 'Link local folder'}</button>${originNeedsRepair ? `<button class="button primary" data-action="repair-origin">${icon('link')}Use current Gitea origin</button>` : ''}${repository.localPath ? `<button class="button" data-action="repair-index-lock">${icon('key')}Repair stale Git lock</button><button class="button danger" data-action="unlink-repo">${icon('link')}Remove link</button>` : `<button class="button primary" data-action="clone-repo">${icon('cloud')}${escapeHtml(clonePrimaryLabel(repository))}</button><button class="button ghost" data-action="clone-repo-custom">Choose another location</button>`}</div></section><section class="settings-group"><h2>Repository behavior</h2><div class="notice">${icon('shield')}Origin repair changes only the Git remote URL. Lock repair refuses recent locks and never changes files or commits.</div></section></div>`;
|
||||
}
|
||||
|
||||
function renderRepositoryWorkspace(repository) {
|
||||
@@ -508,6 +511,7 @@ function renderSettings() {
|
||||
<section class="settings-group"><h2>Gitea connection</h2><div class="form-grid"><div class="field full"><label for="settings-gitea-url">Instance URL</label><input id="settings-gitea-url" class="input" value="${attr(state.gitea.baseUrl)}" placeholder="https://gitea.example.com" /></div><div class="field full"><label for="settings-gitea-token">New access token</label><input id="settings-gitea-token" class="input" type="password" placeholder="Leave empty to keep the existing token" /></div></div><div class="connection-card" style="margin-top:10px"><div><strong>${state.gitea.hasToken ? `Connected as ${escapeHtml(state.gitea.user?.login || 'user')}` : 'Not connected'}</strong><div class="queue-sub">${escapeHtml(state.gitea.baseUrl || 'No Gitea instance configured')}</div></div><button class="button primary" data-action="save-gitea-settings">Validate & save</button></div></section>
|
||||
<section class="settings-group"><div class="section-heading"><div><h2>ForgeFlow updates</h2><span class="meta">Secure source update from ${escapeHtml(state.updates?.owner || 'Jens')}/${escapeHtml(state.updates?.repo || 'ForgeFlow')}</span></div><button class="button" data-action="check-updates" ${ui.updateChecking ? 'disabled' : ''}>${icon('update')}${ui.updateChecking ? 'Checking…' : 'Check now'}</button></div><div class="form-grid"><div class="field"><label>Repository owner</label><input id="update-owner" class="input" value="${attr(state.updates?.owner || 'Jens')}"/></div><div class="field"><label>Repository name</label><input id="update-repo" class="input" value="${attr(state.updates?.repo || 'ForgeFlow')}"/></div><div class="field"><label>Release branch</label><input id="update-branch" class="input" value="${attr(state.updates?.branch || 'main')}"/></div><div class="field"><label>Automatic startup check</label><select id="update-auto-check" class="select"><option value="true" ${state.updates?.autoCheck !== false ? 'selected' : ''}>Enabled</option><option value="false" ${state.updates?.autoCheck === false ? 'selected' : ''}>Disabled</option></select></div></div><div class="update-card ${update?.available ? 'available' : ''}"><div>${icon(update?.available ? 'download' : 'check')}<span><strong>${update ? (update.available ? `ForgeFlow ${escapeHtml(update.remoteVersion)} is available` : `ForgeFlow ${escapeHtml(update.currentVersion)} is up to date`) : `Current version ${escapeHtml(ui.boot.appVersion)}`}</strong><small>${update ? `Branch ${escapeHtml(update.branch)} · commit ${escapeHtml(update.shortSha)} · checked ${formatDate(update.checkedAt)}` : 'No update check in this session.'}</small></span></div><div class="stack horizontal compact">${update?.available && !update.downloaded ? `<button class="button primary" data-action="download-update">${icon('download')}Download update</button>` : ''}${update?.downloaded ? `<button class="button success" data-action="apply-update">${icon('update')}Apply & restart</button>` : ''}<button class="button" data-action="save-update-settings">Save update settings</button></div></div><div class="notice" style="margin-top:10px">${icon('shield')}The updater downloads an authenticated ZIP for the exact remote commit, verifies its SHA-256 checksum, runs the complete quality gate and restores the previous source version if validation fails.</div></section>
|
||||
<section class="settings-group"><div class="section-heading"><div><h2>SSH / Unraid servers</h2><span class="meta">Credentials are entered locally and encrypted with the Windows credential protection used by Electron.</span></div><button class="button primary" data-action="open-add-server">${icon('plus')}Add server</button></div>${servers.length ? `<div class="server-list">${servers.map((server) => `<article class="server-card"><div class="server-card-main">${icon('server')}<div><strong>${escapeHtml(server.name)}</strong><span>${escapeHtml(server.username)}@${escapeHtml(server.host)}:${escapeHtml(server.port)} · ${escapeHtml(server.basePath)}</span><small>${server.hostFingerprint ? `Trusted ${escapeHtml(server.hostFingerprint)}` : 'Host identity not trusted yet'}</small></div></div><div class="stack horizontal compact"><button class="button" data-action="test-server" data-server-id="${attr(server.id)}">Test & trust</button><button class="button" data-action="edit-server" data-server-id="${attr(server.id)}">Edit</button><button class="icon-button danger" data-action="delete-server" data-server-id="${attr(server.id)}" title="Delete server">${icon('trash')}</button></div></article>`).join('')}</div>` : '<div class="empty-state compact"><p>No SSH server configured. Add your Unraid server before creating an SSH deployment profile.</p></div>'}</section>
|
||||
<section class="settings-group"><div class="section-heading"><div><h2>Git remote maintenance</h2><span class="meta">Standardize linked repositories to the current Gitea SSH URLs.</span></div><button class="button" data-action="normalize-origins">${icon('link')}Normalize all origins</button></div><p>This replaces legacy aliases and renamed owners only after an explicit click. Local commits and files are not changed.</p></section>
|
||||
<section class="settings-group"><h2>Project roots</h2><p>The first folder is the default clone destination. ForgeFlow automatically creates one subfolder per repository.</p><div class="stack">${state.workspaceRoots.map((root, index) => `<div class="root-row">${index === 0 ? '<span class="status-pill success">Default</span>' : ''}<input class="input" data-root-index="${index}" value="${attr(root)}"/><button class="icon-button" data-action="remove-root" data-index="${index}" title="Remove">${icon('trash')}</button></div>`).join('')}<button class="button" data-action="add-root">${icon('plus')}Add project root</button><button class="button primary" data-action="save-roots">Save folders & rescan</button></div></section>
|
||||
<section class="settings-group"><h2>Background awareness</h2><div class="form-grid"><div class="field"><label>Automatic repository refresh</label><select id="pref-auto-refresh" class="select"><option value="true" ${prefs.autoRefresh !== false ? 'selected' : ''}>Enabled</option><option value="false" ${prefs.autoRefresh === false ? 'selected' : ''}>Disabled</option></select></div><div class="field"><label>Local poll interval</label><input id="pref-repo-poll" class="input" type="number" min="2" max="60" value="${attr(prefs.repositoryPollSeconds || 4)}"/></div><div class="field"><label>Actions poll interval</label><input id="pref-operation-poll" class="input" type="number" min="3" max="120" value="${attr(prefs.operationPollSeconds || 5)}"/></div><div class="field"><label>Preferred clone protocol</label><select id="pref-clone-protocol" class="select"><option value="https" ${prefs.preferredCloneProtocol !== 'ssh' ? 'selected' : ''}>HTTPS</option><option value="ssh" ${prefs.preferredCloneProtocol === 'ssh' ? 'selected' : ''}>SSH</option></select></div></div><button class="button primary" style="margin-top:12px" data-action="save-preferences">Save awareness settings</button></section>
|
||||
<section class="settings-group"><h2>Appearance</h2><select id="appearance-select" class="select"><option value="dark" ${state.appearance === 'dark' ? 'selected' : ''}>Dark</option><option value="light" ${state.appearance === 'light' ? 'selected' : ''}>Light</option><option value="system" ${state.appearance === 'system' ? 'selected' : ''}>Follow system</option></select></section>
|
||||
@@ -584,7 +588,7 @@ function renderModal() {
|
||||
const provider = ui.modal.provider || existing.provider || (servers.length ? 'ssh-unraid' : 'gitea-actions');
|
||||
const ssh = provider === 'ssh-unraid';
|
||||
const remoteFolder = existing.remoteFolder || safeCloneFolderName(repository);
|
||||
return `<div class="modal-backdrop"><section class="modal wide-modal"><header class="modal-header"><h2>${existing.id ? 'Edit' : 'Add'} deployment environment</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="form-grid"><div class="field full"><label>Deployment provider</label><select id="profile-provider" class="select"><option value="ssh-unraid" ${ssh ? 'selected' : ''}>SSH / Unraid · direct controlled deployment</option><option value="gitea-actions" ${!ssh ? 'selected' : ''}>Gitea Actions · runner workflow</option></select></div><div class="field"><label>Profile name</label><input id="profile-name" class="input" value="${attr(existing.name || 'Production')}" /></div><div class="field"><label>Environment</label><input id="profile-environment" class="input" value="${attr(existing.environment || 'production')}" /></div><div class="field"><label>Allowed branch</label><input id="profile-branch" class="input" value="${attr(existing.branch || repository?.defaultBranch || 'main')}" /></div>${ssh ? `
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>${existing.id ? 'Edit' : 'Add'} deployment environment</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="form-grid"><div class="field full"><label>Deployment provider</label><select id="profile-provider" class="select"><option value="ssh-unraid" ${ssh ? 'selected' : ''}>SSH / Unraid · direct controlled deployment</option><option value="gitea-actions" ${!ssh ? 'selected' : ''}>Gitea Actions · runner workflow</option></select></div><div class="field"><label>Profile name</label><input id="profile-name" class="input" value="${attr(existing.name || 'Production')}" /></div><div class="field"><label>Environment</label><input id="profile-environment" class="input" value="${attr(existing.environment || 'production')}" /></div><div class="field"><label>Allowed branch</label><input id="profile-branch" class="input" value="${attr(existing.branch || repository?.defaultBranch || 'main')}" /></div>${ssh ? `
|
||||
<div class="field"><label>Unraid server</label><select id="profile-server" class="select">${servers.length ? servers.map((server) => `<option value="${attr(server.id)}" ${server.id === existing.serverId ? 'selected' : ''}>${escapeHtml(server.name)} · ${escapeHtml(server.host)}</option>`).join('') : '<option value="">Configure a server first</option>'}</select></div>
|
||||
<div class="field"><label>Server folder name</label><input id="profile-remote-folder" class="input" value="${attr(remoteFolder)}"/></div>
|
||||
<div class="field"><label>Git clone URL used by Unraid</label><input id="profile-clone-url" class="input" value="${attr(existing.cloneUrl || repository?.sshUrl || '')}" placeholder="ssh://git@gitea:222/Jens/project.git"/></div>
|
||||
@@ -607,21 +611,21 @@ function renderModal() {
|
||||
if (ui.modal.type === 'deployment-preflight') {
|
||||
const profile = repository?.deploymentProfiles?.find((item) => item.id === ui.modal.profileId) || selectedProfile(repository);
|
||||
const report = ui.deploymentPreflight;
|
||||
return `<div class="modal-backdrop"><section class="modal wide-modal"><header class="modal-header"><h2>Deployment preflight</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="confirm-hero ${report?.summary?.ready ? '' : 'danger'}">${icon(report?.summary?.ready ? 'shield' : 'error')}<div><strong>${report?.summary?.ready ? 'Environment is ready to test' : 'Deployment is blocked'}</strong><span>${escapeHtml(repository?.fullName || '')} → ${escapeHtml(profile?.environment || '')}</span></div></div><div class="preflight-summary"><span class="status-pill ${report?.summary?.ready ? 'success' : 'danger'}">${report?.summary?.ready ? 'Ready' : `${report?.summary?.blocking?.length || 0} blocking`}</span><span>${report?.summary?.counts?.pass || 0} passed · ${report?.summary?.counts?.warning || 0} warnings · ${report?.summary?.counts?.fail || 0} failed</span></div>${renderPreflightChecks(report)}</div><footer class="modal-footer"><button class="button" data-action="close-modal">Close</button>${report?.summary?.ready ? `<button class="button success" data-action="continue-after-preflight" data-profile-id="${attr(profile?.id || '')}">${icon('rocket')}Continue</button>` : `<button class="button" data-action="edit-deployment-profile" data-profile-id="${attr(profile?.id || '')}">Edit environment</button>`}</footer></section></div>`;
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Deployment preflight</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="confirm-hero ${report?.summary?.ready ? '' : 'danger'}">${icon(report?.summary?.ready ? 'shield' : 'error')}<div><strong>${report?.summary?.ready ? 'Environment is ready to test' : 'Deployment is blocked'}</strong><span>${escapeHtml(repository?.fullName || '')} → ${escapeHtml(profile?.environment || '')}</span></div></div><div class="preflight-summary"><span class="status-pill ${report?.summary?.ready ? 'success' : 'danger'}">${report?.summary?.ready ? 'Ready' : `${report?.summary?.blocking?.length || 0} blocking`}</span><span>${report?.summary?.counts?.pass || 0} passed · ${report?.summary?.counts?.warning || 0} warnings · ${report?.summary?.counts?.fail || 0} failed</span></div>${renderPreflightChecks(report)}</div><footer class="modal-footer"><button class="button" data-action="close-modal">Close</button>${report?.summary?.ready ? `<button class="button success" data-action="continue-after-preflight" data-profile-id="${attr(profile?.id || '')}">${icon('rocket')}Continue</button>` : `<button class="button" data-action="edit-deployment-profile" data-profile-id="${attr(profile?.id || '')}">Edit environment</button>`}</footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === 'deploy-confirm') {
|
||||
const profile = repository?.deploymentProfiles?.find((item) => item.id === ui.modal.profileId) || selectedProfile(repository);
|
||||
return `<div class="modal-backdrop"><section class="modal"><header class="modal-header"><h2>Confirm production action</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="confirm-hero">${icon('rocket')}<div><strong>Deploy ${escapeHtml(repository.localStatus.shortHead)} → ${escapeHtml(profile.environment)}</strong><span>${escapeHtml(repository.fullName)}</span></div></div><div class="confirm-grid"><span>Exact commit</span><strong class="mono">${escapeHtml(repository.localStatus.head)}</strong><span>Branch</span><strong>${escapeHtml(profile.branch)}</strong><span>Provider</span><strong>${profile.provider === 'ssh-unraid' ? `SSH → ${escapeHtml(profile.remoteFolder)}` : escapeHtml(profile.workflowFile)}</strong><span>Healthcheck</span><strong>${escapeHtml(profile.healthcheckUrl || 'Not configured')}</strong></div>${ui.deploymentPreflight ? `<div class="notice success" style="margin-top:12px">${icon('shield')}Preflight passed with ${ui.deploymentPreflight.summary.counts.warning} warning(s). Backend safety checks run again at dispatch time.</div>` : ''}</div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button success" data-action="confirm-deploy" data-profile-id="${attr(profile.id)}">Deploy exact commit</button></footer></section></div>`;
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Confirm production action</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="confirm-hero">${icon('rocket')}<div><strong>Deploy ${escapeHtml(repository.localStatus.shortHead)} → ${escapeHtml(profile.environment)}</strong><span>${escapeHtml(repository.fullName)}</span></div></div><div class="confirm-grid"><span>Exact commit</span><strong class="mono">${escapeHtml(repository.localStatus.head)}</strong><span>Branch</span><strong>${escapeHtml(profile.branch)}</strong><span>Provider</span><strong>${profile.provider === 'ssh-unraid' ? `SSH → ${escapeHtml(profile.remoteFolder)}` : escapeHtml(profile.workflowFile)}</strong><span>Healthcheck</span><strong>${escapeHtml(profile.healthcheckUrl || 'Not configured')}</strong></div>${ui.deploymentPreflight ? `<div class="notice success" style="margin-top:12px">${icon('shield')}Preflight passed with ${ui.deploymentPreflight.summary.counts.warning} warning(s). Backend safety checks run again at dispatch time.</div>` : ''}</div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button success" data-action="confirm-deploy" data-profile-id="${attr(profile.id)}">Deploy exact commit</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === 'rollback-confirm') {
|
||||
const profile = repository?.deploymentProfiles?.find((item) => item.id === ui.modal.profileId);
|
||||
const target = profile?.state?.previousSha;
|
||||
return `<div class="modal-backdrop"><section class="modal"><header class="modal-header"><h2>Confirm rollback</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="confirm-hero danger">${icon('undo')}<div><strong>Rollback ${escapeHtml(profile?.environment || '')} to ${shortSha(target)}</strong><span>The target must still exist on origin/${escapeHtml(profile?.branch || '')}.</span></div></div><div class="confirm-grid"><span>Target commit</span><strong class="mono">${escapeHtml(target || 'Unavailable')}</strong><span>Provider</span><strong>${profile?.provider === 'ssh-unraid' ? 'SSH exact-SHA reset' : escapeHtml(profile?.rollbackWorkflowFile || 'Not configured')}</strong><span>Current live</span><strong class="mono">${escapeHtml(profile?.state?.liveSha || 'Unknown')}</strong></div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button danger" data-action="confirm-rollback" data-profile-id="${attr(profile?.id || '')}" ${target ? '' : 'disabled'}>Rollback exact commit</button></footer></section></div>`;
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Confirm rollback</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="confirm-hero danger">${icon('undo')}<div><strong>Rollback ${escapeHtml(profile?.environment || '')} to ${shortSha(target)}</strong><span>The target must still exist on origin/${escapeHtml(profile?.branch || '')}.</span></div></div><div class="confirm-grid"><span>Target commit</span><strong class="mono">${escapeHtml(target || 'Unavailable')}</strong><span>Provider</span><strong>${profile?.provider === 'ssh-unraid' ? 'SSH exact-SHA reset' : escapeHtml(profile?.rollbackWorkflowFile || 'Not configured')}</strong><span>Current live</span><strong class="mono">${escapeHtml(profile?.state?.liveSha || 'Unknown')}</strong></div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button danger" data-action="confirm-rollback" data-profile-id="${attr(profile?.id || '')}" ${target ? '' : 'disabled'}>Rollback exact commit</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === 'server-config') {
|
||||
const server = (ui.boot.state.servers || []).find((item) => item.id === ui.modal.serverId) || {};
|
||||
const authType = ui.modal.authType || server.authType || 'privateKey';
|
||||
return `<div class="modal-backdrop"><section class="modal wide-modal"><header class="modal-header"><h2>${server.id ? 'Edit' : 'Add'} SSH / Unraid server</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="form-grid"><div class="field"><label>Name</label><input id="server-name" class="input" value="${attr(server.name || 'Unraid')}"/></div><div class="field"><label>Host or IP</label><input id="server-host" class="input" value="${attr(server.host || '')}" placeholder="192.168.1.10"/></div><div class="field"><label>SSH port</label><input id="server-port" class="input" type="number" min="1" max="65535" value="${attr(server.port || 22)}"/></div><div class="field"><label>Username</label><input id="server-username" class="input" value="${attr(server.username || 'root')}"/></div><div class="field"><label>Authentication</label><select id="server-auth-type" class="select"><option value="privateKey" ${authType === 'privateKey' ? 'selected' : ''}>Private key · recommended</option><option value="password" ${authType === 'password' ? 'selected' : ''}>Password</option></select></div><div class="field"><label>Appdata base path</label><input id="server-base-path" class="input" value="${attr(server.basePath || '/mnt/user/appdata')}"/></div>${authType === 'privateKey' ? `<div class="field full"><label>Private key file</label><div class="input-action"><input id="server-private-key" class="input" value="${attr(server.privateKeyPath || '')}" placeholder="C:\\Users\\Jens\\.ssh\\id_ed25519"/><button class="button" data-action="select-private-key">Browse</button></div></div><div class="field full"><label>Private key passphrase</label><input id="server-passphrase" class="input" type="password" placeholder="${server.hasPassphrase ? 'Leave empty to keep stored passphrase' : 'Only when the key is encrypted'}"/></div>` : `<div class="field full"><label>SSH password</label><input id="server-password" class="input" type="password" placeholder="${server.hasPassword ? 'Leave empty to keep stored password' : 'Password'}"/></div>`}<div class="field full"><label>Trusted host fingerprint</label><input id="server-fingerprint" class="input mono" value="${attr(server.hostFingerprint || '')}" readonly placeholder="Filled automatically after Test & trust"/></div></div><div class="notice warning" style="margin-top:12px">${icon('key')}The first connection records the SSH host-key fingerprint. Later deployments fail closed when the server presents a different key.</div></div><footer class="modal-footer">${server.id ? `<button class="button danger" data-action="delete-server" data-server-id="${attr(server.id)}">Delete</button>` : ''}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-server" data-server-id="${attr(server.id || '')}">Save server</button></footer></section></div>`;
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>${server.id ? 'Edit' : 'Add'} SSH / Unraid server</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="form-grid"><div class="field"><label>Name</label><input id="server-name" class="input" value="${attr(server.name || 'Unraid')}"/></div><div class="field"><label>Host or IP</label><input id="server-host" class="input" value="${attr(server.host || '')}" placeholder="192.168.1.10"/></div><div class="field"><label>SSH port</label><input id="server-port" class="input" type="number" min="1" max="65535" value="${attr(server.port || 22)}"/></div><div class="field"><label>Username</label><input id="server-username" class="input" value="${attr(server.username || 'root')}"/></div><div class="field"><label>Authentication</label><select id="server-auth-type" class="select"><option value="privateKey" ${authType === 'privateKey' ? 'selected' : ''}>Private key · recommended</option><option value="password" ${authType === 'password' ? 'selected' : ''}>Password</option></select></div><div class="field"><label>Appdata base path</label><input id="server-base-path" class="input" value="${attr(server.basePath || '/mnt/user/appdata')}"/></div>${authType === 'privateKey' ? `<div class="field full"><label>Private key file</label><div class="input-action"><input id="server-private-key" class="input" value="${attr(server.privateKeyPath || '')}" placeholder="C:\\Users\\Jens\\.ssh\\id_ed25519"/><button class="button" data-action="select-private-key">Browse</button></div></div><div class="field full"><label>Private key passphrase</label><input id="server-passphrase" class="input" type="password" placeholder="${server.hasPassphrase ? 'Leave empty to keep stored passphrase' : 'Only when the key is encrypted'}"/></div>` : `<div class="field full"><label>SSH password</label><input id="server-password" class="input" type="password" placeholder="${server.hasPassword ? 'Leave empty to keep stored password' : 'Password'}"/></div>`}<div class="field full"><label>Trusted host fingerprint</label><input id="server-fingerprint" class="input mono" value="${attr(server.hostFingerprint || '')}" readonly placeholder="Filled automatically after Test & trust"/></div></div><div class="notice warning" style="margin-top:12px">${icon('key')}The first connection records the SSH host-key fingerprint. Later deployments fail closed when the server presents a different key.</div></div><footer class="modal-footer">${server.id ? `<button class="button danger" data-action="delete-server" data-server-id="${attr(server.id)}">Delete</button>` : ''}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-server" data-server-id="${attr(server.id || '')}">Save server</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === 'command-palette') return renderCommandPalette();
|
||||
return '';
|
||||
@@ -950,9 +954,11 @@ app.addEventListener('click', async (event) => {
|
||||
privateKeyPath: document.querySelector('#server-private-key')?.value.trim() || '',
|
||||
hostFingerprint: document.querySelector('#server-fingerprint').value.trim()
|
||||
};
|
||||
const password = document.querySelector('#server-password')?.value || '';
|
||||
const passphrase = document.querySelector('#server-passphrase')?.value || '';
|
||||
setLoading(true, 'Saving encrypted SSH configuration…');
|
||||
try {
|
||||
const result = await window.forgeflow.saveServer(server, document.querySelector('#server-password')?.value || '', document.querySelector('#server-passphrase')?.value || '');
|
||||
const result = await window.forgeflow.saveServer(server, password, passphrase);
|
||||
ui.boot.state = result.state; ui.modal = null; showToast('Server saved', 'Run Test & trust before creating a deployment.', 'success');
|
||||
} catch (error) { showToast('Could not save server', error.message, 'error'); }
|
||||
setLoading(false);
|
||||
@@ -973,6 +979,31 @@ app.addEventListener('click', async (event) => {
|
||||
else if (action === 'save-roots') { const roots = [...document.querySelectorAll('[data-root-index]')].map((input) => input.value.trim()).filter(Boolean); setLoading(true, 'Saving workspace folders…'); try { ui.boot.state = await window.forgeflow.setWorkspaceRoots(roots); await refreshRepositories(false); showToast('Folders saved', 'Repository discovery has been refreshed.', 'success'); } catch (error) { showToast('Could not save folders', error.message, 'error'); } setLoading(false); }
|
||||
else if (action === 'save-gitea-settings') { const baseUrl = document.querySelector('#settings-gitea-url').value.trim(); const token = document.querySelector('#settings-gitea-token').value.trim(); setLoading(true, 'Validating Gitea…'); try { const result = await window.forgeflow.updateGitea({ baseUrl, token }); ui.boot.state = result.state; await refreshRepositories(false); showToast('Gitea connected', `Signed in as ${result.validation.user.login}.`, 'success'); } catch (error) { showToast('Connection failed', error.message, 'error'); } setLoading(false); }
|
||||
else if (action === 'save-preferences') { const preferences = { autoRefresh: document.querySelector('#pref-auto-refresh').value === 'true', repositoryPollSeconds: Number(document.querySelector('#pref-repo-poll').value), operationPollSeconds: Number(document.querySelector('#pref-operation-poll').value), preferredCloneProtocol: document.querySelector('#pref-clone-protocol').value }; setLoading(true, 'Saving background settings…'); try { ui.boot.state = await window.forgeflow.setPreferences(preferences); await refreshRepositories(false); showToast('Settings saved', 'Background awareness has been updated.', 'success'); } catch (error) { showToast('Could not save settings', error.message, 'error'); } setLoading(false); }
|
||||
else if (action === 'repair-origin') {
|
||||
if (!repository?.localPath || !repository.sshUrl) return;
|
||||
if (!confirm(`Replace origin with ${repository.sshUrl}? Local files and commits are not changed.`)) return;
|
||||
setLoading(true, 'Updating Git origin…');
|
||||
try { await window.forgeflow.setOrigin(repository.localPath, repository.sshUrl); await refreshRepositories(false); showToast('Git origin updated', repository.sshUrl, 'success'); }
|
||||
catch (error) { showToast('Could not update origin', error.message, 'error'); }
|
||||
setLoading(false);
|
||||
}
|
||||
else if (action === 'normalize-origins') {
|
||||
if (!confirm('Replace legacy origin URLs for every linked repository with the current Gitea SSH URL? Local files and commits are not changed.')) return;
|
||||
setLoading(true, 'Normalizing linked Git origins…');
|
||||
try {
|
||||
const result = await window.forgeflow.normalizeOrigins();
|
||||
ui.repositories = result.repositories;
|
||||
showToast('Git origins normalized', `${result.changes.length} repository origin${result.changes.length === 1 ? '' : 's'} updated.`, 'success');
|
||||
} catch (error) { showToast('Could not normalize origins', error.message, 'error'); }
|
||||
setLoading(false); render();
|
||||
}
|
||||
else if (action === 'repair-index-lock') {
|
||||
if (!repository?.localPath || !confirm('Remove the stale Git index.lock for this repository? Only continue after other Git tools have stopped.')) return;
|
||||
setLoading(true, 'Repairing stale Git lock…');
|
||||
try { await window.forgeflow.repairIndexLock(repository.localPath); await refreshRepositories(false); showToast('Git lock removed', 'The repository can accept Git changes again.', 'success'); }
|
||||
catch (error) { showToast('Could not remove Git lock', error.message, 'error'); }
|
||||
setLoading(false);
|
||||
}
|
||||
else if (action === 'run-system-preflight') await runSystemPreflight();
|
||||
else if (action === 'save-diagnostics-preferences') {
|
||||
const preferences = {
|
||||
|
||||
@@ -250,7 +250,7 @@
|
||||
}
|
||||
|
||||
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 bootstrap() { await wait(80); snapshot(); return { appVersion: '0.5.1-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 = [
|
||||
@@ -269,9 +269,9 @@
|
||||
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 checkForUpdates() { await wait(300); return { checkedAt: iso(), owner: state.updates.owner, repo: state.updates.repo, branch: state.updates.branch, currentVersion: '0.5.1', remoteVersion: '0.5.2', 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 applyUpdate() { await wait(200); return { launched: true, version: '0.5.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) }; },
|
||||
@@ -297,6 +297,10 @@
|
||||
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 indexLockInfo() { return { exists: false, ageMs: 0 }; },
|
||||
async repairIndexLock() { return { removed: true }; },
|
||||
async setOrigin(localPath, remoteUrl) { const repo = findRepo(localPath); repo.localStatus.remoteUrl = remoteUrl; repo.sshUrl = remoteUrl; emitRepositories(); return clone(repo.localStatus); },
|
||||
async normalizeOrigins() { const changes = []; repositories.filter((repo) => repo.localPath && repo.sshUrl).forEach((repo) => { if (repo.localStatus.remoteUrl !== repo.sshUrl) { changes.push({ fullName: repo.fullName, previous: repo.localStatus.remoteUrl, next: repo.sshUrl }); repo.localStatus.remoteUrl = repo.sshUrl; } }); emitRepositories(); return { changes, repositories: snapshot() }; },
|
||||
async cloneRepository(fullName, mode = 'default') {
|
||||
await wait(620);
|
||||
const repository = repositories.find((item) => item.fullName === fullName);
|
||||
|
||||
+25
-5
@@ -326,12 +326,12 @@ html[data-theme="light"] .diff-line.remove { color: #a31f1f; }
|
||||
.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-backdrop { position: fixed; inset: 0; z-index: 60; display: grid; align-items: center; justify-items: center; padding: clamp(8px,2vh,20px); overflow: auto; overscroll-behavior: contain; background: rgba(4,7,12,.7); backdrop-filter: blur(4px); }
|
||||
.modal { width: min(520px,94vw); max-height: calc(100dvh - clamp(16px,4vh,40px)); min-height: 0; display: flex; flex-direction: column; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-1); box-shadow: var(--shadow); overflow: hidden; }
|
||||
.modal-header { flex: 0 0 auto; display: flex; justify-content: space-between; align-items: center; padding: 15px 17px; border-bottom: 1px solid var(--line); background: var(--surface-1); }
|
||||
.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); }
|
||||
.modal-body { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding: 17px; }
|
||||
.modal-footer { flex: 0 0 auto; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; padding: 12px 17px; border-top: 1px solid var(--line-soft); background: var(--surface-0); box-shadow: 0 -8px 18px rgba(0,0,0,.08); }
|
||||
|
||||
.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; }
|
||||
@@ -533,3 +533,23 @@ kbd { min-width: 24px; padding: 2px 5px; border: 1px solid var(--line); border-b
|
||||
}
|
||||
|
||||
.setup-brand-logo { width: 150px; height: auto; display: block; margin-bottom: 12px; }
|
||||
|
||||
|
||||
/* v0.5 viewport-safe dialogs */
|
||||
.modal-body > .preflight-list:last-child { margin-bottom: 2px; }
|
||||
.modal-footer .button { flex: 0 0 auto; }
|
||||
@media (max-height: 720px) {
|
||||
.modal-backdrop { align-items: start; }
|
||||
.modal { max-height: calc(100dvh - 16px); }
|
||||
.modal-header { padding-block: 11px; }
|
||||
.modal-body { padding-block: 13px; }
|
||||
.modal-footer { padding-block: 10px; }
|
||||
}
|
||||
@media (max-width: 680px) {
|
||||
.modal-backdrop { padding: 0; align-items: stretch; }
|
||||
.modal, .wide-modal { width: 100vw; max-height: 100dvh; border-radius: 0; }
|
||||
.modal-footer .modal-spacer { display: none; }
|
||||
.modal-footer .button { flex: 1 1 auto; }
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.field.full, .check-field.full { grid-column: 1; }
|
||||
}
|
||||
|
||||
@@ -30,7 +30,61 @@ function bashSyntaxCheckInvocation(root, scriptPath = 'examples/server/forgeflow
|
||||
};
|
||||
}
|
||||
|
||||
function bashSyntaxCheckFromTextInvocation(scriptText) {
|
||||
if (typeof scriptText !== 'string' || !scriptText.trim()) {
|
||||
throw new Error('Shell script text is required for syntax validation.');
|
||||
}
|
||||
return {
|
||||
command: 'bash',
|
||||
args: ['-n'],
|
||||
options: {
|
||||
input: scriptText,
|
||||
encoding: 'utf8',
|
||||
windowsHide: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function shouldRunExternalBash(platform = process.platform) {
|
||||
return platform !== 'win32';
|
||||
}
|
||||
|
||||
function validateShellScriptStructure(scriptText) {
|
||||
if (typeof scriptText !== 'string' || !scriptText.trim()) {
|
||||
throw new Error('Shell script text is required for structural validation.');
|
||||
}
|
||||
if (scriptText.includes('\0')) {
|
||||
throw new Error('Shell script may not contain NUL bytes.');
|
||||
}
|
||||
const normalized = scriptText.replace(/\r\n/g, '\n');
|
||||
const firstLine = normalized.split('\n', 1)[0];
|
||||
if (!/^#!\/(?:usr\/bin\/env bash|bin\/bash)$/.test(firstLine)) {
|
||||
throw new Error('Server deployment script must declare Bash in its shebang.');
|
||||
}
|
||||
if (!/^set -E?euo pipefail$/m.test(normalized)) {
|
||||
throw new Error('Server deployment script must enable strict Bash error handling.');
|
||||
}
|
||||
for (const marker of [
|
||||
'readonly CONFIG_FILE="/etc/forgeflow/targets.conf"',
|
||||
'Target configuration must be owned by root',
|
||||
'flock -n 9',
|
||||
'git -C "$APP_DIR" fetch',
|
||||
'git -C "$APP_DIR" reset --hard "$SHA"',
|
||||
'docker compose -f "$COMPOSE_FILE" up -d --build --remove-orphans',
|
||||
'write_status "healthy"',
|
||||
'write_status "unhealthy"'
|
||||
]) {
|
||||
if (!normalized.includes(marker)) {
|
||||
throw new Error(`Server deployment script is missing required safety marker: ${marker}`);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
bashSyntaxCheckInvocation,
|
||||
normalizeRelativePosixPath
|
||||
bashSyntaxCheckFromTextInvocation,
|
||||
normalizeRelativePosixPath,
|
||||
shouldRunExternalBash,
|
||||
validateShellScriptStructure
|
||||
};
|
||||
|
||||
@@ -152,3 +152,29 @@ test('keeps a successful local commit visible as ahead when the following push f
|
||||
const subject = await git(['log', '-1', '--pretty=%s'], working);
|
||||
assert.equal(subject.stdout.trim(), 'Update portfolio');
|
||||
});
|
||||
|
||||
|
||||
test('stages a large Windows-sized partial selection through NUL-delimited stdin', async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-git-large-selection-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const working = path.join(root, 'working');
|
||||
await fs.mkdir(working);
|
||||
await git(['init'], working);
|
||||
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'), '# Large selection\n');
|
||||
await git(['add', '.'], working);
|
||||
await git(['commit', '-m', 'Initial'], working);
|
||||
|
||||
const names = [];
|
||||
for (let index = 0; index < 850; index += 1) {
|
||||
const name = `generated/feature-${String(index).padStart(4, '0')}-${'x'.repeat(28)}.txt`;
|
||||
names.push(name);
|
||||
await fs.mkdir(path.dirname(path.join(working, name)), { recursive: true });
|
||||
await fs.writeFile(path.join(working, name), `file ${index}\n`);
|
||||
}
|
||||
const service = new GitService();
|
||||
const status = await service.stage(working, names);
|
||||
assert.equal(status.counts.staged, names.length);
|
||||
assert.equal(status.counts.unstaged, 0);
|
||||
});
|
||||
|
||||
@@ -22,3 +22,35 @@ test('ITWorx branding is integrated into titlebar and setup', async () => {
|
||||
assert.match(renderer, /itworx-mark\.png/);
|
||||
assert.match(renderer, /itworx-wordmark\.png/);
|
||||
});
|
||||
|
||||
|
||||
test('all modal content stays inside the viewport with a persistent action footer', async () => {
|
||||
const css = await readFile(new URL('../src/renderer/styles.css', import.meta.url), 'utf8');
|
||||
assert.match(css, /\.modal\s*\{[^}]*max-height:\s*calc\(100dvh[^}]*display:\s*flex[^}]*flex-direction:\s*column/);
|
||||
assert.match(css, /\.modal-body\s*\{[^}]*min-height:\s*0[^}]*overflow-y:\s*auto/);
|
||||
assert.match(css, /\.modal-footer\s*\{[^}]*flex:\s*0 0 auto/);
|
||||
});
|
||||
|
||||
test('settings provides one-click normalization for legacy Gitea origins', async () => {
|
||||
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
|
||||
assert.match(renderer, /data-action="normalize-origins"/);
|
||||
assert.match(renderer, /Normalize all origins/);
|
||||
});
|
||||
|
||||
|
||||
test('Git mutations are serialized per repository and expose repair actions', async () => {
|
||||
const ipc = await readFile(new URL('../src/main/ipc.cjs', import.meta.url), 'utf8');
|
||||
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
|
||||
assert.match(ipc, /repositoryMutations = new Map/);
|
||||
assert.match(ipc, /withRepositoryMutation/);
|
||||
assert.match(renderer, /data-action="repair-index-lock"/);
|
||||
assert.match(renderer, /data-action="repair-origin"/);
|
||||
});
|
||||
|
||||
|
||||
test('SSH secrets are captured before the loading render clears password inputs', async () => {
|
||||
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
|
||||
const passwordCapture = renderer.indexOf("const password = document.querySelector('#server-password')");
|
||||
const loading = renderer.indexOf("setLoading(true, 'Saving encrypted SSH configuration…')");
|
||||
assert.ok(passwordCapture >= 0 && loading > passwordCapture);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { spawnSync } from 'node:child_process';
|
||||
import test from 'node:test';
|
||||
import shellVerification from '../src/shared/shell-verification.cjs';
|
||||
|
||||
const { bashSyntaxCheckInvocation, normalizeRelativePosixPath } = shellVerification;
|
||||
const { bashSyntaxCheckInvocation, bashSyntaxCheckFromTextInvocation, normalizeRelativePosixPath, validateShellScriptStructure, shouldRunExternalBash } = shellVerification;
|
||||
|
||||
test('Bash syntax validation keeps Windows project roots in cwd and passes a relative POSIX path', () => {
|
||||
const invocation = bashSyntaxCheckInvocation('C:\\Projects\\ForgeFlow');
|
||||
@@ -47,3 +47,57 @@ test('Bash syntax validation works from a project root containing spaces', async
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('Bash syntax validation from text does not depend on a Windows working directory', () => {
|
||||
const invocation = bashSyntaxCheckFromTextInvocation('#!/usr/bin/env bash\nset -euo pipefail\necho ok\n');
|
||||
assert.equal(invocation.command, 'bash');
|
||||
assert.deepEqual(invocation.args, ['-n']);
|
||||
assert.equal(invocation.options.cwd, undefined);
|
||||
assert.match(invocation.options.input, /set -euo pipefail/);
|
||||
});
|
||||
|
||||
test('Bash syntax validation from text detects malformed scripts', (t) => {
|
||||
if (spawnSync('bash', ['--version'], { encoding: 'utf8' }).status !== 0) {
|
||||
t.skip('Bash is not available in this environment.');
|
||||
return;
|
||||
}
|
||||
const invocation = bashSyntaxCheckFromTextInvocation('if true; then\n echo missing fi\n');
|
||||
const result = spawnSync(invocation.command, invocation.args, invocation.options);
|
||||
assert.notEqual(result.status, 0);
|
||||
});
|
||||
|
||||
|
||||
test('portable server-script validation does not require a local Bash executable', () => {
|
||||
const script = `#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
readonly CONFIG_FILE="/etc/forgeflow/targets.conf"
|
||||
echo "Target configuration must be owned by root"
|
||||
APP_DIR=/tmp/app
|
||||
SHA=0123456789012345678901234567890123456789
|
||||
COMPOSE_FILE=docker-compose.yml
|
||||
write_status() { :; }
|
||||
exec 9>/tmp/test.lock
|
||||
flock -n 9
|
||||
git -C "$APP_DIR" fetch origin main
|
||||
git -C "$APP_DIR" reset --hard "$SHA"
|
||||
docker compose -f "$COMPOSE_FILE" up -d --build --remove-orphans
|
||||
write_status "healthy"
|
||||
write_status "unhealthy"
|
||||
`;
|
||||
assert.equal(validateShellScriptStructure(script), true);
|
||||
});
|
||||
|
||||
test('portable server-script validation refuses missing deployment safety markers', () => {
|
||||
assert.throws(
|
||||
() => validateShellScriptStructure('#!/usr/bin/env bash\nset -Eeuo pipefail\necho unsafe\n'),
|
||||
/missing required safety marker/
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
test('Windows publication never depends on an external Bash shim', () => {
|
||||
assert.equal(shouldRunExternalBash('win32'), false);
|
||||
assert.equal(shouldRunExternalBash('linux'), true);
|
||||
assert.equal(shouldRunExternalBash('darwin'), true);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import toolInvocation from '../src/shared/tool-invocation.cjs';
|
||||
import processRunner from '../src/main/process-runner.cjs';
|
||||
|
||||
const { npmProbeCandidates } = toolInvocation;
|
||||
const { run } = processRunner;
|
||||
|
||||
test('uses npm CLI through Node when doctor is launched by npm on Windows', () => {
|
||||
const candidates = npmProbeCandidates({
|
||||
@@ -39,3 +41,9 @@ test('uses npm directly on non-Windows systems', () => {
|
||||
{ file: 'npm', args: ['--version'], source: 'path' }
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
test('process runner accepts stdin for Git pathspec transport', async () => {
|
||||
const result = await run(process.execPath, ['-e', 'process.stdin.pipe(process.stdout)'], { input: 'a\0b\0' });
|
||||
assert.equal(result.stdout, 'a\0b\0');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user