1286 lines
44 KiB
JavaScript
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,
|
|
};
|