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
+130
View File
@@ -97,6 +97,75 @@ function xmlEscape(value) {
.replace(/'/g, ''');
}
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
};