Files
ForgeFlow/src/main/ipc.cjs
T

1286 lines
44 KiB
JavaScript

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