This commit is contained in:
NuklearRabbit
2026-07-24 20:29:23 +02:00
commit 66060348da
107 changed files with 14771 additions and 0 deletions
+424
View File
@@ -0,0 +1,424 @@
'use strict';
const fs = require('node:fs/promises');
const path = require('node:path');
const crypto = require('node:crypto');
const { safeStorage } = require('electron');
const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
const DEFAULT_CONFIG = {
schemaVersion: 5,
setupComplete: false,
appearance: 'dark',
gitea: { baseUrl: '', user: null, encryptedToken: null },
workspaceRoots: [],
repositoryMappings: {},
deploymentProfiles: {},
deploymentStates: {},
favorites: [],
updates: {
owner: 'Jens',
repo: 'ForgeFlow',
branch: 'main',
autoCheck: true,
lastCheckedAt: null
},
servers: [],
preferences: {
autoRefresh: true,
repositoryPollSeconds: 4,
operationPollSeconds: 5,
fetchIntervalMinutes: 10,
preferredCloneProtocol: 'https',
diagnosticsEnabled: true,
diagnosticLevel: 'info',
logRetentionDays: 14,
maxLogFileMb: 8
},
operations: []
};
function uniqueStrings(values) {
return [...new Set((Array.isArray(values) ? values : []).map((value) => String(value || '').trim()).filter(Boolean))];
}
class ConfigStore {
constructor(userDataPath) {
this.filePath = path.join(userDataPath, 'forgeflow-config.json');
this.sessionToken = null;
this.data = structuredClone(DEFAULT_CONFIG);
}
migrate(parsed) {
const source = parsed && typeof parsed === 'object' ? parsed : {};
return {
...structuredClone(DEFAULT_CONFIG),
...source,
schemaVersion: DEFAULT_CONFIG.schemaVersion,
gitea: { ...DEFAULT_CONFIG.gitea, ...(source.gitea || {}) },
workspaceRoots: uniqueStrings(source.workspaceRoots),
repositoryMappings: source.repositoryMappings && typeof source.repositoryMappings === 'object' ? source.repositoryMappings : {},
deploymentProfiles: source.deploymentProfiles && typeof source.deploymentProfiles === 'object' ? source.deploymentProfiles : {},
deploymentStates: source.deploymentStates && typeof source.deploymentStates === 'object' ? source.deploymentStates : {},
favorites: uniqueStrings(source.favorites).map((item) => item.toLowerCase()),
updates: { ...DEFAULT_CONFIG.updates, ...(source.updates || {}) },
servers: Array.isArray(source.servers) ? source.servers.filter((item) => item && typeof item === 'object') : [],
preferences: { ...DEFAULT_CONFIG.preferences, ...(source.preferences || {}) },
operations: Array.isArray(source.operations) ? source.operations.slice(0, 250).map((operation) => { const { runnerLog, ...safeOperation } = operation || {}; return safeOperation; }) : []
};
}
async load() {
try {
const raw = await fs.readFile(this.filePath, 'utf8');
this.data = this.migrate(JSON.parse(raw));
await this.save();
} catch (error) {
if (error.code !== 'ENOENT') throw error;
await this.save();
}
return this.getPublicState();
}
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 {}
}
setToken(token, { preserveExisting = false } = {}) {
const value = String(token || '').trim();
if (!value && preserveExisting && this.getToken()) return { persistent: Boolean(this.data.gitea.encryptedToken), preserved: true };
if (!value) {
this.data.gitea.encryptedToken = null;
this.sessionToken = null;
return { persistent: true, preserved: false };
}
if (safeStorage.isEncryptionAvailable()) {
this.data.gitea.encryptedToken = safeStorage.encryptString(value).toString('base64');
this.sessionToken = null;
return { persistent: true, preserved: false };
}
this.data.gitea.encryptedToken = null;
this.sessionToken = value;
return { persistent: false, preserved: false };
}
getToken() {
if (this.sessionToken) return this.sessionToken;
if (!this.data.gitea.encryptedToken) return '';
try {
return safeStorage.decryptString(Buffer.from(this.data.gitea.encryptedToken, 'base64'));
} catch {
return '';
}
}
encryptSecret(value) {
const text = String(value || '');
if (!text) return null;
if (!safeStorage.isEncryptionAvailable()) {
const error = new Error('Secure credential storage is unavailable. ForgeFlow will not persist server passwords or key passphrases.');
error.code = 'SECURE_STORAGE_UNAVAILABLE';
throw error;
}
return safeStorage.encryptString(text).toString('base64');
}
decryptSecret(value) {
if (!value) return '';
try { return safeStorage.decryptString(Buffer.from(value, 'base64')); }
catch { return ''; }
}
normalizeServer(server, existing = null) {
const source = server || {};
const name = String(source.name || existing?.name || 'Unraid').trim().slice(0, 100);
const host = String(source.host || existing?.host || '').trim();
if (!host || /[\s/@]/.test(host)) throw new Error('Enter a valid SSH hostname or IP address.');
const port = Math.min(Math.max(Number(source.port || existing?.port || 22), 1), 65535);
const username = String(source.username || existing?.username || '').trim();
if (!username || /[\s@]/.test(username)) throw new Error('Enter a valid SSH username.');
const authType = ['password', 'privateKey'].includes(source.authType) ? source.authType : (existing?.authType || 'privateKey');
const basePath = String(source.basePath || existing?.basePath || '/mnt/user/appdata').trim().replace(/\/+$/, '');
if (!basePath.startsWith('/') || /[\r\n\0]/.test(basePath)) throw new Error('The server base path must be an absolute Unix path.');
const privateKeyPath = String(source.privateKeyPath || existing?.privateKeyPath || '').trim();
const hostFingerprint = String(source.hostFingerprint || existing?.hostFingerprint || '').trim();
return {
id: source.id || existing?.id || crypto.randomUUID(),
name,
host,
port,
username,
authType,
basePath,
privateKeyPath,
hostFingerprint,
encryptedPassword: existing?.encryptedPassword || null,
encryptedPassphrase: existing?.encryptedPassphrase || null,
createdAt: existing?.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString()
};
}
async saveServer(server, secrets = {}) {
const existing = this.data.servers.find((item) => item.id === server?.id) || null;
const normalized = this.normalizeServer(server, existing);
if (Object.prototype.hasOwnProperty.call(secrets, 'password') && String(secrets.password || '')) {
normalized.encryptedPassword = this.encryptSecret(secrets.password);
}
if (Object.prototype.hasOwnProperty.call(secrets, 'passphrase') && String(secrets.passphrase || '')) {
normalized.encryptedPassphrase = this.encryptSecret(secrets.passphrase);
}
if (normalized.authType === 'password') {
normalized.privateKeyPath = '';
normalized.encryptedPassphrase = null;
} else {
normalized.encryptedPassword = null;
}
if (normalized.authType === 'password' && !normalized.encryptedPassword) throw new Error('A password is required for password authentication.');
if (normalized.authType === 'privateKey' && !normalized.privateKeyPath) throw new Error('Select a private key file.');
this.data.servers = [normalized, ...this.data.servers.filter((item) => item.id !== normalized.id)];
await this.save();
return this.getPublicServer(normalized);
}
async deleteServer(serverId) {
this.data.servers = this.data.servers.filter((item) => item.id !== serverId);
for (const [key, profiles] of Object.entries(this.data.deploymentProfiles)) {
this.data.deploymentProfiles[key] = profiles.filter((profile) => profile.serverId !== serverId);
if (!this.data.deploymentProfiles[key].length) delete this.data.deploymentProfiles[key];
}
await this.save();
}
getServer(serverId) {
return this.data.servers.find((item) => item.id === serverId) || null;
}
getServerCredentials(serverId) {
const server = this.getServer(serverId);
if (!server) throw new Error('The configured server no longer exists.');
return {
password: this.decryptSecret(server.encryptedPassword),
passphrase: this.decryptSecret(server.encryptedPassphrase)
};
}
getPublicServer(server) {
if (!server) return null;
const { encryptedPassword, encryptedPassphrase, ...publicServer } = server;
return {
...structuredClone(publicServer),
hasPassword: Boolean(encryptedPassword),
hasPassphrase: Boolean(encryptedPassphrase)
};
}
async setUpdatePreferences(updates) {
const next = { ...this.data.updates, ...(updates || {}) };
next.owner = String(next.owner || 'Jens').trim().slice(0, 100);
next.repo = String(next.repo || 'ForgeFlow').trim().slice(0, 100);
next.branch = assertBranchName(next.branch || 'main');
next.autoCheck = next.autoCheck !== false;
this.data.updates = next;
await this.save();
return this.getPublicState();
}
async patch(patch) {
this.data = this.migrate({ ...this.data, ...patch });
await this.save();
return this.getPublicState();
}
async updateGitea({ baseUrl, token, user }) {
const tokenState = this.setToken(token, { preserveExisting: true });
this.data.gitea = {
...this.data.gitea,
baseUrl,
user: user || this.data.gitea.user,
encryptedToken: this.data.gitea.encryptedToken
};
await this.save();
return tokenState;
}
async completeSetup({ baseUrl, token, user, workspaceRoots }) {
const tokenState = this.setToken(token);
this.data.setupComplete = true;
this.data.gitea = { baseUrl, user, encryptedToken: this.data.gitea.encryptedToken };
this.data.workspaceRoots = uniqueStrings(workspaceRoots);
await this.save();
return { state: this.getPublicState(), tokenState };
}
async saveMapping(fullName, localPath) {
this.data.repositoryMappings[String(fullName).toLowerCase()] = localPath;
await this.save();
}
async removeMapping(fullName) {
delete this.data.repositoryMappings[String(fullName).toLowerCase()];
await this.save();
}
async setFavorite(fullName, favorite) {
const key = String(fullName || '').toLowerCase();
const favorites = new Set(this.data.favorites || []);
if (favorite) favorites.add(key); else favorites.delete(key);
this.data.favorites = [...favorites];
await this.save();
return this.getPublicState();
}
normalizeDeploymentProfile(profile) {
const environment = assertEnvironmentName(profile.environment || 'production');
const provider = ['gitea-actions', 'ssh-unraid'].includes(profile.provider) ? profile.provider : 'gitea-actions';
const healthcheckUrl = assertHttpUrl(profile.healthcheckUrl, { optional: true, label: 'Healthcheck URL' });
const common = {
id: profile.id || crypto.randomUUID(),
name: String(profile.name || environment || 'Production').trim().slice(0, 100),
environment,
provider,
branch: assertBranchName(profile.branch || 'main'),
healthcheckUrl,
confirmationRequired: profile.confirmationRequired !== false,
inputs: {}
};
if (provider === 'ssh-unraid') {
const remoteFolder = String(profile.remoteFolder || '').trim();
if (!remoteFolder || !/^[a-zA-Z0-9._-]+$/.test(remoteFolder)) throw new Error('Remote folder must contain only letters, numbers, dots, underscores and dashes.');
const preservePaths = assertRepositoryRelativePaths(uniqueStrings(profile.preservePaths || ['.env', 'appdata', 'data', 'logs', 'config', 'compose.override.yml']));
return {
...common,
serverId: String(profile.serverId || '').trim(),
remoteFolder,
composeFile: String(profile.composeFile || 'docker-compose.yml').trim(),
composeService: String(profile.composeService || '').trim(),
cloneUrl: profile.cloneUrl ? assertCloneRemote(profile.cloneUrl) : '',
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' }),
iconUrl: assertHttpUrl(profile.iconUrl, { optional: true, label: 'Icon URL' }),
preservePaths,
generatedCompose: profile.generatedCompose === true
};
}
const statusUrl = assertHttpUrl(profile.statusUrl, { label: 'Application status URL' });
return {
...common,
workflowFile: assertWorkflowFileName(profile.workflowFile || 'deploy.yml'),
rollbackWorkflowFile: profile.rollbackWorkflowFile ? assertWorkflowFileName(profile.rollbackWorkflowFile) : '',
statusUrl
};
}
async saveDeploymentProfile(fullName, profile) {
const key = String(fullName).toLowerCase();
const profiles = Array.isArray(this.data.deploymentProfiles[key]) ? this.data.deploymentProfiles[key] : [];
const normalized = this.normalizeDeploymentProfile(profile || {});
const next = profiles.filter((item) => item.id !== normalized.id);
next.push(normalized);
this.data.deploymentProfiles[key] = next;
await this.save();
return normalized;
}
async deleteDeploymentProfile(fullName, profileId) {
const key = String(fullName || '').toLowerCase();
const profiles = Array.isArray(this.data.deploymentProfiles[key]) ? this.data.deploymentProfiles[key] : [];
const next = profiles.filter((item) => item.id !== profileId);
if (next.length) this.data.deploymentProfiles[key] = next;
else delete this.data.deploymentProfiles[key];
delete this.data.deploymentStates[profileId];
await this.save();
return next;
}
getDeploymentProfiles(fullName) {
return structuredClone(this.data.deploymentProfiles[String(fullName || '').toLowerCase()] || []);
}
getDeploymentProfile(fullName, profileId) {
return this.getDeploymentProfiles(fullName).find((item) => item.id === profileId) || null;
}
async saveDeploymentState(profileId, state) {
this.data.deploymentStates[profileId] = {
...(this.data.deploymentStates[profileId] || {}),
...state,
checkedAt: state.checkedAt || new Date().toISOString()
};
await this.save();
return structuredClone(this.data.deploymentStates[profileId]);
}
getDeploymentState(profileId) {
return structuredClone(this.data.deploymentStates[profileId] || null);
}
async addOperation(operation) {
const existing = this.data.operations.find((item) => item.id === operation.id);
const normalized = {
id: operation.id || crypto.randomUUID(),
createdAt: existing?.createdAt || operation.createdAt || new Date().toISOString(),
...existing,
...operation,
updatedAt: new Date().toISOString()
};
this.data.operations = [normalized, ...this.data.operations.filter((item) => item.id !== normalized.id)].slice(0, 250);
await this.save();
return structuredClone(normalized);
}
getOperation(operationId) {
return structuredClone(this.data.operations.find((item) => item.id === operationId) || null);
}
async setPreferences(preferences) {
const next = { ...this.data.preferences, ...(preferences || {}) };
next.repositoryPollSeconds = Math.min(Math.max(Number(next.repositoryPollSeconds) || 4, 2), 60);
next.operationPollSeconds = Math.min(Math.max(Number(next.operationPollSeconds) || 5, 3), 120);
next.fetchIntervalMinutes = Math.min(Math.max(Number(next.fetchIntervalMinutes) || 10, 0), 240);
next.autoRefresh = next.autoRefresh !== false;
next.preferredCloneProtocol = ['https', 'ssh'].includes(next.preferredCloneProtocol) ? next.preferredCloneProtocol : 'https';
next.diagnosticsEnabled = next.diagnosticsEnabled !== false;
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);
this.data.preferences = next;
await this.save();
return this.getPublicState();
}
getPublicState() {
return {
schemaVersion: this.data.schemaVersion,
setupComplete: this.data.setupComplete,
appearance: this.data.appearance,
gitea: {
baseUrl: this.data.gitea.baseUrl,
user: this.data.gitea.user,
hasToken: Boolean(this.getToken())
},
workspaceRoots: [...this.data.workspaceRoots],
repositoryMappings: { ...this.data.repositoryMappings },
deploymentProfiles: structuredClone(this.data.deploymentProfiles),
deploymentStates: structuredClone(this.data.deploymentStates),
favorites: [...this.data.favorites],
updates: { ...this.data.updates },
servers: this.data.servers.map((server) => this.getPublicServer(server)),
preferences: { ...this.data.preferences },
operations: structuredClone(this.data.operations)
};
}
}
module.exports = { ConfigStore, DEFAULT_CONFIG };