"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"); let diagnosticsService = null; const TRUSTED_RENDERER_PATH = path.resolve( __dirname, "..", "renderer", "index.html", ); function toErrorPayload(error) { return { message: error?.message || "Unknown error", code: error?.code || null, status: error?.status || null, recoverable: Boolean(error?.recoverable), commitSha: error?.commitSha || null, }; } function assertTrustedSender(event) { const url = event?.senderFrame?.url || event?.sender?.getURL?.() || ""; try { const parsed = new URL(url); if (parsed.protocol !== "file:") throw new Error("not a file URL"); const senderPath = path.resolve(fileURLToPath(parsed)); const normalize = (value) => process.platform === "win32" ? value.toLowerCase() : value; if (normalize(senderPath) !== normalize(TRUSTED_RENDERER_PATH)) throw new Error("unexpected renderer file"); } catch { throw new Error("Rejected IPC request from an untrusted renderer origin."); } } function register(channel, handler) { ipcMain.handle(channel, async (event, payload) => { const started = Date.now(); try { assertTrustedSender(event); const data = await handler(payload || {}, event); await diagnosticsService?.debug("ipc.completed", { channel, durationMs: Date.now() - started, }); return { ok: true, data }; } catch (error) { await diagnosticsService?.error("ipc.failed", { channel, durationMs: Date.now() - started, error: { name: error?.name, message: error?.message, code: error?.code, status: error?.status, stack: error?.stack, }, }); console.error(`[${channel}]`, error); return { ok: false, error: toErrorPayload(error) }; } }); } function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh, updates, preflight, diagnostics, 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); } }; 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) { if (!git.isGitLockError(error)) throw error; let repair = null; let lockDiagnosis = null; try { repair = await git.repairStaleGitLocks(key, { minimumAgeMs: 2_000 }); } catch (repairError) { lockDiagnosis = repairError; if (repairError?.code === "GIT_LOCKS_RECENT") { await new Promise((resolve) => setTimeout(resolve, 2_500)); try { repair = await git.repairStaleGitLocks(key, { minimumAgeMs: 2_000, }); lockDiagnosis = null; } catch (retryError) { lockDiagnosis = retryError; } } } if (!repair?.repaired) throw lockDiagnosis || error; 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); } }; const canonicalPath = async (value) => { const resolved = path.resolve(String(value || "")); return fs.realpath(resolved).catch(() => resolved); }; const assertKnownRepositoryPath = async (localPath) => { const candidate = await canonicalPath(localPath); let knownPaths = repositories.getWatchPaths(); if (!knownPaths.length && store.data.setupComplete) { await repositories.refresh(); knownPaths = repositories.getWatchPaths(); } const canonicalKnown = await Promise.all(knownPaths.map(canonicalPath)); if (!canonicalKnown.some((known) => known === candidate)) throw new Error( "The requested local repository is not linked or discovered by ForgeFlow.", ); return candidate; }; const resolveRepository = async (repositoryPayload) => { const fullName = String(repositoryPayload?.fullName || "").trim(); if (!fullName) throw new Error("Repository identity is required."); const current = (await repositories.refresh()).find( (item) => item.fullName === fullName, ); if (!current) throw new Error( "The repository is no longer available through the configured Gitea account.", ); return current; }; const assertProjectRoot = async (rootValue) => { const root = await canonicalPath(rootValue); const stat = await fs.stat(root).catch(() => null); if (!stat?.isDirectory()) throw new Error("The selected project root no longer exists."); return root; }; const cloneRepositoryInto = async (fullName, projectRoot) => { const current = await resolveRepository({ fullName }); if (current.localPath) throw new Error("This repository already has a linked local folder."); const remoteUrl = current.preferredCloneUrl || current.cloneUrl || current.sshUrl; if (!remoteUrl) throw new Error( "Gitea did not provide a usable clone URL for this repository.", ); const root = await assertProjectRoot(projectRoot); const { target } = resolveCloneTarget(root, remoteUrl); const status = await git.clone(remoteUrl, target); await store.saveMapping(current.fullName, target); const result = await repositories.refresh(); monitor?.setPaths(repositories.getWatchPaths()); await diagnostics.info( status.reused ? "repository.clone.reused" : "repository.cloned", { fullName: current.fullName, projectRoot: root, target, head: status.head, branch: status.branch?.head, }, ); return { target, status, reused: Boolean(status.reused), repositories: result, state: store.getPublicState(), }; }; register("app:bootstrap", async () => ({ appVersion: app.getVersion(), platform: process.platform, state: store.getPublicState(), git: await git.isAvailable(), diagnostics: await diagnostics.getStatus(), 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-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, }); if (!report.summary.ready || !report.giteaValidation) throw new Error( "Setup readiness checks must pass before configuration can be completed.", ); const validation = report.giteaValidation; const result = await store.completeSetup({ baseUrl: validation.baseUrl, token, user: validation.user, workspaceRoots, }); await diagnostics.info("setup.completed", { baseUrl: validation.baseUrl, user: validation.user?.login || null, workspaceRootCount: workspaceRoots?.length || 0, tokenPersistent: result.tokenState.persistent, }); return result; }); register("settings:update-gitea", async ({ baseUrl, token }) => { const effectiveToken = String(token || "").trim() || store.getToken(); const validation = await gitea.validateConnection(baseUrl, effectiveToken); const tokenState = await store.updateGitea({ baseUrl: validation.baseUrl, token, user: validation.user, }); await diagnostics.info("settings.gitea.updated", { baseUrl: validation.baseUrl, user: validation.user?.login || null, tokenPersistent: tokenState.persistent, tokenPreserved: tokenState.preserved, }); return { validation, tokenState, state: store.getPublicState() }; }); register("settings:set-roots", async ({ roots }) => { store.data.workspaceRoots = [...new Set((roots || []).filter(Boolean))]; await store.save(); await diagnostics.info("settings.workspace-roots.updated", { rootCount: store.data.workspaceRoots.length, roots: store.data.workspaceRoots, }); return store.getPublicState(); }); register("settings:set-appearance", async ({ appearance }) => { if (!["dark", "light", "system"].includes(appearance)) throw new Error("Unsupported appearance setting."); store.data.appearance = appearance; await store.save(); return store.getPublicState(); }); register("settings:set-preferences", async ({ preferences }) => { const state = await store.setPreferences(preferences); monitor?.restart(); onPreferencesChanged?.(); await diagnostics.info("settings.preferences.updated", { preferences: state.preferences, }); return state; }); 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"] }, ], }); 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 }, ); }); 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"] }, ], }); if (result.canceled || !result.filePaths[0]) return null; 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, }); 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] }, ], }); if (result.canceled || !result.filePath) return null; 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 () => { 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.", ); 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 }) => { await store.deleteServer(serverId); await diagnostics.info("server.deleted", { serverId }); return store.getPublicState(); }); register("server:test", async ({ serverId }) => { const server = store.getServer(serverId); if (!server) throw new Error("The configured server no longer exists."); const result = await ssh.test(serverId, { trustOnFirstUse: !server.hostFingerprint, }); if (!server.hostFingerprint) { await store.saveServer( { ...server, hostFingerprint: result.fingerprint }, {}, ); result.trusted = true; } return { ...result, state: store.getPublicState() }; }); register("server:inspect-project", async ({ repository, profileId }) => unraid.inspect({ repository: await resolveRepository(repository), profileId, }), ); register( "server:discover-existing", async ({ repository, serverId, remoteFolder }) => unraid.discoverExisting({ repository: await resolveRepository(repository), serverId, remoteFolder, }), ); register("repositories:refresh", async () => { const result = await repositories.refresh(); monitor?.setPaths(repositories.getWatchPaths()); return result; }); register("repositories:discover", async ({ roots }) => { const paths = await repositories.discoverAll( roots || store.data.workspaceRoots, ); return repositories.getLocalDescriptors(paths); }); register("repository:favorite", async ({ fullName, favorite }) => store.setFavorite(fullName, favorite), ); register("repository:link", async ({ fullName, localPath }) => { await git.ensureRepository(localPath); const remoteUrl = await git.getRemoteUrl(localPath).catch(() => ""); if ( !remoteUrl || !matchRemoteToRepository(remoteUrl, [{ full_name: fullName }]) ) { throw new Error( `The selected folder's origin does not match ${fullName}.`, ); } await store.saveMapping(fullName, localPath); await diagnostics.info("repository.linked", { fullName, localPath }); const result = await repositories.refresh(); monitor?.setPaths(repositories.getWatchPaths()); return result; }); register("repository:unlink", async ({ fullName }) => { await store.removeMapping(fullName); await diagnostics.info("repository.unlinked", { fullName }); const result = await repositories.refresh(); monitor?.setPaths(repositories.getWatchPaths()); return result; }); register("repository:status", async ({ localPath }) => git.status(await assertKnownRepositoryPath(localPath)), ); register("repository:diff", async ({ localPath, filePath, staged }) => git.diff(await assertKnownRepositoryPath(localPath), filePath, staged), ); register("repository:diff-hunks", async ({ localPath, filePath }) => git.diffHunks(await assertKnownRepositoryPath(localPath), filePath), ); register( "repository:stage-hunks", async ({ localPath, filePath, hunkIndexes }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.stageHunks(safePath, filePath, hunkIndexes), ); }, ); register("repository:conflicts", async ({ localPath }) => git.conflictState(await assertKnownRepositoryPath(localPath)), ); register( "repository:resolve-conflict", async ({ localPath, filePath, resolution }) => { const safePath = await assertKnownRepositoryPath(localPath); const result = await withRepositoryMutation(safePath, () => git.resolveConflict(safePath, filePath, resolution), ); await audit.append("git.conflict.resolved", { localPath: safePath, filePath, resolution, }); return result; }, ); register("repository:continue-operation", async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); const result = await withRepositoryMutation(safePath, () => git.continueInterruptedOperation(safePath), ); await audit.append("git.operation.continued", { localPath: safePath }); return result; }); register("repository:abort-operation", async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); const result = await withRepositoryMutation(safePath, () => git.abortInterruptedOperation(safePath), ); await audit.append("git.operation.aborted", { localPath: safePath, operation: result.aborted, }); return result; }); register("repository:stage", async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.stage(safePath, files)); }); register("repository:unstage", async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.unstage(safePath, files)); }); register("repository:commit", async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.commit(safePath, message, files), ); }); register("repository:commit-staged", async ({ localPath, message }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.commitStaged(safePath, message), ); }); register("repository:commit-staged-push", async ({ localPath, message }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.commitStagedAndPush(safePath, message), ); }); register("repository:commit-push", async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.commitAndPush(safePath, message, files), ); }); register("repository:push", async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.push(safePath)); }); register("repository:fetch", async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.fetch(safePath)); }); register("repository:pull", async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.pullFastForward(safePath), ); }); register("repository:history", async ({ localPath, limit }) => git.history(await assertKnownRepositoryPath(localPath), limit), ); register("repository:branch-protection", async ({ fullName, branch }) => { const repository = await resolveRepository({ fullName }); return gitea.getBranchProtection( repository.owner.login, repository.name, branch || repository.localStatus?.branch?.head || repository.defaultBranch, ); }); register("repository:pull-requests", async ({ fullName, state = "open" }) => { const repository = await resolveRepository({ fullName }); return gitea.listPullRequests({ owner: repository.owner.login, repo: repository.name, state, }); }); register( "repository:create-pull-request", async ({ fullName, title, body, base }) => { const repository = await resolveRepository({ fullName }); if (!repository.localPath || !repository.localStatus?.clean) throw new Error( "A clean linked repository is required before creating a pull request.", ); const head = repository.localStatus.branch?.head; if (!head || !repository.localStatus.branch?.upstream) throw new Error( "Publish the current branch before creating a pull request.", ); if (repository.localStatus.branch.ahead > 0) throw new Error( "Push all local commits before creating a pull request.", ); const pullRequest = await gitea.createPullRequest({ owner: repository.owner.login, repo: repository.name, head, base: base || repository.defaultBranch, title, body, }); await audit.append("pull-request.created", { repository: repository.fullName, number: pullRequest.number, head, base: base || repository.defaultBranch, url: pullRequest.html_url, }); return pullRequest; }, ); register("repository:branches", async ({ localPath }) => git.branches(await assertKnownRepositoryPath(localPath)), ); register("repository:checkout-branch", async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.checkoutBranch(safePath, branch), ); }); register("repository:create-branch", async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.createBranch(safePath, branch), ); }); register("repository:stash", async ({ localPath, message }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.stash(safePath, message)); }); register("repository:stash-list", async ({ localPath }) => git.stashList(await assertKnownRepositoryPath(localPath)), ); register("repository:stash-pop", async ({ localPath, ref }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.popStash(safePath, ref)); }); register("repository:index-lock", async ({ localPath }) => git.getIndexLockInfo(await assertKnownRepositoryPath(localPath)), ); register("repository:git-recovery-status", async ({ localPath }) => git.reconcile(await assertKnownRepositoryPath(localPath)), ); register("repository:repair-index-lock", async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.removeStaleIndexLock(safePath), ); }); register( "repository:repair-git-locks", async ({ localPath, force = false }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.repairStaleGitLocks(safePath, { minimumAgeMs: force ? 0 : 10_000, allowWithoutProcessProbe: force === true, }), ); }, ); register("repository:reconcile", async ({ localPath }) => git.reconcile(await assertKnownRepositoryPath(localPath)), ); register("repository:repair-sync", async ({ localPath, strategy }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.repairSync(safePath, strategy), ); }); register("repository:set-origin", async ({ localPath, remoteUrl }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.setRemoteUrl(safePath, remoteUrl), ); }); register("repositories:normalize-origins", async () => { const current = await repositories.refresh(); const changes = []; for (const repository of current) { if (!repository.localPath || !repository.sshUrl) continue; const actual = await git .getRemoteUrl(repository.localPath) .catch(() => ""); if (actual === repository.sshUrl) continue; await withRepositoryMutation(repository.localPath, () => git.setRemoteUrl(repository.localPath, repository.sshUrl), ); changes.push({ fullName: repository.fullName, previous: actual, next: repository.sshUrl, }); } const refreshed = await repositories.refresh(); monitor?.setPaths(repositories.getWatchPaths()); await diagnostics.info("repositories.origins.normalized", { count: changes.length, changes, }); return { changes, repositories: refreshed }; }); register("repository:clone", async ({ fullName, mode = "default" }) => { if (!["default", "custom"].includes(mode)) throw new Error("Unsupported clone location mode."); let projectRoot = store.data.workspaceRoots[0] || null; if (mode === "custom" || !projectRoot) { const result = await dialog.showOpenDialog({ title: `Choose a project root for ${String(fullName || "repository")}`, defaultPath: projectRoot || undefined, buttonLabel: "Use this project root", properties: ["openDirectory", "createDirectory"], }); if (result.canceled || !result.filePaths[0]) return { cancelled: true }; projectRoot = result.filePaths[0]; } return cloneRepositoryInto(fullName, projectRoot); }); register("repository:open-path", async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); const error = await shell.openPath(safePath); if (error) throw new Error(error); return true; }); register( "repository:open-editor", async ({ localPath, filePath = "", line = 1 }) => externalTools.launch( "editor", await assertKnownRepositoryPath(localPath), filePath, line, ), ); register("repository:open-terminal", async ({ localPath }) => externalTools.launch( "terminal", await assertKnownRepositoryPath(localPath), ), ); register("external:open", async ({ url }) => { const parsed = new URL(url); if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("Only HTTP and HTTPS links can be opened."); await shell.openExternal(parsed.toString()); return true; }); register("troubleshooter:scan", async ({ fullName = null }) => { const currentRepositories = await repositories.refresh(); 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, }); 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 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 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, }); } catch (error) { 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; 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, }); } 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, }); } } } 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; 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, }); return result; }); register("troubleshooter:auto-repair", async ({ issues }) => { const results = []; for (const issue of (issues || []).filter( (item) => item.repairable && item.safe, )) { try { 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), ); else continue; results.push({ id: issue.id, ok: true, result }); } 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, }); return results; }); register("deployment:save-profile", async ({ fullName, profile }) => { const saved = await store.saveDeploymentProfile(fullName, profile); await diagnostics.info("deployment.profile.saved", { repository: fullName, profile: saved, }); return { profile: saved, state: store.getPublicState() }; }); register("deployment:delete-profile", async ({ fullName, profileId }) => { const profiles = await store.deleteDeploymentProfile(fullName, profileId); await diagnostics.info("deployment.profile.deleted", { repository: fullName, profileId, }); return { profiles, state: store.getPublicState() }; }); register("deployment:preflight", async ({ repository, profileId }) => { const current = await resolveRepository(repository); const profile = store.getDeploymentProfile(current.fullName, profileId); if (profile?.provider === "ssh-unraid") return unraid.preflight({ repository: current, profileId }); return preflight.runDeployment({ repository: current, profileId }); }); register( "deployment:dispatch", async ({ repository, profileId, sha, 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); return deployments.refreshProfileState(fullName, profileId); }); register( "deployment:apply-dockerman-metadata", async ({ repository, profileId }) => { const current = await resolveRepository(repository); return unraid.applyDockerManMetadata({ repository: current, profileId }); }, ); register("deployment:reconcile", async ({ fullName, profileId }) => { const profile = store.getDeploymentProfile(fullName, profileId); if (profile?.provider !== "ssh-unraid") return deployments.refreshProfileState(fullName, profileId); const [owner, repo] = String(fullName || "").split("/"); const branch = await gitea.getBranch(owner, repo, profile.branch); const giteaSha = branch?.commit?.id || branch?.commit?.sha || branch?.commit?.commit?.id || null; const state = await unraid.refreshProfileState( fullName, profileId, giteaSha, ); const operations = store.data.operations.filter( (item) => item.profileId === profileId && item.provider === "ssh-unraid" && !["success", "failed", "cancelled", "rolled-back"].includes( item.status, ), ); for (const operation of operations) await unraid.refreshOperation(operation.id); return { state, operations: await unraid.reconcileRecordedOperations(profileId, state), }; }); register("operations:refresh", async ({ operationId }) => { if (operationId) { const operation = store.getOperation(operationId); if (operation?.provider === "ssh-unraid") return unraid.refreshOperation(operationId); return deployments.refreshOperation(operationId); } const [actions, sshOperations] = await Promise.all([ deployments.refreshActiveOperations(), unraid.refreshActiveOperations(), ]); return [...actions, ...sshOperations]; }); register("operations:get", ({ operationId }) => store.getOperation(operationId), ); register("diagnostics:status", () => diagnostics.getStatus()); register("diagnostics:clear", () => diagnostics.clear()); register("diagnostics:open-folder", async () => { const error = await shell.openPath(diagnostics.logDirectory); if (error) throw new Error(error); return true; }); register("diagnostics:export", async ({ privacyMode = "standard" }) => { if (!["standard", "strict"].includes(privacyMode)) throw new Error("Unsupported diagnostic privacy mode."); const result = await dialog.showSaveDialog({ title: "Export ForgeFlow diagnostic bundle", defaultPath: path.join( app.getPath("downloads"), `ForgeFlow-Diagnostics-${new Date().toISOString().replace(/[:.]/g, "-")}.zip`, ), filters: [{ name: "ZIP archive", extensions: ["zip"] }], }); if (result.canceled || !result.filePath) return null; const repositoryState = await repositories.refresh().catch((error) => { diagnostics.warning("diagnostics.repository-snapshot.failed", error); return []; }); const systemPreflight = await preflight .runSystem() .catch((error) => ({ error: error.message })); const destinationPath = path.extname(result.filePath).toLowerCase() === ".zip" ? result.filePath : `${result.filePath}.zip`; return diagnostics.exportSupportBundle({ destinationPath, publicState: store.getPublicState(), repositories: repositoryState, operations: store.data.operations, preflight: systemPreflight, privacyMode, extra: { appVersion: app.getVersion(), setupComplete: store.data.setupComplete, }, }); }); register("diagnostics:show-bundle", async ({ filePath }) => { if (!diagnostics.isKnownBundlePath(filePath)) throw new Error( "Only the most recently generated support bundle can be revealed.", ); shell.showItemInFolder(filePath); return true; }); register( "renderer:report", async ({ level = "info", event = "renderer.event", details = {} }) => { const method = ["debug", "info", "warning", "error"].includes(level) ? level : "info"; await diagnostics[method]( `renderer.${String(event || "event").slice(0, 120)}`, details, ); return true; }, ); register("app:reset", async () => { await diagnostics.info("app.reset.requested", {}); store.data = store.migrate({}); store.sessionToken = null; await store.save(); monitor?.setPaths([]); monitor?.restart(); return store.getPublicState(); }); } module.exports = { registerIpc, cloneDirectoryName, assertTrustedSender, toErrorPayload, };