Publish curated ForgeFlow source from 2ed1787c0b52
This commit is contained in:
commit
f60b269686
254 files changed
+46204
No files matched your search
@@ -0,0 +1,57 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
class AuditService {
|
||||
constructor({ userDataPath, appInfo = {} }) {
|
||||
this.filePath = path.join(userDataPath, 'audit', 'forgeflow-audit.jsonl');
|
||||
this.appInfo = appInfo;
|
||||
this.queue = Promise.resolve();
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||
try { await fs.chmod(path.dirname(this.filePath), 0o700); } catch {}
|
||||
}
|
||||
|
||||
append(event, details = {}) {
|
||||
const entry = {
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
event: String(event || 'unknown').slice(0, 120),
|
||||
appVersion: this.appInfo.version || null,
|
||||
details: structuredClone(details || {})
|
||||
};
|
||||
const operation = async () => {
|
||||
await this.initialize();
|
||||
await fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`, { encoding: 'utf8', mode: 0o600 });
|
||||
try { await fs.chmod(this.filePath, 0o600); } catch {}
|
||||
return entry;
|
||||
};
|
||||
this.queue = this.queue.then(operation, operation);
|
||||
return this.queue;
|
||||
}
|
||||
|
||||
async list(limit = 250) {
|
||||
await this.queue.catch(() => {});
|
||||
const text = await fs.readFile(this.filePath, 'utf8').catch((error) => error.code === 'ENOENT' ? '' : Promise.reject(error));
|
||||
return text.split(/\r?\n/).filter(Boolean).slice(-Math.min(Math.max(Number(limit) || 250, 1), 5000)).reverse().map((line) => JSON.parse(line));
|
||||
}
|
||||
|
||||
async exportTo(destinationPath, format = 'json') {
|
||||
const entries = await this.list(5000);
|
||||
if (format === 'csv') {
|
||||
const quote = (value) => `"${String(value ?? '').replace(/"/g, '""')}"`;
|
||||
const rows = [['timestamp', 'event', 'repository', 'profile', 'sha', 'result', 'note'].map(quote).join(',')];
|
||||
for (const item of [...entries].reverse()) rows.push([item.timestamp, item.event, item.details?.repository, item.details?.profileId, item.details?.sha, item.details?.result, item.details?.note].map(quote).join(','));
|
||||
await fs.writeFile(destinationPath, `${rows.join('\r\n')}\r\n`, { mode: 0o600 });
|
||||
} else {
|
||||
await fs.writeFile(destinationPath, JSON.stringify({ format: 'forgeflow-audit', version: 1, entries: [...entries].reverse() }, null, 2), { mode: 0o600 });
|
||||
}
|
||||
return { filePath: destinationPath, count: entries.length };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { AuditService };
|
||||
@@ -0,0 +1,703 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { normalizeBaseUrl, assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePath, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
|
||||
|
||||
let cachedSafeStorage;
|
||||
|
||||
function getSafeStorage() {
|
||||
if (cachedSafeStorage !== undefined) return cachedSafeStorage;
|
||||
try {
|
||||
const electron = require('electron');
|
||||
cachedSafeStorage = electron && typeof electron === 'object' ? electron.safeStorage || null : null;
|
||||
} catch {
|
||||
cachedSafeStorage = null;
|
||||
}
|
||||
return cachedSafeStorage;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
schemaVersion: 13,
|
||||
setupComplete: false,
|
||||
appearance: 'dark',
|
||||
gitea: { baseUrl: '', user: null, encryptedToken: null },
|
||||
workspaceRoots: [],
|
||||
repositoryMappings: {},
|
||||
deploymentProfiles: {},
|
||||
deploymentStates: {},
|
||||
inventoryReviewDecisions: {},
|
||||
gitValidator: { policies: {}, suppressions: {}, trends: {} },
|
||||
favorites: [],
|
||||
updates: {
|
||||
owner: 'Jens',
|
||||
repo: 'ForgeFlow',
|
||||
branch: 'main',
|
||||
autoCheck: true,
|
||||
lastCheckedAt: null
|
||||
},
|
||||
servers: [],
|
||||
preferences: {
|
||||
autoRefresh: true,
|
||||
repositoryPollSeconds: 4,
|
||||
operationPollSeconds: 5,
|
||||
fetchIntervalMinutes: 10,
|
||||
preferredCloneProtocol: 'https',
|
||||
diagnosticsEnabled: true,
|
||||
diagnosticLevel: 'info',
|
||||
logRetentionDays: 14,
|
||||
maxLogFileMb: 8,
|
||||
editor: { executable: 'code', args: ['--reuse-window', '--goto', '{file}:{line}'] },
|
||||
terminal: { executable: 'wt.exe', args: ['-d', '{path}'] },
|
||||
notificationsEnabled: true,
|
||||
trayEnabled: true,
|
||||
closeToTray: false,
|
||||
startAtLogin: false
|
||||
},
|
||||
operations: []
|
||||
};
|
||||
|
||||
function uniqueStrings(values) {
|
||||
return [...new Set((Array.isArray(values) ? values : []).map((value) => String(value || '').trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
class ConfigStore {
|
||||
constructor(userDataPath) {
|
||||
this.filePath = path.join(userDataPath, 'forgeflow-config.json');
|
||||
this.sessionToken = null;
|
||||
this.data = structuredClone(DEFAULT_CONFIG);
|
||||
this.saveQueue = Promise.resolve();
|
||||
this.pendingSave = null;
|
||||
this.lastWrittenSnapshot = null;
|
||||
}
|
||||
|
||||
migrate(parsed) {
|
||||
const source = parsed && typeof parsed === 'object' ? parsed : {};
|
||||
return {
|
||||
...structuredClone(DEFAULT_CONFIG),
|
||||
...source,
|
||||
schemaVersion: DEFAULT_CONFIG.schemaVersion,
|
||||
gitea: { ...DEFAULT_CONFIG.gitea, ...(source.gitea || {}) },
|
||||
workspaceRoots: uniqueStrings(source.workspaceRoots),
|
||||
repositoryMappings: source.repositoryMappings && typeof source.repositoryMappings === 'object' ? source.repositoryMappings : {},
|
||||
inventoryReviewDecisions: source.inventoryReviewDecisions && typeof source.inventoryReviewDecisions === 'object' ? structuredClone(source.inventoryReviewDecisions) : {},
|
||||
gitValidator: {
|
||||
policies: source.gitValidator?.policies && typeof source.gitValidator.policies === 'object' ? structuredClone(source.gitValidator.policies) : {},
|
||||
suppressions: source.gitValidator?.suppressions && typeof source.gitValidator.suppressions === 'object' ? structuredClone(source.gitValidator.suppressions) : {},
|
||||
trends: source.gitValidator?.trends && typeof source.gitValidator.trends === 'object' ? structuredClone(source.gitValidator.trends) : {}
|
||||
},
|
||||
deploymentProfiles: source.deploymentProfiles && typeof source.deploymentProfiles === 'object'
|
||||
? Object.fromEntries(Object.entries(source.deploymentProfiles).map(([key, profiles]) => [key, (Array.isArray(profiles) ? profiles : []).map((profile) => {
|
||||
if (!profile || typeof profile !== 'object' || profile.provider !== 'ssh-unraid') return profile;
|
||||
const iconUrl = String(profile.iconUrl || '').trim();
|
||||
const iconFilePath = String(profile.iconFilePath || '').trim();
|
||||
const requestedMode = String(profile.iconMode || '').trim();
|
||||
const iconMode = ['builtin', 'upload', 'url', 'none'].includes(requestedMode)
|
||||
? requestedMode
|
||||
: iconFilePath ? 'upload' : iconUrl && !/itworx\.tech\/assets\/itworx-icon\.png/i.test(iconUrl) ? 'url' : 'builtin';
|
||||
const visibleName = String(profile.containerName || profile.remoteFolder || '').trim();
|
||||
const internalService = String(profile.composeService || profile.remoteFolder || 'app').trim().toLowerCase().replace(/[^a-z0-9._-]/g, '-') || 'app';
|
||||
const requestedDeploymentMode = String(profile.deploymentMode || '').trim();
|
||||
const deploymentMode = ['push-bundle', 'server-git', 'monitor-only'].includes(requestedDeploymentMode)
|
||||
? requestedDeploymentMode
|
||||
: 'push-bundle';
|
||||
const composeFiles = uniqueStrings(profile.composeFiles || [profile.composeFile || 'docker-compose.yml']);
|
||||
const composeServices = uniqueStrings(profile.composeServices || [internalService]).map((value) => value.toLowerCase());
|
||||
return {
|
||||
...profile,
|
||||
deploymentMode,
|
||||
composeFile: composeFiles[0] || 'docker-compose.yml',
|
||||
composeFiles: composeFiles.length ? composeFiles : ['docker-compose.yml'],
|
||||
composeServices,
|
||||
composeProject: String(profile.composeProject || '').trim(),
|
||||
composeWorkingDir: String(profile.composeWorkingDir || '').trim(),
|
||||
composeService: internalService,
|
||||
containerName: visibleName || internalService,
|
||||
iconMode,
|
||||
manageDockerMan: profile.manageDockerMan === true,
|
||||
forceRecreate: profile.forceRecreate === true,
|
||||
removeOrphans: profile.removeOrphans === true,
|
||||
workloadIdentity: profile.workloadIdentity && typeof profile.workloadIdentity === 'object' ? structuredClone(profile.workloadIdentity) : null
|
||||
};
|
||||
})]))
|
||||
: {},
|
||||
deploymentStates: source.deploymentStates && typeof source.deploymentStates === 'object' ? source.deploymentStates : {},
|
||||
favorites: [...new Set(uniqueStrings(source.favorites).map((item) => item.toLowerCase()))],
|
||||
updates: { ...DEFAULT_CONFIG.updates, ...(source.updates || {}) },
|
||||
servers: Array.isArray(source.servers) ? source.servers.filter((item) => item && typeof item === 'object') : [],
|
||||
preferences: { ...DEFAULT_CONFIG.preferences, ...(source.preferences || {}) },
|
||||
operations: Array.isArray(source.operations) ? source.operations.slice(0, 250).map((operation) => { const { runnerLog, ...safeOperation } = operation || {}; return safeOperation; }) : []
|
||||
};
|
||||
}
|
||||
|
||||
async load() {
|
||||
try {
|
||||
const raw = await fs.readFile(this.filePath, 'utf8');
|
||||
try {
|
||||
this.data = this.migrate(JSON.parse(raw));
|
||||
} catch (parseError) {
|
||||
const suffix = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const recoveryPath = `${this.filePath}.corrupt-${suffix}`;
|
||||
await fs.rename(this.filePath, recoveryPath).catch(async () => fs.writeFile(recoveryPath, raw, { mode: 0o600 }));
|
||||
this.data = structuredClone(DEFAULT_CONFIG);
|
||||
console.error(`ForgeFlow recovered a malformed configuration file to ${recoveryPath}.`, parseError);
|
||||
}
|
||||
await this.save();
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
await this.save();
|
||||
}
|
||||
return this.getPublicState();
|
||||
}
|
||||
|
||||
async save() {
|
||||
// Several callers persist in quick succession (a server scan writes deployment
|
||||
// state per workload). Serializing the configuration once per call is the
|
||||
// expensive part, so saves that are still queued share a single write of the
|
||||
// latest data. That is equivalent because every caller asks for "persist the
|
||||
// current configuration", not "persist the snapshot I saw".
|
||||
if (this.pendingSave) return this.pendingSave;
|
||||
const operation = async () => {
|
||||
this.pendingSave = null;
|
||||
const snapshot = JSON.stringify(this.data, null, 2);
|
||||
if (snapshot === this.lastWrittenSnapshot
|
||||
&& await fs.access(this.filePath).then(() => true).catch(() => false)) return;
|
||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||
const temporary = `${this.filePath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`;
|
||||
await fs.writeFile(temporary, snapshot, { mode: 0o600 });
|
||||
await fs.rename(temporary, this.filePath);
|
||||
try { await fs.chmod(this.filePath, 0o600); } catch {}
|
||||
this.lastWrittenSnapshot = snapshot;
|
||||
};
|
||||
this.pendingSave = this.saveQueue.then(operation, operation);
|
||||
this.saveQueue = this.pendingSave.catch(() => {});
|
||||
return this.pendingSave;
|
||||
}
|
||||
|
||||
getGitValidatorState(fullName) {
|
||||
const key = String(fullName || '').toLowerCase();
|
||||
return {
|
||||
policy: structuredClone(this.data.gitValidator.policies[key] || { id: 'standard' }),
|
||||
suppressions: structuredClone(this.data.gitValidator.suppressions[key] || []),
|
||||
trends: structuredClone(this.data.gitValidator.trends[key] || [])
|
||||
};
|
||||
}
|
||||
|
||||
async setGitValidatorPolicy(fullName, policy) {
|
||||
const key = String(fullName || '').toLowerCase();
|
||||
this.data.gitValidator.policies[key] = structuredClone(policy);
|
||||
await this.save();
|
||||
return this.getGitValidatorState(key);
|
||||
}
|
||||
|
||||
async addGitValidatorSuppression(fullName, suppression) {
|
||||
const key = String(fullName || '').toLowerCase();
|
||||
this.data.gitValidator.suppressions[key] = [...(this.data.gitValidator.suppressions[key] || []), structuredClone(suppression)].slice(-250);
|
||||
await this.save();
|
||||
return this.getGitValidatorState(key);
|
||||
}
|
||||
|
||||
async appendGitValidatorTrend(fullName, trend) {
|
||||
const key = String(fullName || '').toLowerCase();
|
||||
this.data.gitValidator.trends[key] = [...(this.data.gitValidator.trends[key] || []), structuredClone(trend)].slice(-100);
|
||||
await this.save();
|
||||
return this.getGitValidatorState(key);
|
||||
}
|
||||
|
||||
async createRecoverySnapshot(reason = 'configuration-change') {
|
||||
await this.saveQueue.catch(() => {});
|
||||
const safeReason = String(reason || 'configuration-change').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'configuration-change';
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const snapshotDirectory = path.join(path.dirname(this.filePath), 'snapshots');
|
||||
const snapshotPath = path.join(snapshotDirectory, `${timestamp}-${safeReason}.json`);
|
||||
await fs.mkdir(snapshotDirectory, { recursive: true });
|
||||
await fs.writeFile(snapshotPath, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
||||
try { await fs.chmod(snapshotDirectory, 0o700); } catch {}
|
||||
try { await fs.chmod(snapshotPath, 0o600); } catch {}
|
||||
return { filePath: snapshotPath, reason: safeReason, createdAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
setToken(token, { preserveExisting = false } = {}) {
|
||||
const value = String(token || '').trim();
|
||||
if (!value && preserveExisting && this.getToken()) return { persistent: Boolean(this.data.gitea.encryptedToken), preserved: true };
|
||||
if (!value) {
|
||||
this.data.gitea.encryptedToken = null;
|
||||
this.sessionToken = null;
|
||||
return { persistent: true, preserved: false };
|
||||
}
|
||||
|
||||
const safeStorage = getSafeStorage();
|
||||
if (safeStorage?.isEncryptionAvailable?.()) {
|
||||
this.data.gitea.encryptedToken = safeStorage.encryptString(value).toString('base64');
|
||||
this.sessionToken = null;
|
||||
return { persistent: true, preserved: false };
|
||||
}
|
||||
|
||||
this.data.gitea.encryptedToken = null;
|
||||
this.sessionToken = value;
|
||||
return { persistent: false, preserved: false };
|
||||
}
|
||||
|
||||
getToken() {
|
||||
if (this.sessionToken) return this.sessionToken;
|
||||
if (!this.data.gitea.encryptedToken) return '';
|
||||
try {
|
||||
const safeStorage = getSafeStorage();
|
||||
return safeStorage?.decryptString?.(Buffer.from(this.data.gitea.encryptedToken, 'base64')) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
encryptSecret(value) {
|
||||
const text = String(value || '');
|
||||
if (!text) return null;
|
||||
const safeStorage = getSafeStorage();
|
||||
if (!safeStorage?.isEncryptionAvailable?.()) {
|
||||
const error = new Error('Secure credential storage is unavailable. ForgeFlow will not persist server passwords or key passphrases.');
|
||||
error.code = 'SECURE_STORAGE_UNAVAILABLE';
|
||||
throw error;
|
||||
}
|
||||
return safeStorage.encryptString(text).toString('base64');
|
||||
}
|
||||
|
||||
decryptSecret(value) {
|
||||
if (!value) return '';
|
||||
try {
|
||||
const safeStorage = getSafeStorage();
|
||||
return safeStorage?.decryptString?.(Buffer.from(value, 'base64')) || '';
|
||||
}
|
||||
catch { return ''; }
|
||||
}
|
||||
|
||||
normalizeServer(server, existing = null) {
|
||||
const source = server || {};
|
||||
const name = String(source.name || existing?.name || 'Unraid').trim().slice(0, 100);
|
||||
const host = String(source.host || existing?.host || '').trim();
|
||||
if (!host || /[\s/@]/.test(host)) throw new Error('Enter a valid SSH hostname or IP address.');
|
||||
const port = Math.min(Math.max(Number(source.port || existing?.port || 22), 1), 65535);
|
||||
const username = String(source.username || existing?.username || '').trim();
|
||||
if (!username || /[\s@]/.test(username)) throw new Error('Enter a valid SSH username.');
|
||||
const authType = ['password', 'privateKey'].includes(source.authType) ? source.authType : (existing?.authType || 'password');
|
||||
const basePath = String(source.basePath || existing?.basePath || '/mnt/user/appdata').trim().replace(/\/+$/, '');
|
||||
if (!basePath.startsWith('/') || /[\r\n\0]/.test(basePath)) throw new Error('The server base path must be an absolute Unix path.');
|
||||
const privateKeyPath = String(source.privateKeyPath || existing?.privateKeyPath || '').trim();
|
||||
const credentialIdentityChanged = Boolean(existing && [
|
||||
['host', existing.host, host],
|
||||
['port', existing.port, port],
|
||||
['username', existing.username, username],
|
||||
['authType', existing.authType, authType],
|
||||
['privateKeyPath', existing.privateKeyPath, privateKeyPath]
|
||||
].some(([, previous, next]) => String(previous || '') !== String(next || '')));
|
||||
const hostFingerprint = credentialIdentityChanged
|
||||
? ''
|
||||
: String(source.hostFingerprint || existing?.hostFingerprint || '').trim();
|
||||
const scanRoots = uniqueStrings(source.scanRoots || existing?.scanRoots || [basePath]).map((value) => value.replace(/\/+$/, '')).filter((value) => value.startsWith('/') && !/[\r\n\0]/.test(value));
|
||||
const scanExcludes = uniqueStrings(source.scanExcludes || existing?.scanExcludes || ['backups', 'archives', 'releases', 'staging', 'testdata']).filter((value) => /^[a-zA-Z0-9._*-]+$/.test(value));
|
||||
return {
|
||||
id: source.id || existing?.id || crypto.randomUUID(),
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
authType,
|
||||
basePath,
|
||||
scanRoots: scanRoots.length ? scanRoots : [basePath],
|
||||
scanExcludes,
|
||||
privateKeyPath,
|
||||
hostFingerprint,
|
||||
encryptedPassword: credentialIdentityChanged ? null : existing?.encryptedPassword || null,
|
||||
encryptedPassphrase: credentialIdentityChanged ? null : existing?.encryptedPassphrase || null,
|
||||
createdAt: existing?.createdAt || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
async saveServer(server, secrets = {}) {
|
||||
const existing = this.data.servers.find((item) => item.id === server?.id) || null;
|
||||
const normalized = this.normalizeServer(server, existing);
|
||||
if (Object.prototype.hasOwnProperty.call(secrets, 'password') && String(secrets.password || '')) {
|
||||
normalized.encryptedPassword = this.encryptSecret(secrets.password);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(secrets, 'passphrase') && String(secrets.passphrase || '')) {
|
||||
normalized.encryptedPassphrase = this.encryptSecret(secrets.passphrase);
|
||||
}
|
||||
if (normalized.authType === 'password') {
|
||||
normalized.privateKeyPath = '';
|
||||
normalized.encryptedPassphrase = null;
|
||||
} else {
|
||||
normalized.encryptedPassword = null;
|
||||
}
|
||||
if (normalized.authType === 'password' && !normalized.encryptedPassword) throw new Error('A password is required for password authentication.');
|
||||
if (normalized.authType === 'privateKey' && !normalized.privateKeyPath) throw new Error('Select a private key file.');
|
||||
this.data.servers = [normalized, ...this.data.servers.filter((item) => item.id !== normalized.id)];
|
||||
await this.save();
|
||||
return this.getPublicServer(normalized);
|
||||
}
|
||||
|
||||
async deleteServer(serverId) {
|
||||
this.data.servers = this.data.servers.filter((item) => item.id !== serverId);
|
||||
const removedProfileIds = new Set();
|
||||
for (const [key, profiles] of Object.entries(this.data.deploymentProfiles)) {
|
||||
for (const profile of profiles) if (profile.serverId === serverId) removedProfileIds.add(profile.id);
|
||||
this.data.deploymentProfiles[key] = profiles.filter((profile) => profile.serverId !== serverId);
|
||||
if (!this.data.deploymentProfiles[key].length) delete this.data.deploymentProfiles[key];
|
||||
}
|
||||
for (const profileId of removedProfileIds) delete this.data.deploymentStates[profileId];
|
||||
await this.save();
|
||||
}
|
||||
|
||||
getServer(serverId) {
|
||||
return this.data.servers.find((item) => item.id === serverId) || null;
|
||||
}
|
||||
|
||||
getServerCredentials(serverId) {
|
||||
const server = this.getServer(serverId);
|
||||
if (!server) throw new Error('The configured server no longer exists.');
|
||||
return {
|
||||
password: this.decryptSecret(server.encryptedPassword),
|
||||
passphrase: this.decryptSecret(server.encryptedPassphrase)
|
||||
};
|
||||
}
|
||||
|
||||
getPublicServer(server) {
|
||||
if (!server) return null;
|
||||
const { encryptedPassword, encryptedPassphrase, ...publicServer } = server;
|
||||
return {
|
||||
...structuredClone(publicServer),
|
||||
hasPassword: Boolean(encryptedPassword),
|
||||
hasPassphrase: Boolean(encryptedPassphrase)
|
||||
};
|
||||
}
|
||||
|
||||
async setUpdatePreferences(updates) {
|
||||
const next = { ...this.data.updates, ...(updates || {}) };
|
||||
next.owner = String(next.owner || 'Jens').trim().slice(0, 100);
|
||||
next.repo = String(next.repo || 'ForgeFlow').trim().slice(0, 100);
|
||||
next.branch = assertBranchName(next.branch || 'main');
|
||||
next.autoCheck = next.autoCheck !== false;
|
||||
this.data.updates = next;
|
||||
await this.save();
|
||||
return this.getPublicState();
|
||||
}
|
||||
|
||||
async patch(patch) {
|
||||
this.data = this.migrate({ ...this.data, ...patch });
|
||||
await this.save();
|
||||
return this.getPublicState();
|
||||
}
|
||||
|
||||
async restoreConfiguration(configuration) {
|
||||
const restored = this.migrate(configuration);
|
||||
restored.gitea.encryptedToken = String(restored.gitea.baseUrl || '').replace(/\/+$/, '').toLowerCase() === String(this.data.gitea.baseUrl || '').replace(/\/+$/, '').toLowerCase()
|
||||
? this.data.gitea.encryptedToken
|
||||
: null;
|
||||
const existingServers = new Map(this.data.servers.map((server) => [server.id, server]));
|
||||
restored.servers = restored.servers.map((server) => {
|
||||
const existing = existingServers.get(server.id);
|
||||
const sameCredentialTarget = existing
|
||||
&& ['host', 'port', 'username', 'authType', 'privateKeyPath'].every((key) => String(existing[key] || '') === String(server[key] || ''));
|
||||
return {
|
||||
...server,
|
||||
encryptedPassword: sameCredentialTarget ? existing.encryptedPassword || null : null,
|
||||
encryptedPassphrase: sameCredentialTarget ? existing.encryptedPassphrase || null : null
|
||||
};
|
||||
});
|
||||
restored.operations = this.data.operations;
|
||||
this.data = restored;
|
||||
await this.save();
|
||||
return this.getPublicState();
|
||||
}
|
||||
|
||||
async updateGitea({ baseUrl, token, user }) {
|
||||
const nextBaseUrl = normalizeBaseUrl(baseUrl);
|
||||
const currentBaseUrl = this.data.gitea.baseUrl
|
||||
? normalizeBaseUrl(this.data.gitea.baseUrl)
|
||||
: '';
|
||||
if (!String(token || '').trim() && nextBaseUrl !== currentBaseUrl && this.getToken()) {
|
||||
const error = new Error('Enter a new Gitea token when changing the server address.');
|
||||
error.code = 'GITEA_TOKEN_ORIGIN_CHANGED';
|
||||
throw error;
|
||||
}
|
||||
const tokenState = this.setToken(token, { preserveExisting: true });
|
||||
this.data.gitea = {
|
||||
...this.data.gitea,
|
||||
baseUrl: nextBaseUrl,
|
||||
user: user || this.data.gitea.user,
|
||||
encryptedToken: this.data.gitea.encryptedToken
|
||||
};
|
||||
await this.save();
|
||||
return tokenState;
|
||||
}
|
||||
|
||||
async completeSetup({ baseUrl, token, user, workspaceRoots }) {
|
||||
const tokenState = this.setToken(token);
|
||||
this.data.setupComplete = true;
|
||||
this.data.gitea = { baseUrl, user, encryptedToken: this.data.gitea.encryptedToken };
|
||||
this.data.workspaceRoots = uniqueStrings(workspaceRoots);
|
||||
await this.save();
|
||||
return { state: this.getPublicState(), tokenState };
|
||||
}
|
||||
|
||||
async saveMapping(fullName, localPath) {
|
||||
this.data.repositoryMappings[String(fullName).toLowerCase()] = localPath;
|
||||
await this.save();
|
||||
}
|
||||
|
||||
async removeMapping(fullName) {
|
||||
delete this.data.repositoryMappings[String(fullName).toLowerCase()];
|
||||
await this.save();
|
||||
}
|
||||
|
||||
async setFavorite(fullName, favorite) {
|
||||
const key = String(fullName || '').toLowerCase();
|
||||
const favorites = new Set(this.data.favorites || []);
|
||||
if (favorite) favorites.add(key); else favorites.delete(key);
|
||||
this.data.favorites = [...favorites];
|
||||
await this.save();
|
||||
return this.getPublicState();
|
||||
}
|
||||
|
||||
normalizeDeploymentProfile(profile) {
|
||||
const environment = assertEnvironmentName(profile.environment || 'production');
|
||||
const provider = ['gitea-actions', 'ssh-unraid'].includes(profile.provider) ? profile.provider : 'gitea-actions';
|
||||
const healthcheckUrl = assertHttpUrl(profile.healthcheckUrl, { optional: true, label: 'Healthcheck URL' });
|
||||
const common = {
|
||||
id: profile.id || crypto.randomUUID(),
|
||||
name: String(profile.name || environment || 'Production').trim().slice(0, 100),
|
||||
environment,
|
||||
provider,
|
||||
branch: assertBranchName(profile.branch || 'main'),
|
||||
healthcheckUrl,
|
||||
confirmationRequired: profile.confirmationRequired !== false,
|
||||
deploymentPolicy: {
|
||||
frozen: profile.deploymentPolicy?.frozen === true,
|
||||
freezeReason: String(profile.deploymentPolicy?.freezeReason || '').trim().slice(0, 500),
|
||||
requireNote: profile.deploymentPolicy?.requireNote === true,
|
||||
maintenanceWindows: (Array.isArray(profile.deploymentPolicy?.maintenanceWindows) ? profile.deploymentPolicy.maintenanceWindows : []).slice(0, 20).map((window) => ({
|
||||
days: [...new Set((Array.isArray(window?.days) ? window.days : []).map(Number).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6))],
|
||||
start: String(window?.start || '00:00'),
|
||||
end: String(window?.end || '23:59')
|
||||
}))
|
||||
},
|
||||
inputs: {}
|
||||
};
|
||||
if (provider === 'ssh-unraid') {
|
||||
const remoteFolder = assertRepositoryRelativePath(String(profile.remoteFolder || '').trim());
|
||||
if (!remoteFolder || remoteFolder === '.' || remoteFolder.split('/').some((part) => !part || part === '.')) throw new Error('Remote folder must be a safe path relative to the configured server base path.');
|
||||
const preservePaths = assertRepositoryRelativePaths(uniqueStrings(profile.preservePaths || ['.env', 'appdata', 'data', 'logs', 'config', 'compose.override.yml']));
|
||||
const composeFiles = assertRepositoryRelativePaths(uniqueStrings(profile.composeFiles || [profile.composeFile || 'docker-compose.yml']));
|
||||
if (!composeFiles.length && profile.generatedCompose !== true) throw new Error('Select at least one Compose file.');
|
||||
const composeService = (() => {
|
||||
const value = String(profile.composeService || profile.composeServices?.[0] || remoteFolder.split('/').pop()).trim().toLowerCase();
|
||||
if (!/^[a-z0-9._-]+$/.test(value)) throw new Error('Compose service must be lowercase and contain only letters, numbers, dots, underscores and dashes.');
|
||||
return value;
|
||||
})();
|
||||
const composeServices = uniqueStrings(profile.composeServices || [composeService]).map((value) => {
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (!/^[a-z0-9._-]+$/.test(normalized)) throw new Error('Compose services must be lowercase and contain only letters, numbers, dots, underscores and dashes.');
|
||||
return normalized;
|
||||
});
|
||||
const composeProject = String(profile.composeProject || '').trim();
|
||||
if (composeProject && !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(composeProject)) throw new Error('Compose project name contains unsupported characters.');
|
||||
const composeWorkingDir = String(profile.composeWorkingDir || '').trim();
|
||||
if (composeWorkingDir && (!composeWorkingDir.startsWith('/') || /[\r\n\0]/.test(composeWorkingDir))) throw new Error('Compose working directory must be an absolute safe Unix path.');
|
||||
const deploymentMode = ['push-bundle', 'server-git', 'monitor-only'].includes(profile.deploymentMode)
|
||||
? profile.deploymentMode
|
||||
: 'push-bundle';
|
||||
return {
|
||||
...common,
|
||||
serverId: String(profile.serverId || '').trim(),
|
||||
remoteFolder,
|
||||
deploymentMode,
|
||||
composeFile: composeFiles[0] || 'docker-compose.yml',
|
||||
composeFiles: composeFiles.length ? composeFiles : ['docker-compose.yml'],
|
||||
composeProject,
|
||||
composeWorkingDir,
|
||||
composeService,
|
||||
composeServices,
|
||||
containerName: (() => {
|
||||
const value = String(profile.containerName || remoteFolder.split('/').pop()).trim();
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(value)) throw new Error('Container name must contain only letters, numbers, dots, underscores and dashes.');
|
||||
return value;
|
||||
})(),
|
||||
cloneUrl: profile.cloneUrl ? assertCloneRemote(profile.cloneUrl) : '',
|
||||
alignRemote: profile.alignRemote === true,
|
||||
hostPort: profile.hostPort ? Math.min(Math.max(Number(profile.hostPort), 1), 65535) : null,
|
||||
containerPort: profile.containerPort ? Math.min(Math.max(Number(profile.containerPort), 1), 65535) : null,
|
||||
webUiUrl: assertHttpUrl(profile.webUiUrl, { optional: true, label: 'Web UI URL', allowUnraidTemplate: true }),
|
||||
iconMode: ['builtin', 'upload', 'url', 'none'].includes(profile.iconMode)
|
||||
? profile.iconMode
|
||||
: profile.iconFilePath ? 'upload' : profile.iconUrl ? 'url' : 'builtin',
|
||||
iconUrl: assertHttpUrl(profile.iconUrl, { optional: true, label: 'Icon URL' }),
|
||||
iconFilePath: String(profile.iconFilePath || '').trim(),
|
||||
dockerShell: ['/bin/sh', '/bin/bash'].includes(profile.dockerShell) ? profile.dockerShell : '/bin/sh',
|
||||
preservePaths,
|
||||
generatedCompose: profile.generatedCompose === true,
|
||||
adoptedFromServer: profile.adoptedFromServer === true,
|
||||
serverSourceOfTruth: profile.serverSourceOfTruth === true,
|
||||
manageDockerMan: profile.manageDockerMan === true,
|
||||
forceRecreate: profile.forceRecreate === true,
|
||||
removeOrphans: profile.removeOrphans === true,
|
||||
workloadIdentity: profile.workloadIdentity && typeof profile.workloadIdentity === 'object' ? structuredClone(profile.workloadIdentity) : null,
|
||||
serverGitAccess: profile.serverGitAccess && typeof profile.serverGitAccess === 'object' ? {
|
||||
configured: profile.serverGitAccess.configured === true,
|
||||
deployKeyId: Number.isFinite(Number(profile.serverGitAccess.deployKeyId)) ? Number(profile.serverGitAccess.deployKeyId) : null,
|
||||
keyFingerprint: String(profile.serverGitAccess.keyFingerprint || '').trim().slice(0, 200) || null,
|
||||
hostFingerprint: String(profile.serverGitAccess.hostFingerprint || '').trim().slice(0, 200) || null,
|
||||
configuredAt: profile.serverGitAccess.configuredAt || null
|
||||
} : null,
|
||||
detectedAt: profile.detectedAt || null,
|
||||
provenance: profile.provenance && typeof profile.provenance === 'object' ? structuredClone(profile.provenance) : {},
|
||||
detectedMetadata: profile.detectedMetadata && typeof profile.detectedMetadata === 'object' ? structuredClone(profile.detectedMetadata) : {},
|
||||
serverIconReference: String(profile.serverIconReference || '').trim()
|
||||
};
|
||||
}
|
||||
const statusUrl = assertHttpUrl(profile.statusUrl, { label: 'Application status URL' });
|
||||
return {
|
||||
...common,
|
||||
workflowFile: assertWorkflowFileName(profile.workflowFile || 'deploy.yml'),
|
||||
rollbackWorkflowFile: profile.rollbackWorkflowFile ? assertWorkflowFileName(profile.rollbackWorkflowFile) : '',
|
||||
statusUrl
|
||||
};
|
||||
}
|
||||
|
||||
async saveDeploymentProfile(fullName, profile) {
|
||||
const key = String(fullName).toLowerCase();
|
||||
const profiles = Array.isArray(this.data.deploymentProfiles[key]) ? this.data.deploymentProfiles[key] : [];
|
||||
const normalized = this.normalizeDeploymentProfile(profile || {});
|
||||
const next = profiles.filter((item) => item.id !== normalized.id);
|
||||
next.push(normalized);
|
||||
this.data.deploymentProfiles[key] = next;
|
||||
await this.save();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async deleteDeploymentProfile(fullName, profileId) {
|
||||
const key = String(fullName || '').toLowerCase();
|
||||
const profiles = Array.isArray(this.data.deploymentProfiles[key]) ? this.data.deploymentProfiles[key] : [];
|
||||
const next = profiles.filter((item) => item.id !== profileId);
|
||||
if (next.length) this.data.deploymentProfiles[key] = next;
|
||||
else delete this.data.deploymentProfiles[key];
|
||||
delete this.data.deploymentStates[profileId];
|
||||
await this.save();
|
||||
return next;
|
||||
}
|
||||
|
||||
getDeploymentProfiles(fullName) {
|
||||
return structuredClone(this.data.deploymentProfiles[String(fullName || '').toLowerCase()] || []);
|
||||
}
|
||||
|
||||
getDeploymentProfile(fullName, profileId) {
|
||||
return this.getDeploymentProfiles(fullName).find((item) => item.id === profileId) || null;
|
||||
}
|
||||
|
||||
getInventoryReviewDecisions(serverId) {
|
||||
return structuredClone(this.data.inventoryReviewDecisions[String(serverId || '')] || []);
|
||||
}
|
||||
|
||||
async saveInventoryReviewDecision(serverId, decision) {
|
||||
const key = String(serverId || '');
|
||||
if (!key || !decision?.workloadId || !/^[0-9a-f]{64}$/i.test(String(decision.evidenceHash || ''))) throw new Error('A server, workload and evidence hash are required for an inventory review decision.');
|
||||
const decisions = this.getInventoryReviewDecisions(key).filter((item) => item.workloadId !== decision.workloadId);
|
||||
decisions.push(structuredClone(decision));
|
||||
this.data.inventoryReviewDecisions[key] = decisions;
|
||||
await this.save();
|
||||
return structuredClone(decision);
|
||||
}
|
||||
|
||||
async deleteInventoryReviewDecision(serverId, workloadId) {
|
||||
const key = String(serverId || '');
|
||||
this.data.inventoryReviewDecisions[key] = this.getInventoryReviewDecisions(key).filter((item) => item.workloadId !== workloadId);
|
||||
await this.save();
|
||||
return this.getInventoryReviewDecisions(key);
|
||||
}
|
||||
|
||||
async saveDeploymentState(profileId, state) {
|
||||
this.data.deploymentStates[profileId] = {
|
||||
...(this.data.deploymentStates[profileId] || {}),
|
||||
...state,
|
||||
checkedAt: state.checkedAt || new Date().toISOString()
|
||||
};
|
||||
await this.save();
|
||||
return structuredClone(this.data.deploymentStates[profileId]);
|
||||
}
|
||||
|
||||
getDeploymentState(profileId) {
|
||||
return structuredClone(this.data.deploymentStates[profileId] || null);
|
||||
}
|
||||
|
||||
async addOperation(operation) {
|
||||
const existing = this.data.operations.find((item) => item.id === operation.id);
|
||||
const normalized = {
|
||||
id: operation.id || crypto.randomUUID(),
|
||||
createdAt: existing?.createdAt || operation.createdAt || new Date().toISOString(),
|
||||
...existing,
|
||||
...operation,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
this.data.operations = [normalized, ...this.data.operations.filter((item) => item.id !== normalized.id)].slice(0, 250);
|
||||
await this.save();
|
||||
return structuredClone(normalized);
|
||||
}
|
||||
|
||||
getOperation(operationId) {
|
||||
return structuredClone(this.data.operations.find((item) => item.id === operationId) || null);
|
||||
}
|
||||
|
||||
async setPreferences(preferences) {
|
||||
const next = { ...this.data.preferences, ...(preferences || {}) };
|
||||
next.repositoryPollSeconds = Math.min(Math.max(Number(next.repositoryPollSeconds) || 4, 2), 60);
|
||||
next.operationPollSeconds = Math.min(Math.max(Number(next.operationPollSeconds) || 5, 3), 120);
|
||||
const fetchIntervalMinutes = Number(next.fetchIntervalMinutes);
|
||||
next.fetchIntervalMinutes = Number.isFinite(fetchIntervalMinutes)
|
||||
? Math.min(Math.max(fetchIntervalMinutes, 0), 240)
|
||||
: 10;
|
||||
next.autoRefresh = next.autoRefresh !== false;
|
||||
next.preferredCloneProtocol = ['https', 'ssh'].includes(next.preferredCloneProtocol) ? next.preferredCloneProtocol : 'https';
|
||||
next.diagnosticsEnabled = next.diagnosticsEnabled !== false;
|
||||
next.diagnosticLevel = ['debug', 'info', 'warning', 'error'].includes(next.diagnosticLevel) ? next.diagnosticLevel : 'info';
|
||||
next.logRetentionDays = Math.min(Math.max(Number(next.logRetentionDays) || 14, 1), 90);
|
||||
next.maxLogFileMb = Math.min(Math.max(Number(next.maxLogFileMb) || 8, 1), 50);
|
||||
const normalizeTool = (tool, fallback) => ({
|
||||
executable: String(tool?.executable || fallback.executable).trim().slice(0, 500),
|
||||
args: (Array.isArray(tool?.args) ? tool.args : fallback.args).map((item) => String(item).slice(0, 500)).slice(0, 20)
|
||||
});
|
||||
next.editor = normalizeTool(next.editor, DEFAULT_CONFIG.preferences.editor);
|
||||
next.terminal = normalizeTool(next.terminal, DEFAULT_CONFIG.preferences.terminal);
|
||||
next.notificationsEnabled = next.notificationsEnabled !== false;
|
||||
next.trayEnabled = next.trayEnabled !== false;
|
||||
next.closeToTray = next.closeToTray === true;
|
||||
next.startAtLogin = next.startAtLogin === true;
|
||||
this.data.preferences = next;
|
||||
await this.save();
|
||||
return this.getPublicState();
|
||||
}
|
||||
|
||||
getPublicState() {
|
||||
return {
|
||||
schemaVersion: this.data.schemaVersion,
|
||||
setupComplete: this.data.setupComplete,
|
||||
appearance: this.data.appearance,
|
||||
gitea: {
|
||||
baseUrl: this.data.gitea.baseUrl,
|
||||
user: this.data.gitea.user,
|
||||
hasToken: Boolean(this.getToken())
|
||||
},
|
||||
workspaceRoots: [...this.data.workspaceRoots],
|
||||
repositoryMappings: { ...this.data.repositoryMappings },
|
||||
deploymentProfiles: structuredClone(this.data.deploymentProfiles),
|
||||
deploymentStates: structuredClone(this.data.deploymentStates),
|
||||
gitValidator: structuredClone(this.data.gitValidator),
|
||||
favorites: [...this.data.favorites],
|
||||
updates: { ...this.data.updates },
|
||||
servers: this.data.servers.map((server) => this.getPublicServer(server)),
|
||||
preferences: { ...this.data.preferences },
|
||||
operations: structuredClone(this.data.operations)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ConfigStore, DEFAULT_CONFIG };
|
||||
@@ -0,0 +1,62 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const FORMAT = 'forgeflow-config-backup';
|
||||
const VERSION = 1;
|
||||
|
||||
function sanitizeConfiguration(data) {
|
||||
const source = structuredClone(data || {});
|
||||
if (source.gitea) source.gitea.encryptedToken = null;
|
||||
source.servers = (source.servers || []).map(({ encryptedPassword, encryptedPassphrase, ...server }) => server);
|
||||
source.operations = [];
|
||||
return source;
|
||||
}
|
||||
|
||||
function deriveKey(passphrase, salt) {
|
||||
const secret = String(passphrase || '');
|
||||
if (secret.length < 12) throw new Error('Backup passphrase must contain at least 12 characters.');
|
||||
return crypto.scryptSync(secret, salt, 32, { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 });
|
||||
}
|
||||
|
||||
function createEncryptedBackup(data, passphrase) {
|
||||
const salt = crypto.randomBytes(16);
|
||||
const iv = crypto.randomBytes(12);
|
||||
const key = deriveKey(passphrase, salt);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
const plaintext = Buffer.from(JSON.stringify({ exportedAt: new Date().toISOString(), configuration: sanitizeConfiguration(data) }), 'utf8');
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
return JSON.stringify({
|
||||
format: FORMAT,
|
||||
version: VERSION,
|
||||
kdf: 'scrypt',
|
||||
cipher: 'aes-256-gcm',
|
||||
salt: salt.toString('base64'),
|
||||
iv: iv.toString('base64'),
|
||||
tag: cipher.getAuthTag().toString('base64'),
|
||||
data: encrypted.toString('base64')
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
function readEncryptedBackup(serialized, passphrase) {
|
||||
let envelope;
|
||||
try { envelope = JSON.parse(String(serialized || '')); }
|
||||
catch { throw new Error('The selected file is not a valid ForgeFlow backup.'); }
|
||||
if (envelope.format !== FORMAT || envelope.version !== VERSION || envelope.kdf !== 'scrypt' || envelope.cipher !== 'aes-256-gcm') {
|
||||
throw new Error('Unsupported ForgeFlow backup format or version.');
|
||||
}
|
||||
try {
|
||||
const key = deriveKey(passphrase, Buffer.from(envelope.salt, 'base64'));
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(envelope.iv, 'base64'));
|
||||
decipher.setAuthTag(Buffer.from(envelope.tag, 'base64'));
|
||||
const decoded = Buffer.concat([decipher.update(Buffer.from(envelope.data, 'base64')), decipher.final()]);
|
||||
const payload = JSON.parse(decoded.toString('utf8'));
|
||||
if (!payload.configuration || typeof payload.configuration !== 'object') throw new Error('Configuration payload is missing.');
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (/passphrase|payload/i.test(error.message)) throw error;
|
||||
throw new Error('The backup could not be decrypted. Check the passphrase and file integrity.');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { FORMAT, VERSION, sanitizeConfiguration, createEncryptedBackup, readEncryptedBackup };
|
||||
@@ -0,0 +1,191 @@
|
||||
"use strict";
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
function stable(value) {
|
||||
if (Array.isArray(value)) return value.map(stable);
|
||||
if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
|
||||
return value;
|
||||
}
|
||||
|
||||
function planId(value) {
|
||||
return crypto.createHash("sha256").update(JSON.stringify(stable(value))).digest("hex");
|
||||
}
|
||||
|
||||
function keyMaterial(value) {
|
||||
return String(value || "").trim().split(/\s+/).slice(0, 2).join(" ");
|
||||
}
|
||||
|
||||
class DeployKeyLifecycleService {
|
||||
constructor({ store, gitea, keyHost, audit = null, clock = () => new Date().toISOString() }) {
|
||||
this.store = store;
|
||||
this.gitea = gitea;
|
||||
this.keyHost = keyHost;
|
||||
this.audit = audit;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
coordinates(repository) {
|
||||
const [owner, repo] = String(repository?.fullName || "").split("/");
|
||||
if (!owner || !repo) throw Object.assign(new Error("A full Gitea repository name is required."), { code: "DEPLOY_KEY_REPOSITORY_REQUIRED" });
|
||||
return { owner, repo };
|
||||
}
|
||||
|
||||
profile(repository, profileId) {
|
||||
const profile = this.store.getDeploymentProfile(repository.fullName, profileId);
|
||||
if (!profile) throw Object.assign(new Error("Deployment profile not found."), { code: "DEPLOY_KEY_PROFILE_NOT_FOUND" });
|
||||
const server = this.store.getServer(profile.serverId);
|
||||
if (!server) throw Object.assign(new Error("Deployment server not found."), { code: "DEPLOY_KEY_SERVER_NOT_FOUND" });
|
||||
return { profile, server };
|
||||
}
|
||||
|
||||
configuredReferences() {
|
||||
const references = [];
|
||||
const configured = this.store.data?.deploymentProfiles
|
||||
? Object.entries(this.store.data.deploymentProfiles).map(([fullName, profiles]) => ({ fullName, profiles }))
|
||||
: (this.store.getRepositories?.() || []).map((repository) => ({ fullName: repository.fullName, profiles: this.store.getDeploymentProfiles(repository.fullName) || [] }));
|
||||
for (const repository of configured) {
|
||||
for (const profile of repository.profiles || []) {
|
||||
if (!profile.serverGitAccess?.deployKeyId && !profile.serverGitAccess?.keyFingerprint) continue;
|
||||
references.push({ repository: repository.fullName, profileId: profile.id, serverId: profile.serverId, keyId: profile.serverGitAccess.deployKeyId || null, fingerprint: profile.serverGitAccess.keyFingerprint || null });
|
||||
}
|
||||
}
|
||||
return references;
|
||||
}
|
||||
|
||||
async inventory({ repository, profileId }) {
|
||||
const { profile, server } = this.profile(repository, profileId);
|
||||
const { owner, repo } = this.coordinates(repository);
|
||||
const [remoteKeys, serverKey] = await Promise.all([
|
||||
this.gitea.listDeployKeys(owner, repo),
|
||||
this.keyHost.inspect({ repository, profile, server }),
|
||||
]);
|
||||
const configuredId = Number(profile.serverGitAccess?.deployKeyId) || null;
|
||||
const configured = remoteKeys.find((key) => Number(key.id) === configuredId) || null;
|
||||
const material = keyMaterial(serverKey?.publicKey);
|
||||
const matching = material ? remoteKeys.filter((key) => keyMaterial(key.key) === material) : [];
|
||||
const references = this.configuredReferences();
|
||||
const shared = references.filter((reference) => reference.fingerprint && reference.fingerprint === serverKey?.fingerprint && (reference.repository !== repository.fullName || reference.profileId !== profileId));
|
||||
const conflicts = remoteKeys.filter((key) => key.read_only !== true && (!configuredId || Number(key.id) === configuredId || keyMaterial(key.key) === material));
|
||||
const stale = Boolean(configuredId && !configured) || Boolean(profile.serverGitAccess?.keyFingerprint && serverKey?.fingerprint && profile.serverGitAccess.keyFingerprint !== serverKey.fingerprint);
|
||||
const orphaned = remoteKeys.filter((key) => /ForgeFlow/i.test(String(key.title || "")) && !references.some((reference) => Number(reference.keyId) === Number(key.id)));
|
||||
return {
|
||||
repository: repository.fullName, profileId, server: { id: server.id, name: server.name },
|
||||
configuredKey: configured ? { id: configured.id, title: configured.title, readOnly: configured.read_only === true, key: configured.key || null } : null,
|
||||
serverKey, matchingKeys: matching.map((key) => ({ id: key.id, readOnly: key.read_only === true })),
|
||||
stale, orphaned: orphaned.map((key) => ({ id: key.id, title: key.title })), shared, conflicts: conflicts.map((key) => ({ id: key.id, title: key.title, readOnly: false })),
|
||||
ready: Boolean(configured && configured.read_only === true && serverKey?.privateKeyPresent && serverKey?.fingerprint === profile.serverGitAccess?.keyFingerprint && !shared.length && !conflicts.length),
|
||||
checkedAt: this.clock(),
|
||||
};
|
||||
}
|
||||
|
||||
async planRotation({ repository, profileId }) {
|
||||
const evidence = await this.inventory({ repository, profileId });
|
||||
const plan = {
|
||||
operation: "rotate-deploy-key", repository: repository.fullName, profileId,
|
||||
currentKeyId: evidence.configuredKey?.id || null, currentFingerprint: evidence.serverKey?.fingerprint || null,
|
||||
serverId: evidence.server.id, impact: ["Generate a new private key on the linked server", "Register only its public key in this repository", "Verify read-only branch access", "Switch the profile atomically", "Revoke the previous key after the switch"],
|
||||
recovery: "The previous server key and profile metadata remain recoverable until post-rotation verification succeeds.", evidence,
|
||||
};
|
||||
plan.id = planId(plan);
|
||||
await this.audit?.append?.("deployment.deploy-key-rotation-planned", { repository: repository.fullName, profileId, planId: plan.id });
|
||||
return plan;
|
||||
}
|
||||
|
||||
async rotate({ repository, profileId, expectedPlanId }) {
|
||||
const plan = await this.planRotation({ repository, profileId });
|
||||
if (!expectedPlanId) throw Object.assign(new Error("Review a deploy-key rotation plan before applying it."), { code: "DEPLOY_KEY_ROTATION_PLAN_REQUIRED", plan });
|
||||
if (expectedPlanId !== plan.id) throw Object.assign(new Error("Deploy-key evidence changed after preview. Review a fresh plan."), { code: "DEPLOY_KEY_ROTATION_PLAN_STALE", plan });
|
||||
const { profile, server } = this.profile(repository, profileId);
|
||||
const { owner, repo } = this.coordinates(repository);
|
||||
const snapshot = await this.store.createRecoverySnapshot?.(`deploy-key-rotation:${repository.fullName}:${profileId}`);
|
||||
const previous = { profile: structuredClone(profile), key: await this.keyHost.backup({ repository, profile, server }), remoteKey: plan.evidence.configuredKey };
|
||||
let candidate = null;
|
||||
let registered = null;
|
||||
let switched = false;
|
||||
let oldRevoked = false;
|
||||
try {
|
||||
candidate = await this.keyHost.generate({ repository, profile, server });
|
||||
if (!candidate?.publicKey || !candidate?.fingerprint || candidate.privateKey) throw Object.assign(new Error("The server did not return safe public-key evidence."), { code: "DEPLOY_KEY_CANDIDATE_INVALID" });
|
||||
registered = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: `ForgeFlow · ${server.name} · ${candidate.fingerprint.slice(-12)}`, publicKey: candidate.publicKey });
|
||||
if (registered.read_only !== true) throw Object.assign(new Error("Gitea registered the candidate with write access."), { code: "DEPLOY_KEY_NOT_READ_ONLY" });
|
||||
const proof = await this.keyHost.verifyCandidate({ repository, profile, server, candidate, keyId: registered.id });
|
||||
if (!proof?.ready || proof.fingerprint !== candidate.fingerprint) throw Object.assign(new Error("The candidate deploy key could not prove read-only repository access."), { code: "DEPLOY_KEY_CANDIDATE_VERIFICATION_FAILED", proof });
|
||||
await this.keyHost.preflightCandidate({ repository, profile, server, candidate, proof });
|
||||
await this.keyHost.promote({ repository, profile, server, candidate, previous });
|
||||
const updated = await this.store.saveDeploymentProfile(repository.fullName, { ...profile, serverGitAccess: { configured: true, deployKeyId: registered.id, keyFingerprint: candidate.fingerprint, hostFingerprint: proof.hostFingerprint, configuredAt: this.clock(), rotatedAt: this.clock(), previousKeyId: previous.remoteKey?.id || null } });
|
||||
switched = true;
|
||||
if (previous.remoteKey?.id) {
|
||||
await this.gitea.deleteDeployKey(owner, repo, previous.remoteKey.id);
|
||||
oldRevoked = true;
|
||||
}
|
||||
const post = await this.keyHost.verifyActive({ repository, profile: updated, server });
|
||||
if (!post?.ready || post.fingerprint !== candidate.fingerprint) throw Object.assign(new Error("Post-rotation verification failed."), { code: "DEPLOY_KEY_POST_ROTATION_FAILED", post });
|
||||
await this.keyHost.commit({ repository, profile: updated, server, candidate, previous });
|
||||
await this.audit?.append?.("deployment.deploy-key-rotated", { repository: repository.fullName, profileId, oldKeyId: previous.remoteKey?.id || null, newKeyId: registered.id, fingerprint: candidate.fingerprint, snapshot: snapshot?.filePath || null });
|
||||
return { profile: updated, proof: post, snapshot, recovery: previous.key?.recovery || null };
|
||||
} catch (error) {
|
||||
try {
|
||||
if (candidate) await this.keyHost.rollback({ repository, profile, server, candidate, previous });
|
||||
if (registered?.id) await this.gitea.deleteDeployKey(owner, repo, registered.id).catch(() => {});
|
||||
let restoredKey = null;
|
||||
if (oldRevoked && previous.remoteKey?.key) restoredKey = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: previous.remoteKey.title || `ForgeFlow · ${server.name} · restored`, publicKey: previous.remoteKey.key });
|
||||
if (switched) await this.store.saveDeploymentProfile(repository.fullName, restoredKey ? { ...previous.profile, serverGitAccess: { ...previous.profile.serverGitAccess, deployKeyId: restoredKey.id } } : previous.profile);
|
||||
} catch (rollbackError) {
|
||||
error.rollbackError = rollbackError.message;
|
||||
}
|
||||
await this.audit?.append?.("deployment.deploy-key-rotation-failed", { repository: repository.fullName, profileId, code: error.code || "DEPLOY_KEY_ROTATION_FAILED", rollbackError: error.rollbackError || null });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async planRevocation({ repository, profileId }) {
|
||||
const evidence = await this.inventory({ repository, profileId });
|
||||
const plan = { operation: "revoke-deploy-key", repository: repository.fullName, profileId, keyId: evidence.configuredKey?.id || null, fingerprint: evidence.serverKey?.fingerprint || null, linkedDeployments: [profileId], impact: ["Remove this repository deploy key from Gitea", "Disable server-pull deployment until restored", "Preserve server-side recovery material"], containersUnaffected: true, evidence };
|
||||
plan.id = planId(plan);
|
||||
return plan;
|
||||
}
|
||||
|
||||
async revoke({ repository, profileId, expectedPlanId }) {
|
||||
const plan = await this.planRevocation({ repository, profileId });
|
||||
if (!expectedPlanId) throw Object.assign(new Error("Review revocation impact before applying it."), { code: "DEPLOY_KEY_REVOCATION_PLAN_REQUIRED", plan });
|
||||
if (plan.id !== expectedPlanId) throw Object.assign(new Error("Deploy-key evidence changed after preview."), { code: "DEPLOY_KEY_REVOCATION_PLAN_STALE", plan });
|
||||
const { profile, server } = this.profile(repository, profileId);
|
||||
const { owner, repo } = this.coordinates(repository);
|
||||
const snapshot = await this.store.createRecoverySnapshot?.(`deploy-key-revocation:${repository.fullName}:${profileId}`);
|
||||
const recovery = await this.keyHost.backup({ repository, profile, server });
|
||||
let remoteDeleted = false;
|
||||
try {
|
||||
if (plan.keyId) { await this.gitea.deleteDeployKey(owner, repo, plan.keyId); remoteDeleted = true; }
|
||||
await this.keyHost.revoke({ repository, profile, server, recovery });
|
||||
const updated = await this.store.saveDeploymentProfile(repository.fullName, { ...profile, serverGitAccess: { ...profile.serverGitAccess, configured: false, revokedAt: this.clock(), recoveryAvailable: true }, deploymentMode: "monitor-only" });
|
||||
await this.audit?.append?.("deployment.deploy-key-revoked", { repository: repository.fullName, profileId, keyId: plan.keyId, snapshot: snapshot?.filePath || null });
|
||||
return { profile: updated, snapshot, recovery: recovery?.recovery || null };
|
||||
} catch (error) {
|
||||
if (remoteDeleted && plan.evidence.configuredKey?.key) {
|
||||
const restored = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: plan.evidence.configuredKey.title || `ForgeFlow · ${server.name} · restored`, publicKey: plan.evidence.configuredKey.key });
|
||||
await this.store.saveDeploymentProfile(repository.fullName, { ...profile, serverGitAccess: { ...profile.serverGitAccess, deployKeyId: restored.id } });
|
||||
}
|
||||
await this.keyHost.restore({ repository, profile, server }).catch(() => {});
|
||||
await this.audit?.append?.("deployment.deploy-key-revocation-failed", { repository: repository.fullName, profileId, code: error.code || "DEPLOY_KEY_REVOCATION_FAILED" });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async restore({ repository, profileId }) {
|
||||
const { profile, server } = this.profile(repository, profileId);
|
||||
const restored = await this.keyHost.restore({ repository, profile, server });
|
||||
if (!restored?.publicKey || !restored?.fingerprint) throw Object.assign(new Error("No valid deploy-key recovery material exists."), { code: "DEPLOY_KEY_RECOVERY_UNAVAILABLE" });
|
||||
const { owner, repo } = this.coordinates(repository);
|
||||
const key = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: `ForgeFlow · ${server.name} · restored`, publicKey: restored.publicKey });
|
||||
if (key.read_only !== true) throw Object.assign(new Error("The restored key is not read-only."), { code: "DEPLOY_KEY_NOT_READ_ONLY" });
|
||||
const proposed = { ...profile, deploymentMode: "server-git", serverGitAccess: { configured: true, deployKeyId: key.id, keyFingerprint: restored.fingerprint, hostFingerprint: restored.hostFingerprint, restoredAt: this.clock() } };
|
||||
const proof = await this.keyHost.verifyActive({ repository, profile: proposed, server });
|
||||
if (!proof?.ready) throw Object.assign(new Error("Restored access could not be verified."), { code: "DEPLOY_KEY_RECOVERY_VERIFICATION_FAILED" });
|
||||
const updated = await this.store.saveDeploymentProfile(repository.fullName, proposed);
|
||||
await this.audit?.append?.("deployment.deploy-key-restored", { repository: repository.fullName, profileId, keyId: key.id, fingerprint: restored.fingerprint });
|
||||
return { profile: updated, proof };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DeployKeyLifecycleService, keyMaterial, deployKeyPlanId: planId };
|
||||
@@ -0,0 +1,35 @@
|
||||
"use strict";
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const { normalizeRemoteUrl } = require("../shared/repository-match.cjs");
|
||||
|
||||
function canonicalRemote(value) {
|
||||
const normalized = normalizeRemoteUrl(value);
|
||||
return normalized ? `${normalized.host}/${normalized.path}`.toLowerCase() : "";
|
||||
}
|
||||
|
||||
function deploymentIdentity({ workload, profile = null, repository = null }) {
|
||||
const remote = canonicalRemote(workload?.metadata?.sourceRepository || repository?.sshUrl || repository?.cloneUrl || profile?.cloneUrl);
|
||||
return {
|
||||
repository: remote || String(workload?.link?.repositoryFullName || repository?.fullName || profile?._repositoryFullName || "").toLowerCase(),
|
||||
branch: String(workload?.metadata?.branch || profile?.branch || repository?.defaultBranch || "").toLowerCase(),
|
||||
serverId: String(workload?.serverId || profile?.serverId || ""),
|
||||
environment: String(profile?.environment || "production").toLowerCase(),
|
||||
composeProject: String(workload?.compose?.project || profile?.composeProject || "").toLowerCase(),
|
||||
deploymentRoot: String(workload?.compose?.workingDir || profile?.composeWorkingDir || workload?.remoteFolderCandidate || profile?.remoteFolder || "").replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase(),
|
||||
containers: (workload?.containers || []).map((item) => String(item.id || item.name || "").toLowerCase()).sort(),
|
||||
liveSha: String(workload?.metadata?.liveRevision || "").toLowerCase(),
|
||||
profileId: String(profile?.id || workload?.link?.profileId || ""),
|
||||
};
|
||||
}
|
||||
|
||||
function evidenceHash(identity, evidence = {}) {
|
||||
const stable = (value) => Array.isArray(value) ? value.map(stable) : value && typeof value === "object" ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])])) : value;
|
||||
return crypto.createHash("sha256").update(JSON.stringify(stable({ identity, evidence }))).digest("hex");
|
||||
}
|
||||
|
||||
function authorityKey(identity) {
|
||||
return [identity.repository, identity.serverId, identity.environment].join("|");
|
||||
}
|
||||
|
||||
module.exports = { canonicalDeploymentRemote: canonicalRemote, deploymentIdentity, deploymentEvidenceHash: evidenceHash, deploymentAuthorityKey: authorityKey };
|
||||
@@ -0,0 +1,427 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
const { assertDeploymentRequest, assertFullCommitSha, assertHttpUrl } = require('../shared/validation.cjs');
|
||||
const { redactSecrets } = require('./log-redaction.cjs');
|
||||
|
||||
const TERMINAL_STATUSES = new Set(['success', 'failed', 'cancelled', 'rolled-back']);
|
||||
|
||||
function applicationVerificationFailure(operation, state) {
|
||||
if (!state?.statusConfigured) return { stage: 'version-verification', message: 'No server status endpoint is configured.' };
|
||||
if (!state.statusReachable) return { stage: 'version-verification', message: state.error || 'The server status endpoint is not reachable.' };
|
||||
if (!state.statusRepository) return { stage: 'version-verification', message: 'The server status endpoint did not identify its repository.' };
|
||||
if (state.statusRepository !== operation.repository) return { stage: 'version-verification', message: `The status endpoint belongs to ${state.statusRepository}, not ${operation.repository}.` };
|
||||
if (!state.statusEnvironment) return { stage: 'version-verification', message: 'The server status endpoint did not identify its environment.' };
|
||||
if (state.statusEnvironment !== operation.environment) return { stage: 'version-verification', message: `The status endpoint belongs to ${state.statusEnvironment}, not ${operation.environment}.` };
|
||||
if (!state.liveSha) return { stage: 'version-verification', message: 'The server status endpoint did not return a valid full commit SHA.' };
|
||||
if (state.liveSha !== operation.sha) return { stage: 'version-verification', message: `Server reports ${state.liveSha.slice(0, 7)} instead of ${operation.shortSha}.` };
|
||||
if (!state.requestedSha) return { stage: 'version-verification', message: 'The server status endpoint did not return the requested commit SHA.' };
|
||||
if (state.requestedSha !== operation.sha) return { stage: 'version-verification', message: 'The server status document was created for a different requested commit.' };
|
||||
if (!state.requestId) return { stage: 'version-verification', message: 'The server status endpoint did not return the deployment request ID.' };
|
||||
if (state.requestId !== operation.id) return { stage: 'version-verification', message: 'The server status belongs to a different deployment request.' };
|
||||
if (state.lastExitCode !== 0) return { stage: 'server-command', message: `The server deployment command reported exit code ${state.lastExitCode ?? 'unknown'}.` };
|
||||
if (state.healthy !== true) return { stage: 'healthcheck', message: state.error || `The server did not report a healthy application state (${state.healthStatus || 'unknown'}).` };
|
||||
return null;
|
||||
}
|
||||
|
||||
function terminalRunConclusion(run) {
|
||||
const value = String(run?.conclusion || run?.status || '').toLowerCase();
|
||||
if (['success'].includes(value)) return 'success';
|
||||
if (['failure', 'failed', 'timed_out', 'startup_failure'].includes(value)) return 'failed';
|
||||
if (['cancelled', 'canceled', 'skipped'].includes(value)) return 'cancelled';
|
||||
return null;
|
||||
}
|
||||
|
||||
function isRunningStatus(value) {
|
||||
return ['running', 'in_progress', 'processing'].includes(String(value || '').toLowerCase());
|
||||
}
|
||||
|
||||
class DeploymentService {
|
||||
constructor(store, giteaService, gitService, diagnostics = null) {
|
||||
this.store = store;
|
||||
this.gitea = giteaService;
|
||||
this.git = gitService;
|
||||
this.diagnostics = diagnostics;
|
||||
this.refreshLocks = new Set();
|
||||
}
|
||||
|
||||
splitRepository(fullName) {
|
||||
const [owner, repo, ...unexpected] = String(fullName || '').split('/');
|
||||
if (!owner || !repo || unexpected.length) throw new Error('Invalid Gitea repository identity.');
|
||||
return { owner, repo };
|
||||
}
|
||||
|
||||
makeStages() {
|
||||
return [
|
||||
{ id: 'requested', label: 'Requested', status: 'complete' },
|
||||
{ id: 'verified', label: 'Verified', status: 'complete' },
|
||||
{ id: 'queued', label: 'Workflow queued', status: 'active' },
|
||||
{ id: 'runner', label: 'Runner execution', status: 'pending' },
|
||||
{ id: 'healthcheck', label: 'Healthcheck', status: 'pending' },
|
||||
{ id: 'complete', label: 'Complete', status: 'pending' }
|
||||
];
|
||||
}
|
||||
|
||||
setStage(operation, id, status) {
|
||||
const stage = operation.stages?.find((item) => item.id === id);
|
||||
if (stage) stage.status = status;
|
||||
}
|
||||
|
||||
appendLog(operation, line) {
|
||||
const clean = redactSecrets(line, [this.store.getToken()]);
|
||||
operation.logs = Array.isArray(operation.logs) ? operation.logs : [];
|
||||
if (operation.logs.at(-1) !== clean) operation.logs.push(clean);
|
||||
operation.logs = operation.logs.slice(-1000);
|
||||
}
|
||||
|
||||
async captureBaselineRunIds(owner, repo, branch, operation) {
|
||||
try {
|
||||
const result = await this.gitea.listWorkflowRuns({ owner, repo, branch, limit: 50 });
|
||||
const ids = (result.runs || []).map((run) => run.id).filter((id) => id !== null && id !== undefined).map(String);
|
||||
operation.baselineRunIds = [...new Set(ids)].slice(0, 100);
|
||||
this.appendLog(operation, `[info] Captured ${operation.baselineRunIds.length} existing Actions run identifier(s) before dispatch.`);
|
||||
} catch (error) {
|
||||
operation.baselineRunIds = [];
|
||||
this.appendLog(operation, `[warning] Could not capture the pre-dispatch run baseline: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async validateDeploy(repository, profile, sha) {
|
||||
assertDeploymentRequest(profile, sha);
|
||||
const localStatus = await this.git.status(repository.localPath);
|
||||
if (localStatus.head !== sha) throw new Error('The selected commit no longer matches the local repository. Refresh before deploying.');
|
||||
if (localStatus.branch.head !== profile.branch) throw new Error(`This profile only allows deployments from ${profile.branch}.`);
|
||||
if (localStatus.counts.changed) throw new Error('Commit local changes before deploying.');
|
||||
if (localStatus.branch.ahead) throw new Error('Push all local commits before deploying.');
|
||||
if (localStatus.branch.behind) throw new Error('Synchronize with Gitea before deploying.');
|
||||
if (!localStatus.branch.upstream) throw new Error('Publish this branch to Gitea before deploying.');
|
||||
await this.git.verifyCommitOnRemoteBranch(repository.localPath, sha, profile.branch);
|
||||
return localStatus;
|
||||
}
|
||||
|
||||
async deploy({ repository, profileId, sha }) {
|
||||
if (!repository?.fullName || !repository?.localPath) throw new Error('A linked local repository is required for deployment.');
|
||||
const profile = this.store.getDeploymentProfile(repository.fullName, profileId);
|
||||
const fullSha = assertFullCommitSha(sha);
|
||||
await this.validateDeploy(repository, profile, fullSha);
|
||||
const { owner, repo } = this.splitRepository(repository.fullName);
|
||||
|
||||
const operation = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'deployment',
|
||||
action: 'deploy',
|
||||
status: 'requested',
|
||||
repository: repository.fullName,
|
||||
profileId,
|
||||
profileName: profile.name,
|
||||
environment: profile.environment,
|
||||
workflowFile: profile.workflowFile,
|
||||
branch: profile.branch,
|
||||
sha: fullSha,
|
||||
shortSha: fullSha.slice(0, 7),
|
||||
dispatchedAt: new Date().toISOString(),
|
||||
stages: this.makeStages(),
|
||||
logs: [
|
||||
`[info] Verified clean ${profile.branch} at ${fullSha}`,
|
||||
`[info] Dispatching ${profile.workflowFile} for ${repository.fullName}`
|
||||
]
|
||||
};
|
||||
await this.captureBaselineRunIds(owner, repo, profile.branch, operation);
|
||||
await this.store.addOperation(operation);
|
||||
await this.diagnostics?.info('deployment.dispatch.requested', { operationId: operation.id, repository: operation.repository, profileId, environment: operation.environment, branch: operation.branch, sha: operation.sha, workflowFile: operation.workflowFile });
|
||||
|
||||
try {
|
||||
await this.gitea.dispatchWorkflow({
|
||||
owner,
|
||||
repo,
|
||||
workflowFile: profile.workflowFile,
|
||||
ref: profile.branch,
|
||||
inputs: { environment: profile.environment, commit_sha: fullSha, request_id: operation.id }
|
||||
});
|
||||
operation.status = 'queued';
|
||||
this.appendLog(operation, '[ok] Gitea accepted the workflow dispatch request.');
|
||||
this.appendLog(operation, '[info] Resolving the corresponding Actions run…');
|
||||
const saved = await this.store.addOperation(operation);
|
||||
await this.diagnostics?.info('deployment.dispatch.accepted', { operationId: operation.id, repository: operation.repository, status: operation.status });
|
||||
return saved;
|
||||
} catch (error) {
|
||||
operation.status = 'failed';
|
||||
this.setStage(operation, 'queued', 'failed');
|
||||
operation.failure = { stage: 'dispatch', message: error.message };
|
||||
this.appendLog(operation, `[error] ${error.message}`);
|
||||
await this.store.addOperation(operation);
|
||||
await this.diagnostics?.error('deployment.dispatch.failed', { operationId: operation.id, repository: operation.repository, message: error.message, code: error.code, status: error.status });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async rollback({ repository, profileId, targetSha }) {
|
||||
if (!repository?.fullName || !repository?.localPath) throw new Error('A linked local repository is required for rollback.');
|
||||
const profile = this.store.getDeploymentProfile(repository.fullName, profileId);
|
||||
if (!profile) throw new Error('Deployment profile not found.');
|
||||
if (!profile.rollbackWorkflowFile) throw new Error('No rollback workflow is configured for this profile.');
|
||||
const fullSha = assertFullCommitSha(targetSha);
|
||||
assertDeploymentRequest({ ...profile, workflowFile: profile.rollbackWorkflowFile }, fullSha);
|
||||
const state = await this.refreshProfileState(repository.fullName, profileId);
|
||||
if (!state.statusReachable) throw new Error(state.error || 'The server status endpoint must be reachable before rollback.');
|
||||
if (state.statusRepository !== repository.fullName || state.statusEnvironment !== profile.environment) throw new Error('The status endpoint does not match this repository and environment.');
|
||||
if (!state.previousSha) throw new Error('The server status endpoint does not report a previous version.');
|
||||
if (state.previousSha !== fullSha) throw new Error('The requested rollback SHA is no longer the previous server version. Refresh the environment state.');
|
||||
if (state.liveSha === fullSha) throw new Error('The requested rollback version is already live.');
|
||||
await this.git.verifyCommitOnRemoteBranch(repository.localPath, fullSha, profile.branch);
|
||||
const { owner, repo } = this.splitRepository(repository.fullName);
|
||||
|
||||
const operation = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'deployment',
|
||||
action: 'rollback',
|
||||
status: 'requested',
|
||||
repository: repository.fullName,
|
||||
profileId,
|
||||
profileName: profile.name,
|
||||
environment: profile.environment,
|
||||
workflowFile: profile.rollbackWorkflowFile,
|
||||
branch: profile.branch,
|
||||
sha: fullSha,
|
||||
shortSha: fullSha.slice(0, 7),
|
||||
dispatchedAt: new Date().toISOString(),
|
||||
stages: this.makeStages(),
|
||||
logs: [
|
||||
`[warning] Rollback target verified on origin/${profile.branch}: ${fullSha}`,
|
||||
`[info] Dispatching ${profile.rollbackWorkflowFile}`
|
||||
]
|
||||
};
|
||||
await this.captureBaselineRunIds(owner, repo, profile.branch, operation);
|
||||
await this.store.addOperation(operation);
|
||||
await this.diagnostics?.info('deployment.rollback.requested', { operationId: operation.id, repository: operation.repository, profileId, environment: operation.environment, branch: operation.branch, sha: operation.sha, workflowFile: operation.workflowFile });
|
||||
|
||||
try {
|
||||
await this.gitea.dispatchWorkflow({
|
||||
owner,
|
||||
repo,
|
||||
workflowFile: profile.rollbackWorkflowFile,
|
||||
ref: profile.branch,
|
||||
inputs: { environment: profile.environment, target_sha: fullSha, request_id: operation.id }
|
||||
});
|
||||
operation.status = 'queued';
|
||||
this.appendLog(operation, '[ok] Gitea accepted the rollback request.');
|
||||
const saved = await this.store.addOperation(operation);
|
||||
await this.diagnostics?.info('deployment.rollback.accepted', { operationId: operation.id, repository: operation.repository });
|
||||
return saved;
|
||||
} catch (error) {
|
||||
operation.status = 'failed';
|
||||
this.setStage(operation, 'queued', 'failed');
|
||||
operation.failure = { stage: 'dispatch', message: error.message };
|
||||
this.appendLog(operation, `[error] ${error.message}`);
|
||||
await this.store.addOperation(operation);
|
||||
await this.diagnostics?.error('deployment.rollback.failed', { operationId: operation.id, repository: operation.repository, message: error.message, code: error.code, status: error.status });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
mapJobsToStages(operation, jobs) {
|
||||
operation.jobs = jobs;
|
||||
if (!jobs.length) return;
|
||||
const running = jobs.some((job) => isRunningStatus(job.status));
|
||||
const failed = jobs.some((job) => terminalRunConclusion(job) === 'failed');
|
||||
const allDone = jobs.every((job) => terminalRunConclusion(job));
|
||||
this.setStage(operation, 'queued', 'complete');
|
||||
this.setStage(operation, 'runner', failed ? 'failed' : allDone ? 'complete' : running ? 'active' : 'pending');
|
||||
}
|
||||
|
||||
async refreshOperation(operationId) {
|
||||
if (this.refreshLocks.has(operationId)) return this.store.getOperation(operationId);
|
||||
const operation = this.store.getOperation(operationId);
|
||||
if (!operation || operation.type !== 'deployment') throw new Error('Deployment operation not found.');
|
||||
if (TERMINAL_STATUSES.has(operation.status)) return operation;
|
||||
|
||||
this.refreshLocks.add(operationId);
|
||||
try {
|
||||
const profile = this.store.getDeploymentProfile(operation.repository, operation.profileId);
|
||||
if (!profile) throw new Error('The deployment profile used by this operation no longer exists.');
|
||||
const { owner, repo } = this.splitRepository(operation.repository);
|
||||
const found = await this.gitea.findWorkflowRun({
|
||||
owner,
|
||||
repo,
|
||||
sha: operation.sha,
|
||||
branch: operation.branch,
|
||||
workflowFile: operation.workflowFile,
|
||||
dispatchedAt: operation.dispatchedAt || operation.createdAt,
|
||||
excludeRunIds: operation.baselineRunIds || []
|
||||
});
|
||||
|
||||
if (!found.run) {
|
||||
operation.status = 'queued';
|
||||
this.setStage(operation, 'queued', 'active');
|
||||
this.appendLog(operation, '[info] Workflow is queued or not visible through the Actions API yet.');
|
||||
return await this.store.addOperation(operation);
|
||||
}
|
||||
|
||||
operation.run = { ...found.run, source: found.source };
|
||||
operation.runUrl = found.run.htmlUrl || `${this.store.data.gitea.baseUrl}/${operation.repository}/actions/runs/${found.run.runNumber}`;
|
||||
this.setStage(operation, 'queued', 'complete');
|
||||
const runConclusion = terminalRunConclusion(found.run);
|
||||
if (!runConclusion) {
|
||||
operation.status = isRunningStatus(found.run.status) ? 'running' : 'queued';
|
||||
this.setStage(operation, 'runner', operation.status === 'running' ? 'active' : 'pending');
|
||||
}
|
||||
|
||||
try {
|
||||
const jobs = await this.gitea.listWorkflowJobs({ owner, repo, runNumber: found.run.runNumber });
|
||||
this.mapJobsToStages(operation, jobs);
|
||||
for (const job of jobs) {
|
||||
const conclusion = job.conclusion || job.status;
|
||||
this.appendLog(operation, `[job] ${job.name}: ${conclusion}`);
|
||||
}
|
||||
// Raw runner output is intentionally not ingested or persisted. Open the trusted Gitea run for full logs.
|
||||
} catch (error) {
|
||||
this.appendLog(operation, `[warning] Job details unavailable: ${error.message}`);
|
||||
}
|
||||
|
||||
if (runConclusion === 'success') {
|
||||
this.setStage(operation, 'runner', 'complete');
|
||||
this.setStage(operation, 'healthcheck', 'active');
|
||||
const state = await this.refreshProfileState(operation.repository, operation.profileId, { expectedSha: operation.sha });
|
||||
operation.applicationState = state;
|
||||
const verificationFailure = applicationVerificationFailure(operation, state);
|
||||
if (verificationFailure) {
|
||||
operation.status = 'failed';
|
||||
this.setStage(operation, 'healthcheck', 'failed');
|
||||
this.setStage(operation, 'complete', 'failed');
|
||||
operation.failure = verificationFailure;
|
||||
this.appendLog(operation, `[error] ${verificationFailure.message}`);
|
||||
} else {
|
||||
operation.status = operation.action === 'rollback' ? 'rolled-back' : 'success';
|
||||
this.setStage(operation, 'healthcheck', 'complete');
|
||||
this.setStage(operation, 'complete', 'complete');
|
||||
this.appendLog(operation, `[ok] ${operation.action === 'rollback' ? 'Rollback' : 'Deployment'} completed successfully.`);
|
||||
}
|
||||
} else if (runConclusion === 'failed' || runConclusion === 'cancelled') {
|
||||
operation.status = runConclusion;
|
||||
this.setStage(operation, 'runner', runConclusion === 'failed' ? 'failed' : 'cancelled');
|
||||
this.setStage(operation, 'healthcheck', 'skipped');
|
||||
this.setStage(operation, 'complete', runConclusion === 'failed' ? 'failed' : 'cancelled');
|
||||
operation.failure = { stage: 'runner', message: `Gitea Actions finished with ${runConclusion}.` };
|
||||
this.appendLog(operation, `[error] ${operation.failure.message}`);
|
||||
}
|
||||
|
||||
const saved = await this.store.addOperation(operation);
|
||||
if (TERMINAL_STATUSES.has(operation.status)) {
|
||||
await this.diagnostics?.info('deployment.operation.terminal', { operationId: operation.id, repository: operation.repository, status: operation.status, failure: operation.failure || null, applicationState: operation.applicationState || null });
|
||||
} else {
|
||||
await this.diagnostics?.debug('deployment.operation.refreshed', { operationId: operation.id, repository: operation.repository, status: operation.status, run: operation.run ? { id: operation.run.id, runNumber: operation.run.runNumber, status: operation.run.status, conclusion: operation.run.conclusion } : null });
|
||||
}
|
||||
return saved;
|
||||
} catch (error) {
|
||||
operation.pollError = error.message;
|
||||
this.appendLog(operation, `[warning] Status refresh failed: ${error.message}`);
|
||||
await this.diagnostics?.warning('deployment.operation.poll-failed', { operationId: operation.id, repository: operation.repository, message: error.message });
|
||||
return await this.store.addOperation(operation);
|
||||
} finally {
|
||||
this.refreshLocks.delete(operationId);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshActiveOperations() {
|
||||
const active = this.store.data.operations.filter((item) => item.type === 'deployment' && !TERMINAL_STATUSES.has(item.status));
|
||||
const queue = active.slice(0, 20);
|
||||
const results = [];
|
||||
const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
|
||||
while (queue.length) {
|
||||
const operation = queue.shift();
|
||||
results.push(await this.refreshOperation(operation.id));
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
async checkHealth(url) {
|
||||
if (!url) return { configured: false, healthy: null };
|
||||
const normalized = assertHttpUrl(url, { label: 'Healthcheck URL' });
|
||||
const started = Date.now();
|
||||
try {
|
||||
const response = await fetch(normalized, { signal: AbortSignal.timeout(10_000), redirect: 'follow', headers: { Accept: 'application/json, text/plain, */*' } });
|
||||
return { configured: true, healthy: response.ok, status: response.status, latencyMs: Date.now() - started };
|
||||
} catch (error) {
|
||||
return { configured: true, healthy: false, error: error.message, latencyMs: Date.now() - started };
|
||||
}
|
||||
}
|
||||
|
||||
async readStatusEndpoint(url) {
|
||||
if (!url) return { configured: false };
|
||||
const normalized = assertHttpUrl(url, { label: 'Application status URL' });
|
||||
const started = Date.now();
|
||||
try {
|
||||
const response = await fetch(normalized, { signal: AbortSignal.timeout(10_000), redirect: 'follow', headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) return { configured: true, reachable: true, ok: false, status: response.status, latencyMs: Date.now() - started };
|
||||
const payload = await response.json();
|
||||
const liveSha = payload.commit_sha || payload.commitSha || payload.sha || payload.version?.commit_sha || payload.version?.sha || null;
|
||||
const previousSha = payload.previous_sha || payload.previousSha || payload.previous?.sha || null;
|
||||
const requestId = payload.request_id || payload.requestId || null;
|
||||
const requestedSha = payload.requested_sha || payload.requestedSha || null;
|
||||
const repository = payload.repository || null;
|
||||
const environment = payload.environment || null;
|
||||
const rawExitCode = payload.last_exit_code ?? payload.lastExitCode ?? null;
|
||||
return {
|
||||
configured: true,
|
||||
reachable: true,
|
||||
ok: true,
|
||||
status: response.status,
|
||||
latencyMs: Date.now() - started,
|
||||
liveSha: /^[a-f0-9]{40,64}$/i.test(String(liveSha || '')) ? String(liveSha).toLowerCase() : null,
|
||||
previousSha: /^[a-f0-9]{40,64}$/i.test(String(previousSha || '')) ? String(previousSha).toLowerCase() : null,
|
||||
requestId: typeof requestId === 'string' ? requestId.slice(0, 100) : null,
|
||||
requestedSha: /^[a-f0-9]{40,64}$/i.test(String(requestedSha || '')) ? String(requestedSha).toLowerCase() : null,
|
||||
repository: typeof repository === 'string' ? repository.slice(0, 200) : null,
|
||||
environment: typeof environment === 'string' ? environment.slice(0, 64).toLowerCase() : null,
|
||||
lastExitCode: rawExitCode !== null && rawExitCode !== '' && Number.isInteger(Number(rawExitCode)) ? Number(rawExitCode) : null,
|
||||
deployedAt: payload.deployed_at || payload.deployedAt || null,
|
||||
health: payload.health || payload.status || null,
|
||||
payload
|
||||
};
|
||||
} catch (error) {
|
||||
return { configured: true, reachable: false, ok: false, error: error.message, latencyMs: Date.now() - started };
|
||||
}
|
||||
}
|
||||
|
||||
async refreshProfileState(fullName, profileId, { expectedSha = null } = {}) {
|
||||
const profile = this.store.getDeploymentProfile(fullName, profileId);
|
||||
if (!profile) throw new Error('Deployment profile not found.');
|
||||
const [status, health] = await Promise.all([
|
||||
this.readStatusEndpoint(profile.statusUrl),
|
||||
this.checkHealth(profile.healthcheckUrl)
|
||||
]);
|
||||
const state = {
|
||||
profileId,
|
||||
repository: fullName,
|
||||
environment: profile.environment,
|
||||
liveSha: status.liveSha || null,
|
||||
previousSha: status.previousSha || null,
|
||||
deployedAt: status.deployedAt || null,
|
||||
statusConfigured: Boolean(status.configured),
|
||||
statusReachable: status.configured ? Boolean(status.reachable && status.ok) : null,
|
||||
statusCode: status.status || null,
|
||||
statusRepository: status.repository || null,
|
||||
statusEnvironment: status.environment || null,
|
||||
requestedSha: status.requestedSha || null,
|
||||
lastExitCode: status.lastExitCode,
|
||||
healthConfigured: Boolean(health.configured),
|
||||
healthy: health.configured
|
||||
? Boolean(health.healthy)
|
||||
: (['healthy', 'ok', 'success', 'ready'].includes(String(status.health || '').toLowerCase())
|
||||
? true
|
||||
: (['unhealthy', 'failed', 'error', 'degraded'].includes(String(status.health || '').toLowerCase()) ? false : null)),
|
||||
healthStatus: health.status || status.health || null,
|
||||
latencyMs: health.latencyMs ?? status.latencyMs ?? null,
|
||||
expectedSha: expectedSha || null,
|
||||
requestId: status.requestId || null,
|
||||
versionMatches: expectedSha && status.liveSha ? status.liveSha === expectedSha : null,
|
||||
error: health.error || status.error || null,
|
||||
checkedAt: new Date().toISOString()
|
||||
};
|
||||
return this.store.saveDeploymentState(profileId, state);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DeploymentService, TERMINAL_STATUSES, terminalRunConclusion, applicationVerificationFailure };
|
||||
@@ -0,0 +1,385 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const crypto = require('node:crypto');
|
||||
const { createZip } = require('../shared/zip-writer.cjs');
|
||||
const { sanitizeForDiagnostics } = require('./log-redaction.cjs');
|
||||
|
||||
const LEVELS = { debug: 10, info: 20, warning: 30, error: 40 };
|
||||
|
||||
function dateKey(value = new Date()) {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function byteSizeLabel(bytes) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function safeJson(value) {
|
||||
return `${JSON.stringify(value, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function auditBundleEntries(entries, secrets = []) {
|
||||
const candidates = [...new Set((secrets || []).map((item) => String(item || '').trim()).filter((item) => item.length >= 4))];
|
||||
const findings = [];
|
||||
for (const entry of entries) {
|
||||
const text = Buffer.isBuffer(entry.data) ? entry.data.toString('utf8') : String(entry.data ?? '');
|
||||
for (const secret of candidates) {
|
||||
if (text.includes(secret)) findings.push({ file: entry.name, type: 'known-runtime-secret' });
|
||||
}
|
||||
if (/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/i.test(text)) findings.push({ file: entry.name, type: 'private-key-marker' });
|
||||
if (/https?:\/\/[^\s:@/]+:(?!\[REDACTED\])[^@\s/]+@/i.test(text)) findings.push({ file: entry.name, type: 'url-credential' });
|
||||
}
|
||||
return { passed: findings.length === 0, checkedFiles: entries.length, knownRuntimeSecretCount: candidates.length, findings };
|
||||
}
|
||||
|
||||
class DiagnosticsService {
|
||||
constructor({ userDataPath, appInfo = {}, secretProvider = () => [], preferencesProvider = () => ({}) }) {
|
||||
this.userDataPath = userDataPath;
|
||||
this.logDirectory = path.join(userDataPath, 'diagnostics');
|
||||
this.appInfo = appInfo;
|
||||
this.secretProvider = secretProvider;
|
||||
this.preferencesProvider = preferencesProvider;
|
||||
this.sessionId = crypto.randomUUID();
|
||||
this.writeChain = Promise.resolve();
|
||||
this.pendingLines = [];
|
||||
this.pendingFlush = null;
|
||||
this.securedFiles = new Set();
|
||||
this.initialized = false;
|
||||
this.lastWriteError = null;
|
||||
this.lastBundlePath = null;
|
||||
}
|
||||
|
||||
preferences() {
|
||||
const source = this.preferencesProvider?.() || {};
|
||||
return {
|
||||
enabled: source.diagnosticsEnabled !== false,
|
||||
level: ['debug', 'info', 'warning', 'error'].includes(source.diagnosticLevel) ? source.diagnosticLevel : 'info',
|
||||
retentionDays: Math.min(Math.max(Number(source.logRetentionDays) || 14, 1), 90),
|
||||
maxFileMb: Math.min(Math.max(Number(source.maxLogFileMb) || 8, 1), 50)
|
||||
};
|
||||
}
|
||||
|
||||
sanitize(value, options = {}) {
|
||||
return sanitizeForDiagnostics(value, {
|
||||
secrets: this.secretProvider?.() || [],
|
||||
homeDir: os.homedir(),
|
||||
cwd: process.cwd(),
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
await fs.mkdir(this.logDirectory, { recursive: true, mode: 0o700 });
|
||||
try { await fs.chmod(this.logDirectory, 0o700); } catch {}
|
||||
this.initialized = true;
|
||||
await this.prune();
|
||||
await this.info('diagnostics.session.started', {
|
||||
sessionId: this.sessionId,
|
||||
app: this.appInfo,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
node: process.versions.node,
|
||||
electron: process.versions.electron || null
|
||||
});
|
||||
}
|
||||
|
||||
shouldWrite(level) {
|
||||
const preferences = this.preferences();
|
||||
return preferences.enabled && LEVELS[level] >= LEVELS[preferences.level];
|
||||
}
|
||||
|
||||
filePathForToday() {
|
||||
return path.join(this.logDirectory, `forgeflow-${dateKey()}.jsonl`);
|
||||
}
|
||||
|
||||
async rotateIfNeeded(filePath) {
|
||||
const limit = this.preferences().maxFileMb * 1024 * 1024;
|
||||
const stat = await fs.stat(filePath).catch(() => null);
|
||||
if (!stat || stat.size < limit) return filePath;
|
||||
for (let index = 1; index < 100; index += 1) {
|
||||
const candidate = path.join(this.logDirectory, `forgeflow-${dateKey()}-${String(index).padStart(2, '0')}.jsonl`);
|
||||
const candidateStat = await fs.stat(candidate).catch(() => null);
|
||||
if (!candidateStat || candidateStat.size < limit) return candidate;
|
||||
}
|
||||
return path.join(this.logDirectory, `forgeflow-${dateKey()}-${Date.now()}.jsonl`);
|
||||
}
|
||||
|
||||
log(level, event, details = {}) {
|
||||
if (!this.shouldWrite(level)) return Promise.resolve(false);
|
||||
const record = this.sanitize({
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
event: String(event || 'diagnostics.event').slice(0, 160),
|
||||
sessionId: this.sessionId,
|
||||
details
|
||||
});
|
||||
this.pendingLines.push(`${JSON.stringify(record)}\n`);
|
||||
// At the debug level every IPC call and every Gitea request writes a line.
|
||||
// Records that queue up while a write is in flight are appended together, so
|
||||
// a burst costs one open/write/close instead of one per record.
|
||||
if (this.pendingFlush) return this.pendingFlush;
|
||||
this.pendingFlush = this.writeChain.then(async () => {
|
||||
this.pendingFlush = null;
|
||||
const lines = this.pendingLines.splice(0).join('');
|
||||
if (!lines) return true;
|
||||
try {
|
||||
if (!this.initialized) await fs.mkdir(this.logDirectory, { recursive: true, mode: 0o700 });
|
||||
const target = await this.rotateIfNeeded(this.filePathForToday());
|
||||
await fs.appendFile(target, lines, { encoding: 'utf8', mode: 0o600 });
|
||||
// The mode above only applies when appendFile creates the file, so the
|
||||
// explicit chmod is needed once per file rather than once per record.
|
||||
if (!this.securedFiles.has(target)) {
|
||||
try { await fs.chmod(target, 0o600); } catch { /* best effort */ }
|
||||
this.securedFiles.add(target);
|
||||
}
|
||||
this.lastWriteError = null;
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.lastWriteError = error.message;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
this.writeChain = this.pendingFlush.catch(() => {});
|
||||
return this.pendingFlush;
|
||||
}
|
||||
|
||||
debug(event, details) { return this.log('debug', event, details); }
|
||||
info(event, details) { return this.log('info', event, details); }
|
||||
warning(event, details) { return this.log('warning', event, details); }
|
||||
error(event, details) { return this.log('error', event, details); }
|
||||
|
||||
async flush() {
|
||||
await this.writeChain;
|
||||
}
|
||||
|
||||
async listLogFiles() {
|
||||
await fs.mkdir(this.logDirectory, { recursive: true, mode: 0o700 });
|
||||
const entries = await fs.readdir(this.logDirectory, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !/^forgeflow-.*\.jsonl$/i.test(entry.name)) continue;
|
||||
const absolute = path.join(this.logDirectory, entry.name);
|
||||
const stat = await fs.stat(absolute).catch(() => null);
|
||||
if (stat) files.push({ name: entry.name, path: absolute, size: stat.size, modifiedAt: stat.mtime.toISOString() });
|
||||
}
|
||||
return files.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
|
||||
}
|
||||
|
||||
async prune() {
|
||||
const cutoff = Date.now() - this.preferences().retentionDays * 24 * 60 * 60 * 1000;
|
||||
for (const file of await this.listLogFiles()) {
|
||||
if (new Date(file.modifiedAt).getTime() < cutoff) await fs.rm(file.path, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async getStatus() {
|
||||
await this.flush();
|
||||
const files = await this.listLogFiles();
|
||||
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
|
||||
return {
|
||||
enabled: this.preferences().enabled,
|
||||
level: this.preferences().level,
|
||||
retentionDays: this.preferences().retentionDays,
|
||||
maxFileMb: this.preferences().maxFileMb,
|
||||
directory: this.sanitize(this.logDirectory),
|
||||
fileCount: files.length,
|
||||
totalBytes,
|
||||
totalSize: byteSizeLabel(totalBytes),
|
||||
latestAt: files[0]?.modifiedAt || null,
|
||||
lastWriteError: this.lastWriteError
|
||||
};
|
||||
}
|
||||
|
||||
async clear() {
|
||||
await this.flush();
|
||||
for (const file of await this.listLogFiles()) await fs.rm(file.path, { force: true });
|
||||
await this.info('diagnostics.logs.cleared', {});
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
async collectLogs(maxBytes = 20 * 1024 * 1024, { strictIdentifiers = false } = {}) {
|
||||
await this.flush();
|
||||
const output = [];
|
||||
let used = 0;
|
||||
for (const file of await this.listLogFiles()) {
|
||||
if (used >= maxBytes) break;
|
||||
const remaining = maxBytes - used;
|
||||
const content = await fs.readFile(file.path);
|
||||
const slice = content.length > remaining ? content.subarray(content.length - remaining) : content;
|
||||
output.push({
|
||||
name: `logs/${file.name}`,
|
||||
data: Buffer.from(
|
||||
sanitizeForDiagnostics(slice.toString('utf8'), {
|
||||
secrets: this.secretProvider?.() || [],
|
||||
strictIdentifiers,
|
||||
}),
|
||||
'utf8',
|
||||
),
|
||||
});
|
||||
used += slice.length;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async exportSupportBundle({ destinationPath, publicState, repositories = [], operations = [], preflight = null, privacyMode = 'standard', extra = {} }) {
|
||||
if (!destinationPath) throw new Error('No support bundle destination was selected.');
|
||||
if (!['standard', 'strict'].includes(privacyMode)) throw new Error('Unsupported diagnostic privacy mode.');
|
||||
if (path.extname(destinationPath).toLowerCase() !== '.zip') throw new Error('Diagnostic bundles must use the .zip extension.');
|
||||
await this.info('diagnostics.bundle.requested', { privacyMode, repositoryCount: repositories.length, operationCount: operations.length });
|
||||
const strict = privacyMode === 'strict';
|
||||
const sanitize = (value) => this.sanitize(value, { strictIdentifiers: strict });
|
||||
const generatedAt = new Date().toISOString();
|
||||
const diagnosticsStatus = await this.getStatus();
|
||||
const system = sanitize({
|
||||
app: this.appInfo,
|
||||
generatedAt,
|
||||
sessionId: this.sessionId,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
release: os.release(),
|
||||
type: os.type(),
|
||||
cpus: os.cpus()?.map((cpu) => cpu.model).filter((value, index, array) => array.indexOf(value) === index),
|
||||
cpuCount: os.cpus()?.length || null,
|
||||
totalMemoryBytes: os.totalmem(),
|
||||
freeMemoryBytes: os.freemem(),
|
||||
uptimeSeconds: os.uptime(),
|
||||
locale: Intl.DateTimeFormat().resolvedOptions().locale,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
versions: process.versions
|
||||
});
|
||||
|
||||
const sanitizedState = sanitize(publicState || {});
|
||||
if (sanitizedState.gitea) sanitizedState.gitea.hasToken = Boolean(publicState?.gitea?.hasToken);
|
||||
const sanitizedRepositories = sanitize(repositories.map((repository) => ({
|
||||
id: repository.id,
|
||||
fullName: repository.fullName,
|
||||
linkState: repository.linkState,
|
||||
localPath: repository.localPath,
|
||||
attention: repository.attention,
|
||||
attentionReason: repository.attentionReason,
|
||||
readyToDeploy: repository.readyToDeploy,
|
||||
localStatus: repository.localStatus ? {
|
||||
branch: repository.localStatus.branch,
|
||||
head: repository.localStatus.head,
|
||||
counts: repository.localStatus.counts,
|
||||
clean: repository.localStatus.clean,
|
||||
remoteUrl: repository.localStatus.remoteUrl
|
||||
} : null,
|
||||
deploymentProfiles: repository.deploymentProfiles?.map((profile) => ({
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
environment: profile.environment,
|
||||
branch: profile.branch,
|
||||
workflowFile: profile.workflowFile,
|
||||
rollbackWorkflowFile: profile.rollbackWorkflowFile,
|
||||
healthcheckUrl: profile.healthcheckUrl,
|
||||
statusUrl: profile.statusUrl,
|
||||
state: profile.state
|
||||
})) || []
|
||||
})));
|
||||
const sanitizedOperations = sanitize(operations.map((operation) => ({
|
||||
id: operation.id,
|
||||
type: operation.type,
|
||||
action: operation.action,
|
||||
status: operation.status,
|
||||
repository: operation.repository,
|
||||
profileId: operation.profileId,
|
||||
profileName: operation.profileName,
|
||||
environment: operation.environment,
|
||||
workflowFile: operation.workflowFile,
|
||||
branch: operation.branch,
|
||||
sha: operation.sha,
|
||||
shortSha: operation.shortSha,
|
||||
createdAt: operation.createdAt,
|
||||
updatedAt: operation.updatedAt,
|
||||
dispatchedAt: operation.dispatchedAt,
|
||||
stages: operation.stages,
|
||||
jobs: operation.jobs,
|
||||
remoteOutput: operation.logs || operation.failure || operation.pollError ? {
|
||||
included: false,
|
||||
reason: 'Remote build and command output is intentionally omitted because it may contain application secrets unknown to ForgeFlow.',
|
||||
logCharacters: String(operation.logs || '').length,
|
||||
failureRecorded: Boolean(operation.failure),
|
||||
pollErrorRecorded: Boolean(operation.pollError)
|
||||
} : null,
|
||||
applicationState: operation.applicationState,
|
||||
run: operation.run ? {
|
||||
id: operation.run.id,
|
||||
runNumber: operation.run.runNumber,
|
||||
name: operation.run.name,
|
||||
status: operation.run.status,
|
||||
conclusion: operation.run.conclusion,
|
||||
headSha: operation.run.headSha,
|
||||
headBranch: operation.run.headBranch,
|
||||
workflowPath: operation.run.workflowPath,
|
||||
createdAt: operation.run.createdAt,
|
||||
updatedAt: operation.run.updatedAt
|
||||
} : null,
|
||||
runnerLog: operation.runnerLog ? {
|
||||
included: false,
|
||||
reason: 'Raw runner output is intentionally omitted from diagnostic bundles.',
|
||||
characters: String(operation.runnerLog).length,
|
||||
lines: String(operation.runnerLog).split(/\r?\n/).length
|
||||
} : null
|
||||
})));
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
product: 'ForgeFlow Support Bundle',
|
||||
generatedAt,
|
||||
privacyMode,
|
||||
containsSecrets: false,
|
||||
redaction: {
|
||||
knownRuntimeSecrets: true,
|
||||
sensitiveObjectKeys: true,
|
||||
authorizationHeaders: true,
|
||||
credentialUrls: true,
|
||||
privateKeys: true,
|
||||
userHomePaths: true,
|
||||
identifiersHashed: strict
|
||||
},
|
||||
files: []
|
||||
};
|
||||
|
||||
const entries = [
|
||||
{ name: 'README.txt', data: `ForgeFlow diagnostic support bundle\nGenerated: ${generatedAt}\nPrivacy mode: ${privacyMode}\n\nThis bundle is generated locally. Access tokens, passwords, authorization headers, embedded URL credentials, encrypted token blobs and private keys are removed. Review the bundle before sharing it.\n` },
|
||||
{ name: 'system.json', data: safeJson(system) },
|
||||
{ name: 'diagnostics-status.json', data: safeJson(sanitize(diagnosticsStatus)) },
|
||||
{ name: 'configuration-sanitized.json', data: safeJson(sanitizedState) },
|
||||
{ name: 'repositories-sanitized.json', data: safeJson(sanitizedRepositories) },
|
||||
{ name: 'operations-sanitized.json', data: safeJson(sanitizedOperations) },
|
||||
{ name: 'preflight.json', data: safeJson(sanitize(preflight || {})) },
|
||||
{ name: 'context.json', data: safeJson(sanitize(extra || {})) },
|
||||
...(await this.collectLogs(20 * 1024 * 1024, { strictIdentifiers: strict }))
|
||||
];
|
||||
|
||||
const safetyAudit = auditBundleEntries(entries, this.secretProvider?.() || []);
|
||||
if (!safetyAudit.passed) {
|
||||
await this.error('diagnostics.bundle.safety-check-failed', { findings: safetyAudit.findings });
|
||||
throw new Error('The diagnostic bundle failed its local secret-safety check and was not written.');
|
||||
}
|
||||
entries.push({ name: 'safety-audit.json', data: safeJson(safetyAudit) });
|
||||
manifest.files = entries.map((entry) => ({ name: entry.name, bytes: Buffer.byteLength(entry.data) }));
|
||||
entries.unshift({ name: 'manifest.json', data: safeJson(manifest) });
|
||||
const archive = createZip(entries);
|
||||
const temporary = `${destinationPath}.${process.pid}.${Date.now()}.tmp`;
|
||||
await fs.mkdir(path.dirname(destinationPath), { recursive: true });
|
||||
await fs.writeFile(temporary, archive, { mode: 0o600 });
|
||||
await fs.rename(temporary, destinationPath);
|
||||
try { await fs.chmod(destinationPath, 0o600); } catch {}
|
||||
this.lastBundlePath = path.resolve(destinationPath);
|
||||
const sha256 = crypto.createHash('sha256').update(archive).digest('hex');
|
||||
await this.info('diagnostics.bundle.created', { destinationPath, bytes: archive.length, sha256, privacyMode });
|
||||
return { path: destinationPath, bytes: archive.length, size: byteSizeLabel(archive.length), sha256, privacyMode, generatedAt };
|
||||
}
|
||||
|
||||
isKnownBundlePath(filePath) {
|
||||
return Boolean(filePath && this.lastBundlePath && path.resolve(filePath) === this.lastBundlePath);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DiagnosticsService, dateKey, byteSizeLabel, auditBundleEntries, LEVELS };
|
||||
@@ -0,0 +1,51 @@
|
||||
'use strict';
|
||||
|
||||
const { spawn } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
|
||||
const TOOL_PROFILES = Object.freeze({
|
||||
editor: Object.freeze({
|
||||
code: ['--reuse-window', '--goto', '{file}:{line}'],
|
||||
'code.exe': ['--reuse-window', '--goto', '{file}:{line}'],
|
||||
codium: ['--reuse-window', '--goto', '{file}:{line}'],
|
||||
'codium.exe': ['--reuse-window', '--goto', '{file}:{line}'],
|
||||
}),
|
||||
terminal: Object.freeze({
|
||||
wt: ['-d', '{path}'],
|
||||
'wt.exe': ['-d', '{path}'],
|
||||
}),
|
||||
});
|
||||
|
||||
function normalizeTool(tool, defaults, kind) {
|
||||
const source = tool && typeof tool === 'object' ? tool : {};
|
||||
const executable = String(source.executable || defaults.executable).trim();
|
||||
if (!executable || /[\r\n\0]/.test(executable)) throw new Error('Tool executable is invalid.');
|
||||
const profile = TOOL_PROFILES[kind]?.[executable.toLowerCase()];
|
||||
if (!profile) throw new Error(`Unsupported ${kind || 'external'} tool. Select a built-in trusted tool profile.`);
|
||||
return { executable, args: [...profile] };
|
||||
}
|
||||
|
||||
function expandTool(tool, context) {
|
||||
const values = { path: context.path, file: context.file || context.path, line: String(context.line || 1) };
|
||||
return { executable: tool.executable, args: tool.args.map((argument) => argument.replace(/\{(path|file|line)\}/g, (_, key) => values[key])) };
|
||||
}
|
||||
|
||||
class ExternalToolsService {
|
||||
constructor(store) { this.store = store; }
|
||||
|
||||
launch(kind, repositoryPath, filePath = '', line = 1) {
|
||||
const root = path.resolve(repositoryPath);
|
||||
const candidate = filePath ? path.resolve(root, filePath) : root;
|
||||
if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) throw new Error('External tool target escapes the repository.');
|
||||
const defaults = kind === 'terminal'
|
||||
? { executable: 'wt.exe', args: ['-d', '{path}'] }
|
||||
: { executable: 'code', args: ['--reuse-window', '--goto', '{file}:{line}'] };
|
||||
const configured = normalizeTool(this.store.data.preferences?.[kind], defaults, kind);
|
||||
const invocation = expandTool(configured, { path: root, file: candidate, line });
|
||||
const child = spawn(invocation.executable, invocation.args, { cwd: root, detached: true, stdio: 'ignore', windowsHide: false, shell: false });
|
||||
child.unref();
|
||||
return { launched: true, executable: invocation.executable };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ExternalToolsService, normalizeTool, expandTool, TOOL_PROFILES };
|
||||
@@ -0,0 +1,949 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs/promises');
|
||||
const crypto = require('node:crypto');
|
||||
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 MAX_UNTRACKED_DIFF_BYTES = 16 * 1024 * 1024;
|
||||
const {
|
||||
assertSafeRepositoryPath,
|
||||
assertRepositoryRelativePath,
|
||||
assertRepositoryRelativePaths,
|
||||
assertCommitMessage,
|
||||
assertFullCommitSha,
|
||||
assertCloneRemote
|
||||
} = require('../shared/validation.cjs');
|
||||
|
||||
function parseUnifiedDiff(diffText) {
|
||||
const text = String(diffText || '').replace(/\r\n/g, '\n');
|
||||
const firstHunk = text.search(/^@@ /m);
|
||||
if (firstHunk < 0) return { header: text, hunks: [] };
|
||||
const header = text.slice(0, firstHunk);
|
||||
const hunks = text.slice(firstHunk).split(/(?=^@@ )/m).filter(Boolean).map((patch, index) => {
|
||||
const heading = patch.split('\n', 1)[0];
|
||||
return { index, heading, patch, additions: (patch.match(/^\+(?!\+\+)/gm) || []).length, deletions: (patch.match(/^-(?!---)/gm) || []).length };
|
||||
});
|
||||
return { header, hunks };
|
||||
}
|
||||
|
||||
function parseNameStatus(output) {
|
||||
const entries = String(output || '').split('\0');
|
||||
const changes = [];
|
||||
for (let index = 0; index < entries.length;) {
|
||||
const rawStatus = entries[index++];
|
||||
if (!rawStatus) continue;
|
||||
const code = rawStatus[0];
|
||||
if (code === 'R' || code === 'C') {
|
||||
const originalPath = entries[index++] || '';
|
||||
const filePath = entries[index++] || '';
|
||||
if (filePath) changes.push({ code, status: code === 'R' ? 'renamed' : 'copied', path: filePath, originalPath });
|
||||
continue;
|
||||
}
|
||||
const filePath = entries[index++] || '';
|
||||
if (!filePath) continue;
|
||||
const labels = { A: 'added', D: 'deleted', M: 'modified', T: 'type-changed', U: 'conflict' };
|
||||
changes.push({ code, status: labels[code] || 'changed', path: filePath, originalPath: null });
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
function parseCompactLog(output) {
|
||||
return String(output || '').split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
|
||||
const [sha, shortSha, date, subject] = record.split('\x1f');
|
||||
return { sha, shortSha, date, subject };
|
||||
});
|
||||
}
|
||||
|
||||
class GitService {
|
||||
constructor() {
|
||||
// `git remote get-url` is only re-run when the repository configuration file
|
||||
// itself changed. Status polling asks for the remote URL of every repository
|
||||
// every few seconds, and on Windows the child process dominates that cost.
|
||||
this.remoteUrlCache = new Map();
|
||||
}
|
||||
|
||||
async isAvailable() {
|
||||
try {
|
||||
const result = await run('git', ['--version'], { timeout: 10_000 });
|
||||
return { available: true, version: result.stdout.trim() };
|
||||
} catch (error) {
|
||||
return { available: false, version: null, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async ensureRepository(repoPath) {
|
||||
const resolved = assertSafeRepositoryPath(repoPath);
|
||||
const stat = await fs.stat(resolved).catch(() => null);
|
||||
if (!stat?.isDirectory()) throw new Error('The linked local folder no longer exists.');
|
||||
// A directory that carries its own `.git` entry is by definition the top level
|
||||
// of that working tree, for plain repositories as well as for submodules and
|
||||
// linked worktrees where `.git` is a file. Spawning `git rev-parse` to learn
|
||||
// that again is pure overhead, and every status poll passes an already
|
||||
// resolved repository root back in.
|
||||
const marker = await fs.stat(path.join(resolved, '.git')).catch(() => null);
|
||||
if (marker) return resolved;
|
||||
const result = await run('git', ['rev-parse', '--show-toplevel'], { cwd: resolved, timeout: 15_000 });
|
||||
return path.resolve(result.stdout.trim());
|
||||
}
|
||||
|
||||
async status(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
// `--no-optional-locks` keeps a status read from refreshing and rewriting the
|
||||
// index. Without it every read writes inside .git, which both fights a
|
||||
// concurrent Git command for the index lock and retriggers the filesystem
|
||||
// watcher that asked for this read in the first place.
|
||||
const result = await run('git', ['--no-optional-locks', 'status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'], {
|
||||
cwd: root,
|
||||
timeout: 30_000
|
||||
});
|
||||
const parsed = parsePorcelainV2(result.stdout);
|
||||
const remoteUrl = await this.getRemoteUrl(root).catch(() => '');
|
||||
const head = parsed.branch.oid && parsed.branch.oid !== '(initial)' ? parsed.branch.oid : null;
|
||||
return { ...parsed, root, remoteUrl, head, shortHead: head ? head.slice(0, 7) : null };
|
||||
}
|
||||
|
||||
statusFingerprint(status) {
|
||||
return JSON.stringify({
|
||||
head: status?.head || null,
|
||||
branch: status?.branch || null,
|
||||
files: (status?.files || []).map((file) => [file.path, file.originalPath, file.indexCode, file.worktreeCode])
|
||||
});
|
||||
}
|
||||
|
||||
remoteUrlCacheKey(repoPath, remote) {
|
||||
return JSON.stringify([path.resolve(repoPath), remote]);
|
||||
}
|
||||
|
||||
async getRemoteUrl(repoPath, remote = 'origin') {
|
||||
const cacheKey = this.remoteUrlCacheKey(repoPath, remote);
|
||||
const config = await fs.stat(path.join(repoPath, '.git', 'config')).catch(() => null);
|
||||
const cached = this.remoteUrlCache.get(cacheKey);
|
||||
if (config && cached && cached.mtimeMs === config.mtimeMs && cached.size === config.size) {
|
||||
if (cached.error) throw cached.error;
|
||||
return cached.url;
|
||||
}
|
||||
const remember = (entry) => {
|
||||
if (config) this.remoteUrlCache.set(cacheKey, { ...entry, mtimeMs: config.mtimeMs, size: config.size });
|
||||
else this.remoteUrlCache.delete(cacheKey);
|
||||
};
|
||||
try {
|
||||
const result = await run('git', ['remote', 'get-url', remote], { cwd: repoPath, timeout: 15_000 });
|
||||
const url = result.stdout.trim();
|
||||
remember({ url, error: null });
|
||||
return url;
|
||||
} catch (error) {
|
||||
// A repository that has no such remote keeps failing until its configuration
|
||||
// changes, so the failure is remembered too. Without this, every status poll
|
||||
// of an unmatched local repository spawns a child process that cannot succeed.
|
||||
remember({ url: '', error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pathspecInput(paths) {
|
||||
const selected = assertRepositoryRelativePaths(paths);
|
||||
return selected.length ? `${selected.join('\0')}\0` : '';
|
||||
}
|
||||
|
||||
async runWithPathspec(root, args, paths, options = {}) {
|
||||
const selected = assertRepositoryRelativePaths(paths);
|
||||
if (!selected.length) return run('git', args, { cwd: root, ...options });
|
||||
return run('git', [...args, '--pathspec-from-file=-', '--pathspec-file-nul'], {
|
||||
cwd: root,
|
||||
input: this.pathspecInput(selected),
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async gitDirectory(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
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 writeWorkspaceReviewManifest(repoPath, plan, { backupBranch = null, stash = null } = {}) {
|
||||
const { root, gitDir } = await this.gitDirectory(repoPath);
|
||||
const reviewId = String(plan?.id || '').trim();
|
||||
if (!/^[0-9a-f]{64}$/i.test(reviewId)) throw new Error('Workspace review manifest requires a valid synchronization plan.');
|
||||
const reviewDirectory = path.join(gitDir, 'forgeflow', 'workspace-reviews');
|
||||
await fs.mkdir(reviewDirectory, { recursive: true });
|
||||
const manifestPath = path.join(reviewDirectory, `${reviewId}.json`);
|
||||
const payload = {
|
||||
schemaVersion: 1,
|
||||
kind: 'workspace-sync-quarantine',
|
||||
id: reviewId,
|
||||
status: 'pending-codex-review',
|
||||
createdAt: new Date().toISOString(),
|
||||
repositoryRoot: root,
|
||||
branch: plan.branch,
|
||||
upstream: plan.upstream,
|
||||
sourceSha: plan.currentSha,
|
||||
targetSha: plan.targetSha,
|
||||
recoveryBranch: backupBranch,
|
||||
stashRef: stash?.ref || null,
|
||||
stashSha: stash?.sha || null,
|
||||
files: (plan.localFiles || []).map((file) => ({
|
||||
path: file.path,
|
||||
originalPath: file.originalPath || null,
|
||||
status: file.status,
|
||||
staged: Boolean(file.staged),
|
||||
unstaged: Boolean(file.unstaged),
|
||||
untracked: Boolean(file.untracked)
|
||||
})),
|
||||
instructions: [
|
||||
'Review the recovery branch and quarantine stash with Codex before restoring anything.',
|
||||
'ForgeFlow recovery branches are local-only and cannot be pushed to Gitea.',
|
||||
'Restore only files that are still useful; obsolete files can be dropped after review.'
|
||||
],
|
||||
manifestPath
|
||||
};
|
||||
const temporaryPath = `${manifestPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||||
await fs.writeFile(temporaryPath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
|
||||
await fs.rename(temporaryPath, manifestPath);
|
||||
return payload;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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 abortInterruptedOperation(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const gitDirResult = await run('git', ['rev-parse', '--git-dir'], { cwd: root, timeout: 30_000 });
|
||||
const gitDir = path.resolve(root, gitDirResult.stdout.trim());
|
||||
const exists = async (name) => fs.access(path.join(gitDir, name)).then(() => true).catch(() => false);
|
||||
let aborted = null;
|
||||
if (await exists('rebase-merge') || await exists('rebase-apply')) {
|
||||
await run('git', ['rebase', '--abort'], { cwd: root, timeout: 120_000 });
|
||||
aborted = 'rebase';
|
||||
} else if (await exists('MERGE_HEAD')) {
|
||||
await run('git', ['merge', '--abort'], { cwd: root, timeout: 120_000 });
|
||||
aborted = 'merge';
|
||||
} else if (await exists('CHERRY_PICK_HEAD')) {
|
||||
await run('git', ['cherry-pick', '--abort'], { cwd: root, timeout: 120_000 });
|
||||
aborted = 'cherry-pick';
|
||||
} else if (await exists('REVERT_HEAD')) {
|
||||
await run('git', ['revert', '--abort'], { cwd: root, timeout: 120_000 });
|
||||
aborted = 'revert';
|
||||
}
|
||||
return { aborted, status: await this.status(root), lockReport: await this.listGitLocks(root) };
|
||||
}
|
||||
|
||||
async detectInterruptedOperation(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const gitDirResult = await run('git', ['rev-parse', '--git-dir'], { cwd: root, timeout: 30_000 });
|
||||
const gitDir = path.resolve(root, gitDirResult.stdout.trim());
|
||||
const exists = async (name) => fs.access(path.join(gitDir, name)).then(() => true).catch(() => false);
|
||||
if (await exists('rebase-merge') || await exists('rebase-apply')) return 'rebase';
|
||||
if (await exists('MERGE_HEAD')) return 'merge';
|
||||
if (await exists('CHERRY_PICK_HEAD')) return 'cherry-pick';
|
||||
if (await exists('REVERT_HEAD')) return 'revert';
|
||||
return null;
|
||||
}
|
||||
|
||||
async repairSync(repoPath, strategy) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const requested = String(strategy || '').trim();
|
||||
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 previewWorkspaceSync(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const { status } = await this.fetch(root);
|
||||
const branch = status.branch?.head;
|
||||
const upstream = status.branch?.upstream;
|
||||
if (!status.head || !branch || branch === '(detached)') {
|
||||
const error = new Error('Workspace synchronization requires a named branch with at least one commit.');
|
||||
error.code = 'WORKSPACE_SYNC_BRANCH_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
if (!upstream) {
|
||||
const error = new Error('The current branch has no Gitea upstream. Publish it or switch to a tracked branch first.');
|
||||
error.code = 'WORKSPACE_SYNC_UPSTREAM_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const targetSha = (await run('git', ['rev-parse', '--verify', upstream], { cwd: root, timeout: 30_000 })).stdout.trim();
|
||||
const changes = parseNameStatus((await run('git', [
|
||||
'diff', '--name-status', '-z', '--find-renames', 'HEAD', upstream, '--'
|
||||
], { cwd: root, timeout: 60_000, maxBuffer: 16 * 1024 * 1024 })).stdout);
|
||||
const logFormat = '%H%x1f%h%x1f%aI%x1f%s%x1e';
|
||||
const [incomingResult, localResult, interruptedOperation] = await Promise.all([
|
||||
run('git', ['log', `--format=${logFormat}`, `HEAD..${upstream}`, '-20'], { cwd: root, timeout: 30_000 }),
|
||||
run('git', ['log', `--format=${logFormat}`, `${upstream}..HEAD`, '-20'], { cwd: root, timeout: 30_000 }),
|
||||
this.detectInterruptedOperation(root)
|
||||
]);
|
||||
const blockers = [];
|
||||
if (interruptedOperation) blockers.push(`Finish or abort the active Git ${interruptedOperation} before synchronizing.`);
|
||||
if (status.counts.conflicts) blockers.push(`Resolve ${status.counts.conflicts} conflicted file${status.counts.conflicts === 1 ? '' : 's'} before synchronizing.`);
|
||||
const summary = {
|
||||
resultingTrackedChanges: changes.length,
|
||||
added: changes.filter((item) => item.code === 'A').length,
|
||||
modified: changes.filter((item) => ['M', 'T'].includes(item.code)).length,
|
||||
deleted: changes.filter((item) => item.code === 'D').length,
|
||||
renamed: changes.filter((item) => item.code === 'R').length,
|
||||
localFilesToStash: status.counts.changed,
|
||||
untrackedFilesToStash: status.counts.untracked,
|
||||
localCommitsToProtect: status.branch.ahead,
|
||||
incomingCommits: status.branch.behind
|
||||
};
|
||||
const planId = crypto.createHash('sha256').update(JSON.stringify({
|
||||
head: status.head,
|
||||
targetSha,
|
||||
branch,
|
||||
upstream,
|
||||
fingerprint: this.statusFingerprint(status)
|
||||
})).digest('hex');
|
||||
return {
|
||||
id: planId,
|
||||
repositoryRoot: root,
|
||||
branch,
|
||||
upstream,
|
||||
currentSha: status.head,
|
||||
targetSha,
|
||||
needsSync: status.head !== targetSha || !status.clean,
|
||||
cleanBeforeSync: status.clean,
|
||||
blockers,
|
||||
summary,
|
||||
changes: changes.slice(0, 250),
|
||||
changesTruncated: changes.length > 250,
|
||||
localFiles: status.files.slice(0, 250),
|
||||
localFilesTruncated: status.files.length > 250,
|
||||
incomingCommits: parseCompactLog(incomingResult.stdout),
|
||||
localCommits: parseCompactLog(localResult.stdout),
|
||||
recovery: {
|
||||
safetyBranch: status.branch.ahead > 0,
|
||||
stash: status.counts.changed > 0,
|
||||
untrackedCleanup: status.counts.untracked > 0,
|
||||
ignoredFilesPreserved: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async synchronizeWorkspace(repoPath, expectedPlanId) {
|
||||
const expected = String(expectedPlanId || '').trim();
|
||||
if (!/^[0-9a-f]{64}$/i.test(expected)) {
|
||||
const error = new Error('Apply workspace synchronization only from a reviewed preview.');
|
||||
error.code = 'WORKSPACE_SYNC_PLAN_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
const plan = await this.previewWorkspaceSync(repoPath);
|
||||
if (plan.id !== expected) {
|
||||
const error = new Error('The local workspace or Gitea branch changed after the preview. Review a fresh synchronization plan.');
|
||||
error.code = 'WORKSPACE_SYNC_PLAN_STALE';
|
||||
error.recoverable = true;
|
||||
throw error;
|
||||
}
|
||||
if (plan.blockers.length) {
|
||||
const error = new Error(plan.blockers.join(' '));
|
||||
error.code = 'WORKSPACE_SYNC_BLOCKED';
|
||||
error.recoverable = true;
|
||||
throw error;
|
||||
}
|
||||
if (!plan.needsSync) {
|
||||
return { applied: false, unchanged: true, plan, status: await this.status(plan.repositoryRoot), backupBranch: null, stash: null, cleaned: [] };
|
||||
}
|
||||
|
||||
const root = plan.repositoryRoot;
|
||||
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
|
||||
let backupBranch = null;
|
||||
let stash = null;
|
||||
let review = null;
|
||||
if (plan.summary.localCommitsToProtect > 0) {
|
||||
const safeBranch = plan.branch.replace(/[^A-Za-z0-9._-]/g, '-');
|
||||
backupBranch = `forgeflow/recovery-${safeBranch}-${stamp}-${plan.currentSha.slice(0, 7)}`;
|
||||
await run('git', ['check-ref-format', '--branch', backupBranch], { cwd: root, timeout: 30_000 });
|
||||
await run('git', ['branch', backupBranch, 'HEAD'], { cwd: root, timeout: 30_000 });
|
||||
}
|
||||
if (plan.summary.localFilesToStash > 0) {
|
||||
const label = `FORGEFLOW-QUARANTINE:${plan.id} workspace sync ${plan.branch} ${stamp}`;
|
||||
await run('git', ['stash', 'push', '--include-untracked', '-m', label], { cwd: root, timeout: 120_000 });
|
||||
stash = (await this.stashList(root))[0] || null;
|
||||
}
|
||||
if (backupBranch || stash) {
|
||||
review = await this.writeWorkspaceReviewManifest(root, plan, { backupBranch, stash });
|
||||
}
|
||||
|
||||
const protectedStatus = await this.status(root);
|
||||
if (!protectedStatus.clean || protectedStatus.head !== plan.currentSha) {
|
||||
const error = new Error('The workspace changed while ForgeFlow was protecting local work. Nothing was reset; review a fresh synchronization plan.');
|
||||
error.code = 'WORKSPACE_SYNC_CONCURRENT_CHANGE';
|
||||
error.recoverable = true;
|
||||
error.backupBranch = backupBranch;
|
||||
error.stash = stash;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await run('git', ['reset', '--hard', plan.targetSha], { cwd: root, timeout: 2 * 60_000 });
|
||||
const status = await this.status(root);
|
||||
if (status.head !== plan.targetSha || !status.clean) {
|
||||
const error = new Error('Git did not verify an exact clean match with the reviewed Gitea commit. Local recovery references were preserved.');
|
||||
error.code = 'WORKSPACE_SYNC_VERIFICATION_FAILED';
|
||||
error.recoverable = true;
|
||||
error.backupBranch = backupBranch;
|
||||
error.stash = stash;
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
applied: true,
|
||||
unchanged: false,
|
||||
plan,
|
||||
status,
|
||||
backupBranch,
|
||||
stash,
|
||||
review,
|
||||
cleaned: plan.localFiles.filter((file) => file.untracked).map((file) => file.path),
|
||||
ignoredFilesPreserved: true
|
||||
};
|
||||
}
|
||||
|
||||
async setRemoteUrl(repoPath, remoteUrl, remote = 'origin') {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const safeRemote = assertCloneRemote(remoteUrl);
|
||||
const name = String(remote || 'origin').trim();
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(name)) throw new Error('Invalid Git remote name.');
|
||||
await run('git', ['remote', 'set-url', name, safeRemote], { cwd: root, timeout: 30_000 });
|
||||
this.remoteUrlCache.delete(this.remoteUrlCacheKey(root, name));
|
||||
return this.status(root);
|
||||
}
|
||||
|
||||
async diff(repoPath, filePath, staged = false) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const safeFile = filePath ? assertRepositoryRelativePath(filePath) : '';
|
||||
const args = ['diff', '--no-ext-diff', '--no-color', '--unified=4'];
|
||||
if (staged) args.push('--cached');
|
||||
if (safeFile) args.push('--', safeFile);
|
||||
const result = await run('git', args, { cwd: root, timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
|
||||
if (!result.stdout && safeFile && !staged) {
|
||||
const candidate = path.resolve(root, safeFile);
|
||||
if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) throw new Error('File path escapes repository root.');
|
||||
const [realRoot, realCandidate, candidateStat] = await Promise.all([
|
||||
fs.realpath(root).catch(() => root),
|
||||
fs.realpath(candidate).catch(() => candidate),
|
||||
fs.stat(candidate).catch(() => null)
|
||||
]);
|
||||
const normalize = (value) => process.platform === 'win32' ? value.toLowerCase() : value;
|
||||
const normalizedRoot = normalize(realRoot);
|
||||
const normalizedCandidate = normalize(realCandidate);
|
||||
if (normalizedCandidate !== normalizedRoot && !normalizedCandidate.startsWith(`${normalizedRoot}${path.sep}`)) {
|
||||
const error = new Error('ForgeFlow refuses to read a diff target that resolves outside the repository.');
|
||||
error.code = 'DIFF_TARGET_OUTSIDE_REPOSITORY';
|
||||
throw error;
|
||||
}
|
||||
if (candidateStat?.size > MAX_UNTRACKED_DIFF_BYTES) {
|
||||
const error = new Error('The untracked file is too large to render safely as a diff.');
|
||||
error.code = 'DIFF_FILE_TOO_LARGE';
|
||||
error.recoverable = true;
|
||||
throw error;
|
||||
}
|
||||
const content = candidateStat?.isFile() ? await fs.readFile(candidate, 'utf8').catch(() => '') : '';
|
||||
if (content) return `diff --git a/${safeFile} b/${safeFile}\nnew file mode 100644\n--- /dev/null\n+++ b/${safeFile}\n${content.split('\n').map((line) => `+${line}`).join('\n')}`;
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
async diffHunks(repoPath, filePath) {
|
||||
const safeFile = assertRepositoryRelativePath(filePath);
|
||||
const diff = await this.diff(repoPath, safeFile, false);
|
||||
const parsed = parseUnifiedDiff(diff);
|
||||
return { filePath: safeFile, partialSupported: parsed.hunks.length > 0, hunks: parsed.hunks.map(({ patch, ...hunk }) => ({ ...hunk, lines: patch.split('\n') })) };
|
||||
}
|
||||
|
||||
async stageHunks(repoPath, filePath, hunkIndexes) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const safeFile = assertRepositoryRelativePath(filePath);
|
||||
const indexes = [...new Set((Array.isArray(hunkIndexes) ? hunkIndexes : []).map(Number))];
|
||||
if (!indexes.length || indexes.some((index) => !Number.isInteger(index) || index < 0)) throw new Error('Select at least one valid diff hunk.');
|
||||
const parsed = parseUnifiedDiff(await this.diff(root, safeFile, false));
|
||||
if (!parsed.hunks.length) throw new Error('Partial staging is unavailable for this file. Stage the complete file instead.');
|
||||
if (indexes.some((index) => index >= parsed.hunks.length)) throw new Error('The file changed after its diff was loaded. Refresh the diff and try again.');
|
||||
const patch = `${parsed.header}${indexes.map((index) => parsed.hunks[index].patch).join('')}`;
|
||||
await run('git', ['apply', '--cached', '--whitespace=nowarn', '-'], { cwd: root, input: patch, timeout: 60_000, maxBuffer: 16 * 1024 * 1024 });
|
||||
return this.status(root);
|
||||
}
|
||||
|
||||
async conflictState(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const operation = await this.detectInterruptedOperation(root);
|
||||
const result = await run('git', ['diff', '--name-only', '--diff-filter=U', '-z'], { cwd: root, timeout: 30_000 });
|
||||
const files = result.stdout.split('\0').filter(Boolean).map(assertRepositoryRelativePath);
|
||||
return { operation, files, canContinue: Boolean(operation) && files.length === 0, status: await this.status(root) };
|
||||
}
|
||||
|
||||
async resolveConflict(repoPath, filePath, resolution) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const safeFile = assertRepositoryRelativePath(filePath);
|
||||
const choice = String(resolution || 'resolved');
|
||||
if (!['ours', 'theirs', 'resolved'].includes(choice)) throw new Error('Unsupported conflict resolution choice.');
|
||||
if (choice !== 'resolved') await this.runWithPathspec(root, ['checkout', `--${choice}`], [safeFile], { timeout: 30_000 });
|
||||
await this.runWithPathspec(root, ['add'], [safeFile], { timeout: 30_000 });
|
||||
return this.conflictState(root);
|
||||
}
|
||||
|
||||
async continueInterruptedOperation(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const state = await this.conflictState(root);
|
||||
if (!state.operation) throw new Error('No interrupted Git operation is active.');
|
||||
if (state.files.length) throw new Error('Resolve every conflicted file before continuing.');
|
||||
const commands = { rebase: ['rebase', '--continue'], merge: ['merge', '--continue'], 'cherry-pick': ['cherry-pick', '--continue'], revert: ['revert', '--continue'] };
|
||||
await run('git', commands[state.operation], { cwd: root, env: { GIT_EDITOR: 'true' }, timeout: 120_000 });
|
||||
return this.conflictState(root);
|
||||
}
|
||||
|
||||
selectedStatusFiles(status, files) {
|
||||
const selected = assertRepositoryRelativePaths(files);
|
||||
if (!selected.length) return { selected, matches: status.files };
|
||||
const selectedSet = new Set(selected);
|
||||
const matches = status.files.filter((file) => selectedSet.has(file.path) || (file.originalPath && selectedSet.has(file.originalPath)));
|
||||
return { selected, matches };
|
||||
}
|
||||
|
||||
expandStatusPaths(status, files, { unstagedOnly = false } = {}) {
|
||||
const { selected, matches } = this.selectedStatusFiles(status, files);
|
||||
if (!selected.length) return [];
|
||||
const expanded = new Set();
|
||||
for (const file of matches) {
|
||||
if (unstagedOnly && !file.unstaged) continue;
|
||||
expanded.add(file.path);
|
||||
if (file.originalPath) expanded.add(file.originalPath);
|
||||
}
|
||||
return [...expanded];
|
||||
}
|
||||
|
||||
async expandSelectedPaths(root, files, options = {}) {
|
||||
return this.expandStatusPaths(await this.status(root), files, options);
|
||||
}
|
||||
|
||||
// Callers that already read the status pass it in. Reading it again costs a
|
||||
// child process, and a commit used to pay for four of them.
|
||||
async applyStage(root, files, knownStatus = null) {
|
||||
const requested = assertRepositoryRelativePaths(files);
|
||||
if (!requested.length) {
|
||||
await run('git', ['add', '--all'], { cwd: root, timeout: 60_000 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Only stage records that still have a worktree-side change. Re-running
|
||||
// `git add -A -- deleted-file` after that deletion is already staged makes
|
||||
// Git fail with "pathspec did not match any files" because the file no
|
||||
// longer exists in either the worktree or HEAD. Staged-only deletions and
|
||||
// renames are already ready for commit and must therefore be left alone.
|
||||
const status = knownStatus || await this.status(root);
|
||||
const selected = this.expandStatusPaths(status, requested, { unstagedOnly: true });
|
||||
if (selected.length) {
|
||||
await this.runWithPathspec(root, ['add', '-A'], selected, { timeout: 120_000 });
|
||||
}
|
||||
}
|
||||
|
||||
async stage(repoPath, files) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
await this.applyStage(root, files);
|
||||
return this.status(root);
|
||||
}
|
||||
|
||||
async unstage(repoPath, files) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const selected = await this.expandSelectedPaths(root, files);
|
||||
const hasHead = await run('git', ['rev-parse', '--verify', 'HEAD'], { cwd: root, allowExitCodes: [128] });
|
||||
if (hasHead.exitCode === 0) {
|
||||
if (selected.length) await this.runWithPathspec(root, ['restore', '--staged'], selected, { timeout: 120_000 });
|
||||
else await run('git', ['restore', '--staged', '.'], { cwd: root });
|
||||
} else {
|
||||
if (selected.length) await this.runWithPathspec(root, ['rm', '--cached', '--ignore-unmatch'], selected, { timeout: 120_000, allowExitCodes: [1] });
|
||||
else await run('git', ['rm', '--cached', '-r', '.'], { cwd: root, allowExitCodes: [1] });
|
||||
}
|
||||
return this.status(root);
|
||||
}
|
||||
|
||||
async prepareSelectedStage(root, files) {
|
||||
const selected = assertRepositoryRelativePaths(files);
|
||||
let current = null;
|
||||
if (selected.length) {
|
||||
current = await this.status(root);
|
||||
const excludedStaged = current.files
|
||||
.filter((file) => file.staged)
|
||||
.filter((file) => !selected.includes(file.path) && !(file.originalPath && selected.includes(file.originalPath)))
|
||||
.map((file) => file.path);
|
||||
if (excludedStaged.length) {
|
||||
throw new Error(`Some staged files are not selected (${excludedStaged.slice(0, 3).join(', ')}${excludedStaged.length > 3 ? ', …' : ''}). Select them or unstage them first.`);
|
||||
}
|
||||
}
|
||||
await this.applyStage(root, selected, current);
|
||||
const stagedCheck = await run('git', ['diff', '--cached', '--quiet'], { cwd: root, allowExitCodes: [1] });
|
||||
if (stagedCheck.exitCode === 0) throw new Error('There are no staged changes to commit.');
|
||||
return selected;
|
||||
}
|
||||
|
||||
async commit(repoPath, message, files = []) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const commitMessage = assertCommitMessage(message);
|
||||
await this.prepareSelectedStage(root, files);
|
||||
const result = await run('git', ['commit', '-m', commitMessage], { cwd: root, timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
|
||||
const status = await this.status(root);
|
||||
return { output: result.stdout.trim(), sha: status.head, shortSha: status.shortHead, status };
|
||||
}
|
||||
|
||||
async commitStaged(repoPath, message) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const commitMessage = assertCommitMessage(message);
|
||||
const stagedCheck = await run('git', ['diff', '--cached', '--quiet'], { cwd: root, allowExitCodes: [1] });
|
||||
if (stagedCheck.exitCode === 0) throw new Error('There are no staged changes to commit.');
|
||||
const result = await run('git', ['commit', '-m', commitMessage], { cwd: root, timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
|
||||
const status = await this.status(root);
|
||||
return { output: result.stdout.trim(), sha: status.head, shortSha: status.shortHead, status };
|
||||
}
|
||||
|
||||
async commitStagedAndPush(repoPath, message) {
|
||||
const committed = await this.commitStaged(repoPath, message);
|
||||
try {
|
||||
const pushed = await this.push(repoPath);
|
||||
return { commitOutput: committed.output, pushOutput: pushed.output, status: pushed.status, sha: committed.sha };
|
||||
} catch (error) {
|
||||
const wrapped = new Error(`Commit ${committed.shortSha} was created locally, but push failed: ${error.message}`);
|
||||
wrapped.code = 'PUSH_AFTER_COMMIT_FAILED'; wrapped.commitSha = committed.sha; wrapped.recoverable = true;
|
||||
throw wrapped;
|
||||
}
|
||||
}
|
||||
|
||||
async commitAndPush(repoPath, message, files = []) {
|
||||
const committed = await this.commit(repoPath, message, files);
|
||||
try {
|
||||
const pushed = await this.push(repoPath);
|
||||
return { commitOutput: committed.output, pushOutput: pushed.output, status: pushed.status, sha: committed.sha };
|
||||
} catch (error) {
|
||||
const wrapped = new Error(`Commit ${committed.shortSha} was created locally, but push failed: ${error.message}`);
|
||||
wrapped.code = 'PUSH_AFTER_COMMIT_FAILED';
|
||||
wrapped.commitSha = committed.sha;
|
||||
wrapped.recoverable = true;
|
||||
throw wrapped;
|
||||
}
|
||||
}
|
||||
|
||||
async push(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const status = await this.status(root);
|
||||
const branch = status.branch.head;
|
||||
if (!branch || branch === '(detached)') throw new Error('Cannot push from a detached HEAD.');
|
||||
if (/^forgeflow\/recovery-/.test(branch)) {
|
||||
const error = new Error('ForgeFlow recovery branches are local quarantine references and cannot be pushed to Gitea. Review them with Codex and move only approved work onto a normal branch.');
|
||||
error.code = 'WORKSPACE_RECOVERY_BRANCH_LOCAL_ONLY';
|
||||
error.recoverable = true;
|
||||
throw error;
|
||||
}
|
||||
const args = status.branch.upstream ? ['push', '--porcelain'] : ['push', '--porcelain', '--set-upstream', 'origin', branch];
|
||||
const result = await run('git', args, { cwd: root, timeout: 180_000, maxBuffer: 16 * 1024 * 1024 });
|
||||
return { output: `${result.stdout}\n${result.stderr}`.trim(), status: await this.status(root) };
|
||||
}
|
||||
|
||||
async fetch(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const result = await run('git', ['fetch', '--prune'], { cwd: root, timeout: 180_000 });
|
||||
return { output: `${result.stdout}\n${result.stderr}`.trim(), status: await this.status(root) };
|
||||
}
|
||||
|
||||
async pullFastForward(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const status = await this.status(root);
|
||||
if (!status.clean) throw new Error('Commit or stash local changes before synchronizing.');
|
||||
if (!status.branch.upstream) throw new Error('This branch has no upstream branch. Publish it first.');
|
||||
const result = await run('git', ['pull', '--ff-only'], { cwd: root, timeout: 180_000 });
|
||||
return { output: `${result.stdout}\n${result.stderr}`.trim(), status: await this.status(root) };
|
||||
}
|
||||
|
||||
async history(repoPath, limit = 20) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const format = '%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%s%x1e';
|
||||
const result = await run('git', ['log', `-${Math.min(Math.max(Number(limit) || 20, 1), 100)}`, `--format=${format}`], { cwd: root, allowExitCodes: [128] });
|
||||
if (result.exitCode === 128) return [];
|
||||
return result.stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
|
||||
const [sha, shortSha, author, email, date, subject] = record.split('\x1f');
|
||||
return { sha, shortSha, author, email, date, subject };
|
||||
});
|
||||
}
|
||||
|
||||
async branches(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const format = '%(refname:short)%x1f%(objectname)%x1f%(HEAD)%x1f%(upstream:short)%x1f%(upstream:track)%x1e';
|
||||
const result = await run('git', ['for-each-ref', `--format=${format}`, 'refs/heads'], { cwd: root });
|
||||
return result.stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
|
||||
const [name, sha, current, upstream, track] = record.split('\x1f');
|
||||
const ahead = Number(track?.match(/ahead (\d+)/)?.[1] || 0);
|
||||
const behind = Number(track?.match(/behind (\d+)/)?.[1] || 0);
|
||||
return { name, sha, shortSha: sha?.slice(0, 7), current: current === '*', upstream: upstream || null, ahead, behind };
|
||||
});
|
||||
}
|
||||
|
||||
assertBranchName(branch) {
|
||||
const value = String(branch || '').trim();
|
||||
if (!value) throw new Error('Branch name is required.');
|
||||
return value;
|
||||
}
|
||||
|
||||
async checkoutBranch(repoPath, branch) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const status = await this.status(root);
|
||||
if (!status.clean) throw new Error('Commit or stash local changes before switching branches.');
|
||||
const value = this.assertBranchName(branch);
|
||||
await run('git', ['check-ref-format', '--branch', value], { cwd: root });
|
||||
await run('git', ['switch', value], { cwd: root, timeout: 60_000 });
|
||||
return this.status(root);
|
||||
}
|
||||
|
||||
async createBranch(repoPath, branch) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const status = await this.status(root);
|
||||
if (!status.clean) throw new Error('Commit or stash local changes before creating a branch.');
|
||||
const value = this.assertBranchName(branch);
|
||||
await run('git', ['check-ref-format', '--branch', value], { cwd: root });
|
||||
await run('git', ['switch', '-c', value], { cwd: root, timeout: 60_000 });
|
||||
return this.status(root);
|
||||
}
|
||||
|
||||
async stash(repoPath, message = '') {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const status = await this.status(root);
|
||||
if (status.clean) throw new Error('There are no changes to stash.');
|
||||
const args = ['stash', 'push', '--include-untracked'];
|
||||
const label = String(message || '').trim();
|
||||
if (label) args.push('-m', label.slice(0, 200));
|
||||
const result = await run('git', args, { cwd: root, timeout: 120_000 });
|
||||
return { output: result.stdout.trim(), status: await this.status(root), stashes: await this.stashList(root) };
|
||||
}
|
||||
|
||||
async stashList(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const format = '%gd%x1f%H%x1f%aI%x1f%gs%x1e';
|
||||
const result = await run('git', ['stash', 'list', `--format=${format}`], { cwd: root });
|
||||
return result.stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
|
||||
const [ref, sha, date, subject] = record.split('\x1f');
|
||||
const quarantine = String(subject || '').match(/FORGEFLOW-QUARANTINE:([0-9a-f]{64})/i);
|
||||
return {
|
||||
ref,
|
||||
sha,
|
||||
shortSha: sha.slice(0, 7),
|
||||
date,
|
||||
subject,
|
||||
quarantined: Boolean(quarantine),
|
||||
reviewId: quarantine?.[1] || null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async popStash(repoPath, ref = 'stash@{0}') {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const value = String(ref || 'stash@{0}');
|
||||
if (!/^stash@\{\d+\}$/.test(value)) throw new Error('Invalid stash reference.');
|
||||
const candidate = (await this.stashList(root)).find((item) => item.ref === value);
|
||||
if (candidate?.quarantined) {
|
||||
const error = new Error(`This stash is quarantined for Codex review (${candidate.reviewId}). ForgeFlow will not apply and drop it wholesale; restore only reviewed files manually.`);
|
||||
error.code = 'WORKSPACE_QUARANTINE_REVIEW_REQUIRED';
|
||||
error.recoverable = true;
|
||||
throw error;
|
||||
}
|
||||
const result = await run('git', ['stash', 'pop', value], { cwd: root, timeout: 120_000 });
|
||||
return { output: result.stdout.trim(), status: await this.status(root), stashes: await this.stashList(root) };
|
||||
}
|
||||
|
||||
async verifyCommitOnRemoteBranch(repoPath, sha, branch) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const fullSha = assertFullCommitSha(sha);
|
||||
const branchName = this.assertBranchName(branch);
|
||||
await run('git', ['fetch', '--prune', 'origin', branchName], { cwd: root, timeout: 180_000 });
|
||||
await run('git', ['cat-file', '-e', `${fullSha}^{commit}`], { cwd: root, timeout: 30_000 });
|
||||
const ancestor = await run('git', ['merge-base', '--is-ancestor', fullSha, `origin/${branchName}`], { cwd: root, allowExitCodes: [1] });
|
||||
if (ancestor.exitCode !== 0) throw new Error(`Commit ${fullSha.slice(0, 7)} is not contained in origin/${branchName}.`);
|
||||
return { valid: true, sha: fullSha, branch: branchName };
|
||||
}
|
||||
|
||||
async inspectCloneTarget(remoteUrl, destination) {
|
||||
const remote = assertCloneRemote(remoteUrl);
|
||||
const target = assertSafeRepositoryPath(destination);
|
||||
const existing = await fs.stat(target).catch(() => null);
|
||||
|
||||
if (!existing) return { state: 'missing', remote, target };
|
||||
if (!existing.isDirectory()) {
|
||||
const error = new Error('The automatic clone target exists and is not a folder.');
|
||||
error.code = 'CLONE_TARGET_NOT_DIRECTORY';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(target);
|
||||
if (!entries.length) return { state: 'empty', remote, target };
|
||||
|
||||
const existingRemote = await this.getRemoteUrl(target).catch(() => '');
|
||||
const expected = normalizeRemoteUrl(remote);
|
||||
const actual = normalizeRemoteUrl(existingRemote);
|
||||
const sameRepository = Boolean(
|
||||
expected && actual
|
||||
&& expected.host === actual.host
|
||||
&& expected.path === actual.path
|
||||
);
|
||||
|
||||
if (sameRepository) return { state: 'matching-repository', remote, target };
|
||||
|
||||
const error = new Error(existingRemote
|
||||
? 'The automatic clone target already contains a different Git repository.'
|
||||
: 'The automatic clone target already contains files. Choose another location or link the existing folder.');
|
||||
error.code = existingRemote ? 'CLONE_TARGET_DIFFERENT_REPOSITORY' : 'CLONE_TARGET_NOT_EMPTY';
|
||||
throw error;
|
||||
}
|
||||
|
||||
async clone(remoteUrl, destination) {
|
||||
const assessment = await this.inspectCloneTarget(remoteUrl, destination);
|
||||
if (assessment.state === 'matching-repository') {
|
||||
const status = await this.status(assessment.target);
|
||||
return { ...status, reused: true };
|
||||
}
|
||||
|
||||
if (assessment.state === 'missing') {
|
||||
await fs.mkdir(path.dirname(assessment.target), { recursive: true });
|
||||
}
|
||||
|
||||
await run('git', ['clone', '--progress', assessment.remote, assessment.target], { timeout: 15 * 60_000, maxBuffer: 32 * 1024 * 1024 });
|
||||
const status = await this.status(assessment.target);
|
||||
return { ...status, reused: false };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { GitService, parseUnifiedDiff };
|
||||
@@ -0,0 +1,89 @@
|
||||
"use strict";
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const PROFILE_DEFINITIONS = Object.freeze({
|
||||
minimal: { label: "Minimal", requiredScore: 55, severities: ["error"], allowSuppressions: true, maxSuppressionDays: 180 },
|
||||
standard: { label: "Standard", requiredScore: 70, severities: ["error", "warning"], allowSuppressions: true, maxSuppressionDays: 90 },
|
||||
strict: { label: "Strict", requiredScore: 82, severities: ["error", "warning"], allowSuppressions: true, maxSuppressionDays: 30 },
|
||||
production: { label: "Production", requiredScore: 90, severities: ["error", "warning"], allowSuppressions: true, maxSuppressionDays: 14 },
|
||||
});
|
||||
|
||||
function normalizePolicy(policy = {}) {
|
||||
const id = String(policy.id || policy.profile || "standard").toLowerCase();
|
||||
const base = PROFILE_DEFINITIONS[id] || PROFILE_DEFINITIONS.standard;
|
||||
const custom = id === "organization" ? policy : {};
|
||||
return {
|
||||
id,
|
||||
label: custom.label || base.label || "Organization custom",
|
||||
requiredScore: Math.min(100, Math.max(0, Number(custom.requiredScore ?? base.requiredScore ?? 80))),
|
||||
enabledChecks: Array.isArray(custom.enabledChecks) ? [...new Set(custom.enabledChecks.map(String))] : null,
|
||||
severityOverrides: custom.severityOverrides && typeof custom.severityOverrides === "object" ? { ...custom.severityOverrides } : {},
|
||||
blockingChecks: [...new Set((custom.blockingChecks || policy.blockingChecks || []).map(String))],
|
||||
blockingSeverities: [...new Set((custom.blockingSeverities || policy.blockingSeverities || base.severities || ["error"]).map(String))]
|
||||
.filter((severity) => ["warning", "error"].includes(severity)),
|
||||
allowSuppressions: custom.allowSuppressions ?? base.allowSuppressions ?? true,
|
||||
maxSuppressionDays: Math.max(1, Number(custom.maxSuppressionDays ?? base.maxSuppressionDays ?? 30)),
|
||||
};
|
||||
}
|
||||
|
||||
function validateSuppression(input, policy, now = new Date()) {
|
||||
if (!policy.allowSuppressions) throw new Error("The selected policy does not allow suppressions.");
|
||||
const checkId = String(input?.checkId || "").trim();
|
||||
const reason = String(input?.reason || "").trim();
|
||||
const author = String(input?.author || "").trim();
|
||||
const scope = String(input?.scope || "repository").trim();
|
||||
const evidence = String(input?.evidence || "").trim();
|
||||
const expiresAt = new Date(input?.expiresAt || "");
|
||||
if (!checkId || reason.length < 10 || !author || !evidence) throw new Error("A suppression requires a check ID, author, evidence and a reason of at least 10 characters.");
|
||||
if (!Number.isFinite(expiresAt.getTime()) || expiresAt <= now) throw new Error("A suppression expiry must be in the future.");
|
||||
const maximum = new Date(now.getTime() + policy.maxSuppressionDays * 86_400_000);
|
||||
if (expiresAt > maximum) throw new Error(`This policy permits suppressions for at most ${policy.maxSuppressionDays} days.`);
|
||||
return {
|
||||
id: crypto.randomUUID(), checkId, reason, author,
|
||||
createdAt: now.toISOString(), ticket: String(input.ticket || "").trim() || null,
|
||||
expiresAt: expiresAt.toISOString(), scope, evidence,
|
||||
};
|
||||
}
|
||||
|
||||
function applyPolicy(checks, policyInput, suppressions = [], now = new Date()) {
|
||||
const policy = normalizePolicy(policyInput);
|
||||
const enabled = policy.enabledChecks ? new Set(policy.enabledChecks) : null;
|
||||
const relevant = checks.filter((check) => !enabled || enabled.has(check.id)).map((check) => {
|
||||
const status = policy.severityOverrides[check.id] || check.status;
|
||||
const suppression = suppressions.find((item) => item.checkId === check.id && new Date(item.expiresAt) > now);
|
||||
const expiredSuppression = suppressions.find((item) => item.checkId === check.id && new Date(item.expiresAt) <= now);
|
||||
return {
|
||||
...check,
|
||||
status,
|
||||
suppressed: Boolean(suppression),
|
||||
suppression: suppression || null,
|
||||
expiredSuppression: expiredSuppression || null,
|
||||
blocking: !suppression && status !== "pass" && (policy.blockingSeverities.includes(status) || policy.blockingChecks.includes(check.id)),
|
||||
};
|
||||
});
|
||||
return { policy, checks: relevant };
|
||||
}
|
||||
|
||||
function buildTrend(previous, report) {
|
||||
const prior = new Map((previous?.checks || []).map((check) => [check.id, check]));
|
||||
const current = new Map(report.checks.map((check) => [check.id, check]));
|
||||
const active = (check) => check && check.status !== "pass" && !check.suppressed;
|
||||
const newlyFound = [...current.values()].filter((check) => active(check) && !active(prior.get(check.id))).map((check) => check.id);
|
||||
const resolved = [...prior.values()].filter((check) => active(check) && !active(current.get(check.id))).map((check) => check.id);
|
||||
const regressions = [...current.values()].filter((check) => active(check) && prior.get(check.id)?.status === "warning" && check.status === "error").map((check) => check.id);
|
||||
return { score: report.score, categories: report.categories, newlyFound, resolved, regressions, suppressions: report.checks.filter((check) => check.suppressed).map((check) => check.id), checkedAt: report.checkedAt, commitSha: report.commitSha || null, checks: report.checks.map(({ id, status, suppressed }) => ({ id, status, suppressed })) };
|
||||
}
|
||||
|
||||
function exportReport(report, format = "json") {
|
||||
if (format === "json") return { extension: "json", mimeType: "application/json", content: `${JSON.stringify(report, null, 2)}\n` };
|
||||
const rows = report.checks.map((check) => `| ${check.id} | ${check.category} | ${check.status}${check.suppressed ? " (suppressed)" : ""} | ${String(check.detail).replace(/\|/g, "\\|")} |`).join("\n");
|
||||
const markdown = `# Git assurance report — ${report.repository}\n\nPolicy: **${report.policy.label}** · Score: **${report.score}/100** · Commit: \`${report.commitSha || "unknown"}\`\n\n| Check | Category | Status | Evidence |\n|---|---|---|---|\n${rows}\n`;
|
||||
if (format === "markdown") return { extension: "md", mimeType: "text/markdown", content: markdown };
|
||||
if (format !== "html") throw new Error("Unsupported Git Validator export format.");
|
||||
const escape = (value) => String(value).replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]);
|
||||
const htmlRows = report.checks.map((check) => `<tr><td>${escape(check.id)}</td><td>${escape(check.category)}</td><td>${escape(check.status)}${check.suppressed ? " (suppressed)" : ""}</td><td>${escape(check.detail)}</td></tr>`).join("");
|
||||
return { extension: "html", mimeType: "text/html", content: `<!doctype html><html lang="en"><meta charset="utf-8"><title>Git assurance — ${escape(report.repository)}</title><style>body{font:15px system-ui;max-width:1100px;margin:40px auto;padding:0 24px;color:#172033}table{border-collapse:collapse;width:100%}th,td{padding:10px;border:1px solid #ccd4e0;text-align:left}th{background:#edf2f7}</style><h1>Git assurance — ${escape(report.repository)}</h1><p>Policy: <strong>${escape(report.policy.label)}</strong> · Score: <strong>${report.score}/100</strong> · Commit: <code>${escape(report.commitSha || "unknown")}</code></p><table><thead><tr><th>Check</th><th>Category</th><th>Status</th><th>Evidence</th></tr></thead><tbody>${htmlRows}</tbody></table></html>` };
|
||||
}
|
||||
|
||||
module.exports = { PROFILE_DEFINITIONS, normalizePolicy, validateSuppression, applyPolicy, buildTrend, exportReport };
|
||||
@@ -0,0 +1,599 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { run } = require("./process-runner.cjs");
|
||||
const { normalizeRemoteUrl } = require("../shared/repository-match.cjs");
|
||||
const { applyPolicy, buildTrend, exportReport, normalizePolicy, validateSuppression } = require("./git-validator-policy.cjs");
|
||||
|
||||
const RECOMMENDED_GITIGNORE = `# Local configuration and secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.sample
|
||||
|
||||
# Dependencies and generated output
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
|
||||
# Editors and operating systems
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
`;
|
||||
|
||||
const RECOMMENDED_GITATTRIBUTES = `* text=auto eol=lf
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
*.ps1 text eol=crlf
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.zip binary
|
||||
`;
|
||||
|
||||
const RECOMMENDED_EDITORCONFIG = `root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.{bat,cmd,ps1}]
|
||||
end_of_line = crlf
|
||||
`;
|
||||
|
||||
function sameRemote(left, right) {
|
||||
const a = normalizeRemoteUrl(left);
|
||||
const b = normalizeRemoteUrl(right);
|
||||
return Boolean(a && b && a.host === b.host && a.path === b.path);
|
||||
}
|
||||
|
||||
function result(id, category, title, status, detail, options = {}) {
|
||||
return {
|
||||
id,
|
||||
category,
|
||||
title,
|
||||
status,
|
||||
detail,
|
||||
weight: options.weight || 5,
|
||||
fixAction: options.fixAction || null,
|
||||
safe: options.safe === true,
|
||||
confirmation: options.confirmation || null,
|
||||
evidence: options.evidence || null,
|
||||
};
|
||||
}
|
||||
|
||||
function isSensitiveTrackedPath(filePath) {
|
||||
const value = String(filePath || "")
|
||||
.replace(/\\/g, "/")
|
||||
.toLowerCase();
|
||||
if (/\.env\.(example|sample|template)$/.test(value)) return false;
|
||||
return (
|
||||
/(^|\/)\.env($|\.)/.test(value) ||
|
||||
/(^|\/)(id_rsa|id_ed25519)$/.test(value) ||
|
||||
/\.(pem|p12|pfx|key)$/.test(value) ||
|
||||
/(^|\/)(credentials|secrets?)(\.[^/]+)?\.(json|ya?ml)$/.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
class GitValidatorService {
|
||||
constructor({ git, gitea, diagnostics, store }) {
|
||||
this.git = git;
|
||||
this.gitea = gitea;
|
||||
this.diagnostics = diagnostics;
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
async config(root, key, { local = true } = {}) {
|
||||
const response = await run(
|
||||
"git",
|
||||
["config", ...(local ? ["--local"] : []), "--get", key],
|
||||
{
|
||||
cwd: root,
|
||||
timeout: 10_000,
|
||||
allowExitCodes: [1],
|
||||
},
|
||||
);
|
||||
return response.stdout.trim();
|
||||
}
|
||||
|
||||
async trackedFiles(root) {
|
||||
const response = await run("git", ["ls-files", "-z"], {
|
||||
cwd: root,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
return response.stdout.split("\0").filter(Boolean);
|
||||
}
|
||||
|
||||
async scan(repository) {
|
||||
const checks = [];
|
||||
const defaultBranch = repository.defaultBranch || "main";
|
||||
const owner = repository.owner?.login;
|
||||
try {
|
||||
const protection = await this.gitea.getBranchProtection(
|
||||
owner,
|
||||
repository.name,
|
||||
defaultBranch,
|
||||
);
|
||||
checks.push(
|
||||
result(
|
||||
"default-branch-protection",
|
||||
"Gitea governance",
|
||||
"Default branch protection",
|
||||
protection.protected ? "pass" : "warning",
|
||||
protection.protected
|
||||
? `${defaultBranch} is protected; force push is ${protection.enableForcePush ? "allowed" : "blocked"}.`
|
||||
: `${defaultBranch} accepts unprotected direct changes.`,
|
||||
{
|
||||
weight: 18,
|
||||
fixAction: protection.protected ? null : "protect-default-branch",
|
||||
safe: false,
|
||||
confirmation: `Protect ${defaultBranch} on Gitea and block direct and force pushes?`,
|
||||
},
|
||||
),
|
||||
);
|
||||
if (protection.protected)
|
||||
checks.push(
|
||||
result(
|
||||
"force-push",
|
||||
"Gitea governance",
|
||||
"Force-push protection",
|
||||
protection.enableForcePush ? "warning" : "pass",
|
||||
protection.enableForcePush
|
||||
? "Force pushes remain enabled on the protected branch."
|
||||
: "Force pushes are blocked on the protected branch.",
|
||||
{ weight: 8 },
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
checks.push(
|
||||
result(
|
||||
"branch-protection-unavailable",
|
||||
"Gitea governance",
|
||||
"Branch protection could not be verified",
|
||||
"warning",
|
||||
error.message,
|
||||
{ weight: 18 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!repository.localPath) {
|
||||
checks.push(
|
||||
result(
|
||||
"local-link",
|
||||
"Local repository",
|
||||
"Local working tree",
|
||||
"warning",
|
||||
"Link or clone this repository to validate files and local Git configuration.",
|
||||
{ weight: 35 },
|
||||
),
|
||||
);
|
||||
return this.finalize(repository, checks, null);
|
||||
}
|
||||
|
||||
const root = await this.git.ensureRepository(repository.localPath);
|
||||
const status = await this.git.status(root);
|
||||
const tracked = await this.trackedFiles(root);
|
||||
const lowerFiles = tracked.map((file) => file.toLowerCase());
|
||||
const desiredRemote =
|
||||
repository.preferredCloneUrl || repository.cloneUrl || repository.sshUrl;
|
||||
checks.push(
|
||||
result(
|
||||
"origin",
|
||||
"Repository identity",
|
||||
"Origin matches Gitea",
|
||||
sameRemote(status.remoteUrl, desiredRemote) ? "pass" : "error",
|
||||
sameRemote(status.remoteUrl, desiredRemote)
|
||||
? status.remoteUrl
|
||||
: `Current origin ${status.remoteUrl || "is missing"}; expected ${desiredRemote}.`,
|
||||
{
|
||||
weight: 15,
|
||||
fixAction: sameRemote(status.remoteUrl, desiredRemote)
|
||||
? null
|
||||
: "align-origin",
|
||||
safe: true,
|
||||
},
|
||||
),
|
||||
);
|
||||
checks.push(
|
||||
result(
|
||||
"upstream",
|
||||
"Branch hygiene",
|
||||
"Current branch has an upstream",
|
||||
status.branch?.upstream ? "pass" : "warning",
|
||||
status.branch?.upstream
|
||||
? `${status.branch.head} tracks ${status.branch.upstream}.`
|
||||
: `${status.branch?.head || "The current branch"} is not published or tracked.`,
|
||||
{ weight: 8 },
|
||||
),
|
||||
);
|
||||
checks.push(
|
||||
result(
|
||||
"working-tree",
|
||||
"Branch hygiene",
|
||||
"Working tree is intentional",
|
||||
status.clean ? "pass" : "warning",
|
||||
status.clean
|
||||
? "No uncommitted changes."
|
||||
: `${status.counts.changed} changed file(s) require review, commit or stash.`,
|
||||
{ weight: 5 },
|
||||
),
|
||||
);
|
||||
|
||||
const [userName, userEmail, fetchPrune, pullFf, autoStash] =
|
||||
await Promise.all([
|
||||
this.config(root, "user.name", { local: false }),
|
||||
this.config(root, "user.email", { local: false }),
|
||||
this.config(root, "fetch.prune"),
|
||||
this.config(root, "pull.ff"),
|
||||
this.config(root, "rebase.autoStash"),
|
||||
]);
|
||||
checks.push(
|
||||
result(
|
||||
"identity",
|
||||
"Commit integrity",
|
||||
"Repository author identity",
|
||||
userName && userEmail ? "pass" : "warning",
|
||||
userName && userEmail
|
||||
? `${userName} <${userEmail}>`
|
||||
: "The effective Git user.name or user.email is missing.",
|
||||
{ weight: 7 },
|
||||
),
|
||||
);
|
||||
const safetyReady =
|
||||
fetchPrune === "true" && pullFf === "only" && autoStash === "true";
|
||||
checks.push(
|
||||
result(
|
||||
"local-safety",
|
||||
"Local configuration",
|
||||
"Safe synchronization defaults",
|
||||
safetyReady ? "pass" : "warning",
|
||||
safetyReady
|
||||
? "Stale remotes are pruned, pulls are fast-forward-only and rebase autostash is enabled."
|
||||
: "Recommended repository-local fetch, pull and autostash safeguards are incomplete.",
|
||||
{
|
||||
weight: 10,
|
||||
fixAction: safetyReady ? null : "configure-local-safety",
|
||||
safe: true,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const hasReadme = lowerFiles.some((file) =>
|
||||
/(^|\/)readme(\.[^/]+)?$/.test(file),
|
||||
);
|
||||
checks.push(
|
||||
result(
|
||||
"readme",
|
||||
"Repository documentation",
|
||||
"README is versioned",
|
||||
hasReadme ? "pass" : "warning",
|
||||
hasReadme
|
||||
? "Repository purpose and usage can be documented at the source."
|
||||
: "No tracked README was found.",
|
||||
{ weight: 7 },
|
||||
),
|
||||
);
|
||||
const hasGitignore = lowerFiles.includes(".gitignore");
|
||||
checks.push(
|
||||
result(
|
||||
"gitignore",
|
||||
"Repository hygiene",
|
||||
".gitignore is versioned",
|
||||
hasGitignore ? "pass" : "warning",
|
||||
hasGitignore
|
||||
? "Generated and local-only files can be excluded centrally."
|
||||
: "No tracked .gitignore was found.",
|
||||
{
|
||||
weight: 8,
|
||||
fixAction: hasGitignore ? null : "add-gitignore",
|
||||
safe: false,
|
||||
confirmation:
|
||||
"Create a recommended .gitignore in the working tree? It will remain uncommitted for review.",
|
||||
},
|
||||
),
|
||||
);
|
||||
for (const [id, title, filename, action] of [
|
||||
["gitattributes", ".gitattributes normalizes text and binary files", ".gitattributes", "add-gitattributes"],
|
||||
["editorconfig", ".editorconfig keeps editors consistent", ".editorconfig", "add-editorconfig"],
|
||||
]) {
|
||||
const present = lowerFiles.includes(filename);
|
||||
checks.push(result(id, "Repository hygiene", title, present ? "pass" : "warning",
|
||||
present ? `${filename} is versioned.` : `No tracked ${filename} was found.`, {
|
||||
weight: 5,
|
||||
fixAction: present ? null : action,
|
||||
safe: false,
|
||||
confirmation: `Create a recommended ${filename} in the working tree for review?`,
|
||||
}));
|
||||
}
|
||||
|
||||
const packageManagers = [
|
||||
{ manifests: ["package.json"], locks: ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"] },
|
||||
{ manifests: ["pyproject.toml", "requirements.in", "pipfile"], locks: ["uv.lock", "poetry.lock", "requirements.txt", "pipfile.lock"] },
|
||||
{ manifests: ["composer.json"], locks: ["composer.lock"] },
|
||||
{ manifests: ["gemfile"], locks: ["gemfile.lock"] },
|
||||
];
|
||||
const lockCheck = packageManagers.find((entry) => entry.manifests.some((name) => lowerFiles.includes(name)));
|
||||
if (lockCheck) {
|
||||
const lockfile = lockCheck.locks.find((name) => lowerFiles.includes(name));
|
||||
checks.push(result("dependency-lock", "Supply chain", "Dependencies are reproducibly locked", lockfile ? "pass" : "warning",
|
||||
lockfile ? `${lockfile} is versioned.` : "A dependency manifest exists without a recognized lockfile.", { weight: 9 }));
|
||||
}
|
||||
|
||||
const hasCi = lowerFiles.some((file) => /^\.gitea\/workflows\/[^/]+\.ya?ml$/.test(file));
|
||||
checks.push(result("continuous-integration", "Gitea governance", "Automated checks run on Gitea", hasCi ? "pass" : "warning",
|
||||
hasCi ? "At least one Gitea Actions workflow is versioned." : "No .gitea/workflows YAML file was found.", { weight: 8 }));
|
||||
|
||||
const sensitive = tracked.filter(isSensitiveTrackedPath);
|
||||
checks.push(
|
||||
result(
|
||||
"tracked-secrets",
|
||||
"Security",
|
||||
"No secret-shaped files are tracked",
|
||||
sensitive.length ? "error" : "pass",
|
||||
sensitive.length
|
||||
? `Review immediately: ${sensitive.slice(0, 8).join(", ")}${sensitive.length > 8 ? "…" : ""}. Removing a file does not erase Git history.`
|
||||
: "No tracked environment, private-key or credential filenames were detected.",
|
||||
{ weight: 22 },
|
||||
),
|
||||
);
|
||||
|
||||
const large = [];
|
||||
const candidates = tracked.slice(0, 5000);
|
||||
for (
|
||||
let index = 0;
|
||||
index < candidates.length && large.length < 12;
|
||||
index += 64
|
||||
) {
|
||||
const batch = candidates.slice(index, index + 64);
|
||||
const stats = await Promise.all(
|
||||
batch.map(async (file) => ({
|
||||
file,
|
||||
stat: await fs.stat(path.join(root, file)).catch(() => null),
|
||||
})),
|
||||
);
|
||||
for (const item of stats) {
|
||||
if (item.stat?.isFile() && item.stat.size > 10 * 1024 * 1024)
|
||||
large.push({ file: item.file, size: item.stat.size });
|
||||
if (large.length >= 12) break;
|
||||
}
|
||||
}
|
||||
checks.push(
|
||||
result(
|
||||
"large-files",
|
||||
"Repository performance",
|
||||
"No oversized tracked files",
|
||||
large.length ? "warning" : "pass",
|
||||
large.length
|
||||
? `${large.map((item) => `${item.file} (${Math.ceil(item.size / 1024 / 1024)} MB)`).join(", ")}. Consider Git LFS.`
|
||||
: "No tracked files above 10 MB were found.",
|
||||
{ weight: 7 },
|
||||
),
|
||||
);
|
||||
await this.addAssuranceChecks(root, tracked, lowerFiles, checks);
|
||||
return this.finalize(repository, checks, status);
|
||||
}
|
||||
|
||||
async addAssuranceChecks(root, tracked, lowerFiles, checks) {
|
||||
const has = (...patterns) => lowerFiles.some((file) => patterns.some((pattern) => pattern.test(file)));
|
||||
const fileCheck = (id, category, title, patterns, detail, weight = 5) => {
|
||||
const present = has(...patterns);
|
||||
checks.push(result(id, category, title, present ? "pass" : "warning", present ? `${title} is versioned.` : detail, { weight }));
|
||||
};
|
||||
fileCheck("security-policy", "Security", "Security policy", [/(^|\/)security\.md$/], "Add SECURITY.md with supported versions and private disclosure instructions.", 8);
|
||||
fileCheck("codeowners", "Governance", "Code ownership", [/(^|\/)codeowners$/], "Add CODEOWNERS for security-sensitive and release paths.", 6);
|
||||
fileCheck("license", "Governance", "Repository license", [/(^|\/)(license|copying)(\.[^/]+)?$/], "Document the repository license or private-use terms.", 5);
|
||||
fileCheck("changelog", "Release readiness", "Changelog", [/(^|\/)changelog(\.[^/]+)?$/], "Add a changelog that maps releases to user-visible changes.", 7);
|
||||
fileCheck("contributing", "Collaboration", "Contribution guide", [/(^|\/)contributing(\.[^/]+)?$/], "Add contribution, test and review instructions.", 4);
|
||||
fileCheck("issue-templates", "Collaboration", "Issue templates", [/^\.gitea\/issue_template\//, /^\.github\/issue_template\//], "Add structured issue templates.", 3);
|
||||
fileCheck("pull-request-template", "Collaboration", "Pull request template", [/(^|\/)pull_request_template\.md$/], "Add a pull request checklist for tests, risk and rollback.", 4);
|
||||
fileCheck("runtime-pinning", "Reproducibility", "Runtime version pinning", [/(^|\/)(\.nvmrc|\.node-version|\.tool-versions|mise\.toml)$/], "Pin the runtime version used by developers and CI.", 7);
|
||||
fileCheck("build-instructions", "Reproducibility", "Build instructions", [/(^|\/)(readme|building|build)(\.[^/]+)?$/], "Document a clean, reproducible build command.", 6);
|
||||
|
||||
const generated = tracked.filter((file) => /(^|\/)(dist|build|coverage|\.cache)\//i.test(file));
|
||||
checks.push(result("generated-artifacts", "Performance and hygiene", "Generated output is not tracked", generated.length ? "warning" : "pass", generated.length ? `${generated.length} generated-path file(s) are tracked; review ${generated.slice(0, 5).join(", ")}.` : "No common generated output directories are tracked.", { weight: 8, evidence: generated.slice(0, 20) }));
|
||||
const executables = tracked.filter((file) => /\.(exe|dll|msi|scr|com|bat|cmd|ps1)$/i.test(file));
|
||||
checks.push(result("executable-artifacts", "Security", "Executable artifacts are intentional", executables.length ? "warning" : "pass", executables.length ? `Review executable content: ${executables.slice(0, 8).join(", ")}.` : "No executable-shaped artifacts are tracked.", { weight: 8, evidence: executables.slice(0, 20) }));
|
||||
|
||||
const workflowFiles = tracked.filter((file) => /^\.(gitea|github)\/workflows\/[^/]+\.ya?ml$/i.test(file));
|
||||
const workflowText = (await Promise.all(workflowFiles.slice(0, 40).map((file) => fs.readFile(path.join(root, file), "utf8").catch(() => "")))).join("\n");
|
||||
const unpinned = [...workflowText.matchAll(/uses:\s*[^\s@]+@([^\s#]+)/g)].map((match) => match[1]).filter((ref) => !/^[0-9a-f]{40}$/i.test(ref));
|
||||
checks.push(result("pinned-actions", "Security", "External CI actions are commit-pinned", unpinned.length ? "warning" : "pass", unpinned.length ? `${unpinned.length} action reference(s) use mutable tags or branches.` : "External actions are commit-pinned or no external actions are used.", { weight: 9, evidence: unpinned.slice(0, 20) }));
|
||||
const broadPermissions = /permissions:\s*(write-all|write)/i.test(workflowText) || /contents:\s*write/i.test(workflowText);
|
||||
checks.push(result("workflow-permissions", "Security", "Workflow permissions use least privilege", broadPermissions ? "error" : "pass", broadPermissions ? "A workflow requests broad write permissions; scope permissions per job and capability." : "No broad workflow write permission was detected.", { weight: 12 }));
|
||||
|
||||
const [commitSignature, tagSignature, recentSubjects] = await Promise.all([
|
||||
run("git", ["log", "-1", "--format=%G?"], { cwd: root, timeout: 10_000, allowExitCodes: [128] }).then((value) => value.stdout.trim()).catch(() => "N"),
|
||||
run("git", ["tag", "--points-at", "HEAD", "--format=%(contents:signature)"], { cwd: root, timeout: 10_000, allowExitCodes: [128] }).then((value) => value.stdout.trim()).catch(() => ""),
|
||||
run("git", ["log", "-20", "--format=%s"], { cwd: root, timeout: 10_000, allowExitCodes: [128] }).then((value) => value.stdout.trim().split(/\r?\n/).filter(Boolean)).catch(() => []),
|
||||
]);
|
||||
checks.push(result("signed-commits", "Governance", "Latest commit is signed", /[GUYX]/.test(commitSignature) ? "pass" : "warning", /[GUYX]/.test(commitSignature) ? "Git reports a cryptographic signature on HEAD." : "HEAD has no verifiable Git signature.", { weight: 6 }));
|
||||
checks.push(result("signed-tags", "Governance", "Release tags are signed", tagSignature ? "pass" : "warning", tagSignature ? "HEAD has a signed tag." : "HEAD has no signed release tag.", { weight: 5 }));
|
||||
const conventional = recentSubjects.length > 0 && recentSubjects.every((subject) => /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?!?:\s.+/i.test(subject));
|
||||
checks.push(result("conventional-commits", "Governance", "Recent commits follow a convention", conventional ? "pass" : "warning", conventional ? `${recentSubjects.length} recent commit subject(s) follow Conventional Commits.` : "Some recent commit subjects do not follow Conventional Commits.", { weight: 4 }));
|
||||
|
||||
const releaseFiles = {
|
||||
"release-checksums": /(^|\/)(sha256sums|checksums?)(\.[^/]+)?$/,
|
||||
"release-provenance": /(^|\/)(provenance|attestation)(\.[^/]+)?$/,
|
||||
"release-sbom": /(^|\/)(sbom)(\.[^/]+)?$/,
|
||||
};
|
||||
for (const [id, pattern] of Object.entries(releaseFiles)) fileCheck(id, "Release readiness", id.replace(/^release-/, "Release "), [pattern], `No ${id.replace(/^release-/, "")} artifact is versioned or generated in the repository.`, 4);
|
||||
checks.push(result("signing-readiness", "Release readiness", "Signing policy is documented", has(/(^|\/)(security|release|signing)(\.[^/]+)?$/) ? "pass" : "warning", has(/(^|\/)(security|release|signing)(\.[^/]+)?$/) ? "Signing guidance is present." : "Document signing identity, verification and timestamp requirements.", { weight: 6 }));
|
||||
}
|
||||
|
||||
async finalize(repository, checks, status) {
|
||||
const repositoryState = this.store?.getGitValidatorState?.(repository.fullName) || { policy: { id: "standard" }, suppressions: [], trends: [] };
|
||||
const { policy, checks: governedChecks } = applyPolicy(checks, repositoryState.policy, repositoryState.suppressions);
|
||||
const report = this.summarize(repository, governedChecks);
|
||||
report.policy = policy;
|
||||
report.commitSha = status?.head || status?.branch?.oid || null;
|
||||
report.categories = Object.fromEntries([...new Set(governedChecks.map((check) => check.category))].map((category) => {
|
||||
const categoryChecks = governedChecks.filter((check) => check.category === category);
|
||||
return [category, Math.round(categoryChecks.filter((check) => check.status === "pass" || check.suppressed).length / categoryChecks.length * 100)];
|
||||
}));
|
||||
report.ready = report.score >= policy.requiredScore && !governedChecks.some((check) => check.blocking);
|
||||
report.expiredSuppressions = governedChecks.filter((check) => check.expiredSuppression).map((check) => check.id);
|
||||
report.trend = buildTrend(repositoryState.trends.at(-1), report);
|
||||
if (this.store?.appendGitValidatorTrend) await this.store.appendGitValidatorTrend(repository.fullName, report.trend);
|
||||
return report;
|
||||
}
|
||||
|
||||
async setPolicy(repository, policyInput) {
|
||||
const policy = normalizePolicy(policyInput);
|
||||
if (!this.store?.setGitValidatorPolicy) throw new Error("Git Validator policy persistence is unavailable.");
|
||||
await this.store.setGitValidatorPolicy(repository.fullName, policy);
|
||||
return policy;
|
||||
}
|
||||
|
||||
async suppress(repository, input) {
|
||||
const state = this.store?.getGitValidatorState?.(repository.fullName) || { policy: { id: "standard" } };
|
||||
const suppression = validateSuppression(input, normalizePolicy(state.policy));
|
||||
await this.store.addGitValidatorSuppression(repository.fullName, suppression);
|
||||
return suppression;
|
||||
}
|
||||
|
||||
export(report, format) { return exportReport(report, format); }
|
||||
|
||||
async previewRepair(repository, check) {
|
||||
if (!check?.fixAction) throw new Error("This validator check has no repair action.");
|
||||
const root = repository.localPath ? await this.git.ensureRepository(repository.localPath) : null;
|
||||
const fileDefinitions = {
|
||||
"add-gitignore": [".gitignore", RECOMMENDED_GITIGNORE],
|
||||
"add-gitattributes": [".gitattributes", RECOMMENDED_GITATTRIBUTES],
|
||||
"add-editorconfig": [".editorconfig", RECOMMENDED_EDITORCONFIG],
|
||||
};
|
||||
if (fileDefinitions[check.fixAction]) {
|
||||
const [name, content] = fileDefinitions[check.fixAction];
|
||||
if (await fs.stat(path.join(root, name)).catch(() => null)) throw new Error(`${name} already exists; rescan before repairing.`);
|
||||
return { checkId: check.id, action: check.fixAction, files: [name], diff: `diff --git a/${name} b/${name}\nnew file mode 100644\n--- /dev/null\n+++ b/${name}\n${content.split("\n").filter((line, index, lines) => index < lines.length - 1).map((line) => `+${line}`).join("\n")}\n`, remoteMutation: false };
|
||||
}
|
||||
if (check.fixAction === "configure-local-safety") return { checkId: check.id, action: check.fixAction, files: [".git/config"], diff: "+ fetch.prune = true\n+ pull.ff = only\n+ rebase.autoStash = true\n", remoteMutation: false };
|
||||
if (check.fixAction === "align-origin") return { checkId: check.id, action: check.fixAction, files: [".git/config"], diff: `- origin = current\n+ origin = ${repository.preferredCloneUrl || repository.cloneUrl || repository.sshUrl}\n`, remoteMutation: false };
|
||||
if (check.fixAction === "protect-default-branch") return { checkId: check.id, action: check.fixAction, files: [], diff: `Gitea policy change:\n+ protect ${repository.defaultBranch || "main"}\n+ block force pushes\n+ require pull request review\n`, remoteMutation: true };
|
||||
throw new Error("Unsupported Git Validator repair action.");
|
||||
}
|
||||
|
||||
async resolveRepairCheck(repository, candidate) {
|
||||
const checkId = String(candidate?.id || candidate?.checkId || "").trim();
|
||||
if (!checkId) throw new Error("A current Git Validator check ID is required.");
|
||||
const report = await this.scan(repository);
|
||||
const current = report.checks.find((check) => check.id === checkId);
|
||||
if (!current?.fixAction)
|
||||
throw new Error("This finding is resolved, suppressed or no longer repairable. Scan again before repairing.");
|
||||
if (candidate?.fixAction && candidate.fixAction !== current.fixAction)
|
||||
throw new Error("The Git Validator repair request is stale. Scan again before repairing.");
|
||||
return current;
|
||||
}
|
||||
summarize(repository, checks) {
|
||||
const totalWeight = checks.reduce((sum, check) => sum + check.weight, 0);
|
||||
const earned = checks.reduce(
|
||||
(sum, check) =>
|
||||
sum +
|
||||
(check.status === "pass" || check.suppressed
|
||||
? check.weight
|
||||
: check.status === "warning"
|
||||
? check.weight * 0.45
|
||||
: 0),
|
||||
0,
|
||||
);
|
||||
const score = totalWeight ? Math.round((earned / totalWeight) * 100) : 0;
|
||||
return {
|
||||
repository: repository.fullName,
|
||||
checkedAt: new Date().toISOString(),
|
||||
score,
|
||||
grade:
|
||||
score >= 90
|
||||
? "Excellent"
|
||||
: score >= 75
|
||||
? "Good"
|
||||
: score >= 55
|
||||
? "Needs attention"
|
||||
: "High risk",
|
||||
checks,
|
||||
summary: {
|
||||
passed: checks.filter((check) => check.status === "pass").length,
|
||||
warnings: checks.filter((check) => check.status === "warning" && !check.suppressed).length,
|
||||
errors: checks.filter((check) => check.status === "error" && !check.suppressed).length,
|
||||
suppressed: checks.filter((check) => check.suppressed).length,
|
||||
repairable: checks.filter((check) => check.fixAction).length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async repair(repository, check) {
|
||||
if (!check?.fixAction)
|
||||
throw new Error("This validator check has no repair action.");
|
||||
const root = repository.localPath
|
||||
? await this.git.ensureRepository(repository.localPath)
|
||||
: null;
|
||||
if (check.fixAction === "align-origin") {
|
||||
return this.git.setRemoteUrl(
|
||||
root,
|
||||
repository.preferredCloneUrl ||
|
||||
repository.cloneUrl ||
|
||||
repository.sshUrl,
|
||||
);
|
||||
}
|
||||
if (check.fixAction === "configure-local-safety") {
|
||||
for (const [key, value] of [
|
||||
["fetch.prune", "true"],
|
||||
["pull.ff", "only"],
|
||||
["rebase.autoStash", "true"],
|
||||
])
|
||||
await run("git", ["config", "--local", key, value], {
|
||||
cwd: root,
|
||||
timeout: 10_000,
|
||||
});
|
||||
return { configured: true };
|
||||
}
|
||||
if (check.fixAction === "add-gitignore") {
|
||||
const target = path.join(root, ".gitignore");
|
||||
const exists = await fs.stat(target).catch(() => null);
|
||||
if (exists)
|
||||
throw new Error(".gitignore already exists; rescan before repairing.");
|
||||
await fs.writeFile(target, RECOMMENDED_GITIGNORE, {
|
||||
encoding: "utf8",
|
||||
flag: "wx",
|
||||
});
|
||||
return { created: ".gitignore" };
|
||||
}
|
||||
if (["add-gitattributes", "add-editorconfig"].includes(check.fixAction)) {
|
||||
const definition = check.fixAction === "add-gitattributes"
|
||||
? { name: ".gitattributes", content: RECOMMENDED_GITATTRIBUTES }
|
||||
: { name: ".editorconfig", content: RECOMMENDED_EDITORCONFIG };
|
||||
const target = path.join(root, definition.name);
|
||||
if (await fs.stat(target).catch(() => null))
|
||||
throw new Error(`${definition.name} already exists; rescan before repairing.`);
|
||||
await fs.writeFile(target, definition.content, { encoding: "utf8", flag: "wx" });
|
||||
return { created: definition.name };
|
||||
}
|
||||
if (check.fixAction === "protect-default-branch") {
|
||||
return this.gitea.createBranchProtection(
|
||||
repository.owner.login,
|
||||
repository.name,
|
||||
repository.defaultBranch || "main",
|
||||
);
|
||||
}
|
||||
throw new Error("Unsupported Git Validator repair action.");
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
GitValidatorService,
|
||||
RECOMMENDED_GITIGNORE,
|
||||
RECOMMENDED_GITATTRIBUTES,
|
||||
RECOMMENDED_EDITORCONFIG,
|
||||
sameRemote,
|
||||
isSensitiveTrackedPath,
|
||||
};
|
||||
@@ -0,0 +1,623 @@
|
||||
"use strict";
|
||||
|
||||
const {
|
||||
normalizeBaseUrl,
|
||||
assertBranchName,
|
||||
} = require("../shared/validation.cjs");
|
||||
const { redactSecrets } = require("./log-redaction.cjs");
|
||||
|
||||
class GiteaService {
|
||||
constructor(store, diagnostics = null) {
|
||||
this.store = store;
|
||||
this.diagnostics = diagnostics;
|
||||
}
|
||||
|
||||
async request(pathname, options = {}) {
|
||||
const baseUrl = normalizeBaseUrl(
|
||||
options.baseUrl || this.store.data.gitea.baseUrl,
|
||||
);
|
||||
const token = options.token || this.store.getToken();
|
||||
if (!token && options.auth !== false)
|
||||
throw new Error("No Gitea access token is available.");
|
||||
|
||||
const headers = {
|
||||
Accept: options.accept || "application/json",
|
||||
...(token && options.auth !== false
|
||||
? { Authorization: `token ${token}` }
|
||||
: {}),
|
||||
...(options.body ? { "Content-Type": "application/json" } : {}),
|
||||
...(options.headers || {}),
|
||||
};
|
||||
|
||||
const started = Date.now();
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`${baseUrl}/api/v1${pathname}`, {
|
||||
method: options.method || "GET",
|
||||
headers,
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
signal: AbortSignal.timeout(options.timeout || 30_000),
|
||||
redirect: "follow",
|
||||
});
|
||||
} catch (error) {
|
||||
const wrapped = new Error(
|
||||
`Could not reach Gitea: ${redactSecrets(error.message, [token])}`,
|
||||
);
|
||||
wrapped.code = error.code || "GITEA_NETWORK_ERROR";
|
||||
await this.diagnostics?.warning("gitea.request.failed", {
|
||||
method: options.method || "GET",
|
||||
pathname,
|
||||
durationMs: Date.now() - started,
|
||||
code: wrapped.code,
|
||||
message: wrapped.message,
|
||||
});
|
||||
throw wrapped;
|
||||
}
|
||||
|
||||
let text = "";
|
||||
let payload = null;
|
||||
if (options.responseType === "buffer") {
|
||||
payload = Buffer.from(await response.arrayBuffer());
|
||||
} else {
|
||||
text = await response.text();
|
||||
if (text) {
|
||||
if (options.responseType === "text") payload = text;
|
||||
else {
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const detail =
|
||||
typeof payload === "object" &&
|
||||
!Buffer.isBuffer(payload) &&
|
||||
payload?.message
|
||||
? payload.message
|
||||
: text || response.statusText;
|
||||
const error = new Error(
|
||||
`Gitea returned ${response.status}: ${redactSecrets(detail, [token])}`,
|
||||
);
|
||||
error.status = response.status;
|
||||
error.payload = payload;
|
||||
await this.diagnostics?.warning("gitea.request.rejected", {
|
||||
method: options.method || "GET",
|
||||
pathname,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - started,
|
||||
message: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this.diagnostics?.debug("gitea.request.completed", {
|
||||
method: options.method || "GET",
|
||||
pathname,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
return {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
data: payload,
|
||||
};
|
||||
}
|
||||
|
||||
async validateConnection(baseUrl, token) {
|
||||
const normalized = normalizeBaseUrl(baseUrl);
|
||||
const user = await this.request("/user", { baseUrl: normalized, token });
|
||||
const repositories = await this.listRepositories({
|
||||
baseUrl: normalized,
|
||||
token,
|
||||
limitPages: 1,
|
||||
});
|
||||
const version = await this.request("/version", {
|
||||
baseUrl: normalized,
|
||||
token,
|
||||
})
|
||||
.then((result) => result.data?.version || null)
|
||||
.catch(() => null);
|
||||
return {
|
||||
baseUrl: normalized,
|
||||
user: user.data,
|
||||
repositoryCount: repositories.length,
|
||||
version,
|
||||
};
|
||||
}
|
||||
|
||||
async listRepositories(options = {}) {
|
||||
const repositories = [];
|
||||
const pageSize = 50;
|
||||
const limitPages = options.limitPages || 20;
|
||||
for (let page = 1; page <= limitPages; page += 1) {
|
||||
const result = await this.request(
|
||||
`/user/repos?limit=${pageSize}&page=${page}&sort=updated`,
|
||||
options,
|
||||
);
|
||||
const batch = Array.isArray(result.data) ? result.data : [];
|
||||
repositories.push(...batch);
|
||||
if (batch.length < pageSize) break;
|
||||
}
|
||||
return repositories;
|
||||
}
|
||||
|
||||
async getRepository(owner, repo) {
|
||||
return (
|
||||
await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`,
|
||||
)
|
||||
).data;
|
||||
}
|
||||
|
||||
async repositoryFileExists({ owner, repo, filePath, ref }) {
|
||||
const encodedPath = String(filePath || "")
|
||||
.split("/")
|
||||
.map(encodeURIComponent)
|
||||
.join("/");
|
||||
const query = ref ? `?ref=${encodeURIComponent(ref)}` : "";
|
||||
try {
|
||||
await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodedPath}${query}`,
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.status === 404) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getBranch(owner, repo, branch) {
|
||||
return (
|
||||
await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches/${encodeURIComponent(branch)}`,
|
||||
)
|
||||
).data;
|
||||
}
|
||||
|
||||
async getBranchProtection(owner, repo, branch) {
|
||||
const branchInfo = await this.getBranch(owner, repo, branch);
|
||||
let rule = null;
|
||||
try {
|
||||
const result = await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branch_protections`,
|
||||
);
|
||||
const rules = Array.isArray(result.data) ? result.data : [];
|
||||
rule =
|
||||
rules.find(
|
||||
(item) => item.branch_name === branch || item.rule_name === branch,
|
||||
) || null;
|
||||
} catch (error) {
|
||||
if (![403, 404].includes(error.status)) throw error;
|
||||
}
|
||||
return {
|
||||
branch,
|
||||
protected: Boolean(branchInfo?.protected || rule),
|
||||
enablePush: rule?.enable_push ?? null,
|
||||
enableForcePush: rule?.enable_force_push ?? false,
|
||||
requiredApprovals: Number(rule?.required_approvals || 0),
|
||||
requireSignedCommits: Boolean(rule?.require_signed_commits),
|
||||
rule,
|
||||
};
|
||||
}
|
||||
|
||||
async createBranchProtection(owner, repo, branch) {
|
||||
const target = assertBranchName(branch);
|
||||
return (
|
||||
await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branch_protections`,
|
||||
{
|
||||
method: "POST",
|
||||
body: {
|
||||
rule_name: target,
|
||||
branch_name: target,
|
||||
enable_push: false,
|
||||
enable_force_push: false,
|
||||
required_approvals: 0,
|
||||
dismiss_stale_approvals: true,
|
||||
block_on_rejected_reviews: true,
|
||||
block_on_outdated_branch: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
).data;
|
||||
}
|
||||
|
||||
async listDeployKeys(owner, repo) {
|
||||
const result = await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys?limit=100`,
|
||||
);
|
||||
return Array.isArray(result.data) ? result.data : [];
|
||||
}
|
||||
|
||||
async ensureReadOnlyDeployKey({ owner, repo, title, publicKey }) {
|
||||
const key = String(publicKey || "").trim();
|
||||
if (!/^ssh-(ed25519|rsa)\s+[A-Za-z0-9+/=]+(?:\s+.*)?$/.test(key))
|
||||
throw new Error("The server did not return a valid SSH public key.");
|
||||
const keys = await this.listDeployKeys(owner, repo);
|
||||
const keyMaterial = key.split(/\s+/).slice(0, 2).join(" ");
|
||||
const existing = keys.find((item) =>
|
||||
String(item?.key || "").trim().split(/\s+/).slice(0, 2).join(" ") === keyMaterial,
|
||||
);
|
||||
if (existing) {
|
||||
if (existing.read_only !== true) {
|
||||
const error = new Error("The matching Gitea deploy key has write access. Revoke it before ForgeFlow configures a read-only server key.");
|
||||
error.code = "DEPLOY_KEY_NOT_READ_ONLY";
|
||||
throw error;
|
||||
}
|
||||
return { ...existing, created: false };
|
||||
}
|
||||
const result = await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys`,
|
||||
{
|
||||
method: "POST",
|
||||
body: {
|
||||
title: String(title || "ForgeFlow server deploy key").trim().slice(0, 255),
|
||||
key,
|
||||
read_only: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
return { ...result.data, created: true };
|
||||
}
|
||||
|
||||
async createReadOnlyDeployKey({ owner, repo, title, publicKey }) {
|
||||
const key = String(publicKey || "").trim();
|
||||
if (!/^ssh-(ed25519|rsa)\s+[A-Za-z0-9+/=]+(?:\s+.*)?$/.test(key)) throw new Error("A valid SSH public key is required.");
|
||||
const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys`, { method: "POST", body: { title: String(title || "ForgeFlow server deploy key").trim().slice(0, 255), key, read_only: true } });
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async deleteDeployKey(owner, repo, keyId) {
|
||||
if (!Number.isInteger(Number(keyId)) || Number(keyId) <= 0) throw new Error("A valid deploy-key ID is required.");
|
||||
await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys/${Number(keyId)}`, { method: "DELETE" });
|
||||
return { deleted: true, keyId: Number(keyId) };
|
||||
}
|
||||
|
||||
async listPullRequests({ owner, repo, state = "open", limit = 30 } = {}) {
|
||||
const query = new URLSearchParams({
|
||||
state,
|
||||
limit: String(Math.min(Math.max(Number(limit) || 30, 1), 50)),
|
||||
});
|
||||
const result = await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls?${query}`,
|
||||
);
|
||||
return Array.isArray(result.data) ? result.data : [];
|
||||
}
|
||||
|
||||
async createPullRequest({ owner, repo, head, base, title, body = "" }) {
|
||||
const cleanTitle = String(title || "").trim();
|
||||
if (!cleanTitle || cleanTitle.length > 255)
|
||||
throw new Error("Pull request title must contain 1-255 characters.");
|
||||
const cleanBody = String(body || "")
|
||||
.trim()
|
||||
.slice(0, 50_000);
|
||||
const source = assertBranchName(head);
|
||||
const target = assertBranchName(base);
|
||||
if (source === target)
|
||||
throw new Error(
|
||||
"Pull request source and target branches must be different.",
|
||||
);
|
||||
const result = await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`,
|
||||
{
|
||||
method: "POST",
|
||||
body: {
|
||||
head: source,
|
||||
base: target,
|
||||
title: cleanTitle,
|
||||
body: cleanBody,
|
||||
},
|
||||
timeout: 60_000,
|
||||
},
|
||||
);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async getRepositoryFile({ owner, repo, filePath, ref }) {
|
||||
const encodedPath = String(filePath || "")
|
||||
.split("/")
|
||||
.map(encodeURIComponent)
|
||||
.join("/");
|
||||
const query = ref ? `?ref=${encodeURIComponent(ref)}` : "";
|
||||
const payload = (
|
||||
await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodedPath}${query}`,
|
||||
)
|
||||
).data;
|
||||
if (!payload || Array.isArray(payload))
|
||||
throw new Error(`Repository path ${filePath} is not a file.`);
|
||||
if (payload.encoding === "base64" && typeof payload.content === "string") {
|
||||
return {
|
||||
...payload,
|
||||
decoded: Buffer.from(
|
||||
payload.content.replace(/\s/g, ""),
|
||||
"base64",
|
||||
).toString("utf8"),
|
||||
};
|
||||
}
|
||||
if (typeof payload.content === "string")
|
||||
return { ...payload, decoded: payload.content };
|
||||
throw new Error(`Gitea did not return readable content for ${filePath}.`);
|
||||
}
|
||||
|
||||
async getLatestRelease(owner, repo) {
|
||||
try {
|
||||
return (
|
||||
await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/latest`,
|
||||
)
|
||||
).data;
|
||||
} catch (error) {
|
||||
if (error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getReleaseByTag(owner, repo, tag) {
|
||||
try {
|
||||
return (
|
||||
await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`,
|
||||
)
|
||||
).data;
|
||||
} catch (error) {
|
||||
if (error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async downloadAuthenticated(url, { timeout = 180_000 } = {}) {
|
||||
const baseUrl = normalizeBaseUrl(this.store.data.gitea.baseUrl);
|
||||
const base = new URL(baseUrl);
|
||||
const token = this.store.getToken();
|
||||
let target = new URL(url, `${baseUrl}/`);
|
||||
for (let redirects = 0; redirects <= 5; redirects += 1) {
|
||||
const sameOrigin = target.origin === base.origin;
|
||||
if (!sameOrigin && target.protocol !== "https:") {
|
||||
throw new Error(
|
||||
"Refusing an insecure cross-origin update download redirect.",
|
||||
);
|
||||
}
|
||||
const response = await fetch(target, {
|
||||
headers: {
|
||||
...(sameOrigin && token ? { Authorization: `token ${token}` } : {}),
|
||||
Accept: "application/octet-stream",
|
||||
},
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
redirect: "manual",
|
||||
});
|
||||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||
const location = response.headers.get("location");
|
||||
if (!location)
|
||||
throw new Error(
|
||||
"The update download redirect did not contain a destination.",
|
||||
);
|
||||
target = new URL(location, target);
|
||||
continue;
|
||||
}
|
||||
if (!response.ok)
|
||||
throw new Error(`Update download failed with HTTP ${response.status}.`);
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
throw new Error("The update download exceeded the redirect limit.");
|
||||
}
|
||||
|
||||
async downloadReleaseAsset(owner, repo, releaseId, assetId, options = {}) {
|
||||
const numericReleaseId = Number(releaseId);
|
||||
const numericId = Number(assetId);
|
||||
if (!Number.isSafeInteger(numericReleaseId) || numericReleaseId <= 0)
|
||||
throw new Error("Gitea returned an invalid release ID.");
|
||||
if (!Number.isSafeInteger(numericId) || numericId <= 0)
|
||||
throw new Error("Gitea returned an invalid release asset ID.");
|
||||
|
||||
let downloadUrl = String(options.downloadUrl || "").trim();
|
||||
if (!downloadUrl) {
|
||||
const metadataPath = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${numericReleaseId}/assets/${numericId}`;
|
||||
const metadata = (await this.request(metadataPath)).data;
|
||||
if (Number(metadata?.id) !== numericId) {
|
||||
throw new Error("Gitea returned metadata for a different release asset.");
|
||||
}
|
||||
downloadUrl = String(metadata?.browser_download_url || "").trim();
|
||||
}
|
||||
if (!downloadUrl) {
|
||||
throw new Error("Gitea did not provide a release asset download URL.");
|
||||
}
|
||||
const configuredBase = new URL(normalizeBaseUrl(this.store.data.gitea.baseUrl));
|
||||
const publishedUrl = new URL(downloadUrl, configuredBase);
|
||||
const releasePrefix = `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/download/`.toLowerCase();
|
||||
if (publishedUrl.origin !== configuredBase.origin && publishedUrl.protocol === "http:" && publishedUrl.pathname.toLowerCase().startsWith(releasePrefix)) {
|
||||
downloadUrl = new URL(`${publishedUrl.pathname}${publishedUrl.search}`, configuredBase).toString();
|
||||
}
|
||||
return this.downloadAuthenticated(downloadUrl, options);
|
||||
}
|
||||
|
||||
async dispatchWorkflow({ owner, repo, workflowFile, ref, inputs = {} }) {
|
||||
const result = await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/workflows/${encodeURIComponent(workflowFile)}/dispatches`,
|
||||
{ method: "POST", body: { ref, inputs }, timeout: 60_000 },
|
||||
);
|
||||
return {
|
||||
accepted: [200, 201, 204].includes(result.status),
|
||||
status: result.status,
|
||||
};
|
||||
}
|
||||
|
||||
normalizeRun(run) {
|
||||
if (!run || typeof run !== "object") return null;
|
||||
const status = String(run.status || run.conclusion || "").toLowerCase();
|
||||
const conclusion =
|
||||
String(run.conclusion || "").toLowerCase() ||
|
||||
(["success", "failure", "cancelled", "skipped"].includes(status)
|
||||
? status
|
||||
: null);
|
||||
return {
|
||||
id: run.id ?? run.run_id ?? run.task_id ?? null,
|
||||
runNumber: run.run_number ?? run.index ?? run.id ?? null,
|
||||
name: run.name || run.workflow_name || run.workflow_id || "Workflow",
|
||||
event: run.event || null,
|
||||
status,
|
||||
conclusion,
|
||||
headSha: run.head_sha || run.commit_sha || run.commit?.sha || null,
|
||||
headBranch: run.head_branch || run.ref || run.branch || null,
|
||||
workflowPath: run.path || run.workflow_path || run.workflow_file || null,
|
||||
displayTitle: run.display_title || run.title || run.name || null,
|
||||
actor:
|
||||
run.actor?.login || run.trigger_user?.login || run.user?.login || null,
|
||||
createdAt: run.created_at || run.started || run.start_time || null,
|
||||
updatedAt: run.updated_at || run.stopped || run.end_time || null,
|
||||
htmlUrl: run.html_url || run.url || null,
|
||||
raw: run,
|
||||
};
|
||||
}
|
||||
|
||||
async listWorkflowRuns({ owner, repo, sha, branch, limit = 30 } = {}) {
|
||||
const base = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions`;
|
||||
const normalizedLimit = String(Math.min(Math.max(limit, 1), 100));
|
||||
const filtered = new URLSearchParams({ limit: normalizedLimit });
|
||||
if (sha) filtered.set("head_sha", sha);
|
||||
if (branch) filtered.set("branch", branch);
|
||||
const basic = new URLSearchParams({ limit: normalizedLimit });
|
||||
|
||||
const tryEndpoint = async (endpoint) => {
|
||||
try {
|
||||
return await this.request(`${base}/${endpoint}?${filtered}`);
|
||||
} catch (error) {
|
||||
// Action API query support differs across Gitea releases. Retry without
|
||||
// optional filters and apply SHA/branch matching locally.
|
||||
if (
|
||||
![400, 422].includes(error.status) ||
|
||||
String(filtered) === String(basic)
|
||||
)
|
||||
throw error;
|
||||
return this.request(`${base}/${endpoint}?${basic}`);
|
||||
}
|
||||
};
|
||||
|
||||
let result;
|
||||
let source = "runs";
|
||||
try {
|
||||
result = await tryEndpoint("runs");
|
||||
} catch (error) {
|
||||
if (![404, 405].includes(error.status)) throw error;
|
||||
source = "tasks";
|
||||
result = await tryEndpoint("tasks");
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
const items = Array.isArray(data)
|
||||
? data
|
||||
: data?.workflow_runs || data?.runs || data?.tasks || [];
|
||||
return {
|
||||
source,
|
||||
runs: items.map((item) => this.normalizeRun(item)).filter(Boolean),
|
||||
totalCount: data?.total_count ?? items.length,
|
||||
};
|
||||
}
|
||||
|
||||
async findWorkflowRun({
|
||||
owner,
|
||||
repo,
|
||||
sha,
|
||||
branch,
|
||||
workflowFile,
|
||||
dispatchedAt,
|
||||
excludeRunIds = [],
|
||||
}) {
|
||||
const { runs, source } = await this.listWorkflowRuns({
|
||||
owner,
|
||||
repo,
|
||||
sha,
|
||||
branch,
|
||||
limit: 50,
|
||||
});
|
||||
const earliest = dispatchedAt
|
||||
? new Date(dispatchedAt).getTime() - 120_000
|
||||
: 0;
|
||||
const workflowBase = String(workflowFile || "")
|
||||
.split("/")
|
||||
.pop();
|
||||
const excluded = new Set(
|
||||
(excludeRunIds || []).map((value) => String(value)),
|
||||
);
|
||||
const candidates = runs.filter((run) => {
|
||||
if (
|
||||
run.id !== null &&
|
||||
run.id !== undefined &&
|
||||
excluded.has(String(run.id))
|
||||
)
|
||||
return false;
|
||||
if (sha && run.headSha && run.headSha.toLowerCase() !== sha.toLowerCase())
|
||||
return false;
|
||||
if (
|
||||
branch &&
|
||||
run.headBranch &&
|
||||
run.headBranch.replace(/^refs\/heads\//, "") !== branch
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
earliest &&
|
||||
run.createdAt &&
|
||||
new Date(run.createdAt).getTime() < earliest
|
||||
)
|
||||
return false;
|
||||
if (workflowBase && run.workflowPath) {
|
||||
const runBase = String(run.workflowPath).split("/").pop();
|
||||
if (runBase && runBase !== workflowBase) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
candidates.sort(
|
||||
(a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0),
|
||||
);
|
||||
return { source, run: candidates[0] || null };
|
||||
}
|
||||
|
||||
async listWorkflowJobs({ owner, repo, runNumber }) {
|
||||
if (runNumber === null || runNumber === undefined) return [];
|
||||
const result = await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/runs/${encodeURIComponent(runNumber)}/jobs?limit=100`,
|
||||
);
|
||||
const data = result.data;
|
||||
const jobs = Array.isArray(data) ? data : data?.jobs || [];
|
||||
return jobs.map((job) => ({
|
||||
id: job.id,
|
||||
name: job.name || job.job_name || `Job ${job.id}`,
|
||||
status: String(job.status || "").toLowerCase(),
|
||||
conclusion: String(job.conclusion || "").toLowerCase() || null,
|
||||
startedAt: job.started_at || null,
|
||||
completedAt: job.completed_at || null,
|
||||
steps: Array.isArray(job.steps)
|
||||
? job.steps.map((step) => ({
|
||||
name: step.name,
|
||||
status: String(step.status || "").toLowerCase(),
|
||||
conclusion: String(step.conclusion || "").toLowerCase() || null,
|
||||
number: step.number,
|
||||
}))
|
||||
: [],
|
||||
}));
|
||||
}
|
||||
|
||||
async getJobLogs({ owner, repo, jobId }) {
|
||||
if (!jobId) return "";
|
||||
try {
|
||||
const result = await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/jobs/${encodeURIComponent(jobId)}/logs`,
|
||||
{
|
||||
accept: "text/plain, application/octet-stream",
|
||||
responseType: "text",
|
||||
timeout: 60_000,
|
||||
},
|
||||
);
|
||||
return String(result.data || "").slice(-500_000);
|
||||
} catch (error) {
|
||||
if ([404, 410].includes(error.status)) return "";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { GiteaService };
|
||||
@@ -0,0 +1,83 @@
|
||||
"use strict";
|
||||
|
||||
const { deploymentIdentity, deploymentEvidenceHash, deploymentAuthorityKey } = require("./deployment-identity.cjs");
|
||||
|
||||
const BACKUP = /(?:^|[\\/._-])(backup|bak|archive|snapshot|old|previous)(?:[\\/._-]|$)/i;
|
||||
const RELEASE = /(?:^|[\\/])(releases?|versions?)(?:[\\/]|$)/i;
|
||||
const STAGING = /(?:^|[\\/._-])(staging|stage|test|qa|preview)(?:[\\/._-]|$)/i;
|
||||
const TEMPORARY = /(?:^|[\\/._-])(candidate|rollback|ephemeral)(?:[\\/._-]|$)|^GITEA-ACTIONS-TASK-/i;
|
||||
const SYSTEM = /^(?:traefik|nginx-proxy-manager|watchtower|portainer|dockerman|unraid-|cloudflared|redis|postgres|mariadb|mysql)(?:$|[-_.])/i;
|
||||
|
||||
function baseClassification(workload) {
|
||||
const location = `${workload.compose?.workingDir || ""} ${(workload.compose?.configFiles || []).join(" ")}`;
|
||||
const sourceRepository = String(workload.metadata?.sourceRepository || "").trim();
|
||||
const hasGitProvenance = /^(?:git@|ssh:\/\/|https?:\/\/)/i.test(sourceRepository);
|
||||
const decision = workload.reviewDecision;
|
||||
if (["manual-exclude", "exclude-scan-root", "ignore"].includes(decision?.action)) return { type: "manually-excluded", reason: decision.reason || "Persisted manual exclusion", decisionAction: decision.action };
|
||||
if (["mark-historical", "archive-link"].includes(decision?.action)) return { type: "historical-compose", reason: decision.reason || "Reviewed as historical", decisionAction: decision.action };
|
||||
if (decision?.action === "monitor-only") return { type: "monitor-only", reason: decision.reason || "Reviewed for monitoring only", decisionAction: decision.action };
|
||||
if (workload.metadata?.staleLink) return { type: "stale-link", reason: "The linked deployment profile has no matching server workload" };
|
||||
if (BACKUP.test(location)) return { type: "backup", reason: "Path matches backup/archive evidence" };
|
||||
if (RELEASE.test(location)) return { type: "release-folder", reason: "Path is below a release/version directory" };
|
||||
if (STAGING.test(location)) return { type: "staging", reason: "Path or project identifies a staging/test workload" };
|
||||
if (TEMPORARY.test(`${workload.displayName || ""} ${location}`)) return { type: "temporary-runtime", reason: "Runtime identity marks a candidate, rollback or CI workload" };
|
||||
if (SYSTEM.test(workload.displayName || "") && !workload.metadata?.sourceRepository) return { type: "system-container", reason: "Known infrastructure identity without repository provenance" };
|
||||
if (workload.link && workload.runtime?.running) return { type: "active-application", reason: "Linked deployment with running container evidence" };
|
||||
if (workload.link && !workload.runtime?.running) return { type: "stopped-application", reason: "Linked deployment without a running container" };
|
||||
if (!workload.containers?.length && workload.compose?.configFiles?.length) return { type: "historical-compose", reason: "Compose definition exists without container runtime" };
|
||||
if (workload.status === "ambiguous") return { type: "ambiguous", reason: "Multiple candidates have equivalent evidence" };
|
||||
if (!workload.candidates?.length) return { type: "external-container", reason: hasGitProvenance ? "Repository provenance does not match an accessible configured Gitea repository" : "Runtime has no Git repository provenance and remains monitoring-only" };
|
||||
if (!workload.runtime?.running && workload.candidates?.length) return { type: "stopped-application", reason: "Stopped runtime has repository evidence" };
|
||||
return { type: workload.runtime?.running ? "active-application" : "ambiguous", reason: workload.runtime?.running ? "Running application evidence" : "Insufficient authoritative evidence" };
|
||||
}
|
||||
|
||||
function classifyInventory(workloads, profiles = [], decisions = []) {
|
||||
const profileById = new Map(profiles.map((profile) => [profile.id, profile]));
|
||||
const decisionByWorkload = new Map(decisions.map((decision) => [decision.workloadId, decision]));
|
||||
const authorities = new Map();
|
||||
const result = workloads.map((source) => {
|
||||
const workload = structuredClone(source);
|
||||
const profile = profileById.get(workload.link?.profileId) || null;
|
||||
const identity = deploymentIdentity({ workload, profile });
|
||||
const evidence = { candidates: (workload.candidates || []).map((item) => ({ repository: item.repositoryFullName, score: item.score, exact: item.exact === true })), running: workload.runtime?.running === true, health: workload.runtime?.health || null, configFiles: workload.compose?.configFiles || [] };
|
||||
const hash = deploymentEvidenceHash(identity, evidence);
|
||||
const stored = decisionByWorkload.get(workload.workloadId);
|
||||
workload.reviewDecision = stored?.evidenceHash === hash ? stored : null;
|
||||
workload.reviewDecisionStale = Boolean(stored && stored.evidenceHash !== hash);
|
||||
workload.identity = identity;
|
||||
if (workload.reviewDecision?.action === "manual-link" && workload.reviewDecision.repositoryFullName) {
|
||||
workload.identity.repository = String(workload.reviewDecision.repositoryFullName).toLowerCase();
|
||||
}
|
||||
workload.evidenceHash = hash;
|
||||
workload.classification = baseClassification(workload);
|
||||
if (workload.link && ["backup", "release-folder", "staging", "temporary-runtime", "historical-compose", "system-container", "external-container", "manually-excluded"].includes(workload.classification.type)) {
|
||||
workload.shadowedLink = workload.link;
|
||||
workload.link = null;
|
||||
}
|
||||
const key = deploymentAuthorityKey(identity);
|
||||
if (identity.repository && (workload.link || workload.candidates?.length) && !["backup", "release-folder", "staging", "temporary-runtime", "historical-compose", "system-container", "external-container", "manually-excluded"].includes(workload.classification.type)) {
|
||||
const group = authorities.get(key) || [];
|
||||
group.push(workload);
|
||||
authorities.set(key, group);
|
||||
}
|
||||
return workload;
|
||||
});
|
||||
for (const group of authorities.values()) {
|
||||
if (group.length < 2) {
|
||||
group[0].authoritative = true;
|
||||
continue;
|
||||
}
|
||||
const ranked = [...group].sort((a, b) => Number(b.reviewDecision?.action === "select-authoritative") - Number(a.reviewDecision?.action === "select-authoritative") || Number(b.runtime?.running) - Number(a.runtime?.running) || Number(Boolean(b.link)) - Number(Boolean(a.link)) || Number(Boolean(b.metadata?.liveRevision)) - Number(Boolean(a.metadata?.liveRevision)));
|
||||
ranked[0].authoritative = true;
|
||||
for (const duplicate of ranked.slice(1)) {
|
||||
duplicate.authoritative = false;
|
||||
duplicate.classification = { type: "duplicate", reason: `Conflicts with authoritative workload ${ranked[0].workloadId}`, authoritativeWorkloadId: ranked[0].workloadId };
|
||||
duplicate.status = "duplicate";
|
||||
duplicate.shadowedLink = duplicate.link;
|
||||
duplicate.link = null;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = { classifyInventory, classifyWorkload: baseClassification, inventoryPathPatterns: { BACKUP, RELEASE, STAGING, TEMPORARY, SYSTEM } };
|
||||
@@ -0,0 +1,31 @@
|
||||
"use strict";
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const ACTIONS = new Set(["keep-link", "select-authoritative", "mark-historical", "archive-link", "monitor-only", "exclude-scan-root", "manual-link", "ignore", "manual-exclude"]);
|
||||
|
||||
class InventoryReviewService {
|
||||
constructor({ store, audit = null }) { this.store = store; this.audit = audit; }
|
||||
list(serverId) { return this.store.getInventoryReviewDecisions(serverId); }
|
||||
preview({ serverId, workload, action, reason = "", repositoryFullName = null }) {
|
||||
if (!ACTIONS.has(action)) throw Object.assign(new Error("Unsupported inventory review action."), { code: "INVENTORY_REVIEW_ACTION_INVALID" });
|
||||
if (["ignore", "manual-exclude", "exclude-scan-root"].includes(action) && String(reason).trim().length < 5) throw Object.assign(new Error("A meaningful review reason is required."), { code: "INVENTORY_REVIEW_REASON_REQUIRED" });
|
||||
if (action === "manual-link" && !repositoryFullName) throw Object.assign(new Error("Select the repository to link."), { code: "INVENTORY_REVIEW_REPOSITORY_REQUIRED" });
|
||||
const linkedProfile = workload.link?.profileId && workload.link?.repositoryFullName ? { profileId: workload.link.profileId, repositoryFullName: workload.link.repositoryFullName } : null;
|
||||
const configurationChanges = [`Persist review decision ${action} for workload ${workload.workloadId}`];
|
||||
if (action === "archive-link" && linkedProfile) configurationChanges.push(`Archive deployment profile ${linkedProfile.profileId}`);
|
||||
if (action === "manual-link") configurationChanges.push(`Remember ${repositoryFullName} as the reviewed repository match; use Save environment to create the deployment profile`);
|
||||
const mutation = { serverId, workloadId: workload.workloadId, evidenceHash: workload.evidenceHash, action, reason: String(reason).trim(), repositoryFullName, linkedProfile, classification: workload.classification?.type || workload.status, containersUnaffected: true, configurationChanges, recovery: "Restore the configuration snapshot or rescan after evidence changes." };
|
||||
return { ...mutation, id: crypto.createHash("sha256").update(JSON.stringify(mutation)).digest("hex") };
|
||||
}
|
||||
async apply({ plan, expectedPlanId }) {
|
||||
if (!expectedPlanId || plan.id !== expectedPlanId) throw Object.assign(new Error("Inventory review requires the exact preview plan."), { code: expectedPlanId ? "INVENTORY_REVIEW_PLAN_STALE" : "INVENTORY_REVIEW_PLAN_REQUIRED" });
|
||||
const snapshot = await this.store.createRecoverySnapshot?.(`inventory-review:${plan.serverId}:${plan.workloadId}`);
|
||||
if (plan.action === "archive-link" && plan.linkedProfile) await this.store.deleteDeploymentProfile(plan.linkedProfile.repositoryFullName, plan.linkedProfile.profileId);
|
||||
const decision = await this.store.saveInventoryReviewDecision(plan.serverId, { workloadId: plan.workloadId, evidenceHash: plan.evidenceHash, action: plan.action, reason: plan.reason, repositoryFullName: plan.repositoryFullName || null, classification: plan.classification, decidedAt: new Date().toISOString() });
|
||||
await this.audit?.append?.("deployment.inventory-review-applied", { serverId: plan.serverId, workloadId: plan.workloadId, action: plan.action, evidenceHash: plan.evidenceHash, snapshot: snapshot?.filePath || null });
|
||||
return { decision, snapshot };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { InventoryReviewService, INVENTORY_REVIEW_ACTIONS: [...ACTIONS] };
|
||||
@@ -0,0 +1,721 @@
|
||||
"use strict";
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs/promises");
|
||||
const { dialog, shell, app } = require("electron");
|
||||
const { matchRemoteToRepository } = require("../shared/repository-match.cjs");
|
||||
const {
|
||||
cloneDirectoryName,
|
||||
resolveCloneTarget,
|
||||
} = require("../shared/clone-target.cjs");
|
||||
const {
|
||||
createChannelRegistrar,
|
||||
assertTrustedSender,
|
||||
toErrorPayload,
|
||||
} = require("./ipc/channel.cjs");
|
||||
const { registerRepositoryIpc } = require("./ipc/repository-handlers.cjs");
|
||||
const { registerDeploymentIpc } = require("./ipc/deployment-handlers.cjs");
|
||||
const { registerOperationsIpc } = require("./ipc/operations-handlers.cjs");
|
||||
const {
|
||||
createEncryptedBackup,
|
||||
readEncryptedBackup,
|
||||
} = require("./configuration-backup.cjs");
|
||||
const { evaluateDeploymentPolicy } = require("../shared/deployment-policy.cjs");
|
||||
const { normalizeBaseUrl } = require("../shared/validation.cjs");
|
||||
function registerIpc({
|
||||
store,
|
||||
git,
|
||||
gitea,
|
||||
repositories,
|
||||
deployments,
|
||||
unraid,
|
||||
deployKeys,
|
||||
inventoryReviews,
|
||||
ssh,
|
||||
updates,
|
||||
preflight,
|
||||
gitValidator,
|
||||
diagnostics,
|
||||
audit,
|
||||
externalTools,
|
||||
monitor,
|
||||
onPreferencesChanged,
|
||||
}) {
|
||||
const register = createChannelRegistrar(diagnostics);
|
||||
const repositoryMutations = new Map();
|
||||
const withRepositoryPause = async (localPath, action) => {
|
||||
monitor?.pause(localPath);
|
||||
try {
|
||||
return await action();
|
||||
} finally {
|
||||
monitor?.resume(localPath);
|
||||
}
|
||||
};
|
||||
const withRepositoryMutation = async (localPath, action) => {
|
||||
const key = path.resolve(localPath);
|
||||
const previous = repositoryMutations.get(key) || Promise.resolve();
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const canonicalPath = async (value) => {
|
||||
const resolved = path.resolve(String(value || ""));
|
||||
return fs.realpath(resolved).catch(() => resolved);
|
||||
};
|
||||
|
||||
const assertKnownRepositoryPath = async (localPath) => {
|
||||
const candidate = await canonicalPath(localPath);
|
||||
let knownPaths = repositories.getWatchPaths();
|
||||
if (!knownPaths.length && store.data.setupComplete) {
|
||||
await repositories.refresh();
|
||||
knownPaths = repositories.getWatchPaths();
|
||||
}
|
||||
// Watch paths are already canonical, so re-resolving all of them on every
|
||||
// guarded call is only needed when the cheap comparison finds no match.
|
||||
const matched = knownPaths.some((known) => path.resolve(known) === candidate)
|
||||
|| (await Promise.all(knownPaths.map(canonicalPath))).some((known) => known === candidate);
|
||||
if (!matched)
|
||||
throw new Error(
|
||||
"The requested local repository is not linked or discovered by ForgeFlow.",
|
||||
);
|
||||
return candidate;
|
||||
};
|
||||
|
||||
const resolveRepository = async (repositoryPayload) => {
|
||||
const fullName = String(repositoryPayload?.fullName || "").trim();
|
||||
if (!fullName) throw new Error("Repository identity is required.");
|
||||
const current = await repositories.resolveByFullName(fullName);
|
||||
if (!current)
|
||||
throw new Error(
|
||||
"The repository is no longer available through the configured Gitea account.",
|
||||
);
|
||||
return current;
|
||||
};
|
||||
|
||||
const assertProjectRoot = async (rootValue) => {
|
||||
const root = await canonicalPath(rootValue);
|
||||
const stat = await fs.stat(root).catch(() => null);
|
||||
if (!stat?.isDirectory())
|
||||
throw new Error("The selected project root no longer exists.");
|
||||
return root;
|
||||
};
|
||||
|
||||
const cloneRepositoryInto = async (fullName, projectRoot) => {
|
||||
const current = await resolveRepository({ fullName });
|
||||
if (current.localPath)
|
||||
throw new Error("This repository already has a linked local folder.");
|
||||
|
||||
const remoteUrl =
|
||||
current.preferredCloneUrl || current.cloneUrl || current.sshUrl;
|
||||
if (!remoteUrl)
|
||||
throw new Error(
|
||||
"Gitea did not provide a usable clone URL for this repository.",
|
||||
);
|
||||
|
||||
const root = await assertProjectRoot(projectRoot);
|
||||
const { target } = resolveCloneTarget(root, remoteUrl);
|
||||
const status = await git.clone(remoteUrl, target);
|
||||
|
||||
await store.saveMapping(current.fullName, target);
|
||||
const result = await repositories.refresh();
|
||||
monitor?.setPaths(repositories.getWatchPaths());
|
||||
await diagnostics.info(
|
||||
status.reused ? "repository.clone.reused" : "repository.cloned",
|
||||
{
|
||||
fullName: current.fullName,
|
||||
projectRoot: root,
|
||||
target,
|
||||
head: status.head,
|
||||
branch: status.branch?.head,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
target,
|
||||
status,
|
||||
reused: Boolean(status.reused),
|
||||
repositories: result,
|
||||
state: store.getPublicState(),
|
||||
};
|
||||
};
|
||||
|
||||
register("app:bootstrap", async () => ({
|
||||
appVersion: app.getVersion(),
|
||||
platform: process.platform,
|
||||
state: store.getPublicState(),
|
||||
git: await git.isAvailable(),
|
||||
diagnostics: await diagnostics.getStatus(),
|
||||
updateResult: await updates.consumeLatestResult(),
|
||||
}));
|
||||
|
||||
register(
|
||||
"dialog:select-directory",
|
||||
async ({ title = "Select folder", defaultPath }) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title,
|
||||
defaultPath,
|
||||
properties: ["openDirectory", "createDirectory"],
|
||||
});
|
||||
return result.canceled ? null : result.filePaths[0];
|
||||
},
|
||||
);
|
||||
|
||||
register(
|
||||
"dialog:select-key-file",
|
||||
async ({ title = "Select SSH private key", defaultPath }) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title,
|
||||
defaultPath,
|
||||
properties: ["openFile"],
|
||||
});
|
||||
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 }) => {
|
||||
const report = await preflight.runSystem({
|
||||
baseUrl,
|
||||
token,
|
||||
roots: workspaceRoots,
|
||||
});
|
||||
if (!report.summary.ready || !report.giteaValidation)
|
||||
throw new Error(
|
||||
"Setup readiness checks must pass before configuration can be completed.",
|
||||
);
|
||||
const validation = report.giteaValidation;
|
||||
const result = await store.completeSetup({
|
||||
baseUrl: validation.baseUrl,
|
||||
token,
|
||||
user: validation.user,
|
||||
workspaceRoots,
|
||||
});
|
||||
await diagnostics.info("setup.completed", {
|
||||
baseUrl: validation.baseUrl,
|
||||
user: validation.user?.login || null,
|
||||
workspaceRootCount: workspaceRoots?.length || 0,
|
||||
tokenPersistent: result.tokenState.persistent,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
register("settings:update-gitea", async ({ baseUrl, token }) => {
|
||||
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
||||
const currentBaseUrl = store.data.gitea.baseUrl
|
||||
? normalizeBaseUrl(store.data.gitea.baseUrl)
|
||||
: "";
|
||||
const submittedToken = String(token || "").trim();
|
||||
if (!submittedToken && normalizedBaseUrl !== currentBaseUrl) {
|
||||
const error = new Error(
|
||||
"Enter a new Gitea token when changing the server address. Stored tokens are bound to their original origin.",
|
||||
);
|
||||
error.code = "GITEA_TOKEN_ORIGIN_CHANGED";
|
||||
throw error;
|
||||
}
|
||||
const effectiveToken = submittedToken || store.getToken();
|
||||
const validation = await gitea.validateConnection(
|
||||
normalizedBaseUrl,
|
||||
effectiveToken,
|
||||
);
|
||||
const tokenState = await store.updateGitea({
|
||||
baseUrl: validation.baseUrl,
|
||||
token,
|
||||
user: validation.user,
|
||||
});
|
||||
await diagnostics.info("settings.gitea.updated", {
|
||||
baseUrl: validation.baseUrl,
|
||||
user: validation.user?.login || null,
|
||||
tokenPersistent: tokenState.persistent,
|
||||
tokenPreserved: tokenState.preserved,
|
||||
});
|
||||
return { validation, tokenState, state: store.getPublicState() };
|
||||
});
|
||||
|
||||
register("settings:set-roots", async ({ roots }) => {
|
||||
store.data.workspaceRoots = [...new Set((roots || []).filter(Boolean))];
|
||||
await store.save();
|
||||
await diagnostics.info("settings.workspace-roots.updated", {
|
||||
rootCount: store.data.workspaceRoots.length,
|
||||
roots: store.data.workspaceRoots,
|
||||
});
|
||||
return store.getPublicState();
|
||||
});
|
||||
|
||||
register("settings:set-appearance", async ({ appearance }) => {
|
||||
if (!["dark", "light", "system"].includes(appearance))
|
||||
throw new Error("Unsupported appearance setting.");
|
||||
store.data.appearance = appearance;
|
||||
await store.save();
|
||||
return store.getPublicState();
|
||||
});
|
||||
|
||||
register("settings:set-preferences", async ({ preferences }) => {
|
||||
const state = await store.setPreferences(preferences);
|
||||
monitor?.restart();
|
||||
onPreferencesChanged?.();
|
||||
await diagnostics.info("settings.preferences.updated", {
|
||||
preferences: state.preferences,
|
||||
});
|
||||
return state;
|
||||
});
|
||||
|
||||
register("settings:export-backup", async ({ passphrase }) => {
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: "Export encrypted ForgeFlow configuration",
|
||||
defaultPath: path.join(
|
||||
app.getPath("documents"),
|
||||
`ForgeFlow-Configuration-${new Date().toISOString().slice(0, 10)}.ffbackup`,
|
||||
),
|
||||
filters: [
|
||||
{ name: "ForgeFlow encrypted backup", extensions: ["ffbackup"] },
|
||||
],
|
||||
});
|
||||
if (result.canceled || !result.filePath) return null;
|
||||
const destinationPath = result.filePath.toLowerCase().endsWith(".ffbackup")
|
||||
? result.filePath
|
||||
: `${result.filePath}.ffbackup`;
|
||||
await fs
|
||||
.writeFile(
|
||||
destinationPath,
|
||||
createEncryptedBackup(store.data, passphrase),
|
||||
{ mode: 0o600, flag: "wx" },
|
||||
)
|
||||
.catch(async (error) => {
|
||||
if (error.code !== "EEXIST") throw error;
|
||||
await fs.writeFile(
|
||||
destinationPath,
|
||||
createEncryptedBackup(store.data, passphrase),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
});
|
||||
await audit.append("configuration.backup.exported", {
|
||||
fileName: path.basename(destinationPath),
|
||||
});
|
||||
return { filePath: destinationPath };
|
||||
});
|
||||
|
||||
register("settings:import-backup", async ({ passphrase }) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: "Import encrypted ForgeFlow configuration",
|
||||
properties: ["openFile"],
|
||||
filters: [
|
||||
{ name: "ForgeFlow encrypted backup", extensions: ["ffbackup"] },
|
||||
],
|
||||
});
|
||||
if (result.canceled || !result.filePaths[0]) return null;
|
||||
const payload = readEncryptedBackup(
|
||||
await fs.readFile(result.filePaths[0], "utf8"),
|
||||
passphrase,
|
||||
);
|
||||
const state = await store.restoreConfiguration(payload.configuration);
|
||||
monitor?.restart();
|
||||
await audit.append("configuration.backup.imported", {
|
||||
fileName: path.basename(result.filePaths[0]),
|
||||
exportedAt: payload.exportedAt,
|
||||
});
|
||||
return { state, exportedAt: payload.exportedAt };
|
||||
});
|
||||
|
||||
register("audit:list", ({ limit = 250 }) => audit.list(limit));
|
||||
register("audit:export", async ({ format = "json" }) => {
|
||||
if (!["json", "csv"].includes(format))
|
||||
throw new Error("Unsupported audit export format.");
|
||||
const extension = format === "csv" ? "csv" : "json";
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: "Export ForgeFlow audit log",
|
||||
defaultPath: path.join(
|
||||
app.getPath("documents"),
|
||||
`ForgeFlow-Audit-${new Date().toISOString().slice(0, 10)}.${extension}`,
|
||||
),
|
||||
filters: [
|
||||
{ name: `${extension.toUpperCase()} file`, extensions: [extension] },
|
||||
],
|
||||
});
|
||||
if (result.canceled || !result.filePath) return null;
|
||||
return audit.exportTo(
|
||||
result.filePath.toLowerCase().endsWith(`.${extension}`)
|
||||
? result.filePath
|
||||
: `${result.filePath}.${extension}`,
|
||||
format,
|
||||
);
|
||||
});
|
||||
|
||||
register("updates:preferences", ({ updates: next }) =>
|
||||
store.setUpdatePreferences(next),
|
||||
);
|
||||
register("updates:check", () => updates.check());
|
||||
register("updates:download", () => updates.download());
|
||||
register("updates:apply", async () => {
|
||||
const result = await updates.apply();
|
||||
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;
|
||||
});
|
||||
|
||||
register(
|
||||
"server:save",
|
||||
async ({ server, password = "", passphrase = "" }) => {
|
||||
await ssh.validateServerConfiguration(server, { password, passphrase });
|
||||
const saved = await store.saveServer(server, { password, passphrase });
|
||||
await diagnostics.info("server.saved", {
|
||||
serverId: saved.id,
|
||||
name: saved.name,
|
||||
host: saved.host,
|
||||
port: saved.port,
|
||||
username: saved.username,
|
||||
authType: saved.authType,
|
||||
basePath: saved.basePath,
|
||||
});
|
||||
return { server: saved, state: store.getPublicState() };
|
||||
},
|
||||
);
|
||||
register("server:delete", async ({ serverId }) => {
|
||||
await store.deleteServer(serverId);
|
||||
await diagnostics.info("server.deleted", { serverId });
|
||||
return store.getPublicState();
|
||||
});
|
||||
register("server:test", async ({ serverId, expectedFingerprint = "" }) => {
|
||||
const server = store.getServer(serverId);
|
||||
if (!server) throw new Error("The configured server no longer exists.");
|
||||
const expected = String(expectedFingerprint || "").trim();
|
||||
if (!server.hostFingerprint && !expected) {
|
||||
const probe = await ssh.probeHostFingerprint(serverId);
|
||||
return { ...probe, connected: false, needsTrust: true, state: store.getPublicState() };
|
||||
}
|
||||
if (!server.hostFingerprint && !/^SHA256:[A-Za-z0-9+/]{40,44}$/.test(expected))
|
||||
throw new Error("Confirm the exact SSH host fingerprint returned by ForgeFlow.");
|
||||
const result = await ssh.test(serverId, {
|
||||
expectedFingerprint: server.hostFingerprint ? null : expected,
|
||||
});
|
||||
if (!server.hostFingerprint) {
|
||||
if (result.fingerprint !== expected) {
|
||||
const error = new Error("The SSH host identity changed between preview and confirmation.");
|
||||
error.code = "SSH_HOST_KEY_MISMATCH";
|
||||
throw error;
|
||||
}
|
||||
await store.saveServer(
|
||||
{ ...server, hostFingerprint: result.fingerprint },
|
||||
{},
|
||||
);
|
||||
result.trusted = true;
|
||||
}
|
||||
return { ...result, state: store.getPublicState() };
|
||||
});
|
||||
register("server:inspect-project", async ({ repository, profileId }) =>
|
||||
unraid.inspect({
|
||||
repository: await resolveRepository(repository),
|
||||
profileId,
|
||||
}),
|
||||
);
|
||||
register(
|
||||
"server:discover-existing",
|
||||
async ({ repository, serverId, remoteFolder }) =>
|
||||
unraid.discoverExisting({
|
||||
repository: await resolveRepository(repository),
|
||||
serverId,
|
||||
remoteFolder,
|
||||
}),
|
||||
);
|
||||
|
||||
registerRepositoryIpc({
|
||||
register, repositories, store, git, gitea, monitor, diagnostics, audit,
|
||||
externalTools, gitValidator, withRepositoryMutation, assertKnownRepositoryPath,
|
||||
resolveRepository, cloneRepositoryInto, cloneDirectoryName,
|
||||
matchRemoteToRepository, shell, dialog,
|
||||
});
|
||||
|
||||
register("troubleshooter:scan", async ({ fullName = null }) => {
|
||||
const currentRepositories = await repositories.refresh();
|
||||
const candidates = fullName
|
||||
? currentRepositories.filter((item) => item.fullName === fullName)
|
||||
: currentRepositories;
|
||||
const issues = [];
|
||||
for (const repository of candidates) {
|
||||
if (!repository.localPath) {
|
||||
issues.push({
|
||||
id: `${repository.fullName}:not-linked`,
|
||||
repository: repository.fullName,
|
||||
severity: "warning",
|
||||
title: "Local repository is not linked",
|
||||
detail:
|
||||
"Link or clone the repository before running local Git repairs.",
|
||||
repairable: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const interrupted = await git.detectInterruptedOperation(
|
||||
repository.localPath,
|
||||
);
|
||||
if (interrupted)
|
||||
issues.push({
|
||||
id: `${repository.fullName}:abort-operation`,
|
||||
repository: repository.fullName,
|
||||
localPath: repository.localPath,
|
||||
severity: "error",
|
||||
title: `Interrupted Git ${interrupted}`,
|
||||
detail: `A ${interrupted} is still active and blocks normal Git operations. Aborting it can discard conflict-resolution work and therefore always requires separate confirmation.`,
|
||||
repairable: true,
|
||||
action: "abort-operation",
|
||||
safe: false,
|
||||
});
|
||||
const report = await git.reconcile(repository.localPath);
|
||||
for (const lock of report.lockReport?.locks || []) {
|
||||
const stale = lock.ageMs >= 10_000;
|
||||
const processProbeSafe =
|
||||
report.lockReport.processes?.available === true &&
|
||||
!report.lockReport.processes.active?.length;
|
||||
issues.push({
|
||||
id: `${repository.fullName}:locks:${lock.name}`,
|
||||
repository: repository.fullName,
|
||||
localPath: repository.localPath,
|
||||
severity: stale ? "error" : "warning",
|
||||
title: stale
|
||||
? "Stale Git lock detected"
|
||||
: "Recent Git lock detected",
|
||||
detail: lock.name,
|
||||
repairable: stale,
|
||||
action: "repair-locks",
|
||||
safe: stale && processProbeSafe,
|
||||
});
|
||||
}
|
||||
const branch = report.status?.branch || {};
|
||||
if (branch.behind > 0 && branch.ahead === 0 && report.status.clean)
|
||||
issues.push({
|
||||
id: `${repository.fullName}:fast-forward`,
|
||||
repository: repository.fullName,
|
||||
localPath: repository.localPath,
|
||||
severity: "warning",
|
||||
title: "Local branch is behind Gitea",
|
||||
detail: `${branch.behind} commit(s) can be fast-forwarded safely.`,
|
||||
repairable: true,
|
||||
action: "fast-forward",
|
||||
safe: true,
|
||||
});
|
||||
if (branch.ahead > 0 && branch.behind === 0)
|
||||
issues.push({
|
||||
id: `${repository.fullName}:push`,
|
||||
repository: repository.fullName,
|
||||
localPath: repository.localPath,
|
||||
severity: "warning",
|
||||
title: "Local commits are not published",
|
||||
detail: `${branch.ahead} commit(s) can be pushed to Gitea after explicit confirmation.`,
|
||||
repairable: true,
|
||||
action: "push",
|
||||
safe: false,
|
||||
});
|
||||
if (branch.ahead > 0 && branch.behind > 0)
|
||||
issues.push({
|
||||
id: `${repository.fullName}:diverged`,
|
||||
repository: repository.fullName,
|
||||
localPath: repository.localPath,
|
||||
severity: "error",
|
||||
title: "Local and Gitea branches have diverged",
|
||||
detail: `${branch.ahead} ahead and ${branch.behind} behind. ForgeFlow can preserve the local HEAD on a safety branch and use the upstream version.`,
|
||||
repairable: report.status.clean,
|
||||
action: "backup-reset",
|
||||
safe: false,
|
||||
});
|
||||
} catch (error) {
|
||||
issues.push({
|
||||
id: `${repository.fullName}:git-error`,
|
||||
repository: repository.fullName,
|
||||
severity: "error",
|
||||
title: "Git health scan failed",
|
||||
detail: error.message,
|
||||
repairable: false,
|
||||
});
|
||||
}
|
||||
for (const profile of repository.deploymentProfiles || []) {
|
||||
if (profile.provider !== "ssh-unraid") continue;
|
||||
try {
|
||||
const inspection = await unraid.inspect({
|
||||
repository,
|
||||
profileId: profile.id,
|
||||
});
|
||||
if (!inspection.exists)
|
||||
issues.push({
|
||||
id: `${profile.id}:server-folder`,
|
||||
repository: repository.fullName,
|
||||
profileId: profile.id,
|
||||
severity: "error",
|
||||
title: "Deployment folder is missing on the server",
|
||||
detail: inspection.remotePath,
|
||||
repairable: false,
|
||||
});
|
||||
if (inspection.trackedChanges?.length)
|
||||
issues.push({
|
||||
id: `${profile.id}:tracked-server-changes`,
|
||||
repository: repository.fullName,
|
||||
profileId: profile.id,
|
||||
severity: "error",
|
||||
title: "Tracked server-side changes detected",
|
||||
detail: `${inspection.trackedChanges.length} tracked change(s) must be reviewed before deployment.`,
|
||||
repairable: false,
|
||||
});
|
||||
if (inspection.dockerContextExclusionsMissing?.length)
|
||||
issues.push({
|
||||
id: `${profile.id}:dockerignore`,
|
||||
repository: repository.fullName,
|
||||
profileId: profile.id,
|
||||
severity: "warning",
|
||||
title: "Runtime paths are missing from .dockerignore",
|
||||
detail: inspection.dockerContextExclusionsMissing.join(", "),
|
||||
repairable: false,
|
||||
});
|
||||
} catch (error) {
|
||||
issues.push({
|
||||
id: `${profile.id}:server-error`,
|
||||
repository: repository.fullName,
|
||||
profileId: profile.id,
|
||||
severity: "error",
|
||||
title: "Server inspection failed",
|
||||
detail: error.message,
|
||||
repairable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const summary = {
|
||||
total: issues.length,
|
||||
errors: issues.filter((item) => item.severity === "error").length,
|
||||
warnings: issues.filter((item) => item.severity === "warning").length,
|
||||
repairable: issues.filter((item) => item.repairable).length,
|
||||
};
|
||||
return { checkedAt: new Date().toISOString(), issues, summary };
|
||||
});
|
||||
|
||||
register("troubleshooter:repair", async ({ issue }) => {
|
||||
if (!issue || !issue.action)
|
||||
throw new Error("No repair action was supplied.");
|
||||
const localPath = issue.localPath
|
||||
? await assertKnownRepositoryPath(issue.localPath)
|
||||
: null;
|
||||
let result;
|
||||
if (issue.action === "abort-operation")
|
||||
result = await withRepositoryMutation(localPath, () =>
|
||||
git.abortInterruptedOperation(localPath),
|
||||
);
|
||||
else if (issue.action === "repair-locks")
|
||||
result = await withRepositoryMutation(localPath, () =>
|
||||
git.repairStaleGitLocks(localPath, { minimumAgeMs: 2_000 }),
|
||||
);
|
||||
else if (
|
||||
["fast-forward", "push", "backup-reset", "fetch"].includes(issue.action)
|
||||
)
|
||||
result = await withRepositoryMutation(localPath, () =>
|
||||
git.repairSync(localPath, issue.action),
|
||||
);
|
||||
else throw new Error("Unsupported troubleshooter repair action.");
|
||||
await diagnostics.info("troubleshooter.repair.completed", {
|
||||
repository: issue.repository,
|
||||
action: issue.action,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
register("troubleshooter:auto-repair", async ({ issues }) => {
|
||||
const results = [];
|
||||
for (const issue of (issues || []).filter(
|
||||
(item) => item.repairable && item.safe,
|
||||
)) {
|
||||
try {
|
||||
const localPath = issue.localPath
|
||||
? await assertKnownRepositoryPath(issue.localPath)
|
||||
: null;
|
||||
let result;
|
||||
if (issue.action === "repair-locks")
|
||||
result = await withRepositoryMutation(localPath, () =>
|
||||
git.repairStaleGitLocks(localPath, { minimumAgeMs: 10_000 }),
|
||||
);
|
||||
else if (["fast-forward", "fetch"].includes(issue.action))
|
||||
result = await withRepositoryMutation(localPath, () =>
|
||||
git.repairSync(localPath, issue.action),
|
||||
);
|
||||
else continue;
|
||||
results.push({ id: issue.id, ok: true, result });
|
||||
} catch (error) {
|
||||
results.push({ id: issue.id, ok: false, error: error.message });
|
||||
}
|
||||
}
|
||||
await diagnostics.info("troubleshooter.auto-repair.completed", {
|
||||
attempted: results.length,
|
||||
succeeded: results.filter((item) => item.ok).length,
|
||||
});
|
||||
return results;
|
||||
});
|
||||
|
||||
registerDeploymentIpc({
|
||||
register, store, resolveRepository, unraid, deployments, evaluateDeploymentPolicy,
|
||||
audit, deployKeys, repositories, inventoryReviews, diagnostics, git, gitea, ssh,
|
||||
preflight,
|
||||
});
|
||||
registerOperationsIpc({
|
||||
register, store, unraid, deployments, diagnostics, shell, dialog, path, app,
|
||||
repositories, preflight, monitor,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerIpc,
|
||||
cloneDirectoryName,
|
||||
assertTrustedSender,
|
||||
toErrorPayload,
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
"use strict";
|
||||
|
||||
const path = require("node:path");
|
||||
const { fileURLToPath } = require("node:url");
|
||||
const { ipcMain } = require("electron");
|
||||
|
||||
const TRUSTED_RENDERER_PATH = path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"renderer",
|
||||
"index.html",
|
||||
);
|
||||
|
||||
function toErrorPayload(error) {
|
||||
return {
|
||||
message: error?.message || "Unknown error",
|
||||
code: error?.code || null,
|
||||
status: error?.status || null,
|
||||
recoverable: Boolean(error?.recoverable),
|
||||
commitSha: error?.commitSha || null,
|
||||
};
|
||||
}
|
||||
|
||||
function assertTrustedSender(event) {
|
||||
const url = event?.senderFrame?.url || event?.sender?.getURL?.() || "";
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "file:") throw new Error("not a file URL");
|
||||
const senderPath = path.resolve(fileURLToPath(parsed));
|
||||
const normalize = (value) =>
|
||||
process.platform === "win32" ? value.toLowerCase() : value;
|
||||
if (normalize(senderPath) !== normalize(TRUSTED_RENDERER_PATH))
|
||||
throw new Error("unexpected renderer file");
|
||||
} catch {
|
||||
throw new Error("Rejected IPC request from an untrusted renderer origin.");
|
||||
}
|
||||
}
|
||||
|
||||
// Built per registerIpc() call so the diagnostics sink is an argument instead of
|
||||
// module-level mutable state that every handler silently depends on.
|
||||
function createChannelRegistrar(diagnostics) {
|
||||
return function register(channel, handler) {
|
||||
ipcMain.handle(channel, async (event, payload) => {
|
||||
const started = Date.now();
|
||||
try {
|
||||
assertTrustedSender(event);
|
||||
const data = await handler(payload || {}, event);
|
||||
await diagnostics?.debug("ipc.completed", {
|
||||
channel,
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
return { ok: true, data };
|
||||
} catch (error) {
|
||||
await diagnostics?.error("ipc.failed", {
|
||||
channel,
|
||||
durationMs: Date.now() - started,
|
||||
error: {
|
||||
name: error?.name,
|
||||
message: error?.message,
|
||||
code: error?.code,
|
||||
status: error?.status,
|
||||
stack: error?.stack,
|
||||
},
|
||||
});
|
||||
return { ok: false, error: toErrorPayload(error) };
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createChannelRegistrar,
|
||||
assertTrustedSender,
|
||||
toErrorPayload,
|
||||
TRUSTED_RENDERER_PATH,
|
||||
};
|
||||
@@ -0,0 +1,284 @@
|
||||
"use strict";
|
||||
|
||||
function registerDeploymentIpc({
|
||||
register, store, resolveRepository, unraid, deployments, evaluateDeploymentPolicy,
|
||||
audit, deployKeys, repositories, inventoryReviews, diagnostics, git, gitea, ssh,
|
||||
preflight,
|
||||
}) {
|
||||
register("deployment:save-profile", async ({ fullName, profile }) => {
|
||||
const saved = await store.saveDeploymentProfile(fullName, profile);
|
||||
await diagnostics.info("deployment.profile.saved", {
|
||||
repository: fullName,
|
||||
profile: saved,
|
||||
});
|
||||
return { profile: saved, state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:delete-profile", async ({ fullName, profileId }) => {
|
||||
const profiles = await store.deleteDeploymentProfile(fullName, profileId);
|
||||
await diagnostics.info("deployment.profile.deleted", {
|
||||
repository: fullName,
|
||||
profileId,
|
||||
});
|
||||
return { profiles, state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:preflight", async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const profile = store.getDeploymentProfile(current.fullName, profileId);
|
||||
if (profile?.provider === "ssh-unraid")
|
||||
return unraid.preflight({ repository: current, profileId });
|
||||
return preflight.runDeployment({ repository: current, profileId });
|
||||
});
|
||||
register("deployment:repair-write-access", async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const profile = store.getDeploymentProfile(current.fullName, profileId);
|
||||
if (profile?.provider !== "ssh-unraid")
|
||||
throw new Error("Write-access repair is available only for SSH / Unraid deployment profiles.");
|
||||
const result = await unraid.repairWriteAccess({ repository: current, profileId });
|
||||
await audit.append("deployment.write-access.repaired", {
|
||||
repository: current.fullName,
|
||||
profileId,
|
||||
changed: result.changed,
|
||||
remotePath: result.after?.remotePath || result.before?.remotePath || null,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
register(
|
||||
"deployment:dispatch",
|
||||
async ({
|
||||
repository,
|
||||
profileId,
|
||||
sha,
|
||||
note = "",
|
||||
override = false,
|
||||
overrideReason = "",
|
||||
}) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const profile = store.getDeploymentProfile(current.fullName, profileId);
|
||||
const policy = evaluateDeploymentPolicy(profile, {
|
||||
note,
|
||||
override,
|
||||
reason: overrideReason,
|
||||
});
|
||||
await audit.append("deployment.requested", {
|
||||
repository: current.fullName,
|
||||
profileId,
|
||||
sha,
|
||||
note: policy.note,
|
||||
overridden: policy.overridden,
|
||||
overrideReason: policy.reason,
|
||||
});
|
||||
const operation =
|
||||
profile?.provider === "ssh-unraid"
|
||||
? await unraid.deploy({ repository: current, profileId, sha })
|
||||
: await deployments.deploy({ repository: current, profileId, sha });
|
||||
if (operation?.id)
|
||||
await store.addOperation({
|
||||
...operation,
|
||||
releaseNote: policy.note,
|
||||
policyOverride: policy.overridden
|
||||
? { reason: policy.reason, violations: policy.violations }
|
||||
: null,
|
||||
});
|
||||
return operation;
|
||||
},
|
||||
);
|
||||
register(
|
||||
"deployment:rollback",
|
||||
async ({ repository, profileId, targetSha }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const profile = store.getDeploymentProfile(current.fullName, profileId);
|
||||
if (profile?.provider === "ssh-unraid")
|
||||
return unraid.rollback({ repository: current, profileId, targetSha });
|
||||
return deployments.rollback({
|
||||
repository: current,
|
||||
profileId,
|
||||
targetSha,
|
||||
});
|
||||
},
|
||||
);
|
||||
register("deployment:link-server-workload", async ({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const result = await unraid.linkServerWorkload({
|
||||
repository: current,
|
||||
serverId,
|
||||
workloadId,
|
||||
deploymentMode,
|
||||
remoteFolder,
|
||||
});
|
||||
return { ...result, state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:configure-server-git-access", async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const result = await unraid.configureServerGitAccess({ repository: current, profileId });
|
||||
await audit.append("deployment.server-git-access-configured", {
|
||||
repository: current.fullName,
|
||||
profileId,
|
||||
keyFingerprint: result.keyFingerprint,
|
||||
hostFingerprint: result.hostFingerprint,
|
||||
});
|
||||
return { ...result, state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:verify-server-git-profile", async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const result = await unraid.verifyServerGitProfile({ repository: current, profileId });
|
||||
await audit.append("deployment.server-git-access-verified", {
|
||||
repository: current.fullName,
|
||||
profileId,
|
||||
readiness: result.readiness,
|
||||
ready: result.ready,
|
||||
checkedAt: result.checkedAt,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
register("deployment:deploy-key-inventory", async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
return deployKeys.inventory({ repository: current, profileId });
|
||||
});
|
||||
register("deployment:plan-deploy-key-rotation", async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
return deployKeys.planRotation({ repository: current, profileId });
|
||||
});
|
||||
register("deployment:apply-deploy-key-rotation", async ({ repository, profileId, planId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const result = await deployKeys.rotate({ repository: current, profileId, expectedPlanId: planId });
|
||||
return { ...result, state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:plan-deploy-key-revocation", async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
return deployKeys.planRevocation({ repository: current, profileId });
|
||||
});
|
||||
register("deployment:apply-deploy-key-revocation", async ({ repository, profileId, planId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const result = await deployKeys.revoke({ repository: current, profileId, expectedPlanId: planId });
|
||||
return { ...result, state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:restore-deploy-key", async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const result = await deployKeys.restore({ repository: current, profileId });
|
||||
return { ...result, state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:discover-server-workloads", async () => {
|
||||
const repositoryList = await repositories.refresh();
|
||||
const remoteRepositories = repositoryList.filter(
|
||||
(repository) => repository.owner?.login !== "local",
|
||||
);
|
||||
const results = [];
|
||||
for (const server of store.data.servers || []) {
|
||||
try {
|
||||
results.push(
|
||||
await unraid.discoverServerWorkloads(server.id, remoteRepositories),
|
||||
);
|
||||
} catch (error) {
|
||||
results.push({
|
||||
serverId: server.id,
|
||||
serverName: server.name,
|
||||
detected: 0,
|
||||
adopted: 0,
|
||||
verified: 0,
|
||||
linked: 0,
|
||||
unmatched: 0,
|
||||
needsReview: 0,
|
||||
capabilities: {},
|
||||
warnings: [],
|
||||
workloads: [],
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
});
|
||||
register("deployment:plan-server-reconciliation", async ({ serverId }) => {
|
||||
const repositoryList = await repositories.refresh();
|
||||
const remoteRepositories = repositoryList.filter((repository) => repository.owner?.login !== "local");
|
||||
const result = await unraid.planServerInventoryReconciliation(serverId, remoteRepositories, { autoLink: true });
|
||||
await audit.append("deployment.server-reconciliation-planned", {
|
||||
serverId,
|
||||
planId: result.plan.id,
|
||||
summary: result.plan.summary,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
register("deployment:apply-server-reconciliation", async ({ serverId, planId }) => {
|
||||
const repositoryList = await repositories.refresh();
|
||||
const remoteRepositories = repositoryList.filter((repository) => repository.owner?.login !== "local");
|
||||
const result = await unraid.reconcileServerInventory(serverId, remoteRepositories, { autoLink: true, expectedPlanId: planId });
|
||||
await audit.append("deployment.server-reconciliation-applied", {
|
||||
serverId,
|
||||
planId,
|
||||
adopted: result.adopted,
|
||||
refreshed: result.refreshed,
|
||||
retired: result.retired,
|
||||
recoverySnapshot: result.recoverySnapshot?.filePath || null,
|
||||
});
|
||||
return { ...result, state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:plan-inventory-review", async ({ serverId, workloadId, action, reason = "", repositoryFullName = null }) => {
|
||||
const repositoryList = await repositories.refresh();
|
||||
const inventory = await unraid.scanServerInventory(serverId, repositoryList.filter((item) => item.owner?.login !== "local"));
|
||||
const workload = inventory.workloads.find((item) => item.workloadId === workloadId);
|
||||
if (!workload) throw Object.assign(new Error("The workload changed or disappeared. Rescan before reviewing it."), { code: "INVENTORY_REVIEW_WORKLOAD_STALE" });
|
||||
return inventoryReviews.preview({ serverId, workload, action, reason, repositoryFullName });
|
||||
});
|
||||
register("deployment:apply-inventory-review", async ({ serverId, workloadId, action, reason = "", repositoryFullName = null, planId }) => {
|
||||
const repositoryList = await repositories.refresh();
|
||||
const inventory = await unraid.scanServerInventory(serverId, repositoryList.filter((item) => item.owner?.login !== "local"));
|
||||
const workload = inventory.workloads.find((item) => item.workloadId === workloadId);
|
||||
if (!workload) throw Object.assign(new Error("The workload changed or disappeared. Rescan before applying the review."), { code: "INVENTORY_REVIEW_WORKLOAD_STALE" });
|
||||
const plan = inventoryReviews.preview({ serverId, workload, action, reason, repositoryFullName });
|
||||
const result = await inventoryReviews.apply({ plan, expectedPlanId: planId });
|
||||
return { ...result, inventory: await unraid.scanServerInventory(serverId, repositoryList.filter((item) => item.owner?.login !== "local")), state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:profile-state", async ({ fullName, profileId }) => {
|
||||
const profile = store.getDeploymentProfile(fullName, profileId);
|
||||
if (profile?.provider === "ssh-unraid") {
|
||||
let giteaSha = null;
|
||||
try {
|
||||
const [owner, repo] = String(fullName || "").split("/");
|
||||
const branch = await gitea.getBranch(owner, repo, profile.branch);
|
||||
giteaSha = branch?.commit?.id || branch?.commit?.sha || null;
|
||||
} catch {}
|
||||
return unraid.refreshProfileState(fullName, profileId, giteaSha);
|
||||
}
|
||||
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 [owner, repo] = String(fullName || "").split("/");
|
||||
const branch = await gitea.getBranch(owner, repo, profile.branch);
|
||||
const giteaSha =
|
||||
branch?.commit?.id ||
|
||||
branch?.commit?.sha ||
|
||||
branch?.commit?.commit?.id ||
|
||||
null;
|
||||
const state = await unraid.refreshProfileState(
|
||||
fullName,
|
||||
profileId,
|
||||
giteaSha,
|
||||
);
|
||||
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: await unraid.reconcileRecordedOperations(profileId, state),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerDeploymentIpc };
|
||||
@@ -0,0 +1,100 @@
|
||||
"use strict";
|
||||
|
||||
function registerOperationsIpc({
|
||||
register, store, unraid, deployments, diagnostics, shell, dialog, path, app,
|
||||
repositories, preflight, monitor,
|
||||
}) {
|
||||
register("operations:refresh", async ({ operationId }) => {
|
||||
if (operationId) {
|
||||
const operation = store.getOperation(operationId);
|
||||
if (operation?.provider === "ssh-unraid")
|
||||
return unraid.refreshOperation(operationId);
|
||||
return deployments.refreshOperation(operationId);
|
||||
}
|
||||
const [actions, sshOperations] = await Promise.all([
|
||||
deployments.refreshActiveOperations(),
|
||||
unraid.refreshActiveOperations(),
|
||||
]);
|
||||
return [...actions, ...sshOperations];
|
||||
});
|
||||
register("operations:get", ({ operationId }) =>
|
||||
store.getOperation(operationId),
|
||||
);
|
||||
|
||||
register("diagnostics:status", () => diagnostics.getStatus());
|
||||
register("diagnostics:clear", () => diagnostics.clear());
|
||||
register("diagnostics:open-folder", async () => {
|
||||
const error = await shell.openPath(diagnostics.logDirectory);
|
||||
if (error) throw new Error(error);
|
||||
return true;
|
||||
});
|
||||
register("diagnostics:export", async ({ privacyMode = "standard" }) => {
|
||||
if (!["standard", "strict"].includes(privacyMode))
|
||||
throw new Error("Unsupported diagnostic privacy mode.");
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: "Export ForgeFlow diagnostic bundle",
|
||||
defaultPath: path.join(
|
||||
app.getPath("downloads"),
|
||||
`ForgeFlow-Diagnostics-${new Date().toISOString().replace(/[:.]/g, "-")}.zip`,
|
||||
),
|
||||
filters: [{ name: "ZIP archive", extensions: ["zip"] }],
|
||||
});
|
||||
if (result.canceled || !result.filePath) return null;
|
||||
const repositoryState = await repositories.refresh().catch((error) => {
|
||||
diagnostics.warning("diagnostics.repository-snapshot.failed", error);
|
||||
return [];
|
||||
});
|
||||
const systemPreflight = await preflight
|
||||
.runSystem()
|
||||
.catch((error) => ({ error: error.message }));
|
||||
const destinationPath =
|
||||
path.extname(result.filePath).toLowerCase() === ".zip"
|
||||
? result.filePath
|
||||
: `${result.filePath}.zip`;
|
||||
return diagnostics.exportSupportBundle({
|
||||
destinationPath,
|
||||
publicState: store.getPublicState(),
|
||||
repositories: repositoryState,
|
||||
operations: store.data.operations,
|
||||
preflight: systemPreflight,
|
||||
privacyMode,
|
||||
extra: {
|
||||
appVersion: app.getVersion(),
|
||||
setupComplete: store.data.setupComplete,
|
||||
},
|
||||
});
|
||||
});
|
||||
register("diagnostics:show-bundle", async ({ filePath }) => {
|
||||
if (!diagnostics.isKnownBundlePath(filePath))
|
||||
throw new Error(
|
||||
"Only the most recently generated support bundle can be revealed.",
|
||||
);
|
||||
shell.showItemInFolder(filePath);
|
||||
return true;
|
||||
});
|
||||
register(
|
||||
"renderer:report",
|
||||
async ({ level = "info", event = "renderer.event", details = {} }) => {
|
||||
const method = ["debug", "info", "warning", "error"].includes(level)
|
||||
? level
|
||||
: "info";
|
||||
await diagnostics[method](
|
||||
`renderer.${String(event || "event").slice(0, 120)}`,
|
||||
details,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
register("app:reset", async () => {
|
||||
await diagnostics.info("app.reset.requested", {});
|
||||
store.data = store.migrate({});
|
||||
store.sessionToken = null;
|
||||
await store.save();
|
||||
monitor?.setPaths([]);
|
||||
monitor?.restart();
|
||||
return store.getPublicState();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerOperationsIpc };
|
||||
@@ -0,0 +1,450 @@
|
||||
"use strict";
|
||||
|
||||
function registerRepositoryIpc({
|
||||
register, repositories, store, git, gitea, monitor, diagnostics, audit,
|
||||
externalTools, gitValidator, withRepositoryMutation, assertKnownRepositoryPath,
|
||||
resolveRepository, cloneRepositoryInto, cloneDirectoryName,
|
||||
matchRemoteToRepository, shell, dialog,
|
||||
}) {
|
||||
register("repositories:refresh", async ({ force = false }) => {
|
||||
const result = await repositories.refresh({ force: force === true });
|
||||
monitor?.setPaths(repositories.getWatchPaths());
|
||||
return result;
|
||||
});
|
||||
|
||||
register("repositories:discover", async ({ roots }) => {
|
||||
const paths = await repositories.discoverAll(
|
||||
roots || store.data.workspaceRoots,
|
||||
);
|
||||
return repositories.getLocalDescriptors(paths);
|
||||
});
|
||||
|
||||
register("repository:favorite", async ({ fullName, favorite }) =>
|
||||
store.setFavorite(fullName, favorite),
|
||||
);
|
||||
|
||||
register("repository:link", async ({ fullName, localPath }) => {
|
||||
await git.ensureRepository(localPath);
|
||||
const remoteUrl = await git.getRemoteUrl(localPath).catch(() => "");
|
||||
if (
|
||||
!remoteUrl ||
|
||||
!matchRemoteToRepository(remoteUrl, [{ full_name: fullName }])
|
||||
) {
|
||||
throw new Error(
|
||||
`The selected folder's origin does not match ${fullName}.`,
|
||||
);
|
||||
}
|
||||
await store.saveMapping(fullName, localPath);
|
||||
await diagnostics.info("repository.linked", { fullName, localPath });
|
||||
const result = await repositories.refresh();
|
||||
monitor?.setPaths(repositories.getWatchPaths());
|
||||
return result;
|
||||
});
|
||||
|
||||
register("repository:unlink", async ({ fullName }) => {
|
||||
await store.removeMapping(fullName);
|
||||
await diagnostics.info("repository.unlinked", { fullName });
|
||||
const result = await repositories.refresh();
|
||||
monitor?.setPaths(repositories.getWatchPaths());
|
||||
return result;
|
||||
});
|
||||
|
||||
register("repository:status", async ({ localPath }) =>
|
||||
git.status(await assertKnownRepositoryPath(localPath)),
|
||||
);
|
||||
register("repository:diff", async ({ localPath, filePath, staged }) =>
|
||||
git.diff(await assertKnownRepositoryPath(localPath), filePath, staged),
|
||||
);
|
||||
register("repository:diff-hunks", async ({ localPath, filePath }) =>
|
||||
git.diffHunks(await assertKnownRepositoryPath(localPath), filePath),
|
||||
);
|
||||
register(
|
||||
"repository:stage-hunks",
|
||||
async ({ localPath, filePath, hunkIndexes }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.stageHunks(safePath, filePath, hunkIndexes),
|
||||
);
|
||||
},
|
||||
);
|
||||
register("repository:conflicts", async ({ localPath }) =>
|
||||
git.conflictState(await assertKnownRepositoryPath(localPath)),
|
||||
);
|
||||
register(
|
||||
"repository:resolve-conflict",
|
||||
async ({ localPath, filePath, resolution }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
const result = await withRepositoryMutation(safePath, () =>
|
||||
git.resolveConflict(safePath, filePath, resolution),
|
||||
);
|
||||
await audit.append("git.conflict.resolved", {
|
||||
localPath: safePath,
|
||||
filePath,
|
||||
resolution,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
);
|
||||
register("repository:continue-operation", async ({ localPath }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
const result = await withRepositoryMutation(safePath, () =>
|
||||
git.continueInterruptedOperation(safePath),
|
||||
);
|
||||
await audit.append("git.operation.continued", { localPath: safePath });
|
||||
return result;
|
||||
});
|
||||
register("repository:abort-operation", async ({ localPath }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
const result = await withRepositoryMutation(safePath, () =>
|
||||
git.abortInterruptedOperation(safePath),
|
||||
);
|
||||
await audit.append("git.operation.aborted", {
|
||||
localPath: safePath,
|
||||
operation: result.aborted,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
register("repository:stage", async ({ localPath, files }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () => git.stage(safePath, files));
|
||||
});
|
||||
register("repository:unstage", async ({ localPath, files }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () => git.unstage(safePath, files));
|
||||
});
|
||||
register("repository:commit", async ({ localPath, message, files }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.commit(safePath, message, files),
|
||||
);
|
||||
});
|
||||
register("repository:commit-staged", async ({ localPath, message }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.commitStaged(safePath, message),
|
||||
);
|
||||
});
|
||||
register("repository:commit-staged-push", async ({ localPath, message }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.commitStagedAndPush(safePath, message),
|
||||
);
|
||||
});
|
||||
register("repository:commit-push", async ({ localPath, message, files }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.commitAndPush(safePath, message, files),
|
||||
);
|
||||
});
|
||||
register("repository:push", async ({ localPath }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () => git.push(safePath));
|
||||
});
|
||||
register("repository:fetch", async ({ localPath }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () => git.fetch(safePath));
|
||||
});
|
||||
register("repository:pull", async ({ localPath }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.pullFastForward(safePath),
|
||||
);
|
||||
});
|
||||
register("repository:history", async ({ localPath, limit }) =>
|
||||
git.history(await assertKnownRepositoryPath(localPath), limit),
|
||||
);
|
||||
register("repository:branch-protection", async ({ fullName, branch }) => {
|
||||
const repository = await resolveRepository({ fullName });
|
||||
return gitea.getBranchProtection(
|
||||
repository.owner.login,
|
||||
repository.name,
|
||||
branch ||
|
||||
repository.localStatus?.branch?.head ||
|
||||
repository.defaultBranch,
|
||||
);
|
||||
});
|
||||
register("repository:pull-requests", async ({ fullName, state = "open" }) => {
|
||||
const repository = await resolveRepository({ fullName });
|
||||
return gitea.listPullRequests({
|
||||
owner: repository.owner.login,
|
||||
repo: repository.name,
|
||||
state,
|
||||
});
|
||||
});
|
||||
register(
|
||||
"repository:create-pull-request",
|
||||
async ({ fullName, title, body, base }) => {
|
||||
const repository = await resolveRepository({ fullName });
|
||||
if (!repository.localPath || !repository.localStatus?.clean)
|
||||
throw new Error(
|
||||
"A clean linked repository is required before creating a pull request.",
|
||||
);
|
||||
const head = repository.localStatus.branch?.head;
|
||||
if (!head || !repository.localStatus.branch?.upstream)
|
||||
throw new Error(
|
||||
"Publish the current branch before creating a pull request.",
|
||||
);
|
||||
if (repository.localStatus.branch.ahead > 0)
|
||||
throw new Error(
|
||||
"Push all local commits before creating a pull request.",
|
||||
);
|
||||
const pullRequest = await gitea.createPullRequest({
|
||||
owner: repository.owner.login,
|
||||
repo: repository.name,
|
||||
head,
|
||||
base: base || repository.defaultBranch,
|
||||
title,
|
||||
body,
|
||||
});
|
||||
await audit.append("pull-request.created", {
|
||||
repository: repository.fullName,
|
||||
number: pullRequest.number,
|
||||
head,
|
||||
base: base || repository.defaultBranch,
|
||||
url: pullRequest.html_url,
|
||||
});
|
||||
return pullRequest;
|
||||
},
|
||||
);
|
||||
register("repository:branches", async ({ localPath }) =>
|
||||
git.branches(await assertKnownRepositoryPath(localPath)),
|
||||
);
|
||||
register("repository:checkout-branch", async ({ localPath, branch }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.checkoutBranch(safePath, branch),
|
||||
);
|
||||
});
|
||||
register("repository:create-branch", async ({ localPath, branch }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.createBranch(safePath, branch),
|
||||
);
|
||||
});
|
||||
register("repository:stash", async ({ localPath, message }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () => git.stash(safePath, message));
|
||||
});
|
||||
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:workspace-sync-preview", async ({ localPath }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
const plan = await withRepositoryMutation(safePath, () =>
|
||||
git.previewWorkspaceSync(safePath),
|
||||
);
|
||||
await diagnostics.info("repository.workspace-sync.previewed", {
|
||||
localPath: safePath,
|
||||
branch: plan.branch,
|
||||
upstream: plan.upstream,
|
||||
currentSha: plan.currentSha,
|
||||
targetSha: plan.targetSha,
|
||||
planId: plan.id,
|
||||
summary: plan.summary,
|
||||
blockers: plan.blockers,
|
||||
});
|
||||
return plan;
|
||||
});
|
||||
register(
|
||||
"repository:workspace-sync-apply",
|
||||
async ({ localPath, expectedPlanId }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
const result = await withRepositoryMutation(safePath, () =>
|
||||
git.synchronizeWorkspace(safePath, expectedPlanId),
|
||||
);
|
||||
await audit.append("repository.workspace-synchronized", {
|
||||
localPath: safePath,
|
||||
branch: result.plan.branch,
|
||||
upstream: result.plan.upstream,
|
||||
previousSha: result.plan.currentSha,
|
||||
targetSha: result.plan.targetSha,
|
||||
backupBranch: result.backupBranch,
|
||||
stashSha: result.stash?.sha || null,
|
||||
ignoredFilesPreserved: true,
|
||||
applied: result.applied,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
);
|
||||
register("repository:set-origin", async ({ localPath, remoteUrl }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.setRemoteUrl(safePath, remoteUrl),
|
||||
);
|
||||
});
|
||||
|
||||
register("repositories:normalize-origins", async () => {
|
||||
const current = await repositories.refresh();
|
||||
const changes = [];
|
||||
for (const repository of current) {
|
||||
if (!repository.localPath || !repository.sshUrl) continue;
|
||||
const actual = await git
|
||||
.getRemoteUrl(repository.localPath)
|
||||
.catch(() => "");
|
||||
if (actual === repository.sshUrl) continue;
|
||||
await withRepositoryMutation(repository.localPath, () =>
|
||||
git.setRemoteUrl(repository.localPath, repository.sshUrl),
|
||||
);
|
||||
changes.push({
|
||||
fullName: repository.fullName,
|
||||
previous: actual,
|
||||
next: repository.sshUrl,
|
||||
});
|
||||
}
|
||||
const refreshed = await repositories.refresh();
|
||||
monitor?.setPaths(repositories.getWatchPaths());
|
||||
await diagnostics.info("repositories.origins.normalized", {
|
||||
count: changes.length,
|
||||
changes,
|
||||
});
|
||||
return { changes, repositories: refreshed };
|
||||
});
|
||||
|
||||
register("repository:clone", async ({ fullName, mode = "default" }) => {
|
||||
if (!["default", "custom"].includes(mode))
|
||||
throw new Error("Unsupported clone location mode.");
|
||||
|
||||
let projectRoot = store.data.workspaceRoots[0] || null;
|
||||
if (mode === "custom" || !projectRoot) {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: `Choose a project root for ${String(fullName || "repository")}`,
|
||||
defaultPath: projectRoot || undefined,
|
||||
buttonLabel: "Use this project root",
|
||||
properties: ["openDirectory", "createDirectory"],
|
||||
});
|
||||
if (result.canceled || !result.filePaths[0]) return { cancelled: true };
|
||||
projectRoot = result.filePaths[0];
|
||||
}
|
||||
|
||||
return cloneRepositoryInto(fullName, projectRoot);
|
||||
});
|
||||
|
||||
register("repository:open-path", async ({ localPath }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
const error = await shell.openPath(safePath);
|
||||
if (error) throw new Error(error);
|
||||
return true;
|
||||
});
|
||||
register(
|
||||
"repository:open-editor",
|
||||
async ({ localPath, filePath = "", line = 1 }) =>
|
||||
externalTools.launch(
|
||||
"editor",
|
||||
await assertKnownRepositoryPath(localPath),
|
||||
filePath,
|
||||
line,
|
||||
),
|
||||
);
|
||||
register("repository:open-terminal", async ({ localPath }) =>
|
||||
externalTools.launch(
|
||||
"terminal",
|
||||
await assertKnownRepositoryPath(localPath),
|
||||
),
|
||||
);
|
||||
|
||||
register("external:open", async ({ url }) => {
|
||||
const parsed = new URL(url);
|
||||
if (!["http:", "https:"].includes(parsed.protocol))
|
||||
throw new Error("Only HTTP and HTTPS links can be opened.");
|
||||
await shell.openExternal(parsed.toString());
|
||||
return true;
|
||||
});
|
||||
|
||||
register("git-validator:scan", async ({ fullName }) => {
|
||||
const repository = await resolveRepository({ fullName });
|
||||
const report = await gitValidator.scan(repository);
|
||||
await diagnostics.info("git-validator.scan.completed", {
|
||||
repository: repository.fullName,
|
||||
score: report.score,
|
||||
summary: report.summary,
|
||||
});
|
||||
return report;
|
||||
});
|
||||
register("git-validator:set-policy", async ({ fullName, policy }) => {
|
||||
const repository = await resolveRepository({ fullName });
|
||||
const saved = await gitValidator.setPolicy(repository, policy);
|
||||
await audit.append("git-validator.policy.changed", { repository: repository.fullName, policy: saved.id });
|
||||
return saved;
|
||||
});
|
||||
register("git-validator:suppress", async ({ fullName, suppression }) => {
|
||||
const repository = await resolveRepository({ fullName });
|
||||
const saved = await gitValidator.suppress(repository, suppression);
|
||||
await audit.append("git-validator.finding.suppressed", { repository: repository.fullName, checkId: saved.checkId, expiresAt: saved.expiresAt, ticket: saved.ticket });
|
||||
return saved;
|
||||
});
|
||||
register("git-validator:preview-repair", async ({ fullName, check }) => {
|
||||
const repository = await resolveRepository({ fullName });
|
||||
const currentCheck = await gitValidator.resolveRepairCheck(repository, check);
|
||||
return gitValidator.previewRepair(repository, currentCheck);
|
||||
});
|
||||
register("git-validator:export", async ({ fullName, format = "json" }) => {
|
||||
const repository = await resolveRepository({ fullName });
|
||||
const report = await gitValidator.scan(repository);
|
||||
return gitValidator.export(report, format);
|
||||
});
|
||||
register("git-validator:repair", async ({ fullName, check }) => {
|
||||
const repository = await resolveRepository({ fullName });
|
||||
const allowed = new Set([
|
||||
"align-origin",
|
||||
"configure-local-safety",
|
||||
"add-gitignore",
|
||||
"add-gitattributes",
|
||||
"add-editorconfig",
|
||||
"protect-default-branch",
|
||||
]);
|
||||
const currentCheck = await gitValidator.resolveRepairCheck(repository, check);
|
||||
if (!allowed.has(currentCheck.fixAction))
|
||||
throw new Error("Unsupported Git Validator repair request.");
|
||||
const result = await gitValidator.repair(repository, currentCheck);
|
||||
await audit.append("git-validator.repair", {
|
||||
repository: repository.fullName,
|
||||
checkId: currentCheck.id,
|
||||
action: currentCheck.fixAction,
|
||||
});
|
||||
await diagnostics.info("git-validator.repair.completed", {
|
||||
repository: repository.fullName,
|
||||
checkId: currentCheck.id,
|
||||
action: currentCheck.fixAction,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerRepositoryIpc };
|
||||
@@ -0,0 +1,101 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const SENSITIVE_KEY = /(^|_)(token|password|passwd|authorization|secret|credential|clientsecret|client_secret|apikey|api_key|privatekey|private_key|encryptedtoken|encrypted_token)($|_)/i;
|
||||
const MAX_DIAGNOSTIC_STRING = 200_000;
|
||||
|
||||
function redactSecrets(value, secrets = []) {
|
||||
let text = String(value ?? '');
|
||||
const candidates = [...new Set((secrets || []).map((item) => String(item || '').trim()).filter((item) => item.length >= 4))]
|
||||
.sort((a, b) => b.length - a.length);
|
||||
for (const secret of candidates) text = text.split(secret).join('[REDACTED]');
|
||||
|
||||
text = text
|
||||
.replace(/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/gi, '[REDACTED PRIVATE KEY]')
|
||||
.replace(/(authorization\s*[:=]\s*(?:token|bearer|basic)\s+)[^\s,;]+/gi, '$1[REDACTED]')
|
||||
.replace(/([?&](?:access_token|token|api_key|apikey|key|secret|password)=)[^&#\s]+/gi, '$1[REDACTED]')
|
||||
.replace(/((?:access_token|token|api_key|apikey|client_secret|password|passwd|secret)\s*[=:]\s*)[^\s,;]+/gi, '$1[REDACTED]')
|
||||
.replace(/("(?:access_token|token|api_key|apikey|client_secret|password|passwd|secret)"\s*:\s*")[^"]+("?)/gi, '$1[REDACTED]$2')
|
||||
.replace(/(https?:\/\/[^\s:@/]+:)[^@\s/]+@/gi, '$1[REDACTED]@')
|
||||
.replace(/\b(?:ghp|github_pat|glpat|gitea)_[A-Za-z0-9_-]{16,}\b/g, '[REDACTED TOKEN]');
|
||||
|
||||
return text.length > MAX_DIAGNOSTIC_STRING ? `${text.slice(0, MAX_DIAGNOSTIC_STRING)}\n[TRUNCATED]` : text;
|
||||
}
|
||||
|
||||
function pathAlias(value, { homeDir = os.homedir(), cwd = process.cwd() } = {}) {
|
||||
let text = String(value ?? '');
|
||||
const replacements = [
|
||||
[homeDir, '<HOME>'],
|
||||
[cwd, '<APP_ROOT>']
|
||||
].filter(([candidate]) => candidate && candidate.length > 3)
|
||||
.sort((a, b) => b[0].length - a[0].length);
|
||||
for (const [candidate, replacement] of replacements) {
|
||||
const normalized = path.resolve(candidate);
|
||||
text = text.split(normalized).join(replacement);
|
||||
text = text.split(normalized.replace(/\\/g, '/')).join(replacement);
|
||||
text = text.split(normalized.replace(/\//g, '\\')).join(replacement);
|
||||
}
|
||||
text = text
|
||||
.replace(/[A-Za-z]:\\Users\\[^\\\s]+/g, '<HOME>')
|
||||
.replace(/\/(?:home|Users)\/[^/\s]+/g, '<HOME>');
|
||||
return text;
|
||||
}
|
||||
|
||||
function stableAlias(value, prefix = 'item') {
|
||||
const hash = crypto.createHash('sha256').update(String(value || '')).digest('hex').slice(0, 12);
|
||||
return `${prefix}-${hash}`;
|
||||
}
|
||||
|
||||
function redactPrivateInfrastructure(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/\b(?:10(?:\.\d{1,3}){3}|127(?:\.\d{1,3}){3}|169\.254(?:\.\d{1,3}){2}|172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}|192\.168(?:\.\d{1,3}){2})\b/g, '<PRIVATE_ADDRESS>')
|
||||
.replace(/\b(?:https?|ssh):\/\/[^\s"'<>]+/gi, '<PRIVATE_URL>')
|
||||
.replace(/\/(?:mnt|srv|opt|var\/lib)\/[^\s"'<>]*/g, '<SERVER_PATH>');
|
||||
}
|
||||
|
||||
function sanitizeForDiagnostics(value, options = {}, seen = new WeakSet()) {
|
||||
const {
|
||||
secrets = [],
|
||||
pathMode = 'alias',
|
||||
homeDir = os.homedir(),
|
||||
cwd = process.cwd(),
|
||||
strictIdentifiers = false
|
||||
} = options;
|
||||
|
||||
if (value === null || value === undefined || typeof value === 'boolean' || typeof value === 'number') return value;
|
||||
if (typeof value === 'bigint') return value.toString();
|
||||
if (typeof value === 'string') {
|
||||
let output = redactSecrets(value, secrets);
|
||||
if (pathMode === 'alias') output = pathAlias(output, { homeDir, cwd });
|
||||
if (strictIdentifiers) output = redactPrivateInfrastructure(output);
|
||||
return output;
|
||||
}
|
||||
if (value instanceof Error) {
|
||||
return sanitizeForDiagnostics({ name: value.name, message: value.message, code: value.code, stack: value.stack }, options, seen);
|
||||
}
|
||||
if (Array.isArray(value)) return value.slice(0, 1000).map((item) => sanitizeForDiagnostics(item, options, seen));
|
||||
if (typeof value !== 'object') return redactSecrets(String(value), secrets);
|
||||
if (seen.has(value)) return '[CIRCULAR]';
|
||||
seen.add(value);
|
||||
|
||||
const output = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
const normalizedKey = key.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[-.]/g, '_');
|
||||
if (SENSITIVE_KEY.test(normalizedKey)) {
|
||||
output[key] = '[REDACTED]';
|
||||
continue;
|
||||
}
|
||||
if (strictIdentifiers && ['full_name', 'repository', 'owner', 'user', 'login', 'email', 'host', 'hostname', 'username', 'base_path', 'private_key_path', 'local_path', 'remote_folder', 'remote_url', 'clone_url', 'status_url', 'healthcheck_url', 'web_ui_url', 'workspace_roots', 'scan_roots'].includes(normalizedKey.toLowerCase())) {
|
||||
output[key] = stableAlias(typeof item === 'object' ? JSON.stringify(item) : item, key.toLowerCase());
|
||||
continue;
|
||||
}
|
||||
output[key] = sanitizeForDiagnostics(item, options, seen);
|
||||
}
|
||||
seen.delete(value);
|
||||
return output;
|
||||
}
|
||||
|
||||
module.exports = { redactSecrets, sanitizeForDiagnostics, pathAlias, stableAlias, redactPrivateInfrastructure, SENSITIVE_KEY };
|
||||
@@ -0,0 +1,208 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const { run } = require('./process-runner.cjs');
|
||||
|
||||
function check(id, label, status, detail, { required = false, help = '' } = {}) {
|
||||
return { id, label, status, detail, required, help };
|
||||
}
|
||||
|
||||
function summarize(checks) {
|
||||
const counts = checks.reduce((acc, item) => {
|
||||
acc[item.status] = (acc[item.status] || 0) + 1;
|
||||
return acc;
|
||||
}, { pass: 0, warning: 0, fail: 0, skipped: 0 });
|
||||
const blocking = checks.filter((item) => item.required && item.status === 'fail');
|
||||
return { counts, blocking: blocking.map((item) => item.id), ready: blocking.length === 0 };
|
||||
}
|
||||
|
||||
class PreflightService {
|
||||
constructor({ store, git, gitea, deployments, diagnostics, userDataPath, secureStorageAvailable = () => false }) {
|
||||
this.store = store;
|
||||
this.git = git;
|
||||
this.gitea = gitea;
|
||||
this.deployments = deployments;
|
||||
this.diagnostics = diagnostics;
|
||||
this.userDataPath = userDataPath;
|
||||
this.secureStorageAvailable = secureStorageAvailable;
|
||||
}
|
||||
|
||||
async writableDirectory(directory) {
|
||||
const marker = path.join(directory, `.forgeflow-write-test-${process.pid}-${Date.now()}`);
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
await fs.writeFile(marker, 'ok', { mode: 0o600 });
|
||||
await fs.rm(marker, { force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
async gitIdentity() {
|
||||
const [name, email] = await Promise.all([
|
||||
run('git', ['config', '--global', '--get', 'user.name'], { allowExitCodes: [1], timeout: 10_000 }),
|
||||
run('git', ['config', '--global', '--get', 'user.email'], { allowExitCodes: [1], timeout: 10_000 })
|
||||
]);
|
||||
return { name: name.stdout.trim(), email: email.stdout.trim() };
|
||||
}
|
||||
|
||||
async runSystem({ baseUrl = '', token = '', roots = [] } = {}) {
|
||||
const startedAt = new Date().toISOString();
|
||||
const checks = [];
|
||||
|
||||
const git = await this.git.isAvailable();
|
||||
checks.push(check('git.available', 'Git command line', git.available ? 'pass' : 'fail', git.available ? git.version : git.error || 'Git was not found on PATH.', {
|
||||
required: true,
|
||||
help: 'Install Git for Windows and ensure git.exe is available on PATH.'
|
||||
}));
|
||||
|
||||
if (git.available) {
|
||||
try {
|
||||
const identity = await this.gitIdentity();
|
||||
checks.push(check('git.identity', 'Git author identity', identity.name && identity.email ? 'pass' : 'warning', identity.name && identity.email ? `${identity.name} <${identity.email}>` : 'Global user.name or user.email is missing.', {
|
||||
help: 'Set git config --global user.name and user.email before creating commits.'
|
||||
}));
|
||||
} catch (error) {
|
||||
checks.push(check('git.identity', 'Git author identity', 'warning', error.message));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.writableDirectory(this.userDataPath);
|
||||
checks.push(check('storage.userdata', 'Application data storage', 'pass', 'ForgeFlow can write its local configuration.', { required: true }));
|
||||
} catch (error) {
|
||||
checks.push(check('storage.userdata', 'Application data storage', 'fail', error.message, { required: true }));
|
||||
}
|
||||
|
||||
try {
|
||||
await this.writableDirectory(this.diagnostics.logDirectory);
|
||||
checks.push(check('storage.diagnostics', 'Diagnostic log storage', 'pass', 'The diagnostic directory is writable.', { required: true }));
|
||||
} catch (error) {
|
||||
checks.push(check('storage.diagnostics', 'Diagnostic log storage', 'fail', error.message, { required: true }));
|
||||
}
|
||||
|
||||
checks.push(check('storage.credentials', 'Protected credential storage', this.secureStorageAvailable() ? 'pass' : 'warning', this.secureStorageAvailable()
|
||||
? 'The operating system can encrypt the Gitea token at rest.'
|
||||
: 'OS credential encryption is unavailable; the token will remain session-only.', {
|
||||
help: 'Use a normal signed-in desktop session and make sure the OS credential service is available.'
|
||||
}));
|
||||
|
||||
const normalizedRoots = [...new Set((roots || []).map((item) => String(item || '').trim()).filter(Boolean))];
|
||||
if (!normalizedRoots.length) {
|
||||
checks.push(check('workspace.roots', 'Development folders', 'warning', 'No development folder has been selected yet.'));
|
||||
} else {
|
||||
for (let index = 0; index < normalizedRoots.length; index += 1) {
|
||||
const root = normalizedRoots[index];
|
||||
try {
|
||||
const stat = await fs.stat(root);
|
||||
checks.push(check(`workspace.root.${index}`, `Development folder ${index + 1}`, stat.isDirectory() ? 'pass' : 'fail', stat.isDirectory() ? root : 'The selected path is not a directory.', { required: true }));
|
||||
} catch (error) {
|
||||
checks.push(check(`workspace.root.${index}`, `Development folder ${index + 1}`, 'fail', error.message, { required: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveBaseUrl = String(baseUrl || this.store.data.gitea.baseUrl || '').trim();
|
||||
const effectiveToken = String(token || this.store.getToken() || '').trim();
|
||||
let giteaValidation = null;
|
||||
if (!effectiveBaseUrl || !effectiveToken) {
|
||||
checks.push(check('gitea.connection', 'Gitea connection', 'warning', 'Enter the Gitea URL and a local access token to test the connection.'));
|
||||
} else {
|
||||
try {
|
||||
giteaValidation = await this.gitea.validateConnection(effectiveBaseUrl, effectiveToken);
|
||||
checks.push(check('gitea.connection', 'Gitea connection', 'pass', `Connected to Gitea ${giteaValidation.version || 'unknown version'} as ${giteaValidation.user?.login || 'user'}.`, { required: true }));
|
||||
checks.push(check('gitea.repositories', 'Repository access', giteaValidation.repositoryCount >= 0 ? 'pass' : 'warning', `${giteaValidation.repositoryCount} accessible repositories returned.`));
|
||||
} catch (error) {
|
||||
checks.push(check('gitea.connection', 'Gitea connection', 'fail', error.message, { required: true }));
|
||||
}
|
||||
}
|
||||
|
||||
const result = { kind: 'system', startedAt, completedAt: new Date().toISOString(), checks, summary: summarize(checks), giteaValidation };
|
||||
await this.diagnostics.info('preflight.system.completed', { summary: result.summary, checks });
|
||||
return result;
|
||||
}
|
||||
|
||||
async fileExists(filePath) {
|
||||
const stat = await fs.stat(filePath).catch(() => null);
|
||||
return Boolean(stat?.isFile());
|
||||
}
|
||||
|
||||
async runDeployment({ repository, profileId }) {
|
||||
const checks = [];
|
||||
const startedAt = new Date().toISOString();
|
||||
if (!repository?.fullName) throw new Error('Repository identity is required.');
|
||||
const profile = this.store.getDeploymentProfile(repository.fullName, profileId);
|
||||
if (!profile) throw new Error('Deployment profile not found.');
|
||||
|
||||
checks.push(check('repository.linked', 'Local repository link', repository.localPath ? 'pass' : 'fail', repository.localPath || 'No local folder is linked.', { required: true }));
|
||||
if (!repository.localPath) {
|
||||
const result = { kind: 'deployment', repository: repository.fullName, profileId, startedAt, completedAt: new Date().toISOString(), checks, summary: summarize(checks) };
|
||||
await this.diagnostics.info('preflight.deployment.completed', result);
|
||||
return result;
|
||||
}
|
||||
|
||||
let status = null;
|
||||
try {
|
||||
status = await this.git.status(repository.localPath);
|
||||
checks.push(check('git.repository', 'Git working tree', 'pass', status.root, { required: true }));
|
||||
checks.push(check('git.branch', 'Allowed branch', status.branch.head === profile.branch ? 'pass' : 'fail', `Current: ${status.branch.head || 'detached'}; required: ${profile.branch}.`, { required: true }));
|
||||
checks.push(check('git.clean', 'Clean working tree', status.clean ? 'pass' : 'fail', status.clean ? 'No uncommitted changes.' : `${status.counts.changed} changed file(s) remain.`, { required: true }));
|
||||
checks.push(check('git.upstream', 'Published upstream', status.branch.upstream ? 'pass' : 'fail', status.branch.upstream || 'No upstream branch configured.', { required: true }));
|
||||
checks.push(check('git.sync', 'Local and Gitea synchronized', !status.branch.ahead && !status.branch.behind ? 'pass' : 'fail', `${status.branch.ahead || 0} ahead, ${status.branch.behind || 0} behind.`, { required: true }));
|
||||
if (status.head) {
|
||||
try {
|
||||
await this.git.verifyCommitOnRemoteBranch(repository.localPath, status.head, profile.branch);
|
||||
checks.push(check('git.remote-sha', 'Exact commit on remote branch', 'pass', `${status.head.slice(0, 7)} exists on origin/${profile.branch}.`, { required: true }));
|
||||
} catch (error) {
|
||||
checks.push(check('git.remote-sha', 'Exact commit on remote branch', 'fail', error.message, { required: true }));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
checks.push(check('git.repository', 'Git working tree', 'fail', error.message, { required: true }));
|
||||
}
|
||||
|
||||
const workflowPath = path.join(repository.localPath, '.gitea', 'workflows', profile.workflowFile);
|
||||
checks.push(check('workflow.deploy.local', 'Deploy workflow in local repository', await this.fileExists(workflowPath) ? 'pass' : 'fail', workflowPath, { required: true }));
|
||||
if (profile.rollbackWorkflowFile) {
|
||||
const rollbackPath = path.join(repository.localPath, '.gitea', 'workflows', profile.rollbackWorkflowFile);
|
||||
checks.push(check('workflow.rollback.local', 'Rollback workflow in local repository', await this.fileExists(rollbackPath) ? 'pass' : 'warning', rollbackPath));
|
||||
}
|
||||
|
||||
try {
|
||||
const [owner, repo] = repository.fullName.split('/');
|
||||
const remoteWorkflow = await this.gitea.repositoryFileExists({ owner, repo, filePath: `.gitea/workflows/${profile.workflowFile}`, ref: profile.branch });
|
||||
checks.push(check('workflow.deploy.remote', 'Deploy workflow on Gitea branch', remoteWorkflow ? 'pass' : 'fail', remoteWorkflow ? `${profile.workflowFile} exists on ${profile.branch}.` : `${profile.workflowFile} is not present on ${profile.branch}.`, { required: true }));
|
||||
try {
|
||||
await this.gitea.listWorkflowRuns({ owner, repo, branch: profile.branch, limit: 1 });
|
||||
checks.push(check('gitea.actions', 'Gitea Actions API', 'pass', 'The Actions runs endpoint is accessible.', { required: true }));
|
||||
} catch (error) {
|
||||
checks.push(check('gitea.actions', 'Gitea Actions API', 'fail', error.message, { required: true }));
|
||||
}
|
||||
} catch (error) {
|
||||
checks.push(check('workflow.deploy.remote', 'Deploy workflow on Gitea branch', 'fail', error.message, { required: true }));
|
||||
}
|
||||
|
||||
if (profile.statusUrl) {
|
||||
const state = await this.deployments.readStatusEndpoint(profile.statusUrl);
|
||||
checks.push(check('server.status.configured', 'Server version endpoint configured', 'pass', profile.statusUrl, { required: true }));
|
||||
checks.push(check('server.status.reachable', 'Server version endpoint reachable', state.reachable && state.ok ? 'pass' : 'warning', state.reachable && state.ok ? `Endpoint reachable${state.liveSha ? `; live ${state.liveSha.slice(0, 7)}` : '; no live SHA reported yet'}.` : state.error || `HTTP ${state.status || 'unavailable'}.`, { help: 'The first deployment may create the status file. Successful completion still requires the endpoint to return the exact SHA and request ID.' }));
|
||||
if (state.reachable && state.ok) {
|
||||
const identityMatches = (!state.repository || state.repository === repository.fullName) && (!state.environment || state.environment === profile.environment);
|
||||
checks.push(check('server.status.identity', 'Status endpoint target identity', identityMatches ? (state.repository && state.environment ? 'pass' : 'warning') : 'fail', state.repository && state.environment ? `${state.repository} / ${state.environment}` : 'Repository or environment is not present in the current status document.', { required: !identityMatches }));
|
||||
}
|
||||
} else checks.push(check('server.status.configured', 'Server version endpoint configured', 'fail', 'A status URL is required for exact post-deployment verification.', { required: true }));
|
||||
|
||||
if (profile.healthcheckUrl) {
|
||||
const health = await this.deployments.checkHealth(profile.healthcheckUrl);
|
||||
checks.push(check('server.health', 'Application healthcheck', health.healthy ? 'pass' : 'warning', health.healthy ? `HTTP ${health.status} in ${health.latencyMs} ms.` : health.error || `HTTP ${health.status || 'unavailable'}.`));
|
||||
} else checks.push(check('server.health', 'Application healthcheck', 'warning', 'No healthcheck URL is configured.'));
|
||||
|
||||
const result = {
|
||||
kind: 'deployment', repository: repository.fullName, profileId, profileName: profile.name,
|
||||
startedAt, completedAt: new Date().toISOString(), checks, summary: summarize(checks),
|
||||
head: status?.head || null
|
||||
};
|
||||
await this.diagnostics.info('preflight.deployment.completed', { repository: repository.fullName, profileId, summary: result.summary, checks });
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PreflightService, summarize, check };
|
||||
@@ -0,0 +1,26 @@
|
||||
'use strict';
|
||||
|
||||
function isBrokenPipeError(error) {
|
||||
return error?.code === 'EPIPE';
|
||||
}
|
||||
|
||||
function installOutputPipeGuards({
|
||||
stdout = process.stdout,
|
||||
stderr = process.stderr,
|
||||
onBrokenPipe = () => {}
|
||||
} = {}) {
|
||||
const guardedStreams = [stdout, stderr].filter(Boolean);
|
||||
const handlers = guardedStreams.map((stream) => {
|
||||
const handler = (error) => {
|
||||
if (!isBrokenPipeError(error)) throw error;
|
||||
onBrokenPipe(error);
|
||||
};
|
||||
stream.on('error', handler);
|
||||
return { stream, handler };
|
||||
});
|
||||
return () => {
|
||||
for (const { stream, handler } of handlers) stream.off('error', handler);
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { installOutputPipeGuards, isBrokenPipeError };
|
||||
@@ -0,0 +1,50 @@
|
||||
'use strict';
|
||||
|
||||
const { execFile } = require('node:child_process');
|
||||
|
||||
function run(command, args = [], options = {}) {
|
||||
const {
|
||||
cwd,
|
||||
timeout = 60_000,
|
||||
maxBuffer = 8 * 1024 * 1024,
|
||||
env,
|
||||
input = null,
|
||||
allowExitCodes = []
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = execFile(command, args, {
|
||||
cwd,
|
||||
timeout,
|
||||
maxBuffer,
|
||||
windowsHide: true,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...(env || {}) }
|
||||
}, (error, stdout, stderr) => {
|
||||
if (error && !allowExitCodes.includes(error.code)) {
|
||||
const message = (stderr || stdout || error.message).trim();
|
||||
const wrapped = new Error(message);
|
||||
wrapped.code = error.code;
|
||||
if (/\.git[\\/]index\.lock[\s\S]*File exists/i.test(message) || /Unable to create .*index\.lock/i.test(message)) {
|
||||
wrapped.code = 'GIT_INDEX_LOCKED';
|
||||
wrapped.recoverable = true;
|
||||
} else if (error.code === 'ENAMETOOLONG') {
|
||||
wrapped.code = 'GIT_ARGUMENT_LIST_TOO_LONG';
|
||||
wrapped.recoverable = true;
|
||||
}
|
||||
wrapped.stdout = stdout;
|
||||
wrapped.stderr = stderr;
|
||||
wrapped.command = `${command} ${args.join(' ')}`;
|
||||
reject(wrapped);
|
||||
return;
|
||||
}
|
||||
resolve({ stdout: stdout || '', stderr: stderr || '', exitCode: error?.code || 0 });
|
||||
});
|
||||
if (input !== null && input !== undefined) {
|
||||
child.stdin.on('error', () => {});
|
||||
child.stdin.end(input);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
@@ -0,0 +1,149 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const crypto = require("node:crypto");
|
||||
const { run } = require("./process-runner.cjs");
|
||||
|
||||
class ProductionAcceptanceHarness {
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
this.paths = {
|
||||
remote: path.join(root, "gitea", "owner", "app.git"),
|
||||
source: path.join(root, "workspace", "app"),
|
||||
server: path.join(root, "server", "appdata", "app"),
|
||||
releases: path.join(root, "releases"),
|
||||
config: path.join(root, "user-data", "forgeflow-config.json"),
|
||||
keys: path.join(root, "keys"),
|
||||
};
|
||||
this.state = { installed: false, version: null, tokenVersion: 1, auth: null, liveSha: null, previousSha: null, healthy: false, deployment: null, recovery: null, hostFingerprint: "SHA256:fixture-host", keyReadOnly: true };
|
||||
}
|
||||
|
||||
static async create() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "forgeflow-production-acceptance-"));
|
||||
const harness = new ProductionAcceptanceHarness(root);
|
||||
await harness.provision();
|
||||
return harness;
|
||||
}
|
||||
|
||||
async provision() {
|
||||
await Promise.all(Object.values(this.paths).filter((value) => !path.extname(value)).map((directory) => fs.mkdir(directory, { recursive: true })));
|
||||
await fs.mkdir(path.dirname(this.paths.remote), { recursive: true });
|
||||
await run("git", ["init", "--bare", this.paths.remote], { cwd: this.root, timeout: 30_000 });
|
||||
await fs.mkdir(this.paths.source, { recursive: true });
|
||||
await run("git", ["init", "-b", "main"], { cwd: this.paths.source, timeout: 30_000 });
|
||||
await run("git", ["config", "user.name", "ForgeFlow Acceptance"], { cwd: this.paths.source });
|
||||
await run("git", ["config", "user.email", "acceptance@example.invalid"], { cwd: this.paths.source });
|
||||
await fs.writeFile(path.join(this.paths.source, "compose.yml"), "services:\n app:\n image: forgeflow-fixture:latest\n", "utf8");
|
||||
await fs.writeFile(path.join(this.paths.source, "README.md"), "# Acceptance fixture\n", "utf8");
|
||||
await run("git", ["add", "."], { cwd: this.paths.source });
|
||||
await run("git", ["commit", "-m", "feat: initial fixture"], { cwd: this.paths.source });
|
||||
await run("git", ["remote", "add", "origin", this.paths.remote], { cwd: this.paths.source });
|
||||
await run("git", ["push", "-u", "origin", "main"], { cwd: this.paths.source, timeout: 30_000 });
|
||||
this.initialSha = (await run("git", ["rev-parse", "HEAD"], { cwd: this.paths.source })).stdout.trim();
|
||||
await fs.mkdir(this.paths.releases, { recursive: true });
|
||||
await fs.mkdir(path.dirname(this.paths.config), { recursive: true });
|
||||
await fs.mkdir(this.paths.keys, { recursive: true });
|
||||
await fs.writeFile(path.join(this.paths.keys, "deploy_key.pub"), "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixture forgeflow-acceptance\n", "utf8");
|
||||
}
|
||||
|
||||
async cleanup() { await fs.rm(this.root, { recursive: true, force: true }); }
|
||||
|
||||
async install(version = "0.10.0", mode = "installed") {
|
||||
this.state.installed = true; this.state.version = version; this.state.mode = mode;
|
||||
await this.saveConfig({ schemaVersion: 12, version, mode });
|
||||
return structuredClone(this.state);
|
||||
}
|
||||
|
||||
async migrate(targetVersion = "1.0.0") {
|
||||
if (!this.state.installed) throw new Error("Clean installation is required before migration.");
|
||||
const previous = JSON.parse(await fs.readFile(this.paths.config, "utf8"));
|
||||
await fs.writeFile(`${this.paths.config}.backup`, JSON.stringify(previous, null, 2), "utf8");
|
||||
this.state.version = targetVersion;
|
||||
await this.saveConfig({ ...previous, schemaVersion: 13, version: targetVersion, migratedAt: new Date().toISOString() });
|
||||
return { previousVersion: previous.version, version: targetVersion, backup: `${this.paths.config}.backup` };
|
||||
}
|
||||
|
||||
async saveConfig(data) { await fs.writeFile(this.paths.config, `${JSON.stringify(data, null, 2)}\n`, "utf8"); }
|
||||
rotateToken() { this.state.tokenVersion += 1; return { tokenVersion: this.state.tokenVersion }; }
|
||||
authenticate(type, options = {}) {
|
||||
if (!['password', 'ssh-key'].includes(type)) throw new Error("Unsupported authentication fixture.");
|
||||
if (type === 'ssh-key' && options.hostFingerprint !== this.state.hostFingerprint) throw new Error("SSH host fingerprint changed.");
|
||||
this.state.auth = type; return { authenticated: true, type };
|
||||
}
|
||||
setKeyAccess(readOnly) { this.state.keyReadOnly = readOnly; }
|
||||
|
||||
async createCommit(message = "fix: acceptance change") {
|
||||
const target = path.join(this.paths.source, "fixture.txt");
|
||||
await fs.writeFile(target, `${crypto.randomUUID()}\n`, "utf8");
|
||||
await run("git", ["add", "fixture.txt"], { cwd: this.paths.source });
|
||||
await run("git", ["commit", "-m", message], { cwd: this.paths.source });
|
||||
await run("git", ["push", "origin", "main"], { cwd: this.paths.source });
|
||||
return (await run("git", ["rev-parse", "HEAD"], { cwd: this.paths.source })).stdout.trim();
|
||||
}
|
||||
|
||||
plan(sha, mode = "server-git") {
|
||||
return { id: crypto.randomUUID(), evidenceHash: crypto.createHash("sha256").update(JSON.stringify({ sha, mode, liveSha: this.state.liveSha, keyReadOnly: this.state.keyReadOnly })).digest("hex"), sha, mode, previousSha: this.state.liveSha };
|
||||
}
|
||||
|
||||
async deploy(plan, fault = null) {
|
||||
if (!this.state.auth) throw new Error("Server authentication is required.");
|
||||
if (plan.mode === "server-git" && !this.state.keyReadOnly) throw new Error("Writable deploy key rejected.");
|
||||
if (this.plan(plan.sha, plan.mode).evidenceHash !== plan.evidenceHash) throw new Error("Stale reconciliation plan.");
|
||||
this.state.recovery = structuredClone(this.state);
|
||||
this.state.deployment = { id: crypto.randomUUID(), sha: plan.sha, mode: plan.mode, status: "running" };
|
||||
if (fault === "fetch-network") return this.fail("Network interrupted during fetch", false);
|
||||
await fs.mkdir(this.paths.server, { recursive: true });
|
||||
await fs.writeFile(path.join(this.paths.server, "compose.yml"), await fs.readFile(path.join(this.paths.source, "compose.yml")));
|
||||
if (fault === "activation-network") return this.fail("Network interrupted during activation", true);
|
||||
if (fault === "shutdown") { this.state.deployment.status = "interrupted"; return structuredClone(this.state.deployment); }
|
||||
this.state.previousSha = this.state.liveSha;
|
||||
this.state.liveSha = plan.sha;
|
||||
this.state.healthy = fault !== "unhealthy";
|
||||
this.state.deployment.status = this.state.healthy ? "success" : "failed";
|
||||
return structuredClone(this.state.deployment);
|
||||
}
|
||||
|
||||
fail(message, partial) { this.state.deployment.status = "failed"; this.state.deployment.failure = { message, partial }; return structuredClone(this.state.deployment); }
|
||||
recover() {
|
||||
if (this.state.deployment?.status !== "interrupted") throw new Error("No interrupted deployment to recover.");
|
||||
this.state.deployment.status = this.state.liveSha === this.state.deployment.sha && this.state.healthy ? "success" : "failed";
|
||||
return structuredClone(this.state.deployment);
|
||||
}
|
||||
rollback(targetSha) {
|
||||
if (!targetSha || targetSha !== this.state.previousSha) throw new Error("Rollback target is not the exact recorded previous SHA.");
|
||||
[this.state.liveSha, this.state.previousSha] = [targetSha, this.state.liveSha]; this.state.healthy = true;
|
||||
return { status: "rolled-back", liveSha: this.state.liveSha };
|
||||
}
|
||||
|
||||
adoptExisting(sha = this.initialSha) { this.state.liveSha = sha; this.state.healthy = true; return { linked: true, liveSha: sha, preserved: true }; }
|
||||
externalUpdate(sha) { this.state.liveSha = sha; this.state.healthy = true; return { reconciled: true, liveSha: sha }; }
|
||||
rotateDeployKey() { if (!this.state.keyReadOnly) throw new Error("Candidate deploy key is writable."); this.state.keyVersion = (this.state.keyVersion || 1) + 1; return { rotated: true, keyVersion: this.state.keyVersion }; }
|
||||
revokeDeployKey() { this.state.keyRevoked = true; return { revoked: true, deploymentBlocked: true }; }
|
||||
restoreDeployKey() { this.state.keyRevoked = false; this.state.keyReadOnly = true; return { restored: true }; }
|
||||
inventory(count = 20, partial = false) { return { workloads: Array.from({ length: count }, (_, index) => ({ id: `workload-${index + 1}`, classification: index === 1 ? "duplicate" : "active" })), partial, warnings: partial ? ["One scan root was unavailable"] : [] }; }
|
||||
|
||||
async publishRelease(version, options = {}) {
|
||||
const binary = Buffer.from(options.binary || "MZ-forgeflow-acceptance-binary");
|
||||
const name = `ForgeFlow-Portable-${version}-win-x64.exe`;
|
||||
const checksum = crypto.createHash("sha256").update(binary).digest("hex");
|
||||
const manifest = { version, draft: options.draft === true, assets: options.missingAsset ? [] : [{ name, sha256: options.badChecksum ? "0".repeat(64) : checksum }], provenance: { commitSha: options.commitSha || this.initialSha }, sbom: { bomFormat: "CycloneDX" } };
|
||||
await fs.writeFile(path.join(this.paths.releases, `${version}.json`), JSON.stringify(manifest, null, 2));
|
||||
if (!options.missingAsset) await fs.writeFile(path.join(this.paths.releases, name), binary);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
async verifyRelease(version) {
|
||||
const manifest = JSON.parse(await fs.readFile(path.join(this.paths.releases, `${version}.json`), "utf8"));
|
||||
if (manifest.draft) throw new Error("Incomplete draft release rejected.");
|
||||
const asset = manifest.assets[0];
|
||||
if (!asset) throw new Error("Required release asset is missing.");
|
||||
const binary = await fs.readFile(path.join(this.paths.releases, asset.name));
|
||||
if (crypto.createHash("sha256").update(binary).digest("hex") !== asset.sha256) throw new Error("Release checksum mismatch.");
|
||||
if (!manifest.provenance?.commitSha || manifest.sbom?.bomFormat !== "CycloneDX") throw new Error("Release provenance or SBOM is missing.");
|
||||
return { verified: true, version, asset: asset.name };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ProductionAcceptanceHarness };
|
||||
@@ -0,0 +1,229 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
|
||||
// A watched repository is only re-read when the filesystem reports activity. The
|
||||
// interval below stays as a safety net for watchers that silently stop
|
||||
// delivering, which happens on network shares and removed folders.
|
||||
const SAFETY_CHECK_INTERVAL_MS = 30_000;
|
||||
const WATCH_DEBOUNCE_MS = 250;
|
||||
// Busy trees (a build, an install, a fetch) produce a continuous event stream.
|
||||
// This bounds how often that can turn into a Git read.
|
||||
const MIN_WATCH_CHECK_INTERVAL_MS = 1_000;
|
||||
|
||||
class RepositoryMonitor {
|
||||
constructor({ store, git, onChange, diagnostics = null }) {
|
||||
this.store = store;
|
||||
this.git = git;
|
||||
this.onChange = onChange;
|
||||
this.diagnostics = diagnostics;
|
||||
this.paths = [];
|
||||
this.fingerprints = new Map();
|
||||
this.timer = null;
|
||||
this.running = false;
|
||||
this.paused = new Set();
|
||||
this.active = false;
|
||||
this.watchers = new Map();
|
||||
this.changed = new Set();
|
||||
this.lastCheckedAt = new Map();
|
||||
this.lastFetchedAt = new Map();
|
||||
this.watchTimer = null;
|
||||
this.fetchRunning = false;
|
||||
}
|
||||
|
||||
setPaths(paths) {
|
||||
this.paths = [...new Set((paths || []).filter(Boolean))];
|
||||
const watched = new Set(this.paths);
|
||||
for (const existing of [...this.fingerprints.keys()]) {
|
||||
if (!watched.has(existing)) this.fingerprints.delete(existing);
|
||||
}
|
||||
// A repository that is unlinked while a mutation holds it paused would keep
|
||||
// that pause forever, silently freezing its status once it is watched again.
|
||||
for (const existing of [...this.paused]) {
|
||||
if (!watched.has(existing)) this.paused.delete(existing);
|
||||
}
|
||||
for (const existing of [...this.changed]) {
|
||||
if (!watched.has(existing)) this.changed.delete(existing);
|
||||
}
|
||||
for (const existing of [...this.lastCheckedAt.keys()]) {
|
||||
if (!watched.has(existing)) this.lastCheckedAt.delete(existing);
|
||||
}
|
||||
for (const existing of [...this.lastFetchedAt.keys()]) {
|
||||
if (!watched.has(existing)) this.lastFetchedAt.delete(existing);
|
||||
}
|
||||
const now = Date.now();
|
||||
for (const localPath of this.paths) {
|
||||
if (!this.lastFetchedAt.has(localPath)) this.lastFetchedAt.set(localPath, now);
|
||||
}
|
||||
this.syncWatchers();
|
||||
}
|
||||
|
||||
syncWatchers() {
|
||||
for (const [localPath, watcher] of [...this.watchers]) {
|
||||
if (this.active && this.paths.includes(localPath)) continue;
|
||||
this.closeWatcher(localPath, watcher);
|
||||
}
|
||||
if (!this.active) return;
|
||||
for (const localPath of this.paths) {
|
||||
if (this.watchers.has(localPath)) continue;
|
||||
try {
|
||||
const watcher = fs.watch(
|
||||
localPath,
|
||||
{ recursive: true, persistent: false },
|
||||
() => this.noteFilesystemChange(localPath)
|
||||
);
|
||||
watcher.on('error', () => this.dropWatcher(localPath));
|
||||
this.watchers.set(localPath, watcher);
|
||||
} catch {
|
||||
// Watching is unavailable for this folder. Leaving it unwatched makes
|
||||
// shouldCheck() fall back to the interval for that repository only.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeWatcher(localPath, watcher = this.watchers.get(localPath)) {
|
||||
if (!watcher) return;
|
||||
try { watcher.close(); } catch { /* already closed */ }
|
||||
this.watchers.delete(localPath);
|
||||
}
|
||||
|
||||
dropWatcher(localPath) {
|
||||
this.closeWatcher(localPath);
|
||||
this.changed.add(localPath);
|
||||
}
|
||||
|
||||
noteFilesystemChange(localPath) {
|
||||
this.changed.add(localPath);
|
||||
this.scheduleWatchTick();
|
||||
}
|
||||
|
||||
scheduleWatchTick() {
|
||||
if (this.watchTimer) return;
|
||||
this.watchTimer = setTimeout(() => {
|
||||
this.watchTimer = null;
|
||||
this.tick().catch((error) => this.diagnostics?.warning('repository-monitor.tick.failed', error));
|
||||
}, WATCH_DEBOUNCE_MS);
|
||||
this.watchTimer.unref?.();
|
||||
}
|
||||
|
||||
shouldCheck(localPath, now) {
|
||||
if (this.paused.has(localPath)) return false;
|
||||
if (!this.watchers.has(localPath)) return true;
|
||||
const sinceLastCheck = now - (this.lastCheckedAt.get(localPath) || 0);
|
||||
if (this.changed.has(localPath)) return sinceLastCheck >= MIN_WATCH_CHECK_INTERVAL_MS;
|
||||
return sinceLastCheck >= SAFETY_CHECK_INTERVAL_MS;
|
||||
}
|
||||
|
||||
fetchIntervalMs() {
|
||||
const minutes = Number(this.store.data.preferences.fetchIntervalMinutes);
|
||||
return Number.isFinite(minutes) && minutes > 0 ? Math.min(minutes, 240) * 60_000 : 0;
|
||||
}
|
||||
|
||||
shouldFetch(localPath, now) {
|
||||
const interval = this.fetchIntervalMs();
|
||||
return interval > 0
|
||||
&& !this.paused.has(localPath)
|
||||
&& now - (this.lastFetchedAt.get(localPath) || now) >= interval;
|
||||
}
|
||||
|
||||
async recordStatus(localPath, status, reason) {
|
||||
const next = this.git.statusFingerprint(status);
|
||||
const previous = this.fingerprints.get(localPath);
|
||||
this.fingerprints.set(localPath, next);
|
||||
if (previous && previous !== next) {
|
||||
await this.diagnostics?.debug('repository-monitor.changed', { localPath, head: status.head, branch: status.branch?.head, counts: status.counts, reason });
|
||||
this.onChange?.({ localPath, status, reason });
|
||||
}
|
||||
}
|
||||
|
||||
async fetchRemoteUpdates(now = Date.now()) {
|
||||
if (this.fetchRunning) return;
|
||||
const queue = this.paths.filter((localPath) => this.shouldFetch(localPath, now));
|
||||
if (!queue.length) return;
|
||||
this.fetchRunning = true;
|
||||
try {
|
||||
const workers = Array.from({ length: Math.min(2, queue.length) }, async () => {
|
||||
while (queue.length) {
|
||||
const localPath = queue.shift();
|
||||
// Mark the attempt before awaiting the network. A failing remote should
|
||||
// not be retried every local poll interval.
|
||||
this.lastFetchedAt.set(localPath, Date.now());
|
||||
try {
|
||||
const result = await this.git.fetch(localPath);
|
||||
await this.recordStatus(localPath, result.status, 'remote-state-changed');
|
||||
await this.diagnostics?.debug('repository-monitor.fetch.completed', {
|
||||
localPath,
|
||||
branch: result.status?.branch?.head,
|
||||
ahead: result.status?.branch?.ahead,
|
||||
behind: result.status?.branch?.behind,
|
||||
});
|
||||
} catch (error) {
|
||||
await this.diagnostics?.warning('repository-monitor.fetch.failed', { localPath, message: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
} finally {
|
||||
this.fetchRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
pause(localPath) { if (localPath) this.paused.add(localPath); }
|
||||
resume(localPath) { if (localPath) this.paused.delete(localPath); }
|
||||
|
||||
restart() {
|
||||
this.stop();
|
||||
if (!this.store.data.preferences.autoRefresh) return;
|
||||
this.active = true;
|
||||
this.syncWatchers();
|
||||
const seconds = Math.min(Math.max(Number(this.store.data.preferences.repositoryPollSeconds) || 4, 2), 60);
|
||||
this.timer = setInterval(() => this.tick().catch((error) => this.diagnostics?.warning('repository-monitor.tick.failed', error)), seconds * 1000);
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
if (this.watchTimer) clearTimeout(this.watchTimer);
|
||||
this.watchTimer = null;
|
||||
this.active = false;
|
||||
this.syncWatchers();
|
||||
}
|
||||
|
||||
async tick() {
|
||||
void this.fetchRemoteUpdates().catch((error) => this.diagnostics?.warning('repository-monitor.fetch-cycle.failed', error));
|
||||
if (this.running || !this.paths.length) return;
|
||||
this.running = true;
|
||||
try {
|
||||
const now = Date.now();
|
||||
const queue = this.paths.filter((localPath) => this.shouldCheck(localPath, now));
|
||||
const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
|
||||
while (queue.length) {
|
||||
const localPath = queue.shift();
|
||||
this.changed.delete(localPath);
|
||||
this.lastCheckedAt.set(localPath, Date.now());
|
||||
try {
|
||||
const status = await this.git.status(localPath);
|
||||
await this.recordStatus(localPath, status, 'working-tree-changed');
|
||||
} catch (error) {
|
||||
const next = `error:${error.message}`;
|
||||
const previous = this.fingerprints.get(localPath);
|
||||
this.fingerprints.set(localPath, next);
|
||||
if (previous && previous !== next) {
|
||||
await this.diagnostics?.warning('repository-monitor.unavailable', { localPath, message: error.message });
|
||||
this.onChange?.({ localPath, error: error.message, reason: 'repository-unavailable' });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
} finally {
|
||||
this.running = false;
|
||||
// Activity that arrived while the check was running keeps its flag set, so
|
||||
// it must not wait for the safety interval.
|
||||
if (this.active && this.changed.size) this.scheduleWatchTick();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { RepositoryMonitor, SAFETY_CHECK_INTERVAL_MS, WATCH_DEBOUNCE_MS, MIN_WATCH_CHECK_INTERVAL_MS };
|
||||
@@ -0,0 +1,303 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const { matchRemoteToRepository, repositoryKey } = require('../shared/repository-match.cjs');
|
||||
|
||||
const SKIP_DIRECTORIES = new Set([
|
||||
'.git', '.svn', '.hg', 'node_modules', '.next', '.nuxt', 'dist', 'build', 'coverage',
|
||||
'.cache', '.venv', 'venv', '__pycache__', '$RECYCLE.BIN', 'System Volume Information'
|
||||
]);
|
||||
|
||||
async function mapLimit(items, limit, mapper) {
|
||||
const output = new Array(items.length);
|
||||
let cursor = 0;
|
||||
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
while (cursor < items.length) {
|
||||
const index = cursor++;
|
||||
output[index] = await mapper(items[index], index);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return output;
|
||||
}
|
||||
|
||||
class RepositoryService {
|
||||
constructor(store, gitService, giteaService, diagnostics = null) {
|
||||
this.store = store;
|
||||
this.git = gitService;
|
||||
this.gitea = giteaService;
|
||||
this.diagnostics = diagnostics;
|
||||
this.lastKnownLocalPaths = [];
|
||||
this.lastKnownRemoteRepositories = [];
|
||||
this.lastSuccessfulRemoteRefreshAt = null;
|
||||
this.lastRemoteRefreshAtMs = 0;
|
||||
this.lastDiscoveredPaths = [];
|
||||
this.lastDiscoveryAtMs = 0;
|
||||
this.refreshPromise = null;
|
||||
this.lastResult = null;
|
||||
}
|
||||
|
||||
async discoverInRoot(root, maxDepth = 4) {
|
||||
const found = [];
|
||||
const seen = new Set();
|
||||
|
||||
const visit = async (directory, depth) => {
|
||||
let real;
|
||||
try { real = await fs.realpath(directory); } catch { return; }
|
||||
if (seen.has(real)) return;
|
||||
seen.add(real);
|
||||
|
||||
const gitMarker = path.join(directory, '.git');
|
||||
const marker = await fs.stat(gitMarker).catch(() => null);
|
||||
if (marker) {
|
||||
found.push(real);
|
||||
return;
|
||||
}
|
||||
if (depth >= maxDepth) return;
|
||||
|
||||
let entries;
|
||||
try { entries = await fs.readdir(real, { withFileTypes: true }); } catch { return; }
|
||||
// Directory entries report as a symbolic link instead of a directory, which
|
||||
// is how Windows junctions surface. Skipping those made a project folder
|
||||
// that is mapped through a junction invisible; visit() resolves each entry
|
||||
// and the `seen` set above keeps links that point back into the tree from
|
||||
// being scanned twice.
|
||||
await mapLimit(entries
|
||||
.filter((entry) => (entry.isDirectory() || entry.isSymbolicLink()) && !SKIP_DIRECTORIES.has(entry.name)), 12,
|
||||
(entry) => visit(path.join(real, entry.name), depth + 1));
|
||||
};
|
||||
|
||||
await visit(root, 0);
|
||||
return found;
|
||||
}
|
||||
|
||||
async discoverAll(roots) {
|
||||
const grouped = await mapLimit((roots || []).filter(Boolean), 4, (root) => this.discoverInRoot(root));
|
||||
return [...new Set(grouped.flat())];
|
||||
}
|
||||
|
||||
async getLocalDescriptors(paths) {
|
||||
return mapLimit(paths, 5, async (localPath) => {
|
||||
try {
|
||||
const status = await this.git.status(localPath);
|
||||
return { localPath: status.root, remoteUrl: status.remoteUrl, status };
|
||||
} catch (error) {
|
||||
return { localPath, remoteUrl: '', status: null, error: error.message };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getWatchPaths() {
|
||||
return [...this.lastKnownLocalPaths];
|
||||
}
|
||||
|
||||
async getRemoteRepositories({ force = false } = {}) {
|
||||
if (!this.store.data.gitea.baseUrl || !this.store.getToken()) {
|
||||
this.lastKnownRemoteRepositories = [];
|
||||
this.lastSuccessfulRemoteRefreshAt = null;
|
||||
return { repositories: [], stale: false, error: null };
|
||||
}
|
||||
|
||||
if (!force && this.lastSuccessfulRemoteRefreshAt && Date.now() - this.lastRemoteRefreshAtMs < 15_000) {
|
||||
return {
|
||||
repositories: this.lastKnownRemoteRepositories.map((repository) => ({ ...repository })),
|
||||
stale: false,
|
||||
error: null,
|
||||
cached: true
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const repositories = await this.gitea.listRepositories();
|
||||
this.lastKnownRemoteRepositories = repositories.map((repository) => ({ ...repository }));
|
||||
this.lastSuccessfulRemoteRefreshAt = new Date().toISOString();
|
||||
this.lastRemoteRefreshAtMs = Date.now();
|
||||
return { repositories, stale: false, error: null };
|
||||
} catch (error) {
|
||||
if (!this.lastSuccessfulRemoteRefreshAt) throw error;
|
||||
await this.diagnostics?.warning('repositories.remote-refresh.degraded', {
|
||||
message: error.message,
|
||||
cachedCount: this.lastKnownRemoteRepositories.length,
|
||||
lastSuccessfulAt: this.lastSuccessfulRemoteRefreshAt
|
||||
});
|
||||
return {
|
||||
repositories: this.lastKnownRemoteRepositories.map((repository) => ({ ...repository })),
|
||||
stale: true,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async getDiscoveredPaths({ force = false } = {}) {
|
||||
if (!force && this.lastDiscoveryAtMs && Date.now() - this.lastDiscoveryAtMs < 30_000) {
|
||||
return [...this.lastDiscoveredPaths];
|
||||
}
|
||||
const paths = await this.discoverAll(this.store.data.workspaceRoots);
|
||||
this.lastDiscoveredPaths = [...paths];
|
||||
this.lastDiscoveryAtMs = Date.now();
|
||||
return paths;
|
||||
}
|
||||
|
||||
// Resolving a single repository used to go through a full refresh, which runs
|
||||
// `git status` for every discovered repository. Handlers that act on one
|
||||
// repository only need that one, so its local state is read directly. Anything
|
||||
// this cannot answer confidently still falls back to the full scan.
|
||||
async resolveByFullName(fullName) {
|
||||
const name = String(fullName || '').trim();
|
||||
if (!name) return null;
|
||||
const fromFullRefresh = async () => (await this.refresh()).find((item) => item.fullName === name) || null;
|
||||
|
||||
const remoteResult = await this.getRemoteRepositories({});
|
||||
const remote = remoteResult.repositories.find((item) => item.full_name === name);
|
||||
if (!remote) return fromFullRefresh();
|
||||
|
||||
const explicitPath = this.store.data.repositoryMappings[repositoryKey(remote)];
|
||||
const knownPath = explicitPath || (this.lastResult || []).find((item) => item.fullName === name)?.localPath || null;
|
||||
// Without a known path the link can still exist through remote-URL matching,
|
||||
// which only the discovery pass can establish.
|
||||
if (!knownPath && !this.lastResult) return fromFullRefresh();
|
||||
|
||||
const local = knownPath ? (await this.getLocalDescriptors([knownPath]))[0] : null;
|
||||
const profiles = this.store.getDeploymentProfiles(remote.full_name).map((profile) => ({
|
||||
...profile,
|
||||
state: this.store.getDeploymentState(profile.id)
|
||||
}));
|
||||
return {
|
||||
...this.decorate(remote, local, profiles),
|
||||
remoteStale: remoteResult.stale,
|
||||
remoteRefreshError: remoteResult.error,
|
||||
remoteLastRefreshedAt: this.lastSuccessfulRemoteRefreshAt
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(options = {}) {
|
||||
if (this.refreshPromise) return this.refreshPromise;
|
||||
this.refreshPromise = this.performRefresh(options).finally(() => { this.refreshPromise = null; });
|
||||
return this.refreshPromise;
|
||||
}
|
||||
|
||||
async performRefresh({ force = false } = {}) {
|
||||
const started = Date.now();
|
||||
const remoteResult = await this.getRemoteRepositories({ force });
|
||||
const remoteRepositories = remoteResult.repositories;
|
||||
|
||||
const discoveredPaths = await this.getDiscoveredPaths({ force });
|
||||
const mappedPaths = Object.values(this.store.data.repositoryMappings || {});
|
||||
const localPaths = [...new Set([...discoveredPaths, ...mappedPaths])];
|
||||
const localDescriptors = await this.getLocalDescriptors(localPaths);
|
||||
this.lastKnownLocalPaths = localDescriptors.filter((item) => item.status).map((item) => item.status.root);
|
||||
|
||||
const usedLocalPaths = new Set();
|
||||
const repositories = [];
|
||||
|
||||
for (const remote of remoteRepositories) {
|
||||
const key = repositoryKey(remote);
|
||||
const explicitPath = this.store.data.repositoryMappings[key];
|
||||
let local = explicitPath ? localDescriptors.find((item) => path.resolve(item.localPath) === path.resolve(explicitPath)) : null;
|
||||
if (!local) local = localDescriptors.find((item) => !usedLocalPaths.has(item.localPath) && matchRemoteToRepository(item.remoteUrl, [remote]));
|
||||
if (local) usedLocalPaths.add(local.localPath);
|
||||
|
||||
const profiles = this.store.getDeploymentProfiles(remote.full_name).map((profile) => ({
|
||||
...profile,
|
||||
state: this.store.getDeploymentState(profile.id)
|
||||
}));
|
||||
repositories.push({
|
||||
...this.decorate(remote, local, profiles),
|
||||
remoteStale: remoteResult.stale,
|
||||
remoteRefreshError: remoteResult.error,
|
||||
remoteLastRefreshedAt: this.lastSuccessfulRemoteRefreshAt
|
||||
});
|
||||
}
|
||||
|
||||
for (const local of localDescriptors.filter((item) => !usedLocalPaths.has(item.localPath))) {
|
||||
const name = path.basename(local.localPath);
|
||||
repositories.push({
|
||||
id: `local:${local.localPath}`,
|
||||
name,
|
||||
fullName: name,
|
||||
owner: { login: 'local' },
|
||||
description: 'Local repository not matched to Gitea',
|
||||
private: true,
|
||||
defaultBranch: local.status?.branch.head || 'main',
|
||||
htmlUrl: null,
|
||||
cloneUrl: null,
|
||||
sshUrl: null,
|
||||
preferredCloneUrl: null,
|
||||
localPath: local.localPath,
|
||||
localStatus: local.status,
|
||||
linkState: 'unmatched-local',
|
||||
deploymentProfiles: [],
|
||||
readyToDeploy: false,
|
||||
favorite: false,
|
||||
attention: Boolean(local.error),
|
||||
attentionReason: local.error || null
|
||||
});
|
||||
}
|
||||
|
||||
const sorted = repositories.sort((a, b) => {
|
||||
const score = (repo) => (repo.attention ? 100 : 0)
|
||||
+ (repo.localStatus?.counts.changed ? 50 : 0)
|
||||
+ (repo.localStatus?.branch.ahead ? 30 : 0)
|
||||
+ (repo.readyToDeploy ? 20 : 0)
|
||||
+ (repo.favorite ? 5 : 0);
|
||||
return score(b) - score(a) || a.fullName.localeCompare(b.fullName);
|
||||
});
|
||||
await this.diagnostics?.debug('repositories.refresh.completed', {
|
||||
durationMs: Date.now() - started,
|
||||
remoteCount: remoteRepositories.length,
|
||||
remoteStale: remoteResult.stale,
|
||||
remoteCached: remoteResult.cached === true,
|
||||
discoveredCount: discoveredPaths.length,
|
||||
linkedCount: sorted.filter((item) => item.localPath).length,
|
||||
attentionCount: sorted.filter((item) => item.attention).length,
|
||||
readyToDeployCount: sorted.filter((item) => item.readyToDeploy).length
|
||||
});
|
||||
this.lastResult = sorted;
|
||||
return sorted;
|
||||
}
|
||||
|
||||
decorate(remote, local, profiles) {
|
||||
const status = local?.status || null;
|
||||
const hasChanges = Boolean(status?.counts.changed);
|
||||
const ahead = status?.branch.ahead || 0;
|
||||
const behind = status?.branch.behind || 0;
|
||||
const conflict = Boolean(status?.counts.conflicts);
|
||||
const profileForBranch = profiles.find((profile) => profile.branch === status?.branch.head);
|
||||
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)
|
||||
: (remote.clone_url || remote.ssh_url);
|
||||
return {
|
||||
id: remote.id,
|
||||
name: remote.name,
|
||||
fullName: remote.full_name,
|
||||
owner: remote.owner,
|
||||
description: remote.description || '',
|
||||
private: remote.private,
|
||||
defaultBranch: remote.default_branch || 'main',
|
||||
htmlUrl: remote.html_url,
|
||||
cloneUrl: remote.clone_url,
|
||||
sshUrl: remote.ssh_url,
|
||||
preferredCloneUrl,
|
||||
updatedAt: remote.updated_at,
|
||||
localPath: local?.localPath || null,
|
||||
localStatus: status,
|
||||
linkState: local ? 'linked' : 'remote-only',
|
||||
deploymentProfiles: profiles,
|
||||
readyToDeploy,
|
||||
favorite: (this.store.data.favorites || []).includes(key),
|
||||
attention: conflict || behind > 0 || Boolean(local?.error),
|
||||
attentionReason: conflict ? 'Merge conflict' : behind > 0 ? `${behind} commit${behind === 1 ? '' : 's'} behind remote` : local?.error || null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { RepositoryService, SKIP_DIRECTORIES, mapLimit };
|
||||
@@ -0,0 +1,577 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
const path = require('node:path').posix;
|
||||
const { normalizeRemoteUrl } = require('../shared/repository-match.cjs');
|
||||
|
||||
function decodeBase64(value) {
|
||||
try { return Buffer.from(String(value || ''), 'base64').toString('utf8'); }
|
||||
catch { return ''; }
|
||||
}
|
||||
|
||||
function remoteIdentity(value) {
|
||||
const normalized = normalizeRemoteUrl(value);
|
||||
return normalized ? `${normalized.host}/${normalized.path}` : '';
|
||||
}
|
||||
|
||||
function normalizedName(value) {
|
||||
return String(value || '').toLowerCase().replace(/\.git$/i, '').replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
function safeJson(value, fallback) {
|
||||
try { return JSON.parse(value); }
|
||||
catch { return fallback; }
|
||||
}
|
||||
|
||||
function sanitizeLegacyContainer(container) {
|
||||
const labels = container?.Config?.Labels || {};
|
||||
return {
|
||||
id: container?.Id || '',
|
||||
name: String(container?.Name || '').replace(/^\//, ''),
|
||||
image: container?.Config?.Image || '',
|
||||
imageId: container?.Image || '',
|
||||
running: container?.State?.Running === true,
|
||||
status: container?.State?.Status || '',
|
||||
health: container?.State?.Health?.Status || null,
|
||||
labels: {
|
||||
'com.docker.compose.project': labels['com.docker.compose.project'] || '',
|
||||
'com.docker.compose.project.working_dir': labels['com.docker.compose.project.working_dir'] || '',
|
||||
'com.docker.compose.project.config_files': labels['com.docker.compose.project.config_files'] || '',
|
||||
'com.docker.compose.service': labels['com.docker.compose.service'] || '',
|
||||
'org.opencontainers.image.source': labels['org.opencontainers.image.source'] || '',
|
||||
'org.opencontainers.image.revision': labels['org.opencontainers.image.revision'] || '',
|
||||
'tech.itworx.forgeflow.repository': labels['tech.itworx.forgeflow.repository'] || '',
|
||||
'tech.itworx.forgeflow.commit': labels['tech.itworx.forgeflow.commit'] || '',
|
||||
'tech.itworx.forgeflow.branch': labels['tech.itworx.forgeflow.branch'] || '',
|
||||
'net.unraid.docker.webui': labels['net.unraid.docker.webui'] || '',
|
||||
'net.unraid.docker.icon': labels['net.unraid.docker.icon'] || '',
|
||||
'net.unraid.docker.shell': labels['net.unraid.docker.shell'] || '',
|
||||
'net.unraid.docker.managed': labels['net.unraid.docker.managed'] || '',
|
||||
},
|
||||
ports: container?.NetworkSettings?.Ports || {},
|
||||
mounts: Array.isArray(container?.Mounts) ? container.Mounts : [],
|
||||
networks: container?.NetworkSettings?.Networks || {},
|
||||
restartPolicy: container?.HostConfig?.RestartPolicy?.Name || '',
|
||||
};
|
||||
}
|
||||
|
||||
function parseServerInventory(output) {
|
||||
const marker = '__FORGEFLOW_INVENTORY__';
|
||||
const index = String(output || '').lastIndexOf(marker);
|
||||
if (index < 0) throw new Error('The server did not return a ForgeFlow workload inventory.');
|
||||
const inventory = {
|
||||
capabilities: {},
|
||||
checkouts: [],
|
||||
containers: [],
|
||||
dockerMan: [],
|
||||
composeProjects: [],
|
||||
composeDefinitions: [],
|
||||
warnings: [],
|
||||
};
|
||||
for (const line of String(output).slice(index + marker.length).trim().split(/\r?\n/)) {
|
||||
if (!line) continue;
|
||||
const [kind, ...parts] = line.split('\t');
|
||||
if (kind === 'H') {
|
||||
inventory.capabilities = {
|
||||
docker: parts[0] === 'true',
|
||||
compose: parts[1] === 'true',
|
||||
git: parts[2] === 'true',
|
||||
tar: parts[3] === 'true',
|
||||
checksum: parts[4] === 'true',
|
||||
baseWritable: parts[5] === 'true',
|
||||
composeVersion: decodeBase64(parts[6]),
|
||||
platform: decodeBase64(parts[7]),
|
||||
};
|
||||
} else if (kind === 'R' && parts.length >= 4) {
|
||||
inventory.checkouts.push({
|
||||
root: decodeBase64(parts[0]),
|
||||
remote: decodeBase64(parts[1]),
|
||||
liveSha: parts[2] || '',
|
||||
branch: decodeBase64(parts[3]),
|
||||
});
|
||||
} else if (kind === 'C' && parts[0]) {
|
||||
const parsed = safeJson(decodeBase64(parts[0]), null);
|
||||
if (!parsed) continue;
|
||||
if (Array.isArray(parsed)) {
|
||||
for (const item of parsed) if (item) inventory.containers.push(sanitizeLegacyContainer(item));
|
||||
} else if (parsed.Config || parsed.State) inventory.containers.push(sanitizeLegacyContainer(parsed));
|
||||
else inventory.containers.push({
|
||||
...parsed,
|
||||
name: String(parsed.name || '').replace(/^\//, ''),
|
||||
labels: parsed.labels && typeof parsed.labels === 'object' ? parsed.labels : {},
|
||||
mounts: Array.isArray(parsed.mounts) ? parsed.mounts : [],
|
||||
ports: parsed.ports && typeof parsed.ports === 'object' ? parsed.ports : {},
|
||||
networks: parsed.networks && typeof parsed.networks === 'object' ? parsed.networks : {},
|
||||
});
|
||||
} else if (kind === 'D' && parts[0]) {
|
||||
inventory.dockerMan.push({
|
||||
name: decodeBase64(parts[0]),
|
||||
templatePath: decodeBase64(parts[1]),
|
||||
webUiUrl: decodeBase64(parts[2]),
|
||||
iconUrl: decodeBase64(parts[3]),
|
||||
shell: decodeBase64(parts[4]),
|
||||
repository: decodeBase64(parts[5]),
|
||||
network: decodeBase64(parts[6]),
|
||||
});
|
||||
} else if (kind === 'P' && parts[0]) {
|
||||
const parsed = safeJson(decodeBase64(parts[0]), []);
|
||||
const projects = Array.isArray(parsed) ? parsed : parsed ? [parsed] : [];
|
||||
for (const project of projects) {
|
||||
const name = String(project?.Name || project?.name || '').trim();
|
||||
if (!name) continue;
|
||||
const rawFiles = project?.ConfigFiles || project?.configFiles || project?.config_files || [];
|
||||
const configFiles = (Array.isArray(rawFiles) ? rawFiles : String(rawFiles || '').split(','))
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean);
|
||||
inventory.composeProjects.push({
|
||||
name,
|
||||
status: String(project?.Status || project?.status || ''),
|
||||
configFiles,
|
||||
});
|
||||
}
|
||||
} else if (kind === 'Y' && parts[0]) {
|
||||
inventory.composeDefinitions.push({
|
||||
workingDir: decodeBase64(parts[0]).replace(/\/+$/, ''),
|
||||
configFiles: decodeBase64(parts[1]).split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
|
||||
projectName: decodeBase64(parts[2]).trim(),
|
||||
services: decodeBase64(parts[3]).split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
|
||||
images: decodeBase64(parts[4]).split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
|
||||
valid: parts[5] === 'true',
|
||||
error: decodeBase64(parts[6]).trim(),
|
||||
});
|
||||
} else if (kind === 'W') inventory.warnings.push(decodeBase64(parts[0]));
|
||||
}
|
||||
return inventory;
|
||||
}
|
||||
|
||||
function configFilesFor(container) {
|
||||
return String(container?.labels?.['com.docker.compose.project.config_files'] || '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function containerPorts(container) {
|
||||
const ports = [];
|
||||
for (const [containerKey, bindings] of Object.entries(container?.ports || {})) {
|
||||
const [containerPortText, protocol = 'tcp'] = containerKey.split('/');
|
||||
const containerPort = Number(containerPortText) || null;
|
||||
if (Array.isArray(bindings) && bindings.length) {
|
||||
for (const binding of bindings) ports.push({
|
||||
hostIp: binding?.HostIp || '',
|
||||
hostPort: Number(binding?.HostPort) || null,
|
||||
containerPort,
|
||||
protocol,
|
||||
});
|
||||
} else ports.push({ hostIp: '', hostPort: null, containerPort, protocol });
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
|
||||
function safeRelativeToBase(basePath, candidate) {
|
||||
const base = String(basePath || '').replace(/\/+$/, '');
|
||||
const value = String(candidate || '').replace(/\/+$/, '');
|
||||
if (!base || !value || !value.startsWith(`${base}/`)) return '';
|
||||
const relative = value.slice(base.length + 1).replace(/^\/+|\/+$/g, '');
|
||||
if (!relative || relative.split('/').some((part) => !part || part === '.' || part === '..')) return '';
|
||||
return relative;
|
||||
}
|
||||
|
||||
function topLevelRelativeToBase(basePath, candidate) {
|
||||
const relative = safeRelativeToBase(basePath, candidate);
|
||||
return relative ? relative.split('/')[0] : '';
|
||||
}
|
||||
|
||||
function canonicalServerAppdataPath(basePath, candidate) {
|
||||
const value = String(candidate || '').replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
if (!value) return '';
|
||||
const bases = [...new Set([
|
||||
String(basePath || '').replace(/\/+$/, ''),
|
||||
'/mnt/user/appdata',
|
||||
'/mnt/cache/appdata',
|
||||
].filter(Boolean))];
|
||||
for (const base of bases) {
|
||||
const relative = safeRelativeToBase(base, value);
|
||||
if (relative) return `${String(basePath || base).replace(/\/+$/, '')}/${relative}`;
|
||||
if (value === base) return String(basePath || base).replace(/\/+$/, '');
|
||||
}
|
||||
const diskMatch = value.match(/^\/mnt\/disk\d+\/appdata\/(.+)$/i);
|
||||
if (diskMatch) return `${String(basePath || '/mnt/user/appdata').replace(/\/+$/, '')}/${diskMatch[1]}`;
|
||||
return value;
|
||||
}
|
||||
|
||||
function isDeploymentBackupPath(value) {
|
||||
const segments = String(value || '').replace(/\\/g, '/').split('/').filter(Boolean);
|
||||
return segments.some((segment) =>
|
||||
/^source-pre-[0-9a-f]{7,64}$/i.test(segment)
|
||||
|| /^forgeflow-(backup|staging|rollback)(?:[-_.].*)?$/i.test(segment)
|
||||
|| ['.forgeflow', 'releases', 'backups', 'staging', 'incoming', '_audit_quarantine', 'devrunbook-validation'].includes(segment.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
function deploymentRootCandidate(relativePath) {
|
||||
const segments = String(relativePath || '').replace(/\\/g, '/').split('/').filter(Boolean);
|
||||
const forgeFlowIndex = segments.indexOf('.forgeflow');
|
||||
if (forgeFlowIndex > 0) return segments.slice(0, forgeFlowIndex).join('/');
|
||||
const releasesIndex = segments.indexOf('releases');
|
||||
if (releasesIndex > 0 && segments.length > releasesIndex + 1) return segments.slice(0, releasesIndex).join('/');
|
||||
const backupIndex = segments.findIndex((segment) => /^source-pre-[0-9a-f]{7,64}$/i.test(segment));
|
||||
if (backupIndex > 0) return segments.slice(0, backupIndex).join('/');
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
function workloadSelector(group) {
|
||||
if (group.composeProject) return {
|
||||
kind: 'compose',
|
||||
composeProject: group.composeProject,
|
||||
workingDir: group.workingDir || '',
|
||||
configFiles: group.configFiles,
|
||||
};
|
||||
const dockerMan = group.dockerMan || null;
|
||||
if (dockerMan?.templatePath) return {
|
||||
kind: 'dockerman-container',
|
||||
templatePath: dockerMan.templatePath,
|
||||
containerName: group.containers[0]?.name || '',
|
||||
};
|
||||
return { kind: 'docker-container', containerName: group.containers[0]?.name || '' };
|
||||
}
|
||||
|
||||
function stableWorkloadId(serverId, selector) {
|
||||
return `workload-${crypto.createHash('sha256').update(`${serverId}:${JSON.stringify(selector)}`).digest('hex').slice(0, 24)}`;
|
||||
}
|
||||
|
||||
function profileMatchesWorkload(profile, workload) {
|
||||
if (!profile || profile.provider !== 'ssh-unraid' || profile.serverId !== workload.serverId) return false;
|
||||
const identity = profile.workloadIdentity || {};
|
||||
if (identity.workloadId && identity.workloadId === workload.workloadId) return true;
|
||||
if (identity.selector && JSON.stringify(identity.selector) === JSON.stringify(workload.selector)) return true;
|
||||
if (profile.composeProject && workload.compose?.project && profile.composeProject === workload.compose.project) {
|
||||
if (!profile.composeWorkingDir || !workload.compose.workingDir || profile.composeWorkingDir === workload.compose.workingDir) return true;
|
||||
}
|
||||
if (profile.remoteFolder && workload.remoteFolderCandidate && profile.remoteFolder === workload.remoteFolderCandidate) return true;
|
||||
return workload.containers.some((container) => container.name === profile.containerName);
|
||||
}
|
||||
|
||||
function repositoryRemoteMap(repositories) {
|
||||
const map = new Map();
|
||||
for (const repository of repositories || []) {
|
||||
for (const value of [repository.cloneUrl, repository.sshUrl, repository.htmlUrl, repository.preferredCloneUrl]) {
|
||||
const id = remoteIdentity(value);
|
||||
if (id) map.set(id, repository);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function candidateRepositories(workload, repositories, checkouts) {
|
||||
const candidates = new Map();
|
||||
const add = (repository, points, reason, exact = false, identityExact = false) => {
|
||||
if (!repository?.fullName) return;
|
||||
const current = candidates.get(repository.fullName) || { repositoryFullName: repository.fullName, repositoryName: repository.name, score: 0, exact: false, identityExact: false, reasons: [] };
|
||||
current.score += points;
|
||||
current.exact ||= exact;
|
||||
current.identityExact ||= identityExact;
|
||||
if (reason && !current.reasons.includes(reason)) current.reasons.push(reason);
|
||||
candidates.set(repository.fullName, current);
|
||||
};
|
||||
const remotes = repositoryRemoteMap(repositories);
|
||||
const exactRemoteHints = new Set();
|
||||
for (const container of workload.containers) {
|
||||
const labels = container.labels || {};
|
||||
for (const value of [labels['tech.itworx.forgeflow.repository'], labels['org.opencontainers.image.source']]) {
|
||||
const id = remoteIdentity(value);
|
||||
if (id) exactRemoteHints.add(id);
|
||||
}
|
||||
}
|
||||
for (const checkout of checkouts || []) {
|
||||
const root = String(checkout.root || '').replace(/\/+$/, '');
|
||||
const matchesPath = root && (root === workload.compose.workingDir || workload.containers.some((container) => (container.mounts || []).some((mount) => {
|
||||
const source = String(mount?.Source || '').replace(/\/+$/, '');
|
||||
return source === root || source.startsWith(`${root}/`);
|
||||
})));
|
||||
if (matchesPath) {
|
||||
const id = remoteIdentity(checkout.remote);
|
||||
if (id) exactRemoteHints.add(id);
|
||||
}
|
||||
}
|
||||
for (const id of exactRemoteHints) {
|
||||
const repository = remotes.get(id);
|
||||
if (repository) add(repository, 100, 'Exact repository provenance from container or server checkout', true);
|
||||
}
|
||||
const composeProjectName = normalizedName(workload.compose.project);
|
||||
const composeFolderName = normalizedName(path.basename(workload.compose.workingDir || ''));
|
||||
const deploymentFolderName = normalizedName(String(workload.remoteFolderCandidate || '').split('/')[0]);
|
||||
const serviceNames = new Set((workload.compose.services || []).map(normalizedName).filter(Boolean));
|
||||
const containerNames = new Set(workload.containers.map((container) => normalizedName(container.name)).filter(Boolean));
|
||||
const imageNames = new Set([
|
||||
...workload.containers.map((container) => String(container.image || '').split('/').pop()?.split(':')[0]),
|
||||
...(workload.metadata?.images || []).map((image) => String(image || '').split('/').pop()?.split(':')[0]),
|
||||
].map(normalizedName).filter(Boolean));
|
||||
for (const repository of repositories || []) {
|
||||
const repoName = normalizedName(repository.name);
|
||||
if (!repoName) continue;
|
||||
if (composeProjectName && composeProjectName === repoName) add(repository, 55, 'Compose project name matches repository', false, true);
|
||||
if (deploymentFolderName && deploymentFolderName === repoName) add(repository, 70, 'Top-level appdata folder exactly matches repository', false, true);
|
||||
if (composeFolderName && composeFolderName === repoName) add(repository, 50, 'Compose file folder matches repository');
|
||||
if (serviceNames.has(repoName)) add(repository, 25, 'Compose service name matches repository');
|
||||
if (containerNames.has(repoName)) add(repository, 70, 'Container name exactly matches repository', false, true);
|
||||
if (imageNames.has(repoName)) add(repository, 20, 'Container image name matches repository');
|
||||
}
|
||||
return [...candidates.values()].sort((a, b) => b.score - a.score || a.repositoryFullName.localeCompare(b.repositoryFullName)).map((candidate) => ({
|
||||
...candidate,
|
||||
reasons: candidate.exact
|
||||
? candidate.reasons
|
||||
: [...candidate.reasons, 'Manual confirmation is reduced to one click; Compose identity and paths are prefilled from the server.'],
|
||||
confidence: candidate.exact ? 'exact' : candidate.score >= 35 ? 'strong' : 'weak',
|
||||
}));
|
||||
}
|
||||
|
||||
function buildWorkloadInventory({ inventory, server, repositories = [], profiles = [] }) {
|
||||
const dockerManByName = new Map((inventory.dockerMan || []).map((item) => [String(item.name || '').toLowerCase(), item]));
|
||||
const groups = new Map();
|
||||
for (const container of inventory.containers || []) {
|
||||
const labels = container.labels || {};
|
||||
const composeProject = String(labels['com.docker.compose.project'] || '').trim();
|
||||
const workingDir = canonicalServerAppdataPath(server.basePath, labels['com.docker.compose.project.working_dir']);
|
||||
const configFiles = [...new Set(configFilesFor(container).map((file) => canonicalServerAppdataPath(server.basePath, file)))];
|
||||
const key = composeProject
|
||||
? `compose:${composeProject}:${workingDir}:${configFiles.join('|')}`
|
||||
: `container:${container.name}`;
|
||||
const group = groups.get(key) || {
|
||||
composeProject,
|
||||
workingDir,
|
||||
configFiles,
|
||||
services: [],
|
||||
images: [],
|
||||
containers: [],
|
||||
dockerMan: null,
|
||||
};
|
||||
group.containers.push(container);
|
||||
const service = String(labels['com.docker.compose.service'] || '').trim();
|
||||
if (service && !group.services.includes(service)) group.services.push(service);
|
||||
group.dockerMan ||= dockerManByName.get(String(container.name || '').toLowerCase()) || null;
|
||||
groups.set(key, group);
|
||||
}
|
||||
for (const project of inventory.composeProjects || []) {
|
||||
const configFiles = [...new Set((project.configFiles || []).filter(Boolean).map((file) => canonicalServerAppdataPath(server.basePath, file)))];
|
||||
const workingDir = configFiles.length ? canonicalServerAppdataPath(server.basePath, path.dirname(configFiles[0])) : '';
|
||||
const key = `compose:${project.name}:${workingDir}:${configFiles.join('|')}`;
|
||||
if (groups.has(key)) continue;
|
||||
const existingByProject = [...groups.values()].find((group) => group.composeProject === project.name);
|
||||
if (existingByProject) {
|
||||
if (!existingByProject.configFiles.length && configFiles.length) existingByProject.configFiles = configFiles;
|
||||
if (!existingByProject.workingDir && workingDir) existingByProject.workingDir = workingDir;
|
||||
continue;
|
||||
}
|
||||
groups.set(key, {
|
||||
composeProject: project.name,
|
||||
workingDir,
|
||||
configFiles,
|
||||
services: [],
|
||||
images: [],
|
||||
containers: [],
|
||||
dockerMan: dockerManByName.get(String(project.name || '').toLowerCase()) || null,
|
||||
composeStatus: project.status || '',
|
||||
});
|
||||
}
|
||||
for (const definition of inventory.composeDefinitions || []) {
|
||||
const configFiles = [...new Set((definition.configFiles || []).filter(Boolean).map((file) => canonicalServerAppdataPath(server.basePath, file)))];
|
||||
const workingDir = canonicalServerAppdataPath(server.basePath, definition.workingDir || (configFiles[0] ? path.dirname(configFiles[0]) : ''));
|
||||
if (isDeploymentBackupPath(workingDir) || configFiles.some(isDeploymentBackupPath)) continue;
|
||||
const projectName = String(definition.projectName || path.basename(workingDir || '')).trim();
|
||||
const existing = [...groups.values()].find((group) => {
|
||||
if (workingDir && group.workingDir && group.workingDir === workingDir) return true;
|
||||
if (configFiles.length && (group.configFiles || []).some((file) => configFiles.includes(file))) return true;
|
||||
return Boolean(projectName && group.composeProject === projectName && (!workingDir || !group.workingDir));
|
||||
});
|
||||
if (existing) {
|
||||
existing.composeProject ||= projectName;
|
||||
existing.workingDir ||= workingDir;
|
||||
existing.configFiles = [...new Set([...(existing.configFiles || []), ...configFiles])];
|
||||
existing.services = [...new Set([...(existing.services || []), ...(definition.services || [])])];
|
||||
existing.images = [...new Set([...(existing.images || []), ...(definition.images || [])])];
|
||||
existing.composeDefinitionValid = definition.valid;
|
||||
existing.composeDefinitionError = definition.error || '';
|
||||
existing.composeSource = 'server-compose-file';
|
||||
continue;
|
||||
}
|
||||
const key = `compose-file:${projectName}:${workingDir}:${configFiles.join('|')}`;
|
||||
groups.set(key, {
|
||||
composeProject: projectName,
|
||||
workingDir,
|
||||
configFiles,
|
||||
services: [...new Set(definition.services || [])],
|
||||
images: [...new Set(definition.images || [])],
|
||||
containers: [],
|
||||
dockerMan: dockerManByName.get(projectName.toLowerCase()) || null,
|
||||
composeStatus: '',
|
||||
composeDefinitionValid: definition.valid,
|
||||
composeDefinitionError: definition.error || '',
|
||||
composeSource: 'server-compose-file',
|
||||
});
|
||||
}
|
||||
const containerNames = new Set((inventory.containers || []).map((container) => String(container.name || '').toLowerCase()));
|
||||
for (const dockerMan of inventory.dockerMan || []) {
|
||||
const normalized = String(dockerMan.name || '').toLowerCase();
|
||||
if (!normalized || containerNames.has(normalized)) continue;
|
||||
const key = `container:${dockerMan.name}`;
|
||||
if (groups.has(key)) continue;
|
||||
groups.set(key, {
|
||||
composeProject: '',
|
||||
workingDir: '',
|
||||
configFiles: [],
|
||||
services: [],
|
||||
images: dockerMan.repository ? [dockerMan.repository] : [],
|
||||
containers: [{
|
||||
id: '',
|
||||
name: dockerMan.name,
|
||||
image: dockerMan.repository || '',
|
||||
imageId: '',
|
||||
running: false,
|
||||
status: 'template-only',
|
||||
health: null,
|
||||
labels: {},
|
||||
ports: {},
|
||||
mounts: [],
|
||||
networks: dockerMan.network ? { [dockerMan.network]: {} } : {},
|
||||
restartPolicy: '',
|
||||
}],
|
||||
dockerMan,
|
||||
});
|
||||
}
|
||||
const workloads = [];
|
||||
for (const group of groups.values()) {
|
||||
const selector = workloadSelector(group);
|
||||
const workloadId = stableWorkloadId(server.id, selector);
|
||||
const primary = group.containers.find((item) => item.running) || group.containers[0];
|
||||
const ports = group.containers.flatMap(containerPorts);
|
||||
const mounts = group.containers.flatMap((container) => container.mounts || []);
|
||||
const remoteFolderCandidate = deploymentRootCandidate(safeRelativeToBase(server.basePath, canonicalServerAppdataPath(server.basePath, group.workingDir)))
|
||||
|| mounts.map((mount) => topLevelRelativeToBase(server.basePath, canonicalServerAppdataPath(server.basePath, mount?.Source))).find(Boolean)
|
||||
|| '';
|
||||
const workload = {
|
||||
workloadId,
|
||||
serverId: server.id,
|
||||
serverName: server.name,
|
||||
kind: selector.kind,
|
||||
selector,
|
||||
displayName: group.composeProject || primary?.name || group.dockerMan?.name || 'Unnamed workload',
|
||||
compose: {
|
||||
project: group.composeProject,
|
||||
workingDir: group.workingDir,
|
||||
configFiles: group.configFiles,
|
||||
services: group.services,
|
||||
},
|
||||
containers: group.containers.map((container) => ({
|
||||
id: container.id,
|
||||
name: container.name,
|
||||
image: container.image,
|
||||
imageId: container.imageId,
|
||||
running: container.running === true,
|
||||
status: container.status || '',
|
||||
health: container.health || null,
|
||||
service: container.labels?.['com.docker.compose.service'] || '',
|
||||
ports: containerPorts(container),
|
||||
mounts: (container.mounts || []).map((mount) => ({
|
||||
type: mount?.Type || '',
|
||||
source: mount?.Source || '',
|
||||
target: mount?.Destination || '',
|
||||
readOnly: mount?.RW === false,
|
||||
})),
|
||||
networks: Object.keys(container.networks || {}),
|
||||
restartPolicy: container.restartPolicy || '',
|
||||
})),
|
||||
dockerMan: group.dockerMan,
|
||||
metadata: {
|
||||
webUiUrl: primary?.labels?.['net.unraid.docker.webui'] || group.dockerMan?.webUiUrl || '',
|
||||
iconUrl: primary?.labels?.['net.unraid.docker.icon'] || group.dockerMan?.iconUrl || '',
|
||||
shell: primary?.labels?.['net.unraid.docker.shell'] || group.dockerMan?.shell || '/bin/sh',
|
||||
sourceRepository: primary?.labels?.['tech.itworx.forgeflow.repository'] || primary?.labels?.['org.opencontainers.image.source'] || '',
|
||||
liveRevision: primary?.labels?.['tech.itworx.forgeflow.commit'] || primary?.labels?.['org.opencontainers.image.revision'] || '',
|
||||
branch: primary?.labels?.['tech.itworx.forgeflow.branch'] || '',
|
||||
composeStatus: group.composeStatus || '',
|
||||
images: [...new Set(group.images || [])],
|
||||
composeSource: group.composeSource || (group.configFiles?.length ? 'docker-compose-runtime' : ''),
|
||||
composeDefinitionValid: group.composeDefinitionValid !== false,
|
||||
composeDefinitionError: group.composeDefinitionError || '',
|
||||
},
|
||||
runtime: {
|
||||
running: group.containers.some((container) => container.running === true),
|
||||
allRunning: group.containers.length > 0 && group.containers.every((container) => container.running === true),
|
||||
health: group.containers.some((container) => container.health === 'unhealthy')
|
||||
? 'unhealthy'
|
||||
: group.containers.length && group.containers.every((container) => container.health === 'healthy')
|
||||
? 'healthy'
|
||||
: 'unverified',
|
||||
ports,
|
||||
},
|
||||
remoteFolderCandidate,
|
||||
observedAt: new Date().toISOString(),
|
||||
};
|
||||
const matchingCheckout = (inventory.checkouts || []).find((checkout) => {
|
||||
const root = String(checkout.root || '').replace(/\/+$/, '');
|
||||
if (!root) return false;
|
||||
if (root === workload.compose.workingDir) return true;
|
||||
return mounts.some((mount) => {
|
||||
const source = String(mount?.Source || '').replace(/\/+$/, '');
|
||||
return source === root || source.startsWith(`${root}/`);
|
||||
});
|
||||
});
|
||||
if (matchingCheckout) {
|
||||
workload.metadata.sourceRepository ||= matchingCheckout.remote || '';
|
||||
workload.metadata.liveRevision ||= matchingCheckout.liveSha || '';
|
||||
workload.metadata.branch ||= matchingCheckout.branch || '';
|
||||
}
|
||||
workload.candidates = candidateRepositories(workload, repositories, inventory.checkouts || []);
|
||||
const linked = profiles.find((profile) => profileMatchesWorkload(profile, workload));
|
||||
if (linked) {
|
||||
workload.link = {
|
||||
status: 'linked',
|
||||
profileId: linked.id,
|
||||
repositoryFullName: linked.repositoryFullName || linked._repositoryFullName || '',
|
||||
source: linked.workloadIdentity?.linkSource || (linked.adoptedFromServer ? 'automatic' : 'manual'),
|
||||
};
|
||||
workload.status = 'linked';
|
||||
} else if (workload.candidates.length === 1 && workload.candidates[0].exact) workload.status = 'exact-match';
|
||||
else if (workload.candidates.length) workload.status = workload.candidates[1]?.score === workload.candidates[0]?.score ? 'ambiguous' : 'suggested';
|
||||
else workload.status = 'unmatched';
|
||||
workloads.push(workload);
|
||||
}
|
||||
workloads.sort((a, b) => Number(b.runtime.running) - Number(a.runtime.running) || a.displayName.localeCompare(b.displayName));
|
||||
return workloads;
|
||||
}
|
||||
|
||||
function inventoryContainerMatch(checkout, repository, container) {
|
||||
const safe = container?.Config || container?.State ? sanitizeLegacyContainer(container) : container;
|
||||
if (!safe?.running) return 0;
|
||||
const labels = safe.labels || {};
|
||||
const workingDir = String(labels['com.docker.compose.project.working_dir'] || '').replace(/\/$/, '');
|
||||
const source = remoteIdentity(labels['org.opencontainers.image.source'] || labels['tech.itworx.forgeflow.repository'] || '');
|
||||
const mounts = Array.isArray(safe.mounts) ? safe.mounts : [];
|
||||
const root = String(checkout.root || '').replace(/\/$/, '');
|
||||
const name = String(safe.name || '').replace(/^\//, '');
|
||||
const project = String(labels['com.docker.compose.project'] || '');
|
||||
const expectedNames = new Set([repository.name, root.split('/').pop()].filter(Boolean).map(normalizedName));
|
||||
if (workingDir && workingDir === root) return 100;
|
||||
if (mounts.some((mount) => {
|
||||
const mountSource = String(mount.Source || '').replace(/\/$/, '');
|
||||
return mountSource === root || mountSource.startsWith(`${root}/`);
|
||||
})) return 90;
|
||||
if (source && source === remoteIdentity(checkout.remote)) return 85;
|
||||
if (expectedNames.has(normalizedName(project))) return 70;
|
||||
if (expectedNames.has(normalizedName(name))) return 60;
|
||||
return 0;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseServerInventory,
|
||||
buildWorkloadInventory,
|
||||
inventoryContainerMatch,
|
||||
remoteIdentity,
|
||||
stableWorkloadId,
|
||||
profileMatchesWorkload,
|
||||
sanitizeLegacyContainer,
|
||||
safeRelativeToBase,
|
||||
canonicalServerAppdataPath,
|
||||
deploymentRootCandidate,
|
||||
};
|
||||
@@ -0,0 +1,512 @@
|
||||
'use strict';
|
||||
|
||||
const fsp = require('node:fs/promises');
|
||||
const crypto = require('node:crypto');
|
||||
const path = require('node:path').posix;
|
||||
|
||||
function loadSshModule() {
|
||||
try { return require('ssh2'); }
|
||||
catch {
|
||||
const error = new Error('The ssh2 dependency is not installed. Run npm install before configuring SSH deployments.');
|
||||
error.code = 'SSH2_NOT_INSTALLED';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function loadSshClient() {
|
||||
return loadSshModule().Client;
|
||||
}
|
||||
|
||||
function fingerprintKey(key) {
|
||||
const buffer = Buffer.isBuffer(key) ? key : Buffer.from(key);
|
||||
return `SHA256:${crypto.createHash('sha256').update(buffer).digest('base64').replace(/=+$/, '')}`;
|
||||
}
|
||||
|
||||
function shellQuote(value) {
|
||||
return `'${String(value ?? '').replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function parseCapabilityOutput(output) {
|
||||
const marker = '__FORGEFLOW_SERVER_TEST__';
|
||||
const index = String(output || '').lastIndexOf(marker);
|
||||
if (index < 0) return { platform: String(output || '').trim(), docker: false, dockerReady: false, compose: false, git: false, tar: false, checksum: false };
|
||||
const fields = {};
|
||||
for (const line of String(output).slice(index + marker.length).trim().split(/\r?\n/)) {
|
||||
const separator = line.indexOf('=');
|
||||
if (separator > 0) fields[line.slice(0, separator)] = line.slice(separator + 1);
|
||||
}
|
||||
const decode = (value) => {
|
||||
try { return value ? Buffer.from(value, 'base64').toString('utf8') : ''; }
|
||||
catch { return ''; }
|
||||
};
|
||||
return {
|
||||
platform: decode(fields.platform),
|
||||
docker: fields.docker === 'true',
|
||||
dockerReady: fields.dockerReady === 'true',
|
||||
compose: fields.compose === 'true',
|
||||
composeVersion: decode(fields.composeVersion),
|
||||
git: fields.git === 'true',
|
||||
tar: fields.tar === 'true',
|
||||
checksum: fields.checksum === 'true',
|
||||
baseWritable: fields.baseWritable === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
class SshService {
|
||||
constructor({ store, diagnostics, idleConnectionMs = 60_000, clientFactory = loadSshClient }) {
|
||||
this.store = store;
|
||||
this.diagnostics = diagnostics;
|
||||
this.clientFactory = clientFactory;
|
||||
// Every command used to pay for a TCP handshake, a key exchange and an
|
||||
// authentication round trip. Sessions are kept per server for a short while
|
||||
// so a sequence of commands shares one connection.
|
||||
this.sessions = new Map();
|
||||
this.idleConnectionMs = idleConnectionMs;
|
||||
}
|
||||
|
||||
// A connection is only reusable for a server whose identity and credentials
|
||||
// are unchanged. Anything in this key changing means a new connection.
|
||||
sessionKey(server) {
|
||||
return JSON.stringify([
|
||||
server.id,
|
||||
server.host,
|
||||
server.port || 22,
|
||||
server.username,
|
||||
server.authType,
|
||||
server.privateKeyPath || '',
|
||||
server.hostFingerprint || '',
|
||||
]);
|
||||
}
|
||||
|
||||
async validateServerConfiguration(server, secrets = {}) {
|
||||
if (server?.authType !== 'privateKey') return { valid: true, method: 'password' };
|
||||
const privateKeyPath = String(server.privateKeyPath || '').trim();
|
||||
if (!privateKeyPath) throw new Error('Select a private key file.');
|
||||
const stat = await fsp.stat(privateKeyPath).catch(() => null);
|
||||
if (!stat?.isFile()) {
|
||||
const error = new Error(`The SSH private key file was not found: ${privateKeyPath}`);
|
||||
error.code = 'SSH_PRIVATE_KEY_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
const existing = server.id ? this.store.getServer(server.id) : null;
|
||||
const sameKey = existing && String(existing.privateKeyPath || '') === privateKeyPath;
|
||||
const storedPassphrase = sameKey ? this.store.getServerCredentials(existing.id).passphrase : '';
|
||||
const passphrase = Object.prototype.hasOwnProperty.call(secrets, 'passphrase') && String(secrets.passphrase || '')
|
||||
? String(secrets.passphrase)
|
||||
: storedPassphrase;
|
||||
const key = await fsp.readFile(privateKeyPath);
|
||||
const parsed = loadSshModule().utils.parseKey(key, passphrase || undefined);
|
||||
const errorResult = Array.isArray(parsed) ? parsed.find((item) => item instanceof Error) : parsed instanceof Error ? parsed : null;
|
||||
if (errorResult) {
|
||||
const error = new Error(`The selected file is not a usable SSH private key${passphrase ? ' with the supplied passphrase' : ''}: ${errorResult.message}`);
|
||||
error.code = /encrypted|passphrase|decrypt/i.test(errorResult.message) ? 'SSH_PRIVATE_KEY_PASSPHRASE_INVALID' : 'SSH_PRIVATE_KEY_INVALID';
|
||||
throw error;
|
||||
}
|
||||
return { valid: true, method: 'privateKey', encrypted: Boolean(passphrase), privateKeyPath };
|
||||
}
|
||||
|
||||
async connectionOptions(server, { trustOnFirstUse = false, expectedFingerprint = null } = {}) {
|
||||
const credentials = this.store.getServerCredentials(server.id);
|
||||
let observedFingerprint = null;
|
||||
const options = {
|
||||
host: server.host,
|
||||
port: server.port || 22,
|
||||
username: server.username,
|
||||
readyTimeout: 20_000,
|
||||
keepaliveInterval: 10_000,
|
||||
keepaliveCountMax: 3,
|
||||
hostVerifier: (key) => {
|
||||
observedFingerprint = fingerprintKey(key);
|
||||
const trustedFingerprint = String(server.hostFingerprint || expectedFingerprint || '').trim();
|
||||
return trustOnFirstUse || Boolean(trustedFingerprint && observedFingerprint === trustedFingerprint);
|
||||
},
|
||||
};
|
||||
if (server.authType === 'password') options.password = credentials.password;
|
||||
else {
|
||||
try { options.privateKey = await fsp.readFile(server.privateKeyPath); }
|
||||
catch (error) {
|
||||
const wrapped = new Error(`Could not read SSH private key ${server.privateKeyPath}: ${error.message}`);
|
||||
wrapped.code = 'SSH_PRIVATE_KEY_READ_FAILED';
|
||||
throw wrapped;
|
||||
}
|
||||
if (credentials.passphrase) options.passphrase = credentials.passphrase;
|
||||
}
|
||||
return { options, getObservedFingerprint: () => observedFingerprint };
|
||||
}
|
||||
|
||||
async withClient(serverId, action, options = {}) {
|
||||
const server = this.store.getServer(serverId);
|
||||
if (!server) throw new Error('The configured SSH server no longer exists.');
|
||||
// A trust-on-first-use connection is established without checking the
|
||||
// fingerprint, so it must never serve a later verified call.
|
||||
if (options.trustOnFirstUse || options.expectedFingerprint) return this.withDedicatedClient(server, action, options);
|
||||
return this.withPooledClient(server, action, options);
|
||||
}
|
||||
|
||||
// Retrying is only safe while the command has not reached the server. Once a
|
||||
// stream is open the remote side may already be deploying, and repeating that
|
||||
// is not something this layer is allowed to decide.
|
||||
isPreCommandFailure(error) {
|
||||
return error?.beforeCommand === true;
|
||||
}
|
||||
|
||||
async withPooledClient(server, action, options) {
|
||||
const key = this.sessionKey(server);
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
const session = await this.leaseSession(server, key, options);
|
||||
try {
|
||||
const result = await action(session.client, server, session.fingerprint);
|
||||
this.releaseSession(session);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const staleConnection = session.reused && attempt === 0 && this.isPreCommandFailure(error);
|
||||
this.discardSession(session);
|
||||
if (!staleConnection) throw error;
|
||||
await this.diagnostics?.debug('ssh.session.stale-retry', { serverId: server.id, host: server.host, message: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createSession(server, key, options) {
|
||||
const entry = { key, client: null, fingerprint: null, leases: 0, dead: false, established: false, idleTimer: null, opening: null };
|
||||
entry.opening = this
|
||||
.withDedicatedClient(server, async (client, _server, fingerprint) => ({ client, fingerprint }), options, { keepOpen: true })
|
||||
.then((opened) => {
|
||||
entry.client = opened.client;
|
||||
entry.fingerprint = opened.fingerprint;
|
||||
entry.established = true;
|
||||
// Without a standing listener an error on an idle connection is
|
||||
// unhandled, which terminates the main process.
|
||||
opened.client.on('error', () => this.markSessionDead(entry));
|
||||
opened.client.on('close', () => this.markSessionDead(entry));
|
||||
opened.client.on('end', () => this.markSessionDead(entry));
|
||||
});
|
||||
this.sessions.set(key, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
async leaseSession(server, key, options) {
|
||||
const pooled = this.sessions.get(key);
|
||||
// Only a connection that was already up before this call may be retried on
|
||||
// failure. Callers that arrive while one is still being opened share both
|
||||
// the connection and its outcome.
|
||||
const reused = Boolean(pooled && !pooled.dead && pooled.established);
|
||||
const entry = pooled && !pooled.dead ? pooled : this.createSession(server, key, options);
|
||||
entry.leases += 1;
|
||||
if (entry.idleTimer) { clearTimeout(entry.idleTimer); entry.idleTimer = null; }
|
||||
try {
|
||||
await entry.opening;
|
||||
} catch (error) {
|
||||
entry.leases -= 1;
|
||||
this.markSessionDead(entry);
|
||||
throw error;
|
||||
}
|
||||
return { client: entry.client, fingerprint: entry.fingerprint, reused, entry };
|
||||
}
|
||||
|
||||
markSessionDead(entry) {
|
||||
entry.dead = true;
|
||||
if (this.sessions.get(entry.key) === entry) this.sessions.delete(entry.key);
|
||||
if (entry.idleTimer) { clearTimeout(entry.idleTimer); entry.idleTimer = null; }
|
||||
if (entry.leases <= 0) this.endSession(entry);
|
||||
}
|
||||
|
||||
endSession(entry) {
|
||||
if (!entry.client) return;
|
||||
try { entry.client.end(); } catch { /* already closed */ }
|
||||
}
|
||||
|
||||
releaseSession(session) {
|
||||
const entry = session.entry;
|
||||
entry.leases -= 1;
|
||||
if (entry.dead) { if (entry.leases <= 0) this.endSession(entry); return; }
|
||||
if (entry.leases > 0) return;
|
||||
entry.idleTimer = setTimeout(() => {
|
||||
entry.idleTimer = null;
|
||||
this.markSessionDead(entry);
|
||||
}, this.idleConnectionMs);
|
||||
entry.idleTimer.unref?.();
|
||||
}
|
||||
|
||||
discardSession(session) {
|
||||
const entry = session.entry;
|
||||
entry.leases -= 1;
|
||||
this.markSessionDead(entry);
|
||||
}
|
||||
|
||||
// Closes every pooled connection. The application calls this while quitting so
|
||||
// no socket outlives the process.
|
||||
closeAll() {
|
||||
for (const entry of [...this.sessions.values()]) {
|
||||
entry.leases = 0;
|
||||
this.markSessionDead(entry);
|
||||
}
|
||||
}
|
||||
|
||||
async withDedicatedClient(server, action, options = {}, { keepOpen = false } = {}) {
|
||||
const serverId = server.id;
|
||||
const Client = this.clientFactory();
|
||||
const connection = await this.connectionOptions(server, options);
|
||||
const client = new Client();
|
||||
const started = Date.now();
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (callback, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
// A session that stays in the pool is closed by the pool, not here.
|
||||
if (!(keepOpen && callback === resolve)) { try { client.end(); } catch { /* already closed */ } }
|
||||
callback(value);
|
||||
};
|
||||
client.once('ready', async () => {
|
||||
try {
|
||||
const data = await action(client, server, connection.getObservedFingerprint());
|
||||
await this.diagnostics?.debug('ssh.connection.completed', { serverId, host: server.host, durationMs: Date.now() - started });
|
||||
finish(resolve, data);
|
||||
} catch (error) { finish(reject, error); }
|
||||
});
|
||||
// Deliberately not `once`: a connection that already failed can emit a
|
||||
// second error while it is being torn down, and an unhandled 'error' event
|
||||
// on an EventEmitter terminates the main process.
|
||||
client.on('error', async (error) => {
|
||||
if (settled) return;
|
||||
const observed = connection.getObservedFingerprint();
|
||||
const mismatch = Boolean(server.hostFingerprint && observed && server.hostFingerprint !== observed);
|
||||
const wrapped = new Error(mismatch
|
||||
? `SSH host identity changed. Expected ${server.hostFingerprint}, but the server presented ${observed}.`
|
||||
: `SSH connection failed: ${error.message}`);
|
||||
wrapped.code = mismatch ? 'SSH_HOST_KEY_MISMATCH' : (error.code || 'SSH_CONNECTION_FAILED');
|
||||
wrapped.expectedFingerprint = mismatch ? server.hostFingerprint : undefined;
|
||||
wrapped.observedFingerprint = mismatch ? observed : undefined;
|
||||
await this.diagnostics?.warning('ssh.connection.failed', { serverId, host: server.host, durationMs: Date.now() - started, code: wrapped.code, message: wrapped.message });
|
||||
finish(reject, wrapped);
|
||||
});
|
||||
client.connect(connection.options);
|
||||
});
|
||||
}
|
||||
|
||||
execClient(client, command, { timeout = 15 * 60_000, maxOutput = 2 * 1024 * 1024 } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let completed = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (completed) return;
|
||||
completed = true;
|
||||
reject(new Error('The SSH command timed out.'));
|
||||
}, timeout);
|
||||
client.exec(command, (error, stream) => {
|
||||
if (error) {
|
||||
clearTimeout(timer);
|
||||
completed = true;
|
||||
// The channel never opened, so the command did not reach the server.
|
||||
// This is the only failure the pool is allowed to retry.
|
||||
error.beforeCommand = true;
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let stdoutBytes = 0;
|
||||
let stderrBytes = 0;
|
||||
let truncated = false;
|
||||
const append = (target, chunk) => {
|
||||
const text = chunk.toString();
|
||||
const bytes = Buffer.byteLength(text);
|
||||
if (target === 'stdout') {
|
||||
if (stdoutBytes + bytes <= maxOutput) stdout += text;
|
||||
else truncated = true;
|
||||
stdoutBytes += bytes;
|
||||
} else {
|
||||
if (stderrBytes + bytes <= maxOutput) stderr += text;
|
||||
else truncated = true;
|
||||
stderrBytes += bytes;
|
||||
}
|
||||
};
|
||||
stream.on('data', (chunk) => append('stdout', chunk));
|
||||
stream.stderr.on('data', (chunk) => append('stderr', chunk));
|
||||
stream.on('close', (code, signal) => {
|
||||
if (completed) return;
|
||||
completed = true;
|
||||
clearTimeout(timer);
|
||||
if (truncated) {
|
||||
const failure = new Error(`Remote command output exceeded the ${maxOutput}-byte safety limit. ForgeFlow refused to use an incomplete result.`);
|
||||
failure.code = 'SSH_OUTPUT_TRUNCATED';
|
||||
failure.stdoutBytes = stdoutBytes;
|
||||
failure.stderrBytes = stderrBytes;
|
||||
reject(failure);
|
||||
} else if (code !== 0) {
|
||||
const failure = new Error(`Remote command failed with exit code ${code}: ${(stderr || stdout).trim().slice(-4000)}`);
|
||||
failure.code = 'SSH_COMMAND_FAILED';
|
||||
failure.exitCode = code;
|
||||
failure.signal = signal;
|
||||
reject(failure);
|
||||
} else resolve({ stdout, stderr, exitCode: code, truncated: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ensureUploadTarget(target) {
|
||||
const normalized = String(target || '').replace(/\\/g, '/');
|
||||
if (!normalized.startsWith('/') || normalized.includes('\0') || normalized.split('/').includes('..')) throw new Error('Remote upload path must be an absolute safe Unix path.');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async withSftp(serverId, remotePath, action) {
|
||||
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 = this.ensureUploadTarget(remotePath);
|
||||
return this.withClient(serverId, (client) => new Promise((resolve, reject) => {
|
||||
client.sftp((sftpError, sftp) => {
|
||||
if (sftpError) { reject(sftpError); return; }
|
||||
const parts = path.dirname(target).split('/').filter(Boolean);
|
||||
let current = '';
|
||||
const ensureNext = (index) => {
|
||||
if (index >= parts.length) {
|
||||
Promise.resolve(action(sftp, target)).then(resolve, reject);
|
||||
return;
|
||||
}
|
||||
current += `/${parts[index]}`;
|
||||
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; }
|
||||
ensureNext(index + 1);
|
||||
return;
|
||||
}
|
||||
if (![2, 'ENOENT'].includes(statError.code)) { reject(statError); return; }
|
||||
sftp.mkdir(current, { mode: 0o755 }, (mkdirError) => {
|
||||
if (!mkdirError) { ensureNext(index + 1); return; }
|
||||
sftp.stat(current, (retryError, retryAttributes) => {
|
||||
if (!retryError && (typeof retryAttributes?.isDirectory !== 'function' || retryAttributes.isDirectory())) ensureNext(index + 1);
|
||||
else reject(mkdirError);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
ensureNext(0);
|
||||
});
|
||||
}), { trustOnFirstUse: false });
|
||||
}
|
||||
|
||||
async uploadBuffer(serverId, remotePath, content, { mode = 0o600 } = {}) {
|
||||
const data = Buffer.isBuffer(content) ? content : Buffer.from(content);
|
||||
return this.withSftp(serverId, remotePath, (sftp, target) => new Promise((resolve, reject) => {
|
||||
const stream = sftp.createWriteStream(target, { mode });
|
||||
stream.once('error', reject);
|
||||
stream.once('close', () => resolve({ remotePath: target, size: data.length }));
|
||||
stream.end(data);
|
||||
}));
|
||||
}
|
||||
|
||||
async uploadFile(serverId, localPath, remotePath, { mode = 0o600, onProgress = null } = {}) {
|
||||
const stat = await fsp.stat(localPath);
|
||||
if (!stat.isFile()) throw new Error(`Local upload source is not a file: ${localPath}`);
|
||||
return this.withSftp(serverId, remotePath, (sftp, target) => new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
mode,
|
||||
step: (totalTransferred, _chunk, total) => onProgress?.({ transferred: totalTransferred, total: total || stat.size }),
|
||||
};
|
||||
sftp.fastPut(localPath, target, options, (error) => {
|
||||
if (error) { reject(error); return; }
|
||||
resolve({ remotePath: target, size: stat.size });
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
async probeHostFingerprint(serverId) {
|
||||
const server = this.store.getServer(serverId);
|
||||
if (!server) throw new Error('The configured SSH server no longer exists.');
|
||||
const Client = this.clientFactory();
|
||||
const client = new Client();
|
||||
let observedFingerprint = null;
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (callback, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
try { client.end(); } catch { /* handshake already closed */ }
|
||||
callback(value);
|
||||
};
|
||||
const completeProbe = (error = null) => {
|
||||
if (observedFingerprint) {
|
||||
finish(resolve, {
|
||||
fingerprint: observedFingerprint,
|
||||
server: { id: server.id, name: server.name, host: server.host, port: server.port || 22 },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const wrapped = new Error(`Could not read the SSH host fingerprint: ${error?.message || 'the server closed the handshake'}`);
|
||||
wrapped.code = error?.code || 'SSH_HOST_KEY_PROBE_FAILED';
|
||||
finish(reject, wrapped);
|
||||
};
|
||||
const timer = setTimeout(() => completeProbe(new Error('The SSH host-key probe timed out.')), 25_000);
|
||||
client.on('error', completeProbe);
|
||||
client.on('close', () => completeProbe());
|
||||
client.on('end', () => completeProbe());
|
||||
client.connect({
|
||||
host: server.host,
|
||||
port: server.port || 22,
|
||||
username: server.username,
|
||||
readyTimeout: 20_000,
|
||||
hostVerifier: (key) => {
|
||||
observedFingerprint = fingerprintKey(key);
|
||||
return false;
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async test(serverId, { trustOnFirstUse = false, expectedFingerprint = null } = {}) {
|
||||
return this.withClient(serverId, async (client, server, fingerprint) => {
|
||||
const script = `
|
||||
platform=$(uname -srm 2>/dev/null || true)
|
||||
docker=false; docker_ready=false; compose=false; compose_version=''; git=false; tar_ok=false; checksum=false; base_writable=false
|
||||
command -v docker >/dev/null 2>&1 && docker=true
|
||||
[ "$docker" = true ] && docker info >/dev/null 2>&1 && docker_ready=true
|
||||
if [ "$docker" = true ]; then
|
||||
if docker compose version >/dev/null 2>&1; then compose=true; compose_version=$(docker compose version 2>/dev/null | head -n1); elif command -v docker-compose >/dev/null 2>&1; then compose=true; compose_version=$(docker-compose version 2>/dev/null | head -n1); fi
|
||||
fi
|
||||
command -v git >/dev/null 2>&1 && git=true
|
||||
command -v tar >/dev/null 2>&1 && tar_ok=true
|
||||
(command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1) && checksum=true
|
||||
base=${shellQuote(server.basePath)}
|
||||
if [ -d "$base" ]; then [ -w "$base" ] && base_writable=true; else parent=$(dirname "$base"); [ -d "$parent" ] && [ -w "$parent" ] && base_writable=true; fi
|
||||
printf '__FORGEFLOW_SERVER_TEST__\\n'
|
||||
printf 'platform=%s\\n' "$(printf '%s' "$platform" | base64 | tr -d '\\r\\n')"
|
||||
printf 'docker=%s\\n' "$docker"
|
||||
printf 'dockerReady=%s\\n' "$docker_ready"
|
||||
printf 'compose=%s\\n' "$compose"
|
||||
printf 'composeVersion=%s\\n' "$(printf '%s' "$compose_version" | base64 | tr -d '\\r\\n')"
|
||||
printf 'git=%s\\n' "$git"
|
||||
printf 'tar=%s\\n' "$tar_ok"
|
||||
printf 'checksum=%s\\n' "$checksum"
|
||||
printf 'baseWritable=%s\\n' "$base_writable"
|
||||
`;
|
||||
const result = await this.execClient(client, script, { timeout: 30_000, maxOutput: 256 * 1024 });
|
||||
const capabilities = parseCapabilityOutput(result.stdout);
|
||||
return {
|
||||
connected: true,
|
||||
fingerprint,
|
||||
server: { id: server.id, name: server.name, host: server.host, basePath: server.basePath },
|
||||
capabilities,
|
||||
output: [capabilities.platform, capabilities.composeVersion].filter(Boolean).join('\n'),
|
||||
};
|
||||
}, { trustOnFirstUse, expectedFingerprint });
|
||||
}
|
||||
|
||||
async exec(serverId, command, options = {}) {
|
||||
const server = this.store.getServer(serverId);
|
||||
if (!server?.hostFingerprint) {
|
||||
const error = new Error('Test and trust the SSH server fingerprint before running deployment commands.');
|
||||
error.code = 'SSH_HOST_NOT_TRUSTED';
|
||||
throw error;
|
||||
}
|
||||
return this.withClient(serverId, (client) => this.execClient(client, command, options), { trustOnFirstUse: false });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SshService, shellQuote, fingerprintKey, parseCapabilityOutput };
|
||||
@@ -0,0 +1,461 @@
|
||||
"use strict";
|
||||
|
||||
function createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto, parsePermissionInspection, safeRelativeRemoteFile }) {
|
||||
class UnraidAccessMethods {
|
||||
serverGitRemote(repository, profile) {
|
||||
const candidates = [
|
||||
repository.localStatus?.remoteUrl,
|
||||
repository.sshUrl,
|
||||
repository.preferredCloneUrl,
|
||||
profile.cloneUrl,
|
||||
]
|
||||
.map((value) => String(value || "").trim())
|
||||
.filter(Boolean);
|
||||
const value = candidates.find((candidate) => /^ssh:\/\//i.test(candidate) || /^[^@\s]+@[^:\s]+:.+/.test(candidate));
|
||||
if (!value) {
|
||||
const error = new Error("Server pull requires the repository SSH clone URL from Gitea.");
|
||||
error.code = "SERVER_GIT_SSH_URL_REQUIRED";
|
||||
throw error;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
serverGitHost(repository, profile) {
|
||||
const remote = this.serverGitRemote(repository, profile);
|
||||
if (/^ssh:\/\//i.test(remote)) {
|
||||
const parsed = new URL(remote);
|
||||
return { host: parsed.hostname, port: Number(parsed.port || 22) };
|
||||
}
|
||||
const match = remote.match(/^[^@\s]+@([^:\s]+):/);
|
||||
if (!match) throw new Error("Could not determine the Gitea SSH host from the clone URL.");
|
||||
return { host: match[1], port: 22 };
|
||||
}
|
||||
|
||||
serverGitCredentialPaths(repository, server) {
|
||||
const repositoryId = crypto.createHash("sha256").update(String(repository.fullName).toLowerCase()).digest("hex").slice(0, 24);
|
||||
const directory = path.join(server.basePath, ".forgeflow", "git-credentials", repositoryId);
|
||||
return {
|
||||
directory,
|
||||
privateKey: path.join(directory, "deploy-key"),
|
||||
publicKey: path.join(directory, "deploy-key.pub"),
|
||||
knownHosts: path.join(directory, "known_hosts"),
|
||||
};
|
||||
}
|
||||
|
||||
serverGitEnvironment(repository, profile, server) {
|
||||
const credentials = this.serverGitCredentialPaths(repository, server);
|
||||
return `GIT_SSH_COMMAND=${shellQuote(`ssh -i ${credentials.privateKey} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${credentials.knownHosts}`)}`;
|
||||
}
|
||||
|
||||
async configureServerGitAccess({ repository, profileId }) {
|
||||
const { profile, server } = this.resolve(repository, profileId);
|
||||
const remote = this.serverGitRemote(repository, profile);
|
||||
const { host, port } = this.serverGitHost(repository, profile);
|
||||
const credentials = this.serverGitCredentialPaths(repository, server);
|
||||
const trustedHostFingerprint = String(profile.serverGitAccess?.hostFingerprint || "").trim();
|
||||
const marker = "__FORGEFLOW_DEPLOY_KEY__";
|
||||
const setupScript = `
|
||||
command -v git >/dev/null 2>&1 || { echo "Git is not installed on the server" >&2; exit 41; }
|
||||
command -v ssh-keygen >/dev/null 2>&1 || { echo "ssh-keygen is not installed on the server" >&2; exit 42; }
|
||||
command -v ssh-keyscan >/dev/null 2>&1 || { echo "ssh-keyscan is not installed on the server" >&2; exit 43; }
|
||||
credential_dir=${shellQuote(credentials.directory)}
|
||||
private_key=${shellQuote(credentials.privateKey)}
|
||||
public_key=${shellQuote(credentials.publicKey)}
|
||||
known_hosts=${shellQuote(credentials.knownHosts)}
|
||||
expected_host_fingerprint=${shellQuote(trustedHostFingerprint)}
|
||||
mkdir -p "$credential_dir"
|
||||
chmod 700 "$credential_dir"
|
||||
if [ ! -s "$private_key" ] || [ ! -s "$public_key" ]; then
|
||||
rm -f "$private_key" "$public_key"
|
||||
ssh-keygen -q -t ed25519 -N '' -C ${shellQuote(`forgeflow:${repository.fullName}`)} -f "$private_key"
|
||||
fi
|
||||
chmod 600 "$private_key"
|
||||
chmod 644 "$public_key"
|
||||
scan_tmp="$known_hosts.$$.tmp"
|
||||
scan_ok=false
|
||||
for attempt in 1 2 3; do
|
||||
ssh-keyscan -T 10 -H -p ${Number(port)} ${shellQuote(host)} > "$scan_tmp" 2>/dev/null || true
|
||||
if [ -s "$scan_tmp" ]; then scan_ok=true; break; fi
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
[ "$scan_ok" = true ] || { rm -f "$scan_tmp"; echo "Gitea SSH host did not return a host key after three attempts" >&2; exit 44; }
|
||||
scanned_host_fingerprint="$(ssh-keygen -lf "$scan_tmp" -E sha256 2>/dev/null | awk '{print $2}' | sort -u | paste -sd, -)"
|
||||
if [ -n "$expected_host_fingerprint" ] && [ "$scanned_host_fingerprint" != "$expected_host_fingerprint" ]; then
|
||||
rm -f "$scan_tmp"
|
||||
echo "The Gitea SSH host key changed. Verify the Gitea server before replacing trust." >&2
|
||||
exit 46
|
||||
fi
|
||||
mv "$scan_tmp" "$known_hosts"
|
||||
chmod 600 "$known_hosts"
|
||||
printf '%s\n' ${shellQuote(marker)}
|
||||
printf 'publicKey=%s\n' "$(base64 < "$public_key" | tr -d '\\r\\n')"
|
||||
printf 'fingerprint=%s\n' "$(ssh-keygen -lf "$public_key" -E sha256 | awk '{print $2}')"
|
||||
printf 'hostFingerprint=%s\n' "$scanned_host_fingerprint"
|
||||
`;
|
||||
const setup = await this.ssh.exec(server.id, bash(setupScript), { timeout: 60_000, maxOutput: 256 * 1024 });
|
||||
const output = String(setup.stdout || "");
|
||||
const markerIndex = output.lastIndexOf(marker);
|
||||
if (markerIndex < 0) throw new Error("The server did not return the generated deploy key.");
|
||||
const fields = Object.fromEntries(output.slice(markerIndex + marker.length).trim().split(/\r?\n/).map((line) => {
|
||||
const separator = line.indexOf("=");
|
||||
return separator > 0 ? [line.slice(0, separator), line.slice(separator + 1)] : [line, ""];
|
||||
}));
|
||||
if (trustedHostFingerprint && fields.hostFingerprint && trustedHostFingerprint !== fields.hostFingerprint) {
|
||||
const error = new Error("The Gitea SSH host key changed. Server pull was not reconfigured. Verify the Gitea server before replacing trust.");
|
||||
error.code = "GITEA_SSH_HOST_KEY_MISMATCH";
|
||||
throw error;
|
||||
}
|
||||
const publicKey = Buffer.from(fields.publicKey || "", "base64").toString("utf8").trim();
|
||||
const [owner, repo] = String(repository.fullName || "").split("/");
|
||||
if (!owner || !repo) throw new Error("A full Gitea repository name is required to configure server pull.");
|
||||
const deployKey = await this.gitea.ensureReadOnlyDeployKey({
|
||||
owner,
|
||||
repo,
|
||||
title: `ForgeFlow · ${server.name} · read-only`,
|
||||
publicKey,
|
||||
});
|
||||
const probeCommand = `${this.serverGitEnvironment(repository, profile, server)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)}`;
|
||||
const probe = await this.ssh.exec(
|
||||
server.id,
|
||||
bash(`probe_error=''
|
||||
for attempt in 1 2 3; do
|
||||
if probe_output=$(${probeCommand} 2>&1); then printf '%s\n' "$probe_output"; exit 0; fi
|
||||
probe_error=$probe_output
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
printf '%s\n' "$probe_error" >&2
|
||||
exit 45`),
|
||||
{ timeout: 45_000, maxOutput: 256 * 1024 },
|
||||
);
|
||||
const remoteSha = String(probe.stdout || "").trim().split(/\s+/)[0] || null;
|
||||
const updated = await this.store.saveDeploymentProfile(repository.fullName, {
|
||||
...profile,
|
||||
deploymentMode: "server-git",
|
||||
cloneUrl: remote,
|
||||
serverGitAccess: {
|
||||
configured: true,
|
||||
deployKeyId: deployKey.id || null,
|
||||
keyFingerprint: fields.fingerprint || null,
|
||||
hostFingerprint: fields.hostFingerprint || null,
|
||||
configuredAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
return {
|
||||
profile: updated,
|
||||
created: deployKey.created === true,
|
||||
remoteSha,
|
||||
keyFingerprint: fields.fingerprint || null,
|
||||
hostFingerprint: fields.hostFingerprint || null,
|
||||
};
|
||||
}
|
||||
|
||||
async probeServerGitAccess({ repository, profile, server }) {
|
||||
try {
|
||||
const remote = this.serverGitRemote(repository, profile);
|
||||
const credentials = this.serverGitCredentialPaths(repository, server);
|
||||
const trustedHostFingerprint = String(profile.serverGitAccess?.hostFingerprint || "").trim();
|
||||
const trustedKeyFingerprint = String(profile.serverGitAccess?.keyFingerprint || "").trim();
|
||||
const command = `[ -s ${shellQuote(credentials.privateKey)} ] && [ -s ${shellQuote(credentials.publicKey)} ] && [ -s ${shellQuote(credentials.knownHosts)} ] && actual_host_fingerprint="$(ssh-keygen -lf ${shellQuote(credentials.knownHosts)} -E sha256 2>/dev/null | awk '{print $2}' | sort -u | paste -sd, -)" && actual_key_fingerprint="$(ssh-keygen -lf ${shellQuote(credentials.publicKey)} -E sha256 2>/dev/null | awk '{print $2}')" && { [ -z ${shellQuote(trustedHostFingerprint)} ] || [ "$actual_host_fingerprint" = ${shellQuote(trustedHostFingerprint)} ]; } && { [ -z ${shellQuote(trustedKeyFingerprint)} ] || [ "$actual_key_fingerprint" = ${shellQuote(trustedKeyFingerprint)} ]; } && remote_output="$(${this.serverGitEnvironment(repository, profile, server)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)})" && remote_sha="$(printf '%s' "$remote_output" | awk 'NR==1 {print $1}')" && printf '__FORGEFLOW_SERVER_GIT_PROBE__\nremoteSha=%s\nkeyFingerprint=%s\nhostFingerprint=%s\n' "$remote_sha" "$actual_key_fingerprint" "$actual_host_fingerprint"`;
|
||||
const result = await this.ssh.exec(server.id, bash(command), { timeout: 45_000, maxOutput: 256 * 1024 });
|
||||
const output = String(result.stdout || "");
|
||||
const marker = output.lastIndexOf("__FORGEFLOW_SERVER_GIT_PROBE__");
|
||||
if (marker < 0) throw new Error("The server pull probe did not return verifiable fingerprint evidence.");
|
||||
const fields = Object.fromEntries(output.slice(marker + "__FORGEFLOW_SERVER_GIT_PROBE__".length).trim().split(/\r?\n/).map((line) => {
|
||||
const separator = line.indexOf("=");
|
||||
return separator > 0 ? [line.slice(0, separator), line.slice(separator + 1)] : [line, ""];
|
||||
}));
|
||||
return { ready: true, remoteSha: fields.remoteSha || null, keyFingerprint: fields.keyFingerprint || null, hostFingerprint: fields.hostFingerprint || null };
|
||||
} catch (error) {
|
||||
return { ready: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async verifyServerGitProfile({ repository, profileId }) {
|
||||
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
||||
const checks = [];
|
||||
const add = (id, label, status, detail, evidence = {}) => checks.push({ id, label, status, detail, evidence });
|
||||
if (profile.deploymentMode === "monitor-only") {
|
||||
add("mode", "Deployment mode", "warning", "This profile is monitoring only and cannot deploy.");
|
||||
return { readiness: "Monitoring only", ready: false, checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, checks };
|
||||
}
|
||||
if (profile.deploymentMode !== "server-git") {
|
||||
add("mode", "Deployment mode", "unsupported", "Read-only server-pull verification applies only to Server pull profiles.");
|
||||
return { readiness: "Unsupported", ready: false, checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, checks };
|
||||
}
|
||||
let branchSha = null;
|
||||
try {
|
||||
const [owner, repo] = String(repository.fullName || "").split("/");
|
||||
const branch = await this.gitea.getBranch(owner, repo, profile.branch);
|
||||
branchSha = branch?.commit?.id || branch?.commit?.sha || null;
|
||||
add("remote-branch", "Gitea branch", branchSha ? "pass" : "fail", branchSha ? `${profile.branch} at ${branchSha}` : `${profile.branch} did not return a commit SHA.`, { branch: profile.branch, sha: branchSha });
|
||||
const keys = await this.gitea.listDeployKeys(owner, repo);
|
||||
const keyId = Number(profile.serverGitAccess?.deployKeyId);
|
||||
const key = keys.find((item) => Number(item.id) === keyId);
|
||||
add("deploy-key-scope", "Repository deploy key", key?.read_only === true ? "pass" : "fail", !key ? "The configured deploy key is no longer present in Gitea." : key.read_only === true ? `Key ${key.id} is repository-scoped and read-only.` : `Key ${key.id} has write access and is blocked.`, { keyId: key?.id || keyId || null, readOnly: key?.read_only === true });
|
||||
} catch (error) {
|
||||
add("gitea-access", "Gitea verification", "fail", error.message);
|
||||
}
|
||||
const access = await this.probeServerGitAccess({ repository, profile, server });
|
||||
add("server-git-access", "Unraid to Gitea", access.ready ? "pass" : "fail", access.ready ? `Exact branch access verified at ${String(access.remoteSha || "unknown").slice(0, 12)}.` : access.error, access);
|
||||
let inspection = null;
|
||||
try {
|
||||
inspection = await this.inspect({ repository, profileId });
|
||||
const expectedCompose = profile.generatedCompose ? [".forgeflow/compose.forgeflow.yml"] : this.deploymentComposeFiles(profile);
|
||||
const composePresent = !inspection.exists || expectedCompose.every((file) => inspection.composeFiles.includes(file));
|
||||
add("deployment-directory", "Deployment directory", inspection.exists ? "pass" : "warning", inspection.exists ? remotePath : `${remotePath} will be created on first deployment.`, { remotePath, exists: inspection.exists });
|
||||
add("compose", "Compose configuration", composePresent ? "pass" : "warning", composePresent ? expectedCompose.join(", ") : `Expected after deployment: ${expectedCompose.join(", ")}.`, { files: expectedCompose });
|
||||
add("preserved-paths", "Preserved runtime paths", "pass", (profile.preservePaths || []).length ? profile.preservePaths.join(", ") : "No preserved runtime paths configured.", { paths: profile.preservePaths || [] });
|
||||
add("environment-requirements", "Environment requirements", "pass", (profile.detectedMetadata?.envNames || []).length ? `${profile.detectedMetadata.envNames.length} variable name(s) detected; values remain hidden.` : "No environment variable names were detected in server metadata.", { names: profile.detectedMetadata?.envNames || [] });
|
||||
} catch (error) {
|
||||
add("server-inspection", "Server inspection", "fail", error.message);
|
||||
}
|
||||
const state = this.store.getDeploymentState(profile.id) || {};
|
||||
const liveSha = state.liveSha || inspection?.head || null;
|
||||
const running = state.containerRunning;
|
||||
const healthy = state.healthy;
|
||||
add("live-commit", "Live server commit", liveSha ? "pass" : "warning", liveSha || "No verifiable live commit is currently recorded.", { liveSha });
|
||||
add("commit-parity", "Gitea and server parity", branchSha && liveSha && branchSha === liveSha ? "pass" : branchSha && liveSha ? "warning" : "incomplete", branchSha && liveSha ? branchSha === liveSha ? "The exact Gitea commit is live." : `Live ${String(liveSha).slice(0, 12)} differs from Gitea ${String(branchSha).slice(0, 12)}.` : "Parity cannot be proven until both SHAs are available.", { branchSha, liveSha });
|
||||
add("runtime", "Container runtime", running === true ? "pass" : running === false ? "fail" : "incomplete", running === true ? "The linked container is running." : running === false ? "The linked container is stopped." : "Runtime state has not been verified.");
|
||||
add("health", "Runtime health", healthy === true ? "pass" : healthy === false ? "fail" : "incomplete", healthy === true ? "Runtime health passed." : healthy === false ? "Runtime health failed." : "No conclusive runtime health evidence is available.");
|
||||
const deploymentCheckIds = new Set(["gitea-access", "remote-branch", "deploy-key-scope", "server-git-access", "server-inspection"]);
|
||||
const deploymentBlockers = checks.filter((item) => deploymentCheckIds.has(item.id) && item.status !== "pass");
|
||||
const deployReady = Boolean(branchSha) && deploymentBlockers.length === 0;
|
||||
const failed = checks.some((item) => item.status === "fail");
|
||||
const incomplete = checks.some((item) => ["warning", "incomplete", "unsupported"].includes(item.status));
|
||||
const readiness = deploymentBlockers.length
|
||||
? "Access failed"
|
||||
: failed
|
||||
? "Deploy-ready; runtime unhealthy"
|
||||
: incomplete
|
||||
? (branchSha && liveSha && branchSha !== liveSha ? "Deployable update available" : "Deploy-ready; runtime verification incomplete")
|
||||
: "Ready";
|
||||
return { readiness, ready: deployReady, deployReady, deploymentBlockers, checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, server: { id: server.id, name: server.name }, remotePath, branch: profile.branch, branchSha, liveSha, checks };
|
||||
}
|
||||
|
||||
permissionTargets(profile, server, remotePath) {
|
||||
const targets = [
|
||||
{
|
||||
id: "server-base",
|
||||
label: "Configured deployment base",
|
||||
path: server.basePath,
|
||||
kind: "directory",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: "project-root",
|
||||
label: "Project folder",
|
||||
path: remotePath,
|
||||
kind: "directory",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "forgeflow-state",
|
||||
label: "ForgeFlow upload and rollback storage",
|
||||
path: path.join(remotePath, ".forgeflow"),
|
||||
kind: "directory",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "forgeflow-incoming",
|
||||
label: "ForgeFlow incoming upload folder",
|
||||
path: path.join(remotePath, ".forgeflow", "incoming"),
|
||||
kind: "directory",
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
if (!profile.generatedCompose) {
|
||||
for (const file of this.deploymentComposeFiles(profile)) {
|
||||
targets.push({
|
||||
id: `compose:${file}`,
|
||||
label: `Compose file ${file}`,
|
||||
path: path.join(remotePath, file),
|
||||
kind: "file",
|
||||
required: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
const unique = new Map();
|
||||
for (const target of targets) unique.set(`${target.kind}:${target.path}`, target);
|
||||
return [...unique.values()];
|
||||
}
|
||||
|
||||
permissionInspectionScript(profile, server, remotePath) {
|
||||
const targetCalls = this.permissionTargets(profile, server, remotePath)
|
||||
.map(
|
||||
(target) =>
|
||||
`probe ${shellQuote(target.id)} ${shellQuote(target.label)} ${shellQuote(target.path)} ${shellQuote(target.kind)} ${target.required ? "true" : "false"}`,
|
||||
)
|
||||
.join("\n");
|
||||
return `
|
||||
encode() { printf '%s' "$1" | base64 | tr -d '\\r\\n'; }
|
||||
can_elevate=false
|
||||
[ "$(id -u)" = 0 ] && can_elevate=true
|
||||
if [ "$can_elevate" != true ] && command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then can_elevate=true; fi
|
||||
has_acl=false
|
||||
command -v setfacl >/dev/null 2>&1 && has_acl=true
|
||||
printf '__FORGEFLOW_PERMISSIONS__\\n'
|
||||
printf 'I\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \
|
||||
"$(encode "$(id -un 2>/dev/null || echo unknown)")" \
|
||||
"$(id -u 2>/dev/null || echo -1)" \
|
||||
"$(id -g 2>/dev/null || echo -1)" \
|
||||
"$(encode "$(id -Gn 2>/dev/null || true)")" \
|
||||
"$has_acl" "$can_elevate"
|
||||
probe() {
|
||||
target_id=$1
|
||||
label=$2
|
||||
target=$3
|
||||
kind=$4
|
||||
required=$5
|
||||
exists=false; readable=false; writable=false; parent_writable=false; effective=false
|
||||
owner=''; group=''; mode=''; detail=''; nearest=''
|
||||
if [ -e "$target" ] || [ -L "$target" ]; then
|
||||
exists=true
|
||||
[ -r "$target" ] && readable=true
|
||||
[ -w "$target" ] && writable=true
|
||||
owner=$(stat -c '%U' "$target" 2>/dev/null || true)
|
||||
group=$(stat -c '%G' "$target" 2>/dev/null || true)
|
||||
mode=$(stat -c '%a' "$target" 2>/dev/null || true)
|
||||
fi
|
||||
parent=$(dirname "$target")
|
||||
ancestor=$parent
|
||||
while [ ! -d "$ancestor" ] && [ "$ancestor" != / ]; do ancestor=$(dirname "$ancestor"); done
|
||||
nearest=$ancestor
|
||||
marker="$ancestor/.forgeflow-write-test-$$-\${RANDOM:-0}"
|
||||
if [ -d "$ancestor" ] && (umask 077; : > "$marker") 2>/dev/null; then
|
||||
rm -f -- "$marker" >/dev/null 2>&1 || true
|
||||
parent_writable=true
|
||||
fi
|
||||
if [ "$kind" = directory ]; then
|
||||
if [ -d "$target" ]; then
|
||||
marker="$target/.forgeflow-write-test-$$-\${RANDOM:-0}"
|
||||
if (umask 077; : > "$marker") 2>/dev/null; then
|
||||
rm -f -- "$marker" >/dev/null 2>&1 || true
|
||||
effective=true
|
||||
fi
|
||||
elif [ "$parent_writable" = true ]; then
|
||||
effective=true
|
||||
fi
|
||||
else
|
||||
if [ "$exists" = true ] && [ ! -f "$target" ]; then
|
||||
detail='Path exists but is not a regular file.'
|
||||
elif [ "$exists" = true ] && [ "$readable" = true ] && { [ "$writable" = true ] || [ "$parent_writable" = true ]; }; then
|
||||
effective=true
|
||||
elif [ "$exists" = false ] && [ "$parent_writable" = true ]; then
|
||||
effective=true
|
||||
detail='File is absent but can be created by the deployment user.'
|
||||
fi
|
||||
fi
|
||||
if [ -z "$detail" ]; then
|
||||
if [ "$effective" = true ]; then detail='Read/write probe passed.'
|
||||
else detail="No safe create/replace access for $(id -un 2>/dev/null || echo 'the SSH user')."; fi
|
||||
fi
|
||||
printf 'P\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \
|
||||
"$(encode "$target_id")" "$(encode "$label")" "$(encode "$target")" "$kind" "$required" \
|
||||
"$exists" "$readable" "$writable" "$parent_writable" "$effective" \
|
||||
"$(encode "$owner")" "$(encode "$group")" "$mode" "$(encode "$nearest")" "$(encode "$detail")"
|
||||
}
|
||||
${targetCalls}
|
||||
`;
|
||||
}
|
||||
|
||||
async inspectWriteAccess({ repository, profileId }) {
|
||||
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
||||
const result = await this.ssh.exec(
|
||||
server.id,
|
||||
bash(this.permissionInspectionScript(profile, server, remotePath)),
|
||||
{ timeout: 45_000, maxOutput: 2 * 1024 * 1024 },
|
||||
);
|
||||
const report = parsePermissionInspection(result.stdout);
|
||||
report.serverId = server.id;
|
||||
report.remotePath = remotePath;
|
||||
return report;
|
||||
}
|
||||
|
||||
permissionRepairScript(profile, server, remotePath) {
|
||||
const preserve = [
|
||||
".git",
|
||||
"node_modules",
|
||||
".venv",
|
||||
"venv",
|
||||
"__pycache__",
|
||||
...(profile.preservePaths || []),
|
||||
]
|
||||
.map((value) => safeRelativeRemoteFile(value))
|
||||
.filter(Boolean);
|
||||
const pruneExpression = preserve.length
|
||||
? preserve
|
||||
.map((value) => `-path ${shellQuote(path.join(remotePath, value))} -o -path ${shellQuote(path.join(remotePath, value, "*"))}`)
|
||||
.join(" -o ")
|
||||
: "-false";
|
||||
const composePaths = this.deploymentComposeFiles(profile)
|
||||
.map((file) => shellQuote(path.join(remotePath, file)))
|
||||
.join(" ");
|
||||
return `
|
||||
root=${shellQuote(remotePath)}
|
||||
base=${shellQuote(server.basePath)}
|
||||
case "$root" in "$base"|"$base"/*) ;; *) echo "Refusing permission repair outside configured base path: $root" >&2; exit 81 ;; esac
|
||||
run_privileged() {
|
||||
if [ "$(id -u)" = 0 ]; then "$@";
|
||||
elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then sudo -n "$@";
|
||||
else "$@";
|
||||
fi
|
||||
}
|
||||
mkdir_cmd=mkdir
|
||||
if ! mkdir -p "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null; then
|
||||
run_privileged mkdir -p "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups"
|
||||
fi
|
||||
share_group=$(getent group users >/dev/null 2>&1 && echo users || id -gn)
|
||||
if command -v setfacl >/dev/null 2>&1; then
|
||||
run_privileged setfacl -m "u:$(id -un):rwx,g:$share_group:rwx,m:rwx" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true
|
||||
run_privileged setfacl -d -m "u:$(id -un):rwx,g:$share_group:rwx,m:rwx" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true
|
||||
fi
|
||||
run_privileged chgrp "$share_group" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true
|
||||
run_privileged chmod 2775 "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups"
|
||||
if [ -d "$root" ]; then
|
||||
while IFS= read -r -d '' entry; do
|
||||
case "$entry" in
|
||||
"$root/.forgeflow"|"$root/.forgeflow"/*) continue ;;
|
||||
esac
|
||||
run_privileged chgrp "$share_group" "$entry" 2>/dev/null || true
|
||||
if [ -d "$entry" ]; then run_privileged chmod u+rwx,g+rwx,g+s "$entry"; else run_privileged chmod u+rw,g+rw "$entry"; fi
|
||||
done < <(find "$root" -mindepth 1 \\( ${pruneExpression} \\) -prune -o -print0)
|
||||
fi
|
||||
for compose_file in ${composePaths || ""}; do
|
||||
[ -e "$compose_file" ] || continue
|
||||
run_privileged chgrp "$share_group" "$compose_file" 2>/dev/null || true
|
||||
run_privileged chmod u+rw,g+rw "$compose_file"
|
||||
done
|
||||
echo "ForgeFlow repaired project write access for $(id -un) and group $share_group without changing preserved runtime paths."
|
||||
`;
|
||||
}
|
||||
|
||||
async repairWriteAccess({ repository, profileId }) {
|
||||
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
||||
const before = await this.inspectWriteAccess({ repository, profileId });
|
||||
await this.ssh.exec(server.id, bash(this.permissionRepairScript(profile, server, remotePath)), {
|
||||
timeout: 5 * 60_000,
|
||||
maxOutput: 4 * 1024 * 1024,
|
||||
});
|
||||
const after = await this.inspectWriteAccess({ repository, profileId });
|
||||
if (!after.ready) {
|
||||
const error = new Error(
|
||||
`Write-access repair did not make every required path writable: ${after.blocking.map((item) => item.path).join(", ")}`,
|
||||
);
|
||||
error.code = "WRITE_ACCESS_REPAIR_INCOMPLETE";
|
||||
error.permissionReport = after;
|
||||
throw error;
|
||||
}
|
||||
await this.diagnostics?.info("unraid.write-access.repaired", {
|
||||
repository: repository.fullName,
|
||||
profileId,
|
||||
serverId: server.id,
|
||||
remotePath,
|
||||
user: after.identity.user,
|
||||
});
|
||||
return { changed: true, normalized: true, before, after };
|
||||
}
|
||||
}
|
||||
return UnraidAccessMethods.prototype;
|
||||
}
|
||||
|
||||
module.exports = { createUnraidAccessMethods };
|
||||
@@ -0,0 +1,79 @@
|
||||
"use strict";
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const path = require("node:path").posix;
|
||||
const { shellQuote } = require("./ssh-service.cjs");
|
||||
|
||||
const bash = (command) => `printf '%s' ${shellQuote(Buffer.from(`set -euo pipefail\nexport GIT_TERMINAL_PROMPT=0\n${command}`, "utf8").toString("base64"))} | base64 -d | bash`;
|
||||
function parseMarker(stdout, marker) {
|
||||
const text = String(stdout || "");
|
||||
const index = text.lastIndexOf(marker);
|
||||
if (index < 0) throw new Error(`Server key operation did not return ${marker}.`);
|
||||
return Object.fromEntries(text.slice(index + marker.length).trim().split(/\r?\n/).map((line) => { const separator = line.indexOf("="); return separator > 0 ? [line.slice(0, separator), line.slice(separator + 1)] : [line, ""]; }));
|
||||
}
|
||||
|
||||
class UnraidDeployKeyHost {
|
||||
constructor({ ssh }) { this.ssh = ssh; }
|
||||
paths(repository, server) {
|
||||
const id = crypto.createHash("sha256").update(String(repository.fullName).toLowerCase()).digest("hex").slice(0, 24);
|
||||
const directory = path.join(server.basePath, ".forgeflow", "git-credentials", id);
|
||||
return { directory, privateKey: path.join(directory, "deploy-key"), publicKey: path.join(directory, "deploy-key.pub"), knownHosts: path.join(directory, "known_hosts"), recovery: path.join(directory, "recovery") };
|
||||
}
|
||||
remote(repository, profile) {
|
||||
const value = [repository.localStatus?.remoteUrl, repository.sshUrl, repository.preferredCloneUrl, profile.cloneUrl].map((item) => String(item || "").trim()).find((item) => /^ssh:\/\//i.test(item) || /^[^@\s]+@[^:\s]+:.+/.test(item));
|
||||
if (!value) throw Object.assign(new Error("Server pull requires a Gitea SSH URL."), { code: "SERVER_GIT_SSH_URL_REQUIRED" });
|
||||
return value;
|
||||
}
|
||||
environment(paths) { return `GIT_SSH_COMMAND=${shellQuote(`ssh -i ${paths.privateKey} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${paths.knownHosts}`)}`; }
|
||||
async execute(server, script, options = {}) { return this.ssh.exec(server.id, bash(script), { timeout: options.timeout || 30_000, maxOutput: options.maxOutput || 128 * 1024 }); }
|
||||
async inspect({ repository, server }) {
|
||||
const p = this.paths(repository, server); const marker = "__FORGEFLOW_KEY_INSPECT__";
|
||||
const script = `printf '%s\\n' ${shellQuote(marker)}; printf 'privateKeyPresent=%s\\n' "$([ -s ${shellQuote(p.privateKey)} ] && echo true || echo false)"; printf 'publicKey=%s\\n' "$([ -s ${shellQuote(p.publicKey)} ] && base64 < ${shellQuote(p.publicKey)} | tr -d '\\r\\n' || true)"; printf 'fingerprint=%s\\n' "$([ -s ${shellQuote(p.publicKey)} ] && ssh-keygen -lf ${shellQuote(p.publicKey)} -E sha256 | awk '{print $2}' || true)"; printf 'hostFingerprint=%s\\n' "$([ -s ${shellQuote(p.knownHosts)} ] && ssh-keygen -lf ${shellQuote(p.knownHosts)} -E sha256 | awk '{print $2}' | sort -u | paste -sd, - || true)"`;
|
||||
const f = parseMarker((await this.execute(server, script)).stdout, marker);
|
||||
return { privateKeyPresent: f.privateKeyPresent === "true", publicKey: f.publicKey ? Buffer.from(f.publicKey, "base64").toString("utf8").trim() : null, fingerprint: f.fingerprint || null, hostFingerprint: f.hostFingerprint || null };
|
||||
}
|
||||
async backup({ repository, server }) {
|
||||
const p = this.paths(repository, server); const slot = path.join(p.recovery, `backup-${Date.now()}-${crypto.randomUUID()}`); const marker = "__FORGEFLOW_KEY_BACKUP__";
|
||||
const script = `umask 077; mkdir -p ${shellQuote(slot)}; for name in deploy-key deploy-key.pub known_hosts; do [ ! -e ${shellQuote(p.directory)}/"$name" ] || cp -p ${shellQuote(p.directory)}/"$name" ${shellQuote(slot)}/"$name"; done; printf '%s\\n' ${shellQuote(marker)}; printf 'recovery=%s\\n' ${shellQuote(slot)}; printf 'publicKey=%s\\n' "$([ -s ${shellQuote(p.publicKey)} ] && base64 < ${shellQuote(p.publicKey)} | tr -d '\\r\\n' || true)"`;
|
||||
const f = parseMarker((await this.execute(server, script)).stdout, marker);
|
||||
return { recovery: f.recovery, publicKey: f.publicKey ? Buffer.from(f.publicKey, "base64").toString("utf8").trim() : null };
|
||||
}
|
||||
async generate({ repository, server }) {
|
||||
const active = this.paths(repository, server); const directory = path.join(active.directory, `candidate-${crypto.randomUUID()}`); const p = { directory, privateKey: path.join(directory, "deploy-key"), publicKey: path.join(directory, "deploy-key.pub"), knownHosts: path.join(directory, "known_hosts") }; const marker = "__FORGEFLOW_KEY_CANDIDATE__";
|
||||
const script = `umask 077; mkdir -p ${shellQuote(directory)}; ssh-keygen -q -t ed25519 -N '' -C ${shellQuote(`forgeflow-rotation:${repository.fullName}`)} -f ${shellQuote(p.privateKey)}; cp -p ${shellQuote(active.knownHosts)} ${shellQuote(p.knownHosts)}; chmod 600 ${shellQuote(p.privateKey)} ${shellQuote(p.knownHosts)}; chmod 644 ${shellQuote(p.publicKey)}; printf '%s\\n' ${shellQuote(marker)}; printf 'publicKey=%s\\n' "$(base64 < ${shellQuote(p.publicKey)} | tr -d '\\r\\n')"; printf 'fingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.publicKey)} -E sha256 | awk '{print $2}')"; printf 'hostFingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.knownHosts)} -E sha256 | awk '{print $2}' | sort -u | paste -sd, -)"`;
|
||||
const f = parseMarker((await this.execute(server, script)).stdout, marker);
|
||||
return { paths: p, publicKey: Buffer.from(f.publicKey, "base64").toString("utf8").trim(), fingerprint: f.fingerprint, hostFingerprint: f.hostFingerprint };
|
||||
}
|
||||
async verifyCandidate({ repository, profile, server, candidate }) {
|
||||
const marker = "__FORGEFLOW_KEY_PROOF__"; const remote = this.remote(repository, profile); const p = candidate.paths;
|
||||
const script = `output="$(${this.environment(p)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)})"; printf '%s\\n' ${shellQuote(marker)}; printf 'remoteSha=%s\\n' "$(printf '%s' "$output" | awk 'NR==1 {print $1}')"; printf 'fingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.publicKey)} -E sha256 | awk '{print $2}')"; printf 'hostFingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.knownHosts)} -E sha256 | awk '{print $2}' | sort -u | paste -sd, -)"`;
|
||||
const f = parseMarker((await this.execute(server, script, { timeout: 45_000, maxOutput: 256 * 1024 })).stdout, marker);
|
||||
return { ready: /^[0-9a-f]{40}$/i.test(f.remoteSha || ""), remoteSha: f.remoteSha || null, fingerprint: f.fingerprint || null, hostFingerprint: f.hostFingerprint || null };
|
||||
}
|
||||
// A caller that just verified this candidate passes its proof in. Re-running
|
||||
// `git ls-remote` would open a second SSH connection to ask the same question,
|
||||
// with nothing in between that could change the answer.
|
||||
async preflightCandidate(context) { const proof = context?.proof?.remoteSha ? context.proof : await this.verifyCandidate(context); if (!proof.ready) throw new Error("Candidate preflight did not prove the remote branch."); return proof; }
|
||||
async promote({ repository, server, candidate }) {
|
||||
const p = this.paths(repository, server); const c = candidate.paths;
|
||||
await this.execute(server, `test -s ${shellQuote(c.privateKey)}; test -s ${shellQuote(c.publicKey)}; test -s ${shellQuote(c.knownHosts)}; cp -p ${shellQuote(c.privateKey)} ${shellQuote(p.privateKey)}.new; cp -p ${shellQuote(c.publicKey)} ${shellQuote(p.publicKey)}.new; cp -p ${shellQuote(c.knownHosts)} ${shellQuote(p.knownHosts)}.new; mv ${shellQuote(p.privateKey)}.new ${shellQuote(p.privateKey)}; mv ${shellQuote(p.publicKey)}.new ${shellQuote(p.publicKey)}; mv ${shellQuote(p.knownHosts)}.new ${shellQuote(p.knownHosts)}`);
|
||||
}
|
||||
async verifyActive({ repository, profile, server }) { const paths = this.paths(repository, server); return this.verifyCandidate({ repository, profile, server, candidate: { paths } }); }
|
||||
async rollback({ repository, server, candidate, previous }) {
|
||||
const p = this.paths(repository, server); const recovery = previous.key.recovery;
|
||||
await this.execute(server, `for name in deploy-key deploy-key.pub known_hosts; do test ! -s ${shellQuote(recovery)}/"$name" || cp -p ${shellQuote(recovery)}/"$name" ${shellQuote(p.directory)}/"$name"; done; rm -rf -- ${shellQuote(candidate.paths.directory)}`);
|
||||
}
|
||||
async commit({ server, candidate }) { await this.execute(server, `rm -rf -- ${shellQuote(candidate.paths.directory)}`); }
|
||||
async revoke({ repository, server }) {
|
||||
const p = this.paths(repository, server); const revoked = path.join(p.recovery, `revoked-${Date.now()}-${crypto.randomUUID()}`);
|
||||
await this.execute(server, `umask 077; mkdir -p ${shellQuote(revoked)}; for name in deploy-key deploy-key.pub known_hosts; do [ ! -e ${shellQuote(p.directory)}/"$name" ] || mv ${shellQuote(p.directory)}/"$name" ${shellQuote(revoked)}/"$name"; done`);
|
||||
}
|
||||
async restore({ repository, server }) {
|
||||
const p = this.paths(repository, server); const marker = "__FORGEFLOW_KEY_RESTORE__";
|
||||
const script = `slot="$(find ${shellQuote(p.recovery)} -mindepth 1 -maxdepth 1 -type d -print 2>/dev/null | sort | tail -1)"; test -n "$slot"; for name in deploy-key deploy-key.pub known_hosts; do test -s "$slot/$name"; cp -p "$slot/$name" ${shellQuote(p.directory)}/"$name"; done; printf '%s\\n' ${shellQuote(marker)}; printf 'publicKey=%s\\n' "$(base64 < ${shellQuote(p.publicKey)} | tr -d '\\r\\n')"; printf 'fingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.publicKey)} -E sha256 | awk '{print $2}')"; printf 'hostFingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.knownHosts)} -E sha256 | awk '{print $2}' | sort -u | paste -sd, -)"`;
|
||||
const f = parseMarker((await this.execute(server, script)).stdout, marker);
|
||||
return { publicKey: Buffer.from(f.publicKey, "base64").toString("utf8").trim(), fingerprint: f.fingerprint, hostFingerprint: f.hostFingerprint };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { UnraidDeployKeyHost, parseDeployKeyMarker: parseMarker };
|
||||
@@ -0,0 +1,582 @@
|
||||
"use strict";
|
||||
|
||||
function createUnraidDeploymentMethods({
|
||||
path, crypto, bash, shellQuote, assertFullCommitSha, nativePath, fs,
|
||||
}) {
|
||||
class UnraidDeploymentMethods {
|
||||
pushBundleScript({ repository, profile, remotePath, targetSha, requestId, remotePart, digest, metadata, generated, iconReference, rollback = false }) {
|
||||
const compose = this.composeInvocation(profile, repository);
|
||||
const project = String(
|
||||
profile.composeProject || this.internalSlug(profile, repository),
|
||||
).trim();
|
||||
const candidateFiles = [...this.deploymentComposeFiles(profile)];
|
||||
if (profile.generatedCompose) candidateFiles.push(".forgeflow/compose.metadata.yml");
|
||||
const candidateCompose = `forgeflow_compose -p ${shellQuote(project)} ${candidateFiles
|
||||
.map((file) => `-f "$release"/${shellQuote(file)}`)
|
||||
.join(" ")}`;
|
||||
const preservePayload = Buffer.from(
|
||||
[".forgeflow", ".git", ...(profile.preservePaths || [])].join("\n"),
|
||||
"utf8",
|
||||
).toString("base64");
|
||||
const statusJson = this.deploymentStatusDocument({
|
||||
repository,
|
||||
profile,
|
||||
targetSha,
|
||||
requestId,
|
||||
rollback,
|
||||
});
|
||||
const verification = this.containerVerificationScript(
|
||||
profile,
|
||||
repository,
|
||||
compose,
|
||||
{ requireRecreated: true },
|
||||
);
|
||||
const containerHint = String(
|
||||
profile.containerName || profile.remoteFolder || repository.name || "",
|
||||
).trim();
|
||||
return `
|
||||
root=${shellQuote(remotePath)}
|
||||
expected_project=${shellQuote(project)}
|
||||
tracked_container_hint=${shellQuote(containerHint)}
|
||||
target=${shellQuote(targetSha)}
|
||||
request_id=${shellQuote(requestId)}
|
||||
incoming=${shellQuote(remotePart)}
|
||||
expected_digest=${shellQuote(digest)}
|
||||
release_root="$root/.forgeflow/releases/$target"
|
||||
release="$release_root/source"
|
||||
staging="$root/.forgeflow/staging/$request_id"
|
||||
backup="$root/.forgeflow/backups/$request_id"
|
||||
lock="$root/.forgeflow/deploy.lock"
|
||||
mkdir -p "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups"
|
||||
if [ -d "$lock" ] && find "$lock" -maxdepth 0 -mmin +120 -print -quit | grep -q .; then
|
||||
lock_pid=$(cat "$lock/pid" 2>/dev/null || true)
|
||||
if [ -z "$lock_pid" ] || ! kill -0 "$lock_pid" 2>/dev/null; then rm -rf "$lock"; fi
|
||||
fi
|
||||
mkdir "$lock" 2>/dev/null || { echo "Another ForgeFlow deployment is active for $root" >&2; exit 70; }
|
||||
printf '%s\n' "$request_id" > "$lock/request-id"
|
||||
printf '%s\n' "$$" > "$lock/pid"
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ > "$lock/started-at"
|
||||
restore_needed=false
|
||||
activation_started=false
|
||||
is_preserved() {
|
||||
rel="$1"
|
||||
while IFS= read -r keep; do
|
||||
[ -n "$keep" ] || continue
|
||||
if [ "$rel" = "$keep" ] || [[ "$rel" == "$keep/"* ]]; then return 0; fi
|
||||
done < "$staging.preserve"
|
||||
return 1
|
||||
}
|
||||
restore_files() {
|
||||
if [ -f "$backup/present" ]; then
|
||||
while IFS= read -r rel; do
|
||||
[ -n "$rel" ] || continue
|
||||
mkdir -p -- "$root/$(dirname "$rel")"
|
||||
temp="$root/$rel.forgeflow-restore-$request_id"
|
||||
cp -a -- "$backup/source/$rel" "$temp" && mv -f -- "$temp" "$root/$rel"
|
||||
done < "$backup/present"
|
||||
fi
|
||||
if [ -f "$backup/absent" ]; then
|
||||
while IFS= read -r rel; do
|
||||
[ -n "$rel" ] || continue
|
||||
case "$rel" in .forgeflow/*) continue ;; esac
|
||||
[ -e "$root/$rel" ] || [ -L "$root/$rel" ] || continue
|
||||
rm -f -- "$root/$rel"
|
||||
done < "$backup/absent"
|
||||
fi
|
||||
if [ -f "$backup/generated.present" ]; then
|
||||
cp -a "$backup/compose.forgeflow.yml" "$root/.forgeflow/compose.forgeflow.yml"
|
||||
elif [ -f "$backup/generated.created" ]; then
|
||||
rm -f "$root/.forgeflow/compose.forgeflow.yml"
|
||||
fi
|
||||
if [ -f "$backup/metadata.present" ]; then
|
||||
cp -a "$backup/compose.metadata.yml" "$root/.forgeflow/compose.metadata.yml"
|
||||
elif [ -f "$backup/metadata.created" ]; then
|
||||
rm -f "$root/.forgeflow/compose.metadata.yml"
|
||||
fi
|
||||
}
|
||||
restore_images() {
|
||||
[ -f "$backup/containers.before" ] || return 0
|
||||
while IFS=$'\t' read -r service container_id image_id image_ref_b64; do
|
||||
[ -n "$image_id" ] || continue
|
||||
docker image inspect "$image_id" >/dev/null 2>&1 || continue
|
||||
image_ref=$(printf '%s' "$image_ref_b64" | base64 -d 2>/dev/null || true)
|
||||
case "$image_ref" in ''|sha256:*|*@sha256:*) continue ;; esac
|
||||
docker image tag "$image_id" "$image_ref" >/dev/null 2>&1 || true
|
||||
done < "$backup/containers.before"
|
||||
}
|
||||
restore_runtime() {
|
||||
[ "$activation_started" = true ] || return 0
|
||||
restore_images
|
||||
if cd "$root" 2>/dev/null && ${compose} config >/dev/null 2>&1; then
|
||||
${compose} up -d --no-build >/dev/null 2>&1 || return 1
|
||||
old_services=$(${compose} config --services 2>/dev/null | sed '/^$/d')
|
||||
printf '%s\n' "$old_services" | while IFS= read -r service; do
|
||||
[ -n "$service" ] || continue
|
||||
old_id=$(${compose} ps -q "$service" | head -n1)
|
||||
[ -n "$old_id" ] || exit 1
|
||||
[ "$(docker inspect -f '{{.State.Running}}' "$old_id" 2>/dev/null || echo false)" = true ] || exit 1
|
||||
done
|
||||
fi
|
||||
}
|
||||
finish() {
|
||||
status=$?
|
||||
trap - EXIT
|
||||
set +e
|
||||
if [ "$status" -ne 0 ] && [ "$restore_needed" = true ]; then
|
||||
restore_files
|
||||
if ! restore_runtime; then
|
||||
echo "CRITICAL: source files were restored, but the previous Compose runtime could not be restarted automatically. Backup: $backup" >&2
|
||||
else
|
||||
echo "ForgeFlow restored the previous source and runtime after the failed activation." >&2
|
||||
fi
|
||||
fi
|
||||
rm -rf "$staging" "$lock"
|
||||
exit "$status"
|
||||
}
|
||||
trap finish EXIT
|
||||
actual_digest=$(if command -v sha256sum >/dev/null 2>&1; then sha256sum "$incoming" | awk '{print $1}'; else shasum -a 256 "$incoming" | awk '{print $1}'; fi)
|
||||
[ "$actual_digest" = "$expected_digest" ] || { echo "Uploaded bundle checksum mismatch" >&2; exit 71; }
|
||||
tar -tf "$incoming" > "$staging.entries"
|
||||
if grep -E '(^/|(^|/)\\.\\.(/|$))' "$staging.entries" >/dev/null; then echo "Unsafe path detected in deployment bundle" >&2; exit 72; fi
|
||||
rm -rf "$staging" "$release_root.pending"
|
||||
mkdir -p "$staging/source" "$release_root.pending"
|
||||
tar -xf "$incoming" -C "$staging/source"
|
||||
if find "$staging/source" -type l -print -quit | grep -q .; then echo "Symbolic links are not accepted in push bundles" >&2; exit 73; fi
|
||||
mv "$staging/source" "$release_root.pending/source"
|
||||
find "$release_root.pending/source" -type f -printf '%P\n' | LC_ALL=C sort > "$release_root.pending/managed-files"
|
||||
rm -rf "$release_root"
|
||||
mv "$release_root.pending" "$release_root"
|
||||
rm -f "$incoming" "$staging.entries"
|
||||
printf '%s' ${shellQuote(preservePayload)} | base64 -d > "$staging.preserve"
|
||||
for runtime_config in .env compose.override.yml compose.override.yaml docker-compose.override.yml docker-compose.override.yaml; do
|
||||
if [ -f "$root/$runtime_config" ] && [ ! -e "$release/$runtime_config" ]; then
|
||||
mkdir -p "$release/$(dirname "$runtime_config")"
|
||||
cp -a "$root/$runtime_config" "$release/$runtime_config"
|
||||
fi
|
||||
done
|
||||
${profile.generatedCompose ? `mkdir -p "$release/.forgeflow"
|
||||
cat > "$release/.forgeflow/compose.forgeflow.yml" <<'FORGEFLOW_COMPOSE'
|
||||
${generated}FORGEFLOW_COMPOSE
|
||||
cat > "$release/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA'
|
||||
${metadata}FORGEFLOW_METADATA` : ""}
|
||||
cd "$release"
|
||||
${candidateCompose} config >/dev/null
|
||||
candidate_services=$(${candidateCompose} config --services 2>/dev/null | sed '/^$/d')
|
||||
[ -n "$candidate_services" ] || { echo "Candidate Compose project defines no services" >&2; exit 60; }
|
||||
mkdir -p "$backup/source"
|
||||
: > "$backup/present"
|
||||
: > "$backup/absent"
|
||||
: > "$backup/containers.before"
|
||||
had_existing_compose=false
|
||||
if cd "$root" 2>/dev/null && ${compose} config >/dev/null 2>&1; then
|
||||
had_existing_compose=true
|
||||
old_services=$(${compose} config --services 2>/dev/null | sed '/^$/d')
|
||||
printf '%s\n' "$old_services" | while IFS= read -r service; do
|
||||
[ -n "$service" ] || continue
|
||||
container_id=$(${compose} ps -q "$service" | head -n1)
|
||||
image_id=''; image_ref=''
|
||||
if [ -n "$container_id" ] && docker inspect "$container_id" >/dev/null 2>&1; then
|
||||
image_id=$(docker inspect -f '{{.Image}}' "$container_id" 2>/dev/null || true)
|
||||
image_ref=$(docker inspect -f '{{.Config.Image}}' "$container_id" 2>/dev/null || true)
|
||||
fi
|
||||
printf '%s\t%s\t%s\t%s\n' "$service" "$container_id" "$image_id" "$(printf '%s' "$image_ref" | base64 | tr -d '\r\n')"
|
||||
done >> "$backup/containers.before"
|
||||
fi
|
||||
if [ -n "$tracked_container_hint" ] && docker inspect "$tracked_container_hint" >/dev/null 2>&1; then
|
||||
hint_project=$(docker inspect -f '{{index .Config.Labels "com.docker.compose.project"}}' "$tracked_container_hint" 2>/dev/null || true)
|
||||
if [ -n "$hint_project" ] && [ "$hint_project" != "$expected_project" ]; then
|
||||
echo "Refusing activation: container $tracked_container_hint belongs to Compose project $hint_project, not $expected_project" >&2
|
||||
exit 67
|
||||
fi
|
||||
fi
|
||||
# Build all candidate images before any running container is touched.
|
||||
cd "$release"
|
||||
${candidateCompose} build
|
||||
new_manifest="$release_root/managed-files"
|
||||
while IFS= read -r rel; do
|
||||
[ -n "$rel" ] || continue
|
||||
is_preserved "$rel" && continue
|
||||
parent=$(dirname "$rel")
|
||||
current="$root"
|
||||
if [ "$parent" != . ]; then
|
||||
old_ifs=$IFS; IFS='/'; read -r -a parts <<< "$parent"; IFS=$old_ifs
|
||||
for part in "\${parts[@]}"; do
|
||||
current="$current/$part"
|
||||
[ ! -L "$current" ] || { echo "Refusing to deploy through symlinked parent $current" >&2; exit 74; }
|
||||
done
|
||||
fi
|
||||
[ ! -L "$root/$rel" ] || { echo "Refusing to replace symlinked managed path $rel" >&2; exit 74; }
|
||||
if [ -d "$root/$rel" ]; then echo "A directory conflicts with managed file $rel" >&2; exit 75; fi
|
||||
if [ -e "$root/$rel" ]; then
|
||||
mkdir -p "$backup/source/$(dirname "$rel")"
|
||||
cp -a -- "$root/$rel" "$backup/source/$rel"
|
||||
printf '%s\n' "$rel" >> "$backup/present"
|
||||
else
|
||||
printf '%s\n' "$rel" >> "$backup/absent"
|
||||
fi
|
||||
done < "$new_manifest"
|
||||
[ -f "$root/.forgeflow/compose.metadata.yml" ] && { cp -a "$root/.forgeflow/compose.metadata.yml" "$backup/compose.metadata.yml"; touch "$backup/metadata.present"; }
|
||||
[ -f "$root/.forgeflow/compose.forgeflow.yml" ] && { cp -a "$root/.forgeflow/compose.forgeflow.yml" "$backup/compose.forgeflow.yml"; touch "$backup/generated.present"; }
|
||||
restore_needed=true
|
||||
while IFS= read -r rel; do
|
||||
[ -n "$rel" ] || continue
|
||||
is_preserved "$rel" && continue
|
||||
mkdir -p -- "$root/$(dirname "$rel")"
|
||||
temp="$root/$rel.forgeflow-new-$request_id"
|
||||
cp -a -- "$release/$rel" "$temp"
|
||||
mv -f -- "$temp" "$root/$rel"
|
||||
done < "$new_manifest"
|
||||
mkdir -p "$root/.forgeflow"
|
||||
${profile.generatedCompose ? `if [ ! -f "$backup/generated.present" ]; then touch "$backup/generated.created"; fi
|
||||
if [ ! -f "$backup/metadata.present" ]; then touch "$backup/metadata.created"; fi
|
||||
cat > "$root/.forgeflow/compose.forgeflow.yml.pending" <<'FORGEFLOW_COMPOSE'
|
||||
${generated}FORGEFLOW_COMPOSE
|
||||
mv "$root/.forgeflow/compose.forgeflow.yml.pending" "$root/.forgeflow/compose.forgeflow.yml"
|
||||
cat > "$root/.forgeflow/compose.metadata.yml.pending" <<'FORGEFLOW_METADATA'
|
||||
${metadata}FORGEFLOW_METADATA
|
||||
mv "$root/.forgeflow/compose.metadata.yml.pending" "$root/.forgeflow/compose.metadata.yml"` : `cat > "$root/.forgeflow/deployment-metadata.json.pending" <<'FORGEFLOW_METADATA_JSON'
|
||||
${JSON.stringify({ repository: repository.fullName, environment: profile.environment, commit: targetSha, requestId })}
|
||||
FORGEFLOW_METADATA_JSON
|
||||
mv "$root/.forgeflow/deployment-metadata.json.pending" "$root/.forgeflow/deployment-metadata.json"`}
|
||||
share_group=$(getent group users >/dev/null 2>&1 && echo users || id -gn)
|
||||
chgrp "$share_group" "$root" "$root/.forgeflow" 2>/dev/null || true
|
||||
chmod g+rwx "$root" "$root/.forgeflow" 2>/dev/null || true
|
||||
chmod g+s "$root" "$root/.forgeflow" 2>/dev/null || true
|
||||
while IFS= read -r rel; do
|
||||
[ -n "$rel" ] || continue
|
||||
is_preserved "$rel" && continue
|
||||
chgrp "$share_group" "$root/$rel" 2>/dev/null || true
|
||||
chmod u+rw,g+rw "$root/$rel" 2>/dev/null || true
|
||||
parent="$root/$(dirname "$rel")"
|
||||
chgrp "$share_group" "$parent" 2>/dev/null || true
|
||||
chmod g+rwx,g+s "$parent" 2>/dev/null || true
|
||||
done < "$new_manifest"
|
||||
cd "$root"
|
||||
${compose} config >/dev/null
|
||||
actual_services=$(${compose} config --services 2>/dev/null | sed '/^$/d')
|
||||
[ -n "$actual_services" ] || { echo "Compose project defines no services" >&2; exit 60; }
|
||||
if [ "$(printf '%s\n' "$candidate_services" | LC_ALL=C sort)" != "$(printf '%s\n' "$actual_services" | LC_ALL=C sort)" ]; then
|
||||
echo "Refusing activation because candidate and server Compose service sets differ" >&2
|
||||
exit 68
|
||||
fi
|
||||
before_containers="$backup/containers.before"
|
||||
hint_before_id=''
|
||||
if [ -n "$tracked_container_hint" ] && docker inspect "$tracked_container_hint" >/dev/null 2>&1; then
|
||||
hint_before_id=$(docker inspect -f '{{.Id}}' "$tracked_container_hint" 2>/dev/null || true)
|
||||
fi
|
||||
activation_started=true
|
||||
${compose} up -d --no-build
|
||||
${verification}
|
||||
if [ -n "$hint_before_id" ] && docker inspect "$hint_before_id" >/dev/null 2>&1; then
|
||||
old_hint_running=$(docker inspect -f '{{.State.Running}}' "$hint_before_id" 2>/dev/null || echo false)
|
||||
[ "$old_hint_running" != true ] || { echo "Compose left the previous container $tracked_container_hint ($hint_before_id) running" >&2; exit 66; }
|
||||
fi
|
||||
${this.dockerManRefreshScript(profile, repository, iconReference)}
|
||||
previous=$(cat "$root/.forgeflow/current-sha" 2>/dev/null || true)
|
||||
[ -n "$previous" ] || previous=$(git -C "$root" rev-parse HEAD 2>/dev/null || true)
|
||||
[ -n "$previous" ] && printf '%s' "$previous" > "$root/.forgeflow/previous-sha"
|
||||
cp "$new_manifest" "$root/.forgeflow/managed-files.pending"
|
||||
mv "$root/.forgeflow/managed-files.pending" "$root/.forgeflow/managed-files"
|
||||
printf '%s' "$target" > "$root/.forgeflow/current-sha.pending"
|
||||
mv "$root/.forgeflow/current-sha.pending" "$root/.forgeflow/current-sha"
|
||||
cat > "$root/.forgeflow/status.json.pending" <<'FORGEFLOW_STATUS'
|
||||
${statusJson}
|
||||
FORGEFLOW_STATUS
|
||||
mv "$root/.forgeflow/status.json.pending" "$root/.forgeflow/status.json"
|
||||
restore_needed=false
|
||||
printf '%s\n' "successful" > "$backup/result"
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ > "$backup/completed-at"
|
||||
echo "ForgeFlow safely activated push bundle $target; rollback evidence retained at $backup"
|
||||
`;
|
||||
}
|
||||
|
||||
async executePushBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference, rollback = false }) {
|
||||
const permissionReport = await this.inspectWriteAccess({
|
||||
repository,
|
||||
profileId: profile.id,
|
||||
});
|
||||
if (!permissionReport.ready) {
|
||||
const error = new Error(
|
||||
`Deployment stopped before upload because write access is missing for: ${permissionReport.blocking.map((item) => item.path).join(", ")}`,
|
||||
);
|
||||
error.code = "REMOTE_WRITE_ACCESS_REQUIRED";
|
||||
error.permissionReport = permissionReport;
|
||||
throw error;
|
||||
}
|
||||
const bundle = await this.createCommitBundle(repository, targetSha, requestId);
|
||||
const remotePart = path.join(remotePath, ".forgeflow", "incoming", `${requestId}-${targetSha}.tar.part`);
|
||||
try {
|
||||
await this.ssh.exec(server.id, bash(`mkdir -p ${shellQuote(path.dirname(remotePart))}`), { timeout: 30_000 });
|
||||
await this.ssh.uploadFile(server.id, bundle.archivePath, remotePart, { mode: 0o600 });
|
||||
const script = this.pushBundleScript({ repository, profile, remotePath, targetSha, requestId, remotePart, digest: bundle.sha256, metadata, generated, iconReference, rollback });
|
||||
return await this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 8 * 1024 * 1024 });
|
||||
} finally {
|
||||
await fs.rm(bundle.archivePath, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async createServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId }) {
|
||||
const remote = this.serverGitRemote(repository, profile);
|
||||
const repositoryId = crypto.createHash("sha256").update(String(repository.fullName).toLowerCase()).digest("hex").slice(0, 24);
|
||||
const cache = path.join(server.basePath, ".forgeflow", "git-cache", `${repositoryId}.git`);
|
||||
const remotePart = path.join(remotePath, ".forgeflow", "incoming", `${requestId}-${targetSha}.tar.part`);
|
||||
const marker = "__FORGEFLOW_SERVER_ARCHIVE__";
|
||||
const script = `
|
||||
cache=${shellQuote(cache)}
|
||||
incoming=${shellQuote(remotePart)}
|
||||
remote=${shellQuote(remote)}
|
||||
branch=${shellQuote(profile.branch)}
|
||||
target=${shellQuote(targetSha)}
|
||||
mkdir -p "$(dirname "$cache")" "$(dirname "$incoming")"
|
||||
if [ ! -d "$cache" ]; then git init --bare "$cache" >/dev/null; fi
|
||||
if git --git-dir="$cache" remote get-url origin >/dev/null 2>&1; then
|
||||
git --git-dir="$cache" remote set-url origin "$remote"
|
||||
else
|
||||
git --git-dir="$cache" remote add origin "$remote"
|
||||
fi
|
||||
${this.serverGitEnvironment(repository, profile, server)} git --git-dir="$cache" fetch --force --prune origin "+refs/heads/$branch:refs/remotes/origin/$branch"
|
||||
git --git-dir="$cache" cat-file -e "$target^{commit}"
|
||||
git --git-dir="$cache" merge-base --is-ancestor "$target" "refs/remotes/origin/$branch"
|
||||
archive_tmp="$incoming.$$.tmp"
|
||||
git --git-dir="$cache" archive --format=tar --output="$archive_tmp" "$target"
|
||||
[ -s "$archive_tmp" ] || { rm -f "$archive_tmp"; echo "Gitea produced an empty deployment archive" >&2; exit 45; }
|
||||
mv "$archive_tmp" "$incoming"
|
||||
digest=$(if command -v sha256sum >/dev/null 2>&1; then sha256sum "$incoming" | awk '{print $1}'; else shasum -a 256 "$incoming" | awk '{print $1}'; fi)
|
||||
printf '%s\n' ${shellQuote(marker)}
|
||||
printf 'digest=%s\n' "$digest"
|
||||
`;
|
||||
const result = await this.ssh.exec(server.id, bash(script), { timeout: 5 * 60_000, maxOutput: 512 * 1024 });
|
||||
const output = String(result.stdout || "");
|
||||
const markerIndex = output.lastIndexOf(marker);
|
||||
const digest = markerIndex >= 0
|
||||
? String(output.slice(markerIndex + marker.length).match(/(?:^|\n)digest=([0-9a-f]{64})(?:\n|$)/i)?.[1] || "").toLowerCase()
|
||||
: "";
|
||||
if (!digest) throw new Error("The server did not return a valid checksum for the Gitea archive.");
|
||||
return { remotePart, digest };
|
||||
}
|
||||
|
||||
async executeServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference, rollback = false }) {
|
||||
const permissionReport = await this.inspectWriteAccess({ repository, profileId: profile.id });
|
||||
if (!permissionReport.ready) {
|
||||
const error = new Error(`Deployment stopped before the Gitea fetch because write access is missing for: ${permissionReport.blocking.map((item) => item.path).join(", ")}`);
|
||||
error.code = "REMOTE_WRITE_ACCESS_REQUIRED";
|
||||
error.permissionReport = permissionReport;
|
||||
throw error;
|
||||
}
|
||||
const bundle = await this.createServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId });
|
||||
const script = this.pushBundleScript({ repository, profile, remotePath, targetSha, requestId, remotePart: bundle.remotePart, digest: bundle.digest, metadata, generated, iconReference, rollback });
|
||||
return this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 8 * 1024 * 1024 });
|
||||
}
|
||||
|
||||
async deploy({ repository, profileId, sha }) {
|
||||
const targetSha = assertFullCommitSha(sha);
|
||||
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
||||
if (profile.deploymentMode === "server-git") {
|
||||
const verification = await this.verifyServerGitProfile({ repository, profileId });
|
||||
const requiredChecks = ["remote-branch", "deploy-key-scope", "server-git-access"];
|
||||
const blocked = verification.checks.filter((check) => requiredChecks.includes(check.id) && check.status !== "pass");
|
||||
if (blocked.length || !verification.branchSha) {
|
||||
const error = new Error(`Server pull verification failed: ${blocked.map((check) => check.detail).join("; ") || "the target branch could not be proven"}`);
|
||||
error.code = "SERVER_GIT_VERIFICATION_FAILED";
|
||||
error.verification = verification;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const preflight = await this.preflight({ repository, profileId, sha: targetSha });
|
||||
if (!preflight.summary.ready) {
|
||||
const error = new Error(`SSH deployment preflight failed: ${preflight.summary.blocking.join(", ")}`);
|
||||
error.code = "SSH_DEPLOYMENT_PREFLIGHT_FAILED";
|
||||
throw error;
|
||||
}
|
||||
const requestId = crypto.randomUUID();
|
||||
const mode = ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode)
|
||||
? profile.deploymentMode
|
||||
: "push-bundle";
|
||||
const operation = await this.saveOperation({
|
||||
id: requestId,
|
||||
type: "deployment",
|
||||
action: "deploy",
|
||||
provider: "ssh-unraid",
|
||||
repository: repository.fullName,
|
||||
environment: profile.environment,
|
||||
profileId,
|
||||
serverId: server.id,
|
||||
remotePath,
|
||||
sha: targetSha,
|
||||
shortSha: targetSha.slice(0, 7),
|
||||
status: "running",
|
||||
logs: [
|
||||
"Preflight passed.",
|
||||
mode === "push-bundle"
|
||||
? "Creating and uploading the exact committed local project directly to Unraid."
|
||||
: mode === "server-git"
|
||||
? "Fetching the exact commit from Gitea with a repository-scoped read-only deploy key."
|
||||
: "This workload is monitor-only and cannot be deployed.",
|
||||
`Deploying exact commit ${targetSha} in the background.`,
|
||||
],
|
||||
});
|
||||
|
||||
const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : "";
|
||||
const iconReference = await this.prepareIcon(profile, repository, server);
|
||||
const deploymentRepositoryUrl = mode === "server-git"
|
||||
? this.serverGitRemote(repository, profile)
|
||||
: repository.localStatus?.remoteUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName;
|
||||
const metadata = this.metadataCompose(profile, repository, iconReference, {
|
||||
sha: targetSha,
|
||||
repositoryUrl: deploymentRepositoryUrl,
|
||||
});
|
||||
const previousState = this.store.getDeploymentState?.(profileId) || null;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
if (mode === "monitor-only") throw new Error("This workload is monitor-only. Select Server pull or Direct copy before deploying.");
|
||||
const result = mode === "server-git"
|
||||
? await this.executeServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference })
|
||||
: await this.executePushBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference });
|
||||
const health = await this.checkHealth(profile.healthcheckUrl);
|
||||
const finalStatus = health.healthy === false ? "failed" : "success";
|
||||
const completed = await this.saveOperation({
|
||||
...operation,
|
||||
status: finalStatus,
|
||||
previousSha: previousState?.liveSha || preflight.inspection?.head || null,
|
||||
health,
|
||||
logs: [
|
||||
...operation.logs,
|
||||
...result.stdout.trim().split("\n").filter(Boolean).slice(-80),
|
||||
"Docker Compose activation and runtime verification completed.",
|
||||
health.configured
|
||||
? `Healthcheck ${health.healthy ? "passed" : "failed"}${health.status ? ` with HTTP ${health.status}` : ""}.`
|
||||
: "No desktop healthcheck configured; running containers were verified and health remains unverified.",
|
||||
],
|
||||
error: health.healthy === false ? "The application healthcheck did not pass after deployment." : null,
|
||||
});
|
||||
await this.store.saveDeploymentState(profileId, {
|
||||
liveSha: targetSha,
|
||||
previousSha: previousState?.liveSha || preflight.inspection?.head || null,
|
||||
healthy: health.configured ? health.healthy : null,
|
||||
runtimeVerification: health.configured ? "desktop-healthcheck" : "running-unverified",
|
||||
healthStatus: health.status ?? null,
|
||||
healthLatencyMs: health.latencyMs ?? null,
|
||||
requestId,
|
||||
remotePath,
|
||||
provider: "ssh-unraid",
|
||||
deploymentMode: mode,
|
||||
containerName: String(profile.containerName || profile.remoteFolder || repository.name),
|
||||
containerRunning: true,
|
||||
dockerMan: {
|
||||
webUi: this.dockerManWebUi(profile),
|
||||
icon: iconReference,
|
||||
shell: this.dockerManShell(profile),
|
||||
templateExists: profile.manageDockerMan === true || previousState?.dockerMan?.templateExists === true,
|
||||
configured: Boolean(this.dockerManWebUi(profile) || iconReference || previousState?.dockerMan?.configured),
|
||||
},
|
||||
webUiUrl: profile.webUiUrl || (profile.hostPort ? `http://${server.host}:${profile.hostPort}/` : null),
|
||||
});
|
||||
void this.refreshProfileState(repository.fullName, profileId).catch(() => {});
|
||||
await this.diagnostics?.info("unraid.deployment.completed", { requestId, repository: repository.fullName, serverId: server.id, remotePath, sha: targetSha, status: completed.status });
|
||||
} catch (error) {
|
||||
await this.saveOperation({
|
||||
...operation,
|
||||
status: "failed",
|
||||
error: error.message,
|
||||
failure: { stage: mode === "server-git" ? "Gitea server pull / Compose activation" : "Direct copy / Compose activation", message: error.message },
|
||||
logs: [...operation.logs, error.message, "The live SHA was not promoted. Previous release evidence remains authoritative."],
|
||||
});
|
||||
await this.diagnostics?.error("unraid.deployment.failed", { requestId, repository: repository.fullName, serverId: server.id, remotePath, sha: targetSha, error });
|
||||
}
|
||||
})();
|
||||
|
||||
return operation;
|
||||
}
|
||||
|
||||
async rollback({ repository, profileId, targetSha }) {
|
||||
const target = assertFullCommitSha(targetSha);
|
||||
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
||||
const deploymentState = this.store.getDeploymentState(profileId);
|
||||
if (!deploymentState?.previousSha || deploymentState.previousSha !== target) {
|
||||
const error = new Error("Rollback is allowed only to the exact previous SHA reported by ForgeFlow for this deployment profile.");
|
||||
error.code = "ROLLBACK_TARGET_NOT_PREVIOUS_SHA";
|
||||
throw error;
|
||||
}
|
||||
const rollbackMode = ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode)
|
||||
? profile.deploymentMode
|
||||
: "push-bundle";
|
||||
if (rollbackMode === "push-bundle" && !repository.localPath)
|
||||
throw new Error("A linked local repository is required for Direct copy rollback verification.");
|
||||
const requestId = crypto.randomUUID();
|
||||
const operation = await this.saveOperation({
|
||||
id: requestId,
|
||||
type: "deployment",
|
||||
action: "rollback",
|
||||
provider: "ssh-unraid",
|
||||
repository: repository.fullName,
|
||||
environment: profile.environment,
|
||||
profileId,
|
||||
serverId: server.id,
|
||||
remotePath,
|
||||
sha: target,
|
||||
shortSha: target.slice(0, 7),
|
||||
status: "running",
|
||||
logs: [`Rolling back to exact commit ${target}.`],
|
||||
});
|
||||
const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : "";
|
||||
const iconReference = await this.prepareIcon(profile, repository, server);
|
||||
const rollbackRepositoryUrl = rollbackMode === "server-git"
|
||||
? this.serverGitRemote(repository, profile)
|
||||
: repository.localStatus?.remoteUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName;
|
||||
const metadata = this.metadataCompose(profile, repository, iconReference, {
|
||||
sha: target,
|
||||
repositoryUrl: rollbackRepositoryUrl,
|
||||
});
|
||||
try {
|
||||
const mode = rollbackMode;
|
||||
if (mode === "monitor-only") throw new Error("This workload is monitor-only. Select Server pull or Direct copy before rolling back.");
|
||||
const result = mode === "server-git"
|
||||
? await this.executeServerGitBundle({ repository, profile, server, remotePath, targetSha: target, requestId, metadata, generated, iconReference, rollback: true })
|
||||
: await this.executePushBundle({ repository, profile, server, remotePath, targetSha: target, requestId, metadata, generated, iconReference, rollback: true });
|
||||
const health = await this.checkHealth(profile.healthcheckUrl);
|
||||
const finalStatus = health.healthy === false ? "failed" : "rolled-back";
|
||||
const completed = await this.saveOperation({
|
||||
...operation,
|
||||
status: finalStatus,
|
||||
previousSha: deploymentState.liveSha || null,
|
||||
health,
|
||||
error: health.healthy === false ? "The application healthcheck did not pass after rollback." : null,
|
||||
logs: [
|
||||
...operation.logs,
|
||||
...result.stdout.trim().split("\n").filter(Boolean).slice(-80),
|
||||
"Rollback activation completed.",
|
||||
health.configured ? `Healthcheck ${health.healthy ? "passed" : "failed"}.` : "Runtime is running; no desktop healthcheck was configured.",
|
||||
],
|
||||
});
|
||||
await this.store.saveDeploymentState(profileId, {
|
||||
liveSha: target,
|
||||
previousSha: deploymentState.liveSha || null,
|
||||
healthy: health.configured ? health.healthy : null,
|
||||
runtimeVerification: health.configured ? "desktop-healthcheck" : "running-unverified",
|
||||
healthStatus: health.status ?? null,
|
||||
healthLatencyMs: health.latencyMs ?? null,
|
||||
requestId,
|
||||
remotePath,
|
||||
provider: "ssh-unraid",
|
||||
containerRunning: true,
|
||||
});
|
||||
if (health.healthy === false) {
|
||||
const error = new Error("Rollback completed, but the configured healthcheck failed.");
|
||||
error.code = "ROLLBACK_HEALTHCHECK_FAILED";
|
||||
error.operationId = completed.id;
|
||||
throw error;
|
||||
}
|
||||
return completed;
|
||||
} catch (error) {
|
||||
if (error.code !== "ROLLBACK_HEALTHCHECK_FAILED") {
|
||||
await this.saveOperation({ ...operation, status: "failed", error: error.message, logs: [...operation.logs, error.message] });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
return UnraidDeploymentMethods.prototype;
|
||||
}
|
||||
|
||||
module.exports = { createUnraidDeploymentMethods };
|
||||
@@ -0,0 +1,524 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs/promises");
|
||||
const fileSystem = require("node:fs");
|
||||
const path = require("node:path").posix;
|
||||
const nativePath = require("node:path");
|
||||
const crypto = require("node:crypto");
|
||||
const os = require("node:os");
|
||||
const { shellQuote } = require("./ssh-service.cjs");
|
||||
const { assertFullCommitSha } = require("../shared/validation.cjs");
|
||||
const { run } = require("./process-runner.cjs");
|
||||
const {
|
||||
parseServerInventory: parseWorkloadInventory,
|
||||
buildWorkloadInventory,
|
||||
inventoryContainerMatch: matchInventoryContainer,
|
||||
remoteIdentity: inventoryRemoteIdentity,
|
||||
} = require("./server-inventory.cjs");
|
||||
const { classifyInventory } = require("./inventory-classifier.cjs");
|
||||
const { createUnraidInventoryMethods } = require("./unraid-inventory-methods.cjs");
|
||||
const { createUnraidAccessMethods } = require("./unraid-access-methods.cjs");
|
||||
const { createUnraidPreflightMethods } = require("./unraid-preflight-methods.cjs");
|
||||
const { createUnraidRuntimeMethods } = require("./unraid-runtime-methods.cjs");
|
||||
const { createUnraidDeploymentMethods } = require("./unraid-deployment-methods.cjs");
|
||||
const { createUnraidStateMethods } = require("./unraid-state-methods.cjs");
|
||||
|
||||
function safeRemoteFolder(value) {
|
||||
const text = String(value || "").trim().replace(/\\/g, "/").replace(/^\.\//, "");
|
||||
if (
|
||||
!text ||
|
||||
path.isAbsolute(text) ||
|
||||
text.split("/").some((part) => !part || part === "." || part === ".." || !/^[a-zA-Z0-9._-]+$/.test(part))
|
||||
) throw new Error("Remote folder must be a safe path below the configured server base path.");
|
||||
return text;
|
||||
}
|
||||
|
||||
function safeRelativeRemoteFile(value, fallback = "") {
|
||||
const text = String(value || fallback)
|
||||
.trim()
|
||||
.replace(/\\/g, "/");
|
||||
if (
|
||||
!text ||
|
||||
text.startsWith("/") ||
|
||||
text.split("/").some((part) => !part || part === "." || part === "..")
|
||||
) {
|
||||
throw new Error("Remote file path must remain inside the project folder.");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function bash(command) {
|
||||
const script = `set -euo pipefail
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
export GIT_SSH_COMMAND='ssh -o BatchMode=yes'
|
||||
forgeflow_compose() {
|
||||
if docker compose version >/dev/null 2>&1; then docker compose "$@";
|
||||
elif command -v docker-compose >/dev/null 2>&1; then docker-compose "$@";
|
||||
else echo "Docker Compose is not available on the server." >&2; return 127;
|
||||
fi
|
||||
}
|
||||
${command}`;
|
||||
const payload = Buffer.from(script, "utf8").toString("base64");
|
||||
return `printf '%s' ${shellQuote(payload)} | base64 -d | bash`;
|
||||
}
|
||||
|
||||
function parseInspection(text) {
|
||||
const jsonMarker = "__FORGEFLOW_JSON__";
|
||||
const jsonIndex = text.lastIndexOf(jsonMarker);
|
||||
if (jsonIndex >= 0)
|
||||
return JSON.parse(text.slice(jsonIndex + jsonMarker.length).trim());
|
||||
|
||||
const kvMarker = "__FORGEFLOW_KV__";
|
||||
const kvIndex = text.lastIndexOf(kvMarker);
|
||||
if (kvIndex < 0)
|
||||
throw new Error("The server inspection did not return a ForgeFlow result.");
|
||||
const fields = {};
|
||||
for (const line of text
|
||||
.slice(kvIndex + kvMarker.length)
|
||||
.trim()
|
||||
.split(/\r?\n/)) {
|
||||
const separator = line.indexOf("=");
|
||||
if (separator > 0)
|
||||
fields[line.slice(0, separator)] = line.slice(separator + 1);
|
||||
}
|
||||
const decodeLines = (value) => {
|
||||
try {
|
||||
return value
|
||||
? Buffer.from(value, "base64")
|
||||
.toString("utf8")
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const decodeText = (value) => {
|
||||
try {
|
||||
return value ? Buffer.from(value, "base64").toString("utf8") : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
return {
|
||||
exists: fields.exists === "true",
|
||||
rootGit: fields.rootGit === "true",
|
||||
head: fields.head || null,
|
||||
branch: fields.branch || null,
|
||||
remote: fields.remote
|
||||
? Buffer.from(fields.remote, "base64").toString("utf8")
|
||||
: null,
|
||||
trackedChanges: decodeLines(fields.trackedChanges),
|
||||
composeFiles: decodeLines(fields.composeFiles),
|
||||
nestedGit: decodeLines(fields.nestedGit),
|
||||
dockerfile: fields.dockerfile === "true",
|
||||
dockerignoreContent: decodeText(fields.dockerignoreContent),
|
||||
existingPreservePaths: decodeLines(fields.existingPreservePaths),
|
||||
};
|
||||
}
|
||||
|
||||
function dockerIgnoreHasPath(content, value) {
|
||||
const target = String(value || "")
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/^\.\//, "")
|
||||
.replace(/^\//, "")
|
||||
.replace(/\/$/, "");
|
||||
if (!target) return false;
|
||||
return String(content || "")
|
||||
.split(/\r?\n/)
|
||||
.some((line) => {
|
||||
let rule = line.trim();
|
||||
if (!rule || rule.startsWith("#") || rule.startsWith("!")) return false;
|
||||
rule = rule.replace(/^\.\//, "").replace(/^\//, "").replace(/\/$/, "");
|
||||
return (
|
||||
rule === target || rule === `${target}/**` || rule === `${target}/**/*`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function checksSummary(checks) {
|
||||
const counts = {
|
||||
pass: checks.filter((item) => item.status === "pass").length,
|
||||
warning: checks.filter((item) => item.status === "warning").length,
|
||||
fail: checks.filter((item) => item.status === "fail").length,
|
||||
};
|
||||
return {
|
||||
ready: counts.fail === 0,
|
||||
counts,
|
||||
blocking: checks
|
||||
.filter((item) => item.status === "fail")
|
||||
.map((item) => item.id),
|
||||
};
|
||||
}
|
||||
|
||||
function xmlEscape(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function decodeBase64Json(value, fallback) {
|
||||
try {
|
||||
return value
|
||||
? JSON.parse(Buffer.from(value, "base64").toString("utf8"))
|
||||
: fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function parseDockerManXml(xml) {
|
||||
const text = String(xml || "");
|
||||
const tag = (name) => {
|
||||
const match = text.match(
|
||||
new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`, "i"),
|
||||
);
|
||||
return match
|
||||
? match[1]
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.trim()
|
||||
: "";
|
||||
};
|
||||
return {
|
||||
name: tag("Name"),
|
||||
webUiUrl: tag("WebUI"),
|
||||
iconUrl: tag("Icon"),
|
||||
shell: tag("Shell"),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePermissionInspection(text) {
|
||||
const marker = "__FORGEFLOW_PERMISSIONS__";
|
||||
const index = String(text || "").lastIndexOf(marker);
|
||||
if (index < 0)
|
||||
throw new Error("The server permission check did not return a ForgeFlow marker.");
|
||||
const decode = (value) => {
|
||||
try {
|
||||
return value ? Buffer.from(value, "base64").toString("utf8") : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
const result = {
|
||||
identity: { user: "", uid: null, gid: null, groups: [], hasAcl: false, canElevate: false },
|
||||
targets: [],
|
||||
};
|
||||
for (const line of String(text)
|
||||
.slice(index + marker.length)
|
||||
.trim()
|
||||
.split(/\r?\n/)) {
|
||||
const parts = line.split("\t");
|
||||
if (parts[0] === "I") {
|
||||
result.identity = {
|
||||
user: decode(parts[1]),
|
||||
uid: Number(parts[2]),
|
||||
gid: Number(parts[3]),
|
||||
groups: decode(parts[4]).split(/\s+/).filter(Boolean),
|
||||
hasAcl: parts[5] === "true",
|
||||
canElevate: parts[6] === "true",
|
||||
};
|
||||
} else if (parts[0] === "P") {
|
||||
result.targets.push({
|
||||
id: decode(parts[1]),
|
||||
label: decode(parts[2]),
|
||||
path: decode(parts[3]),
|
||||
kind: parts[4] || "directory",
|
||||
required: parts[5] === "true",
|
||||
exists: parts[6] === "true",
|
||||
readable: parts[7] === "true",
|
||||
writable: parts[8] === "true",
|
||||
parentWritable: parts[9] === "true",
|
||||
effectiveWritable: parts[10] === "true",
|
||||
owner: decode(parts[11]),
|
||||
group: decode(parts[12]),
|
||||
mode: parts[13] || "",
|
||||
nearestWritableAncestor: decode(parts[14]),
|
||||
detail: decode(parts[15]),
|
||||
});
|
||||
}
|
||||
}
|
||||
result.blocking = result.targets.filter(
|
||||
(target) => target.required && !target.effectiveWritable,
|
||||
);
|
||||
result.ready = result.blocking.length === 0;
|
||||
result.repairable = result.blocking.some((target) => target.id !== "server-base");
|
||||
return result;
|
||||
}
|
||||
|
||||
function deriveDetectedProfile({
|
||||
repository,
|
||||
server,
|
||||
remoteFolder,
|
||||
remotePath,
|
||||
payload,
|
||||
}) {
|
||||
const compose = payload.compose || {};
|
||||
const services =
|
||||
compose.services && typeof compose.services === "object"
|
||||
? compose.services
|
||||
: {};
|
||||
const inspections = Array.isArray(payload.containers)
|
||||
? payload.containers
|
||||
: [];
|
||||
const primaryContainer =
|
||||
inspections.find((item) => item?.State?.Running) || inspections[0] || null;
|
||||
const labels = primaryContainer?.Config?.Labels || {};
|
||||
const serviceName =
|
||||
labels["com.docker.compose.service"] ||
|
||||
Object.keys(services)[0] ||
|
||||
remoteFolder;
|
||||
const service = services[serviceName] || {};
|
||||
const containerName = String(
|
||||
primaryContainer?.Name || service.container_name || serviceName,
|
||||
).replace(/^\//, "");
|
||||
const ports = [];
|
||||
for (const [containerKey, bindings] of Object.entries(
|
||||
primaryContainer?.NetworkSettings?.Ports || {},
|
||||
)) {
|
||||
const [containerPortText, protocol = "tcp"] = containerKey.split("/");
|
||||
const containerPort = Number(containerPortText) || null;
|
||||
if (Array.isArray(bindings) && bindings.length) {
|
||||
for (const binding of bindings)
|
||||
ports.push({
|
||||
hostIp: binding.HostIp || "",
|
||||
hostPort: Number(binding.HostPort) || null,
|
||||
containerPort,
|
||||
protocol,
|
||||
});
|
||||
} else ports.push({ hostIp: "", hostPort: null, containerPort, protocol });
|
||||
}
|
||||
const primaryPort = ports.find((item) => item.hostPort) || ports[0] || {};
|
||||
const mounts = (primaryContainer?.Mounts || []).map((item) => ({
|
||||
type: item.Type,
|
||||
source: item.Source,
|
||||
target: item.Destination,
|
||||
readOnly: item.RW === false,
|
||||
}));
|
||||
const networks = Object.keys(
|
||||
primaryContainer?.NetworkSettings?.Networks || {},
|
||||
);
|
||||
const envNames = (primaryContainer?.Config?.Env || [])
|
||||
.map((item) => String(item).split("=")[0])
|
||||
.filter(Boolean);
|
||||
const dockerMan = parseDockerManXml(payload.dockerManXml || "");
|
||||
const webUiUrl =
|
||||
dockerMan.webUiUrl || labels["net.unraid.docker.webui"] || "";
|
||||
const iconUrl = dockerMan.iconUrl || labels["net.unraid.docker.icon"] || "";
|
||||
const shell =
|
||||
dockerMan.shell || labels["net.unraid.docker.shell"] || "/bin/sh";
|
||||
const preservePaths = [
|
||||
...new Set([
|
||||
".env",
|
||||
"appdata",
|
||||
"data",
|
||||
"logs",
|
||||
"config",
|
||||
"compose.override.yml",
|
||||
...mounts
|
||||
.filter((item) =>
|
||||
String(item.source || "").startsWith(`${remotePath}/`),
|
||||
)
|
||||
.map(
|
||||
(item) =>
|
||||
String(item.source)
|
||||
.slice(remotePath.length + 1)
|
||||
.split("/")[0],
|
||||
)
|
||||
.filter(Boolean),
|
||||
]),
|
||||
];
|
||||
const source = (value, origin, confidence = "confirmed") => ({
|
||||
value,
|
||||
origin,
|
||||
confidence,
|
||||
detectedAt: new Date().toISOString(),
|
||||
overridden: false,
|
||||
});
|
||||
const composeFiles = payload.composeFiles || [];
|
||||
const composeFile =
|
||||
composeFiles[0] ||
|
||||
labels["com.docker.compose.project.config_files"]
|
||||
?.split(",")[0]
|
||||
?.replace(`${remotePath}/`, "") ||
|
||||
"docker-compose.yml";
|
||||
return {
|
||||
profile: {
|
||||
name: "Production",
|
||||
environment: "production",
|
||||
provider: "ssh-unraid",
|
||||
branch: payload.branch || repository.defaultBranch || "main",
|
||||
serverId: server.id,
|
||||
remoteFolder,
|
||||
cloneUrl: payload.remote || repository.sshUrl || "",
|
||||
alignRemote: false,
|
||||
generatedCompose: false,
|
||||
composeFile,
|
||||
composeService: serviceName,
|
||||
containerName,
|
||||
hostPort: primaryPort.hostPort || null,
|
||||
containerPort: primaryPort.containerPort || null,
|
||||
webUiUrl,
|
||||
iconMode: /^https?:\/\//i.test(iconUrl) ? "url" : "none",
|
||||
iconUrl: /^https?:\/\//i.test(iconUrl) ? iconUrl : "",
|
||||
serverIconReference: iconUrl,
|
||||
iconFilePath: "",
|
||||
dockerShell: ["/bin/bash", "/bin/sh"].includes(shell) ? shell : "/bin/sh",
|
||||
healthcheckUrl: "",
|
||||
preservePaths,
|
||||
confirmationRequired: true,
|
||||
adoptedFromServer: true,
|
||||
serverSourceOfTruth: true,
|
||||
detectedAt: new Date().toISOString(),
|
||||
detectedMetadata: {
|
||||
head: payload.head || null,
|
||||
composeProject: labels["com.docker.compose.project"] || "",
|
||||
composeFiles,
|
||||
services: Object.keys(services),
|
||||
ports,
|
||||
mounts,
|
||||
networks,
|
||||
envNames,
|
||||
restartPolicy: primaryContainer?.HostConfig?.RestartPolicy?.Name || "",
|
||||
healthcheck: primaryContainer?.Config?.Healthcheck || null,
|
||||
image: primaryContainer?.Config?.Image || service.image || "",
|
||||
dockerMan,
|
||||
},
|
||||
},
|
||||
provenance: {
|
||||
remoteFolder: source(remoteFolder, "server-path"),
|
||||
cloneUrl: source(payload.remote || "", "git-origin"),
|
||||
branch: source(payload.branch || "", "git"),
|
||||
composeFile: source(composeFile, "docker-compose"),
|
||||
composeService: source(serviceName, "docker-labels"),
|
||||
containerName: source(containerName, "docker-inspect"),
|
||||
hostPort: source(primaryPort.hostPort || null, "docker-inspect"),
|
||||
containerPort: source(
|
||||
primaryPort.containerPort || null,
|
||||
"docker-inspect",
|
||||
),
|
||||
webUiUrl: source(
|
||||
webUiUrl,
|
||||
dockerMan.webUiUrl ? "unraid-dockerman" : "docker-labels",
|
||||
),
|
||||
iconUrl: source(
|
||||
iconUrl,
|
||||
dockerMan.iconUrl ? "unraid-dockerman" : "docker-labels",
|
||||
),
|
||||
dockerShell: source(
|
||||
shell,
|
||||
dockerMan.shell ? "unraid-dockerman" : "docker-labels",
|
||||
),
|
||||
},
|
||||
runtime: {
|
||||
remotePath,
|
||||
containerRunning: Boolean(primaryContainer?.State?.Running),
|
||||
containers: inspections.length,
|
||||
services: Object.keys(services).length,
|
||||
ports,
|
||||
mounts,
|
||||
networks,
|
||||
envNames,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function iconReferenceLocalPath(iconReference) {
|
||||
const value = String(iconReference || "").trim();
|
||||
if (value.startsWith("file:///")) return `/${value.slice("file:///".length)}`;
|
||||
if (value.startsWith("/")) return value;
|
||||
return "";
|
||||
}
|
||||
|
||||
class UnraidDeploymentService {
|
||||
constructor({
|
||||
store,
|
||||
ssh,
|
||||
git,
|
||||
gitea,
|
||||
diagnostics,
|
||||
sourcePath = process.cwd(),
|
||||
onOperationChange = null,
|
||||
}) {
|
||||
this.store = store;
|
||||
this.ssh = ssh;
|
||||
this.git = git;
|
||||
this.gitea = gitea;
|
||||
this.diagnostics = diagnostics;
|
||||
this.sourcePath = sourcePath;
|
||||
this.onOperationChange = onOperationChange;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
const stateMethods = createUnraidStateMethods({ path, bash, shellQuote, inventoryRemoteIdentity });
|
||||
for (const name of Object.getOwnPropertyNames(stateMethods)) {
|
||||
if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(stateMethods, name));
|
||||
}
|
||||
|
||||
const deploymentMethods = createUnraidDeploymentMethods({
|
||||
path, crypto, bash, shellQuote, assertFullCommitSha, nativePath, fs,
|
||||
});
|
||||
for (const name of Object.getOwnPropertyNames(deploymentMethods)) {
|
||||
if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(deploymentMethods, name));
|
||||
}
|
||||
|
||||
const runtimeMethods = createUnraidRuntimeMethods({
|
||||
safeRelativeRemoteFile, xmlEscape, nativePath, fileSystem, fs, crypto, os, run,
|
||||
bash, shellQuote, iconReferenceLocalPath,
|
||||
});
|
||||
for (const name of Object.getOwnPropertyNames(runtimeMethods)) {
|
||||
if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(runtimeMethods, name));
|
||||
}
|
||||
|
||||
const preflightMethods = createUnraidPreflightMethods({
|
||||
safeRemoteFolder, path, bash, parseInspection, dockerIgnoreHasPath, checksSummary,
|
||||
inventoryRemoteIdentity, deriveDetectedProfile, decodeBase64Json, shellQuote,
|
||||
assertFullCommitSha, nativePath, safeRelativeRemoteFile, fs,
|
||||
});
|
||||
for (const name of Object.getOwnPropertyNames(preflightMethods)) {
|
||||
if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(preflightMethods, name));
|
||||
}
|
||||
|
||||
const accessMethods = createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto, parsePermissionInspection, safeRelativeRemoteFile });
|
||||
for (const name of Object.getOwnPropertyNames(accessMethods)) {
|
||||
if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(accessMethods, name));
|
||||
}
|
||||
|
||||
const inventoryMethods = createUnraidInventoryMethods({
|
||||
shellQuote, path, parseWorkloadInventory, buildWorkloadInventory, classifyInventory,
|
||||
inventoryRemoteIdentity, deriveDetectedProfile, crypto, matchInventoryContainer,
|
||||
safeRemoteFolder, bash,
|
||||
});
|
||||
for (const name of Object.getOwnPropertyNames(inventoryMethods)) {
|
||||
if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(inventoryMethods, name));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
UnraidDeploymentService,
|
||||
safeRemoteFolder,
|
||||
safeRelativeRemoteFile,
|
||||
parseInspection,
|
||||
dockerIgnoreHasPath,
|
||||
checksSummary,
|
||||
xmlEscape,
|
||||
iconReferenceLocalPath,
|
||||
decodeBase64Json,
|
||||
parseDockerManXml,
|
||||
parsePermissionInspection,
|
||||
parseServerInventory: parseWorkloadInventory,
|
||||
inventoryContainerMatch: matchInventoryContainer,
|
||||
remoteIdentity: inventoryRemoteIdentity,
|
||||
deriveDetectedProfile,
|
||||
bash,
|
||||
};
|
||||
@@ -0,0 +1,709 @@
|
||||
"use strict";
|
||||
|
||||
function createUnraidInventoryMethods({
|
||||
shellQuote, path, parseWorkloadInventory, buildWorkloadInventory, classifyInventory,
|
||||
inventoryRemoteIdentity, deriveDetectedProfile, crypto, matchInventoryContainer,
|
||||
safeRemoteFolder, bash,
|
||||
}) {
|
||||
class UnraidInventoryMethods {
|
||||
inventoryScript(server) {
|
||||
const configuredRoots = [...new Set([server.basePath, ...(server.scanRoots || [])])].map((root) => ` add_scan_root ${shellQuote(root)}`).join("\n");
|
||||
const configuredExcludes = (server.scanExcludes || []).map((name) => ` -o -name ${shellQuote(name)}`).join("");
|
||||
return `
|
||||
base=${shellQuote(server.basePath)}
|
||||
platform=$(uname -srm 2>/dev/null || true)
|
||||
docker_ok=false; compose_ok=false; compose_v2=false; git_ok=false; tar_ok=false; checksum_ok=false; base_writable=false; compose_version=''
|
||||
command -v docker >/dev/null 2>&1 && docker_ok=true
|
||||
if [ "$docker_ok" = true ]; then
|
||||
if docker compose version >/dev/null 2>&1; then compose_ok=true; compose_v2=true; compose_version=$(docker compose version 2>/dev/null | head -n1); elif command -v docker-compose >/dev/null 2>&1; then compose_ok=true; compose_version=$(docker-compose version 2>/dev/null | head -n1); fi
|
||||
fi
|
||||
command -v git >/dev/null 2>&1 && git_ok=true
|
||||
command -v tar >/dev/null 2>&1 && tar_ok=true
|
||||
(command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1) && checksum_ok=true
|
||||
if [ -d "$base" ]; then [ -w "$base" ] && base_writable=true; else parent=$(dirname "$base"); [ -d "$parent" ] && [ -w "$parent" ] && base_writable=true; fi
|
||||
printf '__FORGEFLOW_INVENTORY__\\n'
|
||||
printf 'H\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' "$docker_ok" "$compose_ok" "$git_ok" "$tar_ok" "$checksum_ok" "$base_writable" "$(printf '%s' "$compose_version" | base64 | tr -d '\\r\\n')" "$(printf '%s' "$platform" | base64 | tr -d '\\r\\n')"
|
||||
ids=''
|
||||
if [ "$docker_ok" != true ]; then
|
||||
printf 'W\\t%s\\n' "$(printf '%s' 'Docker is not installed or not in PATH. Compose files and DockerMan templates will still be scanned.' | base64 | tr -d '\\r\\n')"
|
||||
else
|
||||
if ! ids=$(docker ps -aq --no-trunc 2>&1); then
|
||||
printf 'W\\t%s\\n' "$(printf '%s' "Docker inventory failed: $ids. Compose files and DockerMan templates will still be scanned." | head -c 2000 | base64 | tr -d '\\r\\n')"
|
||||
ids=''
|
||||
fi
|
||||
fi
|
||||
if [ -n "$ids" ]; then
|
||||
disappeared=0
|
||||
mapfile -t container_ids <<< "$ids"
|
||||
# Docker accepts multiple IDs and returns one JSON array. This avoids one
|
||||
# daemon round-trip per container on larger Unraid installations.
|
||||
if inspect=$(docker inspect "\${container_ids[@]}" 2>/dev/null); then
|
||||
printf 'C\\t%s\\n' "$(printf '%s' "$inspect" | base64 | tr -d '\\r\\n')"
|
||||
else
|
||||
# A container can disappear between docker ps and inspect. Fall back to
|
||||
# individual reads so the remaining inventory stays complete.
|
||||
for container_id in "\${container_ids[@]}"; do
|
||||
[ -n "$container_id" ] || continue
|
||||
if inspect=$(docker inspect "$container_id" 2>/dev/null); then
|
||||
printf 'C\\t%s\\n' "$(printf '%s' "$inspect" | base64 | tr -d '\\r\\n')"
|
||||
else
|
||||
disappeared=$((disappeared + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ "$disappeared" -gt 0 ]; then
|
||||
printf 'W\\t%s\\n' "$(printf '%s' "$disappeared stale container reference(s) disappeared during inventory; current containers were still processed." | base64 | tr -d '\\r\\n')"
|
||||
fi
|
||||
fi
|
||||
templates_dir=/boot/config/plugins/dockerMan/templates-user
|
||||
if [ -d "$templates_dir" ]; then
|
||||
find "$templates_dir" -maxdepth 1 -type f -name '*.xml' -print0 2>/dev/null | while IFS= read -r -d '' template; do
|
||||
read_tag() { sed -n "s#.*<$1>\\(.*\\)</$1>.*#\\1#p" "$template" | head -n1; }
|
||||
name=$(read_tag Name)
|
||||
[ -n "$name" ] || continue
|
||||
printf 'D\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \\
|
||||
"$(printf '%s' "$name" | base64 | tr -d '\\r\\n')" \\
|
||||
"$(printf '%s' "$template" | base64 | tr -d '\\r\\n')" \\
|
||||
"$(printf '%s' "$(read_tag WebUI)" | base64 | tr -d '\\r\\n')" \\
|
||||
"$(printf '%s' "$(read_tag Icon)" | base64 | tr -d '\\r\\n')" \\
|
||||
"$(printf '%s' "$(read_tag Shell)" | base64 | tr -d '\\r\\n')" \\
|
||||
"$(printf '%s' "$(read_tag Repository)" | base64 | tr -d '\\r\\n')" \\
|
||||
"$(printf '%s' "$(read_tag Network)" | base64 | tr -d '\\r\\n')"
|
||||
done
|
||||
fi
|
||||
if [ "$compose_ok" = true ]; then
|
||||
compose_projects=$(docker compose ls --all --format json 2>/dev/null || docker-compose ls --all --format json 2>/dev/null || true)
|
||||
if [ -n "$compose_projects" ]; then
|
||||
printf 'P\\t%s\\n' "$(printf '%s' "$compose_projects" | base64 | tr -d '\\r\\n')"
|
||||
fi
|
||||
|
||||
fi
|
||||
|
||||
scan_roots=()
|
||||
add_scan_root() {
|
||||
candidate=$1
|
||||
[ -d "$candidate" ] || return 0
|
||||
for existing in "\${scan_roots[@]}"; do [ "$existing" = "$candidate" ] && return 0; done
|
||||
scan_roots+=("$candidate")
|
||||
}
|
||||
${configuredRoots}
|
||||
|
||||
for root in "\${scan_roots[@]}"; do
|
||||
scan_error=$(mktemp)
|
||||
while IFS= read -r -d '' primary; do
|
||||
dir=$(dirname "$primary")
|
||||
filename=$(basename "$primary")
|
||||
case "$filename" in
|
||||
compose.override.yml|compose.override.yaml|docker-compose.override.yml|docker-compose.override.yaml) continue ;;
|
||||
compose.yml) ;;
|
||||
compose.yaml) [ -f "$dir/compose.yml" ] && continue ;;
|
||||
docker-compose.yml) { [ -f "$dir/compose.yml" ] || [ -f "$dir/compose.yaml" ]; } && continue ;;
|
||||
docker-compose.yaml) { [ -f "$dir/compose.yml" ] || [ -f "$dir/compose.yaml" ] || [ -f "$dir/docker-compose.yml" ]; } && continue ;;
|
||||
*) { [ -f "$dir/compose.yml" ] || [ -f "$dir/compose.yaml" ] || [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/docker-compose.yaml" ]; } && continue ;;
|
||||
esac
|
||||
(
|
||||
set -- -f "$primary"
|
||||
files_text=$primary
|
||||
has_override=false
|
||||
for extra in "$dir/compose.override.yml" "$dir/compose.override.yaml" "$dir/docker-compose.override.yml" "$dir/docker-compose.override.yaml"; do
|
||||
[ -f "$extra" ] || continue
|
||||
has_override=true
|
||||
set -- "$@" -f "$extra"
|
||||
files_text="$files_text
|
||||
$extra"
|
||||
done
|
||||
project_name=$(sed -n 's/^name:[[:space:]]*//p' "$primary" 2>/dev/null | head -n1 | cut -d'#' -f1 | tr -d '"' | tr -d "'" | xargs 2>/dev/null || true)
|
||||
[ -n "$project_name" ] || project_name=$(basename "$dir")
|
||||
valid=false; services=''; compose_error=''
|
||||
images=$(awk '
|
||||
/^[[:space:]]*services:[[:space:]]*($|#)/ { in_services=1; next }
|
||||
in_services && /^[^[:space:]]/ { exit }
|
||||
in_services && /^[[:space:]]+image:[[:space:]]*/ {
|
||||
line=$0; sub(/^[[:space:]]*image:[[:space:]]*/, "", line); sub(/[[:space:]]+#.*/, "", line); gsub(/"/, "", line); print line
|
||||
}
|
||||
' "$primary" 2>/dev/null || true)
|
||||
if [ "$compose_ok" != true ]; then
|
||||
compose_error='Docker Compose is unavailable; file metadata was still detected.'
|
||||
elif [ "$compose_v2" = true ]; then
|
||||
if services=$(cd "$dir" && docker compose "$@" config --services 2>&1); then
|
||||
valid=true
|
||||
if [ "$has_override" = true ] || [ -z "$images" ] || printf '%s' "$images" | grep -q '\$'; then images=$(cd "$dir" && docker compose "$@" config --images 2>/dev/null || true); fi
|
||||
else compose_error=$services; services=''; fi
|
||||
else
|
||||
if services=$(cd "$dir" && docker-compose "$@" config --services 2>&1); then
|
||||
valid=true
|
||||
if [ "$has_override" = true ] || [ -z "$images" ] || printf '%s' "$images" | grep -q '\$'; then images=$(cd "$dir" && docker-compose "$@" config --images 2>/dev/null || true); fi
|
||||
else compose_error=$services; services=''; fi
|
||||
fi
|
||||
if [ -z "$services" ]; then
|
||||
services=$(awk '
|
||||
/^[[:space:]]*services:[[:space:]]*($|#)/ { in_services=1; next }
|
||||
in_services && /^[^[:space:]]/ { exit }
|
||||
in_services && /^ [A-Za-z0-9._-]+:[[:space:]]*($|#)/ {
|
||||
line=$0; sub(/^[[:space:]]*/, "", line); sub(/:.*/, "", line); print line
|
||||
}
|
||||
' "$primary" 2>/dev/null || true)
|
||||
fi
|
||||
printf 'Y\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \\
|
||||
"$(printf '%s' "$dir" | base64 | tr -d '\\r\\n')" \\
|
||||
"$(printf '%s' "$files_text" | base64 | tr -d '\\r\\n')" \\
|
||||
"$(printf '%s' "$project_name" | base64 | tr -d '\\r\\n')" \\
|
||||
"$(printf '%s' "$services" | base64 | tr -d '\\r\\n')" \\
|
||||
"$(printf '%s' "$images" | base64 | tr -d '\\r\\n')" \\
|
||||
"$valid" \\
|
||||
"$(printf '%s' "$compose_error" | head -c 2000 | base64 | tr -d '\\r\\n')"
|
||||
)
|
||||
done < <(find "$root" -mindepth 2 -maxdepth 4 \\( -type d \\( -name .git -o -name node_modules -o -name .forgeflow -o -name releases -o -name backups -o -name staging -o -name incoming -o -name '_audit_quarantine' -o -name 'devrunbook-validation' -o -name 'source-pre-*' -o -name cache -o -name caches -o -name logs -o -name database -o -name databases${configuredExcludes} \\) -prune \\) -o \\( -type f \\( -name '*compose*.yml' -o -name '*compose*.yaml' -o -name 'stack.yml' -o -name 'stack.yaml' \\) -print0 \\) 2>"$scan_error" || true)
|
||||
if [ -s "$scan_error" ]; then
|
||||
scan_message=$(printf 'Inventory scan partially failed for %s: %s' "$root" "$(head -n 1 "$scan_error")")
|
||||
printf 'W\\t%s\\n' "$(printf '%s' "$scan_message" | base64 | tr -d '\\r\\n')"
|
||||
fi
|
||||
rm -f "$scan_error"
|
||||
done
|
||||
`;
|
||||
}
|
||||
|
||||
allSshProfiles() {
|
||||
const result = [];
|
||||
for (const [repositoryFullName, profiles] of Object.entries(this.store.data?.deploymentProfiles || {})) {
|
||||
for (const profile of profiles || []) {
|
||||
if (profile?.provider === "ssh-unraid") result.push({ ...profile, _repositoryFullName: repositoryFullName });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
relativeComposeFiles(workload) {
|
||||
const workingDir = String(workload.compose?.workingDir || "").replace(/\/+$/, "");
|
||||
const files = (workload.compose?.configFiles || []).map((file) => {
|
||||
const value = String(file || "").trim();
|
||||
if (workingDir && value.startsWith(`${workingDir}/`)) return value.slice(workingDir.length + 1);
|
||||
return value.startsWith("/") ? path.basename(value) : value;
|
||||
}).filter(Boolean);
|
||||
return [...new Set(files.length ? files : ["docker-compose.yml"])];
|
||||
}
|
||||
|
||||
profileFromWorkload(repository, server, workload, { linkSource = "manual", deploymentMode = "server-git", remoteFolder = "" } = {}) {
|
||||
const effectiveDeploymentMode = ["push-bundle", "server-git", "monitor-only"].includes(deploymentMode)
|
||||
? deploymentMode
|
||||
: "server-git";
|
||||
const selectedFolder = safeRemoteFolder(remoteFolder || workload.remoteFolderCandidate || repository.name);
|
||||
const composeFiles = this.relativeComposeFiles(workload);
|
||||
const services = [...new Set((workload.compose?.services || [])
|
||||
.map((service) => String(service || "").trim().toLowerCase().replace(/[^a-z0-9._-]/g, "-"))
|
||||
.filter(Boolean))];
|
||||
const primary = workload.containers.find((container) => container.running) || workload.containers[0] || {};
|
||||
const primaryPort = (primary.ports || []).find((item) => item.hostPort) || primary.ports?.[0] || {};
|
||||
const remotePath = path.join(server.basePath, selectedFolder);
|
||||
const preservePaths = new Set([".env", "appdata", "data", "logs", "config", "compose.override.yml"]);
|
||||
for (const container of workload.containers || []) {
|
||||
for (const mount of container.mounts || []) {
|
||||
const source = String(mount.source || "");
|
||||
if (!source.startsWith(`${remotePath}/`)) continue;
|
||||
const relative = source.slice(remotePath.length + 1).split("/")[0];
|
||||
if (relative) preservePaths.add(relative);
|
||||
}
|
||||
}
|
||||
const idPrefix = String(linkSource).startsWith("automatic") ? "auto" : "link";
|
||||
const profileId = `${idPrefix}-${crypto.createHash("sha256").update(`${server.id}:${repository.fullName}:${workload.workloadId}`).digest("hex").slice(0, 20)}`;
|
||||
return {
|
||||
id: profileId,
|
||||
name: `${server.name} · ${workload.displayName}`,
|
||||
environment: "production",
|
||||
provider: "ssh-unraid",
|
||||
branch: workload.metadata?.branch || repository.defaultBranch || "main",
|
||||
serverId: server.id,
|
||||
remoteFolder: selectedFolder,
|
||||
deploymentMode: effectiveDeploymentMode,
|
||||
composeFile: composeFiles[0],
|
||||
composeFiles,
|
||||
composeProject: workload.compose?.project || "",
|
||||
composeWorkingDir: workload.compose?.workingDir || "",
|
||||
composeService: services[0] || String(primary.service || selectedFolder.split("/").pop()).toLowerCase().replace(/[^a-z0-9._-]/g, "-") || "app",
|
||||
composeServices: services.length ? services : [String(primary.service || selectedFolder.split("/").pop()).toLowerCase().replace(/[^a-z0-9._-]/g, "-") || "app"],
|
||||
containerName: primary.name || selectedFolder.split("/").pop(),
|
||||
cloneUrl: workload.metadata?.sourceRepository || repository.sshUrl || repository.cloneUrl || "",
|
||||
alignRemote: false,
|
||||
hostPort: primaryPort.hostPort || null,
|
||||
containerPort: primaryPort.containerPort || null,
|
||||
webUiUrl: workload.metadata?.webUiUrl || workload.dockerMan?.webUiUrl || "",
|
||||
iconMode: "none",
|
||||
iconUrl: "",
|
||||
iconFilePath: "",
|
||||
serverIconReference: workload.metadata?.iconUrl || workload.dockerMan?.iconUrl || "",
|
||||
dockerShell: ["/bin/bash", "/bin/sh"].includes(workload.metadata?.shell) ? workload.metadata.shell : "/bin/sh",
|
||||
preservePaths: [...preservePaths],
|
||||
generatedCompose: false,
|
||||
adoptedFromServer: true,
|
||||
serverSourceOfTruth: true,
|
||||
manageDockerMan: false,
|
||||
forceRecreate: false,
|
||||
removeOrphans: false,
|
||||
workloadIdentity: {
|
||||
workloadId: workload.workloadId,
|
||||
selector: workload.selector,
|
||||
linkSource,
|
||||
linkedAt: new Date().toISOString(),
|
||||
},
|
||||
detectedAt: new Date().toISOString(),
|
||||
detectedMetadata: {
|
||||
source: `${linkSource}-server-inventory`,
|
||||
kind: workload.kind,
|
||||
composeProject: workload.compose?.project || "",
|
||||
composeFiles,
|
||||
services,
|
||||
image: primary.image || "",
|
||||
dockerManTemplatePath: workload.dockerMan?.templatePath || "",
|
||||
},
|
||||
confirmationRequired: !String(linkSource).startsWith("automatic"),
|
||||
};
|
||||
}
|
||||
|
||||
refreshedProfileFromWorkload(repository, server, workload, existingProfile) {
|
||||
const configuredRoot = path.join(server.basePath, existingProfile.remoteFolder || "").replace(/\/+$/, "");
|
||||
const composeWorkingDir = String(workload.compose?.workingDir || "").replace(/\/+$/, "");
|
||||
const configuredRootOwnsCompose = Boolean(
|
||||
configuredRoot
|
||||
&& composeWorkingDir
|
||||
&& (composeWorkingDir === configuredRoot || composeWorkingDir.startsWith(`${configuredRoot}/`)),
|
||||
);
|
||||
const detected = this.profileFromWorkload(repository, server, workload, {
|
||||
linkSource: existingProfile.workloadIdentity?.linkSource || "automatic-compose",
|
||||
deploymentMode: ["push-bundle", "server-git", "monitor-only"].includes(existingProfile.deploymentMode)
|
||||
? existingProfile.deploymentMode
|
||||
: "push-bundle",
|
||||
remoteFolder: configuredRootOwnsCompose
|
||||
? existingProfile.remoteFolder
|
||||
: workload.remoteFolderCandidate || existingProfile.remoteFolder,
|
||||
});
|
||||
const repositoryRelativeComposeFiles = configuredRootOwnsCompose
|
||||
? [...new Set((workload.compose?.configFiles || []).map((file) => {
|
||||
const value = String(file || "").trim().replace(/\\/g, "/");
|
||||
if (value.startsWith(`${configuredRoot}/`)) return value.slice(configuredRoot.length + 1);
|
||||
return value.startsWith("/") ? "" : value;
|
||||
}).filter(Boolean))]
|
||||
: [];
|
||||
const composeFiles = repositoryRelativeComposeFiles.length
|
||||
? repositoryRelativeComposeFiles
|
||||
: detected.composeFiles;
|
||||
return {
|
||||
...existingProfile,
|
||||
deploymentMode: detected.deploymentMode,
|
||||
remoteFolder: detected.remoteFolder,
|
||||
composeFile: composeFiles[0],
|
||||
composeFiles,
|
||||
composeProject: detected.composeProject,
|
||||
composeWorkingDir: detected.composeWorkingDir,
|
||||
composeService: detected.composeService,
|
||||
composeServices: detected.composeServices,
|
||||
containerName: detected.containerName || existingProfile.containerName,
|
||||
hostPort: detected.hostPort || existingProfile.hostPort || null,
|
||||
containerPort: detected.containerPort || existingProfile.containerPort || null,
|
||||
webUiUrl: detected.webUiUrl || existingProfile.webUiUrl || "",
|
||||
serverIconReference: detected.serverIconReference || existingProfile.serverIconReference || "",
|
||||
dockerShell: detected.dockerShell || existingProfile.dockerShell || "/bin/sh",
|
||||
preservePaths: [...new Set([...(existingProfile.preservePaths || []), ...(detected.preservePaths || [])])],
|
||||
generatedCompose: false,
|
||||
adoptedFromServer: true,
|
||||
serverSourceOfTruth: true,
|
||||
manageDockerMan: false,
|
||||
forceRecreate: false,
|
||||
removeOrphans: false,
|
||||
workloadIdentity: detected.workloadIdentity,
|
||||
detectedAt: detected.detectedAt,
|
||||
detectedMetadata: detected.detectedMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
async saveWorkloadState(profile, workload, server, { expectedGiteaSha = null, health = null } = {}) {
|
||||
const candidateSha = String(workload.metadata?.liveRevision || "");
|
||||
const previousState = this.store.getDeploymentState?.(profile.id) || {};
|
||||
const observedLiveSha = /^[0-9a-f]{40,64}$/i.test(candidateSha) ? candidateSha.toLowerCase() : null;
|
||||
const liveSha = observedLiveSha || previousState.liveSha || null;
|
||||
const profileRemote = inventoryRemoteIdentity(profile.cloneUrl);
|
||||
const workloadRemote = inventoryRemoteIdentity(workload.metadata?.sourceRepository);
|
||||
const repositoryMatches = Boolean(observedLiveSha && profileRemote && workloadRemote && profileRemote === workloadRemote);
|
||||
const verifiedGiteaSha = /^[0-9a-f]{40}$/i.test(String(expectedGiteaSha || ""))
|
||||
? String(expectedGiteaSha).toLowerCase()
|
||||
: null;
|
||||
const matchesGitea = Boolean(repositoryMatches && verifiedGiteaSha && observedLiveSha === verifiedGiteaSha);
|
||||
const primary = workload.containers.find((container) => container.running) || workload.containers[0] || {};
|
||||
const dockerHealthy = workload.runtime.health === "healthy" ? true : workload.runtime.health === "unhealthy" ? false : null;
|
||||
const effectiveHealthy = workload.runtime.running === false
|
||||
? false
|
||||
: health?.configured ? health.healthy : dockerHealthy;
|
||||
return this.store.saveDeploymentState(profile.id, {
|
||||
liveSha,
|
||||
healthy: effectiveHealthy,
|
||||
healthStatus: health?.status ?? null,
|
||||
healthLatencyMs: health?.latencyMs ?? null,
|
||||
runtimeVerification: workload.runtime.running === false ? "stopped" : health?.configured ? "desktop-healthcheck" : workload.runtime.health === "unverified" ? "running-unverified" : workload.runtime.health,
|
||||
containerRunning: workload.runtime.running,
|
||||
dockerHealth: primary.health || null,
|
||||
containerName: primary.name || profile.containerName,
|
||||
remotePath: path.join(server.basePath, profile.remoteFolder),
|
||||
provider: "ssh-unraid",
|
||||
workloadId: workload.workloadId,
|
||||
composeProject: workload.compose?.project || null,
|
||||
observedAt: workload.observedAt,
|
||||
evidence: liveSha ? "container-provenance-label" : "runtime-only",
|
||||
giteaSha: verifiedGiteaSha,
|
||||
matchesGitea,
|
||||
previousSha: previousState.previousSha || null,
|
||||
});
|
||||
}
|
||||
|
||||
async collectServerInventory(serverId, repositories) {
|
||||
const server = this.store.getServer(serverId);
|
||||
if (!server) throw new Error("The deployment server no longer exists.");
|
||||
const result = await this.ssh.exec(server.id, bash(this.inventoryScript(server)), {
|
||||
timeout: 180_000,
|
||||
maxOutput: 64 * 1024 * 1024,
|
||||
});
|
||||
const inventory = parseWorkloadInventory(result.stdout);
|
||||
const profiles = this.allSshProfiles();
|
||||
const detectedWorkloads = buildWorkloadInventory({
|
||||
inventory,
|
||||
server,
|
||||
repositories,
|
||||
profiles,
|
||||
});
|
||||
const detectedIds = new Set(detectedWorkloads.map((item) => item.workloadId));
|
||||
const staleLinks = profiles.filter((profile) => profile.serverId === serverId && profile.workloadIdentity?.workloadId && !detectedIds.has(profile.workloadIdentity.workloadId)).map((profile) => ({
|
||||
workloadId: profile.workloadIdentity.workloadId,
|
||||
serverId,
|
||||
displayName: profile.name || profile.remoteFolder || profile._repositoryFullName,
|
||||
status: "stale",
|
||||
link: { profileId: profile.id, repositoryFullName: profile._repositoryFullName },
|
||||
compose: { project: profile.composeProject || "", workingDir: profile.composeWorkingDir || path.join(server.basePath, profile.remoteFolder || ""), configFiles: profile.composeFiles || [profile.composeFile].filter(Boolean), services: profile.composeServices || [profile.composeService].filter(Boolean) },
|
||||
containers: [],
|
||||
runtime: { running: false, health: "missing" },
|
||||
metadata: { sourceRepository: profile.cloneUrl || "", liveRevision: "", branch: profile.branch || "", staleLink: true },
|
||||
candidates: [{ repositoryFullName: profile._repositoryFullName, repositoryName: profile._repositoryFullName.split("/").pop(), score: 100, exact: true, reasons: ["persisted deployment profile"] }],
|
||||
remoteFolderCandidate: profile.remoteFolder || "",
|
||||
observedAt: new Date().toISOString(),
|
||||
}));
|
||||
const workloads = classifyInventory([...detectedWorkloads, ...staleLinks], profiles, this.store.getInventoryReviewDecisions?.(serverId) || []);
|
||||
return { server, inventory, workloads };
|
||||
}
|
||||
|
||||
inventoryResponse(server, inventory, workloads, changes = {}) {
|
||||
const summary = {
|
||||
serverId: server.id,
|
||||
serverName: server.name,
|
||||
detected: workloads.length,
|
||||
adopted: Number(changes.adopted || 0),
|
||||
refreshed: Number(changes.refreshed || 0),
|
||||
retired: Number(changes.retired || 0),
|
||||
staleProfiles: Array.isArray(changes.staleProfiles) ? changes.staleProfiles : [],
|
||||
verified: workloads.filter((item) => item.runtime.health === "healthy" && item.link).length,
|
||||
linked: workloads.filter((item) => item.status === "linked").length,
|
||||
unmatched: workloads.filter((item) => !item.link).length,
|
||||
needsReview: workloads.filter((item) => {
|
||||
if (item.reviewDecision) return false;
|
||||
const type = item.classification?.type;
|
||||
if (type === "duplicate") return item.runtime?.running === true;
|
||||
if (type === "stale-link") return true;
|
||||
if (["system-container", "external-container", "temporary-runtime", "backup", "release-folder", "historical-compose", "manually-excluded"].includes(type)) return false;
|
||||
return item.runtime?.running && ["suggested", "ambiguous", "unmatched"].includes(item.status);
|
||||
}).length,
|
||||
duplicates: workloads.filter((item) => item.classification?.type === "duplicate").length,
|
||||
excluded: workloads.filter((item) => ["system-container", "external-container", "temporary-runtime", "backup", "release-folder", "historical-compose", "manually-excluded"].includes(item.classification?.type)).length,
|
||||
running: workloads.filter((item) => item.runtime.running).length,
|
||||
stopped: workloads.filter((item) => !item.runtime.running).length,
|
||||
};
|
||||
return {
|
||||
...summary,
|
||||
server: { id: server.id, name: server.name, host: server.host, basePath: server.basePath },
|
||||
capabilities: inventory.capabilities,
|
||||
warnings: inventory.warnings,
|
||||
workloads,
|
||||
observedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async scanServerInventory(serverId, repositories, { autoLink = false } = {}) {
|
||||
const started = Date.now();
|
||||
const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories);
|
||||
let adopted = 0;
|
||||
const adoptedLinks = [];
|
||||
if (autoLink) {
|
||||
const plan = this.reconciliationPlan(server, workloads, repositories, { autoLink: true });
|
||||
if (plan.additions.length) await this.store.createRecoverySnapshot?.(`automatic-server-links-${serverId}`);
|
||||
const linkedRepositories = new Set(workloads
|
||||
.filter((workload) => workload.classification?.type !== "stale-link" && workload.link?.repositoryFullName)
|
||||
.map((workload) => String(workload.link.repositoryFullName).toLowerCase()));
|
||||
for (const addition of plan.additions) {
|
||||
const workload = workloads.find((item) => item.workloadId === addition.workloadId);
|
||||
const repository = (repositories || []).find((item) => String(item.fullName).toLowerCase() === String(addition.repositoryFullName).toLowerCase());
|
||||
const key = String(repository?.fullName || "").toLowerCase();
|
||||
if (!workload || !repository || linkedRepositories.has(key)) continue;
|
||||
const linkSource = addition.evidence === "exact-provenance" ? "automatic" : "automatic-runtime-identity";
|
||||
const profile = this.profileFromWorkload(repository, server, workload, { linkSource, deploymentMode: "server-git" });
|
||||
const saved = await this.store.saveDeploymentProfile(repository.fullName, profile);
|
||||
await this.saveWorkloadState(saved, workload, server);
|
||||
workload.status = "linked";
|
||||
workload.link = { status: "linked", profileId: saved.id, repositoryFullName: repository.fullName, source: linkSource };
|
||||
linkedRepositories.add(key);
|
||||
adopted += 1;
|
||||
adoptedLinks.push({ repositoryFullName: repository.fullName, profileId: saved.id, workloadId: workload.workloadId });
|
||||
}
|
||||
}
|
||||
const response = this.inventoryResponse(server, inventory, workloads, { adopted });
|
||||
await this.diagnostics?.info("unraid.workloads.scanned", {
|
||||
serverId,
|
||||
detected: response.detected,
|
||||
linked: response.linked,
|
||||
needsReview: response.needsReview,
|
||||
adopted,
|
||||
adoptedLinks,
|
||||
readOnly: !autoLink,
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
reconciliationPlan(server, workloads, repositories, { autoLink = true } = {}) {
|
||||
const profiles = this.allSshProfiles().filter((profile) => profile.serverId === server.id);
|
||||
const activeWorkloadIds = new Set(workloads.filter((item) => item.classification?.type !== "stale-link").map((item) => item.workloadId));
|
||||
const linkedRepositories = new Set(workloads
|
||||
.filter((item) => item.classification?.type !== "stale-link" && item.link?.repositoryFullName)
|
||||
.map((item) => String(item.link.repositoryFullName).toLowerCase()));
|
||||
const additions = [];
|
||||
const updates = [];
|
||||
const conflicts = [];
|
||||
for (const workload of workloads) {
|
||||
if (["duplicate", "backup", "release-folder", "historical-compose", "system-container", "external-container", "temporary-runtime", "manually-excluded", "stale-link"].includes(workload.classification?.type)) {
|
||||
if (!workload.reviewDecision && (["historical-compose", "stale-link"].includes(workload.classification?.type) || (workload.classification?.type === "duplicate" && workload.runtime?.running))) conflicts.push({ workloadId: workload.workloadId, displayName: workload.displayName, status: workload.classification.type, reason: workload.classification.reason, candidates: (workload.candidates || []).slice(0, 5).map((item) => ({ repositoryFullName: item.repositoryFullName, score: item.score, exact: item.exact === true })) });
|
||||
continue;
|
||||
}
|
||||
if (workload.link?.profileId && workload.link?.repositoryFullName) {
|
||||
updates.push({
|
||||
workloadId: workload.workloadId,
|
||||
profileId: workload.link.profileId,
|
||||
repositoryFullName: workload.link.repositoryFullName,
|
||||
impact: "Refresh detected Compose identity and observed deployment state",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const candidate = workload.candidates?.[0];
|
||||
const unique = workload.candidates?.length === 1;
|
||||
const exact = unique && (candidate?.exact === true || (candidate?.identityExact === true && candidate.score >= 70));
|
||||
if (autoLink && exact && workload.runtime?.running && !linkedRepositories.has(String(candidate.repositoryFullName).toLowerCase())) {
|
||||
additions.push({
|
||||
workloadId: workload.workloadId,
|
||||
repositoryFullName: candidate.repositoryFullName,
|
||||
evidence: candidate.exact ? "exact-provenance" : "exact-runtime-identity",
|
||||
impact: "Create a server-pull deployment profile; no container changes",
|
||||
});
|
||||
linkedRepositories.add(String(candidate.repositoryFullName).toLowerCase());
|
||||
} else if (["suggested", "ambiguous"].includes(workload.status) || (workload.runtime?.running && workload.candidates?.length)) {
|
||||
conflicts.push({
|
||||
workloadId: workload.workloadId,
|
||||
displayName: workload.displayName,
|
||||
status: workload.status,
|
||||
candidates: (workload.candidates || []).slice(0, 5).map((item) => ({ repositoryFullName: item.repositoryFullName, score: item.score, exact: item.exact === true })),
|
||||
});
|
||||
}
|
||||
}
|
||||
const stale = profiles.filter((profile) =>
|
||||
String(profile.workloadIdentity?.linkSource || "").startsWith("automatic")
|
||||
&& profile.workloadIdentity?.workloadId
|
||||
&& !activeWorkloadIds.has(profile.workloadIdentity.workloadId),
|
||||
).map((profile) => ({
|
||||
profileId: profile.id,
|
||||
repositoryFullName: profile._repositoryFullName,
|
||||
reason: "workload-missing",
|
||||
impact: "Review only; ForgeFlow will not remove this profile automatically",
|
||||
}));
|
||||
const payload = { serverId: server.id, additions, updates, stale, conflicts };
|
||||
return {
|
||||
id: crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex"),
|
||||
createdAt: new Date().toISOString(),
|
||||
...payload,
|
||||
summary: { additions: additions.length, updates: updates.length, stale: stale.length, conflicts: conflicts.length },
|
||||
};
|
||||
}
|
||||
|
||||
async planServerInventoryReconciliation(serverId, repositories, options = {}) {
|
||||
const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories);
|
||||
const plan = this.reconciliationPlan(server, workloads, repositories, options);
|
||||
return { inventory: this.inventoryResponse(server, inventory, workloads), plan };
|
||||
}
|
||||
|
||||
async reconcileServerInventory(serverId, repositories, { autoLink = true, expectedPlanId = "" } = {}) {
|
||||
const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories);
|
||||
const plan = this.reconciliationPlan(server, workloads, repositories, { autoLink });
|
||||
if (!expectedPlanId || expectedPlanId !== plan.id) {
|
||||
const error = new Error(expectedPlanId ? "The server inventory changed after the reconciliation preview. Review a fresh plan before applying it." : "Apply reconciliation only with an explicitly reviewed plan ID.");
|
||||
error.code = expectedPlanId ? "RECONCILIATION_PLAN_STALE" : "RECONCILIATION_PLAN_REQUIRED";
|
||||
error.plan = plan;
|
||||
throw error;
|
||||
}
|
||||
const recoverySnapshot = await this.store.createRecoverySnapshot?.(`server-reconciliation-${serverId}`) || null;
|
||||
let adopted = 0;
|
||||
let refreshed = 0;
|
||||
let retired = 0;
|
||||
let staleProfiles = [];
|
||||
const inventoryStable = (inventory.warnings || []).every((warning) => /stale container reference\(s\) disappeared during inventory/i.test(warning));
|
||||
if (inventoryStable && workloads.length) {
|
||||
const activeWorkloadIds = new Set(workloads.filter((item) => item.classification?.type !== "stale-link").map((item) => item.workloadId));
|
||||
const staleAutomaticProfiles = this.allSshProfiles().filter((profile) =>
|
||||
profile.serverId === serverId
|
||||
&& String(profile.workloadIdentity?.linkSource || "").startsWith("automatic")
|
||||
&& profile.workloadIdentity?.workloadId
|
||||
&& !activeWorkloadIds.has(profile.workloadIdentity.workloadId),
|
||||
);
|
||||
const runningRepositoryLinks = new Set(workloads
|
||||
.filter((workload) => workload.runtime?.running && workload.link?.repositoryFullName)
|
||||
.map((workload) => String(workload.link.repositoryFullName).toLowerCase()));
|
||||
const runningProfileIds = new Set(workloads
|
||||
.filter((workload) => workload.runtime?.running && workload.link?.profileId)
|
||||
.map((workload) => workload.link.profileId));
|
||||
const shadowedAutomaticProfiles = workloads
|
||||
.filter((workload) => !workload.runtime?.running && workload.shadowedLink?.profileId && !runningProfileIds.has(workload.shadowedLink.profileId) && runningRepositoryLinks.has(String(workload.shadowedLink.repositoryFullName).toLowerCase()))
|
||||
.map((workload) => this.allSshProfiles().find((profile) => profile.id === workload.shadowedLink.profileId && String(profile._repositoryFullName).toLowerCase() === String(workload.shadowedLink.repositoryFullName).toLowerCase()))
|
||||
.filter((profile) => profile && String(profile.workloadIdentity?.linkSource || "").startsWith("automatic"));
|
||||
staleProfiles = [...new Map([...staleAutomaticProfiles, ...shadowedAutomaticProfiles].map((profile) => [profile.id, {
|
||||
profileId: profile.id,
|
||||
repositoryFullName: profile._repositoryFullName,
|
||||
reason: staleAutomaticProfiles.includes(profile) ? "workload-missing" : "shadowed-by-running-workload",
|
||||
}])).values()];
|
||||
}
|
||||
for (const workload of workloads) {
|
||||
if (workload.status !== "linked" || !workload.link?.profileId || !workload.link?.repositoryFullName) continue;
|
||||
const repository = (repositories || []).find((item) => String(item.fullName).toLowerCase() === String(workload.link.repositoryFullName).toLowerCase());
|
||||
const existingProfile = this.store.getDeploymentProfile?.(workload.link.repositoryFullName, workload.link.profileId)
|
||||
|| this.allSshProfiles().find((item) => item.id === workload.link.profileId && item._repositoryFullName === workload.link.repositoryFullName);
|
||||
if (!repository || !existingProfile) continue;
|
||||
const updated = this.refreshedProfileFromWorkload(repository, server, workload, existingProfile);
|
||||
const saved = await this.store.saveDeploymentProfile(repository.fullName, updated);
|
||||
await this.saveWorkloadState(saved, workload, server);
|
||||
refreshed += 1;
|
||||
}
|
||||
if (autoLink) {
|
||||
const alreadyLinkedRepositories = new Set(workloads
|
||||
.filter((item) => item.runtime?.running && item.link?.repositoryFullName)
|
||||
.map((item) => String(item.link.repositoryFullName).toLowerCase()));
|
||||
for (const workload of workloads) {
|
||||
if (workload.status === "linked") continue;
|
||||
if (["duplicate", "backup", "release-folder", "historical-compose", "system-container", "external-container", "temporary-runtime", "manually-excluded"].includes(workload.classification?.type)) continue;
|
||||
const candidate = workload.candidates[0];
|
||||
const uniqueCandidate = workload.candidates.length === 1;
|
||||
if (candidate && alreadyLinkedRepositories.has(String(candidate.repositoryFullName).toLowerCase())) continue;
|
||||
const exactMatch = uniqueCandidate && candidate?.exact === true;
|
||||
const exactRuntimeIdentity = uniqueCandidate
|
||||
&& candidate?.identityExact === true
|
||||
&& candidate.score >= 70
|
||||
&& workload.runtime?.running === true
|
||||
&& Boolean(workload.remoteFolderCandidate)
|
||||
&& !alreadyLinkedRepositories.has(String(candidate.repositoryFullName).toLowerCase());
|
||||
if (!exactMatch && !exactRuntimeIdentity) continue;
|
||||
const repository = (repositories || []).find((item) => String(item.fullName).toLowerCase() === String(candidate.repositoryFullName).toLowerCase());
|
||||
if (!repository) continue;
|
||||
const linkSource = exactMatch ? "automatic" : "automatic-runtime-identity";
|
||||
const profile = this.profileFromWorkload(repository, server, workload, { linkSource, deploymentMode: "server-git" });
|
||||
const saved = await this.store.saveDeploymentProfile(repository.fullName, profile);
|
||||
await this.saveWorkloadState(saved, workload, server);
|
||||
workload.status = "linked";
|
||||
workload.link = { status: "linked", profileId: saved.id, repositoryFullName: repository.fullName, source: linkSource };
|
||||
alreadyLinkedRepositories.add(String(repository.fullName).toLowerCase());
|
||||
adopted += 1;
|
||||
}
|
||||
}
|
||||
for (const stale of staleProfiles) {
|
||||
await this.store.deleteDeploymentProfile(stale.repositoryFullName, stale.profileId);
|
||||
retired += 1;
|
||||
}
|
||||
const response = this.inventoryResponse(server, inventory, workloads, { adopted, refreshed, retired, staleProfiles });
|
||||
response.recoverySnapshot = recoverySnapshot;
|
||||
await this.diagnostics?.info("unraid.workloads.reconciled", {
|
||||
serverId,
|
||||
detected: response.detected,
|
||||
adopted,
|
||||
refreshed,
|
||||
retired,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
async discoverServerWorkloads(serverId, repositories) {
|
||||
const started = Date.now();
|
||||
const inventory = await this.scanServerInventory(serverId, repositories, { autoLink: true });
|
||||
const server = this.store.getServer(serverId);
|
||||
const queue = inventory.workloads.filter((workload) => workload.link?.profileId && workload.link?.repositoryFullName);
|
||||
const refreshedProfileIds = [];
|
||||
let giteaUnavailable = false;
|
||||
let giteaFailureReported = false;
|
||||
const workers = Array.from({ length: Math.min(5, queue.length) }, async () => {
|
||||
while (queue.length) {
|
||||
const workload = queue.shift();
|
||||
const repository = repositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link.repositoryFullName).toLowerCase());
|
||||
const profile = this.store.getDeploymentProfile?.(workload.link.repositoryFullName, workload.link.profileId)
|
||||
|| this.store.getDeploymentProfiles?.(workload.link.repositoryFullName)?.find((item) => item.id === workload.link.profileId)
|
||||
|| this.allSshProfiles().find((item) => item.id === workload.link.profileId);
|
||||
if (!repository || !profile) continue;
|
||||
let expectedGiteaSha = null;
|
||||
const status = repository.localStatus;
|
||||
if (status?.head && status.branch?.head === profile.branch && status.branch?.upstream && status.branch.ahead === 0 && status.branch.behind === 0) {
|
||||
expectedGiteaSha = status.head;
|
||||
} else if (!giteaUnavailable) {
|
||||
try {
|
||||
const [owner, repo] = String(repository.fullName).split("/");
|
||||
const branch = await this.gitea.getBranch(owner, repo, profile.branch);
|
||||
expectedGiteaSha = branch?.commit?.id || branch?.commit?.sha || null;
|
||||
} catch (error) {
|
||||
if (!error?.status || Number(error.status) >= 500) {
|
||||
giteaUnavailable = true;
|
||||
if (!giteaFailureReported) {
|
||||
giteaFailureReported = true;
|
||||
await this.diagnostics?.warning("unraid.workloads.gitea-verification-degraded", {
|
||||
serverId,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const health = workload.runtime.running
|
||||
? await this.checkHealth(profile.healthcheckUrl)
|
||||
: { configured: false, healthy: false, skipped: "container-stopped" };
|
||||
await this.saveWorkloadState(profile, workload, server, { expectedGiteaSha, health });
|
||||
refreshedProfileIds.push(profile.id);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
await this.diagnostics?.debug("unraid.workloads.states-refreshed", {
|
||||
serverId,
|
||||
profiles: refreshedProfileIds.length,
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
return { ...inventory, refreshedProfiles: refreshedProfileIds.length, refreshedProfileIds };
|
||||
}
|
||||
|
||||
async linkServerWorkload({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) {
|
||||
const effectiveDeploymentMode = ["push-bundle", "server-git", "monitor-only"].includes(deploymentMode)
|
||||
? deploymentMode
|
||||
: "server-git";
|
||||
const server = this.store.getServer(serverId);
|
||||
if (!server) throw new Error("The deployment server no longer exists.");
|
||||
const inventory = await this.scanServerInventory(serverId, [repository]);
|
||||
const workload = inventory.workloads.find((item) => item.workloadId === workloadId);
|
||||
if (!workload) throw new Error("The selected server workload no longer exists. Scan the server again.");
|
||||
const existing = this.allSshProfiles().find((profile) => profile.workloadIdentity?.workloadId === workloadId && profile.serverId === serverId);
|
||||
if (existing && String(existing._repositoryFullName).toLowerCase() !== String(repository.fullName).toLowerCase()) {
|
||||
const error = new Error(`This workload is already linked to ${existing._repositoryFullName}. Remove or edit that link first.`);
|
||||
error.code = "WORKLOAD_ALREADY_LINKED";
|
||||
throw error;
|
||||
}
|
||||
const profile = this.profileFromWorkload(repository, server, workload, { linkSource: "manual", deploymentMode: effectiveDeploymentMode, remoteFolder });
|
||||
const saved = await this.store.saveDeploymentProfile(repository.fullName, profile);
|
||||
const state = await this.saveWorkloadState(saved, workload, server);
|
||||
await this.diagnostics?.info("unraid.workload.linked", { serverId, workloadId, repository: repository.fullName, profileId: saved.id, deploymentMode: effectiveDeploymentMode });
|
||||
return { profile: saved, state, workload };
|
||||
}
|
||||
}
|
||||
return UnraidInventoryMethods.prototype;
|
||||
}
|
||||
|
||||
module.exports = { createUnraidInventoryMethods };
|
||||
@@ -0,0 +1,622 @@
|
||||
"use strict";
|
||||
|
||||
function createUnraidPreflightMethods({
|
||||
safeRemoteFolder, path, bash, parseInspection, dockerIgnoreHasPath, checksSummary,
|
||||
inventoryRemoteIdentity, deriveDetectedProfile, decodeBase64Json, shellQuote,
|
||||
assertFullCommitSha, nativePath, safeRelativeRemoteFile, fs,
|
||||
}) {
|
||||
class UnraidPreflightMethods {
|
||||
async saveOperation(operation) {
|
||||
const saved = await this.store.addOperation(operation);
|
||||
this.onOperationChange?.({ operations: [saved] });
|
||||
return saved;
|
||||
}
|
||||
|
||||
resolve(repository, profileId) {
|
||||
const profile = this.store.getDeploymentProfile(
|
||||
repository.fullName,
|
||||
profileId,
|
||||
);
|
||||
if (!profile || profile.provider !== "ssh-unraid")
|
||||
throw new Error("The SSH / Unraid deployment profile no longer exists.");
|
||||
const server = this.store.getServer(profile.serverId);
|
||||
if (!server) throw new Error("The deployment server no longer exists.");
|
||||
const remoteFolder = safeRemoteFolder(
|
||||
profile.remoteFolder || repository.name,
|
||||
);
|
||||
const remotePath = path.join(server.basePath, remoteFolder);
|
||||
if (!remotePath.startsWith(`${server.basePath}/`))
|
||||
throw new Error(
|
||||
"Remote project path escapes the configured server base path.",
|
||||
);
|
||||
const effectiveProfile = {
|
||||
...profile,
|
||||
deploymentMode: ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode)
|
||||
? profile.deploymentMode
|
||||
: "push-bundle",
|
||||
};
|
||||
return { profile: effectiveProfile, server, remoteFolder, remotePath };
|
||||
}
|
||||
|
||||
async discoverExisting({ repository, serverId, remoteFolder = "" }) {
|
||||
const server = this.store.getServer(serverId);
|
||||
if (!server) throw new Error("The deployment server no longer exists.");
|
||||
const folder = safeRemoteFolder(remoteFolder || repository.name);
|
||||
const remotePath = path.join(server.basePath, folder);
|
||||
const inventory = await this.scanServerInventory(serverId, [repository], { autoLink: false });
|
||||
const workload = inventory.workloads.find((item) =>
|
||||
item.remoteFolderCandidate === folder ||
|
||||
item.compose?.workingDir === remotePath ||
|
||||
item.containers.some((container) => (container.mounts || []).some((mount) => {
|
||||
const source = String(mount.source || "").replace(/\/+$/, "");
|
||||
return source === remotePath || source.startsWith(`${remotePath}/`);
|
||||
}))
|
||||
);
|
||||
if (!workload) {
|
||||
const error = new Error(`No Docker or Compose workload could be matched to ${remotePath}. Use Server Inventory to select the running container directly.`);
|
||||
error.code = "SERVER_WORKLOAD_NOT_FOUND";
|
||||
throw error;
|
||||
}
|
||||
const profile = this.profileFromWorkload(repository, server, workload, {
|
||||
linkSource: "manual",
|
||||
deploymentMode: "server-git",
|
||||
remoteFolder: folder,
|
||||
});
|
||||
const source = (value, origin, confidence = "confirmed") => ({
|
||||
value,
|
||||
origin,
|
||||
confidence,
|
||||
detectedAt: new Date().toISOString(),
|
||||
overridden: false,
|
||||
});
|
||||
const provenance = {
|
||||
remoteFolder: source(folder, "server-inventory"),
|
||||
cloneUrl: source(profile.cloneUrl, workload.metadata?.sourceRepository ? "container-provenance" : "repository"),
|
||||
branch: source(profile.branch, workload.metadata?.branch ? "container-provenance" : "repository"),
|
||||
composeFile: source(profile.composeFile, "docker-compose-labels"),
|
||||
composeService: source(profile.composeService, "docker-compose-labels"),
|
||||
containerName: source(profile.containerName, "docker-inspect"),
|
||||
hostPort: source(profile.hostPort, "docker-inspect"),
|
||||
containerPort: source(profile.containerPort, "docker-inspect"),
|
||||
webUiUrl: source(profile.webUiUrl, workload.dockerMan?.webUiUrl ? "unraid-dockerman" : "docker-labels"),
|
||||
iconUrl: source(profile.serverIconReference, workload.dockerMan?.iconUrl ? "unraid-dockerman" : "docker-labels"),
|
||||
dockerShell: source(profile.dockerShell, workload.dockerMan?.shell ? "unraid-dockerman" : "docker-labels"),
|
||||
};
|
||||
return {
|
||||
repository: repository.fullName,
|
||||
profile: { ...profile, id: undefined, provenance },
|
||||
provenance,
|
||||
workload,
|
||||
runtime: {
|
||||
remotePath,
|
||||
containerRunning: workload.runtime.running,
|
||||
containers: workload.containers.length,
|
||||
services: workload.compose?.services?.length || workload.containers.length,
|
||||
ports: workload.runtime.ports,
|
||||
mounts: workload.containers.flatMap((container) => container.mounts || []),
|
||||
networks: [...new Set(workload.containers.flatMap((container) => container.networks || []))],
|
||||
envNames: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async inspect({ repository, profileId }) {
|
||||
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
||||
const preserveProbe = (profile.preservePaths || [])
|
||||
.map(
|
||||
(relativePath) =>
|
||||
`if [ -e "$root"/${shellQuote(relativePath)} ]; then printf '%s\\n' ${shellQuote(relativePath)}; fi`,
|
||||
)
|
||||
.join("\n");
|
||||
const script = `
|
||||
root=${shellQuote(remotePath)}
|
||||
exists=false; root_git=false; head=""; branch=""; remote=""; tracked_changes=""; compose_files=""; nested_git=""; dockerfile=false; dockerignore_content=""; existing_preserve_paths=""
|
||||
if [ -d "$root" ]; then
|
||||
exists=true
|
||||
if [ -d "$root/.git" ]; then
|
||||
root_git=true
|
||||
head=$(git -C "$root" rev-parse HEAD 2>/dev/null || true)
|
||||
branch=$(git -C "$root" branch --show-current 2>/dev/null || true)
|
||||
remote=$(git -C "$root" remote get-url origin 2>/dev/null || true)
|
||||
tracked_changes=$(git -C "$root" status --porcelain --untracked-files=no 2>/dev/null | head -n 25 | base64 | tr -d '\\r\\n' || true)
|
||||
fi
|
||||
compose_files=$(find "$root" -maxdepth 2 -type f \\( -name 'docker-compose.yml' -o -name 'docker-compose.yaml' -o -name 'compose.yml' -o -name 'compose.yaml' -o -name 'compose.forgeflow.yml' \\) -printf '%P\\n' 2>/dev/null | sort | base64 | tr -d '\\r\\n' || true)
|
||||
nested_git=$(find "$root" -mindepth 2 -maxdepth 4 -type d -name .git -printf '%h\\n' 2>/dev/null | sed "s#^$root/##" | sort | base64 | tr -d '\\r\\n' || true)
|
||||
[ -f "$root/Dockerfile" ] && dockerfile=true
|
||||
[ -f "$root/.dockerignore" ] && dockerignore_content=$(base64 < "$root/.dockerignore" | tr -d '\\r\\n' || true)
|
||||
existing_preserve_paths=$({ ${preserveProbe || ":"}; } | sort -u | base64 | tr -d '\\r\\n' || true)
|
||||
fi
|
||||
printf '__FORGEFLOW_KV__\\n'
|
||||
printf 'exists=%s\\n' "$exists"
|
||||
printf 'rootGit=%s\\n' "$root_git"
|
||||
printf 'head=%s\\n' "$head"
|
||||
printf 'branch=%s\\n' "$branch"
|
||||
printf 'remote=%s\\n' "$(printf '%s' "$remote" | base64 | tr -d '\\r\\n')"
|
||||
printf 'trackedChanges=%s\\n' "$tracked_changes"
|
||||
printf 'composeFiles=%s\\n' "$compose_files"
|
||||
printf 'nestedGit=%s\\n' "$nested_git"
|
||||
printf 'dockerfile=%s\\n' "$dockerfile"
|
||||
printf 'dockerignoreContent=%s\\n' "$dockerignore_content"
|
||||
printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths"
|
||||
`;
|
||||
const wrapped = bash(script);
|
||||
const result = await this.ssh.exec(server.id, wrapped, { timeout: 60_000 });
|
||||
const parsed = parseInspection(result.stdout);
|
||||
const contextCandidates = [
|
||||
...new Set([
|
||||
...(parsed.existingPreservePaths || []),
|
||||
...(parsed.nestedGit || []),
|
||||
]),
|
||||
];
|
||||
const inspection = {
|
||||
...parsed,
|
||||
dockerignore: Boolean(parsed.dockerignoreContent),
|
||||
dockerignoreGitExcluded: dockerIgnoreHasPath(
|
||||
parsed.dockerignoreContent,
|
||||
".git",
|
||||
),
|
||||
dockerContextExclusionsMissing: parsed.dockerfile
|
||||
? contextCandidates.filter(
|
||||
(item) => !dockerIgnoreHasPath(parsed.dockerignoreContent, item),
|
||||
)
|
||||
: [],
|
||||
serverId: server.id,
|
||||
serverName: server.name,
|
||||
remotePath,
|
||||
profileId: profile.id,
|
||||
};
|
||||
await this.diagnostics?.info("unraid.inspected", {
|
||||
repository: repository.fullName,
|
||||
serverId: server.id,
|
||||
remotePath,
|
||||
exists: inspection.exists,
|
||||
rootGit: inspection.rootGit,
|
||||
head: inspection.head,
|
||||
composeFiles: inspection.composeFiles,
|
||||
nestedGitCount: inspection.nestedGit.length,
|
||||
trackedChangeCount: inspection.trackedChanges.length,
|
||||
dockerContextExclusionsMissing: inspection.dockerContextExclusionsMissing,
|
||||
});
|
||||
return inspection;
|
||||
}
|
||||
|
||||
async preflight({ repository, profileId, sha = null }) {
|
||||
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
||||
const deploymentMode = ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode)
|
||||
? profile.deploymentMode
|
||||
: "push-bundle";
|
||||
let requestedSha = sha || repository.localStatus?.head;
|
||||
if (deploymentMode === "server-git" && !sha) {
|
||||
const [owner, repo] = String(repository.fullName || "").split("/");
|
||||
const branch = await this.gitea.getBranch(owner, repo, profile.branch);
|
||||
requestedSha = branch?.commit?.id || branch?.commit?.sha || null;
|
||||
}
|
||||
const targetSha = assertFullCommitSha(requestedSha);
|
||||
const checks = [];
|
||||
let inspection = null;
|
||||
let connectionCapabilities = null;
|
||||
let permissions = null;
|
||||
|
||||
if (deploymentMode === "monitor-only") checks.push({
|
||||
id: "deployment-mode",
|
||||
label: "Deployment mode",
|
||||
status: "fail",
|
||||
detail: "This workload is linked for monitoring only. Select Server pull or Direct copy before deploying.",
|
||||
});
|
||||
else checks.push({
|
||||
id: "deployment-mode",
|
||||
label: "Deployment mode",
|
||||
status: "pass",
|
||||
detail: deploymentMode === "server-git"
|
||||
? "Unraid fetches the exact Gitea commit with a repository-scoped read-only deploy key."
|
||||
: "ForgeFlow copies the exact committed local project directly to Unraid and runs Docker Compose there.",
|
||||
});
|
||||
|
||||
if (!repository.localPath) {
|
||||
checks.push({
|
||||
id: "local-repository",
|
||||
label: "Local repository",
|
||||
status: deploymentMode === "server-git" ? "pass" : "fail",
|
||||
detail: deploymentMode === "server-git"
|
||||
? "Not required: the exact commit is fetched from Gitea by the server."
|
||||
: "Link or clone the repository locally before using Direct copy.",
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
const localStatus = await this.git.status(repository.localPath);
|
||||
checks.push({
|
||||
id: "local-repository",
|
||||
label: "Local repository",
|
||||
status: "pass",
|
||||
detail: localStatus.root,
|
||||
});
|
||||
checks.push({
|
||||
id: "local-branch",
|
||||
label: "Allowed branch",
|
||||
status: localStatus.branch.head === profile.branch ? "pass" : deploymentMode === "server-git" ? "warning" : "fail",
|
||||
detail: `Current: ${localStatus.branch.head || "detached"}; required: ${profile.branch}.`,
|
||||
});
|
||||
checks.push({
|
||||
id: "local-clean",
|
||||
label: "Clean local working tree",
|
||||
status: localStatus.clean ? "pass" : deploymentMode === "server-git" ? "warning" : "fail",
|
||||
detail: localStatus.clean
|
||||
? "No uncommitted changes."
|
||||
: `${localStatus.counts.changed} changed file(s) remain.`,
|
||||
});
|
||||
checks.push({
|
||||
id: "deployment-source",
|
||||
label: deploymentMode === "server-git" ? "Gitea deployment source" : "Direct deployment source",
|
||||
status: "pass",
|
||||
detail: deploymentMode === "server-git"
|
||||
? "Local files are not uploaded; the exact requested commit is fetched from Gitea."
|
||||
: "The exact committed local HEAD is archived and copied directly to Unraid. No server-side repository access is involved.",
|
||||
});
|
||||
|
||||
|
||||
const localDeploymentFiles = deploymentMode === "push-bundle" && profile.generatedCompose
|
||||
? [nativePath.join(repository.localPath, "Dockerfile")]
|
||||
: deploymentMode === "push-bundle" ? this.deploymentComposeFiles(profile).map((file) =>
|
||||
nativePath.join(repository.localPath, safeRelativeRemoteFile(file)),
|
||||
) : [];
|
||||
const missingDeploymentFiles = [];
|
||||
for (const file of localDeploymentFiles) {
|
||||
if (!(await fs.stat(file).catch(() => null))?.isFile()) missingDeploymentFiles.push(file);
|
||||
}
|
||||
if (deploymentMode === "push-bundle") checks.push({
|
||||
id: "local-deployment-file",
|
||||
label: profile.generatedCompose
|
||||
? "Dockerfile in repository"
|
||||
: localDeploymentFiles.length > 1 ? "Compose files in repository" : "Compose file in repository",
|
||||
status: missingDeploymentFiles.length ? "fail" : "pass",
|
||||
detail: missingDeploymentFiles.length
|
||||
? `Missing from the exact local checkout: ${missingDeploymentFiles.join(", ")}`
|
||||
: localDeploymentFiles.join(", "),
|
||||
});
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: "local-repository",
|
||||
label: "Local repository",
|
||||
status: "fail",
|
||||
detail: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (deploymentMode === "server-git") {
|
||||
const [owner, repo] = String(repository.fullName || "").split("/");
|
||||
const deploymentFiles = profile.generatedCompose
|
||||
? ["Dockerfile"]
|
||||
: this.deploymentComposeFiles(profile);
|
||||
try {
|
||||
const existence = await Promise.all(deploymentFiles.map(async (filePath) => ({
|
||||
filePath,
|
||||
exists: await this.gitea.repositoryFileExists({ owner, repo, filePath, ref: targetSha }),
|
||||
})));
|
||||
const missing = existence.filter((item) => !item.exists).map((item) => item.filePath);
|
||||
checks.push({
|
||||
id: "gitea-deployment-files",
|
||||
label: profile.generatedCompose ? "Dockerfile at Gitea commit" : "Compose files at Gitea commit",
|
||||
status: missing.length ? "fail" : "pass",
|
||||
detail: missing.length
|
||||
? `Missing at exact commit ${targetSha.slice(0, 12)}: ${missing.join(", ")}.`
|
||||
: `${deploymentFiles.join(", ")} verified at exact commit ${targetSha.slice(0, 12)}.`,
|
||||
});
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: "gitea-deployment-files",
|
||||
label: "Deployment files at Gitea commit",
|
||||
status: "fail",
|
||||
detail: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const connection = await this.ssh.test(server.id, { trustOnFirstUse: false });
|
||||
connectionCapabilities = connection.capabilities || {};
|
||||
checks.push({
|
||||
id: "ssh",
|
||||
label: "Desktop → Unraid SSH",
|
||||
status: "pass",
|
||||
detail: `${server.username}@${server.host}:${server.port}`,
|
||||
});
|
||||
checks.push({
|
||||
id: "docker-runtime",
|
||||
label: "Docker runtime",
|
||||
status: connectionCapabilities.docker && connectionCapabilities.dockerReady ? "pass" : "fail",
|
||||
detail: connectionCapabilities.dockerReady
|
||||
? "Docker is reachable by the configured SSH user."
|
||||
: connectionCapabilities.docker
|
||||
? "Docker is installed, but the configured SSH user cannot query the daemon."
|
||||
: "Docker was not detected on the server.",
|
||||
});
|
||||
checks.push({
|
||||
id: "compose-command",
|
||||
label: "Docker Compose",
|
||||
status: connectionCapabilities.compose ? "pass" : "fail",
|
||||
detail: connectionCapabilities.composeVersion || "Docker Compose was not detected on the server.",
|
||||
});
|
||||
checks.push({
|
||||
id: "bundle-tools",
|
||||
label: deploymentMode === "server-git" ? "Server pull tools" : "Direct copy tools",
|
||||
status: connectionCapabilities.tar && connectionCapabilities.checksum && (deploymentMode !== "server-git" || connectionCapabilities.git) ? "pass" : "fail",
|
||||
detail: deploymentMode === "server-git"
|
||||
? `Git ${connectionCapabilities.git ? "available" : "missing"}; tar ${connectionCapabilities.tar ? "available" : "missing"}; checksum ${connectionCapabilities.checksum ? "available" : "missing"}.`
|
||||
: connectionCapabilities.tar && connectionCapabilities.checksum
|
||||
? "tar and a SHA-256 checksum tool are available."
|
||||
: `tar ${connectionCapabilities.tar ? "available" : "missing"}; checksum tool ${connectionCapabilities.checksum ? "available" : "missing"}.`,
|
||||
});
|
||||
checks.push({
|
||||
id: "server-base-writable",
|
||||
label: "Deployment storage writable",
|
||||
status: connectionCapabilities.baseWritable ? "pass" : "fail",
|
||||
detail: connectionCapabilities.baseWritable ? `${server.basePath} is writable.` : `${server.basePath} cannot be created or written by this SSH user.`,
|
||||
});
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: "ssh",
|
||||
label: "Desktop → Unraid SSH",
|
||||
status: "fail",
|
||||
detail: error.message,
|
||||
});
|
||||
}
|
||||
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,
|
||||
});
|
||||
|
||||
if (deploymentMode === "server-git") {
|
||||
const access = await this.probeServerGitAccess({ repository, profile, server });
|
||||
checks.push({
|
||||
id: "server-git-access",
|
||||
label: "Unraid → Gitea read access",
|
||||
status: access.ready ? "pass" : "fail",
|
||||
detail: access.ready
|
||||
? `Read-only deploy key verified${access.remoteSha ? ` at ${access.remoteSha.slice(0, 7)}` : ""}.`
|
||||
: access.error,
|
||||
repairAction: access.ready ? null : "configure-server-git-access",
|
||||
repairLabel: "Configure read-only deploy key",
|
||||
});
|
||||
} else checks.push({
|
||||
id: "transfer-path",
|
||||
label: "Desktop → Unraid transfer",
|
||||
status: "pass",
|
||||
detail: "Files are copied over the configured server connection. No Gitea credential is required on Unraid.",
|
||||
});
|
||||
|
||||
try {
|
||||
permissions = await this.inspectWriteAccess({ repository, profileId });
|
||||
const blockingPaths = permissions.blocking.map((target) => target.path);
|
||||
checks.push({
|
||||
id: "project-write-access",
|
||||
label: "Project write access",
|
||||
status: permissions.ready ? "pass" : "fail",
|
||||
detail: permissions.ready
|
||||
? `${permissions.identity.user} can create and atomically replace deployment files in ${remotePath}.`
|
||||
: `No safe write access for ${permissions.identity.user}: ${blockingPaths.join(", ")}`,
|
||||
help: permissions.ready
|
||||
? "ForgeFlow rechecks these paths immediately before every upload and Compose activation."
|
||||
: "Use Fix write access to repair only the linked project source and ForgeFlow state folders. Preserved runtime data is excluded.",
|
||||
repairAction: permissions.ready ? null : "repair-deployment-write-access",
|
||||
repairLabel: "Fix write access",
|
||||
});
|
||||
for (const target of permissions.targets.filter(
|
||||
(item) => item.required && !item.effectiveWritable,
|
||||
)) {
|
||||
checks.push({
|
||||
id: `write-path:${target.id}`,
|
||||
label: target.label,
|
||||
status: "fail",
|
||||
detail: `${target.path} · owner ${target.owner || "unknown"}:${target.group || "unknown"} · mode ${target.mode || "unknown"}. ${target.detail}`,
|
||||
repairAction: "repair-deployment-write-access",
|
||||
repairLabel: "Fix write access",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: "project-write-access",
|
||||
label: "Project write access",
|
||||
status: "fail",
|
||||
detail: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
inspection = await this.inspect({ repository, profileId });
|
||||
if (!inspection.exists) {
|
||||
checks.push({
|
||||
id: "remote-folder",
|
||||
label: "Remote project folder",
|
||||
status: "pass",
|
||||
detail: `${remotePath} will be created.`,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
id: "remote-folder",
|
||||
label: inspection.rootGit ? "Remote project folder" : "Existing server installation",
|
||||
status: "pass",
|
||||
detail: inspection.rootGit
|
||||
? `${remotePath} currently contains Git commit ${String(inspection.head || "").slice(0, 7) || "unknown"}.`
|
||||
: `${remotePath} will receive managed release files while preserved and unknown runtime data remains untouched.`,
|
||||
});
|
||||
}
|
||||
if (inspection.rootGit) {
|
||||
checks.push({
|
||||
id: "tracked-changes",
|
||||
label: "Server-side tracked changes",
|
||||
status: inspection.trackedChanges.length ? "warning" : "pass",
|
||||
detail: inspection.trackedChanges.length
|
||||
? `${inspection.trackedChanges.length} tracked server edit(s) exist. Direct copy preserves unknown runtime data and does not depend on the server Git checkout.`
|
||||
: "No tracked server-only edits detected.",
|
||||
});
|
||||
}
|
||||
|
||||
if (inspection.nestedGit.length) {
|
||||
checks.push({
|
||||
id: "nested-git",
|
||||
label: "Nested Git repositories",
|
||||
status: "warning",
|
||||
detail: `Detected: ${inspection.nestedGit.join(", ")}. ForgeFlow will not delete them automatically.`,
|
||||
});
|
||||
}
|
||||
if (inspection.dockerfile && !inspection.dockerignore) {
|
||||
checks.push({
|
||||
id: "dockerignore",
|
||||
label: "Docker build context",
|
||||
status: "warning",
|
||||
detail:
|
||||
"A Dockerfile exists but .dockerignore is missing. Add one in the repository before large builds.",
|
||||
});
|
||||
} else if (inspection.dockerfile && !inspection.dockerignoreGitExcluded) {
|
||||
checks.push({
|
||||
id: "dockerignore-git",
|
||||
label: "Git metadata excluded from Docker",
|
||||
status: "warning",
|
||||
detail: ".dockerignore does not explicitly exclude .git.",
|
||||
});
|
||||
} else if (inspection.dockerfile) {
|
||||
checks.push({
|
||||
id: "dockerignore-git",
|
||||
label: "Git metadata excluded from Docker",
|
||||
status: "pass",
|
||||
detail: ".git is excluded from the Docker build context.",
|
||||
});
|
||||
}
|
||||
if (inspection.dockerContextExclusionsMissing.length) {
|
||||
checks.push({
|
||||
id: "dockerignore-runtime",
|
||||
label: "Runtime data excluded from Docker",
|
||||
status: "warning",
|
||||
detail: `Add these existing runtime or legacy paths to .dockerignore: ${inspection.dockerContextExclusionsMissing.join(", ")}.`,
|
||||
});
|
||||
} else if (
|
||||
inspection.dockerfile &&
|
||||
inspection.existingPreservePaths.length
|
||||
) {
|
||||
checks.push({
|
||||
id: "dockerignore-runtime",
|
||||
label: "Runtime data excluded from Docker",
|
||||
status: "pass",
|
||||
detail:
|
||||
"Detected preserved runtime paths are excluded from the Docker build context.",
|
||||
});
|
||||
}
|
||||
const composeFiles = profile.generatedCompose
|
||||
? [".forgeflow/compose.forgeflow.yml"]
|
||||
: (profile.composeFiles?.length ? profile.composeFiles : [profile.composeFile || "docker-compose.yml"])
|
||||
.map((value) => safeRelativeRemoteFile(value));
|
||||
const missingRemoteCompose = composeFiles.filter((composeFile) => !inspection.composeFiles.includes(composeFile));
|
||||
checks.push({
|
||||
id: "compose-file",
|
||||
label: "Compose configuration",
|
||||
status: "pass",
|
||||
detail: profile.generatedCompose
|
||||
? "ForgeFlow will generate an isolated Compose file."
|
||||
: missingRemoteCompose.length
|
||||
? `${composeFiles.join(", ")} will be uploaded from the exact local commit.`
|
||||
: composeFiles.join(", "),
|
||||
});
|
||||
} 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",
|
||||
repository: repository.fullName,
|
||||
environment: profile.environment,
|
||||
sha: targetSha,
|
||||
server: { id: server.id, name: server.name, host: server.host },
|
||||
remotePath,
|
||||
inspection,
|
||||
permissions,
|
||||
checks,
|
||||
summary: checksSummary(checks),
|
||||
};
|
||||
}
|
||||
}
|
||||
return UnraidPreflightMethods.prototype;
|
||||
}
|
||||
|
||||
module.exports = { createUnraidPreflightMethods };
|
||||
@@ -0,0 +1,387 @@
|
||||
"use strict";
|
||||
|
||||
function createUnraidRuntimeMethods({
|
||||
safeRelativeRemoteFile, xmlEscape, nativePath, fileSystem, fs, crypto, os, run,
|
||||
bash, shellQuote, iconReferenceLocalPath,
|
||||
}) {
|
||||
class UnraidRuntimeMethods {
|
||||
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 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.",
|
||||
);
|
||||
return (
|
||||
[
|
||||
"services:",
|
||||
` ${service}:`,
|
||||
` image: forgeflow/${this.internalSlug(profile, repository)}:${String(profile.environment || "production").toLowerCase()}`,
|
||||
" build:",
|
||||
" context: ..",
|
||||
` container_name: ${containerName}`,
|
||||
" restart: unless-stopped",
|
||||
" ports:",
|
||||
` - "${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 = "") {
|
||||
if (profile.manageDockerMan !== true || profile.adoptedFromServer === true || profile.generatedCompose !== true) {
|
||||
return `echo 'ForgeFlow left the existing DockerMan template unchanged.'`;
|
||||
}
|
||||
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)}`;
|
||||
}
|
||||
|
||||
deploymentServices(profile, repository) {
|
||||
const values = profile.generatedCompose
|
||||
? [profile.composeService || repository.name || "app"]
|
||||
: (profile.composeServices?.length ? profile.composeServices : [profile.composeService || repository.name || "app"]);
|
||||
return [...new Set(values.map((value) => String(value || "").trim().toLowerCase().replace(/[^a-z0-9._-]/g, "-")).filter(Boolean))];
|
||||
}
|
||||
|
||||
deploymentComposeFiles(profile) {
|
||||
if (profile.generatedCompose) return [".forgeflow/compose.forgeflow.yml"];
|
||||
const values = profile.composeFiles?.length ? profile.composeFiles : [profile.composeFile || "docker-compose.yml"];
|
||||
return [...new Set(values
|
||||
.map((value) => safeRelativeRemoteFile(value))
|
||||
.filter((value) => value !== ".forgeflow/compose.metadata.yml" && value !== ".forgeflow/compose.forgeflow.yml"))];
|
||||
}
|
||||
|
||||
metadataCompose(profile, repository, iconReference = "", deployment = {}) {
|
||||
const services = this.deploymentServices(profile, repository);
|
||||
const labels = {
|
||||
"net.unraid.docker.managed": "dockerman",
|
||||
"net.unraid.docker.shell": this.dockerManShell(profile),
|
||||
"tech.itworx.forgeflow.repository":
|
||||
deployment.repositoryUrl ||
|
||||
profile.cloneUrl ||
|
||||
repository.sshUrl ||
|
||||
repository.cloneUrl ||
|
||||
repository.htmlUrl ||
|
||||
repository.fullName || repository.name || "unknown",
|
||||
"tech.itworx.forgeflow.branch": profile.branch || "main",
|
||||
};
|
||||
if (deployment.sha) labels["tech.itworx.forgeflow.commit"] = deployment.sha;
|
||||
const webUiLabel = this.dockerManWebUi(profile);
|
||||
if (webUiLabel) labels["net.unraid.docker.webui"] = webUiLabel;
|
||||
if (iconReference) labels["net.unraid.docker.icon"] = iconReference;
|
||||
|
||||
const output = ["services:"];
|
||||
for (const service of services) {
|
||||
output.push(` ${service}:`);
|
||||
if (profile.generatedCompose) {
|
||||
const containerName = String(
|
||||
profile.containerName || profile.remoteFolder || repository.name || service,
|
||||
).replace(/[^A-Za-z0-9._-]/g, "-") || "app";
|
||||
output.push(` image: forgeflow/${this.internalSlug(profile, repository)}:${String(profile.environment || "production").toLowerCase().replace(/[^a-z0-9._-]/g, "-")}`);
|
||||
output.push(` container_name: ${containerName}`);
|
||||
}
|
||||
output.push(" labels:");
|
||||
output.push(...Object.entries(labels).map(([key, value]) => ` ${JSON.stringify(key)}: ${JSON.stringify(value)}`));
|
||||
}
|
||||
return `${output.join("\n")}\n`;
|
||||
}
|
||||
|
||||
async prepareIcon(profile, repository, server) {
|
||||
const mode =
|
||||
profile.iconMode ||
|
||||
(profile.iconFilePath ? "upload" : profile.iconUrl ? "url" : "builtin");
|
||||
if (mode === "none") return profile.serverIconReference || "";
|
||||
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) {
|
||||
const project = String(profile.composeProject || this.internalSlug(profile, repository)).trim();
|
||||
const files = [...this.deploymentComposeFiles(profile)];
|
||||
// A labels-only Compose fragment is valid only when every service key also
|
||||
// exists in the base definition. Imported profiles can contain stale service
|
||||
// hints, so adopted workloads must activate from their real server Compose
|
||||
// files only. ForgeFlow tracks the deployed SHA in .forgeflow/status.json.
|
||||
if (profile.generatedCompose) files.push(".forgeflow/compose.metadata.yml");
|
||||
return `forgeflow_compose -p ${shellQuote(project)} ${files.map((file) => `-f ${shellQuote(file)}`).join(" ")}`;
|
||||
}
|
||||
|
||||
composeUpFlags(profile) {
|
||||
// ForgeFlow never adds destructive recreation or orphan-removal flags.
|
||||
// Compose may replace a service when its built image or configuration changed,
|
||||
// but unrelated containers are never deleted by ForgeFlow.
|
||||
void profile;
|
||||
return "";
|
||||
}
|
||||
|
||||
containerVerificationScript(profile, repository, compose, { requireRecreated = false } = {}) {
|
||||
const recreationCheck = requireRecreated
|
||||
? ` before_id=$(awk -F '\t' -v wanted="$service" '$1 == wanted { print $2; exit }' "$before_containers" 2>/dev/null || true)
|
||||
if [ -n "$before_id" ] && [ "$before_id" = "$container_id" ]; then
|
||||
echo "Compose reported success but service $service still uses the previous container $container_id" >&2
|
||||
exit 65
|
||||
fi`
|
||||
: ` before_id=""`;
|
||||
return `actual_services=$(${compose} config --services 2>/dev/null | sed '/^$/d')
|
||||
[ -n "$actual_services" ] || { echo "Compose project defines no services" >&2; exit 60; }
|
||||
printf '%s\n' "$actual_services" | while IFS= read -r service; do
|
||||
[ -n "$service" ] || continue
|
||||
attempt=0; container_id=''; running=false; health=''
|
||||
while [ "$attempt" -lt 30 ]; do
|
||||
container_id=$(${compose} ps -q "$service" | head -n1)
|
||||
if [ -n "$container_id" ]; then
|
||||
running=$(docker inspect -f '{{.State.Running}}' "$container_id" 2>/dev/null || echo false)
|
||||
health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container_id" 2>/dev/null || true)
|
||||
if [ "$running" = true ] && [ "$health" != unhealthy ] && [ "$health" != starting ]; then break; fi
|
||||
fi
|
||||
attempt=$((attempt + 1)); sleep 2
|
||||
done
|
||||
[ -n "$container_id" ] || { echo "Compose service $service did not create a container" >&2; exit 61; }
|
||||
[ "$running" = true ] || { echo "Compose service $service is not running after 60 seconds" >&2; exit 62; }
|
||||
[ "$health" != unhealthy ] && [ "$health" != starting ] || { echo "Compose service $service did not become healthy" >&2; exit 63; }
|
||||
${recreationCheck}
|
||||
image_id=$(docker inspect -f '{{.Image}}' "$container_id" 2>/dev/null || true)
|
||||
printf 'ForgeFlow verified service %s: container=%s previous=%s image=%s\n' "$service" "$container_id" "\${before_id:-none}" "\${image_id:-unknown}"
|
||||
done`;
|
||||
}
|
||||
|
||||
async checkHealth(url) {
|
||||
if (!url)
|
||||
return {
|
||||
configured: false,
|
||||
healthy: null,
|
||||
status: null,
|
||||
latencyMs: null,
|
||||
};
|
||||
let last = null;
|
||||
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
redirect: "manual",
|
||||
});
|
||||
last = {
|
||||
configured: true,
|
||||
healthy: response.ok,
|
||||
status: response.status,
|
||||
latencyMs: Date.now() - started,
|
||||
};
|
||||
if (response.ok) return last;
|
||||
} catch (error) {
|
||||
last = {
|
||||
configured: true,
|
||||
healthy: false,
|
||||
status: null,
|
||||
latencyMs: Date.now() - started,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
if (attempt < 5)
|
||||
await new Promise((resolve) => setTimeout(resolve, 3_000));
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
hashFile(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash("sha256");
|
||||
const stream = fileSystem.createReadStream(filePath);
|
||||
stream.on("error", reject);
|
||||
stream.on("data", (chunk) => hash.update(chunk));
|
||||
stream.on("end", () => resolve(hash.digest("hex")));
|
||||
});
|
||||
}
|
||||
|
||||
async createCommitBundle(repository, sha, requestId) {
|
||||
if (!repository.localPath) throw new Error("A linked local repository is required to create a push bundle.");
|
||||
const bundleDirectory = nativePath.join(os.tmpdir(), "forgeflow-bundles");
|
||||
await fs.mkdir(bundleDirectory, { recursive: true });
|
||||
const archivePath = nativePath.join(bundleDirectory, `${requestId}-${sha}.tar`);
|
||||
await run("git", ["-C", repository.localPath, "archive", "--format=tar", `--output=${archivePath}`, sha], {
|
||||
timeout: 5 * 60_000,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
});
|
||||
const stat = await fs.stat(archivePath);
|
||||
if (!stat.isFile() || stat.size <= 0) throw new Error("Git produced an empty deployment bundle.");
|
||||
return { archivePath, bytes: stat.size, sha256: await this.hashFile(archivePath) };
|
||||
}
|
||||
|
||||
deploymentStatusDocument({ repository, profile, targetSha, requestId, rollback = false }) {
|
||||
return JSON.stringify({
|
||||
repository: repository.fullName,
|
||||
environment: profile.environment,
|
||||
requested_sha: targetSha,
|
||||
live_sha: targetSha,
|
||||
request_id: requestId,
|
||||
healthy: null,
|
||||
healthcheck_url_configured: Boolean(profile.healthcheckUrl),
|
||||
rollback,
|
||||
deployment_mode: profile.deploymentMode || "push-bundle",
|
||||
deployed_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return UnraidRuntimeMethods.prototype;
|
||||
}
|
||||
|
||||
module.exports = { createUnraidRuntimeMethods };
|
||||
@@ -0,0 +1,293 @@
|
||||
"use strict";
|
||||
|
||||
function createUnraidStateMethods({ path, bash, shellQuote, inventoryRemoteIdentity }) {
|
||||
class UnraidStateMethods {
|
||||
async refreshProfileState(fullName, profileId, expectedGiteaSha = null) {
|
||||
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)}
|
||||
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")
|
||||
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)
|
||||
[ -z "$live" ] && live=$(docker inspect -f '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$container" 2>/dev/null || true)
|
||||
[ -z "$live" ] && live=$(docker inspect -f '{{index .Config.Labels "tech.itworx.forgeflow.commit"}}' "$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 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 containerRunning = fields.containerRunning === "true";
|
||||
const health = containerRunning
|
||||
? await this.checkHealth(profile.healthcheckUrl)
|
||||
: { configured: false, healthy: false, skipped: "container-stopped" };
|
||||
const dockerHealthy = fields.dockerHealth
|
||||
? fields.dockerHealth === "healthy"
|
||||
: null;
|
||||
const effectiveHealthy = !containerRunning ? false : health.configured ? health.healthy : dockerHealthy;
|
||||
const runtimeVerification = !containerRunning
|
||||
? "stopped"
|
||||
: health.configured
|
||||
? "desktop-healthcheck"
|
||||
: dockerHealthy === true
|
||||
? "docker-healthcheck"
|
||||
: dockerHealthy === false
|
||||
? "docker-unhealthy"
|
||||
: containerRunning
|
||||
? "running-unverified"
|
||||
: "stopped";
|
||||
return this.store.saveDeploymentState(profile.id, {
|
||||
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,
|
||||
runtimeVerification,
|
||||
healthStatus: health.status,
|
||||
healthLatencyMs: health.latencyMs,
|
||||
containerName,
|
||||
containerRunning,
|
||||
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",
|
||||
giteaSha: /^[0-9a-f]{40}$/i.test(String(expectedGiteaSha || ""))
|
||||
? expectedGiteaSha
|
||||
: null,
|
||||
matchesGitea:
|
||||
/^[0-9a-f]{40}$/i.test(String(expectedGiteaSha || "")) &&
|
||||
fields.liveSha === expectedGiteaSha,
|
||||
});
|
||||
}
|
||||
|
||||
async applyDockerManMetadata({ repository, profileId }) {
|
||||
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
||||
if (profile.generatedCompose !== true) {
|
||||
// Existing Compose files remain authoritative. Applying a generated
|
||||
// labels-only service fragment can create a phantom service when a stale
|
||||
// profile hint no longer matches the real Compose service keys.
|
||||
return this.refreshProfileState(repository.fullName, profileId);
|
||||
}
|
||||
const iconReference = await this.prepareIcon(profile, repository, server);
|
||||
const metadata = this.metadataCompose(profile, repository, iconReference);
|
||||
const compose = this.composeInvocation(profile, repository);
|
||||
const flags = this.composeUpFlags(profile);
|
||||
const script = `
|
||||
root=${shellQuote(remotePath)}
|
||||
test -d "$root"
|
||||
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 ${flags}
|
||||
${this.containerVerificationScript(profile, repository, compose)}
|
||||
${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,
|
||||
{ includeTerminal = false, state: suppliedState = null } = {},
|
||||
) {
|
||||
const operation = this.store.getOperation(operationId);
|
||||
if (!operation || operation.provider !== "ssh-unraid") return operation;
|
||||
if (
|
||||
!includeTerminal &&
|
||||
["success", "failed", "cancelled", "rolled-back"].includes(
|
||||
operation.status,
|
||||
)
|
||||
)
|
||||
return operation;
|
||||
try {
|
||||
const state =
|
||||
suppliedState ||
|
||||
(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 reconcileRecordedOperations(profileId, state) {
|
||||
const operations = this.store.data.operations
|
||||
.filter(
|
||||
(item) =>
|
||||
item.profileId === profileId && item.provider === "ssh-unraid",
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
new Date(right.updatedAt || right.createdAt || 0) -
|
||||
new Date(left.updatedAt || left.createdAt || 0),
|
||||
);
|
||||
const matching = operations.find(
|
||||
(item) => item.sha === state.liveSha && item.status === "failed",
|
||||
);
|
||||
if (matching && state.containerRunning && state.healthy !== false) {
|
||||
await this.refreshOperation(matching.id, {
|
||||
includeTerminal: true,
|
||||
state,
|
||||
});
|
||||
}
|
||||
const latestFailed = operations.find((item) => item.status === "failed");
|
||||
if (
|
||||
latestFailed &&
|
||||
latestFailed.id !== matching?.id &&
|
||||
state.matchesGitea &&
|
||||
state.containerRunning &&
|
||||
state.healthy !== false
|
||||
) {
|
||||
await this.saveOperation({
|
||||
...latestFailed,
|
||||
status: "cancelled",
|
||||
error: `Superseded by Gitea/live commit ${state.liveSha.slice(0, 7)}.`,
|
||||
logs: [
|
||||
...(latestFailed.logs || []),
|
||||
`Reconciled: Gitea and Unraid now both report ${state.liveSha}.`,
|
||||
],
|
||||
});
|
||||
}
|
||||
return this.store.data.operations
|
||||
.filter((item) => item.profileId === profileId)
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
const queue = [...active];
|
||||
const results = [];
|
||||
const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
|
||||
while (queue.length) {
|
||||
const operation = queue.shift();
|
||||
results.push(await this.refreshOperation(operation.id));
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
}
|
||||
return UnraidStateMethods.prototype;
|
||||
}
|
||||
|
||||
module.exports = { createUnraidStateMethods };
|
||||
@@ -0,0 +1,871 @@
|
||||
"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");
|
||||
const { isNewerVersion } = require("../shared/semver.cjs");
|
||||
|
||||
function safeRepositoryPart(value, label) {
|
||||
const text = String(value || "").trim();
|
||||
if (!/^[a-zA-Z0-9_.-]+$/.test(text))
|
||||
throw new Error(`${label} contains unsupported characters.`);
|
||||
return text;
|
||||
}
|
||||
|
||||
function verifyReleaseManifest({
|
||||
manifestBytes,
|
||||
signatureBytes,
|
||||
publicKey,
|
||||
update,
|
||||
assetName,
|
||||
}) {
|
||||
if (
|
||||
!Buffer.isBuffer(manifestBytes) ||
|
||||
manifestBytes.length < 100 ||
|
||||
manifestBytes.length > 1_000_000
|
||||
) {
|
||||
throw new Error("The signed release manifest has an invalid size.");
|
||||
}
|
||||
const signatureText = Buffer.from(signatureBytes || "")
|
||||
.toString("utf8")
|
||||
.trim();
|
||||
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(signatureText)) {
|
||||
throw new Error("The release manifest signature is invalid.");
|
||||
}
|
||||
const signature = Buffer.from(signatureText, "base64");
|
||||
if (signature.length !== 64) {
|
||||
throw new Error("The release manifest signature is invalid.");
|
||||
}
|
||||
let verified = false;
|
||||
try {
|
||||
verified = crypto.verify(null, manifestBytes, publicKey, signature);
|
||||
} catch {
|
||||
verified = false;
|
||||
}
|
||||
if (!verified) {
|
||||
const error = new Error(
|
||||
"The release manifest was not signed by the trusted ForgeFlow publisher key.",
|
||||
);
|
||||
error.code = "RELEASE_SIGNATURE_INVALID";
|
||||
throw error;
|
||||
}
|
||||
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(manifestBytes.toString("utf8"));
|
||||
} catch {
|
||||
throw new Error("The signed release manifest is not valid JSON.");
|
||||
}
|
||||
const expectedVersion = String(update.remoteVersion || "").trim();
|
||||
const expectedCommit = String(update.remoteSha || "").toLowerCase();
|
||||
if (
|
||||
manifest.schemaVersion !== 1 ||
|
||||
manifest.product !== "ForgeFlow" ||
|
||||
manifest.version !== expectedVersion ||
|
||||
manifest.tag !== `v${expectedVersion}` ||
|
||||
manifest.signature?.algorithm !== "Ed25519" ||
|
||||
(expectedCommit &&
|
||||
String(manifest.commit || "").toLowerCase() !== expectedCommit)
|
||||
) {
|
||||
const error = new Error(
|
||||
"The signed release manifest does not match the requested ForgeFlow update.",
|
||||
);
|
||||
error.code = "RELEASE_MANIFEST_MISMATCH";
|
||||
throw error;
|
||||
}
|
||||
const artifact = Array.isArray(manifest.artifacts)
|
||||
? manifest.artifacts.find((item) => item?.name === assetName)
|
||||
: null;
|
||||
if (
|
||||
!artifact ||
|
||||
!Number.isSafeInteger(artifact.bytes) ||
|
||||
artifact.bytes < 1_000_000 ||
|
||||
!/^[a-f0-9]{64}$/.test(String(artifact.sha256 || ""))
|
||||
) {
|
||||
const error = new Error(
|
||||
`The signed release manifest has no valid entry for ${assetName}.`,
|
||||
);
|
||||
error.code = "RELEASE_MANIFEST_INCOMPLETE";
|
||||
throw error;
|
||||
}
|
||||
return { manifest, artifact };
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function requireSignedSourceUpdate(message) {
|
||||
const error = new Error(message);
|
||||
error.code = "SIGNED_SOURCE_UPDATE_REQUIRED";
|
||||
throw error;
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
function windowsUpdaterSpawnOptions(cwd) {
|
||||
return {
|
||||
// A detached hidden PowerShell child can exit successfully on Windows
|
||||
// without ever executing its -File script. Normal Windows children survive
|
||||
// their parent; unref() below releases the event-loop reference instead.
|
||||
detached: false,
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
cwd,
|
||||
};
|
||||
}
|
||||
|
||||
async function readJsonFile(filePath) {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readLogTail(filePath, maxLines = 12) {
|
||||
if (!filePath) return "";
|
||||
try {
|
||||
const text = await fs.readFile(filePath, "utf8");
|
||||
return text.split(/\r?\n/).filter(Boolean).slice(-maxLines).join("\n");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function updaterStartupError(
|
||||
message,
|
||||
code,
|
||||
{ statusPath, logPath, expectedUpdateId } = {},
|
||||
) {
|
||||
const status = statusPath ? await readJsonFile(statusPath) : null;
|
||||
const logTail = await readLogTail(logPath);
|
||||
const details = [];
|
||||
if (
|
||||
status?.updateId &&
|
||||
expectedUpdateId &&
|
||||
status.updateId !== expectedUpdateId
|
||||
)
|
||||
details.push("The helper wrote a status for a different update request.");
|
||||
if (status?.message) details.push(status.message);
|
||||
if (logTail) details.push(`Update helper log:\n${logTail}`);
|
||||
const error = new Error([message, ...details].filter(Boolean).join("\n\n"));
|
||||
error.code = code;
|
||||
error.status = status;
|
||||
error.logPath = logPath || null;
|
||||
return error;
|
||||
}
|
||||
|
||||
async function waitForUpdaterStarted(
|
||||
statusPath,
|
||||
{
|
||||
timeoutMs = 15000,
|
||||
pollMs = 100,
|
||||
childState = null,
|
||||
expectedUpdateId = null,
|
||||
logPath = null,
|
||||
} = {},
|
||||
) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const status = await readJsonFile(statusPath);
|
||||
const belongsToRequest =
|
||||
!expectedUpdateId || status?.updateId === expectedUpdateId;
|
||||
if (
|
||||
status &&
|
||||
belongsToRequest &&
|
||||
[
|
||||
"started",
|
||||
"waiting-for-exit",
|
||||
"backing-up",
|
||||
"extracting",
|
||||
"applying",
|
||||
"validating",
|
||||
].includes(status.state)
|
||||
) {
|
||||
return status;
|
||||
}
|
||||
if (
|
||||
status &&
|
||||
belongsToRequest &&
|
||||
["failed", "rolled-back"].includes(status.state)
|
||||
) {
|
||||
throw await updaterStartupError(
|
||||
"The update helper reported a failure before ForgeFlow could close.",
|
||||
"UPDATE_HELPER_START_FAILED",
|
||||
{ statusPath, logPath, expectedUpdateId },
|
||||
);
|
||||
}
|
||||
if (childState?.error) throw childState.error;
|
||||
if (childState?.exited) {
|
||||
throw await updaterStartupError(
|
||||
`The update helper exited before it confirmed startup (exit code ${childState.code ?? "unknown"}).`,
|
||||
"UPDATE_HELPER_EXITED_EARLY",
|
||||
{ statusPath, logPath, expectedUpdateId },
|
||||
);
|
||||
}
|
||||
await delay(pollMs);
|
||||
}
|
||||
throw await updaterStartupError(
|
||||
"The update helper did not confirm startup. ForgeFlow was left open and no source files were changed.",
|
||||
"UPDATE_HELPER_START_TIMEOUT",
|
||||
{ statusPath, logPath, expectedUpdateId },
|
||||
);
|
||||
}
|
||||
|
||||
class UpdateService {
|
||||
constructor({
|
||||
store,
|
||||
gitea,
|
||||
diagnostics,
|
||||
appInfo,
|
||||
sourcePath,
|
||||
userDataPath,
|
||||
platform = process.platform,
|
||||
spawnProcess = spawn,
|
||||
powershellPath = null,
|
||||
handshakeTimeoutMs = 12000,
|
||||
handshakePollMs = 100,
|
||||
updatePublicKey = null,
|
||||
}) {
|
||||
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.updatePublicKey = updatePublicKey;
|
||||
this.staged = null;
|
||||
}
|
||||
|
||||
async check() {
|
||||
const settings = this.store.data.updates || {};
|
||||
const owner = safeRepositoryPart(
|
||||
settings.owner || "Jens",
|
||||
"Update repository owner",
|
||||
);
|
||||
const repo = safeRepositoryPart(
|
||||
settings.repo || "ForgeFlow",
|
||||
"Update repository name",
|
||||
);
|
||||
const branchName = String(settings.branch || "main").trim();
|
||||
const branch = await this.gitea.getBranch(owner, repo, branchName);
|
||||
const remoteSha =
|
||||
branch?.commit?.id || branch?.commit?.sha || branch?.commit?.commit?.id;
|
||||
if (!/^[0-9a-f]{40}$/i.test(String(remoteSha || "")))
|
||||
throw new Error(
|
||||
"Gitea did not return a full commit SHA for the update branch.",
|
||||
);
|
||||
|
||||
const file = await this.gitea.getRepositoryFile({
|
||||
owner,
|
||||
repo,
|
||||
filePath: "package.json",
|
||||
ref: remoteSha,
|
||||
});
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(file.decoded);
|
||||
} catch {
|
||||
throw new Error("The remote ForgeFlow package.json is not valid JSON.");
|
||||
}
|
||||
if (manifest.name !== "forgeflow")
|
||||
throw new Error(
|
||||
"The configured update repository is not a ForgeFlow source repository.",
|
||||
);
|
||||
const remoteVersion = String(manifest.version || "").trim();
|
||||
const currentVersion = String(this.appInfo.version || "").trim();
|
||||
const available = isNewerVersion(remoteVersion, currentVersion);
|
||||
const result = {
|
||||
checkedAt: new Date().toISOString(),
|
||||
owner,
|
||||
repo,
|
||||
branch: branchName,
|
||||
currentVersion,
|
||||
remoteVersion,
|
||||
remoteSha,
|
||||
shortSha: remoteSha.slice(0, 7),
|
||||
available,
|
||||
packaged: Boolean(this.appInfo.packaged),
|
||||
mode: this.appInfo.packaged ? "packaged" : "source",
|
||||
};
|
||||
this.store.data.updates.lastCheckedAt = result.checkedAt;
|
||||
await this.store.save();
|
||||
await this.diagnostics?.info("updates.checked", {
|
||||
repository: `${owner}/${repo}`,
|
||||
branch: branchName,
|
||||
currentVersion,
|
||||
remoteVersion,
|
||||
remoteSha,
|
||||
available,
|
||||
mode: result.mode,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async download(expected = null) {
|
||||
const update = expected?.remoteSha ? expected : await this.check();
|
||||
if (!update.available)
|
||||
return { ...update, downloaded: false, reason: "up-to-date" };
|
||||
if (this.appInfo.packaged) {
|
||||
return this.downloadPackaged(update);
|
||||
}
|
||||
|
||||
requireSignedSourceUpdate(
|
||||
"Integrated source updates are disabled because source archives do not yet carry an independently signed publisher manifest. Update a source checkout with Git after reviewing the exact commit.",
|
||||
);
|
||||
|
||||
/* c8 ignore start -- retained for a future signed source-archive implementation */
|
||||
await fs.mkdir(this.updateDirectory, { recursive: true });
|
||||
const archiveUrl = `${this.store.data.gitea.baseUrl.replace(/\/+$/, "")}/${encodeURIComponent(update.owner)}/${encodeURIComponent(update.repo)}/archive/${update.remoteSha}.zip`;
|
||||
const archive = await this.gitea.downloadAuthenticated(archiveUrl);
|
||||
if (archive.length < 1000 || archive[0] !== 0x50 || archive[1] !== 0x4b)
|
||||
throw new Error("The downloaded update is not a valid ZIP archive.");
|
||||
const sha256 = crypto.createHash("sha256").update(archive).digest("hex");
|
||||
const archivePath = path.join(
|
||||
this.updateDirectory,
|
||||
`ForgeFlow-${update.remoteVersion}-${update.shortSha}.zip`,
|
||||
);
|
||||
const metadataPath = `${archivePath}.json`;
|
||||
await fs.writeFile(archivePath, archive, { mode: 0o600 });
|
||||
const metadata = {
|
||||
...update,
|
||||
archivePath,
|
||||
sha256,
|
||||
downloadedAt: new Date().toISOString(),
|
||||
};
|
||||
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), {
|
||||
mode: 0o600,
|
||||
});
|
||||
this.staged = metadata;
|
||||
await this.diagnostics?.info("updates.downloaded", {
|
||||
remoteVersion: update.remoteVersion,
|
||||
remoteSha: update.remoteSha,
|
||||
bytes: archive.length,
|
||||
sha256,
|
||||
});
|
||||
return { ...metadata, downloaded: true };
|
||||
/* c8 ignore stop */
|
||||
}
|
||||
|
||||
async downloadPackaged(update) {
|
||||
if (this.platform !== "win32")
|
||||
throw new Error("Packaged auto-update currently supports Windows only.");
|
||||
const release =
|
||||
(await this.gitea.getReleaseByTag(
|
||||
update.owner,
|
||||
update.repo,
|
||||
`v${update.remoteVersion}`,
|
||||
)) ||
|
||||
(await this.gitea.getReleaseByTag(
|
||||
update.owner,
|
||||
update.repo,
|
||||
update.remoteVersion,
|
||||
));
|
||||
if (!release || release.draft || release.prerelease) {
|
||||
const error = new Error(
|
||||
`ForgeFlow ${update.remoteVersion} has no published binary release yet. The source branch was updated, but the matching signed Windows release was not published. Run Publish-Missing-Binary-Release.ps1 from the release source.`,
|
||||
);
|
||||
error.code = "BINARY_RELEASE_NOT_FOUND";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const portable = Boolean(this.appInfo.portableExecutablePath);
|
||||
const assetName = `ForgeFlow-${portable ? "Portable" : "Setup"}-${update.remoteVersion}-win-x64.exe`;
|
||||
const checksumName = `${assetName}.sha256`;
|
||||
const manifestName = `ForgeFlow-${update.remoteVersion}-release-manifest.json`;
|
||||
const signatureName = `${manifestName}.sig`;
|
||||
const assets = Array.isArray(release.assets) ? release.assets : [];
|
||||
const asset = assets.find((item) => item.name === assetName);
|
||||
const checksumAsset = assets.find((item) => item.name === checksumName);
|
||||
const manifestAsset = assets.find((item) => item.name === manifestName);
|
||||
const signatureAsset = assets.find((item) => item.name === signatureName);
|
||||
if (
|
||||
!asset?.id ||
|
||||
!checksumAsset?.id ||
|
||||
!manifestAsset?.id ||
|
||||
!signatureAsset?.id
|
||||
) {
|
||||
const error = new Error(
|
||||
`Release v${update.remoteVersion} is incomplete: the executable, SHA-256 file, signed manifest and signature are all required.`,
|
||||
);
|
||||
error.code = "BINARY_RELEASE_INCOMPLETE";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const [binary, checksumBytes, manifestBytes, signatureBytes] =
|
||||
await Promise.all([
|
||||
this.gitea.downloadReleaseAsset(
|
||||
update.owner,
|
||||
update.repo,
|
||||
release.id,
|
||||
asset.id,
|
||||
{ downloadUrl: asset.browser_download_url },
|
||||
),
|
||||
this.gitea.downloadReleaseAsset(
|
||||
update.owner,
|
||||
update.repo,
|
||||
release.id,
|
||||
checksumAsset.id,
|
||||
{ downloadUrl: checksumAsset.browser_download_url },
|
||||
),
|
||||
this.gitea.downloadReleaseAsset(
|
||||
update.owner,
|
||||
update.repo,
|
||||
release.id,
|
||||
manifestAsset.id,
|
||||
{ downloadUrl: manifestAsset.browser_download_url },
|
||||
),
|
||||
this.gitea.downloadReleaseAsset(
|
||||
update.owner,
|
||||
update.repo,
|
||||
release.id,
|
||||
signatureAsset.id,
|
||||
{ downloadUrl: signatureAsset.browser_download_url },
|
||||
),
|
||||
]);
|
||||
|
||||
const publicKey =
|
||||
this.updatePublicKey ||
|
||||
(await fs.readFile(
|
||||
path.join(this.sourcePath, "build", "update-signing-public.pem"),
|
||||
));
|
||||
const { manifest, artifact } = verifyReleaseManifest({
|
||||
manifestBytes,
|
||||
signatureBytes,
|
||||
publicKey,
|
||||
update,
|
||||
assetName,
|
||||
});
|
||||
if (binary.length < 1_000_000 || binary[0] !== 0x4d || binary[1] !== 0x5a) {
|
||||
const preview = binary.subarray(0, 200).toString("utf8").trim();
|
||||
const looksLikeMetadata =
|
||||
/^\s*[{[]/.test(preview) || /browser_download_url/i.test(preview);
|
||||
const error = new Error(
|
||||
looksLikeMetadata
|
||||
? "Gitea returned release-asset metadata instead of the Windows executable. Upgrade ForgeFlow with the 0.9.1 installer once; later in-app updates use the actual browser download URL."
|
||||
: "The downloaded Windows update is not a valid executable.",
|
||||
);
|
||||
error.code = looksLikeMetadata
|
||||
? "RELEASE_ASSET_METADATA_RECEIVED"
|
||||
: "INVALID_WINDOWS_UPDATE";
|
||||
throw error;
|
||||
}
|
||||
const expectedSha256 = checksumBytes
|
||||
.toString("utf8")
|
||||
.trim()
|
||||
.split(/\s+/)[0]
|
||||
?.toLowerCase();
|
||||
if (!/^[a-f0-9]{64}$/.test(expectedSha256 || ""))
|
||||
throw new Error("The release SHA-256 file is invalid.");
|
||||
if (expectedSha256 !== artifact.sha256) {
|
||||
throw new Error(
|
||||
"The release checksum does not match the signed publisher manifest.",
|
||||
);
|
||||
}
|
||||
if (binary.length !== artifact.bytes) {
|
||||
throw new Error(
|
||||
"The downloaded Windows update size does not match the signed publisher manifest.",
|
||||
);
|
||||
}
|
||||
const sha256 = crypto.createHash("sha256").update(binary).digest("hex");
|
||||
if (sha256 !== expectedSha256)
|
||||
throw new Error(
|
||||
"The downloaded Windows update failed SHA-256 verification.",
|
||||
);
|
||||
|
||||
await fs.mkdir(this.updateDirectory, { recursive: true });
|
||||
const binaryPath = path.join(this.updateDirectory, assetName);
|
||||
await fs.writeFile(binaryPath, binary, { mode: 0o600 });
|
||||
const metadata = {
|
||||
...update,
|
||||
kind: "binary",
|
||||
binaryPath,
|
||||
assetName,
|
||||
sha256,
|
||||
portable,
|
||||
executablePath: portable
|
||||
? this.appInfo.portableExecutablePath
|
||||
: this.appInfo.executablePath,
|
||||
releaseTag: release.tag_name,
|
||||
publisherKeyId: manifest.signature.keyId,
|
||||
releaseManifest: manifestName,
|
||||
downloadedAt: new Date().toISOString(),
|
||||
downloaded: true,
|
||||
};
|
||||
await fs.writeFile(
|
||||
`${binaryPath}.json`,
|
||||
JSON.stringify(metadata, null, 2),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
this.staged = metadata;
|
||||
await this.diagnostics?.info("updates.binary-downloaded", {
|
||||
remoteVersion: update.remoteVersion,
|
||||
assetName,
|
||||
bytes: binary.length,
|
||||
sha256,
|
||||
portable,
|
||||
publisherKeyId: manifest.signature.keyId,
|
||||
});
|
||||
return metadata;
|
||||
}
|
||||
|
||||
async apply(staged = null) {
|
||||
const update =
|
||||
staged?.archivePath || staged?.binaryPath ? staged : this.staged;
|
||||
if (!update?.archivePath && !update?.binaryPath)
|
||||
throw new Error("Download an update before applying it.");
|
||||
if (this.platform !== "win32")
|
||||
throw new Error(
|
||||
"The integrated updater currently supports Windows only.",
|
||||
);
|
||||
if (update.kind === "binary") return this.applyPackaged(update);
|
||||
requireSignedSourceUpdate(
|
||||
"This source archive cannot be applied because it has no independently signed publisher manifest.",
|
||||
);
|
||||
/* c8 ignore start -- legacy helper retained only for migration compatibility */
|
||||
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.");
|
||||
|
||||
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 = [
|
||||
"-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,
|
||||
"-StatusPath",
|
||||
statusPath,
|
||||
"-UpdateId",
|
||||
updateId,
|
||||
];
|
||||
|
||||
const childState = { exited: false, code: null, error: null };
|
||||
let child;
|
||||
try {
|
||||
child = this.spawnProcess(
|
||||
executable,
|
||||
args,
|
||||
windowsUpdaterSpawnOptions(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));
|
||||
// Kept attached rather than `once`: a process that fails to start can
|
||||
// report a second error, and an unhandled 'error' event ends this process.
|
||||
child.on?.("error", (error) => finish(reject, error));
|
||||
if (!child.once) finish(resolve);
|
||||
});
|
||||
|
||||
const started = await waitForUpdaterStarted(statusPath, {
|
||||
timeoutMs: this.handshakeTimeoutMs,
|
||||
pollMs: this.handshakePollMs,
|
||||
childState,
|
||||
expectedUpdateId: updateId,
|
||||
logPath,
|
||||
});
|
||||
child.unref?.();
|
||||
|
||||
await this.diagnostics?.info("updates.apply-started", {
|
||||
updateId,
|
||||
remoteVersion: update.remoteVersion,
|
||||
remoteSha: update.remoteSha,
|
||||
logPath,
|
||||
statusPath,
|
||||
helperPid: child.pid,
|
||||
helperState: started.state,
|
||||
});
|
||||
return {
|
||||
launched: true,
|
||||
confirmed: true,
|
||||
updateId,
|
||||
version: update.remoteVersion,
|
||||
logPath,
|
||||
statusPath,
|
||||
};
|
||||
/* c8 ignore stop */
|
||||
}
|
||||
|
||||
async applyPackaged(update) {
|
||||
const stat = await fs.stat(update.binaryPath).catch(() => null);
|
||||
if (!stat?.isFile())
|
||||
throw new Error("The staged Windows update is no longer available.");
|
||||
const actualSha256 = crypto
|
||||
.createHash("sha256")
|
||||
.update(await fs.readFile(update.binaryPath))
|
||||
.digest("hex");
|
||||
if (actualSha256 !== update.sha256)
|
||||
throw new Error(
|
||||
"The staged Windows update failed its final SHA-256 check.",
|
||||
);
|
||||
const helperRoot = this.sourcePath.toLowerCase().endsWith("app.asar")
|
||||
? `${this.sourcePath}.unpacked`
|
||||
: this.sourcePath;
|
||||
const scriptPath = path.join(
|
||||
helperRoot,
|
||||
"scripts",
|
||||
"apply-binary-update.ps1",
|
||||
);
|
||||
if (!(await fs.stat(scriptPath).catch(() => null))?.isFile())
|
||||
throw new Error("The binary update helper is missing.");
|
||||
|
||||
await fs.mkdir(this.updateDirectory, { recursive: true });
|
||||
const updateId = `${Date.now()}-${crypto.randomUUID()}`;
|
||||
const logPath = path.join(this.updateDirectory, `binary-${updateId}.log`);
|
||||
const statusPath = path.join(
|
||||
this.updateDirectory,
|
||||
`binary-${updateId}.status.json`,
|
||||
);
|
||||
const launching = {
|
||||
schemaVersion: 1,
|
||||
updateId,
|
||||
state: "launching",
|
||||
expectedVersion: update.remoteVersion,
|
||||
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 = [
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
scriptPath,
|
||||
"-BinaryPath",
|
||||
update.binaryPath,
|
||||
"-ExpectedSha256",
|
||||
update.sha256,
|
||||
"-ExpectedVersion",
|
||||
update.remoteVersion,
|
||||
"-CurrentExecutable",
|
||||
update.executablePath || this.appInfo.executablePath,
|
||||
"-Portable",
|
||||
String(Boolean(update.portable)),
|
||||
"-ParentPid",
|
||||
String(process.pid),
|
||||
"-LogPath",
|
||||
logPath,
|
||||
"-StatusPath",
|
||||
statusPath,
|
||||
"-UpdateId",
|
||||
updateId,
|
||||
];
|
||||
const child = this.spawnProcess(
|
||||
executable,
|
||||
args,
|
||||
windowsUpdaterSpawnOptions(this.updateDirectory),
|
||||
);
|
||||
const childState = { exited: false, code: null, error: null };
|
||||
child.once?.("error", (error) => {
|
||||
childState.error = error;
|
||||
});
|
||||
child.once?.("exit", (code) => {
|
||||
childState.exited = true;
|
||||
childState.code = code;
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
Object.assign(
|
||||
new Error("Windows did not start the binary update helper."),
|
||||
{ code: "UPDATE_HELPER_SPAWN_TIMEOUT" },
|
||||
),
|
||||
),
|
||||
5000,
|
||||
);
|
||||
child.once?.("spawn", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
// Kept attached rather than `once`: a second error would otherwise have no
|
||||
// listener left, and an unhandled 'error' event ends this process.
|
||||
child.on?.("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
if (!child.once) {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
const started = await waitForUpdaterStarted(statusPath, {
|
||||
timeoutMs: this.handshakeTimeoutMs,
|
||||
pollMs: this.handshakePollMs,
|
||||
childState,
|
||||
expectedUpdateId: updateId,
|
||||
logPath,
|
||||
});
|
||||
child.unref?.();
|
||||
await this.diagnostics?.info("updates.binary-apply-started", {
|
||||
updateId,
|
||||
remoteVersion: update.remoteVersion,
|
||||
assetName: update.assetName,
|
||||
helperState: started.state,
|
||||
});
|
||||
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|binary)-.*\.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,
|
||||
verifyReleaseManifest,
|
||||
resolveWindowsPowerShellPath,
|
||||
windowsUpdaterSpawnOptions,
|
||||
waitForUpdaterStarted,
|
||||
readJsonFile,
|
||||
readLogTail,
|
||||
requireSignedSourceUpdate,
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
async function handleCommandActions(event, target, action, repository) {
|
||||
if (action === "run-command") {
|
||||
const command = target.dataset.command;
|
||||
ui.modal = null;
|
||||
if (command === "overview") ui.currentView = "overview";
|
||||
else if (command === "deployments") ui.currentView = "deployments";
|
||||
else if (command === "diagnostics") ui.currentView = "diagnostics";
|
||||
else if (command === "settings") ui.currentView = "settings";
|
||||
else if (command === "refresh") await refreshRepositories(true);
|
||||
else if (command === "open-folder" && repository?.localPath)
|
||||
await window.forgeflow.openPath(repository.localPath);
|
||||
else if (command === "git-tools" && repository)
|
||||
await loadGitTools(repository);
|
||||
else if (command === "deploy-selected" && canDeploy(repository)) {
|
||||
const profile = selectedProfile(repository);
|
||||
if (profile) await runDeploymentPreflight(repository, profile.id);
|
||||
}
|
||||
render();
|
||||
}
|
||||
else return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
async function handleDeploymentOperationActions(event, target, action, repository) {
|
||||
if (action === "deploy-profile") {
|
||||
if (repository && String(repository.id) !== String(ui.selectedRepoId))
|
||||
selectRepository(repository.id, false);
|
||||
const profile =
|
||||
repository?.deploymentProfiles?.find(
|
||||
(item) => item.id === target.dataset.profileId,
|
||||
) || selectedProfile(repository);
|
||||
ui.selectedProfileId = profile?.id || null;
|
||||
if (!profile) return;
|
||||
const report = await runDeploymentPreflight(repository, profile.id, {
|
||||
showModal: true,
|
||||
});
|
||||
if (!report) return;
|
||||
} else if (action === "continue-after-preflight") {
|
||||
const profile = selectedRepository()?.deploymentProfiles?.find(
|
||||
(item) => item.id === target.dataset.profileId,
|
||||
);
|
||||
if (!profile || !ui.deploymentPreflight?.summary?.ready) return;
|
||||
if (profile.confirmationRequired !== false) {
|
||||
ui.modal = { type: "deploy-confirm", profileId: profile.id };
|
||||
render();
|
||||
} else await executeDeployment(profile.id);
|
||||
} else if (action === "confirm-deploy")
|
||||
await executeDeployment(target.dataset.profileId);
|
||||
else if (action === "rollback-profile") {
|
||||
if (!repository) repository = profileRepository(target.dataset.profileId);
|
||||
if (repository && String(repository.id) !== String(ui.selectedRepoId))
|
||||
selectRepository(repository.id, false);
|
||||
ui.modal = {
|
||||
type: "rollback-confirm",
|
||||
profileId: target.dataset.profileId,
|
||||
};
|
||||
render();
|
||||
} else if (action === "confirm-rollback")
|
||||
await executeRollback(target.dataset.profileId);
|
||||
else if (action === "refresh-profile-state") {
|
||||
if (!repository) repository = profileRepository(target.dataset.profileId);
|
||||
const profile = repository?.deploymentProfiles?.find(
|
||||
(item) => item.id === target.dataset.profileId,
|
||||
);
|
||||
setLoading(true, `Checking ${profile?.environment || "environment"}…`);
|
||||
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 === "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") {
|
||||
stopOperationPolling();
|
||||
ui.activeDeployment = null;
|
||||
ui.currentView = selectedRepository() ? "repository" : "deployments";
|
||||
render();
|
||||
}
|
||||
else return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
async function handleDeploymentProfileActions(event, target, action, repository) {
|
||||
if (action === "configure-deployment") {
|
||||
ui.deploymentDiscovery = null;
|
||||
ui.modal = {
|
||||
type: "deployment-config",
|
||||
profileId: null,
|
||||
provider: (ui.boot.state.servers || []).length
|
||||
? "ssh-unraid"
|
||||
: "gitea-actions",
|
||||
};
|
||||
render();
|
||||
} else if (action === "edit-deployment-profile") {
|
||||
ui.deploymentDiscovery = null;
|
||||
repository = profileRepository(target.dataset.profileId) || repository;
|
||||
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") {
|
||||
if (ui.modal?.type === "workspace-sync") ui.workspaceSyncPlan = null;
|
||||
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 === "discover-existing-deployment") {
|
||||
const serverId = document.querySelector("#profile-server")?.value;
|
||||
const remoteFolder =
|
||||
document.querySelector("#profile-remote-folder")?.value.trim() ||
|
||||
safeCloneFolderName(repository);
|
||||
if (!serverId) {
|
||||
showToast(
|
||||
"Select an Unraid server",
|
||||
"Configure and select the server before importing an existing deployment.",
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setLoading(
|
||||
true,
|
||||
"Reading Git, Compose, Docker and DockerMan from the server…",
|
||||
);
|
||||
try {
|
||||
const result = await window.forgeflow.discoverExistingDeployment(
|
||||
repository,
|
||||
serverId,
|
||||
remoteFolder,
|
||||
);
|
||||
ui.deploymentDiscovery = { ...result, repository: repository.fullName };
|
||||
showToast(
|
||||
"Existing deployment imported",
|
||||
`${result.runtime.containers} container(s), ${result.runtime.services} service(s) and ${result.runtime.ports.length} port mapping(s) detected.`,
|
||||
"success",
|
||||
);
|
||||
render();
|
||||
} catch (error) {
|
||||
showToast("Could not import deployment", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
} else if (action === "save-deployment-profile") {
|
||||
const previousProfile =
|
||||
repository?.deploymentProfiles?.find(
|
||||
(item) => item.id === target.dataset.profileId,
|
||||
) || {};
|
||||
const provider = document.querySelector("#profile-provider").value;
|
||||
let maintenanceWindows = [];
|
||||
try {
|
||||
maintenanceWindows = (
|
||||
document.querySelector("#profile-policy-windows")?.value || ""
|
||||
)
|
||||
.split("|")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.map((item) => {
|
||||
const match = item.match(
|
||||
/^([0-6](?:,[0-6])*)\s*:\s*((?:[01]\d|2[0-3]):[0-5]\d)-((?:[01]\d|2[0-3]):[0-5]\d)$/,
|
||||
);
|
||||
if (!match) throw new Error(`Invalid maintenance window: ${item}`);
|
||||
return {
|
||||
days: match[1].split(",").map(Number),
|
||||
start: match[2],
|
||||
end: match[3],
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
showToast("Could not save profile", error.message, "error");
|
||||
return;
|
||||
}
|
||||
const composeFiles =
|
||||
provider === "ssh-unraid"
|
||||
? (document.querySelector("#profile-compose-files")?.value || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const composeServices =
|
||||
provider === "ssh-unraid"
|
||||
? (document.querySelector("#profile-compose-services")?.value || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const profile = {
|
||||
id: target.dataset.profileId || undefined,
|
||||
provider,
|
||||
name: document.querySelector("#profile-name").value.trim(),
|
||||
environment: document.querySelector("#profile-environment").value.trim(),
|
||||
branch: document.querySelector("#profile-branch").value.trim(),
|
||||
healthcheckUrl:
|
||||
document.querySelector("#profile-healthcheck")?.value.trim() || "",
|
||||
confirmationRequired: document.querySelector("#profile-confirmation")
|
||||
.checked,
|
||||
deploymentPolicy: {
|
||||
frozen:
|
||||
document.querySelector("#profile-policy-frozen")?.checked === true,
|
||||
freezeReason:
|
||||
document
|
||||
.querySelector("#profile-policy-freeze-reason")
|
||||
?.value.trim() || "",
|
||||
requireNote:
|
||||
document.querySelector("#profile-policy-note")?.checked === true,
|
||||
maintenanceWindows,
|
||||
},
|
||||
...(provider === "ssh-unraid"
|
||||
? {
|
||||
serverId: document.querySelector("#profile-server").value,
|
||||
remoteFolder: document
|
||||
.querySelector("#profile-remote-folder")
|
||||
.value.trim(),
|
||||
deploymentMode: ["server-git", "push-bundle", "monitor-only"].includes(
|
||||
document.querySelector("#profile-deployment-mode")?.value,
|
||||
) ? document.querySelector("#profile-deployment-mode").value : "server-git",
|
||||
cloneUrl: previousProfile.cloneUrl || "",
|
||||
alignRemote: false,
|
||||
generatedCompose:
|
||||
document.querySelector("#profile-generated-compose").value ===
|
||||
"true",
|
||||
composeProject:
|
||||
document.querySelector("#profile-compose-project")?.value.trim() ||
|
||||
previousProfile.composeProject ||
|
||||
"",
|
||||
composeWorkingDir: previousProfile.composeWorkingDir || "",
|
||||
composeFiles: composeFiles.length ? composeFiles : ["docker-compose.yml"],
|
||||
composeFile: composeFiles[0] || "docker-compose.yml",
|
||||
composeServices: composeServices.length
|
||||
? composeServices
|
||||
: [safeCloneFolderName(repository).toLowerCase()],
|
||||
composeService:
|
||||
composeServices[0] || safeCloneFolderName(repository).toLowerCase(),
|
||||
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),
|
||||
manageDockerMan:
|
||||
document.querySelector("#profile-manage-dockerman")?.checked ===
|
||||
true,
|
||||
forceRecreate: false,
|
||||
removeOrphans: false,
|
||||
adoptedFromServer: Boolean(
|
||||
ui.deploymentDiscovery || previousProfile.adoptedFromServer,
|
||||
),
|
||||
serverSourceOfTruth: Boolean(
|
||||
ui.deploymentDiscovery || previousProfile.serverSourceOfTruth,
|
||||
),
|
||||
workloadIdentity:
|
||||
ui.deploymentDiscovery?.profile?.workloadIdentity ||
|
||||
previousProfile.workloadIdentity ||
|
||||
null,
|
||||
detectedAt:
|
||||
ui.deploymentDiscovery?.profile?.detectedAt ||
|
||||
previousProfile.detectedAt ||
|
||||
null,
|
||||
provenance:
|
||||
ui.deploymentDiscovery?.provenance ||
|
||||
previousProfile.provenance ||
|
||||
{},
|
||||
detectedMetadata:
|
||||
ui.deploymentDiscovery?.profile?.detectedMetadata ||
|
||||
previousProfile.detectedMetadata ||
|
||||
{},
|
||||
}
|
||||
: {
|
||||
workflowFile: document
|
||||
.querySelector("#profile-workflow")
|
||||
.value.trim(),
|
||||
rollbackWorkflowFile: document
|
||||
.querySelector("#profile-rollback-workflow")
|
||||
.value.trim(),
|
||||
statusUrl: document
|
||||
.querySelector("#profile-status-url")
|
||||
.value.trim(),
|
||||
}),
|
||||
};
|
||||
setLoading(true, "Saving deployment environment…");
|
||||
try {
|
||||
const result = await window.forgeflow.saveDeploymentProfile(
|
||||
repository.fullName,
|
||||
profile,
|
||||
);
|
||||
ui.boot.state = result.state;
|
||||
ui.modal = null;
|
||||
ui.deploymentDiscovery = null;
|
||||
await refreshRepositories(false);
|
||||
ui.selectedProfileId = result.profile.id;
|
||||
showToast(
|
||||
"Deployment configured",
|
||||
`${profile.name} targets ${profile.environment}.`,
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Could not save profile", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
} else if (action === "delete-deployment-profile") {
|
||||
if (
|
||||
!confirm("Delete this deployment profile? Operation history is retained.")
|
||||
)
|
||||
return;
|
||||
setLoading(true, "Deleting deployment profile…");
|
||||
try {
|
||||
const result = await window.forgeflow.deleteDeploymentProfile(
|
||||
repository.fullName,
|
||||
target.dataset.profileId,
|
||||
);
|
||||
ui.boot.state = result.state;
|
||||
ui.modal = null;
|
||||
await refreshRepositories(false);
|
||||
showToast(
|
||||
"Profile deleted",
|
||||
"Deployment environment removed.",
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Could not delete profile", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
} else if (action === "run-deployment-preflight") {
|
||||
if (!repository) repository = profileRepository(target.dataset.profileId);
|
||||
await runDeploymentPreflight(repository, target.dataset.profileId);
|
||||
} else if (action === "manage-deploy-key") {
|
||||
const profileId = target.dataset.profileId || ui.selectedProfileId;
|
||||
if (!repository) repository = profileRepository(profileId);
|
||||
if (!repository || !profileId) return;
|
||||
setLoading(true, "Inspecting deploy-key lifecycle without changing access…");
|
||||
try {
|
||||
const [inventory, rotation, revocation] = await Promise.all([
|
||||
window.forgeflow.deployKeyInventory(repository, profileId),
|
||||
window.forgeflow.planDeployKeyRotation(repository, profileId),
|
||||
window.forgeflow.planDeployKeyRevocation(repository, profileId),
|
||||
]);
|
||||
ui.deployKeyLifecycle = { repositoryId: repository.id, profileId, inventory, rotation, revocation };
|
||||
ui.modal = { type: "deploy-key-lifecycle" };
|
||||
render();
|
||||
} catch (error) {
|
||||
showToast("Could not inspect deploy key", error.message, "error");
|
||||
} finally { setLoading(false); }
|
||||
} else if (action === "confirm-rotate-deploy-key") {
|
||||
const lifecycle = ui.deployKeyLifecycle;
|
||||
const targetRepository = repositories().find((item) => item.id === lifecycle?.repositoryId);
|
||||
if (!targetRepository || !lifecycle?.rotation?.id) return;
|
||||
setLoading(true, "Rotating and verifying the repository deploy key…");
|
||||
try {
|
||||
const result = await window.forgeflow.applyDeployKeyRotation(targetRepository, lifecycle.profileId, lifecycle.rotation.id);
|
||||
if (result.state) ui.boot.state = result.state;
|
||||
ui.modal = null; ui.deployKeyLifecycle = null;
|
||||
await refreshRepositories(false, true);
|
||||
showToast("Deploy key rotated", `New fingerprint ${result.profile?.serverGitAccess?.keyFingerprint || "verified"}.`, "success");
|
||||
} catch (error) { showToast("Deploy-key rotation failed safely", error.message, "error"); }
|
||||
finally { setLoading(false); }
|
||||
} else if (action === "confirm-revoke-deploy-key") {
|
||||
const lifecycle = ui.deployKeyLifecycle;
|
||||
const targetRepository = repositories().find((item) => item.id === lifecycle?.repositoryId);
|
||||
if (!targetRepository || !lifecycle?.revocation?.id) return;
|
||||
setLoading(true, "Revoking repository access while preserving recovery…");
|
||||
try {
|
||||
const result = await window.forgeflow.applyDeployKeyRevocation(targetRepository, lifecycle.profileId, lifecycle.revocation.id);
|
||||
if (result.state) ui.boot.state = result.state;
|
||||
ui.modal = null; ui.deployKeyLifecycle = null;
|
||||
await refreshRepositories(false, true);
|
||||
showToast("Deploy key revoked", "Server pull is disabled; containers were not changed and recovery is available.", "success");
|
||||
} catch (error) { showToast("Deploy-key revocation failed", error.message, "error"); }
|
||||
finally { setLoading(false); }
|
||||
} else if (action === "restore-deploy-key") {
|
||||
const lifecycle = ui.deployKeyLifecycle;
|
||||
const targetRepository = repositories().find((item) => item.id === lifecycle?.repositoryId);
|
||||
if (!targetRepository || !lifecycle?.profileId) return;
|
||||
setLoading(true, "Restoring and verifying repository access…");
|
||||
try {
|
||||
const result = await window.forgeflow.restoreDeployKey(targetRepository, lifecycle.profileId);
|
||||
if (result.state) ui.boot.state = result.state;
|
||||
ui.modal = null; ui.deployKeyLifecycle = null;
|
||||
await refreshRepositories(false, true);
|
||||
showToast("Deploy key restored", "Read-only server pull access is verified again.", "success");
|
||||
} catch (error) { showToast("Deploy-key recovery failed", error.message, "error"); }
|
||||
finally { setLoading(false); }
|
||||
} else if (action === "verify-server-git-access") {
|
||||
const profileId = target.dataset.profileId || ui.selectedProfileId;
|
||||
if (!repository) repository = profileRepository(profileId);
|
||||
if (!repository || !profileId) return;
|
||||
setLoading(true, "Verifying Gitea, deploy key, server commit and runtime…");
|
||||
try {
|
||||
const result = await window.forgeflow.verifyServerGitProfile(repository, profileId);
|
||||
ui.serverGitVerifications[profileId] = result;
|
||||
render();
|
||||
const blockers = result.deploymentBlockers || [];
|
||||
const warnings = result.checks.filter((check) => check.status !== "pass" && !blockers.some((blocker) => blocker.id === check.id));
|
||||
showToast(
|
||||
result.readiness,
|
||||
blockers[0]?.detail || warnings[0]?.detail || `Verified ${result.checks.length} server-pull checks without changing the server.`,
|
||||
blockers.length ? "error" : warnings.length ? "warning" : "success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Server-pull verification failed", error.message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} else if (action === "configure-server-git-access") {
|
||||
const profileId = target.dataset.profileId || ui.selectedProfileId;
|
||||
if (!repository) repository = profileRepository(profileId);
|
||||
if (!repository || !profileId) return;
|
||||
const approved = confirm(
|
||||
`Configure read-only Gitea access for ${repository.fullName}?\n\nForgeFlow creates a dedicated SSH deploy key on the selected server, adds only its public key to this Gitea repository and pins the observed Gitea SSH host key. The private key never leaves the server.`,
|
||||
);
|
||||
if (!approved) return;
|
||||
setLoading(true, "Configuring repository-scoped Gitea access…");
|
||||
try {
|
||||
const result = await window.forgeflow.configureServerGitAccess(repository, profileId);
|
||||
if (result.state) ui.boot.state = result.state;
|
||||
await refreshRepositories(false, true);
|
||||
await refreshDeploymentTruth(false);
|
||||
showToast(
|
||||
"Server pull ready",
|
||||
`Read-only Gitea access verified at ${shortSha(result.remoteSha)}.`,
|
||||
"success",
|
||||
);
|
||||
await runDeploymentPreflight(repository, profileId, { showModal: true });
|
||||
} catch (error) {
|
||||
showToast("Could not configure Gitea access", error.message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} else if (action === "repair-deployment-write-access") {
|
||||
const profileId = target.dataset.profileId || ui.selectedProfileId;
|
||||
if (!repository) repository = profileRepository(profileId);
|
||||
if (!repository || !profileId) return;
|
||||
const profile = repository.deploymentProfiles?.find((item) => item.id === profileId);
|
||||
const approved = confirm(
|
||||
`Repair write access for ${repository.fullName} on ${profile?.name || profile?.environment || "the linked Unraid deployment"}?\n\nForgeFlow will only adjust the linked project source tree and its .forgeflow state folders. Preserved runtime paths such as appdata, data, config and logs are excluded. No container will be stopped, removed or recreated.`,
|
||||
);
|
||||
if (!approved) return;
|
||||
setLoading(true, "Repairing scoped Unraid write access…");
|
||||
try {
|
||||
const result = await window.forgeflow.repairDeploymentWriteAccess(
|
||||
repository,
|
||||
profileId,
|
||||
);
|
||||
showToast(
|
||||
"Write access normalized",
|
||||
"Project source and ForgeFlow upload folders now use safe shared write permissions. Preserved runtime data was not changed.",
|
||||
"success",
|
||||
);
|
||||
await runDeploymentPreflight(repository, profileId, { showModal: true });
|
||||
} catch (error) {
|
||||
showToast("Write-access repair failed", error.message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
else return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
async function handleInventoryActions(event, target, action, repository) {
|
||||
if (action === "scan-server-inventory") {
|
||||
setLoading(true, "Scanning Docker, Compose and DockerMan workloads…");
|
||||
try {
|
||||
await refreshDeploymentTruth(true);
|
||||
const detected = (ui.serverDiscovery || []).reduce(
|
||||
(total, item) => total + Number(item.detected || 0),
|
||||
0,
|
||||
);
|
||||
const review = (ui.serverDiscovery || []).reduce(
|
||||
(total, item) => total + Number(item.needsReview || 0),
|
||||
0,
|
||||
);
|
||||
const failures = (ui.serverDiscovery || []).filter((item) => item.error);
|
||||
if (failures.length) {
|
||||
showToast(
|
||||
"Server scan failed",
|
||||
failures.map((item) => `${item.serverName || item.serverId}: ${item.error}`).join(" · "),
|
||||
"error",
|
||||
);
|
||||
} else {
|
||||
showToast(
|
||||
"Server inventory updated",
|
||||
`${detected} workload${detected === 1 ? "" : "s"} detected; ${review} require manual review.`,
|
||||
review ? "info" : "success",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast("Server scan failed", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
} else if (action === "plan-server-reconciliation") {
|
||||
setLoading(true, "Building a read-only reconciliation preview…");
|
||||
try {
|
||||
const result = await window.forgeflow.planServerReconciliation(target.dataset.serverId);
|
||||
ui.modal = { type: "server-reconciliation-plan", result };
|
||||
render();
|
||||
} catch (error) {
|
||||
showToast("Could not build reconciliation plan", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
} else if (action === "apply-server-reconciliation") {
|
||||
setLoading(true, "Applying the reviewed configuration plan…");
|
||||
try {
|
||||
const result = await window.forgeflow.applyServerReconciliation(target.dataset.serverId, target.dataset.planId);
|
||||
if (result.state) ui.boot.state = result.state;
|
||||
ui.modal = null;
|
||||
await refreshRepositories(false, true);
|
||||
await refreshDeploymentTruth(false);
|
||||
showToast("Reconciliation applied", `${result.adopted || 0} link(s) added and ${result.refreshed || 0} profile(s) refreshed. No containers were changed.`, "success");
|
||||
} catch (error) {
|
||||
showToast("Reconciliation was not applied", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
} else if (action === "quick-link-server-workload") {
|
||||
const serverResult = (ui.serverDiscovery || []).find(
|
||||
(item) => item.serverId === target.dataset.serverId,
|
||||
);
|
||||
const workload = serverResult?.workloads?.find(
|
||||
(item) => item.workloadId === target.dataset.workloadId,
|
||||
);
|
||||
const linkedRepository = ui.repositories.find(
|
||||
(item) => item.fullName === target.dataset.repository,
|
||||
);
|
||||
if (!workload || !linkedRepository || !workload.remoteFolderCandidate) {
|
||||
showToast("Automatic link unavailable", "Scan the server again and use Review & link.", "error");
|
||||
return;
|
||||
}
|
||||
setLoading(true, `Linking ${workload.displayName} to ${linkedRepository.fullName}…`);
|
||||
try {
|
||||
const result = await window.forgeflow.linkServerWorkload(
|
||||
linkedRepository,
|
||||
target.dataset.serverId,
|
||||
target.dataset.workloadId,
|
||||
"server-git",
|
||||
workload.remoteFolderCandidate,
|
||||
);
|
||||
if (result.state) ui.boot.state = result.state;
|
||||
ui.selectedProfileId = result.profile?.id || null;
|
||||
await refreshRepositories(false, true);
|
||||
await refreshDeploymentTruth(false);
|
||||
showToast(
|
||||
"Deployment linked",
|
||||
`${linkedRepository.fullName} is linked to ${workload.compose?.workingDir || workload.remoteFolderCandidate}. Compose values were read from the server.`,
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Could not link deployment", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
} else if (action === "preview-inventory-review") {
|
||||
const reviewAction = document.querySelector("#inventory-review-action")?.value || "ignore";
|
||||
const reason = document.querySelector("#inventory-review-reason")?.value.trim() || "";
|
||||
const serverId = target.dataset.serverId;
|
||||
const workloadId = target.dataset.workloadId;
|
||||
try {
|
||||
ui.inventoryReviewPlan = await window.forgeflow.planInventoryReview(serverId, workloadId, reviewAction, reason, document.querySelector("#workload-repository")?.value || null);
|
||||
ui.modal = { type: "inventory-review-plan" };
|
||||
render();
|
||||
} catch (error) { showToast("Review preview unavailable", error.message, "error"); }
|
||||
} else if (action === "apply-inventory-review") {
|
||||
const plan = ui.inventoryReviewPlan;
|
||||
if (!plan?.id) return;
|
||||
setLoading(true, "Saving the evidence-bound inventory decision…");
|
||||
try {
|
||||
const result = await window.forgeflow.applyInventoryReview(plan.serverId, plan.workloadId, plan.action, plan.reason, plan.repositoryFullName, plan.id);
|
||||
if (result.state) ui.boot.state = result.state;
|
||||
ui.serverDiscovery = (ui.serverDiscovery || []).map((item) => item.serverId === plan.serverId ? result.inventory : item);
|
||||
ui.inventoryReviewPlan = null; ui.modal = null; render();
|
||||
showToast("Inventory decision saved", "Containers and Compose runtime were not changed.", "success");
|
||||
} catch (error) { showToast("Inventory review failed safely", error.message, "error"); }
|
||||
finally { setLoading(false); }
|
||||
} else if (action === "link-server-workload") {
|
||||
const serverResult = (ui.serverDiscovery || []).find(
|
||||
(item) => item.serverId === target.dataset.serverId,
|
||||
);
|
||||
const workload = serverResult?.workloads?.find(
|
||||
(item) => item.workloadId === target.dataset.workloadId,
|
||||
);
|
||||
if (!workload) {
|
||||
showToast(
|
||||
"Workload unavailable",
|
||||
"Scan the server inventory again before linking this workload.",
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
ui.modal = {
|
||||
type: "workload-link",
|
||||
serverId: target.dataset.serverId,
|
||||
workloadId: target.dataset.workloadId,
|
||||
repositoryFullName:
|
||||
workload.candidates?.[0]?.repositoryFullName ||
|
||||
repository?.fullName ||
|
||||
ui.repositories[0]?.fullName ||
|
||||
"",
|
||||
remoteFolder: workload.remoteFolderCandidate || "",
|
||||
};
|
||||
render();
|
||||
} else if (action === "confirm-link-server-workload") {
|
||||
const repositoryFullName = document
|
||||
.querySelector("#workload-repository")
|
||||
?.value.trim();
|
||||
const deploymentMode = document.querySelector("#workload-deployment-mode")?.value || "server-git";
|
||||
const remoteFolder = document
|
||||
.querySelector("#workload-remote-folder")
|
||||
?.value.trim();
|
||||
const linkedRepository = ui.repositories.find(
|
||||
(item) => item.fullName === repositoryFullName,
|
||||
);
|
||||
if (!linkedRepository) {
|
||||
showToast(
|
||||
"Choose a repository",
|
||||
"The workload must be linked to a ForgeFlow project.",
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setLoading(true, "Saving the permanent server workload link…");
|
||||
try {
|
||||
const result = await window.forgeflow.linkServerWorkload(
|
||||
linkedRepository,
|
||||
target.dataset.serverId,
|
||||
target.dataset.workloadId,
|
||||
deploymentMode,
|
||||
remoteFolder,
|
||||
);
|
||||
if (result.state) ui.boot.state = result.state;
|
||||
ui.modal = null;
|
||||
ui.selectedProfileId = result.profile?.id || null;
|
||||
await refreshRepositories(false, true);
|
||||
await refreshDeploymentTruth(false);
|
||||
showToast(
|
||||
"Workload linked",
|
||||
`${linkedRepository.fullName} now uses direct desktop-to-Unraid copy and the Compose configuration detected on the server.`,
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Could not link workload", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
else return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
async function handleRecoveryActions(event, target, action, repository) {
|
||||
if (action === "repair-origin") {
|
||||
if (!repository?.localPath || !repository.sshUrl) return;
|
||||
if (
|
||||
!confirm(
|
||||
`Replace origin with ${repository.sshUrl}? Local files and commits are not changed.`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
setLoading(true, "Updating Git origin…");
|
||||
try {
|
||||
await window.forgeflow.setOrigin(repository.localPath, repository.sshUrl);
|
||||
await refreshRepositories(false);
|
||||
showToast("Git origin updated", repository.sshUrl, "success");
|
||||
} catch (error) {
|
||||
showToast("Could not update origin", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
} else if (action === "normalize-origins") {
|
||||
if (
|
||||
!confirm(
|
||||
"Replace legacy origin URLs for every linked repository with the current Gitea SSH URL? Local files and commits are not changed.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
setLoading(true, "Normalizing linked Git origins…");
|
||||
try {
|
||||
const result = await window.forgeflow.normalizeOrigins();
|
||||
ui.repositories = result.repositories;
|
||||
showToast(
|
||||
"Git origins normalized",
|
||||
`${result.changes.length} repository origin${result.changes.length === 1 ? "" : "s"} updated.`,
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Could not normalize origins", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
render();
|
||||
} 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 === "preview-workspace-sync") {
|
||||
if (!repository?.localPath) return;
|
||||
setLoading(true, "Fetching Gitea and building a safe synchronization plan…");
|
||||
try {
|
||||
ui.workspaceSyncPlan = await window.forgeflow.previewWorkspaceSync(
|
||||
repository.localPath,
|
||||
);
|
||||
ui.modal = { type: "workspace-sync" };
|
||||
showToast(
|
||||
ui.workspaceSyncPlan.needsSync
|
||||
? "Workspace sync preview ready"
|
||||
: "Workspace already synchronized",
|
||||
ui.workspaceSyncPlan.needsSync
|
||||
? `${ui.workspaceSyncPlan.summary.resultingTrackedChanges} tracked change(s) and ${ui.workspaceSyncPlan.summary.localFilesToStash} local file(s) reviewed.`
|
||||
: `Local ${ui.workspaceSyncPlan.branch} already matches ${ui.workspaceSyncPlan.upstream}.`,
|
||||
ui.workspaceSyncPlan.blockers?.length ? "error" : "success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Could not preview Gitea sync", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
render();
|
||||
} else if (action === "confirm-workspace-sync") {
|
||||
if (!repository?.localPath || !ui.workspaceSyncPlan) return;
|
||||
const expectedPlanId = target.dataset.planId;
|
||||
setLoading(true, "Protecting local work and synchronizing exact Gitea state…");
|
||||
try {
|
||||
const result = await window.forgeflow.applyWorkspaceSync(
|
||||
repository.localPath,
|
||||
expectedPlanId,
|
||||
);
|
||||
ui.modal = null;
|
||||
ui.workspaceSyncPlan = null;
|
||||
await refreshRepositories(false);
|
||||
[ui.branches, ui.stashes] = await Promise.all([
|
||||
window.forgeflow.branches(repository.localPath),
|
||||
window.forgeflow.stashList(repository.localPath),
|
||||
]);
|
||||
const recovery = [
|
||||
result.backupBranch ? `recovery branch ${result.backupBranch}` : null,
|
||||
result.stash ? `quarantine stash ${result.stash.ref}` : null,
|
||||
result.review ? `Codex review manifest ${result.review.manifestPath}` : null,
|
||||
].filter(Boolean).join(" and ");
|
||||
showToast(
|
||||
"Workspace synchronized with Gitea",
|
||||
recovery
|
||||
? `Local work is quarantined in ${recovery}. Review it before restoring anything; ignored runtime files were retained.`
|
||||
: `Tracked files now match ${result.plan.upstream}; ignored runtime files were retained.`,
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.code === "WORKSPACE_SYNC_PLAN_STALE") {
|
||||
try {
|
||||
ui.workspaceSyncPlan = await window.forgeflow.previewWorkspaceSync(
|
||||
repository.localPath,
|
||||
);
|
||||
ui.modal = { type: "workspace-sync" };
|
||||
} catch {
|
||||
ui.modal = null;
|
||||
ui.workspaceSyncPlan = null;
|
||||
}
|
||||
}
|
||||
showToast("Workspace synchronization stopped", 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-troubleshooter") {
|
||||
setLoading(
|
||||
true,
|
||||
"Scanning repositories, Git operations and deployment servers…",
|
||||
);
|
||||
try {
|
||||
ui.troubleshooter = await window.forgeflow.troubleshooterScan();
|
||||
showToast(
|
||||
"Troubleshooter completed",
|
||||
ui.troubleshooter.summary.total
|
||||
? `${ui.troubleshooter.summary.total} issue(s) found; ${ui.troubleshooter.summary.repairable} repairable.`
|
||||
: "No problems were detected.",
|
||||
ui.troubleshooter.summary.errors ? "error" : "success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Troubleshooter failed", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
render();
|
||||
} else if (action === "troubleshooter-auto-repair") {
|
||||
const safeIssues = (ui.troubleshooter?.issues || []).filter(
|
||||
(item) => item.repairable && item.safe,
|
||||
);
|
||||
if (
|
||||
!safeIssues.length ||
|
||||
!confirm(
|
||||
`Repair ${safeIssues.length} safe issue(s) now? ForgeFlow will not run destructive reset actions automatically.`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
setLoading(true, "Applying safe one-click repairs…");
|
||||
try {
|
||||
const results =
|
||||
await window.forgeflow.troubleshooterAutoRepair(safeIssues);
|
||||
ui.troubleshooter = await window.forgeflow.troubleshooterScan();
|
||||
await refreshRepositories(false);
|
||||
const failed = results.filter((item) => !item.ok);
|
||||
showToast(
|
||||
failed.length
|
||||
? "Repairs partially completed"
|
||||
: "Safe repairs completed",
|
||||
`${results.length - failed.length} repaired, ${failed.length} failed.`,
|
||||
failed.length ? "error" : "success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Automatic repair failed", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
render();
|
||||
} else if (action === "troubleshooter-repair") {
|
||||
const issue =
|
||||
ui.troubleshooter?.issues?.[Number(target.dataset.issueIndex)];
|
||||
if (!issue) return;
|
||||
const warning = issue.safe
|
||||
? `Repair “${issue.title}” now?`
|
||||
: `“${issue.title}” requires a safety branch or another potentially destructive change. Continue?`;
|
||||
if (!confirm(warning)) return;
|
||||
setLoading(true, `Repairing ${issue.title}…`);
|
||||
try {
|
||||
const result = await window.forgeflow.troubleshooterRepair(issue);
|
||||
ui.troubleshooter = await window.forgeflow.troubleshooterScan();
|
||||
await refreshRepositories(false);
|
||||
showToast(
|
||||
"Problem repaired",
|
||||
result?.backupBranch
|
||||
? `Safety branch created: ${result.backupBranch}`
|
||||
: issue.title,
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Repair failed", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
render();
|
||||
} else if (action === "run-system-preflight") await runSystemPreflight();
|
||||
else if (action === "load-audit-log") {
|
||||
try {
|
||||
ui.auditEvents = await window.forgeflow.listAuditEvents(500);
|
||||
render();
|
||||
} catch (error) {
|
||||
showToast("Could not load audit log", error.message, "error");
|
||||
}
|
||||
} else if (action === "export-audit-json" || action === "export-audit-csv") {
|
||||
try {
|
||||
const result = await window.forgeflow.exportAuditLog(
|
||||
action.endsWith("csv") ? "csv" : "json",
|
||||
);
|
||||
if (result)
|
||||
showToast(
|
||||
"Audit log exported",
|
||||
`${result.count} records exported.`,
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Could not export audit log", error.message, "error");
|
||||
}
|
||||
} else if (action === "save-diagnostics-preferences") {
|
||||
const preferences = {
|
||||
diagnosticsEnabled:
|
||||
document.querySelector("#diagnostics-enabled").value === "true",
|
||||
diagnosticLevel: document.querySelector("#diagnostic-level").value,
|
||||
logRetentionDays: Number(
|
||||
document.querySelector("#diagnostic-retention").value,
|
||||
),
|
||||
maxLogFileMb: Number(
|
||||
document.querySelector("#diagnostic-max-file").value,
|
||||
),
|
||||
};
|
||||
setLoading(true, "Saving diagnostic policy…");
|
||||
try {
|
||||
ui.boot.state = await window.forgeflow.setPreferences(preferences);
|
||||
ui.diagnosticsStatus = await window.forgeflow.diagnosticsStatus();
|
||||
showToast(
|
||||
"Diagnostic policy saved",
|
||||
"New events now use the updated retention and logging level.",
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Could not save diagnostics", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
} else if (action === "export-diagnostics") {
|
||||
const privacyMode =
|
||||
document.querySelector("#diagnostic-privacy")?.value || "standard";
|
||||
setLoading(true, "Creating redacted diagnostic bundle…");
|
||||
try {
|
||||
const bundle = await window.forgeflow.exportDiagnostics(privacyMode);
|
||||
if (bundle) {
|
||||
ui.lastDiagnosticBundle = bundle;
|
||||
ui.diagnosticsStatus = await window.forgeflow.diagnosticsStatus();
|
||||
showToast(
|
||||
"Diagnostic bundle created",
|
||||
`${bundle.size} · SHA-256 ${shortSha(bundle.sha256)}`,
|
||||
"success",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast("Could not export diagnostics", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
} else if (action === "show-diagnostic-bundle") {
|
||||
if (!ui.lastDiagnosticBundle?.path) return;
|
||||
await window.forgeflow
|
||||
.showDiagnosticBundle(ui.lastDiagnosticBundle.path)
|
||||
.catch((error) =>
|
||||
showToast("Could not show bundle", error.message, "error"),
|
||||
);
|
||||
} else if (action === "open-diagnostics-folder")
|
||||
await window.forgeflow
|
||||
.openDiagnosticsFolder()
|
||||
.catch((error) =>
|
||||
showToast("Could not open diagnostic folder", error.message, "error"),
|
||||
);
|
||||
else if (action === "clear-diagnostics") {
|
||||
if (
|
||||
!confirm(
|
||||
"Clear local ForgeFlow diagnostic logs? This does not affect repositories or configuration.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
ui.diagnosticsStatus = await window.forgeflow.clearDiagnostics();
|
||||
showToast(
|
||||
"Diagnostic logs cleared",
|
||||
"A new session marker was created.",
|
||||
"success",
|
||||
);
|
||||
render();
|
||||
} catch (error) {
|
||||
showToast("Could not clear logs", error.message, "error");
|
||||
}
|
||||
} else if (action === "reset-app") {
|
||||
if (
|
||||
!confirm(
|
||||
"Reset ForgeFlow configuration? Your Git repositories and Gitea data are not modified.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
ui.boot.state = await window.forgeflow.reset();
|
||||
ui.repositories = [];
|
||||
ui.setupStep = 0;
|
||||
ui.setupValidation = null;
|
||||
ui.systemPreflight = null;
|
||||
ui.deploymentPreflight = null;
|
||||
ui.lastDiagnosticBundle = null;
|
||||
ui.setupDraft = {
|
||||
baseUrl: "https://",
|
||||
token: "",
|
||||
user: null,
|
||||
roots: [],
|
||||
discovered: [],
|
||||
};
|
||||
render();
|
||||
}
|
||||
else return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
async function handleSetupAndSettingsActions(event, target, action, repository) {
|
||||
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);
|
||||
} else if (action === "setup-add-root") {
|
||||
const root = await window.forgeflow.selectDirectory({
|
||||
title: "Select a development folder",
|
||||
});
|
||||
if (root && !ui.setupDraft.roots.includes(root))
|
||||
ui.setupDraft.roots.push(root);
|
||||
render();
|
||||
} else if (action === "setup-remove-root") {
|
||||
ui.setupDraft.roots.splice(Number(target.dataset.index), 1);
|
||||
render();
|
||||
} else if (action === "setup-next") {
|
||||
if (ui.setupStep === 2) {
|
||||
ui.setupStep = 3;
|
||||
ui.setupDraft.discovered = [];
|
||||
render();
|
||||
try {
|
||||
ui.setupDraft.discovered = await window.forgeflow.discoverRepositories(
|
||||
ui.setupDraft.roots,
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Discovery failed", error.message, "error");
|
||||
}
|
||||
ui.setupStep = 4;
|
||||
render();
|
||||
}
|
||||
} else if (action === "setup-back") {
|
||||
ui.setupStep = Math.max(0, ui.setupStep - 1);
|
||||
render();
|
||||
} else if (action === "setup-finish") {
|
||||
setLoading(true, "Saving configuration…");
|
||||
try {
|
||||
const result = await window.forgeflow.completeSetup({
|
||||
baseUrl: ui.setupDraft.baseUrl,
|
||||
token: ui.setupDraft.token,
|
||||
user: ui.setupDraft.user,
|
||||
workspaceRoots: ui.setupDraft.roots,
|
||||
});
|
||||
ui.boot.state = result.state;
|
||||
await refreshRepositories(false);
|
||||
showToast(
|
||||
"Setup complete",
|
||||
result.tokenState.persistent
|
||||
? "Your token is stored securely."
|
||||
: "Your token is available for this session only.",
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Could not complete setup", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
} else if (action === "check-updates") {
|
||||
ui.updateChecking = true;
|
||||
render();
|
||||
try {
|
||||
ui.updateStatus = await window.forgeflow.checkForUpdates();
|
||||
showToast(
|
||||
ui.updateStatus.available ? "Update available" : "ForgeFlow is current",
|
||||
ui.updateStatus.available
|
||||
? `Version ${ui.updateStatus.remoteVersion} can be downloaded.`
|
||||
: `Version ${ui.updateStatus.currentVersion} is the newest release.`,
|
||||
ui.updateStatus.available ? "success" : "info",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Update check failed", error.message, "error");
|
||||
}
|
||||
ui.updateChecking = false;
|
||||
render();
|
||||
} else if (action === "save-update-settings") {
|
||||
const updates = {
|
||||
owner: document.querySelector("#update-owner").value.trim(),
|
||||
repo: document.querySelector("#update-repo").value.trim(),
|
||||
branch: document.querySelector("#update-branch").value.trim(),
|
||||
autoCheck: document.querySelector("#update-auto-check").value === "true",
|
||||
};
|
||||
try {
|
||||
ui.boot.state = await window.forgeflow.setUpdatePreferences(updates);
|
||||
ui.updateStatus = null;
|
||||
showToast(
|
||||
"Update settings saved",
|
||||
"The next check will use this repository and branch.",
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Could not save update settings", error.message, "error");
|
||||
}
|
||||
render();
|
||||
} else if (action === "download-update") {
|
||||
setLoading(true, "Downloading and verifying the exact ForgeFlow update…");
|
||||
try {
|
||||
ui.updateStatus = await window.forgeflow.downloadUpdate();
|
||||
showToast(
|
||||
"Update downloaded",
|
||||
`Version ${ui.updateStatus.remoteVersion} passed the integrity check.`,
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Update download failed", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
} else if (action === "apply-update") {
|
||||
if (
|
||||
!confirm(
|
||||
`Apply ForgeFlow ${ui.updateStatus?.remoteVersion || "update"} now? ForgeFlow closes, validates the update and restarts automatically.`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
setLoading(true, "Launching safe updater…");
|
||||
try {
|
||||
await window.forgeflow.applyUpdate();
|
||||
showToast(
|
||||
"Update launched",
|
||||
"ForgeFlow will close and restart after validation.",
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Could not launch update", error.message, "error");
|
||||
setLoading(false);
|
||||
}
|
||||
} else if (action === "use-server-password") {
|
||||
ui.modal = {
|
||||
type: "server-password",
|
||||
serverId: target.dataset.serverId,
|
||||
retry: { type: target.dataset.retry || "scan" },
|
||||
};
|
||||
render();
|
||||
} else if (action === "confirm-server-password") {
|
||||
const server = (ui.boot?.state?.servers || []).find((item) => item.id === target.dataset.serverId);
|
||||
const password = document.querySelector("#quick-server-password")?.value || "";
|
||||
if (!server || !password) {
|
||||
showToast("Password required", "Enter the Unraid SSH password.", "error");
|
||||
return;
|
||||
}
|
||||
const retry = ui.modal?.retry || { type: "scan" };
|
||||
setLoading(true, "Switching the server connection to password authentication…");
|
||||
try {
|
||||
const saved = await window.forgeflow.saveServer(
|
||||
{ ...server, authType: "password", privateKeyPath: "" },
|
||||
password,
|
||||
"",
|
||||
);
|
||||
ui.boot.state = saved.state;
|
||||
const tested = await window.forgeflow.testServer(server.id);
|
||||
ui.boot.state = tested.state;
|
||||
ui.modal = null;
|
||||
showToast("Server password saved", "ForgeFlow will no longer use an SSH key for this server.", "success");
|
||||
if (retry.type === "deploy") {
|
||||
const retryRepository = ui.repositories.find((item) => item.fullName === retry.repositoryFullName);
|
||||
if (retryRepository) ui.selectedRepoId = retryRepository.id;
|
||||
await executeDeployment(retry.profileId);
|
||||
} else {
|
||||
await refreshDeploymentTruth(true);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast("Server authentication failed", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
} else if (action === "open-add-server") {
|
||||
ui.modal = {
|
||||
type: "server-config",
|
||||
serverId: null,
|
||||
authType: "password",
|
||||
};
|
||||
render();
|
||||
} else if (action === "edit-server") {
|
||||
const server = (ui.boot.state.servers || []).find(
|
||||
(item) => item.id === target.dataset.serverId,
|
||||
);
|
||||
ui.modal = {
|
||||
type: "server-config",
|
||||
serverId: target.dataset.serverId,
|
||||
authType: server?.authType || "password",
|
||||
};
|
||||
render();
|
||||
} else if (action === "select-private-key") {
|
||||
const keyPath = await window.forgeflow.selectKeyFile({
|
||||
title: "Select SSH private key",
|
||||
defaultPath:
|
||||
document.querySelector("#server-private-key")?.value || undefined,
|
||||
});
|
||||
if (keyPath) document.querySelector("#server-private-key").value = keyPath;
|
||||
} else if (action === "save-server") {
|
||||
const authType = document.querySelector("#server-auth-type").value;
|
||||
const server = {
|
||||
id: target.dataset.serverId || undefined,
|
||||
name: document.querySelector("#server-name").value.trim(),
|
||||
host: document.querySelector("#server-host").value.trim(),
|
||||
port: Number(document.querySelector("#server-port").value),
|
||||
username: document.querySelector("#server-username").value.trim(),
|
||||
authType,
|
||||
basePath: document.querySelector("#server-base-path").value.trim(),
|
||||
scanRoots: document.querySelector("#server-scan-roots").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean),
|
||||
scanExcludes: document.querySelector("#server-scan-excludes").value.split(",").map((value) => value.trim()).filter(Boolean),
|
||||
privateKeyPath:
|
||||
document.querySelector("#server-private-key")?.value.trim() || "",
|
||||
hostFingerprint: document
|
||||
.querySelector("#server-fingerprint")
|
||||
.value.trim(),
|
||||
};
|
||||
const password = document.querySelector("#server-password")?.value || "";
|
||||
const passphrase =
|
||||
document.querySelector("#server-passphrase")?.value || "";
|
||||
setLoading(true, "Saving encrypted SSH configuration…");
|
||||
try {
|
||||
const result = await window.forgeflow.saveServer(
|
||||
server,
|
||||
password,
|
||||
passphrase,
|
||||
);
|
||||
ui.boot.state = result.state;
|
||||
ui.modal = null;
|
||||
showToast(
|
||||
"Server saved",
|
||||
"Run Test & trust before creating a deployment.",
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Could not save server", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
} else if (action === "test-server") {
|
||||
setLoading(
|
||||
true,
|
||||
"Checking SSH identity, Docker, Compose and optional Git capabilities…",
|
||||
);
|
||||
try {
|
||||
let result = await window.forgeflow.testServer(target.dataset.serverId);
|
||||
if (result.needsTrust) {
|
||||
const approved = confirm(
|
||||
`Verify this fingerprint on the SSH server before trusting it:\n\n${result.fingerprint}\n\nServer: ${result.server.host}:${result.server.port}\n\nTrust this exact host identity and continue with authentication?`,
|
||||
);
|
||||
if (!approved) {
|
||||
showToast("SSH trust cancelled", "No credentials were sent and the host identity was not saved.", "info");
|
||||
setLoading(false);
|
||||
return true;
|
||||
}
|
||||
result = await window.forgeflow.testServer(target.dataset.serverId, result.fingerprint);
|
||||
}
|
||||
ui.boot.state = result.state;
|
||||
const capabilities = result.capabilities || {};
|
||||
const deploymentReady =
|
||||
capabilities.docker && capabilities.dockerReady && capabilities.compose;
|
||||
showToast(
|
||||
deploymentReady ? "SSH server ready" : "SSH connected with missing tools",
|
||||
`${result.server.name} presented ${result.fingerprint}. Docker ${capabilities.dockerReady ? "ready" : "unavailable"}; Compose ${capabilities.compose ? "ready" : "missing"}.`,
|
||||
deploymentReady ? "success" : "info",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("SSH test failed", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
} else if (action === "delete-server") {
|
||||
if (
|
||||
!confirm("Delete this server and all deployment profiles linked to it?")
|
||||
)
|
||||
return;
|
||||
try {
|
||||
ui.boot.state = await window.forgeflow.deleteServer(
|
||||
target.dataset.serverId,
|
||||
);
|
||||
ui.modal = null;
|
||||
await refreshRepositories(false);
|
||||
showToast(
|
||||
"Server deleted",
|
||||
"Linked SSH deployment profiles were removed.",
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Could not delete server", error.message, "error");
|
||||
}
|
||||
} else if (action === "add-root") {
|
||||
const root = await window.forgeflow.selectDirectory({
|
||||
title: "Add development folder",
|
||||
});
|
||||
if (root && !ui.boot.state.workspaceRoots.includes(root))
|
||||
ui.boot.state.workspaceRoots.push(root);
|
||||
render();
|
||||
} else if (action === "remove-root") {
|
||||
ui.boot.state.workspaceRoots.splice(Number(target.dataset.index), 1);
|
||||
render();
|
||||
} else if (action === "save-roots") {
|
||||
const roots = [...document.querySelectorAll("[data-root-index]")]
|
||||
.map((input) => input.value.trim())
|
||||
.filter(Boolean);
|
||||
setLoading(true, "Saving workspace folders…");
|
||||
try {
|
||||
ui.boot.state = await window.forgeflow.setWorkspaceRoots(roots);
|
||||
await refreshRepositories(false);
|
||||
showToast(
|
||||
"Folders saved",
|
||||
"Repository discovery has been refreshed.",
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Could not save folders", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
} else if (action === "save-gitea-settings") {
|
||||
const baseUrl = document.querySelector("#settings-gitea-url").value.trim();
|
||||
const token = document.querySelector("#settings-gitea-token").value.trim();
|
||||
setLoading(true, "Validating Gitea…");
|
||||
try {
|
||||
const result = await window.forgeflow.updateGitea({ baseUrl, token });
|
||||
ui.boot.state = result.state;
|
||||
await refreshRepositories(false);
|
||||
showToast(
|
||||
"Gitea connected",
|
||||
`Signed in as ${result.validation.user.login}.`,
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Connection failed", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
} else if (action === "save-preferences") {
|
||||
const preferences = {
|
||||
autoRefresh:
|
||||
document.querySelector("#pref-auto-refresh").value === "true",
|
||||
repositoryPollSeconds: Number(
|
||||
document.querySelector("#pref-repo-poll").value,
|
||||
),
|
||||
operationPollSeconds: Number(
|
||||
document.querySelector("#pref-operation-poll").value,
|
||||
),
|
||||
fetchIntervalMinutes: Number(
|
||||
document.querySelector("#pref-fetch-interval").value,
|
||||
),
|
||||
preferredCloneProtocol: document.querySelector("#pref-clone-protocol")
|
||||
.value,
|
||||
};
|
||||
setLoading(true, "Saving background settings…");
|
||||
try {
|
||||
ui.boot.state = await window.forgeflow.setPreferences(preferences);
|
||||
await refreshRepositories(false);
|
||||
showToast(
|
||||
"Settings saved",
|
||||
"Background awareness has been updated.",
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Could not save settings", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
} else if (action === "save-desktop-preferences") {
|
||||
const splitArgs = (selector) =>
|
||||
document
|
||||
.querySelector(selector)
|
||||
.value.split("|")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
const preferences = {
|
||||
editor: {
|
||||
executable: document
|
||||
.querySelector("#pref-editor-executable")
|
||||
.value.trim(),
|
||||
args: splitArgs("#pref-editor-args"),
|
||||
},
|
||||
terminal: {
|
||||
executable: document
|
||||
.querySelector("#pref-terminal-executable")
|
||||
.value.trim(),
|
||||
args: splitArgs("#pref-terminal-args"),
|
||||
},
|
||||
notificationsEnabled: document.querySelector("#pref-notifications")
|
||||
.checked,
|
||||
trayEnabled: document.querySelector("#pref-tray").checked,
|
||||
closeToTray: document.querySelector("#pref-close-tray").checked,
|
||||
startAtLogin: document.querySelector("#pref-login").checked,
|
||||
};
|
||||
try {
|
||||
ui.boot.state = await window.forgeflow.setPreferences(preferences);
|
||||
showToast(
|
||||
"Desktop integration saved",
|
||||
"Editor, terminal, tray and notification settings are active.",
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Could not save desktop integration", error.message, "error");
|
||||
}
|
||||
render();
|
||||
} else if (
|
||||
action === "export-config-backup" ||
|
||||
action === "import-config-backup"
|
||||
) {
|
||||
const passphrase = document.querySelector("#backup-passphrase").value;
|
||||
if (passphrase.length < 12) {
|
||||
showToast("Passphrase too short", "Use at least 12 characters.", "error");
|
||||
return;
|
||||
}
|
||||
setLoading(
|
||||
true,
|
||||
action === "export-config-backup"
|
||||
? "Encrypting configuration backup…"
|
||||
: "Decrypting and validating configuration…",
|
||||
);
|
||||
try {
|
||||
const result =
|
||||
action === "export-config-backup"
|
||||
? await window.forgeflow.exportConfigurationBackup(passphrase)
|
||||
: await window.forgeflow.importConfigurationBackup(passphrase);
|
||||
if (result?.state) {
|
||||
ui.boot.state = result.state;
|
||||
await refreshRepositories(false);
|
||||
}
|
||||
if (result)
|
||||
showToast(
|
||||
action === "export-config-backup"
|
||||
? "Encrypted backup created"
|
||||
: "Configuration restored",
|
||||
action === "export-config-backup"
|
||||
? result.filePath
|
||||
: `Backup from ${result.exportedAt} imported; credentials were preserved only where already present.`,
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Configuration backup failed", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
else return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
async function handleShellActions(event, target, action, repository) {
|
||||
if (action === "navigate") {
|
||||
ui.currentView = target.dataset.view;
|
||||
ui.modal = null;
|
||||
render();
|
||||
if (ui.currentView === "deployments" && (ui.boot?.state?.servers || []).length && !(ui.serverDiscovery || []).length) {
|
||||
setLoading(true, "Reading Docker, Compose and DockerMan inventory from Unraid…");
|
||||
await refreshDeploymentTruth(true);
|
||||
setLoading(false);
|
||||
}
|
||||
} else if (action === "open-context-help" || action === "help-topic") {
|
||||
ui.helpTopic = target.dataset.topic || "getting-started";
|
||||
ui.helpQuery = "";
|
||||
ui.currentView = "help";
|
||||
ui.modal = null;
|
||||
render();
|
||||
requestAnimationFrame(() =>
|
||||
document.querySelector(`[data-help-topic="${ui.helpTopic}"]`)?.scrollIntoView({ block: "start", behavior: "smooth" }),
|
||||
);
|
||||
} else if (action === "clear-help-search") {
|
||||
ui.helpQuery = "";
|
||||
render();
|
||||
requestAnimationFrame(() => document.querySelector("#help-search")?.focus());
|
||||
} else if (action === "select-repo") selectRepository(target.dataset.id);
|
||||
else if (action === "open-deployment-link") {
|
||||
if (!repository) return true;
|
||||
selectRepository(repository.id, false);
|
||||
ui.selectedProfileId = target.dataset.profileId || selectedProfile(repository)?.id || null;
|
||||
ui.repositoryTab = "deployments";
|
||||
ui.currentView = "repository";
|
||||
render();
|
||||
} else if (action === "select-deployment-profile") {
|
||||
ui.selectedProfileId = target.dataset.profileId || null;
|
||||
ui.repositoryTab = "deployments";
|
||||
render();
|
||||
}
|
||||
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") {
|
||||
ui.repositoryTab = target.dataset.tab;
|
||||
if (ui.repositoryTab === "gittools" && !ui.branches.length)
|
||||
await loadGitTools(repository);
|
||||
else if (ui.repositoryTab === "validator" && !ui.gitValidation) {
|
||||
setLoading(true, "Validating Git and Gitea best practices…");
|
||||
try {
|
||||
ui.gitValidation = await window.forgeflow.gitValidatorScan(
|
||||
repository.fullName,
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Git Validator failed", error.message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
render();
|
||||
} else if (ui.repositoryTab === "settings") {
|
||||
try {
|
||||
ui.pullRequests = await window.forgeflow.pullRequests(
|
||||
repository.fullName,
|
||||
"open",
|
||||
);
|
||||
} catch (error) {
|
||||
ui.pullRequests = [];
|
||||
showToast("Could not load pull requests", error.message, "error");
|
||||
}
|
||||
render();
|
||||
} else render();
|
||||
} else if (action === "git-validator-scan") {
|
||||
setLoading(true, "Validating Git and Gitea best practices…");
|
||||
try {
|
||||
ui.gitValidation = await window.forgeflow.gitValidatorScan(
|
||||
repository.fullName,
|
||||
);
|
||||
showToast(
|
||||
"Git validation complete",
|
||||
`${ui.gitValidation.score}/100 · ${ui.gitValidation.grade}`,
|
||||
ui.gitValidation.summary.errors ? "error" : "success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Git Validator failed", error.message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} else if (action === "git-validator-repair") {
|
||||
const check = ui.gitValidation?.checks?.[Number(target.dataset.checkIndex)];
|
||||
if (!check?.fixAction) return;
|
||||
let preview;
|
||||
try {
|
||||
preview = await window.forgeflow.gitValidatorPreviewRepair(repository.fullName, check);
|
||||
} catch (error) {
|
||||
showToast("Preview failed", error.message, "error");
|
||||
return;
|
||||
}
|
||||
if (!confirm(`${check.confirmation || `Apply ${check.title}?`}\n\nReviewable change:\n${preview.diff}\n\nNothing will be committed or pushed.`)) return;
|
||||
setLoading(true, `Repairing ${check.title}…`);
|
||||
try {
|
||||
await window.forgeflow.gitValidatorRepair(repository.fullName, check);
|
||||
await refreshRepositories(false, true);
|
||||
ui.gitValidation = await window.forgeflow.gitValidatorScan(
|
||||
repository.fullName,
|
||||
);
|
||||
showToast("Git best practice repaired", check.title, "success");
|
||||
} catch (error) {
|
||||
showToast("Repair failed", error.message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} else if (action === "git-validator-policy") {
|
||||
setLoading(true, "Applying assurance policy…");
|
||||
try {
|
||||
await window.forgeflow.gitValidatorSetPolicy(repository.fullName, { id: target.value });
|
||||
ui.gitValidation = await window.forgeflow.gitValidatorScan(repository.fullName);
|
||||
showToast("Policy updated", ui.gitValidation.policy.label, "success");
|
||||
} catch (error) {
|
||||
showToast("Policy update failed", error.message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} else if (action === "git-validator-export") {
|
||||
try {
|
||||
const exported = await window.forgeflow.gitValidatorExport(repository.fullName, target.dataset.format || "markdown");
|
||||
const url = URL.createObjectURL(new Blob([exported.content], { type: exported.mimeType }));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${repository.name || "repository"}-git-assurance.${exported.extension}`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast("Report exported", link.download, "success");
|
||||
} catch (error) {
|
||||
showToast("Export failed", error.message, "error");
|
||||
}
|
||||
} else if (action === "git-validator-suppress") {
|
||||
const check = ui.gitValidation?.checks?.[Number(target.dataset.checkIndex)];
|
||||
if (!check) return;
|
||||
const reason = prompt("Reason for this temporary exception (minimum 10 characters):", "Accepted temporarily while remediation is tracked.");
|
||||
if (!reason) return;
|
||||
const author = prompt("Exception owner:", ui.boot?.state?.gitea?.user?.login || "");
|
||||
if (!author) return;
|
||||
const ticket = prompt("Ticket reference (optional):", "") || "";
|
||||
const expiresAt = new Date(Date.now() + 7 * 86_400_000).toISOString();
|
||||
try {
|
||||
await window.forgeflow.gitValidatorSuppress(repository.fullName, { checkId: check.id, reason, author, ticket, expiresAt, scope: "repository", evidence: `${ui.gitValidation.commitSha || "unknown"}:${check.id}:${check.status}` });
|
||||
ui.gitValidation = await window.forgeflow.gitValidatorScan(repository.fullName);
|
||||
showToast("Exception documented", `Expires ${formatDate(expiresAt)}.`, "success");
|
||||
} catch (error) {
|
||||
showToast("Exception rejected", error.message, "error");
|
||||
}
|
||||
} else if (action === "toggle-favorite") {
|
||||
ui.boot.state = await window.forgeflow.favoriteRepository(
|
||||
repository.fullName,
|
||||
!repository.favorite,
|
||||
);
|
||||
repository.favorite = !repository.favorite;
|
||||
render();
|
||||
} else if (action === "select-file") {
|
||||
if (event.target.matches("input[type=checkbox]")) return;
|
||||
ui.selectedFile = target.dataset.path;
|
||||
await loadDiff(repository, ui.selectedFile);
|
||||
} else if (action === "toggle-all-files") {
|
||||
const files = repository.localStatus?.files || [];
|
||||
ui.selectedFiles =
|
||||
ui.selectedFiles.size === files.length
|
||||
? new Set()
|
||||
: new Set(files.map((file) => file.path));
|
||||
render();
|
||||
} else if (action === "copy-diff") {
|
||||
await navigator.clipboard.writeText(ui.diff || "");
|
||||
showToast("Copied", "Diff copied to clipboard.", "success");
|
||||
} else if (action === "open-hunk-staging") {
|
||||
if (ui.diffHunks?.partialSupported) {
|
||||
ui.modal = { type: "hunk-staging" };
|
||||
render();
|
||||
}
|
||||
} else if (action === "stage-chosen-hunks") {
|
||||
const indexes = [
|
||||
...document.querySelectorAll("[data-hunk-index]:checked"),
|
||||
].map((input) => Number(input.dataset.hunkIndex));
|
||||
if (!indexes.length) return;
|
||||
const result = await runOperation(
|
||||
"Staging selected hunks…",
|
||||
() =>
|
||||
window.forgeflow.stageHunks(
|
||||
repository.localPath,
|
||||
ui.selectedFile,
|
||||
indexes,
|
||||
),
|
||||
"Selected hunks staged.",
|
||||
);
|
||||
if (result) {
|
||||
ui.modal = null;
|
||||
await loadDiff(selectedRepository(), ui.selectedFile);
|
||||
}
|
||||
} else if (action === "open-file-editor") {
|
||||
await window.forgeflow
|
||||
.openEditor(repository.localPath, ui.selectedFile)
|
||||
.catch((error) =>
|
||||
showToast("Could not open editor", error.message, "error"),
|
||||
);
|
||||
} else if (action === "open-editor") {
|
||||
await window.forgeflow
|
||||
.openEditor(repository.localPath)
|
||||
.catch((error) =>
|
||||
showToast("Could not open editor", error.message, "error"),
|
||||
);
|
||||
} else if (action === "open-terminal") {
|
||||
await window.forgeflow
|
||||
.openTerminal(repository.localPath)
|
||||
.catch((error) =>
|
||||
showToast("Could not open terminal", error.message, "error"),
|
||||
);
|
||||
} else if (action === "load-conflicts") {
|
||||
ui.conflictState = await window.forgeflow.conflictState(
|
||||
repository.localPath,
|
||||
);
|
||||
ui.modal = { type: "conflict-guide" };
|
||||
render();
|
||||
} else if (action === "resolve-conflict") {
|
||||
if (
|
||||
!ui.selectedFile ||
|
||||
!confirm(`Apply “${target.dataset.resolution}” to ${ui.selectedFile}?`)
|
||||
)
|
||||
return;
|
||||
ui.conflictState = await window.forgeflow.resolveConflict(
|
||||
repository.localPath,
|
||||
ui.selectedFile,
|
||||
target.dataset.resolution,
|
||||
);
|
||||
await refreshRepositories(false);
|
||||
ui.modal = { type: "conflict-guide" };
|
||||
render();
|
||||
} else if (action === "open-conflict-file") {
|
||||
await window.forgeflow.openEditor(
|
||||
repository.localPath,
|
||||
target.dataset.path,
|
||||
);
|
||||
} else if (action === "continue-git-operation") {
|
||||
ui.conflictState = await window.forgeflow.continueGitOperation(
|
||||
repository.localPath,
|
||||
);
|
||||
ui.modal = null;
|
||||
await refreshRepositories(false);
|
||||
showToast(
|
||||
"Git operation continued",
|
||||
"The repository operation completed.",
|
||||
"success",
|
||||
);
|
||||
} else if (action === "abort-git-operation") {
|
||||
if (
|
||||
!confirm(
|
||||
"Abort the active Git operation? Conflict-resolution work may be discarded.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
await window.forgeflow.abortGitOperation(repository.localPath);
|
||||
ui.modal = null;
|
||||
await refreshRepositories(false);
|
||||
} else if (action === "check-branch-protection") {
|
||||
ui.branchProtection = await window.forgeflow.branchProtection(
|
||||
repository.fullName,
|
||||
repository.localStatus.branch.head,
|
||||
);
|
||||
showToast(
|
||||
ui.branchProtection.protected
|
||||
? "Protected branch"
|
||||
: "Branch is not protected",
|
||||
ui.branchProtection.protected
|
||||
? `${ui.branchProtection.requiredApprovals} approval(s) required.`
|
||||
: "Direct pushes are permitted by the reported branch rule.",
|
||||
ui.branchProtection.protected ? "info" : "success",
|
||||
);
|
||||
render();
|
||||
} else if (action === "open-pull-request") {
|
||||
const subject =
|
||||
ui.history[0]?.subject || repository.localStatus.branch.head;
|
||||
ui.modal = {
|
||||
type: "pull-request",
|
||||
title: subject,
|
||||
body: `## Summary\n\nChanges from ${repository.localStatus.branch.head}.`,
|
||||
};
|
||||
render();
|
||||
} else if (action === "load-pull-requests") {
|
||||
try {
|
||||
ui.pullRequests = await window.forgeflow.pullRequests(
|
||||
repository.fullName,
|
||||
"open",
|
||||
);
|
||||
render();
|
||||
} catch (error) {
|
||||
showToast("Could not load pull requests", error.message, "error");
|
||||
}
|
||||
} else if (action === "open-pull-request-url") {
|
||||
if (target.dataset.url)
|
||||
await window.forgeflow.openExternal(target.dataset.url);
|
||||
} else if (action === "create-pull-request") {
|
||||
setLoading(true, "Creating pull request…");
|
||||
try {
|
||||
const pull = await window.forgeflow.createPullRequest(
|
||||
repository.fullName,
|
||||
document.querySelector("#pr-title").value,
|
||||
document.querySelector("#pr-body").value,
|
||||
document.querySelector("#pr-base").value,
|
||||
);
|
||||
ui.modal = null;
|
||||
ui.pullRequests = await window.forgeflow
|
||||
.pullRequests(repository.fullName, "open")
|
||||
.catch(() => ui.pullRequests);
|
||||
showToast("Pull request created", `#${pull.number}`, "success");
|
||||
if (pull.html_url) await window.forgeflow.openExternal(pull.html_url);
|
||||
} catch (error) {
|
||||
showToast("Could not create pull request", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
} else if (action === "copy-logs") {
|
||||
const text = (ui.activeDeployment?.logs || []).join("\n");
|
||||
await navigator.clipboard.writeText(text);
|
||||
showToast(
|
||||
"Copied",
|
||||
"Safe operation output copied. Open Gitea for raw runner logs.",
|
||||
"success",
|
||||
);
|
||||
} else if (action === "stage-selected") {
|
||||
if (!repository?.localPath || !ui.selectedFiles.size) return;
|
||||
await runOperation(
|
||||
"Staging selected files…",
|
||||
() =>
|
||||
window.forgeflow.stageFiles(repository.localPath, [
|
||||
...ui.selectedFiles,
|
||||
]),
|
||||
"Files staged.",
|
||||
);
|
||||
} else if (action === "unstage-selected") {
|
||||
if (!repository?.localPath || !ui.selectedFiles.size) return;
|
||||
await runOperation(
|
||||
"Unstaging selected files…",
|
||||
() =>
|
||||
window.forgeflow.unstageFiles(repository.localPath, [
|
||||
...ui.selectedFiles,
|
||||
]),
|
||||
"Files unstaged.",
|
||||
);
|
||||
} else if (action === "commit-push" || action === "commit-only") {
|
||||
if (
|
||||
!repository?.localPath ||
|
||||
!ui.commitMessage.trim() ||
|
||||
(!ui.selectedFiles.size && !repository.localStatus?.counts?.staged)
|
||||
)
|
||||
return;
|
||||
const selected = [...ui.selectedFiles];
|
||||
const result = await runOperation(
|
||||
action === "commit-push"
|
||||
? "Committing and pushing…"
|
||||
: "Creating local commit…",
|
||||
() =>
|
||||
action === "commit-push"
|
||||
? selected.length
|
||||
? window.forgeflow.commitAndPush(
|
||||
repository.localPath,
|
||||
ui.commitMessage,
|
||||
selected,
|
||||
)
|
||||
: window.forgeflow.commitStagedAndPush(
|
||||
repository.localPath,
|
||||
ui.commitMessage,
|
||||
)
|
||||
: selected.length
|
||||
? window.forgeflow.commit(
|
||||
repository.localPath,
|
||||
ui.commitMessage,
|
||||
selected,
|
||||
)
|
||||
: window.forgeflow.commitStaged(
|
||||
repository.localPath,
|
||||
ui.commitMessage,
|
||||
),
|
||||
action === "commit-push"
|
||||
? "Changes committed and pushed to Gitea."
|
||||
: "Local commit created.",
|
||||
);
|
||||
if (result) {
|
||||
ui.commitMessage = "";
|
||||
ui.selectedFiles.clear();
|
||||
ui.selectedFile = null;
|
||||
ui.diff = "";
|
||||
}
|
||||
} else if (action === "push")
|
||||
await runOperation(
|
||||
"Pushing local commits…",
|
||||
() => window.forgeflow.push(repository.localPath),
|
||||
"Push completed.",
|
||||
);
|
||||
else if (action === "fetch")
|
||||
await runOperation(
|
||||
"Fetching from Gitea…",
|
||||
() => window.forgeflow.fetch(repository.localPath),
|
||||
"Remote state refreshed.",
|
||||
);
|
||||
else if (action === "pull")
|
||||
await runOperation(
|
||||
"Synchronizing from Gitea…",
|
||||
() => window.forgeflow.pull(repository.localPath),
|
||||
"Local branch fast-forwarded.",
|
||||
);
|
||||
else if (action === "load-history") {
|
||||
setLoading(true, "Loading commit history…");
|
||||
try {
|
||||
ui.history = await window.forgeflow.history(repository.localPath, 50);
|
||||
} catch (error) {
|
||||
showToast("History unavailable", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
} else if (action === "load-git-tools") await loadGitTools(repository);
|
||||
else if (action === "create-branch") {
|
||||
const branch = document.querySelector("#new-branch-name")?.value.trim();
|
||||
if (branch)
|
||||
await runOperation(
|
||||
`Creating ${branch}…`,
|
||||
() => window.forgeflow.createBranch(repository.localPath, branch),
|
||||
`Switched to ${branch}.`,
|
||||
);
|
||||
await loadGitTools(selectedRepository());
|
||||
} else if (action === "checkout-branch") {
|
||||
await runOperation(
|
||||
`Switching to ${target.dataset.branch}…`,
|
||||
() =>
|
||||
window.forgeflow.checkoutBranch(
|
||||
repository.localPath,
|
||||
target.dataset.branch,
|
||||
),
|
||||
`Switched to ${target.dataset.branch}.`,
|
||||
);
|
||||
await loadGitTools(selectedRepository());
|
||||
} else if (action === "stash-changes") {
|
||||
const result = await runOperation(
|
||||
"Stashing local changes…",
|
||||
() =>
|
||||
window.forgeflow.stash(
|
||||
repository.localPath,
|
||||
`ForgeFlow ${new Date().toLocaleString()}`,
|
||||
),
|
||||
"Local changes stashed.",
|
||||
);
|
||||
if (result) ui.stashes = result.stashes;
|
||||
} else if (action === "pop-stash") {
|
||||
const result = await runOperation(
|
||||
`Applying ${target.dataset.stashRef}…`,
|
||||
() =>
|
||||
window.forgeflow.popStash(
|
||||
repository.localPath,
|
||||
target.dataset.stashRef,
|
||||
),
|
||||
"Stash applied.",
|
||||
);
|
||||
if (result) ui.stashes = result.stashes;
|
||||
} else if (action === "open-path")
|
||||
await window.forgeflow
|
||||
.openPath(repository.localPath)
|
||||
.catch((error) =>
|
||||
showToast("Could not open folder", error.message, "error"),
|
||||
);
|
||||
else if (action === "open-gitea")
|
||||
await window.forgeflow
|
||||
.openExternal(repository.htmlUrl)
|
||||
.catch((error) =>
|
||||
showToast("Could not open Gitea", error.message, "error"),
|
||||
);
|
||||
else if (action === "link-repo") {
|
||||
const localPath = await window.forgeflow.selectDirectory({
|
||||
title: `Link local folder for ${repository.name}`,
|
||||
});
|
||||
if (localPath) {
|
||||
ui.repositories =
|
||||
(await runOperation(
|
||||
"Linking local repository…",
|
||||
() => window.forgeflow.linkRepository(repository.fullName, localPath),
|
||||
"Local folder linked.",
|
||||
{ refresh: false },
|
||||
)) || ui.repositories;
|
||||
selectRepository(repository.id);
|
||||
}
|
||||
} else if (action === "unlink-repo") {
|
||||
ui.repositories =
|
||||
(await runOperation(
|
||||
"Removing local link…",
|
||||
() => window.forgeflow.unlinkRepository(repository.fullName),
|
||||
"Repository link removed.",
|
||||
{ refresh: false },
|
||||
)) || ui.repositories;
|
||||
selectRepository(repository.id);
|
||||
} else if (action === "clone-repo" || action === "clone-repo-custom") {
|
||||
const mode = action === "clone-repo-custom" ? "custom" : "default";
|
||||
const clone = await runOperation(
|
||||
mode === "custom"
|
||||
? "Choosing location and cloning repository…"
|
||||
: `Cloning ${repository.name} into the default project root…`,
|
||||
() => window.forgeflow.cloneRepository(repository.fullName, mode),
|
||||
null,
|
||||
{ refresh: false },
|
||||
);
|
||||
if (clone?.cancelled) return;
|
||||
if (clone?.target) {
|
||||
if (clone.state) ui.boot.state = clone.state;
|
||||
ui.repositories =
|
||||
clone.repositories || (await window.forgeflow.refreshRepositories());
|
||||
selectRepository(repository.id);
|
||||
showToast(
|
||||
clone.reused ? "Existing repository linked" : "Repository cloned",
|
||||
clone.target,
|
||||
"success",
|
||||
);
|
||||
}
|
||||
}
|
||||
else return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,737 @@
|
||||
const app = document.querySelector("#app");
|
||||
const toastRoot = document.querySelector("#toast-root");
|
||||
|
||||
const icons = {
|
||||
overview:
|
||||
'<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>',
|
||||
repository:
|
||||
'<path d="M6 3v12"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="6" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
|
||||
deploy: '<path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/>',
|
||||
settings:
|
||||
'<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.83 2.83-.06-.06a1.7 1.7 0 0 0-1.88-.34 1.7 1.7 0 0 0-1.03 1.56V21h-4v-.09A1.7 1.7 0 0 0 8.95 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06-2.83-2.83.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.56-1.03H3v-4h.09A1.7 1.7 0 0 0 4.6 8.95a1.7 1.7 0 0 0-.34-1.88l-.06-.06 2.83-2.83.06.06A1.7 1.7 0 0 0 8.95 4.6a1.7 1.7 0 0 0 1.03-1.56V3h4v.09A1.7 1.7 0 0 0 15.05 4.6a1.7 1.7 0 0 0 1.88-.34l.06-.06 2.83 2.83-.06.06A1.7 1.7 0 0 0 19.4 8.95a1.7 1.7 0 0 0 1.56 1.03H21v4h-.09A1.7 1.7 0 0 0 19.4 15Z"/>',
|
||||
search: '<circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/>',
|
||||
refresh: '<path d="M20 11a8 8 0 1 0-2.34 5.66"/><path d="M20 4v7h-7"/>',
|
||||
folder: '<path d="M3 7h6l2 2h10v10H3z"/><path d="M3 7V5h6l2 2"/>',
|
||||
git: '<circle cx="6" cy="5" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="6" cy="19" r="2"/><path d="M6 7v10M8 6h8a2 2 0 0 1 2 2v0a5 5 0 0 1-5 5H6"/>',
|
||||
branch:
|
||||
'<circle cx="6" cy="5" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="6" cy="19" r="2"/><path d="M6 7v10M8 6h8M18 8a7 7 0 0 1-7 7H6"/>',
|
||||
file: '<path d="M6 2h8l4 4v16H6z"/><path d="M14 2v5h5"/>',
|
||||
check: '<path d="m5 12 4 4L19 6"/>',
|
||||
warning: '<path d="M12 3 2 21h20L12 3Z"/><path d="M12 9v5M12 18h.01"/>',
|
||||
error: '<circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/>',
|
||||
arrowRight: '<path d="M5 12h14M14 7l5 5-5 5"/>',
|
||||
arrowUp: '<path d="M12 19V5m-5 5 5-5 5 5"/>',
|
||||
arrowDown: '<path d="M12 5v14m-5-5 5 5 5-5"/>',
|
||||
external:
|
||||
'<path d="M14 3h7v7M10 14 21 3"/><path d="M21 14v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h6"/>',
|
||||
play: '<path d="m8 5 11 7-11 7V5Z"/>',
|
||||
terminal: '<path d="m4 6 5 5-5 5M11 18h9"/>',
|
||||
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
||||
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.42 1.42M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.42-1.42M17.66 6.34l1.41-1.41"/>',
|
||||
moon: '<path d="M20 15.2A8 8 0 0 1 8.8 4 8.5 8.5 0 1 0 20 15.2Z"/>',
|
||||
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||
trash: '<path d="M4 7h16M9 7V4h6v3M7 7l1 14h8l1-14M10 11v6M14 11v6"/>',
|
||||
close: '<path d="m6 6 12 12M18 6 6 18"/>',
|
||||
copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/>',
|
||||
chevron: '<path d="m9 18 6-6-6-6"/>',
|
||||
link: '<path d="M10 13a5 5 0 0 0 7.07.07l2-2A5 5 0 0 0 12 4l-1.15 1.15"/><path d="M14 11a5 5 0 0 0-7.07-.07l-2 2A5 5 0 0 0 12 20l1.15-1.15"/>',
|
||||
cloud:
|
||||
'<path d="M17.5 19H6a4 4 0 0 1-.4-7.98A6.5 6.5 0 0 1 18 9.5h.5a4.75 4.75 0 0 1-1 9.5Z"/>',
|
||||
pulse: '<path d="M3 12h4l2-6 4 12 2-6h6"/>',
|
||||
history:
|
||||
'<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5M12 7v5l3 2"/>',
|
||||
more: '<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
|
||||
star: '<path d="m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2-5.6-2.9-5.6 2.9 1.1-6.2L3 9.6l6.2-.9L12 3Z"/>',
|
||||
archive: '<path d="M4 7h16v13H4zM3 3h18v4H3zM9 11h6"/>',
|
||||
shield:
|
||||
'<path d="M12 3 20 6v6c0 5-3.4 8-8 9-4.6-1-8-4-8-9V6l8-3Z"/><path d="m9 12 2 2 4-5"/>',
|
||||
rocket:
|
||||
'<path d="M14 5c2-2 5-2 6-2 0 1 0 4-2 6l-4 4-5-4 5-4Z"/><path d="m9 9-4 1-2 3 6 1M14 14l-1 6-3 2-1-6"/><path d="m5 19 3-3"/>',
|
||||
layers:
|
||||
'<path d="m12 3 9 5-9 5-9-5 9-5Z"/><path d="m3 12 9 5 9-5M3 16l9 5 9-5"/>',
|
||||
undo: '<path d="M9 7 4 12l5 5"/><path d="M4 12h9a7 7 0 0 1 7 7"/>',
|
||||
menu: '<path d="M4 6h16M4 12h16M4 18h16"/>',
|
||||
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"/>',
|
||||
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"/>',
|
||||
help:
|
||||
'<circle cx="12" cy="12" r="9"/><path d="M9.7 9a2.5 2.5 0 1 1 3.8 2.1c-.9.5-1.5 1.1-1.5 2.4M12 17.5h.01"/>',
|
||||
};
|
||||
|
||||
function icon(name, className = "") {
|
||||
return `<span class="icon ${className}" aria-hidden="true"><svg viewBox="0 0 24 24">${icons[name] || icons.file}</svg></span>`;
|
||||
}
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "").replace(
|
||||
/[&<>'"]/g,
|
||||
(character) =>
|
||||
({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ })[
|
||||
character
|
||||
],
|
||||
);
|
||||
}
|
||||
function attr(value) {
|
||||
return escapeHtml(value).replace(/`/g, "`");
|
||||
}
|
||||
function formatDate(value) {
|
||||
if (!value) return "Unknown";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return String(value);
|
||||
const diff = Date.now() - date.getTime();
|
||||
if (diff < 60_000) return "just now";
|
||||
if (diff < 3_600_000) return `${Math.max(1, Math.floor(diff / 60_000))}m ago`;
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
||||
if (diff < 604_800_000) return `${Math.floor(diff / 86_400_000)}d ago`;
|
||||
return date.toLocaleDateString(undefined, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year:
|
||||
date.getFullYear() !== new Date().getFullYear() ? "numeric" : undefined,
|
||||
});
|
||||
}
|
||||
function truncate(value, length = 76) {
|
||||
const text = String(value || "");
|
||||
return text.length > length ? `${text.slice(0, length - 1)}…` : text;
|
||||
}
|
||||
function shortSha(value) {
|
||||
return String(value || "").slice(0, 7) || "—";
|
||||
}
|
||||
function defaultWorkspaceRoot() {
|
||||
return ui.boot?.state?.workspaceRoots?.[0] || null;
|
||||
}
|
||||
function safeCloneFolderName(repository) {
|
||||
return (
|
||||
String(repository?.name || "repository")
|
||||
.replace(/\.git$/i, "")
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "-") || "repository"
|
||||
);
|
||||
}
|
||||
function displayCloneTarget(repository) {
|
||||
const root = defaultWorkspaceRoot();
|
||||
if (!root) return null;
|
||||
const separator = ui.boot?.platform === "win32" ? "\\" : "/";
|
||||
return `${String(root).replace(/[\\/]+$/, "")}${separator}${safeCloneFolderName(repository)}`;
|
||||
}
|
||||
function clonePrimaryLabel(repository) {
|
||||
return defaultWorkspaceRoot()
|
||||
? `Clone to ${safeCloneFolderName(repository)}`
|
||||
: "Choose project root & clone";
|
||||
}
|
||||
function isTerminalOperation(status) {
|
||||
return ["success", "failed", "cancelled", "rolled-back"].includes(status);
|
||||
}
|
||||
function toneForStatus(status) {
|
||||
if (["success", "healthy", "complete", "rolled-back"].includes(status))
|
||||
return "success";
|
||||
if (["failed", "failure", "unhealthy", "danger"].includes(status))
|
||||
return "danger";
|
||||
if (["queued", "running", "requested", "warning", "active"].includes(status))
|
||||
return "warning";
|
||||
return "";
|
||||
}
|
||||
|
||||
const ui = {
|
||||
boot: null,
|
||||
repositories: [],
|
||||
currentView: "overview",
|
||||
selectedRepoId: null,
|
||||
repositoryTab: "changes",
|
||||
selectedFile: null,
|
||||
selectedFiles: new Set(),
|
||||
selectedProfileId: null,
|
||||
diff: "",
|
||||
history: [],
|
||||
branches: [],
|
||||
stashes: [],
|
||||
search: "",
|
||||
repoSearch: "",
|
||||
commitMessage: "",
|
||||
loading: false,
|
||||
loadingMessage: "",
|
||||
modal: null,
|
||||
setupStep: 0,
|
||||
systemPreflight: null,
|
||||
deploymentPreflight: null,
|
||||
serverGitVerifications: {},
|
||||
deployKeyLifecycle: null,
|
||||
inventoryReviewPlan: null,
|
||||
diagnosticsStatus: null,
|
||||
troubleshooter: null,
|
||||
deploymentDiscovery: null,
|
||||
serverDiscovery: [],
|
||||
lastDiagnosticBundle: null,
|
||||
setupDraft: {
|
||||
baseUrl: "https://",
|
||||
token: "",
|
||||
user: null,
|
||||
roots: [],
|
||||
discovered: [],
|
||||
},
|
||||
setupValidation: null,
|
||||
activeDeployment: null,
|
||||
operationPollTimer: null,
|
||||
inputRenderTimer: null,
|
||||
isMock: false,
|
||||
refreshError: null,
|
||||
refreshWarning: null,
|
||||
autoRefreshPending: false,
|
||||
repositoryRefreshPromise: null,
|
||||
repositoryRefreshRequest: null,
|
||||
deploymentTruthPromise: null,
|
||||
deploymentTruthRequest: null,
|
||||
paletteQuery: "",
|
||||
helpQuery: "",
|
||||
helpTopic: "getting-started",
|
||||
updateStatus: null,
|
||||
updateChecking: false,
|
||||
servers: [],
|
||||
serverInspection: null,
|
||||
gitRecovery: null,
|
||||
workspaceSyncPlan: null,
|
||||
gitValidation: null,
|
||||
diffHunks: null,
|
||||
conflictState: null,
|
||||
branchProtection: null,
|
||||
pullRequests: [],
|
||||
auditEvents: [],
|
||||
};
|
||||
|
||||
function scheduleInputRender(delay = 120) {
|
||||
if (ui.inputRenderTimer) clearTimeout(ui.inputRenderTimer);
|
||||
ui.inputRenderTimer = setTimeout(() => {
|
||||
ui.inputRenderTimer = null;
|
||||
render();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function selectedRepository() {
|
||||
return (
|
||||
ui.repositories.find(
|
||||
(repository) => String(repository.id) === String(ui.selectedRepoId),
|
||||
) || null
|
||||
);
|
||||
}
|
||||
function selectedProfile(repository = selectedRepository()) {
|
||||
if (!repository?.deploymentProfiles?.length) return null;
|
||||
return (
|
||||
repository.deploymentProfiles.find(
|
||||
(profile) => profile.id === ui.selectedProfileId,
|
||||
) ||
|
||||
repository.deploymentProfiles.find(
|
||||
(profile) => profile.branch === repository.localStatus?.branch.head,
|
||||
) ||
|
||||
repository.deploymentProfiles[0]
|
||||
);
|
||||
}
|
||||
|
||||
function canDirectPushDeploy(repository, profile = selectedProfile(repository)) {
|
||||
const status = repository?.localStatus;
|
||||
const mode = deploymentMode(profile);
|
||||
return Boolean(
|
||||
repository?.localPath
|
||||
&& status?.head
|
||||
&& !status?.counts?.changed
|
||||
&& !status?.counts?.conflicts
|
||||
&& profile
|
||||
&& mode === "push-bundle"
|
||||
&& profile.branch === status.branch?.head,
|
||||
);
|
||||
}
|
||||
|
||||
function deploymentMode(profile) {
|
||||
if (profile?.provider !== "ssh-unraid") return "gitea-actions";
|
||||
return ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode)
|
||||
? profile.deploymentMode
|
||||
: "push-bundle";
|
||||
}
|
||||
|
||||
function deploymentTargetSha(repository, profile = selectedProfile(repository)) {
|
||||
return deploymentMode(profile) === "server-git"
|
||||
? profile?.state?.giteaSha || null
|
||||
: repository?.localStatus?.head || null;
|
||||
}
|
||||
|
||||
function canServerGitDeploy(repository, profile = selectedProfile(repository)) {
|
||||
const target = deploymentTargetSha(repository, profile);
|
||||
return Boolean(
|
||||
profile
|
||||
&& deploymentMode(profile) === "server-git"
|
||||
&& target
|
||||
&& profile.branch
|
||||
&& !(profile.state?.liveSha === target && profile.state?.healthy !== false),
|
||||
);
|
||||
}
|
||||
|
||||
function canDeploy(repository, profile = selectedProfile(repository)) {
|
||||
const mode = deploymentMode(profile);
|
||||
if (mode === "server-git") return canServerGitDeploy(repository, profile);
|
||||
if (mode === "push-bundle") return canDirectPushDeploy(repository, profile);
|
||||
return profile?.provider === "gitea-actions" && repository?.readyToDeploy;
|
||||
}
|
||||
function operations() {
|
||||
return ui.boot?.state?.operations || [];
|
||||
}
|
||||
function repositoryOperations(repository) {
|
||||
return operations().filter(
|
||||
(operation) => operation.repository === repository?.fullName,
|
||||
);
|
||||
}
|
||||
|
||||
function applyTheme(appearance) {
|
||||
const resolved =
|
||||
appearance === "system"
|
||||
? matchMedia("(prefers-color-scheme: light)").matches
|
||||
? "light"
|
||||
: "dark"
|
||||
: appearance;
|
||||
document.documentElement.dataset.theme = resolved || "dark";
|
||||
}
|
||||
|
||||
function showToast(title, message, type = "info") {
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast ${type}`;
|
||||
toast.innerHTML = `${icon(type === "error" ? "error" : type === "success" ? "check" : "warning")}<div><strong>${escapeHtml(title)}</strong><span>${escapeHtml(message)}</span></div>`;
|
||||
toastRoot.append(toast);
|
||||
setTimeout(() => toast.remove(), 5600);
|
||||
}
|
||||
|
||||
function isSshCredentialError(error) {
|
||||
const code = String(error?.code || "");
|
||||
const message = String(error?.message || "");
|
||||
return ["SSH_PRIVATE_KEY_READ_FAILED", "SSH_PRIVATE_KEY_NOT_FOUND", "SSH_CONNECTION_FAILED"].includes(code)
|
||||
|| /private key|publickey|authentication methods failed|permission denied|authentication failed/i.test(message);
|
||||
}
|
||||
|
||||
function setLoading(loading, message = "") {
|
||||
ui.loading = loading;
|
||||
ui.loadingMessage = message;
|
||||
render();
|
||||
}
|
||||
function updateOperationInState(operation) {
|
||||
if (!operation || !ui.boot) return;
|
||||
const list = operations();
|
||||
ui.boot.state.operations = [
|
||||
operation,
|
||||
...list.filter((item) => item.id !== operation.id),
|
||||
].slice(0, 250);
|
||||
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();
|
||||
ui.isMock = String(ui.boot.appVersion).includes("demo");
|
||||
ui.diagnosticsStatus = ui.boot.diagnostics || null;
|
||||
applyTheme(ui.boot.state.appearance);
|
||||
ui.setupDraft.roots = [...(ui.boot.state.workspaceRoots || [])];
|
||||
if (ui.boot.state.setupComplete) {
|
||||
await refreshRepositories(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) => {
|
||||
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) => {
|
||||
ui.updateStatus = payload;
|
||||
render();
|
||||
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>`;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAutoRefresh(delay = 450) {
|
||||
if (
|
||||
ui.loading ||
|
||||
ui.autoRefreshPending ||
|
||||
!ui.boot?.state?.preferences?.autoRefresh
|
||||
)
|
||||
return;
|
||||
ui.autoRefreshPending = true;
|
||||
setTimeout(async () => {
|
||||
ui.autoRefreshPending = false;
|
||||
await refreshRepositories(false, true);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
async function refreshRepositories(withLoader = true, silent = false) {
|
||||
ui.repositoryRefreshRequest = {
|
||||
withLoader: ui.repositoryRefreshRequest?.withLoader === true || withLoader,
|
||||
silent: ui.repositoryRefreshRequest ? ui.repositoryRefreshRequest.silent && silent : silent,
|
||||
};
|
||||
if (ui.repositoryRefreshPromise) return ui.repositoryRefreshPromise;
|
||||
ui.repositoryRefreshPromise = (async () => {
|
||||
let result;
|
||||
while (ui.repositoryRefreshRequest) {
|
||||
const request = ui.repositoryRefreshRequest;
|
||||
ui.repositoryRefreshRequest = null;
|
||||
result = await performRepositoryRefresh(request.withLoader, request.silent);
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
try {
|
||||
return await ui.repositoryRefreshPromise;
|
||||
} finally {
|
||||
ui.repositoryRefreshPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function performRepositoryRefresh(withLoader = true, silent = false) {
|
||||
if (withLoader) setLoading(true, "Refreshing Local → Gitea → Server state…");
|
||||
try {
|
||||
const selectedId = ui.selectedRepoId;
|
||||
ui.repositories = await window.forgeflow.refreshRepositories({ force: withLoader });
|
||||
ui.refreshError = null;
|
||||
const staleRepository = ui.repositories.find(
|
||||
(repository) => repository.remoteStale,
|
||||
);
|
||||
ui.refreshWarning = staleRepository
|
||||
? `Gitea could not be reached. Showing repository data last refreshed ${formatDate(staleRepository.remoteLastRefreshedAt)} while local and server state continue to refresh.`
|
||||
: null;
|
||||
if (selectedId && !selectedRepository()) ui.selectedRepoId = null;
|
||||
const repository = selectedRepository();
|
||||
if (
|
||||
repository &&
|
||||
!repository.deploymentProfiles.some(
|
||||
(profile) => profile.id === ui.selectedProfileId,
|
||||
)
|
||||
)
|
||||
ui.selectedProfileId = selectedProfile(repository)?.id || null;
|
||||
if (repository) {
|
||||
const availablePaths = new Set(
|
||||
(repository.localStatus?.files || []).map((file) => file.path),
|
||||
);
|
||||
ui.selectedFiles = new Set(
|
||||
[...ui.selectedFiles].filter((filePath) =>
|
||||
availablePaths.has(filePath),
|
||||
),
|
||||
);
|
||||
if (ui.selectedFile && !availablePaths.has(ui.selectedFile)) {
|
||||
ui.selectedFile = repository.localStatus?.files?.[0]?.path || null;
|
||||
ui.diff = "";
|
||||
}
|
||||
}
|
||||
if (
|
||||
!ui.selectedRepoId &&
|
||||
ui.currentView === "repository" &&
|
||||
ui.repositories.length
|
||||
)
|
||||
selectRepository(ui.repositories[0].id, false);
|
||||
} catch (error) {
|
||||
ui.refreshError = error.message;
|
||||
ui.refreshWarning = null;
|
||||
if (!silent) showToast("Refresh failed", error.message, "error");
|
||||
} finally {
|
||||
if (withLoader) setLoading(false);
|
||||
else render();
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshActiveOperations(showErrors = true) {
|
||||
try {
|
||||
const updated = await window.forgeflow.refreshOperations();
|
||||
for (const operation of Array.isArray(updated) ? updated : [])
|
||||
updateOperationInState(operation);
|
||||
return updated;
|
||||
} catch (error) {
|
||||
if (showErrors)
|
||||
showToast("Deployment status unavailable", error.message, "error");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDeploymentTruth(showErrors = false) {
|
||||
ui.deploymentTruthRequest = { showErrors: ui.deploymentTruthRequest?.showErrors === true || showErrors };
|
||||
if (ui.deploymentTruthPromise) return ui.deploymentTruthPromise;
|
||||
ui.deploymentTruthPromise = (async () => {
|
||||
let result;
|
||||
while (ui.deploymentTruthRequest) {
|
||||
const request = ui.deploymentTruthRequest;
|
||||
ui.deploymentTruthRequest = null;
|
||||
result = await performDeploymentTruthRefresh(request.showErrors);
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
try {
|
||||
return await ui.deploymentTruthPromise;
|
||||
} finally {
|
||||
ui.deploymentTruthPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function performDeploymentTruthRefresh(showErrors = false) {
|
||||
let discovery = [];
|
||||
try {
|
||||
discovery = (await window.forgeflow.discoverServerDeployments?.()) || [];
|
||||
ui.serverDiscovery = discovery;
|
||||
const adopted = discovery.reduce(
|
||||
(total, server) => total + Number(server.adopted || 0),
|
||||
0,
|
||||
);
|
||||
if (adopted > 0) {
|
||||
await refreshRepositories(false, true);
|
||||
showToast(
|
||||
"Server workloads discovered",
|
||||
`${adopted} workload${adopted === 1 ? " was" : "s were"} linked automatically from exact repository provenance.`,
|
||||
"success",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (showErrors)
|
||||
showToast("Server discovery unavailable", error.message, "error");
|
||||
}
|
||||
const targets = ui.repositories.flatMap((repository) =>
|
||||
(repository.deploymentProfiles || []).map((profile) => ({
|
||||
repository,
|
||||
profile,
|
||||
})),
|
||||
);
|
||||
if (!targets.length) return { checked: 0, failed: 0, discovery };
|
||||
|
||||
const inventoryRefreshedProfiles = new Set(discovery.flatMap((server) => server.refreshedProfileIds || []));
|
||||
const pendingTargets = targets.filter(({ profile }) => !inventoryRefreshedProfiles.has(profile.id));
|
||||
|
||||
const failures = [];
|
||||
const queue = [...pendingTargets];
|
||||
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, reusedInventory: targets.length - pendingTargets.length, failed: failures.length, discovery };
|
||||
}
|
||||
|
||||
function selectRepository(id, shouldRender = true) {
|
||||
ui.selectedRepoId = id;
|
||||
ui.currentView = "repository";
|
||||
ui.repositoryTab = "changes";
|
||||
ui.commitMessage = "";
|
||||
ui.history = [];
|
||||
ui.branches = [];
|
||||
ui.stashes = [];
|
||||
ui.gitRecovery = null;
|
||||
ui.workspaceSyncPlan = null;
|
||||
ui.gitValidation = null;
|
||||
ui.branchProtection = null;
|
||||
const repository = selectedRepository();
|
||||
ui.selectedProfileId = selectedProfile(repository)?.id || null;
|
||||
const files = repository?.localStatus?.files || [];
|
||||
ui.selectedFiles = new Set(files.map((file) => file.path));
|
||||
ui.selectedFile = files[0]?.path || null;
|
||||
ui.diff = "";
|
||||
if (ui.selectedFile && repository?.localPath)
|
||||
loadDiff(repository, ui.selectedFile);
|
||||
if (repository?.owner?.login && repository?.localStatus?.branch?.head) {
|
||||
window.forgeflow
|
||||
.branchProtection(repository.fullName, repository.localStatus.branch.head)
|
||||
.then((protection) => {
|
||||
if (String(ui.selectedRepoId) === String(id)) {
|
||||
ui.branchProtection = protection;
|
||||
render();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
if (shouldRender) render();
|
||||
}
|
||||
|
||||
async function loadDiff(repository, filePath) {
|
||||
ui.diff = "Loading diff…";
|
||||
render();
|
||||
try {
|
||||
const file = repository.localStatus?.files.find(
|
||||
(item) => item.path === filePath,
|
||||
);
|
||||
ui.diff = await window.forgeflow.repositoryDiff(
|
||||
repository.localPath,
|
||||
filePath,
|
||||
Boolean(file?.staged && !file?.unstaged),
|
||||
);
|
||||
ui.diffHunks = file?.unstaged
|
||||
? await window.forgeflow
|
||||
.repositoryDiffHunks(repository.localPath, filePath)
|
||||
.catch(() => null)
|
||||
: null;
|
||||
} catch (error) {
|
||||
ui.diff = `Unable to load diff: ${error.message}`;
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function repositoryAction(repository) {
|
||||
if (!repository.localPath)
|
||||
return {
|
||||
kind: "link",
|
||||
title: "Connect this repository",
|
||||
detail: "Link an existing local folder or clone it from Gitea.",
|
||||
};
|
||||
const status = repository.localStatus;
|
||||
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 deployment.`,
|
||||
};
|
||||
if (status.counts.changed)
|
||||
return {
|
||||
kind: "commit",
|
||||
title: "Commit local changes",
|
||||
detail: `${status.counts.changed} changed file${status.counts.changed === 1 ? "" : "s"} detected.`,
|
||||
};
|
||||
if (!repository.deploymentProfiles?.length)
|
||||
return {
|
||||
kind: "configure",
|
||||
title: "Configure deployment",
|
||||
detail: "Connect an Unraid server or a Gitea Actions workflow before deploying.",
|
||||
};
|
||||
const profile = selectedProfile(repository);
|
||||
if (profile?.branch !== status.branch.head)
|
||||
return {
|
||||
kind: "branch-profile",
|
||||
title: "No deployment for this branch",
|
||||
detail: `The selected profile accepts ${profile.branch}; you are on ${status.branch.head}.`,
|
||||
};
|
||||
if (canDirectPushDeploy(repository, profile))
|
||||
return {
|
||||
kind: "deploy",
|
||||
title: "Ready for direct redeploy",
|
||||
detail: `ForgeFlow will copy committed HEAD ${status.shortHead} directly to ${profile.environment} over the configured desktop → Unraid connection.`,
|
||||
};
|
||||
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.`,
|
||||
};
|
||||
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.readyToDeploy)
|
||||
return {
|
||||
kind: "deploy",
|
||||
title: "Ready for deployment",
|
||||
detail: `Commit ${status.shortHead} can be released to ${profile.environment}.`,
|
||||
};
|
||||
return {
|
||||
kind: "clean",
|
||||
title: "Repository synchronized",
|
||||
detail: "No local or remote action is required.",
|
||||
};
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
@@ -0,0 +1,434 @@
|
||||
// These three sections used to be pushed into the DOM after render() had already
|
||||
// written the shell. Keeping them in the markup makes the rendered output the
|
||||
// single source of truth, so an unchanged render can be skipped safely.
|
||||
function renderDeploymentPolicyFields(policy) {
|
||||
const windows = (policy.maintenanceWindows || [])
|
||||
.map((window) => `${window.days.join(",")}:${window.start}-${window.end}`)
|
||||
.join(" | ");
|
||||
return `<div class="field full"><h3>Deployment policy</h3></div><label class="check-field"><input id="profile-policy-frozen" type="checkbox" ${policy.frozen ? "checked" : ""}/><span>Freeze deployments</span></label><label class="check-field"><input id="profile-policy-note" type="checkbox" ${policy.requireNote ? "checked" : ""}/><span>Require release note</span></label><div class="field full"><label>Freeze reason</label><input id="profile-policy-freeze-reason" class="input" value="${attr(policy.freezeReason || "")}"/></div><div class="field full"><label>Maintenance windows</label><input id="profile-policy-windows" class="input" value="${attr(windows)}" placeholder="1,2,3,4,5:09:00-17:00"/><small>Day 0 is Sunday. Separate windows with |.</small></div>`;
|
||||
}
|
||||
|
||||
function renderReleaseNoteFields(profile) {
|
||||
return `<div class="form-grid" style="margin-top:14px"><div class="field full"><label>Release note ${profile?.deploymentPolicy?.requireNote ? "(required)" : "(optional)"}</label><textarea id="deployment-note" class="textarea" placeholder="What is being released and why?"></textarea></div><label class="check-field"><input id="deployment-override" type="checkbox"/><span>Emergency policy override</span></label><div class="field"><label>Override reason</label><input id="deployment-override-reason" class="input" placeholder="Required when overriding"/></div></div>`;
|
||||
}
|
||||
|
||||
function renderWorkloadClassificationFields(workload) {
|
||||
const type = workload?.classification?.type || "ambiguous";
|
||||
const recommended = type === "duplicate" ? "select-authoritative" : type === "stale-link" ? "archive-link" : type === "historical-compose" ? "mark-historical" : type === "orphan-container" ? "monitor-only" : "manual-link";
|
||||
const actions = [["manual-link", "Confirm selected repository match"], ["select-authoritative", "Select as authoritative instance"], ["mark-historical", "Mark historical definition"], ["archive-link", "Archive stale link"], ["monitor-only", "Keep for monitoring only"], ["manual-exclude", "Exclude this workload"], ["ignore", "Ignore with reason"]];
|
||||
const options = actions.map(([value, label]) => `<option value="${value}" ${value === recommended ? "selected" : ""}>${escapeHtml(label)}${value === recommended ? " · recommended" : ""}</option>`).join("");
|
||||
return `<section class="settings-group" style="margin-top:14px"><h3>Classify without touching containers</h3><div class="notice" style="margin-bottom:10px">${icon("info")}<div><strong>${escapeHtml(type)}</strong><p>${escapeHtml(workload?.classification?.reason || "ForgeFlow needs an explicit decision for this workload.")}</p></div></div><div class="form-grid"><div class="field"><label for="inventory-review-action">Review decision</label><select id="inventory-review-action" class="select">${options}</select></div><div class="field"><label for="inventory-review-reason">Reason</label><input id="inventory-review-reason" class="input" placeholder="Why is this the correct classification?"/></div></div><button class="button" style="margin-top:10px" data-action="preview-inventory-review" data-server-id="${attr(ui.modal.serverId)}" data-workload-id="${attr(ui.modal.workloadId)}">${icon("shield")}Preview classification impact</button><p class="meta">The decision is tied to current evidence and becomes stale automatically when server truth changes.</p></section>`;
|
||||
}
|
||||
|
||||
function renderModal() {
|
||||
if (!ui.modal) return "";
|
||||
const repository =
|
||||
selectedRepository() ||
|
||||
ui.repositories.find(
|
||||
(repo) => repo.fullName === ui.modal.repositoryFullName,
|
||||
);
|
||||
if (ui.modal.type === "server-password") {
|
||||
const server = (ui.boot?.state?.servers || []).find((item) => item.id === ui.modal.serverId);
|
||||
if (!server) return `<div class="modal-backdrop"><section class="modal"><header class="modal-header"><h2>Server password</h2></header><div class="modal-body"><div class="notice danger">${icon("error")}The selected server no longer exists.</div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Close</button></footer></section></div>`;
|
||||
const retryText = ui.modal.retry?.type === "deploy" ? "Save password & redeploy" : "Save password & rescan";
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Use password for ${escapeHtml(server.name)}</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="notice success">${icon("shield")}ForgeFlow stores the server password with Windows protected storage and uses it only for the desktop → Unraid connection.</div><div class="field" style="margin-top:14px"><label>SSH password for ${escapeHtml(server.username)}@${escapeHtml(server.host)}</label><input id="quick-server-password" class="input" type="password" autocomplete="current-password" autofocus placeholder="Server password"/></div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="confirm-server-password" data-server-id="${attr(server.id)}">${escapeHtml(retryText)}</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === "server-reconciliation-plan") {
|
||||
const plan = ui.modal.result?.plan || {};
|
||||
const summary = plan.summary || {};
|
||||
const rows = [
|
||||
...(plan.additions || []).map((item) => ({ tone: "success", title: `Link ${item.repositoryFullName}`, detail: `${item.evidence} · ${item.impact}` })),
|
||||
...(plan.updates || []).map((item) => ({ tone: "", title: `Refresh ${item.repositoryFullName}`, detail: item.impact })),
|
||||
...(plan.stale || []).map((item) => ({ tone: "warning", title: `Review stale link ${item.repositoryFullName}`, detail: `${item.reason} · no automatic removal` })),
|
||||
...(plan.conflicts || []).map((item) => ({ tone: "danger", title: `Manual review: ${item.displayName}`, detail: `${item.status} · ${(item.candidates || []).map((candidate) => candidate.repositoryFullName).join(", ") || "no unique repository"}` })),
|
||||
];
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true" aria-labelledby="reconciliation-title"><header class="modal-header"><h2 id="reconciliation-title">Review server reconciliation</h2><button class="icon-button" data-action="close-modal" aria-label="Close reconciliation preview">${icon("close")}</button></header><div class="modal-body"><div class="notice success">${icon("shield")}This reviewed plan may update ForgeFlow configuration only. It never starts, stops or recreates containers, and stale profiles are never removed automatically.</div><div class="summary-grid" style="margin-top:12px"><div class="summary-card"><span>New links</span><strong>${Number(summary.additions || 0)}</strong></div><div class="summary-card"><span>Refreshes</span><strong>${Number(summary.updates || 0)}</strong></div><div class="summary-card"><span>Stale reviews</span><strong>${Number(summary.stale || 0)}</strong></div><div class="summary-card"><span>Conflicts</span><strong>${Number(summary.conflicts || 0)}</strong></div></div><div class="tool-list" style="margin-top:14px">${rows.length ? rows.map((item) => `<div class="tool-row"><div><strong>${escapeHtml(item.title)}</strong><span>${escapeHtml(item.detail)}</span></div>${item.tone ? `<span class="status-pill ${item.tone}">${escapeHtml(item.tone === "success" ? "Planned" : item.tone === "warning" ? "Review" : "Blocked")}</span>` : ""}</div>`).join("") : '<div class="empty-state compact"><p>No configuration changes are proposed.</p></div>'}</div><div class="notice" style="margin-top:12px">${icon("archive")}A private recovery snapshot is written before the plan is applied. Plan ID: <span class="mono">${escapeHtml(String(plan.id || "").slice(0, 12))}</span></div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="apply-server-reconciliation" data-server-id="${attr(plan.serverId || "")}" data-plan-id="${attr(plan.id || "")}" ${summary.conflicts ? "disabled title=\"Resolve ambiguous workloads manually before applying reconciliation\"" : ""}>Apply reviewed plan</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === "workspace-sync") {
|
||||
const plan = ui.workspaceSyncPlan;
|
||||
if (!plan) return "";
|
||||
const summary = plan.summary || {};
|
||||
const blocked = Boolean(plan.blockers?.length);
|
||||
const changeRows = (plan.changes || []).map((change) => `<div class="tool-row"><div><strong>${escapeHtml(change.path)}</strong><span>${change.originalPath ? `${escapeHtml(change.originalPath)} → ` : ""}${escapeHtml(change.status)}</span></div><span class="status-pill ${change.code === "D" ? "danger" : change.code === "A" ? "success" : "warning"}">${escapeHtml(change.code)}</span></div>`).join("");
|
||||
const recoveryRows = [
|
||||
plan.recovery?.safetyBranch ? "Local commits → recovery branch" : "No local commits require a recovery branch",
|
||||
plan.recovery?.stash ? "Modified and untracked files → named Git stash" : "No working-tree files require a stash",
|
||||
plan.recovery?.untrackedCleanup ? "Untracked files are removed after they are stashed" : "No untracked cleanup required",
|
||||
"Ignored runtime files remain in place",
|
||||
];
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true" aria-labelledby="workspace-sync-title"><header class="modal-header"><h2 id="workspace-sync-title">Review Gitea workspace sync</h2><button class="icon-button" data-action="close-modal" aria-label="Close workspace sync preview">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero ${blocked ? "danger" : plan.needsSync ? "" : "success"}">${icon(blocked ? "error" : "shield")}<div><strong>${blocked ? "Synchronization is blocked" : plan.needsSync ? `${escapeHtml(plan.branch)} will match ${escapeHtml(plan.upstream)}` : "Workspace already matches Gitea"}</strong><span>${shortSha(plan.currentSha)} → ${shortSha(plan.targetSha)} · reviewed plan ${escapeHtml(plan.id.slice(0, 12))}</span></div></div>${blocked ? `<div class="notice danger" style="margin-top:12px">${icon("error")}<div><strong>Resolve before applying</strong><p>${escapeHtml(plan.blockers.join(" "))}</p></div></div>` : ""}<div class="summary-grid" style="margin-top:12px"><div class="summary-card"><span>Incoming commits</span><strong>${Number(summary.incomingCommits || 0)}</strong></div><div class="summary-card"><span>Tracked file changes</span><strong>${Number(summary.resultingTrackedChanges || 0)}</strong></div><div class="summary-card"><span>Files removed by sync</span><strong>${Number(summary.deleted || 0)}</strong></div><div class="summary-card"><span>Local files protected</span><strong>${Number(summary.localFilesToStash || 0)}</strong></div><div class="summary-card"><span>Local commits protected</span><strong>${Number(summary.localCommitsToProtect || 0)}</strong></div></div><section class="settings-group" style="margin-top:14px"><h3>Recovery contract</h3><ul>${recoveryRows.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul><div class="notice success">${icon("archive")}ForgeFlow never reapplies saved local work automatically. You can review the recovery branch or stash later, file by file.</div></section><section class="settings-group"><div class="section-heading"><div><h3>Resulting tracked changes</h3><span class="meta">${summary.added || 0} added · ${summary.modified || 0} modified · ${summary.deleted || 0} deleted · ${summary.renamed || 0} renamed</span></div></div><div class="tool-list">${changeRows || '<div class="empty-state compact"><p>No tracked file changes between local HEAD and Gitea.</p></div>'}</div>${plan.changesTruncated ? '<p class="meta">Only the first 250 paths are shown. Counts include the complete plan.</p>' : ""}</section></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="confirm-workspace-sync" data-plan-id="${attr(plan.id)}" ${blocked || !plan.needsSync ? "disabled" : ""}>Protect local work & synchronize</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === "workload-link") {
|
||||
const serverResult = (ui.serverDiscovery || []).find(
|
||||
(item) => item.serverId === ui.modal.serverId,
|
||||
);
|
||||
const workload = serverResult?.workloads?.find(
|
||||
(item) => item.workloadId === ui.modal.workloadId,
|
||||
);
|
||||
if (!workload) {
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Link server workload</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="notice danger">${icon("error")}This workload is no longer present in the latest server inventory. Scan the servers again.</div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Close</button></footer></section></div>`;
|
||||
}
|
||||
const availableRepositories = ui.repositories.filter((item) => item.fullName);
|
||||
const suggestedRepository =
|
||||
ui.modal.repositoryFullName ||
|
||||
workload.candidates?.[0]?.repositoryFullName ||
|
||||
selectedRepository()?.fullName ||
|
||||
availableRepositories[0]?.fullName ||
|
||||
"";
|
||||
const selectedLinkRepository = availableRepositories.find(
|
||||
(item) => item.fullName === suggestedRepository,
|
||||
);
|
||||
const remoteFolder =
|
||||
ui.modal.remoteFolder ||
|
||||
workload.remoteFolderCandidate ||
|
||||
safeCloneFolderName(selectedLinkRepository);
|
||||
const candidateSummary = workload.candidates?.length
|
||||
? workload.candidates
|
||||
.slice(0, 4)
|
||||
.map(
|
||||
(candidate) =>
|
||||
`<div class="context-row"><span>${escapeHtml(candidate.repositoryFullName)}</span><strong>${escapeHtml(candidate.exact ? "Exact provenance" : `${candidate.score} confidence`)} · ${escapeHtml((candidate.reasons || []).join(", ") || "name similarity")}</strong></div>`,
|
||||
)
|
||||
.join("")
|
||||
: '<div class="context-row"><span>Repository candidates</span><strong>No confident match; choose manually.</strong></div>';
|
||||
const containerNames = (workload.containers || [])
|
||||
.map((container) => container.name)
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Link existing server workload</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero">${icon("link")}<div><strong>${escapeHtml(workload.displayName)}</strong><span>${escapeHtml(serverResult?.serverName || serverResult?.server?.name || ui.modal.serverId)} · ${workload.runtime?.running ? "running" : "stopped"}</span></div></div><div class="context-summary"><div class="context-row"><span>Containers</span><strong>${escapeHtml(containerNames || "Unknown")}</strong></div><div class="context-row"><span>Compose identity</span><strong>${escapeHtml(workload.compose?.project || "DockerMan / standalone container")} ${workload.compose?.services?.length ? `· ${escapeHtml(workload.compose.services.join(", "))}` : ""}</strong></div><div class="context-row"><span>Detected folder</span><strong class="mono">${escapeHtml(workload.compose?.workingDir || workload.dockerMan?.templatePath || "No Git checkout required")}</strong></div>${candidateSummary}</div><div class="form-grid" style="margin-top:14px"><div class="field full"><label>Repository to link</label><select id="workload-repository" class="select">${availableRepositories.map((item) => `<option value="${attr(item.fullName)}" ${item.fullName === suggestedRepository ? "selected" : ""}>${escapeHtml(item.fullName)}</option>`).join("") || '<option value="">No repositories available</option>'}</select></div><div class="field"><label>Deployment source</label><select id="workload-deployment-mode" class="select"><option value="server-git" selected>Server pull from Gitea</option><option value="push-bundle">Direct copy fallback</option><option value="monitor-only">Monitor only</option></select></div><div class="field"><label>Detected deployment folder</label><input id="workload-remote-folder" class="input" value="${attr(remoteFolder)}" readonly/></div></div><div class="notice success" style="margin-top:12px">${icon("shield")}ForgeFlow preserves the detected Compose project, services and container identity. Server pull provisions a repository-scoped read-only key and activates only the selected Gitea commit.</div>${renderWorkloadClassificationFields(workload)}</div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="confirm-link-server-workload" data-server-id="${attr(ui.modal.serverId)}" data-workload-id="${attr(ui.modal.workloadId)}" ${availableRepositories.length ? "" : "disabled"}>Link workload</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === "deployment-config") {
|
||||
const storedProfile =
|
||||
repository?.deploymentProfiles?.find(
|
||||
(profile) => profile.id === ui.modal.profileId,
|
||||
) || {};
|
||||
const discovery =
|
||||
ui.deploymentDiscovery?.repository === repository?.fullName
|
||||
? ui.deploymentDiscovery
|
||||
: null;
|
||||
const existing = { ...storedProfile, ...(discovery?.profile || {}) };
|
||||
if (discovery?.provenance) existing.provenance = discovery.provenance;
|
||||
const servers = ui.boot.state.servers || [];
|
||||
const provider =
|
||||
ui.modal.provider ||
|
||||
existing.provider ||
|
||||
(servers.length ? "ssh-unraid" : "gitea-actions");
|
||||
const ssh = provider === "ssh-unraid";
|
||||
const remoteFolder =
|
||||
existing.remoteFolder || safeCloneFolderName(repository);
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>${existing.id ? "Edit" : "Add"} deployment environment</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="form-grid"><div class="field full"><label>Deployment provider</label><select id="profile-provider" class="select"><option value="ssh-unraid" ${ssh ? "selected" : ""}>SSH / Unraid · direct controlled deployment</option><option value="gitea-actions" ${!ssh ? "selected" : ""}>Gitea Actions · runner workflow</option></select></div>${ssh ? `<div class="field full"><div class="notice ${discovery ? "success" : ""}">${icon(discovery ? "check" : "server")}<div><strong>${discovery ? "Existing deployment imported from server" : "Import by folder or use Server inventory"}</strong><p>${discovery ? `${escapeHtml(discovery.runtime?.containers || 0)} container(s), ${escapeHtml(discovery.runtime?.services || 0)} service(s) and ${escapeHtml(discovery.runtime?.ports?.length || 0)} port mapping(s) detected. Every imported value remains editable as an explicit override.` : "For a known folder, ForgeFlow can read Docker, Compose and DockerMan metadata. For uncertain matches, use Server inventory and select the actual running workload."}</p><button type="button" class="button ${discovery ? "" : "primary"}" data-action="discover-existing-deployment">${icon("refresh")}${discovery ? "Rescan folder" : "Import known folder"}</button></div></div></div>` : ""}<div class="field"><label>Profile name</label><input id="profile-name" class="input" value="${attr(existing.name || "Production")}" /></div><div class="field"><label>Environment</label><input id="profile-environment" class="input" value="${attr(existing.environment || "production")}" /></div><div class="field"><label>Allowed branch</label><input id="profile-branch" class="input" value="${attr(existing.branch || repository?.defaultBranch || "main")}" /></div>${
|
||||
ssh
|
||||
? `
|
||||
<div class="field"><label>Unraid server</label><select id="profile-server" class="select">${servers.length ? servers.map((server) => `<option value="${attr(server.id)}" ${server.id === existing.serverId ? "selected" : ""}>${escapeHtml(server.name)} · ${escapeHtml(server.host)}</option>`).join("") : '<option value="">Configure a server first</option>'}</select></div>
|
||||
<div class="field"><label>Server folder name</label><input id="profile-remote-folder" class="input" value="${attr(remoteFolder)}"/></div>
|
||||
<div class="field"><label>Deployment mode</label><select id="profile-deployment-mode" class="select"><option value="server-git" ${(existing.deploymentMode || "server-git") === "server-git" ? "selected" : ""}>Server pull from Gitea</option><option value="push-bundle" ${existing.deploymentMode === "push-bundle" ? "selected" : ""}>Direct copy & deploy</option><option value="monitor-only" ${existing.deploymentMode === "monitor-only" ? "selected" : ""}>Monitor only</option></select><small>Server pull uses an automatically managed repository-scoped read-only deploy key. Direct copy remains available as a fallback and never requires Gitea credentials on Unraid.</small></div>
|
||||
<div class="field"><label>Compose mode</label><select id="profile-generated-compose" class="select"><option value="false" ${existing.generatedCompose !== true ? "selected" : ""}>Use existing Compose definition</option><option value="true" ${existing.generatedCompose === true ? "selected" : ""}>Generate a basic ForgeFlow Compose file</option></select></div>
|
||||
<div class="field"><label>Compose project identity</label><input id="profile-compose-project" class="input" value="${attr(existing.composeProject || "")}" placeholder="Existing docker compose project name"/><small>Kept stable to update the existing containers instead of creating duplicates.</small></div>
|
||||
<div class="field full"><label>Compose files</label><input id="profile-compose-files" class="input" value="${attr((existing.composeFiles?.length ? existing.composeFiles : [existing.composeFile || "docker-compose.yml"]).join(", "))}"/><small>Comma-separated, in the same order used by the existing deployment. These real server Compose files remain authoritative; ForgeFlow does not inject a synthetic service overlay.</small></div>
|
||||
<div class="field full"><label>Compose services to verify</label><input id="profile-compose-services" class="input" value="${attr((existing.composeServices?.length ? existing.composeServices : [existing.composeService || safeCloneFolderName(repository).toLowerCase()]).join(", "))}"/><small>Discovery hints only. At deployment time ForgeFlow reads the actual service keys from docker compose config and verifies every active service.</small></div><div class="field"><label>Visible container name</label><input id="profile-container-name" class="input" value="${attr(existing.containerName || remoteFolder)}"/><small>Used as an inventory hint; adopted Compose identity remains authoritative.</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"><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>Push bundle never replaces these paths and only removes files previously managed by ForgeFlow.</small></div>
|
||||
<label class="check-field"><input id="profile-manage-dockerman" type="checkbox" ${existing.manageDockerMan === true ? "checked" : ""}/><span>Manage a generated DockerMan template</span><small>Existing/imported DockerMan templates are always preserved. This applies only to ForgeFlow-generated Compose deployments.</small></label>
|
||||
<label class="check-field"><input id="profile-force-recreate" type="checkbox" disabled/><span>Destructive force-recreate disabled</span><small>ForgeFlow builds first and lets Compose replace only services whose image or configuration actually changed.</small></label>
|
||||
<label class="check-field"><input id="profile-remove-orphans" type="checkbox" disabled/><span>Orphan removal disabled</span><small>ForgeFlow never removes unrelated or orphaned containers during a deployment.</small></label>
|
||||
`
|
||||
: `
|
||||
<div class="field"><label>Deploy workflow file</label><input id="profile-workflow" class="input" value="${attr(existing.workflowFile || "deploy.yml")}" /></div>
|
||||
<div class="field full"><label>Rollback workflow file (optional)</label><input id="profile-rollback-workflow" class="input" value="${attr(existing.rollbackWorkflowFile || "")}" placeholder="rollback.yml" /></div>
|
||||
<div class="field full"><label>Application status URL</label><input id="profile-status-url" class="input" value="${attr(existing.statusUrl || "")}" required placeholder="https://app.example.com/.well-known/forgeflow" /></div>
|
||||
<div class="field full"><label>Healthcheck URL (optional)</label><input id="profile-healthcheck" class="input" value="${attr(existing.healthcheckUrl || "")}" placeholder="https://app.example.com/health" /></div>`
|
||||
}<label class="check-field full"><input id="profile-confirmation" type="checkbox" ${existing.confirmationRequired !== false ? "checked" : ""}/><span>Require an explicit confirmation before deployment</span></label>${renderDeploymentPolicyFields(storedProfile.deploymentPolicy || {})}</div><div class="notice" style="margin-top:13px">${icon("shield")}${ssh ? "Server pull fetches the exact selected Gitea commit with a repository-scoped read-only key, validates Compose and services, then promotes atomically with rollback protection." : "ForgeFlow sends only controlled workflow inputs: environment, exact SHA and a unique request ID."}</div></div><footer class="modal-footer">${existing.id ? `<button class="button danger" data-action="delete-deployment-profile" data-profile-id="${attr(existing.id)}">Delete</button>` : ""}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-deployment-profile" data-profile-id="${attr(existing.id || "")}" ${ssh && !servers.length ? "disabled" : ""}>Save environment</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === "inventory-review-plan") {
|
||||
const plan = ui.inventoryReviewPlan;
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true" aria-labelledby="inventory-review-title"><header class="modal-header"><h2 id="inventory-review-title">Review inventory decision</h2><button class="icon-button" data-action="close-modal" aria-label="Close inventory review">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero">${icon("shield")}<div><strong>${escapeHtml(plan?.action || "Review")}</strong><span>${escapeHtml(plan?.workloadId || "")} · ${escapeHtml(plan?.classification || "unclassified")}</span></div></div><div class="confirm-grid"><span>Evidence hash</span><strong class="mono">${escapeHtml(plan?.evidenceHash || "")}</strong><span>Configuration change</span><strong>${escapeHtml(plan?.configurationChanges?.join("; ") || "None")}</strong><span>Containers</span><strong>${plan?.containersUnaffected ? "Unaffected" : "Review required"}</strong><span>Recovery</span><strong>${escapeHtml(plan?.recovery || "")}</strong><span>Reason</span><strong>${escapeHtml(plan?.reason || "Not supplied")}</strong></div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="apply-inventory-review">Apply reviewed decision</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === "deploy-key-lifecycle") {
|
||||
const lifecycle = ui.deployKeyLifecycle;
|
||||
const inventory = lifecycle?.inventory;
|
||||
const rotation = lifecycle?.rotation;
|
||||
const revocation = lifecycle?.revocation;
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true" aria-labelledby="deploy-key-title"><header class="modal-header"><h2 id="deploy-key-title">Deploy key lifecycle</h2><button class="icon-button" data-action="close-modal" aria-label="Close deploy key lifecycle">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero ${inventory?.ready ? "" : "danger"}">${icon(inventory?.ready ? "shield" : "warning")}<div><strong>${inventory?.ready ? "Repository access is verified" : "Deploy key requires review"}</strong><span>${escapeHtml(inventory?.repository || "")} · ${escapeHtml(inventory?.server?.name || "server")}</span></div></div><div class="confirm-grid"><span>Key ID</span><strong>${escapeHtml(inventory?.configuredKey?.id || "Missing")}</strong><span>Fingerprint</span><strong class="mono">${escapeHtml(inventory?.serverKey?.fingerprint || "Unavailable")}</strong><span>Rights</span><strong>${inventory?.configuredKey?.readOnly ? "Repository-scoped · read-only" : "Unverified or writable"}</strong><span>Stale</span><strong>${inventory?.stale ? "Yes · blocked" : "No"}</strong><span>Shared references</span><strong>${inventory?.shared?.length || 0}</strong><span>Orphaned Gitea keys</span><strong>${inventory?.orphaned?.length || 0}</strong></div><section class="settings-group" style="margin-top:16px"><h3>Rotation impact</h3><ul>${(rotation?.impact || []).map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul><p>${escapeHtml(rotation?.recovery || "")}</p><small class="mono">Plan ${escapeHtml(rotation?.id || "unavailable")}</small></section><section class="settings-group"><h3>Revocation impact</h3><ul>${(revocation?.impact || []).map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul><p>Containers remain untouched. Server pull changes to monitoring-only until restored.</p><small class="mono">Plan ${escapeHtml(revocation?.id || "unavailable")}</small></section></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button" data-action="restore-deploy-key" data-profile-id="${attr(lifecycle?.profileId || "")}">Restore</button><button class="button danger" data-action="confirm-revoke-deploy-key" data-profile-id="${attr(lifecycle?.profileId || "")}" ${revocation?.id ? "" : "disabled"}>Revoke key</button><button class="button primary" data-action="confirm-rotate-deploy-key" data-profile-id="${attr(lifecycle?.profileId || "")}" ${rotation?.id ? "" : "disabled"}>Rotate safely</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === "deployment-preflight") {
|
||||
const profile =
|
||||
repository?.deploymentProfiles?.find(
|
||||
(item) => item.id === ui.modal.profileId,
|
||||
) || selectedProfile(repository);
|
||||
const report = ui.deploymentPreflight;
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Deployment preflight</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero ${report?.summary?.ready ? "" : "danger"}">${icon(report?.summary?.ready ? "shield" : "error")}<div><strong>${report?.summary?.ready ? "Environment is ready to test" : "Deployment is blocked"}</strong><span>${escapeHtml(repository?.fullName || "")} → ${escapeHtml(profile?.environment || "")}</span></div></div><div class="preflight-summary"><span class="status-pill ${report?.summary?.ready ? "success" : "danger"}">${report?.summary?.ready ? "Ready" : `${report?.summary?.blocking?.length || 0} blocking`}</span><span>${report?.summary?.counts?.pass || 0} passed · ${report?.summary?.counts?.warning || 0} warnings · ${report?.summary?.counts?.fail || 0} failed</span></div>${renderPreflightChecks(report)}</div><footer class="modal-footer"><button class="button" data-action="close-modal">Close</button>${report?.summary?.ready ? `<button class="button success" data-action="continue-after-preflight" data-profile-id="${attr(profile?.id || "")}">${icon("rocket")}Continue</button>` : `<button class="button" data-action="edit-deployment-profile" data-profile-id="${attr(profile?.id || "")}">Edit environment</button>`}</footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === "deploy-confirm") {
|
||||
const profile =
|
||||
repository?.deploymentProfiles?.find(
|
||||
(item) => item.id === ui.modal.profileId,
|
||||
) || selectedProfile(repository);
|
||||
const targetSha = deploymentTargetSha(repository, profile);
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Confirm production action</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero">${icon("rocket")}<div><strong>Deploy ${escapeHtml(shortSha(targetSha))} → ${escapeHtml(profile.environment)}</strong><span>${escapeHtml(repository.fullName)}</span></div></div><div class="confirm-grid"><span>Exact commit</span><strong class="mono">${escapeHtml(targetSha || "Unavailable")}</strong><span>Branch</span><strong>${escapeHtml(profile.branch)}</strong><span>Provider</span><strong>${profile.provider === "ssh-unraid" ? `${deploymentMode(profile) === "server-git" ? "Gitea → Unraid" : "Desktop → Unraid"} · ${escapeHtml(profile.remoteFolder)}` : escapeHtml(profile.workflowFile)}</strong><span>Healthcheck</span><strong>${escapeHtml(profile.healthcheckUrl || "Not configured")}</strong></div>${ui.deploymentPreflight ? `<div class="notice success" style="margin-top:12px">${icon("shield")}Preflight passed with ${ui.deploymentPreflight.summary.counts.warning} warning(s). Backend safety checks run again at dispatch time.</div>` : ""}${renderReleaseNoteFields(profile)}</div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button success" data-action="confirm-deploy" data-profile-id="${attr(profile.id)}" ${targetSha ? "" : "disabled"}>Deploy exact commit</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === "rollback-confirm") {
|
||||
const profile = repository?.deploymentProfiles?.find(
|
||||
(item) => item.id === ui.modal.profileId,
|
||||
);
|
||||
const target = profile?.state?.previousSha;
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Confirm rollback</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero danger">${icon("undo")}<div><strong>Rollback ${escapeHtml(profile?.environment || "")} to ${shortSha(target)}</strong><span>The target must still exist on origin/${escapeHtml(profile?.branch || "")}.</span></div></div><div class="confirm-grid"><span>Target commit</span><strong class="mono">${escapeHtml(target || "Unavailable")}</strong><span>Provider</span><strong>${profile?.provider === "ssh-unraid" ? "SSH exact-SHA reset" : escapeHtml(profile?.rollbackWorkflowFile || "Not configured")}</strong><span>Current live</span><strong class="mono">${escapeHtml(profile?.state?.liveSha || "Unknown")}</strong></div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button danger" data-action="confirm-rollback" data-profile-id="${attr(profile?.id || "")}" ${target ? "" : "disabled"}>Rollback exact commit</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === "server-config") {
|
||||
const server =
|
||||
(ui.boot.state.servers || []).find(
|
||||
(item) => item.id === ui.modal.serverId,
|
||||
) || {};
|
||||
const authType = ui.modal.authType || server.authType || "password";
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>${server.id ? "Edit" : "Add"} SSH / Unraid server</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="form-grid"><div class="field"><label>Name</label><input id="server-name" class="input" value="${attr(server.name || "Unraid")}"/></div><div class="field"><label>Host or IP</label><input id="server-host" class="input" value="${attr(server.host || "")}" placeholder="192.168.1.10"/></div><div class="field"><label>SSH port</label><input id="server-port" class="input" type="number" min="1" max="65535" value="${attr(server.port || 22)}"/></div><div class="field"><label>Username</label><input id="server-username" class="input" value="${attr(server.username || "root")}"/></div><div class="field"><label>Authentication</label><select id="server-auth-type" class="select"><option value="privateKey" ${authType === "privateKey" ? "selected" : ""}>Private key · optional</option><option value="password" ${authType === "password" ? "selected" : ""}>Password · no key</option></select></div><div class="field"><label>Appdata base path</label><input id="server-base-path" class="input" value="${attr(server.basePath || "/mnt/user/appdata")}"/></div><div class="field full"><label>Inventory scan roots</label><textarea id="server-scan-roots" class="input" rows="3" placeholder="One absolute server path per line">${escapeHtml((server.scanRoots || [server.basePath || "/mnt/user/appdata"]).join("\n"))}</textarea><small>ForgeFlow scans only these roots and never changes containers during discovery.</small></div><div class="field full"><label>Excluded folder names</label><input id="server-scan-excludes" class="input" value="${attr((server.scanExcludes || ["backups", "archives", "releases", "staging", "testdata"]).join(", "))}"/><small>Comma-separated directory names or safe wildcard patterns.</small></div>${authType === "privateKey" ? `<div class="field full"><label>Private key file</label><div class="input-action"><input id="server-private-key" class="input" value="${attr(server.privateKeyPath || "")}" placeholder="C:\\Users\\example\\.ssh\\id_ed25519"/><button class="button" data-action="select-private-key">Browse</button></div></div><div class="field full"><label>Private key passphrase</label><input id="server-passphrase" class="input" type="password" placeholder="${server.hasPassphrase ? "Leave empty to keep stored passphrase" : "Only when the key is encrypted"}"/></div>` : `<div class="field full"><label>SSH password</label><input id="server-password" class="input" type="password" placeholder="${server.hasPassword ? "Leave empty to keep stored password" : "Password"}"/></div>`}<div class="field full"><label>Trusted host fingerprint</label><input id="server-fingerprint" class="input mono" value="${attr(server.hostFingerprint || "")}" readonly placeholder="Filled automatically after Test & trust"/></div></div><div class="notice warning" style="margin-top:12px">${icon("key")}This login secures the desktop → Unraid connection. Server pull separately creates one read-only deploy key per repository and pins the Gitea SSH host key. No reusable Gitea token is stored on Unraid.</div></div><footer class="modal-footer">${server.id ? `<button class="button danger" data-action="delete-server" data-server-id="${attr(server.id)}">Delete</button>` : ""}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-server" data-server-id="${attr(server.id || "")}">Save server</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === "hunk-staging") {
|
||||
const hunks = ui.diffHunks?.hunks || [];
|
||||
return `<div class="modal-backdrop"><section class="modal wide-modal"><header class="modal-header"><h2>Stage selected hunks</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><p>${escapeHtml(ui.selectedFile)}</p><div class="stack">${hunks.map((hunk) => `<label class="check-field"><input type="checkbox" data-hunk-index="${hunk.index}" checked/><span><strong>${escapeHtml(hunk.heading)}</strong><small>${hunk.additions} additions · ${hunk.deletions} deletions</small></span></label><pre class="log-lines">${escapeHtml(hunk.lines.join("\n"))}</pre>`).join("")}</div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="stage-chosen-hunks">Stage selected hunks</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === "pull-request") {
|
||||
return `<div class="modal-backdrop"><section class="modal"><header class="modal-header"><h2>Create Gitea pull request</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="form-grid"><div class="field"><label>Source branch</label><input class="input" value="${attr(repository?.localStatus?.branch?.head || "")}" readonly/></div><div class="field"><label>Target branch</label><input id="pr-base" class="input" value="${attr(repository?.defaultBranch || "main")}"/></div><div class="field full"><label>Title</label><input id="pr-title" class="input" value="${attr(ui.modal.title || "")}"/></div><div class="field full"><label>Description</label><textarea id="pr-body" class="textarea">${escapeHtml(ui.modal.body || "")}</textarea></div></div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="create-pull-request">Create pull request</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === "conflict-guide") {
|
||||
const state = ui.conflictState || {};
|
||||
return `<div class="modal-backdrop"><section class="modal"><header class="modal-header"><h2>Conflict guide · ${escapeHtml(state.operation || "Git")}</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body">${state.files?.length ? `<p>Resolve these files, then continue:</p><div class="tool-list">${state.files.map((file) => `<div class="tool-row"><strong>${escapeHtml(file)}</strong><button class="button" data-action="open-conflict-file" data-path="${attr(file)}">Open in editor</button></div>`).join("")}</div>` : '<div class="notice success">All conflicts are marked resolved. You can continue the Git operation.</div>'}</div><footer class="modal-footer"><button class="button danger" data-action="abort-git-operation">Abort operation</button><span class="modal-spacer"></span><button class="button" data-action="close-modal">Close</button><button class="button primary" data-action="continue-git-operation" ${state.canContinue ? "" : "disabled"}>Continue</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === "command-palette") return renderCommandPalette();
|
||||
return "";
|
||||
}
|
||||
|
||||
function paletteCommands() {
|
||||
const repository = selectedRepository();
|
||||
return [
|
||||
{
|
||||
id: "overview",
|
||||
label: "Go to release overview",
|
||||
detail: "Workspace",
|
||||
icon: "overview",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "refresh",
|
||||
label: "Refresh all repositories",
|
||||
detail: "Local and Gitea",
|
||||
icon: "refresh",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "deployments",
|
||||
label: "Open deployments",
|
||||
detail: "Release history",
|
||||
icon: "deploy",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "diagnostics",
|
||||
label: "Open diagnostics",
|
||||
detail: "Logs, preflight and support bundle",
|
||||
icon: "shield",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
label: "Open settings",
|
||||
detail: "Connections and awareness",
|
||||
icon: "settings",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "open-folder",
|
||||
label: "Open selected project folder",
|
||||
detail: repository?.name || "No repository selected",
|
||||
icon: "folder",
|
||||
enabled: Boolean(repository?.localPath),
|
||||
},
|
||||
{
|
||||
id: "git-tools",
|
||||
label: "Open branch and stash tools",
|
||||
detail: repository?.name || "No repository selected",
|
||||
icon: "branch",
|
||||
enabled: Boolean(repository?.localPath),
|
||||
},
|
||||
{
|
||||
id: "deploy-selected",
|
||||
label: "Deploy selected repository",
|
||||
detail: canDeploy(repository)
|
||||
? `${repository.name} ${shortSha(deploymentTargetSha(repository))}`
|
||||
: "Not ready",
|
||||
icon: "rocket",
|
||||
enabled: canDeploy(repository),
|
||||
},
|
||||
];
|
||||
}
|
||||
function renderCommandPalette() {
|
||||
const query = ui.paletteQuery.toLowerCase();
|
||||
const commands = paletteCommands().filter(
|
||||
(command) =>
|
||||
!query ||
|
||||
`${command.label} ${command.detail}`.toLowerCase().includes(query),
|
||||
);
|
||||
return `<div class="modal-backdrop palette-backdrop"><section class="command-palette"><div class="palette-search">${icon("search")}<input id="palette-input" value="${attr(ui.paletteQuery)}" placeholder="Type a command…" autofocus/></div><div class="palette-list">${commands.map((command) => `<button class="palette-row" data-action="run-command" data-command="${command.id}" ${command.enabled ? "" : "disabled"}>${icon(command.icon)}<span><strong>${escapeHtml(command.label)}</strong><small>${escapeHtml(command.detail)}</small></span><kbd>↵</kbd></button>`).join("")}</div><div class="palette-footer">Esc to close · Ctrl K anywhere</div></section></div>`;
|
||||
}
|
||||
|
||||
function enhanceRenderedUi() {
|
||||
document.querySelectorAll("button.icon-button:not([aria-label])").forEach((button) => {
|
||||
const action = String(button.title || button.dataset.action || "Action").replaceAll("-", " ");
|
||||
button.setAttribute("aria-label", action.charAt(0).toUpperCase() + action.slice(1));
|
||||
});
|
||||
document.querySelectorAll(".field > label:not([for])").forEach((label, index) => {
|
||||
const control = label.parentElement?.querySelector("input, select, textarea");
|
||||
if (!control) return;
|
||||
if (!control.id) control.id = `forgeflow-field-${index}`;
|
||||
label.htmlFor = control.id;
|
||||
});
|
||||
document.querySelectorAll("input:not([aria-label]), select:not([aria-label]), textarea:not([aria-label])").forEach((control) => {
|
||||
if (control.labels?.length) return;
|
||||
const fallback = String(control.placeholder || control.name || control.id || "Form control").replaceAll("-", " ").trim();
|
||||
control.setAttribute("aria-label", fallback.charAt(0).toUpperCase() + fallback.slice(1));
|
||||
});
|
||||
}
|
||||
|
||||
// A render replaces the complete application shell. Without this, a background
|
||||
// repository poll or deployment poll destroys the element the user is typing in,
|
||||
// discarding the caret position and every scroll offset on screen.
|
||||
function elementRenderPath(element) {
|
||||
const parts = [];
|
||||
let node = element;
|
||||
while (node && node !== app) {
|
||||
const parent = node.parentElement;
|
||||
if (!parent) return null;
|
||||
parts.push(`${node.tagName}.${Array.prototype.indexOf.call(parent.children, node)}`);
|
||||
node = parent;
|
||||
}
|
||||
return node === app ? parts.reverse().join(">") : null;
|
||||
}
|
||||
|
||||
function elementAtRenderPath(renderPath) {
|
||||
let node = app;
|
||||
for (const part of renderPath.split(">")) {
|
||||
const separator = part.lastIndexOf(".");
|
||||
node = node?.children?.[Number(part.slice(separator + 1))];
|
||||
// The shell can be structurally different after a view change, in which case
|
||||
// the old offset belongs to an unrelated element and must be dropped.
|
||||
if (!node || node.tagName !== part.slice(0, separator)) return null;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
// enhanceRenderedUi() re-injects these controls empty on every render, so a
|
||||
// background refresh would otherwise discard a release note or review reason
|
||||
// while the user is still writing it.
|
||||
const INJECTED_FIELD_IDS = [
|
||||
"deployment-note",
|
||||
"deployment-override",
|
||||
"deployment-override-reason",
|
||||
"inventory-review-action",
|
||||
"inventory-review-reason",
|
||||
"profile-policy-frozen",
|
||||
"profile-policy-note",
|
||||
"profile-policy-freeze-reason",
|
||||
"profile-policy-windows",
|
||||
];
|
||||
|
||||
function captureInjectedFieldValues() {
|
||||
const values = [];
|
||||
for (const id of INJECTED_FIELD_IDS) {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) continue;
|
||||
if (element.type === "checkbox") values.push({ id, checked: element.checked });
|
||||
else if (element.value) values.push({ id, value: element.value });
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function restoreInjectedFieldValues(values) {
|
||||
for (const entry of values) {
|
||||
const element = document.getElementById(entry.id);
|
||||
if (!element) continue;
|
||||
// Never overwrite a value the freshly rendered control already carries; only
|
||||
// fill back in what the injection left empty.
|
||||
if ("checked" in entry) {
|
||||
if (!element.checked) element.checked = entry.checked;
|
||||
} else if (!element.value) element.value = entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
function captureInteractionState() {
|
||||
const scroll = [];
|
||||
for (const element of app.querySelectorAll("*")) {
|
||||
if (!element.scrollTop && !element.scrollLeft) continue;
|
||||
const renderPath = elementRenderPath(element);
|
||||
if (renderPath) scroll.push({ renderPath, top: element.scrollTop, left: element.scrollLeft });
|
||||
}
|
||||
const injectedFields = captureInjectedFieldValues();
|
||||
const active = document.activeElement;
|
||||
if (!active?.id || !app.contains(active)) return { scroll, injectedFields, focus: null };
|
||||
const focus = { id: active.id, start: null, end: null, direction: "none" };
|
||||
try {
|
||||
focus.start = active.selectionStart;
|
||||
focus.end = active.selectionEnd;
|
||||
focus.direction = active.selectionDirection || "none";
|
||||
} catch {}
|
||||
return { scroll, injectedFields, focus };
|
||||
}
|
||||
|
||||
function restoreInteractionState(state) {
|
||||
restoreInjectedFieldValues(state.injectedFields);
|
||||
for (const entry of state.scroll) {
|
||||
const element = elementAtRenderPath(entry.renderPath);
|
||||
if (!element) continue;
|
||||
element.scrollTop = entry.top;
|
||||
element.scrollLeft = entry.left;
|
||||
}
|
||||
if (!state.focus) return;
|
||||
const element = document.getElementById(state.focus.id);
|
||||
if (!element || !app.contains(element)) return;
|
||||
element.focus({ preventScroll: true });
|
||||
if (state.focus.start === null) return;
|
||||
try {
|
||||
element.setSelectionRange(state.focus.start, state.focus.end, state.focus.direction);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
let lastRenderedMarkup = null;
|
||||
|
||||
function render() {
|
||||
if (!ui.boot) return;
|
||||
const repository = selectedRepository();
|
||||
const main =
|
||||
ui.currentView === "overview"
|
||||
? renderOverview()
|
||||
: ui.currentView === "deployments"
|
||||
? renderDeployments()
|
||||
: ui.currentView === "help"
|
||||
? renderHelp()
|
||||
: ui.currentView === "settings"
|
||||
? renderSettings()
|
||||
: ui.currentView === "diagnostics"
|
||||
? renderDiagnostics()
|
||||
: ui.currentView === "deployment-run"
|
||||
? renderPipelineView()
|
||||
: repository
|
||||
? renderRepositoryWorkspace(repository)
|
||||
: renderOverview();
|
||||
const withPanel = ui.currentView === "repository" && repository;
|
||||
const markup = `<div class="app-shell">${renderTitlebar()}<div class="app-body">${renderSidebar()}<main class="workspace ${withPanel ? "with-panel" : ""}"><section class="main-canvas ${withPanel ? "repository-canvas" : ""}">${main}</section>${withPanel ? renderActionPanel(repository) : ""}${ui.loading ? `<div class="loading-overlay"><div class="boot-screen"><div class="spinner"></div><strong>${escapeHtml(ui.loadingMessage || "Working…")}</strong></div></div>` : ""}</main></div>${renderStatusbar()}</div>${ui.boot.state.setupComplete ? "" : renderSetup()}${renderModal()}`;
|
||||
// Most renders are triggered by a poll that found nothing new. Rebuilding an
|
||||
// identical shell would only cost layout work and interrupt the user. The
|
||||
// markup is the complete rendered state, so comparing it is sufficient:
|
||||
// enhanceRenderedUi() only derives labels and ids from what is already there.
|
||||
if (markup === lastRenderedMarkup) return;
|
||||
const interaction = captureInteractionState();
|
||||
app.innerHTML = markup;
|
||||
enhanceRenderedUi();
|
||||
restoreInteractionState(interaction);
|
||||
lastRenderedMarkup = markup;
|
||||
if (ui.modal?.type === "command-palette")
|
||||
requestAnimationFrame(() =>
|
||||
document.querySelector("#palette-input")?.focus(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Rendering a unified diff is a self-contained concern with its own size
|
||||
// limits, kept out of views.js so that file stays within the project's
|
||||
// architecture budget.
|
||||
// A regenerated lock file runs into tens of thousands of lines, and one element
|
||||
// per line freezes the window. Only the rendered view is capped.
|
||||
const DIFF_RENDER_LINE_LIMIT = 2000;
|
||||
|
||||
function diffAtmosphere(diff, allLines = null) {
|
||||
if (!ui.selectedFile) return "";
|
||||
const lines = allLines || String(diff || "").split("\n");
|
||||
const additions = lines.filter(
|
||||
(line) => line.startsWith("+") && !line.startsWith("+++"),
|
||||
).length;
|
||||
const removals = lines.filter(
|
||||
(line) => line.startsWith("-") && !line.startsWith("---"),
|
||||
).length;
|
||||
const extension =
|
||||
String(ui.selectedFile).split(".").pop()?.slice(0, 8).toUpperCase() ||
|
||||
"FILE";
|
||||
return `<div class="diff-atmosphere ${lines.length > 34 ? "dense" : ""}" data-diff-atmosphere aria-hidden="true"><svg viewBox="0 0 360 260" role="presentation"><path class="code-route route-a" d="M38 195 C92 84 178 214 318 74"/><path class="code-route route-b" d="M52 74 C132 8 230 34 310 156"/><g class="code-card"><rect x="110" y="75" width="142" height="106" rx="18"/><path d="M136 108h90M136 128h58M136 148h76"/></g><g class="code-node node-one"><circle cx="48" cy="190" r="15"/><path d="m41 190 5 5 9-12"/></g><g class="code-node node-two"><circle cx="315" cy="76" r="13"/><path d="M308 76h14M315 69v14"/></g><circle class="code-packet packet-one" cx="0" cy="0" r="5"/><circle class="code-packet packet-two" cx="0" cy="0" r="4"/></svg><div class="diff-atmosphere-caption"><span>${escapeHtml(extension)} change map</span><strong><i>+${additions}</i><i>−${removals}</i></strong></div></div>`;
|
||||
}
|
||||
|
||||
function diffLineType(line) {
|
||||
if (line.startsWith("+") && !line.startsWith("+++")) return "add";
|
||||
if (line.startsWith("-") && !line.startsWith("---")) return "remove";
|
||||
return line.startsWith("@@") ? "hunk" : "";
|
||||
}
|
||||
|
||||
function renderDiff(diff) {
|
||||
if (!diff)
|
||||
return '<div class="empty-state"><div class="empty-icon">↔</div><h3>No textual diff</h3><p>Select another file or open the project folder for binary changes.</p></div>';
|
||||
const lines = String(diff).split("\n");
|
||||
const rendered = lines
|
||||
.slice(0, DIFF_RENDER_LINE_LIMIT)
|
||||
.map((line) => `<span class="diff-line ${diffLineType(line)}">${escapeHtml(line) || " "}</span>`)
|
||||
.join("");
|
||||
const hidden = Math.max(0, lines.length - DIFF_RENDER_LINE_LIMIT);
|
||||
const notice = hidden ? `<span class="diff-line hunk">… ${hidden.toLocaleString()} more line${hidden === 1 ? "" : "s"} are not shown. Copy diff and the editor still give you the complete change.</span>` : "";
|
||||
return `${rendered}${notice}${diffAtmosphere(diff, lines)}`;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
app.addEventListener("click", async (event) => {
|
||||
const target = event.target.closest("[data-action]");
|
||||
if (!target) return;
|
||||
const action = target.dataset.action;
|
||||
let repository = selectedRepository();
|
||||
if (target.dataset.repositoryId) {
|
||||
const actionRepository = ui.repositories.find(
|
||||
(item) => String(item.id) === String(target.dataset.repositoryId),
|
||||
);
|
||||
if (actionRepository) repository = actionRepository;
|
||||
}
|
||||
|
||||
const handlers = [handleShellActions, handleInventoryActions, handleDeploymentProfileActions, handleDeploymentOperationActions, handleSetupAndSettingsActions, handleRecoveryActions, handleCommandActions];
|
||||
for (const handler of handlers) if (await handler(event, target, action, repository)) return;
|
||||
});
|
||||
|
||||
app.addEventListener("input", (event) => {
|
||||
if (event.target.id === "global-search") {
|
||||
ui.search = event.target.value;
|
||||
scheduleInputRender();
|
||||
} else if (event.target.id === "repo-filter") {
|
||||
ui.repoSearch = event.target.value;
|
||||
scheduleInputRender();
|
||||
} else if (event.target.id === "commit-message") {
|
||||
ui.commitMessage = event.target.value;
|
||||
const repository = selectedRepository();
|
||||
const hasSelection = Boolean(ui.selectedFiles.size || repository?.localStatus?.counts?.staged);
|
||||
const ready = Boolean(hasSelection && ui.commitMessage.trim());
|
||||
const blocker = !hasSelection
|
||||
? "Select files or stage one or more hunks."
|
||||
: ready
|
||||
? ui.selectedFiles.size
|
||||
? "Ready to commit. ForgeFlow stages the selected files automatically."
|
||||
: "Ready to commit only the reviewed staged hunks."
|
||||
: "Enter a commit message to enable commit and push.";
|
||||
const readiness = document.querySelector(".commit-readiness");
|
||||
if (readiness) {
|
||||
readiness.classList.toggle("ready", ready);
|
||||
readiness.classList.toggle("blocked", !ready);
|
||||
readiness.innerHTML = `${icon(ready ? "check" : "warning")}<span>${escapeHtml(blocker)}</span>`;
|
||||
}
|
||||
for (const button of document.querySelectorAll('[data-action="commit-push"], [data-action="commit-only"]')) {
|
||||
button.disabled = !ready;
|
||||
if (ready) button.removeAttribute("title");
|
||||
else button.title = blocker;
|
||||
}
|
||||
} else if (event.target.id === "setup-url")
|
||||
ui.setupDraft.baseUrl = event.target.value;
|
||||
else if (event.target.id === "setup-token")
|
||||
ui.setupDraft.token = event.target.value;
|
||||
else if (event.target.id === "palette-input") {
|
||||
ui.paletteQuery = event.target.value;
|
||||
scheduleInputRender(60);
|
||||
} else if (event.target.id === "help-search") {
|
||||
ui.helpQuery = event.target.value;
|
||||
scheduleInputRender(60);
|
||||
}
|
||||
});
|
||||
|
||||
app.addEventListener("change", async (event) => {
|
||||
if (event.target.matches("[data-file-select]")) {
|
||||
const filePath = event.target.dataset.fileSelect;
|
||||
if (event.target.checked) ui.selectedFiles.add(filePath);
|
||||
else ui.selectedFiles.delete(filePath);
|
||||
render();
|
||||
} else if (event.target.id === "appearance-select") {
|
||||
ui.boot.state = await window.forgeflow.setAppearance(event.target.value);
|
||||
applyTheme(event.target.value);
|
||||
render();
|
||||
} else if (event.target.id === "action-profile-select") {
|
||||
ui.selectedProfileId = event.target.value;
|
||||
render();
|
||||
} else if (event.target.id === "validator-policy") {
|
||||
await handleShellActions(event, event.target, "git-validator-policy", selectedRepository());
|
||||
} else if (event.target.id === "profile-provider") {
|
||||
ui.modal.provider = event.target.value;
|
||||
render();
|
||||
} else if (event.target.id === "server-auth-type") {
|
||||
ui.modal.authType = event.target.value;
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (
|
||||
(event.key === "Enter" || event.key === " ") &&
|
||||
event.target.matches('.file-row[data-action="select-file"]')
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.target.click();
|
||||
return;
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
|
||||
event.preventDefault();
|
||||
ui.paletteQuery = "";
|
||||
ui.modal = { type: "command-palette" };
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "f" && ui.currentView === "help") {
|
||||
event.preventDefault();
|
||||
document.querySelector("#help-search")?.focus();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
event.key === "Enter" &&
|
||||
ui.currentView === "repository"
|
||||
) {
|
||||
const button = document.querySelector(
|
||||
'[data-action="commit-push"]:not(:disabled)',
|
||||
);
|
||||
if (button) button.click();
|
||||
}
|
||||
if (event.key === "F5") {
|
||||
event.preventDefault();
|
||||
refreshRepositories(true);
|
||||
}
|
||||
if (event.key === "Escape" && ui.modal) {
|
||||
ui.modal = null;
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
let pointerAnimationFrame = null;
|
||||
let pendingPointer = null;
|
||||
|
||||
document.addEventListener("pointermove", (event) => {
|
||||
pendingPointer = { target: event.target, clientX: event.clientX, clientY: event.clientY };
|
||||
if (pointerAnimationFrame) return;
|
||||
pointerAnimationFrame = requestAnimationFrame(() => {
|
||||
pointerAnimationFrame = null;
|
||||
const current = pendingPointer;
|
||||
pendingPointer = null;
|
||||
if (!current) return;
|
||||
const illustration = current.target.closest?.("[data-project-illustration]");
|
||||
if (illustration) {
|
||||
const bounds = illustration.getBoundingClientRect();
|
||||
illustration.style.setProperty("--tilt-x", `${((current.clientY - bounds.top) / bounds.height - 0.5) * -7}deg`);
|
||||
illustration.style.setProperty("--tilt-y", `${((current.clientX - bounds.left) / bounds.width - 0.5) * 9}deg`);
|
||||
}
|
||||
const diffPanel = current.target.closest?.(".diff-panel");
|
||||
const atmosphere = diffPanel?.querySelector("[data-diff-atmosphere]");
|
||||
if (atmosphere) {
|
||||
const bounds = diffPanel.getBoundingClientRect();
|
||||
atmosphere.style.setProperty("--diff-tilt-x", `${((current.clientY - bounds.top) / bounds.height - 0.5) * -3}deg`);
|
||||
atmosphere.style.setProperty("--diff-tilt-y", `${((current.clientX - bounds.left) / bounds.width - 0.5) * 4}deg`);
|
||||
}
|
||||
});
|
||||
});
|
||||
document.addEventListener("pointerout", (event) => {
|
||||
const illustration = event.target.closest?.("[data-project-illustration]");
|
||||
if (illustration && !illustration.contains(event.relatedTarget)) {
|
||||
illustration.style.removeProperty("--tilt-x");
|
||||
illustration.style.removeProperty("--tilt-y");
|
||||
}
|
||||
|
||||
const diffPanel = event.target.closest?.(".diff-panel");
|
||||
if (diffPanel && !diffPanel.contains(event.relatedTarget)) {
|
||||
const atmosphere = diffPanel.querySelector("[data-diff-atmosphere]");
|
||||
atmosphere?.style.removeProperty("--diff-tilt-x");
|
||||
atmosphere?.style.removeProperty("--diff-tilt-y");
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("error", (event) => {
|
||||
window.forgeflow
|
||||
.reportRendererEvent?.("error", "uncaught-error", {
|
||||
message: event.message,
|
||||
filename: event.filename,
|
||||
line: event.lineno,
|
||||
column: event.colno,
|
||||
stack: event.error?.stack,
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
const reason = event.reason;
|
||||
window.forgeflow
|
||||
.reportRendererEvent?.("error", "unhandled-rejection", {
|
||||
message: reason?.message || String(reason || "Unknown rejection"),
|
||||
stack: reason?.stack,
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<title>ForgeFlow</title>
|
||||
<link rel="icon" type="image/png" href="./assets/itworx-mark.png" />
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="boot-screen">
|
||||
<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>
|
||||
</div>
|
||||
<div id="toast-root" class="toast-root" aria-live="assertive"></div>
|
||||
<script defer src="mock-repository-bridge.js"></script>
|
||||
<script defer src="mock-deployment-bridge.js"></script>
|
||||
<script defer src="mock-bridge.js"></script>
|
||||
<script defer src="app.js"></script>
|
||||
<script defer src="diff-view.js"></script>
|
||||
<script defer src="views.js"></script>
|
||||
<script defer src="dialogs.js"></script>
|
||||
<script defer src="operations.js"></script>
|
||||
<script defer src="actions/shell.js"></script>
|
||||
<script defer src="actions/inventory.js"></script>
|
||||
<script defer src="actions/deployment-profile.js"></script>
|
||||
<script defer src="actions/deployment-operation.js"></script>
|
||||
<script defer src="actions/setup-and-settings.js"></script>
|
||||
<script defer src="actions/recovery.js"></script>
|
||||
<script defer src="actions/command.js"></script>
|
||||
<script defer src="events.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,589 @@
|
||||
(() => {
|
||||
if (window.forgeflow) return;
|
||||
|
||||
const wait = (ms = 180) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const clone = (value) => JSON.parse(JSON.stringify(value));
|
||||
const iso = (offset = 0) => new Date(Date.now() + offset).toISOString();
|
||||
const storage = {
|
||||
get(key) {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
set(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {}
|
||||
},
|
||||
};
|
||||
const repositoryListeners = new Set();
|
||||
const operationListeners = new Set();
|
||||
const updateListeners = new Set();
|
||||
const emitRepositories = () =>
|
||||
repositoryListeners.forEach((listener) =>
|
||||
listener({ reason: "demo-change" }),
|
||||
);
|
||||
const emitOperations = (operations) =>
|
||||
operationListeners.forEach((listener) =>
|
||||
listener({ operations: clone(operations) }),
|
||||
);
|
||||
const randomSha = () =>
|
||||
`${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`
|
||||
.padEnd(40, "a")
|
||||
.slice(0, 40);
|
||||
|
||||
const makeStatus = ({
|
||||
head,
|
||||
branch = "main",
|
||||
ahead = 0,
|
||||
behind = 0,
|
||||
upstream = `origin/${branch}`,
|
||||
files = [],
|
||||
}) => ({
|
||||
branch: { oid: head, head: branch, upstream, ahead, behind },
|
||||
files,
|
||||
counts: {
|
||||
changed: files.length,
|
||||
staged: files.filter((item) => item.staged).length,
|
||||
unstaged: files.filter((item) => item.unstaged).length,
|
||||
conflicts: files.filter((item) => item.conflict).length,
|
||||
untracked: files.filter((item) => item.untracked).length,
|
||||
},
|
||||
clean: files.length === 0,
|
||||
root: "",
|
||||
remoteUrl: "",
|
||||
head,
|
||||
shortHead: head.slice(0, 7),
|
||||
fingerprint: `${head}:${branch}:${ahead}:${behind}:${files.map((item) => `${item.path}:${item.indexCode}${item.worktreeCode}`).join("|")}`,
|
||||
});
|
||||
|
||||
const makeFile = (path, status = "modified", options = {}) => ({
|
||||
path,
|
||||
originalPath: options.originalPath || null,
|
||||
indexCode: options.staged
|
||||
? status === "added"
|
||||
? "A"
|
||||
: status === "deleted"
|
||||
? "D"
|
||||
: "M"
|
||||
: ".",
|
||||
worktreeCode: options.staged
|
||||
? "."
|
||||
: status === "untracked"
|
||||
? "?"
|
||||
: status === "deleted"
|
||||
? "D"
|
||||
: status === "conflict"
|
||||
? "U"
|
||||
: "M",
|
||||
staged: Boolean(options.staged),
|
||||
unstaged: !options.staged,
|
||||
untracked: status === "untracked",
|
||||
conflict: status === "conflict",
|
||||
status,
|
||||
});
|
||||
|
||||
const profile = (id, name, environment, options = {}) => ({
|
||||
id,
|
||||
name,
|
||||
environment,
|
||||
provider: "gitea-actions",
|
||||
branch: options.branch || "main",
|
||||
workflowFile: options.workflowFile || "deploy.yml",
|
||||
rollbackWorkflowFile: options.rollbackWorkflowFile ?? "rollback.yml",
|
||||
healthcheckUrl:
|
||||
options.healthcheckUrl || `https://${environment}.internal/health`,
|
||||
statusUrl:
|
||||
options.statusUrl ||
|
||||
`https://${environment}.internal/.well-known/forgeflow`,
|
||||
confirmationRequired: options.confirmationRequired !== false,
|
||||
inputs: {},
|
||||
state: {
|
||||
liveSha: options.liveSha || null,
|
||||
previousSha: options.previousSha || null,
|
||||
healthy: options.healthy ?? null,
|
||||
healthConfigured: true,
|
||||
statusConfigured: true,
|
||||
healthStatus: options.healthy === false ? 503 : 200,
|
||||
healthLatencyMs: 42,
|
||||
checkedAt: options.checkedAt || iso(-120000),
|
||||
},
|
||||
});
|
||||
|
||||
const sshProfile = (id, name, environment, options = {}) => ({
|
||||
id, name, environment, provider: "ssh-unraid", branch: options.branch || "main",
|
||||
serverId: "demo-unraid", remoteFolder: options.remoteFolder || name,
|
||||
deploymentMode: "server-git", composeFiles: ["compose.yml"],
|
||||
composeProject: options.composeProject || String(options.remoteFolder || name).toLowerCase(),
|
||||
composeServices: options.composeServices || [String(options.remoteFolder || name).toLowerCase()],
|
||||
containerName: options.containerName || options.remoteFolder || name,
|
||||
generatedCompose: false, adoptedFromServer: true, serverSourceOfTruth: true,
|
||||
confirmationRequired: true,
|
||||
serverGitAccess: { configured: options.accessConfigured !== false, keyFingerprint: "SHA256:demo", hostFingerprint: "SHA256:gitea", configuredAt: iso(-3600000) },
|
||||
state: {
|
||||
liveSha: options.liveSha || null, giteaSha: options.giteaSha || options.liveSha || null,
|
||||
previousSha: options.previousSha || null, healthy: options.healthy ?? true,
|
||||
containerRunning: true, runtimeVerification: "verified", matchesGitea: options.matchesGitea ?? true,
|
||||
checkedAt: iso(-120000), dockerMan: { templateExists: true, webUi: true, icon: true },
|
||||
},
|
||||
});
|
||||
|
||||
const now = iso();
|
||||
const defaultPreferences = {
|
||||
autoRefresh: true,
|
||||
repositoryPollSeconds: 4,
|
||||
operationPollSeconds: 5,
|
||||
fetchIntervalMinutes: 10,
|
||||
preferredCloneProtocol: "https",
|
||||
diagnosticsEnabled: true,
|
||||
diagnosticLevel: "info",
|
||||
logRetentionDays: 14,
|
||||
maxLogFileMb: 8,
|
||||
};
|
||||
|
||||
let state = {
|
||||
schemaVersion: 8,
|
||||
setupComplete: storage.get("forgeflow-demo-setup") !== "false",
|
||||
appearance: storage.get("forgeflow-theme") || "dark",
|
||||
gitea: {
|
||||
baseUrl: "https://gitea.internal",
|
||||
user: { login: "jens", full_name: "Jens" },
|
||||
hasToken: true,
|
||||
},
|
||||
workspaceRoots: ["C:\\Development"],
|
||||
repositoryMappings: {},
|
||||
deploymentProfiles: {},
|
||||
deploymentStates: {},
|
||||
favorites: [
|
||||
"jens/microsoft-cloud-operations-platform",
|
||||
"jens/unraid-appops-gateway",
|
||||
],
|
||||
updates: {
|
||||
owner: "Jens",
|
||||
repo: "ForgeFlow",
|
||||
branch: "main",
|
||||
autoCheck: true,
|
||||
lastCheckedAt: null,
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
id: "server-unraid",
|
||||
name: "Unraid",
|
||||
host: "192.168.1.10",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "privateKey",
|
||||
basePath: "/mnt/user/appdata",
|
||||
privateKeyPath: "C:\\Users\\your-name\\.ssh\\id_ed25519",
|
||||
hostFingerprint: "SHA256:demo",
|
||||
hasPassword: false,
|
||||
hasPassphrase: false,
|
||||
},
|
||||
],
|
||||
preferences: { ...defaultPreferences },
|
||||
operations: [
|
||||
{
|
||||
id: "op-success",
|
||||
type: "deployment",
|
||||
action: "deploy",
|
||||
status: "success",
|
||||
repository: "jens/microsoft-cloud-operations-platform",
|
||||
profileId: "profile-mcop-prod",
|
||||
profileName: "Production",
|
||||
environment: "production",
|
||||
workflowFile: "deploy.yml",
|
||||
branch: "main",
|
||||
sha: "b82f91ab0173cd4346ca0f0f7dcc3e8182cc8fd0",
|
||||
shortSha: "b82f91a",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
stages: [
|
||||
{ id: "requested", label: "Requested", status: "complete" },
|
||||
{ id: "verified", label: "Verified", status: "complete" },
|
||||
{ id: "queued", label: "Workflow queued", status: "complete" },
|
||||
{ id: "runner", label: "Runner execution", status: "complete" },
|
||||
{ id: "healthcheck", label: "Healthcheck", status: "complete" },
|
||||
{ id: "complete", label: "Complete", status: "complete" },
|
||||
],
|
||||
logs: [
|
||||
"[info] Exact commit verified.",
|
||||
"[job] deploy: success",
|
||||
"[ok] Server reports b82f91a and healthcheck returned 200.",
|
||||
],
|
||||
run: {
|
||||
id: 48,
|
||||
runNumber: 48,
|
||||
status: "completed",
|
||||
conclusion: "success",
|
||||
name: "ForgeFlow deployment",
|
||||
},
|
||||
runUrl:
|
||||
"https://gitea.internal/jens/microsoft-cloud-operations-platform/actions/runs/48",
|
||||
},
|
||||
{
|
||||
id: "op-failed",
|
||||
type: "deployment",
|
||||
action: "deploy",
|
||||
status: "failed",
|
||||
repository: "jens/portfolio",
|
||||
profileId: "profile-portfolio",
|
||||
profileName: "Production",
|
||||
environment: "production",
|
||||
workflowFile: "deploy.yml",
|
||||
branch: "main",
|
||||
sha: "a7f2e1c1bb6147fc8b6633d2b08500c93402a719",
|
||||
shortSha: "a7f2e1c",
|
||||
createdAt: iso(-86400000),
|
||||
updatedAt: iso(-86300000),
|
||||
failure: { stage: "healthcheck", message: "Healthcheck returned 502." },
|
||||
stages: [
|
||||
{ id: "requested", label: "Requested", status: "complete" },
|
||||
{ id: "verified", label: "Verified", status: "complete" },
|
||||
{ id: "queued", label: "Workflow queued", status: "complete" },
|
||||
{ id: "runner", label: "Runner execution", status: "complete" },
|
||||
{ id: "healthcheck", label: "Healthcheck", status: "failed" },
|
||||
{ id: "complete", label: "Complete", status: "failed" },
|
||||
],
|
||||
logs: ["[job] deploy: success", "[error] Healthcheck returned 502."],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let repositories = [
|
||||
{
|
||||
id: 1,
|
||||
name: "microsoft-cloud-operations-platform",
|
||||
fullName: "jens/microsoft-cloud-operations-platform",
|
||||
owner: { login: "jens" },
|
||||
description: "Tenant-aware Microsoft cloud operations console.",
|
||||
private: true,
|
||||
defaultBranch: "main",
|
||||
htmlUrl:
|
||||
"https://gitea.internal/jens/microsoft-cloud-operations-platform",
|
||||
cloneUrl:
|
||||
"https://gitea.internal/jens/microsoft-cloud-operations-platform.git",
|
||||
sshUrl: "git@gitea.internal:jens/microsoft-cloud-operations-platform.git",
|
||||
updatedAt: now,
|
||||
localPath: "C:\\Development\\Microsoft-Cloud-Operations-Platform",
|
||||
localStatus: makeStatus({
|
||||
head: "b82f91ab0173cd4346ca0f0f7dcc3e8182cc8fd0",
|
||||
}),
|
||||
linkState: "linked",
|
||||
deploymentProfiles: [
|
||||
profile("profile-mcop-prod", "Production", "production", {
|
||||
liveSha: "72bd10eb0173cd4346ca0f0f7dcc3e8182cc8fd0",
|
||||
previousSha: "6ac991ab0173cd4346ca0f0f7dcc3e8182cc8fd0",
|
||||
healthy: true,
|
||||
}),
|
||||
profile("profile-mcop-stage", "Staging", "staging", {
|
||||
liveSha: "b82f91ab0173cd4346ca0f0f7dcc3e8182cc8fd0",
|
||||
previousSha: "72bd10eb0173cd4346ca0f0f7dcc3e8182cc8fd0",
|
||||
healthy: true,
|
||||
confirmationRequired: false,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "vacancyradar",
|
||||
fullName: "jens/vacancyradar",
|
||||
owner: { login: "jens" },
|
||||
description: "Local-first vacancy intelligence cockpit.",
|
||||
private: true,
|
||||
defaultBranch: "main",
|
||||
htmlUrl: "https://gitea.internal/jens/vacancyradar",
|
||||
cloneUrl: "https://gitea.internal/jens/vacancyradar.git",
|
||||
sshUrl: "git@gitea.internal:jens/vacancyradar.git",
|
||||
updatedAt: now,
|
||||
localPath: "C:\\Development\\VacancyRadar",
|
||||
localStatus: makeStatus({
|
||||
head: "c9182d0d28318c8cf0af109edc054732426aadf1",
|
||||
branch: "feature/deployment-api",
|
||||
files: [
|
||||
makeFile("src/api/deploy.ts", "added", { staged: true }),
|
||||
makeFile("src/main.tsx"),
|
||||
makeFile("src/components/Sidebar.tsx"),
|
||||
],
|
||||
}),
|
||||
linkState: "linked",
|
||||
deploymentProfiles: [
|
||||
profile("profile-vr", "Production", "production", {
|
||||
liveSha: "c117ab9d28318c8cf0af109edc054732426aadf1",
|
||||
previousSha: "b1f57aad28318c8cf0af109edc054732426aadf1",
|
||||
healthy: true,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "unraid-appops-gateway",
|
||||
fullName: "jens/unraid-appops-gateway",
|
||||
owner: { login: "jens" },
|
||||
description: "Safe operations gateway for Unraid and Portainer.",
|
||||
private: true,
|
||||
defaultBranch: "main",
|
||||
htmlUrl: "https://gitea.internal/jens/unraid-appops-gateway",
|
||||
cloneUrl: "https://gitea.internal/jens/unraid-appops-gateway.git",
|
||||
sshUrl: "git@gitea.internal:jens/unraid-appops-gateway.git",
|
||||
updatedAt: now,
|
||||
localPath: "C:\\Development\\Unraid-AppOps-Gateway",
|
||||
localStatus: makeStatus({
|
||||
head: "f2d1e0a1bb6147fc8b6633d2b08500c93402a719",
|
||||
ahead: 2,
|
||||
}),
|
||||
linkState: "linked",
|
||||
deploymentProfiles: [
|
||||
profile("profile-appops", "Production", "production", {
|
||||
liveSha: "8ac731b1bb6147fc8b6633d2b08500c93402a719",
|
||||
previousSha: "7bc198a1bb6147fc8b6633d2b08500c93402a719",
|
||||
healthy: true,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "support-bundle-collector",
|
||||
fullName: "jens/support-bundle-collector",
|
||||
owner: { login: "jens" },
|
||||
description: "Privacy-aware Windows support bundle collector.",
|
||||
private: true,
|
||||
defaultBranch: "main",
|
||||
htmlUrl: "https://gitea.internal/jens/support-bundle-collector",
|
||||
cloneUrl: "https://gitea.internal/jens/support-bundle-collector.git",
|
||||
sshUrl: "git@gitea.internal:jens/support-bundle-collector.git",
|
||||
updatedAt: now,
|
||||
localPath: null,
|
||||
localStatus: null,
|
||||
linkState: "remote-only",
|
||||
deploymentProfiles: [],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "portfolio",
|
||||
fullName: "jens/portfolio",
|
||||
owner: { login: "jens" },
|
||||
description: "Professional infrastructure and automation portfolio.",
|
||||
private: false,
|
||||
defaultBranch: "main",
|
||||
htmlUrl: "https://gitea.internal/jens/portfolio",
|
||||
cloneUrl: "https://gitea.internal/jens/portfolio.git",
|
||||
sshUrl: "git@gitea.internal:jens/portfolio.git",
|
||||
updatedAt: now,
|
||||
localPath: "C:\\Development\\portfolio",
|
||||
localStatus: makeStatus({
|
||||
head: "a7f2e1c1bb6147fc8b6633d2b08500c93402a719",
|
||||
behind: 1,
|
||||
}),
|
||||
linkState: "linked",
|
||||
deploymentProfiles: [
|
||||
sshProfile("profile-portfolio", "Production", "production", {
|
||||
remoteFolder: "Portfolio",
|
||||
containerName: "Portfolio",
|
||||
liveSha: "4c20dd11bb6147fc8b6633d2b08500c93402a719",
|
||||
giteaSha: "a7f2e1c1bb6147fc8b6633d2b08500c93402a719",
|
||||
previousSha: "31adfe11bb6147fc8b6633d2b08500c93402a719",
|
||||
healthy: false,
|
||||
matchesGitea: false,
|
||||
}),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const diffs = {
|
||||
"src/api/deploy.ts": `diff --git a/src/api/deploy.ts b/src/api/deploy.ts\nnew file mode 100644\n--- /dev/null\n+++ b/src/api/deploy.ts\n@@ -0,0 +1,18 @@\n+export interface DeploymentRequest {\n+ environment: 'staging' | 'production';\n+ commitSha: string;\n+}\n+\n+export async function deploy(request: DeploymentRequest) {\n+ return api.post('/deployments', request);\n+}`,
|
||||
"src/main.tsx": `diff --git a/src/main.tsx b/src/main.tsx\nindex 45ad1a2..939fc17 100644\n--- a/src/main.tsx\n+++ b/src/main.tsx\n@@ -24,8 +24,9 @@ import { Router } from './routes';\n-const API_ENDPOINT = 'http://localhost:3000';\n+const API_ENDPOINT = process.env.VITE_API_URL || '/api';\n+const DEPLOY_VERSION = '1.0.4-rc1';`,
|
||||
"src/components/Sidebar.tsx": `diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx\nindex a7bbd82..bf21e90 100644\n--- a/src/components/Sidebar.tsx\n+++ b/src/components/Sidebar.tsx\n@@ -31,6 +31,7 @@ export function Sidebar() {\n+ <NavItem to="/deployments">Deployments</NavItem>`,
|
||||
};
|
||||
|
||||
const findRepo = (localPath) =>
|
||||
repositories.find((item) => item.localPath === localPath);
|
||||
const findProfileRepo = (profileId) =>
|
||||
repositories.find((item) =>
|
||||
item.deploymentProfiles.some((entry) => entry.id === profileId),
|
||||
);
|
||||
const syncState = () => {
|
||||
state.deploymentProfiles = {};
|
||||
state.deploymentStates = {};
|
||||
state.repositoryMappings = {};
|
||||
for (const repository of repositories) {
|
||||
if (repository.localPath)
|
||||
state.repositoryMappings[repository.fullName.toLowerCase()] =
|
||||
repository.localPath;
|
||||
state.deploymentProfiles[repository.fullName.toLowerCase()] =
|
||||
repository.deploymentProfiles.map(
|
||||
({ state: profileState, ...entry }) => entry,
|
||||
);
|
||||
for (const entry of repository.deploymentProfiles)
|
||||
if (entry.state) state.deploymentStates[entry.id] = clone(entry.state);
|
||||
}
|
||||
};
|
||||
const recompute = (repository) => {
|
||||
const status = repository.localStatus;
|
||||
if (status) {
|
||||
status.counts = {
|
||||
changed: status.files.length,
|
||||
staged: status.files.filter((item) => item.staged).length,
|
||||
unstaged: status.files.filter((item) => item.unstaged).length,
|
||||
conflicts: status.files.filter((item) => item.conflict).length,
|
||||
untracked: status.files.filter((item) => item.untracked).length,
|
||||
};
|
||||
status.clean = status.files.length === 0;
|
||||
status.shortHead = status.head.slice(0, 7);
|
||||
status.branch.oid = status.head;
|
||||
}
|
||||
repository.favorite = state.favorites.includes(
|
||||
repository.fullName.toLowerCase(),
|
||||
);
|
||||
repository.readyToDeploy = Boolean(
|
||||
repository.localPath &&
|
||||
status?.clean &&
|
||||
status.branch.upstream &&
|
||||
status.branch.ahead === 0 &&
|
||||
status.branch.behind === 0 &&
|
||||
repository.deploymentProfiles.some(
|
||||
(entry) => entry.branch === status.branch.head,
|
||||
),
|
||||
);
|
||||
repository.attention =
|
||||
!repository.localPath ||
|
||||
Boolean(
|
||||
status?.counts.conflicts ||
|
||||
status?.branch.behind ||
|
||||
status?.branch.ahead ||
|
||||
status?.counts.changed,
|
||||
);
|
||||
repository.attentionReason = !repository.localPath
|
||||
? "No local folder linked"
|
||||
: status?.counts.conflicts
|
||||
? `${status.counts.conflicts} conflict(s)`
|
||||
: status?.counts.changed
|
||||
? `${status.counts.changed} local change(s)`
|
||||
: status?.branch.behind
|
||||
? `${status.branch.behind} commit(s) behind remote`
|
||||
: status?.branch.ahead
|
||||
? `${status.branch.ahead} unpushed commit(s)`
|
||||
: null;
|
||||
repository.preferredCloneUrl =
|
||||
state.preferences.preferredCloneProtocol === "ssh"
|
||||
? repository.sshUrl
|
||||
: repository.cloneUrl;
|
||||
};
|
||||
const snapshot = () => {
|
||||
repositories.forEach(recompute);
|
||||
syncState();
|
||||
return clone(repositories);
|
||||
};
|
||||
syncState();
|
||||
|
||||
const commitHistory = [
|
||||
{
|
||||
sha: "c9182d0d28318c8cf0af109edc054732426aadf1",
|
||||
shortSha: "c9182d0",
|
||||
author: "Jens",
|
||||
date: now,
|
||||
subject: "feat: add deployment provider contract",
|
||||
},
|
||||
{
|
||||
sha: "1fa7399d28318c8cf0af109edc054732426aadf1",
|
||||
shortSha: "1fa7399",
|
||||
author: "Jens",
|
||||
date: iso(-86400000),
|
||||
subject: "refactor: consolidate repository state",
|
||||
},
|
||||
{
|
||||
sha: "a251a11d28318c8cf0af109edc054732426aadf1",
|
||||
shortSha: "a251a11",
|
||||
author: "Jens",
|
||||
date: iso(-172800000),
|
||||
subject: "docs: define deployment safety gates",
|
||||
},
|
||||
];
|
||||
const branchesByRepo = new Map();
|
||||
const stashesByRepo = new Map();
|
||||
|
||||
function updateOperation(operation) {
|
||||
state.operations = [
|
||||
clone(operation),
|
||||
...state.operations.filter((item) => item.id !== operation.id),
|
||||
].slice(0, 250);
|
||||
emitOperations([operation]);
|
||||
return clone(operation);
|
||||
}
|
||||
|
||||
function advanceOperation(operation) {
|
||||
if (
|
||||
!operation ||
|
||||
["success", "failed", "cancelled", "rolled-back"].includes(
|
||||
operation.status,
|
||||
)
|
||||
)
|
||||
return operation;
|
||||
operation.demoPolls = (operation.demoPolls || 0) + 1;
|
||||
if (operation.demoPolls === 1) {
|
||||
operation.status = "running";
|
||||
operation.run = {
|
||||
id: 81,
|
||||
runNumber: 81,
|
||||
status: "running",
|
||||
conclusion: null,
|
||||
name:
|
||||
operation.action === "rollback"
|
||||
? "ForgeFlow rollback"
|
||||
: "ForgeFlow deployment",
|
||||
};
|
||||
operation.runUrl = `https://gitea.internal/${operation.repository}/actions/runs/81`;
|
||||
operation.stages.find((item) => item.id === "queued").status = "complete";
|
||||
operation.stages.find((item) => item.id === "runner").status = "active";
|
||||
operation.jobs = [
|
||||
{
|
||||
id: 201,
|
||||
name: operation.action === "rollback" ? "rollback" : "deploy",
|
||||
status: "running",
|
||||
conclusion: null,
|
||||
},
|
||||
];
|
||||
operation.logs.push(`[job] ${operation.jobs[0].name}: running`);
|
||||
} else if (operation.demoPolls >= 2) {
|
||||
operation.status =
|
||||
operation.action === "rollback" ? "rolled-back" : "success";
|
||||
operation.stages.forEach((item) => {
|
||||
item.status = "complete";
|
||||
});
|
||||
operation.jobs = [
|
||||
{
|
||||
id: 201,
|
||||
name: operation.action === "rollback" ? "rollback" : "deploy",
|
||||
status: "completed",
|
||||
conclusion: "success",
|
||||
},
|
||||
];
|
||||
operation.logs.push(
|
||||
"[ok] Runner completed successfully.",
|
||||
`[ok] Server status endpoint confirms ${operation.shortSha}.`,
|
||||
);
|
||||
const repository = repositories.find(
|
||||
(item) => item.fullName === operation.repository,
|
||||
);
|
||||
const targetProfile = repository?.deploymentProfiles.find(
|
||||
(item) => item.id === operation.profileId,
|
||||
);
|
||||
if (targetProfile) {
|
||||
const oldLive = targetProfile.state.liveSha;
|
||||
targetProfile.state.previousSha = oldLive;
|
||||
targetProfile.state.liveSha = operation.sha;
|
||||
targetProfile.state.healthy = true;
|
||||
targetProfile.state.checkedAt = iso();
|
||||
}
|
||||
}
|
||||
operation.updatedAt = iso();
|
||||
return operation;
|
||||
}
|
||||
|
||||
const bridgeContext = { wait, clone, iso, storage, repositoryListeners, operationListeners, updateListeners, emitRepositories, emitOperations, randomSha, now, profile, state, repositories, recompute, snapshot, commitHistory, advanceOperation, syncState, diffs, findRepo, findProfileRepo, branchesByRepo, stashesByRepo, updateOperation };
|
||||
window.forgeflow = Object.freeze({
|
||||
...createMockRepositoryBridge(bridgeContext),
|
||||
...createMockDeploymentBridge(bridgeContext),
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,700 @@
|
||||
function createMockDeploymentBridge(context) {
|
||||
const { wait, clone, iso, storage, repositoryListeners, operationListeners, updateListeners, emitRepositories, emitOperations, randomSha, now, profile, state, repositories, recompute, snapshot, commitHistory, advanceOperation, syncState, diffs, findRepo, findProfileRepo, branchesByRepo, stashesByRepo, updateOperation } = context;
|
||||
return {
|
||||
async saveDeploymentProfile(fullName, input) {
|
||||
const repo = repositories.find((item) => item.fullName === fullName);
|
||||
const existing = repo.deploymentProfiles.find(
|
||||
(item) => item.id === input.id,
|
||||
);
|
||||
const saved = {
|
||||
...(existing ||
|
||||
profile(
|
||||
input.id || `profile-${Date.now()}`,
|
||||
input.name || input.environment,
|
||||
input.environment || "production",
|
||||
)),
|
||||
...input,
|
||||
id: input.id || `profile-${Date.now()}`,
|
||||
provider: input.provider || existing?.provider || "gitea-actions",
|
||||
inputs: existing?.inputs || {},
|
||||
state: existing?.state || {
|
||||
liveSha: null,
|
||||
previousSha: null,
|
||||
healthy: null,
|
||||
healthConfigured: Boolean(input.healthcheckUrl),
|
||||
statusConfigured: Boolean(input.statusUrl),
|
||||
checkedAt: null,
|
||||
},
|
||||
};
|
||||
repo.deploymentProfiles = [
|
||||
...repo.deploymentProfiles.filter((item) => item.id !== saved.id),
|
||||
saved,
|
||||
];
|
||||
snapshot();
|
||||
return { profile: clone(saved), state: clone(state) };
|
||||
},
|
||||
async deleteDeploymentProfile(fullName, profileId) {
|
||||
const repo = repositories.find((item) => item.fullName === fullName);
|
||||
repo.deploymentProfiles = repo.deploymentProfiles.filter(
|
||||
(item) => item.id !== profileId,
|
||||
);
|
||||
snapshot();
|
||||
return { profiles: clone(repo.deploymentProfiles), state: clone(state) };
|
||||
},
|
||||
async deploymentPreflight(repository, profileId) {
|
||||
await wait(280);
|
||||
const profile = repository.deploymentProfiles.find(
|
||||
(item) => item.id === profileId,
|
||||
);
|
||||
const status = repository.localStatus;
|
||||
const checks = [
|
||||
{
|
||||
id: "repository.linked",
|
||||
label: "Local repository link",
|
||||
status: repository.localPath ? "pass" : "fail",
|
||||
detail: repository.localPath || "No local folder linked.",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "git.branch",
|
||||
label: "Allowed branch",
|
||||
status: status?.branch.head === profile?.branch ? "pass" : "fail",
|
||||
detail: `Current: ${status?.branch.head || "unknown"}; required: ${profile?.branch || "unknown"}.`,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "git.clean",
|
||||
label: "Clean working tree",
|
||||
status: status?.clean ? "pass" : "fail",
|
||||
detail: status?.clean
|
||||
? "No uncommitted changes."
|
||||
: `${status?.counts.changed || 0} changed file(s).`,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "git.sync",
|
||||
label: "Local and Gitea synchronized",
|
||||
status:
|
||||
!status?.branch.ahead && !status?.branch.behind ? "pass" : "fail",
|
||||
detail: `${status?.branch.ahead || 0} ahead, ${status?.branch.behind || 0} behind.`,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "workflow.deploy.remote",
|
||||
label: "Deploy workflow on Gitea branch",
|
||||
status: "pass",
|
||||
detail: `${profile?.workflowFile || "deploy.yml"} exists on ${profile?.branch || "main"}.`,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "gitea.actions",
|
||||
label: "Gitea Actions API",
|
||||
status: "pass",
|
||||
detail: "The Actions runs endpoint is accessible.",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "server.status",
|
||||
label: "Server version endpoint",
|
||||
status: profile?.statusUrl ? "pass" : "warning",
|
||||
detail: profile?.statusUrl
|
||||
? `Endpoint reachable; live ${profile.state?.liveSha?.slice(0, 7) || "unknown"}.`
|
||||
: "No status URL configured.",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: "server.health",
|
||||
label: "Application healthcheck",
|
||||
status: profile?.healthcheckUrl ? "pass" : "warning",
|
||||
detail: profile?.healthcheckUrl
|
||||
? "HTTP 200 in 42 ms."
|
||||
: "No healthcheck URL configured.",
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
const blocking = checks
|
||||
.filter((i) => i.required && i.status === "fail")
|
||||
.map((i) => i.id);
|
||||
return {
|
||||
kind: "deployment",
|
||||
repository: repository.fullName,
|
||||
profileId,
|
||||
startedAt: iso(-100),
|
||||
completedAt: iso(),
|
||||
checks,
|
||||
summary: {
|
||||
counts: {
|
||||
pass: checks.filter((i) => i.status === "pass").length,
|
||||
warning: checks.filter((i) => i.status === "warning").length,
|
||||
fail: checks.filter((i) => i.status === "fail").length,
|
||||
skipped: 0,
|
||||
},
|
||||
blocking,
|
||||
ready: blocking.length === 0,
|
||||
},
|
||||
head: status?.head || null,
|
||||
};
|
||||
},
|
||||
async deploy(repository, profileId, sha) {
|
||||
await wait(320);
|
||||
const selected = repository.deploymentProfiles.find(
|
||||
(item) => item.id === profileId,
|
||||
);
|
||||
const operation = {
|
||||
id: `deploy-${Date.now()}`,
|
||||
type: "deployment",
|
||||
action: "deploy",
|
||||
status: "queued",
|
||||
repository: repository.fullName,
|
||||
profileId,
|
||||
profileName: selected.name,
|
||||
environment: selected.environment,
|
||||
workflowFile: selected.workflowFile,
|
||||
branch: selected.branch,
|
||||
sha,
|
||||
shortSha: sha.slice(0, 7),
|
||||
dispatchedAt: iso(),
|
||||
createdAt: iso(),
|
||||
updatedAt: iso(),
|
||||
demoPolls: 0,
|
||||
stages: [
|
||||
{ id: "requested", label: "Requested", status: "complete" },
|
||||
{ id: "verified", label: "Verified", status: "complete" },
|
||||
{ id: "queued", label: "Workflow queued", status: "active" },
|
||||
{ id: "runner", label: "Runner execution", status: "pending" },
|
||||
{ id: "healthcheck", label: "Healthcheck", status: "pending" },
|
||||
{ id: "complete", label: "Complete", status: "pending" },
|
||||
],
|
||||
logs: [
|
||||
`[info] Verified clean ${selected.branch} at ${sha}`,
|
||||
`[ok] Gitea accepted ${selected.workflowFile}.`,
|
||||
],
|
||||
};
|
||||
return updateOperation(operation);
|
||||
},
|
||||
async rollback(repository, profileId, targetSha) {
|
||||
await wait(320);
|
||||
const selected = repository.deploymentProfiles.find(
|
||||
(item) => item.id === profileId,
|
||||
);
|
||||
const operation = {
|
||||
id: `rollback-${Date.now()}`,
|
||||
type: "deployment",
|
||||
action: "rollback",
|
||||
status: "queued",
|
||||
repository: repository.fullName,
|
||||
profileId,
|
||||
profileName: selected.name,
|
||||
environment: selected.environment,
|
||||
workflowFile: selected.rollbackWorkflowFile,
|
||||
branch: selected.branch,
|
||||
sha: targetSha,
|
||||
shortSha: targetSha.slice(0, 7),
|
||||
dispatchedAt: iso(),
|
||||
createdAt: iso(),
|
||||
updatedAt: iso(),
|
||||
demoPolls: 0,
|
||||
stages: [
|
||||
{ id: "requested", label: "Requested", status: "complete" },
|
||||
{ id: "verified", label: "Verified", status: "complete" },
|
||||
{ id: "queued", label: "Workflow queued", status: "active" },
|
||||
{ id: "runner", label: "Runner execution", status: "pending" },
|
||||
{ id: "healthcheck", label: "Healthcheck", status: "pending" },
|
||||
{ id: "complete", label: "Complete", status: "pending" },
|
||||
],
|
||||
logs: [
|
||||
`[warning] Rollback target verified: ${targetSha}`,
|
||||
`[ok] Gitea accepted ${selected.rollbackWorkflowFile}.`,
|
||||
],
|
||||
};
|
||||
return updateOperation(operation);
|
||||
},
|
||||
async healthcheck() {
|
||||
await wait(160);
|
||||
return { configured: true, healthy: true, status: 200, latencyMs: 42 };
|
||||
},
|
||||
async refreshProfileState(fullName, profileId) {
|
||||
await wait(240);
|
||||
const repo =
|
||||
repositories.find((item) => item.fullName === fullName) ||
|
||||
findProfileRepo(profileId);
|
||||
const target = repo?.deploymentProfiles.find(
|
||||
(item) => item.id === profileId,
|
||||
);
|
||||
if (!target) throw new Error("Deployment profile not found.");
|
||||
target.state = {
|
||||
...target.state,
|
||||
checkedAt: iso(),
|
||||
healthy: target.state.healthy !== false,
|
||||
healthConfigured: Boolean(target.healthcheckUrl),
|
||||
statusConfigured: Boolean(target.statusUrl),
|
||||
};
|
||||
syncState();
|
||||
return clone(target.state);
|
||||
},
|
||||
async discoverServerDeployments() {
|
||||
await wait(80);
|
||||
return [
|
||||
{
|
||||
serverId: "server-unraid",
|
||||
serverName: "Unraid",
|
||||
detected: 3,
|
||||
adopted: 0,
|
||||
verified: 1,
|
||||
refreshedProfiles: 1,
|
||||
refreshedProfileIds: ["profile-portfolio"],
|
||||
linked: 2,
|
||||
unmatched: 0,
|
||||
needsReview: 2,
|
||||
running: 3,
|
||||
stopped: 0,
|
||||
capabilities: {
|
||||
docker: true,
|
||||
dockerReady: true,
|
||||
compose: true,
|
||||
git: false,
|
||||
tar: true,
|
||||
checksum: true,
|
||||
},
|
||||
warnings: [],
|
||||
workloads: [
|
||||
{
|
||||
workloadId: "workload-demo-linked",
|
||||
displayName: "Portfolio",
|
||||
status: "linked",
|
||||
runtime: { running: true, health: "healthy" },
|
||||
compose: {
|
||||
project: "portfolio",
|
||||
workingDir: "/mnt/user/appdata/portfolio",
|
||||
configFiles: ["/mnt/user/appdata/portfolio/docker-compose.yml"],
|
||||
services: ["web"],
|
||||
},
|
||||
containers: [{ name: "Portfolio", running: true }],
|
||||
candidates: [],
|
||||
link: {
|
||||
profileId: "profile-portfolio",
|
||||
repositoryFullName: "jens/portfolio",
|
||||
source: "manual",
|
||||
},
|
||||
},
|
||||
{
|
||||
workloadId: "workload-demo-review",
|
||||
displayName: "OmniRoute",
|
||||
status: "suggested",
|
||||
runtime: { running: true, health: "unverified" },
|
||||
compose: {
|
||||
project: "omniroute",
|
||||
workingDir: "/mnt/user/appdata/OmniRoute",
|
||||
configFiles: ["/mnt/user/appdata/OmniRoute/docker-compose.yml"],
|
||||
services: ["omniroute"],
|
||||
},
|
||||
containers: [{ name: "omniroute", running: true }],
|
||||
remoteFolderCandidate: "OmniRoute",
|
||||
candidates: repositories.slice(0, 1).map((repository) => ({
|
||||
repositoryFullName: repository.fullName,
|
||||
repositoryName: repository.name,
|
||||
score: 55,
|
||||
exact: false,
|
||||
reasons: ["container and repository names are similar"],
|
||||
})),
|
||||
},
|
||||
{
|
||||
workloadId: "workload-demo-unresolved",
|
||||
displayName: "Legacy Worker",
|
||||
status: "linked",
|
||||
runtime: { running: true, health: "healthy" },
|
||||
containers: [{ name: "legacy-worker", running: true }],
|
||||
candidates: [],
|
||||
link: {
|
||||
profileId: "profile-that-no-longer-exists",
|
||||
repositoryFullName: "jens/removed-repository",
|
||||
source: "manual",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
async planServerReconciliation(serverId) {
|
||||
await wait(90);
|
||||
const id = "a".repeat(64);
|
||||
return {
|
||||
inventory: (await this.discoverServerDeployments()).find((item) => item.serverId === serverId),
|
||||
plan: {
|
||||
id,
|
||||
serverId,
|
||||
summary: { additions: 0, updates: 1, stale: 0, conflicts: 1 },
|
||||
additions: [],
|
||||
updates: [{ workloadId: "workload-demo-linked", profileId: "profile-portfolio", repositoryFullName: "jens/portfolio", impact: "Refresh detected Compose identity and observed deployment state" }],
|
||||
stale: [],
|
||||
conflicts: [{ workloadId: "workload-demo-review", displayName: "OmniRoute", status: "suggested", candidates: [{ repositoryFullName: repositories[0].fullName, score: 55, exact: false }] }],
|
||||
},
|
||||
};
|
||||
},
|
||||
async applyServerReconciliation(serverId, planId) {
|
||||
await wait(120);
|
||||
if (serverId !== "server-unraid" || planId !== "a".repeat(64)) throw new Error("The reconciliation plan is stale.");
|
||||
return { adopted: 0, refreshed: 1, retired: 0, state: clone(state) };
|
||||
},
|
||||
async planInventoryReview(serverId, workloadId, action, reason = "", repositoryFullName = null) {
|
||||
if (["ignore", "manual-exclude", "exclude-scan-root"].includes(action) && reason.length < 5) throw new Error("A meaningful review reason is required.");
|
||||
return { id: "b".repeat(64), serverId, workloadId, action, reason, repositoryFullName, evidenceHash: "c".repeat(64), classification: "ambiguous", containersUnaffected: true, configurationChanges: [`Persist review decision ${action}`], recovery: "Remove the decision or rescan after evidence changes." };
|
||||
},
|
||||
async applyInventoryReview(serverId, workloadId, action, reason, repositoryFullName, planId) {
|
||||
if (planId !== "b".repeat(64)) throw new Error("The inventory review plan is stale.");
|
||||
const inventory = (await this.discoverServerDeployments()).find((item) => item.serverId === serverId);
|
||||
const workload = inventory.workloads.find((item) => item.workloadId === workloadId);
|
||||
if (workload) workload.reviewDecision = { action, reason, repositoryFullName, evidenceHash: "c".repeat(64) };
|
||||
return { decision: workload?.reviewDecision, inventory, state: clone(state) };
|
||||
},
|
||||
async linkServerWorkload(repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "") {
|
||||
await wait(120);
|
||||
const repo = repositories.find((item) => item.fullName === repository.fullName);
|
||||
if (!repo) throw new Error("Repository not found.");
|
||||
const id = `profile-${workloadId}`;
|
||||
const saved = {
|
||||
id,
|
||||
name: `Unraid · ${remoteFolder || repo.name}`,
|
||||
environment: "production",
|
||||
provider: "ssh-unraid",
|
||||
branch: repo.defaultBranch || "main",
|
||||
serverId,
|
||||
remoteFolder: remoteFolder || repo.name,
|
||||
deploymentMode,
|
||||
composeFile: "docker-compose.yml",
|
||||
composeFiles: ["docker-compose.yml"],
|
||||
composeProject: String(remoteFolder || repo.name).toLowerCase(),
|
||||
composeService: String(remoteFolder || repo.name).toLowerCase(),
|
||||
composeServices: [String(remoteFolder || repo.name).toLowerCase()],
|
||||
containerName: remoteFolder || repo.name,
|
||||
preservePaths: [".env", "appdata", "data", "logs", "config"],
|
||||
generatedCompose: false,
|
||||
adoptedFromServer: true,
|
||||
serverSourceOfTruth: true,
|
||||
manageDockerMan: false,
|
||||
forceRecreate: false,
|
||||
removeOrphans: false,
|
||||
workloadIdentity: { workloadId, linkSource: "manual", linkedAt: iso() },
|
||||
confirmationRequired: true,
|
||||
state: {
|
||||
liveSha: null,
|
||||
healthy: null,
|
||||
containerRunning: true,
|
||||
runtimeVerification: "running-unverified",
|
||||
checkedAt: iso(),
|
||||
},
|
||||
};
|
||||
repo.deploymentProfiles = [
|
||||
...repo.deploymentProfiles.filter((item) => item.id !== id),
|
||||
saved,
|
||||
];
|
||||
syncState();
|
||||
return { profile: clone(saved), state: clone(state) };
|
||||
},
|
||||
async configureServerGitAccess(repository, profileId) {
|
||||
const repo = repositories.find((item) => item.fullName === repository.fullName);
|
||||
const target = repo?.deploymentProfiles.find((item) => item.id === profileId);
|
||||
if (!target) throw new Error("Deployment profile not found.");
|
||||
target.deploymentMode = "server-git";
|
||||
target.serverGitAccess = { configured: true, keyFingerprint: "SHA256:demo", hostFingerprint: "SHA256:gitea", configuredAt: iso() };
|
||||
syncState();
|
||||
return { profile: clone(target), created: true, remoteSha: target.state?.giteaSha || repo.localStatus?.head };
|
||||
},
|
||||
async verifyServerGitProfile(repository, profileId) {
|
||||
const repo = repositories.find((item) => item.fullName === repository.fullName);
|
||||
const target = repo?.deploymentProfiles.find((item) => item.id === profileId);
|
||||
if (!target) throw new Error("Deployment profile not found.");
|
||||
const branchSha = target.state?.giteaSha || repo.localStatus?.head || null;
|
||||
const liveSha = target.state?.liveSha || null;
|
||||
return {
|
||||
readiness: branchSha && liveSha === branchSha ? "Ready" : "Commit mismatch",
|
||||
ready: true,
|
||||
checkedAt: iso(),
|
||||
repository: repo.fullName,
|
||||
profileId,
|
||||
branchSha,
|
||||
liveSha,
|
||||
checks: [
|
||||
{ id: "remote-branch", label: "Gitea branch", status: "pass", detail: "Exact branch resolved." },
|
||||
{ id: "deploy-key-scope", label: "Repository deploy key", status: "pass", detail: "Repository-scoped and read-only." },
|
||||
{ id: "server-git-access", label: "Unraid to Gitea", status: "pass", detail: "Pinned SSH access verified." },
|
||||
],
|
||||
};
|
||||
},
|
||||
async deployKeyInventory(repository, profileId) {
|
||||
const repo = repositories.find((item) => item.fullName === repository.fullName);
|
||||
const profile = repo?.deploymentProfiles.find((item) => item.id === profileId);
|
||||
return { repository: repo.fullName, profileId, server: { id: profile.serverId, name: "Unraid" }, configuredKey: { id: profile.serverGitAccess?.deployKeyId || 17, readOnly: true }, serverKey: { privateKeyPresent: true, fingerprint: profile.serverGitAccess?.keyFingerprint || "SHA256:demo" }, stale: false, orphaned: [], shared: [], conflicts: [], ready: true, checkedAt: iso() };
|
||||
},
|
||||
async planDeployKeyRotation(repository, profileId) {
|
||||
const evidence = await this.deployKeyInventory(repository, profileId);
|
||||
return { id: `rotation-${profileId}`, operation: "rotate-deploy-key", impact: ["Generate a new server-side key", "Verify read-only access", "Switch atomically", "Revoke the previous key"], recovery: "Previous access remains recoverable until verification succeeds.", evidence };
|
||||
},
|
||||
async applyDeployKeyRotation(repository, profileId) {
|
||||
const repo = repositories.find((item) => item.fullName === repository.fullName); const profile = repo.deploymentProfiles.find((item) => item.id === profileId);
|
||||
profile.serverGitAccess = { ...profile.serverGitAccess, configured: true, deployKeyId: 18, keyFingerprint: "SHA256:rotated", rotatedAt: iso() }; syncState(); return { profile: clone(profile), state: clone(state) };
|
||||
},
|
||||
async planDeployKeyRevocation(repository, profileId) {
|
||||
const evidence = await this.deployKeyInventory(repository, profileId);
|
||||
return { id: `revocation-${profileId}`, operation: "revoke-deploy-key", impact: ["Remove the repository key", "Disable server pull", "Preserve recovery material"], containersUnaffected: true, evidence };
|
||||
},
|
||||
async applyDeployKeyRevocation(repository, profileId) {
|
||||
const repo = repositories.find((item) => item.fullName === repository.fullName); const profile = repo.deploymentProfiles.find((item) => item.id === profileId);
|
||||
profile.deploymentMode = "monitor-only"; profile.serverGitAccess = { ...profile.serverGitAccess, configured: false, revokedAt: iso(), recoveryAvailable: true }; syncState(); return { profile: clone(profile), state: clone(state) };
|
||||
},
|
||||
async restoreDeployKey(repository, profileId) {
|
||||
const repo = repositories.find((item) => item.fullName === repository.fullName); const profile = repo.deploymentProfiles.find((item) => item.id === profileId);
|
||||
profile.deploymentMode = "server-git"; profile.serverGitAccess = { ...profile.serverGitAccess, configured: true, deployKeyId: 19, keyFingerprint: "SHA256:restored", restoredAt: iso() }; syncState(); return { profile: clone(profile), state: clone(state), proof: { ready: true } };
|
||||
},
|
||||
async refreshOperations(operationId = null) {
|
||||
await wait(300);
|
||||
if (operationId) {
|
||||
const operation = state.operations.find(
|
||||
(item) => item.id === operationId,
|
||||
);
|
||||
if (!operation) throw new Error("Operation not found.");
|
||||
return updateOperation(advanceOperation(operation));
|
||||
}
|
||||
const active = state.operations
|
||||
.filter(
|
||||
(item) =>
|
||||
!["success", "failed", "cancelled", "rolled-back"].includes(
|
||||
item.status,
|
||||
),
|
||||
)
|
||||
.map(advanceOperation);
|
||||
if (active.length) emitOperations(active);
|
||||
state.operations = state.operations.map(
|
||||
(item) => active.find((entry) => entry.id === item.id) || item,
|
||||
);
|
||||
return clone(active);
|
||||
},
|
||||
async getOperation(operationId) {
|
||||
return clone(
|
||||
state.operations.find((item) => item.id === operationId) || null,
|
||||
);
|
||||
},
|
||||
async gitValidatorScan(fullName) {
|
||||
await wait(260);
|
||||
const policy = state.gitValidatorPolicy || { id: "standard", label: "Standard", requiredScore: 70 };
|
||||
const activeWarnings = 3;
|
||||
return {
|
||||
repository: fullName,
|
||||
checkedAt: iso(),
|
||||
score: 78,
|
||||
grade: "Good",
|
||||
policy,
|
||||
ready: 78 >= policy.requiredScore && (policy.id === "minimal" || activeWarnings === 0),
|
||||
commitSha: "8cbaf303aa3bb9b4023a7c89aa13fb70ce612847",
|
||||
trend: { newlyFound: ["working-tree"], resolved: ["editorconfig"], regressions: [], suppressions: [] },
|
||||
expiredSuppressions: [],
|
||||
summary: { passed: 7, warnings: 3, errors: 0, repairable: 2 },
|
||||
checks: [
|
||||
{
|
||||
id: "origin",
|
||||
category: "Repository identity",
|
||||
title: "Origin matches Gitea",
|
||||
status: "pass",
|
||||
detail: "The local origin resolves to this Gitea repository.",
|
||||
weight: 15,
|
||||
},
|
||||
{
|
||||
id: "default-branch-protection",
|
||||
category: "Gitea governance",
|
||||
title: "Default branch protection",
|
||||
status: "warning",
|
||||
detail: "main accepts unprotected direct changes.",
|
||||
weight: 18,
|
||||
fixAction: "protect-default-branch",
|
||||
safe: false,
|
||||
confirmation:
|
||||
"Protect main on Gitea and block direct and force pushes?",
|
||||
},
|
||||
{
|
||||
id: "force-push",
|
||||
category: "Gitea governance",
|
||||
title: "Force-push protection",
|
||||
status: "pass",
|
||||
detail: "Force pushes are blocked.",
|
||||
weight: 8,
|
||||
},
|
||||
{
|
||||
id: "upstream",
|
||||
category: "Branch hygiene",
|
||||
title: "Current branch has an upstream",
|
||||
status: "pass",
|
||||
detail: "main tracks origin/main.",
|
||||
weight: 8,
|
||||
},
|
||||
{
|
||||
id: "working-tree",
|
||||
category: "Branch hygiene",
|
||||
title: "Working tree is intentional",
|
||||
status: "warning",
|
||||
detail: "3 changed files require review, commit or stash.",
|
||||
weight: 5,
|
||||
},
|
||||
{
|
||||
id: "identity",
|
||||
category: "Commit integrity",
|
||||
title: "Repository author identity",
|
||||
status: "pass",
|
||||
detail: "Jens <jens@example.test>",
|
||||
weight: 7,
|
||||
},
|
||||
{
|
||||
id: "local-safety",
|
||||
category: "Local configuration",
|
||||
title: "Safe synchronization defaults",
|
||||
status: "warning",
|
||||
detail: "Recommended repository-local safeguards are incomplete.",
|
||||
weight: 10,
|
||||
fixAction: "configure-local-safety",
|
||||
safe: true,
|
||||
},
|
||||
{
|
||||
id: "readme",
|
||||
category: "Repository documentation",
|
||||
title: "README is versioned",
|
||||
status: "pass",
|
||||
detail: "Repository documentation is tracked.",
|
||||
weight: 7,
|
||||
},
|
||||
{
|
||||
id: "gitignore",
|
||||
category: "Repository hygiene",
|
||||
title: ".gitignore is versioned",
|
||||
status: "pass",
|
||||
detail: "Generated files are excluded centrally.",
|
||||
weight: 8,
|
||||
},
|
||||
{
|
||||
id: "tracked-secrets",
|
||||
category: "Security",
|
||||
title: "No secret-shaped files are tracked",
|
||||
status: "pass",
|
||||
detail:
|
||||
"No tracked environment, key or credential filenames detected.",
|
||||
weight: 22,
|
||||
},
|
||||
{
|
||||
id: "large-files",
|
||||
category: "Repository performance",
|
||||
title: "No oversized tracked files",
|
||||
status: "pass",
|
||||
detail: "No tracked files above 10 MB were found.",
|
||||
weight: 7,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
async gitValidatorSetPolicy(_fullName, policy) {
|
||||
const requiredScores = { minimal: 55, standard: 70, strict: 82, production: 90 };
|
||||
state.gitValidatorPolicy = {
|
||||
id: policy.id,
|
||||
label: policy.id[0].toUpperCase() + policy.id.slice(1),
|
||||
requiredScore: requiredScores[policy.id] || 70,
|
||||
};
|
||||
return clone(state.gitValidatorPolicy);
|
||||
},
|
||||
async gitValidatorSuppress(_fullName, suppression) {
|
||||
return { ...suppression, id: `suppression-${Date.now()}`, createdAt: iso() };
|
||||
},
|
||||
async gitValidatorPreviewRepair(_fullName, check) {
|
||||
return { checkId: check.id, action: check.fixAction, files: [".git/config"], diff: "+ reviewed configuration change\n", remoteMutation: check.fixAction === "protect-default-branch" };
|
||||
},
|
||||
async gitValidatorExport(fullName, format) {
|
||||
return { extension: format === "markdown" ? "md" : format, mimeType: "text/plain", content: `# Git assurance — ${fullName}\n` };
|
||||
},
|
||||
async gitValidatorRepair() {
|
||||
await wait(180);
|
||||
return { repaired: true };
|
||||
},
|
||||
async diagnosticsStatus() {
|
||||
return {
|
||||
enabled: state.preferences.diagnosticsEnabled !== false,
|
||||
level: state.preferences.diagnosticLevel,
|
||||
retentionDays: state.preferences.logRetentionDays,
|
||||
maxFileMb: state.preferences.maxLogFileMb,
|
||||
directory: "<HOME>/AppData/Roaming/ForgeFlow/diagnostics",
|
||||
fileCount: 2,
|
||||
totalBytes: 18432,
|
||||
totalSize: "18.0 KB",
|
||||
latestAt: iso(-2000),
|
||||
lastWriteError: null,
|
||||
};
|
||||
},
|
||||
async exportConfigurationBackup() {
|
||||
return {
|
||||
filePath: "C:\\Downloads\\ForgeFlow-Configuration-demo.ffbackup",
|
||||
};
|
||||
},
|
||||
async importConfigurationBackup() {
|
||||
return { state: clone(state), exportedAt: iso(-86400000) };
|
||||
},
|
||||
async listAuditEvents() {
|
||||
return [
|
||||
{
|
||||
id: "audit-1",
|
||||
timestamp: iso(-60000),
|
||||
event: "deployment.completed",
|
||||
details: { repository: "Jens/ForgeFlow", result: "success" },
|
||||
},
|
||||
];
|
||||
},
|
||||
async exportAuditLog() {
|
||||
return { filePath: "C:\\Downloads\\ForgeFlow-Audit-demo.json", count: 1 };
|
||||
},
|
||||
async clearDiagnostics() {
|
||||
return {
|
||||
enabled: true,
|
||||
level: state.preferences.diagnosticLevel,
|
||||
retentionDays: state.preferences.logRetentionDays,
|
||||
maxFileMb: state.preferences.maxLogFileMb,
|
||||
directory: "<HOME>/AppData/Roaming/ForgeFlow/diagnostics",
|
||||
fileCount: 1,
|
||||
totalBytes: 256,
|
||||
totalSize: "256 B",
|
||||
latestAt: iso(),
|
||||
lastWriteError: null,
|
||||
};
|
||||
},
|
||||
async openDiagnosticsFolder() {
|
||||
return true;
|
||||
},
|
||||
async exportDiagnostics(privacyMode = "standard") {
|
||||
await wait(500);
|
||||
return {
|
||||
path: `C:\Users\your-name\Downloads\ForgeFlow-Diagnostics-demo.zip`,
|
||||
bytes: 38221,
|
||||
size: "37.3 KB",
|
||||
sha256: "b".repeat(64),
|
||||
privacyMode,
|
||||
generatedAt: iso(),
|
||||
};
|
||||
},
|
||||
async showDiagnosticBundle() {
|
||||
return true;
|
||||
},
|
||||
async reportRendererEvent() {
|
||||
return true;
|
||||
},
|
||||
onRepositoriesChanged(listener) {
|
||||
repositoryListeners.add(listener);
|
||||
return () => repositoryListeners.delete(listener);
|
||||
},
|
||||
onOperationsChanged(listener) {
|
||||
operationListeners.add(listener);
|
||||
return () => operationListeners.delete(listener);
|
||||
},
|
||||
onUpdatesChanged(listener) {
|
||||
updateListeners.add(listener);
|
||||
return () => updateListeners.delete(listener);
|
||||
},
|
||||
async reset() {
|
||||
state.setupComplete = false;
|
||||
storage.set("forgeflow-demo-setup", "false");
|
||||
return clone(state);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,779 @@
|
||||
function createMockRepositoryBridge(context) {
|
||||
const { wait, clone, iso, storage, repositoryListeners, operationListeners, updateListeners, emitRepositories, emitOperations, randomSha, now, profile, state, repositories, recompute, snapshot, commitHistory, advanceOperation, syncState, diffs, findRepo, findProfileRepo, branchesByRepo, stashesByRepo, updateOperation } = context;
|
||||
return {
|
||||
async bootstrap() {
|
||||
await wait(80);
|
||||
snapshot();
|
||||
return {
|
||||
appVersion: "0.10.15-demo",
|
||||
platform: "win32",
|
||||
state: clone(state),
|
||||
git: { available: true, version: "git version 2.47.3" },
|
||||
diagnostics: {
|
||||
enabled: true,
|
||||
level: state.preferences.diagnosticLevel,
|
||||
retentionDays: state.preferences.logRetentionDays,
|
||||
maxFileMb: state.preferences.maxLogFileMb,
|
||||
directory: "<HOME>/AppData/Roaming/ForgeFlow/diagnostics",
|
||||
fileCount: 2,
|
||||
totalBytes: 18432,
|
||||
totalSize: "18.0 KB",
|
||||
latestAt: iso(-2000),
|
||||
lastWriteError: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
async selectDirectory() {
|
||||
await wait();
|
||||
return "C:\\Development";
|
||||
},
|
||||
async selectKeyFile() {
|
||||
await wait();
|
||||
return "C:\\Users\\your-name\\.ssh\\id_ed25519";
|
||||
},
|
||||
async setupPreflight({ baseUrl, token, roots = [] }) {
|
||||
await wait(240);
|
||||
const checks = [
|
||||
{
|
||||
id: "git.available",
|
||||
label: "Git command line",
|
||||
status: "pass",
|
||||
detail: "git version 2.47.3",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "git.identity",
|
||||
label: "Git author identity",
|
||||
status: "pass",
|
||||
detail: "Jens <jens@example.invalid>",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: "storage.userdata",
|
||||
label: "Application data storage",
|
||||
status: "pass",
|
||||
detail: "ForgeFlow can write its local configuration.",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "storage.diagnostics",
|
||||
label: "Diagnostic log storage",
|
||||
status: "pass",
|
||||
detail: "The diagnostic directory is writable.",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "storage.credentials",
|
||||
label: "Protected credential storage",
|
||||
status: "pass",
|
||||
detail: "The operating system can encrypt the Gitea token at rest.",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: "workspace.roots",
|
||||
label: "Development folders",
|
||||
status: roots.length ? "pass" : "warning",
|
||||
detail: roots.length
|
||||
? `${roots.length} folder(s) selected.`
|
||||
: "No development folder selected yet.",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: "gitea.connection",
|
||||
label: "Gitea connection",
|
||||
status: baseUrl && token ? "pass" : "warning",
|
||||
detail:
|
||||
baseUrl && token
|
||||
? "Connection parameters are ready for validation."
|
||||
: "Enter the Gitea URL and token.",
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
return {
|
||||
kind: "system",
|
||||
startedAt: iso(-100),
|
||||
completedAt: iso(),
|
||||
checks,
|
||||
summary: {
|
||||
counts: {
|
||||
pass: checks.filter((i) => i.status === "pass").length,
|
||||
warning: checks.filter((i) => i.status === "warning").length,
|
||||
fail: 0,
|
||||
skipped: 0,
|
||||
},
|
||||
blocking: [],
|
||||
ready: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
async validateGitea({ baseUrl, token }) {
|
||||
await wait(320);
|
||||
if (!baseUrl || !token)
|
||||
throw new Error("Enter an instance URL and access token.");
|
||||
return {
|
||||
baseUrl: baseUrl.replace(/\/$/, ""),
|
||||
user: { login: "jens", full_name: "Jens" },
|
||||
repositoryCount: repositories.length,
|
||||
version: "1.26.0",
|
||||
};
|
||||
},
|
||||
async completeSetup(payload) {
|
||||
await wait(300);
|
||||
state.setupComplete = true;
|
||||
state.gitea = {
|
||||
baseUrl: payload.baseUrl,
|
||||
user: payload.user,
|
||||
hasToken: true,
|
||||
};
|
||||
state.workspaceRoots = payload.workspaceRoots;
|
||||
storage.set("forgeflow-demo-setup", "true");
|
||||
return { state: clone(state), tokenState: { persistent: true } };
|
||||
},
|
||||
async updateGitea(payload) {
|
||||
const validation = await this.validateGitea({
|
||||
...payload,
|
||||
token: payload.token || "preserved-demo-token",
|
||||
});
|
||||
state.gitea = {
|
||||
baseUrl: validation.baseUrl,
|
||||
user: validation.user,
|
||||
hasToken: true,
|
||||
};
|
||||
return {
|
||||
validation,
|
||||
tokenState: { persistent: true, preserved: !payload.token },
|
||||
state: clone(state),
|
||||
};
|
||||
},
|
||||
async setWorkspaceRoots(roots) {
|
||||
state.workspaceRoots = [...new Set(roots)];
|
||||
return clone(state);
|
||||
},
|
||||
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.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, 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),
|
||||
};
|
||||
},
|
||||
async inspectServerProject() {
|
||||
return {
|
||||
exists: true,
|
||||
rootGit: true,
|
||||
head: "d42d4a7".padEnd(40, "0"),
|
||||
branch: "main",
|
||||
trackedChanges: [],
|
||||
composeFiles: ["docker-compose.yml"],
|
||||
nestedGit: ["source"],
|
||||
dockerfile: true,
|
||||
};
|
||||
},
|
||||
async refreshRepositories() {
|
||||
await wait(260);
|
||||
return snapshot();
|
||||
},
|
||||
async discoverRepositories() {
|
||||
await wait(360);
|
||||
return snapshot()
|
||||
.filter((repo) => repo.localPath)
|
||||
.map((repo) => ({
|
||||
localPath: repo.localPath,
|
||||
remoteUrl: repo.cloneUrl,
|
||||
status: repo.localStatus,
|
||||
}));
|
||||
},
|
||||
async favoriteRepository(fullName, favorite) {
|
||||
const key = fullName.toLowerCase();
|
||||
state.favorites = favorite
|
||||
? [...new Set([...state.favorites, key])]
|
||||
: state.favorites.filter((item) => item !== key);
|
||||
snapshot();
|
||||
return clone(state);
|
||||
},
|
||||
async linkRepository(fullName, localPath) {
|
||||
const repo = repositories.find((item) => item.fullName === fullName);
|
||||
repo.localPath = localPath;
|
||||
repo.linkState = "linked";
|
||||
repo.localStatus = makeStatus({ head: randomSha() });
|
||||
emitRepositories();
|
||||
return snapshot();
|
||||
},
|
||||
async unlinkRepository(fullName) {
|
||||
const repo = repositories.find((item) => item.fullName === fullName);
|
||||
repo.localPath = null;
|
||||
repo.localStatus = null;
|
||||
repo.linkState = "remote-only";
|
||||
emitRepositories();
|
||||
return snapshot();
|
||||
},
|
||||
async repositoryStatus(localPath) {
|
||||
return clone(findRepo(localPath)?.localStatus);
|
||||
},
|
||||
async repositoryDiff(localPath, filePath) {
|
||||
await wait(80);
|
||||
return (
|
||||
diffs[filePath] ||
|
||||
`diff --git a/${filePath} b/${filePath}\n--- a/${filePath}\n+++ b/${filePath}\n@@ -1 +1 @@\n-old\n+new`
|
||||
);
|
||||
},
|
||||
async repositoryDiffHunks(localPath, filePath) {
|
||||
const diff = await this.repositoryDiff(localPath, filePath);
|
||||
return {
|
||||
filePath,
|
||||
partialSupported: true,
|
||||
hunks: [
|
||||
{
|
||||
index: 0,
|
||||
heading: "@@ -1 +1 @@",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
lines: diff.split("\n").slice(-4),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
async stageHunks(localPath, filePath) {
|
||||
return this.stageFiles(localPath, [filePath]);
|
||||
},
|
||||
async conflictState(localPath) {
|
||||
const repo = findRepo(localPath);
|
||||
const files = repo.localStatus.files
|
||||
.filter((item) => item.conflict)
|
||||
.map((item) => item.path);
|
||||
return {
|
||||
operation: files.length ? "merge" : null,
|
||||
files,
|
||||
canContinue: false,
|
||||
status: clone(repo.localStatus),
|
||||
};
|
||||
},
|
||||
async resolveConflict(localPath, filePath) {
|
||||
const repo = findRepo(localPath);
|
||||
const file = repo.localStatus.files.find(
|
||||
(item) => item.path === filePath,
|
||||
);
|
||||
if (file) {
|
||||
file.conflict = false;
|
||||
file.staged = true;
|
||||
file.unstaged = false;
|
||||
}
|
||||
recompute(repo);
|
||||
return this.conflictState(localPath);
|
||||
},
|
||||
async continueGitOperation(localPath) {
|
||||
return this.conflictState(localPath);
|
||||
},
|
||||
async abortGitOperation(localPath) {
|
||||
return this.conflictState(localPath);
|
||||
},
|
||||
async stageFiles(localPath, files) {
|
||||
const repo = findRepo(localPath);
|
||||
repo.localStatus.files.forEach((item) => {
|
||||
if (!files?.length || files.includes(item.path)) {
|
||||
item.staged = true;
|
||||
item.unstaged = false;
|
||||
item.indexCode = item.untracked ? "A" : "M";
|
||||
item.worktreeCode = ".";
|
||||
}
|
||||
});
|
||||
recompute(repo);
|
||||
emitRepositories();
|
||||
return clone(repo.localStatus);
|
||||
},
|
||||
async unstageFiles(localPath, files) {
|
||||
const repo = findRepo(localPath);
|
||||
repo.localStatus.files.forEach((item) => {
|
||||
if (!files?.length || files.includes(item.path)) {
|
||||
item.staged = false;
|
||||
item.unstaged = true;
|
||||
item.indexCode = ".";
|
||||
item.worktreeCode = item.untracked ? "?" : "M";
|
||||
}
|
||||
});
|
||||
recompute(repo);
|
||||
emitRepositories();
|
||||
return clone(repo.localStatus);
|
||||
},
|
||||
async commit(localPath, message, files) {
|
||||
await wait(520);
|
||||
if (!message?.trim()) throw new Error("Enter a commit message.");
|
||||
const repo = findRepo(localPath);
|
||||
repo.localStatus.files = repo.localStatus.files.filter(
|
||||
(item) => !files?.includes(item.path),
|
||||
);
|
||||
repo.localStatus.head = randomSha();
|
||||
repo.localStatus.branch.ahead += 1;
|
||||
recompute(repo);
|
||||
emitRepositories();
|
||||
return {
|
||||
commitOutput: `[${repo.localStatus.branch.head} ${repo.localStatus.shortHead}] ${message}`,
|
||||
commitSha: repo.localStatus.head,
|
||||
status: clone(repo.localStatus),
|
||||
};
|
||||
},
|
||||
async commitAndPush(localPath, message, files) {
|
||||
const result = await this.commit(localPath, message, files);
|
||||
const repo = findRepo(localPath);
|
||||
await wait(240);
|
||||
repo.localStatus.branch.ahead = 0;
|
||||
recompute(repo);
|
||||
emitRepositories();
|
||||
return {
|
||||
...result,
|
||||
pushOutput: "Push completed.",
|
||||
status: clone(repo.localStatus),
|
||||
};
|
||||
},
|
||||
async commitStaged(localPath, message) {
|
||||
const repo = findRepo(localPath);
|
||||
return this.commit(
|
||||
localPath,
|
||||
message,
|
||||
repo.localStatus.files
|
||||
.filter((item) => item.staged)
|
||||
.map((item) => item.path),
|
||||
);
|
||||
},
|
||||
async commitStagedAndPush(localPath, message) {
|
||||
const repo = findRepo(localPath);
|
||||
return this.commitAndPush(
|
||||
localPath,
|
||||
message,
|
||||
repo.localStatus.files
|
||||
.filter((item) => item.staged)
|
||||
.map((item) => item.path),
|
||||
);
|
||||
},
|
||||
async push(localPath) {
|
||||
await wait(360);
|
||||
const repo = findRepo(localPath);
|
||||
repo.localStatus.branch.ahead = 0;
|
||||
recompute(repo);
|
||||
emitRepositories();
|
||||
return { output: "Push completed.", status: clone(repo.localStatus) };
|
||||
},
|
||||
async fetch() {
|
||||
await wait(260);
|
||||
return { output: "Fetch completed." };
|
||||
},
|
||||
async pull(localPath) {
|
||||
await wait(380);
|
||||
const repo = findRepo(localPath);
|
||||
repo.localStatus.branch.behind = 0;
|
||||
recompute(repo);
|
||||
emitRepositories();
|
||||
return { output: "Fast-forwarded.", status: clone(repo.localStatus) };
|
||||
},
|
||||
async previewWorkspaceSync(localPath) {
|
||||
await wait(260);
|
||||
const repo = findRepo(localPath);
|
||||
const status = repo.localStatus;
|
||||
const targetSha = status.branch.behind ? "f".repeat(40) : status.head;
|
||||
return {
|
||||
id: `demo-${String(status.head).slice(0, 7)}-${status.branch.ahead}-${status.branch.behind}`.padEnd(64, "0").slice(0, 64),
|
||||
branch: status.branch.head,
|
||||
upstream: status.branch.upstream || `origin/${status.branch.head}`,
|
||||
currentSha: status.head,
|
||||
targetSha,
|
||||
needsSync: !status.clean || status.head !== targetSha || status.branch.ahead > 0,
|
||||
blockers: [],
|
||||
summary: {
|
||||
resultingTrackedChanges: status.branch.behind ? 3 : 0,
|
||||
added: status.branch.behind ? 1 : 0,
|
||||
modified: status.branch.behind ? 1 : 0,
|
||||
deleted: status.branch.behind ? 1 : 0,
|
||||
renamed: 0,
|
||||
localFilesToStash: status.counts.changed,
|
||||
untrackedFilesToStash: status.counts.untracked,
|
||||
localCommitsToProtect: status.branch.ahead,
|
||||
incomingCommits: status.branch.behind,
|
||||
},
|
||||
changes: status.branch.behind
|
||||
? [
|
||||
{ code: "A", status: "added", path: "src/remote-feature.js" },
|
||||
{ code: "M", status: "modified", path: "README.md" },
|
||||
{ code: "D", status: "deleted", path: "docs/obsolete.md" },
|
||||
]
|
||||
: [],
|
||||
localFiles: clone(status.files),
|
||||
incomingCommits: [],
|
||||
localCommits: [],
|
||||
recovery: {
|
||||
safetyBranch: status.branch.ahead > 0,
|
||||
stash: status.counts.changed > 0,
|
||||
untrackedCleanup: status.counts.untracked > 0,
|
||||
ignoredFilesPreserved: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
async applyWorkspaceSync(localPath, expectedPlanId) {
|
||||
const plan = await this.previewWorkspaceSync(localPath);
|
||||
if (plan.id !== expectedPlanId) throw new Error("The workspace sync preview is stale.");
|
||||
const repo = findRepo(localPath);
|
||||
const hadChanges = repo.localStatus.counts.changed > 0;
|
||||
repo.localStatus.head = plan.targetSha;
|
||||
repo.localStatus.shortHead = plan.targetSha.slice(0, 7);
|
||||
repo.localStatus.files = [];
|
||||
repo.localStatus.branch.ahead = 0;
|
||||
repo.localStatus.branch.behind = 0;
|
||||
recompute(repo);
|
||||
emitRepositories();
|
||||
return {
|
||||
applied: plan.needsSync,
|
||||
unchanged: !plan.needsSync,
|
||||
plan,
|
||||
status: clone(repo.localStatus),
|
||||
backupBranch: plan.summary.localCommitsToProtect ? `forgeflow/recovery-${plan.branch}-demo` : null,
|
||||
stash: hadChanges ? { ref: "stash@{0}", shortSha: "demo123", subject: "ForgeFlow workspace sync" } : null,
|
||||
ignoredFilesPreserved: true,
|
||||
cleaned: [],
|
||||
};
|
||||
},
|
||||
async history() {
|
||||
await wait(100);
|
||||
return clone(commitHistory);
|
||||
},
|
||||
async branchProtection(fullName, branch) {
|
||||
return {
|
||||
branch,
|
||||
protected: branch === "main",
|
||||
requiredApprovals: branch === "main" ? 1 : 0,
|
||||
requireSignedCommits: false,
|
||||
};
|
||||
},
|
||||
async pullRequests() {
|
||||
return [
|
||||
{
|
||||
number: 42,
|
||||
title: "Harden deployment preflight",
|
||||
html_url: "https://gitea.internal/jens/vacancyradar/pulls/42",
|
||||
created_at: iso(-7_200_000),
|
||||
updated_at: iso(-900_000),
|
||||
head: { ref: "feature/deployment-api" },
|
||||
base: { ref: "main" },
|
||||
},
|
||||
];
|
||||
},
|
||||
async createPullRequest(fullName, title, body, base) {
|
||||
return {
|
||||
number: 42,
|
||||
title,
|
||||
body,
|
||||
base,
|
||||
html_url: `https://gitea.internal/${fullName}/pulls/42`,
|
||||
};
|
||||
},
|
||||
async branches(localPath) {
|
||||
const repo = findRepo(localPath);
|
||||
if (!branchesByRepo.has(localPath))
|
||||
branchesByRepo.set(localPath, [
|
||||
{
|
||||
name: repo.localStatus.branch.head,
|
||||
current: true,
|
||||
sha: repo.localStatus.head,
|
||||
shortSha: repo.localStatus.shortHead,
|
||||
upstream: repo.localStatus.branch.upstream,
|
||||
},
|
||||
{
|
||||
name: "main",
|
||||
current: repo.localStatus.branch.head === "main",
|
||||
sha: repo.localStatus.head,
|
||||
shortSha: repo.localStatus.shortHead,
|
||||
upstream: "origin/main",
|
||||
},
|
||||
]);
|
||||
return clone(branchesByRepo.get(localPath));
|
||||
},
|
||||
async checkoutBranch(localPath, branch) {
|
||||
const repo = findRepo(localPath);
|
||||
if (!repo.localStatus.clean)
|
||||
throw new Error(
|
||||
"Commit or stash local changes before switching branches.",
|
||||
);
|
||||
const list = await this.branches(localPath);
|
||||
list.forEach((item) => {
|
||||
item.current = item.name === branch;
|
||||
});
|
||||
branchesByRepo.set(localPath, list);
|
||||
repo.localStatus.branch.head = branch;
|
||||
repo.localStatus.branch.upstream = `origin/${branch}`;
|
||||
recompute(repo);
|
||||
emitRepositories();
|
||||
return { status: clone(repo.localStatus), branches: clone(list) };
|
||||
},
|
||||
async createBranch(localPath, branch) {
|
||||
const repo = findRepo(localPath);
|
||||
const list = await this.branches(localPath);
|
||||
list.forEach((item) => {
|
||||
item.current = false;
|
||||
});
|
||||
list.unshift({
|
||||
name: branch,
|
||||
current: true,
|
||||
sha: repo.localStatus.head,
|
||||
shortSha: repo.localStatus.shortHead,
|
||||
upstream: null,
|
||||
});
|
||||
branchesByRepo.set(localPath, list);
|
||||
repo.localStatus.branch.head = branch;
|
||||
repo.localStatus.branch.upstream = null;
|
||||
recompute(repo);
|
||||
emitRepositories();
|
||||
return { status: clone(repo.localStatus), branches: clone(list) };
|
||||
},
|
||||
async stash(localPath, message) {
|
||||
const repo = findRepo(localPath);
|
||||
const list = stashesByRepo.get(localPath) || [];
|
||||
list.unshift({
|
||||
ref: `stash@{${list.length}}`,
|
||||
subject: message || "ForgeFlow stash",
|
||||
date: iso(),
|
||||
});
|
||||
stashesByRepo.set(localPath, list);
|
||||
repo.localStatus.files = [];
|
||||
recompute(repo);
|
||||
emitRepositories();
|
||||
return {
|
||||
output: "Saved working directory and index state.",
|
||||
status: clone(repo.localStatus),
|
||||
stashes: clone(list),
|
||||
};
|
||||
},
|
||||
async stashList(localPath) {
|
||||
return clone(stashesByRepo.get(localPath) || []);
|
||||
},
|
||||
async popStash(localPath, ref) {
|
||||
const repo = findRepo(localPath);
|
||||
const list = stashesByRepo.get(localPath) || [];
|
||||
const index = list.findIndex((item) => item.ref === ref);
|
||||
if (index < 0) throw new Error("Stash not found.");
|
||||
list.splice(index, 1);
|
||||
stashesByRepo.set(localPath, list);
|
||||
repo.localStatus.files = [makeFile("src/restored-from-stash.ts")];
|
||||
recompute(repo);
|
||||
emitRepositories();
|
||||
return {
|
||||
output: "Stash applied.",
|
||||
status: clone(repo.localStatus),
|
||||
stashes: clone(list),
|
||||
};
|
||||
},
|
||||
async gitRecoveryStatus(localPath) {
|
||||
const repo = findRepo(localPath);
|
||||
const status = clone(repo.localStatus);
|
||||
const upstream = status.branch?.upstream;
|
||||
const recommendations = [
|
||||
{
|
||||
id: "fetch",
|
||||
label: "Fetch and recalculate remote state",
|
||||
action: "fetch",
|
||||
safe: true,
|
||||
},
|
||||
];
|
||||
if (
|
||||
status.clean &&
|
||||
status.branch.behind > 0 &&
|
||||
status.branch.ahead === 0 &&
|
||||
upstream
|
||||
) {
|
||||
recommendations.push({
|
||||
id: "pull",
|
||||
label: `Fast-forward from ${upstream}`,
|
||||
action: "fast-forward",
|
||||
safe: true,
|
||||
});
|
||||
}
|
||||
if (
|
||||
status.branch.ahead > 0 &&
|
||||
status.branch.behind === 0 &&
|
||||
upstream
|
||||
) {
|
||||
recommendations.push({
|
||||
id: "push",
|
||||
label: `Push ${status.branch.ahead} local commit(s)`,
|
||||
action: "push",
|
||||
safe: true,
|
||||
});
|
||||
}
|
||||
return {
|
||||
status,
|
||||
lockReport: {
|
||||
root: localPath,
|
||||
gitDir: `${localPath}\\.git`,
|
||||
locks: [],
|
||||
processes: { available: true, active: [] },
|
||||
},
|
||||
recommendations,
|
||||
};
|
||||
},
|
||||
async reconcileRepository(localPath) {
|
||||
await wait(160);
|
||||
return this.gitRecoveryStatus(localPath);
|
||||
},
|
||||
async repairGitLocks(localPath) {
|
||||
return {
|
||||
...(await this.gitRecoveryStatus(localPath)).lockReport,
|
||||
removed: [],
|
||||
skipped: [],
|
||||
repaired: false,
|
||||
};
|
||||
},
|
||||
async repairRepositorySync(localPath, strategy) {
|
||||
const repo = findRepo(localPath);
|
||||
if (strategy === "fast-forward") {
|
||||
repo.localStatus.branch.behind = 0;
|
||||
repo.localStatus.head = "f".repeat(40);
|
||||
} else if (strategy === "push") {
|
||||
repo.localStatus.branch.ahead = 0;
|
||||
} else if (strategy !== "fetch") {
|
||||
throw new Error("Unsupported demo synchronization strategy.");
|
||||
}
|
||||
recompute(repo);
|
||||
emitRepositories();
|
||||
return {
|
||||
strategy,
|
||||
backupBranch: null,
|
||||
status: clone(repo.localStatus),
|
||||
lockReport: (await this.gitRecoveryStatus(localPath)).lockReport,
|
||||
};
|
||||
},
|
||||
async indexLockInfo() {
|
||||
return { exists: false, ageMs: 0 };
|
||||
},
|
||||
async repairIndexLock() {
|
||||
return { removed: true };
|
||||
},
|
||||
async setOrigin(localPath, remoteUrl) {
|
||||
const repo = findRepo(localPath);
|
||||
repo.localStatus.remoteUrl = remoteUrl;
|
||||
repo.sshUrl = remoteUrl;
|
||||
emitRepositories();
|
||||
return clone(repo.localStatus);
|
||||
},
|
||||
async normalizeOrigins() {
|
||||
const changes = [];
|
||||
repositories
|
||||
.filter((repo) => repo.localPath && repo.sshUrl)
|
||||
.forEach((repo) => {
|
||||
if (repo.localStatus.remoteUrl !== repo.sshUrl) {
|
||||
changes.push({
|
||||
fullName: repo.fullName,
|
||||
previous: repo.localStatus.remoteUrl,
|
||||
next: repo.sshUrl,
|
||||
});
|
||||
repo.localStatus.remoteUrl = repo.sshUrl;
|
||||
}
|
||||
});
|
||||
emitRepositories();
|
||||
return { changes, repositories: snapshot() };
|
||||
},
|
||||
async cloneRepository(fullName, mode = "default") {
|
||||
await wait(620);
|
||||
const repository = repositories.find(
|
||||
(item) => item.fullName === fullName,
|
||||
);
|
||||
if (!repository) throw new Error("Repository not found.");
|
||||
if (repository.localPath)
|
||||
throw new Error("This repository already has a linked local folder.");
|
||||
const root =
|
||||
mode === "custom" ? "D:\\OtherProjects" : state.workspaceRoots[0];
|
||||
if (!root) return { cancelled: true };
|
||||
const target = `${root.replace(/[\\/]+$/, "")}\\${repository.name}`;
|
||||
const head = randomSha();
|
||||
repository.localPath = target;
|
||||
repository.localStatus = makeStatus({
|
||||
head,
|
||||
branch: repository.defaultBranch || "main",
|
||||
});
|
||||
repository.localStatus.root = target;
|
||||
repository.localStatus.remoteUrl =
|
||||
repository.preferredCloneUrl || repository.cloneUrl;
|
||||
repository.linkState = "linked";
|
||||
recompute(repository);
|
||||
const current = snapshot();
|
||||
emitRepositories();
|
||||
return {
|
||||
target,
|
||||
status: clone(repository.localStatus),
|
||||
reused: false,
|
||||
repositories: current,
|
||||
state: clone(state),
|
||||
};
|
||||
},
|
||||
async openPath() {
|
||||
return true;
|
||||
},
|
||||
async openEditor() {
|
||||
return { launched: true, executable: "code" };
|
||||
},
|
||||
async openTerminal() {
|
||||
return { launched: true, executable: "wt.exe" };
|
||||
},
|
||||
async openExternal() {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
async function runOperation(
|
||||
message,
|
||||
operation,
|
||||
successMessage,
|
||||
{ refresh = true } = {},
|
||||
) {
|
||||
setLoading(true, message);
|
||||
try {
|
||||
const result = await operation();
|
||||
if (successMessage) showToast("Done", successMessage, "success");
|
||||
if (refresh) await refreshRepositories(false);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const pushAfterCommit = error.code === "PUSH_AFTER_COMMIT_FAILED";
|
||||
showToast(
|
||||
pushAfterCommit
|
||||
? "Commit saved locally; push failed"
|
||||
: "Operation failed",
|
||||
error.message,
|
||||
"error",
|
||||
);
|
||||
// Always reload the real Git state. A failed stage must keep changes visible, while a
|
||||
// failed push after a successful commit must immediately surface as an ahead branch.
|
||||
await refreshRepositories(false, true);
|
||||
if (pushAfterCommit) {
|
||||
ui.selectedFiles.clear();
|
||||
ui.selectedFile = null;
|
||||
ui.diff = "";
|
||||
ui.commitMessage = "";
|
||||
render();
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeDeployment(profileId) {
|
||||
const repository = selectedRepository();
|
||||
const profile =
|
||||
repository?.deploymentProfiles?.find((item) => item.id === profileId) ||
|
||||
selectedProfile(repository);
|
||||
if (!repository || !profile) return;
|
||||
const targetSha = deploymentTargetSha(repository, profile);
|
||||
if (!targetSha) {
|
||||
showToast("Refresh required", "Refresh Gitea and server truth before deploying this environment.", "error");
|
||||
return;
|
||||
}
|
||||
const deploymentOptions = {
|
||||
note: document.querySelector("#deployment-note")?.value.trim() || "",
|
||||
override: document.querySelector("#deployment-override")?.checked === true,
|
||||
overrideReason:
|
||||
document.querySelector("#deployment-override-reason")?.value.trim() || "",
|
||||
};
|
||||
ui.modal = null;
|
||||
setLoading(
|
||||
true,
|
||||
profile.provider === "ssh-unraid"
|
||||
? `Deploying ${repository.name} to ${profile.remoteFolder} over SSH…`
|
||||
: `Dispatching ${profile.name} workflow…`,
|
||||
);
|
||||
try {
|
||||
ui.activeDeployment = await window.forgeflow.deploy(
|
||||
repository,
|
||||
profile.id,
|
||||
targetSha,
|
||||
deploymentOptions,
|
||||
);
|
||||
updateOperationInState(ui.activeDeployment);
|
||||
ui.currentView = "deployment-run";
|
||||
showToast(
|
||||
"Deployment started",
|
||||
`${repository.name} ${shortSha(targetSha)} → ${profile.environment}`,
|
||||
"success",
|
||||
);
|
||||
startOperationPolling();
|
||||
} catch (error) {
|
||||
if (profile.provider === "ssh-unraid" && isSshCredentialError(error)) {
|
||||
ui.modal = {
|
||||
type: "server-password",
|
||||
serverId: profile.serverId,
|
||||
retry: {
|
||||
type: "deploy",
|
||||
repositoryFullName: repository.fullName,
|
||||
profileId: profile.id,
|
||||
},
|
||||
};
|
||||
showToast("SSH key rejected", "Enter the Unraid server password once; ForgeFlow will retry the direct desktop → Unraid connection.", "error");
|
||||
render();
|
||||
} else {
|
||||
showToast("Deployment failed to start", error.message, "error");
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function executeRollback(profileId) {
|
||||
const repository = selectedRepository();
|
||||
const profile = repository?.deploymentProfiles?.find(
|
||||
(item) => item.id === profileId,
|
||||
);
|
||||
const target = profile?.state?.previousSha;
|
||||
if (!repository || !profile || !target) return;
|
||||
ui.modal = null;
|
||||
setLoading(
|
||||
true,
|
||||
profile?.provider === "ssh-unraid"
|
||||
? `Rolling back ${profile.remoteFolder} over SSH…`
|
||||
: `Dispatching rollback to ${shortSha(target)}…`,
|
||||
);
|
||||
try {
|
||||
ui.activeDeployment = await window.forgeflow.rollback(
|
||||
repository,
|
||||
profile.id,
|
||||
target,
|
||||
);
|
||||
updateOperationInState(ui.activeDeployment);
|
||||
ui.currentView = "deployment-run";
|
||||
showToast(
|
||||
"Rollback requested",
|
||||
`${profile.environment} → ${shortSha(target)}`,
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Rollback failed to start", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function loadGitTools(repository) {
|
||||
if (!repository?.localPath) return;
|
||||
setLoading(true, "Loading branches and stashes…");
|
||||
try {
|
||||
[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);
|
||||
}
|
||||
|
||||
function profileRepository(profileId) {
|
||||
return ui.repositories.find((repository) =>
|
||||
repository.deploymentProfiles?.some((profile) => profile.id === profileId),
|
||||
);
|
||||
}
|
||||
|
||||
async function runSystemPreflight({ setup = false } = {}) {
|
||||
setLoading(true, "Checking local readiness…");
|
||||
try {
|
||||
ui.systemPreflight = await window.forgeflow.setupPreflight({
|
||||
baseUrl: ui.setupDraft.baseUrl,
|
||||
token: ui.setupDraft.token,
|
||||
roots: setup ? ui.setupDraft.roots : ui.boot.state.workspaceRoots,
|
||||
});
|
||||
if (!setup)
|
||||
ui.diagnosticsStatus = await window.forgeflow.diagnosticsStatus();
|
||||
showToast(
|
||||
ui.systemPreflight.summary.ready
|
||||
? "Readiness checks passed"
|
||||
: "Readiness needs attention",
|
||||
ui.systemPreflight.summary.ready
|
||||
? `${ui.systemPreflight.summary.counts.pass} checks passed.`
|
||||
: `${ui.systemPreflight.summary.blocking.length} blocking check(s) must be resolved.`,
|
||||
ui.systemPreflight.summary.ready ? "success" : "error",
|
||||
);
|
||||
return ui.systemPreflight;
|
||||
} catch (error) {
|
||||
showToast("Readiness check failed", error.message, "error");
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runDeploymentPreflight(
|
||||
repository,
|
||||
profileId,
|
||||
{ showModal = true } = {},
|
||||
) {
|
||||
if (!repository || !profileId) return null;
|
||||
if (String(repository.id) !== String(ui.selectedRepoId))
|
||||
selectRepository(repository.id, false);
|
||||
ui.selectedProfileId = profileId;
|
||||
ui.deploymentPreflight = null;
|
||||
setLoading(true, "Verifying repository, workflow and server…");
|
||||
try {
|
||||
const report = await window.forgeflow.deploymentPreflight(
|
||||
repository,
|
||||
profileId,
|
||||
);
|
||||
ui.deploymentPreflight = report;
|
||||
if (showModal)
|
||||
ui.modal = {
|
||||
type: "deployment-preflight",
|
||||
profileId,
|
||||
repositoryFullName: repository.fullName,
|
||||
};
|
||||
return report;
|
||||
} catch (error) {
|
||||
showToast("Deployment preflight failed", error.message, "error");
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,875 @@
|
||||
function navButton(view, label, iconName, count = "") {
|
||||
return `<button class="nav-button ${ui.currentView === view ? "active" : ""}" data-action="navigate" data-view="${view}">${icon(iconName)}<span>${label}</span>${count !== "" ? `<span class="nav-count">${count}</span>` : ""}</button>`;
|
||||
}
|
||||
|
||||
function renderTitlebar() {
|
||||
const state = ui.boot?.state;
|
||||
const user = state?.gitea?.user;
|
||||
const connected = Boolean(state?.gitea?.hasToken);
|
||||
const repository = selectedRepository();
|
||||
const title =
|
||||
ui.currentView === "repository" && repository
|
||||
? repository.fullName
|
||||
: {
|
||||
overview: "Release overview",
|
||||
deployments: "Deployments",
|
||||
diagnostics: "Diagnostics",
|
||||
settings: "Settings",
|
||||
help: "Help center",
|
||||
"deployment-run": "Deployment run",
|
||||
}[ui.currentView] || "Workspace";
|
||||
return `<header class="titlebar">
|
||||
<div class="titlebar-left"><div class="wordmark"><img class="brand-logo" src="./assets/itworx-mark.png" alt="ITWorx.tech"/><span>ForgeFlow</span><small>by ITWorx.tech</small></div><span class="workspace-name">${escapeHtml(title)}</span></div>
|
||||
<div class="titlebar-right">
|
||||
<button class="command-trigger" data-action="open-palette" aria-label="Open command palette">${icon("search")}<span>Commands</span><kbd>Ctrl K</kbd></button>
|
||||
<div class="search-wrap">${icon("search")}<input id="global-search" class="global-search" value="${attr(ui.search)}" placeholder="Search repositories…" aria-label="Search repositories" /></div>
|
||||
<span class="connection-chip" title="${connected ? `Connected as ${attr(user?.login || "user")}` : "Not connected"}"><span class="dot" style="${connected ? "" : "background:var(--danger)"}"></span>${connected ? escapeHtml(user?.login || "Gitea") : "Offline"}</span>
|
||||
<button class="icon-button" data-action="refresh" title="Refresh repositories">${icon("refresh")}</button>
|
||||
<button class="icon-button" data-action="toggle-theme" title="Toggle theme">${icon(document.documentElement.dataset.theme === "dark" ? "sun" : "moon")}</button>
|
||||
</div>
|
||||
</header>`;
|
||||
}
|
||||
|
||||
function renderRepositoryRow(repository) {
|
||||
const status = repository.localStatus;
|
||||
const profiles = repository.deploymentProfiles || [];
|
||||
const workloads = linkedWorkloadsForRepository(repository);
|
||||
const runningWorkloads = workloads.filter((workload) => workload.runtime?.running);
|
||||
const badges = [];
|
||||
if (status?.counts.conflicts)
|
||||
badges.push('<span class="mini-badge danger" title="Conflicts">!</span>');
|
||||
else if (status?.counts.changed)
|
||||
badges.push(
|
||||
`<span class="mini-badge warning" title="Changed files">${status.counts.changed}</span>`,
|
||||
);
|
||||
if (status?.branch.ahead)
|
||||
badges.push(
|
||||
`<span class="mini-badge" title="Commits ahead">↑${status.branch.ahead}</span>`,
|
||||
);
|
||||
if (status?.branch.behind)
|
||||
badges.push(
|
||||
`<span class="mini-badge danger" title="Commits behind">↓${status.branch.behind}</span>`,
|
||||
);
|
||||
if (repository.readyToDeploy)
|
||||
badges.push(
|
||||
'<span class="mini-badge success" title="Ready to deploy">↗</span>',
|
||||
);
|
||||
if (profiles.length)
|
||||
badges.push(
|
||||
`<span class="mini-badge ${runningWorkloads.length ? "success" : "warning"} deployment-badge" title="${attr(`${profiles.length} server deployment${profiles.length === 1 ? "" : "s"} linked${runningWorkloads.length ? ` · ${runningWorkloads.length} running` : ""}`)}">S${profiles.length}</span>`,
|
||||
);
|
||||
if (!repository.localPath)
|
||||
badges.push('<span class="mini-badge" title="No local folder">—</span>');
|
||||
const branch = status?.branch.head || repository.defaultBranch || "remote";
|
||||
return `<button class="repo-row ${String(repository.id) === String(ui.selectedRepoId) ? "active" : ""} ${repository.attention ? "attention" : ""}" data-action="select-repo" data-id="${attr(repository.id)}" data-deployment-count="${profiles.length}">
|
||||
<span class="repo-icon">${repository.favorite ? icon("star") : icon(repository.localPath ? "git" : "cloud")}</span>
|
||||
<span class="repo-main"><span class="repo-name">${escapeHtml(repository.name)}</span><span class="repo-sub"><span>${escapeHtml(branch)}</span>${status?.shortHead ? `<span>• ${escapeHtml(status.shortHead)}</span>` : ""}</span></span>
|
||||
<span class="repo-badges">${badges.join("")}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function renderSidebar() {
|
||||
const query = `${ui.search} ${ui.repoSearch}`.trim().toLowerCase();
|
||||
const repositories = ui.repositories.filter(
|
||||
(repository) =>
|
||||
!query ||
|
||||
`${repository.name} ${repository.fullName} ${repository.description}`
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
);
|
||||
const favorites = repositories.filter((repository) => repository.favorite);
|
||||
const others = repositories.filter((repository) => !repository.favorite);
|
||||
const attention = ui.repositories.filter(
|
||||
(repository) =>
|
||||
repository.attention ||
|
||||
repository.localStatus?.counts.changed ||
|
||||
repository.localStatus?.branch.ahead ||
|
||||
repository.readyToDeploy,
|
||||
).length;
|
||||
const rows = (list) => list.map(renderRepositoryRow).join("");
|
||||
return `<aside class="sidebar">
|
||||
<nav class="primary-nav">${navButton("overview", "Overview", "overview", attention || "")}${navButton("deployments", "Deployments", "deploy", operations().filter((item) => item.type === "deployment" && !isTerminalOperation(item.status)).length || "")}${navButton("diagnostics", "Diagnostics", "shield", ui.diagnosticsStatus?.lastWriteError ? "!" : "")}${navButton("settings", "Settings", "settings")}${navButton("help", "Help", "help")}</nav>
|
||||
<div class="sidebar-section"><span>Repositories</span><button data-action="refresh" title="Refresh">${icon("refresh")}</button></div>
|
||||
<input class="repo-filter" id="repo-filter" value="${attr(ui.repoSearch)}" placeholder="Filter projects" aria-label="Filter projects" />
|
||||
<div class="repo-list">
|
||||
${favorites.length ? `<div class="repo-group-label">Favorites</div>${rows(favorites)}` : ""}
|
||||
${favorites.length && others.length ? '<div class="repo-group-label">All repositories</div>' : ""}
|
||||
${others.length ? rows(others) : !favorites.length ? '<div class="empty-state compact"><p>No matching repositories.</p></div>' : ""}
|
||||
</div>
|
||||
<div class="sidebar-footer"><div class="sidebar-diagnostic-state"><span class="state-dot ${ui.diagnosticsStatus?.lastWriteError ? "danger" : ui.diagnosticsStatus?.enabled === false ? "" : "success"}"></span><div><strong>${ui.diagnosticsStatus?.lastWriteError ? "Diagnostic write error" : ui.diagnosticsStatus?.enabled === false ? "Diagnostics disabled" : "Safe diagnostics active"}</strong><span>${ui.diagnosticsStatus?.lastWriteError ? "Open Diagnostics for details" : "Credentials are redacted locally"}</span></div></div></div>
|
||||
</aside>`;
|
||||
}
|
||||
|
||||
function renderSummaryCard(label, value, note, iconName, tone = "") {
|
||||
return `<div class="summary-card ${tone}">${icon(iconName)}<div class="eyebrow">${label}</div><div class="summary-value">${value}</div><div class="summary-label">${note}</div></div>`;
|
||||
}
|
||||
|
||||
function queueActionFor(repository) {
|
||||
const action = repositoryAction(repository);
|
||||
const mapping = {
|
||||
link: ["folder", "Link folder", "Local project is not connected", ""],
|
||||
error: ["error", "Inspect problem", action.detail, "danger"],
|
||||
conflict: ["warning", "Resolve conflicts", action.detail, "danger"],
|
||||
commit: ["file", "Review & commit", action.detail, "warning"],
|
||||
diverged: ["warning", "Resolve divergence", action.detail, "danger"],
|
||||
pull: ["arrowDown", "Synchronize", action.detail, "warning"],
|
||||
push: ["arrowUp", "Push commits", action.detail, ""],
|
||||
configure: ["settings", "Configure deploy", action.detail, ""],
|
||||
"branch-profile": ["branch", "Select profile", action.detail, ""],
|
||||
deploy: ["rocket", "Deploy release", action.detail, "success"],
|
||||
clean: ["check", "Synchronized", action.detail, "success"],
|
||||
};
|
||||
return mapping[action.kind] || mapping.clean;
|
||||
}
|
||||
|
||||
function projectIllustration(kind = "flow") {
|
||||
return `<div class="project-illustration ${attr(kind)}" data-project-illustration aria-hidden="true">
|
||||
<div class="illustration-glow"></div><svg viewBox="0 0 260 150" role="presentation">
|
||||
<path class="orbit orbit-a" d="M32 92 C72 20 190 18 230 82"/><path class="orbit orbit-b" d="M42 116 C98 150 190 136 222 60"/>
|
||||
<g class="illustration-core"><rect x="83" y="39" width="94" height="74" rx="17"/><path d="M103 66h54M103 79h36M103 92h45"/><circle cx="160" cy="92" r="5"/></g>
|
||||
<g class="illustration-node node-a"><circle cx="37" cy="92" r="12"/><path d="m32 92 4 4 7-9"/></g>
|
||||
<g class="illustration-node node-b"><circle cx="226" cy="82" r="12"/><path d="M221 82h10M226 77v10"/></g>
|
||||
<g class="illustration-node node-c"><circle cx="74" cy="31" r="8"/></g>
|
||||
<circle class="signal signal-a" cx="0" cy="0" r="4"/><circle class="signal signal-b" cx="0" cy="0" r="3"/>
|
||||
</svg><span class="illustration-label">${kind === "deploy" ? "Live release topology" : kind === "repo" ? "Project signal" : "Release flow"}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderOverview() {
|
||||
const changed = ui.repositories.filter(
|
||||
(repository) => repository.localStatus?.counts.changed,
|
||||
).length;
|
||||
const unpushed = ui.repositories.filter(
|
||||
(repository) => repository.localStatus?.branch.ahead,
|
||||
).length;
|
||||
const deployable = ui.repositories.filter(
|
||||
(repository) => repository.readyToDeploy,
|
||||
).length;
|
||||
const unhealthy = ui.repositories
|
||||
.flatMap((repository) => repository.deploymentProfiles || [])
|
||||
.filter((profile) => profile.state?.healthy === false).length;
|
||||
const queue = ui.repositories
|
||||
.filter((repository) => repositoryAction(repository).kind !== "clean")
|
||||
.slice(0, 8);
|
||||
const recent = operations().slice(0, 7);
|
||||
const active = recent.filter(
|
||||
(operation) => !isTerminalOperation(operation.status),
|
||||
);
|
||||
return `<div class="page">
|
||||
<div class="page-header visual-page-header"><div><div class="eyebrow">Coding flow</div><h1>Release overview</h1><p>One decision surface for local work, Gitea synchronization and the exact version running on your server.</p></div>${projectIllustration("flow")}<button class="button" data-action="refresh">${icon("refresh")}Refresh all</button></div>
|
||||
${ui.refreshError ? `<div class="notice danger">${icon("error")} ${escapeHtml(ui.refreshError)}</div>` : ""}
|
||||
${ui.refreshWarning ? `<div class="notice warning">${icon("warning")} ${escapeHtml(ui.refreshWarning)}</div>` : ""}
|
||||
<div class="summary-grid">
|
||||
${renderSummaryCard("Local work", changed, changed === 1 ? "repository has changes" : "repositories have changes", "file", changed ? "warning" : "success")}
|
||||
${renderSummaryCard("Unpushed", unpushed, "repositories ahead of Gitea", "arrowUp", unpushed ? "warning" : "success")}
|
||||
${renderSummaryCard("Ready", deployable, "exact commits ready to deploy", "rocket", deployable ? "success" : "")}
|
||||
${renderSummaryCard("Health", unhealthy || active.length, unhealthy ? "unhealthy environments" : active.length ? "operations in progress" : "all checked environments healthy", "pulse", unhealthy ? "danger" : active.length ? "warning" : "success")}
|
||||
</div>
|
||||
<section class="section-block"><div class="section-heading"><h2>Action queue</h2><span class="meta">Sorted by required attention</span></div><div class="action-queue">
|
||||
${
|
||||
queue.length
|
||||
? queue
|
||||
.map((repository) => {
|
||||
const [iconName, label, reason, tone] =
|
||||
queueActionFor(repository);
|
||||
return `<div class="queue-row"><span class="queue-icon ${tone}">${icon(iconName)}</span><div><div class="queue-title">${escapeHtml(repository.name)}</div><div class="queue-sub">${escapeHtml(repository.localStatus?.branch.head || repository.defaultBranch || "remote")} ${repository.localStatus?.shortHead ? `• ${repository.localStatus.shortHead}` : ""}</div></div><div class="queue-reason"><strong>${escapeHtml(label)}</strong><span>${escapeHtml(reason)}</span></div><button class="button" data-action="select-repo" data-id="${attr(repository.id)}">Open ${icon("arrowRight")}</button></div>`;
|
||||
})
|
||||
.join("")
|
||||
: '<div class="empty-state"><div class="empty-icon">✓</div><h3>Everything is synchronized</h3><p>No repository needs immediate attention.</p></div>'
|
||||
}
|
||||
</div></section>
|
||||
<section class="section-block two-column">
|
||||
<div class="panel"><div class="panel-header"><h2>Recent deployments</h2><button class="button ghost" data-action="navigate" data-view="deployments">View all</button></div><div class="activity-list">${recent.length ? recent.map((operation) => `<div class="activity-item"><span class="activity-dot ${toneForStatus(operation.status)}"></span><div><div class="activity-title">${escapeHtml(operation.repository)} → ${escapeHtml(operation.environment || "environment")}</div><div class="activity-sub">${escapeHtml(operation.action === "rollback" ? "Rollback" : "Deploy")} ${escapeHtml(operation.shortSha || shortSha(operation.sha))} · ${escapeHtml(operation.status)}</div></div><span class="activity-time">${formatDate(operation.updatedAt || operation.createdAt)}</span></div>`).join("") : '<div class="empty-state compact"><p>No deployment history yet.</p></div>'}</div></div>
|
||||
<div class="panel"><div class="panel-header"><h2>Workspace readiness</h2></div><div class="panel-body readiness-list">
|
||||
${readinessRow("Git executable", ui.boot.git.available, ui.boot.git.version || ui.boot.git.error)}
|
||||
${readinessRow("Gitea connection", ui.boot.state.gitea.hasToken, ui.boot.state.gitea.baseUrl || "Not configured")}
|
||||
${readinessRow("Workspace folders", ui.boot.state.workspaceRoots.length > 0, `${ui.boot.state.workspaceRoots.length} configured`)}
|
||||
${readinessRow("Automatic awareness", ui.boot.state.preferences?.autoRefresh !== false, ui.boot.state.preferences?.autoRefresh === false ? "Manual refresh only" : `Local every ${ui.boot.state.preferences?.repositoryPollSeconds || 4}s · Gitea every ${ui.boot.state.preferences?.fetchIntervalMinutes || "manual"}${ui.boot.state.preferences?.fetchIntervalMinutes ? " min" : ""}`)}
|
||||
</div></div>
|
||||
</section>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function readinessRow(label, ok, detail) {
|
||||
return `<div class="readiness-row"><span class="state-dot ${ok ? "success" : "danger"}"></span><div><strong>${escapeHtml(label)}</strong><span>${escapeHtml(detail)}</span></div></div>`;
|
||||
}
|
||||
function releaseNode(label, value, description, tone = "") {
|
||||
return `<div class="release-node"><div class="release-label">${label}</div><div class="release-value"><span class="state-dot ${tone}"></span><strong>${escapeHtml(value)}</strong><span>${escapeHtml(description)}</span></div></div>`;
|
||||
}
|
||||
|
||||
function linkedWorkloadsForRepository(repository) {
|
||||
const fullName = String(repository?.fullName || "").toLowerCase();
|
||||
if (!fullName) return [];
|
||||
return (ui.serverDiscovery || []).flatMap((server) =>
|
||||
(server.workloads || [])
|
||||
.filter((workload) => String(workload.link?.repositoryFullName || "").toLowerCase() === fullName)
|
||||
.map((workload) => ({ ...workload, serverId: server.serverId, serverName: server.serverName || server.server?.name || "Server" })),
|
||||
);
|
||||
}
|
||||
|
||||
function fileStatusCode(file) {
|
||||
if (file.conflict) return "U";
|
||||
if (file.untracked) return "?";
|
||||
return (
|
||||
{
|
||||
modified: "M",
|
||||
added: "A",
|
||||
deleted: "D",
|
||||
renamed: "R",
|
||||
copied: "C",
|
||||
"type-changed": "T",
|
||||
}[file.status] || "M"
|
||||
);
|
||||
}
|
||||
|
||||
function renderChanges(repository) {
|
||||
const status = repository.localStatus;
|
||||
if (!repository.localPath) {
|
||||
const target = displayCloneTarget(repository);
|
||||
return `<div class="empty-state full"><div class="empty-icon">${icon("link")}</div><h3>Connect a local project</h3><p>Clone directly into your default project root, or link an existing working tree.</p>${target ? `<div class="notice"><span>${icon("folder")}Automatic destination</span><strong class="mono">${escapeHtml(target)}</strong></div>` : '<div class="notice warning">No default project root is configured. ForgeFlow will ask for one.</div>'}<div class="stack horizontal"><button class="button primary" data-action="clone-repo">${icon("cloud")}${escapeHtml(clonePrimaryLabel(repository))}</button><button class="button" data-action="link-repo">${icon("link")}Link existing folder</button><button class="button ghost" data-action="clone-repo-custom">Choose another location</button></div></div>`;
|
||||
}
|
||||
if (!status)
|
||||
return `<div class="empty-state full"><div class="empty-icon">${icon("error")}</div><h3>Repository unavailable</h3><p>${escapeHtml(repository.attentionReason || "The local working tree could not be read.")}</p></div>`;
|
||||
if (!status.files.length)
|
||||
return `<div class="empty-state full"><div class="empty-icon">${icon("check")}</div><h3>Working tree clean</h3><p>Local ${escapeHtml(status.branch.head)} is at ${escapeHtml(status.shortHead)} with no uncommitted files.</p><div class="stack horizontal"><button class="button" data-action="fetch">${icon("refresh")}Fetch remote state</button><button class="button" data-action="open-path">${icon("folder")}Open project</button></div></div>`;
|
||||
const selected = status.files.find((file) => file.path === ui.selectedFile);
|
||||
const conflictActions = selected?.conflict
|
||||
? `<div class="notice danger"><div><strong>Conflicted file</strong><p>Choose one side, or edit the file and mark it resolved.</p></div><div class="stack horizontal compact"><button class="button" data-action="resolve-conflict" data-resolution="ours">Use ours</button><button class="button" data-action="resolve-conflict" data-resolution="theirs">Use theirs</button><button class="button primary" data-action="resolve-conflict" data-resolution="resolved">Mark resolved</button></div></div>`
|
||||
: "";
|
||||
return `<div class="changes-layout"><section class="file-panel"><div class="file-panel-tools"><span><strong>${ui.selectedFiles.size}</strong> selected · ${status.counts.changed} changed · ${status.counts.staged} staged</span><button class="button ghost small" data-action="toggle-all-files">${ui.selectedFiles.size === status.files.length ? "Clear" : "Select all"}</button></div><div class="file-list" tabindex="0" aria-label="Changed files">${status.files.map((file) => `<div class="file-row ${ui.selectedFile === file.path ? "active" : ""}" role="button" tabindex="0" data-action="select-file" data-path="${attr(file.path)}"><input type="checkbox" data-file-select="${attr(file.path)}" ${ui.selectedFiles.has(file.path) ? "checked" : ""} aria-label="Include ${attr(file.path)}"/><span class="file-status ${attr(file.status)}">${fileStatusCode(file)}</span><span class="file-path" title="${attr(file.path)}">${escapeHtml(file.path)}</span><span title="${file.staged ? "Staged" : "Unstaged"}">${file.staged ? "●" : "○"}</span></div>`).join("")}</div>${status.counts.conflicts ? `<div class="card-actions"><button class="button danger" data-action="load-conflicts">${icon("warning")}Conflict guide</button></div>` : ""}</section><section class="diff-panel">${conflictActions}<div class="diff-toolbar"><span class="diff-title">${escapeHtml(ui.selectedFile || "Select a file")}</span><div class="stack horizontal compact">${ui.diffHunks?.partialSupported ? `<button class="button small" data-action="open-hunk-staging">Stage hunks</button>` : ""}${ui.selectedFile ? `<button class="button ghost small" data-action="open-file-editor">${icon("external")}Editor</button>` : ""}<span class="status-pill">${ui.selectedFile ? escapeHtml(selected?.status || "") : ""}</span><button class="icon-button" data-action="copy-diff" title="Copy diff">${icon("copy")}</button></div></div><div class="diff-view">${renderDiff(ui.diff)}</div></section></div>`;
|
||||
}
|
||||
|
||||
function renderHistory(repository) {
|
||||
if (!repository.localPath)
|
||||
return '<div class="empty-state full"><p>Link a local repository to view commit history.</p></div>';
|
||||
if (!ui.history.length)
|
||||
return `<div class="empty-state full"><div class="empty-icon">${icon("history")}</div><h3>Load local commit history</h3><p>Review the last commits from this working tree.</p><button class="button primary" data-action="load-history">Load history</button></div>`;
|
||||
return `<div class="tab-page"><div class="panel"><table class="data-table"><thead><tr><th>Commit</th><th>Message</th><th>Author</th><th>Date</th></tr></thead><tbody>${ui.history.map((commit) => `<tr><td class="mono">${escapeHtml(commit.shortSha)}</td><td>${escapeHtml(commit.subject)}</td><td>${escapeHtml(commit.author)}</td><td>${formatDate(commit.date)}</td></tr>`).join("")}</tbody></table></div></div>`;
|
||||
}
|
||||
|
||||
function environmentState(profile) {
|
||||
const state = profile.state || {};
|
||||
if (state.healthy === false) return { label: "Unhealthy", tone: "danger" };
|
||||
if (state.healthy === true) return { label: "Healthy", tone: "success" };
|
||||
if (state.containerRunning === true) return { label: "Running · unverified", tone: "warning" };
|
||||
if (state.containerRunning === false) return { label: "Stopped", tone: "danger" };
|
||||
if (profile.provider === "ssh-unraid" || state.statusConfigured || state.healthConfigured)
|
||||
return { label: "Not checked", tone: "" };
|
||||
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 deploymentIdentity(profile, repository) {
|
||||
const name = String(
|
||||
profile.state?.containerName ||
|
||||
profile.containerName ||
|
||||
profile.remoteFolder ||
|
||||
repository.name ||
|
||||
"container",
|
||||
);
|
||||
let hash = 0;
|
||||
for (const character of name)
|
||||
hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
|
||||
return { name, initial: name.slice(0, 1).toUpperCase(), accent: hash % 6 };
|
||||
}
|
||||
|
||||
function renderProfileCard(repository, profile, compact = false) {
|
||||
const state = profile.state || {};
|
||||
const health = environmentState(profile);
|
||||
const isSsh = profile.provider === "ssh-unraid";
|
||||
const mode = deploymentMode(profile);
|
||||
const verification = ui.serverGitVerifications[profile.id];
|
||||
const targetSha = deploymentTargetSha(repository, profile);
|
||||
const ready = canDeploy(repository, profile);
|
||||
const modeLabel = {
|
||||
"push-bundle": "Direct copy",
|
||||
"server-git": "Server pull from Gitea",
|
||||
"monitor-only": "Monitor only",
|
||||
}[mode] || mode;
|
||||
const providerDetail = isSsh
|
||||
? `SSH / Unraid · ${modeLabel} · ${profile.remoteFolder || repository.name} · ${profile.branch}${profile.adoptedFromServer ? " · server-linked" : ""}`
|
||||
: `${profile.workflowFile} · ${profile.branch}`;
|
||||
const rollbackConfigured = (isSsh && mode !== "monitor-only") || Boolean(profile.rollbackWorkflowFile);
|
||||
const dockerMan = dockerManIntegration(profile);
|
||||
const { templateReady, webUiReady, iconReady } = dockerMan;
|
||||
const dockerManReady = dockerMan.ready;
|
||||
const managesDockerMan = isSsh && profile.manageDockerMan === true;
|
||||
const webUi = profile.webUiUrl || state.webUiUrl || state.dockerMan?.webUi || "";
|
||||
const identity = deploymentIdentity(profile, repository);
|
||||
const syncLabel = isSsh
|
||||
? state.matchesGitea
|
||||
? `<span class="sync-proof success">${icon("check")}Live = Gitea · ${shortSha(state.liveSha)}</span>`
|
||||
: state.liveSha && state.giteaSha
|
||||
? `<span class="sync-proof warning">Live ${shortSha(state.liveSha)} · Gitea ${shortSha(state.giteaSha)}</span>`
|
||||
: state.liveSha ? `<span class="sync-proof success">${icon("check")}Live · ${shortSha(state.liveSha)}</span>` : ""
|
||||
: state.matchesGitea
|
||||
? `<span class="sync-proof success">${icon("check")}Live = Gitea · ${shortSha(state.liveSha)}</span>`
|
||||
: state.giteaSha && state.liveSha
|
||||
? `<span class="sync-proof warning">Live ${shortSha(state.liveSha)} · Gitea ${shortSha(state.giteaSha)}</span>`
|
||||
: "";
|
||||
const dockerManLabel = managesDockerMan
|
||||
? dockerManReady
|
||||
? templateReady
|
||||
? "Managed labels/template active"
|
||||
: "Managed labels active"
|
||||
: `Managed · WebUI ${webUiReady ? "ready" : "missing"} · icon ${iconReady ? "ready" : "missing"}`
|
||||
: "Existing DockerMan template preserved";
|
||||
const sourceLabel = isSsh
|
||||
? mode === "server-git" ? `Gitea ${state.giteaSha ? shortSha(state.giteaSha) : "refresh required"}` : "Committed local HEAD"
|
||||
: state.giteaSha ? shortSha(state.giteaSha) : "Refresh to compare";
|
||||
const serverAccessAction = isSsh && mode === "server-git"
|
||||
? `<button class="button" data-action="verify-server-git-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Verify server pull</button><button class="button" data-action="manage-deploy-key" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("key")}Deploy key lifecycle</button><button class="button" data-action="configure-server-git-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("key")}Configure Gitea access</button>`
|
||||
: "";
|
||||
return `<article class="deploy-card accent-${identity.accent} ${compact ? "compact-card" : ""}"><div class="container-identity"><span class="container-avatar">${escapeHtml(identity.initial)}</span><div><span>Container</span><strong>${escapeHtml(identity.name)}</strong><small>${escapeHtml(repository.fullName)} · ${escapeHtml(profile.environment)}</small></div>${syncLabel}</div><div class="deploy-card-header"><div><div class="eyebrow">${escapeHtml(isSsh ? "SSH / UNRAID" : "GITEA ACTIONS")}</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>Live commit</span><strong>${state.liveSha ? shortSha(state.liveSha) : "Unknown"}</strong><span>Deploy source</span><strong>${escapeHtml(sourceLabel)}</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>Deployment mode</span><strong>${escapeHtml(modeLabel)}</strong>${mode === "server-git" ? `<span>Server pull</span><strong class="${verification ? verification.deployReady ? "text-success" : "text-warning" : ""}">${escapeHtml(verification?.readiness || "Verify before deployment")}</strong>` : ""}<span>Compose project</span><strong>${escapeHtml(profile.composeProject || "ForgeFlow-generated identity")}</strong><span>Runtime</span><strong>${state.containerRunning === false ? "Stopped" : state.containerRunning ? state.runtimeVerification === "running-unverified" ? "Running · unverified" : "Running" : "Unknown"}</strong><span>DockerMan</span><strong class="${managesDockerMan && !dockerManReady ? "text-warning" : "text-success"}">${escapeHtml(dockerManLabel)}</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>${isSsh ? `<button class="button" data-action="repair-deployment-write-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}Check / fix write access</button>` : ""}${serverAccessAction}<button class="button" data-action="reconcile-deployment" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("refresh")}Refresh truth</button>${webUi ? `<button class="button" data-action="open-profile-webui" data-url="${attr(webUi)}">${icon("external")}Open Web UI</button>` : ""}${managesDockerMan ? `<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 integration" : "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(shortSha(targetSha))}</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) {
|
||||
const profiles = repository.deploymentProfiles || [];
|
||||
const workloads = linkedWorkloadsForRepository(repository);
|
||||
const profileIds = new Set(profiles.map((profile) => profile.id));
|
||||
const workloadRows = workloads.map((workload) => {
|
||||
const containers = (workload.containers || []).map((container) => container.name).filter(Boolean);
|
||||
const profileResolved = Boolean(workload.link?.profileId && profileIds.has(workload.link.profileId));
|
||||
return `<div class="tool-row repository-workload-row"><div><strong>${escapeHtml(workload.displayName || containers[0] || "Server workload")}</strong><span>${escapeHtml(workload.serverName)} · ${containers.length ? escapeHtml(containers.join(", ")) : "container identity unavailable"} · ${workload.runtime?.running ? "running" : "stopped"}</span><span>${escapeHtml(workload.compose?.project ? `Compose ${workload.compose.project}` : workload.remoteFolderCandidate || "Docker workload")}</span></div><div class="stack horizontal compact"><span class="status-pill ${profileResolved ? "success" : "danger"}">${profileResolved ? "Repository linked" : "Link needs reconciliation"}</span>${profileResolved ? `<button class="button ghost" data-action="select-deployment-profile" data-profile-id="${attr(workload.link.profileId)}">Open profile</button>` : `<button class="button" data-action="navigate" data-view="deployments">Review inventory</button>`}</div></div>`;
|
||||
}).join("");
|
||||
const repoOps = repositoryOperations(repository).slice(0, 10);
|
||||
return `<div class="tab-page"><div class="section-heading"><div><h2>Deployment environments</h2><span class="meta">${profiles.length} configured profile${profiles.length === 1 ? "" : "s"} · ${workloads.length} server workload${workloads.length === 1 ? "" : "s"} linked to this repository</span></div><button class="button primary" data-action="configure-deployment">${icon("plus")}Add environment</button></div>${workloads.length ? `<section class="panel repository-workloads"><div class="panel-header"><div><h3>Detected on server</h3><span class="meta">Live Docker / Compose identities resolved back to this repository</span></div></div><div class="panel-body"><div class="tool-list">${workloadRows}</div></div></section>` : ""}${profiles.length ? `<div class="deploy-card-grid">${profiles.map((profile) => renderProfileCard(repository, profile)).join("")}</div>` : '<div class="empty-state panel"><div class="empty-icon">↗</div><h3>No deployment profile</h3><p>Connect a Gitea Actions workflow or a trusted SSH / Unraid server.</p><button class="button primary" data-action="configure-deployment">Configure deployment</button></div>'}<section class="section-block"><div class="section-heading"><h2>Release history</h2></div><div class="panel">${repoOps.length ? `<table class="data-table"><thead><tr><th>Action</th><th>Environment</th><th>Commit</th><th>Status</th><th>Updated</th><th></th></tr></thead><tbody>${repoOps.map((operation) => `<tr><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 releases for this repository yet.</p></div>'}</div></section></div>`;
|
||||
}
|
||||
|
||||
function renderGitTools(repository) {
|
||||
if (!repository.localPath)
|
||||
return '<div class="empty-state full"><p>Link a local repository to manage branches and stashes.</p></div>';
|
||||
const recovery = ui.gitRecovery;
|
||||
const locks = recovery?.lockReport?.locks || [];
|
||||
const activeProcesses = recovery?.lockReport?.processes?.active || [];
|
||||
const recommendations = recovery?.recommendations || [];
|
||||
const status = repository.localStatus || {};
|
||||
const branchRows = 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>';
|
||||
const stashRows = 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>${stash.quarantined ? `<span class="status-pill warning" title="Workspace review ${attr(stash.reviewId || "")}">Codex review required</span>` : `<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>';
|
||||
const recoveryBody = 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>';
|
||||
const syncState = status.counts?.changed
|
||||
? `${status.counts.changed} local file${status.counts.changed === 1 ? "" : "s"} need protection`
|
||||
: status.branch?.ahead || status.branch?.behind
|
||||
? `${status.branch.ahead || 0} ahead · ${status.branch.behind || 0} behind`
|
||||
: "Preview against Gitea before changing files";
|
||||
|
||||
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">${branchRows}</div></div></section>
|
||||
<section class="panel"><div class="panel-header"><h2>Stashes</h2><button class="button" data-action="stash-changes" ${status.clean ? "disabled" : ""}>${icon("archive")}Stash changes</button></div><div class="panel-body"><div class="tool-list">${stashRows}</div></div></section>
|
||||
<section class="panel workspace-sync-panel"><div class="panel-header"><div><h2>Gitea workspace sync</h2><span class="meta">Make tracked files match the current upstream branch exactly</span></div><div class="panel-header-actions"><button class="button ghost" data-action="open-context-help" data-topic="workspace-sync">${icon("help")}How does this work?</button><span class="status-pill ${status.branch?.behind || status.branch?.ahead || status.counts?.changed ? "warning" : "success"}">${escapeHtml(syncState)}</span></div></div><div class="panel-body"><div class="workspace-sync-layout"><div><h3>Safe mirror, never silent overwrite</h3><p>ForgeFlow fetches Gitea, previews additions, changes and deletions, then protects local Codex work before resetting. Local commits go to a recovery branch; modified and untracked files go to a stash.</p><div class="notice">${icon("shield")}Ignored runtime data such as <span class="mono">.env</span>, dependency folders and local databases is preserved. Background awareness only fetches; it never applies this sync automatically.</div></div><div class="workspace-sync-actions"><span class="meta">${escapeHtml(status.branch?.head || "No branch")} → ${escapeHtml(status.branch?.upstream || "No upstream")}</span><button class="button primary" data-action="preview-workspace-sync">${icon("refresh")}Preview Gitea sync</button></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">${recoveryBody}<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 && status.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) {
|
||||
const automaticTarget = displayCloneTarget(repository);
|
||||
const currentOrigin = repository.localStatus?.remoteUrl || "Unavailable";
|
||||
const desiredOrigin = repository.sshUrl || repository.preferredCloneUrl || "";
|
||||
const originNeedsRepair = Boolean(
|
||||
repository.localPath && desiredOrigin && currentOrigin !== desiredOrigin,
|
||||
);
|
||||
const pullRequests = ui.pullRequests || [];
|
||||
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"><div class="section-heading"><div><h2>Open pull requests</h2><span class="meta">Live from Gitea</span></div><button class="button" data-action="load-pull-requests">${icon("refresh")}Refresh</button></div>${pullRequests.length ? `<div class="tool-list">${pullRequests.map((pull) => `<div class="tool-row"><div><strong>#${pull.number} · ${escapeHtml(pull.title)}</strong><span>${escapeHtml(pull.head?.ref || pull.head?.label || "source")} → ${escapeHtml(pull.base?.ref || pull.base?.label || "target")} · ${formatDate(pull.updated_at || pull.created_at)}</span></div><button class="button" data-action="open-pull-request-url" data-url="${attr(pull.html_url || "")}">Open</button></div>`).join("")}</div>` : '<div class="empty-state compact"><p>No open pull requests.</p></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 renderGitValidator(repository) {
|
||||
const report = ui.gitValidation;
|
||||
if (!report)
|
||||
return `<div class="validator-empty panel">${projectIllustration("diagnostics")}<div><div class="eyebrow">Repository assurance</div><h2>Validate Git best practices</h2><p>Inspect repository identity, branch governance, tracked secrets, file hygiene and safe local synchronization settings.</p><button class="button primary" data-action="git-validator-scan">${icon("shield")}Run Git Validator</button></div></div>`;
|
||||
const tone =
|
||||
report.score >= 90 ? "success" : report.score >= 70 ? "warning" : "danger";
|
||||
const groups = report.checks.reduce((grouped, check) => {
|
||||
(grouped[check.category] ||= []).push(check);
|
||||
return grouped;
|
||||
}, {});
|
||||
const trend = report.trend || {};
|
||||
return `<div class="validator-page"><section class="validator-hero panel ${tone}"><div class="validator-score"><strong>${report.score}</strong><span>/ 100</span></div><div><div class="eyebrow">${escapeHtml(report.policy?.label || "Standard")} policy · ${report.ready ? "release-ready" : "review required"}</div><h2>${escapeHtml(report.grade)}</h2><p>${report.summary.passed} passed · ${report.summary.warnings} recommendations · ${report.summary.errors} critical</p><p class="meta">${trend.newlyFound?.length || 0} new · ${trend.resolved?.length || 0} resolved · ${trend.regressions?.length || 0} regressions · ${report.expiredSuppressions?.length || 0} expired exceptions</p></div>${projectIllustration("diagnostics")}<div class="validator-actions"><label class="sr-only" for="validator-policy">Assurance policy</label><select id="validator-policy" class="select" data-action="git-validator-policy">${["minimal", "standard", "strict", "production"].map((policy) => `<option value="${policy}" ${report.policy?.id === policy ? "selected" : ""}>${policy[0].toUpperCase() + policy.slice(1)}</option>`).join("")}</select><button class="button" data-action="git-validator-scan">${icon("refresh")}Scan again</button><button class="button" data-action="git-validator-export" data-format="markdown">Export report</button></div></section><div class="validator-groups">${Object.entries(
|
||||
groups,
|
||||
)
|
||||
.map(
|
||||
([category, checks]) =>
|
||||
`<section class="panel validator-group"><div class="panel-header"><h3>${escapeHtml(category)}</h3><span class="meta">${checks.filter((check) => check.status === "pass").length}/${checks.length} passed</span></div><div class="validator-checks">${checks
|
||||
.map((check) => {
|
||||
const checkIndex = report.checks.indexOf(check);
|
||||
return `<article class="validator-check ${check.status}"><span class="validator-check-icon">${icon(check.status === "pass" ? "check" : check.status === "error" ? "error" : "warning")}</span><div><strong>${escapeHtml(check.title)}</strong><p>${escapeHtml(check.detail)}</p>${check.suppressed ? `<small>Suppressed until ${formatDate(check.suppression.expiresAt)} · ${escapeHtml(check.suppression.reason)}</small>` : check.expiredSuppression ? `<small>Exception expired; finding is active again.</small>` : ""}</div>${check.fixAction ? `<button class="button ${check.safe ? "" : "primary"}" data-action="git-validator-repair" data-check-index="${checkIndex}">${icon("wrench")}Preview fix</button>` : check.status !== "pass" && !check.suppressed ? `<button class="button" data-action="git-validator-suppress" data-check-index="${checkIndex}">Document exception</button>` : `<span class="status-pill ${check.suppressed ? "warning" : check.status === "pass" ? "success" : check.status === "error" ? "danger" : "warning"}">${check.suppressed ? "Suppressed" : check.status === "pass" ? "Best practice" : "Review"}</span>`}</article>`;
|
||||
})
|
||||
.join("")}</div></section>`,
|
||||
)
|
||||
.join("")}</div></div>`;
|
||||
}
|
||||
|
||||
function renderRepositoryWorkspace(repository) {
|
||||
const status = repository.localStatus;
|
||||
const profiles = repository.deploymentProfiles || [];
|
||||
const linkedWorkloads = linkedWorkloadsForRepository(repository);
|
||||
const profile = selectedProfile(repository);
|
||||
const profileWorkload = linkedWorkloads.find((workload) => workload.link?.profileId === profile?.id);
|
||||
const serverState = profile?.state || {};
|
||||
const localTone = status?.counts.conflicts
|
||||
? "danger"
|
||||
: status?.counts.changed
|
||||
? "warning"
|
||||
: status
|
||||
? "success"
|
||||
: "";
|
||||
const remoteTone = status?.branch.behind
|
||||
? "danger"
|
||||
: status?.branch.ahead
|
||||
? "warning"
|
||||
: status?.branch.upstream
|
||||
? "success"
|
||||
: "";
|
||||
const serverTone =
|
||||
serverState.healthy === false
|
||||
? "danger"
|
||||
: serverState.healthy === true
|
||||
? "success"
|
||||
: profileWorkload?.runtime?.running
|
||||
? "success"
|
||||
: "";
|
||||
const content = (
|
||||
{
|
||||
changes: renderChanges,
|
||||
history: renderHistory,
|
||||
deployments: renderRepositoryDeployments,
|
||||
gittools: renderGitTools,
|
||||
validator: renderGitValidator,
|
||||
settings: renderRepositorySettings,
|
||||
}[ui.repositoryTab] || renderChanges
|
||||
)(repository);
|
||||
const deploymentLinks = profiles.length
|
||||
? `<div class="repository-deployment-summary"><span class="repository-deployment-summary-label">${icon("server")}Linked deployments</span><div class="repository-deployment-chips">${profiles.map((item) => {
|
||||
const workload = linkedWorkloads.find((candidate) => candidate.link?.profileId === item.id);
|
||||
const itemState = item.state || {};
|
||||
const tone = itemState.healthy === false ? "danger" : itemState.healthy === true ? "success" : workload?.runtime?.running ? "success" : "warning";
|
||||
const identity = workload?.displayName || item.containerName || item.remoteFolder || item.environment;
|
||||
return `<button class="repository-deployment-chip" data-action="select-deployment-profile" data-profile-id="${attr(item.id)}"><span class="state-dot ${tone}"></span><strong>${escapeHtml(identity)}</strong><span>${escapeHtml(item.environment)}${workload?.serverName ? ` · ${escapeHtml(workload.serverName)}` : ""}</span></button>`;
|
||||
}).join("")}</div><button class="button ghost" data-action="select-deployment-profile" data-profile-id="${attr(profile?.id || profiles[0].id)}">View all</button></div>`
|
||||
: "";
|
||||
return `<div class="repo-workspace"><header class="repo-header illustrated-repo-header"><div class="repo-heading"><h1><button class="favorite-button ${repository.favorite ? "active" : ""}" data-action="toggle-favorite" title="Toggle favorite">${icon("star")}</button>${escapeHtml(repository.fullName)}</h1><p>${escapeHtml(repository.localPath || "No local working tree linked")}</p></div>${projectIllustration("repo")}<div class="repo-header-actions"><button class="button" data-action="fetch" ${!repository.localPath ? "disabled" : ""}>${icon("refresh")}Fetch</button><button class="button" data-action="open-path" ${!repository.localPath ? "disabled" : ""}>${icon("folder")}Folder</button><button class="button" data-action="open-gitea" ${!repository.htmlUrl ? "disabled" : ""}>${icon("external")}Gitea</button></div></header>
|
||||
<div class="repo-context">
|
||||
${repository.localPath ? `<div class="repo-quick-actions"><button class="button" data-action="open-editor">${icon("external")}Open in editor</button><button class="button" data-action="open-terminal">${icon("terminal")}Open terminal</button><button class="button" data-action="check-branch-protection">${icon("shield")}Check branch protection</button><button class="button primary" data-action="open-pull-request">${icon("git")}Create pull request</button>${ui.branchProtection ? `<span class="status-pill ${ui.branchProtection.protected ? "warning" : "success"}">${ui.branchProtection.protected ? `Protected · ${ui.branchProtection.requiredApprovals || 0} approval(s)` : "Direct pushes allowed"}</span>` : ""}</div>` : ""}
|
||||
<div class="release-rail">${releaseNode("Local", status?.shortHead || "Not linked", status ? `${status.counts.changed} changes · ${status.branch.head}` : "No working tree", localTone)}${releaseNode("Gitea", status?.shortHead || "Unknown", status?.branch.upstream ? `${status.branch.ahead} ahead · ${status.branch.behind} behind` : "Branch not published", remoteTone)}${releaseNode(`Server${profile ? ` · ${profile.environment}` : ""}`, serverState.liveSha ? shortSha(serverState.liveSha) : profile ? "Linked" : "Unknown", profileWorkload ? `${profileWorkload.displayName || profile.containerName || "Container"} · ${profileWorkload.runtime?.running ? "running" : "stopped"} on ${profileWorkload.serverName}` : profile ? (serverState.checkedAt ? `checked ${formatDate(serverState.checkedAt)}` : "profile linked · awaiting live scan") : "No deployment profile", serverTone)}</div>
|
||||
${deploymentLinks}
|
||||
</div>
|
||||
<nav class="tabs">${[
|
||||
["changes", "Changes"],
|
||||
["history", "History"],
|
||||
["deployments", "Deployments"],
|
||||
["gittools", "Git tools"],
|
||||
["validator", "Git Validator"],
|
||||
["settings", "Project settings"],
|
||||
]
|
||||
.map(
|
||||
([id, label]) =>
|
||||
`<button class="tab ${ui.repositoryTab === id ? "active" : ""}" data-action="repo-tab" data-tab="${id}">${label}</button>`,
|
||||
)
|
||||
.join("")}</nav><div class="repo-content">${content}</div></div>`;
|
||||
}
|
||||
|
||||
function renderActionPanel(repository) {
|
||||
const action = repositoryAction(repository);
|
||||
const status = repository.localStatus;
|
||||
const profile = selectedProfile(repository);
|
||||
let body = "";
|
||||
if (action.kind === "link") {
|
||||
const target = displayCloneTarget(repository);
|
||||
body = `<div class="panel-callout"><div class="callout-icon">${icon("link")}</div><h2>${action.title}</h2><p>${action.detail}</p>${target ? `<div class="deploy-proof"><span>Project root</span><strong class="mono">${escapeHtml(defaultWorkspaceRoot())}</strong><span>New folder</span><strong class="mono">${escapeHtml(safeCloneFolderName(repository))}</strong></div>` : '<div class="notice warning">No default project root is configured yet.</div>'}<button class="button primary block" data-action="clone-repo">${icon("cloud")}${escapeHtml(clonePrimaryLabel(repository))}</button><button class="button block" style="margin-top:8px" data-action="link-repo">Link existing folder</button><button class="button ghost block" style="margin-top:8px" data-action="clone-repo-custom">Choose another clone location</button></div>`;
|
||||
} else if (action.kind === "commit") {
|
||||
const hasStagedSelection = status.counts.staged > 0;
|
||||
const commitReady = Boolean(
|
||||
(ui.selectedFiles.size || hasStagedSelection) && ui.commitMessage.trim(),
|
||||
);
|
||||
const commitBlocker =
|
||||
!ui.selectedFiles.size && !hasStagedSelection
|
||||
? "Select files or stage one or more hunks."
|
||||
: !ui.commitMessage.trim()
|
||||
? "Enter a commit message to enable commit and push."
|
||||
: ui.selectedFiles.size
|
||||
? "Ready to commit. ForgeFlow stages the selected files automatically."
|
||||
: "Ready to commit only the reviewed staged hunks.";
|
||||
body = `<label class="field-label" for="commit-message">Commit message <span class="required-mark">required</span></label><textarea id="commit-message" class="textarea" placeholder="Describe what changed and why…">${escapeHtml(ui.commitMessage)}</textarea><div class="field-hint"><span>${ui.selectedFiles.size ? `${ui.selectedFiles.size} of ${status.counts.changed} files selected` : `${status.counts.staged} staged file(s)`}</span><span>Ctrl+Enter</span></div><div class="commit-readiness ${commitReady ? "ready" : "blocked"}">${icon(commitReady ? "check" : "warning")}<span>${escapeHtml(commitBlocker)}</span></div><button class="button primary block" style="margin-top:10px" data-action="commit-push" ${commitReady ? "" : `disabled title="${attr(commitBlocker)}"`}>${icon("arrowUp")}${ui.selectedFiles.size ? "Commit selected" : "Commit staged hunks"} & push to Gitea</button><button class="button block" style="margin-top:8px" data-action="commit-only" ${commitReady ? "" : `disabled title="${attr(commitBlocker)}"`}>${icon("git")}${ui.selectedFiles.size ? "Commit selected locally" : "Commit staged hunks locally"}</button><div class="stage-note">Partial hunk staging is preserved when no complete files are selected.</div><div class="stack" style="margin-top:8px"><button class="button block" data-action="stage-selected" ${ui.selectedFiles.size ? "" : "disabled"}>Stage selected files</button><button class="button block" data-action="unstage-selected" ${ui.selectedFiles.size ? "" : "disabled"}>Unstage selected files</button><button class="button block" data-action="stash-changes">${icon("archive")}Stash all changes</button></div>`;
|
||||
} 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>${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>`;
|
||||
else
|
||||
body = `<div class="panel-callout"><div class="callout-icon success">${icon("check")}</div><h2>${action.title}</h2><p>${action.detail}</p>${profile ? `<button class="button block" data-action="refresh-profile-state" data-profile-id="${attr(profile.id)}">${icon("pulse")}Check ${escapeHtml(profile.environment)}</button>` : ""}</div>`;
|
||||
return `<aside class="action-panel"><div class="action-panel-head"><div class="eyebrow">Next action</div><h2>${escapeHtml(action.title)}</h2><p>${escapeHtml(action.detail)}</p></div><div class="action-panel-body">${body}</div>${repository.localPath ? `<div class="action-panel-footer"><button class="button ghost" data-action="open-path">${icon("folder")}Open folder</button><button class="button ghost" data-action="load-git-tools">${icon("branch")}Git tools</button></div>` : ""}</aside>`;
|
||||
}
|
||||
|
||||
function renderServerInventory() {
|
||||
const servers = ui.serverDiscovery || [];
|
||||
const configuredServers = ui.boot?.state?.servers || [];
|
||||
const hiddenClassifications = new Set(["backup", "release-folder", "system-container", "manually-excluded"]);
|
||||
const visibleForServer = (server) => (server.workloads || []).filter((workload) =>
|
||||
workload.reviewDecisionStale || workload.classification?.type === "duplicate" || (!hiddenClassifications.has(workload.classification?.type) && (workload.link || workload.runtime?.running || ["ambiguous", "orphan-container", "stopped-application", "historical-compose", "stale-link", "monitor-only"].includes(workload.classification?.type))),
|
||||
);
|
||||
const reviewCount = servers.reduce((total, server) => total + visibleForServer(server).filter((workload) => !workload.link || workload.classification?.type === "stale-link" || workload.reviewDecisionStale).length, 0);
|
||||
const serverCards = servers.map((server) => {
|
||||
const capabilities = server.capabilities || {};
|
||||
const capabilityText = [
|
||||
capabilities.docker ? "Docker" : "Docker missing",
|
||||
capabilities.compose ? "Compose" : "Compose missing",
|
||||
capabilities.git ? "Git available" : "Git optional",
|
||||
capabilities.tar && capabilities.checksum ? "Push ready" : "Push tools incomplete",
|
||||
].join(" · ");
|
||||
const errorBlock = server.error
|
||||
? `<div class="notice danger">${icon("error")}<div><strong>Server scan failed</strong><p>${escapeHtml(server.error)}</p><div class="stack horizontal compact" style="margin-top:8px"><button class="button primary" data-action="use-server-password" data-server-id="${attr(server.serverId)}" data-retry="scan">Use server password instead</button><button class="button" data-action="test-server" data-server-id="${attr(server.serverId)}">Test connection</button></div></div></div>`
|
||||
: "";
|
||||
const warnings = (server.warnings || []).map((warning) => `<div class="notice warning">${icon("warning")}${escapeHtml(warning)}</div>`).join("");
|
||||
const visibleWorkloads = visibleForServer(server);
|
||||
const hiddenCount = Math.max(0, (server.workloads || []).length - visibleWorkloads.length);
|
||||
const workloads = visibleWorkloads.length
|
||||
? visibleWorkloads.map((workload) => {
|
||||
const containers = (workload.containers || []).map((container) => container.name).filter(Boolean).join(", ");
|
||||
const topCandidate = workload.candidates?.[0];
|
||||
const linkedRepository = ui.repositories.find((repository) => String(repository.fullName).toLowerCase() === String(workload.link?.repositoryFullName || "").toLowerCase());
|
||||
const linkedProfile = linkedRepository?.deploymentProfiles?.find((profile) => profile.id === workload.link?.profileId);
|
||||
const claimsLink = workload.status === "linked" || Boolean(workload.link);
|
||||
const linked = Boolean(claimsLink && linkedRepository && linkedProfile) && workload.classification?.type !== "stale-link";
|
||||
const inconsistentLink = claimsLink && !linked;
|
||||
const classification = workload.classification?.type || workload.status || "review";
|
||||
const statusTone = linked && !workload.reviewDecisionStale ? "success" : inconsistentLink || ["ambiguous", "duplicate", "orphan-container"].includes(classification) || workload.reviewDecisionStale ? "danger" : "warning";
|
||||
const detail = workload.compose?.project
|
||||
? `Compose ${workload.compose.project} · ${(workload.compose.services || []).join(", ") || "services unknown"}`
|
||||
: workload.dockerMan?.templatePath
|
||||
? `DockerMan ${workload.dockerMan.name || workload.displayName} · ${containers || "template only"}`
|
||||
: `Container installation · ${containers || "unnamed"}`;
|
||||
const candidate = linked
|
||||
? `Linked to ${workload.link?.repositoryFullName || "repository"}`
|
||||
: inconsistentLink
|
||||
? `Stored link cannot be resolved to a loaded repository profile`
|
||||
: topCandidate
|
||||
? `${topCandidate.repositoryFullName} suggested · ${topCandidate.confidence || topCandidate.status || "review required"}`
|
||||
: "No repository candidate; select one manually";
|
||||
const canQuickLink = !linked && topCandidate && ["exact", "strong"].includes(topCandidate.confidence) && Boolean(workload.remoteFolderCandidate);
|
||||
const linkButton = canQuickLink
|
||||
? `<button class="button primary" data-action="quick-link-server-workload" data-server-id="${attr(server.serverId)}" data-workload-id="${attr(workload.workloadId)}" data-repository="${attr(topCandidate.repositoryFullName)}">${icon("link")}Link to ${escapeHtml(topCandidate.repositoryName || topCandidate.repositoryFullName)}</button>`
|
||||
: `<button class="button primary" data-action="link-server-workload" data-server-id="${attr(server.serverId)}" data-workload-id="${attr(workload.workloadId)}">${icon("link")}Review & link</button>`;
|
||||
const evidenceNote = workload.reviewDecisionStale ? "Saved decision is stale because server evidence changed" : workload.classification?.reason || "Awaiting review";
|
||||
return `<div class="tool-row"><div><strong>${escapeHtml(workload.displayName)}</strong><span>${escapeHtml(detail)} · ${workload.runtime?.running ? "running" : "stopped"}</span><span>${escapeHtml(candidate)}</span><span class="${workload.reviewDecisionStale || inconsistentLink ? "text-warning" : "meta"}">${escapeHtml(inconsistentLink ? "Reconcile this inventory link before deployment" : evidenceNote)}</span>${workload.metadata?.composeDefinitionError ? `<span class="text-warning">Compose file found; validation warning: ${escapeHtml(workload.metadata.composeDefinitionError)}</span>` : ""}</div><div class="stack horizontal compact"><span class="status-pill ${statusTone}">${escapeHtml(workload.reviewDecisionStale ? "Decision stale" : linked ? "Linked" : inconsistentLink ? "Link unresolved" : classification)}</span>${linked ? `<button class="button ghost" data-action="open-deployment-link" data-repository-id="${attr(linkedRepository.id)}" data-profile-id="${attr(linkedProfile.id)}">Open in repository</button>` : inconsistentLink ? `<button class="button" data-action="plan-server-reconciliation" data-server-id="${attr(server.serverId)}">Reconcile</button>` : linkButton}</div></div>`;
|
||||
}).join("")
|
||||
: `<div class="empty-state compact"><p>${server.error ? "No inventory could be read until the SSH connection works." : "Docker returned no containers, Compose projects or DockerMan templates."}</p></div>`;
|
||||
const resolvedLinks = visibleWorkloads.filter((workload) => {
|
||||
const repository = ui.repositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link?.repositoryFullName || "").toLowerCase());
|
||||
return repository?.deploymentProfiles?.some((profile) => profile.id === workload.link?.profileId);
|
||||
}).length;
|
||||
const unresolvedLinks = visibleWorkloads.filter((workload) => {
|
||||
if (!(workload.status === "linked" || workload.link)) return false;
|
||||
const repository = ui.repositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link?.repositoryFullName || "").toLowerCase());
|
||||
return !repository?.deploymentProfiles?.some((profile) => profile.id === workload.link?.profileId);
|
||||
}).length;
|
||||
return `<section class="panel server-inventory-panel"><div class="panel-header"><div><h3>${escapeHtml(server.serverName || server.server?.name || server.serverId)}</h3><span class="meta">${server.running || 0} running · ${resolvedLinks} visible repository link${resolvedLinks === 1 ? "" : "s"}${unresolvedLinks ? ` · ${unresolvedLinks} unresolved` : ""} · ${visibleWorkloads.filter((workload) => !workload.link).length} to review${hiddenCount ? ` · ${hiddenCount} unrelated/system workloads hidden` : ""}</span></div><div class="stack horizontal compact"><span class="status-pill ${server.error ? "danger" : capabilities.docker && capabilities.compose ? "success" : "warning"}">${server.error ? "Scan failed" : escapeHtml(capabilityText)}</span>${server.error ? "" : `<button class="button" data-action="plan-server-reconciliation" data-server-id="${attr(server.serverId)}">${icon("shield")}Review reconciliation</button>`}</div></div><div class="panel-body">${errorBlock}${warnings}<div class="tool-list">${workloads}</div></div></section>`;
|
||||
}).join("");
|
||||
const empty = configuredServers.length
|
||||
? `<div class="empty-state panel"><h3>Server inventory has not completed</h3><p>ForgeFlow will query Docker directly. A failed connection is shown explicitly instead of being reported as zero deployments.</p><button class="button primary" data-action="scan-server-inventory">Scan servers now</button></div>`
|
||||
: `<div class="empty-state panel"><h3>No Unraid server configured</h3><p>Add the server with password authentication and ForgeFlow can copy and deploy projects directly.</p><button class="button primary" data-action="open-add-server">Add server</button></div>`;
|
||||
return `<section class="section-block"><div class="section-heading"><div><h2>Server inventory</h2><span class="meta">Live Docker, Compose and DockerMan discovery, linked to Gitea</span></div><button class="button ${reviewCount ? "primary" : ""}" data-action="scan-server-inventory">${icon("refresh")}Scan servers</button></div>${servers.length ? `<div class="stack">${serverCards}</div>` : empty}<div class="notice" style="margin-top:12px">${icon("shield")}Server pull fetches an exact Gitea commit through a repository-scoped read-only deploy key, validates Compose and only then promotes the release. Direct copy remains an explicit fallback.</div></section>`;
|
||||
}
|
||||
|
||||
function renderDeployments() {
|
||||
const cards = ui.repositories.flatMap((repository) =>
|
||||
(repository.deploymentProfiles || []).map((profile) => ({ repository, profile })),
|
||||
);
|
||||
const active = operations().filter((operation) => !isTerminalOperation(operation.status));
|
||||
const missingDockerMan = cards.filter(({ profile }) =>
|
||||
profile.provider === "ssh-unraid" &&
|
||||
profile.manageDockerMan === true &&
|
||||
profile.state?.containerRunning &&
|
||||
!dockerManIntegration(profile).ready,
|
||||
);
|
||||
return `<div class="page"><div class="page-header visual-page-header"><div><div class="eyebrow">Server releases</div><h1>Deployments</h1><p>Discover live Unraid workloads, verify them against Gitea and release an exact commit through a protected server pull.</p></div>${projectIllustration("deploy")}<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} managed 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>` : ""}${renderServerInventory()}<section class="section-block"><div class="section-heading"><div><h2>Linked deployment environments</h2><span class="meta">Stable Compose identity, live container health and exact Gitea commit parity</span></div></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>Scan a server and link an existing workload, or open a repository and add an environment.</p></div>'}</div></section><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 HELP_TOPICS = [
|
||||
{
|
||||
id: "getting-started",
|
||||
icon: "rocket",
|
||||
category: "Basics",
|
||||
title: "Start with a repository",
|
||||
summary: "Connect Gitea, discover local projects and understand the Local → Gitea → Server flow.",
|
||||
keywords: "setup connect token roots clone local remote overview",
|
||||
steps: [
|
||||
"Open Settings and validate the Gitea URL and token.",
|
||||
"Add the parent folders that contain your projects, then save and rescan.",
|
||||
"Select a repository. The release rail shows local changes, Gitea parity and the linked server release.",
|
||||
"Use Changes to review work; use Git tools for branches, synchronization and pull requests.",
|
||||
],
|
||||
note: "ForgeFlow does not modify a project merely because you opened it or refreshed the overview.",
|
||||
},
|
||||
{
|
||||
id: "workspace-sync",
|
||||
icon: "refresh",
|
||||
category: "Git & Gitea",
|
||||
title: "Make a local project match Gitea",
|
||||
summary: "Clean tracked leftovers without losing local Codex work or ignored runtime data.",
|
||||
keywords: "sync mirror pull reset deleted files cleanup stash recovery codex upstream dirty",
|
||||
steps: [
|
||||
"Open the repository, choose Git tools and select Preview Gitea sync.",
|
||||
"Review every file that will be added, changed or removed.",
|
||||
"Choose Protect local work & synchronize only when the preview matches your intention.",
|
||||
"ForgeFlow creates a recovery branch for local commits and a stash for modified or untracked files before matching the upstream commit.",
|
||||
],
|
||||
note: "Ignored files such as .env, local databases and dependency folders remain untouched. Background awareness only fetches metadata and never applies a sync.",
|
||||
},
|
||||
{
|
||||
id: "changes",
|
||||
icon: "file",
|
||||
category: "Git & Gitea",
|
||||
title: "Review, commit and publish changes",
|
||||
summary: "Keep intentional local changes separate from remote updates.",
|
||||
keywords: "changes stage hunk commit push branch pull request conflict",
|
||||
steps: [
|
||||
"Review selected files or individual hunks in Changes.",
|
||||
"Enter a clear commit message and commit the reviewed selection.",
|
||||
"Fetch before publishing; if Gitea changed, synchronize or resolve divergence first.",
|
||||
"Push directly only when branch protection permits it, otherwise create a pull request.",
|
||||
],
|
||||
note: "The action panel explains the current blocker and only enables operations that are safe for the selected state.",
|
||||
},
|
||||
{
|
||||
id: "deployment-linking",
|
||||
icon: "link",
|
||||
category: "Deployments",
|
||||
title: "Link server workloads to repositories",
|
||||
summary: "Turn Docker, Compose and DockerMan evidence into one explicit repository deployment.",
|
||||
keywords: "server inventory docker compose dockerman container detect linked review reconcile",
|
||||
steps: [
|
||||
"Open Deployments and scan the configured server.",
|
||||
"Review proposed matches. ForgeFlow uses Compose paths, Git provenance, labels and container metadata; names alone are not trusted.",
|
||||
"Confirm Review & link for a correct candidate, or choose the repository manually.",
|
||||
"Use Review reconciliation whenever a saved link conflicts with current server evidence.",
|
||||
],
|
||||
note: "External and system containers remain monitoring-only until you explicitly link them. A linked workload also appears on the matching repository.",
|
||||
},
|
||||
{
|
||||
id: "deployments",
|
||||
icon: "deploy",
|
||||
category: "Deployments",
|
||||
title: "Deploy an exact Gitea commit",
|
||||
summary: "Validate, pull, build and promote a release without damaging the live service.",
|
||||
keywords: "deploy release unraid preflight rollback health exact sha commit server pull",
|
||||
steps: [
|
||||
"Open the repository Deployments tab and select the intended environment.",
|
||||
"Run preflight and repair blocking configuration before deployment.",
|
||||
"Deploy only the shown target commit. Server pull fetches that exact commit instead of an ambiguous latest branch state.",
|
||||
"Follow the run stages and health result. Failed promotion keeps or restores the previous release where supported.",
|
||||
],
|
||||
note: "Commit parity means Local, Gitea and Server identify the same revision; a running container alone does not prove a correct release.",
|
||||
},
|
||||
{
|
||||
id: "deploy-keys",
|
||||
icon: "key",
|
||||
category: "Deployments",
|
||||
title: "Repair repository deploy keys",
|
||||
summary: "Give the server read-only access to exactly the repository it must pull.",
|
||||
keywords: "ssh key deploy key gitea permission server pull fingerprint authentication",
|
||||
steps: [
|
||||
"Run deployment preflight for the environment.",
|
||||
"Use the offered deploy-key repair when repository access is missing.",
|
||||
"ForgeFlow creates or reuses a repository-scoped key, registers the public key read-only in Gitea and verifies access from the server.",
|
||||
"Re-run preflight and deploy only after the server can resolve and fetch the target commit.",
|
||||
],
|
||||
note: "The private key remains on the configured server. ForgeFlow does not copy your personal Gitea token into deployment commands.",
|
||||
},
|
||||
{
|
||||
id: "git-validator",
|
||||
icon: "shield",
|
||||
category: "Quality",
|
||||
title: "Use Git Validator safely",
|
||||
summary: "Find repository hygiene issues and apply only reviewed repairs.",
|
||||
keywords: "validator hygiene gitignore readme license branch protection secrets large files fix repair",
|
||||
steps: [
|
||||
"Open a repository and choose Git Validator.",
|
||||
"Select the policy that fits the repository and run a fresh scan.",
|
||||
"Open each finding to understand its evidence and recommended repair.",
|
||||
"Preview repairable findings before applying them, then rescan to verify the result.",
|
||||
],
|
||||
note: "A score is guidance, not proof of correctness. Repairs that can alter repository policy or files always require an explicit action.",
|
||||
},
|
||||
{
|
||||
id: "updates-diagnostics",
|
||||
icon: "update",
|
||||
category: "Maintenance",
|
||||
title: "Update or troubleshoot ForgeFlow",
|
||||
summary: "Install signed releases and collect useful diagnostics without exposing credentials.",
|
||||
keywords: "update installer signed release diagnostics logs support error updater restart",
|
||||
steps: [
|
||||
"Open Settings and choose Check now under ForgeFlow updates.",
|
||||
"Download the signed packaged release, then choose Apply & restart.",
|
||||
"If an operation fails, open Diagnostics and run the troubleshooter.",
|
||||
"Export a diagnostic bundle when deeper inspection is needed; tokens, passwords and private keys are redacted.",
|
||||
],
|
||||
note: "Binary auto-update is available only from a packaged installation. Source checkouts continue to use the normal development workflow.",
|
||||
},
|
||||
];
|
||||
|
||||
function renderHelp() {
|
||||
const query = String(ui.helpQuery || "").trim().toLowerCase();
|
||||
const topics = HELP_TOPICS.filter((topic) =>
|
||||
!query || `${topic.category} ${topic.title} ${topic.summary} ${topic.keywords} ${topic.steps.join(" ")} ${topic.note}`.toLowerCase().includes(query),
|
||||
);
|
||||
const categories = [...new Set(HELP_TOPICS.map((topic) => topic.category))];
|
||||
const topicMarkup = topics.map((topic) => {
|
||||
const isOpen = topic.id === ui.helpTopic || Boolean(query);
|
||||
return `<details class="help-topic" data-help-topic="${attr(topic.id)}" ${isOpen ? "open" : ""}><summary><span class="help-topic-icon">${icon(topic.icon)}</span><span><small>${escapeHtml(topic.category)}</small><strong>${escapeHtml(topic.title)}</strong><span>${escapeHtml(topic.summary)}</span></span>${icon("chevron", "help-chevron")}</summary><div class="help-topic-body"><ol>${topic.steps.map((step) => `<li>${escapeHtml(step)}</li>`).join("")}</ol><div class="help-note">${icon("shield")}<span>${escapeHtml(topic.note)}</span></div></div></details>`;
|
||||
}).join("");
|
||||
return `<div class="page help-page"><header class="help-hero"><div><div class="eyebrow">ForgeFlow guide</div><h1>How can we help?</h1><p>Clear, practical instructions for repositories, Gitea synchronization, server deployments and maintenance.</p><label class="help-search">${icon("search")}<input id="help-search" value="${attr(ui.helpQuery)}" placeholder="Search sync, deploy keys, Git Validator…" aria-label="Search help"/><kbd>Ctrl F</kbd></label></div>${projectIllustration("flow")}</header><div class="help-layout"><aside class="help-navigation"><span class="eyebrow">Topics</span>${categories.map((category) => `<div class="help-category"><strong>${escapeHtml(category)}</strong>${HELP_TOPICS.filter((topic) => topic.category === category).map((topic) => `<button data-action="help-topic" data-topic="${attr(topic.id)}" class="${ui.helpTopic === topic.id ? "active" : ""}">${icon(topic.icon)}${escapeHtml(topic.title)}</button>`).join("")}</div>`).join("")}</aside><section class="help-results"><div class="help-results-header"><div><h2>${query ? `Search results` : "Everything you need to operate ForgeFlow"}</h2><span>${topics.length} topic${topics.length === 1 ? "" : "s"}${query ? ` matching “${escapeHtml(ui.helpQuery)}”` : ""}</span></div></div>${topicMarkup || `<div class="empty-state panel"><h3>No help topic found</h3><p>Try a broader term such as sync, deployment, keys, validator or update.</p><button class="button" data-action="clear-help-search">Clear search</button></div>`}</section></div></div>`;
|
||||
}
|
||||
|
||||
function renderSettings() {
|
||||
const state = ui.boot.state;
|
||||
const prefs = state.preferences || {};
|
||||
const update = ui.updateStatus;
|
||||
const servers = state.servers || [];
|
||||
return `<div class="settings-layout"><aside class="settings-nav"><button class="nav-button active">${icon("settings")}<span>General</span></button><button class="nav-button" data-action="check-updates">${icon("update")}<span>Updates</span></button><button class="nav-button" data-action="open-add-server">${icon("server")}<span>Servers</span></button><button class="nav-button" data-action="reset-app">${icon("trash")}<span>Reset setup</span></button></aside><div class="settings-content"><div class="page-header"><div><div class="eyebrow">Application</div><h1>Settings</h1><p>Connections, project discovery, secure SSH servers and application updates.</p></div></div>
|
||||
<section class="settings-group"><h2>Gitea connection</h2><div class="form-grid"><div class="field full"><label for="settings-gitea-url">Instance URL</label><input id="settings-gitea-url" class="input" value="${attr(state.gitea.baseUrl)}" placeholder="https://gitea.example.com" /></div><div class="field full"><label for="settings-gitea-token">New access token</label><input id="settings-gitea-token" class="input" type="password" placeholder="Leave empty only when keeping the same server" /></div></div><div class="connection-card" style="margin-top:10px"><div><strong>${state.gitea.hasToken ? `Connected as ${escapeHtml(state.gitea.user?.login || "user")}` : "Not connected"}</strong><div class="queue-sub">${escapeHtml(state.gitea.baseUrl || "No Gitea instance configured")}</div></div><button class="button primary" data-action="save-gitea-settings">Validate & save</button></div></section>
|
||||
<section class="settings-group"><div class="section-heading"><div><h2>ForgeFlow updates</h2><span class="meta">Signed packaged updates from ${escapeHtml(state.updates?.owner || "Jens")}/${escapeHtml(state.updates?.repo || "ForgeFlow")}</span></div><button class="button" data-action="check-updates" ${ui.updateChecking ? "disabled" : ""}>${icon("update")}${ui.updateChecking ? "Checking…" : "Check now"}</button></div><div class="form-grid"><div class="field"><label>Repository owner</label><input id="update-owner" class="input" value="${attr(state.updates?.owner || "Jens")}"/></div><div class="field"><label>Repository name</label><input id="update-repo" class="input" value="${attr(state.updates?.repo || "ForgeFlow")}"/></div><div class="field"><label>Release branch</label><input id="update-branch" class="input" value="${attr(state.updates?.branch || "main")}"/></div><div class="field"><label>Automatic startup check</label><select id="update-auto-check" class="select"><option value="true" ${state.updates?.autoCheck !== false ? "selected" : ""}>Enabled</option><option value="false" ${state.updates?.autoCheck === false ? "selected" : ""}>Disabled</option></select></div></div><div class="update-card ${update?.available ? "available" : ""}"><div>${icon(update?.available ? "download" : "check")}<span><strong>${update ? (update.available ? `ForgeFlow ${escapeHtml(update.remoteVersion)} is available${update.packaged ? "" : " in the source repository"}` : `ForgeFlow ${escapeHtml(update.currentVersion)} is up to date`) : `Current version ${escapeHtml(ui.boot.appVersion)}`}</strong><small>${update ? `Branch ${escapeHtml(update.branch)} · commit ${escapeHtml(update.shortSha)} · checked ${formatDate(update.checkedAt)}` : "No update check in this session."}</small></span></div><div class="stack horizontal compact">${update?.available && update.packaged && !update.downloaded ? `<button class="button primary" data-action="download-update">${icon("download")}Download signed update</button>` : ""}${update?.downloaded ? `<button class="button success" data-action="apply-update">${icon("update")}Apply & restart</button>` : ""}<button class="button" data-action="save-update-settings">Save update settings</button></div></div><div class="notice" style="margin-top:10px">${icon("shield")}${update && !update.packaged ? "Source checkouts must be updated with Git after reviewing the exact commit. Integrated source replacement remains disabled until source archives are publisher-signed." : "Packaged updates require an Ed25519 publisher signature that binds the exact commit, artifact name, size and SHA-256 digest."}</div></section>
|
||||
<section class="settings-group"><div class="section-heading"><div><h2>SSH / Unraid servers</h2><span class="meta">Credentials are encrypted locally; a new host fingerprint is shown before authentication.</span></div><button class="button primary" data-action="open-add-server">${icon("plus")}Add server</button></div>${servers.length ? `<div class="server-list">${servers.map((server) => `<article class="server-card"><div class="server-card-main">${icon("server")}<div><strong>${escapeHtml(server.name)}</strong><span>${escapeHtml(server.username)}@${escapeHtml(server.host)}:${escapeHtml(server.port)} · ${escapeHtml(server.basePath)}</span><small>${server.hostFingerprint ? `Trusted ${escapeHtml(server.hostFingerprint)}` : "Host identity not trusted yet"}</small></div></div><div class="stack horizontal compact"><button class="button" data-action="test-server" data-server-id="${attr(server.id)}">${server.hostFingerprint ? "Test connection" : "Preview & trust fingerprint"}</button><button class="button" data-action="edit-server" data-server-id="${attr(server.id)}">Edit</button><button class="icon-button danger" data-action="delete-server" data-server-id="${attr(server.id)}" title="Delete server">${icon("trash")}</button></div></article>`).join("")}</div>` : '<div class="empty-state compact"><p>No SSH server configured. Add your Unraid server before creating an SSH deployment profile.</p></div>'}</section>
|
||||
<section class="settings-group"><div class="section-heading"><div><h2>Git remote maintenance</h2><span class="meta">Standardize linked repositories to the current Gitea SSH URLs.</span></div><button class="button" data-action="normalize-origins">${icon("link")}Normalize all origins</button></div><p>This replaces legacy aliases and renamed owners only after an explicit click. Local commits and files are not changed.</p></section>
|
||||
<section class="settings-group"><h2>Project roots</h2><p>The first folder is the default clone destination. ForgeFlow automatically creates one subfolder per repository.</p><div class="stack">${state.workspaceRoots.map((root, index) => `<div class="root-row">${index === 0 ? '<span class="status-pill success">Default</span>' : ""}<input class="input" data-root-index="${index}" value="${attr(root)}" aria-label="Project root ${index + 1}"/><button class="icon-button" data-action="remove-root" data-index="${index}" title="Remove">${icon("trash")}</button></div>`).join("")}<button class="button" data-action="add-root">${icon("plus")}Add project root</button><button class="button primary" data-action="save-roots">Save folders & rescan</button></div></section>
|
||||
<section class="settings-group"><h2>Background awareness</h2><div class="form-grid"><div class="field"><label>Automatic repository refresh</label><select id="pref-auto-refresh" class="select"><option value="true" ${prefs.autoRefresh !== false ? "selected" : ""}>Enabled</option><option value="false" ${prefs.autoRefresh === false ? "selected" : ""}>Disabled</option></select></div><div class="field"><label>Local poll interval</label><input id="pref-repo-poll" class="input" type="number" min="2" max="60" value="${attr(prefs.repositoryPollSeconds || 4)}"/></div><div class="field"><label>Gitea fetch interval (minutes)</label><input id="pref-fetch-interval" class="input" type="number" min="0" max="240" value="${attr(Number.isFinite(Number(prefs.fetchIntervalMinutes)) ? prefs.fetchIntervalMinutes : 10)}"/><small>Read-only remote awareness. Use 0 to disable; fetching never changes project files.</small></div><div class="field"><label>Actions poll interval</label><input id="pref-operation-poll" class="input" type="number" min="3" max="120" value="${attr(prefs.operationPollSeconds || 5)}"/></div><div class="field"><label>Preferred clone protocol</label><select id="pref-clone-protocol" class="select"><option value="https" ${prefs.preferredCloneProtocol !== "ssh" ? "selected" : ""}>HTTPS</option><option value="ssh" ${prefs.preferredCloneProtocol === "ssh" ? "selected" : ""}>SSH</option></select></div></div><div class="notice" style="margin-top:12px">${icon("shield")}Remote awareness only fetches branch metadata. ForgeFlow never resets, cleans or overwrites a workspace in the background.</div><button class="button primary" style="margin-top:12px" data-action="save-preferences">Save awareness settings</button></section>
|
||||
<section class="settings-group"><h2>Desktop integration</h2><div class="form-grid"><div class="field"><label>Editor executable</label><input id="pref-editor-executable" class="input" value="${attr(prefs.editor?.executable || "code")}"/></div><div class="field"><label>Editor arguments</label><input id="pref-editor-args" class="input" value="${attr((prefs.editor?.args || ["--reuse-window", "--goto", "{file}:{line}"]).join(" | "))}"/><small>Separate arguments with |. Placeholders: {path}, {file}, {line}</small></div><div class="field"><label>Terminal executable</label><input id="pref-terminal-executable" class="input" value="${attr(prefs.terminal?.executable || "wt.exe")}"/></div><div class="field"><label>Terminal arguments</label><input id="pref-terminal-args" class="input" value="${attr((prefs.terminal?.args || ["-d", "{path}"]).join(" | "))}"/></div><label class="check-field"><input id="pref-notifications" type="checkbox" ${prefs.notificationsEnabled !== false ? "checked" : ""}/><span>Native deployment notifications</span></label><label class="check-field"><input id="pref-tray" type="checkbox" ${prefs.trayEnabled !== false ? "checked" : ""}/><span>Show system tray icon</span></label><label class="check-field"><input id="pref-close-tray" type="checkbox" ${prefs.closeToTray === true ? "checked" : ""}/><span>Hide to tray when closing</span></label><label class="check-field"><input id="pref-login" type="checkbox" ${prefs.startAtLogin === true ? "checked" : ""}/><span>Start ForgeFlow at login</span></label></div><button class="button primary" data-action="save-desktop-preferences">Save desktop integration</button></section>
|
||||
<section class="settings-group"><h2>Encrypted configuration backup</h2><p>Repository mappings, servers, deployment profiles and preferences are encrypted. Tokens, passwords, passphrases and operation history are never exported.</p><div class="inline-form"><input id="backup-passphrase" class="input" type="password" minlength="12" placeholder="Passphrase of at least 12 characters"/><button class="button" data-action="export-config-backup">Export</button><button class="button" data-action="import-config-backup">Import</button></div></section>
|
||||
<section class="settings-group"><h2>Appearance</h2><div class="field"><label for="appearance-select">Color theme</label><select id="appearance-select" class="select"><option value="dark" ${state.appearance === "dark" ? "selected" : ""}>Dark</option><option value="light" ${state.appearance === "light" ? "selected" : ""}>Light</option><option value="system" ${state.appearance === "system" ? "selected" : ""}>Follow system</option></select></div></section>
|
||||
<section class="settings-group danger-zone"><h2>Danger zone</h2><p>Reset removes local ForgeFlow configuration, repository links, profiles and operation history. It does not modify Git repositories or Gitea.</p><button class="button danger" data-action="reset-app">Reset ForgeFlow</button></section>
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
function preflightTone(status) {
|
||||
return status === "pass"
|
||||
? "success"
|
||||
: status === "fail"
|
||||
? "danger"
|
||||
: status === "warning"
|
||||
? "warning"
|
||||
: "";
|
||||
}
|
||||
|
||||
function renderPreflightChecks(
|
||||
report,
|
||||
emptyMessage = "Run the preflight to verify this configuration.",
|
||||
) {
|
||||
if (!report?.checks?.length)
|
||||
return `<div class="empty-state compact"><p>${escapeHtml(emptyMessage)}</p></div>`;
|
||||
return `<div class="preflight-list">${report.checks.map((item) => `<div class="preflight-row"><span class="preflight-state ${preflightTone(item.status)}">${item.status === "pass" ? icon("check") : item.status === "fail" ? icon("error") : icon("warning")}</span><div><strong>${escapeHtml(item.label)}</strong><span>${escapeHtml(item.detail)}</span>${item.help ? `<small>${escapeHtml(item.help)}</small>` : ""}${item.repairAction ? `<button class="button primary compact-button" data-action="${attr(item.repairAction)}" data-profile-id="${attr(ui.selectedProfileId || "")}">${icon("wrench")}${escapeHtml(item.repairLabel || "Repair")}</button>` : ""}</div><span class="status-pill ${preflightTone(item.status)}">${escapeHtml(item.status)}</span></div>`).join("")}</div>`;
|
||||
}
|
||||
|
||||
function renderDiagnostics() {
|
||||
const prefs = ui.boot.state.preferences || {};
|
||||
const status = ui.diagnosticsStatus || ui.boot.diagnostics || {};
|
||||
const report = ui.systemPreflight;
|
||||
const trouble = ui.troubleshooter;
|
||||
const troubleRows =
|
||||
trouble?.issues
|
||||
?.map(
|
||||
(item, index) =>
|
||||
`<div class="preflight-row"><span class="preflight-state ${item.severity === "error" ? "danger" : "warning"}">${icon(item.severity === "error" ? "error" : "warning")}</span><div><strong>${escapeHtml(item.title)}</strong><span>${escapeHtml(item.repository || "System")} · ${escapeHtml(item.detail)}</span></div>${item.repairable ? `<button class="button ${item.safe ? "primary" : "danger"}" data-action="troubleshooter-repair" data-issue-index="${index}">${icon("wrench")}${item.safe ? "Repair" : "Review & repair"}</button>` : '<span class="status-pill">Manual review</span>'}</div>`,
|
||||
)
|
||||
.join("") || "";
|
||||
return `<div class="page diagnostics-page"><div class="page-header"><div><div class="eyebrow">Local troubleshooting</div><h1>Diagnostics & support bundle</h1><p>ForgeFlow records structured development diagnostics locally while removing tokens, passwords, authorization headers, private keys and user-home paths.</p></div><button class="button primary" data-action="export-diagnostics">${icon("archive")}Export safe bundle</button></div>
|
||||
<div class="notice success">${icon("shield")}Credentials are never added to the diagnostic bundle. Known runtime secrets are redacted again during export. You can inspect the ZIP before sharing it.</div>
|
||||
<div class="diagnostic-grid">
|
||||
<section class="panel"><div class="panel-header"><h2>Log storage</h2><span class="status-pill ${status.lastWriteError ? "danger" : status.enabled ? "success" : ""}">${status.lastWriteError ? "Write error" : status.enabled ? "Recording" : "Disabled"}</span></div><div class="panel-body"><div class="diagnostic-metrics"><div><span>Files</span><strong>${escapeHtml(status.fileCount ?? "—")}</strong></div><div><span>Total size</span><strong>${escapeHtml(status.totalSize || "—")}</strong></div><div><span>Latest event</span><strong>${status.latestAt ? formatDate(status.latestAt) : "None"}</strong></div><div><span>Retention</span><strong>${escapeHtml(status.retentionDays || prefs.logRetentionDays || 14)} days</strong></div></div><div class="context-summary" style="margin-top:12px"><div class="context-row"><span>Location</span><strong>${escapeHtml(status.directory || "Unavailable")}</strong></div><div class="context-row"><span>Level</span><strong>${escapeHtml(status.level || prefs.diagnosticLevel || "info")}</strong></div>${status.lastWriteError ? `<div class="context-row"><span>Error</span><strong>${escapeHtml(status.lastWriteError)}</strong></div>` : ""}</div><div class="card-actions"><button class="button" data-action="open-diagnostics-folder">${icon("folder")}Open logs</button><button class="button danger" data-action="clear-diagnostics">${icon("trash")}Clear logs</button></div></div></section>
|
||||
<section class="panel"><div class="panel-header"><h2>Recording policy</h2></div><div class="panel-body"><div class="form-grid"><div class="field"><label>Diagnostic logging</label><select id="diagnostics-enabled" class="select"><option value="true" ${prefs.diagnosticsEnabled !== false ? "selected" : ""}>Enabled</option><option value="false" ${prefs.diagnosticsEnabled === false ? "selected" : ""}>Disabled</option></select></div><div class="field"><label>Minimum level</label><select id="diagnostic-level" class="select"><option value="debug" ${prefs.diagnosticLevel === "debug" ? "selected" : ""}>Debug</option><option value="info" ${!prefs.diagnosticLevel || prefs.diagnosticLevel === "info" ? "selected" : ""}>Info</option><option value="warning" ${prefs.diagnosticLevel === "warning" ? "selected" : ""}>Warning</option><option value="error" ${prefs.diagnosticLevel === "error" ? "selected" : ""}>Error only</option></select></div><div class="field"><label>Retention days</label><input id="diagnostic-retention" class="input" type="number" min="1" max="90" value="${attr(prefs.logRetentionDays || 14)}"/></div><div class="field"><label>Maximum file size (MB)</label><input id="diagnostic-max-file" class="input" type="number" min="1" max="50" value="${attr(prefs.maxLogFileMb || 8)}"/></div></div><button class="button primary" style="margin-top:12px" data-action="save-diagnostics-preferences">Save diagnostic policy</button></div></section>
|
||||
</div>
|
||||
<section class="section-block"><div class="section-heading"><div><h2>One-click troubleshooter</h2><span class="meta">Git locks, interrupted operations, branch synchronization and deployment/server inconsistencies</span></div><div class="stack horizontal compact"><button class="button" data-action="run-troubleshooter">${icon("pulse")}Scan everything</button>${trouble?.issues?.some((item) => item.repairable && item.safe) ? `<button class="button primary" data-action="troubleshooter-auto-repair">${icon("wrench")}Repair ${trouble.issues.filter((item) => item.repairable && item.safe).length} safe issue(s)</button>` : ""}</div></div><div class="panel"><div class="preflight-summary">${trouble ? `<span class="status-pill ${trouble.summary.errors ? "danger" : trouble.summary.warnings ? "warning" : "success"}">${trouble.summary.total ? `${trouble.summary.total} issue(s)` : "Healthy"}</span><span>${trouble.summary.errors} errors · ${trouble.summary.warnings} warnings · ${trouble.summary.repairable} repairable</span>` : "<span>Run the troubleshooter to inspect all linked repositories and deployments.</span>"}</div>${troubleRows || '<div class="empty-state compact"><p>No problems detected.</p></div>'}</div></section>
|
||||
<section class="section-block"><div class="section-heading"><div><h2>System preflight</h2><span class="meta">Git, writable storage, credential protection, folders and Gitea</span></div><button class="button" data-action="run-system-preflight">${icon("shield")}Run checks</button></div><div class="panel"><div class="preflight-summary">${report ? `<span class="status-pill ${report.summary.ready ? "success" : "danger"}">${report.summary.ready ? "Ready" : `${report.summary.blocking.length} blocking`}</span><span>${report.summary.counts.pass} passed · ${report.summary.counts.warning} warnings · ${report.summary.counts.fail} failed</span>` : "<span>Not run in this session</span>"}</div>${renderPreflightChecks(report)}</div></section>
|
||||
<section class="section-block"><div class="section-heading"><div><h2>Export support bundle</h2><span class="meta">Configuration summary, repository states, operations, preflight and redacted JSONL logs</span></div></div><div class="panel panel-body"><div class="form-grid"><div class="field"><label>Privacy mode</label><select id="diagnostic-privacy" class="select"><option value="standard">Standard · preserve repository names</option><option value="strict">Strict · hash repository and user identifiers</option></select></div></div><div class="card-actions"><button class="button primary" data-action="export-diagnostics">${icon("archive")}Create diagnostic ZIP</button></div>${ui.lastDiagnosticBundle ? `<div class="notice success" style="margin-top:12px">${icon("check")}<div><strong>${escapeHtml(ui.lastDiagnosticBundle.size)} bundle created</strong><p class="mono">SHA-256 ${escapeHtml(ui.lastDiagnosticBundle.sha256)}</p><button class="button ghost" data-action="show-diagnostic-bundle">Show file</button></div></div>` : ""}</div></section>
|
||||
<section class="section-block"><div class="section-heading"><div><h2>Operational audit log</h2><span class="meta">Append-only release, pull-request and recovery events</span></div><div class="stack horizontal compact"><button class="button" data-action="load-audit-log">Refresh</button><button class="button" data-action="export-audit-json">Export JSON</button><button class="button" data-action="export-audit-csv">Export CSV</button></div></div><div class="panel">${ui.auditEvents.length ? `<table class="data-table"><thead><tr><th>Time</th><th>Event</th><th>Repository</th><th>Result</th></tr></thead><tbody>${ui.auditEvents.map((item) => `<tr><td>${formatDate(item.timestamp)}</td><td>${escapeHtml(item.event)}</td><td>${escapeHtml(item.details?.repository || "—")}</td><td>${escapeHtml(item.details?.result || item.details?.note || "—")}</td></tr>`).join("")}</tbody></table>` : '<div class="empty-state compact"><p>Load the operational audit log.</p></div>'}</div></section>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderPipelineView() {
|
||||
const operation = ui.activeDeployment;
|
||||
if (!operation)
|
||||
return '<div class="empty-state full"><p>No deployment operation selected.</p></div>';
|
||||
const logs = (operation.logs || []).join("\n");
|
||||
return `<div class="deployment-view"><div class="page-header"><div><div class="eyebrow">${escapeHtml(operation.action || "deployment")} · ${escapeHtml(operation.environment || "")}</div><h1>${escapeHtml(operation.repository)}</h1><p>Exact commit <span class="mono">${escapeHtml(operation.sha || "")}</span></p></div><div class="stack horizontal"><button class="button" data-action="refresh-current-operation">${icon("refresh")}Refresh</button>${operation.runUrl ? `<button class="button" data-action="open-run-url">${icon("external")}Open in Gitea</button>` : ""}<button class="button" data-action="close-deployment">Close</button></div></div><section class="pipeline-card"><div class="pipeline-head"><div><h2>${escapeHtml(operation.status)}</h2><p>${escapeHtml(operation.profileName || operation.workflowFile || "")} · ${escapeHtml(operation.shortSha || shortSha(operation.sha))}</p></div><span class="status-pill ${toneForStatus(operation.status)}">${escapeHtml(operation.status)}</span></div><div class="pipeline-stages">${(operation.stages || []).map((stage) => `<div class="pipeline-stage ${stage.status}"><span class="stage-icon">${stage.status === "complete" ? icon("check") : stage.status === "failed" ? icon("error") : stage.status === "active" ? icon("pulse") : icon("clock")}</span><span>${escapeHtml(stage.label)}</span></div>`).join("")}</div></section>${operation.jobs?.length ? `<section class="section-block"><div class="section-heading"><h2>Runner jobs</h2></div><div class="panel"><table class="data-table"><thead><tr><th>Job</th><th>Status</th><th>Started</th><th>Completed</th></tr></thead><tbody>${operation.jobs.map((job) => `<tr><td>${escapeHtml(job.name)}</td><td><span class="status-pill ${toneForStatus(job.conclusion || job.status)}">${escapeHtml(job.conclusion || job.status)}</span></td><td>${job.startedAt ? formatDate(job.startedAt) : "—"}</td><td>${job.completedAt ? formatDate(job.completedAt) : "—"}</td></tr>`).join("")}</tbody></table></div></section>` : ""}<div class="log-view"><div class="log-toolbar"><span>Deployment output</span><button class="button ghost" data-action="copy-logs">${icon("copy")}Copy</button></div><pre class="log-lines">${escapeHtml(logs || "Waiting for operation output…")}</pre></div>${operation.failure ? `<div class="notice danger" style="margin-top:14px">${icon("error")}<div><strong>${escapeHtml(operation.failure.stage)}</strong><p>${escapeHtml(operation.failure.message)}</p></div></div>` : ""}</div>`;
|
||||
}
|
||||
|
||||
function renderStatusbar() {
|
||||
const state = ui.boot?.state;
|
||||
const repository = selectedRepository();
|
||||
const active = operations().filter(
|
||||
(operation) => !isTerminalOperation(operation.status),
|
||||
).length;
|
||||
return `<footer class="statusbar"><div class="statusbar-left"><span class="statusbar-item ${ui.boot?.git?.available ? "success" : "danger"}">${icon("git")}${escapeHtml(ui.boot?.git?.version || "Git unavailable")}</span><span class="statusbar-item">${icon("folder")}${state?.workspaceRoots?.length || 0} roots</span>${repository?.localStatus ? `<span class="statusbar-item">${icon("branch")}${escapeHtml(repository.localStatus.branch.head)}</span>` : ""}</div><div class="statusbar-right">${ui.autoRefreshPending ? `<span class="statusbar-item warning">${icon("refresh")}Change detected</span>` : ""}${active ? `<span class="statusbar-item warning">${icon("pulse")}${active} active</span>` : ""}<span class="statusbar-item">ForgeFlow ${escapeHtml(ui.boot?.appVersion || "")}</span></div></footer>`;
|
||||
}
|
||||
|
||||
function renderSetup() {
|
||||
const steps = ["Readiness", "Gitea", "Folders", "Discovery", "Ready"];
|
||||
let body = "";
|
||||
if (ui.setupStep === 0) {
|
||||
body = `<div class="setup-body"><h1>Check this computer</h1><p>ForgeFlow verifies Git, writable storage and protected credential support before you enter any connection details.</p><div class="notice" style="margin-top:16px">${icon("shield")}Your Gitea token is entered only inside this local desktop application. It is never included in diagnostic logs or support bundles.</div><div class="setup-preflight">${renderPreflightChecks(ui.systemPreflight, "Run the readiness check to verify this computer.")}</div><div class="setup-support-actions"><button class="button ghost" data-action="export-diagnostics">${icon("archive")}Export setup diagnostics</button><span class="meta">Available even before Gitea is connected.</span></div></div>`;
|
||||
} else if (ui.setupStep === 1) {
|
||||
body = `<div class="setup-body"><h1>Connect your Gitea instance</h1><p>Enter the URL and a personal access token created on your own Gitea server. ForgeFlow validates it locally and stores it using operating-system encryption when available.</p><div class="form-grid" style="margin-top:24px"><div class="field full"><label>Instance URL</label><input id="setup-url" class="input" value="${attr(ui.setupDraft.baseUrl)}" placeholder="https://gitea.example.com" /></div><div class="field full"><label>Access token</label><input id="setup-token" class="input" type="password" value="${attr(ui.setupDraft.token)}" placeholder="Paste token locally" autocomplete="off" /></div></div>${ui.setupValidation ? `<div class="notice success" style="margin-top:14px">${icon("check")}Connected as ${escapeHtml(ui.setupValidation.user.login)} · ${ui.setupValidation.repositoryCount} repositories · Gitea ${escapeHtml(ui.setupValidation.version || "version unknown")}</div>` : `<div class="notice" style="margin-top:14px">${icon("shield")}Use the narrowest permissions that allow repository reads and Actions workflow dispatch. The setup guide explains this without requiring you to share the token.</div>`}</div>`;
|
||||
} else if (ui.setupStep === 2) {
|
||||
body = `<div class="setup-body"><h1>Select development folders</h1><p>Choose parent folders. ForgeFlow discovers Git working trees below them and matches their origin to Gitea.</p><div class="stack" style="margin-top:22px">${ui.setupDraft.roots.map((root, index) => `<div class="root-row"><input class="input" value="${attr(root)}" readonly/><button class="icon-button" data-action="setup-remove-root" data-index="${index}">${icon("trash")}</button></div>`).join("")}<button class="button" data-action="setup-add-root">${icon("plus")}Add development folder</button></div></div>`;
|
||||
} else if (ui.setupStep === 3) {
|
||||
body = `<div class="setup-body"><h1>Discovering repositories</h1><p>Inspecting local Git metadata. Generated folders and nested dependency trees are skipped.</p><div class="discovery-progress"><div class="spinner"></div><strong>Scanning configured folders…</strong></div></div>`;
|
||||
} else {
|
||||
body = `<div class="setup-body"><h1>ForgeFlow is ready</h1><p>${ui.setupDraft.discovered.length} local repositories were found. You can add deployment environments after opening a repository.</p><div class="setup-summary"><div class="readiness-row"><span class="state-dot success"></span><div><strong>Gitea connected</strong><span>${escapeHtml(ui.setupDraft.baseUrl)} · ${escapeHtml(ui.setupDraft.user?.login || "user")}</span></div></div><div class="readiness-row"><span class="state-dot success"></span><div><strong>Workspace discovery</strong><span>${ui.setupDraft.roots.length} root folder(s), ${ui.setupDraft.discovered.length} repository/repositories</span></div></div><div class="readiness-row"><span class="state-dot success"></span><div><strong>Safe diagnostics</strong><span>Structured local logs with credential redaction are enabled by default.</span></div></div></div><div class="discovery-list">${
|
||||
ui.setupDraft.discovered.length
|
||||
? ui.setupDraft.discovered
|
||||
.slice(0, 8)
|
||||
.map(
|
||||
(item) =>
|
||||
`<div class="discovery-row">${icon(item.error ? "error" : "git")}<div><strong>${escapeHtml(item.localPath.split(/[\\/]/).pop())}</strong><span>${escapeHtml(item.localPath)}</span></div><span class="status-pill ${item.error ? "danger" : "success"}">${item.error ? "Unreadable" : "Ready"}</span></div>`,
|
||||
)
|
||||
.join("")
|
||||
: '<div class="empty-state compact"><p>No repositories found. You can link or clone repositories later.</p></div>'
|
||||
}</div></div>`;
|
||||
}
|
||||
const nextAction =
|
||||
ui.setupStep === 0
|
||||
? ui.systemPreflight?.summary?.ready
|
||||
? '<button class="button primary" data-action="setup-continue">Continue</button>'
|
||||
: '<button class="button primary" data-action="setup-run-preflight">Run readiness check</button>'
|
||||
: 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 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>`;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('node:path');
|
||||
|
||||
function cloneDirectoryName(remoteUrl) {
|
||||
const raw = String(remoteUrl || '').trim().replace(/[?#].*$/, '').replace(/[\\/]+$/, '');
|
||||
const segment = raw.split(/[\\/:]/).filter(Boolean).at(-1) || 'repository';
|
||||
const name = segment.replace(/\.git$/i, '').replace(/[^a-zA-Z0-9._-]/g, '-');
|
||||
// A name made only of dots is not a usable directory. Windows strips trailing
|
||||
// dots, so "..." would resolve back to the project root itself and slip past
|
||||
// the escape check in resolveCloneTarget below.
|
||||
return !name || /^\.+$/.test(name) ? 'repository' : name;
|
||||
}
|
||||
|
||||
function resolveCloneTarget(workspaceRoot, remoteUrl) {
|
||||
const root = path.resolve(String(workspaceRoot || ''));
|
||||
if (!String(workspaceRoot || '').trim()) throw new Error('A project root is required.');
|
||||
const target = path.resolve(root, cloneDirectoryName(remoteUrl));
|
||||
const normalize = (value) => process.platform === 'win32' ? value.toLowerCase() : value;
|
||||
const normalizedRoot = normalize(root);
|
||||
const normalizedTarget = normalize(target);
|
||||
if (normalizedTarget === normalizedRoot || !normalizedTarget.startsWith(`${normalizedRoot}${path.sep}`)) {
|
||||
throw new Error('Clone target escapes the selected project root.');
|
||||
}
|
||||
return { root, target, directoryName: path.basename(target) };
|
||||
}
|
||||
|
||||
module.exports = { cloneDirectoryName, resolveCloneTarget };
|
||||
@@ -0,0 +1,44 @@
|
||||
'use strict';
|
||||
|
||||
function parseClock(value) {
|
||||
const match = String(value || '').match(/^([01]\d|2[0-3]):([0-5]\d)$/);
|
||||
if (!match) throw new Error('Maintenance window times must use HH:mm.');
|
||||
return Number(match[1]) * 60 + Number(match[2]);
|
||||
}
|
||||
|
||||
function normalizeMaintenanceWindows(windows) {
|
||||
return (Array.isArray(windows) ? windows : []).slice(0, 20).map((window) => ({
|
||||
days: [...new Set((Array.isArray(window?.days) ? window.days : []).map(Number).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6))],
|
||||
start: String(window?.start || '00:00'),
|
||||
end: String(window?.end || '23:59')
|
||||
})).map((window) => ({ ...window, startMinutes: parseClock(window.start), endMinutes: parseClock(window.end) }));
|
||||
}
|
||||
|
||||
function isInsideWindow(window, date) {
|
||||
const minutes = date.getHours() * 60 + date.getMinutes();
|
||||
if (window.startMinutes <= window.endMinutes) return window.days.includes(date.getDay()) && minutes >= window.startMinutes && minutes <= window.endMinutes;
|
||||
if (minutes >= window.startMinutes) return window.days.includes(date.getDay());
|
||||
const previousDay = (date.getDay() + 6) % 7;
|
||||
return minutes <= window.endMinutes && window.days.includes(previousDay);
|
||||
}
|
||||
|
||||
function evaluateDeploymentPolicy(profile, { now = new Date(), override = false, reason = '', note = '' } = {}) {
|
||||
const cleanReason = String(reason || '').trim();
|
||||
const cleanNote = String(note || '').trim();
|
||||
const policy = profile?.deploymentPolicy || {};
|
||||
const windows = normalizeMaintenanceWindows(policy.maintenanceWindows);
|
||||
const violations = [];
|
||||
if (policy.frozen) violations.push(policy.freezeReason ? `Deployment frozen: ${policy.freezeReason}` : 'Deployment is frozen.');
|
||||
if (windows.length && !windows.some((window) => isInsideWindow(window, now))) violations.push('Current time is outside the configured maintenance windows.');
|
||||
if (policy.requireNote && !cleanNote) violations.push('A release note is required for this environment.');
|
||||
if (violations.length && override && !cleanReason) throw new Error('An override reason is required to bypass deployment policy.');
|
||||
if (violations.length && !override) {
|
||||
const error = new Error(violations.join(' '));
|
||||
error.code = 'DEPLOYMENT_POLICY_BLOCKED';
|
||||
error.recoverable = true;
|
||||
throw error;
|
||||
}
|
||||
return { allowed: true, overridden: violations.length > 0, violations, reason: cleanReason, note: cleanNote };
|
||||
}
|
||||
|
||||
module.exports = { parseClock, normalizeMaintenanceWindows, isInsideWindow, evaluateDeploymentPolicy };
|
||||
@@ -0,0 +1,93 @@
|
||||
'use strict';
|
||||
|
||||
function parseBranchHeader(line, branch) {
|
||||
if (line.startsWith('# branch.oid ')) branch.oid = line.slice(13).trim();
|
||||
if (line.startsWith('# branch.head ')) branch.head = line.slice(14).trim();
|
||||
if (line.startsWith('# branch.upstream ')) branch.upstream = line.slice(18).trim();
|
||||
if (line.startsWith('# branch.ab ')) {
|
||||
const match = line.match(/\+(\d+)\s+-(\d+)/);
|
||||
if (match) {
|
||||
branch.ahead = Number(match[1]);
|
||||
branch.behind = Number(match[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(code) {
|
||||
const map = {
|
||||
M: 'modified', A: 'added', D: 'deleted', R: 'renamed', C: 'copied',
|
||||
U: 'conflict', T: 'type-changed', '?': 'untracked', '!': 'ignored', '.': 'clean', ' ': 'clean'
|
||||
};
|
||||
return map[code] || 'changed';
|
||||
}
|
||||
|
||||
function buildFile(path, originalPath, xy, kind) {
|
||||
const indexCode = xy?.[0] || '.';
|
||||
const worktreeCode = xy?.[1] || '.';
|
||||
const conflict = kind === 'u' || indexCode === 'U' || worktreeCode === 'U';
|
||||
const untracked = kind === '?';
|
||||
return {
|
||||
path,
|
||||
originalPath: originalPath || null,
|
||||
indexCode,
|
||||
worktreeCode,
|
||||
staged: !untracked && indexCode !== '.' && indexCode !== ' ',
|
||||
unstaged: untracked || (worktreeCode !== '.' && worktreeCode !== ' '),
|
||||
untracked,
|
||||
conflict,
|
||||
status: conflict ? 'conflict' : untracked ? 'untracked' : statusLabel(worktreeCode !== '.' ? worktreeCode : indexCode)
|
||||
};
|
||||
}
|
||||
|
||||
function parsePorcelainV2(output) {
|
||||
const branch = { oid: null, head: null, upstream: null, ahead: 0, behind: 0 };
|
||||
const files = [];
|
||||
const entries = String(output || '').split('\0');
|
||||
|
||||
for (let index = 0; index < entries.length; index += 1) {
|
||||
const entry = entries[index];
|
||||
if (!entry) continue;
|
||||
if (entry.startsWith('# ')) {
|
||||
parseBranchHeader(entry, branch);
|
||||
continue;
|
||||
}
|
||||
|
||||
const kind = entry[0];
|
||||
if (kind === '1') {
|
||||
const parts = entry.split(' ');
|
||||
const xy = parts[1];
|
||||
const path = parts.slice(8).join(' ');
|
||||
files.push(buildFile(path, null, xy, kind));
|
||||
} else if (kind === '2') {
|
||||
const parts = entry.split(' ');
|
||||
const xy = parts[1];
|
||||
const path = parts.slice(9).join(' ');
|
||||
const originalPath = entries[index + 1] || null;
|
||||
index += 1;
|
||||
files.push(buildFile(path, originalPath, xy, kind));
|
||||
} else if (kind === 'u') {
|
||||
const parts = entry.split(' ');
|
||||
const xy = parts[1];
|
||||
const path = parts.slice(10).join(' ');
|
||||
files.push(buildFile(path, null, xy, kind));
|
||||
} else if (kind === '?' || kind === '!') {
|
||||
const path = entry.slice(2);
|
||||
if (kind === '?') files.push(buildFile(path, null, '??', kind));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
branch,
|
||||
files,
|
||||
counts: {
|
||||
changed: files.length,
|
||||
staged: files.filter((file) => file.staged).length,
|
||||
unstaged: files.filter((file) => file.unstaged).length,
|
||||
conflicts: files.filter((file) => file.conflict).length,
|
||||
untracked: files.filter((file) => file.untracked).length
|
||||
},
|
||||
clean: files.length === 0
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { parsePorcelainV2, statusLabel };
|
||||
@@ -0,0 +1,39 @@
|
||||
'use strict';
|
||||
|
||||
function stripGitSuffix(value) {
|
||||
return value.replace(/\.git$/i, '').replace(/^\/+|\/+$/g, '');
|
||||
}
|
||||
|
||||
function normalizeRemoteUrl(remote) {
|
||||
const raw = String(remote || '').trim();
|
||||
if (!raw) return null;
|
||||
|
||||
const scp = raw.match(/^(?:[^@]+@)?([^:]+):(.+)$/);
|
||||
if (scp && !raw.includes('://') && !/^[a-zA-Z]:[\\/]/.test(raw)) {
|
||||
return { host: scp[1].toLowerCase(), path: stripGitSuffix(scp[2]).toLowerCase() };
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
return { host: url.hostname.toLowerCase(), path: stripGitSuffix(url.pathname).toLowerCase() };
|
||||
} catch {
|
||||
return { host: '', path: stripGitSuffix(raw.replace(/\\/g, '/')).toLowerCase() };
|
||||
}
|
||||
}
|
||||
|
||||
function repositoryKey(repository) {
|
||||
return String(repository?.full_name || `${repository?.owner?.login || repository?.owner || ''}/${repository?.name || ''}`)
|
||||
.replace(/^\/+|\/+$/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function matchRemoteToRepository(remote, repositories) {
|
||||
const normalized = normalizeRemoteUrl(remote);
|
||||
if (!normalized) return null;
|
||||
return repositories.find((repository) => {
|
||||
const key = repositoryKey(repository);
|
||||
return normalized.path === key || normalized.path.endsWith(`/${key}`);
|
||||
}) || null;
|
||||
}
|
||||
|
||||
module.exports = { normalizeRemoteUrl, repositoryKey, matchRemoteToRepository };
|
||||
@@ -0,0 +1,32 @@
|
||||
'use strict';
|
||||
|
||||
function parseVersion(value) {
|
||||
const match = String(value || '').trim().replace(/^v/i, '').match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
raw: String(value).trim(),
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2]),
|
||||
patch: Number(match[3]),
|
||||
prerelease: match[4] || ''
|
||||
};
|
||||
}
|
||||
|
||||
function compareVersions(leftValue, rightValue) {
|
||||
const left = parseVersion(leftValue);
|
||||
const right = parseVersion(rightValue);
|
||||
if (!left || !right) throw new Error('Both versions must use semantic versioning (for example 1.2.3).');
|
||||
for (const key of ['major', 'minor', 'patch']) {
|
||||
if (left[key] !== right[key]) return left[key] > right[key] ? 1 : -1;
|
||||
}
|
||||
if (left.prerelease === right.prerelease) return 0;
|
||||
if (!left.prerelease) return 1;
|
||||
if (!right.prerelease) return -1;
|
||||
return left.prerelease.localeCompare(right.prerelease, undefined, { numeric: true }) > 0 ? 1 : -1;
|
||||
}
|
||||
|
||||
function isNewerVersion(candidate, current) {
|
||||
return compareVersions(candidate, current) > 0;
|
||||
}
|
||||
|
||||
module.exports = { parseVersion, compareVersions, isNewerVersion };
|
||||
@@ -0,0 +1,94 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
function normalizeRelativePosixPath(value) {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new Error('Shell validation path must be a non-empty string.');
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (path.isAbsolute(trimmed) || /^[A-Za-z]:[\\/]/.test(trimmed)) {
|
||||
throw new Error('Shell validation path must be relative to the project root.');
|
||||
}
|
||||
const normalized = trimmed.replace(/\\/g, '/').replace(/^\.\//, '');
|
||||
if (normalized.split('/').some((segment) => segment === '..')) {
|
||||
throw new Error('Shell validation path may not escape the project root.');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function bashSyntaxCheckInvocation(root, scriptPath = 'examples/server/forgeflow-deploy') {
|
||||
if (typeof root !== 'string' || !root.trim()) {
|
||||
throw new Error('Project root is required for shell validation.');
|
||||
}
|
||||
const relativeScriptPath = normalizeRelativePosixPath(scriptPath);
|
||||
const scriptText = fs.readFileSync(path.join(root, ...relativeScriptPath.split('/')), 'utf8');
|
||||
return {
|
||||
command: 'bash',
|
||||
args: ['-n'],
|
||||
options: {
|
||||
cwd: root,
|
||||
input: scriptText.replace(/\r\n?/g, '\n'),
|
||||
encoding: 'utf8',
|
||||
windowsHide: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function bashSyntaxCheckFromTextInvocation(scriptText) {
|
||||
if (typeof scriptText !== 'string' || !scriptText.trim()) {
|
||||
throw new Error('Shell script text is required for syntax validation.');
|
||||
}
|
||||
return {
|
||||
command: 'bash',
|
||||
args: ['-n'],
|
||||
options: {
|
||||
input: scriptText,
|
||||
encoding: 'utf8',
|
||||
windowsHide: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function shouldRunExternalBash(platform = process.platform) {
|
||||
return platform !== 'win32';
|
||||
}
|
||||
|
||||
function validateShellScriptStructure(scriptText) {
|
||||
if (typeof scriptText !== 'string' || !scriptText.trim()) {
|
||||
throw new Error('Shell script text is required for structural validation.');
|
||||
}
|
||||
if (scriptText.includes('\0')) {
|
||||
throw new Error('Shell script may not contain NUL bytes.');
|
||||
}
|
||||
const normalized = scriptText.replace(/\r\n/g, '\n');
|
||||
const firstLine = normalized.split('\n', 1)[0];
|
||||
if (!/^#!\/(?:usr\/bin\/env bash|bin\/bash)$/.test(firstLine)) {
|
||||
throw new Error('Server deployment script must declare Bash in its shebang.');
|
||||
}
|
||||
if (!/^set -E?euo pipefail$/m.test(normalized)) {
|
||||
throw new Error('Server deployment script must enable strict Bash error handling.');
|
||||
}
|
||||
for (const marker of [
|
||||
'readonly CONFIG_FILE="/etc/forgeflow/targets.conf"',
|
||||
'Target configuration must be owned by root',
|
||||
'flock -n 9',
|
||||
'git -C "$APP_DIR" fetch',
|
||||
'git -C "$APP_DIR" reset --hard "$SHA"',
|
||||
'docker compose -f "$COMPOSE_FILE" up -d --build',
|
||||
'write_status "healthy"',
|
||||
'write_status "unhealthy"'
|
||||
]) {
|
||||
if (!normalized.includes(marker)) {
|
||||
throw new Error(`Server deployment script is missing required safety marker: ${marker}`);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
bashSyntaxCheckInvocation,
|
||||
bashSyntaxCheckFromTextInvocation,
|
||||
normalizeRelativePosixPath,
|
||||
shouldRunExternalBash,
|
||||
validateShellScriptStructure
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
'use strict';
|
||||
|
||||
function uniqueCandidates(candidates) {
|
||||
const seen = new Set();
|
||||
return candidates.filter((candidate) => {
|
||||
const key = JSON.stringify([candidate.file, candidate.args]);
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function npmProbeCandidates(options = {}) {
|
||||
const platform = options.platform || process.platform;
|
||||
const env = options.env || process.env;
|
||||
const execPath = options.execPath || process.execPath;
|
||||
const candidates = [];
|
||||
|
||||
// npm exposes the exact CLI entry point while running an npm script. Calling
|
||||
// it through Node avoids Windows' inability to exec .cmd shims directly.
|
||||
if (env.npm_execpath) {
|
||||
candidates.push({
|
||||
file: env.npm_node_execpath || execPath,
|
||||
args: [env.npm_execpath, '--version'],
|
||||
source: 'npm_execpath'
|
||||
});
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
candidates.push({
|
||||
file: env.ComSpec || env.COMSPEC || 'cmd.exe',
|
||||
args: ['/d', '/s', '/c', 'npm --version'],
|
||||
source: 'windows-command-shim'
|
||||
});
|
||||
} else {
|
||||
candidates.push({ file: 'npm', args: ['--version'], source: 'path' });
|
||||
}
|
||||
|
||||
return uniqueCandidates(candidates);
|
||||
}
|
||||
|
||||
module.exports = { npmProbeCandidates };
|
||||
@@ -0,0 +1,130 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('node:path');
|
||||
|
||||
function normalizeBaseUrl(value) {
|
||||
const raw = String(value || '').trim().replace(/\/+$/, '');
|
||||
if (!raw) throw new Error('Gitea URL is required.');
|
||||
const url = new URL(raw);
|
||||
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported.');
|
||||
if (url.username || url.password) throw new Error('Do not include credentials in the Gitea URL.');
|
||||
const loopback = new Set(['localhost', '127.0.0.1', '[::1]']);
|
||||
if (url.protocol !== 'https:' && !loopback.has(url.hostname.toLowerCase())) {
|
||||
throw new Error('Gitea must use HTTPS so access tokens are never sent over plaintext HTTP. Loopback HTTP is allowed for local development only.');
|
||||
}
|
||||
url.hash = '';
|
||||
url.search = '';
|
||||
return url.toString().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function assertSafeRepositoryPath(value) {
|
||||
if (!value || typeof value !== 'string') throw new Error('A repository path is required.');
|
||||
if (value.includes('\0')) throw new Error('Invalid repository path.');
|
||||
return path.resolve(value);
|
||||
}
|
||||
|
||||
function assertRepositoryRelativePath(value) {
|
||||
const filePath = String(value || '');
|
||||
if (!filePath || filePath.includes('\0')) throw new Error('A repository-relative file path is required.');
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
if (path.posix.isAbsolute(normalized) || /^[a-zA-Z]:\//.test(normalized)) throw new Error('Absolute file paths are not allowed.');
|
||||
if (normalized.split('/').some((segment) => segment === '..')) throw new Error('File path may not escape the repository.');
|
||||
return normalized.replace(/^\.\//, '');
|
||||
}
|
||||
|
||||
function assertRepositoryRelativePaths(values) {
|
||||
if (!Array.isArray(values)) return [];
|
||||
return [...new Set(values.filter(Boolean).map(assertRepositoryRelativePath))];
|
||||
}
|
||||
|
||||
function assertCommitMessage(value) {
|
||||
const message = String(value || '').trim();
|
||||
if (!message) throw new Error('Enter a commit message.');
|
||||
if (message.length > 5000) throw new Error('Commit message is too long.');
|
||||
if (message.includes('\0')) throw new Error('Commit message contains an invalid character.');
|
||||
return message;
|
||||
}
|
||||
|
||||
function assertFullCommitSha(value) {
|
||||
const sha = String(value || '').trim();
|
||||
if (!/^[a-f0-9]{40,64}$/i.test(sha)) throw new Error('A full commit SHA is required.');
|
||||
return sha.toLowerCase();
|
||||
}
|
||||
|
||||
function assertWorkflowFile(value) {
|
||||
const workflow = assertRepositoryRelativePath(String(value || '').trim());
|
||||
if (!/^[a-zA-Z0-9._/-]+\.ya?ml$/i.test(workflow)) throw new Error('Workflow file must be a YAML filename.');
|
||||
return workflow;
|
||||
}
|
||||
|
||||
|
||||
function assertBranchName(value) {
|
||||
const branch = String(value || '').trim();
|
||||
if (!branch) throw new Error('A branch name is required.');
|
||||
if (branch.length > 255) throw new Error('The branch name is too long.');
|
||||
if (branch === '@' || branch.startsWith('-') || branch.startsWith('/') || branch.endsWith('/') || branch.endsWith('.')) throw new Error('The branch name is invalid.');
|
||||
if (branch.includes('..') || branch.includes('@{') || branch.includes('//') || /[\x00-\x20\x7f~^:?*\[\\]/.test(branch)) throw new Error('The branch name is invalid.');
|
||||
if (branch.split('/').some((part) => !part || part.startsWith('.') || part.endsWith('.lock'))) throw new Error('The branch name is invalid.');
|
||||
return branch;
|
||||
}
|
||||
|
||||
function assertEnvironmentName(value) {
|
||||
const environment = String(value || '').trim().toLowerCase();
|
||||
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(environment)) {
|
||||
throw new Error('Environment must use 1-64 lowercase letters, numbers, dots, dashes or underscores.');
|
||||
}
|
||||
return environment;
|
||||
}
|
||||
|
||||
function assertWorkflowFileName(value) {
|
||||
const workflow = assertWorkflowFile(value);
|
||||
if (workflow.includes('/')) throw new Error('Workflow must be a filename from .gitea/workflows, not a path.');
|
||||
return workflow;
|
||||
}
|
||||
|
||||
function assertDeploymentRequest(profile, sha) {
|
||||
if (!profile) throw new Error('Deployment profile not found.');
|
||||
assertFullCommitSha(sha);
|
||||
assertWorkflowFileName(profile.workflowFile);
|
||||
assertBranchName(profile.branch);
|
||||
assertEnvironmentName(profile.environment);
|
||||
assertHttpUrl(profile.statusUrl, { label: 'Application status URL' });
|
||||
}
|
||||
|
||||
function assertHttpUrl(value, { optional = false, label = 'URL', allowUnraidTemplate = false } = {}) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw && optional) return '';
|
||||
if (!raw) throw new Error(`${label} is required.`);
|
||||
const validationValue = allowUnraidTemplate ? raw.replace(/\[IP\]/gi, '127.0.0.1').replace(/\[PORT(?::\d+)?\]/gi, '8080') : raw;
|
||||
const url = new URL(validationValue);
|
||||
if (!['http:', 'https:'].includes(url.protocol)) throw new Error(`${label} must use HTTP or HTTPS.`);
|
||||
if (url.username || url.password) throw new Error(`${label} may not contain credentials.`);
|
||||
return allowUnraidTemplate ? raw : url.toString();
|
||||
}
|
||||
|
||||
function assertCloneRemote(value) {
|
||||
const remote = String(value || '').trim();
|
||||
if (!remote || remote.includes('\0')) throw new Error('Clone URL is required.');
|
||||
const scp = /^(?:[^@\s]+@)?[^:\s]+:[^\s]+$/.test(remote) && !remote.includes('://');
|
||||
if (scp) return remote;
|
||||
const url = new URL(remote);
|
||||
if (!['http:', 'https:', 'ssh:', 'git:'].includes(url.protocol)) throw new Error('Unsupported Git remote protocol.');
|
||||
if (url.password) throw new Error('Do not include a password in the clone URL.');
|
||||
return remote;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeBaseUrl,
|
||||
assertSafeRepositoryPath,
|
||||
assertRepositoryRelativePath,
|
||||
assertRepositoryRelativePaths,
|
||||
assertCommitMessage,
|
||||
assertFullCommitSha,
|
||||
assertWorkflowFile,
|
||||
assertWorkflowFileName,
|
||||
assertBranchName,
|
||||
assertEnvironmentName,
|
||||
assertDeploymentRequest,
|
||||
assertHttpUrl,
|
||||
assertCloneRemote
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
'use strict';
|
||||
|
||||
const zlib = require('node:zlib');
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n += 1) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k += 1) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
table[n] = c >>> 0;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32(buffer) {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of buffer) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function dosDateTime(date = new Date()) {
|
||||
const year = Math.max(1980, date.getFullYear());
|
||||
const time = (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2);
|
||||
const day = date.getDate();
|
||||
const month = date.getMonth() + 1;
|
||||
const dosDate = ((year - 1980) << 9) | (month << 5) | day;
|
||||
return { time, date: dosDate };
|
||||
}
|
||||
|
||||
function createZip(entries) {
|
||||
const localParts = [];
|
||||
const centralParts = [];
|
||||
let offset = 0;
|
||||
const stamp = dosDateTime();
|
||||
|
||||
for (const entry of entries) {
|
||||
const name = Buffer.from(String(entry.name).replace(/\\/g, '/').replace(/^\/+/, ''), 'utf8');
|
||||
const source = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(String(entry.data ?? ''), 'utf8');
|
||||
const compressed = zlib.deflateRawSync(source, { level: 6 });
|
||||
const checksum = crc32(source);
|
||||
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(20, 4);
|
||||
local.writeUInt16LE(0x0800, 6);
|
||||
local.writeUInt16LE(8, 8);
|
||||
local.writeUInt16LE(stamp.time, 10);
|
||||
local.writeUInt16LE(stamp.date, 12);
|
||||
local.writeUInt32LE(checksum, 14);
|
||||
local.writeUInt32LE(compressed.length, 18);
|
||||
local.writeUInt32LE(source.length, 22);
|
||||
local.writeUInt16LE(name.length, 26);
|
||||
local.writeUInt16LE(0, 28);
|
||||
localParts.push(local, name, compressed);
|
||||
|
||||
const central = Buffer.alloc(46);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(20, 4);
|
||||
central.writeUInt16LE(20, 6);
|
||||
central.writeUInt16LE(0x0800, 8);
|
||||
central.writeUInt16LE(8, 10);
|
||||
central.writeUInt16LE(stamp.time, 12);
|
||||
central.writeUInt16LE(stamp.date, 14);
|
||||
central.writeUInt32LE(checksum, 16);
|
||||
central.writeUInt32LE(compressed.length, 20);
|
||||
central.writeUInt32LE(source.length, 24);
|
||||
central.writeUInt16LE(name.length, 28);
|
||||
central.writeUInt16LE(0, 30);
|
||||
central.writeUInt16LE(0, 32);
|
||||
central.writeUInt16LE(0, 34);
|
||||
central.writeUInt16LE(0, 36);
|
||||
central.writeUInt32LE(0, 38);
|
||||
central.writeUInt32LE(offset, 42);
|
||||
centralParts.push(central, name);
|
||||
offset += local.length + name.length + compressed.length;
|
||||
}
|
||||
|
||||
const centralDirectory = Buffer.concat(centralParts);
|
||||
const end = Buffer.alloc(22);
|
||||
end.writeUInt32LE(0x06054b50, 0);
|
||||
end.writeUInt16LE(0, 4);
|
||||
end.writeUInt16LE(0, 6);
|
||||
end.writeUInt16LE(entries.length, 8);
|
||||
end.writeUInt16LE(entries.length, 10);
|
||||
end.writeUInt32LE(centralDirectory.length, 12);
|
||||
end.writeUInt32LE(offset, 16);
|
||||
end.writeUInt16LE(0, 20);
|
||||
return Buffer.concat([...localParts, centralDirectory, end]);
|
||||
}
|
||||
|
||||
module.exports = { createZip, crc32 };
|
||||
Reference in new issue
Block a user