'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, assertRepositoryRelativePath, assertRepositoryRelativePaths } = require('../shared/validation.cjs'); const DEFAULT_CONFIG = { schemaVersion: 13, setupComplete: false, appearance: 'dark', gitea: { baseUrl: '', user: null, encryptedToken: null }, workspaceRoots: [], repositoryMappings: {}, deploymentProfiles: {}, deploymentStates: {}, inventoryReviewDecisions: {}, gitValidator: { policies: {}, suppressions: {}, trends: {} }, 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, 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: [] }; 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); this.saveQueue = Promise.resolve(); } 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 : {}, inventoryReviewDecisions: source.inventoryReviewDecisions && typeof source.inventoryReviewDecisions === 'object' ? structuredClone(source.inventoryReviewDecisions) : {}, gitValidator: { policies: source.gitValidator?.policies && typeof source.gitValidator.policies === 'object' ? structuredClone(source.gitValidator.policies) : {}, suppressions: source.gitValidator?.suppressions && typeof source.gitValidator.suppressions === 'object' ? structuredClone(source.gitValidator.suppressions) : {}, trends: source.gitValidator?.trends && typeof source.gitValidator.trends === 'object' ? structuredClone(source.gitValidator.trends) : {} }, deploymentProfiles: source.deploymentProfiles && typeof source.deploymentProfiles === 'object' ? Object.fromEntries(Object.entries(source.deploymentProfiles).map(([key, profiles]) => [key, (Array.isArray(profiles) ? profiles : []).map((profile) => { if (!profile || typeof profile !== 'object' || profile.provider !== 'ssh-unraid') return profile; const iconUrl = String(profile.iconUrl || '').trim(); const iconFilePath = String(profile.iconFilePath || '').trim(); const requestedMode = String(profile.iconMode || '').trim(); const iconMode = ['builtin', 'upload', 'url', 'none'].includes(requestedMode) ? requestedMode : iconFilePath ? 'upload' : iconUrl && !/itworx\.tech\/assets\/itworx-icon\.png/i.test(iconUrl) ? 'url' : 'builtin'; const visibleName = String(profile.containerName || profile.remoteFolder || '').trim(); const internalService = String(profile.composeService || profile.remoteFolder || 'app').trim().toLowerCase().replace(/[^a-z0-9._-]/g, '-') || 'app'; const requestedDeploymentMode = String(profile.deploymentMode || '').trim(); const deploymentMode = ['push-bundle', 'server-git', 'monitor-only'].includes(requestedDeploymentMode) ? requestedDeploymentMode : 'push-bundle'; const composeFiles = uniqueStrings(profile.composeFiles || [profile.composeFile || 'docker-compose.yml']); const composeServices = uniqueStrings(profile.composeServices || [internalService]).map((value) => value.toLowerCase()); return { ...profile, deploymentMode, composeFile: composeFiles[0] || 'docker-compose.yml', composeFiles: composeFiles.length ? composeFiles : ['docker-compose.yml'], composeServices, composeProject: String(profile.composeProject || '').trim(), composeWorkingDir: String(profile.composeWorkingDir || '').trim(), composeService: internalService, containerName: visibleName || internalService, iconMode, manageDockerMan: profile.manageDockerMan === true, forceRecreate: profile.forceRecreate === true, removeOrphans: profile.removeOrphans === true, workloadIdentity: profile.workloadIdentity && typeof profile.workloadIdentity === 'object' ? structuredClone(profile.workloadIdentity) : null }; })])) : {}, deploymentStates: source.deploymentStates && typeof source.deploymentStates === 'object' ? source.deploymentStates : {}, favorites: [...new Set(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'); 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; await this.save(); } return this.getPublicState(); } async save() { 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; } getGitValidatorState(fullName) { const key = String(fullName || '').toLowerCase(); return { policy: structuredClone(this.data.gitValidator.policies[key] || { id: 'standard' }), suppressions: structuredClone(this.data.gitValidator.suppressions[key] || []), trends: structuredClone(this.data.gitValidator.trends[key] || []) }; } async setGitValidatorPolicy(fullName, policy) { const key = String(fullName || '').toLowerCase(); this.data.gitValidator.policies[key] = structuredClone(policy); await this.save(); return this.getGitValidatorState(key); } async addGitValidatorSuppression(fullName, suppression) { const key = String(fullName || '').toLowerCase(); this.data.gitValidator.suppressions[key] = [...(this.data.gitValidator.suppressions[key] || []), structuredClone(suppression)].slice(-250); await this.save(); return this.getGitValidatorState(key); } async appendGitValidatorTrend(fullName, trend) { const key = String(fullName || '').toLowerCase(); this.data.gitValidator.trends[key] = [...(this.data.gitValidator.trends[key] || []), structuredClone(trend)].slice(-100); await this.save(); return this.getGitValidatorState(key); } async createRecoverySnapshot(reason = 'configuration-change') { await this.saveQueue.catch(() => {}); const safeReason = String(reason || 'configuration-change').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'configuration-change'; const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const snapshotDirectory = path.join(path.dirname(this.filePath), 'snapshots'); const snapshotPath = path.join(snapshotDirectory, `${timestamp}-${safeReason}.json`); await fs.mkdir(snapshotDirectory, { recursive: true }); await fs.writeFile(snapshotPath, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600, flag: 'wx' }); try { await fs.chmod(snapshotDirectory, 0o700); } catch {} try { await fs.chmod(snapshotPath, 0o600); } catch {} return { filePath: snapshotPath, reason: safeReason, createdAt: new Date().toISOString() }; } 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 || 'password'); 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(); const scanRoots = uniqueStrings(source.scanRoots || existing?.scanRoots || [basePath]).map((value) => value.replace(/\/+$/, '')).filter((value) => value.startsWith('/') && !/[\r\n\0]/.test(value)); const scanExcludes = uniqueStrings(source.scanExcludes || existing?.scanExcludes || ['backups', 'archives', 'releases', 'staging', 'testdata']).filter((value) => /^[a-zA-Z0-9._*-]+$/.test(value)); return { id: source.id || existing?.id || crypto.randomUUID(), name, host, port, username, authType, basePath, scanRoots: scanRoots.length ? scanRoots : [basePath], scanExcludes, 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); const removedProfileIds = new Set(); for (const [key, profiles] of Object.entries(this.data.deploymentProfiles)) { for (const profile of profiles) if (profile.serverId === serverId) removedProfileIds.add(profile.id); this.data.deploymentProfiles[key] = profiles.filter((profile) => profile.serverId !== serverId); if (!this.data.deploymentProfiles[key].length) delete this.data.deploymentProfiles[key]; } for (const profileId of removedProfileIds) delete this.data.deploymentStates[profileId]; 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 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 = { ...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, 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') { const remoteFolder = assertRepositoryRelativePath(String(profile.remoteFolder || '').trim()); if (!remoteFolder || remoteFolder === '.' || remoteFolder.split('/').some((part) => !part || part === '.')) throw new Error('Remote folder must be a safe path relative to the configured server base path.'); const preservePaths = assertRepositoryRelativePaths(uniqueStrings(profile.preservePaths || ['.env', 'appdata', 'data', 'logs', 'config', 'compose.override.yml'])); const composeFiles = assertRepositoryRelativePaths(uniqueStrings(profile.composeFiles || [profile.composeFile || 'docker-compose.yml'])); if (!composeFiles.length && profile.generatedCompose !== true) throw new Error('Select at least one Compose file.'); const composeService = (() => { const value = String(profile.composeService || profile.composeServices?.[0] || remoteFolder.split('/').pop()).trim().toLowerCase(); if (!/^[a-z0-9._-]+$/.test(value)) throw new Error('Compose service must be lowercase and contain only letters, numbers, dots, underscores and dashes.'); return value; })(); const composeServices = uniqueStrings(profile.composeServices || [composeService]).map((value) => { const normalized = String(value).trim().toLowerCase(); if (!/^[a-z0-9._-]+$/.test(normalized)) throw new Error('Compose services must be lowercase and contain only letters, numbers, dots, underscores and dashes.'); return normalized; }); const composeProject = String(profile.composeProject || '').trim(); if (composeProject && !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(composeProject)) throw new Error('Compose project name contains unsupported characters.'); const composeWorkingDir = String(profile.composeWorkingDir || '').trim(); if (composeWorkingDir && (!composeWorkingDir.startsWith('/') || /[\r\n\0]/.test(composeWorkingDir))) throw new Error('Compose working directory must be an absolute safe Unix path.'); const deploymentMode = ['push-bundle', 'server-git', 'monitor-only'].includes(profile.deploymentMode) ? profile.deploymentMode : 'push-bundle'; return { ...common, serverId: String(profile.serverId || '').trim(), remoteFolder, deploymentMode, composeFile: composeFiles[0] || 'docker-compose.yml', composeFiles: composeFiles.length ? composeFiles : ['docker-compose.yml'], composeProject, composeWorkingDir, composeService, composeServices, containerName: (() => { const value = String(profile.containerName || remoteFolder.split('/').pop()).trim(); if (!/^[A-Za-z0-9._-]+$/.test(value)) throw new Error('Container name must contain only letters, numbers, dots, underscores and dashes.'); return value; })(), 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', allowUnraidTemplate: true }), iconMode: ['builtin', 'upload', 'url', 'none'].includes(profile.iconMode) ? profile.iconMode : profile.iconFilePath ? 'upload' : profile.iconUrl ? 'url' : 'builtin', iconUrl: assertHttpUrl(profile.iconUrl, { optional: true, label: 'Icon URL' }), iconFilePath: String(profile.iconFilePath || '').trim(), dockerShell: ['/bin/sh', '/bin/bash'].includes(profile.dockerShell) ? profile.dockerShell : '/bin/sh', preservePaths, generatedCompose: profile.generatedCompose === true, adoptedFromServer: profile.adoptedFromServer === true, serverSourceOfTruth: profile.serverSourceOfTruth === true, manageDockerMan: profile.manageDockerMan === true, forceRecreate: profile.forceRecreate === true, removeOrphans: profile.removeOrphans === true, workloadIdentity: profile.workloadIdentity && typeof profile.workloadIdentity === 'object' ? structuredClone(profile.workloadIdentity) : null, serverGitAccess: profile.serverGitAccess && typeof profile.serverGitAccess === 'object' ? { configured: profile.serverGitAccess.configured === true, deployKeyId: Number.isFinite(Number(profile.serverGitAccess.deployKeyId)) ? Number(profile.serverGitAccess.deployKeyId) : null, keyFingerprint: String(profile.serverGitAccess.keyFingerprint || '').trim().slice(0, 200) || null, hostFingerprint: String(profile.serverGitAccess.hostFingerprint || '').trim().slice(0, 200) || null, configuredAt: profile.serverGitAccess.configuredAt || null } : null, 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' }); 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; } getInventoryReviewDecisions(serverId) { return structuredClone(this.data.inventoryReviewDecisions[String(serverId || '')] || []); } async saveInventoryReviewDecision(serverId, decision) { const key = String(serverId || ''); if (!key || !decision?.workloadId || !/^[0-9a-f]{64}$/i.test(String(decision.evidenceHash || ''))) throw new Error('A server, workload and evidence hash are required for an inventory review decision.'); const decisions = this.getInventoryReviewDecisions(key).filter((item) => item.workloadId !== decision.workloadId); decisions.push(structuredClone(decision)); this.data.inventoryReviewDecisions[key] = decisions; await this.save(); return structuredClone(decision); } async deleteInventoryReviewDecision(serverId, workloadId) { const key = String(serverId || ''); this.data.inventoryReviewDecisions[key] = this.getInventoryReviewDecisions(key).filter((item) => item.workloadId !== workloadId); await this.save(); return this.getInventoryReviewDecisions(key); } 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); 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(); } 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), gitValidator: structuredClone(this.data.gitValidator), 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 };