Release ForgeFlow 0.6.0
This commit is contained in:
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -88,12 +88,36 @@ function checksSummary(checks) {
|
||||
};
|
||||
}
|
||||
|
||||
function xmlEscape(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user