Release ForgeFlow 0.8.1

Add advanced Git and deployment workflows, secure backups and auditing, live Gitea integration, desktop notifications, connection validation, and the premium responsive UX refresh.
This commit is contained in:
NuklearRabbit
2026-07-26 00:42:17 +02:00
parent 971896a1d5
commit 4ad698c4eb
47 changed files with 9114 additions and 1648 deletions
+145 -4
View File
@@ -6,6 +6,8 @@ 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');
@@ -53,7 +55,7 @@ function register(channel, handler) {
});
}
function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh, updates, preflight, diagnostics, monitor }) {
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) => {
@@ -228,10 +230,46 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
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());
@@ -271,6 +309,7 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
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();
@@ -308,14 +347,40 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
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)); });
@@ -370,6 +435,8 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
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);
@@ -378,6 +445,75 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
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 });
@@ -394,11 +530,16 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
if (profile?.provider === 'ssh-unraid') return unraid.preflight({ repository: current, profileId });
return preflight.runDeployment({ repository: current, profileId });
});
register('deployment:dispatch', async ({ repository, profileId, sha }) => {
register('deployment:dispatch', async ({ repository, profileId, sha, note = '', override = false, overrideReason = '' }) => {
const current = await resolveRepository(repository);
const profile = store.getDeploymentProfile(current.fullName, profileId);
if (profile?.provider === 'ssh-unraid') return unraid.deploy({ repository: current, profileId, sha });
return deployments.deploy({ repository: current, profileId, sha });
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);