Release ForgeFlow 0.8.1

Add advanced Git and deployment workflows, secure backups and auditing, live Gitea integration, desktop notifications, connection validation, and the premium responsive UX refresh.
This commit is contained in:
NuklearRabbit
2026-07-26 00:42:17 +02:00
parent 971896a1d5
commit 4ad698c4eb
47 changed files with 9114 additions and 1648 deletions
+57
View File
@@ -0,0 +1,57 @@
'use strict';
const fs = require('node:fs/promises');
const path = require('node:path');
const crypto = require('node:crypto');
class AuditService {
constructor({ userDataPath, appInfo = {} }) {
this.filePath = path.join(userDataPath, 'audit', 'forgeflow-audit.jsonl');
this.appInfo = appInfo;
this.queue = Promise.resolve();
}
async initialize() {
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
try { await fs.chmod(path.dirname(this.filePath), 0o700); } catch {}
}
append(event, details = {}) {
const entry = {
id: crypto.randomUUID(),
timestamp: new Date().toISOString(),
event: String(event || 'unknown').slice(0, 120),
appVersion: this.appInfo.version || null,
details: structuredClone(details || {})
};
const operation = async () => {
await this.initialize();
await fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`, { encoding: 'utf8', mode: 0o600 });
try { await fs.chmod(this.filePath, 0o600); } catch {}
return entry;
};
this.queue = this.queue.then(operation, operation);
return this.queue;
}
async list(limit = 250) {
await this.queue.catch(() => {});
const text = await fs.readFile(this.filePath, 'utf8').catch((error) => error.code === 'ENOENT' ? '' : Promise.reject(error));
return text.split(/\r?\n/).filter(Boolean).slice(-Math.min(Math.max(Number(limit) || 250, 1), 5000)).reverse().map((line) => JSON.parse(line));
}
async exportTo(destinationPath, format = 'json') {
const entries = await this.list(5000);
if (format === 'csv') {
const quote = (value) => `"${String(value ?? '').replace(/"/g, '""')}"`;
const rows = [['timestamp', 'event', 'repository', 'profile', 'sha', 'result', 'note'].map(quote).join(',')];
for (const item of [...entries].reverse()) rows.push([item.timestamp, item.event, item.details?.repository, item.details?.profileId, item.details?.sha, item.details?.result, item.details?.note].map(quote).join(','));
await fs.writeFile(destinationPath, `${rows.join('\r\n')}\r\n`, { mode: 0o600 });
} else {
await fs.writeFile(destinationPath, JSON.stringify({ format: 'forgeflow-audit', version: 1, entries: [...entries].reverse() }, null, 2), { mode: 0o600 });
}
return { filePath: destinationPath, count: entries.length };
}
}
module.exports = { AuditService };
+78 -10
View File
@@ -7,7 +7,7 @@ const { safeStorage } = require('electron');
const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
const DEFAULT_CONFIG = {
schemaVersion: 7,
schemaVersion: 8,
setupComplete: false,
appearance: 'dark',
gitea: { baseUrl: '', user: null, encryptedToken: null },
@@ -33,7 +33,13 @@ const DEFAULT_CONFIG = {
diagnosticsEnabled: true,
diagnosticLevel: 'info',
logRetentionDays: 14,
maxLogFileMb: 8
maxLogFileMb: 8,
editor: { executable: 'code', args: ['--reuse-window', '--goto', '{file}:{line}'] },
terminal: { executable: 'wt.exe', args: ['-d', '{path}'] },
notificationsEnabled: true,
trayEnabled: true,
closeToTray: false,
startAtLogin: false
},
operations: []
};
@@ -47,6 +53,7 @@ class ConfigStore {
this.filePath = path.join(userDataPath, 'forgeflow-config.json');
this.sessionToken = null;
this.data = structuredClone(DEFAULT_CONFIG);
this.saveQueue = Promise.resolve();
}
migrate(parsed) {
@@ -84,7 +91,15 @@ class ConfigStore {
async load() {
try {
const raw = await fs.readFile(this.filePath, 'utf8');
this.data = this.migrate(JSON.parse(raw));
try {
this.data = this.migrate(JSON.parse(raw));
} catch (parseError) {
const suffix = new Date().toISOString().replace(/[:.]/g, '-');
const recoveryPath = `${this.filePath}.corrupt-${suffix}`;
await fs.rename(this.filePath, recoveryPath).catch(async () => fs.writeFile(recoveryPath, raw, { mode: 0o600 }));
this.data = structuredClone(DEFAULT_CONFIG);
console.error(`ForgeFlow recovered a malformed configuration file to ${recoveryPath}.`, parseError);
}
await this.save();
} catch (error) {
if (error.code !== 'ENOENT') throw error;
@@ -94,11 +109,16 @@ class ConfigStore {
}
async save() {
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
const temporary = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
await fs.writeFile(temporary, JSON.stringify(this.data, null, 2), { mode: 0o600 });
await fs.rename(temporary, this.filePath);
try { await fs.chmod(this.filePath, 0o600); } catch {}
const snapshot = JSON.stringify(this.data, null, 2);
const operation = async () => {
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
const temporary = `${this.filePath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`;
await fs.writeFile(temporary, snapshot, { mode: 0o600 });
await fs.rename(temporary, this.filePath);
try { await fs.chmod(this.filePath, 0o600); } catch {}
};
this.saveQueue = this.saveQueue.then(operation, operation);
return this.saveQueue;
}
setToken(token, { preserveExisting = false } = {}) {
@@ -250,6 +270,28 @@ class ConfigStore {
return this.getPublicState();
}
async restoreConfiguration(configuration) {
const restored = this.migrate(configuration);
restored.gitea.encryptedToken = String(restored.gitea.baseUrl || '').replace(/\/+$/, '').toLowerCase() === String(this.data.gitea.baseUrl || '').replace(/\/+$/, '').toLowerCase()
? this.data.gitea.encryptedToken
: null;
const existingServers = new Map(this.data.servers.map((server) => [server.id, server]));
restored.servers = restored.servers.map((server) => {
const existing = existingServers.get(server.id);
const sameCredentialTarget = existing
&& ['host', 'port', 'username', 'authType', 'privateKeyPath'].every((key) => String(existing[key] || '') === String(server[key] || ''));
return {
...server,
encryptedPassword: sameCredentialTarget ? existing.encryptedPassword || null : null,
encryptedPassphrase: sameCredentialTarget ? existing.encryptedPassphrase || null : null
};
});
restored.operations = this.data.operations;
this.data = restored;
await this.save();
return this.getPublicState();
}
async updateGitea({ baseUrl, token, user }) {
const tokenState = this.setToken(token, { preserveExisting: true });
this.data.gitea = {
@@ -302,6 +344,16 @@ class ConfigStore {
branch: assertBranchName(profile.branch || 'main'),
healthcheckUrl,
confirmationRequired: profile.confirmationRequired !== false,
deploymentPolicy: {
frozen: profile.deploymentPolicy?.frozen === true,
freezeReason: String(profile.deploymentPolicy?.freezeReason || '').trim().slice(0, 500),
requireNote: profile.deploymentPolicy?.requireNote === true,
maintenanceWindows: (Array.isArray(profile.deploymentPolicy?.maintenanceWindows) ? profile.deploymentPolicy.maintenanceWindows : []).slice(0, 20).map((window) => ({
days: [...new Set((Array.isArray(window?.days) ? window.days : []).map(Number).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6))],
start: String(window?.start || '00:00'),
end: String(window?.end || '23:59')
}))
},
inputs: {}
};
if (provider === 'ssh-unraid') {
@@ -327,7 +379,7 @@ class ConfigStore {
alignRemote: profile.alignRemote === true,
hostPort: profile.hostPort ? Math.min(Math.max(Number(profile.hostPort), 1), 65535) : null,
containerPort: profile.containerPort ? Math.min(Math.max(Number(profile.containerPort), 1), 65535) : null,
webUiUrl: assertHttpUrl(profile.webUiUrl, { optional: true, label: 'Web UI URL' }),
webUiUrl: assertHttpUrl(profile.webUiUrl, { optional: true, label: 'Web UI URL', allowUnraidTemplate: true }),
iconMode: ['builtin', 'upload', 'url', 'none'].includes(profile.iconMode)
? profile.iconMode
: profile.iconFilePath ? 'upload' : profile.iconUrl ? 'url' : 'builtin',
@@ -335,7 +387,13 @@ class ConfigStore {
iconFilePath: String(profile.iconFilePath || '').trim(),
dockerShell: ['/bin/sh', '/bin/bash'].includes(profile.dockerShell) ? profile.dockerShell : '/bin/sh',
preservePaths,
generatedCompose: profile.generatedCompose === true
generatedCompose: profile.generatedCompose === true,
adoptedFromServer: profile.adoptedFromServer === true,
serverSourceOfTruth: profile.serverSourceOfTruth === true,
detectedAt: profile.detectedAt || null,
provenance: profile.provenance && typeof profile.provenance === 'object' ? structuredClone(profile.provenance) : {},
detectedMetadata: profile.detectedMetadata && typeof profile.detectedMetadata === 'object' ? structuredClone(profile.detectedMetadata) : {},
serverIconReference: String(profile.serverIconReference || '').trim()
};
}
const statusUrl = assertHttpUrl(profile.statusUrl, { label: 'Application status URL' });
@@ -420,6 +478,16 @@ class ConfigStore {
next.diagnosticLevel = ['debug', 'info', 'warning', 'error'].includes(next.diagnosticLevel) ? next.diagnosticLevel : 'info';
next.logRetentionDays = Math.min(Math.max(Number(next.logRetentionDays) || 14, 1), 90);
next.maxLogFileMb = Math.min(Math.max(Number(next.maxLogFileMb) || 8, 1), 50);
const normalizeTool = (tool, fallback) => ({
executable: String(tool?.executable || fallback.executable).trim().slice(0, 500),
args: (Array.isArray(tool?.args) ? tool.args : fallback.args).map((item) => String(item).slice(0, 500)).slice(0, 20)
});
next.editor = normalizeTool(next.editor, DEFAULT_CONFIG.preferences.editor);
next.terminal = normalizeTool(next.terminal, DEFAULT_CONFIG.preferences.terminal);
next.notificationsEnabled = next.notificationsEnabled !== false;
next.trayEnabled = next.trayEnabled !== false;
next.closeToTray = next.closeToTray === true;
next.startAtLogin = next.startAtLogin === true;
this.data.preferences = next;
await this.save();
return this.getPublicState();
+62
View File
@@ -0,0 +1,62 @@
'use strict';
const crypto = require('node:crypto');
const FORMAT = 'forgeflow-config-backup';
const VERSION = 1;
function sanitizeConfiguration(data) {
const source = structuredClone(data || {});
if (source.gitea) source.gitea.encryptedToken = null;
source.servers = (source.servers || []).map(({ encryptedPassword, encryptedPassphrase, ...server }) => server);
source.operations = [];
return source;
}
function deriveKey(passphrase, salt) {
const secret = String(passphrase || '');
if (secret.length < 12) throw new Error('Backup passphrase must contain at least 12 characters.');
return crypto.scryptSync(secret, salt, 32, { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 });
}
function createEncryptedBackup(data, passphrase) {
const salt = crypto.randomBytes(16);
const iv = crypto.randomBytes(12);
const key = deriveKey(passphrase, salt);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const plaintext = Buffer.from(JSON.stringify({ exportedAt: new Date().toISOString(), configuration: sanitizeConfiguration(data) }), 'utf8');
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
return JSON.stringify({
format: FORMAT,
version: VERSION,
kdf: 'scrypt',
cipher: 'aes-256-gcm',
salt: salt.toString('base64'),
iv: iv.toString('base64'),
tag: cipher.getAuthTag().toString('base64'),
data: encrypted.toString('base64')
}, null, 2);
}
function readEncryptedBackup(serialized, passphrase) {
let envelope;
try { envelope = JSON.parse(String(serialized || '')); }
catch { throw new Error('The selected file is not a valid ForgeFlow backup.'); }
if (envelope.format !== FORMAT || envelope.version !== VERSION || envelope.kdf !== 'scrypt' || envelope.cipher !== 'aes-256-gcm') {
throw new Error('Unsupported ForgeFlow backup format or version.');
}
try {
const key = deriveKey(passphrase, Buffer.from(envelope.salt, 'base64'));
const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(envelope.iv, 'base64'));
decipher.setAuthTag(Buffer.from(envelope.tag, 'base64'));
const decoded = Buffer.concat([decipher.update(Buffer.from(envelope.data, 'base64')), decipher.final()]);
const payload = JSON.parse(decoded.toString('utf8'));
if (!payload.configuration || typeof payload.configuration !== 'object') throw new Error('Configuration payload is missing.');
return payload;
} catch (error) {
if (/passphrase|payload/i.test(error.message)) throw error;
throw new Error('The backup could not be decrypted. Check the passphrase and file integrity.');
}
}
module.exports = { FORMAT, VERSION, sanitizeConfiguration, createEncryptedBackup, readEncryptedBackup };
+38
View File
@@ -0,0 +1,38 @@
'use strict';
const { spawn } = require('node:child_process');
const path = require('node:path');
function normalizeTool(tool, defaults) {
const source = tool && typeof tool === 'object' ? tool : {};
const executable = String(source.executable || defaults.executable).trim();
if (!executable || /[\r\n\0]/.test(executable)) throw new Error('Tool executable is invalid.');
const args = (Array.isArray(source.args) ? source.args : defaults.args).map((item) => String(item)).slice(0, 20);
if (args.some((item) => /[\r\n\0]/.test(item))) throw new Error('Tool argument is invalid.');
return { executable, args };
}
function expandTool(tool, context) {
const values = { path: context.path, file: context.file || context.path, line: String(context.line || 1) };
return { executable: tool.executable, args: tool.args.map((argument) => argument.replace(/\{(path|file|line)\}/g, (_, key) => values[key])) };
}
class ExternalToolsService {
constructor(store) { this.store = store; }
launch(kind, repositoryPath, filePath = '', line = 1) {
const root = path.resolve(repositoryPath);
const candidate = filePath ? path.resolve(root, filePath) : root;
if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) throw new Error('External tool target escapes the repository.');
const defaults = kind === 'terminal'
? { executable: 'wt.exe', args: ['-d', '{path}'] }
: { executable: 'code', args: ['--reuse-window', '--goto', '{file}:{line}'] };
const configured = normalizeTool(this.store.data.preferences?.[kind], defaults);
const invocation = expandTool(configured, { path: root, file: candidate, line });
const child = spawn(invocation.executable, invocation.args, { cwd: root, detached: true, stdio: 'ignore', windowsHide: false, shell: false });
child.unref();
return { launched: true, executable: invocation.executable };
}
}
module.exports = { ExternalToolsService, normalizeTool, expandTool };
+117 -1
View File
@@ -16,6 +16,18 @@ const {
assertCloneRemote
} = require('../shared/validation.cjs');
function parseUnifiedDiff(diffText) {
const text = String(diffText || '').replace(/\r\n/g, '\n');
const firstHunk = text.search(/^@@ /m);
if (firstHunk < 0) return { header: text, hunks: [] };
const header = text.slice(0, firstHunk);
const hunks = text.slice(firstHunk).split(/(?=^@@ )/m).filter(Boolean).map((patch, index) => {
const heading = patch.split('\n', 1)[0];
return { index, heading, patch, additions: (patch.match(/^\+(?!\+\+)/gm) || []).length, deletions: (patch.match(/^-(?!---)/gm) || []).length };
});
return { header, hunks };
}
class GitService {
async isAvailable() {
try {
@@ -195,6 +207,40 @@ class GitService {
};
}
async abortInterruptedOperation(repoPath) {
const root = await this.ensureRepository(repoPath);
const gitDirResult = await run('git', ['rev-parse', '--git-dir'], { cwd: root, timeout: 30_000 });
const gitDir = path.resolve(root, gitDirResult.stdout.trim());
const exists = async (name) => fs.access(path.join(gitDir, name)).then(() => true).catch(() => false);
let aborted = null;
if (await exists('rebase-merge') || await exists('rebase-apply')) {
await run('git', ['rebase', '--abort'], { cwd: root, timeout: 120_000 });
aborted = 'rebase';
} else if (await exists('MERGE_HEAD')) {
await run('git', ['merge', '--abort'], { cwd: root, timeout: 120_000 });
aborted = 'merge';
} else if (await exists('CHERRY_PICK_HEAD')) {
await run('git', ['cherry-pick', '--abort'], { cwd: root, timeout: 120_000 });
aborted = 'cherry-pick';
} else if (await exists('REVERT_HEAD')) {
await run('git', ['revert', '--abort'], { cwd: root, timeout: 120_000 });
aborted = 'revert';
}
return { aborted, status: await this.status(root), lockReport: await this.listGitLocks(root) };
}
async detectInterruptedOperation(repoPath) {
const root = await this.ensureRepository(repoPath);
const gitDirResult = await run('git', ['rev-parse', '--git-dir'], { cwd: root, timeout: 30_000 });
const gitDir = path.resolve(root, gitDirResult.stdout.trim());
const exists = async (name) => fs.access(path.join(gitDir, name)).then(() => true).catch(() => false);
if (await exists('rebase-merge') || await exists('rebase-apply')) return 'rebase';
if (await exists('MERGE_HEAD')) return 'merge';
if (await exists('CHERRY_PICK_HEAD')) return 'cherry-pick';
if (await exists('REVERT_HEAD')) return 'revert';
return null;
}
async repairSync(repoPath, strategy) {
const root = await this.ensureRepository(repoPath);
const requested = String(strategy || '').trim();
@@ -252,6 +298,54 @@ class GitService {
return result.stdout;
}
async diffHunks(repoPath, filePath) {
const safeFile = assertRepositoryRelativePath(filePath);
const diff = await this.diff(repoPath, safeFile, false);
const parsed = parseUnifiedDiff(diff);
return { filePath: safeFile, partialSupported: parsed.hunks.length > 0, hunks: parsed.hunks.map(({ patch, ...hunk }) => ({ ...hunk, lines: patch.split('\n') })) };
}
async stageHunks(repoPath, filePath, hunkIndexes) {
const root = await this.ensureRepository(repoPath);
const safeFile = assertRepositoryRelativePath(filePath);
const indexes = [...new Set((Array.isArray(hunkIndexes) ? hunkIndexes : []).map(Number))];
if (!indexes.length || indexes.some((index) => !Number.isInteger(index) || index < 0)) throw new Error('Select at least one valid diff hunk.');
const parsed = parseUnifiedDiff(await this.diff(root, safeFile, false));
if (!parsed.hunks.length) throw new Error('Partial staging is unavailable for this file. Stage the complete file instead.');
if (indexes.some((index) => index >= parsed.hunks.length)) throw new Error('The file changed after its diff was loaded. Refresh the diff and try again.');
const patch = `${parsed.header}${indexes.map((index) => parsed.hunks[index].patch).join('')}`;
await run('git', ['apply', '--cached', '--whitespace=nowarn', '-'], { cwd: root, input: patch, timeout: 60_000, maxBuffer: 16 * 1024 * 1024 });
return this.status(root);
}
async conflictState(repoPath) {
const root = await this.ensureRepository(repoPath);
const operation = await this.detectInterruptedOperation(root);
const result = await run('git', ['diff', '--name-only', '--diff-filter=U', '-z'], { cwd: root, timeout: 30_000 });
const files = result.stdout.split('\0').filter(Boolean).map(assertRepositoryRelativePath);
return { operation, files, canContinue: Boolean(operation) && files.length === 0, status: await this.status(root) };
}
async resolveConflict(repoPath, filePath, resolution) {
const root = await this.ensureRepository(repoPath);
const safeFile = assertRepositoryRelativePath(filePath);
const choice = String(resolution || 'resolved');
if (!['ours', 'theirs', 'resolved'].includes(choice)) throw new Error('Unsupported conflict resolution choice.');
if (choice !== 'resolved') await this.runWithPathspec(root, ['checkout', `--${choice}`], [safeFile], { timeout: 30_000 });
await this.runWithPathspec(root, ['add'], [safeFile], { timeout: 30_000 });
return this.conflictState(root);
}
async continueInterruptedOperation(repoPath) {
const root = await this.ensureRepository(repoPath);
const state = await this.conflictState(root);
if (!state.operation) throw new Error('No interrupted Git operation is active.');
if (state.files.length) throw new Error('Resolve every conflicted file before continuing.');
const commands = { rebase: ['rebase', '--continue'], merge: ['merge', '--continue'], 'cherry-pick': ['cherry-pick', '--continue'], revert: ['revert', '--continue'] };
await run('git', commands[state.operation], { cwd: root, env: { GIT_EDITOR: 'true' }, timeout: 120_000 });
return this.conflictState(root);
}
selectedStatusFiles(status, files) {
const selected = assertRepositoryRelativePaths(files);
if (!selected.length) return { selected, matches: status.files };
@@ -334,6 +428,28 @@ class GitService {
return { output: result.stdout.trim(), sha: status.head, shortSha: status.shortHead, status };
}
async commitStaged(repoPath, message) {
const root = await this.ensureRepository(repoPath);
const commitMessage = assertCommitMessage(message);
const stagedCheck = await run('git', ['diff', '--cached', '--quiet'], { cwd: root, allowExitCodes: [1] });
if (stagedCheck.exitCode === 0) throw new Error('There are no staged changes to commit.');
const result = await run('git', ['commit', '-m', commitMessage], { cwd: root, timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
const status = await this.status(root);
return { output: result.stdout.trim(), sha: status.head, shortSha: status.shortHead, status };
}
async commitStagedAndPush(repoPath, message) {
const committed = await this.commitStaged(repoPath, message);
try {
const pushed = await this.push(repoPath);
return { commitOutput: committed.output, pushOutput: pushed.output, status: pushed.status, sha: committed.sha };
} catch (error) {
const wrapped = new Error(`Commit ${committed.shortSha} was created locally, but push failed: ${error.message}`);
wrapped.code = 'PUSH_AFTER_COMMIT_FAILED'; wrapped.commitSha = committed.sha; wrapped.recoverable = true;
throw wrapped;
}
}
async commitAndPush(repoPath, message, files = []) {
const committed = await this.commit(repoPath, message, files);
try {
@@ -512,4 +628,4 @@ class GitService {
}
}
module.exports = { GitService };
module.exports = { GitService, parseUnifiedDiff };
+39 -1
View File
@@ -1,6 +1,6 @@
'use strict';
const { normalizeBaseUrl } = require('../shared/validation.cjs');
const { normalizeBaseUrl, assertBranchName } = require('../shared/validation.cjs');
const { redactSecrets } = require('./log-redaction.cjs');
class GiteaService {
@@ -107,6 +107,44 @@ class GiteaService {
return (await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches/${encodeURIComponent(branch)}`)).data;
}
async getBranchProtection(owner, repo, branch) {
const branchInfo = await this.getBranch(owner, repo, branch);
let rule = null;
try {
const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branch_protections`);
const rules = Array.isArray(result.data) ? result.data : [];
rule = rules.find((item) => item.branch_name === branch || item.rule_name === branch) || null;
} catch (error) {
if (![403, 404].includes(error.status)) throw error;
}
return {
branch,
protected: Boolean(branchInfo?.protected || rule),
enablePush: rule?.enable_push ?? null,
enableForcePush: rule?.enable_force_push ?? false,
requiredApprovals: Number(rule?.required_approvals || 0),
requireSignedCommits: Boolean(rule?.require_signed_commits),
rule
};
}
async listPullRequests({ owner, repo, state = 'open', limit = 30 } = {}) {
const query = new URLSearchParams({ state, limit: String(Math.min(Math.max(Number(limit) || 30, 1), 50)) });
const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls?${query}`);
return Array.isArray(result.data) ? result.data : [];
}
async createPullRequest({ owner, repo, head, base, title, body = '' }) {
const cleanTitle = String(title || '').trim();
if (!cleanTitle || cleanTitle.length > 255) throw new Error('Pull request title must contain 1-255 characters.');
const cleanBody = String(body || '').trim().slice(0, 50_000);
const source = assertBranchName(head);
const target = assertBranchName(base);
if (source === target) throw new Error('Pull request source and target branches must be different.');
const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`, { method: 'POST', body: { head: source, base: target, title: cleanTitle, body: cleanBody }, timeout: 60_000 });
return result.data;
}
async getRepositoryFile({ owner, repo, filePath, ref }) {
const encodedPath = String(filePath || '').split('/').map(encodeURIComponent).join('/');
const query = ref ? `?ref=${encodeURIComponent(ref)}` : '';
+145 -4
View File
@@ -6,6 +6,8 @@ const { fileURLToPath } = require('node:url');
const { ipcMain, dialog, shell, app } = require('electron');
const { matchRemoteToRepository } = require('../shared/repository-match.cjs');
const { cloneDirectoryName, resolveCloneTarget } = require('../shared/clone-target.cjs');
const { createEncryptedBackup, readEncryptedBackup } = require('./configuration-backup.cjs');
const { evaluateDeploymentPolicy } = require('../shared/deployment-policy.cjs');
let diagnosticsService = null;
const TRUSTED_RENDERER_PATH = path.resolve(__dirname, '..', 'renderer', 'index.html');
@@ -53,7 +55,7 @@ function register(channel, handler) {
});
}
function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh, updates, preflight, diagnostics, monitor }) {
function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh, updates, preflight, diagnostics, audit, externalTools, monitor, onPreferencesChanged }) {
diagnosticsService = diagnostics;
const repositoryMutations = new Map();
const withRepositoryPause = async (localPath, action) => {
@@ -228,10 +230,46 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
register('settings:set-preferences', async ({ preferences }) => {
const state = await store.setPreferences(preferences);
monitor?.restart();
onPreferencesChanged?.();
await diagnostics.info('settings.preferences.updated', { preferences: state.preferences });
return state;
});
register('settings:export-backup', async ({ passphrase }) => {
const result = await dialog.showSaveDialog({
title: 'Export encrypted ForgeFlow configuration',
defaultPath: path.join(app.getPath('documents'), `ForgeFlow-Configuration-${new Date().toISOString().slice(0, 10)}.ffbackup`),
filters: [{ name: 'ForgeFlow encrypted backup', extensions: ['ffbackup'] }]
});
if (result.canceled || !result.filePath) return null;
const destinationPath = result.filePath.toLowerCase().endsWith('.ffbackup') ? result.filePath : `${result.filePath}.ffbackup`;
await fs.writeFile(destinationPath, createEncryptedBackup(store.data, passphrase), { mode: 0o600, flag: 'wx' }).catch(async (error) => {
if (error.code !== 'EEXIST') throw error;
await fs.writeFile(destinationPath, createEncryptedBackup(store.data, passphrase), { mode: 0o600 });
});
await audit.append('configuration.backup.exported', { fileName: path.basename(destinationPath) });
return { filePath: destinationPath };
});
register('settings:import-backup', async ({ passphrase }) => {
const result = await dialog.showOpenDialog({ title: 'Import encrypted ForgeFlow configuration', properties: ['openFile'], filters: [{ name: 'ForgeFlow encrypted backup', extensions: ['ffbackup'] }] });
if (result.canceled || !result.filePaths[0]) return null;
const payload = readEncryptedBackup(await fs.readFile(result.filePaths[0], 'utf8'), passphrase);
const state = await store.restoreConfiguration(payload.configuration);
monitor?.restart();
await audit.append('configuration.backup.imported', { fileName: path.basename(result.filePaths[0]), exportedAt: payload.exportedAt });
return { state, exportedAt: payload.exportedAt };
});
register('audit:list', ({ limit = 250 }) => audit.list(limit));
register('audit:export', async ({ format = 'json' }) => {
if (!['json', 'csv'].includes(format)) throw new Error('Unsupported audit export format.');
const extension = format === 'csv' ? 'csv' : 'json';
const result = await dialog.showSaveDialog({ title: 'Export ForgeFlow audit log', defaultPath: path.join(app.getPath('documents'), `ForgeFlow-Audit-${new Date().toISOString().slice(0, 10)}.${extension}`), filters: [{ name: `${extension.toUpperCase()} file`, extensions: [extension] }] });
if (result.canceled || !result.filePath) return null;
return audit.exportTo(result.filePath.toLowerCase().endsWith(`.${extension}`) ? result.filePath : `${result.filePath}.${extension}`, format);
});
register('updates:preferences', ({ updates: next }) => store.setUpdatePreferences(next));
register('updates:check', () => updates.check());
register('updates:download', () => updates.download());
@@ -271,6 +309,7 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
return { ...result, state: store.getPublicState() };
});
register('server:inspect-project', async ({ repository, profileId }) => unraid.inspect({ repository: await resolveRepository(repository), profileId }));
register('server:discover-existing', async ({ repository, serverId, remoteFolder }) => unraid.discoverExisting({ repository: await resolveRepository(repository), serverId, remoteFolder }));
register('repositories:refresh', async () => {
const result = await repositories.refresh();
@@ -308,14 +347,40 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
register('repository:status', async ({ localPath }) => git.status(await assertKnownRepositoryPath(localPath)));
register('repository:diff', async ({ localPath, filePath, staged }) => git.diff(await assertKnownRepositoryPath(localPath), filePath, staged));
register('repository:diff-hunks', async ({ localPath, filePath }) => git.diffHunks(await assertKnownRepositoryPath(localPath), filePath));
register('repository:stage-hunks', async ({ localPath, filePath, hunkIndexes }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.stageHunks(safePath, filePath, hunkIndexes)); });
register('repository:conflicts', async ({ localPath }) => git.conflictState(await assertKnownRepositoryPath(localPath)));
register('repository:resolve-conflict', async ({ localPath, filePath, resolution }) => { const safePath = await assertKnownRepositoryPath(localPath); const result = await withRepositoryMutation(safePath, () => git.resolveConflict(safePath, filePath, resolution)); await audit.append('git.conflict.resolved', { localPath: safePath, filePath, resolution }); return result; });
register('repository:continue-operation', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); const result = await withRepositoryMutation(safePath, () => git.continueInterruptedOperation(safePath)); await audit.append('git.operation.continued', { localPath: safePath }); return result; });
register('repository:abort-operation', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); const result = await withRepositoryMutation(safePath, () => git.abortInterruptedOperation(safePath)); await audit.append('git.operation.aborted', { localPath: safePath, operation: result.aborted }); return result; });
register('repository:stage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.stage(safePath, files)); });
register('repository:unstage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.unstage(safePath, files)); });
register('repository:commit', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.commit(safePath, message, files)); });
register('repository:commit-staged', async ({ localPath, message }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.commitStaged(safePath, message)); });
register('repository:commit-staged-push', async ({ localPath, message }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.commitStagedAndPush(safePath, message)); });
register('repository:commit-push', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.commitAndPush(safePath, message, files)); });
register('repository:push', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.push(safePath)); });
register('repository:fetch', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.fetch(safePath)); });
register('repository:pull', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.pullFastForward(safePath)); });
register('repository:history', async ({ localPath, limit }) => git.history(await assertKnownRepositoryPath(localPath), limit));
register('repository:branch-protection', async ({ fullName, branch }) => {
const repository = await resolveRepository({ fullName });
return gitea.getBranchProtection(repository.owner.login, repository.name, branch || repository.localStatus?.branch?.head || repository.defaultBranch);
});
register('repository:pull-requests', async ({ fullName, state = 'open' }) => {
const repository = await resolveRepository({ fullName });
return gitea.listPullRequests({ owner: repository.owner.login, repo: repository.name, state });
});
register('repository:create-pull-request', async ({ fullName, title, body, base }) => {
const repository = await resolveRepository({ fullName });
if (!repository.localPath || !repository.localStatus?.clean) throw new Error('A clean linked repository is required before creating a pull request.');
const head = repository.localStatus.branch?.head;
if (!head || !repository.localStatus.branch?.upstream) throw new Error('Publish the current branch before creating a pull request.');
if (repository.localStatus.branch.ahead > 0) throw new Error('Push all local commits before creating a pull request.');
const pullRequest = await gitea.createPullRequest({ owner: repository.owner.login, repo: repository.name, head, base: base || repository.defaultBranch, title, body });
await audit.append('pull-request.created', { repository: repository.fullName, number: pullRequest.number, head, base: base || repository.defaultBranch, url: pullRequest.html_url });
return pullRequest;
});
register('repository:branches', async ({ localPath }) => git.branches(await assertKnownRepositoryPath(localPath)));
register('repository:checkout-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.checkoutBranch(safePath, branch)); });
register('repository:create-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.createBranch(safePath, branch)); });
@@ -370,6 +435,8 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
if (error) throw new Error(error);
return true;
});
register('repository:open-editor', async ({ localPath, filePath = '', line = 1 }) => externalTools.launch('editor', await assertKnownRepositoryPath(localPath), filePath, line));
register('repository:open-terminal', async ({ localPath }) => externalTools.launch('terminal', await assertKnownRepositoryPath(localPath)));
register('external:open', async ({ url }) => {
const parsed = new URL(url);
@@ -378,6 +445,75 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
return true;
});
register('troubleshooter:scan', async ({ fullName = null }) => {
const currentRepositories = await repositories.refresh();
const candidates = fullName ? currentRepositories.filter((item) => item.fullName === fullName) : currentRepositories;
const issues = [];
for (const repository of candidates) {
if (!repository.localPath) {
issues.push({ id: `${repository.fullName}:not-linked`, repository: repository.fullName, severity: 'warning', title: 'Local repository is not linked', detail: 'Link or clone the repository before running local Git repairs.', repairable: false });
continue;
}
try {
const interrupted = await git.detectInterruptedOperation(repository.localPath);
if (interrupted) issues.push({ id: `${repository.fullName}:abort-operation`, repository: repository.fullName, localPath: repository.localPath, severity: 'error', title: `Interrupted Git ${interrupted}`, detail: `A ${interrupted} is still active and blocks normal Git operations. Aborting it can discard conflict-resolution work and therefore always requires separate confirmation.`, repairable: true, action: 'abort-operation', safe: false });
const report = await git.reconcile(repository.localPath);
for (const lock of report.lockReport?.locks || []) {
const stale = lock.ageMs >= 10_000;
const processProbeSafe = report.lockReport.processes?.available === true && !report.lockReport.processes.active?.length;
issues.push({ id: `${repository.fullName}:locks:${lock.name}`, repository: repository.fullName, localPath: repository.localPath, severity: stale ? 'error' : 'warning', title: stale ? 'Stale Git lock detected' : 'Recent Git lock detected', detail: lock.name, repairable: stale, action: 'repair-locks', safe: stale && processProbeSafe });
}
const branch = report.status?.branch || {};
if (branch.behind > 0 && branch.ahead === 0 && report.status.clean) issues.push({ id: `${repository.fullName}:fast-forward`, repository: repository.fullName, localPath: repository.localPath, severity: 'warning', title: 'Local branch is behind Gitea', detail: `${branch.behind} commit(s) can be fast-forwarded safely.`, repairable: true, action: 'fast-forward', safe: true });
if (branch.ahead > 0 && branch.behind === 0) issues.push({ id: `${repository.fullName}:push`, repository: repository.fullName, localPath: repository.localPath, severity: 'warning', title: 'Local commits are not published', detail: `${branch.ahead} commit(s) can be pushed to Gitea after explicit confirmation.`, repairable: true, action: 'push', safe: false });
if (branch.ahead > 0 && branch.behind > 0) issues.push({ id: `${repository.fullName}:diverged`, repository: repository.fullName, localPath: repository.localPath, severity: 'error', title: 'Local and Gitea branches have diverged', detail: `${branch.ahead} ahead and ${branch.behind} behind. ForgeFlow can preserve the local HEAD on a safety branch and use the upstream version.`, repairable: report.status.clean, action: 'backup-reset', safe: false });
} catch (error) {
issues.push({ id: `${repository.fullName}:git-error`, repository: repository.fullName, severity: 'error', title: 'Git health scan failed', detail: error.message, repairable: false });
}
for (const profile of repository.deploymentProfiles || []) {
if (profile.provider !== 'ssh-unraid') continue;
try {
const inspection = await unraid.inspect({ repository, profileId: profile.id });
if (!inspection.exists) issues.push({ id: `${profile.id}:server-folder`, repository: repository.fullName, profileId: profile.id, severity: 'error', title: 'Deployment folder is missing on the server', detail: inspection.remotePath, repairable: false });
if (inspection.trackedChanges?.length) issues.push({ id: `${profile.id}:tracked-server-changes`, repository: repository.fullName, profileId: profile.id, severity: 'error', title: 'Tracked server-side changes detected', detail: `${inspection.trackedChanges.length} tracked change(s) must be reviewed before deployment.`, repairable: false });
if (inspection.dockerContextExclusionsMissing?.length) issues.push({ id: `${profile.id}:dockerignore`, repository: repository.fullName, profileId: profile.id, severity: 'warning', title: 'Runtime paths are missing from .dockerignore', detail: inspection.dockerContextExclusionsMissing.join(', '), repairable: false });
} catch (error) {
issues.push({ id: `${profile.id}:server-error`, repository: repository.fullName, profileId: profile.id, severity: 'error', title: 'Server inspection failed', detail: error.message, repairable: false });
}
}
}
const summary = { total: issues.length, errors: issues.filter((item) => item.severity === 'error').length, warnings: issues.filter((item) => item.severity === 'warning').length, repairable: issues.filter((item) => item.repairable).length };
return { checkedAt: new Date().toISOString(), issues, summary };
});
register('troubleshooter:repair', async ({ issue }) => {
if (!issue || !issue.action) throw new Error('No repair action was supplied.');
const localPath = issue.localPath ? await assertKnownRepositoryPath(issue.localPath) : null;
let result;
if (issue.action === 'abort-operation') result = await withRepositoryMutation(localPath, () => git.abortInterruptedOperation(localPath));
else if (issue.action === 'repair-locks') result = await withRepositoryMutation(localPath, () => git.repairStaleGitLocks(localPath, { minimumAgeMs: 2_000 }));
else if (['fast-forward', 'push', 'backup-reset', 'fetch'].includes(issue.action)) result = await withRepositoryMutation(localPath, () => git.repairSync(localPath, issue.action));
else throw new Error('Unsupported troubleshooter repair action.');
await diagnostics.info('troubleshooter.repair.completed', { repository: issue.repository, action: issue.action });
return result;
});
register('troubleshooter:auto-repair', async ({ issues }) => {
const results = [];
for (const issue of (issues || []).filter((item) => item.repairable && item.safe)) {
try {
const localPath = issue.localPath ? await assertKnownRepositoryPath(issue.localPath) : null;
let result;
if (issue.action === 'repair-locks') result = await withRepositoryMutation(localPath, () => git.repairStaleGitLocks(localPath, { minimumAgeMs: 10_000 }));
else if (['fast-forward', 'fetch'].includes(issue.action)) result = await withRepositoryMutation(localPath, () => git.repairSync(localPath, issue.action));
else continue;
results.push({ id: issue.id, ok: true, result });
} catch (error) { results.push({ id: issue.id, ok: false, error: error.message }); }
}
await diagnostics.info('troubleshooter.auto-repair.completed', { attempted: results.length, succeeded: results.filter((item) => item.ok).length });
return results;
});
register('deployment:save-profile', async ({ fullName, profile }) => {
const saved = await store.saveDeploymentProfile(fullName, profile);
await diagnostics.info('deployment.profile.saved', { repository: fullName, profile: saved });
@@ -394,11 +530,16 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
if (profile?.provider === 'ssh-unraid') return unraid.preflight({ repository: current, profileId });
return preflight.runDeployment({ repository: current, profileId });
});
register('deployment:dispatch', async ({ repository, profileId, sha }) => {
register('deployment:dispatch', async ({ repository, profileId, sha, note = '', override = false, overrideReason = '' }) => {
const current = await resolveRepository(repository);
const profile = store.getDeploymentProfile(current.fullName, profileId);
if (profile?.provider === 'ssh-unraid') return unraid.deploy({ repository: current, profileId, sha });
return deployments.deploy({ repository: current, profileId, sha });
const policy = evaluateDeploymentPolicy(profile, { note, override, reason: overrideReason });
await audit.append('deployment.requested', { repository: current.fullName, profileId, sha, note: policy.note, overridden: policy.overridden, overrideReason: policy.reason });
const operation = profile?.provider === 'ssh-unraid'
? await unraid.deploy({ repository: current, profileId, sha })
: await deployments.deploy({ repository: current, profileId, sha });
if (operation?.id) await store.addOperation({ ...operation, releaseNote: policy.note, policyOverride: policy.overridden ? { reason: policy.reason, violations: policy.violations } : null });
return operation;
});
register('deployment:rollback', async ({ repository, profileId, targetSha }) => {
const current = await resolveRepository(repository);
+130
View File
@@ -97,6 +97,75 @@ function xmlEscape(value) {
.replace(/'/g, '&apos;');
}
function decodeBase64Json(value, fallback) {
try { return value ? JSON.parse(Buffer.from(value, 'base64').toString('utf8')) : fallback; }
catch { return fallback; }
}
function parseDockerManXml(xml) {
const text = String(xml || '');
const tag = (name) => {
const match = text.match(new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`, 'i'));
return match ? match[1].replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').trim() : '';
};
return { name: tag('Name'), webUiUrl: tag('WebUI'), iconUrl: tag('Icon'), shell: tag('Shell') };
}
function deriveDetectedProfile({ repository, server, remoteFolder, remotePath, payload }) {
const compose = payload.compose || {};
const services = compose.services && typeof compose.services === 'object' ? compose.services : {};
const inspections = Array.isArray(payload.containers) ? payload.containers : [];
const primaryContainer = inspections.find((item) => item?.State?.Running) || inspections[0] || null;
const labels = primaryContainer?.Config?.Labels || {};
const serviceName = labels['com.docker.compose.service'] || Object.keys(services)[0] || remoteFolder;
const service = services[serviceName] || {};
const containerName = String(primaryContainer?.Name || service.container_name || serviceName).replace(/^\//, '');
const ports = [];
for (const [containerKey, bindings] of Object.entries(primaryContainer?.NetworkSettings?.Ports || {})) {
const [containerPortText, protocol = 'tcp'] = containerKey.split('/');
const containerPort = Number(containerPortText) || null;
if (Array.isArray(bindings) && bindings.length) {
for (const binding of bindings) ports.push({ hostIp: binding.HostIp || '', hostPort: Number(binding.HostPort) || null, containerPort, protocol });
} else ports.push({ hostIp: '', hostPort: null, containerPort, protocol });
}
const primaryPort = ports.find((item) => item.hostPort) || ports[0] || {};
const mounts = (primaryContainer?.Mounts || []).map((item) => ({ type: item.Type, source: item.Source, target: item.Destination, readOnly: item.RW === false }));
const networks = Object.keys(primaryContainer?.NetworkSettings?.Networks || {});
const envNames = (primaryContainer?.Config?.Env || []).map((item) => String(item).split('=')[0]).filter(Boolean);
const dockerMan = parseDockerManXml(payload.dockerManXml || '');
const webUiUrl = dockerMan.webUiUrl || labels['net.unraid.docker.webui'] || '';
const iconUrl = dockerMan.iconUrl || labels['net.unraid.docker.icon'] || '';
const shell = dockerMan.shell || labels['net.unraid.docker.shell'] || '/bin/sh';
const preservePaths = [...new Set([
'.env', 'appdata', 'data', 'logs', 'config', 'compose.override.yml',
...mounts.filter((item) => String(item.source || '').startsWith(`${remotePath}/`)).map((item) => String(item.source).slice(remotePath.length + 1).split('/')[0]).filter(Boolean)
])];
const source = (value, origin, confidence = 'confirmed') => ({ value, origin, confidence, detectedAt: new Date().toISOString(), overridden: false });
const composeFiles = payload.composeFiles || [];
const composeFile = composeFiles[0] || labels['com.docker.compose.project.config_files']?.split(',')[0]?.replace(`${remotePath}/`, '') || 'docker-compose.yml';
return {
profile: {
name: 'Production', environment: 'production', provider: 'ssh-unraid', branch: payload.branch || repository.defaultBranch || 'main',
serverId: server.id, remoteFolder, cloneUrl: payload.remote || repository.sshUrl || '', alignRemote: false,
generatedCompose: false, composeFile, composeService: serviceName, containerName,
hostPort: primaryPort.hostPort || null, containerPort: primaryPort.containerPort || null,
webUiUrl, iconMode: /^https?:\/\//i.test(iconUrl) ? 'url' : 'none', iconUrl: /^https?:\/\//i.test(iconUrl) ? iconUrl : '', serverIconReference: iconUrl, iconFilePath: '', dockerShell: ['/bin/bash','/bin/sh'].includes(shell) ? shell : '/bin/sh',
healthcheckUrl: '', preservePaths, confirmationRequired: true,
adoptedFromServer: true, serverSourceOfTruth: true, detectedAt: new Date().toISOString(),
detectedMetadata: { head: payload.head || null, composeProject: labels['com.docker.compose.project'] || '', composeFiles, services: Object.keys(services), ports, mounts, networks, envNames, restartPolicy: primaryContainer?.HostConfig?.RestartPolicy?.Name || '', healthcheck: primaryContainer?.Config?.Healthcheck || null, image: primaryContainer?.Config?.Image || service.image || '', dockerMan }
},
provenance: {
remoteFolder: source(remoteFolder, 'server-path'), cloneUrl: source(payload.remote || '', 'git-origin'), branch: source(payload.branch || '', 'git'),
composeFile: source(composeFile, 'docker-compose'), composeService: source(serviceName, 'docker-labels'), containerName: source(containerName, 'docker-inspect'),
hostPort: source(primaryPort.hostPort || null, 'docker-inspect'), containerPort: source(primaryPort.containerPort || null, 'docker-inspect'),
webUiUrl: source(webUiUrl, dockerMan.webUiUrl ? 'unraid-dockerman' : 'docker-labels'), iconUrl: source(iconUrl, dockerMan.iconUrl ? 'unraid-dockerman' : 'docker-labels'), dockerShell: source(shell, dockerMan.shell ? 'unraid-dockerman' : 'docker-labels')
},
runtime: { remotePath, containerRunning: Boolean(primaryContainer?.State?.Running), containers: inspections.length, services: Object.keys(services).length, ports, mounts, networks, envNames }
};
}
function iconReferenceLocalPath(iconReference) {
const value = String(iconReference || '').trim();
if (value.startsWith('file:///')) return `/${value.slice('file:///'.length)}`;
@@ -131,6 +200,64 @@ class UnraidDeploymentService {
return { profile, server, remoteFolder, remotePath };
}
async discoverExisting({ repository, serverId, remoteFolder = '' }) {
const server = this.store.getServer(serverId);
if (!server) throw new Error('The deployment server no longer exists.');
const folder = safeRemoteFolder(remoteFolder || repository.name);
const remotePath = path.join(server.basePath, folder);
if (!remotePath.startsWith(`${server.basePath}/`)) throw new Error('Remote project path escapes the configured server base path.');
const script = `
root=${shellQuote(remotePath)}
test -d "$root" || { echo "Existing server folder not found: $root" >&2; exit 44; }
head=$(git -C "$root" rev-parse HEAD 2>/dev/null || true)
branch=$(git -C "$root" branch --show-current 2>/dev/null || true)
remote=$(git -C "$root" remote get-url origin 2>/dev/null || true)
compose_files=$(find "$root" -maxdepth 2 -type f \\( -name 'docker-compose.yml' -o -name 'docker-compose.yaml' -o -name 'compose.yml' -o -name 'compose.yaml' \\) -printf '%P\\n' 2>/dev/null | sort)
compose_file=$(printf '%s\\n' "$compose_files" | head -n1)
compose_json='{}'
container_json='[]'
if [ -n "$compose_file" ] && command -v docker >/dev/null 2>&1; then
compose_json=$(cd "$root" && docker compose -f "$compose_file" config --format json 2>/dev/null || printf '{}')
ids=$(cd "$root" && docker compose -f "$compose_file" ps -aq 2>/dev/null || true)
[ -n "$ids" ] && container_json=$(docker inspect $ids 2>/dev/null || printf '[]')
fi
container_name=$(printf '%s' "$container_json" | sed -n 's/.*"Name"[[:space:]]*:[[:space:]]*"\\/\\([^" ]*\\)".*/\\1/p' | head -n1)
dockerman_xml=''
if [ -n "$container_name" ] && [ -d /boot/config/plugins/dockerMan/templates-user ]; then
template=$(grep -ril "<Name>${container_name}</Name>" /boot/config/plugins/dockerMan/templates-user 2>/dev/null | head -n1 || true)
[ -n "$template" ] && dockerman_xml=$(cat "$template")
fi
printf '__FORGEFLOW_DISCOVERY__\\n'
printf 'head=%s\\n' "$head"
printf 'branch=%s\\n' "$branch"
printf 'remote=%s\\n' "$(printf '%s' "$remote" | base64 | tr -d '\\r\\n')"
printf 'composeFiles=%s\\n' "$(printf '%s\\n' "$compose_files" | base64 | tr -d '\\r\\n')"
printf 'compose=%s\\n' "$(printf '%s' "$compose_json" | base64 | tr -d '\\r\\n')"
printf 'containers=%s\\n' "$(printf '%s' "$container_json" | base64 | tr -d '\\r\\n')"
printf 'dockerManXml=%s\\n' "$(printf '%s' "$dockerman_xml" | base64 | tr -d '\\r\\n')"
`;
const result = await this.ssh.exec(server.id, bash(script), { timeout: 90_000, maxOutput: 8 * 1024 * 1024 });
const marker = '__FORGEFLOW_DISCOVERY__';
const index = result.stdout.lastIndexOf(marker);
if (index < 0) throw new Error('The server did not return deployment discovery data.');
const fields = {};
for (const line of result.stdout.slice(index + marker.length).trim().split(/\r?\n/)) {
const split = line.indexOf('='); if (split > 0) fields[line.slice(0, split)] = line.slice(split + 1);
}
const payload = {
head: fields.head || null, branch: fields.branch || null,
remote: fields.remote ? Buffer.from(fields.remote, 'base64').toString('utf8') : '',
composeFiles: fields.composeFiles ? Buffer.from(fields.composeFiles, 'base64').toString('utf8').split(/\r?\n/).filter(Boolean) : [],
compose: decodeBase64Json(fields.compose, {}), containers: decodeBase64Json(fields.containers, []),
dockerManXml: fields.dockerManXml ? Buffer.from(fields.dockerManXml, 'base64').toString('utf8') : ''
};
const discovery = deriveDetectedProfile({ repository, server, remoteFolder: folder, remotePath, payload });
await this.diagnostics?.info('unraid.existing-discovered', { repository: repository.fullName, serverId, remotePath, containers: discovery.runtime.containers, services: discovery.runtime.services });
return discovery;
}
async inspect({ repository, profileId }) {
const { profile, server, remotePath } = this.resolve(repository, profileId);
const preserveProbe = (profile.preservePaths || []).map((relativePath) =>
@@ -906,5 +1033,8 @@ module.exports = {
checksSummary,
xmlEscape,
iconReferenceLocalPath,
decodeBase64Json,
parseDockerManXml,
deriveDetectedProfile,
bash
};
+2634 -686
View File
File diff suppressed because it is too large Load Diff
+1388 -203
View File
File diff suppressed because it is too large Load Diff
+2996 -473
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
'use strict';
function parseClock(value) {
const match = String(value || '').match(/^([01]\d|2[0-3]):([0-5]\d)$/);
if (!match) throw new Error('Maintenance window times must use HH:mm.');
return Number(match[1]) * 60 + Number(match[2]);
}
function normalizeMaintenanceWindows(windows) {
return (Array.isArray(windows) ? windows : []).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')
})).map((window) => ({ ...window, startMinutes: parseClock(window.start), endMinutes: parseClock(window.end) }));
}
function isInsideWindow(window, date) {
const minutes = date.getHours() * 60 + date.getMinutes();
if (window.startMinutes <= window.endMinutes) return window.days.includes(date.getDay()) && minutes >= window.startMinutes && minutes <= window.endMinutes;
if (minutes >= window.startMinutes) return window.days.includes(date.getDay());
const previousDay = (date.getDay() + 6) % 7;
return minutes <= window.endMinutes && window.days.includes(previousDay);
}
function evaluateDeploymentPolicy(profile, { now = new Date(), override = false, reason = '', note = '' } = {}) {
const cleanReason = String(reason || '').trim();
const cleanNote = String(note || '').trim();
const policy = profile?.deploymentPolicy || {};
const windows = normalizeMaintenanceWindows(policy.maintenanceWindows);
const violations = [];
if (policy.frozen) violations.push(policy.freezeReason ? `Deployment frozen: ${policy.freezeReason}` : 'Deployment is frozen.');
if (windows.length && !windows.some((window) => isInsideWindow(window, now))) violations.push('Current time is outside the configured maintenance windows.');
if (policy.requireNote && !cleanNote) violations.push('A release note is required for this environment.');
if (violations.length && override && !cleanReason) throw new Error('An override reason is required to bypass deployment policy.');
if (violations.length && !override) {
const error = new Error(violations.join(' '));
error.code = 'DEPLOYMENT_POLICY_BLOCKED';
error.recoverable = true;
throw error;
}
return { allowed: true, overridden: violations.length > 0, violations, reason: cleanReason, note: cleanNote };
}
module.exports = { parseClock, normalizeMaintenanceWindows, isInsideWindow, evaluateDeploymentPolicy };
+4 -3
View File
@@ -87,14 +87,15 @@ function assertDeploymentRequest(profile, sha) {
assertHttpUrl(profile.statusUrl, { label: 'Application status URL' });
}
function assertHttpUrl(value, { optional = false, label = 'URL' } = {}) {
function assertHttpUrl(value, { optional = false, label = 'URL', allowUnraidTemplate = false } = {}) {
const raw = String(value || '').trim();
if (!raw && optional) return '';
if (!raw) throw new Error(`${label} is required.`);
const url = new URL(raw);
const validationValue = allowUnraidTemplate ? raw.replace(/\[IP\]/gi, '127.0.0.1').replace(/\[PORT(?::\d+)?\]/gi, '8080') : raw;
const url = new URL(validationValue);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error(`${label} must use HTTP or HTTPS.`);
if (url.username || url.password) throw new Error(`${label} may not contain credentials.`);
return url.toString();
return allowUnraidTemplate ? raw : url.toString();
}
function assertCloneRemote(value) {