706 lines
24 KiB
JavaScript
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,
|
|
};
|