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
+78 -10
View File
@@ -7,7 +7,7 @@ const { safeStorage } = require('electron');
const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
const DEFAULT_CONFIG = {
schemaVersion: 7,
schemaVersion: 8,
setupComplete: false,
appearance: 'dark',
gitea: { baseUrl: '', user: null, encryptedToken: null },
@@ -33,7 +33,13 @@ const DEFAULT_CONFIG = {
diagnosticsEnabled: true,
diagnosticLevel: 'info',
logRetentionDays: 14,
maxLogFileMb: 8
maxLogFileMb: 8,
editor: { executable: 'code', args: ['--reuse-window', '--goto', '{file}:{line}'] },
terminal: { executable: 'wt.exe', args: ['-d', '{path}'] },
notificationsEnabled: true,
trayEnabled: true,
closeToTray: false,
startAtLogin: false
},
operations: []
};
@@ -47,6 +53,7 @@ class ConfigStore {
this.filePath = path.join(userDataPath, 'forgeflow-config.json');
this.sessionToken = null;
this.data = structuredClone(DEFAULT_CONFIG);
this.saveQueue = Promise.resolve();
}
migrate(parsed) {
@@ -84,7 +91,15 @@ class ConfigStore {
async load() {
try {
const raw = await fs.readFile(this.filePath, 'utf8');
this.data = this.migrate(JSON.parse(raw));
try {
this.data = this.migrate(JSON.parse(raw));
} catch (parseError) {
const suffix = new Date().toISOString().replace(/[:.]/g, '-');
const recoveryPath = `${this.filePath}.corrupt-${suffix}`;
await fs.rename(this.filePath, recoveryPath).catch(async () => fs.writeFile(recoveryPath, raw, { mode: 0o600 }));
this.data = structuredClone(DEFAULT_CONFIG);
console.error(`ForgeFlow recovered a malformed configuration file to ${recoveryPath}.`, parseError);
}
await this.save();
} catch (error) {
if (error.code !== 'ENOENT') throw error;
@@ -94,11 +109,16 @@ class ConfigStore {
}
async save() {
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
const temporary = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
await fs.writeFile(temporary, JSON.stringify(this.data, null, 2), { mode: 0o600 });
await fs.rename(temporary, this.filePath);
try { await fs.chmod(this.filePath, 0o600); } catch {}
const snapshot = JSON.stringify(this.data, null, 2);
const operation = async () => {
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
const temporary = `${this.filePath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`;
await fs.writeFile(temporary, snapshot, { mode: 0o600 });
await fs.rename(temporary, this.filePath);
try { await fs.chmod(this.filePath, 0o600); } catch {}
};
this.saveQueue = this.saveQueue.then(operation, operation);
return this.saveQueue;
}
setToken(token, { preserveExisting = false } = {}) {
@@ -250,6 +270,28 @@ class ConfigStore {
return this.getPublicState();
}
async restoreConfiguration(configuration) {
const restored = this.migrate(configuration);
restored.gitea.encryptedToken = String(restored.gitea.baseUrl || '').replace(/\/+$/, '').toLowerCase() === String(this.data.gitea.baseUrl || '').replace(/\/+$/, '').toLowerCase()
? this.data.gitea.encryptedToken
: null;
const existingServers = new Map(this.data.servers.map((server) => [server.id, server]));
restored.servers = restored.servers.map((server) => {
const existing = existingServers.get(server.id);
const sameCredentialTarget = existing
&& ['host', 'port', 'username', 'authType', 'privateKeyPath'].every((key) => String(existing[key] || '') === String(server[key] || ''));
return {
...server,
encryptedPassword: sameCredentialTarget ? existing.encryptedPassword || null : null,
encryptedPassphrase: sameCredentialTarget ? existing.encryptedPassphrase || null : null
};
});
restored.operations = this.data.operations;
this.data = restored;
await this.save();
return this.getPublicState();
}
async updateGitea({ baseUrl, token, user }) {
const tokenState = this.setToken(token, { preserveExisting: true });
this.data.gitea = {
@@ -302,6 +344,16 @@ class ConfigStore {
branch: assertBranchName(profile.branch || 'main'),
healthcheckUrl,
confirmationRequired: profile.confirmationRequired !== false,
deploymentPolicy: {
frozen: profile.deploymentPolicy?.frozen === true,
freezeReason: String(profile.deploymentPolicy?.freezeReason || '').trim().slice(0, 500),
requireNote: profile.deploymentPolicy?.requireNote === true,
maintenanceWindows: (Array.isArray(profile.deploymentPolicy?.maintenanceWindows) ? profile.deploymentPolicy.maintenanceWindows : []).slice(0, 20).map((window) => ({
days: [...new Set((Array.isArray(window?.days) ? window.days : []).map(Number).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6))],
start: String(window?.start || '00:00'),
end: String(window?.end || '23:59')
}))
},
inputs: {}
};
if (provider === 'ssh-unraid') {
@@ -327,7 +379,7 @@ class ConfigStore {
alignRemote: profile.alignRemote === true,
hostPort: profile.hostPort ? Math.min(Math.max(Number(profile.hostPort), 1), 65535) : null,
containerPort: profile.containerPort ? Math.min(Math.max(Number(profile.containerPort), 1), 65535) : null,
webUiUrl: assertHttpUrl(profile.webUiUrl, { optional: true, label: 'Web UI URL' }),
webUiUrl: assertHttpUrl(profile.webUiUrl, { optional: true, label: 'Web UI URL', allowUnraidTemplate: true }),
iconMode: ['builtin', 'upload', 'url', 'none'].includes(profile.iconMode)
? profile.iconMode
: profile.iconFilePath ? 'upload' : profile.iconUrl ? 'url' : 'builtin',
@@ -335,7 +387,13 @@ class ConfigStore {
iconFilePath: String(profile.iconFilePath || '').trim(),
dockerShell: ['/bin/sh', '/bin/bash'].includes(profile.dockerShell) ? profile.dockerShell : '/bin/sh',
preservePaths,
generatedCompose: profile.generatedCompose === true
generatedCompose: profile.generatedCompose === true,
adoptedFromServer: profile.adoptedFromServer === true,
serverSourceOfTruth: profile.serverSourceOfTruth === true,
detectedAt: profile.detectedAt || null,
provenance: profile.provenance && typeof profile.provenance === 'object' ? structuredClone(profile.provenance) : {},
detectedMetadata: profile.detectedMetadata && typeof profile.detectedMetadata === 'object' ? structuredClone(profile.detectedMetadata) : {},
serverIconReference: String(profile.serverIconReference || '').trim()
};
}
const statusUrl = assertHttpUrl(profile.statusUrl, { label: 'Application status URL' });
@@ -420,6 +478,16 @@ class ConfigStore {
next.diagnosticLevel = ['debug', 'info', 'warning', 'error'].includes(next.diagnosticLevel) ? next.diagnosticLevel : 'info';
next.logRetentionDays = Math.min(Math.max(Number(next.logRetentionDays) || 14, 1), 90);
next.maxLogFileMb = Math.min(Math.max(Number(next.maxLogFileMb) || 8, 1), 50);
const normalizeTool = (tool, fallback) => ({
executable: String(tool?.executable || fallback.executable).trim().slice(0, 500),
args: (Array.isArray(tool?.args) ? tool.args : fallback.args).map((item) => String(item).slice(0, 500)).slice(0, 20)
});
next.editor = normalizeTool(next.editor, DEFAULT_CONFIG.preferences.editor);
next.terminal = normalizeTool(next.terminal, DEFAULT_CONFIG.preferences.terminal);
next.notificationsEnabled = next.notificationsEnabled !== false;
next.trayEnabled = next.trayEnabled !== false;
next.closeToTray = next.closeToTray === true;
next.startAtLogin = next.startAtLogin === true;
this.data.preferences = next;
await this.save();
return this.getPublicState();