Files
ForgeFlow/src/main/ipc.cjs
T
NuklearRabbit d47c7b5e41
ForgeFlow quality gate / secret-scan (push) Failing after 32s
ForgeFlow quality gate / quality (push) Failing after 0s
feat: add safe Gitea sync and signed updates
2026-08-27 00:38:58 +02:00

706 lines
24 KiB
JavaScript

"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,
};