diff --git a/CHANGELOG.md b/CHANGELOG.md index a2e80c1..cc1f997 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.8.3 - 2026-07-26 + +- enriched light mode with layered color, depth and stronger navigation hierarchy; +- made every deployment visually identifiable by container, repository, environment and stable accent color; +- added live-versus-Gitea commit proof directly to deployment cards; +- reconciled stale failed operations against healthy live Unraid and current Gitea truth. + ## 0.8.2 - 2026-07-26 - enabled checksum-verified binary auto-update for installed and portable Windows builds; @@ -35,7 +42,6 @@ - Fixed Windows PowerShell 5.1 updater status replacement and STARTED handshake. - Added helper-log diagnostics and update-request identity validation. - ## 0.6.0 - Added complete repository troubleshooting for stale `HEAD.lock`, `index.lock`, ref locks and diverged branches. @@ -57,7 +63,6 @@ - Preserved configured Compose casing such as Portfolio. - Guaranteed failed remote operations become terminal failed records. - ## 0.5.3 - Confirmed updater handoff before application exit. @@ -70,7 +75,6 @@ - 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. @@ -84,7 +88,6 @@ - Bulk normalization of legacy Gitea origins. - Expanded regression coverage. - - Correctly stage deleted and renamed paths. - Preserve and surface local commits when push fails. - Always refresh the actual Git state after operation errors. @@ -156,6 +159,7 @@ - Added safe workflow and server deployment examples. ## 0.4.5 + - Fixed commit/push after manually staging a deleted file. - Already staged deletions and renames are no longer re-added as missing pathspecs. - Added a real bare-remote regression test for `silent-zebra-glow.zip`. diff --git a/SOURCE_MANIFEST.txt b/SOURCE_MANIFEST.txt index ac6c960..b5d7770 100644 --- a/SOURCE_MANIFEST.txt +++ b/SOURCE_MANIFEST.txt @@ -1,4 +1,4 @@ -ForgeFlow 0.8.2 source manifest +ForgeFlow 0.8.3 source manifest SHA-256 BYTES PATH (The manifest excludes itself, dependencies and generated release artifacts.) 755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 58 .gitignore @@ -12,7 +12,7 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263 4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183 3364 build/icon-64.png 25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png -973e58a92fb2fdaff471dbe7a20549c9fc79e6014933cd56dce971ad7d474bd4 8234 CHANGELOG.md +02c39f04f01394f687eac1d8bae0cdd85018d8e95ff941b6ce6d9f9d7b19d697 8606 CHANGELOG.md 21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md 30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md @@ -40,6 +40,7 @@ f3d04f2d3419a7a010d5399cdd9351ff85ab2b3fdf8023977e559b0a5f8bcdc3 2571 d7bdc61d9b617ad5acf0b2d468eda547fd7509d4af08f33d2661393f25bdcb5a 576 docs/RELEASE_NOTES_0.8.0.md 1e056bfcf2105843402f4b14c63480240cc55456a4a63e229b3fdbaf3156b803 754 docs/RELEASE_NOTES_0.8.1.md 7f1d7c8bc895d309dad2f8ab444d6d2ba3e68c8daa240fecd2abdd9d8a56ba20 729 docs/RELEASE_NOTES_0.8.2.md +c2802fa5dbff392c846b82b55e84a8bfb8e1625546fd1eba39f7129318bece96 654 docs/RELEASE_NOTES_0.8.3.md 2b631b9d6d973bdd70869d84886ff339da351e29e17598970b3b27915674661d 4175 docs/ROADMAP.md 1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png @@ -63,8 +64,8 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE 6765015bdf27b288a250192272750b243c3cb8d1326b752d056d1e43317b6344 12935 main.cjs 91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md -ebb0b154137c113205351216e79dc0aad79949ea465b1734f41dd524f913cb34 134141 package-lock.json -7b4d634271b7b312e8b6e7337a0c8e55eb7281803a38847f26907a5b4576517f 3684 package.json +8f1b45693760790cca87c0643a0e43adb38b1b7182a592b0ac5e1f3883d47566 130466 package-lock.json +17ec2a34f0182f0752212765674afd79df9aa47c6b833065b943743737ce6bc8 3721 package.json 3d2ac366a13e9418e3ec6d13ce95b611f30f0228eb3a80ef9e7a936ce9578e24 9080 preload.cjs b31c43d9355c13b5ae4efc0f3649d8cb8d509b2bb7ebb042ff546b7820fb7de8 8411 Publish-ForgeFlow-Release.ps1 a6d32a742412b7836606be00f17be0465f1b6f55d3911f6c73a14029787ba206 14037 README.md @@ -77,7 +78,7 @@ f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287 74433d8a6b24afe368197a469e2fe0c5050c239d7250b84c2f3f598c304778b0 4736 scripts/publish-binary-release.cjs 444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs 42203f9e0fd4aae517284d387f265cf1b0b180379bc253a092b5c3c5c4caef0a 2992 scripts/validate-installed-connections.cjs -d67596aa0b4ffbd456a816ff464533e0138819f97b0f0660152b5966b9ad58bc 12178 scripts/verify.mjs +a2f19d7c45132973b3db3a56b57bd4a8e34de98e7c446b19cfaf3c4904fe5715 12209 scripts/verify.mjs 0079701b5acbfef07b71a9623613d1940805ccd20649d77e3f34c37e79df7655 735 scripts/write-release-checksums.mjs 619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1 dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs @@ -88,23 +89,23 @@ c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs 0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs f5b4e468c92eb0d96d02357290ac4bfe2d30eef9c7287267882bc146237a8693 16559 src/main/gitea-service.cjs -b2d768a9dfd1e494edee6609a233469e60c31c362143d5c37c3c9f908b7bca79 40559 src/main/ipc.cjs +79593a28b8f40f48a73028be34464f94be2e4c67a027a3f5da78a33bcadc76eb 45539 src/main/ipc.cjs 62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs 958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs 3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508 src/main/repository-monitor.cjs 17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs b31a63bf8cb1807b3e838e2bf8a0e742738f119d13de8ca9f42e471f072217d3 8328 src/main/ssh-service.cjs -720103f14cbedd7fd2776e49fd970a634f14e03d548d90bf93bcd878b6b3c674 58758 src/main/unraid-deployment-service.cjs +8cf5013de91737dd9345b121586ceec38a6fc8518648975d60593d81fc225c4b 67376 src/main/unraid-deployment-service.cjs 36cc05deda3395e5e9de92b880c8315f508cb88e9b080ae34705057ae696804f 20696 src/main/update-service.cjs -2df4cd7b7d685871e40ce86ce4f75d8451cfdfca6184ce6cae5eaa6651ce97da 191018 src/renderer/app.js +982039f1243c3e48b589b8ad6f8fe712b5b8dde3d23f6f171ad31b09ff1a70c2 192055 src/renderer/app.js 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png 094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png e1c463d6cda9f2b9b78c468845c0a7e8688f0362be5642074a1a5f7122dfe811 762 src/renderer/index.html -d72bca0e649392dbf5c0e9c676dd08b80d5c9f4493f59a32d9201763c139b690 50942 src/renderer/mock-bridge.js -51f6777fd7d2dc73dc3ddd96c91a11882483099b0a62e0ce172e25e3dce474a6 59851 src/renderer/styles.css +68e565f0dc3d8a7fbce0d57fc55535c0fe022fd96ebd716d1adf279f995b0256 50922 src/renderer/mock-bridge.js +09c832aaea5994558eddcd17b20e7edd6840df5fc80e50354c627c41989711d9 63138 src/renderer/styles.css 0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs 5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs 029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs @@ -132,7 +133,7 @@ e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs 96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 3718 tests/preflight.test.mjs -9b56c259cbf6c45c671267343c0871d56ac336082c531050b44fbcfca7476f62 6989 tests/renderer-workflow.test.mjs +128a3b87c26db9edd4413d6ab8c06bdfada311d2135d342ea298ebea89e40712 7061 tests/renderer-workflow.test.mjs 2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 1068 tests/repository-monitor.test.mjs 75b5b83836c75675bb9a48fe4363fcb8a24fc425e6af6f822d7955c6f3c79eac 2265 tests/repository-service.test.mjs @@ -140,7 +141,7 @@ d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463 bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs 020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs 8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs -54f641103a91d98974c41a3c0a568c617b76fa9913a0e20710cd11adc39b0deb 18092 tests/unraid-deployment.test.mjs +05c791ea262aef85a7c79874ed900c5d792a78778c577cb803b88a433cd6e2b6 21413 tests/unraid-deployment.test.mjs 0c49d3222ea362dbef171f5ad556a335bad670b47adef660ede101d84814d05e 14849 tests/update-service.test.mjs 9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs 7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs diff --git a/docs/RELEASE_NOTES_0.8.3.md b/docs/RELEASE_NOTES_0.8.3.md new file mode 100644 index 0000000..4352d11 --- /dev/null +++ b/docs/RELEASE_NOTES_0.8.3.md @@ -0,0 +1,13 @@ +# ForgeFlow 0.8.3 + +ForgeFlow 0.8.3 refreshes the complete light appearance with richer surfaces, +subtle color, clearer depth and stronger active states. + +Deployment cards now lead with the exact container identity, repository, +environment and a stable visual accent. Live and Gitea commits are shown side by +side, with an explicit confirmation when both sources agree. + +Deployment reconciliation now revisits stale failed records. When the requested +commit is healthy on Unraid it is corrected to success. When a newer commit is +both live and current on Gitea, the old failure is marked as superseded instead +of remaining an apparently current incident. diff --git a/package-lock.json b/package-lock.json index c515a71..9055845 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "forgeflow", - "version": "0.8.2", + "version": "0.8.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "forgeflow", - "version": "0.8.2", + "version": "0.8.3", "dependencies": { "ssh2": "1.17.0" }, diff --git a/package.json b/package.json index 64675de..c85bcc3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "forgeflow", - "version": "0.8.2", + "version": "0.8.3", "private": true, "description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.", "main": "main.cjs", @@ -75,6 +75,7 @@ "docs/RELEASE_NOTES_0.8.0.md", "docs/RELEASE_NOTES_0.8.1.md", "docs/RELEASE_NOTES_0.8.2.md", + "docs/RELEASE_NOTES_0.8.3.md", "docs/ACCEPTANCE.md" ], "asarUnpack": [ diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 5086dd4..0ed22f8 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -63,6 +63,7 @@ const required = [ "docs/RELEASE_NOTES_0.8.0.md", "docs/RELEASE_NOTES_0.8.1.md", "docs/RELEASE_NOTES_0.8.2.md", + "docs/RELEASE_NOTES_0.8.3.md", "docs/UPDATING.md", "docs/DIAGNOSTICS.md", "docs/DEPLOYMENT_SETUP.md", @@ -101,9 +102,9 @@ for (const file of required) await access(path.join(root, file)); const packageJson = JSON.parse( await readFile(path.join(root, "package.json"), "utf8"), ); -if (packageJson.version !== "0.8.2") +if (packageJson.version !== "0.8.3") throw new Error( - `Expected package version 0.8.2, got ${packageJson.version}.`, + `Expected package version 0.8.3, got ${packageJson.version}.`, ); const sourceManifest = await readFile( path.join(root, "SOURCE_MANIFEST.txt"), @@ -368,7 +369,7 @@ for (const phrase of [ "deriveDetectedProfile", "docker inspect", "net.unraid.docker.managed", - "'dockerman'", + "dockerman", "iconCacheRefresh", "[PORT:", "Superseded by live commit", diff --git a/src/main/ipc.cjs b/src/main/ipc.cjs index 5e0f749..6b67999 100644 --- a/src/main/ipc.cjs +++ b/src/main/ipc.cjs @@ -1,37 +1,50 @@ -'use strict'; +"use strict"; -const path = require('node:path'); -const fs = require('node:fs/promises'); -const { fileURLToPath } = require('node:url'); -const { ipcMain, dialog, shell, app } = require('electron'); -const { matchRemoteToRepository } = require('../shared/repository-match.cjs'); -const { cloneDirectoryName, resolveCloneTarget } = require('../shared/clone-target.cjs'); -const { createEncryptedBackup, readEncryptedBackup } = require('./configuration-backup.cjs'); -const { evaluateDeploymentPolicy } = require('../shared/deployment-policy.cjs'); +const path = require("node:path"); +const fs = require("node:fs/promises"); +const { fileURLToPath } = require("node:url"); +const { ipcMain, dialog, shell, app } = require("electron"); +const { matchRemoteToRepository } = require("../shared/repository-match.cjs"); +const { + cloneDirectoryName, + resolveCloneTarget, +} = require("../shared/clone-target.cjs"); +const { + createEncryptedBackup, + readEncryptedBackup, +} = require("./configuration-backup.cjs"); +const { evaluateDeploymentPolicy } = require("../shared/deployment-policy.cjs"); let diagnosticsService = null; -const TRUSTED_RENDERER_PATH = path.resolve(__dirname, '..', 'renderer', 'index.html'); +const TRUSTED_RENDERER_PATH = path.resolve( + __dirname, + "..", + "renderer", + "index.html", +); function toErrorPayload(error) { return { - message: error?.message || 'Unknown error', + message: error?.message || "Unknown error", code: error?.code || null, status: error?.status || null, recoverable: Boolean(error?.recoverable), - commitSha: error?.commitSha || null + commitSha: error?.commitSha || null, }; } function assertTrustedSender(event) { - const url = event?.senderFrame?.url || event?.sender?.getURL?.() || ''; + const url = event?.senderFrame?.url || event?.sender?.getURL?.() || ""; try { const parsed = new URL(url); - if (parsed.protocol !== 'file:') throw new Error('not a file URL'); + if (parsed.protocol !== "file:") throw new Error("not a file URL"); const senderPath = path.resolve(fileURLToPath(parsed)); - const normalize = (value) => process.platform === 'win32' ? value.toLowerCase() : value; - if (normalize(senderPath) !== normalize(TRUSTED_RENDERER_PATH)) throw new Error('unexpected renderer file'); + const normalize = (value) => + process.platform === "win32" ? value.toLowerCase() : value; + if (normalize(senderPath) !== normalize(TRUSTED_RENDERER_PATH)) + throw new Error("unexpected renderer file"); } catch { - throw new Error('Rejected IPC request from an untrusted renderer origin.'); + throw new Error("Rejected IPC request from an untrusted renderer origin."); } } @@ -41,13 +54,22 @@ function register(channel, handler) { try { assertTrustedSender(event); const data = await handler(payload || {}, event); - await diagnosticsService?.debug('ipc.completed', { channel, durationMs: Date.now() - started }); - return { ok: true, data }; - } catch (error) { - await diagnosticsService?.error('ipc.failed', { + await diagnosticsService?.debug("ipc.completed", { channel, durationMs: Date.now() - started, - error: { name: error?.name, message: error?.message, code: error?.code, status: error?.status, stack: error?.stack } + }); + return { ok: true, data }; + } catch (error) { + await diagnosticsService?.error("ipc.failed", { + channel, + durationMs: Date.now() - started, + error: { + name: error?.name, + message: error?.message, + code: error?.code, + status: error?.status, + stack: error?.stack, + }, }); console.error(`[${channel}]`, error); return { ok: false, error: toErrorPayload(error) }; @@ -55,20 +77,39 @@ function register(channel, handler) { }); } -function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh, updates, preflight, diagnostics, audit, externalTools, monitor, onPreferencesChanged }) { +function registerIpc({ + store, + git, + gitea, + repositories, + deployments, + unraid, + ssh, + updates, + preflight, + diagnostics, + audit, + externalTools, + monitor, + onPreferencesChanged, +}) { diagnosticsService = diagnostics; const repositoryMutations = new Map(); const withRepositoryPause = async (localPath, action) => { monitor?.pause(localPath); - try { return await action(); } - finally { monitor?.resume(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 execute = async () => { - try { return await withRepositoryPause(key, action); } - catch (error) { + try { + return await withRepositoryPause(key, action); + } catch (error) { if (!git.isGitLockError(error)) throw error; let repair = null; let lockDiagnosis = null; @@ -76,10 +117,12 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh repair = await git.repairStaleGitLocks(key, { minimumAgeMs: 2_000 }); } catch (repairError) { lockDiagnosis = repairError; - if (repairError?.code === 'GIT_LOCKS_RECENT') { + if (repairError?.code === "GIT_LOCKS_RECENT") { await new Promise((resolve) => setTimeout(resolve, 2_500)); try { - repair = await git.repairStaleGitLocks(key, { minimumAgeMs: 2_000 }); + repair = await git.repairStaleGitLocks(key, { + minimumAgeMs: 2_000, + }); lockDiagnosis = null; } catch (retryError) { lockDiagnosis = retryError; @@ -87,18 +130,25 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh } } if (!repair?.repaired) throw lockDiagnosis || error; - await diagnostics.info('git.lock.auto-repaired', { localPath: key, locks: repair.removed.map((item) => item.name) }); + await diagnostics.info("git.lock.auto-repaired", { + localPath: key, + locks: repair.removed.map((item) => item.name), + }); return withRepositoryPause(key, action); } }; const current = previous.catch(() => {}).then(execute); repositoryMutations.set(key, current); - try { return await current; } - finally { if (repositoryMutations.get(key) === current) repositoryMutations.delete(key); } + try { + return await current; + } finally { + if (repositoryMutations.get(key) === current) + repositoryMutations.delete(key); + } }; const canonicalPath = async (value) => { - const resolved = path.resolve(String(value || '')); + const resolved = path.resolve(String(value || "")); return fs.realpath(resolved).catch(() => resolved); }; @@ -110,32 +160,45 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh knownPaths = repositories.getWatchPaths(); } const canonicalKnown = await Promise.all(knownPaths.map(canonicalPath)); - if (!canonicalKnown.some((known) => known === candidate)) throw new Error('The requested local repository is not linked or discovered by ForgeFlow.'); + if (!canonicalKnown.some((known) => known === candidate)) + throw new Error( + "The requested local repository is not linked or discovered by ForgeFlow.", + ); return candidate; }; const resolveRepository = async (repositoryPayload) => { - const fullName = String(repositoryPayload?.fullName || '').trim(); - if (!fullName) throw new Error('Repository identity is required.'); - const current = (await repositories.refresh()).find((item) => item.fullName === fullName); - if (!current) throw new Error('The repository is no longer available through the configured Gitea account.'); + const fullName = String(repositoryPayload?.fullName || "").trim(); + if (!fullName) throw new Error("Repository identity is required."); + const current = (await repositories.refresh()).find( + (item) => item.fullName === fullName, + ); + if (!current) + throw new Error( + "The repository is no longer available through the configured Gitea account.", + ); return current; }; - const assertProjectRoot = async (rootValue) => { const root = await canonicalPath(rootValue); const stat = await fs.stat(root).catch(() => null); - if (!stat?.isDirectory()) throw new Error('The selected project root no longer exists.'); + if (!stat?.isDirectory()) + throw new Error("The selected project root no longer exists."); return root; }; const cloneRepositoryInto = async (fullName, projectRoot) => { const current = await resolveRepository({ fullName }); - if (current.localPath) throw new Error('This repository already has a linked local folder.'); + if (current.localPath) + throw new Error("This repository already has a linked local folder."); - const remoteUrl = current.preferredCloneUrl || current.cloneUrl || current.sshUrl; - if (!remoteUrl) throw new Error('Gitea did not provide a usable clone URL for this repository.'); + const remoteUrl = + current.preferredCloneUrl || current.cloneUrl || current.sshUrl; + if (!remoteUrl) + throw new Error( + "Gitea did not provide a usable clone URL for this repository.", + ); const root = await assertProjectRoot(projectRoot); const { target } = resolveCloneTarget(root, remoteUrl); @@ -144,283 +207,601 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh await store.saveMapping(current.fullName, target); const result = await repositories.refresh(); monitor?.setPaths(repositories.getWatchPaths()); - await diagnostics.info(status.reused ? 'repository.clone.reused' : 'repository.cloned', { - fullName: current.fullName, - projectRoot: root, - target, - head: status.head, - branch: status.branch?.head - }); + await diagnostics.info( + status.reused ? "repository.clone.reused" : "repository.cloned", + { + fullName: current.fullName, + projectRoot: root, + target, + head: status.head, + branch: status.branch?.head, + }, + ); return { target, status, reused: Boolean(status.reused), repositories: result, - state: store.getPublicState() + state: store.getPublicState(), }; }; - register('app:bootstrap', async () => ({ + register("app:bootstrap", async () => ({ appVersion: app.getVersion(), platform: process.platform, state: store.getPublicState(), git: await git.isAvailable(), diagnostics: await diagnostics.getStatus(), - updateResult: await updates.consumeLatestResult() + updateResult: await updates.consumeLatestResult(), })); - register('dialog:select-directory', async ({ title = 'Select folder', defaultPath }) => { - const result = await dialog.showOpenDialog({ title, defaultPath, properties: ['openDirectory', 'createDirectory'] }); - return result.canceled ? null : result.filePaths[0]; - }); + register( + "dialog:select-directory", + async ({ title = "Select folder", defaultPath }) => { + const result = await dialog.showOpenDialog({ + title, + defaultPath, + properties: ["openDirectory", "createDirectory"], + }); + return result.canceled ? null : result.filePaths[0]; + }, + ); - register('dialog:select-key-file', async ({ title = 'Select SSH private key', defaultPath }) => { - const result = await dialog.showOpenDialog({ - title, - defaultPath, - properties: ['openFile'] + register( + "dialog:select-key-file", + async ({ title = "Select SSH private key", defaultPath }) => { + const result = await dialog.showOpenDialog({ + title, + defaultPath, + properties: ["openFile"], + }); + return result.canceled ? null : result.filePaths[0]; + }, + ); + + register( + "dialog:select-image-file", + async ({ title = "Select PNG image", defaultPath }) => { + const result = await dialog.showOpenDialog({ + title, + defaultPath, + properties: ["openFile"], + filters: [{ name: "PNG image", extensions: ["png"] }], + }); + return result.canceled ? null : result.filePaths[0]; + }, + ); + + register("setup:preflight", ({ baseUrl, token, roots }) => + preflight.runSystem({ baseUrl, token, roots }), + ); + register("setup:validate-gitea", ({ baseUrl, token }) => + gitea.validateConnection(baseUrl, token), + ); + register("setup:complete", async ({ baseUrl, token, workspaceRoots }) => { + const report = await preflight.runSystem({ + baseUrl, + token, + roots: workspaceRoots, }); - return result.canceled ? null : result.filePaths[0]; - }); - - register('dialog:select-image-file', async ({ title = 'Select PNG image', defaultPath }) => { - const result = await dialog.showOpenDialog({ - title, - defaultPath, - properties: ['openFile'], - filters: [{ name: 'PNG image', extensions: ['png'] }] - }); - return result.canceled ? null : result.filePaths[0]; - }); - - register('setup:preflight', ({ baseUrl, token, roots }) => preflight.runSystem({ baseUrl, token, roots })); - register('setup:validate-gitea', ({ baseUrl, token }) => gitea.validateConnection(baseUrl, token)); - register('setup:complete', async ({ baseUrl, token, workspaceRoots }) => { - const report = await preflight.runSystem({ baseUrl, token, roots: workspaceRoots }); - if (!report.summary.ready || !report.giteaValidation) throw new Error('Setup readiness checks must pass before configuration can be completed.'); + if (!report.summary.ready || !report.giteaValidation) + throw new Error( + "Setup readiness checks must pass before configuration can be completed.", + ); const validation = report.giteaValidation; - const result = await store.completeSetup({ baseUrl: validation.baseUrl, token, user: validation.user, workspaceRoots }); - await diagnostics.info('setup.completed', { baseUrl: validation.baseUrl, user: validation.user?.login || null, workspaceRootCount: workspaceRoots?.length || 0, tokenPersistent: result.tokenState.persistent }); + const result = await store.completeSetup({ + baseUrl: validation.baseUrl, + token, + user: validation.user, + workspaceRoots, + }); + await diagnostics.info("setup.completed", { + baseUrl: validation.baseUrl, + user: validation.user?.login || null, + workspaceRootCount: workspaceRoots?.length || 0, + tokenPersistent: result.tokenState.persistent, + }); return result; }); - register('settings:update-gitea', async ({ baseUrl, token }) => { - const effectiveToken = String(token || '').trim() || store.getToken(); + register("settings:update-gitea", async ({ baseUrl, token }) => { + const effectiveToken = String(token || "").trim() || store.getToken(); const validation = await gitea.validateConnection(baseUrl, effectiveToken); - const tokenState = await store.updateGitea({ baseUrl: validation.baseUrl, token, user: validation.user }); - await diagnostics.info('settings.gitea.updated', { baseUrl: validation.baseUrl, user: validation.user?.login || null, tokenPersistent: tokenState.persistent, tokenPreserved: tokenState.preserved }); + const tokenState = await store.updateGitea({ + baseUrl: validation.baseUrl, + token, + user: validation.user, + }); + await diagnostics.info("settings.gitea.updated", { + baseUrl: validation.baseUrl, + user: validation.user?.login || null, + tokenPersistent: tokenState.persistent, + tokenPreserved: tokenState.preserved, + }); return { validation, tokenState, state: store.getPublicState() }; }); - register('settings:set-roots', async ({ roots }) => { + register("settings:set-roots", async ({ roots }) => { store.data.workspaceRoots = [...new Set((roots || []).filter(Boolean))]; await store.save(); - await diagnostics.info('settings.workspace-roots.updated', { rootCount: store.data.workspaceRoots.length, roots: store.data.workspaceRoots }); + await diagnostics.info("settings.workspace-roots.updated", { + rootCount: store.data.workspaceRoots.length, + roots: store.data.workspaceRoots, + }); return store.getPublicState(); }); - register('settings:set-appearance', async ({ appearance }) => { - if (!['dark', 'light', 'system'].includes(appearance)) throw new Error('Unsupported appearance setting.'); + register("settings:set-appearance", async ({ appearance }) => { + if (!["dark", "light", "system"].includes(appearance)) + throw new Error("Unsupported appearance setting."); store.data.appearance = appearance; await store.save(); return store.getPublicState(); }); - register('settings:set-preferences', async ({ preferences }) => { + register("settings:set-preferences", async ({ preferences }) => { const state = await store.setPreferences(preferences); monitor?.restart(); onPreferencesChanged?.(); - await diagnostics.info('settings.preferences.updated', { preferences: state.preferences }); + await diagnostics.info("settings.preferences.updated", { + preferences: state.preferences, + }); return state; }); - register('settings:export-backup', async ({ passphrase }) => { + register("settings:export-backup", async ({ passphrase }) => { const result = await dialog.showSaveDialog({ - title: 'Export encrypted ForgeFlow configuration', - defaultPath: path.join(app.getPath('documents'), `ForgeFlow-Configuration-${new Date().toISOString().slice(0, 10)}.ffbackup`), - filters: [{ name: 'ForgeFlow encrypted backup', extensions: ['ffbackup'] }] + title: "Export encrypted ForgeFlow configuration", + defaultPath: path.join( + app.getPath("documents"), + `ForgeFlow-Configuration-${new Date().toISOString().slice(0, 10)}.ffbackup`, + ), + filters: [ + { name: "ForgeFlow encrypted backup", extensions: ["ffbackup"] }, + ], }); if (result.canceled || !result.filePath) return null; - const destinationPath = result.filePath.toLowerCase().endsWith('.ffbackup') ? result.filePath : `${result.filePath}.ffbackup`; - await fs.writeFile(destinationPath, createEncryptedBackup(store.data, passphrase), { mode: 0o600, flag: 'wx' }).catch(async (error) => { - if (error.code !== 'EEXIST') throw error; - await fs.writeFile(destinationPath, createEncryptedBackup(store.data, passphrase), { mode: 0o600 }); + const destinationPath = result.filePath.toLowerCase().endsWith(".ffbackup") + ? result.filePath + : `${result.filePath}.ffbackup`; + await fs + .writeFile( + destinationPath, + createEncryptedBackup(store.data, passphrase), + { mode: 0o600, flag: "wx" }, + ) + .catch(async (error) => { + if (error.code !== "EEXIST") throw error; + await fs.writeFile( + destinationPath, + createEncryptedBackup(store.data, passphrase), + { mode: 0o600 }, + ); + }); + await audit.append("configuration.backup.exported", { + fileName: path.basename(destinationPath), }); - await audit.append('configuration.backup.exported', { fileName: path.basename(destinationPath) }); return { filePath: destinationPath }; }); - register('settings:import-backup', async ({ passphrase }) => { - const result = await dialog.showOpenDialog({ title: 'Import encrypted ForgeFlow configuration', properties: ['openFile'], filters: [{ name: 'ForgeFlow encrypted backup', extensions: ['ffbackup'] }] }); + register("settings:import-backup", async ({ passphrase }) => { + const result = await dialog.showOpenDialog({ + title: "Import encrypted ForgeFlow configuration", + properties: ["openFile"], + filters: [ + { name: "ForgeFlow encrypted backup", extensions: ["ffbackup"] }, + ], + }); if (result.canceled || !result.filePaths[0]) return null; - const payload = readEncryptedBackup(await fs.readFile(result.filePaths[0], 'utf8'), passphrase); + const payload = readEncryptedBackup( + await fs.readFile(result.filePaths[0], "utf8"), + passphrase, + ); const state = await store.restoreConfiguration(payload.configuration); monitor?.restart(); - await audit.append('configuration.backup.imported', { fileName: path.basename(result.filePaths[0]), exportedAt: payload.exportedAt }); + await audit.append("configuration.backup.imported", { + fileName: path.basename(result.filePaths[0]), + exportedAt: payload.exportedAt, + }); return { state, exportedAt: payload.exportedAt }; }); - register('audit:list', ({ limit = 250 }) => audit.list(limit)); - register('audit:export', async ({ format = 'json' }) => { - if (!['json', 'csv'].includes(format)) throw new Error('Unsupported audit export format.'); - const extension = format === 'csv' ? 'csv' : 'json'; - const result = await dialog.showSaveDialog({ title: 'Export ForgeFlow audit log', defaultPath: path.join(app.getPath('documents'), `ForgeFlow-Audit-${new Date().toISOString().slice(0, 10)}.${extension}`), filters: [{ name: `${extension.toUpperCase()} file`, extensions: [extension] }] }); + register("audit:list", ({ limit = 250 }) => audit.list(limit)); + register("audit:export", async ({ format = "json" }) => { + if (!["json", "csv"].includes(format)) + throw new Error("Unsupported audit export format."); + const extension = format === "csv" ? "csv" : "json"; + const result = await dialog.showSaveDialog({ + title: "Export ForgeFlow audit log", + defaultPath: path.join( + app.getPath("documents"), + `ForgeFlow-Audit-${new Date().toISOString().slice(0, 10)}.${extension}`, + ), + filters: [ + { name: `${extension.toUpperCase()} file`, extensions: [extension] }, + ], + }); if (result.canceled || !result.filePath) return null; - return audit.exportTo(result.filePath.toLowerCase().endsWith(`.${extension}`) ? result.filePath : `${result.filePath}.${extension}`, format); + return audit.exportTo( + result.filePath.toLowerCase().endsWith(`.${extension}`) + ? result.filePath + : `${result.filePath}.${extension}`, + format, + ); }); - register('updates:preferences', ({ updates: next }) => store.setUpdatePreferences(next)); - register('updates:check', () => updates.check()); - register('updates:download', () => updates.download()); - register('updates:apply', async () => { + register("updates:preferences", ({ updates: next }) => + store.setUpdatePreferences(next), + ); + register("updates:check", () => updates.check()); + register("updates:download", () => updates.download()); + register("updates:apply", async () => { const result = await updates.apply(); - if (!result?.confirmed) throw new Error('The update helper did not confirm ownership of the update. ForgeFlow will remain open.'); + if (!result?.confirmed) + throw new Error( + "The update helper did not confirm ownership of the update. ForgeFlow will remain open.", + ); setTimeout(() => app.quit(), 350).unref?.(); return result; }); - register('server:save', async ({ server, password = '', passphrase = '' }) => { - const saved = await store.saveServer(server, { password, passphrase }); - await diagnostics.info('server.saved', { - serverId: saved.id, - name: saved.name, - host: saved.host, - port: saved.port, - username: saved.username, - authType: saved.authType, - basePath: saved.basePath - }); - return { server: saved, state: store.getPublicState() }; - }); - register('server:delete', async ({ serverId }) => { + register( + "server:save", + async ({ server, password = "", passphrase = "" }) => { + const saved = await store.saveServer(server, { password, passphrase }); + await diagnostics.info("server.saved", { + serverId: saved.id, + name: saved.name, + host: saved.host, + port: saved.port, + username: saved.username, + authType: saved.authType, + basePath: saved.basePath, + }); + return { server: saved, state: store.getPublicState() }; + }, + ); + register("server:delete", async ({ serverId }) => { await store.deleteServer(serverId); - await diagnostics.info('server.deleted', { serverId }); + await diagnostics.info("server.deleted", { serverId }); return store.getPublicState(); }); - register('server:test', async ({ serverId }) => { + register("server:test", async ({ serverId }) => { const server = store.getServer(serverId); - if (!server) throw new Error('The configured server no longer exists.'); - const result = await ssh.test(serverId, { trustOnFirstUse: !server.hostFingerprint }); + if (!server) throw new Error("The configured server no longer exists."); + const result = await ssh.test(serverId, { + trustOnFirstUse: !server.hostFingerprint, + }); if (!server.hostFingerprint) { - await store.saveServer({ ...server, hostFingerprint: result.fingerprint }, {}); + await store.saveServer( + { ...server, hostFingerprint: result.fingerprint }, + {}, + ); result.trusted = true; } return { ...result, state: store.getPublicState() }; }); - register('server:inspect-project', async ({ repository, profileId }) => unraid.inspect({ repository: await resolveRepository(repository), profileId })); - register('server:discover-existing', async ({ repository, serverId, remoteFolder }) => unraid.discoverExisting({ repository: await resolveRepository(repository), serverId, remoteFolder })); + register("server:inspect-project", async ({ repository, profileId }) => + unraid.inspect({ + repository: await resolveRepository(repository), + profileId, + }), + ); + register( + "server:discover-existing", + async ({ repository, serverId, remoteFolder }) => + unraid.discoverExisting({ + repository: await resolveRepository(repository), + serverId, + remoteFolder, + }), + ); - register('repositories:refresh', async () => { + register("repositories:refresh", async () => { const result = await repositories.refresh(); monitor?.setPaths(repositories.getWatchPaths()); return result; }); - register('repositories:discover', async ({ roots }) => { - const paths = await repositories.discoverAll(roots || store.data.workspaceRoots); + register("repositories:discover", async ({ roots }) => { + const paths = await repositories.discoverAll( + roots || store.data.workspaceRoots, + ); return repositories.getLocalDescriptors(paths); }); - register('repository:favorite', async ({ fullName, favorite }) => store.setFavorite(fullName, favorite)); + register("repository:favorite", async ({ fullName, favorite }) => + store.setFavorite(fullName, favorite), + ); - register('repository:link', async ({ fullName, localPath }) => { + register("repository:link", async ({ fullName, localPath }) => { await git.ensureRepository(localPath); - const remoteUrl = await git.getRemoteUrl(localPath).catch(() => ''); - if (!remoteUrl || !matchRemoteToRepository(remoteUrl, [{ full_name: fullName }])) { - throw new Error(`The selected folder's origin does not match ${fullName}.`); + const remoteUrl = await git.getRemoteUrl(localPath).catch(() => ""); + if ( + !remoteUrl || + !matchRemoteToRepository(remoteUrl, [{ full_name: fullName }]) + ) { + throw new Error( + `The selected folder's origin does not match ${fullName}.`, + ); } await store.saveMapping(fullName, localPath); - await diagnostics.info('repository.linked', { fullName, localPath }); + await diagnostics.info("repository.linked", { fullName, localPath }); const result = await repositories.refresh(); monitor?.setPaths(repositories.getWatchPaths()); return result; }); - register('repository:unlink', async ({ fullName }) => { + register("repository:unlink", async ({ fullName }) => { await store.removeMapping(fullName); - await diagnostics.info('repository.unlinked', { fullName }); + await diagnostics.info("repository.unlinked", { fullName }); const result = await repositories.refresh(); monitor?.setPaths(repositories.getWatchPaths()); return result; }); - register('repository:status', async ({ localPath }) => git.status(await assertKnownRepositoryPath(localPath))); - register('repository:diff', async ({ localPath, filePath, staged }) => git.diff(await assertKnownRepositoryPath(localPath), filePath, staged)); - register('repository:diff-hunks', async ({ localPath, filePath }) => git.diffHunks(await assertKnownRepositoryPath(localPath), filePath)); - register('repository:stage-hunks', async ({ localPath, filePath, hunkIndexes }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.stageHunks(safePath, filePath, hunkIndexes)); }); - register('repository:conflicts', async ({ localPath }) => git.conflictState(await assertKnownRepositoryPath(localPath))); - register('repository:resolve-conflict', async ({ localPath, filePath, resolution }) => { const safePath = await assertKnownRepositoryPath(localPath); const result = await withRepositoryMutation(safePath, () => git.resolveConflict(safePath, filePath, resolution)); await audit.append('git.conflict.resolved', { localPath: safePath, filePath, resolution }); return result; }); - register('repository:continue-operation', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); const result = await withRepositoryMutation(safePath, () => git.continueInterruptedOperation(safePath)); await audit.append('git.operation.continued', { localPath: safePath }); return result; }); - register('repository:abort-operation', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); const result = await withRepositoryMutation(safePath, () => git.abortInterruptedOperation(safePath)); await audit.append('git.operation.aborted', { localPath: safePath, operation: result.aborted }); return result; }); - register('repository:stage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.stage(safePath, files)); }); - register('repository:unstage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.unstage(safePath, files)); }); - register('repository:commit', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.commit(safePath, message, files)); }); - register('repository:commit-staged', async ({ localPath, message }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.commitStaged(safePath, message)); }); - register('repository:commit-staged-push', async ({ localPath, message }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.commitStagedAndPush(safePath, message)); }); - register('repository:commit-push', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.commitAndPush(safePath, message, files)); }); - register('repository:push', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.push(safePath)); }); - register('repository:fetch', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.fetch(safePath)); }); - register('repository:pull', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.pullFastForward(safePath)); }); - register('repository:history', async ({ localPath, limit }) => git.history(await assertKnownRepositoryPath(localPath), limit)); - register('repository:branch-protection', async ({ fullName, branch }) => { - const repository = await resolveRepository({ fullName }); - return gitea.getBranchProtection(repository.owner.login, repository.name, branch || repository.localStatus?.branch?.head || repository.defaultBranch); + register("repository:status", async ({ localPath }) => + git.status(await assertKnownRepositoryPath(localPath)), + ); + register("repository:diff", async ({ localPath, filePath, staged }) => + git.diff(await assertKnownRepositoryPath(localPath), filePath, staged), + ); + register("repository:diff-hunks", async ({ localPath, filePath }) => + git.diffHunks(await assertKnownRepositoryPath(localPath), filePath), + ); + register( + "repository:stage-hunks", + async ({ localPath, filePath, hunkIndexes }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.stageHunks(safePath, filePath, hunkIndexes), + ); + }, + ); + register("repository:conflicts", async ({ localPath }) => + git.conflictState(await assertKnownRepositoryPath(localPath)), + ); + register( + "repository:resolve-conflict", + async ({ localPath, filePath, resolution }) => { + const safePath = await assertKnownRepositoryPath(localPath); + const result = await withRepositoryMutation(safePath, () => + git.resolveConflict(safePath, filePath, resolution), + ); + await audit.append("git.conflict.resolved", { + localPath: safePath, + filePath, + resolution, + }); + return result; + }, + ); + register("repository:continue-operation", async ({ localPath }) => { + const safePath = await assertKnownRepositoryPath(localPath); + const result = await withRepositoryMutation(safePath, () => + git.continueInterruptedOperation(safePath), + ); + await audit.append("git.operation.continued", { localPath: safePath }); + return result; }); - register('repository:pull-requests', async ({ fullName, state = 'open' }) => { - const repository = await resolveRepository({ fullName }); - return gitea.listPullRequests({ owner: repository.owner.login, repo: repository.name, state }); + register("repository:abort-operation", async ({ localPath }) => { + const safePath = await assertKnownRepositoryPath(localPath); + const result = await withRepositoryMutation(safePath, () => + git.abortInterruptedOperation(safePath), + ); + await audit.append("git.operation.aborted", { + localPath: safePath, + operation: result.aborted, + }); + return result; }); - register('repository:create-pull-request', async ({ fullName, title, body, base }) => { + register("repository:stage", async ({ localPath, files }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => git.stage(safePath, files)); + }); + register("repository:unstage", async ({ localPath, files }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => git.unstage(safePath, files)); + }); + register("repository:commit", async ({ localPath, message, files }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.commit(safePath, message, files), + ); + }); + register("repository:commit-staged", async ({ localPath, message }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.commitStaged(safePath, message), + ); + }); + register("repository:commit-staged-push", async ({ localPath, message }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.commitStagedAndPush(safePath, message), + ); + }); + register("repository:commit-push", async ({ localPath, message, files }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.commitAndPush(safePath, message, files), + ); + }); + register("repository:push", async ({ localPath }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => git.push(safePath)); + }); + register("repository:fetch", async ({ localPath }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => git.fetch(safePath)); + }); + register("repository:pull", async ({ localPath }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.pullFastForward(safePath), + ); + }); + register("repository:history", async ({ localPath, limit }) => + git.history(await assertKnownRepositoryPath(localPath), limit), + ); + register("repository:branch-protection", async ({ fullName, branch }) => { const repository = await resolveRepository({ fullName }); - if (!repository.localPath || !repository.localStatus?.clean) throw new Error('A clean linked repository is required before creating a pull request.'); - const head = repository.localStatus.branch?.head; - if (!head || !repository.localStatus.branch?.upstream) throw new Error('Publish the current branch before creating a pull request.'); - if (repository.localStatus.branch.ahead > 0) throw new Error('Push all local commits before creating a pull request.'); - const pullRequest = await gitea.createPullRequest({ owner: repository.owner.login, repo: repository.name, head, base: base || repository.defaultBranch, title, body }); - await audit.append('pull-request.created', { repository: repository.fullName, number: pullRequest.number, head, base: base || repository.defaultBranch, url: pullRequest.html_url }); - return pullRequest; + return gitea.getBranchProtection( + repository.owner.login, + repository.name, + branch || + repository.localStatus?.branch?.head || + repository.defaultBranch, + ); + }); + register("repository:pull-requests", async ({ fullName, state = "open" }) => { + const repository = await resolveRepository({ fullName }); + return gitea.listPullRequests({ + owner: repository.owner.login, + repo: repository.name, + state, + }); + }); + register( + "repository:create-pull-request", + async ({ fullName, title, body, base }) => { + const repository = await resolveRepository({ fullName }); + if (!repository.localPath || !repository.localStatus?.clean) + throw new Error( + "A clean linked repository is required before creating a pull request.", + ); + const head = repository.localStatus.branch?.head; + if (!head || !repository.localStatus.branch?.upstream) + throw new Error( + "Publish the current branch before creating a pull request.", + ); + if (repository.localStatus.branch.ahead > 0) + throw new Error( + "Push all local commits before creating a pull request.", + ); + const pullRequest = await gitea.createPullRequest({ + owner: repository.owner.login, + repo: repository.name, + head, + base: base || repository.defaultBranch, + title, + body, + }); + await audit.append("pull-request.created", { + repository: repository.fullName, + number: pullRequest.number, + head, + base: base || repository.defaultBranch, + url: pullRequest.html_url, + }); + return pullRequest; + }, + ); + register("repository:branches", async ({ localPath }) => + git.branches(await assertKnownRepositoryPath(localPath)), + ); + register("repository:checkout-branch", async ({ localPath, branch }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.checkoutBranch(safePath, branch), + ); + }); + register("repository:create-branch", async ({ localPath, branch }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.createBranch(safePath, branch), + ); + }); + register("repository:stash", async ({ localPath, message }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => git.stash(safePath, message)); + }); + register("repository:stash-list", async ({ localPath }) => + git.stashList(await assertKnownRepositoryPath(localPath)), + ); + register("repository:stash-pop", async ({ localPath, ref }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => git.popStash(safePath, ref)); + }); + register("repository:index-lock", async ({ localPath }) => + git.getIndexLockInfo(await assertKnownRepositoryPath(localPath)), + ); + register("repository:git-recovery-status", async ({ localPath }) => + git.reconcile(await assertKnownRepositoryPath(localPath)), + ); + register("repository:repair-index-lock", async ({ localPath }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.removeStaleIndexLock(safePath), + ); + }); + register( + "repository:repair-git-locks", + async ({ localPath, force = false }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.repairStaleGitLocks(safePath, { + minimumAgeMs: force ? 0 : 10_000, + allowWithoutProcessProbe: force === true, + }), + ); + }, + ); + register("repository:reconcile", async ({ localPath }) => + git.reconcile(await assertKnownRepositoryPath(localPath)), + ); + register("repository:repair-sync", async ({ localPath, strategy }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.repairSync(safePath, strategy), + ); + }); + register("repository:set-origin", async ({ localPath, remoteUrl }) => { + const safePath = await assertKnownRepositoryPath(localPath); + return withRepositoryMutation(safePath, () => + git.setRemoteUrl(safePath, remoteUrl), + ); }); - register('repository:branches', async ({ localPath }) => git.branches(await assertKnownRepositoryPath(localPath))); - register('repository:checkout-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.checkoutBranch(safePath, branch)); }); - register('repository:create-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.createBranch(safePath, branch)); }); - register('repository:stash', async ({ localPath, message }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.stash(safePath, message)); }); - register('repository:stash-list', async ({ localPath }) => git.stashList(await assertKnownRepositoryPath(localPath))); - register('repository:stash-pop', async ({ localPath, ref }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.popStash(safePath, ref)); }); - register('repository:index-lock', async ({ localPath }) => git.getIndexLockInfo(await assertKnownRepositoryPath(localPath))); - register('repository:git-recovery-status', async ({ localPath }) => git.reconcile(await assertKnownRepositoryPath(localPath))); - register('repository:repair-index-lock', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.removeStaleIndexLock(safePath)); }); - register('repository:repair-git-locks', async ({ localPath, force = false }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.repairStaleGitLocks(safePath, { minimumAgeMs: force ? 0 : 10_000, allowWithoutProcessProbe: force === true })); }); - register('repository:reconcile', async ({ localPath }) => git.reconcile(await assertKnownRepositoryPath(localPath))); - register('repository:repair-sync', async ({ localPath, strategy }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.repairSync(safePath, strategy)); }); - register('repository:set-origin', async ({ localPath, remoteUrl }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.setRemoteUrl(safePath, remoteUrl)); }); - register('repositories:normalize-origins', async () => { + 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(() => ''); + 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 }); + 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 }); + 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.'); + register("repository:clone", async ({ fullName, mode = "default" }) => { + if (!["default", "custom"].includes(mode)) + throw new Error("Unsupported clone location mode."); let projectRoot = store.data.workspaceRoots[0] || null; - if (mode === 'custom' || !projectRoot) { + if (mode === "custom" || !projectRoot) { const result = await dialog.showOpenDialog({ - title: `Choose a project root for ${String(fullName || 'repository')}`, + title: `Choose a project root for ${String(fullName || "repository")}`, defaultPath: projectRoot || undefined, - buttonLabel: 'Use this project root', - properties: ['openDirectory', 'createDirectory'] + buttonLabel: "Use this project root", + properties: ["openDirectory", "createDirectory"], }); if (result.canceled || !result.filePaths[0]) return { cancelled: true }; projectRoot = result.filePaths[0]; @@ -429,174 +810,427 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh return cloneRepositoryInto(fullName, projectRoot); }); - register('repository:open-path', async ({ localPath }) => { + register("repository:open-path", async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); const error = await shell.openPath(safePath); if (error) throw new Error(error); return true; }); - register('repository:open-editor', async ({ localPath, filePath = '', line = 1 }) => externalTools.launch('editor', await assertKnownRepositoryPath(localPath), filePath, line)); - register('repository:open-terminal', async ({ localPath }) => externalTools.launch('terminal', await assertKnownRepositoryPath(localPath))); + register( + "repository:open-editor", + async ({ localPath, filePath = "", line = 1 }) => + externalTools.launch( + "editor", + await assertKnownRepositoryPath(localPath), + filePath, + line, + ), + ); + register("repository:open-terminal", async ({ localPath }) => + externalTools.launch( + "terminal", + await assertKnownRepositoryPath(localPath), + ), + ); - register('external:open', async ({ url }) => { + register("external:open", async ({ url }) => { const parsed = new URL(url); - if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('Only HTTP and HTTPS links can be opened.'); + if (!["http:", "https:"].includes(parsed.protocol)) + throw new Error("Only HTTP and HTTPS links can be opened."); await shell.openExternal(parsed.toString()); return true; }); - register('troubleshooter:scan', async ({ fullName = null }) => { + register("troubleshooter:scan", async ({ fullName = null }) => { const currentRepositories = await repositories.refresh(); - const candidates = fullName ? currentRepositories.filter((item) => item.fullName === fullName) : currentRepositories; + const candidates = fullName + ? currentRepositories.filter((item) => item.fullName === fullName) + : currentRepositories; const issues = []; for (const repository of candidates) { if (!repository.localPath) { - issues.push({ id: `${repository.fullName}:not-linked`, repository: repository.fullName, severity: 'warning', title: 'Local repository is not linked', detail: 'Link or clone the repository before running local Git repairs.', repairable: false }); + issues.push({ + id: `${repository.fullName}:not-linked`, + repository: repository.fullName, + severity: "warning", + title: "Local repository is not linked", + detail: + "Link or clone the repository before running local Git repairs.", + repairable: false, + }); continue; } try { - const interrupted = await git.detectInterruptedOperation(repository.localPath); - if (interrupted) issues.push({ id: `${repository.fullName}:abort-operation`, repository: repository.fullName, localPath: repository.localPath, severity: 'error', title: `Interrupted Git ${interrupted}`, detail: `A ${interrupted} is still active and blocks normal Git operations. Aborting it can discard conflict-resolution work and therefore always requires separate confirmation.`, repairable: true, action: 'abort-operation', safe: false }); + const interrupted = await git.detectInterruptedOperation( + repository.localPath, + ); + if (interrupted) + issues.push({ + id: `${repository.fullName}:abort-operation`, + repository: repository.fullName, + localPath: repository.localPath, + severity: "error", + title: `Interrupted Git ${interrupted}`, + detail: `A ${interrupted} is still active and blocks normal Git operations. Aborting it can discard conflict-resolution work and therefore always requires separate confirmation.`, + repairable: true, + action: "abort-operation", + safe: false, + }); const report = await git.reconcile(repository.localPath); for (const lock of report.lockReport?.locks || []) { const stale = lock.ageMs >= 10_000; - const processProbeSafe = report.lockReport.processes?.available === true && !report.lockReport.processes.active?.length; - issues.push({ id: `${repository.fullName}:locks:${lock.name}`, repository: repository.fullName, localPath: repository.localPath, severity: stale ? 'error' : 'warning', title: stale ? 'Stale Git lock detected' : 'Recent Git lock detected', detail: lock.name, repairable: stale, action: 'repair-locks', safe: stale && processProbeSafe }); + const processProbeSafe = + report.lockReport.processes?.available === true && + !report.lockReport.processes.active?.length; + issues.push({ + id: `${repository.fullName}:locks:${lock.name}`, + repository: repository.fullName, + localPath: repository.localPath, + severity: stale ? "error" : "warning", + title: stale + ? "Stale Git lock detected" + : "Recent Git lock detected", + detail: lock.name, + repairable: stale, + action: "repair-locks", + safe: stale && processProbeSafe, + }); } const branch = report.status?.branch || {}; - if (branch.behind > 0 && branch.ahead === 0 && report.status.clean) issues.push({ id: `${repository.fullName}:fast-forward`, repository: repository.fullName, localPath: repository.localPath, severity: 'warning', title: 'Local branch is behind Gitea', detail: `${branch.behind} commit(s) can be fast-forwarded safely.`, repairable: true, action: 'fast-forward', safe: true }); - if (branch.ahead > 0 && branch.behind === 0) issues.push({ id: `${repository.fullName}:push`, repository: repository.fullName, localPath: repository.localPath, severity: 'warning', title: 'Local commits are not published', detail: `${branch.ahead} commit(s) can be pushed to Gitea after explicit confirmation.`, repairable: true, action: 'push', safe: false }); - if (branch.ahead > 0 && branch.behind > 0) issues.push({ id: `${repository.fullName}:diverged`, repository: repository.fullName, localPath: repository.localPath, severity: 'error', title: 'Local and Gitea branches have diverged', detail: `${branch.ahead} ahead and ${branch.behind} behind. ForgeFlow can preserve the local HEAD on a safety branch and use the upstream version.`, repairable: report.status.clean, action: 'backup-reset', safe: false }); + if (branch.behind > 0 && branch.ahead === 0 && report.status.clean) + issues.push({ + id: `${repository.fullName}:fast-forward`, + repository: repository.fullName, + localPath: repository.localPath, + severity: "warning", + title: "Local branch is behind Gitea", + detail: `${branch.behind} commit(s) can be fast-forwarded safely.`, + repairable: true, + action: "fast-forward", + safe: true, + }); + if (branch.ahead > 0 && branch.behind === 0) + issues.push({ + id: `${repository.fullName}:push`, + repository: repository.fullName, + localPath: repository.localPath, + severity: "warning", + title: "Local commits are not published", + detail: `${branch.ahead} commit(s) can be pushed to Gitea after explicit confirmation.`, + repairable: true, + action: "push", + safe: false, + }); + if (branch.ahead > 0 && branch.behind > 0) + issues.push({ + id: `${repository.fullName}:diverged`, + repository: repository.fullName, + localPath: repository.localPath, + severity: "error", + title: "Local and Gitea branches have diverged", + detail: `${branch.ahead} ahead and ${branch.behind} behind. ForgeFlow can preserve the local HEAD on a safety branch and use the upstream version.`, + repairable: report.status.clean, + action: "backup-reset", + safe: false, + }); } catch (error) { - issues.push({ id: `${repository.fullName}:git-error`, repository: repository.fullName, severity: 'error', title: 'Git health scan failed', detail: error.message, repairable: false }); + issues.push({ + id: `${repository.fullName}:git-error`, + repository: repository.fullName, + severity: "error", + title: "Git health scan failed", + detail: error.message, + repairable: false, + }); } for (const profile of repository.deploymentProfiles || []) { - if (profile.provider !== 'ssh-unraid') continue; + if (profile.provider !== "ssh-unraid") continue; try { - const inspection = await unraid.inspect({ repository, profileId: profile.id }); - if (!inspection.exists) issues.push({ id: `${profile.id}:server-folder`, repository: repository.fullName, profileId: profile.id, severity: 'error', title: 'Deployment folder is missing on the server', detail: inspection.remotePath, repairable: false }); - if (inspection.trackedChanges?.length) issues.push({ id: `${profile.id}:tracked-server-changes`, repository: repository.fullName, profileId: profile.id, severity: 'error', title: 'Tracked server-side changes detected', detail: `${inspection.trackedChanges.length} tracked change(s) must be reviewed before deployment.`, repairable: false }); - if (inspection.dockerContextExclusionsMissing?.length) issues.push({ id: `${profile.id}:dockerignore`, repository: repository.fullName, profileId: profile.id, severity: 'warning', title: 'Runtime paths are missing from .dockerignore', detail: inspection.dockerContextExclusionsMissing.join(', '), repairable: false }); + const inspection = await unraid.inspect({ + repository, + profileId: profile.id, + }); + if (!inspection.exists) + issues.push({ + id: `${profile.id}:server-folder`, + repository: repository.fullName, + profileId: profile.id, + severity: "error", + title: "Deployment folder is missing on the server", + detail: inspection.remotePath, + repairable: false, + }); + if (inspection.trackedChanges?.length) + issues.push({ + id: `${profile.id}:tracked-server-changes`, + repository: repository.fullName, + profileId: profile.id, + severity: "error", + title: "Tracked server-side changes detected", + detail: `${inspection.trackedChanges.length} tracked change(s) must be reviewed before deployment.`, + repairable: false, + }); + if (inspection.dockerContextExclusionsMissing?.length) + issues.push({ + id: `${profile.id}:dockerignore`, + repository: repository.fullName, + profileId: profile.id, + severity: "warning", + title: "Runtime paths are missing from .dockerignore", + detail: inspection.dockerContextExclusionsMissing.join(", "), + repairable: false, + }); } catch (error) { - issues.push({ id: `${profile.id}:server-error`, repository: repository.fullName, profileId: profile.id, severity: 'error', title: 'Server inspection failed', detail: error.message, repairable: false }); + issues.push({ + id: `${profile.id}:server-error`, + repository: repository.fullName, + profileId: profile.id, + severity: "error", + title: "Server inspection failed", + detail: error.message, + repairable: false, + }); } } } - const summary = { total: issues.length, errors: issues.filter((item) => item.severity === 'error').length, warnings: issues.filter((item) => item.severity === 'warning').length, repairable: issues.filter((item) => item.repairable).length }; + const summary = { + total: issues.length, + errors: issues.filter((item) => item.severity === "error").length, + warnings: issues.filter((item) => item.severity === "warning").length, + repairable: issues.filter((item) => item.repairable).length, + }; return { checkedAt: new Date().toISOString(), issues, summary }; }); - register('troubleshooter:repair', async ({ issue }) => { - if (!issue || !issue.action) throw new Error('No repair action was supplied.'); - const localPath = issue.localPath ? await assertKnownRepositoryPath(issue.localPath) : null; + register("troubleshooter:repair", async ({ issue }) => { + if (!issue || !issue.action) + throw new Error("No repair action was supplied."); + const localPath = issue.localPath + ? await assertKnownRepositoryPath(issue.localPath) + : null; let result; - if (issue.action === 'abort-operation') result = await withRepositoryMutation(localPath, () => git.abortInterruptedOperation(localPath)); - else if (issue.action === 'repair-locks') result = await withRepositoryMutation(localPath, () => git.repairStaleGitLocks(localPath, { minimumAgeMs: 2_000 })); - else if (['fast-forward', 'push', 'backup-reset', 'fetch'].includes(issue.action)) result = await withRepositoryMutation(localPath, () => git.repairSync(localPath, issue.action)); - else throw new Error('Unsupported troubleshooter repair action.'); - await diagnostics.info('troubleshooter.repair.completed', { repository: issue.repository, action: issue.action }); + if (issue.action === "abort-operation") + result = await withRepositoryMutation(localPath, () => + git.abortInterruptedOperation(localPath), + ); + else if (issue.action === "repair-locks") + result = await withRepositoryMutation(localPath, () => + git.repairStaleGitLocks(localPath, { minimumAgeMs: 2_000 }), + ); + else if ( + ["fast-forward", "push", "backup-reset", "fetch"].includes(issue.action) + ) + result = await withRepositoryMutation(localPath, () => + git.repairSync(localPath, issue.action), + ); + else throw new Error("Unsupported troubleshooter repair action."); + await diagnostics.info("troubleshooter.repair.completed", { + repository: issue.repository, + action: issue.action, + }); return result; }); - register('troubleshooter:auto-repair', async ({ issues }) => { + register("troubleshooter:auto-repair", async ({ issues }) => { const results = []; - for (const issue of (issues || []).filter((item) => item.repairable && item.safe)) { + for (const issue of (issues || []).filter( + (item) => item.repairable && item.safe, + )) { try { - const localPath = issue.localPath ? await assertKnownRepositoryPath(issue.localPath) : null; + const localPath = issue.localPath + ? await assertKnownRepositoryPath(issue.localPath) + : null; let result; - if (issue.action === 'repair-locks') result = await withRepositoryMutation(localPath, () => git.repairStaleGitLocks(localPath, { minimumAgeMs: 10_000 })); - else if (['fast-forward', 'fetch'].includes(issue.action)) result = await withRepositoryMutation(localPath, () => git.repairSync(localPath, issue.action)); + if (issue.action === "repair-locks") + result = await withRepositoryMutation(localPath, () => + git.repairStaleGitLocks(localPath, { minimumAgeMs: 10_000 }), + ); + else if (["fast-forward", "fetch"].includes(issue.action)) + result = await withRepositoryMutation(localPath, () => + git.repairSync(localPath, issue.action), + ); else continue; results.push({ id: issue.id, ok: true, result }); - } catch (error) { results.push({ id: issue.id, ok: false, error: error.message }); } + } catch (error) { + results.push({ id: issue.id, ok: false, error: error.message }); + } } - await diagnostics.info('troubleshooter.auto-repair.completed', { attempted: results.length, succeeded: results.filter((item) => item.ok).length }); + await diagnostics.info("troubleshooter.auto-repair.completed", { + attempted: results.length, + succeeded: results.filter((item) => item.ok).length, + }); return results; }); - register('deployment:save-profile', async ({ fullName, profile }) => { + register("deployment:save-profile", async ({ fullName, profile }) => { const saved = await store.saveDeploymentProfile(fullName, profile); - await diagnostics.info('deployment.profile.saved', { repository: fullName, profile: saved }); + await diagnostics.info("deployment.profile.saved", { + repository: fullName, + profile: saved, + }); return { profile: saved, state: store.getPublicState() }; }); - register('deployment:delete-profile', async ({ fullName, profileId }) => { + register("deployment:delete-profile", async ({ fullName, profileId }) => { const profiles = await store.deleteDeploymentProfile(fullName, profileId); - await diagnostics.info('deployment.profile.deleted', { repository: fullName, profileId }); + await diagnostics.info("deployment.profile.deleted", { + repository: fullName, + profileId, + }); return { profiles, state: store.getPublicState() }; }); - register('deployment:preflight', async ({ repository, profileId }) => { + register("deployment:preflight", async ({ repository, profileId }) => { const current = await resolveRepository(repository); const profile = store.getDeploymentProfile(current.fullName, profileId); - if (profile?.provider === 'ssh-unraid') return unraid.preflight({ repository: current, profileId }); + if (profile?.provider === "ssh-unraid") + return unraid.preflight({ repository: current, profileId }); return preflight.runDeployment({ repository: current, profileId }); }); - register('deployment:dispatch', async ({ repository, profileId, sha, note = '', override = false, overrideReason = '' }) => { - const current = await resolveRepository(repository); - const profile = store.getDeploymentProfile(current.fullName, profileId); - const policy = evaluateDeploymentPolicy(profile, { note, override, reason: overrideReason }); - await audit.append('deployment.requested', { repository: current.fullName, profileId, sha, note: policy.note, overridden: policy.overridden, overrideReason: policy.reason }); - const operation = profile?.provider === 'ssh-unraid' - ? await unraid.deploy({ repository: current, profileId, sha }) - : await deployments.deploy({ repository: current, profileId, sha }); - if (operation?.id) await store.addOperation({ ...operation, releaseNote: policy.note, policyOverride: policy.overridden ? { reason: policy.reason, violations: policy.violations } : null }); - return operation; - }); - register('deployment:rollback', async ({ repository, profileId, targetSha }) => { - const current = await resolveRepository(repository); - const profile = store.getDeploymentProfile(current.fullName, profileId); - if (profile?.provider === 'ssh-unraid') return unraid.rollback({ repository: current, profileId, targetSha }); - return deployments.rollback({ repository: current, profileId, targetSha }); - }); - register('deployment:health', ({ url }) => deployments.checkHealth(url)); - register('deployment:profile-state', ({ fullName, profileId }) => { + register( + "deployment:dispatch", + async ({ + repository, + profileId, + sha, + note = "", + override = false, + overrideReason = "", + }) => { + const current = await resolveRepository(repository); + const profile = store.getDeploymentProfile(current.fullName, profileId); + const policy = evaluateDeploymentPolicy(profile, { + note, + override, + reason: overrideReason, + }); + await audit.append("deployment.requested", { + repository: current.fullName, + profileId, + sha, + note: policy.note, + overridden: policy.overridden, + overrideReason: policy.reason, + }); + const operation = + profile?.provider === "ssh-unraid" + ? await unraid.deploy({ repository: current, profileId, sha }) + : await deployments.deploy({ repository: current, profileId, sha }); + if (operation?.id) + await store.addOperation({ + ...operation, + releaseNote: policy.note, + policyOverride: policy.overridden + ? { reason: policy.reason, violations: policy.violations } + : null, + }); + return operation; + }, + ); + register( + "deployment:rollback", + async ({ repository, profileId, targetSha }) => { + const current = await resolveRepository(repository); + const profile = store.getDeploymentProfile(current.fullName, profileId); + if (profile?.provider === "ssh-unraid") + return unraid.rollback({ repository: current, profileId, targetSha }); + return deployments.rollback({ + repository: current, + profileId, + targetSha, + }); + }, + ); + register("deployment:health", ({ url }) => deployments.checkHealth(url)); + register("deployment:profile-state", ({ fullName, profileId }) => { const profile = store.getDeploymentProfile(fullName, profileId); - if (profile?.provider === 'ssh-unraid') return unraid.refreshProfileState(fullName, profileId); + if (profile?.provider === "ssh-unraid") + return unraid.refreshProfileState(fullName, profileId); return deployments.refreshProfileState(fullName, profileId); }); - register('deployment:apply-dockerman-metadata', async ({ repository, profileId }) => { - const current = await resolveRepository(repository); - return unraid.applyDockerManMetadata({ repository: current, profileId }); - }); - register('deployment:reconcile', async ({ fullName, profileId }) => { + register( + "deployment:apply-dockerman-metadata", + async ({ repository, profileId }) => { + const current = await resolveRepository(repository); + return unraid.applyDockerManMetadata({ repository: current, profileId }); + }, + ); + register("deployment:reconcile", async ({ fullName, profileId }) => { const profile = store.getDeploymentProfile(fullName, profileId); - if (profile?.provider !== 'ssh-unraid') return deployments.refreshProfileState(fullName, profileId); - const state = await unraid.refreshProfileState(fullName, profileId); - const operations = store.data.operations.filter((item) => item.profileId === profileId && item.provider === 'ssh-unraid' && !['success', 'failed', 'cancelled', 'rolled-back'].includes(item.status)); - for (const operation of operations) await unraid.refreshOperation(operation.id); - return { state, operations: store.data.operations.filter((item) => item.profileId === profileId).slice(0, 10) }; + if (profile?.provider !== "ssh-unraid") + return deployments.refreshProfileState(fullName, profileId); + const [owner, repo] = String(fullName || "").split("/"); + const branch = await gitea.getBranch(owner, repo, profile.branch); + const giteaSha = + branch?.commit?.id || + branch?.commit?.sha || + branch?.commit?.commit?.id || + null; + const state = await unraid.refreshProfileState( + fullName, + profileId, + giteaSha, + ); + const operations = store.data.operations.filter( + (item) => + item.profileId === profileId && + item.provider === "ssh-unraid" && + !["success", "failed", "cancelled", "rolled-back"].includes( + item.status, + ), + ); + for (const operation of operations) + await unraid.refreshOperation(operation.id); + return { + state, + operations: await unraid.reconcileRecordedOperations(profileId, state), + }; }); - register('operations:refresh', async ({ operationId }) => { + register("operations:refresh", async ({ operationId }) => { if (operationId) { const operation = store.getOperation(operationId); - if (operation?.provider === 'ssh-unraid') return unraid.refreshOperation(operationId); + if (operation?.provider === "ssh-unraid") + return unraid.refreshOperation(operationId); return deployments.refreshOperation(operationId); } - const [actions, sshOperations] = await Promise.all([deployments.refreshActiveOperations(), unraid.refreshActiveOperations()]); + const [actions, sshOperations] = await Promise.all([ + deployments.refreshActiveOperations(), + unraid.refreshActiveOperations(), + ]); return [...actions, ...sshOperations]; }); - register('operations:get', ({ operationId }) => store.getOperation(operationId)); + register("operations:get", ({ operationId }) => + store.getOperation(operationId), + ); - register('diagnostics:status', () => diagnostics.getStatus()); - register('diagnostics:clear', () => diagnostics.clear()); - register('diagnostics:open-folder', async () => { + register("diagnostics:status", () => diagnostics.getStatus()); + register("diagnostics:clear", () => diagnostics.clear()); + register("diagnostics:open-folder", async () => { const error = await shell.openPath(diagnostics.logDirectory); if (error) throw new Error(error); return true; }); - register('diagnostics:export', async ({ privacyMode = 'standard' }) => { - if (!['standard', 'strict'].includes(privacyMode)) throw new Error('Unsupported diagnostic privacy mode.'); + register("diagnostics:export", async ({ privacyMode = "standard" }) => { + if (!["standard", "strict"].includes(privacyMode)) + throw new Error("Unsupported diagnostic privacy mode."); const result = await dialog.showSaveDialog({ - title: 'Export ForgeFlow diagnostic bundle', - defaultPath: path.join(app.getPath('downloads'), `ForgeFlow-Diagnostics-${new Date().toISOString().replace(/[:.]/g, '-')}.zip`), - filters: [{ name: 'ZIP archive', extensions: ['zip'] }] + title: "Export ForgeFlow diagnostic bundle", + defaultPath: path.join( + app.getPath("downloads"), + `ForgeFlow-Diagnostics-${new Date().toISOString().replace(/[:.]/g, "-")}.zip`, + ), + filters: [{ name: "ZIP archive", extensions: ["zip"] }], }); if (result.canceled || !result.filePath) return null; const repositoryState = await repositories.refresh().catch((error) => { - diagnostics.warning('diagnostics.repository-snapshot.failed', error); + diagnostics.warning("diagnostics.repository-snapshot.failed", error); return []; }); - const systemPreflight = await preflight.runSystem().catch((error) => ({ error: error.message })); - const destinationPath = path.extname(result.filePath).toLowerCase() === '.zip' ? result.filePath : `${result.filePath}.zip`; + const systemPreflight = await preflight + .runSystem() + .catch((error) => ({ error: error.message })); + const destinationPath = + path.extname(result.filePath).toLowerCase() === ".zip" + ? result.filePath + : `${result.filePath}.zip`; return diagnostics.exportSupportBundle({ destinationPath, publicState: store.getPublicState(), @@ -604,22 +1238,36 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh operations: store.data.operations, preflight: systemPreflight, privacyMode, - extra: { appVersion: app.getVersion(), setupComplete: store.data.setupComplete } + extra: { + appVersion: app.getVersion(), + setupComplete: store.data.setupComplete, + }, }); }); - register('diagnostics:show-bundle', async ({ filePath }) => { - if (!diagnostics.isKnownBundlePath(filePath)) throw new Error('Only the most recently generated support bundle can be revealed.'); + register("diagnostics:show-bundle", async ({ filePath }) => { + if (!diagnostics.isKnownBundlePath(filePath)) + throw new Error( + "Only the most recently generated support bundle can be revealed.", + ); shell.showItemInFolder(filePath); return true; }); - register('renderer:report', async ({ level = 'info', event = 'renderer.event', details = {} }) => { - const method = ['debug', 'info', 'warning', 'error'].includes(level) ? level : 'info'; - await diagnostics[method](`renderer.${String(event || 'event').slice(0, 120)}`, details); - return true; - }); + register( + "renderer:report", + async ({ level = "info", event = "renderer.event", details = {} }) => { + const method = ["debug", "info", "warning", "error"].includes(level) + ? level + : "info"; + await diagnostics[method]( + `renderer.${String(event || "event").slice(0, 120)}`, + details, + ); + return true; + }, + ); - register('app:reset', async () => { - await diagnostics.info('app.reset.requested', {}); + register("app:reset", async () => { + await diagnostics.info("app.reset.requested", {}); store.data = store.migrate({}); store.sessionToken = null; await store.save(); @@ -629,4 +1277,9 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh }); } -module.exports = { registerIpc, cloneDirectoryName, assertTrustedSender, toErrorPayload }; +module.exports = { + registerIpc, + cloneDirectoryName, + assertTrustedSender, + toErrorPayload, +}; diff --git a/src/main/unraid-deployment-service.cjs b/src/main/unraid-deployment-service.cjs index 8b7c5b9..cb9e401 100644 --- a/src/main/unraid-deployment-service.cjs +++ b/src/main/unraid-deployment-service.cjs @@ -1,180 +1,363 @@ -'use strict'; +"use strict"; -const fs = require('node:fs/promises'); -const path = require('node:path').posix; -const nativePath = require('node:path'); -const crypto = require('node:crypto'); -const { shellQuote } = require('./ssh-service.cjs'); -const { assertFullCommitSha } = require('../shared/validation.cjs'); -const { normalizeRemoteUrl } = require('../shared/repository-match.cjs'); +const fs = require("node:fs/promises"); +const path = require("node:path").posix; +const nativePath = require("node:path"); +const crypto = require("node:crypto"); +const { shellQuote } = require("./ssh-service.cjs"); +const { assertFullCommitSha } = require("../shared/validation.cjs"); +const { normalizeRemoteUrl } = require("../shared/repository-match.cjs"); function safeRemoteFolder(value) { - const text = String(value || '').trim(); - if (!/^[a-zA-Z0-9._-]+$/.test(text) || text === '.' || text === '..') throw new Error('Remote folder contains unsupported characters.'); + const text = String(value || "").trim(); + if (!/^[a-zA-Z0-9._-]+$/.test(text) || text === "." || text === "..") + throw new Error("Remote folder contains unsupported characters."); return text; } -function safeRelativeRemoteFile(value, fallback = '') { - const text = String(value || fallback).trim().replace(/\\/g, '/'); - if (!text || text.startsWith('/') || text.split('/').some((part) => !part || part === '.' || part === '..')) { - throw new Error('Remote file path must remain inside the project folder.'); +function safeRelativeRemoteFile(value, fallback = "") { + const text = String(value || fallback) + .trim() + .replace(/\\/g, "/"); + if ( + !text || + text.startsWith("/") || + text.split("/").some((part) => !part || part === "." || part === "..") + ) { + throw new Error("Remote file path must remain inside the project folder."); } return text; } function bash(command) { const script = `set -euo pipefail\nexport GIT_TERMINAL_PROMPT=0\nexport GIT_SSH_COMMAND='ssh -o BatchMode=yes'\n${command}`; - const payload = Buffer.from(script, 'utf8').toString('base64'); + const payload = Buffer.from(script, "utf8").toString("base64"); return `printf '%s' ${shellQuote(payload)} | base64 -d | bash`; } function parseInspection(text) { - const jsonMarker = '__FORGEFLOW_JSON__'; + const jsonMarker = "__FORGEFLOW_JSON__"; const jsonIndex = text.lastIndexOf(jsonMarker); - if (jsonIndex >= 0) return JSON.parse(text.slice(jsonIndex + jsonMarker.length).trim()); + if (jsonIndex >= 0) + return JSON.parse(text.slice(jsonIndex + jsonMarker.length).trim()); - const kvMarker = '__FORGEFLOW_KV__'; + const kvMarker = "__FORGEFLOW_KV__"; const kvIndex = text.lastIndexOf(kvMarker); - if (kvIndex < 0) throw new Error('The server inspection did not return a ForgeFlow result.'); + if (kvIndex < 0) + throw new Error("The server inspection did not return a ForgeFlow result."); const fields = {}; - for (const line of text.slice(kvIndex + kvMarker.length).trim().split(/\r?\n/)) { - const separator = line.indexOf('='); - if (separator > 0) fields[line.slice(0, separator)] = line.slice(separator + 1); + for (const line of text + .slice(kvIndex + kvMarker.length) + .trim() + .split(/\r?\n/)) { + const separator = line.indexOf("="); + if (separator > 0) + fields[line.slice(0, separator)] = line.slice(separator + 1); } const decodeLines = (value) => { - try { return value ? Buffer.from(value, 'base64').toString('utf8').split(/\r?\n/).filter(Boolean) : []; } - catch { return []; } + try { + return value + ? Buffer.from(value, "base64") + .toString("utf8") + .split(/\r?\n/) + .filter(Boolean) + : []; + } catch { + return []; + } }; const decodeText = (value) => { - try { return value ? Buffer.from(value, 'base64').toString('utf8') : ''; } - catch { return ''; } + try { + return value ? Buffer.from(value, "base64").toString("utf8") : ""; + } catch { + return ""; + } }; return { - exists: fields.exists === 'true', - rootGit: fields.rootGit === 'true', + exists: fields.exists === "true", + rootGit: fields.rootGit === "true", head: fields.head || null, branch: fields.branch || null, - remote: fields.remote ? Buffer.from(fields.remote, 'base64').toString('utf8') : null, + remote: fields.remote + ? Buffer.from(fields.remote, "base64").toString("utf8") + : null, trackedChanges: decodeLines(fields.trackedChanges), composeFiles: decodeLines(fields.composeFiles), nestedGit: decodeLines(fields.nestedGit), - dockerfile: fields.dockerfile === 'true', + dockerfile: fields.dockerfile === "true", dockerignoreContent: decodeText(fields.dockerignoreContent), - existingPreservePaths: decodeLines(fields.existingPreservePaths) + existingPreservePaths: decodeLines(fields.existingPreservePaths), }; } function dockerIgnoreHasPath(content, value) { - const target = String(value || '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\//, '').replace(/\/$/, ''); + const target = String(value || "") + .replace(/\\/g, "/") + .replace(/^\.\//, "") + .replace(/^\//, "") + .replace(/\/$/, ""); if (!target) return false; - return String(content || '').split(/\r?\n/).some((line) => { - let rule = line.trim(); - if (!rule || rule.startsWith('#') || rule.startsWith('!')) return false; - rule = rule.replace(/^\.\//, '').replace(/^\//, '').replace(/\/$/, ''); - return rule === target || rule === `${target}/**` || rule === `${target}/**/*`; - }); + return String(content || "") + .split(/\r?\n/) + .some((line) => { + let rule = line.trim(); + if (!rule || rule.startsWith("#") || rule.startsWith("!")) return false; + rule = rule.replace(/^\.\//, "").replace(/^\//, "").replace(/\/$/, ""); + return ( + rule === target || rule === `${target}/**` || rule === `${target}/**/*` + ); + }); } function checksSummary(checks) { const counts = { - pass: checks.filter((item) => item.status === 'pass').length, - warning: checks.filter((item) => item.status === 'warning').length, - fail: checks.filter((item) => item.status === 'fail').length + pass: checks.filter((item) => item.status === "pass").length, + warning: checks.filter((item) => item.status === "warning").length, + fail: checks.filter((item) => item.status === "fail").length, }; return { ready: counts.fail === 0, counts, - blocking: checks.filter((item) => item.status === 'fail').map((item) => item.id) + blocking: checks + .filter((item) => item.status === "fail") + .map((item) => item.id), }; } function xmlEscape(value) { - return String(value ?? '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); } - - function decodeBase64Json(value, fallback) { - try { return value ? JSON.parse(Buffer.from(value, 'base64').toString('utf8')) : fallback; } - catch { return fallback; } + try { + return value + ? JSON.parse(Buffer.from(value, "base64").toString("utf8")) + : fallback; + } catch { + return fallback; + } } function parseDockerManXml(xml) { - const text = String(xml || ''); + const text = String(xml || ""); const tag = (name) => { - const match = text.match(new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`, 'i')); - return match ? match[1].replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').trim() : ''; + const match = text.match( + new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`, "i"), + ); + return match + ? match[1] + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .trim() + : ""; + }; + return { + name: tag("Name"), + webUiUrl: tag("WebUI"), + iconUrl: tag("Icon"), + shell: tag("Shell"), }; - return { name: tag('Name'), webUiUrl: tag('WebUI'), iconUrl: tag('Icon'), shell: tag('Shell') }; } -function deriveDetectedProfile({ repository, server, remoteFolder, remotePath, payload }) { +function deriveDetectedProfile({ + repository, + server, + remoteFolder, + remotePath, + payload, +}) { const compose = payload.compose || {}; - const services = compose.services && typeof compose.services === 'object' ? compose.services : {}; - const inspections = Array.isArray(payload.containers) ? payload.containers : []; - const primaryContainer = inspections.find((item) => item?.State?.Running) || inspections[0] || null; + const services = + compose.services && typeof compose.services === "object" + ? compose.services + : {}; + const inspections = Array.isArray(payload.containers) + ? payload.containers + : []; + const primaryContainer = + inspections.find((item) => item?.State?.Running) || inspections[0] || null; const labels = primaryContainer?.Config?.Labels || {}; - const serviceName = labels['com.docker.compose.service'] || Object.keys(services)[0] || remoteFolder; + const serviceName = + labels["com.docker.compose.service"] || + Object.keys(services)[0] || + remoteFolder; const service = services[serviceName] || {}; - const containerName = String(primaryContainer?.Name || service.container_name || serviceName).replace(/^\//, ''); + const containerName = String( + primaryContainer?.Name || service.container_name || serviceName, + ).replace(/^\//, ""); const ports = []; - for (const [containerKey, bindings] of Object.entries(primaryContainer?.NetworkSettings?.Ports || {})) { - const [containerPortText, protocol = 'tcp'] = containerKey.split('/'); + for (const [containerKey, bindings] of Object.entries( + primaryContainer?.NetworkSettings?.Ports || {}, + )) { + const [containerPortText, protocol = "tcp"] = containerKey.split("/"); const containerPort = Number(containerPortText) || null; if (Array.isArray(bindings) && bindings.length) { - for (const binding of bindings) ports.push({ hostIp: binding.HostIp || '', hostPort: Number(binding.HostPort) || null, containerPort, protocol }); - } else ports.push({ hostIp: '', hostPort: null, containerPort, protocol }); + for (const binding of bindings) + ports.push({ + hostIp: binding.HostIp || "", + hostPort: Number(binding.HostPort) || null, + containerPort, + protocol, + }); + } else ports.push({ hostIp: "", hostPort: null, containerPort, protocol }); } const primaryPort = ports.find((item) => item.hostPort) || ports[0] || {}; - const mounts = (primaryContainer?.Mounts || []).map((item) => ({ type: item.Type, source: item.Source, target: item.Destination, readOnly: item.RW === false })); - const networks = Object.keys(primaryContainer?.NetworkSettings?.Networks || {}); - const envNames = (primaryContainer?.Config?.Env || []).map((item) => String(item).split('=')[0]).filter(Boolean); - const dockerMan = parseDockerManXml(payload.dockerManXml || ''); - const webUiUrl = dockerMan.webUiUrl || labels['net.unraid.docker.webui'] || ''; - const iconUrl = dockerMan.iconUrl || labels['net.unraid.docker.icon'] || ''; - const shell = dockerMan.shell || labels['net.unraid.docker.shell'] || '/bin/sh'; - const preservePaths = [...new Set([ - '.env', 'appdata', 'data', 'logs', 'config', 'compose.override.yml', - ...mounts.filter((item) => String(item.source || '').startsWith(`${remotePath}/`)).map((item) => String(item.source).slice(remotePath.length + 1).split('/')[0]).filter(Boolean) - ])]; - const source = (value, origin, confidence = 'confirmed') => ({ value, origin, confidence, detectedAt: new Date().toISOString(), overridden: false }); + const mounts = (primaryContainer?.Mounts || []).map((item) => ({ + type: item.Type, + source: item.Source, + target: item.Destination, + readOnly: item.RW === false, + })); + const networks = Object.keys( + primaryContainer?.NetworkSettings?.Networks || {}, + ); + const envNames = (primaryContainer?.Config?.Env || []) + .map((item) => String(item).split("=")[0]) + .filter(Boolean); + const dockerMan = parseDockerManXml(payload.dockerManXml || ""); + const webUiUrl = + dockerMan.webUiUrl || labels["net.unraid.docker.webui"] || ""; + const iconUrl = dockerMan.iconUrl || labels["net.unraid.docker.icon"] || ""; + const shell = + dockerMan.shell || labels["net.unraid.docker.shell"] || "/bin/sh"; + const preservePaths = [ + ...new Set([ + ".env", + "appdata", + "data", + "logs", + "config", + "compose.override.yml", + ...mounts + .filter((item) => + String(item.source || "").startsWith(`${remotePath}/`), + ) + .map( + (item) => + String(item.source) + .slice(remotePath.length + 1) + .split("/")[0], + ) + .filter(Boolean), + ]), + ]; + const source = (value, origin, confidence = "confirmed") => ({ + value, + origin, + confidence, + detectedAt: new Date().toISOString(), + overridden: false, + }); const composeFiles = payload.composeFiles || []; - const composeFile = composeFiles[0] || labels['com.docker.compose.project.config_files']?.split(',')[0]?.replace(`${remotePath}/`, '') || 'docker-compose.yml'; + const composeFile = + composeFiles[0] || + labels["com.docker.compose.project.config_files"] + ?.split(",")[0] + ?.replace(`${remotePath}/`, "") || + "docker-compose.yml"; return { profile: { - name: 'Production', environment: 'production', provider: 'ssh-unraid', branch: payload.branch || repository.defaultBranch || 'main', - serverId: server.id, remoteFolder, cloneUrl: payload.remote || repository.sshUrl || '', alignRemote: false, - generatedCompose: false, composeFile, composeService: serviceName, containerName, - hostPort: primaryPort.hostPort || null, containerPort: primaryPort.containerPort || null, - webUiUrl, iconMode: /^https?:\/\//i.test(iconUrl) ? 'url' : 'none', iconUrl: /^https?:\/\//i.test(iconUrl) ? iconUrl : '', serverIconReference: iconUrl, iconFilePath: '', dockerShell: ['/bin/bash','/bin/sh'].includes(shell) ? shell : '/bin/sh', - healthcheckUrl: '', preservePaths, confirmationRequired: true, - adoptedFromServer: true, serverSourceOfTruth: true, detectedAt: new Date().toISOString(), - detectedMetadata: { head: payload.head || null, composeProject: labels['com.docker.compose.project'] || '', composeFiles, services: Object.keys(services), ports, mounts, networks, envNames, restartPolicy: primaryContainer?.HostConfig?.RestartPolicy?.Name || '', healthcheck: primaryContainer?.Config?.Healthcheck || null, image: primaryContainer?.Config?.Image || service.image || '', dockerMan } + name: "Production", + environment: "production", + provider: "ssh-unraid", + branch: payload.branch || repository.defaultBranch || "main", + serverId: server.id, + remoteFolder, + cloneUrl: payload.remote || repository.sshUrl || "", + alignRemote: false, + generatedCompose: false, + composeFile, + composeService: serviceName, + containerName, + hostPort: primaryPort.hostPort || null, + containerPort: primaryPort.containerPort || null, + webUiUrl, + iconMode: /^https?:\/\//i.test(iconUrl) ? "url" : "none", + iconUrl: /^https?:\/\//i.test(iconUrl) ? iconUrl : "", + serverIconReference: iconUrl, + iconFilePath: "", + dockerShell: ["/bin/bash", "/bin/sh"].includes(shell) ? shell : "/bin/sh", + healthcheckUrl: "", + preservePaths, + confirmationRequired: true, + adoptedFromServer: true, + serverSourceOfTruth: true, + detectedAt: new Date().toISOString(), + detectedMetadata: { + head: payload.head || null, + composeProject: labels["com.docker.compose.project"] || "", + composeFiles, + services: Object.keys(services), + ports, + mounts, + networks, + envNames, + restartPolicy: primaryContainer?.HostConfig?.RestartPolicy?.Name || "", + healthcheck: primaryContainer?.Config?.Healthcheck || null, + image: primaryContainer?.Config?.Image || service.image || "", + dockerMan, + }, }, provenance: { - remoteFolder: source(remoteFolder, 'server-path'), cloneUrl: source(payload.remote || '', 'git-origin'), branch: source(payload.branch || '', 'git'), - composeFile: source(composeFile, 'docker-compose'), composeService: source(serviceName, 'docker-labels'), containerName: source(containerName, 'docker-inspect'), - hostPort: source(primaryPort.hostPort || null, 'docker-inspect'), containerPort: source(primaryPort.containerPort || null, 'docker-inspect'), - webUiUrl: source(webUiUrl, dockerMan.webUiUrl ? 'unraid-dockerman' : 'docker-labels'), iconUrl: source(iconUrl, dockerMan.iconUrl ? 'unraid-dockerman' : 'docker-labels'), dockerShell: source(shell, dockerMan.shell ? 'unraid-dockerman' : 'docker-labels') + remoteFolder: source(remoteFolder, "server-path"), + cloneUrl: source(payload.remote || "", "git-origin"), + branch: source(payload.branch || "", "git"), + composeFile: source(composeFile, "docker-compose"), + composeService: source(serviceName, "docker-labels"), + containerName: source(containerName, "docker-inspect"), + hostPort: source(primaryPort.hostPort || null, "docker-inspect"), + containerPort: source( + primaryPort.containerPort || null, + "docker-inspect", + ), + webUiUrl: source( + webUiUrl, + dockerMan.webUiUrl ? "unraid-dockerman" : "docker-labels", + ), + iconUrl: source( + iconUrl, + dockerMan.iconUrl ? "unraid-dockerman" : "docker-labels", + ), + dockerShell: source( + shell, + dockerMan.shell ? "unraid-dockerman" : "docker-labels", + ), + }, + runtime: { + remotePath, + containerRunning: Boolean(primaryContainer?.State?.Running), + containers: inspections.length, + services: Object.keys(services).length, + ports, + mounts, + networks, + envNames, }, - runtime: { remotePath, containerRunning: Boolean(primaryContainer?.State?.Running), containers: inspections.length, services: Object.keys(services).length, ports, mounts, networks, envNames } }; } function iconReferenceLocalPath(iconReference) { - const value = String(iconReference || '').trim(); - if (value.startsWith('file:///')) return `/${value.slice('file:///'.length)}`; - if (value.startsWith('/')) return value; - return ''; + const value = String(iconReference || "").trim(); + if (value.startsWith("file:///")) return `/${value.slice("file:///".length)}`; + if (value.startsWith("/")) return value; + return ""; } class UnraidDeploymentService { - constructor({ store, ssh, git, diagnostics, sourcePath = process.cwd(), onOperationChange = null }) { + constructor({ + store, + ssh, + git, + diagnostics, + sourcePath = process.cwd(), + onOperationChange = null, + }) { this.store = store; this.ssh = ssh; this.git = git; @@ -190,24 +373,34 @@ class UnraidDeploymentService { } resolve(repository, profileId) { - const profile = this.store.getDeploymentProfile(repository.fullName, profileId); - if (!profile || profile.provider !== 'ssh-unraid') throw new Error('The SSH / Unraid deployment profile no longer exists.'); + const profile = this.store.getDeploymentProfile( + repository.fullName, + profileId, + ); + if (!profile || profile.provider !== "ssh-unraid") + throw new Error("The SSH / Unraid deployment profile no longer exists."); const server = this.store.getServer(profile.serverId); - if (!server) throw new Error('The deployment server no longer exists.'); - const remoteFolder = safeRemoteFolder(profile.remoteFolder || repository.name); + if (!server) throw new Error("The deployment server no longer exists."); + const remoteFolder = safeRemoteFolder( + profile.remoteFolder || repository.name, + ); const remotePath = path.join(server.basePath, remoteFolder); - if (!remotePath.startsWith(`${server.basePath}/`)) throw new Error('Remote project path escapes the configured server base path.'); + if (!remotePath.startsWith(`${server.basePath}/`)) + throw new Error( + "Remote project path escapes the configured server base path.", + ); return { profile, server, remoteFolder, remotePath }; } - - - async discoverExisting({ repository, serverId, remoteFolder = '' }) { + async discoverExisting({ repository, serverId, remoteFolder = "" }) { const server = this.store.getServer(serverId); - if (!server) throw new Error('The deployment server no longer exists.'); + if (!server) throw new Error("The deployment server no longer exists."); const folder = safeRemoteFolder(remoteFolder || repository.name); const remotePath = path.join(server.basePath, folder); - if (!remotePath.startsWith(`${server.basePath}/`)) throw new Error('Remote project path escapes the configured server base path.'); + if (!remotePath.startsWith(`${server.basePath}/`)) + throw new Error( + "Remote project path escapes the configured server base path.", + ); const script = ` root=${shellQuote(remotePath)} test -d "$root" || { echo "Existing server folder not found: $root" >&2; exit 44; } @@ -238,31 +431,65 @@ printf 'compose=%s\\n' "$(printf '%s' "$compose_json" | base64 | tr -d '\\r\\n') printf 'containers=%s\\n' "$(printf '%s' "$container_json" | base64 | tr -d '\\r\\n')" printf 'dockerManXml=%s\\n' "$(printf '%s' "$dockerman_xml" | base64 | tr -d '\\r\\n')" `; - const result = await this.ssh.exec(server.id, bash(script), { timeout: 90_000, maxOutput: 8 * 1024 * 1024 }); - const marker = '__FORGEFLOW_DISCOVERY__'; + const result = await this.ssh.exec(server.id, bash(script), { + timeout: 90_000, + maxOutput: 8 * 1024 * 1024, + }); + const marker = "__FORGEFLOW_DISCOVERY__"; const index = result.stdout.lastIndexOf(marker); - if (index < 0) throw new Error('The server did not return deployment discovery data.'); + if (index < 0) + throw new Error("The server did not return deployment discovery data."); const fields = {}; - for (const line of result.stdout.slice(index + marker.length).trim().split(/\r?\n/)) { - const split = line.indexOf('='); if (split > 0) fields[line.slice(0, split)] = line.slice(split + 1); + for (const line of result.stdout + .slice(index + marker.length) + .trim() + .split(/\r?\n/)) { + const split = line.indexOf("="); + if (split > 0) fields[line.slice(0, split)] = line.slice(split + 1); } const payload = { - head: fields.head || null, branch: fields.branch || null, - remote: fields.remote ? Buffer.from(fields.remote, 'base64').toString('utf8') : '', - composeFiles: fields.composeFiles ? Buffer.from(fields.composeFiles, 'base64').toString('utf8').split(/\r?\n/).filter(Boolean) : [], - compose: decodeBase64Json(fields.compose, {}), containers: decodeBase64Json(fields.containers, []), - dockerManXml: fields.dockerManXml ? Buffer.from(fields.dockerManXml, 'base64').toString('utf8') : '' + head: fields.head || null, + branch: fields.branch || null, + remote: fields.remote + ? Buffer.from(fields.remote, "base64").toString("utf8") + : "", + composeFiles: fields.composeFiles + ? Buffer.from(fields.composeFiles, "base64") + .toString("utf8") + .split(/\r?\n/) + .filter(Boolean) + : [], + compose: decodeBase64Json(fields.compose, {}), + containers: decodeBase64Json(fields.containers, []), + dockerManXml: fields.dockerManXml + ? Buffer.from(fields.dockerManXml, "base64").toString("utf8") + : "", }; - const discovery = deriveDetectedProfile({ repository, server, remoteFolder: folder, remotePath, payload }); - await this.diagnostics?.info('unraid.existing-discovered', { repository: repository.fullName, serverId, remotePath, containers: discovery.runtime.containers, services: discovery.runtime.services }); + const discovery = deriveDetectedProfile({ + repository, + server, + remoteFolder: folder, + remotePath, + payload, + }); + await this.diagnostics?.info("unraid.existing-discovered", { + repository: repository.fullName, + serverId, + remotePath, + containers: discovery.runtime.containers, + services: discovery.runtime.services, + }); return discovery; } async inspect({ repository, profileId }) { const { profile, server, remotePath } = this.resolve(repository, profileId); - const preserveProbe = (profile.preservePaths || []).map((relativePath) => - `if [ -e "$root"/${shellQuote(relativePath)} ]; then printf '%s\\n' ${shellQuote(relativePath)}; fi` - ).join('\n'); + const preserveProbe = (profile.preservePaths || []) + .map( + (relativePath) => + `if [ -e "$root"/${shellQuote(relativePath)} ]; then printf '%s\\n' ${shellQuote(relativePath)}; fi`, + ) + .join("\n"); const script = ` root=${shellQuote(remotePath)} exists=false; root_git=false; head=""; branch=""; remote=""; tracked_changes=""; compose_files=""; nested_git=""; dockerfile=false; dockerignore_content=""; existing_preserve_paths="" @@ -279,7 +506,7 @@ if [ -d "$root" ]; then nested_git=$(find "$root" -mindepth 2 -maxdepth 5 -type d -name .git -printf '%h\\n' 2>/dev/null | sed "s#^$root/##" | sort | base64 | tr -d '\\r\\n' || true) [ -f "$root/Dockerfile" ] && dockerfile=true [ -f "$root/.dockerignore" ] && dockerignore_content=$(base64 < "$root/.dockerignore" | tr -d '\\r\\n' || true) - existing_preserve_paths=$({ ${preserveProbe || ':'}; } | sort -u | base64 | tr -d '\\r\\n' || true) + existing_preserve_paths=$({ ${preserveProbe || ":"}; } | sort -u | base64 | tr -d '\\r\\n' || true) fi printf '__FORGEFLOW_KV__\\n' printf 'exists=%s\\n' "$exists" @@ -297,20 +524,30 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths" const wrapped = bash(script); const result = await this.ssh.exec(server.id, wrapped, { timeout: 60_000 }); const parsed = parseInspection(result.stdout); - const contextCandidates = [...new Set([...(parsed.existingPreservePaths || []), ...(parsed.nestedGit || [])])]; + const contextCandidates = [ + ...new Set([ + ...(parsed.existingPreservePaths || []), + ...(parsed.nestedGit || []), + ]), + ]; const inspection = { ...parsed, dockerignore: Boolean(parsed.dockerignoreContent), - dockerignoreGitExcluded: dockerIgnoreHasPath(parsed.dockerignoreContent, '.git'), + dockerignoreGitExcluded: dockerIgnoreHasPath( + parsed.dockerignoreContent, + ".git", + ), dockerContextExclusionsMissing: parsed.dockerfile - ? contextCandidates.filter((item) => !dockerIgnoreHasPath(parsed.dockerignoreContent, item)) + ? contextCandidates.filter( + (item) => !dockerIgnoreHasPath(parsed.dockerignoreContent, item), + ) : [], serverId: server.id, serverName: server.name, remotePath, - profileId: profile.id + profileId: profile.id, }; - await this.diagnostics?.info('unraid.inspected', { + await this.diagnostics?.info("unraid.inspected", { repository: repository.fullName, serverId: server.id, remotePath, @@ -320,7 +557,7 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths" composeFiles: inspection.composeFiles, nestedGitCount: inspection.nestedGit.length, trackedChangeCount: inspection.trackedChanges.length, - dockerContextExclusionsMissing: inspection.dockerContextExclusionsMissing + dockerContextExclusionsMissing: inspection.dockerContextExclusionsMissing, }); return inspection; } @@ -332,129 +569,425 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths" let inspection = null; if (!repository.localPath) { - checks.push({ id: 'local-repository', label: 'Local repository', status: 'fail', detail: 'Link or clone the repository locally before deploying.' }); + checks.push({ + id: "local-repository", + label: "Local repository", + status: "fail", + detail: "Link or clone the repository locally before deploying.", + }); } else { try { const localStatus = await this.git.status(repository.localPath); - checks.push({ id: 'local-repository', label: 'Local repository', status: 'pass', detail: localStatus.root }); - checks.push({ id: 'local-branch', label: 'Allowed branch', status: localStatus.branch.head === profile.branch ? 'pass' : 'fail', detail: `Current: ${localStatus.branch.head || 'detached'}; required: ${profile.branch}.` }); - checks.push({ id: 'local-clean', label: 'Clean local working tree', status: localStatus.clean ? 'pass' : 'fail', detail: localStatus.clean ? 'No uncommitted changes.' : `${localStatus.counts.changed} changed file(s) remain.` }); - checks.push({ id: 'local-upstream', label: 'Published upstream', status: localStatus.branch.upstream ? 'pass' : 'fail', detail: localStatus.branch.upstream || 'No upstream branch is configured.' }); - checks.push({ id: 'local-sync', label: 'Local and Gitea synchronized', status: !localStatus.branch.ahead && !localStatus.branch.behind ? 'pass' : 'fail', detail: `${localStatus.branch.ahead || 0} ahead, ${localStatus.branch.behind || 0} behind.` }); - checks.push({ id: 'local-target-sha', label: 'Selected deployment commit', status: localStatus.head === targetSha ? 'pass' : 'fail', detail: localStatus.head === targetSha ? targetSha : `Local HEAD is ${localStatus.head || 'unknown'}, but deployment requested ${targetSha}.` }); + checks.push({ + id: "local-repository", + label: "Local repository", + status: "pass", + detail: localStatus.root, + }); + checks.push({ + id: "local-branch", + label: "Allowed branch", + status: localStatus.branch.head === profile.branch ? "pass" : "fail", + detail: `Current: ${localStatus.branch.head || "detached"}; required: ${profile.branch}.`, + }); + checks.push({ + id: "local-clean", + label: "Clean local working tree", + status: localStatus.clean ? "pass" : "fail", + detail: localStatus.clean + ? "No uncommitted changes." + : `${localStatus.counts.changed} changed file(s) remain.`, + }); + checks.push({ + id: "local-upstream", + label: "Published upstream", + status: localStatus.branch.upstream ? "pass" : "fail", + detail: + localStatus.branch.upstream || "No upstream branch is configured.", + }); + checks.push({ + id: "local-sync", + label: "Local and Gitea synchronized", + status: + !localStatus.branch.ahead && !localStatus.branch.behind + ? "pass" + : "fail", + detail: `${localStatus.branch.ahead || 0} ahead, ${localStatus.branch.behind || 0} behind.`, + }); + checks.push({ + id: "local-target-sha", + label: "Selected deployment commit", + status: localStatus.head === targetSha ? "pass" : "fail", + detail: + localStatus.head === targetSha + ? targetSha + : `Local HEAD is ${localStatus.head || "unknown"}, but deployment requested ${targetSha}.`, + }); try { - await this.git.verifyCommitOnRemoteBranch(repository.localPath, targetSha, profile.branch); - checks.push({ id: 'remote-target-sha', label: 'Exact commit on Gitea branch', status: 'pass', detail: `${targetSha.slice(0, 7)} exists on origin/${profile.branch}.` }); + await this.git.verifyCommitOnRemoteBranch( + repository.localPath, + targetSha, + profile.branch, + ); + checks.push({ + id: "remote-target-sha", + label: "Exact commit on Gitea branch", + status: "pass", + detail: `${targetSha.slice(0, 7)} exists on origin/${profile.branch}.`, + }); } catch (error) { - checks.push({ id: 'remote-target-sha', label: 'Exact commit on Gitea branch', status: 'fail', detail: error.message }); + checks.push({ + id: "remote-target-sha", + label: "Exact commit on Gitea branch", + status: "fail", + detail: error.message, + }); } const localDeploymentFile = profile.generatedCompose - ? nativePath.join(repository.localPath, 'Dockerfile') - : nativePath.join(repository.localPath, safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml')); - const localDeploymentFileExists = Boolean((await fs.stat(localDeploymentFile).catch(() => null))?.isFile()); + ? nativePath.join(repository.localPath, "Dockerfile") + : nativePath.join( + repository.localPath, + safeRelativeRemoteFile( + profile.composeFile || "docker-compose.yml", + ), + ); + const localDeploymentFileExists = Boolean( + (await fs.stat(localDeploymentFile).catch(() => null))?.isFile(), + ); checks.push({ - id: 'local-deployment-file', - label: profile.generatedCompose ? 'Dockerfile in repository' : 'Compose file in repository', - status: localDeploymentFileExists ? 'pass' : 'fail', - detail: localDeploymentFileExists ? localDeploymentFile : `${localDeploymentFile} was not found in the exact local checkout.` + id: "local-deployment-file", + label: profile.generatedCompose + ? "Dockerfile in repository" + : "Compose file in repository", + status: localDeploymentFileExists ? "pass" : "fail", + detail: localDeploymentFileExists + ? localDeploymentFile + : `${localDeploymentFile} was not found in the exact local checkout.`, }); } catch (error) { - checks.push({ id: 'local-repository', label: 'Local repository', status: 'fail', detail: error.message }); + checks.push({ + id: "local-repository", + label: "Local repository", + status: "fail", + detail: error.message, + }); } } try { - const connection = await this.ssh.test(server.id, { trustOnFirstUse: false }); - checks.push({ id: 'ssh', label: 'SSH connection', status: 'pass', detail: `${server.username}@${server.host}:${server.port}` }); + const connection = await this.ssh.test(server.id, { + trustOnFirstUse: false, + }); + checks.push({ + id: "ssh", + label: "SSH connection", + status: "pass", + detail: `${server.username}@${server.host}:${server.port}`, + }); if (!/docker compose|docker-compose/i.test(connection.output)) { - checks.push({ id: 'compose-command', label: 'Docker Compose', status: 'fail', detail: 'Docker Compose was not detected on the server.' }); - } else checks.push({ id: 'compose-command', label: 'Docker Compose', status: 'pass', detail: 'Docker Compose is available.' }); + checks.push({ + id: "compose-command", + label: "Docker Compose", + status: "fail", + detail: "Docker Compose was not detected on the server.", + }); + } else + checks.push({ + id: "compose-command", + label: "Docker Compose", + status: "pass", + detail: "Docker Compose is available.", + }); } catch (error) { - checks.push({ id: 'ssh', label: 'SSH connection', status: 'fail', detail: error.message }); + checks.push({ + id: "ssh", + label: "SSH connection", + status: "fail", + detail: error.message, + }); } - if (!server.hostFingerprint) checks.push({ id: 'host-key', label: 'Server identity', status: 'fail', detail: 'Test and trust the SSH host key first.' }); - else checks.push({ id: 'host-key', label: 'Server identity', status: 'pass', detail: server.hostFingerprint }); + if (!server.hostFingerprint) + checks.push({ + id: "host-key", + label: "Server identity", + status: "fail", + detail: "Test and trust the SSH host key first.", + }); + else + checks.push({ + id: "host-key", + label: "Server identity", + status: "pass", + detail: server.hostFingerprint, + }); - const cloneUrl = String(profile.cloneUrl || repository.sshUrl || repository.preferredCloneUrl || '').trim(); + const cloneUrl = String( + profile.cloneUrl || + repository.sshUrl || + repository.preferredCloneUrl || + "", + ).trim(); if (!cloneUrl) { - checks.push({ id: 'server-git-access', label: 'Unraid → Gitea access', status: 'fail', detail: 'No server-usable Git clone URL is configured.' }); + checks.push({ + id: "server-git-access", + label: "Unraid → Gitea access", + status: "fail", + detail: "No server-usable Git clone URL is configured.", + }); } else { try { - const branchRef = `refs/heads/${String(profile.branch || 'main')}`; - const probe = await this.ssh.exec(server.id, bash(`git ls-remote --exit-code ${shellQuote(cloneUrl)} ${shellQuote(branchRef)}`), { timeout: 45_000, maxOutput: 256 * 1024 }); - const remoteSha = String(probe.stdout || '').trim().split(/\s+/)[0] || 'reachable'; - checks.push({ id: 'server-git-access', label: 'Unraid → Gitea access', status: 'pass', detail: `${cloneUrl} · ${String(remoteSha).slice(0, 7)}` }); + const branchRef = `refs/heads/${String(profile.branch || "main")}`; + const probe = await this.ssh.exec( + server.id, + bash( + `git ls-remote --exit-code ${shellQuote(cloneUrl)} ${shellQuote(branchRef)}`, + ), + { timeout: 45_000, maxOutput: 256 * 1024 }, + ); + const remoteSha = + String(probe.stdout || "") + .trim() + .split(/\s+/)[0] || "reachable"; + checks.push({ + id: "server-git-access", + label: "Unraid → Gitea access", + status: "pass", + detail: `${cloneUrl} · ${String(remoteSha).slice(0, 7)}`, + }); } catch (error) { - checks.push({ id: 'server-git-access', label: 'Unraid → Gitea access', status: 'fail', detail: `Unraid cannot read the repository with the configured clone URL: ${error.message}` }); + checks.push({ + id: "server-git-access", + label: "Unraid → Gitea access", + status: "fail", + detail: `Unraid cannot read the repository with the configured clone URL: ${error.message}`, + }); } } try { inspection = await this.inspect({ repository, profileId }); if (!inspection.exists) { - checks.push({ id: 'remote-folder', label: 'Remote project folder', status: 'pass', detail: `${remotePath} will be created.` }); + checks.push({ + id: "remote-folder", + label: "Remote project folder", + status: "pass", + detail: `${remotePath} will be created.`, + }); } else if (!inspection.rootGit) { - checks.push({ id: 'remote-folder', label: 'Remote project folder', status: 'fail', detail: `${remotePath} exists but is not a Git working tree. Adopt or migrate it before deployment.` }); + checks.push({ + id: "remote-folder", + label: "Remote project folder", + status: "fail", + detail: `${remotePath} exists but is not a Git working tree. Adopt or migrate it before deployment.`, + }); } else { - checks.push({ id: 'remote-folder', label: 'Remote Git working tree', status: 'pass', detail: `${remotePath} at ${String(inspection.head || '').slice(0, 7) || 'unknown'}.` }); + checks.push({ + id: "remote-folder", + label: "Remote Git working tree", + status: "pass", + detail: `${remotePath} at ${String(inspection.head || "").slice(0, 7) || "unknown"}.`, + }); } if (inspection.trackedChanges.length) { - checks.push({ id: 'tracked-changes', label: 'Server-side tracked changes', status: 'fail', detail: `${inspection.trackedChanges.length} tracked change(s) would be overwritten. Commit, revert or migrate them first.` }); - } else if (inspection.rootGit) checks.push({ id: 'tracked-changes', label: 'Server-side tracked changes', status: 'pass', detail: 'No tracked server-only edits detected.' }); + checks.push({ + id: "tracked-changes", + label: "Server-side tracked changes", + status: "fail", + detail: `${inspection.trackedChanges.length} tracked change(s) would be overwritten. Commit, revert or migrate them first.`, + }); + } else if (inspection.rootGit) + checks.push({ + id: "tracked-changes", + label: "Server-side tracked changes", + status: "pass", + detail: "No tracked server-only edits detected.", + }); if (inspection.rootGit && profile.cloneUrl && inspection.remote) { const expectedRemote = normalizeRemoteUrl(profile.cloneUrl); const currentRemote = normalizeRemoteUrl(inspection.remote); - const matches = Boolean(expectedRemote && currentRemote && expectedRemote.host === currentRemote.host && expectedRemote.path === currentRemote.path); + const matches = Boolean( + expectedRemote && + currentRemote && + expectedRemote.host === currentRemote.host && + expectedRemote.path === currentRemote.path, + ); if (!matches && profile.alignRemote) { - checks.push({ id: 'origin-url', label: 'Server Git origin', status: 'warning', detail: `Origin will be aligned from ${inspection.remote} to the configured clone URL before fetch.` }); + checks.push({ + id: "origin-url", + label: "Server Git origin", + status: "warning", + detail: `Origin will be aligned from ${inspection.remote} to the configured clone URL before fetch.`, + }); } else if (!matches) { - checks.push({ id: 'origin-url', label: 'Server Git origin', status: 'fail', detail: `Current origin ${inspection.remote} does not match the configured clone URL. Enable controlled origin alignment or correct the profile.` }); + checks.push({ + id: "origin-url", + label: "Server Git origin", + status: "fail", + detail: `Current origin ${inspection.remote} does not match the configured clone URL. Enable controlled origin alignment or correct the profile.`, + }); } else { - checks.push({ id: 'origin-url', label: 'Server Git origin', status: 'pass', detail: inspection.remote }); + checks.push({ + id: "origin-url", + label: "Server Git origin", + status: "pass", + detail: inspection.remote, + }); } } if (inspection.nestedGit.length) { - checks.push({ id: 'nested-git', label: 'Nested Git repositories', status: 'warning', detail: `Detected: ${inspection.nestedGit.join(', ')}. ForgeFlow will not delete them automatically.` }); + checks.push({ + id: "nested-git", + label: "Nested Git repositories", + status: "warning", + detail: `Detected: ${inspection.nestedGit.join(", ")}. ForgeFlow will not delete them automatically.`, + }); } if (inspection.dockerfile && !inspection.dockerignore) { - checks.push({ id: 'dockerignore', label: 'Docker build context', status: 'warning', detail: 'A Dockerfile exists but .dockerignore is missing. Add one in the repository before large builds.' }); + checks.push({ + id: "dockerignore", + label: "Docker build context", + status: "warning", + detail: + "A Dockerfile exists but .dockerignore is missing. Add one in the repository before large builds.", + }); } else if (inspection.dockerfile && !inspection.dockerignoreGitExcluded) { - checks.push({ id: 'dockerignore-git', label: 'Git metadata excluded from Docker', status: 'warning', detail: '.dockerignore does not explicitly exclude .git.' }); + checks.push({ + id: "dockerignore-git", + label: "Git metadata excluded from Docker", + status: "warning", + detail: ".dockerignore does not explicitly exclude .git.", + }); } else if (inspection.dockerfile) { - checks.push({ id: 'dockerignore-git', label: 'Git metadata excluded from Docker', status: 'pass', detail: '.git is excluded from the Docker build context.' }); + checks.push({ + id: "dockerignore-git", + label: "Git metadata excluded from Docker", + status: "pass", + detail: ".git is excluded from the Docker build context.", + }); } if (inspection.dockerContextExclusionsMissing.length) { - checks.push({ id: 'dockerignore-runtime', label: 'Runtime data excluded from Docker', status: 'warning', detail: `Add these existing runtime or legacy paths to .dockerignore: ${inspection.dockerContextExclusionsMissing.join(', ')}.` }); - } else if (inspection.dockerfile && inspection.existingPreservePaths.length) { - checks.push({ id: 'dockerignore-runtime', label: 'Runtime data excluded from Docker', status: 'pass', detail: 'Detected preserved runtime paths are excluded from the Docker build context.' }); + checks.push({ + id: "dockerignore-runtime", + label: "Runtime data excluded from Docker", + status: "warning", + detail: `Add these existing runtime or legacy paths to .dockerignore: ${inspection.dockerContextExclusionsMissing.join(", ")}.`, + }); + } else if ( + inspection.dockerfile && + inspection.existingPreservePaths.length + ) { + checks.push({ + id: "dockerignore-runtime", + label: "Runtime data excluded from Docker", + status: "pass", + detail: + "Detected preserved runtime paths are excluded from the Docker build context.", + }); } - const composeFile = safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml'); - if (inspection.exists && !inspection.composeFiles.includes(composeFile) && !profile.generatedCompose) { - checks.push({ id: 'compose-file', label: 'Compose configuration', status: 'fail', detail: `${composeFile} was not found. Select an existing file or enable generated Compose.` }); + const composeFile = safeRelativeRemoteFile( + profile.composeFile || "docker-compose.yml", + ); + if ( + inspection.exists && + !inspection.composeFiles.includes(composeFile) && + !profile.generatedCompose + ) { + checks.push({ + id: "compose-file", + label: "Compose configuration", + status: "fail", + detail: `${composeFile} was not found. Select an existing file or enable generated Compose.`, + }); } else { - checks.push({ id: 'compose-file', label: 'Compose configuration', status: 'pass', detail: profile.generatedCompose ? 'ForgeFlow will generate an isolated Compose file.' : composeFile }); + checks.push({ + id: "compose-file", + label: "Compose configuration", + status: "pass", + detail: profile.generatedCompose + ? "ForgeFlow will generate an isolated Compose file." + : composeFile, + }); } } catch (error) { - checks.push({ id: 'inspection', label: 'Server project inspection', status: 'fail', detail: error.message }); + checks.push({ + id: "inspection", + label: "Server project inspection", + status: "fail", + detail: error.message, + }); } - const iconMode = profile.iconMode || (profile.iconFilePath ? 'upload' : profile.iconUrl ? 'url' : 'builtin'); - if (iconMode === 'upload') { + const iconMode = + profile.iconMode || + (profile.iconFilePath ? "upload" : profile.iconUrl ? "url" : "builtin"); + if (iconMode === "upload") { const iconStat = await fs.stat(profile.iconFilePath).catch(() => null); - checks.push({ id: 'dockerman-icon-file', label: 'DockerMan icon upload', status: iconStat?.isFile() && nativePath.extname(profile.iconFilePath).toLowerCase() === '.png' ? 'pass' : 'fail', detail: iconStat?.isFile() ? profile.iconFilePath : 'The selected local PNG icon file was not found.' }); - } else if (iconMode === 'builtin') { - const builtinIcon = nativePath.join(this.sourcePath, 'src', 'renderer', 'assets', 'itworx-mark.png'); + checks.push({ + id: "dockerman-icon-file", + label: "DockerMan icon upload", + status: + iconStat?.isFile() && + nativePath.extname(profile.iconFilePath).toLowerCase() === ".png" + ? "pass" + : "fail", + detail: iconStat?.isFile() + ? profile.iconFilePath + : "The selected local PNG icon file was not found.", + }); + } else if (iconMode === "builtin") { + const builtinIcon = nativePath.join( + this.sourcePath, + "src", + "renderer", + "assets", + "itworx-mark.png", + ); const iconStat = await fs.stat(builtinIcon).catch(() => null); - checks.push({ id: 'dockerman-icon-builtin', label: 'DockerMan icon', status: iconStat?.isFile() ? 'pass' : 'fail', detail: iconStat?.isFile() ? 'Built-in high-contrast ITWorx mark.' : 'The built-in ITWorx icon asset is missing.' }); - } else if (iconMode === 'url') checks.push({ id: 'dockerman-icon', label: 'DockerMan icon', status: profile.iconUrl ? 'pass' : 'fail', detail: profile.iconUrl || 'Icon URL mode requires an HTTPS or HTTP PNG URL.' }); - else checks.push({ id: 'dockerman-icon', label: 'DockerMan icon', status: 'warning', detail: 'Custom DockerMan icon disabled.' }); + checks.push({ + id: "dockerman-icon-builtin", + label: "DockerMan icon", + status: iconStat?.isFile() ? "pass" : "fail", + detail: iconStat?.isFile() + ? "Built-in high-contrast ITWorx mark." + : "The built-in ITWorx icon asset is missing.", + }); + } else if (iconMode === "url") + checks.push({ + id: "dockerman-icon", + label: "DockerMan icon", + status: profile.iconUrl ? "pass" : "fail", + detail: + profile.iconUrl || "Icon URL mode requires an HTTPS or HTTP PNG URL.", + }); + else + checks.push({ + id: "dockerman-icon", + label: "DockerMan icon", + status: "warning", + detail: "Custom DockerMan icon disabled.", + }); const webUiLabel = this.dockerManWebUi(profile); - checks.push({ id: 'dockerman-webui', label: 'DockerMan Web UI action', status: webUiLabel ? 'pass' : 'warning', detail: webUiLabel || 'No Web UI URL or host port is configured.' }); - checks.push({ id: 'compose-identity', label: 'Safe Docker Compose identity', status: 'pass', detail: `Internal project/image: ${this.internalSlug(profile, repository)}; visible container: ${profile.containerName || profile.remoteFolder || repository.name}.` }); - checks.push({ id: 'exact-sha', label: 'Exact deployment commit', status: 'pass', detail: targetSha }); + checks.push({ + id: "dockerman-webui", + label: "DockerMan Web UI action", + status: webUiLabel ? "pass" : "warning", + detail: webUiLabel || "No Web UI URL or host port is configured.", + }); + checks.push({ + id: "compose-identity", + label: "Safe Docker Compose identity", + status: "pass", + detail: `Internal project/image: ${this.internalSlug(profile, repository)}; visible container: ${profile.containerName || profile.remoteFolder || repository.name}.`, + }); + checks.push({ + id: "exact-sha", + label: "Exact deployment commit", + status: "pass", + detail: targetSha, + }); return { - provider: 'ssh-unraid', + provider: "ssh-unraid", repository: repository.fullName, environment: profile.environment, sha: targetSha, @@ -462,88 +995,140 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths" remotePath, inspection, checks, - summary: checksSummary(checks) + summary: checksSummary(checks), }; } internalSlug(profile, repository) { - return String(profile.remoteFolder || repository.name || profile.composeService || 'app') - .toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'app'; + return ( + String( + profile.remoteFolder || + repository.name || + profile.composeService || + "app", + ) + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") || "app" + ); } generatedCompose(profile, repository) { - const service = String(profile.composeService || repository.name || 'app').toLowerCase().replace(/[^a-z0-9._-]/g, '-') || 'app'; - const containerName = String(profile.containerName || profile.remoteFolder || repository.name || service).replace(/[^A-Za-z0-9._-]/g, '-') || service; - if (!profile.hostPort || !profile.containerPort) throw new Error('Host and container ports are required for generated Compose.'); - return [ - 'services:', - ` ${service}:`, - ` image: forgeflow/${this.internalSlug(profile, repository)}:${String(profile.environment || 'production').toLowerCase()}`, - ' build:', - ' context: ..', - ` container_name: ${containerName}`, - ' restart: unless-stopped', - ' ports:', - ` - "${profile.hostPort}:${profile.containerPort}"` - ].join('\n') + '\n'; + const service = + String(profile.composeService || repository.name || "app") + .toLowerCase() + .replace(/[^a-z0-9._-]/g, "-") || "app"; + const containerName = + String( + profile.containerName || + profile.remoteFolder || + repository.name || + service, + ).replace(/[^A-Za-z0-9._-]/g, "-") || service; + if (!profile.hostPort || !profile.containerPort) + throw new Error( + "Host and container ports are required for generated Compose.", + ); + return ( + [ + "services:", + ` ${service}:`, + ` image: forgeflow/${this.internalSlug(profile, repository)}:${String(profile.environment || "production").toLowerCase()}`, + " build:", + " context: ..", + ` container_name: ${containerName}`, + " restart: unless-stopped", + " ports:", + ` - "${profile.hostPort}:${profile.containerPort}"`, + ].join("\n") + "\n" + ); } dockerManWebUi(profile) { if (profile.hostPort) { - let suffix = '/'; + let suffix = "/"; try { const parsed = profile.webUiUrl ? new URL(profile.webUiUrl) : null; - suffix = parsed ? `${parsed.pathname || '/'}${parsed.search || ''}${parsed.hash || ''}` : '/'; + suffix = parsed + ? `${parsed.pathname || "/"}${parsed.search || ""}${parsed.hash || ""}` + : "/"; } catch {} - if (!suffix.startsWith('/')) suffix = `/${suffix}`; + if (!suffix.startsWith("/")) suffix = `/${suffix}`; return `http://[IP]:[PORT:${profile.hostPort}]${suffix}`; } - return profile.webUiUrl || ''; + return profile.webUiUrl || ""; } dockerManShell(profile) { - return String(profile.dockerShell || '/bin/sh').toLowerCase().includes('bash') ? 'bash' : 'sh'; + return String(profile.dockerShell || "/bin/sh") + .toLowerCase() + .includes("bash") + ? "bash" + : "sh"; } dockerManTemplatePath(profile, repository) { - const containerName = String(profile.containerName || profile.remoteFolder || repository.name || 'app').replace(/[^A-Za-z0-9._-]/g, '-') || 'app'; + const containerName = + String( + profile.containerName || + profile.remoteFolder || + repository.name || + "app", + ).replace(/[^A-Za-z0-9._-]/g, "-") || "app"; return `/boot/config/plugins/dockerMan/templates-user/my-${containerName}.xml`; } - dockerManTemplate(profile, repository, iconReference = '') { - const containerName = String(profile.containerName || profile.remoteFolder || repository.name || 'app').replace(/[^A-Za-z0-9._-]/g, '-') || 'app'; + dockerManTemplate(profile, repository, iconReference = "") { + const containerName = + String( + profile.containerName || + profile.remoteFolder || + repository.name || + "app", + ).replace(/[^A-Za-z0-9._-]/g, "-") || "app"; const slug = this.internalSlug(profile, repository); - const environment = String(profile.environment || 'production').toLowerCase().replace(/[^a-z0-9._-]/g, '-') || 'production'; + const environment = + String(profile.environment || "production") + .toLowerCase() + .replace(/[^a-z0-9._-]/g, "-") || "production"; const image = `forgeflow/${slug}:${environment}`; const webUi = this.dockerManWebUi(profile); - return [ - '', - '', - ` ${xmlEscape(containerName)}`, - ` ${xmlEscape(image)}`, - ' ', - ' bridge', - ' ', - ` ${xmlEscape(this.dockerManShell(profile))}`, - ' false', - ' ', - ' ', - ' Managed by ForgeFlow through Docker Compose. Use ForgeFlow or the Compose files for configuration changes.', - ' Tools:', - ` ${xmlEscape(webUi)}`, - ' ', - ` ${xmlEscape(iconReference)}`, - ' ', - ' ', - ' ', - ' ', - ' ', - '' - ].join('\n') + '\n'; + return ( + [ + '', + '', + ` ${xmlEscape(containerName)}`, + ` ${xmlEscape(image)}`, + " ", + " bridge", + " ", + ` ${xmlEscape(this.dockerManShell(profile))}`, + " false", + " ", + " ", + " Managed by ForgeFlow through Docker Compose. Use ForgeFlow or the Compose files for configuration changes.", + " Tools:", + ` ${xmlEscape(webUi)}`, + " ", + ` ${xmlEscape(iconReference)}`, + " ", + " ", + " ", + " ", + " ", + "", + ].join("\n") + "\n" + ); } - iconCacheRefresh(profile, repository, iconReference = '') { - const containerName = String(profile.containerName || profile.remoteFolder || repository.name || 'app').replace(/[^A-Za-z0-9._-]/g, '-') || 'app'; + iconCacheRefresh(profile, repository, iconReference = "") { + const containerName = + String( + profile.containerName || + profile.remoteFolder || + repository.name || + "app", + ).replace(/[^A-Za-z0-9._-]/g, "-") || "app"; const cacheLoop = `for icon_dir in /var/lib/docker/unraid/images /usr/local/emhttp/state/plugins/dynamix.docker.manager/images /var/local/emhttp/plugins/dynamix.docker.manager/images; do [ -d "$icon_dir" ] || continue; rm -f "$icon_dir/${containerName}-icon.png" "$icon_dir/${containerName}.png"; done`; const invalidateMetadata = `rm -f /usr/local/emhttp/state/plugins/dynamix.docker.manager/docker.json`; const localIconPath = iconReferenceLocalPath(iconReference); @@ -553,7 +1138,7 @@ if [ -f ${shellQuote(localIconPath)} ]; then for icon_dir in /var/lib/docker/unr ${invalidateMetadata}`; } - dockerManRefreshScript(profile, repository, iconReference = '') { + dockerManRefreshScript(profile, repository, iconReference = "") { const templatePath = this.dockerManTemplatePath(profile, repository); const template = this.dockerManTemplate(profile, repository, iconReference); return `mkdir -p /boot/config/plugins/dockerMan/templates-user @@ -563,43 +1148,90 @@ chmod 0644 ${shellQuote(templatePath)} ${this.iconCacheRefresh(profile, repository, iconReference)}`; } - metadataCompose(profile, repository, iconReference = '') { - const service = String(profile.composeService || repository.name || 'app').trim().toLowerCase().replace(/[^a-z0-9._-]/g, '-') || 'app'; - const containerName = String(profile.containerName || profile.remoteFolder || repository.name || service).replace(/[^A-Za-z0-9._-]/g, '-') || 'app'; + metadataCompose(profile, repository, iconReference = "") { + const service = + String(profile.composeService || repository.name || "app") + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]/g, "-") || "app"; + const containerName = + String( + profile.containerName || + profile.remoteFolder || + repository.name || + service, + ).replace(/[^A-Za-z0-9._-]/g, "-") || "app"; const slug = this.internalSlug(profile, repository); const labels = { - 'net.unraid.docker.managed': 'dockerman', - 'net.unraid.docker.shell': this.dockerManShell(profile) + "net.unraid.docker.managed": "dockerman", + "net.unraid.docker.shell": this.dockerManShell(profile), }; const webUiLabel = this.dockerManWebUi(profile); - if (webUiLabel) labels['net.unraid.docker.webui'] = webUiLabel; - if (iconReference) labels['net.unraid.docker.icon'] = iconReference; - return [ - 'services:', - ` ${service}:`, - ` image: forgeflow/${slug}:${String(profile.environment || 'production').toLowerCase().replace(/[^a-z0-9._-]/g, '-')}`, - ` container_name: ${containerName}`, - ' labels:', - ...Object.entries(labels).map(([key, value]) => ` ${JSON.stringify(key)}: ${JSON.stringify(value)}`) - ].join('\n') + '\n'; + if (webUiLabel) labels["net.unraid.docker.webui"] = webUiLabel; + if (iconReference) labels["net.unraid.docker.icon"] = iconReference; + return ( + [ + "services:", + ` ${service}:`, + ` image: forgeflow/${slug}:${String( + profile.environment || "production", + ) + .toLowerCase() + .replace(/[^a-z0-9._-]/g, "-")}`, + ` container_name: ${containerName}`, + " labels:", + ...Object.entries(labels).map( + ([key, value]) => + ` ${JSON.stringify(key)}: ${JSON.stringify(value)}`, + ), + ].join("\n") + "\n" + ); } async prepareIcon(profile, repository, server) { - const mode = profile.iconMode || (profile.iconFilePath ? 'upload' : profile.iconUrl ? 'url' : 'builtin'); - if (mode === 'none') return ''; - if (mode === 'url') { - if (!profile.iconUrl) throw new Error('DockerMan icon URL mode is selected, but no icon URL is configured.'); + const mode = + profile.iconMode || + (profile.iconFilePath ? "upload" : profile.iconUrl ? "url" : "builtin"); + if (mode === "none") return ""; + if (mode === "url") { + if (!profile.iconUrl) + throw new Error( + "DockerMan icon URL mode is selected, but no icon URL is configured.", + ); return profile.iconUrl; } - const localIconPath = mode === 'builtin' - ? nativePath.join(this.sourcePath, 'src', 'renderer', 'assets', 'itworx-mark.png') - : profile.iconFilePath; + const localIconPath = + mode === "builtin" + ? nativePath.join( + this.sourcePath, + "src", + "renderer", + "assets", + "itworx-mark.png", + ) + : profile.iconFilePath; const stat = await fs.stat(localIconPath).catch(() => null); - if (!stat?.isFile()) throw new Error(mode === 'builtin' ? 'The built-in ITWorx DockerMan icon is missing.' : `The selected DockerMan icon file no longer exists: ${localIconPath}`); - if (nativePath.extname(localIconPath).toLowerCase() !== '.png') throw new Error('DockerMan icon upload currently accepts PNG files only.'); - const containerName = String(profile.containerName || profile.remoteFolder || repository.name || 'app').replace(/[^A-Za-z0-9._-]/g, '-') || 'app'; + if (!stat?.isFile()) + throw new Error( + mode === "builtin" + ? "The built-in ITWorx DockerMan icon is missing." + : `The selected DockerMan icon file no longer exists: ${localIconPath}`, + ); + if (nativePath.extname(localIconPath).toLowerCase() !== ".png") + throw new Error( + "DockerMan icon upload currently accepts PNG files only.", + ); + const containerName = + String( + profile.containerName || + profile.remoteFolder || + repository.name || + "app", + ).replace(/[^A-Za-z0-9._-]/g, "-") || "app"; const remoteIconPath = `/boot/config/plugins/dockerMan/images/${containerName}-icon.png`; - await this.ssh.uploadFile(server.id, localIconPath, remoteIconPath, { mode: 0o644 }); + await this.ssh.uploadFile(server.id, localIconPath, remoteIconPath, { + mode: 0o644, + }); return `file://${remoteIconPath}`; } @@ -609,18 +1241,39 @@ ${this.iconCacheRefresh(profile, repository, iconReference)}`; } async checkHealth(url) { - if (!url) return { configured: false, healthy: null, status: null, latencyMs: null }; + if (!url) + return { + configured: false, + healthy: null, + status: null, + latencyMs: null, + }; let last = null; for (let attempt = 1; attempt <= 5; attempt += 1) { const started = Date.now(); try { - const response = await fetch(url, { signal: AbortSignal.timeout(8_000), redirect: 'manual' }); - last = { configured: true, healthy: response.ok, status: response.status, latencyMs: Date.now() - started }; + const response = await fetch(url, { + signal: AbortSignal.timeout(8_000), + redirect: "manual", + }); + last = { + configured: true, + healthy: response.ok, + status: response.status, + latencyMs: Date.now() - started, + }; if (response.ok) return last; } catch (error) { - last = { configured: true, healthy: false, status: null, latencyMs: Date.now() - started, error: error.message }; + last = { + configured: true, + healthy: false, + status: null, + latencyMs: Date.now() - started, + error: error.message, + }; } - if (attempt < 5) await new Promise((resolve) => setTimeout(resolve, 3_000)); + if (attempt < 5) + await new Promise((resolve) => setTimeout(resolve, 3_000)); } return last; } @@ -628,18 +1281,24 @@ ${this.iconCacheRefresh(profile, repository, iconReference)}`; async deploy({ repository, profileId, sha }) { const targetSha = assertFullCommitSha(sha); const { profile, server, remotePath } = this.resolve(repository, profileId); - const preflight = await this.preflight({ repository, profileId, sha: targetSha }); + const preflight = await this.preflight({ + repository, + profileId, + sha: targetSha, + }); if (!preflight.summary.ready) { - const error = new Error(`SSH deployment preflight failed: ${preflight.summary.blocking.join(', ')}`); - error.code = 'SSH_DEPLOYMENT_PREFLIGHT_FAILED'; + const error = new Error( + `SSH deployment preflight failed: ${preflight.summary.blocking.join(", ")}`, + ); + error.code = "SSH_DEPLOYMENT_PREFLIGHT_FAILED"; throw error; } const requestId = crypto.randomUUID(); const operation = await this.saveOperation({ id: requestId, - type: 'deployment', - action: 'deploy', - provider: 'ssh-unraid', + type: "deployment", + action: "deploy", + provider: "ssh-unraid", repository: repository.fullName, environment: profile.environment, profileId, @@ -647,17 +1306,30 @@ ${this.iconCacheRefresh(profile, repository, iconReference)}`; remotePath, sha: targetSha, shortSha: targetSha.slice(0, 7), - status: 'running', - logs: ['Preflight passed.', 'Unraid can read the Gitea repository.', `Deploying exact commit ${targetSha} in the background.`] + status: "running", + logs: [ + "Preflight passed.", + "Unraid can read the Gitea repository.", + `Deploying exact commit ${targetSha} in the background.`, + ], }); - const cloneUrl = String(profile.cloneUrl || repository.sshUrl || repository.preferredCloneUrl || '').trim(); - const composeFile = profile.generatedCompose ? '.forgeflow/compose.forgeflow.yml' : safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml'); - const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : ''; + const cloneUrl = String( + profile.cloneUrl || + repository.sshUrl || + repository.preferredCloneUrl || + "", + ).trim(); + const composeFile = profile.generatedCompose + ? ".forgeflow/compose.forgeflow.yml" + : safeRelativeRemoteFile(profile.composeFile || "docker-compose.yml"); + const generated = profile.generatedCompose + ? this.generatedCompose(profile, repository) + : ""; const iconReference = await this.prepareIcon(profile, repository, server); const metadata = this.metadataCompose(profile, repository, iconReference); const compose = this.composeInvocation(profile, repository, composeFile); - const branch = String(profile.branch || 'main'); + const branch = String(profile.branch || "main"); const statusJson = JSON.stringify({ repository: repository.fullName, environment: profile.environment, @@ -666,7 +1338,7 @@ ${this.iconCacheRefresh(profile, repository, iconReference)}`; request_id: requestId, healthy: null, healthcheck_url_configured: Boolean(profile.healthcheckUrl), - deployed_at: new Date().toISOString() + deployed_at: new Date().toISOString(), }); const script = ` root=${shellQuote(remotePath)} @@ -676,7 +1348,7 @@ if [ ! -d "$root" ]; then git clone --branch ${shellQuote(branch)} --single-branch ${shellQuote(cloneUrl)} "$root" fi test -d "$root/.git" || { echo "Existing folder is not a Git working tree" >&2; exit 32; } -${profile.alignRemote ? `git -C "$root" remote set-url origin ${shellQuote(cloneUrl)}` : ''} +${profile.alignRemote ? `git -C "$root" remote set-url origin ${shellQuote(cloneUrl)}` : ""} changes=$(git -C "$root" status --porcelain --untracked-files=no) test -z "$changes" || { echo "Tracked server-side changes block deployment" >&2; printf '%s\n' "$changes" >&2; exit 33; } git -C "$root" fetch --prune origin ${shellQuote(branch)} @@ -688,7 +1360,7 @@ git -C "$root" reset --hard ${shellQuote(targetSha)} mkdir -p "$root/.forgeflow" printf '%s' "$previous" > "$root/.forgeflow/previous-sha" printf '%s' ${shellQuote(targetSha)} > "$root/.forgeflow/current-sha" -${profile.generatedCompose ? `cat > "$root/.forgeflow/compose.forgeflow.yml" <<'FORGEFLOW_COMPOSE'\n${generated}FORGEFLOW_COMPOSE` : ''} +${profile.generatedCompose ? `cat > "$root/.forgeflow/compose.forgeflow.yml" <<'FORGEFLOW_COMPOSE'\n${generated}FORGEFLOW_COMPOSE` : ""} cat > "$root/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA' ${metadata}FORGEFLOW_METADATA cd "$root" @@ -704,20 +1376,23 @@ FORGEFLOW_STATUS void (async () => { try { - const result = await this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 4 * 1024 * 1024 }); + const result = await this.ssh.exec(server.id, bash(script), { + timeout: 30 * 60_000, + maxOutput: 4 * 1024 * 1024, + }); const health = await this.checkHealth(profile.healthcheckUrl); // The remote deployment script already verifies that Docker created the expected // container. Complete the operation before a secondary state inspection so a slow or // failed refresh cannot leave ForgeFlow stuck in deployment mode after a successful run. const effectiveHealthy = health.configured ? health.healthy : true; - const finalStatus = effectiveHealthy === false ? 'failed' : 'success'; + const finalStatus = effectiveHealthy === false ? "failed" : "success"; const finalLogs = [ ...operation.logs, - ...result.stdout.trim().split('\n').filter(Boolean).slice(-60), - 'Docker Compose deployment completed.', + ...result.stdout.trim().split("\n").filter(Boolean).slice(-60), + "Docker Compose deployment completed.", health.configured - ? `Healthcheck ${health.healthy ? 'passed' : 'failed'}${health.status ? ` with HTTP ${health.status}` : ''}.` - : 'No desktop healthcheck URL configured; the remote container inspection passed.' + ? `Healthcheck ${health.healthy ? "passed" : "failed"}${health.status ? ` with HTTP ${health.status}` : ""}.` + : "No desktop healthcheck URL configured; the remote container inspection passed.", ]; await this.saveOperation({ ...operation, @@ -725,7 +1400,10 @@ FORGEFLOW_STATUS previousSha: preflight.inspection?.head || null, health: { ...health, healthy: effectiveHealthy }, logs: finalLogs, - error: effectiveHealthy === false ? 'The application healthcheck did not pass after deployment.' : null + error: + effectiveHealthy === false + ? "The application healthcheck did not pass after deployment." + : null, }); await this.store.saveDeploymentState(profileId, { liveSha: targetSha, @@ -735,52 +1413,63 @@ FORGEFLOW_STATUS healthLatencyMs: health.latencyMs ?? null, requestId, remotePath, - provider: 'ssh-unraid', - containerName: String(profile.containerName || profile.remoteFolder || repository.name), + provider: "ssh-unraid", + containerName: String( + profile.containerName || profile.remoteFolder || repository.name, + ), containerRunning: true, dockerMan: { webUi: this.dockerManWebUi(profile), icon: iconReference, shell: this.dockerManShell(profile), templateExists: true, - configured: Boolean(this.dockerManWebUi(profile) || iconReference) + configured: Boolean(this.dockerManWebUi(profile) || iconReference), }, - webUiUrl: profile.webUiUrl || (profile.hostPort ? `http://${server.host}:${profile.hostPort}/` : null) + webUiUrl: + profile.webUiUrl || + (profile.hostPort + ? `http://${server.host}:${profile.hostPort}/` + : null), }); // Reconcile authoritative Unraid/Docker state in the background and preserve the already // completed operation if that follow-up inspection is unavailable. - void this.refreshProfileState(repository.fullName, profileId).catch(async (refreshError) => { - await this.diagnostics?.warning('unraid.deployment.post-refresh-failed', { - requestId, - repository: repository.fullName, - serverId: server.id, - error: refreshError - }); - }); - await this.diagnostics?.info('unraid.deployment.completed', { + void this.refreshProfileState(repository.fullName, profileId).catch( + async (refreshError) => { + await this.diagnostics?.warning( + "unraid.deployment.post-refresh-failed", + { + requestId, + repository: repository.fullName, + serverId: server.id, + error: refreshError, + }, + ); + }, + ); + await this.diagnostics?.info("unraid.deployment.completed", { requestId, repository: repository.fullName, serverId: server.id, remotePath, sha: targetSha, healthy: effectiveHealthy, - healthStatus: health.status ?? null + healthStatus: health.status ?? null, }); } catch (error) { await this.saveOperation({ ...operation, - status: 'failed', + status: "failed", error: error.message, - failure: { stage: 'SSH / Docker deployment', message: error.message }, - logs: [...operation.logs, error.message] + failure: { stage: "SSH / Docker deployment", message: error.message }, + logs: [...operation.logs, error.message], }); - await this.diagnostics?.error('unraid.deployment.failed', { + await this.diagnostics?.error("unraid.deployment.failed", { requestId, repository: repository.fullName, serverId: server.id, remotePath, sha: targetSha, - error + error, }); } })(); @@ -792,26 +1481,46 @@ FORGEFLOW_STATUS const target = assertFullCommitSha(targetSha); const { profile, server, remotePath } = this.resolve(repository, profileId); const deploymentState = this.store.getDeploymentState(profileId); - if (!deploymentState?.previousSha || deploymentState.previousSha !== target) { - const error = new Error('Rollback is allowed only to the exact previous SHA reported by ForgeFlow for this deployment profile.'); - error.code = 'ROLLBACK_TARGET_NOT_PREVIOUS_SHA'; + if ( + !deploymentState?.previousSha || + deploymentState.previousSha !== target + ) { + const error = new Error( + "Rollback is allowed only to the exact previous SHA reported by ForgeFlow for this deployment profile.", + ); + error.code = "ROLLBACK_TARGET_NOT_PREVIOUS_SHA"; throw error; } - if (!repository.localPath) throw new Error('A linked local repository is required for rollback verification.'); - await this.git.verifyCommitOnRemoteBranch(repository.localPath, target, profile.branch); + if (!repository.localPath) + throw new Error( + "A linked local repository is required for rollback verification.", + ); + await this.git.verifyCommitOnRemoteBranch( + repository.localPath, + target, + profile.branch, + ); const inspection = await this.inspect({ repository, profileId }); - if (!inspection.rootGit) throw new Error('The configured server project is not a root Git working tree.'); - if (inspection.trackedChanges.length) throw new Error('Tracked server-side changes block rollback. Commit, revert or migrate them first.'); - const composeFile = profile.generatedCompose ? '.forgeflow/compose.forgeflow.yml' : safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml'); + if (!inspection.rootGit) + throw new Error( + "The configured server project is not a root Git working tree.", + ); + if (inspection.trackedChanges.length) + throw new Error( + "Tracked server-side changes block rollback. Commit, revert or migrate them first.", + ); + const composeFile = profile.generatedCompose + ? ".forgeflow/compose.forgeflow.yml" + : safeRelativeRemoteFile(profile.composeFile || "docker-compose.yml"); const iconReference = await this.prepareIcon(profile, repository, server); const metadata = this.metadataCompose(profile, repository, iconReference); const compose = this.composeInvocation(profile, repository, composeFile); const requestId = crypto.randomUUID(); const operation = await this.saveOperation({ id: requestId, - type: 'deployment', - action: 'rollback', - provider: 'ssh-unraid', + type: "deployment", + action: "rollback", + provider: "ssh-unraid", repository: repository.fullName, environment: profile.environment, profileId, @@ -819,8 +1528,8 @@ FORGEFLOW_STATUS remotePath, sha: target, shortSha: target.slice(0, 7), - status: 'running', - logs: [`Rolling back to exact commit ${target}.`] + status: "running", + logs: [`Rolling back to exact commit ${target}.`], }); const statusJson = JSON.stringify({ repository: repository.fullName, @@ -831,7 +1540,7 @@ FORGEFLOW_STATUS healthy: null, healthcheck_url_configured: Boolean(profile.healthcheckUrl), rollback: true, - deployed_at: new Date().toISOString() + deployed_at: new Date().toISOString(), }); const script = ` root=${shellQuote(remotePath)} @@ -853,21 +1562,29 @@ ${statusJson} FORGEFLOW_STATUS `; try { - const result = await this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 4 * 1024 * 1024 }); + const result = await this.ssh.exec(server.id, bash(script), { + timeout: 30 * 60_000, + maxOutput: 4 * 1024 * 1024, + }); const health = await this.checkHealth(profile.healthcheckUrl); - const finalStatus = health.healthy === false ? 'failed' : 'rolled-back'; + const finalStatus = health.healthy === false ? "failed" : "rolled-back"; const completed = await this.saveOperation({ ...operation, status: finalStatus, previousSha: deploymentState.liveSha || inspection.head || null, health, - error: health.healthy === false ? 'The application healthcheck did not pass after rollback.' : null, + error: + health.healthy === false + ? "The application healthcheck did not pass after rollback." + : null, logs: [ ...operation.logs, - ...result.stdout.trim().split('\n').filter(Boolean).slice(-60), - 'Rollback completed.', - health.configured ? `Healthcheck ${health.healthy ? 'passed' : 'failed'}${health.status ? ` with HTTP ${health.status}` : ''}.` : 'No desktop healthcheck URL configured.' - ] + ...result.stdout.trim().split("\n").filter(Boolean).slice(-60), + "Rollback completed.", + health.configured + ? `Healthcheck ${health.healthy ? "passed" : "failed"}${health.status ? ` with HTTP ${health.status}` : ""}.` + : "No desktop healthcheck URL configured.", + ], }); await this.store.saveDeploymentState(profileId, { liveSha: target, @@ -877,31 +1594,40 @@ FORGEFLOW_STATUS healthLatencyMs: health.latencyMs, requestId, remotePath, - provider: 'ssh-unraid' + provider: "ssh-unraid", }); if (health.healthy === false) { - const error = new Error('Rollback completed, but the configured healthcheck failed.'); - error.code = 'ROLLBACK_HEALTHCHECK_FAILED'; + const error = new Error( + "Rollback completed, but the configured healthcheck failed.", + ); + error.code = "ROLLBACK_HEALTHCHECK_FAILED"; error.operationId = completed.id; throw error; } return completed; } catch (error) { - if (error.code !== 'ROLLBACK_HEALTHCHECK_FAILED') { - await this.saveOperation({ ...operation, status: 'failed', error: error.message, logs: [...operation.logs, error.message] }); + if (error.code !== "ROLLBACK_HEALTHCHECK_FAILED") { + await this.saveOperation({ + ...operation, + status: "failed", + error: error.message, + logs: [...operation.logs, error.message], + }); } throw error; } } - async refreshProfileState(fullName, profileId) { - const repository = { fullName, name: fullName.split('/').pop() }; + async refreshProfileState(fullName, profileId, expectedGiteaSha = null) { + const repository = { fullName, name: fullName.split("/").pop() }; const { profile, server, remotePath } = this.resolve(repository, profileId); - const containerName = String(profile.containerName || profile.remoteFolder || repository.name); + const containerName = String( + profile.containerName || profile.remoteFolder || repository.name, + ); const script = ` root=${shellQuote(remotePath)} container=${shellQuote(containerName)} -template_path=${shellQuote('/boot/config/plugins/dockerMan/templates-user/my-' + containerName + '.xml')} +template_path=${shellQuote("/boot/config/plugins/dockerMan/templates-user/my-" + containerName + ".xml")} live=""; previous=""; running=false; docker_health=""; webui=""; icon=""; shell_label=""; template_exists=false [ -f "$template_path" ] && template_exists=true [ -f "$root/.forgeflow/current-sha" ] && live=$(cat "$root/.forgeflow/current-sha") @@ -924,43 +1650,81 @@ printf 'iconLabel=%s\n' "$(printf '%s' "$icon" | base64 | tr -d '\r\n')" printf 'shellLabel=%s\n' "$(printf '%s' "$shell_label" | base64 | tr -d '\r\n')" printf 'templateExists=%s\n' "$template_exists" `; - const result = await this.ssh.exec(server.id, bash(script), { timeout: 30_000 }); - const marker = result.stdout.lastIndexOf('__FORGEFLOW_KV__'); - if (marker < 0) throw new Error('Unraid state inspection did not return a ForgeFlow marker.'); + const result = await this.ssh.exec(server.id, bash(script), { + timeout: 30_000, + }); + const marker = result.stdout.lastIndexOf("__FORGEFLOW_KV__"); + if (marker < 0) + throw new Error( + "Unraid state inspection did not return a ForgeFlow marker.", + ); const fields = {}; - for (const line of result.stdout.slice(marker + '__FORGEFLOW_KV__'.length).trim().split(/\r?\n/)) { - const index = line.indexOf('='); + for (const line of result.stdout + .slice(marker + "__FORGEFLOW_KV__".length) + .trim() + .split(/\r?\n/)) { + const index = line.indexOf("="); if (index > 0) fields[line.slice(0, index)] = line.slice(index + 1); } - const decode = (value) => { try { return value ? Buffer.from(value, 'base64').toString('utf8') : ''; } catch { return ''; } }; + const decode = (value) => { + try { + return value ? Buffer.from(value, "base64").toString("utf8") : ""; + } catch { + return ""; + } + }; const health = await this.checkHealth(profile.healthcheckUrl); - const dockerHealthy = fields.dockerHealth ? fields.dockerHealth === 'healthy' : null; - const effectiveHealthy = health.configured ? health.healthy : (dockerHealthy ?? (fields.containerRunning === 'true' ? true : false)); + const dockerHealthy = fields.dockerHealth + ? fields.dockerHealth === "healthy" + : null; + const effectiveHealthy = health.configured + ? health.healthy + : (dockerHealthy ?? (fields.containerRunning === "true" ? true : false)); return this.store.saveDeploymentState(profile.id, { - liveSha: /^[0-9a-f]{40}$/i.test(fields.liveSha || '') ? fields.liveSha : null, - previousSha: /^[0-9a-f]{40}$/i.test(fields.previousSha || '') ? fields.previousSha : null, + liveSha: /^[0-9a-f]{40}$/i.test(fields.liveSha || "") + ? fields.liveSha + : null, + previousSha: /^[0-9a-f]{40}$/i.test(fields.previousSha || "") + ? fields.previousSha + : null, healthy: effectiveHealthy, healthStatus: health.status, healthLatencyMs: health.latencyMs, containerName, - containerRunning: fields.containerRunning === 'true', + containerRunning: fields.containerRunning === "true", dockerHealth: fields.dockerHealth || null, dockerMan: { webUi: decode(fields.webUiLabel), icon: decode(fields.iconLabel), shell: decode(fields.shellLabel), - templateExists: fields.templateExists === 'true', - configured: Boolean(decode(fields.webUiLabel) || decode(fields.iconLabel) || fields.templateExists === 'true') + templateExists: fields.templateExists === "true", + configured: Boolean( + decode(fields.webUiLabel) || + decode(fields.iconLabel) || + fields.templateExists === "true", + ), }, - webUiUrl: profile.webUiUrl || (profile.hostPort ? `http://${server.host}:${profile.hostPort}/` : null), + webUiUrl: + profile.webUiUrl || + (profile.hostPort + ? `http://${server.host}:${profile.hostPort}/` + : null), remotePath, - provider: 'ssh-unraid' + provider: "ssh-unraid", + giteaSha: /^[0-9a-f]{40}$/i.test(String(expectedGiteaSha || "")) + ? expectedGiteaSha + : null, + matchesGitea: + /^[0-9a-f]{40}$/i.test(String(expectedGiteaSha || "")) && + fields.liveSha === expectedGiteaSha, }); } async applyDockerManMetadata({ repository, profileId }) { const { profile, server, remotePath } = this.resolve(repository, profileId); - const composeFile = profile.generatedCompose ? '.forgeflow/compose.forgeflow.yml' : safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml'); + const composeFile = profile.generatedCompose + ? ".forgeflow/compose.forgeflow.yml" + : safeRelativeRemoteFile(profile.composeFile || "docker-compose.yml"); const iconReference = await this.prepareIcon(profile, repository, server); const metadata = this.metadataCompose(profile, repository, iconReference); const compose = this.composeInvocation(profile, repository, composeFile); @@ -975,40 +1739,78 @@ ${compose} config >/dev/null ${compose} up -d --build --remove-orphans --force-recreate ${this.dockerManRefreshScript(profile, repository, iconReference)} `; - await this.ssh.exec(server.id, bash(script), { timeout: 10 * 60_000, maxOutput: 2 * 1024 * 1024 }); + await this.ssh.exec(server.id, bash(script), { + timeout: 10 * 60_000, + maxOutput: 2 * 1024 * 1024, + }); return this.refreshProfileState(repository.fullName, profileId); } - async refreshOperation(operationId) { + async refreshOperation( + operationId, + { includeTerminal = false, state: suppliedState = null } = {}, + ) { const operation = this.store.getOperation(operationId); - if (!operation || operation.provider !== 'ssh-unraid') return operation; - if (['success', 'failed', 'cancelled', 'rolled-back'].includes(operation.status)) return operation; + if (!operation || operation.provider !== "ssh-unraid") return operation; + if ( + !includeTerminal && + ["success", "failed", "cancelled", "rolled-back"].includes( + operation.status, + ) + ) + return operation; try { - const state = await this.refreshProfileState(operation.repository, operation.profileId); - if (state.liveSha === operation.sha && state.containerRunning && state.healthy !== false) { + const state = + suppliedState || + (await this.refreshProfileState( + operation.repository, + operation.profileId, + )); + if ( + state.liveSha === operation.sha && + state.containerRunning && + state.healthy !== false + ) { return this.saveOperation({ ...operation, - status: operation.action === 'rollback' ? 'rolled-back' : 'success', + status: operation.action === "rollback" ? "rolled-back" : "success", health: { healthy: state.healthy, status: state.healthStatus }, - logs: [...(operation.logs || []), 'Deployment state reconciled from Unraid.'] + logs: [ + ...(operation.logs || []), + "Deployment state reconciled from Unraid.", + ], }); } - if (/^[0-9a-f]{40}$/i.test(String(state.liveSha || '')) && state.liveSha !== operation.sha && state.containerRunning && state.healthy !== false) { + if ( + /^[0-9a-f]{40}$/i.test(String(state.liveSha || "")) && + state.liveSha !== operation.sha && + state.containerRunning && + state.healthy !== false + ) { return this.saveOperation({ ...operation, - status: 'cancelled', + status: "cancelled", error: `Superseded by live commit ${state.liveSha.slice(0, 7)}.`, health: { healthy: state.healthy, status: state.healthStatus }, - logs: [...(operation.logs || []), `Operation superseded by live Unraid commit ${state.liveSha}.`] + logs: [ + ...(operation.logs || []), + `Operation superseded by live Unraid commit ${state.liveSha}.`, + ], }); } - const ageMs = Date.now() - new Date(operation.updatedAt || operation.createdAt || 0).getTime(); + const ageMs = + Date.now() - + new Date(operation.updatedAt || operation.createdAt || 0).getTime(); if (ageMs > 45 * 60_000) { return this.saveOperation({ ...operation, - status: 'failed', - error: 'Deployment was interrupted or did not reach the requested commit within 45 minutes.', - logs: [...(operation.logs || []), 'Stale deployment was marked failed during reconciliation.'] + status: "failed", + error: + "Deployment was interrupted or did not reach the requested commit within 45 minutes.", + logs: [ + ...(operation.logs || []), + "Stale deployment was marked failed during reconciliation.", + ], }); } return operation; @@ -1017,11 +1819,60 @@ ${this.dockerManRefreshScript(profile, repository, iconReference)} } } - async refreshActiveOperations() { - const active = this.store.data.operations.filter((item) => item.provider === 'ssh-unraid' && item.type === 'deployment' && !['success', 'failed', 'cancelled', 'rolled-back'].includes(item.status)); - return Promise.all(active.map((item) => this.refreshOperation(item.id))); + async reconcileRecordedOperations(profileId, state) { + const operations = this.store.data.operations + .filter( + (item) => + item.profileId === profileId && item.provider === "ssh-unraid", + ) + .sort( + (left, right) => + new Date(right.updatedAt || right.createdAt || 0) - + new Date(left.updatedAt || left.createdAt || 0), + ); + const matching = operations.find( + (item) => item.sha === state.liveSha && item.status === "failed", + ); + if (matching && state.containerRunning && state.healthy !== false) { + await this.refreshOperation(matching.id, { + includeTerminal: true, + state, + }); + } + const latestFailed = operations.find((item) => item.status === "failed"); + if ( + latestFailed && + latestFailed.id !== matching?.id && + state.matchesGitea && + state.containerRunning && + state.healthy !== false + ) { + await this.saveOperation({ + ...latestFailed, + status: "cancelled", + error: `Superseded by Gitea/live commit ${state.liveSha.slice(0, 7)}.`, + logs: [ + ...(latestFailed.logs || []), + `Reconciled: Gitea and Unraid now both report ${state.liveSha}.`, + ], + }); + } + return this.store.data.operations + .filter((item) => item.profileId === profileId) + .slice(0, 10); } + async refreshActiveOperations() { + const active = this.store.data.operations.filter( + (item) => + item.provider === "ssh-unraid" && + item.type === "deployment" && + !["success", "failed", "cancelled", "rolled-back"].includes( + item.status, + ), + ); + return Promise.all(active.map((item) => this.refreshOperation(item.id))); + } } module.exports = { @@ -1036,5 +1887,5 @@ module.exports = { decodeBase64Json, parseDockerManXml, deriveDetectedProfile, - bash + bash, }; diff --git a/src/renderer/app.js b/src/renderer/app.js index 3595e08..0eb0f23 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -852,6 +852,20 @@ function dockerManIntegration(profile) { }; } +function deploymentIdentity(profile, repository) { + const name = String( + profile.state?.containerName || + profile.containerName || + profile.remoteFolder || + repository.name || + "container", + ); + let hash = 0; + for (const character of name) + hash = (hash * 31 + character.charCodeAt(0)) >>> 0; + return { name, initial: name.slice(0, 1).toUpperCase(), accent: hash % 6 }; +} + function renderProfileCard(repository, profile, compact = false) { const state = profile.state || {}; const health = environmentState(profile); @@ -868,7 +882,13 @@ function renderProfileCard(repository, profile, compact = false) { const dockerManReady = dockerMan.ready; const webUi = profile.webUiUrl || state.webUiUrl || state.dockerMan?.webUi || ""; - return `
${escapeHtml(profile.environment)}

${escapeHtml(profile.name)}

${escapeHtml(providerDetail)}

${health.label}
${webUi ? `` : ""}${isSsh ? `` : ""}${ready ? `` : ""}${state.previousSha && rollbackConfigured ? `` : ""}
`; + const identity = deploymentIdentity(profile, repository); + const syncLabel = state.matchesGitea + ? `${icon("check")}Live = Gitea · ${shortSha(state.liveSha)}` + : state.giteaSha && state.liveSha + ? `Live ${shortSha(state.liveSha)} · Gitea ${shortSha(state.giteaSha)}` + : ""; + return `
${escapeHtml(identity.initial)}
Container${escapeHtml(identity.name)}${escapeHtml(repository.fullName)} · ${escapeHtml(profile.environment)}
${syncLabel}
${escapeHtml(isSsh ? "SSH / UNRAID" : "GITEA ACTIONS")}

${escapeHtml(profile.name)}

${escapeHtml(providerDetail)}

${health.label}
${webUi ? `` : ""}${isSsh ? `` : ""}${ready ? `` : ""}${state.previousSha && rollbackConfigured ? `` : ""}
`; } function renderRepositoryDeployments(repository) { diff --git a/src/renderer/mock-bridge.js b/src/renderer/mock-bridge.js index 0d1c94a..630f79f 100644 --- a/src/renderer/mock-bridge.js +++ b/src/renderer/mock-bridge.js @@ -416,21 +416,21 @@ ); repository.readyToDeploy = Boolean( repository.localPath && - status?.clean && - status.branch.upstream && - status.branch.ahead === 0 && - status.branch.behind === 0 && - repository.deploymentProfiles.some( - (entry) => entry.branch === status.branch.head, - ), + status?.clean && + status.branch.upstream && + status.branch.ahead === 0 && + status.branch.behind === 0 && + repository.deploymentProfiles.some( + (entry) => entry.branch === status.branch.head, + ), ); repository.attention = !repository.localPath || Boolean( status?.counts.conflicts || - status?.branch.behind || - status?.branch.ahead || - status?.counts.changed, + status?.branch.behind || + status?.branch.ahead || + status?.counts.changed, ); repository.attentionReason = !repository.localPath ? "No local folder linked" @@ -564,7 +564,7 @@ await wait(80); snapshot(); return { - appVersion: "0.8.2-demo", + appVersion: "0.8.3-demo", platform: "win32", state: clone(state), git: { available: true, version: "git version 2.47.3" }, diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 4ad498e..557a7bc 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -32,27 +32,67 @@ html[data-theme="light"] { color-scheme: light; - --bg: #eef2f7; - --surface-0: #f7f9fc; + --bg: #e9eef7; + --surface-0: #f2f6fc; --surface-1: #ffffff; --surface-2: #f3f6fa; --surface-3: #e8edf4; --surface-hover: #edf2f8; - --line: #cdd5e1; - --line-soft: #e1e6ee; + --line: #c5d0df; + --line-soft: #dce4ef; --text: #172033; --text-muted: #526078; --text-faint: #7b879a; - --primary: #295fca; - --primary-strong: #326ee0; - --primary-soft: rgba(50, 110, 224, 0.1); + --primary: #2857bf; + --primary-strong: #346ee8; + --primary-soft: rgba(52, 110, 232, 0.13); --success: #087a57; --success-soft: rgba(8, 122, 87, 0.1); --warning: #9b5b00; --warning-soft: rgba(155, 91, 0, 0.1); --danger: #c73737; --danger-soft: rgba(199, 55, 55, 0.1); - --shadow: 0 18px 70px rgba(43, 55, 77, 0.15); + --shadow: 0 18px 54px rgba(31, 55, 94, 0.14); +} + +html[data-theme="light"] body, +html[data-theme="light"] .app-shell { + background: + radial-gradient( + circle at 82% 2%, + rgba(67, 123, 235, 0.13), + transparent 31% + ), + radial-gradient( + circle at 20% 100%, + rgba(31, 170, 141, 0.08), + transparent 33% + ), + var(--bg); +} +html[data-theme="light"] .titlebar, +html[data-theme="light"] .sidebar { + background: rgba(250, 252, 255, 0.92); + backdrop-filter: blur(18px); +} +html[data-theme="light"] .panel, +html[data-theme="light"] .summary-card, +html[data-theme="light"] .deploy-card, +html[data-theme="light"] .settings-group, +html[data-theme="light"] .pipeline-card { + border-color: rgba(151, 169, 197, 0.48); + box-shadow: 0 8px 28px rgba(49, 75, 116, 0.08); +} +html[data-theme="light"] .nav-button.active { + background: linear-gradient( + 105deg, + rgba(52, 110, 232, 0.16), + rgba(48, 181, 151, 0.08) + ); + box-shadow: inset 3px 0 var(--primary-strong); +} +html[data-theme="light"] .button.primary { + box-shadow: 0 7px 18px rgba(52, 110, 232, 0.22); } * { @@ -1309,10 +1349,110 @@ html[data-theme="light"] .diff-line.remove { gap: 12px; } .deploy-card { + --card-accent: var(--primary-strong); border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-1); overflow: hidden; + box-shadow: 0 10px 34px rgba(0, 0, 0, 0.1); + transition: + transform 160ms ease, + border-color 160ms ease, + box-shadow 160ms ease; +} +.deploy-card:hover { + transform: translateY(-2px); + border-color: color-mix(in srgb, var(--card-accent) 42%, var(--line)); + box-shadow: 0 16px 42px + color-mix(in srgb, var(--card-accent) 12%, transparent); +} +.deploy-card.accent-0 { + --card-accent: #4d7df3; +} +.deploy-card.accent-1 { + --card-accent: #8b5cf6; +} +.deploy-card.accent-2 { + --card-accent: #0ea5a0; +} +.deploy-card.accent-3 { + --card-accent: #e2783f; +} +.deploy-card.accent-4 { + --card-accent: #d24e83; +} +.deploy-card.accent-5 { + --card-accent: #4b9b55; +} +.container-identity { + min-height: 78px; + display: flex; + align-items: center; + gap: 12px; + padding: 13px 14px; + border-top: 4px solid var(--card-accent); + border-bottom: 1px solid var(--line-soft); + background: linear-gradient( + 110deg, + color-mix(in srgb, var(--card-accent) 15%, var(--surface-1)), + var(--surface-1) 68% + ); +} +.container-avatar { + width: 44px; + height: 44px; + flex: 0 0 44px; + display: grid; + place-items: center; + border-radius: 12px; + color: #fff; + background: linear-gradient( + 145deg, + color-mix(in srgb, var(--card-accent) 72%, #fff), + var(--card-accent) + ); + box-shadow: 0 8px 20px color-mix(in srgb, var(--card-accent) 28%, transparent); + font-size: 19px; + font-weight: 780; +} +.container-identity > div { + min-width: 0; +} +.container-identity span:not(.container-avatar):not(.sync-proof), +.container-identity small { + display: block; + color: var(--text-faint); + font-size: 10px; +} +.container-identity strong { + display: block; + margin: 2px 0 3px; + overflow: hidden; + font-size: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} +.sync-proof { + margin-left: auto; + padding: 6px 8px; + border-radius: 6px; + background: var(--surface-2); + font-size: 10px; + font-weight: 700; + white-space: nowrap; +} +.sync-proof.success { + color: var(--success); + background: var(--success-soft); +} +.sync-proof.warning { + color: var(--warning); + background: var(--warning-soft); +} +.sync-proof .icon { + width: 12px; + height: 12px; + vertical-align: -2px; } .deploy-card-header { display: flex; diff --git a/tests/renderer-workflow.test.mjs b/tests/renderer-workflow.test.mjs index eb107fc..d17fa49 100644 --- a/tests/renderer-workflow.test.mjs +++ b/tests/renderer-workflow.test.mjs @@ -179,13 +179,13 @@ test("one-click troubleshooting excludes destructive or publishing Git actions", new URL("../src/main/ipc.cjs", import.meta.url), "utf8", ); - assert.match(ipc, /action: 'abort-operation', safe: false/); - assert.match(ipc, /action: 'push', safe: false/); + assert.match(ipc, /action:\s*["']abort-operation["'],\s*safe:\s*false/); + assert.match(ipc, /action:\s*["']push["'],\s*safe:\s*false/); assert.match(ipc, /const stale = lock\.ageMs >= 10_000/); - assert.match(ipc, /\['fast-forward', 'fetch'\]\.includes\(issue\.action\)/); + assert.match(ipc, /\[\s*["']fast-forward["'],\s*["']fetch["']\s*\]\.includes\(issue\.action\)/); assert.doesNotMatch( ipc, - /\['fast-forward', 'push', 'fetch'\]\.includes\(issue\.action\)/, + /\[\s*["']fast-forward["'],\s*["']push["'],\s*["']fetch["']\s*\]\.includes\(issue\.action\)/, ); assert.match( renderer, diff --git a/tests/unraid-deployment.test.mjs b/tests/unraid-deployment.test.mjs index 7f5093e..fa0e34d 100644 --- a/tests/unraid-deployment.test.mjs +++ b/tests/unraid-deployment.test.mjs @@ -1,247 +1,484 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { createRequire } from 'node:module'; +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; const require = createRequire(import.meta.url); -const { UnraidDeploymentService, safeRemoteFolder, safeRelativeRemoteFile, parseInspection, dockerIgnoreHasPath, checksSummary, xmlEscape, bash } = require('../src/main/unraid-deployment-service.cjs'); -const { fingerprintKey, shellQuote } = require('../src/main/ssh-service.cjs'); +const { + UnraidDeploymentService, + safeRemoteFolder, + safeRelativeRemoteFile, + parseInspection, + dockerIgnoreHasPath, + checksSummary, + xmlEscape, + bash, +} = require("../src/main/unraid-deployment-service.cjs"); +const { fingerprintKey, shellQuote } = require("../src/main/ssh-service.cjs"); -test('Unraid remote paths cannot escape appdata project folder', () => { - assert.equal(safeRemoteFolder('lumaops'), 'lumaops'); - assert.throws(() => safeRemoteFolder('../lumaops')); - assert.equal(safeRelativeRemoteFile('deploy/docker-compose.yml'), 'deploy/docker-compose.yml'); - assert.throws(() => safeRelativeRemoteFile('../../etc/passwd')); +test("Unraid remote paths cannot escape appdata project folder", () => { + assert.equal(safeRemoteFolder("lumaops"), "lumaops"); + assert.throws(() => safeRemoteFolder("../lumaops")); + assert.equal( + safeRelativeRemoteFile("deploy/docker-compose.yml"), + "deploy/docker-compose.yml", + ); + assert.throws(() => safeRelativeRemoteFile("../../etc/passwd")); }); -test('server inspection key-value payload is decoded safely', () => { - const b64 = (value) => Buffer.from(value).toString('base64'); - const parsed = parseInspection(`noise\n__FORGEFLOW_KV__\nexists=true\nrootGit=true\nhead=${'a'.repeat(40)}\nbranch=main\nremote=${b64('ssh://git@gitea/Jens/LumaOps.git')}\ntrackedChanges=${b64(' M docker-compose.yml\n')}\ncomposeFiles=${b64('docker-compose.yml\n')}\nnestedGit=${b64('source\n')}\ndockerfile=true\ndockerignoreContent=${b64('.git\ndata/\n')}\nexistingPreservePaths=${b64('data\nlogs\n')}\n`); +test("server inspection key-value payload is decoded safely", () => { + const b64 = (value) => Buffer.from(value).toString("base64"); + const parsed = parseInspection( + `noise\n__FORGEFLOW_KV__\nexists=true\nrootGit=true\nhead=${"a".repeat(40)}\nbranch=main\nremote=${b64("ssh://git@gitea/Jens/LumaOps.git")}\ntrackedChanges=${b64(" M docker-compose.yml\n")}\ncomposeFiles=${b64("docker-compose.yml\n")}\nnestedGit=${b64("source\n")}\ndockerfile=true\ndockerignoreContent=${b64(".git\ndata/\n")}\nexistingPreservePaths=${b64("data\nlogs\n")}\n`, + ); assert.equal(parsed.rootGit, true); - assert.deepEqual(parsed.composeFiles, ['docker-compose.yml']); - assert.deepEqual(parsed.nestedGit, ['source']); + assert.deepEqual(parsed.composeFiles, ["docker-compose.yml"]); + assert.deepEqual(parsed.nestedGit, ["source"]); assert.equal(parsed.trackedChanges.length, 1); assert.match(parsed.dockerignoreContent, /\.git/); - assert.deepEqual(parsed.existingPreservePaths, ['data', 'logs']); + assert.deepEqual(parsed.existingPreservePaths, ["data", "logs"]); }); -test('Docker ignore checks identify exact runtime and Git context exclusions', () => { - const rules = '# build context\n.git\ndata/\nlogs/**\n!logs/keep.txt\n'; - assert.equal(dockerIgnoreHasPath(rules, '.git'), true); - assert.equal(dockerIgnoreHasPath(rules, 'data'), true); - assert.equal(dockerIgnoreHasPath(rules, 'logs'), true); - assert.equal(dockerIgnoreHasPath(rules, 'source'), false); +test("Docker ignore checks identify exact runtime and Git context exclusions", () => { + const rules = "# build context\n.git\ndata/\nlogs/**\n!logs/keep.txt\n"; + assert.equal(dockerIgnoreHasPath(rules, ".git"), true); + assert.equal(dockerIgnoreHasPath(rules, "data"), true); + assert.equal(dockerIgnoreHasPath(rules, "logs"), true); + assert.equal(dockerIgnoreHasPath(rules, "source"), false); }); -test('server inspection detects preserved runtime paths and missing Docker context exclusions', async () => { - const b64 = (value) => Buffer.from(value).toString('base64'); - let receivedCommand = ''; +test("server inspection detects preserved runtime paths and missing Docker context exclusions", async () => { + const b64 = (value) => Buffer.from(value).toString("base64"); + let receivedCommand = ""; const store = { getDeploymentProfile: () => ({ - id: 'production', - provider: 'ssh-unraid', - serverId: 'unraid', - remoteFolder: 'lumaops', - preservePaths: ['data', 'logs'] + id: "production", + provider: "ssh-unraid", + serverId: "unraid", + remoteFolder: "lumaops", + preservePaths: ["data", "logs"], }), getServer: () => ({ - id: 'unraid', - name: 'Unraid', - basePath: '/mnt/user/appdata' - }) + id: "unraid", + name: "Unraid", + basePath: "/mnt/user/appdata", + }), }; const ssh = { exec: async (_serverId, command) => { receivedCommand = command; return { - stdout: `__FORGEFLOW_KV__\nexists=true\nrootGit=true\nhead=${'a'.repeat(40)}\nbranch=main\nremote=${b64('ssh://git@gitea/Jens/LumaOps.git')}\ntrackedChanges=\ncomposeFiles=${b64('docker-compose.yml\n')}\nnestedGit=${b64('source\n')}\ndockerfile=true\ndockerignoreContent=${b64('.git\ndata/\n')}\nexistingPreservePaths=${b64('data\nlogs\n')}\n`, - stderr: '', - exitCode: 0 + stdout: `__FORGEFLOW_KV__\nexists=true\nrootGit=true\nhead=${"a".repeat(40)}\nbranch=main\nremote=${b64("ssh://git@gitea/Jens/LumaOps.git")}\ntrackedChanges=\ncomposeFiles=${b64("docker-compose.yml\n")}\nnestedGit=${b64("source\n")}\ndockerfile=true\ndockerignoreContent=${b64(".git\ndata/\n")}\nexistingPreservePaths=${b64("data\nlogs\n")}\n`, + stderr: "", + exitCode: 0, }; - } + }, }; - const service = new UnraidDeploymentService({ store, ssh, git: {}, diagnostics: null }); - const inspection = await service.inspect({ repository: { fullName: 'Jens/LumaOps', name: 'LumaOps' }, profileId: 'production' }); + const service = new UnraidDeploymentService({ + store, + ssh, + git: {}, + diagnostics: null, + }); + const inspection = await service.inspect({ + repository: { fullName: "Jens/LumaOps", name: "LumaOps" }, + profileId: "production", + }); assert.match(receivedCommand, /base64 -d \| bash$/); - assert.equal(inspection.remotePath, '/mnt/user/appdata/lumaops'); + assert.equal(inspection.remotePath, "/mnt/user/appdata/lumaops"); assert.equal(inspection.dockerignoreGitExcluded, true); - assert.deepEqual(inspection.existingPreservePaths.sort(), ['data', 'logs']); - assert.deepEqual(inspection.dockerContextExclusionsMissing.sort(), ['logs', 'source']); + assert.deepEqual(inspection.existingPreservePaths.sort(), ["data", "logs"]); + assert.deepEqual(inspection.dockerContextExclusionsMissing.sort(), [ + "logs", + "source", + ]); }); -test('preflight summary blocks only failed checks', () => { - const result = checksSummary([{ id: 'a', status: 'pass' }, { id: 'b', status: 'warning' }, { id: 'c', status: 'fail' }]); +test("preflight summary blocks only failed checks", () => { + const result = checksSummary([ + { id: "a", status: "pass" }, + { id: "b", status: "warning" }, + { id: "c", status: "fail" }, + ]); assert.equal(result.ready, false); - assert.deepEqual(result.blocking, ['c']); + assert.deepEqual(result.blocking, ["c"]); }); -test('SSH helpers produce pinned fingerprints and quoted commands', () => { - assert.match(fingerprintKey(Buffer.from('host-key')), /^SHA256:/); +test("SSH helpers produce pinned fingerprints and quoted commands", () => { + assert.match(fingerprintKey(Buffer.from("host-key")), /^SHA256:/); assert.equal(shellQuote("a'b"), "'a'\\''b'"); - const wrapped = bash('git fetch origin main'); + const wrapped = bash("git fetch origin main"); assert.match(wrapped, /base64 -d \| bash$/); - assert.equal(wrapped.includes('\n'), false); + assert.equal(wrapped.includes("\n"), false); const encoded = wrapped.match(/printf '%s' '([A-Za-z0-9+/=]+)'/)[1]; - const decoded = Buffer.from(encoded, 'base64').toString('utf8'); + const decoded = Buffer.from(encoded, "base64").toString("utf8"); assert.match(decoded, /GIT_TERMINAL_PROMPT=0/); assert.match(decoded, /BatchMode=yes/); assert.match(decoded, /git fetch origin main/); }); -test('SSH rollback refuses any SHA other than the exact recorded previous deployment', async () => { - const previousSha = 'a'.repeat(40); +test("SSH rollback refuses any SHA other than the exact recorded previous deployment", async () => { + const previousSha = "a".repeat(40); const store = { - getDeploymentProfile: () => ({ id: 'production', provider: 'ssh-unraid', serverId: 'unraid', remoteFolder: 'lumaops', composeFile: 'docker-compose.yml', branch: 'main', environment: 'production' }), - getServer: () => ({ id: 'unraid', basePath: '/mnt/user/appdata' }), - getDeploymentState: () => ({ liveSha: 'b'.repeat(40), previousSha }) + getDeploymentProfile: () => ({ + id: "production", + provider: "ssh-unraid", + serverId: "unraid", + remoteFolder: "lumaops", + composeFile: "docker-compose.yml", + branch: "main", + environment: "production", + }), + getServer: () => ({ id: "unraid", basePath: "/mnt/user/appdata" }), + getDeploymentState: () => ({ liveSha: "b".repeat(40), previousSha }), }; - const service = new UnraidDeploymentService({ store, ssh: {}, git: {}, diagnostics: null }); + const service = new UnraidDeploymentService({ + store, + ssh: {}, + git: {}, + diagnostics: null, + }); await assert.rejects( - service.rollback({ repository: { fullName: 'Jens/LumaOps', name: 'LumaOps', localPath: '/tmp/lumaops' }, profileId: 'production', targetSha: 'c'.repeat(40) }), - (error) => error.code === 'ROLLBACK_TARGET_NOT_PREVIOUS_SHA' + service.rollback({ + repository: { + fullName: "Jens/LumaOps", + name: "LumaOps", + localPath: "/tmp/lumaops", + }, + profileId: "production", + targetSha: "c".repeat(40), + }), + (error) => error.code === "ROLLBACK_TARGET_NOT_PREVIOUS_SHA", ); }); -test('successful SSH rollback records the formerly live SHA as the new rollback target', async () => { - const previousSha = 'a'.repeat(40); - const liveSha = 'b'.repeat(40); +test("successful SSH rollback records the formerly live SHA as the new rollback target", async () => { + const previousSha = "a".repeat(40); + const liveSha = "b".repeat(40); const savedStates = []; const operations = []; const store = { - getDeploymentProfile: () => ({ id: 'production', provider: 'ssh-unraid', serverId: 'unraid', remoteFolder: 'lumaops', composeFile: 'docker-compose.yml', branch: 'main', environment: 'production', healthcheckUrl: '', iconMode: 'none' }), - getServer: () => ({ id: 'unraid', name: 'Unraid', basePath: '/mnt/user/appdata' }), + getDeploymentProfile: () => ({ + id: "production", + provider: "ssh-unraid", + serverId: "unraid", + remoteFolder: "lumaops", + composeFile: "docker-compose.yml", + branch: "main", + environment: "production", + healthcheckUrl: "", + iconMode: "none", + }), + getServer: () => ({ + id: "unraid", + name: "Unraid", + basePath: "/mnt/user/appdata", + }), getDeploymentState: () => ({ liveSha, previousSha }), - addOperation: async (operation) => { operations.push(operation); return operation; }, - saveDeploymentState: async (_profileId, state) => { savedStates.push(state); return state; } + addOperation: async (operation) => { + operations.push(operation); + return operation; + }, + saveDeploymentState: async (_profileId, state) => { + savedStates.push(state); + return state; + }, }; const git = { verifyCommitOnRemoteBranch: async () => true }; - const ssh = { exec: async () => ({ stdout: '', stderr: '', exitCode: 0 }) }; - const service = new UnraidDeploymentService({ store, ssh, git, diagnostics: null }); - service.inspect = async () => ({ rootGit: true, trackedChanges: [], head: liveSha }); - service.checkHealth = async () => ({ configured: false, healthy: null, status: null, latencyMs: null }); - const result = await service.rollback({ repository: { fullName: 'Jens/LumaOps', name: 'LumaOps', localPath: '/tmp/lumaops' }, profileId: 'production', targetSha: previousSha }); - assert.equal(result.status, 'rolled-back'); + const ssh = { exec: async () => ({ stdout: "", stderr: "", exitCode: 0 }) }; + const service = new UnraidDeploymentService({ + store, + ssh, + git, + diagnostics: null, + }); + service.inspect = async () => ({ + rootGit: true, + trackedChanges: [], + head: liveSha, + }); + service.checkHealth = async () => ({ + configured: false, + healthy: null, + status: null, + latencyMs: null, + }); + const result = await service.rollback({ + repository: { + fullName: "Jens/LumaOps", + name: "LumaOps", + localPath: "/tmp/lumaops", + }, + profileId: "production", + targetSha: previousSha, + }); + assert.equal(result.status, "rolled-back"); assert.equal(savedStates.at(-1).liveSha, previousSha); assert.equal(savedStates.at(-1).previousSha, liveSha); assert.equal(operations.at(-1).previousSha, liveSha); }); -test('generated Compose uses a lowercase-safe service while preserving the visible Portfolio container name', () => { - const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {}, diagnostics: null }); - const compose = service.generatedCompose({ composeService: 'Portfolio', hostPort: 5150, containerPort: 80 }, { name: 'Portfolio' }); +test("generated Compose uses a lowercase-safe service while preserving the visible Portfolio container name", () => { + const service = new UnraidDeploymentService({ + store: {}, + ssh: {}, + git: {}, + diagnostics: null, + }); + const compose = service.generatedCompose( + { composeService: "Portfolio", hostPort: 5150, containerPort: 80 }, + { name: "Portfolio" }, + ); assert.match(compose, / portfolio:/); assert.match(compose, /image: forgeflow\/portfolio:production/); assert.match(compose, /container_name: Portfolio/); }); -test('SSH deployment dispatch returns a running operation while the remote build continues in background', async () => { - const sha = 'd'.repeat(40); +test("SSH deployment dispatch returns a running operation while the remote build continues in background", async () => { + const sha = "d".repeat(40); const operations = []; let resolveRemote; const store = { - getDeploymentProfile: () => ({ id: 'production', provider: 'ssh-unraid', serverId: 'unraid', remoteFolder: 'Portfolio', cloneUrl: 'forgeflow-gitea:Jens/Portfolio.git', composeFile: 'docker-compose.yml', branch: 'main', environment: 'production', healthcheckUrl: '', iconMode: 'none' }), - getServer: () => ({ id: 'unraid', name: 'Unraid', basePath: '/mnt/user/appdata' }), - addOperation: async (operation) => { operations.push(structuredClone(operation)); return structuredClone(operation); }, - saveDeploymentState: async () => ({}) + getDeploymentProfile: () => ({ + id: "production", + provider: "ssh-unraid", + serverId: "unraid", + remoteFolder: "Portfolio", + cloneUrl: "forgeflow-gitea:Jens/Portfolio.git", + composeFile: "docker-compose.yml", + branch: "main", + environment: "production", + healthcheckUrl: "", + iconMode: "none", + }), + getServer: () => ({ + id: "unraid", + name: "Unraid", + basePath: "/mnt/user/appdata", + }), + addOperation: async (operation) => { + operations.push(structuredClone(operation)); + return structuredClone(operation); + }, + saveDeploymentState: async () => ({}), }; - const ssh = { exec: async () => new Promise((resolve) => { resolveRemote = resolve; }) }; - const service = new UnraidDeploymentService({ store, ssh, git: {}, diagnostics: null }); - service.preflight = async () => ({ summary: { ready: true, blocking: [] }, inspection: { head: null } }); - service.checkHealth = async () => ({ configured: false, healthy: null, status: null, latencyMs: null }); + const ssh = { + exec: async () => + new Promise((resolve) => { + resolveRemote = resolve; + }), + }; + const service = new UnraidDeploymentService({ + store, + ssh, + git: {}, + diagnostics: null, + }); + service.preflight = async () => ({ + summary: { ready: true, blocking: [] }, + inspection: { head: null }, + }); + service.checkHealth = async () => ({ + configured: false, + healthy: null, + status: null, + latencyMs: null, + }); - const operation = await service.deploy({ repository: { fullName: 'Jens/Portfolio', name: 'Portfolio' }, profileId: 'production', sha }); - assert.equal(operation.status, 'running'); - assert.match(operation.logs.join('\n'), /background/i); + const operation = await service.deploy({ + repository: { fullName: "Jens/Portfolio", name: "Portfolio" }, + profileId: "production", + sha, + }); + assert.equal(operation.status, "running"); + assert.match(operation.logs.join("\n"), /background/i); - resolveRemote({ stdout: 'Container Portfolio started\n', stderr: '', exitCode: 0 }); + resolveRemote({ + stdout: "Container Portfolio started\n", + stderr: "", + exitCode: 0, + }); await new Promise((resolve) => setTimeout(resolve, 10)); - assert.equal(operations.at(-1).status, 'success'); + assert.equal(operations.at(-1).status, "success"); }); -test('Unraid preflight verifies repository access before a deployment can start', async () => { - const source = await import('node:fs/promises').then(({ readFile }) => readFile(new URL('../src/main/unraid-deployment-service.cjs', import.meta.url), 'utf8')); +test("Unraid preflight verifies repository access before a deployment can start", async () => { + const source = await import("node:fs/promises").then(({ readFile }) => + readFile( + new URL("../src/main/unraid-deployment-service.cjs", import.meta.url), + "utf8", + ), + ); assert.match(source, /server-git-access/); assert.match(source, /git ls-remote --exit-code/); assert.match(source, /Unraid → Gitea access/); }); - -test('DockerMan metadata uses dockerman labels, a template WebUI and lowercase-safe service/image names', () => { - const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {}, diagnostics: null }); - const metadata = service.metadataCompose({ - composeService: 'portfolio', containerName: 'Portfolio', remoteFolder: 'Portfolio', - environment: 'production', hostPort: 5150, webUiUrl: 'http://192.168.10.150:5150/admin', dockerShell: '/bin/sh' - }, { name: 'Portfolio' }, 'file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png'); +test("DockerMan metadata uses dockerman labels, a template WebUI and lowercase-safe service/image names", () => { + const service = new UnraidDeploymentService({ + store: {}, + ssh: {}, + git: {}, + diagnostics: null, + }); + const metadata = service.metadataCompose( + { + composeService: "portfolio", + containerName: "Portfolio", + remoteFolder: "Portfolio", + environment: "production", + hostPort: 5150, + webUiUrl: "http://192.168.10.150:5150/admin", + dockerShell: "/bin/sh", + }, + { name: "Portfolio" }, + "file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png", + ); assert.match(metadata, / portfolio:/); assert.match(metadata, /image: forgeflow\/portfolio:production/); assert.match(metadata, /container_name: Portfolio/); assert.match(metadata, /net\.unraid\.docker\.managed.*dockerman/); - assert.match(metadata, /net\.unraid\.docker\.webui.*http:\/\/\[IP\]:\[PORT:5150\]\/admin/); - assert.match(metadata, /net\.unraid\.docker\.icon.*file:\/\/\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png/); + assert.match( + metadata, + /net\.unraid\.docker\.webui.*http:\/\/\[IP\]:\[PORT:5150\]\/admin/, + ); + assert.match( + metadata, + /net\.unraid\.docker\.icon.*file:\/\/\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png/, + ); }); - - -test('DockerMan integration writes a persistent template fallback and invalidates cached metadata', () => { - const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {}, diagnostics: null }); +test("DockerMan integration writes a persistent template fallback and invalidates cached metadata", () => { + const service = new UnraidDeploymentService({ + store: {}, + ssh: {}, + git: {}, + diagnostics: null, + }); const profile = { - composeService: 'portfolio', containerName: 'Portfolio', remoteFolder: 'Portfolio', - environment: 'production', hostPort: 5150, webUiUrl: 'http://192.168.10.150:5150/', dockerShell: '/bin/sh' + composeService: "portfolio", + containerName: "Portfolio", + remoteFolder: "Portfolio", + environment: "production", + hostPort: 5150, + webUiUrl: "http://192.168.10.150:5150/", + dockerShell: "/bin/sh", }; - const repository = { name: 'Portfolio' }; - const icon = 'file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png'; + const repository = { name: "Portfolio" }; + const icon = + "file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png"; const template = service.dockerManTemplate(profile, repository, icon); const refresh = service.dockerManRefreshScript(profile, repository, icon); assert.match(template, /Portfolio<\/Name>/); - assert.match(template, /forgeflow\/portfolio:production<\/Repository>/); + assert.match( + template, + /forgeflow\/portfolio:production<\/Repository>/, + ); assert.match(template, /http:\/\/\[IP\]:\[PORT:5150\]\/<\/WebUI>/); - assert.match(template, /file:\/\/\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png<\/Icon>/); + assert.match( + template, + /file:\/\/\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png<\/Icon>/, + ); assert.match(refresh, /templates-user\/my-Portfolio\.xml/); assert.match(refresh, /dynamix\.docker\.manager\/docker\.json/); - assert.match(refresh, /cp '\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png'/); + assert.match( + refresh, + /cp '\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png'/, + ); assert.doesNotMatch(refresh, /dockerManRefreshScript/); - assert.equal(xmlEscape('A&B<"x">'), 'A&B<"x">'); + assert.equal(xmlEscape('A&B<"x">'), "A&B<"x">"); }); -test('built-in ITWorx DockerMan icon is uploaded to persistent Unraid storage', async (t) => { - const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises'); - const os = await import('node:os'); - const path = await import('node:path'); - const sourcePath = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-icon-')); +test("built-in ITWorx DockerMan icon is uploaded to persistent Unraid storage", async (t) => { + const { mkdtemp, mkdir, writeFile, rm } = await import("node:fs/promises"); + const os = await import("node:os"); + const path = await import("node:path"); + const sourcePath = await mkdtemp(path.join(os.tmpdir(), "forgeflow-icon-")); t.after(() => rm(sourcePath, { recursive: true, force: true })); - const asset = path.join(sourcePath, 'src', 'renderer', 'assets', 'itworx-mark.png'); + const asset = path.join( + sourcePath, + "src", + "renderer", + "assets", + "itworx-mark.png", + ); await mkdir(path.dirname(asset), { recursive: true }); await writeFile(asset, Buffer.from([137, 80, 78, 71])); const uploads = []; const service = new UnraidDeploymentService({ - store: {}, git: {}, diagnostics: null, sourcePath, - ssh: { uploadFile: async (...args) => { uploads.push(args); return {}; } } + store: {}, + git: {}, + diagnostics: null, + sourcePath, + ssh: { + uploadFile: async (...args) => { + uploads.push(args); + return {}; + }, + }, }); - const icon = await service.prepareIcon({ iconMode: 'builtin', remoteFolder: 'Portfolio' }, { name: 'Portfolio' }, { id: 'unraid' }); - assert.equal(icon, 'file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png'); + const icon = await service.prepareIcon( + { iconMode: "builtin", remoteFolder: "Portfolio" }, + { name: "Portfolio" }, + { id: "unraid" }, + ); + assert.equal( + icon, + "file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png", + ); assert.equal(uploads.length, 1); - assert.equal(uploads[0][0], 'unraid'); + assert.equal(uploads[0][0], "unraid"); assert.equal(uploads[0][1], asset); - assert.equal(uploads[0][2], '/boot/config/plugins/dockerMan/images/Portfolio-icon.png'); + assert.equal( + uploads[0][2], + "/boot/config/plugins/dockerMan/images/Portfolio-icon.png", + ); }); -test('stuck SSH deployment is reconciled to success when exact SHA and container health are live', async () => { - const sha = 'f'.repeat(40); +test("stuck SSH deployment is reconciled to success when exact SHA and container health are live", async () => { + const sha = "f".repeat(40); const saved = []; - const operation = { id: 'op-1', type: 'deployment', provider: 'ssh-unraid', action: 'deploy', repository: 'Jens/Portfolio', profileId: 'production', sha, status: 'running', logs: [] }; + const operation = { + id: "op-1", + type: "deployment", + provider: "ssh-unraid", + action: "deploy", + repository: "Jens/Portfolio", + profileId: "production", + sha, + status: "running", + logs: [], + }; const store = { getOperation: () => operation, - addOperation: async (next) => { saved.push(next); return next; } + addOperation: async (next) => { + saved.push(next); + return next; + }, }; - const service = new UnraidDeploymentService({ store, ssh: {}, git: {}, diagnostics: null }); - service.refreshProfileState = async () => ({ liveSha: sha, containerRunning: true, healthy: true }); - const result = await service.refreshOperation('op-1'); - assert.equal(result.status, 'success'); + const service = new UnraidDeploymentService({ + store, + ssh: {}, + git: {}, + diagnostics: null, + }); + service.refreshProfileState = async () => ({ + liveSha: sha, + containerRunning: true, + healthy: true, + }); + const result = await service.refreshOperation("op-1"); + assert.equal(result.status, "success"); assert.match(result.logs.at(-1), /reconciled/i); - assert.equal(saved.at(-1).status, 'success'); + assert.equal(saved.at(-1).status, "success"); }); -test('DockerMan metadata repair refreshes known Unraid icon caches after container recreation', async () => { - const source = await import('node:fs/promises').then(({ readFile }) => readFile(new URL('../src/main/unraid-deployment-service.cjs', import.meta.url), 'utf8')); +test("DockerMan metadata repair refreshes known Unraid icon caches after container recreation", async () => { + const source = await import("node:fs/promises").then(({ readFile }) => + readFile( + new URL("../src/main/unraid-deployment-service.cjs", import.meta.url), + "utf8", + ), + ); assert.match(source, /\/var\/lib\/docker\/unraid\/images/); assert.match(source, /dynamix\.docker\.manager\/images/); assert.match(source, /-icon\.png/); @@ -249,55 +486,182 @@ test('DockerMan metadata repair refreshes known Unraid icon caches after contain assert.match(source, /--force-recreate/); }); - -test('stuck deployment is cleared as superseded when a different healthy commit is already live', async () => { - const requested = 'a'.repeat(40); - const live = 'b'.repeat(40); - const operation = { id: 'op-superseded', type: 'deployment', provider: 'ssh-unraid', action: 'deploy', repository: 'Jens/Portfolio', profileId: 'production', sha: requested, status: 'running', logs: [] }; +test("stuck deployment is cleared as superseded when a different healthy commit is already live", async () => { + const requested = "a".repeat(40); + const live = "b".repeat(40); + const operation = { + id: "op-superseded", + type: "deployment", + provider: "ssh-unraid", + action: "deploy", + repository: "Jens/Portfolio", + profileId: "production", + sha: requested, + status: "running", + logs: [], + }; const saved = []; const service = new UnraidDeploymentService({ - store: { getOperation: () => operation, addOperation: async (next) => { saved.push(next); return next; } }, - ssh: {}, git: {}, diagnostics: null + store: { + getOperation: () => operation, + addOperation: async (next) => { + saved.push(next); + return next; + }, + }, + ssh: {}, + git: {}, + diagnostics: null, + }); + service.refreshProfileState = async () => ({ + liveSha: live, + containerRunning: true, + healthy: true, }); - service.refreshProfileState = async () => ({ liveSha: live, containerRunning: true, healthy: true }); const result = await service.refreshOperation(operation.id); - assert.equal(result.status, 'cancelled'); + assert.equal(result.status, "cancelled"); assert.match(result.error, /Superseded/); - assert.equal(saved.at(-1).status, 'cancelled'); + assert.equal(saved.at(-1).status, "cancelled"); }); -test('existing Unraid deployment discovery derives profile values from Docker, Compose and DockerMan truth', () => { - const { deriveDetectedProfile } = require('../src/main/unraid-deployment-service.cjs'); +test("existing Unraid deployment discovery derives profile values from Docker, Compose and DockerMan truth", () => { + const { + deriveDetectedProfile, + } = require("../src/main/unraid-deployment-service.cjs"); const result = deriveDetectedProfile({ - repository: { name: 'blockpilot-autonomous', defaultBranch: 'main', sshUrl: 'ssh://git@gitea/Jens/blockpilot-autonomous.git' }, - server: { id: 'unraid', host: '192.168.10.150' }, - remoteFolder: 'blockpilot-autonomous', - remotePath: '/mnt/user/appdata/blockpilot-autonomous', + repository: { + name: "blockpilot-autonomous", + defaultBranch: "main", + sshUrl: "ssh://git@gitea/Jens/blockpilot-autonomous.git", + }, + server: { id: "unraid", host: "192.168.10.150" }, + remoteFolder: "blockpilot-autonomous", + remotePath: "/mnt/user/appdata/blockpilot-autonomous", payload: { - head: 'a'.repeat(40), - branch: 'main', - remote: 'ssh://git@gitea/Jens/blockpilot-autonomous.git', - composeFiles: ['compose.yml'], - compose: { services: { app: { image: 'blockpilot:test' } } }, - containers: [{ - Name: '/blockpilot', - State: { Running: true }, - Config: { Image: 'blockpilot:test', Env: ['TOKEN=secret', 'MODE=prod'], Labels: { 'com.docker.compose.service': 'app', 'com.docker.compose.project': 'blockpilot' } }, - HostConfig: { RestartPolicy: { Name: 'unless-stopped' } }, - NetworkSettings: { Ports: { '8080/tcp': [{ HostIp: '0.0.0.0', HostPort: '1223' }] }, Networks: { bridge: {} } }, - Mounts: [{ Type: 'bind', Source: '/mnt/user/appdata/blockpilot-autonomous/data', Destination: '/data', RW: true }] - }], - dockerManXml: 'blockpilothttp://[IP]:[PORT:1223]/https://example.test/icon.png/bin/bash' - } + head: "a".repeat(40), + branch: "main", + remote: "ssh://git@gitea/Jens/blockpilot-autonomous.git", + composeFiles: ["compose.yml"], + compose: { services: { app: { image: "blockpilot:test" } } }, + containers: [ + { + Name: "/blockpilot", + State: { Running: true }, + Config: { + Image: "blockpilot:test", + Env: ["TOKEN=secret", "MODE=prod"], + Labels: { + "com.docker.compose.service": "app", + "com.docker.compose.project": "blockpilot", + }, + }, + HostConfig: { RestartPolicy: { Name: "unless-stopped" } }, + NetworkSettings: { + Ports: { "8080/tcp": [{ HostIp: "0.0.0.0", HostPort: "1223" }] }, + Networks: { bridge: {} }, + }, + Mounts: [ + { + Type: "bind", + Source: "/mnt/user/appdata/blockpilot-autonomous/data", + Destination: "/data", + RW: true, + }, + ], + }, + ], + dockerManXml: + "blockpilothttp://[IP]:[PORT:1223]/https://example.test/icon.png/bin/bash", + }, }); assert.equal(result.profile.hostPort, 1223); assert.equal(result.profile.containerPort, 8080); - assert.equal(result.profile.containerName, 'blockpilot'); - assert.equal(result.profile.composeService, 'app'); - assert.equal(result.profile.webUiUrl, 'http://[IP]:[PORT:1223]/'); - assert.equal(result.profile.iconUrl, 'https://example.test/icon.png'); - assert.equal(result.profile.dockerShell, '/bin/bash'); - assert.deepEqual(result.profile.detectedMetadata.envNames, ['TOKEN', 'MODE']); - assert.ok(result.profile.preservePaths.includes('data')); - assert.equal(result.provenance.hostPort.origin, 'docker-inspect'); + assert.equal(result.profile.containerName, "blockpilot"); + assert.equal(result.profile.composeService, "app"); + assert.equal(result.profile.webUiUrl, "http://[IP]:[PORT:1223]/"); + assert.equal(result.profile.iconUrl, "https://example.test/icon.png"); + assert.equal(result.profile.dockerShell, "/bin/bash"); + assert.deepEqual(result.profile.detectedMetadata.envNames, ["TOKEN", "MODE"]); + assert.ok(result.profile.preservePaths.includes("data")); + assert.equal(result.provenance.hostPort.origin, "docker-inspect"); +}); + +test("a previously failed deployment is corrected when its exact commit is healthy on Unraid", async () => { + const sha = "e".repeat(40); + const failed = { + id: "failed-1", + type: "deployment", + provider: "ssh-unraid", + action: "deploy", + profileId: "production", + sha, + status: "failed", + logs: [], + }; + const operations = [failed]; + const service = new UnraidDeploymentService({ + store: { + data: { operations }, + getOperation: (id) => operations.find((item) => item.id === id), + addOperation: async (next) => { + operations.splice( + operations.findIndex((item) => item.id === next.id), + 1, + next, + ); + return next; + }, + }, + ssh: {}, + git: {}, + diagnostics: null, + }); + await service.reconcileRecordedOperations("production", { + liveSha: sha, + matchesGitea: true, + containerRunning: true, + healthy: true, + }); + assert.equal(operations[0].status, "success"); + assert.match(operations[0].logs.at(-1), /reconciled/i); +}); + +test("a failed deployment is marked superseded when Gitea and Unraid agree on a newer commit", async () => { + const liveSha = "d".repeat(40); + const operations = [ + { + id: "failed-2", + type: "deployment", + provider: "ssh-unraid", + profileId: "production", + sha: "c".repeat(40), + status: "failed", + logs: [], + }, + ]; + const service = new UnraidDeploymentService({ + store: { + data: { operations }, + getOperation: (id) => operations.find((item) => item.id === id), + addOperation: async (next) => { + operations.splice( + operations.findIndex((item) => item.id === next.id), + 1, + next, + ); + return next; + }, + }, + ssh: {}, + git: {}, + diagnostics: null, + }); + await service.reconcileRecordedOperations("production", { + liveSha, + matchesGitea: true, + containerRunning: true, + healthy: true, + }); + assert.equal(operations[0].status, "cancelled"); + assert.match(operations[0].error, /Superseded/); });