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:
+78
-10
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user