Publish curated ForgeFlow source from 2ed1787c0b52
This commit is contained in:
commit
f60b269686
254 files changed
+46204
No files matched your search
@@ -0,0 +1,703 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { normalizeBaseUrl, assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePath, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
|
||||
|
||||
let cachedSafeStorage;
|
||||
|
||||
function getSafeStorage() {
|
||||
if (cachedSafeStorage !== undefined) return cachedSafeStorage;
|
||||
try {
|
||||
const electron = require('electron');
|
||||
cachedSafeStorage = electron && typeof electron === 'object' ? electron.safeStorage || null : null;
|
||||
} catch {
|
||||
cachedSafeStorage = null;
|
||||
}
|
||||
return cachedSafeStorage;
|
||||
}
|
||||
|
||||
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();
|
||||
this.pendingSave = null;
|
||||
this.lastWrittenSnapshot = null;
|
||||
}
|
||||
|
||||
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() {
|
||||
// Several callers persist in quick succession (a server scan writes deployment
|
||||
// state per workload). Serializing the configuration once per call is the
|
||||
// expensive part, so saves that are still queued share a single write of the
|
||||
// latest data. That is equivalent because every caller asks for "persist the
|
||||
// current configuration", not "persist the snapshot I saw".
|
||||
if (this.pendingSave) return this.pendingSave;
|
||||
const operation = async () => {
|
||||
this.pendingSave = null;
|
||||
const snapshot = JSON.stringify(this.data, null, 2);
|
||||
if (snapshot === this.lastWrittenSnapshot
|
||||
&& await fs.access(this.filePath).then(() => true).catch(() => false)) return;
|
||||
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.lastWrittenSnapshot = snapshot;
|
||||
};
|
||||
this.pendingSave = this.saveQueue.then(operation, operation);
|
||||
this.saveQueue = this.pendingSave.catch(() => {});
|
||||
return this.pendingSave;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
const safeStorage = getSafeStorage();
|
||||
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 {
|
||||
const safeStorage = getSafeStorage();
|
||||
return safeStorage?.decryptString?.(Buffer.from(this.data.gitea.encryptedToken, 'base64')) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
encryptSecret(value) {
|
||||
const text = String(value || '');
|
||||
if (!text) return null;
|
||||
const safeStorage = getSafeStorage();
|
||||
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 {
|
||||
const safeStorage = getSafeStorage();
|
||||
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 credentialIdentityChanged = Boolean(existing && [
|
||||
['host', existing.host, host],
|
||||
['port', existing.port, port],
|
||||
['username', existing.username, username],
|
||||
['authType', existing.authType, authType],
|
||||
['privateKeyPath', existing.privateKeyPath, privateKeyPath]
|
||||
].some(([, previous, next]) => String(previous || '') !== String(next || '')));
|
||||
const hostFingerprint = credentialIdentityChanged
|
||||
? ''
|
||||
: 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: credentialIdentityChanged ? null : existing?.encryptedPassword || null,
|
||||
encryptedPassphrase: credentialIdentityChanged ? null : 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 nextBaseUrl = normalizeBaseUrl(baseUrl);
|
||||
const currentBaseUrl = this.data.gitea.baseUrl
|
||||
? normalizeBaseUrl(this.data.gitea.baseUrl)
|
||||
: '';
|
||||
if (!String(token || '').trim() && nextBaseUrl !== currentBaseUrl && this.getToken()) {
|
||||
const error = new Error('Enter a new Gitea token when changing the server address.');
|
||||
error.code = 'GITEA_TOKEN_ORIGIN_CHANGED';
|
||||
throw error;
|
||||
}
|
||||
const tokenState = this.setToken(token, { preserveExisting: true });
|
||||
this.data.gitea = {
|
||||
...this.data.gitea,
|
||||
baseUrl: nextBaseUrl,
|
||||
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);
|
||||
const fetchIntervalMinutes = Number(next.fetchIntervalMinutes);
|
||||
next.fetchIntervalMinutes = Number.isFinite(fetchIntervalMinutes)
|
||||
? Math.min(Math.max(fetchIntervalMinutes, 0), 240)
|
||||
: 10;
|
||||
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 };
|
||||
Reference in new issue
Block a user