Release ForgeFlow 0.6.0

This commit is contained in:
NuklearRabbit
2026-07-25 05:59:07 +02:00
parent cf1f67a823
commit 9d3933c878
48 changed files with 2208 additions and 466 deletions
+30 -3
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: 5,
schemaVersion: 7,
setupComplete: false,
appearance: 'dark',
gitea: { baseUrl: '', user: null, encryptedToken: null },
@@ -58,7 +58,20 @@ class ConfigStore {
gitea: { ...DEFAULT_CONFIG.gitea, ...(source.gitea || {}) },
workspaceRoots: uniqueStrings(source.workspaceRoots),
repositoryMappings: source.repositoryMappings && typeof source.repositoryMappings === 'object' ? source.repositoryMappings : {},
deploymentProfiles: source.deploymentProfiles && typeof source.deploymentProfiles === 'object' ? source.deploymentProfiles : {},
deploymentProfiles: source.deploymentProfiles && typeof source.deploymentProfiles === 'object'
? Object.fromEntries(Object.entries(source.deploymentProfiles).map(([key, profiles]) => [key, (Array.isArray(profiles) ? profiles : []).map((profile) => {
if (!profile || typeof profile !== 'object' || profile.provider !== 'ssh-unraid') return profile;
const iconUrl = String(profile.iconUrl || '').trim();
const iconFilePath = String(profile.iconFilePath || '').trim();
const requestedMode = String(profile.iconMode || '').trim();
const iconMode = ['builtin', 'upload', 'url', 'none'].includes(requestedMode)
? requestedMode
: iconFilePath ? 'upload' : iconUrl && !/itworx\.tech\/assets\/itworx-icon\.png/i.test(iconUrl) ? 'url' : 'builtin';
const visibleName = String(profile.containerName || profile.remoteFolder || '').trim();
const internalService = String(profile.composeService || profile.remoteFolder || 'app').trim().toLowerCase().replace(/[^a-z0-9._-]/g, '-') || 'app';
return { ...profile, composeService: internalService, containerName: visibleName || internalService, iconMode };
})]))
: {},
deploymentStates: source.deploymentStates && typeof source.deploymentStates === 'object' ? source.deploymentStates : {},
favorites: uniqueStrings(source.favorites).map((item) => item.toLowerCase()),
updates: { ...DEFAULT_CONFIG.updates, ...(source.updates || {}) },
@@ -300,13 +313,27 @@ class ConfigStore {
serverId: String(profile.serverId || '').trim(),
remoteFolder,
composeFile: String(profile.composeFile || 'docker-compose.yml').trim(),
composeService: String(profile.composeService || '').trim(),
composeService: (() => {
const value = String(profile.composeService || remoteFolder).trim().toLowerCase();
if (!/^[a-z0-9._-]+$/.test(value)) throw new Error('Compose service must be lowercase and contain only letters, numbers, dots, underscores and dashes.');
return value;
})(),
containerName: (() => {
const value = String(profile.containerName || remoteFolder).trim();
if (!/^[A-Za-z0-9._-]+$/.test(value)) throw new Error('Container name must contain only letters, numbers, dots, underscores and dashes.');
return value;
})(),
cloneUrl: profile.cloneUrl ? assertCloneRemote(profile.cloneUrl) : '',
alignRemote: profile.alignRemote === true,
hostPort: profile.hostPort ? Math.min(Math.max(Number(profile.hostPort), 1), 65535) : null,
containerPort: profile.containerPort ? Math.min(Math.max(Number(profile.containerPort), 1), 65535) : null,
webUiUrl: assertHttpUrl(profile.webUiUrl, { optional: true, label: 'Web UI URL' }),
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
};
+147 -12
View File
@@ -5,6 +5,8 @@ const fs = require('node:fs/promises');
const { run } = require('./process-runner.cjs');
const { parsePorcelainV2 } = require('../shared/git-status.cjs');
const { normalizeRemoteUrl } = require('../shared/repository-match.cjs');
const COMMON_GIT_LOCK_FILES = ['HEAD.lock', 'index.lock'];
const {
assertSafeRepositoryPath,
assertRepositoryRelativePath,
@@ -73,23 +75,156 @@ class GitService {
});
}
async getIndexLockInfo(repoPath) {
async gitDirectory(repoPath) {
const root = await this.ensureRepository(repoPath);
const lockPath = path.join(root, '.git', 'index.lock');
const stat = await fs.stat(lockPath).catch(() => null);
return stat ? { exists: true, lockPath, ageMs: Math.max(0, Date.now() - stat.mtimeMs) } : { exists: false, lockPath, ageMs: 0 };
const result = await run('git', ['rev-parse', '--path-format=absolute', '--git-dir'], { cwd: root, timeout: 15_000 });
return { root, gitDir: path.resolve(result.stdout.trim()) };
}
async removeStaleIndexLock(repoPath, minimumAgeMs = 30_000) {
const info = await this.getIndexLockInfo(repoPath);
if (!info.exists) return { removed: false, reason: 'missing', ...info };
if (info.ageMs < minimumAgeMs) {
const error = new Error('The Git index lock is recent. Close other Git tools and try again before removing it.');
error.code = 'INDEX_LOCK_RECENT';
isGitLockError(error) {
const message = String(error?.message || error || '');
return /(?:cannot lock ref|Unable to create .*\.lock|another git process)/i.test(message)
|| COMMON_GIT_LOCK_FILES.some((lockName) => message.toLowerCase().includes(lockName.toLowerCase()));
}
async gitProcessProbe(root) {
if (process.platform !== 'win32') return { available: false, active: [], reason: 'process probe is Windows-only' };
const escaped = root.replace(/'/g, "''");
const script = `$root='${escaped}'; Get-CimInstance Win32_Process -Filter \"Name='git.exe' OR Name='git-remote-https.exe' OR Name='ssh.exe'\" -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -and $_.CommandLine.IndexOf($root,[System.StringComparison]::OrdinalIgnoreCase) -ge 0 } | Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Compress`;
try {
const result = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], { timeout: 15_000, allowExitCodes: [1] });
const text = result.stdout.trim();
const parsed = text ? JSON.parse(text) : [];
return { available: true, active: Array.isArray(parsed) ? parsed : [parsed] };
} catch (error) {
return { available: false, active: [], reason: error.message };
}
}
async listGitLocks(repoPath) {
const { root, gitDir } = await this.gitDirectory(repoPath);
const locks = [];
const walk = async (directory, depth = 0) => {
if (depth > 8) return;
const entries = await fs.readdir(directory, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
const fullPath = path.join(directory, entry.name);
const relative = path.relative(gitDir, fullPath).replace(/\\/g, '/');
if (entry.isDirectory()) {
const segments = relative.split('/');
if (segments.includes('objects') || segments.includes('lfs')) continue;
await walk(fullPath, depth + 1);
} else if (entry.isFile() && entry.name.endsWith('.lock')) {
const stat = await fs.stat(fullPath).catch(() => null);
if (stat) locks.push({
name: path.relative(gitDir, fullPath).replace(/\\/g, '/'),
lockPath: fullPath,
ageMs: Math.max(0, Date.now() - stat.mtimeMs),
size: stat.size,
modifiedAt: stat.mtime.toISOString()
});
}
}
};
await walk(gitDir);
const processes = await this.gitProcessProbe(root);
return { root, gitDir, locks: locks.sort((a, b) => a.name.localeCompare(b.name)), processes };
}
async repairStaleGitLocks(repoPath, { minimumAgeMs = 15_000, allowWithoutProcessProbe = false } = {}) {
const report = await this.listGitLocks(repoPath);
if (!report.locks.length) return { ...report, removed: [], skipped: [], repaired: false };
if (report.processes.active.length) {
const error = new Error(`A Git-related process is still using this repository (${report.processes.active.map((item) => `${item.Name || 'process'} ${item.ProcessId || ''}`.trim()).join(', ')}). Close it before repairing locks.`);
error.code = 'GIT_PROCESS_ACTIVE';
error.processes = report.processes.active;
throw error;
}
await fs.rm(info.lockPath, { force: true });
return { removed: true, ...info };
if (!report.processes.available && !allowWithoutProcessProbe) {
const error = new Error('ForgeFlow could not prove that no Git process is active. Use the explicit force repair only after closing Git tools for this repository.');
error.code = 'GIT_PROCESS_PROBE_UNAVAILABLE';
error.recoverable = true;
throw error;
}
const removed = [];
const skipped = [];
for (const lock of report.locks) {
if (lock.ageMs < minimumAgeMs) { skipped.push({ ...lock, reason: 'recent' }); continue; }
await fs.rm(lock.lockPath, { force: true });
removed.push(lock);
}
if (!removed.length && skipped.length) {
const error = new Error('All Git lock files are recent. Wait a few seconds after closing Git tools, then scan again.');
error.code = 'GIT_LOCKS_RECENT';
error.recoverable = true;
throw error;
}
return { ...report, removed, skipped, repaired: removed.length > 0 };
}
async getIndexLockInfo(repoPath) {
const report = await this.listGitLocks(repoPath);
const lock = report.locks.find((item) => item.name === 'index.lock');
return lock ? { exists: true, ...lock } : { exists: false, lockPath: path.join(report.gitDir, 'index.lock'), ageMs: 0 };
}
async removeStaleIndexLock(repoPath, minimumAgeMs = 15_000) {
const result = await this.repairStaleGitLocks(repoPath, { minimumAgeMs });
const removed = result.removed.find((item) => item.name === 'index.lock');
return removed ? { removed: true, ...removed } : { removed: false, reason: 'missing', ...(await this.getIndexLockInfo(repoPath)) };
}
async reconcile(repoPath) {
const root = await this.ensureRepository(repoPath);
await this.fetch(root).catch(() => null);
const status = await this.status(root);
const upstream = status.branch?.upstream || '';
return {
status,
lockReport: await this.listGitLocks(root),
recommendations: [
{ id: 'fetch', label: 'Fetch and recalculate remote state', action: 'fetch', safe: true },
...(status.branch?.behind > 0 && status.branch?.ahead === 0 && status.clean && upstream ? [{ id: 'pull', label: `Fast-forward from ${upstream}`, action: 'fast-forward', safe: true }] : []),
...(status.branch?.ahead > 0 && status.branch?.behind === 0 && upstream ? [{ id: 'push', label: `Push ${status.branch.ahead} local commit(s)`, action: 'push', safe: true }] : []),
...(status.branch?.ahead > 0 && status.branch?.behind > 0 && upstream ? [
{ id: 'diverged', label: `Branch diverged (${status.branch.ahead} ahead, ${status.branch.behind} behind)`, action: null, safe: false },
{ id: 'backup-reset', label: `Create a safety branch and reset to ${upstream}`, action: 'backup-reset', safe: false }
] : [])
]
};
}
async repairSync(repoPath, strategy) {
const root = await this.ensureRepository(repoPath);
const requested = String(strategy || '').trim();
if (!['fetch', 'fast-forward', 'push', 'backup-reset'].includes(requested)) throw new Error('Unsupported Git synchronization repair strategy.');
await this.fetch(root);
let status = await this.status(root);
const branch = status.branch?.head;
const upstream = status.branch?.upstream;
if (!branch || branch === '(detached)') throw new Error('Synchronization repair requires a named local branch.');
if (!upstream && requested !== 'fetch') throw new Error('The current branch has no upstream branch. Repair origin or publish the branch first.');
if (requested === 'fast-forward') {
if (!status.clean) throw new Error('Fast-forward repair requires a clean working tree. Commit or stash changes first.');
if (status.branch.ahead > 0) throw new Error('Fast-forward repair is only safe when there are no local commits ahead of upstream.');
await run('git', ['merge', '--ff-only', upstream], { cwd: root, timeout: 2 * 60_000 });
} else if (requested === 'push') {
if (status.branch.behind > 0) throw new Error('Push repair is blocked because the remote branch contains commits that are not local.');
await this.push(root);
} else if (requested === 'backup-reset') {
if (!status.clean) throw new Error('Backup-and-reset requires a clean working tree. Commit or stash changes first.');
if (!(status.branch.ahead > 0 && status.branch.behind > 0)) throw new Error('Backup-and-reset is only offered for a diverged branch.');
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
const backupBranch = `forgeflow/backup-${branch.replace(/[^A-Za-z0-9._-]/g, '-')}-${stamp}`;
await run('git', ['branch', backupBranch, 'HEAD'], { cwd: root, timeout: 30_000 });
await run('git', ['reset', '--hard', upstream], { cwd: root, timeout: 2 * 60_000 });
status = await this.status(root);
return { strategy: requested, backupBranch, status, lockReport: await this.listGitLocks(root) };
}
status = await this.status(root);
return { strategy: requested, backupBranch: null, status, lockReport: await this.listGitLocks(root) };
}
async setRemoteUrl(repoPath, remoteUrl, remote = 'origin') {
+59 -5
View File
@@ -64,7 +64,32 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
const withRepositoryMutation = async (localPath, action) => {
const key = path.resolve(localPath);
const previous = repositoryMutations.get(key) || Promise.resolve();
const current = previous.catch(() => {}).then(() => withRepositoryPause(key, action));
const execute = async () => {
try { return await withRepositoryPause(key, action); }
catch (error) {
if (!git.isGitLockError(error)) throw error;
let repair = null;
let lockDiagnosis = null;
try {
repair = await git.repairStaleGitLocks(key, { minimumAgeMs: 2_000 });
} catch (repairError) {
lockDiagnosis = repairError;
if (repairError?.code === 'GIT_LOCKS_RECENT') {
await new Promise((resolve) => setTimeout(resolve, 2_500));
try {
repair = await git.repairStaleGitLocks(key, { minimumAgeMs: 2_000 });
lockDiagnosis = null;
} catch (retryError) {
lockDiagnosis = retryError;
}
}
}
if (!repair?.repaired) throw lockDiagnosis || error;
await diagnostics.info('git.lock.auto-repaired', { localPath: key, locks: repair.removed.map((item) => item.name) });
return withRepositoryPause(key, action);
}
};
const current = previous.catch(() => {}).then(execute);
repositoryMutations.set(key, current);
try { return await current; }
finally { if (repositoryMutations.get(key) === current) repositoryMutations.delete(key); }
@@ -139,7 +164,8 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
platform: process.platform,
state: store.getPublicState(),
git: await git.isAvailable(),
diagnostics: await diagnostics.getStatus()
diagnostics: await diagnostics.getStatus(),
updateResult: await updates.consumeLatestResult()
}));
register('dialog:select-directory', async ({ title = 'Select folder', defaultPath }) => {
@@ -156,6 +182,16 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
return result.canceled ? null : result.filePaths[0];
});
register('dialog:select-image-file', async ({ title = 'Select PNG image', defaultPath }) => {
const result = await dialog.showOpenDialog({
title,
defaultPath,
properties: ['openFile'],
filters: [{ name: 'PNG image', extensions: ['png'] }]
});
return result.canceled ? null : result.filePaths[0];
});
register('setup:preflight', ({ baseUrl, token, roots }) => preflight.runSystem({ baseUrl, token, roots }));
register('setup:validate-gitea', ({ baseUrl, token }) => gitea.validateConnection(baseUrl, token));
register('setup:complete', async ({ baseUrl, token, workspaceRoots }) => {
@@ -201,7 +237,8 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
register('updates:download', () => updates.download());
register('updates:apply', async () => {
const result = await updates.apply();
setTimeout(() => app.quit(), 650).unref?.();
if (!result?.confirmed) throw new Error('The update helper did not confirm ownership of the update. ForgeFlow will remain open.');
setTimeout(() => app.quit(), 350).unref?.();
return result;
});
@@ -286,7 +323,11 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
register('repository:stash-list', async ({ localPath }) => git.stashList(await assertKnownRepositoryPath(localPath)));
register('repository:stash-pop', async ({ localPath, ref }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.popStash(safePath, ref)); });
register('repository:index-lock', async ({ localPath }) => git.getIndexLockInfo(await assertKnownRepositoryPath(localPath)));
register('repository:git-recovery-status', async ({ localPath }) => git.reconcile(await assertKnownRepositoryPath(localPath)));
register('repository:repair-index-lock', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.removeStaleIndexLock(safePath)); });
register('repository:repair-git-locks', async ({ localPath, force = false }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.repairStaleGitLocks(safePath, { minimumAgeMs: force ? 0 : 10_000, allowWithoutProcessProbe: force === true })); });
register('repository:reconcile', async ({ localPath }) => git.reconcile(await assertKnownRepositoryPath(localPath)));
register('repository:repair-sync', async ({ localPath, strategy }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.repairSync(safePath, strategy)); });
register('repository:set-origin', async ({ localPath, remoteUrl }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.setRemoteUrl(safePath, remoteUrl)); });
register('repositories:normalize-origins', async () => {
@@ -371,13 +412,26 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
if (profile?.provider === 'ssh-unraid') return unraid.refreshProfileState(fullName, profileId);
return deployments.refreshProfileState(fullName, profileId);
});
register('deployment:apply-dockerman-metadata', async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
return unraid.applyDockerManMetadata({ repository: current, profileId });
});
register('deployment:reconcile', async ({ fullName, profileId }) => {
const profile = store.getDeploymentProfile(fullName, profileId);
if (profile?.provider !== 'ssh-unraid') return deployments.refreshProfileState(fullName, profileId);
const state = await unraid.refreshProfileState(fullName, profileId);
const operations = store.data.operations.filter((item) => item.profileId === profileId && item.provider === 'ssh-unraid' && !['success', 'failed', 'cancelled', 'rolled-back'].includes(item.status));
for (const operation of operations) await unraid.refreshOperation(operation.id);
return { state, operations: store.data.operations.filter((item) => item.profileId === profileId).slice(0, 10) };
});
register('operations:refresh', async ({ operationId }) => {
if (operationId) {
const operation = store.getOperation(operationId);
if (operation?.provider === 'ssh-unraid') return operation;
if (operation?.provider === 'ssh-unraid') return unraid.refreshOperation(operationId);
return deployments.refreshOperation(operationId);
}
return deployments.refreshActiveOperations();
const [actions, sshOperations] = await Promise.all([deployments.refreshActiveOperations(), unraid.refreshActiveOperations()]);
return [...actions, ...sshOperations];
});
register('operations:get', ({ operationId }) => store.getOperation(operationId));
+7 -1
View File
@@ -160,7 +160,13 @@ class RepositoryService {
const behind = status?.branch.behind || 0;
const conflict = Boolean(status?.counts.conflicts);
const profileForBranch = profiles.find((profile) => profile.branch === status?.branch.head);
const readyToDeploy = Boolean(profileForBranch && status?.head && status?.branch.upstream && !hasChanges && ahead === 0 && behind === 0);
const synchronized = Boolean(profileForBranch && status?.head && status?.branch.upstream && !hasChanges && ahead === 0 && behind === 0);
const alreadyLiveAndHealthy = Boolean(
synchronized
&& profileForBranch?.state?.liveSha === status.head
&& profileForBranch?.state?.healthy !== false
);
const readyToDeploy = synchronized && !alreadyLiveAndHealthy;
const key = String(remote.full_name || '').toLowerCase();
const preferredCloneUrl = this.store.data.preferences.preferredCloneProtocol === 'ssh'
? (remote.ssh_url || remote.clone_url)
+58
View File
@@ -2,6 +2,7 @@
const fs = require('node:fs/promises');
const crypto = require('node:crypto');
const path = require('node:path').posix;
function loadSshClient() {
try { return require('ssh2').Client; }
@@ -120,6 +121,63 @@ class SshService {
});
}
async uploadBuffer(serverId, remotePath, content, { mode = 0o600 } = {}) {
const server = this.store.getServer(serverId);
if (!server?.hostFingerprint) {
const error = new Error('Test and trust the SSH server fingerprint before uploading deployment assets.');
error.code = 'SSH_HOST_NOT_TRUSTED';
throw error;
}
const target = String(remotePath || '').replace(/\\/g, '/');
if (!target.startsWith('/') || target.includes('\0') || target.split('/').includes('..')) throw new Error('Remote upload path must be an absolute safe Unix path.');
const data = Buffer.isBuffer(content) ? content : Buffer.from(content);
return this.withClient(serverId, (client) => new Promise((resolve, reject) => {
client.sftp((sftpError, sftp) => {
if (sftpError) { reject(sftpError); return; }
const directory = path.dirname(target);
const mkdirParts = directory.split('/').filter(Boolean);
let current = '';
const makeNext = (index) => {
if (index >= mkdirParts.length) {
const stream = sftp.createWriteStream(target, { mode });
stream.once('error', reject);
stream.once('close', () => resolve({ remotePath: target, size: data.length }));
stream.end(data);
return;
}
current += `/${mkdirParts[index]}`;
const ensureDirectory = () => {
sftp.stat(current, (statError, attributes) => {
if (!statError) {
if (typeof attributes?.isDirectory === 'function' && !attributes.isDirectory()) {
reject(new Error(`Remote upload parent exists but is not a directory: ${current}`));
return;
}
makeNext(index + 1);
return;
}
if (![2, 'ENOENT'].includes(statError.code)) { reject(statError); return; }
sftp.mkdir(current, { mode: 0o755 }, (mkdirError) => {
if (!mkdirError) { makeNext(index + 1); return; }
sftp.stat(current, (retryError, retryAttributes) => {
if (!retryError && (typeof retryAttributes?.isDirectory !== 'function' || retryAttributes.isDirectory())) makeNext(index + 1);
else reject(mkdirError);
});
});
});
};
ensureDirectory();
};
makeNext(0);
});
}), { trustOnFirstUse: false });
}
async uploadFile(serverId, localPath, remotePath, options = {}) {
const data = await fs.readFile(localPath);
return this.uploadBuffer(serverId, remotePath, data, options);
}
async test(serverId, { trustOnFirstUse = true } = {}) {
return this.withClient(serverId, async (client, server, fingerprint) => {
const result = await this.execClient(client, 'uname -srm && command -v git && (docker compose version || docker-compose version)', { timeout: 30_000 });
+406 -91
View File
@@ -88,12 +88,36 @@ function checksSummary(checks) {
};
}
function xmlEscape(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function iconReferenceLocalPath(iconReference) {
const value = String(iconReference || '').trim();
if (value.startsWith('file:///')) return `/${value.slice('file:///'.length)}`;
if (value.startsWith('/')) return value;
return '';
}
class UnraidDeploymentService {
constructor({ store, ssh, git, diagnostics }) {
constructor({ store, ssh, git, diagnostics, sourcePath = process.cwd(), onOperationChange = null }) {
this.store = store;
this.ssh = ssh;
this.git = git;
this.diagnostics = diagnostics;
this.sourcePath = sourcePath;
this.onOperationChange = onOperationChange;
}
async saveOperation(operation) {
const saved = await this.store.addOperation(operation);
this.onOperationChange?.({ operations: [saved] });
return saved;
}
resolve(repository, profileId) {
@@ -225,6 +249,20 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths"
if (!server.hostFingerprint) checks.push({ id: 'host-key', label: 'Server identity', status: 'fail', detail: 'Test and trust the SSH host key first.' });
else checks.push({ id: 'host-key', label: 'Server identity', status: 'pass', detail: server.hostFingerprint });
const cloneUrl = String(profile.cloneUrl || repository.sshUrl || repository.preferredCloneUrl || '').trim();
if (!cloneUrl) {
checks.push({ id: 'server-git-access', label: 'Unraid → Gitea access', status: 'fail', detail: 'No server-usable Git clone URL is configured.' });
} else {
try {
const branchRef = `refs/heads/${String(profile.branch || 'main')}`;
const probe = await this.ssh.exec(server.id, bash(`git ls-remote --exit-code ${shellQuote(cloneUrl)} ${shellQuote(branchRef)}`), { timeout: 45_000, maxOutput: 256 * 1024 });
const remoteSha = String(probe.stdout || '').trim().split(/\s+/)[0] || 'reachable';
checks.push({ id: 'server-git-access', label: 'Unraid → Gitea access', status: 'pass', detail: `${cloneUrl} · ${String(remoteSha).slice(0, 7)}` });
} catch (error) {
checks.push({ id: 'server-git-access', label: 'Unraid → Gitea access', status: 'fail', detail: `Unraid cannot read the repository with the configured clone URL: ${error.message}` });
}
}
try {
inspection = await this.inspect({ repository, profileId });
if (!inspection.exists) {
@@ -274,6 +312,19 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths"
} catch (error) {
checks.push({ id: 'inspection', label: 'Server project inspection', status: 'fail', detail: error.message });
}
const iconMode = profile.iconMode || (profile.iconFilePath ? 'upload' : profile.iconUrl ? 'url' : 'builtin');
if (iconMode === 'upload') {
const iconStat = await fs.stat(profile.iconFilePath).catch(() => null);
checks.push({ id: 'dockerman-icon-file', label: 'DockerMan icon upload', status: iconStat?.isFile() && nativePath.extname(profile.iconFilePath).toLowerCase() === '.png' ? 'pass' : 'fail', detail: iconStat?.isFile() ? profile.iconFilePath : 'The selected local PNG icon file was not found.' });
} else if (iconMode === 'builtin') {
const builtinIcon = nativePath.join(this.sourcePath, 'src', 'renderer', 'assets', 'itworx-mark.png');
const iconStat = await fs.stat(builtinIcon).catch(() => null);
checks.push({ id: 'dockerman-icon-builtin', label: 'DockerMan icon', status: iconStat?.isFile() ? 'pass' : 'fail', detail: iconStat?.isFile() ? 'Built-in high-contrast ITWorx mark.' : 'The built-in ITWorx icon asset is missing.' });
} else if (iconMode === 'url') checks.push({ id: 'dockerman-icon', label: 'DockerMan icon', status: profile.iconUrl ? 'pass' : 'fail', detail: profile.iconUrl || 'Icon URL mode requires an HTTPS or HTTP PNG URL.' });
else checks.push({ id: 'dockerman-icon', label: 'DockerMan icon', status: 'warning', detail: 'Custom DockerMan icon disabled.' });
const webUiLabel = this.dockerManWebUi(profile);
checks.push({ id: 'dockerman-webui', label: 'DockerMan Web UI action', status: webUiLabel ? 'pass' : 'warning', detail: webUiLabel || 'No Web UI URL or host port is configured.' });
checks.push({ id: 'compose-identity', label: 'Safe Docker Compose identity', status: 'pass', detail: `Internal project/image: ${this.internalSlug(profile, repository)}; visible container: ${profile.containerName || profile.remoteFolder || repository.name}.` });
checks.push({ id: 'exact-sha', label: 'Exact deployment commit', status: 'pass', detail: targetSha });
return {
provider: 'ssh-unraid',
@@ -288,27 +339,148 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths"
};
}
internalSlug(profile, repository) {
return String(profile.remoteFolder || repository.name || profile.composeService || 'app')
.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'app';
}
generatedCompose(profile, repository) {
const service = String(profile.composeService || repository.name || 'app').toLowerCase().replace(/[^a-z0-9_-]/g, '-') || 'app';
const service = String(profile.composeService || repository.name || 'app').toLowerCase().replace(/[^a-z0-9._-]/g, '-') || 'app';
const containerName = String(profile.containerName || profile.remoteFolder || repository.name || service).replace(/[^A-Za-z0-9._-]/g, '-') || service;
if (!profile.hostPort || !profile.containerPort) throw new Error('Host and container ports are required for generated Compose.');
const labels = [
'net.unraid.docker.managed=dockerman',
profile.webUiUrl ? `net.unraid.docker.webui=${profile.webUiUrl}` : '',
profile.iconUrl ? `net.unraid.docker.icon=${profile.iconUrl}` : ''
].filter(Boolean);
return [
'services:',
` ${service}:`,
` image: forgeflow/${this.internalSlug(profile, repository)}:${String(profile.environment || 'production').toLowerCase()}`,
' build:',
' context: ..',
` container_name: ${service}`,
` container_name: ${containerName}`,
' restart: unless-stopped',
' ports:',
` - "${profile.hostPort}:${profile.containerPort}"`,
...(labels.length ? [' labels:', ...labels.map((label) => ` - ${JSON.stringify(label)}`)] : [])
` - "${profile.hostPort}:${profile.containerPort}"`
].join('\n') + '\n';
}
dockerManWebUi(profile) {
if (profile.hostPort) {
let suffix = '/';
try {
const parsed = profile.webUiUrl ? new URL(profile.webUiUrl) : null;
suffix = parsed ? `${parsed.pathname || '/'}${parsed.search || ''}${parsed.hash || ''}` : '/';
} catch {}
if (!suffix.startsWith('/')) suffix = `/${suffix}`;
return `http://[IP]:[PORT:${profile.hostPort}]${suffix}`;
}
return profile.webUiUrl || '';
}
dockerManShell(profile) {
return String(profile.dockerShell || '/bin/sh').toLowerCase().includes('bash') ? 'bash' : 'sh';
}
dockerManTemplatePath(profile, repository) {
const containerName = String(profile.containerName || profile.remoteFolder || repository.name || 'app').replace(/[^A-Za-z0-9._-]/g, '-') || 'app';
return `/boot/config/plugins/dockerMan/templates-user/my-${containerName}.xml`;
}
dockerManTemplate(profile, repository, iconReference = '') {
const containerName = String(profile.containerName || profile.remoteFolder || repository.name || 'app').replace(/[^A-Za-z0-9._-]/g, '-') || 'app';
const slug = this.internalSlug(profile, repository);
const environment = String(profile.environment || 'production').toLowerCase().replace(/[^a-z0-9._-]/g, '-') || 'production';
const image = `forgeflow/${slug}:${environment}`;
const webUi = this.dockerManWebUi(profile);
return [
'<?xml version="1.0"?>',
'<Container version="2">',
` <Name>${xmlEscape(containerName)}</Name>`,
` <Repository>${xmlEscape(image)}</Repository>`,
' <Registry/>',
' <Network>bridge</Network>',
' <MyIP/>',
` <Shell>${xmlEscape(this.dockerManShell(profile))}</Shell>`,
' <Privileged>false</Privileged>',
' <Support/>',
' <Project/>',
' <Overview>Managed by ForgeFlow through Docker Compose. Use ForgeFlow or the Compose files for configuration changes.</Overview>',
' <Category>Tools:</Category>',
` <WebUI>${xmlEscape(webUi)}</WebUI>`,
' <TemplateURL/>',
` <Icon>${xmlEscape(iconReference)}</Icon>`,
' <ExtraParams/>',
' <PostArgs/>',
' <CPUset/>',
' <DonateText/>',
' <DonateLink/>',
'</Container>'
].join('\n') + '\n';
}
iconCacheRefresh(profile, repository, iconReference = '') {
const containerName = String(profile.containerName || profile.remoteFolder || repository.name || 'app').replace(/[^A-Za-z0-9._-]/g, '-') || 'app';
const cacheLoop = `for icon_dir in /var/lib/docker/unraid/images /usr/local/emhttp/state/plugins/dynamix.docker.manager/images /var/local/emhttp/plugins/dynamix.docker.manager/images; do [ -d "$icon_dir" ] || continue; rm -f "$icon_dir/${containerName}-icon.png" "$icon_dir/${containerName}.png"; done`;
const invalidateMetadata = `rm -f /usr/local/emhttp/state/plugins/dynamix.docker.manager/docker.json`;
const localIconPath = iconReferenceLocalPath(iconReference);
if (!localIconPath) return `${cacheLoop}\n${invalidateMetadata}`;
return `${cacheLoop}
if [ -f ${shellQuote(localIconPath)} ]; then for icon_dir in /var/lib/docker/unraid/images /usr/local/emhttp/state/plugins/dynamix.docker.manager/images /var/local/emhttp/plugins/dynamix.docker.manager/images; do [ -d "$icon_dir" ] || continue; cp ${shellQuote(localIconPath)} "$icon_dir/${containerName}-icon.png"; chmod 0644 "$icon_dir/${containerName}-icon.png"; done; fi
${invalidateMetadata}`;
}
dockerManRefreshScript(profile, repository, iconReference = '') {
const templatePath = this.dockerManTemplatePath(profile, repository);
const template = this.dockerManTemplate(profile, repository, iconReference);
return `mkdir -p /boot/config/plugins/dockerMan/templates-user
cat > ${shellQuote(templatePath)} <<'FORGEFLOW_DOCKERMAN_TEMPLATE'
${template}FORGEFLOW_DOCKERMAN_TEMPLATE
chmod 0644 ${shellQuote(templatePath)}
${this.iconCacheRefresh(profile, repository, iconReference)}`;
}
metadataCompose(profile, repository, iconReference = '') {
const service = String(profile.composeService || repository.name || 'app').trim().toLowerCase().replace(/[^a-z0-9._-]/g, '-') || 'app';
const containerName = String(profile.containerName || profile.remoteFolder || repository.name || service).replace(/[^A-Za-z0-9._-]/g, '-') || 'app';
const slug = this.internalSlug(profile, repository);
const labels = {
'net.unraid.docker.managed': 'dockerman',
'net.unraid.docker.shell': this.dockerManShell(profile)
};
const webUiLabel = this.dockerManWebUi(profile);
if (webUiLabel) labels['net.unraid.docker.webui'] = webUiLabel;
if (iconReference) labels['net.unraid.docker.icon'] = iconReference;
return [
'services:',
` ${service}:`,
` image: forgeflow/${slug}:${String(profile.environment || 'production').toLowerCase().replace(/[^a-z0-9._-]/g, '-')}`,
` container_name: ${containerName}`,
' labels:',
...Object.entries(labels).map(([key, value]) => ` ${JSON.stringify(key)}: ${JSON.stringify(value)}`)
].join('\n') + '\n';
}
async prepareIcon(profile, repository, server) {
const mode = profile.iconMode || (profile.iconFilePath ? 'upload' : profile.iconUrl ? 'url' : 'builtin');
if (mode === 'none') return '';
if (mode === 'url') {
if (!profile.iconUrl) throw new Error('DockerMan icon URL mode is selected, but no icon URL is configured.');
return profile.iconUrl;
}
const localIconPath = mode === 'builtin'
? nativePath.join(this.sourcePath, 'src', 'renderer', 'assets', 'itworx-mark.png')
: profile.iconFilePath;
const stat = await fs.stat(localIconPath).catch(() => null);
if (!stat?.isFile()) throw new Error(mode === 'builtin' ? 'The built-in ITWorx DockerMan icon is missing.' : `The selected DockerMan icon file no longer exists: ${localIconPath}`);
if (nativePath.extname(localIconPath).toLowerCase() !== '.png') throw new Error('DockerMan icon upload currently accepts PNG files only.');
const containerName = String(profile.containerName || profile.remoteFolder || repository.name || 'app').replace(/[^A-Za-z0-9._-]/g, '-') || 'app';
const remoteIconPath = `/boot/config/plugins/dockerMan/images/${containerName}-icon.png`;
await this.ssh.uploadFile(server.id, localIconPath, remoteIconPath, { mode: 0o644 });
return `file://${remoteIconPath}`;
}
composeInvocation(profile, repository, composeFile) {
const slug = this.internalSlug(profile, repository);
return `docker compose -p ${shellQuote(slug)} -f ${shellQuote(composeFile)} -f '.forgeflow/compose.metadata.yml'`;
}
async checkHealth(url) {
if (!url) return { configured: false, healthy: null, status: null, latencyMs: null };
let last = null;
@@ -336,7 +508,7 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths"
throw error;
}
const requestId = crypto.randomUUID();
const operation = await this.store.addOperation({
const operation = await this.saveOperation({
id: requestId,
type: 'deployment',
action: 'deploy',
@@ -349,13 +521,15 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths"
sha: targetSha,
shortSha: targetSha.slice(0, 7),
status: 'running',
logs: ['SSH connection verified.', `Deploying exact commit ${targetSha}.`]
logs: ['Preflight passed.', 'Unraid can read the Gitea repository.', `Deploying exact commit ${targetSha} in the background.`]
});
const cloneUrl = String(profile.cloneUrl || repository.sshUrl || repository.preferredCloneUrl || '').trim();
if (!cloneUrl) throw new Error('No server-usable Git clone URL is configured.');
const composeFile = profile.generatedCompose ? '.forgeflow/compose.forgeflow.yml' : safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml');
const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : '';
const iconReference = await this.prepareIcon(profile, repository, server);
const metadata = this.metadataCompose(profile, repository, iconReference);
const compose = this.composeInvocation(profile, repository, composeFile);
const branch = String(profile.branch || 'main');
const statusJson = JSON.stringify({
repository: repository.fullName,
@@ -377,7 +551,7 @@ fi
test -d "$root/.git" || { echo "Existing folder is not a Git working tree" >&2; exit 32; }
${profile.alignRemote ? `git -C "$root" remote set-url origin ${shellQuote(cloneUrl)}` : ''}
changes=$(git -C "$root" status --porcelain --untracked-files=no)
test -z "$changes" || { echo "Tracked server-side changes block deployment" >&2; printf '%s\\n' "$changes" >&2; exit 33; }
test -z "$changes" || { echo "Tracked server-side changes block deployment" >&2; printf '%s\n' "$changes" >&2; exit 33; }
git -C "$root" fetch --prune origin ${shellQuote(branch)}
git -C "$root" cat-file -e ${shellQuote(`${targetSha}^{commit}`)}
git -C "$root" merge-base --is-ancestor ${shellQuote(targetSha)} ${shellQuote(`origin/${branch}`)}
@@ -388,71 +562,103 @@ mkdir -p "$root/.forgeflow"
printf '%s' "$previous" > "$root/.forgeflow/previous-sha"
printf '%s' ${shellQuote(targetSha)} > "$root/.forgeflow/current-sha"
${profile.generatedCompose ? `cat > "$root/.forgeflow/compose.forgeflow.yml" <<'FORGEFLOW_COMPOSE'\n${generated}FORGEFLOW_COMPOSE` : ''}
cat > "$root/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA'
${metadata}FORGEFLOW_METADATA
cd "$root"
docker compose -f ${shellQuote(composeFile)} config >/dev/null
docker compose -f ${shellQuote(composeFile)} up -d --build --remove-orphans
${compose} config >/dev/null
${compose} up -d --build --remove-orphans --force-recreate
${this.dockerManRefreshScript(profile, repository, iconReference)}
container=${shellQuote(String(profile.containerName || profile.remoteFolder || repository.name))}
docker inspect "$container" >/dev/null
cat > "$root/.forgeflow/status.json" <<'FORGEFLOW_STATUS'
${statusJson}
FORGEFLOW_STATUS
`;
try {
const result = await this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 4 * 1024 * 1024 });
const health = await this.checkHealth(profile.healthcheckUrl);
const finalStatus = health.healthy === false ? 'failed' : 'success';
const finalLogs = [
...operation.logs,
...result.stdout.trim().split('\n').filter(Boolean).slice(-60),
'Docker Compose deployment completed.',
health.configured ? `Healthcheck ${health.healthy ? 'passed' : 'failed'}${health.status ? ` with HTTP ${health.status}` : ''}.` : 'No desktop healthcheck URL configured.'
];
const completed = await this.store.addOperation({
...operation,
status: finalStatus,
previousSha: preflight.inspection?.head || null,
health,
logs: finalLogs,
error: health.healthy === false ? 'The application healthcheck did not pass after deployment.' : null
});
await this.store.saveDeploymentState(profileId, {
liveSha: targetSha,
previousSha: preflight.inspection?.head || null,
healthy: health.healthy,
healthStatus: health.status,
healthLatencyMs: health.latencyMs,
requestId,
remotePath,
provider: 'ssh-unraid'
});
await this.diagnostics?.info('unraid.deployment.completed', {
requestId,
repository: repository.fullName,
serverId: server.id,
remotePath,
sha: targetSha,
healthy: health.healthy,
healthStatus: health.status
});
if (health.healthy === false) {
const error = new Error('Deployment completed, but the configured healthcheck failed. The previous SHA remains available for rollback.');
error.code = 'DEPLOYMENT_HEALTHCHECK_FAILED';
error.operationId = completed.id;
throw error;
void (async () => {
try {
const result = await this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 4 * 1024 * 1024 });
const health = await this.checkHealth(profile.healthcheckUrl);
// The remote deployment script already verifies that Docker created the expected
// container. Complete the operation before a secondary state inspection so a slow or
// failed refresh cannot leave ForgeFlow stuck in deployment mode after a successful run.
const effectiveHealthy = health.configured ? health.healthy : true;
const finalStatus = effectiveHealthy === false ? 'failed' : 'success';
const finalLogs = [
...operation.logs,
...result.stdout.trim().split('\n').filter(Boolean).slice(-60),
'Docker Compose deployment completed.',
health.configured
? `Healthcheck ${health.healthy ? 'passed' : 'failed'}${health.status ? ` with HTTP ${health.status}` : ''}.`
: 'No desktop healthcheck URL configured; the remote container inspection passed.'
];
await this.saveOperation({
...operation,
status: finalStatus,
previousSha: preflight.inspection?.head || null,
health: { ...health, healthy: effectiveHealthy },
logs: finalLogs,
error: effectiveHealthy === false ? 'The application healthcheck did not pass after deployment.' : null
});
await this.store.saveDeploymentState(profileId, {
liveSha: targetSha,
previousSha: preflight.inspection?.head || null,
healthy: effectiveHealthy,
healthStatus: health.status ?? null,
healthLatencyMs: health.latencyMs ?? null,
requestId,
remotePath,
provider: 'ssh-unraid',
containerName: String(profile.containerName || profile.remoteFolder || repository.name),
containerRunning: true,
dockerMan: {
webUi: this.dockerManWebUi(profile),
icon: iconReference,
shell: this.dockerManShell(profile),
templateExists: true,
configured: Boolean(this.dockerManWebUi(profile) || iconReference)
},
webUiUrl: profile.webUiUrl || (profile.hostPort ? `http://${server.host}:${profile.hostPort}/` : null)
});
// Reconcile authoritative Unraid/Docker state in the background and preserve the already
// completed operation if that follow-up inspection is unavailable.
void this.refreshProfileState(repository.fullName, profileId).catch(async (refreshError) => {
await this.diagnostics?.warning('unraid.deployment.post-refresh-failed', {
requestId,
repository: repository.fullName,
serverId: server.id,
error: refreshError
});
});
await this.diagnostics?.info('unraid.deployment.completed', {
requestId,
repository: repository.fullName,
serverId: server.id,
remotePath,
sha: targetSha,
healthy: effectiveHealthy,
healthStatus: health.status ?? null
});
} catch (error) {
await this.saveOperation({
...operation,
status: 'failed',
error: error.message,
failure: { stage: 'SSH / Docker deployment', message: error.message },
logs: [...operation.logs, error.message]
});
await this.diagnostics?.error('unraid.deployment.failed', {
requestId,
repository: repository.fullName,
serverId: server.id,
remotePath,
sha: targetSha,
error
});
}
return completed;
} catch (error) {
if (error.code !== 'DEPLOYMENT_HEALTHCHECK_FAILED') {
await this.store.addOperation({ ...operation, status: 'failed', error: error.message, logs: [...operation.logs, error.message] });
}
await this.diagnostics?.error('unraid.deployment.failed', {
requestId,
repository: repository.fullName,
serverId: server.id,
remotePath,
sha: targetSha,
error
});
throw error;
}
})();
return operation;
}
async rollback({ repository, profileId, targetSha }) {
@@ -470,8 +676,11 @@ FORGEFLOW_STATUS
if (!inspection.rootGit) throw new Error('The configured server project is not a root Git working tree.');
if (inspection.trackedChanges.length) throw new Error('Tracked server-side changes block rollback. Commit, revert or migrate them first.');
const composeFile = profile.generatedCompose ? '.forgeflow/compose.forgeflow.yml' : safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml');
const iconReference = await this.prepareIcon(profile, repository, server);
const metadata = this.metadataCompose(profile, repository, iconReference);
const compose = this.composeInvocation(profile, repository, composeFile);
const requestId = crypto.randomUUID();
const operation = await this.store.addOperation({
const operation = await this.saveOperation({
id: requestId,
type: 'deployment',
action: 'rollback',
@@ -504,9 +713,12 @@ git -C "$root" fetch --prune origin ${shellQuote(profile.branch)}
git -C "$root" cat-file -e ${shellQuote(`${target}^{commit}`)}
current=$(git -C "$root" rev-parse HEAD)
git -C "$root" reset --hard ${shellQuote(target)}
cat > "$root/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA'
${metadata}FORGEFLOW_METADATA
cd "$root"
docker compose -f ${shellQuote(composeFile)} config >/dev/null
docker compose -f ${shellQuote(composeFile)} up -d --build --remove-orphans
${compose} config >/dev/null
${compose} up -d --build --remove-orphans --force-recreate
${this.dockerManRefreshScript(profile, repository, iconReference)}
printf '%s' "$current" > "$root/.forgeflow/previous-sha"
printf '%s' ${shellQuote(target)} > "$root/.forgeflow/current-sha"
cat > "$root/.forgeflow/status.json" <<'FORGEFLOW_STATUS'
@@ -517,7 +729,7 @@ FORGEFLOW_STATUS
const result = await this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 4 * 1024 * 1024 });
const health = await this.checkHealth(profile.healthcheckUrl);
const finalStatus = health.healthy === false ? 'failed' : 'rolled-back';
const completed = await this.store.addOperation({
const completed = await this.saveOperation({
...operation,
status: finalStatus,
previousSha: deploymentState.liveSha || inspection.head || null,
@@ -549,7 +761,7 @@ FORGEFLOW_STATUS
return completed;
} catch (error) {
if (error.code !== 'ROLLBACK_HEALTHCHECK_FAILED') {
await this.store.addOperation({ ...operation, status: 'failed', error: error.message, logs: [...operation.logs, error.message] });
await this.saveOperation({ ...operation, status: 'failed', error: error.message, logs: [...operation.logs, error.message] });
}
throw error;
}
@@ -558,30 +770,131 @@ FORGEFLOW_STATUS
async refreshProfileState(fullName, profileId) {
const repository = { fullName, name: fullName.split('/').pop() };
const { profile, server, remotePath } = this.resolve(repository, profileId);
const containerName = String(profile.containerName || profile.remoteFolder || repository.name);
const script = `
root=${shellQuote(remotePath)}
live=""; previous=""; status=""
container=${shellQuote(containerName)}
template_path=${shellQuote('/boot/config/plugins/dockerMan/templates-user/my-' + containerName + '.xml')}
live=""; previous=""; running=false; docker_health=""; webui=""; icon=""; shell_label=""; template_exists=false
[ -f "$template_path" ] && template_exists=true
[ -f "$root/.forgeflow/current-sha" ] && live=$(cat "$root/.forgeflow/current-sha")
[ -z "$live" ] && [ -d "$root/.git" ] && live=$(git -C "$root" rev-parse HEAD 2>/dev/null || true)
[ -f "$root/.forgeflow/previous-sha" ] && previous=$(cat "$root/.forgeflow/previous-sha")
[ -f "$root/.forgeflow/status.json" ] && status=$(base64 "$root/.forgeflow/status.json" | tr -d '\\r\\n')
printf '__FORGEFLOW_JSON__\\n{"liveSha":"%s","previousSha":"%s","statusBase64":"%s"}\\n' "$live" "$previous" "$status"
if docker inspect "$container" >/dev/null 2>&1; then
running=$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null || echo false)
docker_health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container" 2>/dev/null || true)
webui=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.webui"}}' "$container" 2>/dev/null || true)
icon=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.icon"}}' "$container" 2>/dev/null || true)
shell_label=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.shell"}}' "$container" 2>/dev/null || true)
fi
printf '__FORGEFLOW_KV__\n'
printf 'liveSha=%s\n' "$live"
printf 'previousSha=%s\n' "$previous"
printf 'containerRunning=%s\n' "$running"
printf 'dockerHealth=%s\n' "$docker_health"
printf 'webUiLabel=%s\n' "$(printf '%s' "$webui" | base64 | tr -d '\r\n')"
printf 'iconLabel=%s\n' "$(printf '%s' "$icon" | base64 | tr -d '\r\n')"
printf 'shellLabel=%s\n' "$(printf '%s' "$shell_label" | base64 | tr -d '\r\n')"
printf 'templateExists=%s\n' "$template_exists"
`;
const result = await this.ssh.exec(server.id, bash(script), { timeout: 30_000 });
const raw = parseInspection(result.stdout);
let remoteStatus = null;
try { remoteStatus = raw.statusBase64 ? JSON.parse(Buffer.from(raw.statusBase64, 'base64').toString('utf8')) : null; } catch {}
const existing = this.store.getDeploymentState(profile.id) || {};
const marker = result.stdout.lastIndexOf('__FORGEFLOW_KV__');
if (marker < 0) throw new Error('Unraid state inspection did not return a ForgeFlow marker.');
const fields = {};
for (const line of result.stdout.slice(marker + '__FORGEFLOW_KV__'.length).trim().split(/\r?\n/)) {
const index = line.indexOf('=');
if (index > 0) fields[line.slice(0, index)] = line.slice(index + 1);
}
const decode = (value) => { try { return value ? Buffer.from(value, 'base64').toString('utf8') : ''; } catch { return ''; } };
const health = await this.checkHealth(profile.healthcheckUrl);
const dockerHealthy = fields.dockerHealth ? fields.dockerHealth === 'healthy' : null;
const effectiveHealthy = health.configured ? health.healthy : (dockerHealthy ?? (fields.containerRunning === 'true' ? true : false));
return this.store.saveDeploymentState(profile.id, {
liveSha: /^[0-9a-f]{40}$/i.test(raw.liveSha || '') ? raw.liveSha : null,
previousSha: /^[0-9a-f]{40}$/i.test(raw.previousSha || '') ? raw.previousSha : null,
healthy: remoteStatus?.healthy ?? existing.healthy ?? null,
healthStatus: existing.healthStatus ?? null,
healthLatencyMs: existing.healthLatencyMs ?? null,
requestId: remoteStatus?.request_id || existing.requestId || null,
liveSha: /^[0-9a-f]{40}$/i.test(fields.liveSha || '') ? fields.liveSha : null,
previousSha: /^[0-9a-f]{40}$/i.test(fields.previousSha || '') ? fields.previousSha : null,
healthy: effectiveHealthy,
healthStatus: health.status,
healthLatencyMs: health.latencyMs,
containerName,
containerRunning: fields.containerRunning === 'true',
dockerHealth: fields.dockerHealth || null,
dockerMan: {
webUi: decode(fields.webUiLabel),
icon: decode(fields.iconLabel),
shell: decode(fields.shellLabel),
templateExists: fields.templateExists === 'true',
configured: Boolean(decode(fields.webUiLabel) || decode(fields.iconLabel) || fields.templateExists === 'true')
},
webUiUrl: profile.webUiUrl || (profile.hostPort ? `http://${server.host}:${profile.hostPort}/` : null),
remotePath,
provider: 'ssh-unraid'
});
}
async applyDockerManMetadata({ repository, profileId }) {
const { profile, server, remotePath } = this.resolve(repository, profileId);
const composeFile = profile.generatedCompose ? '.forgeflow/compose.forgeflow.yml' : safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml');
const iconReference = await this.prepareIcon(profile, repository, server);
const metadata = this.metadataCompose(profile, repository, iconReference);
const compose = this.composeInvocation(profile, repository, composeFile);
const script = `
root=${shellQuote(remotePath)}
test -d "$root/.git"
mkdir -p "$root/.forgeflow"
cat > "$root/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA'
${metadata}FORGEFLOW_METADATA
cd "$root"
${compose} config >/dev/null
${compose} up -d --build --remove-orphans --force-recreate
${this.dockerManRefreshScript(profile, repository, iconReference)}
`;
await this.ssh.exec(server.id, bash(script), { timeout: 10 * 60_000, maxOutput: 2 * 1024 * 1024 });
return this.refreshProfileState(repository.fullName, profileId);
}
async refreshOperation(operationId) {
const operation = this.store.getOperation(operationId);
if (!operation || operation.provider !== 'ssh-unraid') return operation;
if (['success', 'failed', 'cancelled', 'rolled-back'].includes(operation.status)) return operation;
try {
const state = await this.refreshProfileState(operation.repository, operation.profileId);
if (state.liveSha === operation.sha && state.containerRunning && state.healthy !== false) {
return this.saveOperation({
...operation,
status: operation.action === 'rollback' ? 'rolled-back' : 'success',
health: { healthy: state.healthy, status: state.healthStatus },
logs: [...(operation.logs || []), 'Deployment state reconciled from Unraid.']
});
}
if (/^[0-9a-f]{40}$/i.test(String(state.liveSha || '')) && state.liveSha !== operation.sha && state.containerRunning && state.healthy !== false) {
return this.saveOperation({
...operation,
status: 'cancelled',
error: `Superseded by live commit ${state.liveSha.slice(0, 7)}.`,
health: { healthy: state.healthy, status: state.healthStatus },
logs: [...(operation.logs || []), `Operation superseded by live Unraid commit ${state.liveSha}.`]
});
}
const ageMs = Date.now() - new Date(operation.updatedAt || operation.createdAt || 0).getTime();
if (ageMs > 45 * 60_000) {
return this.saveOperation({
...operation,
status: 'failed',
error: 'Deployment was interrupted or did not reach the requested commit within 45 minutes.',
logs: [...(operation.logs || []), 'Stale deployment was marked failed during reconciliation.']
});
}
return operation;
} catch {
return operation;
}
}
async refreshActiveOperations() {
const active = this.store.data.operations.filter((item) => item.provider === 'ssh-unraid' && item.type === 'deployment' && !['success', 'failed', 'cancelled', 'rolled-back'].includes(item.status));
return Promise.all(active.map((item) => this.refreshOperation(item.id)));
}
}
module.exports = {
@@ -591,5 +904,7 @@ module.exports = {
parseInspection,
dockerIgnoreHasPath,
checksSummary,
xmlEscape,
iconReferenceLocalPath,
bash
};
+165 -15
View File
@@ -1,6 +1,7 @@
'use strict';
const fs = require('node:fs/promises');
const fsSync = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const { spawn } = require('node:child_process');
@@ -12,14 +13,73 @@ function safeRepositoryPart(value, label) {
return text;
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function resolveWindowsPowerShellPath(environment = process.env) {
const windowsRoot = environment.SystemRoot || environment.WINDIR;
if (windowsRoot) {
const absolute = path.join(windowsRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
if (fsSync.existsSync(absolute)) return absolute;
}
return 'powershell.exe';
}
async function readJsonFile(filePath) {
try { return JSON.parse(await fs.readFile(filePath, 'utf8')); }
catch { return null; }
}
async function waitForUpdaterStarted(statusPath, {
timeoutMs = 12000,
pollMs = 100,
childState = null
} = {}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const status = await readJsonFile(statusPath);
if (status && ['started', 'waiting-for-exit', 'backing-up', 'extracting', 'applying', 'validating'].includes(status.state)) {
return status;
}
if (childState?.error) throw childState.error;
if (childState?.exited) {
const error = new Error(`The update helper exited before it confirmed startup (exit code ${childState.code ?? 'unknown'}).`);
error.code = 'UPDATE_HELPER_EXITED_EARLY';
throw error;
}
await delay(pollMs);
}
const error = new Error('The update helper did not confirm startup. ForgeFlow was left open and no source files were changed.');
error.code = 'UPDATE_HELPER_START_TIMEOUT';
throw error;
}
class UpdateService {
constructor({ store, gitea, diagnostics, appInfo, sourcePath, userDataPath }) {
constructor({
store,
gitea,
diagnostics,
appInfo,
sourcePath,
userDataPath,
platform = process.platform,
spawnProcess = spawn,
powershellPath = null,
handshakeTimeoutMs = 12000,
handshakePollMs = 100
}) {
this.store = store;
this.gitea = gitea;
this.diagnostics = diagnostics;
this.appInfo = appInfo;
this.sourcePath = sourcePath;
this.updateDirectory = path.join(userDataPath, 'updates');
this.platform = platform;
this.spawnProcess = spawnProcess;
this.powershellPath = powershellPath;
this.handshakeTimeoutMs = handshakeTimeoutMs;
this.handshakePollMs = handshakePollMs;
this.staged = null;
}
@@ -99,37 +159,127 @@ class UpdateService {
async apply(staged = null) {
const update = staged?.archivePath ? staged : this.staged;
if (!update?.archivePath) throw new Error('Download an update before applying it.');
if (process.platform !== 'win32') throw new Error('The integrated source updater currently supports Windows only.');
if (this.platform !== 'win32') throw new Error('The integrated source updater currently supports Windows only.');
const stat = await fs.stat(update.archivePath).catch(() => null);
if (!stat?.isFile()) throw new Error('The staged update archive is no longer available.');
const scriptPath = path.join(this.sourcePath, 'scripts', 'apply-source-update.ps1');
const scriptStat = await fs.stat(scriptPath).catch(() => null);
if (!scriptStat?.isFile()) throw new Error('The source update helper is missing.');
const logPath = path.join(this.updateDirectory, `apply-${Date.now()}.log`);
await fs.mkdir(this.updateDirectory, { recursive: true });
const updateId = `${Date.now()}-${crypto.randomUUID()}`;
const logPath = path.join(this.updateDirectory, `apply-${updateId}.log`);
const statusPath = path.join(this.updateDirectory, `apply-${updateId}.status.json`);
const launching = {
schemaVersion: 1,
updateId,
state: 'launching',
expectedVersion: update.remoteVersion,
sourcePath: this.sourcePath,
logPath,
statusPath,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
await fs.writeFile(statusPath, JSON.stringify(launching, null, 2), { mode: 0o600 });
const executable = this.powershellPath || resolveWindowsPowerShellPath();
const args = [
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath,
'-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath,
'-SourcePath', this.sourcePath,
'-ArchivePath', update.archivePath,
'-ExpectedVersion', update.remoteVersion,
'-ExpectedSha256', update.sha256,
'-ParentPid', String(process.pid),
'-LogPath', logPath
'-LogPath', logPath,
'-StatusPath', statusPath,
'-UpdateId', updateId
];
const child = spawn('powershell.exe', args, {
detached: true,
stdio: 'ignore',
windowsHide: false,
cwd: this.sourcePath
const childState = { exited: false, code: null, error: null };
let child;
try {
child = this.spawnProcess(executable, args, {
detached: true,
stdio: 'ignore',
windowsHide: true,
cwd: this.sourcePath
});
} catch (error) {
error.code ||= 'UPDATE_HELPER_SPAWN_FAILED';
throw error;
}
child.once?.('error', (error) => { childState.error = error; });
child.once?.('exit', (code) => { childState.exited = true; childState.code = code; });
await new Promise((resolve, reject) => {
let settled = false;
const finish = (handler, value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
handler(value);
};
const timer = setTimeout(() => finish(reject, Object.assign(new Error('Windows did not start the update helper process.'), { code: 'UPDATE_HELPER_SPAWN_TIMEOUT' })), 5000);
child.once?.('spawn', () => finish(resolve));
child.once?.('error', (error) => finish(reject, error));
if (!child.once) finish(resolve);
});
child.unref();
await this.diagnostics?.info('updates.apply-launched', {
child.unref?.();
const started = await waitForUpdaterStarted(statusPath, {
timeoutMs: this.handshakeTimeoutMs,
pollMs: this.handshakePollMs,
childState
});
await this.diagnostics?.info('updates.apply-started', {
updateId,
remoteVersion: update.remoteVersion,
remoteSha: update.remoteSha,
logPath
logPath,
statusPath,
helperPid: child.pid,
helperState: started.state
});
return { launched: true, version: update.remoteVersion, logPath };
return { launched: true, confirmed: true, updateId, version: update.remoteVersion, logPath, statusPath };
}
async consumeLatestResult() {
await fs.mkdir(this.updateDirectory, { recursive: true });
const entries = await fs.readdir(this.updateDirectory, { withFileTypes: true }).catch(() => []);
const candidates = [];
for (const entry of entries) {
if (!entry.isFile() || !/^apply-.*\.status\.json$/i.test(entry.name)) continue;
const filePath = path.join(this.updateDirectory, entry.name);
const stat = await fs.stat(filePath).catch(() => null);
if (stat) candidates.push({ filePath, mtimeMs: stat.mtimeMs });
}
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
for (const candidate of candidates) {
const status = await readJsonFile(candidate.filePath);
if (!status || status.acknowledgedAt || !['success', 'rolled-back', 'failed'].includes(status.state)) continue;
status.acknowledgedAt = new Date().toISOString();
await fs.writeFile(candidate.filePath, JSON.stringify(status, null, 2), { mode: 0o600 });
return {
state: status.state,
expectedVersion: status.expectedVersion || null,
installedVersion: status.installedVersion || null,
message: status.message || '',
logPath: status.logPath || null,
restartLaunched: Boolean(status.restartLaunched),
completedAt: status.completedAt || status.updatedAt || null
};
}
return null;
}
}
module.exports = { UpdateService, safeRepositoryPart };
module.exports = {
UpdateService,
safeRepositoryPart,
resolveWindowsPowerShellPath,
waitForUpdaterStarted,
readJsonFile
};
+224 -27
View File
@@ -44,7 +44,8 @@ const icons = {
download: '<path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M4 21h16"/>',
server: '<rect x="3" y="4" width="18" height="6" rx="2"/><rect x="3" y="14" width="18" height="6" rx="2"/><path d="M7 7h.01M7 17h.01"/>',
key: '<circle cx="8" cy="15" r="4"/><path d="m11 12 9-9M16 7l2 2M14 9l2 2"/>',
update: '<path d="M21 12a9 9 0 0 1-15.3 6.4L3 16"/><path d="M3 21v-5h5"/><path d="M3 12A9 9 0 0 1 18.3 5.6L21 8"/><path d="M21 3v5h-5"/>'
update: '<path d="M21 12a9 9 0 0 1-15.3 6.4L3 16"/><path d="M3 21v-5h5"/><path d="M3 12A9 9 0 0 1 18.3 5.6L21 8"/><path d="M21 3v5h-5"/>',
wrench: '<path d="M14.7 6.3a4 4 0 0 0-5-5l2.1 2.1-2.8 2.8-2.1-2.1a4 4 0 0 0 5 5L20 17.2 17.2 20l-8.1-8.1a4 4 0 0 0-5-5l2.1 2.1-2.8 2.8-2.1-2.1a4 4 0 0 0 5 5"/>'
};
function icon(name, className = '') {
@@ -116,6 +117,7 @@ const ui = {
setupDraft: { baseUrl: 'https://', token: '', user: null, roots: [], discovered: [] },
setupValidation: null,
activeDeployment: null,
operationPollTimer: null,
isMock: false,
refreshError: null,
autoRefreshPending: false,
@@ -123,7 +125,8 @@ const ui = {
updateStatus: null,
updateChecking: false,
servers: [],
serverInspection: null
serverInspection: null,
gitRecovery: null
};
function selectedRepository() { return ui.repositories.find((repository) => String(repository.id) === String(ui.selectedRepoId)) || null; }
@@ -157,6 +160,30 @@ function updateOperationInState(operation) {
if (ui.activeDeployment?.id === operation.id) ui.activeDeployment = operation;
}
function stopOperationPolling() {
if (ui.operationPollTimer) clearTimeout(ui.operationPollTimer);
ui.operationPollTimer = null;
}
function startOperationPolling() {
stopOperationPolling();
const operationId = ui.activeDeployment?.id;
if (!operationId || isTerminalOperation(ui.activeDeployment.status)) return;
const seconds = Math.max(2, Number(ui.boot?.state?.preferences?.operationPollSeconds) || 3);
ui.operationPollTimer = setTimeout(async () => {
try {
const operation = await window.forgeflow.refreshOperations(operationId);
if (operation) updateOperationInState(operation);
render();
if (operation && !isTerminalOperation(operation.status)) startOperationPolling();
else stopOperationPolling();
} catch (error) {
showToast('Deployment status refresh failed', error.message, 'error');
stopOperationPolling();
}
}, seconds * 1000);
}
async function bootstrap() {
try {
ui.boot = await window.forgeflow.bootstrap();
@@ -166,11 +193,14 @@ async function bootstrap() {
ui.setupDraft.roots = [...(ui.boot.state.workspaceRoots || [])];
if (ui.boot.state.setupComplete) {
await refreshRepositories(false);
await refreshActiveOperations(false);
const reconciled = await refreshActiveOperations(false);
if ((Array.isArray(reconciled) ? reconciled : []).some((operation) => isTerminalOperation(operation.status))) await refreshRepositories(false);
}
window.forgeflow.onRepositoriesChanged?.(() => scheduleAutoRefresh());
window.forgeflow.onOperationsChanged?.((payload) => {
for (const operation of payload?.operations || []) updateOperationInState(operation);
const changed = payload?.operations || [];
for (const operation of changed) updateOperationInState(operation);
if (changed.some((operation) => isTerminalOperation(operation.status))) scheduleAutoRefresh(250);
render();
});
window.forgeflow.onUpdatesChanged?.((payload) => {
@@ -179,6 +209,16 @@ async function bootstrap() {
if (payload?.available) showToast('ForgeFlow update available', `Version ${payload.remoteVersion} is ready to download.`, 'success');
});
render();
const updateResult = ui.boot.updateResult;
if (updateResult?.state === 'success') {
const restartNote = updateResult.restartLaunched ? '' : ' Automatic restart was unavailable, but the update itself succeeded.';
showToast('ForgeFlow updated successfully', `Version ${updateResult.installedVersion || updateResult.expectedVersion || ui.boot.appVersion} is installed.${restartNote}`, 'success');
} else if (updateResult?.state === 'rolled-back') {
showToast('ForgeFlow update rolled back', updateResult.message || 'The update failed and the previous version was restored.', 'error');
} else if (updateResult?.state === 'failed') {
showToast('ForgeFlow update failed', updateResult.message || 'See the update log for technical details.', 'error');
}
setTimeout(() => { void refreshDeploymentTruth(false); }, 500);
} catch (error) {
app.innerHTML = `<div class="boot-screen">${icon('error')}<strong>ForgeFlow could not start</strong><span>${escapeHtml(error.message)}</span></div>`;
}
@@ -225,11 +265,37 @@ async function refreshActiveOperations(showErrors = true) {
for (const operation of Array.isArray(updated) ? updated : []) updateOperationInState(operation);
return updated;
} catch (error) {
if (showErrors) showToast('Actions status unavailable', error.message, 'error');
if (showErrors) showToast('Deployment status unavailable', error.message, 'error');
return [];
}
}
async function refreshDeploymentTruth(showErrors = false) {
const targets = ui.repositories.flatMap((repository) =>
(repository.deploymentProfiles || []).map((profile) => ({ repository, profile }))
);
if (!targets.length) return { checked: 0, failed: 0 };
const failures = [];
const queue = [...targets];
const workers = Array.from({ length: Math.min(3, queue.length) }, async () => {
while (queue.length) {
const target = queue.shift();
try {
target.profile.state = await window.forgeflow.refreshProfileState(target.repository.fullName, target.profile.id);
} catch (error) {
failures.push({ repository: target.repository.fullName, profile: target.profile.name, message: error.message });
}
}
});
await Promise.all(workers);
await refreshRepositories(false);
if (showErrors && failures.length) {
showToast('Some environments could not be checked', `${failures.length} profile${failures.length === 1 ? '' : 's'} could not be refreshed. Open Deployments for details.`, 'error');
}
return { checked: targets.length, failed: failures.length };
}
function selectRepository(id, shouldRender = true) {
ui.selectedRepoId = id;
ui.currentView = 'repository';
@@ -238,6 +304,7 @@ function selectRepository(id, shouldRender = true) {
ui.history = [];
ui.branches = [];
ui.stashes = [];
ui.gitRecovery = null;
const repository = selectedRepository();
ui.selectedProfileId = selectedProfile(repository)?.id || null;
const files = repository?.localStatus?.files || [];
@@ -264,7 +331,7 @@ function repositoryAction(repository) {
if (!status) return { kind: 'error', title: 'Local repository unavailable', detail: repository.attentionReason || 'The linked folder could not be read.' };
if (status.counts.conflicts) return { kind: 'conflict', title: 'Resolve merge conflicts', detail: `${status.counts.conflicts} conflicted file${status.counts.conflicts === 1 ? '' : 's'} block synchronization.` };
if (status.counts.changed) return { kind: 'commit', title: 'Commit local changes', detail: `${status.counts.changed} changed file${status.counts.changed === 1 ? '' : 's'} detected.` };
if (status.branch.behind && status.branch.ahead) return { kind: 'diverged', title: 'Branches have diverged', detail: `Local is ${status.branch.ahead} ahead and ${status.branch.behind} behind. Resolve this in your Git tooling.` };
if (status.branch.behind && status.branch.ahead) return { kind: 'diverged', title: 'Branches have diverged', detail: `Local is ${status.branch.ahead} ahead and ${status.branch.behind} behind. ForgeFlow can create a safety branch and repair this from Git tools.` };
if (status.branch.behind) return { kind: 'pull', title: 'Synchronize from Gitea', detail: `Local ${status.branch.head} is ${status.branch.behind} commit${status.branch.behind === 1 ? '' : 's'} behind.` };
if (status.branch.ahead) return { kind: 'push', title: 'Push local commits', detail: `${status.branch.ahead} commit${status.branch.ahead === 1 ? '' : 's'} ready to push.` };
if (!repository.deploymentProfiles?.length) return { kind: 'configure', title: 'Configure deployment', detail: 'Connect a predefined Gitea Actions workflow before deploying.' };
@@ -428,6 +495,23 @@ function environmentState(profile) {
return { label: 'Status not configured', tone: '' };
}
function dockerManIntegration(profile) {
const state = profile.state || {};
const iconMode = profile.iconMode || (profile.iconFilePath ? 'upload' : profile.iconUrl ? 'url' : 'builtin');
const webUiExpected = Boolean(profile.webUiUrl || profile.hostPort);
const iconExpected = iconMode !== 'none';
const templateReady = Boolean(state.dockerMan?.templateExists);
const webUiReady = !webUiExpected || Boolean(state.dockerMan?.webUi) || templateReady;
const iconReady = !iconExpected || Boolean(state.dockerMan?.icon) || templateReady;
return {
iconMode,
templateReady,
webUiReady,
iconReady,
ready: Boolean(state.containerRunning && webUiReady && iconReady)
};
}
function renderProfileCard(repository, profile, compact = false) {
const state = profile.state || {};
const health = environmentState(profile);
@@ -437,7 +521,11 @@ function renderProfileCard(repository, profile, compact = false) {
? `SSH / Unraid · ${profile.remoteFolder || repository.name} · ${profile.branch}`
: `${profile.workflowFile} · ${profile.branch}`;
const rollbackConfigured = isSsh || Boolean(profile.rollbackWorkflowFile);
return `<article class="deploy-card ${compact ? 'compact-card' : ''}"><div class="deploy-card-header"><div><div class="eyebrow">${escapeHtml(profile.environment)}</div><h3>${escapeHtml(profile.name)}</h3><p>${escapeHtml(providerDetail)}</p></div><span class="status-pill ${health.tone}"><span class="state-dot ${health.tone}"></span>${health.label}</span></div><div class="deploy-card-body"><div class="deploy-metadata"><span>Provider</span><strong>${isSsh ? 'SSH / Unraid' : 'Gitea Actions'}</strong><span>Live version</span><strong>${state.liveSha ? shortSha(state.liveSha) : 'Unknown'}</strong><span>Previous version</span><strong>${state.previousSha ? shortSha(state.previousSha) : 'Unknown'}</strong><span>Last checked</span><strong>${state.checkedAt ? formatDate(state.checkedAt) : 'Never'}</strong><span>Rollback</span><strong>${rollbackConfigured ? 'Available after first deploy' : 'Not configured'}</strong></div><div class="card-actions"><button class="button" data-action="run-deployment-preflight" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon('shield')}Preflight</button><button class="button" data-action="refresh-profile-state" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon('pulse')}Check state</button>${ready ? `<button class="button primary" data-action="deploy-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon('rocket')}Deploy ${escapeHtml(repository.localStatus.shortHead)}</button>` : ''}<button class="button ghost" data-action="edit-deployment-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">Edit</button>${state.previousSha && rollbackConfigured ? `<button class="button danger" data-action="rollback-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon('undo')}Rollback</button>` : ''}</div></div></article>`;
const dockerMan = dockerManIntegration(profile);
const { templateReady, webUiReady, iconReady } = dockerMan;
const dockerManReady = dockerMan.ready;
const webUi = profile.webUiUrl || state.webUiUrl || state.dockerMan?.webUi || '';
return `<article class="deploy-card ${compact ? 'compact-card' : ''}"><div class="deploy-card-header"><div><div class="eyebrow">${escapeHtml(profile.environment)}</div><h3>${escapeHtml(profile.name)}</h3><p>${escapeHtml(providerDetail)}</p></div><span class="status-pill ${health.tone}"><span class="state-dot ${health.tone}"></span>${health.label}</span></div><div class="deploy-card-body"><div class="deploy-metadata"><span>Provider</span><strong>${isSsh ? 'SSH / Unraid' : 'Gitea Actions'}</strong><span>Live version</span><strong>${state.liveSha ? shortSha(state.liveSha) : 'Unknown'}</strong><span>Previous version</span><strong>${state.previousSha ? shortSha(state.previousSha) : 'Unknown'}</strong><span>Last checked</span><strong>${state.checkedAt ? formatDate(state.checkedAt) : 'Never'}</strong>${isSsh ? `<span>Container</span><strong>${escapeHtml(state.containerName || profile.containerName || profile.remoteFolder || repository.name)}${state.containerRunning === false ? ' · stopped' : state.containerRunning ? ' · running' : ''}</strong><span>DockerMan</span><strong class="${dockerManReady ? 'text-success' : 'text-warning'}">${dockerManReady ? (templateReady ? 'Labels/template active' : 'WebUI/icon labels active') : `WebUI ${webUiReady ? 'ready' : 'missing'} · icon ${iconReady ? 'ready' : 'missing'}`}</strong>` : ''}<span>Rollback</span><strong>${rollbackConfigured ? 'Available after first deploy' : 'Not configured'}</strong></div><div class="card-actions"><button class="button" data-action="run-deployment-preflight" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon('shield')}Preflight</button><button class="button" data-action="reconcile-deployment" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon('refresh')}Reconcile</button>${webUi ? `<button class="button" data-action="open-profile-webui" data-url="${attr(webUi)}">${icon('external')}Open Web UI</button>` : ''}${isSsh ? `<button class="button ${dockerManReady ? 'ghost' : ''}" data-action="apply-dockerman-metadata" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon('wrench')}${dockerManReady ? 'Reapply DockerMan metadata' : 'Repair DockerMan integration'}</button>` : ''}${ready ? `<button class="button primary" data-action="deploy-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon('rocket')}Deploy ${escapeHtml(repository.localStatus.shortHead)}</button>` : ''}<button class="button ghost" data-action="edit-deployment-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">Edit</button>${state.previousSha && rollbackConfigured ? `<button class="button danger" data-action="rollback-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon('undo')}Rollback</button>` : ''}</div></div></article>`;
}
function renderRepositoryDeployments(repository) {
@@ -448,7 +536,11 @@ function renderRepositoryDeployments(repository) {
function renderGitTools(repository) {
if (!repository.localPath) return '<div class="empty-state full"><p>Link a local repository to manage branches and stashes.</p></div>';
return `<div class="tab-page git-tools-grid"><section class="panel"><div class="panel-header"><h2>Branches</h2><button class="button ghost" data-action="load-git-tools">${icon('refresh')}Refresh</button></div><div class="panel-body"><div class="inline-form"><input id="new-branch-name" class="input" placeholder="feature/name"/><button class="button" data-action="create-branch">${icon('plus')}Create & switch</button></div><div class="tool-list">${ui.branches.length ? ui.branches.map((branch) => `<div class="tool-row"><div><strong>${escapeHtml(branch.name)}</strong><span>${escapeHtml(branch.shortSha)}${branch.upstream ? ` · ${escapeHtml(branch.upstream)}` : ' · unpublished'}</span></div>${branch.current ? '<span class="status-pill success">Current</span>' : `<button class="button" data-action="checkout-branch" data-branch="${attr(branch.name)}">Switch</button>`}</div>`).join('') : '<div class="empty-state compact"><p>Load branch information.</p></div>'}</div></div></section><section class="panel"><div class="panel-header"><h2>Stashes</h2><button class="button" data-action="stash-changes" ${repository.localStatus?.clean ? 'disabled' : ''}>${icon('archive')}Stash changes</button></div><div class="panel-body"><div class="tool-list">${ui.stashes.length ? ui.stashes.map((stash) => `<div class="tool-row"><div><strong>${escapeHtml(stash.ref)}</strong><span>${escapeHtml(stash.subject)} · ${formatDate(stash.date)}</span></div><button class="button" data-action="pop-stash" data-stash-ref="${attr(stash.ref)}">Apply & drop</button></div>`).join('') : '<div class="empty-state compact"><p>No stashes, or Git tools have not been loaded.</p></div>'}</div></div></section></div>`;
const recovery = ui.gitRecovery;
const locks = recovery?.lockReport?.locks || [];
const activeProcesses = recovery?.lockReport?.processes?.active || [];
const recommendations = recovery?.recommendations || [];
return `<div class="tab-page git-tools-grid"><section class="panel"><div class="panel-header"><h2>Branches</h2><button class="button ghost" data-action="load-git-tools">${icon('refresh')}Refresh</button></div><div class="panel-body"><div class="inline-form"><input id="new-branch-name" class="input" placeholder="feature/name"/><button class="button" data-action="create-branch">${icon('plus')}Create & switch</button></div><div class="tool-list">${ui.branches.length ? ui.branches.map((branch) => `<div class="tool-row"><div><strong>${escapeHtml(branch.name)}</strong><span>${escapeHtml(branch.shortSha)}${branch.upstream ? ` · ${escapeHtml(branch.upstream)}` : ' · unpublished'}</span></div>${branch.current ? '<span class="status-pill success">Current</span>' : `<button class="button" data-action="checkout-branch" data-branch="${attr(branch.name)}">Switch</button>`}</div>`).join('') : '<div class="empty-state compact"><p>Load branch information.</p></div>'}</div></div></section><section class="panel"><div class="panel-header"><h2>Stashes</h2><button class="button" data-action="stash-changes" ${repository.localStatus?.clean ? 'disabled' : ''}>${icon('archive')}Stash changes</button></div><div class="panel-body"><div class="tool-list">${ui.stashes.length ? ui.stashes.map((stash) => `<div class="tool-row"><div><strong>${escapeHtml(stash.ref)}</strong><span>${escapeHtml(stash.subject)} · ${formatDate(stash.date)}</span></div><button class="button" data-action="pop-stash" data-stash-ref="${attr(stash.ref)}">Apply & drop</button></div>`).join('') : '<div class="empty-state compact"><p>No stashes, or Git tools have not been loaded.</p></div>'}</div></div></section><section class="panel troubleshooting-panel"><div class="panel-header"><div><h2>Repository troubleshooting</h2><span class="meta">Safe, repository-specific recovery actions</span></div><button class="button primary" data-action="scan-git-recovery">${icon('pulse')}Scan</button></div><div class="panel-body">${recovery ? `<div class="troubleshooting-summary"><span class="status-pill ${locks.length ? 'warning' : 'success'}">${locks.length ? `${locks.length} lock${locks.length === 1 ? '' : 's'}` : 'No Git locks'}</span><span>${activeProcesses.length ? `${activeProcesses.length} active Git process(es)` : 'No matching active Git process detected'}</span></div>${locks.length ? `<div class="tool-list">${locks.map((lock) => `<div class="tool-row"><div><strong>${escapeHtml(lock.name)}</strong><span>${Math.round(lock.ageMs / 1000)}s old · ${escapeHtml(lock.modifiedAt)}</span></div></div>`).join('')}</div>` : ''}${recommendations.length ? `<div class="tool-list recovery-actions">${recommendations.map((item) => `<div class="tool-row"><div><strong>${escapeHtml(item.label)}</strong><span>${item.safe ? 'Safe automated action' : item.action ? 'Creates a safety branch before changing history' : 'Review required'}</span></div>${item.action ? `<button class="button ${item.safe ? '' : 'danger'}" data-action="repair-repository-sync" data-strategy="${attr(item.action)}">Run</button>` : ''}</div>`).join('')}</div>` : ''}` : '<div class="empty-state compact"><p>Scan before repairing. ForgeFlow checks every .lock file in the actual Git directory, not only index.lock.</p></div>'}<div class="card-actions"><button class="button" data-action="repair-git-locks">${icon('wrench')}Repair proven stale locks</button><button class="button" data-action="reconcile-repository">${icon('refresh')}Refresh Git state</button>${repository.sshUrl && repository.localStatus?.remoteUrl !== repository.sshUrl ? `<button class="button" data-action="repair-origin">${icon('link')}Repair origin</button>` : ''}</div><div class="notice warning">Lock repair refuses to run while a matching Git process is active. A force option is shown only when process detection itself is unavailable.</div></div></section></div>`;
}
function renderRepositorySettings(repository) {
@@ -456,7 +548,7 @@ function renderRepositorySettings(repository) {
const currentOrigin = repository.localStatus?.remoteUrl || 'Unavailable';
const desiredOrigin = repository.sshUrl || repository.preferredCloneUrl || '';
const originNeedsRepair = Boolean(repository.localPath && desiredOrigin && currentOrigin !== desiredOrigin);
return `<div class="tab-page"><section class="settings-group"><h2>Repository identity</h2><div class="form-grid"><div class="field full"><label>Gitea repository</label><input class="input" value="${attr(repository.fullName)}" readonly/></div><div class="field full"><label>Local working tree</label><input class="input mono" value="${attr(repository.localPath || automaticTarget || 'Not linked')}" readonly/></div><div class="field full"><label>Current origin</label><input class="input mono" value="${attr(currentOrigin)}" readonly/></div>${desiredOrigin ? `<div class="field full"><label>Current Gitea SSH origin</label><input class="input mono" value="${attr(desiredOrigin)}" readonly/></div>` : ''}</div><div class="card-actions"><button class="button" data-action="${repository.localPath ? 'open-path' : 'link-repo'}">${icon('folder')}${repository.localPath ? 'Open project folder' : 'Link local folder'}</button>${originNeedsRepair ? `<button class="button primary" data-action="repair-origin">${icon('link')}Use current Gitea origin</button>` : ''}${repository.localPath ? `<button class="button" data-action="repair-index-lock">${icon('key')}Repair stale Git lock</button><button class="button danger" data-action="unlink-repo">${icon('link')}Remove link</button>` : `<button class="button primary" data-action="clone-repo">${icon('cloud')}${escapeHtml(clonePrimaryLabel(repository))}</button><button class="button ghost" data-action="clone-repo-custom">Choose another location</button>`}</div></section><section class="settings-group"><h2>Repository behavior</h2><div class="notice">${icon('shield')}Origin repair changes only the Git remote URL. Lock repair refuses recent locks and never changes files or commits.</div></section></div>`;
return `<div class="tab-page"><section class="settings-group"><h2>Repository identity</h2><div class="form-grid"><div class="field full"><label>Gitea repository</label><input class="input" value="${attr(repository.fullName)}" readonly/></div><div class="field full"><label>Local working tree</label><input class="input mono" value="${attr(repository.localPath || automaticTarget || 'Not linked')}" readonly/></div><div class="field full"><label>Current origin</label><input class="input mono" value="${attr(currentOrigin)}" readonly/></div>${desiredOrigin ? `<div class="field full"><label>Current Gitea SSH origin</label><input class="input mono" value="${attr(desiredOrigin)}" readonly/></div>` : ''}</div><div class="card-actions"><button class="button" data-action="${repository.localPath ? 'open-path' : 'link-repo'}">${icon('folder')}${repository.localPath ? 'Open project folder' : 'Link local folder'}</button>${originNeedsRepair ? `<button class="button primary" data-action="repair-origin">${icon('link')}Use current Gitea origin</button>` : ''}${repository.localPath ? `<button class="button" data-action="scan-git-recovery">${icon('pulse')}Scan Git health</button><button class="button danger" data-action="unlink-repo">${icon('link')}Remove link</button>` : `<button class="button primary" data-action="clone-repo">${icon('cloud')}${escapeHtml(clonePrimaryLabel(repository))}</button><button class="button ghost" data-action="clone-repo-custom">Choose another location</button>`}</div></section><section class="settings-group"><h2>Repository behavior</h2><div class="notice">${icon('shield')}Origin repair changes only the Git remote URL. Git health scans the actual Git directory, repairs only proven stale lock files and never changes source files or commits.</div></section></div>`;
}
function renderRepositoryWorkspace(repository) {
@@ -488,7 +580,7 @@ function renderActionPanel(repository) {
}
else if (action.kind === 'pull') body = `<div class="panel-callout"><div class="callout-icon warning">${icon('arrowDown')}</div><h2>${action.title}</h2><p>${action.detail}</p><button class="button primary block" data-action="pull">Fast-forward from Gitea</button></div>`;
else if (action.kind === 'push') body = `<div class="panel-callout"><div class="callout-icon">${icon('arrowUp')}</div><h2>${action.title}</h2><p>${action.detail}</p><button class="button primary block" data-action="push">Push ${status.branch.ahead} commit${status.branch.ahead === 1 ? '' : 's'}</button></div>`;
else if (action.kind === 'diverged' || action.kind === 'conflict' || action.kind === 'error') body = `<div class="panel-callout"><div class="callout-icon danger">${icon('error')}</div><h2>${action.title}</h2><p>${action.detail}</p><button class="button block" data-action="open-path">Open project folder</button><button class="button block" data-action="refresh">Refresh status</button></div>`;
else if (action.kind === 'diverged' || action.kind === 'conflict' || action.kind === 'error') body = `<div class="panel-callout"><div class="callout-icon danger">${icon('error')}</div><h2>${action.title}</h2><p>${action.detail}</p>${action.kind === 'diverged' ? `<button class="button primary block" data-action="load-git-tools">${icon('wrench')}Open guided repository repair</button>` : ''}<button class="button block" style="margin-top:8px" data-action="open-path">Open project folder</button><button class="button block" style="margin-top:8px" data-action="refresh">Refresh status</button></div>`;
else if (action.kind === 'configure') body = `<div class="panel-callout"><div class="callout-icon">${icon('settings')}</div><h2>${action.title}</h2><p>${action.detail}</p><button class="button primary block" data-action="configure-deployment">Configure first environment</button></div>`;
else if (action.kind === 'branch-profile') body = `<div class="panel-callout"><div class="callout-icon">${icon('branch')}</div><h2>${action.title}</h2><p>${action.detail}</p>${repository.deploymentProfiles.length > 1 ? `<label class="field-label">Deployment profile</label><select id="action-profile-select" class="select">${repository.deploymentProfiles.map((item) => `<option value="${attr(item.id)}" ${item.id === profile?.id ? 'selected' : ''}>${escapeHtml(item.name)} · ${escapeHtml(item.branch)}</option>`).join('')}</select>` : ''}<button class="button block" style="margin-top:8px" data-action="edit-deployment-profile" data-profile-id="${attr(profile?.id || '')}">Edit profile</button></div>`;
else if (action.kind === 'deploy') body = `<div class="panel-callout"><div class="callout-icon success">${icon('rocket')}</div><h2>Release ${escapeHtml(status.shortHead)}</h2><p>${escapeHtml(profile.name)} will deploy the exact commit from ${escapeHtml(profile.branch)} to ${escapeHtml(profile.environment)}.</p>${repository.deploymentProfiles.length > 1 ? `<label class="field-label">Environment</label><select id="action-profile-select" class="select">${repository.deploymentProfiles.map((item) => `<option value="${attr(item.id)}" ${item.id === profile.id ? 'selected' : ''}>${escapeHtml(item.name)} · ${escapeHtml(item.environment)}</option>`).join('')}</select>` : ''}<div class="deploy-proof"><span>Local</span><strong>${escapeHtml(status.shortHead)}</strong><span>Gitea</span><strong>${escapeHtml(status.shortHead)}</strong><span>Target</span><strong>${escapeHtml(profile.environment)}</strong></div><button class="button success block" data-action="deploy-profile" data-profile-id="${attr(profile.id)}">${icon('rocket')}Deploy ${escapeHtml(status.shortHead)}${escapeHtml(profile.environment)}</button>${profile.state?.previousSha && profile.rollbackWorkflowFile ? `<button class="button danger block" style="margin-top:8px" data-action="rollback-profile" data-profile-id="${attr(profile.id)}">${icon('undo')}Rollback to ${shortSha(profile.state.previousSha)}</button>` : ''}</div>`;
@@ -499,7 +591,8 @@ function renderActionPanel(repository) {
function renderDeployments() {
const cards = ui.repositories.flatMap((repository) => (repository.deploymentProfiles || []).map((profile) => ({ repository, profile })));
const active = operations().filter((operation) => !isTerminalOperation(operation.status));
return `<div class="page"><div class="page-header"><div><div class="eyebrow">Server releases</div><h1>Deployments</h1><p>Exact commits, predefined workflows, authoritative Actions status and server-side version checks.</p></div><button class="button" data-action="refresh-operations">${icon('refresh')}Refresh runs</button></div>${active.length ? `<div class="notice warning">${icon('pulse')} ${active.length} deployment operation${active.length === 1 ? ' is' : 's are'} still active.</div>` : ''}<div class="deploy-card-grid">${cards.length ? cards.map(({ repository, profile }) => renderProfileCard(repository, profile, true)).join('') : '<div class="empty-state panel"><h3>No deployment environments configured</h3><p>Open a repository and add an environment.</p></div>'}</div><section class="section-block"><div class="section-heading"><h2>All operations</h2><span class="meta">Newest first</span></div><div class="panel">${operations().length ? `<table class="data-table"><thead><tr><th>Repository</th><th>Action</th><th>Environment</th><th>Commit</th><th>Status</th><th>Updated</th><th></th></tr></thead><tbody>${operations().map((operation) => `<tr><td>${escapeHtml(operation.repository)}</td><td>${escapeHtml(operation.action || 'deploy')}</td><td>${escapeHtml(operation.environment || '—')}</td><td class="mono">${escapeHtml(operation.shortSha || shortSha(operation.sha))}</td><td><span class="status-pill ${toneForStatus(operation.status)}">${escapeHtml(operation.status)}</span></td><td>${formatDate(operation.updatedAt || operation.createdAt)}</td><td><button class="button ghost" data-action="open-operation" data-operation-id="${attr(operation.id)}">Open</button></td></tr>`).join('')}</tbody></table>` : '<div class="empty-state compact"><p>No operations recorded.</p></div>'}</div></section></div>`;
const missingDockerMan = cards.filter(({ profile }) => profile.provider === 'ssh-unraid' && profile.state?.containerRunning && !dockerManIntegration(profile).ready);
return `<div class="page"><div class="page-header"><div><div class="eyebrow">Server releases</div><h1>Deployments</h1><p>Exact commits, live container truth, DockerMan integration and controlled release recovery.</p></div><div class="stack horizontal compact"><button class="button" data-action="refresh-operations">${icon('refresh')}Refresh runs & servers</button>${missingDockerMan.length ? `<button class="button primary" data-action="repair-missing-dockerman">${icon('wrench')}Repair ${missingDockerMan.length} missing integration${missingDockerMan.length === 1 ? '' : 's'}</button>` : ''}</div></div>${active.length ? `<div class="notice warning">${icon('pulse')} ${active.length} deployment operation${active.length === 1 ? ' is' : 's are'} still active. ForgeFlow reconciles these against the live server automatically.</div>` : ''}<div class="deploy-card-grid">${cards.length ? cards.map(({ repository, profile }) => renderProfileCard(repository, profile, true)).join('') : '<div class="empty-state panel"><h3>No deployment environments configured</h3><p>Open a repository and add an environment.</p></div>'}</div><section class="section-block"><div class="section-heading"><h2>All operations</h2><span class="meta">Newest first</span></div><div class="panel">${operations().length ? `<table class="data-table"><thead><tr><th>Repository</th><th>Action</th><th>Environment</th><th>Commit</th><th>Status</th><th>Updated</th><th></th></tr></thead><tbody>${operations().map((operation) => `<tr><td>${escapeHtml(operation.repository)}</td><td>${escapeHtml(operation.action || 'deploy')}</td><td>${escapeHtml(operation.environment || '—')}</td><td class="mono">${escapeHtml(operation.shortSha || shortSha(operation.sha))}</td><td><span class="status-pill ${toneForStatus(operation.status)}">${escapeHtml(operation.status)}</span></td><td>${formatDate(operation.updatedAt || operation.createdAt)}</td><td><button class="button ghost" data-action="open-operation" data-operation-id="${attr(operation.id)}">Open</button></td></tr>`).join('')}</tbody></table>` : '<div class="empty-state compact"><p>No operations recorded.</p></div>'}</div></section></div>`;
}
function renderSettings() {
@@ -576,7 +669,7 @@ function renderSetup() {
: ui.setupStep === 1 ? '<button class="button primary" data-action="setup-validate">Validate & continue</button>'
: ui.setupStep === 2 ? `<button class="button primary" data-action="setup-next" ${ui.setupDraft.roots.length ? '' : 'disabled'}>Scan folders</button>`
: ui.setupStep === 4 ? '<button class="button primary" data-action="setup-finish">Enter ForgeFlow</button>' : '';
return `<div class="setup-backdrop"><section class="setup-window"><aside class="setup-sidebar"><img class="setup-brand-logo" src="./assets/itworx-wordmark.png" alt="ITWorx.tech"/><h2>Set up ForgeFlow</h2><p>Local code to controlled deployment.</p>${steps.map((step,index) => `<div class="setup-step ${ui.setupStep === index ? 'active' : ui.setupStep > index ? 'complete' : ''}"><span class="step-number">${ui.setupStep > index ? '✓' : index + 1}</span><span>${step}</span></div>`).join('')}</aside><div class="setup-content">${body}<footer class="setup-actions"><button class="button" data-action="setup-back" ${ui.setupStep === 0 || ui.setupStep === 3 ? 'disabled' : ''}>Back</button>${nextAction}</footer></div></section></div>`;
return `<div class="setup-backdrop"><section class="setup-window"><aside class="setup-sidebar"><img class="setup-brand-logo setup-brand-logo-dark" src="./assets/itworx-wordmark-dark.png" alt="ITWorx.tech"/><img class="setup-brand-logo setup-brand-logo-light" src="./assets/itworx-wordmark-light.png" alt="ITWorx.tech"/><h2>Set up ForgeFlow</h2><p>Local code to controlled deployment.</p>${steps.map((step,index) => `<div class="setup-step ${ui.setupStep === index ? 'active' : ui.setupStep > index ? 'complete' : ''}"><span class="step-number">${ui.setupStep > index ? '✓' : index + 1}</span><span>${step}</span></div>`).join('')}</aside><div class="setup-content">${body}<footer class="setup-actions"><button class="button" data-action="setup-back" ${ui.setupStep === 0 || ui.setupStep === 3 ? 'disabled' : ''}>Back</button>${nextAction}</footer></div></section></div>`;
}
function renderModal() {
@@ -595,11 +688,12 @@ function renderModal() {
<label class="check-field"><input id="profile-align-remote" type="checkbox" ${existing.alignRemote === true ? 'checked' : ''}/><span>Align an existing server origin to this URL</span></label>
<div class="field"><label>Compose mode</label><select id="profile-generated-compose" class="select"><option value="false" ${existing.generatedCompose !== true ? 'selected' : ''}>Use Compose file from repository/server</option><option value="true" ${existing.generatedCompose === true ? 'selected' : ''}>Generate a basic ForgeFlow Compose file</option></select></div>
<div class="field"><label>Compose file</label><input id="profile-compose-file" class="input" value="${attr(existing.composeFile || 'docker-compose.yml')}"/></div>
<div class="field"><label>Compose service / container name</label><input id="profile-compose-service" class="input" value="${attr(existing.composeService || safeCloneFolderName(repository).toLowerCase())}"/></div>
<div class="field"><label>Compose service (internal)</label><input id="profile-compose-service" class="input" value="${attr(existing.composeService || safeCloneFolderName(repository).toLowerCase())}"/><small>Must match the Compose service key and remain lowercase.</small></div><div class="field"><label>Visible container name</label><input id="profile-container-name" class="input" value="${attr(existing.containerName || remoteFolder)}"/><small>May remain Portfolio while internal image/service names are lowercase.</small></div>
<div class="field"><label>Host port</label><input id="profile-host-port" class="input" type="number" min="1" max="65535" value="${attr(existing.hostPort || '')}" placeholder="1223"/></div>
<div class="field"><label>Container port</label><input id="profile-container-port" class="input" type="number" min="1" max="65535" value="${attr(existing.containerPort || '')}" placeholder="8080"/></div>
<div class="field full"><label>Unraid Web UI URL (optional)</label><input id="profile-web-ui" class="input" value="${attr(existing.webUiUrl || '')}" placeholder="http://[IP]:[PORT:1223]/"/></div>
<div class="field full"><label>Unraid icon URL (optional)</label><input id="profile-icon-url" class="input" value="${attr(existing.iconUrl || '')}" placeholder="https://…/icon.png"/></div>
<div class="field"><label>DockerMan icon source</label><select id="profile-icon-mode" class="select"><option value="builtin" ${(existing.iconMode || (!existing.iconUrl && !existing.iconFilePath ? 'builtin' : existing.iconFilePath ? 'upload' : 'url')) === 'builtin' ? 'selected' : ''}>Built-in high-contrast ITWorx mark</option><option value="upload" ${existing.iconMode === 'upload' || (!existing.iconMode && existing.iconFilePath) ? 'selected' : ''}>Upload local PNG</option><option value="url" ${existing.iconMode === 'url' || (!existing.iconMode && existing.iconUrl) ? 'selected' : ''}>Use icon URL</option><option value="none" ${existing.iconMode === 'none' ? 'selected' : ''}>No custom icon</option></select></div><div class="field"><label>Container shell</label><select id="profile-docker-shell" class="select"><option value="/bin/sh" ${(existing.dockerShell || '/bin/sh') === '/bin/sh' ? 'selected' : ''}>/bin/sh</option><option value="/bin/bash" ${existing.dockerShell === '/bin/bash' ? 'selected' : ''}>/bin/bash</option></select></div>
<div class="field full"><label>DockerMan icon URL</label><input id="profile-icon-url" class="input" value="${attr(existing.iconUrl || '')}" placeholder="https://…/icon.png"/></div><div class="field full"><label>Local PNG</label><div class="inline-form"><input id="profile-icon-file" class="input mono" value="${attr(existing.iconFilePath || '')}" placeholder="Select a local transparent PNG" readonly/><button class="button" data-action="select-profile-icon">${icon('folder')}Browse</button><button class="button ghost" data-action="clear-profile-icon">Clear</button></div><small>Built-in or uploaded PNGs are copied to DockerMan's persistent image folder and referenced through a file:/// URL. ForgeFlow also refreshes the relevant Unraid icon cache after recreating the container.</small></div>
<div class="field full"><label>Healthcheck URL from this desktop (optional)</label><input id="profile-healthcheck" class="input" value="${attr(existing.healthcheckUrl || '')}" placeholder="http://unraid:1223/health"/></div>
<div class="field full"><label>Preserve server-only paths</label><input id="profile-preserve-paths" class="input" value="${attr((existing.preservePaths || ['.env','appdata','data','logs','config','compose.override.yml']).join(', '))}"/><small>These untracked runtime paths remain untouched by Git deployments.</small></div>
` : `
@@ -693,7 +787,8 @@ async function executeDeployment(profileId) {
ui.activeDeployment = await window.forgeflow.deploy(repository, profile.id, repository.localStatus.head);
updateOperationInState(ui.activeDeployment);
ui.currentView = 'deployment-run';
showToast('Deployment requested', `${repository.name} ${repository.localStatus.shortHead}${profile.environment}`, 'success');
showToast('Deployment started', `${repository.name} ${repository.localStatus.shortHead}${profile.environment}`, 'success');
startOperationPolling();
} catch (error) { showToast('Deployment failed to start', error.message, 'error'); }
setLoading(false);
}
@@ -718,7 +813,11 @@ async function loadGitTools(repository) {
if (!repository?.localPath) return;
setLoading(true, 'Loading branches and stashes…');
try {
[ui.branches, ui.stashes] = await Promise.all([window.forgeflow.branches(repository.localPath), window.forgeflow.stashList(repository.localPath)]);
[ui.branches, ui.stashes, ui.gitRecovery] = await Promise.all([
window.forgeflow.branches(repository.localPath),
window.forgeflow.stashList(repository.localPath),
window.forgeflow.gitRecoveryStatus(repository.localPath)
]);
ui.repositoryTab = 'gittools';
} catch (error) { showToast('Git tools unavailable', error.message, 'error'); }
setLoading(false);
@@ -780,8 +879,17 @@ app.addEventListener('click', async (event) => {
if (action === 'navigate') { ui.currentView = target.dataset.view; ui.modal = null; render(); }
else if (action === 'select-repo') selectRepository(target.dataset.id);
else if (action === 'refresh') await refreshRepositories(true);
else if (action === 'refresh-operations') { setLoading(true, 'Refreshing Gitea Actions runs…'); await refreshActiveOperations(); setLoading(false); }
else if (action === 'refresh') {
await refreshRepositories(true);
await refreshActiveOperations(false);
await refreshDeploymentTruth(false);
}
else if (action === 'refresh-operations') {
setLoading(true, 'Refreshing deployment operations and live server state…');
await refreshActiveOperations();
await refreshDeploymentTruth(true);
setLoading(false);
}
else if (action === 'toggle-theme') { const appearance = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; applyTheme(appearance); ui.boot.state = await window.forgeflow.setAppearance(appearance); render(); }
else if (action === 'open-palette') { ui.paletteQuery = ''; ui.modal = { type: 'command-palette' }; render(); }
else if (action === 'repo-tab') {
@@ -832,6 +940,11 @@ app.addEventListener('click', async (event) => {
else if (action === 'configure-deployment') { ui.modal = { type: 'deployment-config', profileId: null, provider: (ui.boot.state.servers || []).length ? 'ssh-unraid' : 'gitea-actions' }; render(); }
else if (action === 'edit-deployment-profile') { if (!repository) repository = profileRepository(target.dataset.profileId); if (repository && String(repository.id) !== String(ui.selectedRepoId)) selectRepository(repository.id, false); ui.modal = { type: 'deployment-config', profileId: target.dataset.profileId || null, provider: repository?.deploymentProfiles?.find((item) => item.id === target.dataset.profileId)?.provider }; render(); }
else if (action === 'close-modal') { ui.modal = null; render(); }
else if (action === 'select-profile-icon') {
const iconPath = await window.forgeflow.selectImageFile({ title: 'Select DockerMan PNG icon', defaultPath: document.querySelector('#profile-icon-file')?.value || undefined });
if (iconPath) { document.querySelector('#profile-icon-file').value = iconPath; const mode = document.querySelector('#profile-icon-mode'); if (mode) mode.value = 'upload'; }
}
else if (action === 'clear-profile-icon') { const input = document.querySelector('#profile-icon-file'); if (input) input.value = ''; const mode = document.querySelector('#profile-icon-mode'); if (mode) mode.value = 'builtin'; }
else if (action === 'save-deployment-profile') {
const provider = document.querySelector('#profile-provider').value;
const profile = {
@@ -850,10 +963,14 @@ app.addEventListener('click', async (event) => {
generatedCompose: document.querySelector('#profile-generated-compose').value === 'true',
composeFile: document.querySelector('#profile-compose-file').value.trim(),
composeService: document.querySelector('#profile-compose-service').value.trim(),
containerName: document.querySelector('#profile-container-name').value.trim(),
hostPort: Number(document.querySelector('#profile-host-port').value) || null,
containerPort: Number(document.querySelector('#profile-container-port').value) || null,
webUiUrl: document.querySelector('#profile-web-ui').value.trim(),
iconMode: document.querySelector('#profile-icon-mode').value,
iconUrl: document.querySelector('#profile-icon-url').value.trim(),
iconFilePath: document.querySelector('#profile-icon-file').value.trim(),
dockerShell: document.querySelector('#profile-docker-shell').value,
preservePaths: document.querySelector('#profile-preserve-paths').value.split(',').map((item) => item.trim()).filter(Boolean)
} : {
workflowFile: document.querySelector('#profile-workflow').value.trim(),
@@ -894,10 +1011,52 @@ app.addEventListener('click', async (event) => {
try { const state = await window.forgeflow.refreshProfileState(repository.fullName, target.dataset.profileId); profile.state = state; showToast('Environment checked', state.healthy === false ? 'Healthcheck reports an unhealthy state.' : state.liveSha ? `Server reports ${shortSha(state.liveSha)}.` : 'Connection checked; no live SHA reported.', state.healthy === false ? 'error' : 'success'); } catch (error) { showToast('Status check failed', error.message, 'error'); }
setLoading(false);
}
else if (action === 'open-operation') { const operation = await window.forgeflow.getOperation(target.dataset.operationId); if (operation) { ui.activeDeployment = operation; ui.currentView = 'deployment-run'; render(); } }
else if (action === 'refresh-current-operation') { setLoading(true, 'Refreshing Actions run…'); try { const operation = await window.forgeflow.refreshOperations(ui.activeDeployment.id); updateOperationInState(operation); } catch (error) { showToast('Status refresh failed', error.message, 'error'); } setLoading(false); }
else if (action === 'repair-missing-dockerman') {
const targets = ui.repositories.flatMap((candidate) =>
(candidate.deploymentProfiles || [])
.filter((profile) => profile.provider === 'ssh-unraid' && profile.state?.containerRunning && !dockerManIntegration(profile).ready)
.map((profile) => ({ repository: candidate, profile }))
);
if (!targets.length) return;
if (!confirm(`Recreate ${targets.length} running container${targets.length === 1 ? '' : 's'} with the missing DockerMan WebUI, icon and template metadata?`)) return;
setLoading(true, 'Repairing missing DockerMan integrations…');
let repaired = 0;
const failures = [];
for (const item of targets) {
try {
await window.forgeflow.applyDockerManMetadata(item.repository, item.profile.id);
repaired += 1;
} catch (error) {
failures.push(`${item.repository.name}: ${error.message}`);
}
}
await refreshDeploymentTruth(false);
showToast(
failures.length ? 'DockerMan repair partially completed' : 'DockerMan integrations repaired',
failures.length ? `${repaired} repaired, ${failures.length} failed.` : `${repaired} running container${repaired === 1 ? '' : 's'} updated.`,
failures.length ? 'error' : 'success'
);
setLoading(false);
}
else if (action === 'apply-dockerman-metadata') {
if (!repository) repository = profileRepository(target.dataset.profileId);
setLoading(true, 'Applying DockerMan labels, template, icon and WebUI metadata…');
try { await window.forgeflow.applyDockerManMetadata(repository, target.dataset.profileId); await refreshRepositories(false); showToast('DockerMan integration repaired', 'The container was recreated with labels, a persistent template, WebUI and icon metadata.', 'success'); }
catch (error) { showToast('Could not repair DockerMan integration', error.message, 'error'); }
setLoading(false);
}
else if (action === 'reconcile-deployment') {
if (!repository) repository = profileRepository(target.dataset.profileId);
setLoading(true, 'Reconciling ForgeFlow with the live Unraid container…');
try { await window.forgeflow.reconcileDeployment(repository.fullName, target.dataset.profileId); await refreshActiveOperations(false); await refreshRepositories(false); showToast('Deployment reconciled', 'Live SHA, container health and operation status were refreshed.', 'success'); }
catch (error) { showToast('Could not reconcile deployment', error.message, 'error'); }
setLoading(false);
}
else if (action === 'open-profile-webui') await window.forgeflow.openExternal(target.dataset.url);
else if (action === 'open-operation') { const operation = await window.forgeflow.getOperation(target.dataset.operationId); if (operation) { ui.activeDeployment = operation; ui.currentView = 'deployment-run'; render(); startOperationPolling(); } }
else if (action === 'refresh-current-operation') { setLoading(true, 'Refreshing deployment status…'); try { const operation = await window.forgeflow.refreshOperations(ui.activeDeployment.id); updateOperationInState(operation); if (!isTerminalOperation(operation.status)) startOperationPolling(); } catch (error) { showToast('Status refresh failed', error.message, 'error'); } setLoading(false); }
else if (action === 'open-run-url') await window.forgeflow.openExternal(ui.activeDeployment.runUrl);
else if (action === 'close-deployment') { ui.activeDeployment = null; ui.currentView = selectedRepository() ? 'repository' : 'deployments'; render(); }
else if (action === 'close-deployment') { stopOperationPolling(); ui.activeDeployment = null; ui.currentView = selectedRepository() ? 'repository' : 'deployments'; render(); }
else if (action === 'setup-run-preflight') await runSystemPreflight({ setup: true });
else if (action === 'setup-continue') { if (ui.systemPreflight?.summary?.ready) { ui.setupStep = 1; render(); } }
else if (action === 'setup-validate') { setLoading(true, 'Validating Gitea connection…'); try { ui.setupValidation = await window.forgeflow.validateGitea(ui.setupDraft); ui.setupDraft.baseUrl = ui.setupValidation.baseUrl; ui.setupDraft.user = ui.setupValidation.user; ui.setupStep = 2; } catch (error) { showToast('Connection failed', error.message, 'error'); } setLoading(false); }
@@ -997,12 +1156,50 @@ app.addEventListener('click', async (event) => {
} catch (error) { showToast('Could not normalize origins', error.message, 'error'); }
setLoading(false); render();
}
else if (action === 'repair-index-lock') {
if (!repository?.localPath || !confirm('Remove the stale Git index.lock for this repository? Only continue after other Git tools have stopped.')) return;
setLoading(true, 'Repairing stale Git lock…');
try { await window.forgeflow.repairIndexLock(repository.localPath); await refreshRepositories(false); showToast('Git lock removed', 'The repository can accept Git changes again.', 'success'); }
catch (error) { showToast('Could not remove Git lock', error.message, 'error'); }
setLoading(false);
else if (action === 'scan-git-recovery') {
if (!repository?.localPath) return;
setLoading(true, 'Scanning Git directory and active processes…');
try { ui.gitRecovery = await window.forgeflow.gitRecoveryStatus(repository.localPath); ui.repositoryTab = 'gittools'; showToast('Git health scan complete', `${ui.gitRecovery.lockReport.locks.length} lock file(s) found.`, ui.gitRecovery.lockReport.locks.length ? 'info' : 'success'); }
catch (error) { showToast('Git health scan failed', error.message, 'error'); }
setLoading(false); render();
}
else if (action === 'repair-git-locks' || action === 'repair-index-lock') {
if (!repository?.localPath || !confirm('Repair stale Git lock files for this repository? ForgeFlow refuses while a matching Git process is active.')) return;
setLoading(true, 'Safely repairing stale Git locks…');
try { const result = await window.forgeflow.repairGitLocks(repository.localPath, false); ui.gitRecovery = await window.forgeflow.gitRecoveryStatus(repository.localPath); await refreshRepositories(false); showToast('Git locks repaired', `${result.removed.length} stale lock file(s) removed.`, 'success'); }
catch (error) {
if (error.code === 'GIT_PROCESS_PROBE_UNAVAILABLE' && confirm(`${error.message}
Force repair after you have closed all Git tools for this repository?`)) {
try { const result = await window.forgeflow.repairGitLocks(repository.localPath, true); showToast('Git locks force-repaired', `${result.removed.length} lock file(s) removed.`, 'success'); await refreshRepositories(false); }
catch (forceError) { showToast('Could not repair Git locks', forceError.message, 'error'); }
} else showToast('Could not repair Git locks', error.message, 'error');
}
setLoading(false); render();
}
else if (action === 'reconcile-repository') {
if (!repository?.localPath) return;
setLoading(true, 'Refreshing repository truth from Git…');
try { ui.gitRecovery = await window.forgeflow.reconcileRepository(repository.localPath); await refreshRepositories(false); showToast('Repository reconciled', 'Branch, upstream, lock and working-tree state were refreshed.', 'success'); }
catch (error) { showToast('Could not reconcile repository', error.message, 'error'); }
setLoading(false); render();
}
else if (action === 'repair-repository-sync') {
if (!repository?.localPath) return;
const strategy = target.dataset.strategy;
const destructive = strategy === 'backup-reset';
const message = destructive
? 'Create a safety branch from the current HEAD and reset this branch to its upstream? Uncommitted changes are never discarded.'
: `Run the repository-specific ${strategy} repair now?`;
if (!confirm(message)) return;
setLoading(true, destructive ? 'Creating safety branch and repairing divergence…' : 'Repairing repository synchronization…');
try {
const result = await window.forgeflow.repairRepositorySync(repository.localPath, strategy);
ui.gitRecovery = await window.forgeflow.gitRecoveryStatus(repository.localPath);
await refreshRepositories(false);
showToast('Repository synchronization repaired', result.backupBranch ? `Safety branch created: ${result.backupBranch}` : `Completed ${strategy}.`, 'success');
} catch (error) { showToast('Synchronization repair failed', error.message, 'error'); }
setLoading(false); render();
}
else if (action === 'run-system-preflight') await runSystemPreflight();
else if (action === 'save-diagnostics-preferences') {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 80 KiB

+1 -1
View File
@@ -10,7 +10,7 @@
<body>
<div id="app" aria-live="polite">
<div class="boot-screen">
<div class="brand-mark">F</div>
<img class="boot-brand-logo" src="./assets/itworx-mark.png" alt="ITWorx.tech"/>
<strong>Starting ForgeFlow</strong>
<span>Checking Git and local configuration…</span>
</div>
+2 -2
View File
@@ -269,9 +269,9 @@
async setAppearance(appearance) { state.appearance = appearance; storage.set('forgeflow-theme', appearance); return clone(state); },
async setPreferences(preferences) { state.preferences = { ...state.preferences, ...preferences }; snapshot(); return clone(state); },
async setUpdatePreferences(updates) { state.updates = { ...state.updates, ...updates }; return clone(state); },
async checkForUpdates() { await wait(300); return { checkedAt: iso(), owner: state.updates.owner, repo: state.updates.repo, branch: state.updates.branch, currentVersion: '0.5.1', remoteVersion: '0.5.2', remoteSha: 'a'.repeat(40), shortSha: 'aaaaaaa', available: true, mode: 'source' }; },
async checkForUpdates() { await wait(300); return { checkedAt: iso(), owner: state.updates.owner, repo: state.updates.repo, branch: state.updates.branch, currentVersion: '0.5.4', remoteVersion: '0.6.0', remoteSha: 'a'.repeat(40), shortSha: 'aaaaaaa', available: true, mode: 'source' }; },
async downloadUpdate() { await wait(500); return { ...(await this.checkForUpdates()), downloaded: true, archivePath: 'C:\\Temp\\ForgeFlow-0.4.1.zip', sha256: 'b'.repeat(64) }; },
async applyUpdate() { await wait(200); return { launched: true, version: '0.5.1' }; },
async applyUpdate() { await wait(200); return { launched: true, confirmed: true, version: '0.6.0' }; },
async saveServer(server) { const saved = { ...server, id: server.id || `server-${Date.now()}`, hasPassword: server.authType === 'password', hasPassphrase: false }; state.servers = [saved, ...state.servers.filter((item) => item.id !== saved.id)]; return { server: clone(saved), state: clone(state) }; },
async deleteServer(serverId) { state.servers = state.servers.filter((item) => item.id !== serverId); return clone(state); },
async testServer(serverId) { const server = state.servers.find((item) => item.id === serverId); server.hostFingerprint = server.hostFingerprint || 'SHA256:demo'; return { connected: true, fingerprint: server.hostFingerprint, server: clone(server), output: 'Linux\n/usr/bin/git\nDocker Compose version v2', state: clone(state) }; },
+13 -1
View File
@@ -66,6 +66,7 @@ button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-
.boot-screen { height: 100vh; display: grid; place-content: center; justify-items: center; gap: 10px; color: var(--text-muted); }
.boot-screen strong { color: var(--text); font-size: 16px; }
.boot-brand-logo { width: 72px; height: 56px; object-fit: contain; filter: drop-shadow(0 10px 28px rgba(0,174,255,.24)); }
.brand-mark { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 10px; background: linear-gradient(145deg, var(--primary), var(--primary-strong)); color: #07152e; font-weight: 800; font-size: 20px; box-shadow: 0 8px 30px rgba(91,143,249,.25); }
.app-shell { height: 100vh; display: grid; grid-template-rows: 48px minmax(0,1fr) 25px; background: var(--bg); }
@@ -532,7 +533,10 @@ kbd { min-width: 24px; padding: 2px 5px; border: 1px solid var(--line); border-b
.update-card, .server-card { align-items: flex-start; flex-direction: column; }
}
.setup-brand-logo { width: 150px; height: auto; display: block; margin-bottom: 12px; }
.setup-brand-logo { width: 180px; max-height: 76px; object-fit: contain; object-position: left center; display: block; margin-bottom: 12px; }
.setup-brand-logo-light { display: none; }
html[data-theme="light"] .setup-brand-logo-dark { display: none; }
html[data-theme="light"] .setup-brand-logo-light { display: block; }
/* v0.5 viewport-safe dialogs */
@@ -553,3 +557,11 @@ kbd { min-width: 24px; padding: 2px 5px; border: 1px solid var(--line); border-b
.form-grid { grid-template-columns: 1fr; }
.field.full, .check-field.full { grid-column: 1; }
}
/* ForgeFlow 0.6 recovery and DockerMan controls */
.git-tools-grid .troubleshooting-panel { grid-column: 1 / -1; }
.troubleshooting-summary { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; color: var(--text-muted); }
.text-success { color: var(--success) !important; }
.text-warning { color: var(--warning) !important; }
.deploy-card .card-actions { flex-wrap: wrap; }
.field small { display: block; margin-top: 5px; color: var(--text-faint); line-height: 1.35; }