Add advanced Git and deployment workflows, secure backups and auditing, live Gitea integration, desktop notifications, connection validation, and the premium responsive UX refresh.
520 lines
23 KiB
JavaScript
520 lines
23 KiB
JavaScript
'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: 8,
|
|
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,
|
|
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 : {},
|
|
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';
|
|
return { ...profile, composeService: internalService, containerName: visibleName || internalService, iconMode };
|
|
})]))
|
|
: {},
|
|
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');
|
|
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;
|
|
}
|
|
|
|
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 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 = 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: (() => {
|
|
const value = String(profile.composeService || remoteFolder).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;
|
|
})(),
|
|
containerName: (() => {
|
|
const value = String(profile.containerName || remoteFolder).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,
|
|
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;
|
|
}
|
|
|
|
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),
|
|
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 };
|