"use strict"; const path = require("node:path"); const fs = require("node:fs/promises"); const { dialog, shell, app } = require("electron"); const { matchRemoteToRepository } = require("../shared/repository-match.cjs"); const { cloneDirectoryName, resolveCloneTarget, } = require("../shared/clone-target.cjs"); const { createChannelRegistrar, assertTrustedSender, toErrorPayload, } = require("./ipc/channel.cjs"); const { registerRepositoryIpc } = require("./ipc/repository-handlers.cjs"); const { registerDeploymentIpc } = require("./ipc/deployment-handlers.cjs"); const { registerOperationsIpc } = require("./ipc/operations-handlers.cjs"); const { createEncryptedBackup, readEncryptedBackup, } = require("./configuration-backup.cjs"); const { evaluateDeploymentPolicy } = require("../shared/deployment-policy.cjs"); function registerIpc({ store, git, gitea, repositories, deployments, unraid, deployKeys, inventoryReviews, ssh, updates, preflight, gitValidator, diagnostics, audit, externalTools, monitor, onPreferencesChanged, }) { const register = createChannelRegistrar(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(); } // Watch paths are already canonical, so re-resolving all of them on every // guarded call is only needed when the cheap comparison finds no match. const matched = knownPaths.some((known) => path.resolve(known) === candidate) || (await Promise.all(knownPaths.map(canonicalPath))).some((known) => known === candidate); if (!matched) 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.resolveByFullName(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 = "" }) => { await ssh.validateServerConfiguration(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, expectedFingerprint = "" }) => { const server = store.getServer(serverId); if (!server) throw new Error("The configured server no longer exists."); const expected = String(expectedFingerprint || "").trim(); if (!server.hostFingerprint && !expected) { const probe = await ssh.probeHostFingerprint(serverId); return { ...probe, connected: false, needsTrust: true, state: store.getPublicState() }; } if (!server.hostFingerprint && !/^SHA256:[A-Za-z0-9+/]{40,44}$/.test(expected)) throw new Error("Confirm the exact SSH host fingerprint returned by ForgeFlow."); const result = await ssh.test(serverId, { expectedFingerprint: server.hostFingerprint ? null : expected, }); if (!server.hostFingerprint) { if (result.fingerprint !== expected) { const error = new Error("The SSH host identity changed between preview and confirmation."); error.code = "SSH_HOST_KEY_MISMATCH"; throw error; } 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, }), ); registerRepositoryIpc({ register, repositories, store, git, gitea, monitor, diagnostics, audit, externalTools, gitValidator, withRepositoryMutation, assertKnownRepositoryPath, resolveRepository, cloneRepositoryInto, cloneDirectoryName, matchRemoteToRepository, shell, dialog, }); 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; }); registerDeploymentIpc({ register, store, resolveRepository, unraid, deployments, evaluateDeploymentPolicy, audit, deployKeys, repositories, inventoryReviews, diagnostics, git, gitea, ssh, preflight, }); registerOperationsIpc({ register, store, unraid, deployments, diagnostics, shell, dialog, path, app, repositories, preflight, monitor, }); } module.exports = { registerIpc, cloneDirectoryName, assertTrustedSender, toErrorPayload, };